"""This module holds classes that handle duplication of different kinds of fields."""

from functools import partial

from django.db.models import Model, NOT_PROVIDED

from ..models import Duplicable

__all__ = [
    'SimpleFieldDuplicator',
    'ForeignKeyDuplicator',
    'ReverseOneToOneDuplicator',
    'ManyToManyDuplicator',
    'ReverseManyToManyDuplicator',
    'ReverseForeignKeyDuplicator',
    'GenericRelationDuplicator',
]


class FieldDuplicator:
    """Base class for all field duplicators.

    Provides the ``duplicate`` and ``compare`` methods to be implemented in subclasses.
    And some methods to help the duplication of objects: ``duplicate_single_object`` and
    ``duplicate_many_objects``, and to help the comparison of objects: ``compare_single_object``
    and ``compare_many_objects``.

    """

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate the given field for the instance duplicator.

        Must be defined in subclasses.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current duplication.
        field : django.db.models.fields.Field
            The field we want to be duplicated for the instance in
            ``instance_duplicator.instance``
        value : any
            If set, will be used to fill the value on the new instance.
        detach : bool
            When set to ``True``, if the field is tied to other objects that will be
            duplicated, these new duplicates will be detached from the original objects
            by clearing the `parent_version` and `original_version` fields.

        Returns
        -------
        None

        """

        raise NotImplementedError

    @classmethod
    def duplicate_single_object(cls, instance_duplicator, instance, non_duplicable_relations=None,
                                detach=False):
        """Duplicate the given instance.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current duplication.
        instance: 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
            instance 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.
        Returns
        -------
        django.db.models.Model
            The duplicated instance (saved in ``self.new_instance``)

        """

        return instance_duplicator.duplicate_object(instance, non_duplicable_relations, detach)

    @classmethod
    def duplicate_many_objects(cls, instance_duplicator, instances, non_duplicable_relations=None,
                               detach=False):
        """Duplicate all the given instances.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current duplication.
        instances: iterable(django.db.models.Model)
            An iterable (list, queryset...) of instances to duplicate
        non_duplicable_relations : iterable, optional
            Some entries to *add* to the default ``non_duplicable_relations`` set defined on the
            instances model.
        detach: bool
            When set to ``True``, the new duplicates will be detached from the original objects
            by clearing the `parent_version` and `original_version` fields.

        """

        for instance in instances:
            cls.duplicate_single_object(
                instance_duplicator,
                instance,
                non_duplicable_relations,
                detach
            )

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare the given field from instance in ``instance_duplicator`` and ``other_instance``.

        Must be defined in subclasses.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current comparison.
        field : django.db.models.fields.Field
            The field we want to be compared for the instance in
            ``instance_duplicator.instance`` and ``other_instance``.
        other_instance : django.db.models.Model
            An instance of the same model as ``instance_duplicator.instance`` for which we want
            to compare the value of ``field``.

        Returns
        -------
        dict
            A dict with a single key: the name of the field being compared, and as value, the
            result of the comparison. Will be merged with the ``fields`` entry of the current
            instance, as defined in the ``compare_refs`` attribute of the ``InstanceDuplicator``
            class.

        """

        raise NotImplementedError

    @classmethod
    def compare_single_object_by_key(cls, obj, other_obj):
        """Compare two objects by their pk or, if duplicable, by the pk of the original version.

        Parameters
        ----------
        obj : django.db.models.Model
            The first object to be compared
        other_obj : django.db.models.Model
            The other object to be compared. Must be from the same model as ``obj``.

        Returns
        -------
        dict
            The result of the comparison, with ``old``, ``new`` and ``changed`` items.

        """

        # Base of the value to be returned
        result = {
            'old': obj,
            'new': other_obj,
            'changed': None,
        }

        # If at least one object is None, stop comparison, and set the "changed" status to
        # ``False`` only if both are None
        if not obj or not other_obj:
            result['changed'] = not (not obj and not other_obj)
            return result

        assert obj.__class__ == other_obj.__class__,\
            "Objects to compare should be from the same model"

        for some_obj in (obj, other_obj):
            if not hasattr(some_obj, 'compare_key'):
                some_obj.compare_key = getattr(some_obj, 'original_version_id', None) or some_obj.pk

        result['changed'] = obj.compare_key != other_obj.compare_key

        return result

    @classmethod
    def compare_single_object(cls, instance_duplicator, obj, other_obj, ignore_fields=None,
                              backref_callback=None):
        """Compare two "sub objects" in a comparison.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current comparison.
        obj : django.db.models.Model
            The first object to be compared
        other_obj : django.db.models.Model
            The other object to be compared. Must be from the same model as ``obj``.
        ignore_fields : iterable
            If passed, must be an iterable with a list of field names that must be excluded from
            the comparison.
        backref_callback : function
            The function to call when the comparison need to be managed by a backref.
            By default a function will be created that simply set the value of the "changed" key
            of the result to the result of the comparison.
            The function should accept one argument, the result dict that contain the ``old``,
            ``new``, ``changed`` and ``status`` items

        Returns
        -------
        dict
            The result of the comparison, with ``old``, ``new`` and ``changed`` items.

        """

        # Base of the value to be returned
        result = {
            'old': obj,
            'new': other_obj,
            'changed': None,
        }

        # If at least one object is None, stop comparison, and set the "changed" status to
        # ``False`` only if both are None
        if not obj or not other_obj:
            result['changed'] = not (not obj and not other_obj)
            return result

        # Check models
        assert obj.__class__ == other_obj.__class__,\
            "Objects to compare should be from the same model"

        # Check if they are the same object
        if obj == other_obj:
            result['changed'] = False

        # We have a duplicable, so we'll have to compare it field by field
        elif isinstance(obj, Duplicable):
            model, ref_key = obj._meta.model, (obj.pk, other_obj.pk)

            # Compare the two objects
            instance_duplicator.compare_object(obj, other_obj, ignore_fields)
            comparison = instance_duplicator.compare_refs[model][ref_key]
            # Keep a ref to allow, in future use, to get the result of the comparison
            result['ref'] = (model, ref_key)
            # Allows caller to know the current status of the comparison
            result['status'] = comparison['status']

            # The comparison of the two objects already started and we are here because of
            # circular dependency, so we let the "changed" status to ``None`` and ask for an
            # update later.
            if comparison['changed'] is None:

                def callback(entry_result):
                    """Called when the comparison between the two objects will be done."""

                    # We can update the ``result`` directly as it's a "pointer" in the big
                    # ``compare_refs`` and not a standalone object
                    result['changed'] = entry_result['changed']
                    result['status'] = 'done'
                    if result['changed']:
                        result['old'].compare_status = result['new'].compare_status = 'changed'

                    if backref_callback:
                        backref_callback(result)

                comparison['back_refs'].append(callback)

            # The comparison of the two objects is done, we can get the real "changed" status.
            else:
                result['changed'] = comparison['changed']

        # It's not a duplicable but they are not the same object
        else:
            result['changed'] = True

        if result['changed']:
            result['old'].compare_status = result['new'].compare_status = 'changed'

        return result

    # We use 16 local variables, it's 15 for pylint (too-many-locals).
    # We use 17 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
    @classmethod
    def compare_many_objects(cls, instance_duplicator, objs, other_objs, manage_through=False,
                             ordered=False):
        """Compare two lists of "sub objects" in a comparison.

        Parameters
        ----------
        instance_duplicator : InstanceDuplicator
            The ``InstanceDuplicator`` making the current comparison.
        objs : iterable(django.db.models.Model)
            The first list (or other iterable) of objects to be compared
        other_objs : iterable(django.db.models.Model)
            The other list (or other iterable) of objects to be compared.
        manage_through : boolean
            If ``True``, will manage the through object stored in the ``through_obj``
            property of objects.
        ordered : boolean
            if ``True``, the two lists are considered ordered. It's actually only used to render
            the ``old`` and ``new`` entries in the result: it's a list if ordered, or a set if not.

        Returns
        -------
        dict
            The result of the comparison, with ``old``, ``new`` ``changed`` and ``details`` items.

        Notes
        -----
        Each entry in both lists will have a ``compare_key`` attribute that is the key used to
        compare two entries. If they are ``Duplicable``, the ``original_version_id`` will be used,
        so they can be compared, and if not, the ``pk`` attribute will be used, and they will be
        marked as different if both pk are not the same.

        Each entry in both list that is added, removed, or changed, will have a new attribute,
        ``compare_status`` being a string with "added", "removed" or "changed", to be able to
        iterate on the whole list and know state of the entry in the comparison.

        """

        result = {}

        # Convert iterable to lists to easier use
        objs, other_objs = list(objs), list(other_objs)

        # Check models
        models = set(obj.__class__ for obj in objs + other_objs)
        assert len(models) <= 1, "Objects to compare should be from the same model"

        def prepare_entries(entries):
            """Regroup entries in a dict by key, being the ``original_version_id`` or the pk."""
            entries_by_key = {}
            for entry_ in entries:
                # If entries are duplicable, use the original version to relate them, else use
                # the real PK
                entry_.compare_key = getattr(entry_, 'original_version_id', None) or entry_.pk
                entries_by_key[entry_.compare_key] = entry_

            keys_set = set(entries_by_key.keys())

            return entries, entries_by_key, keys_set

        # Store entries by "key" (``original_version_id`` or pk)
        old_entries, __, old_entries_keys = prepare_entries(objs)
        new_entries, new_entries_by_key, new_entries_keys = prepare_entries(other_objs)

        # Compute the key that were added, removed, or that are in both lists
        removed_keys = old_entries_keys - new_entries_keys
        added_keys = new_entries_keys - old_entries_keys
        kept_keys = old_entries_keys - removed_keys

        # We know that the list has changed if we have some removed or added entries
        list_changed = bool(removed_keys or added_keys)

        # Removed entries are the one in ``old`` but not in ``new``
        removed_entries = []
        for entry in old_entries:
            if entry.compare_key not in removed_keys:
                continue
            removed_entries.append(entry)
            entry.compare_status = 'removed'

        # Added entries are the one in ``new`` but not in ``old``
        added_entries = []
        for entry in new_entries:
            if entry.compare_key not in added_keys:
                continue
            added_entries.append(entry)
            entry.compare_status = 'added'

        # Now, the complicated part
        # Entries that "seem" still the same: we need to compare all of them

        same_entries = []
        changed_entries = []

        for old_entry in old_entries:

            if old_entry.compare_key not in kept_keys:
                continue

            # Both entries share the same "compare key" (``original_version_id`` or pk)
            new_entry = new_entries_by_key[old_entry.compare_key]

            # Track if there is a back ref for the objs comparison, or the comparison of their
            # through objs (if ``manage_through`` is ``True``)
            entry_has_backref = False

            def backref_callback(current_entry_result):
                """Called when the comparison between the two objects will be done."""

                if current_entry_result['changed']:
                    # We now know that the two objects are different, so we can mark the
                    # whole list as "changed".
                    if current_entry_result['changed']:
                        result['changed'] = True

                    # And remove entry from ones marked as not changed
                    result['details']['same'].remove(current_entry_result)

                else:
                    # We now know that the two objects are the same, so if the
                    # "changed" status of the whole list was not already set, because
                    # nothing else (except backrefs) seemed to have changed, we can
                    # set the whole list as "not changed".
                    if result['changed'] is None:
                        result['changed'] = False

                    # Else if not changed, remove entry from ones marked as changed
                    result['details']['changed'].remove(current_entry_result)

            # Compare the two objects
            entry_result = cls.compare_single_object(
                instance_duplicator,
                old_entry,
                new_entry,
                backref_callback=backref_callback
            )

            # We don't know for now if the entries are the same or not, so we add the result
            # in "changed" and "same" lists to keep the order. The bad one will be removed
            # by the backref callback, or if no backref is needed, when the entries and their
            # through objs will be compared
            changed_entries.append(entry_result)
            same_entries.append(entry_result)

            # Update the "changed" status of the whole list depending on the result of this
            # comparison
            if entry_result['changed'] is None:
                entry_has_backref = True
                if not list_changed:
                    list_changed = None
            elif entry_result['changed']:
                list_changed = True

            # We can now manage the through object in both entries
            if manage_through:

                # Because of how closures work in loops, we need to create a callback on the
                # fly because using ``entry_result`` in the method will always use the last
                # one in the loop (ie the one for the last ``old_entry`` in ``old_entries``.
                def make_through_backref_callback(current_entry_result):
                    """Create a callback."""

                    def through_backref_callback(through_result):
                        """Called when the comparison between the 2 through objects will be done."""

                        # Manage the status of the whole list, and the same/changed lists
                        backref_callback(current_entry_result)

                        # We need to mark the current entry result as changed if the through are
                        # different
                        if through_result['changed']:
                            current_entry_result['changed'] = True
                            current_entry_result['old'].compare_status = \
                                current_entry_result['new'].compare_status = 'changed'
                        elif current_entry_result['changed'] is None:
                            current_entry_result['changed'] = False

                    return through_backref_callback

                # Compare the two through objects
                entry_result['through'] = cls.compare_single_object(
                    instance_duplicator,
                    old_entry.through_obj,
                    new_entry.through_obj,
                    ignore_fields=old_entry.through_fks,
                    backref_callback=make_through_backref_callback(entry_result)
                )

                # Update the "changed" status of the whole list depending on the result of this
                # comparison
                if entry_result['through']['changed'] is None:
                    entry_has_backref = True
                    if not list_changed:
                        list_changed = None
                elif entry_result['through']['changed']:
                    entry_result['changed'] = list_changed = True
                else:  # ie entry_result['through']['changed'] is False
                    if entry_result['changed'] is None and not entry_has_backref:
                        entry_result['changed'] = False

            # Remove from one of ``changed`` and ``same`` if no backrefs
            if not entry_has_backref:
                if entry_result['changed']:
                    same_entries.remove(entry_result)
                else:
                    changed_entries.remove(entry_result)

        result.update({
            'old': old_entries if ordered else set(old_entries),
            'new': new_entries if ordered else set(new_entries),
            'changed': list_changed,
            'details': {
                'added': added_entries,
                'removed': removed_entries,
                'changed': changed_entries,
                'same': same_entries,
            }
        })

        return result


