"""Provide the ``Graph`` class for the ``django_instances_graph`` package"""

import pickle
from uuid import UUID

from django.apps import apps
from django.db import models

from django_fields_iterator import FieldsIterator, FieldTypes

from . import states
from .instance import Instance
from .utils import get_through_info


VALID_RELATION_FIELD_TYPES = (FieldTypes.FK, FieldTypes.REVERSE_FK, FieldTypes.REVERSE_O2O)


class Graph:
    """A graph holds a set of instances.

    In this graph, many ``Instance`` of the same object will exist only once, to avoid
    infinite recursion while serializing the objects

    Attributes
    ----------

    default_instance_class: type of Instance
        Class attribute defining he default class to use to create ``Instance`` objects
    instance_classes_for_models: dict
        Class attribute defining which class to use to create ``Instance`` objects for django
        models. Empty in ``Graph`` but can be changed in sub classes.
    instances: dict
        Will hold the cache of ``Instance`` objects having a primary key
        The keys are uuids, and the values are ``Instance`` objects
    instances_pk_to_uuid: dict
        Will keep a relation between a tuple (model, pk) to the matching instance uuid
    relations: dict
        It will cache all relations in a bidirectional way, so it is enough to define relations in
        one way (source.uuid => FK => target.uuid) and be able to get them the reverse way
        (target.uuid => reverse FK => source.uuid).
        Or (target.uuid => reverse FK => source.uuid) to get (source.uuid => FK => target.uuid).

        The relations are cached on the graph (source and target must be in the graph) this way:
            source.uuid (a dict) => accessor name (a set) => target.uuid
        and
            target.uuid (a dict) => reverse accessor name (a set) => source.uuid
    deserializing_instances: list
        The whole list of instances that are being deserialized by the graph (an instance is
        added to the list once its deserialization process starts)
    deserialize_stack: list
        The list of instances currently being serialized. Each time a relation is followed that
        has instances to deserialize, they are added to the stack.
        The current instance being serialized is at the end of the stack.
        Once the stack is empty, the graph will check in `deserializing_instances` if there is
        still some instance that are not fully deserialized yet

    """

    default_instance_class = Instance
    instance_classes_for_models = {}

    def __init__(self):
        """Create the ``instances`` dictionary to use for cache of ``Instance``."""

        super().__init__()

        self.instances = {}
        self.instances_pk_to_uuid = {}
        self.relations = {}

        self.deserializing_instances = []
        self.deserialize_stack = []

    def clone(self):
        """Return a new graph with the same attributes of the currentone

        Returns
        -------
        Graph
            A new graph

        Notes
        -----
        This use ``pickle`` to do the operation, and there is some specificities: all the
        deserialize states are reset (in the graph and the instances)

        """

        return pickle.loads(pickle.dumps(self))

    def __getstate__(self):
        """Prepare the state to pickle the instance

        Returns
        -------
        dict
            The state that will be used by ``pickle``

        Notes
        -----
        We reinitialize the attributes about deserialize state, and in ``instances_pk_to_uuid`` we
        replace models by tuples with the app label and the model name, because auto through models
        are not defined and so pickle cannot access them.

        """

        state = self.__dict__.copy()

        state.update({
            'deserializing_instances': [],
            'deserialize_stack': [],
            'instances_pk_to_uuid': {
                ((model._meta.app_label, model._meta.model_name), pk): uuid
                for (model, pk), uuid
                in self.instances_pk_to_uuid.items()
            }
        })

        return state

    def __setstate__(self, state):
        """Restore the state from pickle

        We simply update the ``__dict__`` of the object, and restore the real models in
        ``instances_pk_to_uuid`` instead of the tuples with the app label and the model name that
        were stored in the state.

        Parameters
        ----------
        state: dict
            The state that was previously returned by ``__getstate__``

        """

        self.__dict__.update(state)

        self.instances_pk_to_uuid = {
            (apps.get_model(app_label, model_name), pk): uuid
            for ((app_label, model_name), pk), uuid
            in state['instances_pk_to_uuid'].items()
        }

    def has_instance(self, *key):
        """Check if the cache has an instance for the given model and pk

        For parameters, see `get_instance`

        Returns
        -------
        bool
            ``True`` if the instance exists in the cache, ``False`` otherwise

        """

        try:
            self.get_instance(*key)
        except KeyError:
            return False
        else:
            return True

    def add_instance(self, instance):
        """Set an instance in the cache

        Parameters
        ----------
        instance: django_instances_graph.graph_objects.instance.Instance
            The ``Instance`` object to save in the cache

        Raises
        ------
        ValueError
            When an existing instance has the same uuid but not the same model or pk

        """

        if instance.uuid not in self.instances:
            self.instances[instance.uuid] = instance
        else:
            existing = self.instances[instance.uuid]
            if existing.model != instance.model:
                raise ValueError('An instance with this uuid exists but not on the same model')
            if existing.pk != instance.pk:
                raise ValueError('An instance with this uuid and model exists but not with the '
                                 'same pk')

        if instance.pk:
            key = (instance.model, instance.pk)
            if key not in self.instances_pk_to_uuid:
                self.instances_pk_to_uuid[key] = instance.uuid

    def get_instance(self, *key):
        """Returns the saved instance for the given model and pk

        Parameters
        ----------
        key: tuple
            If one entry, it must be a UUID
            If two entries, it must be a model and a pk:
                model: type of models.Model
                    The model of the instance, must be a django model
                pk: scalar or str
                    The primary key of a `model`

        Returns
        -------
        django_instances_graph.graph_objects.instance.Instance
            The ``Instance`` previously saved

        Raises
        ------
        KeyError
            If the instance doesn't exist in the cache
        ValueError
            If the arguments are not the expected ones

        """

        if not 1 <= len(key) <= 2:
            raise ValueError('`get_instance` expect one or two arguments')

        if len(key) == 2:
            if not issubclass(key[0], models.Model):
                raise ValueError('When passing two arguments to `get_instance`, the first one '
                                 'must be a django model')
            key = self.instances_pk_to_uuid[key]

        else:
            key = key[0]

            if not isinstance(key, UUID):
                raise ValueError('If only one argument, a UUID is expected')

        return self.instances[key]

    def remove_instance(self, *key):
        """Totally remove an instance (and its relation) from the graph

        Parameters
        ----------
        key: tuple
            If one entry, it must be a UUID or an ``Instance``
            If two entries, it must be a model and a pk:
                model: type of models.Model
                    The model of the instance, must be a django model
                pk: scalar or str
                    The primary key of a `model`

        """

        if len(key) == 1 and isinstance(key[0], Instance):
            instance = key[0]
        else:
            instance = self.get_instance(*key)

        # We need first to remove the relations
        fields_iterator = FieldsIterator(instance.model)
        for __, field_name, field_type in fields_iterator.iter_fields(exclude=[FieldTypes.SIMPLE]):
            if field_type in (FieldTypes.M2M, FieldTypes.REVERSE_M2M):
                self.clear_m2m_relation(instance, field_name)
            else:
                self.clear_relation(instance, field_name)

        # Then remove the link pk => uuid
        if instance.pk:
            self.instances_pk_to_uuid.pop((instance.model, instance.pk), None)

        # And finally remove the entry in the cache
        del self.instances[instance.uuid]

    def clear_pks(self):
        """Clear the pk of all the non-boundary instances.

        This is useful to duplicate a graph of instances after having serialized it

        """

        for instance in self.instances.values():
            if not instance.is_boundary:
                if instance.pk:
                    self.instances_pk_to_uuid.pop((instance.model, instance.pk), None)
                instance.pk = None

    # pylint: disable=unused-argument
    def is_relation_boundary(self, instance, accessor_name, field, field_type):
        """Tell if the serialization should stop in the graph after encountered this field

        When this method returns ``True``, the target instance(s) will be created with
        ``is_boundary`` set to ``True``, and will not be serialized and will


        Parameters
        ----------
        instance: django_instances_graph.graph_objects.instance.Instance
            The instance on which the relation is defined
        accessor_name: str
            The name of the attribute to get the relation on the model defined in `instance`
        field: models.fields.related.ForeignObject or models.fields.related.ForeignObjectRel
            The field for this relation. The related model can be accessed via `field.related_model`
        field_type: str
            The kind of field from which the value come from. One of ``FieldTypes.allowed_types``

        Returns
        -------
        bool
            ``False`` by default: the relation target will not be followed. If ``True``, it will.

        Notes
        -----
        This method is called by the method of the same name on `instance`, where it can be
        overridden to return ``True`` to set some boundaries, but it can also be overridden
        here in the graph for a more general behavior.

        """

        return False

    def begin_deserializing_instance(self, instance):
        """Tell the graph that the given `instance` just started its serialization process

        Parameters
        ----------
        instance: django_instances_graph.graph_objects.instance.Instance
            The ``Instance`` that started its deserialization process

        """

        # Add it in the list only if not in yet
        if instance not in self.deserializing_instances:
            self.deserializing_instances.append(instance)

        # And add it at the top of the stack
        self.deserialize_stack.append(instance)

    def end_deserializing_instance(self, instance):
        """Tell the graph that `instance` just finished a pass of its serialization process

        The deserialization process may not be finished: it may not be possible to do it now,
        waiting for other instances to be deserialized.

        When the stack is empty, we'll start again the deserialization process if not all
        instances are deserialized.

        Parameters
        ----------
        instance: django_instances_graph.graph_objects.instance.Instance
            The ``Instance`` that ended its deserialization process

        """

        # Remove the instance from the stack
        self.deserialize_stack.remove(instance)

        if not self.deserialize_stack:
            # We finished a pass, check if we have to do another one
            instances = [instance
                         for instance
                         in self.deserializing_instances
                         if instance.deserialize_state != states.DONE]

            if instances:
                # Ok we can do another pass, starting with the first instance not fully deserialized
                # If more than one instances, an other pass will happen and then the next "first"
                # not deserialized will be deserialized, etc...
                instances[0].deserialize(instances[0].deserialize_model_instance)

            # Ok we're done, all instances are deserialized
            self.clear_deserialization_state()

    def clear_deserialization_state(self):
        """Clear all the deserialization related attributes for the graph and instances"""

        for instance in self.deserializing_instances:
            instance.clear_deserialization_state()

        self.deserializing_instances = []
        self.deserialize_stack = []

    def save_model_instance(self, instance, model_instance):  # pylint: disable=unused-argument
        """A hook to save a model instance from an instance.

        It's normally managed by the `instance` itself that will call ``model_instance.save()``
        but this method is here to ease the overriding.

        If the saving is managed here, the saved model instance should be returned. Otherwise,
        this method should return ``None`` letting the ``save_model_instance`` method of `instance`
        know that it should handle the saving itself.

        Parameters
        ----------
        instance: django_instances_graph.graph_objects.instance.Instance
            The ``Instance`` object used to generate `model_instance`
        model_instance: models.Model
            The django model instance to be saved

        Returns
        -------
        None or models.Model
            If ``None`` it's assumed that nothing was done to the `model_instance`.
            Else, `model_instance` should have been saved, and the saved one must be returned.

        """

        return None

    def add_relation(self, source, accessor_name, target):
        """Create if needed, and return the relation stored in cache

        Both the direct and reverse relations are created.

        Parameters
        ----------
        source: Instance
            The instance creating a relation to `target` via its `accessor_name`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the `target`
        target: Instance
            The instance on the other side of the relation from `source` via its `accessor_name`

        Raises
        ------
        ValueError
            - If `source` and `target` are not on the current graph
            - If the `accessor_name` is not a valid field type for a relation

        """

        if source.graph != target.graph or source.graph != self:
            raise ValueError('`source` and `target` must belong to the same `Graph` object')

        field = source.model._meta.get_field(accessor_name)
        field_type = FieldTypes.for_field(field)

        if field_type not in VALID_RELATION_FIELD_TYPES:
            raise ValueError(
                '`%s` is not an accessor of a valid field type for a relation from `%s`'
                % (accessor_name, source.model.__name__))

        if target.model is not field.related_model:
            raise ValueError(
                '`target` is from model `%s` which is not the expected one for the relation `%s.%s`'
                % (target.model.__name__, source.model.__name__, accessor_name))

        if field_type in FieldTypes.reverse_types:
            # get the relation argument in the reverse relation
            reverse_field = source.model._meta.get_field(accessor_name)
            reverse_accessor_name, accessor_name = accessor_name, reverse_field.field.name
            source, target = reverse_target, reverse_source = target, source
        else:
            reverse_target, reverse_source = source, target
            reverse_field = source.model._meta.get_field(accessor_name).remote_field
            reverse_accessor_name = reverse_field.get_accessor_name()

        # We can save the target and also make it accessible from the reverse side

        for (source_uuid, name, target_uuid) in [
                (source.uuid, accessor_name, target.uuid),
                (reverse_source.uuid, reverse_accessor_name, reverse_target.uuid),
            ]:
            self.relations.setdefault(source_uuid, {}).setdefault(name, set()).add(target_uuid)

    def add_direct_m2m_relation(self, source, accessor_name, targets):
        """Helper that add the relations between the through model and the both sides of a m2m

        This only works for DIRECT m2m, without a "through" model defined on the source model (at
        least it work but none of the fields on the "through" model except the FKs to `source`
        and `targets` will be filled.)

        Parameters
        ----------
        source: Instance
            The instance on which the `accessor_name` m2m (or reverse m2m) is defined from `source`
            to `target`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the `target`
        targets: list of Instance
            The instances on the other side of the m2m

        Returns
        -------
        list of Instance
            Returns the list of created through instances (in the same order as the `targets`)

        """

        through_model, through_source_field, through_target_field = \
            get_through_info(source.model, accessor_name)[:3]

        through_instances = []

        for target in targets:

            through_instance = Instance(through_model, graph=self)
            through_instances.append(through_instance)

            self.add_relation(through_instance, through_source_field.name, source)
            self.add_relation(through_instance, through_target_field.name, target)

        return through_instances

    def get_relation_targets(self, source, accessor_name):
        """Returns all available targets accessible from `source` via `accessor_name`

        Parameters
        ----------
        source: Instance
            The source ``Instance`` from which we want to get the targets
        accessor_name: str
            The name of the "field" used to access the targets from `source`

        Returns
        -------
        set of Instance
            A set of unique targets got from the `source`

        Raises
        ------
        ValueError
            If `source` is not on the current graph

        """

        if source.graph != self:
            raise ValueError('`source` must belong to the current `Graph` object')

        try:
            return {
                self.instances[target_uuid]
                for target_uuid
                in self.relations[source.uuid][accessor_name]
            }
        except KeyError:
            return set()

    def get_m2m_relation_targets(self, source, accessor_name):
        """Helper to get the instances on the other side of a m2m or reverse m2m relation.

        It get the target on the reverse fk from the source to the through, then the fk from the
        through to the reverse side

        Parameters
        ----------
        source: Instance
            The source ``Instance`` from which we want to get the targets
        accessor_name: str
            The name of the "field" used to access the targets from `source`

        Returns
        -------
        set of Instance
            A set of unique targets got in the relations cache

        """

        through_target_field = get_through_info(source.model, accessor_name)[2]

        through_instances = self.get_relation_targets(
            source,
            through_target_field.remote_field.get_accessor_name()
        )
        related_targets = set()
        for through_instance in through_instances:
            related_targets.update(
                self.get_relation_targets(through_instance, through_target_field.name)
            )
        return related_targets

    def remove_relation(self, source, accessor_name, target):
        """Remove a relation from the cache if it exists

        Both the direct and reverse relations are removed.

        Parameters
        ----------
        source: Instance
            The instance from one side of the relation to `target` via its `accessor_name`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the `target`
        target: Instance
            The instance on the other side of the relation from `source` via its `accessor_name`

        Raises
        ------
        ValueError
            - If `source` and `target` are not on the current graph
            - If the `accessor_name` is not a valid field type for a relation

        """

        if source.graph != target.graph or source.graph != self:
            raise ValueError('`source` and `target` must belong to the same `Graph` object')

        field = source.model._meta.get_field(accessor_name)
        field_type = FieldTypes.for_field(field)

        if field_type not in VALID_RELATION_FIELD_TYPES:
            raise ValueError(
                '`%s` is not an accessor of a valid field type for a relation from `%s`'
                % (accessor_name, source.model.__name__))

        if target.model is not field.related_model:
            raise ValueError(
                '`target` is from model `%s` which is not the expected one for the relation `%s.%s`'
                % (target.model.__name__, source.model.__name__, accessor_name))

        if field_type in FieldTypes.reverse_types:
            # get the relation argument in the reverse relation
            reverse_field = source.model._meta.get_field(accessor_name)
            reverse_accessor_name, accessor_name = accessor_name, reverse_field.field.name
            # field_type = FieldTypes.reverse_matching[field_type]
            source, target = reverse_target, reverse_source = target, source
        else:
            reverse_target, reverse_source = source, target
            reverse_field = source.model._meta.get_field(accessor_name).remote_field
            reverse_accessor_name = reverse_field.get_accessor_name()

        # We can remove the target and the reverse side
        for (source_uuid, name, target_uuid) in [
                (source.uuid, accessor_name, target.uuid),
                (reverse_source.uuid, reverse_accessor_name, reverse_target.uuid),
            ]:
            if source_uuid in self.relations and name in self.relations[source_uuid]:
                # Remove the uuid from the set
                self.relations[source_uuid][name].remove(target_uuid)
                # Clear the set and parent dicts if they are empty
                if not self.relations[source_uuid][name]:
                    del self.relations[source_uuid][name]
                    if not self.relations[source_uuid]:
                        del self.relations[source_uuid]

    def remove_m2m_relation(self, source, accessor_name, targets, remove_through_instances=True):
        """Helper that remove a m2m relation from the cache if it exists

        All the relations from the source to the through instances, and from the targets to the
        through instances are removed.

        Parameters
        ----------
        source: Instance
            The instance from one side of the relation to the `targets` via its `accessor_name`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the `targets`
        targets: list of Instance
            The instances on the other side of the relation from `source` via its `accessor_name`
        remove_through_instances: bool
            If ``True`` (the default), the ``Instance`` objects for the "through" entries will be
            removed from the graph too, else they'll still be there but not tied to any relation.

        Returns
        -------
        list of Instance
            Returns the list of through instances that were at the center of the removed
            relations between the `source` and the `targets` (in the same order as the `targets`)

        """

        through_source_field, through_target_field = \
            get_through_info(source.model, accessor_name)[1:3]

        # Get all the relation from `source` to the through model
        source_to_through_accessor_name = through_source_field.remote_field.get_accessor_name()
        through_instances = self.get_relation_targets(source, source_to_through_accessor_name)

        # Get all the through instances pointing to `targets
        to_delete = []
        for through_instance in through_instances:
            try:
                target = list(
                    self.get_relation_targets(through_instance, through_target_field.name)
                )[0]
            except IndexError:
                # no relation, we can continue
                continue

            if target in targets:
                # we have a relation to delete
                to_delete.append((through_instance, target))

        # Now we can delete the relations
        for through_instance, target in to_delete:
            self.remove_relation(through_instance, through_source_field.name, source)
            self.remove_relation(through_instance, through_target_field.name, target)
            if remove_through_instances:
                self.remove_instance(through_instance)

        return through_instances

    def clear_relation(self, source, accessor_name):
        """Clear all the relations for a `source` and a specific `accessor_name`

        Both the direct and reverse relations are removed.

        Parameters
        ----------
        source: Instance
            The instance from one side of the relation via its `accessor_name`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the targets

        """

        targets = self.get_relation_targets(source, accessor_name)
        for target in targets:
            self.remove_relation(source, accessor_name, target)

    def clear_m2m_relation(self, source, accessor_name, remove_through_instances=True):
        """Helper that remove all m2m relations for `source` via `accessor_name`

        All the relations from the source to the through instances, and from all the targets to the
        through instances are removed.

        Parameters
        ----------
        source: Instance
            The instance from one side of the relation to `target` via its `accessor_name`
        accessor_name: str
            The name of the accessor ("field") used to go from the `source` to the `target`
        remove_through_instances: bool
            If ``True`` (the default), the ``Instance`` objects for the "through" entries will be
            removed from the graph too, else they'll still be there but not tied to any relation.

        Returns
        -------
        list of Instance
            Returns the list of through instances that were at the center of the removed
            relations between the `source` and the targets

        """

        targets = self.get_m2m_relation_targets(source, accessor_name)
        return self.remove_m2m_relation(source, accessor_name, targets, remove_through_instances)
