"""Models (mixins) for the ``versionable`` app

This app provides two mixins:

- ``Versionable`` : based on ``Duplicable``, it's a mixin to use in a model at the top level of
    something that may be versioned.
    An instance can be in different state (see ``VERSION_STATES`` in the ``Versionable``
    docstring).
    It allows to have an history of the model: each update must be done in a draft copy of an
    existing instance. And it also allows to copy a version to someone else: they will be able to
    edit their own version(s).
- ``Dependency`` : based on ``Duplicable``, it's a mixin to use for models that works together with
    the one used with ``Versionable``. Each time a versionable model is copied, all its
    dependencies are copied too.


"""

from contextlib import contextmanager

from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from django_lockable.exceptions import LockedField
from django_lockable.models import Lockable
from extended_choices import Choices

from mtp.discrete_apps.duplicable.models import Duplicable

from .exceptions import NotCopiable, NotDraftable, NotEditable, NotPublishable
from .managers import DependencyManager, VersionableManager


class BaseVersionable(Lockable, Duplicable, models.Model):
    """Base class for the ``Versionable`` and ``Dependency`` models.

    It define default constants and some default methods.

    Attributes
    ----------
    non_lockable_fields : list
        List of field names on this model that must not be locked.

    is_editable : bool
        Default to ``True``, must be overridden in subclasses for specific behaviour.

    loaded_version_state : str
        The value of the ``version_state`` field when the instance was last loaded from DB.
        It's this field that is used to know the state to take into account to know what
        operations are available.

    LockedFieldException : exception
        The exception to raise when a attempt to edit a locked field is done.
        Replace the default one from the ``Lockable`` model, which is ``LockedField`` by
        ``NotEditable``, which give more information on why the field couldn't be edited: the
        actual state of the versionable instance.


    """

    non_lockable_fields = [
        'original_version',
        'parent_version',
        'all_duplicates',
        'direct_duplicates',
    ]

    is_editable = True

    LockedFieldException = NotEditable

    class Meta:
        abstract = True

    def is_field_locked(self, field_name):
        """Override from ``Lockable`` to return ``False`` if the object is editable.

        Parameters
        ----------
        field_name : str
            The name of the field we want to check if it is locked or not.

        Returns
        -------
        bool
            ``True`` if the field is considered locked, or ``False`` otherwise.

        """

        if not super().is_field_locked(field_name):
            return False

        return not self.is_editable