class SimpleFieldDuplicator(FieldDuplicator):
    """A field duplicator for fields that are not relations."""

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate the value from the field in the old instance to the new instance.

        Uses the given value if provided, ignoring the value in the old instance.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``
        """

        if value is NOT_PROVIDED:
            # Get the value from the old instance
            value = getattr(instance_duplicator.instance, field.name)

        # Set the value to the new instance
        setattr(instance_duplicator.new_instance, field.name, value)

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare two "simple" values.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``
        """

        value = getattr(instance_duplicator.instance, field.name)
        other_value = getattr(other_instance, field.name)

        result = {
            'old': value,
            'new': other_value,
            'changed': value != other_value,
        }

        if result['changed']:
            instance_duplicator.compare_log_debug('Field "%(field_name)s" (changed=True)',
                                                  other_instance=other_instance,
                                                  field_name=field.name)

        return {field.name: result}


class ForeignKeyDuplicator(FieldDuplicator):
    """A field duplicator for foreign keys (including generic ones) and OneToOne fields."""

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate the value from the FK in the old instance to the new instance.

        Uses the given value if provided, ignoring the value in the old instance.

        If the related instance is duplicable, the duplicated value will be used.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

        if value is NOT_PROVIDED:

            instance_duplicator.duplicate_log_debug('Start FK "%(field_name)s"',
                                                    field_name=field.name)

            # Read the actual instance pointed by the foreign key
            related_instance = getattr(instance_duplicator.instance, field.name)

            # Use value from instance if not duplicable or None
            if (related_instance is None
                    or (field.related_model and not issubclass(field.related_model, Duplicable))
                    or field.name in instance_duplicator.non_duplicable_relations):

                value = related_instance

            else:
                # The related instance is duplicable, so we duplicate it

                non_duplicable_relations = None

                # If the field is a pointer to a parent model in a non-abstract inheritance, we
                # pass the same ``non_duplicable_relations``.

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

                if related_instance.__class__ in non_abstract_parent_models:
                    non_duplicable_relations = instance_duplicator.non_duplicable_relations

                value = cls.duplicate_single_object(
                    instance_duplicator,
                    related_instance,
                    non_duplicable_relations,
                    detach
                )

                if value is None:

                    # Problem: the duplicate has started but the object is not saved yet
                    related_ref = instance_duplicator.duplicate_refs[
                        related_instance.__class__
                    ][related_instance.pk]

                    # Mark entry as to be updated once related instance will be duplicated
                    related_ref['back_refs'].append(
                        (instance_duplicator.new_instance, field.name)
                    )

                    # And return current value for now (to be able to save something)
                    value = related_instance

            instance_duplicator.duplicate_log_debug('End FK "%(field_name)s"',
                                                    field_name=field.name)

        # Set the value to the new instance
        setattr(instance_duplicator.new_instance, field.name, value)

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare two related objects (FKs) by key (``original_version_id`` or PK).

        If the field is a ``non_duplicable_relation``, ignore the comparison.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

        if field.name in instance_duplicator.non_duplicable_relations:
            return

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

        compare_log_debug('Start FK "%(field_name)s"', field_name=field.name)

        result = cls.compare_single_object_by_key(
            getattr(instance_duplicator.instance, field.name),
            getattr(other_instance, field.name),
        )

        compare_log_debug('End FK "%(field_name)s" (changed=%(changed)s)',
                          field_name=field.name, changed=result['changed'])

        return {field.name: result}


