"""DiffSync front-end classes and logic.

Copyright (c) 2020 Network To Code, LLC <info@networktocode.com>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from collections import defaultdict
from inspect import isclass
from typing import ClassVar, Dict, List, Mapping, MutableMapping, Optional, Text, Tuple, Type, Union

from pydantic import BaseModel, PrivateAttr
import structlog  # type: ignore

from .diff import Diff
from .enum import DiffSyncModelFlags, DiffSyncFlags, DiffSyncStatus
from .exceptions import ObjectAlreadyExists, ObjectStoreWrongType, ObjectNotFound
from .helpers import DiffSyncDiffer, DiffSyncSyncer


class DiffSyncModel(BaseModel):
    """Base class for all DiffSync object models.

    Note that read-only APIs of this class are implemented as `get_*()` functions rather than as properties;
    this is intentional as specific model classes may want to use these names (`type`, `keys`, `attrs`, etc.)
    as model attributes and we want to avoid any ambiguity or collisions.

    This class has several underscore-prefixed class variables that subclasses should set as desired; see below.

    NOTE: The groupings _identifiers, _attributes, and _children are mutually exclusive; any given field name can
          be included in **at most** one of these three tuples.
    """

    _modelname: ClassVar[str] = "diffsyncmodel"
    """Name of this model, used by DiffSync to store and look up instances of this model or its equivalents.

    Lowercase by convention; typically corresponds to the class name, but that is not enforced.
    """

    _identifiers: ClassVar[Tuple[str, ...]] = ()
    """List of model fields which together uniquely identify an instance of this model.

    This identifier MUST be globally unique among all instances of this class.
    """

    _shortname: ClassVar[Tuple[str, ...]] = ()
    """Optional: list of model fields that together form a shorter identifier of an instance.

    This MUST be locally unique (e.g., interface shortnames MUST be unique among all interfaces on a given device),
    but does not need to be guaranteed to be globally unique among all instances.
    """

    _attributes: ClassVar[Tuple[str, ...]] = ()
    """Optional: list of additional model fields (beyond those in `_identifiers`) that are relevant to this model.

    Only the fields in `_attributes` (as well as any `_children` fields, see below) will be considered
    for the purposes of Diff calculation.
    A model may define additional fields (not included in `_attributes`) for its internal use;
    a common example would be a locally significant database primary key or id value.

    Note: inclusion in `_attributes` is mutually exclusive from inclusion in `_identifiers`; a field cannot be in both!
    """

    _children: ClassVar[Mapping[str, str]] = {}
    """Optional: dict of `{_modelname: field_name}` entries describing how to store "child" models in this model.

    When calculating a Diff or performing a sync, DiffSync will automatically recurse into these child models.

    Note: inclusion in `_children` is mutually exclusive from inclusion in `_identifiers` or `_attributes`.
    """

    model_flags: DiffSyncModelFlags = DiffSyncModelFlags.NONE
    """Optional: any non-default behavioral flags for this DiffSyncModel.

    Can be set as a class attribute or an instance attribute as needed.
    """

    diffsync: Optional["DiffSync"] = None
    """Optional: the DiffSync instance that owns this model instance."""

    _status: DiffSyncStatus = PrivateAttr(DiffSyncStatus.SUCCESS)
    """Status of the last attempt at creating/updating/deleting this model."""

    _status_message: str = PrivateAttr("")
    """Message, if any, associated with the create/update/delete status value."""

    class Config:  # pylint: disable=too-few-public-methods
        """Pydantic class configuration."""

        # Let us have a DiffSync as an instance variable even though DiffSync is not a Pydantic model itself.
        arbitrary_types_allowed = True

    def __init_subclass__(cls):
        """Validate that the various class attribute declarations correspond to actual instance fields.

        Called automatically on subclass declaration.
        """
        variables = cls.__fields__.keys()
        # Make sure that any field referenced by name actually exists on the model
        for attr in cls._identifiers:
            if attr not in variables and not hasattr(cls, attr):
                raise AttributeError(f"_identifiers {cls._identifiers} references missing or un-annotated attr {attr}")
        for attr in cls._shortname:
            if attr not in variables:
                raise AttributeError(f"_shortname {cls._shortname} references missing or un-annotated attr {attr}")
        for attr in cls._attributes:
            if attr not in variables:
                raise AttributeError(f"_attributes {cls._attributes} references missing or un-annotated attr {attr}")
        for attr in cls._children.values():
            if attr not in variables:
                raise AttributeError(f"_children {cls._children} references missing or un-annotated attr {attr}")

        # Any given field can only be in one of (_identifiers, _attributes, _children)
        id_attr_overlap = set(cls._identifiers).intersection(cls._attributes)
        if id_attr_overlap:
            raise AttributeError(f"Fields {id_attr_overlap} are included in both _identifiers and _attributes.")
        id_child_overlap = set(cls._identifiers).intersection(cls._children.values())
        if id_child_overlap:
            raise AttributeError(f"Fields {id_child_overlap} are included in both _identifiers and _children.")
        attr_child_overlap = set(cls._attributes).intersection(cls._children.values())
        if attr_child_overlap:
            raise AttributeError(f"Fields {attr_child_overlap} are included in both _attributes and _children.")

    def __repr__(self):
        return f'{self.get_type()} "{self.get_unique_id()}"'

    def __str__(self):
        return self.get_unique_id()

    def dict(self, **kwargs) -> dict:
        """Convert this DiffSyncModel to a dict, excluding the diffsync field by default as it is not serializable."""
        if "exclude" not in kwargs:
            kwargs["exclude"] = {"diffsync"}
        return super().dict(**kwargs)

    def json(self, **kwargs) -> str:
        """Convert this DiffSyncModel to a JSON string, excluding the diffsync field by default as it is not serializable."""
        if "exclude" not in kwargs:
            kwargs["exclude"] = {"diffsync"}
        if "exclude_defaults" not in kwargs:
            kwargs["exclude_defaults"] = True
        return super().json(**kwargs)

    def str(self, include_children: bool = True, indent: int = 0) -> str:
        """Build a detailed string representation of this DiffSyncModel and optionally its children."""
        margin = " " * indent
        output = f"{margin}{self.get_type()}: {self.get_unique_id()}: {self.get_attrs()}"
        for modelname, fieldname in self._children.items():
            output += f"\n{margin}  {fieldname}"
            child_ids = getattr(self, fieldname)
            if not child_ids:
                output += ": []"
            elif not self.diffsync or not include_children:
                output += f": {child_ids}"
            else:
                for child_id in child_ids:
                    try:
                        child = self.diffsync.get(modelname, child_id)
                        output += "\n" + child.str(include_children=include_children, indent=indent + 4)
                    except ObjectNotFound:
                        output += f"\n{margin}    {child_id} (ERROR: details unavailable)"
        return output

    def set_status(self, status: DiffSyncStatus, message: Text = ""):
        """Update the status (and optionally status message) of this model in response to a create/update/delete call."""
        self._status = status
        self._status_message = message

    @classmethod
    def create(cls, diffsync: "DiffSync", ids: Mapping, attrs: Mapping) -> Optional["DiffSyncModel"]:
        """Instantiate this class, along with any platform-specific data creation.

        Subclasses must call `super().create()`; they may wish to then override the default status information
        by calling `set_status()` to provide more context (such as details of any interactions with underlying systems).

        Args:
            diffsync: The master data store for other DiffSyncModel instances that we might need to reference
            ids: Dictionary of unique-identifiers needed to create the new object
            attrs: Dictionary of additional attributes to set on the new object

        Returns:
            DiffSyncModel: instance of this class, if all data was successfully created.
            None: if data creation failed in such a way that child objects of this model should not be created.

        Raises:
            ObjectNotCreated: if an error occurred.
        """
        model = cls(**ids, diffsync=diffsync, **attrs)
        model.set_status(DiffSyncStatus.SUCCESS, "Created successfully")
        return model

    def update(self, attrs: Mapping) -> Optional["DiffSyncModel"]:
        """Update the attributes of this instance, along with any platform-specific data updates.

        Subclasses must call `super().update()`; they may wish to then override the default status information
        by calling `set_status()` to provide more context (such as details of any interactions with underlying systems).

        Args:
            attrs: Dictionary of attributes to update on the object

        Returns:
            DiffSyncModel: this instance, if all data was successfully updated.
            None: if data updates failed in such a way that child objects of this model should not be modified.

        Raises:
            ObjectNotUpdated: if an error occurred.
        """
        for attr, value in attrs.items():
            # TODO: enforce that only attrs in self._attributes can be updated in this way?
            setattr(self, attr, value)

        self.set_status(DiffSyncStatus.SUCCESS, "Updated successfully")
        return self

    def delete(self) -> Optional["DiffSyncModel"]:
        """Delete any platform-specific data corresponding to this instance.

        Subclasses must call `super().delete()`; they may wish to then override the default status information
        by calling `set_status()` to provide more context (such as details of any interactions with underlying systems).

        Returns:
            DiffSyncModel: this instance, if all data was successfully deleted.
            None: if data deletion failed in such a way that child objects of this model should not be deleted.

        Raises:
            ObjectNotDeleted: if an error occurred.
        """
        self.set_status(DiffSyncStatus.SUCCESS, "Deleted successfully")
        return self

    @classmethod
    def get_type(cls) -> Text:
        """Return the type AKA modelname of the object or the class

        Returns:
            str: modelname of the class, used in to store all objects
        """
        return cls._modelname

    @classmethod
    def create_unique_id(cls, **identifiers) -> Text:
        """Construct a unique identifier for this model class.

        Args:
            **identifiers: Dict of identifiers and their values, as in `get_identifiers()`.
        """
        return "__".join(str(identifiers[key]) for key in cls._identifiers)

    @classmethod
    def get_children_mapping(cls) -> Mapping[Text, Text]:
        """Get the mapping of types to fieldnames for child models of this model."""
        return cls._children

    def get_identifiers(self) -> Mapping:
        """Get a dict of all identifiers (primary keys) and their values for this object.

        Returns:
            dict: dictionary containing all primary keys for this device, as defined in _identifiers
        """
        return self.dict(include=set(self._identifiers))

    def get_attrs(self) -> Mapping:
        """Get all the non-primary-key attributes or parameters for this object.

        Similar to Pydantic's `BaseModel.dict()` method, with the following key differences:
        1. Does not include the fields in `_identifiers`
        2. Only includes fields explicitly listed in `_attributes`
        3. Does not include any additional fields not listed in `_attributes`

        Returns:
            dict: Dictionary of attributes for this object
        """
        return self.dict(include=set(self._attributes))

    def get_unique_id(self) -> Text:
        """Get the unique ID of an object.

        By default the unique ID is built based on all the primary keys defined in `_identifiers`.

        Returns:
            str: Unique ID for this object
        """
        return self.create_unique_id(**self.get_identifiers())

    def get_shortname(self) -> Text:
        """Get the (not guaranteed-unique) shortname of an object, if any.

        By default the shortname is built based on all the keys defined in `_shortname`.
        If `_shortname` is not specified, then this function is equivalent to `get_unique_id()`.

        Returns:
            str: Shortname of this object
        """
        if self._shortname:
            return "__".join([str(getattr(self, key)) for key in self._shortname])
        return self.get_unique_id()

    def get_status(self) -> Tuple[DiffSyncStatus, Text]:
        """Get the status of the last create/update/delete operation on this object, and any associated message."""
        return (self._status, self._status_message)

    def add_child(self, child: "DiffSyncModel"):
        """Add a child reference to an object.

        The child object isn't stored, only its unique id.
        The name of the target attribute is defined in `_children` per object type

        Raises:
            ObjectStoreWrongType: if the type is not part of `_children`
            ObjectAlreadyExists: if the unique id is already stored
        """
        child_type = child.get_type()

        if child_type not in self._children:
            raise ObjectStoreWrongType(
                f"Unable to store {child_type} as a child of {self.get_type()}; "
                f"valid types are {sorted(self._children.keys())}"
            )

        attr_name = self._children[child_type]
        childs = getattr(self, attr_name)
        if child.get_unique_id() in childs:
            raise ObjectAlreadyExists(f"Already storing a {child_type} with unique_id {child.get_unique_id()}")
        childs.append(child.get_unique_id())

    def remove_child(self, child: "DiffSyncModel"):
        """Remove a child reference from an object.

        The name of the storage attribute is defined in `_children` per object type.

        Raises:
            ObjectStoreWrongType: if the child model type is not part of `_children`
            ObjectNotFound: if the child wasn't previously present.
        """
        child_type = child.get_type()

        if child_type not in self._children:
            raise ObjectStoreWrongType(
                f"Unable to find and delete {child_type} as a child of {self.get_type()}; "
                f"valid types are {sorted(self._children.keys())}"
            )

        attr_name = self._children[child_type]
        childs = getattr(self, attr_name)
        if child.get_unique_id() not in childs:
            raise ObjectNotFound(f"{child} was not found as a child in {attr_name}")
        childs.remove(child.get_unique_id())


class DiffSync:
    """Class for storing a group of DiffSyncModel instances and diffing/synchronizing to another DiffSync instance."""

    # Add mapping of names to specific model classes here:
    # modelname1 = MyModelClass1
    # modelname2 = MyModelClass2

    type: ClassVar[Optional[str]] = None
    """Type of the object, will default to the name of the class if not provided."""

    top_level: ClassVar[List[str]] = []
    """List of top-level modelnames to begin from when diffing or synchronizing."""

    _data: MutableMapping[str, MutableMapping[str, DiffSyncModel]]
    """Defaultdict storing model instances.

    `self._data[modelname][unique_id] == model_instance`
    """

    def __init__(self, name=None):
        """Generic initialization function.

        Subclasses should be careful to call super().__init__() if they override this method.
        """
        self._data = defaultdict(dict)
        self._log = structlog.get_logger().new(diffsync=self)

        # If the type is not defined, use the name of the class as the default value
        if self.type is None:
            self.type = self.__class__.__name__

        # If the name has not been provided, use the type as the name
        self.name = name if name else self.type

    def __init_subclass__(cls):
        """Validate that references to specific DiffSyncModels use the correct modelnames.

        Called automatically on subclass declaration.
        """
        contents = cls.__dict__
        for name, value in contents.items():
            if isclass(value) and issubclass(value, DiffSyncModel) and value.get_type() != name:
                raise AttributeError(
                    f'Incorrect field name - {value.__name__} has type name "{value.get_type()}", not "{name}"'
                )

        for name in cls.top_level:
            if not hasattr(cls, name):
                raise AttributeError(f'top_level references attribute "{name}" but it is not a class attribute!')
            value = getattr(cls, name)
            if not isclass(value) or not issubclass(value, DiffSyncModel):
                raise AttributeError(f'top_level references attribute "{name}" but it is not a DiffSyncModel subclass!')

    def __str__(self):
        """String representation of a DiffSync."""
        if self.type != self.name:
            return f'{self.type} "{self.name}"'
        return self.type

    def __repr__(self):
        return f"<{str(self)}>"

    def load(self):
        """Load all desired data from whatever backend data source into this instance."""
        # No-op in this generic class

    def dict(self, exclude_defaults: bool = True, **kwargs) -> Mapping:
        """Represent the DiffSync contents as a dict, as if it were a Pydantic model."""
        data: Dict[str, Dict[str, Dict]] = {}
        for modelname in self._data:
            data[modelname] = {}
            for unique_id, model in self._data[modelname].items():
                data[modelname][unique_id] = model.dict(exclude_defaults=exclude_defaults, **kwargs)
        return data

    def str(self, indent: int = 0) -> str:
        """Build a detailed string representation of this DiffSync."""
        margin = " " * indent
        output = ""
        for modelname in self.top_level:
            if output:
                output += "\n"
            output += f"{margin}{modelname}"
            models = self.get_all(modelname)
            if not models:
                output += ": []"
            else:
                for model in models:
                    output += "\n" + model.str(indent=indent + 2)
        return output

    # ------------------------------------------------------------------------------
    # Synchronization between DiffSync instances
    # ------------------------------------------------------------------------------

    def sync_from(self, source: "DiffSync", diff_class: Type[Diff] = Diff, flags: DiffSyncFlags = DiffSyncFlags.NONE):
        """Synchronize data from the given source DiffSync object into the current DiffSync object.

        Args:
            source (DiffSync): object to sync data from into this one
            diff_class (class): Diff or subclass thereof to use to calculate the diffs to use for synchronization
            flags (DiffSyncFlags): Flags influencing the behavior of this sync.
        """
        diff = self.diff_from(source, diff_class=diff_class, flags=flags)
        syncer = DiffSyncSyncer(diff=diff, src_diffsync=source, dst_diffsync=self, flags=flags)
        result = syncer.perform_sync()
        if result:
            self.sync_complete(source, diff, flags, syncer.base_logger)

    def sync_to(self, target: "DiffSync", diff_class: Type[Diff] = Diff, flags: DiffSyncFlags = DiffSyncFlags.NONE):
        """Synchronize data from the current DiffSync object into the given target DiffSync object.

        Args:
            target (DiffSync): object to sync data into from this one.
            diff_class (class): Diff or subclass thereof to use to calculate the diffs to use for synchronization
            flags (DiffSyncFlags): Flags influencing the behavior of this sync.
        """
        target.sync_from(self, diff_class=diff_class, flags=flags)

    def sync_complete(
        self,
        source: "DiffSync",
        diff: Diff,
        flags: DiffSyncFlags = DiffSyncFlags.NONE,
        logger: structlog.BoundLogger = None,
    ):
        """Callback triggered after a `sync_from` operation has completed and updated the model data of this instance.

        Note that this callback is **only** triggered if the sync actually resulted in data changes. If there are no
        detected changes, this callback will **not** be called.

        The default implementation does nothing, but a subclass could use this, for example, to perform bulk updates
        to a backend (such as a file) that doesn't readily support incremental updates to individual records.

        Args:
          source: The DiffSync whose data was used to update this instance.
          diff: The Diff calculated prior to the sync operation.
          flags: Any flags that influenced the sync.
          logger: Logging context for the sync.
        """

    # ------------------------------------------------------------------------------
    # Diff calculation and construction
    # ------------------------------------------------------------------------------

    def diff_from(
        self, source: "DiffSync", diff_class: Type[Diff] = Diff, flags: DiffSyncFlags = DiffSyncFlags.NONE
    ) -> Diff:
        """Generate a Diff describing the difference from the other DiffSync to this one.

        Args:
            source (DiffSync): Object to diff against.
            diff_class (class): Diff or subclass thereof to use for diff calculation and storage.
            flags (DiffSyncFlags): Flags influencing the behavior of this diff operation.
        """
        differ = DiffSyncDiffer(src_diffsync=source, dst_diffsync=self, flags=flags, diff_class=diff_class)
        return differ.calculate_diffs()

    def diff_to(
        self, target: "DiffSync", diff_class: Type[Diff] = Diff, flags: DiffSyncFlags = DiffSyncFlags.NONE
    ) -> Diff:
        """Generate a Diff describing the difference from this DiffSync to another one.

        Args:
            target (DiffSync): Object to diff against.
            diff_class (class): Diff or subclass thereof to use for diff calculation and storage.
            flags (DiffSyncFlags): Flags influencing the behavior of this diff operation.
        """
        return target.diff_from(self, diff_class=diff_class, flags=flags)

    # ------------------------------------------------------------------------------
    # Object Storage Management
    # ------------------------------------------------------------------------------

    def get(
        self, obj: Union[Text, DiffSyncModel, Type[DiffSyncModel]], identifier: Union[Text, Mapping]
    ) -> DiffSyncModel:
        """Get one object from the data store based on its unique id.

        Args:
            obj: DiffSyncModel class or instance, or modelname string, that defines the type of the object to retrieve
            identifier: Unique ID of the object to retrieve, or dict of unique identifier keys/values

        Raises:
            ValueError: if obj is a str and identifier is a dict (can't convert dict into a uid str without a model class)
            ObjectNotFound: if the requested object is not present
        """
        if isinstance(obj, str):
            modelname = obj
            if not hasattr(self, obj):
                object_class = None
            else:
                object_class = getattr(self, obj)
        else:
            object_class = obj
            modelname = obj.get_type()

        if isinstance(identifier, str):
            uid = identifier
        elif object_class:
            uid = object_class.create_unique_id(**identifier)
        else:
            raise ValueError(
                f"Invalid args: ({obj}, {identifier}): "
                f"either {obj} should be a class/instance or {identifier} should be a str"
            )

        if uid not in self._data[modelname]:
            raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")
        return self._data[modelname][uid]

    def get_all(self, obj: Union[Text, DiffSyncModel, Type[DiffSyncModel]]):
        """Get all objects of a given type.

        Args:
            obj: DiffSyncModel class or instance, or modelname string, that defines the type of the objects to retrieve

        Returns:
            ValuesList[DiffSyncModel]: List of Object
        """
        if isinstance(obj, str):
            modelname = obj
        else:
            modelname = obj.get_type()

        return self._data[modelname].values()

    def get_by_uids(
        self, uids: List[Text], obj: Union[Text, DiffSyncModel, Type[DiffSyncModel]]
    ) -> List[DiffSyncModel]:
        """Get multiple objects from the store by their unique IDs/Keys and type.

        Args:
            uids: List of unique id / key identifying object in the database.
            obj: DiffSyncModel class or instance, or modelname string, that defines the type of the objects to retrieve

        Raises:
            ObjectNotFound: if any of the requested UIDs are not found in the store
        """
        if isinstance(obj, str):
            modelname = obj
        else:
            modelname = obj.get_type()

        results = []
        for uid in uids:
            if uid not in self._data[modelname]:
                raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")
            results.append(self._data[modelname][uid])
        return results

    def add(self, obj: DiffSyncModel):
        """Add a DiffSyncModel object to the store.

        Args:
            obj (DiffSyncModel): Object to store

        Raises:
            ObjectAlreadyExists: if an object with the same uid is already present
        """
        modelname = obj.get_type()
        uid = obj.get_unique_id()

        if uid in self._data[modelname]:
            raise ObjectAlreadyExists(f"Object {uid} already present")

        if not obj.diffsync:
            obj.diffsync = self

        self._data[modelname][uid] = obj

    def remove(self, obj: DiffSyncModel, remove_children: bool = False):
        """Remove a DiffSyncModel object from the store.

        Args:
            obj (DiffSyncModel): object to remove
            remove_children (bool): If True, also recursively remove any children of this object

        Raises:
            ObjectNotFound: if the object is not present
        """
        modelname = obj.get_type()
        uid = obj.get_unique_id()

        if uid not in self._data[modelname]:
            raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")

        if obj.diffsync is self:
            obj.diffsync = None

        del self._data[modelname][uid]

        if remove_children:
            for child_type, child_fieldname in obj.get_children_mapping().items():
                for child_id in getattr(obj, child_fieldname):
                    try:
                        child_obj = self.get(child_type, child_id)
                        self.remove(child_obj, remove_children=remove_children)
                    except ObjectNotFound:
                        # Since this is "cleanup" code, log an error and continue, instead of letting the exception raise
                        self._log.error(f"Unable to remove child {child_id} of {modelname} {uid} - not found!")


# DiffSyncModel references DiffSync and DiffSync references DiffSyncModel. Break the typing loop:
DiffSyncModel.update_forward_refs()
