"""Data structures used to manipulate the model locally within a data source."""
from collections import namedtuple
from ipaddress import ip_address, ip_network
import time
from uuid import UUID

from ._apiutil import field_is_repeated
from .grpc_nm_model import NetworkMetadataEntity, NetworkMetadataReference, Reference
from .util import normalize_mac
from preseem_protobuf.model import service_pb2
from preseem_protobuf.model.account_pb2 import AccountStatus
from preseem_protobuf.model.common_pb2 import AccountType


def _validate_ip(value, field_name='ip_prefix'):
    """Check an IP prefix string value and return a normalized IP string."""
    net = ip_network(value, strict=False)
    if net.prefixlen == 0:
        raise ValueError(f"The '{field_name}' network cannot have prefixlen of 0")
    if net.prefixlen == net.max_prefixlen:
        return str(net.network_address)
    return str(net)


def _validate_mac(value, field_name='mac_address'):
    """Check a MAC address string value and return a normalized MAC address."""
    if value is None:
        raise ValueError(f"The '{field_name}' parameter must be non-null.")
    if not isinstance(value, str):
        raise TypeError(f"The '{field_name}' parameter must be of type string.")
    if value == '':
        raise ValueError(f"The '{field_name}' parameter must be non-empty.")
    mac = normalize_mac(value)
    if mac == '00:00:00:00:00:00':
        raise ValueError(f"The '{field_name}' parameter must not be the zero mac.")
    return mac


class Errorable:
    """A class to handle errors in a flexible way.  If error_parent is assigned to
       another Errorable class, the error will be set on the parent."""
    __slots__ = ["_errors", "error_parent"]
    Error = namedtuple('Error', ('field', 'value', 'exc_type', 'message'))
    Error.__repr__ = lambda x: f"Error setting '{x.field}' to '{x.value}': {x.message}"

    def __init__(self):
        self._errors = []
        self.error_parent = None  # set this to have errors set on a parent object.

    @property
    def errors(self):
        return self._errors

    def err(self, field, value, exc=None):
        if self.error_parent:
            self.error_parent.err(field, value, exc)
        else:
            self._errors.append(
                self.Error(field, value,
                           type(exc).__name__ if exc else None, str(exc)))

    def _check_enum(self, name, pb_enum_type, value):
        """helper method to check for a valid protobuf enum being set."""
        if value is not None:
            if not isinstance(value, int):
                self.err(
                    name, value,
                    TypeError(f"The {self.typename} '{name}' must be of type int."))
                return False
            if value not in pb_enum_type.DESCRIPTOR.values_by_number:
                self.err(
                    name, value,
                    f"The {self.typename} '{name}' must be in the range {tuple(pb_enum_type.DESCRIPTOR.values_by_number)}."
                )
                return False
            if value == 0:
                self.err(
                    name, value,
                    f"The {self.typename} '{name}' must not be set to {pb_enum_type.Name(0)}"
                )
                return False
        return True

    def _check_str(self, name, value):
        """helper method to check for a valid string parameter being set."""
        if value is not None:
            if not isinstance(value, str):
                self.err(
                    name, value,
                    TypeError(f"The {self.typename} '{name}' must be of type string."))
                return False
            if value == "":
                self.err(name, value,
                         f"The {self.typename} '{name}' must be non-empty.")
                return False
        return True