class ReverseOneToOneDuplicator(FieldDuplicator):
    """A field duplicator for One2One fields poiting to the current instance."""

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate the related object to keep the one to one relation.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``
        """

        # Get the field name (the related name defined in the related O2O, or automatic)
        field_name = field.get_accessor_name()

        if value is not NOT_PROVIDED:
            raise ValueError('Value not expected for reverse OneToOne fields (%s)' %
                             field_name)

        instance_duplicator.duplicate_log_debug('Start reverse O2O "%(field_name)s"',
                                                field_name=field_name)

        # So we can get the related object that points to us
        try:
            related_instance = getattr(instance_duplicator.instance, field_name)
        except AttributeError:
            instance_duplicator.duplicate_log_debug('Cancel reverse O2O "%(field_name)s"',
                                                    field_name=field_name)
            return

        # We need to duplicate the related object (if we have it) to keep the one to one relation
        if related_instance is not None:
            cls.duplicate_single_object(instance_duplicator, related_instance, None, detach)

        instance_duplicator.duplicate_log_debug('End reverse O2O "%(field_name)s"',
                                                field_name=field_name)

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare two related objects one to one as duplicable or by PKs.

        If the related model is duplicable, will compare both objects, but if not, will simply
        compare the primary keys.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

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

        # Get the field name (related name defined in the related O2O, or automatic)
        field_name = field.get_accessor_name()

        compare_log_debug('Start reverse O2O "%(field_name)s"', field_name=field_name)

        try:
            related_instance = getattr(instance_duplicator.instance, field.name)
        except AttributeError:
            compare_log_debug('Cancel reverse O2O "%(field_name)s"', field_name=field_name)
            return

        result = cls.compare_single_object(
            instance_duplicator,
            related_instance,
            getattr(other_instance, field.name),
        )

        compare_log_debug('End reverse O2O "%(field_name)s" (changed=%(changed)s)',
                          field_name=field_name, changed=result['changed'])

        return {field_name: result}


class ManyToManyBaseDuplicator(FieldDuplicator):
    """Base field duplicator for ManyToMany fields (from or to the current instance."""

    is_reverse = False

    @classmethod
    def get_through_info(cls, field):
        """Get all needed information about the ``through`` table in a M2M field.

        Parameters
        ----------
        field : django.db.models.ManyToManyField or django.db.models.fields.related.ManyToManyRel
            The M2M field, or reverse field, from which to get the "through" information.

        Returns
        -------
        tuple
            A tuple with three entries:
            - the model that serves as the through table, manually defined when in the M2M
              declaration, or auto-created by django
            - the field name on this through model to access the current object
            - the field name on this through model to access the objects on the other side of the
              M2M relation

        """

        # Get the "ManyToManyRel" object
        rel = field if cls.is_reverse else field.remote_field

        # We're going through the ``through`` table
        through_model = rel.through

        # ``through_field`` is the name on the ``through`` table that link to the current model
        # ``reverse_through_field``, to the other model
        through_field = rel.get_path_info()[1].join_field.name
        reverse_through_field = rel.get_path_info()[0].join_field.field.name

        if cls.is_reverse:
            through_field, reverse_through_field = reverse_through_field, through_field

        return through_model, through_field, reverse_through_field

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate all the "through" entries between the two linked instances.

        So it works the same for many to many fields with automatic and manual "through" tables.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """
        field_name = field.get_accessor_name() if cls.is_reverse else field.name

        through_model, through_field, __ = cls.get_through_info(field)
        auto_through = bool(through_model._meta.auto_created)

        if value is not NOT_PROVIDED:

            if cls.is_reverse:
                raise ValueError('Value not expected for reverse ManyToMany fields (%s)' %
                                 field_name)

            if not auto_through:
                raise ValueError('Value not expected for ManyToMany fields with "through" (%s)' %
                                 field_name)

            # If we have values for a direct m2m that has auto-through, use them
            setattr(instance_duplicator.new_instance, field_name, value)
            return

        instance_duplicator.duplicate_log_debug('Start %(reverse)s M2M "%(field_name)s"',
                                                reverse='reverse' if cls.is_reverse else '',
                                                field_name=field_name)

        # Read through instances from the through table.
        through_objs = through_model.objects.filter(
            **{through_field: instance_duplicator.instance}
        )

        # We always want do duplicate the through tables, but in this through table we may not want
        # to duplicate some relations to other duplicables. For this we check the
        # ``non_duplicable_through_relations`` attribute of the model declaring the M2M
        non_duplicable_through_relations = getattr(
            instance_duplicator.instance,
            'non_duplicable_through_relations', {}
        ).get(field_name, [])

        # We create new through entries by duplicating the existing one.
        cls.duplicate_many_objects(
            instance_duplicator,
            through_objs,
            non_duplicable_through_relations,
            detach
        )

        instance_duplicator.duplicate_log_debug('End %(reverse)s M2M "%(field_name)s"',
                                                reverse='reverse' if cls.is_reverse else '',
                                                field_name=field_name)

    # We use 16 local variables, it's 15 for pylint (too-many-locals).
    # pylint: disable=too-many-locals
    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare two lists of objects from many to many fields.

        Each entry on both list has its "through" entry attached to it. It's used to
        compare the fields in the "through" table if the table is not auto-created.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

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

        field_name = field.get_accessor_name() if cls.is_reverse else field.name

        compare_log_debug('Start %(reverse)s M2M "%(field_name)s"',
                          reverse='reverse' if cls.is_reverse else '', field_name=field_name)

        through_model, through_field, reverse_through_field = cls.get_through_info(field)

        # We'll manage the fields in the "through" table if:
        # - it's not an auto-created through table
        # - it has other fields than its pk and the relation to the two sides
        through_meta = through_model._meta
        auto_through = bool(through_meta.auto_created)
        has_through_fields = not auto_through and any(
            f for f in through_meta.concrete_fields
            if (
                f.name not in (through_field, reverse_through_field) and not f.primary_key
            )
        )

        # Remember if the other side and the trough table have default ordering
        is_other_side_ordered = (
            field if cls.is_reverse else field.remote_field
        ).model._meta.ordering
        is_through_ordered = not auto_through and through_meta.ordering

        def get_entries(instance):
            """Get related entries with their "through" object."""

            ordered_entries = getattr(instance, field_name).all()
            entries_by_pk = {obj.pk: obj for obj in ordered_entries}

            # Read through instances from the through table
            through_objs = through_model.objects.filter(
                **{through_field: instance}
            ).select_related(reverse_through_field)

            # Organise result by final obj, with the ``through`` obj as a property, referencing
            # the final objects by their ``original_version_id`` field if Duplicable, else by id

            # Save the "through" object as an attribute on each related entry, and the two FK
            # to the two sides, too.
            for through_obj in through_objs:
                related_entry = entries_by_pk[getattr(through_obj, reverse_through_field).pk]
                related_entry.through_obj = through_obj
                related_entry.through_fks = (through_field, reverse_through_field)

            # If we have an ordering in the through but not on the main model, reorder the result
            if is_through_ordered and not is_other_side_ordered:
                ordered_entries = [
                    entries_by_pk[getattr(through_obj, reverse_through_field).pk]
                    for through_obj in through_objs
                ]

            return ordered_entries

        result = cls.compare_many_objects(
            instance_duplicator,
            get_entries(instance_duplicator.instance),
            get_entries(other_instance),
            manage_through=has_through_fields,
            ordered=is_other_side_ordered or is_through_ordered
        )

        compare_log_debug('End %(reverse)s M2M "%(field_name)s" (changed=%(changed)s)',
                          reverse='reverse' if cls.is_reverse else '',
                          field_name=field_name,
                          changed=result['changed'])

        return {field_name: result}


class ManyToManyDuplicator(ManyToManyBaseDuplicator):
    """Field duplicator for ManyToMany fields from the current instance to another."""

    is_reverse = False


class ReverseManyToManyDuplicator(ManyToManyBaseDuplicator):
    """Field duplicator for ManyToMany fields from another instance to the current one."""

    is_reverse = True

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Do not compare M2M from another model to the current one.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``
        """

        return None


class ReverseForeignKeyDuplicator(FieldDuplicator):
    """Field duplicator for foreign keys pointing to the current instance."""

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate all the related instances if they are duplicable.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``
        """

        # Get the field name (related name defined in the related FK, or automatic)
        field_name = field.get_accessor_name()

        if value is not NOT_PROVIDED:
            raise ValueError('Value not expected for reverse ForeignKey fields (%s)' %
                             field_name)

        instance_duplicator.duplicate_log_debug('Start reverse FK "%(field_name)s"',
                                                field_name=field_name)

        # So we can get the related objects that points to us
        related_instances = getattr(instance_duplicator.instance, field_name).all()

        # And we duplicate each related object
        cls.duplicate_many_objects(instance_duplicator, related_instances, None, detach)

        instance_duplicator.duplicate_log_debug('End reverse FK "%(field_name)s"',
                                                field_name=field_name)

    @classmethod
    def compare(cls, instance_duplicator, field, other_instance):
        """Compare two lists of objects that point to the current instance.

        We ignore the reverse foreign keys from through models, as this is already handled by
        the m2m relation.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

        # Ignore reverse FK from through models
        from ..utils import is_through_model
        if is_through_model(field.related_model):
            return None

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

        # Get the field name (related name defined in the related FK, or automatic)
        field_name = field.get_accessor_name()

        compare_log_debug('Start reverse FK "%(field_name)s"', field_name=field_name)

        result = cls.compare_many_objects(
            instance_duplicator,
            getattr(instance_duplicator.instance, field_name).all(),
            getattr(other_instance, field_name).all(),
        )

        compare_log_debug('End reverse FK "%(field_name)s" (changed=%(changed)s)',
                          field_name=field_name, changed=result['changed'])

        return {field_name: result}


class GenericRelationDuplicator(FieldDuplicator):
    """Field duplicator for generic relations on the current instance"""

    @classmethod
    def duplicate(cls, instance_duplicator, field, value=NOT_PROVIDED, detach=False):
        """Duplicate all the related instances if they are duplicable.

        For the parameters, see ``mtp.discrete_apps.duplicable.duplicators.fields.FieldDuplicator``

        """

        # Get the related objects from the generic relation
        related_instances = getattr(instance_duplicator.instance, field.name).all()

        for related_instance in related_instances:
            if isinstance(related_instance, Duplicable):
                cls.duplicate_single_object(instance_duplicator, related_instance, None, detach)
