"""This module holds the classes to handle the whole duplication process."""

from collections import defaultdict, OrderedDict
from functools import partial
from operator import attrgetter
import logging

from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.db import models
from django.db.models import NOT_PROVIDED

from ..models import Duplicable
from ..utils import get_instance_duplicator_class
from .fields import *  # pylint: disable=wildcard-import


# We use 11 instance attributes, it's 10 for pylint (too-many-instance-attributes).
# Already tried to reduce it without a full refactor...
# pylint: disable=too-many-instance-attributes
class InstanceDuplicator:
    """An object that can take an instance and duplicate it.

    Attributes
    ----------
    instance : django.db.models.Model
        An instance of a django model
    meta : django.db.models.options.Options
        The ``_meta`` object linked to ``instance``
    model : subclass of django.db.models.Model
        The django model of the ``instance``
    non_duplicable_relations : set
        The name of the relations on ``instance`` that must not be duplicated
    all_fields : list
        The list of all fields defined on the instance, and on other models that point to it
    new_instance : django.db.models.Model
        Will hold the new duplicated instance after a call to ``duplicate``
    duplicate_refs : defaultdict
        Will hold all the duplicated objects. Used internally for recursive purpose.
        It's a multi-level default dict.
        First level is the model itself, second level is the primary key of an instance of
        this model. Then we have a dict with two entries:
        - ``new_instance``: holds the duplicate instance
        - ``back_refs``: holds the entries to update when the instance will be duplicated (used
          in case of circular links between models)
    duplicated_objects : list
        List of tuples with, for each tuple, the first entry being an object that was duplicated
        and the second entry, it's duplicated instance.
    compare_refs : defaultdict
        Will hold all the compared objects. Used internally for recursive purpose.
        It's a multi-level default dict.
        First level is the model itself, second level is a key containing the primary key of the
        two compared instances. Then we have a dict with the following entries:
        - ``old``: the "first" instance to be compared (``self.instance``)
        - ``new``: the "second" instance to be compared (``other_instance`` in the ``compare``
                   method)
        - ``changed``: boolean, ``True`` if at least one field is different, ``False`` otherwise
        - ``fields``: dict with the name of the field as key, and a dict as value with:
            - ``old``: the value for this field, for the "old" instance
            - ``new``: the value for the "new" instance", it's a tuple to get the comparison from
                       ``compare_refs``, the first entry being the model, the second the key
                       containing both primary keys
            - ``changed``: boolean, ``True`` if the field is different, ``False`` otherwise
            - ``ref``: if both entries are duplicable
            - ``details``: dict present only for field with many entries, with 4 lists:
              - ``removed`: entries from the "old" field that is not in "new" anymore
              - ``added``: entries from the "new" field that are not in "old"
              - ``same``: entries present in both "old" and "new", ie same exact objects or
                          duplicate that was not changed. Each item of the list is a dict with:
                - ``old``: the value in the "old" list
                - ``new``: the value in the "new" list
                - ``ref``: if both entries are duplicable, it's a tuple to get the comparison
                           from ``compare_refs``, the first entry being the model, the second the
                           key containing both primary keys.
              - ``changed``: entries present in both "old" and "new" that are duplicate but changed.
                             Each item of the list is a dict with the same content as in the
                             ``same`` entry described above.

    """

    def __init__(self, instance, non_duplicable_relations=None):
        """Instantiate a new object for the given instance.

        Parameters
        ----------
        instance : django.db.models.Model
            An instance we may want to duplicate
        non_duplicable_relations : iterable, optional
            Some entries to *add* to the default ``non_duplicable_relations`` set defined on the
            model.

        """

        self.instance = instance
        self.meta = self.instance._meta
        self.model = self.meta.model

        # Get relations that must not be duplicated
        self.non_duplicable_relations = set(non_duplicable_relations or [])
        if hasattr(self.instance, 'get_non_duplicable_relations'):
            self.non_duplicable_relations |= self.instance.get_non_duplicable_relations()

        self.all_fields = sorted(self.meta.get_fields(), key=attrgetter('name'))

        # Set in "duplicate" mode
        self.new_instance = self.model()
        self.duplicate_refs = {}
        self.duplicated_objects = []

        # Set in "compare" mode
        self.compare_refs = {}

        self.duplicate_logger = logging.getLogger('duplicate.duplicate')
        self.compare_logger = logging.getLogger('duplicate.compare')

    def duplicate_log_debug(self, message, **kwargs):
        """Helper for logging debug information about duplication of the current instance.

        Parameters
        ----------
        message : str
            Message to log
        kwargs : dict
            Named parameters to use in ``message``

        """

        final_message = 'Duplicate(%(source_model)s:%(source_pk)s) ' + message
        message_kwargs = {
            'source_model': '%s.%s' % (
                self.meta.app_label,
                self.meta.model_name,
            ),
            'source_pk': self.instance.pk
        }
        message_kwargs.update(kwargs)

        self.duplicate_logger.debug(final_message, message_kwargs)

    def compare_log_debug(self, message, other_instance, **kwargs):
        """Helper for logging debug information about comparison.

        Parameters
        ----------
        other_instance : django.db.models.Model
            The second object to compare. Must be from the same model as ``self.instance``.
        message : str
            Message to log
        kwargs : dict
            Named parameters to use in ``message``

        """

        final_message = 'Compare(%(source_model)s:%(source_pk)s|%(other_pk)s) ' + message
        message_kwargs = {
            'source_model': '%s.%s' % (
                self.meta.app_label,
                self.meta.model_name,
            ),
            'source_pk': self.instance.pk,
            'other_pk': other_instance.pk,
        }
        message_kwargs.update(kwargs)

        # Readable changed status if not finished yet
        if 'changed' in kwargs and kwargs['changed'] is None:
            message_kwargs['changed'] = 'waiting'

        self.compare_logger.debug(final_message, message_kwargs)

    # Lot of checks, so we have 14 "branches" but pylint expect 12
    # pylint: disable=too-many-branches
    def duplicate(self, refs=None, detach=False, **values):
        """Duplicate the current instance and all its duplicable relations.

        Parameters
        ----------
        refs : defaultdict
            See ``duplicate_refs`` attribute of the ``InstanceDuplicator`` class.
        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.
            Default to ``False``
        values :
            To force some field (normal fields, foreign keys or OneToOne fields) on the new
            instance instead of taking the value from ``self.instance`` or, if it's a relation,
            to duplicate it.

        Returns
        -------
        django.db.models.Model
            The duplicated instance (saved in ``self.new_instance``)
            Will be ``None`` if the status in ``refs`` is ``started`` (ie the duplicate has started
            but the object is not saved yet)

        """

        if refs is None:
            pk_defaultdict = lambda: {
                'new_instance': None,
                'status': 'init',
                'back_refs': []
            }
            model_defaultdict = lambda: defaultdict(pk_defaultdict)
            refs = defaultdict(model_defaultdict)

        self.duplicate_refs = refs
        self.duplicated_objects = []
        self.new_instance = self.model()

        # Reference that will hold the copied object (using ``setdefault`` if nothing set)
        ref = refs[self.model][self.instance.pk]

        self.duplicate_log_debug('Start')

        # Check if we already duplicated this object
        if ref['new_instance']:
            self.duplicate_log_debug('End (Already duplicated)')
            return ref['new_instance']

        # Check if we try to duplicate a duplicata
        same_model_pks = [
            orig['new_instance'].pk
            for orig in self.duplicate_refs[self.model].values()
            if orig['new_instance']
        ]
        if self.instance.pk in same_model_pks:
            self.duplicate_log_debug('End (Already the duplicate)')
            return self.instance

        # The duplicate has started but the object is not saved yet
        if ref['status'] == 'started':
            # Will tell the caller to create a backref
            self.duplicate_log_debug('End (Currently being duplicated)')
            return None

        ref['status'] = 'started'

        # Work on simple fields
        for field, field_name, duplicator in self.iter_simple_fields():
            duplicator.duplicate(self, field, values.get(field_name, NOT_PROVIDED), detach)

        # Manage links to parent and original versions if it's a duplicable (it may be a M2M through
        # table, so we have to check before)
        if isinstance(self.instance, Duplicable):
            self.duplicate_log_debug('Detach set to %(detach)s', detach=detach)
            if detach:
                # Force remove the link to the parent instance
                values['parent_version'] = None
                # And the link to the first version in the duplicate history
                values['original_version'] = None
            else:
                # Keep a link to the parent instance
                values['parent_version'] = self.instance
                # And a link to the first version in the duplicate history
                values['original_version'] = self.instance.original_version or self.instance

        # Work on generic relations
        for field, field_name, duplicator in self.iter_generic_relation():
            duplicator.duplicate(self, field, values.get(field_name, NOT_PROVIDED), detach)

        # Work on foreign keys (including generic ones)
        for field, field_name, duplicator in self.iter_foreign_keys():
            duplicator.duplicate(self, field, values.get(field_name, NOT_PROVIDED), detach)

        # We can now save the new instance before updating the relations
        self.new_instance.save()
        self.duplicate_log_debug('Base done (new pk: %(duplicate_pk)s)',
                                 duplicate_pk=self.new_instance.pk)

        # And save the reference.
        ref['new_instance'] = self.new_instance

        # The ref is now usable
        ref['status'] = 'ready'

        # And add to the list of duplicated objects
        self.duplicated_objects.append([self.instance, self.new_instance])

        # Work on non-concrete fields
        for field, field_name, duplicator in self.iter_duplicable_fields(
                exclude=['simple_fields', 'foreign_keys']):
            duplicator.duplicate(self, field, values.get(field_name, NOT_PROVIDED), detach)

        # We can update all back refs for this object
        for rel_obj, field_name in ref['back_refs']:
            if isinstance(rel_obj, Duplicable):
                rel_obj.set_duplicated_field(field_name, self.new_instance)
            else:
                setattr(rel_obj, field_name, self.new_instance)
            rel_obj.save()

        # Back refs are not needed anymore
        if ref['back_refs']:
            del ref['back_refs']

        ref['status'] = 'done'

        self.duplicate_log_debug('End (new pk: %(duplicate_pk)s)',
                                 duplicate_pk=self.new_instance.pk)
        return self.new_instance

    def duplicate_object(self, obj, non_duplicable_relations=None, detach=False):
        """Duplicate an other object.

        This is used to duplicate sub-objects of ``self.instance`` and save them and refs in the
        current ``InstanceDuplicator``.

        Parameters
        ----------
        obj : django.db.models.Model
            The object to duplicate
        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.
            Default to ``False``

        Returns
        -------
        django.db.models.Model
            The duplicated instance (saved in ``self.new_instance``)

        """

        instance_duplicator = get_instance_duplicator_class(obj)(obj, non_duplicable_relations)
        new_instance = instance_duplicator.duplicate(self.duplicate_refs, detach)
        self.duplicated_objects.extend(instance_duplicator.duplicated_objects)
        return new_instance

    # We use 17 local variables, it's 15 for pylint (too-many-locals).
    # We use 14 branches, it's 12 for pylint (too-many-branches).
    # Already tried to reduce it without a full refactor...
    # pylint: disable=too-many-branches, too-many-locals
    def compare(self, other_instance, refs=None, ignore_fields=None):
        """Compare the current instance with the one given.

        Parameters
        ----------
        other_instance : django.db.models.Model
            The second object to compare. Must be from the same model as ``self.instance``.
        refs : defaultdict
            See ``compare_refs`` attribute of the ``InstanceDuplicator`` class.
        ignore_fields : iterable
            If passed, must be an iterable with a list of field names that must be excluded from
            the comparison.

        Returns
        -------
        boolean
            ``True`` if the two objects are different, ``False`` otherwise.

        """

        compare_log_debug = partial(self.compare_log_debug, other_instance=other_instance)

        if refs is None:
            pk_defaultdict = lambda: {
                'status': 'init',
                'changed': None,
                'fields': OrderedDict(),
                'back_refs': []
            }
            model_defaultdict = lambda: defaultdict(pk_defaultdict)
            refs = defaultdict(model_defaultdict)

        self.compare_refs = refs

        # Reference that will hold the comparison between the two instances
        result = refs[self.model][(self.instance.pk, other_instance.pk)]

        compare_log_debug('Start')

        # The comparison between these two objects is already done.
        if result['status'] == 'done':
            compare_log_debug('End (Already compared, changed=%(changed)s)',
                              changed=result['changed'])
            return result

        # The comparison between these two objects is in progress, we stop here
        if result['status'] != 'init':
            compare_log_debug('End (Currently being compared)')
            return result

        # Start the comparison
        result.update({
            'status': 'started',
            'old': self.instance,
            'new': other_instance,
        })

        # Compare each field
        for field, field_name, duplicator in self.iter_duplicable_fields():
            if ignore_fields and field_name in ignore_fields:
                continue

            result['fields'].update(duplicator.compare(self, field, other_instance) or {})

        # Comparison is done
        result['status'] = 'done'

        # Set the final changed status: ``True`` if any field is changed.
        result['changed'] = None
        waiting_field_results = []

        for field_result in result['fields'].values():

            # One field has changed, we can stop here, we mark the whole object as changed
            if field_result['changed'] is True:
                result['changed'] = True
                break

            # At least one field is not finished, we mark it to handle it later
            if field_result['changed'] is None:
                waiting_field_results.append(field_result)
                continue

        # We don't have any field marked as changed, but we wait for some fields, so
        # we'll manage this with back-refs
        if not result['changed'] and waiting_field_results:

            def callback(entry_result):
                """Called when the comparison for the field will be done."""

                object_ready = False

                # We already know that the whole object is changed, no need to continue
                if result['changed']:
                    return

                # The field has changed, we now know that the whole object is changed, we mark
                # it as changed and run the back refs to itself
                if entry_result['changed']:
                    result['changed'] = True
                    object_ready = True

                # The field has not changed, and the changed status for all fields is ``False``,
                # So we now know that the main object has not changed, and we can run the
                # back refs
                elif not any(f['changed'] is None for f in result['fields'].values()):
                    result['changed'] = False
                    object_ready = True

                # We can run the back-refs
                if object_ready:
                    # Manage back refs
                    for back_ref in result['back_refs']:
                        back_ref(result)

                    # Back refs are not needed anymore
                    del result['back_refs']

            # Add a backref for each waiting field
            for field_result in waiting_field_results:
                if 'ref' not in field_result:
                    continue
                field_model, field_ref_pk = field_result['ref']
                refs[field_model][field_ref_pk]['back_refs'].append(callback)

        # We don't wait for any fields, or we know that the result has changed, we can go on
        else:
            if result['changed'] is None:
                # No field marked as changed
                result['changed'] = False

            # Manage back refs
            for back_ref in result['back_refs']:
                back_ref(result)

            # Back refs are not needed anymore
            del result['back_refs']

        compare_log_debug('End (changed=%(changed)s)', changed=result['changed'])

        return result['changed']

    def compare_object(self, obj, other_obj, ignore_fields=None):
        """Compare two objects.

        This is used to compare sub-objects and save refs in the current ``InstanceDuplicator``.

        Parameters
        ----------
        obj : django.db.models.Model
            The first object to compare.
        other_obj : django.db.models.Model
            The second object to compare. Must be from the same model.
        ignore_fields : iterable
            If passed, must be an iterable with a list of field names that must be excluded from
            the comparison.

        Returns
        -------
        boolean
            ``True`` if the two objects are different, ``False`` otherwise.

        """

        instance_duplicator = get_instance_duplicator_class(obj)(obj)

        return instance_duplicator.compare(other_obj, self.compare_refs, ignore_fields)

    def iter_simple_fields(self):
        """Iterate on all normal fields (ie non relations) of ``self.instance``

        Primary key is not included in returned fields.

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.Field``, the name used to
            access the field from the instance as second entry, and, as third entry, the field
            duplicator class to use, ``SimpleFieldDuplicator``.

        """

        for field in self.all_fields:

            if field.is_relation or field.primary_key:
                continue

            yield field, field.name, SimpleFieldDuplicator

    def iter_foreign_keys(self):
        """Iterate on all foreign keys (including OneToOne and Generic) defined on ``self.instance``

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.related.ForeignKey``, the
            name used to access the field from the instance as second entry, and, as third entry,
            the field duplicator class to use, ``ForeignKeyDuplicator``.

        """

        for field in self.all_fields:

            if not isinstance(field, (models.ForeignKey, GenericForeignKey)):
                continue

            yield field, field.name, ForeignKeyDuplicator

    def iter_generic_relation(self):
        """Iterate on all generic relations defined on ``self.instance``

        Yields
        ------
        tuple
            A tuple with, as first entry, ``django.db.models.fields.related.GenericRelation``, the
            name used to access the field from the instance as second entry, and, as third entry,
            the field duplicator class to use, ``GenericRelationDuplicator``.

        """

        for field in self.all_fields:

            if not isinstance(field, GenericRelation):
                continue

            yield field, field.name, GenericRelationDuplicator

    def is_duplicable_reverse_field(self, field):
        """Helper to know if a given related field (defined on another model) is duplicable.

        It is duplicable if it's from a model that is a subclass of ``Duplicable``, and if its
        related name (defined in the FK/M2M/O2O, or automatic) is not listed in the
        ``non_duplicable_relations`` set.

        Parameters
        ----------
        field : django.db.models.fields.related.ForeignObjectRel
            A "relation field" based on a ForeignKey, OneToOneField or ManyToManyField defined
            on another model (or the same with (``to='self'``), pointing to the current instance.

        Returns
        -------
        bool
            ``True`` if the related model (the one defining the related field) is duplicable.
            Otherwise ``False``.

        """

        related_name = field.get_accessor_name()
        related_model = field.related_model

        return (
            issubclass(related_model, Duplicable)
            and related_name not in self.non_duplicable_relations
        )

    def iter_reverse_onetoone_fields(self):
        """Iterate on all OneToOne fields pointing to ``self.instance``.

        For a field to be returned, the model defining it must be duplicable for the current
        instance (see ``is_duplicable_reverse_field``)

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.related.OneToOneRel``, the
            name used to access the field from the instance as second entry, and, as third entry,
            the field duplicator class to use, ``ReverseOneToOneDuplicator``.

        """

        for field in self.all_fields:

            if not isinstance(field, models.OneToOneRel):
                continue

            if not self.is_duplicable_reverse_field(field):
                continue

            yield field, field.get_accessor_name(), ReverseOneToOneDuplicator

    def iter_manytomany_fields(self):
        """Iterate on all ManyToMany fields defined on ``self.instance``

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.related.ManyToManyField``,
            the name used to access the field from the instance as second entry, and, as third
            entry, the field duplicator class to use, ``ManyToManyDuplicator``.

        """
        for field in self.all_fields:

            if not isinstance(field, models.ManyToManyField):
                continue

            if field.name in self.non_duplicable_relations:
                continue

            yield field, field.name, ManyToManyDuplicator

    def iter_reverse_manytomany_fields(self):
        """Iterate on all ManyToMany fields pointing to ``self.instance``.

        For a field to be returned, the model defining it must be duplicable for the current
        instance (see ``is_duplicable_reverse_field``).

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.related.ManyToManyRel``, the
            name used to access the field from the instance as second entry, and, as third entry,
            the field duplicator class to use, ``ReverseManyToManyDuplicator``.

        """
        for field in self.all_fields:

            if not isinstance(field, models.ManyToManyRel):
                continue

            if not self.is_duplicable_reverse_field(field):
                continue

            yield field, field.get_accessor_name(), ReverseManyToManyDuplicator

    def iter_reverse_foreign_keys(self):
        """Iterate on all foreign keys fields pointing to ``self.instance``.

        For a field to be returned, the model defining it must be duplicable for the current
        instance (see ``is_duplicable_reverse_field``).

        The OneToOne related fields, that are also ``ManyToOneRel`` are excluded because already
        managed by the ``iter_reverse_onetoone_fields`` method.

        Yields
        ------
        tuple
            A tuple with, as first entry, a ``django.db.models.fields.related.ManyToOneRel``, th
            name used to access the field from the instance as second entry, and, as third entry,
            the field duplicator class to use, ``ReverseForeignKeyDuplicator``.

        """

        for field in self.all_fields:
            if not isinstance(field, models.ManyToOneRel):
                continue

            if isinstance(field, models.OneToOneRel):
                # Managed in ``iter_reverse_onetoone_fields``
                continue

            if not self.is_duplicable_reverse_field(field):
                continue

            yield field, field.get_accessor_name(), ReverseForeignKeyDuplicator

    def iter_duplicable_fields(self, exclude=None):
        """Iterate on all the duplicable fields on ``self.instance``

        The iterator yields fields in this order:
        - simple fields
        - foreign keys (and one to one fields)
        - related one to one fields (defined on another model, pointing to the current one)
        - many to many fields
        - related many to many fields (defined on another model, pointing to the current one)
        - related foreign keys (defined on another model, pointing to the current one)

        Parameters
        ----------
        exclude : list
            If passed, a list of field types to ignore.
            Available field types are:
            - simple_fields
            - foreign_keys
            - reverse_onetoone_fields
            - manytomany_fields
            - reverse_manytomany_fields
            - reverse_foreign_keys

        Yields
        ------
        tuple
            A tuple with, as first entry, a field, the name used to access the field from the
            instance as second entry, and, as third entry, the field duplicator class to use for
            this field.

        """

        if exclude is None:
            exclude = []

        field_types = [
            'simple_fields',
            'foreign_keys',
            'reverse_onetoone_fields',
            'manytomany_fields',
            'reverse_manytomany_fields',
            'reverse_foreign_keys',
        ]

        for field_type in field_types:

            if field_type in exclude:
                continue

            iterator = getattr(self, 'iter_%s' % field_type)

            yield from iterator()