class Repeated(Errorable):
    """Class to make a "repeated" protobuf attribute pythonic."""

    def __init__(self, name, pb_field, cleaner=None, nodups=False):
        """Initialize a repeated field named pf_field in the protobuf, with an optional
           cleaner function that checks/cleans the data and returns a clean copy."""
        super().__init__()
        self.name = name
        self.pb_field = pb_field
        self.cleaner = cleaner
        self.nodups = nodups

    def _clean(self, value):
        if self.cleaner:
            try:
                value = self.cleaner(value)
            except (TypeError, ValueError) as err:
                self.err(self.name, value, err)
                return None
        return value

    def __iter__(self):
        return iter(self.pb_field)

    def __len__(self):
        return len(self.pb_field)

    def __getitem__(self, i):
        return self.pb_field[i]

    def __setitem__(self, i, value):
        value = self._clean(value)
        if value is not None:
            self.pb_field[i] = value

    def __delitem__(self, i):
        del self.pb_field[i]

    def __reversed__(self):
        return reversed(self.pb_field)

    def append(self, value):
        value = self._clean(value)
        if value is not None:
            if self.nodups and value in self.pb_field:
                return
            self.pb_field.append(value)

    def insert(self, i, value):
        value = self._clean(value)
        if value is not None:
            if self.nodups and value in self.pb_field:
                return
            self.pb_field.insert(i, value)

    def extend(self, values):
        if self.cleaner:
            values = [self._clean(value) for value in values]
        if self.nodups:
            values = [x for x in set(values) if x not in self.pb_field]
        self.pb_field.extend([x for x in values if x is not None])

    def __repr__(self):
        return f'{self.pb_field}'


class Account(Errorable):
    """Account class to use within a bss/oss data source."""
    typename = 'account'
    __slots__ = [
        "_id",
        "_type",
        "_status",
        "_name",
        "_first_name",
        "_last_name",
        "_url",
    ]
    # this is just used to list fields we want to print with __repr__
    attrs = ('id', 'type', 'status', 'name', 'first_name', 'last_name', 'url')

    def __init__(self, id, **kwargs):
        super().__init__()
        if id is None or str(id) == '':
            raise ValueError("The 'id' parameter must be non-None.")
        self._id = str(id)
        self._type = None
        self._status = None
        self._name = None
        self._first_name = None
        self._last_name = None
        self._url = None
        for key, value in kwargs.items():
            setattr(self, key, value)

    @property
    def id(self):
        return self._id

    @property
    def type(self):
        return self._type

    @type.setter
    def type(self, value):
        if self._check_enum('type', AccountType, value):
            self._type = value if value is not None else None

    @property
    def status(self):
        return self._status

    @status.setter
    def status(self, value):
        if self._check_enum('status', AccountStatus, value):
            self._status = value if value is not None else None

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if self._check_str('name', value):
            self._name = value if value is not None else None

    @property
    def first_name(self):
        return self._first_name

    @first_name.setter
    def first_name(self, value):
        if self._check_str('first_name', value):
            self._first_name = value if value is not None else None

    @property
    def last_name(self):
        return self._last_name

    @last_name.setter
    def last_name(self, value):
        if self._check_str('last_name', value):
            self._last_name = value if value is not None else None

    @property
    def url(self):
        return self._url

    @url.setter
    def url(self, value):
        if self._check_str('url', value):
            self._url = value if value is not None else None

    def __repr__(self):
        attr_values = [(attr, getattr(self, attr)) for attr in self.attrs]
        attributes_str = ', '.join(f"{attr}={value!r}" for attr, value in attr_values)
        return f"{type(self).__name__}({attributes_str})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for slot in self.__slots__:
                if getattr(self, slot) != getattr(other, slot):
                    return False
            return True
        return False

    def __hash__(self):
        return hash(self.id)


class Site(Errorable):
    """A Site object."""
    typename = 'site'
    __slots__ = ["_id", "_name", "_network_prefixes"]
    attrs = ('id', 'name', 'network_prefixes')

    def __init__(self, id, **kwargs):
        super().__init__()
        if id is None or str(id) == '':
            raise ValueError("The 'id' parameter must be non-None.")
        self._id = str(id)
        self._name = None
        self._network_prefixes = []
        for key, value in kwargs.items():
            setattr(self, key, value)

    @property
    def id(self):
        return self._id

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if self._check_str('name', value):
            self._name = value if value is not None else None

    @property
    def network_prefixes(self):
        return self._network_prefixes

    @network_prefixes.setter
    def network_prefixes(self, value):
        if value is not None:
            if not isinstance(value, list):
                raise TypeError("The 'netwock_prefixes' parameter must be a list.")
        self._network_prefixes = value or []

    def __repr__(self):
        attr_values = [(attr, getattr(self, attr)) for attr in self.attrs]
        attributes_str = ', '.join(f"{attr}={value!r}" for attr, value in attr_values)
        return f"{type(self).__name__}({attributes_str})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for slot in self.__slots__:
                if getattr(self, slot) != getattr(other, slot):
                    return False
            return True
        return False

    def __hash__(self):
        return hash(self.id)


