"""Base model ``Duplicable`` that allows duplication of a object and its relations."""

from django.db import models
from django.db import transaction
from django.db.models.fields.related import (
    ForwardManyToOneDescriptor,
    ReverseOneToOneDescriptor,
)

from django_children_models_dependencies.manager import ChildrenDependenciesManager

from .managers import DuplicableManager


class Duplicable(models.Model):
    """Base model for duplication features.

    Use this class as one of the baseclass of a model that you want to be duplicated
    (by itself or by duplicating one of its relation).

    Attributes
    ----------
    original_version : models.ForeignKey(self), related_name = 'all_duplicates'
        Link to the very first version where this duplicate came from. It's the very root
        of the duplicate tree.
    parent_version : models.ForeignKey(self), related_name = 'direct_duplicates'
        Link to the direct parent version where this duplicate came from.

    non_duplicable_relations : list
        List of relation names on this model that must not be duplicated.
        A relation name is:
        - the name of a M2M, FK, O2O field to another model
        - the related name of a M2M, FK, O2O from another model pointing to to *this* model
        There is no need to repeat non duplicable relations from parent models when defining
        ones for a subclass.

    _non_duplicable_relations_by_class : dict(classL set)
        Will hold the cached set of all non duplicable relations by class (defined in the current
        model and all parent models). Should not be used manually: call
        `get_non_duplicable_relations`` instead.

    instance_duplicator_class : type
        Default to ``None``, it will use ``InstanceDuplicator`` if not set
        It's the class that will be used to duplicate an instance.

    Example
    -------

    This example shows how to use ``non_duplicable_relations`` with inheritance

    >>> class BaseModel(Duplicable):
    ...
    ...     non_duplicable_relations = ['foo']
    ...
    ...     # We don't want this to be duplicated
    ...     foo = models.ForeignKey('SomeModel', on_delete=models.CASCADE)
    ...
    ...     class Meta:
    ...         abstract =True
    ...
    ... class MyModel(BaseModel):
    ...
    ...     # We don't need to repeat the non duplicable relations from parent models
    ...     non_duplicable_relations = ['bar']
    ...
    ...     # We don't want this to be duplicated
    ...     bar = models.ForeignKey('SomeOtherModel', on_delete=models.CASCADE)

    """

    non_duplicable_relations = [
        'original_version',
        'parent_version',
        'all_duplicates',
        'direct_duplicates',
    ]
    _non_duplicable_relations_by_class = {}  # pylint: disable=invalid-name

    instance_duplicator_class = None  # will default to InstanceDuplicator

    original_version = models.ForeignKey(
        'self',
        null=True,
        related_name="all_duplicates",
        on_delete=models.CASCADE
    )

    parent_version = models.ForeignKey(
        'self',
        null=True,
        related_name="direct_duplicates",
        on_delete=models.CASCADE
    )

    class Meta:
        abstract = True

    objects = DuplicableManager()

    @classmethod
    def get_non_duplicable_relations(cls):
        """Get all the relations that are set to not be duplicated.

        It reads the ``non_duplicable_relations`` attribute of the current model and all
        its parents to avoid redefining in sub-models the list of relations defined in
        parents.

        It is computed only once and cached at class level in ``cls._non_duplicable_relations``.

        """

        # First time we call this for this class, create the cached set
        if cls not in Duplicable._non_duplicable_relations_by_class:
            _non_duplicable_relations = set()
            for parent_model in cls.__mro__:
                _non_duplicable_relations.update(getattr(parent_model,
                                                         'non_duplicable_relations', []))

            Duplicable._non_duplicable_relations_by_class[cls] = _non_duplicable_relations

        return Duplicable._non_duplicable_relations_by_class[cls]

    @staticmethod
    def get_all_duplicable_models():
        """Return the ordered list of duplicable models.

        The ordering depends of the dependencies: each dependencies of a model should come
        before a model in the list.

        Returns
        -------
        list
            List the representation of all models to duplicate.

        """

        return ChildrenDependenciesManager.get_children_dependencies(Duplicable)

    def set_duplicated_field(self, field_name, value):
        """Set a new value for the given field when duplicating the object.

        Must only be called when the object is being duplicated.
        Allows for example to make some checks in subclasses.

        Parameters
        ----------
        field_name: str
            The name of the field to set
        value:
            The value to set on the field

        """

        setattr(self, field_name, value)

    def duplicate(self, non_duplicable_relations=None, detach=False, **kwargs):
        """Return a duplicated version of the current duplicable instance.

        All duplicable relations will be duplicated too.

        The whole duplicate work is done in an atomic way to avoid having the main object
        duplicated without all of its dependencies before the end, or in case of errors.

        See ``.utils.duplicable`` docstring for more information.

        Parameters
        ----------
        non_duplicable_relations : iterable, optional
            Some entries to *add* to the default ``non_duplicable_relations`` set defined on the
            model.
        detach: bool
            When set to ``True``, the new duplicate will be detached from the original object
            by clearing the `parent_version` and `original_version` fields.
        kwargs : dict, optional
            To force some field values (normal fields, foreign keys or OneToOne fields) on
            the new instance instead of taking the value from ``self`` or, if it's a relation,
            to duplicate it.

        Returns
        -------
        model instance
            A new instance of the same model.

        """

        from .utils import duplicate  # avoid circular import

        with transaction.atomic():
            duplicated_self, __ = duplicate(self, non_duplicable_relations, detach, **kwargs)

        return duplicated_self

    def compare(self, other_instance):
        """Compare the current instance with another one.

        Parameters
        ----------
        other_instance : django.db.models.Model
            The second object to compare. Must be from the same model as ``self``.

        Returns
        -------
        tuple :
            boolean
                If the two instances are the same or not
            dict
                Informations about comparison for self and all dependencies
                (see ``compare_refs`` attribute of ``.duplicators.InstanceDuplicator``)

        """

        from .utils import compare  # avoid circular import

        return compare(self, other_instance)


