"""This module holds models for ``common`` app."""

import random

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models, transaction, IntegrityError
from django.utils.functional import cached_property
from django_extensions.db.fields import UUIDField

from orderable.models import Orderable as BaseOrderable

from . import constants
from .managers import ReferenceableManager


class UUIDPkModel(models.Model):
    """Base model that use ``UUIDField`` as primary key.

    Notes
    -----

We cannot use the ``UUIDField`` from Django for now because it causes some issues:
- have to manage the pk of a new value in ``save``, because we cannot use the ``default`` argument
  when defining the field as proposed by, because it would cause every place where we (or external
  libs) check if the pk is filled or not to see if it's an update or create
- the new field is not json serializable, except by using something like
  ``data = serializers.serialize("json", somedata_with_uuid_field)``, but you cannot be sure that
  no external library use this. And also we don't have enough test to be sure that nothing will be
  broken, as we rely a lot on the API, and so JSON.
- globally there are too much referenced issues on django about this field

    """

    class Meta:
        abstract = True

    id = UUIDField(primary_key=True)


class Orderable(BaseOrderable):
    """Simple override of ``Orderable`` to allow bypassing default saving.

    To bypass the saving, use the ``.utils.bypass_orderable`` context manager for the instance, its
    class or any other parent class."""

    class Meta:
        abstract = True
        ordering = ['sort_order']

    _bypass = False

    @property
    def _is_bypassed(self):
        """Tells if we currenty bypass orderable saving.

        Returns
        -------
        bool
            ``False`` by default, but ``True`` when in the ``with`` block of the
            ``.utils.bypass_orderable`` context manager for the instance, its class or any
            other parent class.

        """

        # Tell pylint that we are allowed to access ``_bypass``
        # pylint:disable=protected-access

        # Check at the instance level
        if self._bypass:
            return True

        # Check for each parent class that have the ``_bypass`` attribute
        for klass in self.__class__.mro():
            if hasattr(klass, '_bypass') and klass._bypass:
                return True

        return False

    def save(self, *args, **kwargs):
        """Call the inherited method or its parent if we were asked to bypass it.

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

        if self._is_bypassed:
            # Totally bypass the ``save`` method of the original ``Orderable`` model
            # pylint: disable=bad-super-call
            return super(BaseOrderable, self).save(*args, **kwargs)

        # Call the ``save`` method of the original ``Orderable`` model
        super().save(*args, **kwargs)


class NonAbstractChildOrderable(Orderable):
    """Class to use instead of ``Orderable`` on child models on non-abstract inheritance.

    It's needed because ``Orderable`` use things like ``type(self`` to get the current class
    (but we want the main class to order with all children), or ``self._meta`` to get
    ``unique_together``, but in concrete model inheritance, ``Meta`` attributes are not shared.

    """

    class Meta:
        abstract = True
        ordering = ['sort_order']

    @cached_property
    def parent_model(self):
        """Property that returns the parent model of the current child instance.

        We know that the link from the child to the parent is a ``OneToOneField`` that links to
        a model that is in the classes the child model inherits from.
        We only check auto-created ``OneToOneField``, and that does not point to the current class.

        So the parent model is this ``OneToOneField`` related model

        """

        current_model = self.__class__

        parent_models = [
            model
            for model in current_model.mro()
            if model != current_model and
            issubclass(model, models.Model) and
            hasattr(model, '_meta') and
            not model._meta.abstract
        ]

        try:
            return [
                field
                for field in self._meta.get_fields()
                if (
                    isinstance(field, models.OneToOneField) and
                    field.auto_created and
                    field.related_model in parent_models
                )
            ][0].related_model

        except IndexError:
            # We have no matching ``OneToOneField``, so we are the parent !!
            return current_model

    def get_unique_fields(self):
        """List field names that are unique_together with `sort_order`.

        It's an override of the method from ``Orderable`` where we use ``self.parent_model._meta``
        instead of ``self._meta`` because in concrete inheritance, the ``Meta`` attributes are
        not inherited. So we need to get the parent to get the ``unique_together`` entries.

        """

        for unique_together in self.parent_model._meta.unique_together:  # only modified line
            if 'sort_order' in unique_together:
                unique_fields = list(unique_together)
                unique_fields.remove('sort_order')
                return ['%s_id' % f for f in unique_fields]
        return []

    def get_filtered_manager(self):
        """Return manager which may already have filtered results as needed.

        It's an override of the method from ``Orderable`` where we use ``self.parent_model``
        instead of ``type(self)`` because we wan't to get all the childs of all the child classes
        instead of only the one for the current child class.

        """

        manager = self.parent_model.objects  # only modified line
        kwargs = {field: getattr(self, field) for field in self.get_unique_fields()}
        return manager.filter(**kwargs)


class Referenceable(models.Model):
    """An abstract model which allow to reference an instance among all other.

    Attributes
    ----------
    is_reference: django.db.models.BooleanField
        Allow to set this instances as reference.
    objects: django.db.models.Manager
        The default manager.
    references: mtp.apps.common.managers.ReferenceableManager
        The referenceable manager.
        This manager allow to select only reference instances.

    Notes
    -----
    The references instances are generally used for comparison.

    """

    is_reference = models.BooleanField(default=False)

    references = ReferenceableManager()

    class Meta:
        abstract = True

    def __init__(self, *args, **kwargs):
        """Set the ``_reference_uniqueness_checked`` attribute to ``False``.

        For the parameters, see ``django.db.models.Model"""
        self._reference_uniqueness_checked = False
        super().__init__(*args, **kwargs)

    def get_reference_dependencies(self, **kwargs):
        """Get the reference dependencies.

        Notes
        -----
        This method is used to select the reference area.
        Indeed, in some cases reference can depends on a certain criterion.

        Examples
        --------
        We can have a reference user among all other OR have a reference user by user group.
        In the second case, we need this method.

        Returns
        -------
        dict
            The dictionary containing values used to handle dependencies.

        """

        return kwargs

    def validate_reference_uniqueness(self):
        """Check if the reference uniqueness is respected.

        Notes
        -----
        This method can be called two times when we save an instance.
        So we use a ``_reference_uniqueness_checked`` flag to ensure
        that this method is run only one time.

        Raises
        ------
        ValidationError
            If we try to save a second reference instance.

        """

        if self.is_reference and not self._reference_uniqueness_checked:
            dependencies = self.get_reference_dependencies()
            other_references = self.__class__.references.exclude(id=self.id).filter(
                **dependencies
            )
            if other_references.exists():
                raise ValidationError({
                    'is_reference': ValidationError(
                        "Only one reference is allowed. "
                        "Use the `flag_as_reference` method to flag "
                        "an %s instance as reference." % self.__class__.__name__)
                })

        # We ensure that this method will not be run before its reset.
        self._reference_uniqueness_checked = True

    def validate_unique(self, exclude=None):
        """Override validate_unique method to prevent multiple reference instance.

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

        if 'is_reference' not in exclude:
            self.validate_reference_uniqueness()

        super().validate_unique(exclude)

    def save(self, *args, **kwargs):
        """Override save method to prevent multiple reference instance.

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

        self.validate_reference_uniqueness()

        # We reset the ``validate_reference_uniqueness`` method to run it again.
        self._reference_uniqueness_checked = False

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

    def unflag_as_reference(self):
        """Unflag current instance as reference."""

        # We don't unflag as reference if is not a reference.
        if not self.is_reference:
            return

        self.is_reference = False
        self.save()

    def unflag_all_instances(self):
        """Unflag all instances as reference."""

        queryset = self.__class__.references

        dependencies = self.get_reference_dependencies()
        if dependencies:
            queryset = queryset.filter(**dependencies)

        for instance in queryset.all():
            instance.unflag_as_reference()

    def flag_as_reference(self):
        """Flag current instance as reference."""

        # We don't flag as reference if is already a reference.
        if self.is_reference:
            return

        # We unflag others instances to keep only one reference instance according dependencies.
        self.unflag_all_instances()

        self.is_reference = True
        self.save()


class Tokenable(models.Model):
    """A mixin model to easily compute values for a token field.

    Notes
    -----
    We expose ``token_field``, ``token_patterns`` and ``token_generation_method`` in the model
    to be able to override them in a sub-model.

    Attributes
    ----------
    token_field: str
        The field name to set.
    token_patterns: iterator(str)
        An iterator which contains the wanted patterns.
    token_generation_method: str
        The method name which generate a token.

    """

    token_field = 'token'
    token_patterns = settings.DEFAULT_TOKEN_PATTERNS
    token_generation_method = '_generate_token'

    class Meta:
        abstract = True

    @staticmethod
    def _generate_token(patterns, length):
        """Generate a random token.

        To generate a token, we take a random pattern,
        the necessary number of times to obtain the wanted length.

        Parameters
        ----------
        patterns: iterator(str)
            An iterator which contains the wanted patterns.
        length: int
            The length of the random string to be generated.

        Returns
        -------
        str
            The generated token composed by the number of wanted patterns.

        """

        return ''.join([
            random.choice(patterns)
            for __ in range(length)
        ])

    @property
    def token_length(self):
        """Get the token length.

        We get the wanted length thanks to the ``max_length`` field attribute.

        """

        return self._meta.get_field(self.token_field).max_length

    def _assign_token(self, retries_number=constants.TOKEN_RETRIES):
        """Assign a new token.

        After to have assigned a token, we check if the instance is valid
        according the defined uniqueness rules in the model.

        Parameters
        ----------
        retries_number: int
            The retries number which we have to find an available token.
            After that, we raise a ValidationError.

        Raises
        ------
        ValidationError
            If we can't find an available token.

        Returns
        -------
        str
            Return the new generated token.

        """

        if not retries_number:
            raise ValidationError({
                'token': ValidationError("Impossible to find an available token.")
            })

        generation_method = getattr(self, self.token_generation_method)
        setattr(self, self.token_field, generation_method(self.token_patterns, self.token_length))

        try:
            with transaction.atomic():
                super().save()
        except IntegrityError:
            if self._already_assigned():
                self._assign_token(retries_number=retries_number - 1)
            else:
                raise

        return getattr(self, self.token_field)

    def _already_assigned(self):
        """Check if the current token is already assigned.

        Returns
        -------
        bool
            Return True if the token is already assigned.
            Else False.

        """

        data = {self.token_field: getattr(self, self.token_field)}
        return self.__class__.objects.filter(**data).exists()

    def get_or_assign_token(self):
        """Get the currently assigned token or create it if it don't exists yet.

        If no token exists, we generate a random token according the configuration.

        """

        if not getattr(self, self.token_field):
            self._assign_token()

        return getattr(self, self.token_field)

    def delete_token(self):
        """Delete the generated token."""

        setattr(self, self.token_field, None)
        super().save()