class Attachment(Errorable):
    """A Service Attachment object that can be easily manimulated in Python."""
    typename = 'attachment'
    __slots__ = ["pb", "_mac_addresses", "_network_prefixes"]

    def __init__(self, pb=None, **kwargs):
        super().__init__()
        self.pb = pb if pb else service_pb2.Attachment()
        self._mac_addresses = Repeated('mac_addresses',
                                       self.pb.mac_addresses,
                                       _validate_mac,
                                       nodups=True)
        self._mac_addresses.error_parent = self
        self._network_prefixes = Repeated('network_prefixes',
                                          self.pb.network_prefixes,
                                          _validate_ip,
                                          nodups=True)
        self._network_prefixes.error_parent = self
        for key, value in kwargs.items():
            setattr(self, key, value)

    @property
    def cpe_mac_address(self):
        return self.pb.cpe_mac_address if self.pb.HasField('cpe_mac_address') else None

    @cpe_mac_address.setter
    def cpe_mac_address(self, value):
        if value is not None:
            try:
                self.pb.cpe_mac_address = _validate_mac(value, 'cpe_mac_address')
            except (TypeError, ValueError) as err:
                self.err('cpe_mac_address', value, err)
        else:
            self.pb.ClearField('cpe_mac_address')

    @property
    def imsi(self):
        return self.pb.imsi if self.pb.HasField('imsi') else None

    @imsi.setter
    def imsi(self, value):
        if self._check_str('imsi', value):
            if value is not None:
                self.pb.imsi = value
            else:
                self.pb.ClearField('imsi')

    @property
    def cpe_serial_number(self):
        return self.pb.cpe_serial_number if self.pb.HasField(
            'cpe_serial_number') else None

    @cpe_serial_number.setter
    def cpe_serial_number(self, value):
        if self._check_str('cpe_serial_number', value):
            if value is not None:
                self.pb.cpe_serial_number = value
            else:
                self.pb.ClearField('cpe_serial_number')

    @property
    def cpe_name(self):
        return self.pb.cpe_name if self.pb.HasField('cpe_name') else None

    @cpe_name.setter
    def cpe_name(self, value):
        if self._check_str('cpe_name', value):
            if value is not None:
                self.pb.cpe_name = str(value)
            else:
                self.pb.ClearField('cpe_name')

    @property
    def device_mac_address(self):
        return self.pb.device_mac_address if self.pb.HasField(
            'device_mac_address') else None

    @device_mac_address.setter
    def device_mac_address(self, value):
        if value is not None:
            try:
                self.pb.device_mac_address = _validate_mac(value, 'device_mac_address')
            except (TypeError, ValueError) as err:
                self.err('device_mac_address', value, err)
        else:
            self.pb.ClearField('device_mac_address')

    @property
    def username(self):
        return self.pb.username if self.pb.HasField('username') else None

    @username.setter
    def username(self, value):
        if value is not None:
            if not isinstance(value, str):
                self.err('username', value,
                         TypeError("The 'username' parameter must be of type string."))
                return
            if value == "":
                self.err('username', value,
                         TypeError("The 'username' parameter must be non-empty."))
                return
            self.pb.username = value
        else:
            self.pb.ClearField('username')

    @property
    def mac_addresses(self):
        return self._mac_addresses

    @property
    def network_prefixes(self):
        return self._network_prefixes

    def __repr__(self):
        field_values = [
            f"{field.name}={getattr(self, field.name)!r}"
            for field in self.pb.DESCRIPTOR.fields if hasattr(self, field.name)
        ]
        return f"{type(self).__name__}({', '.join(field_values)})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for field in self.pb.DESCRIPTOR.fields:
                self_field_value = getattr(self.pb, field.name)
                other_field_value = getattr(other.pb, field.name)
                if field_is_repeated(field):  # compare lists as sets
                    if set(self_field_value) != set(other_field_value):
                        return False
                else:
                    if self_field_value != other_field_value:
                        return False
            return True
        return False

    def __getstate__(self):
        """Serialize this object (pickle support)."""
        return self.pb.SerializeToString()

    def __setstate__(self, state):
        """Deserialize this object (pickle support)."""
        pb = service_pb2.Attachment()
        pb.ParseFromString(state)
        self.__init__(pb)