class Versionable(BaseVersionable):
    """Mixin for versionable models.

    A ``Versionable`` model is the root of a versionable tree.
    It has some dependencies, ie models inheriting from the ``Dependency`` that are linked to
    this versionable model by defining their ``dependency_link``.

    A versionable model has a state that allow or not editing, copying...

    Attributes
    ----------
    VERSION_STATES : extended_choices.Choices
       A ``Choices`` object holding the different values for the ``version_state`` field:
       - ``COPIED``: the object is copied from a published version. It can be published as is or a
                     draft can be created from it (normal draft, or detached draft via ``copy``)
       - ``COPYING``: the object is currently being copied. Used only internally when copying
                      instances to avoid raising ``NotEditable`` exceptions.
       - ``DRAFT`` : the object is not published but editable. A detached draft can be created
                     from it.
       - ``PUBLISHED`` : the object is published and not editable. A draft can be created from it.
       - ``ARCHIVED`` : the object is archived, ie not editable and cannot be published anymore.
                        A detached draft can be created from it
       Subsets:
       - ``PUBLISHABLE`` : only matches states in which the instance can be published
       - ``DRAFTABLE`` : only matches states in which a draft can be created from the instance
       - ``COPIABLE`` : only matches states in which a detached draft can be created
       - ``EDITABLE`` : only matches states in which the instance is editable

    _dependency_links : list (class attribute)
        List of all dependency links to this model.
        A dependency link is a tuple with two entries: the model that depends on the ``Versionable``
        model, and the name of the field (or lookup with ``__`` if multi-level relation) that
        refers to it (a ``ForeignKey``). It's defined via the ``versionable_link`` in each
        dependency.

    version_state : models.CharField
        The field holding the current version of the state. See ``VERSION_STATES`` for available
        values
    link_dependency_models : list
        List all the models that have a dependency on this one.

    created_at : models.DateTimeField
        Moment this object was created. Automatically set to ``timezone.now()`` when saved the
        first time.

    For the parameters, see ``django.db.models.Model``

    """

    VERSION_STATES = Choices(
        ('COPIED', 'c', _("Copied")),
        ('COPYING', 'C', _("Copying")),
        ('DRAFT', 'd', _("Draft")),
        ('PUBLISHED', 'p', _("Published")),
        ('ARCHIVED', 'a', _("Archived")),
    )
    VERSION_STATES.add_subset('PUBLISHABLE', ['COPIED', 'DRAFT'])
    VERSION_STATES.add_subset('DRAFTABLE', ['COPIED', 'PUBLISHED'])
    VERSION_STATES.add_subset('COPIABLE', ['ARCHIVED', 'COPIED', 'DRAFT', 'PUBLISHED'])
    VERSION_STATES.add_subset('EDITABLE', ['COPYING', 'DRAFT'])

    version_state = models.CharField(
        verbose_name=_("Version state"),
        db_index=True,
        choices=VERSION_STATES,
        max_length=1,
        default=VERSION_STATES.DRAFT,
    )

    created_at = models.DateTimeField(
        verbose_name=_("Creation date"),
        default=timezone.now
    )

    non_lockable_fields = BaseVersionable.non_lockable_fields + [
        'version_state',
    ]

    _dependency_links = None
    loaded_version_state = None

    class Meta:
        abstract = True

    objects = VersionableManager()

    def __init__(self, *args, **kwargs):
        """Save the ``version_state`` at load time.

        For the parameters, see ``django.db.models.Model``"""

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

        self.loaded_version_state = self.version_state

    def save(self, *args, **kwargs):
        """Save the new ``version_state`` as if loaded from DB.

        For the parameters, see ``django.db.models.Model``"""

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

        self.loaded_version_state = self.version_state

    def refresh_from_db(self, *args, **kwargs):
        """Save the ``version_state`` at load time.

        For the parameters, see ``django.db.models.Model``"""

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

        self.loaded_version_state = self.version_state

    def get_loaded_version_state(self):
        """Get the ``loaded_version_state`` of the object, using global cache if available."""

        # Check if we are in the ``override_version_state`` context manager for another python
        # object of the same db object
        try:
            key = self.pk
        except AttributeError:
            # ``self.pk`` could not be fetch because we are too early in the initialization of
            # the django model instance
            pass
        else:
            value = getattr(self.__class__, 'version_state_override_cache', {}).get(key, None)
            if value is not None:
                return value

        return self.loaded_version_state

    def get_loaded_version_state_display(self):
        """Return the display value of the actual ``loaded_version_state``.

        Same as ``get_version_state_display`` but for ``loaded_version_state``.

        """

        return self.VERSION_STATES.for_value(self.get_loaded_version_state()).display

    @property
    def is_editable(self):
        """Property to know if an instance is in an editable state.

        Returns
        -------
        boolean
            ``True`` if the instance is editable, ``False`` otherwise.

        """

        return not self.pk or self.get_loaded_version_state() in self.VERSION_STATES.EDITABLE

    def is_field_locked(self, field_name):
        """Override from ``BaseVersionable`` to return ``False`` for some fields.

        Parameters
        ----------
        field_name : str
            The name of the field we want to check if it is locked or not.

        Returns
        -------
        bool
            ``True`` if the field is considered locked, or ``False`` otherwise.

        """

        # The update of original and parent versions is forbidden

        is_version_field = False
        try:
            is_version_field = self.pk and field_name in (
                'original_version',
                'original_version_id',
                'parent_version',
                'parent_version_id',
            )
        except AttributeError:
            # ``self.pk`` could not be fetch because we are too early in the initialization of
            # the django model instance
            pass

        if is_version_field:
            # Raise if the field is already set (always check with the ``_id`` suffix to
            # avoid fetching an object: checking the pk is enough)
            try:
                if getattr(self, field_name if field_name.endswith('_id') else field_name + '_id'):
                    raise LockedField(self, field_name)
            except AttributeError:
                # The attribute suffixed by ``_id`` could not be fetch because we are too early in
                # the initialization of the django model instance
                pass

            return False

        return super().is_field_locked(field_name)

    @property
    def is_draftable(self):
        """Property to know if an instance can be used to create a draft.

        Returns
        -------
        boolean
            ``True`` if a draft can be created from an instance, ``False`` otherwise.

        """

        return self.get_loaded_version_state() in self.VERSION_STATES.DRAFTABLE

    @property
    def is_copiable(self):
        """Property to know if an instance can be used to create a copy (ie a detached draft).

        Returns
        -------
        boolean
            ``True`` if a copy can be created from an instance, ``False`` otherwise.

        """

        return self.get_loaded_version_state() in self.VERSION_STATES.COPIABLE

    @property
    def is_publishable(self):
        """Property to know if an instance can be published.

        Returns
        -------
        boolean
            ``True`` if the instance can be published, ``False`` otherwise.

        """

        return self.get_loaded_version_state() in self.VERSION_STATES.PUBLISHABLE

    @contextmanager
    def override_version_state(self, new_state):
        """A context manager to locally change the version state. TO USE IN TESTS ONLY.

        Parameters
        ----------
        new_state : string
            A value from the ``VERSION_STATES`` choice to apply locally.

        Example
        -------

        >>> with instance.override_version_state(instance.VERSION_STATES.DRAFT):
        ...     instance.name = 'forced name'

        """

        cache_key = None
        try:
            cache_key = self.pk
        except AttributeError:
            pass
        else:
            if not hasattr(self.__class__, 'version_state_override_cache'):
                self.__class__.version_state_override_cache = {}

        # Save the previous value
        old_state = self.loaded_version_state

        # Replace with the given one
        self.loaded_version_state = new_state

        # Keep that info in cache to use it for another python objects of the same db object
        if cache_key:
            self.__class__.version_state_override_cache[cache_key] = new_state

        try:
            # Run the code encapsulated in the ``with`` statement
            yield
        finally:
            # Restore the previous value
            self.loaded_version_state = old_state

            # Clear the cache for all python objects of the same db object
            if cache_key:
                del self.__class__.version_state_override_cache[cache_key]

    @property
    def dependency_links(self):
        """Property to get all the ``Dependency`` models depending from this ``Versionable`` model.

        Returns
        -------
        list
            List of all models depending of this one.
            Each entry is a tuple with the dependent model, and the field name (or lookup with
            ``__`` if multi-level relation) on that model that links to this ``Versionable`` model.

        Notes
        -----
        The list is computed only once and cached at the class level.

        """

        cls = self.__class__
        if cls._dependency_links is None:

            # Will hold all the links.
            links = []

            # Are accepted: the real model class or a string representation.
            current_class = (cls, '%s.%s' % (cls._meta.app_label, cls._meta.model_name))

            for model in reversed(self.get_all_duplicable_models()):
                if not issubclass(model, Dependency) or not model.versionable_link:
                    continue
                if model.versionable_link[0] in current_class:
                    # We save the dependency model and its field to reach the current one.
                    links.append((model, model.versionable_link[1]))

            cls._dependency_links = links

        return cls._dependency_links

    @property
    def dependencies(self):
        """Property returning all the dependency instances related to this instance.

        It uses info in the dependency links to create a queryset for each model with a
        reference to the current object.

        Returns
        -------
        list
            A list containing all the instances from all dependent models

        """

        result = []

        for model, field_name in self.dependency_links:
            queryset = model.objects.filter(**{field_name: self})
            result.extend(list(queryset))

        return result

    def create_draft(self, **fields):
        """Create a draft from the current version, keeping all the fields.

        Parameters
        ----------
        fields: dict
            Some fields to override on the new draft

        Returns
        -------
        object
            A new instance of the same model with all dependencies duplicated.

        Raises
        ------
        .exceptions.NotDraftable
            When the instance is not in a state that permits it to be duplicated to create a draft.

        Notes
        -----
        It calls ``create_new_version`` under the hood.

        """

        if not self.is_draftable:
            raise NotDraftable(self)

        new_instance = self.create_new_version(
            new_state=self.VERSION_STATES.DRAFT,
            params=fields
        )

        return new_instance

    def copy(self, **fields):
        """Create a new detached draft from the current version, keeping all the fields.

        "Detached" from the parent and original versions means the ``parent_version`` and
        `original_version`` fields will be set to ``None``.

        Parameters
        ----------
        fields: dict
            Some fields to override on the new draft

        Returns
        -------
        object
            A new instance of the same model with all dependencies duplicated.

        Raises
        ------
        .exceptions.NotCopiable
            When the instance is not in a state that permits it to be copied.

        Notes
        -----
        It calls ``create_new_version`` under the hood.

        """

        if not self.is_copiable:
            raise NotCopiable(self)

        new_instance = self.create_new_version(
            new_state=self.VERSION_STATES.DRAFT,
            params=fields,
            detach=True
        )

        return new_instance

    def create_new_versions(self, new_state, params_list=None, detach=False):
        """Duplicate the actual version in many ones.

        Parameters
        ----------
        new_state : VERSION_STATES
            A entry in ``VERSION_STATES`` that will be used to initialize the ``version_state``
            attribute of all copies.
        params_list : list, optional
            A list of dict of new fields to set on each new version (one duplicate per entry)
            If ``None``, only one new copy will be done, without overriding any field (except for
            the state). Note that it's different from passing an empty list: in that case no copy
            will be done.
        detach: bool
            When set to ``True``, the new versions will be detached from the original objects
            by clearing the `parent_version` and `original_version` fields.

        Returns
        -------
        list
            List of all new instances of the same model with all dependencies duplicated.

        """

        if params_list is None:
            params_list = [{}]

        return [
            self.create_new_version(
                new_state=new_state,
                params=params,
                detach=detach
            )
            for params in params_list
        ]

    def create_new_version(self, new_state, params=None, detach=False):
        """Duplicate the actual version.

        Parameters
        ----------
        new_state : VERSION_STATES
            A entry in ``VERSION_STATES`` that will be used to initialize the ``version_state``
            attribute of the copy.
        params : dict, optional
            A dict of new fields to set on the new version.
        detach: bool
            When set to ``True``, the new version will be detached from the original object
            by clearing the `parent_version` and `original_version` fields.

        Returns
        -------
        Versionable
            An instance of the same model with all dependencies duplicated.

        """

        if params is None:
            params = {}

        return self.duplicate(
            created_at=timezone.now(),
            version_state=new_state,
            detach=detach,
            **params
        )

    def duplicate(self, detach=False, **kwargs):
        """Duplicate and enforce ``COPIED`` state if not set in kwargs.

        During the duplicate, the state is forced to ``COPYING`` to make it editable (to allow
        creating objects and setting fields values)

        For the parameters, see ``mtp.apps.duplicable.models.Duplicable``

        """

        wanted_state = kwargs.get('version_state') or self.VERSION_STATES.COPIED

        # State "Copying" during the copy.
        kwargs['version_state'] = self.VERSION_STATES.COPYING
        new_instance = super().duplicate(detach=detach, **kwargs)

        # Duplicate is done, set the final state
        new_instance.version_state = wanted_state
        new_instance.save()

        return new_instance

    def publish(self, commit=True):
        """Mark the current instance as published.

        Parameters
        ----------
        commit : bool
            When ``True``, the default, the instance will be saved after its ``version_state``
            being set. Otherwise, it won't be saved.

        Raises
        ------
        .exceptions.NotDraftable
            When the instance is not in a state that permits it to be duplicated to create a draft.

        """

        if not self.is_publishable:
            raise NotPublishable(self)

        self.version_state = self.VERSION_STATES.PUBLISHED
        if commit:
            self.save()

    def archive(self, commit=True):
        """Mark the current instance as archived.

        Parameters
        ----------
        commit : bool
            When ``True``, the default, the instance will be saved after its ``version_state``
            being set. Otherwise, it won't be saved.

        """

        self.version_state = self.VERSION_STATES.ARCHIVED
        if commit:
            self.save()