# pylint: disable=wrong-import-position,wrong-import-order
try:
    # This import is just to define some classes if ``django-polymorphic`` is used
    from polymorphic.models import PolymorphicModel
except ImportError:
    # django-polymorphic is not installed so we don't need to manage it
    pass
else:
    from .managers import PolymorphicManagerForDuplicable

    class PolymorphicModelForDuplicable(PolymorphicModel):
        """Base model to use instead of ``PolymorphicModel`` if the model is duplicable.

        The ``PolymorphicModel`` replace the descriptor set by django on some fields, ommiting the
        setter, which causes some troubles because we use ``__setattr``.
        The goal for ``django-polymorphic`` for replacing the descriptor is just to tell django
        to use the original manager, not the one defined by the polymorphic app.
        But since, the code of the descriptor has evolved and there is a better way to tell it to
        use a specific manager: forcing the one we want where django needs it.
        It appears that it's in the ``get_queryset`` method of the descriptor, so we monkey
        patch it with our own manager.

        Example
        -------

        class MyBaseModel(Duplicable, PolymorphicModelForDuplicable):
            # Uses ``PolymorphicModelForDuplicable`` instead of ``PolymorphicModel``
            pass

        class MySubModel(MyBaseModel):
            pass

        """

        class Meta:
            abstract = True

        duplicable_objects = PolymorphicManagerForDuplicable()

        def __init__(self, *args, **kwargs):

            # We don't want ``PolymorphicModel`` to do the replacement itself
            self.__class__.polymorphic_super_sub_accessors_replaced = True

            super().__init__(*args, **kwargs)

            def monkey_patch_descriptor(descriptor, manager):
                """Tell the ``get_queryset`` of the descriptor to use the base manager, not ours"""
                descriptor.get_queryset = lambda **hints: manager.db_manager(hints=hints).all()

            # Default code in ``PolymorphicModel`` to get and iterate on fields that need to be
            # updated.

            for name, model in self._get_inheritance_relation_fields_and_models().items():
                orig_accessor = getattr(self.__class__, name, None)
                if isinstance(orig_accessor, (ReverseOneToOneDescriptor,
                                              ForwardManyToOneDescriptor)):

                    # Here we use our monkey patch instead of the one from ``PolymorphicModel``
                    monkey_patch_descriptor(orig_accessor, model.base_objects)