class Service(Errorable):
    """Service class to use within a bss/oss data source."""
    __slots__ = [
        "_id", "_account_id", "_package_id", "_download_rate", "_upload_rate",
        "_attachment", "_subscriber_identifier", "_url", "_account_uuid",
        "_package_uuid"
    ]
    # this is just used to list fields we want to print with __repr__
    attrs = ('id', 'account_id', 'package_id', 'download_rate', 'upload_rate',
             'subscriber_identifier', 'url', 'attachment')

    def __init__(self, id, **kwargs):
        super().__init__()
        if id is None or str(id) == '':
            raise ValueError("The 'id' parameter must be non-None.")
        self._id = str(id)
        self._account_id = None
        self._package_id = None
        self._download_rate = None
        self._upload_rate = None
        self._attachment = None
        self._subscriber_identifier = None
        self._url = None
        self._account_uuid = None  # used by sync_preseem_model for resolution
        self._package_uuid = None  # used by sync_preseem_model for resolution
        if 'attachment' not in kwargs:
            self._attachment = Attachment()  # for convenience; we usually want one
            self._attachment.error_parent = self
        for key, value in kwargs.items():
            setattr(self, key, value)

    @property
    def id(self):
        return self._id

    @property
    def account_id(self):
        """Get the ID of the account.  The id is immutable for the Service object."""
        return self._account_id

    @account_id.setter
    def account_id(self, value):
        if value == "":
            self.err('account_id', value,
                     ValueError("The 'account_id' parameter must be non-empty."))
            return
        self._account_id = str(value) if value is not None else None

    @property
    def package_id(self):
        return self._package_id

    @package_id.setter
    def package_id(self, value):
        if value == "":
            self.err('package_id', value,
                     ValueError("The 'package_id' parameter must be non-empty."))
            return
        self._package_id = str(value) if value is not None else None

    @property
    def download_rate(self):
        return self._download_rate

    @download_rate.setter
    def download_rate(self, value):
        try:
            self._download_rate = int(value) if value is not None else None
        except ValueError as err:
            self.err('download_rate', value, err)

    @property
    def upload_rate(self):
        return self._upload_rate

    @upload_rate.setter
    def upload_rate(self, value):
        try:
            self._upload_rate = int(value) if value is not None else None
        except ValueError as err:
            self.err('upload_rate', value, err)

    @property
    def attachment(self):
        return self._attachment

    @attachment.setter
    def attachment(self, value):
        if value is not None and not isinstance(value, Attachment):
            raise TypeError("service attachment must be of type Attachment.")
        elif value is None:
            self._attachment = None
        else:
            self._attachment = Attachment()
            self._attachment.pb.CopyFrom(value.pb)
            self._attachment.error_parent = self

    @property
    def subscriber_identifier(self):
        return self._subscriber_identifier

    @subscriber_identifier.setter
    def subscriber_identifier(self, value):
        if value == "":
            self.err('subscriber_identifier', value,
                     "The 'subscriber_identifier' parameter must be non-empty.")
            return
        self._subscriber_identifier = str(value) if value is not None else None

    @property
    def url(self):
        return self._url

    @url.setter
    def url(self, value):
        if not isinstance(value, str):
            self.err('url', value, "The router 'url' parameter must be a string.")
            return
        if value == "":
            self.err('url', value, "The router 'url' parameter must be non-empty.")
            return
        self._url = value if value is not None else None

    def __repr__(self):
        attr_values = [(attr, getattr(self, attr)) for attr in self.attrs]
        attributes_str = ', '.join(f"{attr}={value!r}" for attr, value in attr_values)
        return f"{type(self).__name__}({attributes_str})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for slot in self.__slots__:
                if getattr(self, slot) != getattr(other, slot):
                    return False
            return True
        return False

    def __hash__(self):
        return hash(self.id)