class Dependency(BaseVersionable):
    """Mixin for models that depend on a ``Versionable`` one.

    A ``Dependency`` model is part of a versionable tree, with a ``Versionable`` model at the root.

    To specify the versionable model of the tree the dependency belongs to, the ``versionable_link``
    attribute must be set.

    A dependency is only updatable if the versionable root (``versionable_instance`` property)
    is updatable too.

    Attributes
    ----------
    versionable_link : tuple
        A tuple of two items indicating how to reach the related ``Versionable`` model.
        The first entry of the tuple is the related model, either as a class, or as a string
        following this format : ``%(app_label)s.%(model_name)s``.
        The second entry is the name of the field in the current model (or lookup with
        ``__`` if multi-level relation) used to reach the ``Versionable`` model.

    Example
    -------

    This example shows how to use ``versionable_link``

    >>> class VersionableModel1(Versionable):
    ...     pass
    ...
    ... class DependencyModel1(Dependency):
    ...     parent = models.ForeignKey(to=VersionableModel1, on_delete=models.CASCADE)
    ...     versionable_link = (VersionableModel1, 'parent')
    ...
    ... class SubDependencyModel1(Dependency):
    ...     parent = models.ForeignKey(to=DependencyModel1, on_delete=models.CASCADE)
    ...     versionable_link = (VersionableModel1, 'parent__parent')

    """

    versionable_link = None

    class Meta:
        abstract = True

    objects = DependencyManager()

    @property
    def versionable_instance(self):
        """Property to return the related ``Versionable`` instance.

        Returns
        -------
        Versionable
            Instance of a ``Versionable`` model, the root of the versionable tree.

        """

        # If we have "foo__bar", we want "foo" from self, then "bar" from this result.
        parts = self.versionable_link[1].split('__')  # pylint: disable=unsubscriptable-object
        result = self
        while parts:
            part = parts.pop(0)
            result = getattr(result, part)

        return result

    @property
    def is_editable(self):
        """Property to know if this instance is editable.

        It is related to the ``is_editable`` property of the related ``Versionable`` object.
        If the object has no versionable instance yet, it will be considered as editable.

        Returns
        -------
        boolean
            ``True`` if the instance is editable, ``False`` otherwise.

        """

        if not self.pk:
            return True

        try:
            return self.versionable_instance.is_editable
        # If `self.versionable_instance` is None, we have an AttributeError
        except (AttributeError, ObjectDoesNotExist):
            return True