class BaseNetworkElement(Errorable):
    """Base class for NetworkElement types to use within a data source."""
    __slots__ = [
        "_id",
        "_community",
        "_host",
        "_name",
        "_site",
        "_url",
        "_site_uuid",
        "_uuid",
        "_site_id",
        "_poller_hash",
    ]
    # this is just used to list fields we want to print with __repr__
    attrs = ('id', 'name', 'site', 'host', 'community', 'url')

    def __init__(self, id, **kwargs):
        super().__init__()
        if id is None or str(id) == '':
            raise ValueError("The 'id' parameter for a Router must be non-empty.")
        self._id = str(id)
        self._community = None
        self._host = None
        self._name = None
        self._site = None
        self._url = None
        self._site_uuid = None  # used by sync_preseem_model for resolution
        self._uuid = None  # used to pass uuid back from sync_preseem_model
        self._site_id = None  # used for old topology mapping to a site
        self._poller_hash = None  # used for explicit element creation
        for key, value in kwargs.items():
            setattr(self, key, value)

    def get_nm(self, system=None):
        """Return network metadata objects for this object."""
        ref = NetworkMetadataReference(self.typename, self._id, {})
        if self._host:
            ref.attributes['host'] = self._host
        if self._name:
            ref.attributes['name'] = self._name
        if self._site:
            ref.attributes['site'] = self._site
        if self._community:
            ref.attributes['community'] = self._community
        if self._uuid:
            ref.attributes['uuid'] = str(UUID(bytes=self._uuid))
        if system:
            ref.attributes['system'] = system
        return [ref]

    @property
    def id(self):
        return self._id

    @property
    def community(self):
        return self._community

    @community.setter
    def community(self, value):
        if self._check_str('community', value):
            self._community = str(value) if value is not None else None

    @property
    def host(self):
        return self._host

    @host.setter
    def host(self, value):
        if value is not None:
            try:
                ip = ip_address(value)
            except (TypeError, ValueError) as err:
                self.err('host', value, err)
                return None
        self._host = str(ip) if value is not None else None

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if self._check_str('name', value):
            self._name = str(value) if value is not None else None

    @property
    def site(self):
        return self._site

    @site.setter
    def site(self, value):
        if self._check_str('site', value):
            self._site = str(value) if value is not None else None

    @property
    def url(self):
        return self._url

    @url.setter
    def url(self, value):
        if self._check_str('url', value):
            self._url = value if value is not None else None

    @property
    def site_id(self):
        return self._site_id

    @site_id.setter
    def site_id(self, value):
        if self._check_str('site_id', value):
            self._site_id = value if value is not None else None

    @property
    def poller_hash(self):
        return self._poller_hash

    @poller_hash.setter
    def poller_hash(self, value):
        if value is not None:
            if not isinstance(value, bytes):
                self.err(
                    'poller_hash', value,
                    TypeError(
                        f"The {self.typename} 'poller_hash' must be of type bytes."))
                return
            if len(value) != 32:
                self.err(
                    'poller_hash', value,
                    ValueError(f"The {self.typename} 'poller_hash' must be 32 bytes."))
                return
        self._poller_hash = value if value is not None else None

    def __repr__(self):
        attr_values = [(attr, getattr(self, attr)) for attr in self.attrs]
        attributes_str = ', '.join(f"{attr}={value!r}" for attr, value in attr_values)
        return f"{type(self).__name__}({attributes_str})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for slot in self.__slots__:
                if getattr(self, slot) != getattr(other, slot):
                    return False
            return True
        return False

    def __hash__(self):
        return hash(self.id)


class AccessPoint(BaseNetworkElement):
    """AP class to use within a data source."""
    typename = 'ap'
    role_uuid = UUID('ae3f1aa5-063a-4a24-9744-822a82d107d9')

    def get_nm(self, system=None):
        """Return network metadata objects for an Access Point."""
        ref = NetworkMetadataReference(self.typename, self._id, {})
        if self._host:
            ref.attributes['ip_address'] = self._host
            ref.attributes['ap_config'] = Reference(self._id)
            ref.attributes['topology1_snmp'] = Reference(self._id)
        if self._name:
            ref.attributes['topology1'] = self._name
        if self._site:
            ref.attributes['topology2'] = self._site
        if self._community:
            ref.attributes['community'] = self._community
        if self._uuid:
            ref.attributes['uuid'] = str(UUID(bytes=self._uuid))
        if system:
            ref.attributes['system'] = system
        objs = [ref]
        if self._host:
            attrs = {
                'service_rate_down': 10000000,
                'service_rate_up': 10000000,
                'subscriber_identifier': self._host,
                'subscriber_name': self._name
            }
            if system:
                attrs['system'] = system
            if self._site_id:
                attrs['ap'] = Reference(self._site_id)
            objs.append(NetworkMetadataReference('session', self._host, attrs))
            objs.append(
                NetworkMetadataEntity(self._host, time.time(), {
                    'session': Reference(self._host),
                    'snmp_session': Reference(self._host)
                }))
        return objs


class Olt(BaseNetworkElement):
    """OLT class to use within a data source."""
    typename = 'olt'
    role_uuid = UUID('2d9027d6-8639-40eb-8b65-b86c8ee2238b')


class Router(BaseNetworkElement):
    """Router class to use within a data source."""
    typename = 'router'
    role_uuid = UUID('bf433e28-122b-41df-b49a-77ac6544deac')


class Package(Errorable):
    """Package class to use within a bss/oss data source."""
    typename = 'package'
    __slots__ = ["_id", "_name", "_download_rate", "_upload_rate"]
    attrs = ('id', 'name', 'download_rate', 'upload_rate')

    def __init__(self, id, **kwargs):
        super().__init__()
        if id is None or str(id) == '':
            raise ValueError("The 'id' parameter must be non-None.")
        self._id = str(id)
        self._name = None
        self._download_rate = None
        self._upload_rate = None
        for key, value in kwargs.items():
            setattr(self, key, value)

    @property
    def id(self):
        return self._id

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if self._check_str('name', value):
            self._name = value if value is not None else None

    @property
    def download_rate(self):
        return self._download_rate

    @download_rate.setter
    def download_rate(self, value):
        try:
            self._download_rate = int(value) if value is not None else None
        except (TypeError, ValueError) as err:
            self.err('download_rate', value, err)

    @property
    def upload_rate(self):
        return self._upload_rate

    @upload_rate.setter
    def upload_rate(self, value):
        try:
            self._upload_rate = int(value) if value is not None else None
        except (TypeError, ValueError) as err:
            self.err('upload_rate', value, err)

    def get_nm(self, system=None):
        """Return network metadata objects for a Package."""
        ref = NetworkMetadataReference('package', self._id, {})
        if self._name:
            ref.attributes['service_name'] = self._name
        if self._download_rate:
            ref.attributes['service_rate_down'] = self._download_rate
        if self._upload_rate:
            ref.attributes['service_rate_up'] = self._upload_rate
        if system:
            ref.attributes['system'] = system
        return [ref]

    def __repr__(self):
        attr_values = [(attr, getattr(self, attr)) for attr in self.attrs]
        attributes_str = ', '.join(f"{attr}={value!r}" for attr, value in attr_values)
        return f"{type(self).__name__}({attributes_str})"

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            for slot in self.__slots__:
                if getattr(self, slot) != getattr(other, slot):
                    return False
            return True
        return False

    def __hash__(self):
        return hash(self.id)