# pylint: disable=wrong-import-position,wrong-import-order
try:
    # This import is just to define some classes if ``django-polymorphic`` is used
    from mtp.discrete_apps.duplicable.models import PolymorphicModelForDuplicable

except ImportError:
    # django-polymorphic is not installed so we don't need to manage it
    pass

else:

    from .managers import PolymorphicManagerForDependency, PolymorphicManagerForVersionable

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

        Example
        -------

        class MyBaseModel(Versionable, PolymorphicModelForVersionable):
            # Uses ``PolymorphicModelForVersionable`` instead of ``PolymorphicModel``
            pass

        class MySubModel(MyBaseModel):
            pass

        """

        class Meta:
            abstract = True

        versionable_objects = PolymorphicManagerForVersionable()

    class PolymorphicModelForDependency(PolymorphicModelForDuplicable):
        """Base to use instead of ``PolymorphicModelForDuplicable`` if the model is a dependency.

        Example
        -------

        class MyDependencyBaseModel(Dependency, PolymorphicModelForDependency):
            # Uses ``PolymorphicManagerForDependency`` instead of ``PolymorphicModel``
            pass

        class MySubModel(MyDependencyBaseModel):
            pass

        """

        class Meta:
            abstract = True

        dependency_objects = PolymorphicManagerForDependency()
