"""
Contains things to detect changes - either using options passed in on the
commandline, or by using autodetection, etc.
"""

from __future__ import print_function

from django.db import models
from django.contrib.contenttypes.generic import GenericRelation
from django.utils.datastructures import SortedDict

from south.creator.freezer import remove_useless_attributes, freeze_apps, model_key
from south.utils import auto_through
from south.utils.py3 import string_types


class BaseChanges(object):
    """
    Base changes class.
    """

    def suggest_name(self):
        return ""

    def split_model_def(self, model, model_def):
        """
        Given a model and its model def (a dict of field: triple), returns three
        items: the real fields dict, the Meta dict, and the M2M fields dict.
        """
        real_fields = SortedDict()
        meta = SortedDict()
        m2m_fields = SortedDict()
        for name, triple in model_def.items():
            if name == "Meta":
                meta = triple
            elif isinstance(model._meta.get_field_by_name(name)[0], models.ManyToManyField):
                m2m_fields[name] = triple
            else:
                real_fields[name] = triple
        return real_fields, meta, m2m_fields

    def current_model_from_key(self, key):
        app_label, model_name = key.split(".")
        return models.get_model(app_label, model_name)

    def current_field_from_key(self, key, fieldname):
        app_label, model_name = key.split(".")
        # Special, for the magical field from order_with_respect_to
        if fieldname == "_order":
            field = models.IntegerField()
            field.name = "_order"
            field.attname = "_order"
            field.column = "_order"
            field.default = 0
            return field
        # Otherwise, normal.
        return models.get_model(app_label, model_name)._meta.get_field_by_name(fieldname)[0]


class AutoChanges(BaseChanges):
    """
    Detects changes by 'diffing' two sets of frozen model definitions.
    """

    # Field types we don't generate add/remove field changes for.
    IGNORED_FIELD_TYPES = [GenericRelation]

    def __init__(self, migrations, old_defs, old_orm, new_defs):
        self.migrations = migrations
        self.old_defs = old_defs
        self.old_orm = old_orm
        self.new_defs = new_defs

    def suggest_name(self):
        parts = ["auto"]
        for change_name, params in self.get_changes():
            if change_name == "AddModel":
                parts.append("add_%s" % params["model"]._meta.object_name.lower())
            elif change_name == "DeleteModel":
                parts.append("del_%s" % params["model"]._meta.object_name.lower())
            elif change_name == "AddField":
                parts.append(
                    "add_field_%s_%s"
                    % (params["model"]._meta.object_name.lower(), params["field"].name)
                )
            elif change_name == "DeleteField":
                parts.append(
                    "del_field_%s_%s"
                    % (params["model"]._meta.object_name.lower(), params["field"].name)
                )
            elif change_name == "ChangeField":
                parts.append(
                    "chg_field_%s_%s"
                    % (params["model"]._meta.object_name.lower(), params["new_field"].name)
                )
            elif change_name == "AddUnique":
                parts.append(
                    "add_unique_%s_%s"
                    % (
                        params["model"]._meta.object_name.lower(),
                        "_".join([x.name for x in params["fields"]]),
                    )
                )
            elif change_name == "DeleteUnique":
                parts.append(
                    "del_unique_%s_%s"
                    % (
                        params["model"]._meta.object_name.lower(),
                        "_".join([x.name for x in params["fields"]]),
                    )
                )
            elif change_name == "AddIndex":
                parts.append(
                    "add_index_%s_%s"
                    % (
                        params["model"]._meta.object_name.lower(),
                        "_".join([x.name for x in params["fields"]]),
                    )
                )
            elif change_name == "DeleteIndex":
                parts.append(
                    "del_index_%s_%s"
                    % (
                        params["model"]._meta.object_name.lower(),
                        "_".join([x.name for x in params["fields"]]),
                    )
                )
        return ("__".join(parts))[:70]

    def get_changes(self):
        """
        Returns the difference between the old and new sets of models as a 5-tuple:
        added_models, deleted_models, added_fields, deleted_fields, changed_fields
        """

        deleted_models = set()

        # See if anything's vanished
        for key in self.old_defs:
            if key not in self.new_defs:
                # We shouldn't delete it if it was managed=False
                old_fields, old_meta, old_m2ms = self.split_model_def(
                    self.old_orm[key], self.old_defs[key]
                )
                if old_meta.get("managed", "True") != "False":
                    # Alright, delete it.
                    yield ("DeleteModel", {"model": self.old_orm[key], "model_def": old_fields})
                    # Also make sure we delete any M2Ms it had.
                    for fieldname in old_m2ms:
                        # Only delete its stuff if it wasn't a through=.
                        field = self.old_orm[key + ":" + fieldname]
                        if auto_through(field):
                            yield ("DeleteM2M", {"model": self.old_orm[key], "field": field})
                    # And any index/uniqueness constraints it had
                    for attr, operation in (
                        ("unique_together", "DeleteUnique"),
                        ("index_together", "DeleteIndex"),
                    ):
                        together = eval(old_meta.get(attr, "[]"))
                        if together:
                            # If it's only a single tuple, make it into the longer one
                            if isinstance(together[0], string_types):
                                together = [together]
                            # For each combination, make an action for it
                            for fields in together:
                                yield (
                                    operation,
                                    {
                                        "model": self.old_orm[key],
                                        "fields": [
                                            self.old_orm[key]._meta.get_field_by_name(x)[0]
                                            for x in fields
                                        ],
                                    },
                                )
                # We always add it in here so we ignore it later
                deleted_models.add(key)

        # Or appeared
        for key in self.new_defs:
            if key not in self.old_defs:
                # We shouldn't add it if it's managed=False
                new_fields, new_meta, new_m2ms = self.split_model_def(
                    self.current_model_from_key(key), self.new_defs[key]
                )
                if new_meta.get("managed", "True") != "False":
                    yield (
                        "AddModel",
                        {"model": self.current_model_from_key(key), "model_def": new_fields},
                    )
                    # Also make sure we add any M2Ms it has.
                    for fieldname in new_m2ms:
                        # Only create its stuff if it wasn't a through=.
                        field = self.current_field_from_key(key, fieldname)
                        if auto_through(field):
                            yield (
                                "AddM2M",
                                {"model": self.current_model_from_key(key), "field": field},
                            )
                    # And any index/uniqueness constraints it has
                    for attr, operation in (
                        ("unique_together", "AddUnique"),
                        ("index_together", "AddIndex"),
                    ):
                        together = eval(new_meta.get(attr, "[]"))
                        if together:
                            # If it's only a single tuple, make it into the longer one
                            if isinstance(together[0], string_types):
                                together = [together]
                            # For each combination, make an action for it
                            for fields in together:
                                yield (
                                    operation,
                                    {
                                        "model": self.current_model_from_key(key),
                                        "fields": [
                                            self.current_model_from_key(
                                                key
                                            )._meta.get_field_by_name(x)[0]
                                            for x in fields
                                        ],
                                    },
                                )

        # Now, for every model that's stayed the same, check its fields.
        for key in self.old_defs:
            if key not in deleted_models:

                old_fields, old_meta, old_m2ms = self.split_model_def(
                    self.old_orm[key], self.old_defs[key]
                )
                new_fields, new_meta, new_m2ms = self.split_model_def(
                    self.current_model_from_key(key), self.new_defs[key]
                )

                # Do nothing for models which are now not managed.
                if new_meta.get("managed", "True") == "False":
                    continue

                # Find fields that have vanished.
                for fieldname in old_fields:
                    if fieldname not in new_fields:
                        # Don't do it for any fields we're ignoring
                        field = self.old_orm[key + ":" + fieldname]
                        field_allowed = True
                        for field_type in self.IGNORED_FIELD_TYPES:
                            if isinstance(field, field_type):
                                field_allowed = False
                        if field_allowed:
                            # Looks alright.
                            yield (
                                "DeleteField",
                                {
                                    "model": self.old_orm[key],
                                    "field": field,
                                    "field_def": old_fields[fieldname],
                                },
                            )

                # And ones that have appeared
                for fieldname in new_fields:
                    if fieldname not in old_fields:
                        # Don't do it for any fields we're ignoring
                        field = self.current_field_from_key(key, fieldname)
                        field_allowed = True
                        for field_type in self.IGNORED_FIELD_TYPES:
                            if isinstance(field, field_type):
                                field_allowed = False
                        if field_allowed:
                            # Looks alright.
                            yield (
                                "AddField",
                                {
                                    "model": self.current_model_from_key(key),
                                    "field": field,
                                    "field_def": new_fields[fieldname],
                                },
                            )

                # Find M2Ms that have vanished
                for fieldname in old_m2ms:
                    if fieldname not in new_m2ms:
                        # Only delete its stuff if it wasn't a through=.
                        field = self.old_orm[key + ":" + fieldname]
                        if auto_through(field):
                            yield ("DeleteM2M", {"model": self.old_orm[key], "field": field})

                # Find M2Ms that have appeared
                for fieldname in new_m2ms:
                    if fieldname not in old_m2ms:
                        # Only create its stuff if it wasn't a through=.
                        field = self.current_field_from_key(key, fieldname)
                        if auto_through(field):
                            yield (
                                "AddM2M",
                                {"model": self.current_model_from_key(key), "field": field},
                            )

                # For the ones that exist in both models, see if they were changed
                for fieldname in set(old_fields).intersection(set(new_fields)):
                    # Non-index changes
                    if self.different_attributes(
                        remove_useless_attributes(old_fields[fieldname], True, True),
                        remove_useless_attributes(new_fields[fieldname], True, True),
                    ):
                        yield (
                            "ChangeField",
                            {
                                "model": self.current_model_from_key(key),
                                "old_field": self.old_orm[key + ":" + fieldname],
                                "new_field": self.current_field_from_key(key, fieldname),
                                "old_def": old_fields[fieldname],
                                "new_def": new_fields[fieldname],
                            },
                        )
                    # Index changes
                    old_field = self.old_orm[key + ":" + fieldname]
                    new_field = self.current_field_from_key(key, fieldname)
                    if not old_field.db_index and new_field.db_index:
                        # They've added an index.
                        yield (
                            "AddIndex",
                            {"model": self.current_model_from_key(key), "fields": [new_field]},
                        )
                    if old_field.db_index and not new_field.db_index:
                        # They've removed an index.
                        yield ("DeleteIndex", {"model": self.old_orm[key], "fields": [old_field]})
                    # See if their uniques have changed
                    if old_field.unique != new_field.unique:
                        # Make sure we look at the one explicitly given to see what happened
                        if new_field.unique:
                            yield (
                                "AddUnique",
                                {"model": self.current_model_from_key(key), "fields": [new_field]},
                            )
                        else:
                            yield (
                                "DeleteUnique",
                                {"model": self.old_orm[key], "fields": [old_field]},
                            )

                # See if there's any M2Ms that have changed.
                for fieldname in set(old_m2ms).intersection(set(new_m2ms)):
                    old_field = self.old_orm[key + ":" + fieldname]
                    new_field = self.current_field_from_key(key, fieldname)
                    # Have they _added_ a through= ?
                    if auto_through(old_field) and not auto_through(new_field):
                        yield ("DeleteM2M", {"model": self.old_orm[key], "field": old_field})
                    # Have they _removed_ a through= ?
                    if not auto_through(old_field) and auto_through(new_field):
                        yield (
                            "AddM2M",
                            {"model": self.current_model_from_key(key), "field": new_field},
                        )

                # See if the {index,unique}_togethers have changed
                for attr, add_operation, del_operation in (
                    ("unique_together", "AddUnique", "DeleteUnique"),
                    ("index_together", "AddIndex", "DeleteIndex"),
                ):
                    # First, normalise them into lists of sets.
                    old_together = eval(old_meta.get(attr, "[]"))
                    new_together = eval(new_meta.get(attr, "[]"))
                    if old_together and isinstance(old_together[0], string_types):
                        old_together = [old_together]
                    if new_together and isinstance(new_together[0], string_types):
                        new_together = [new_together]
                    old_together = frozenset(tuple(o) for o in old_together)
                    new_together = frozenset(tuple(n) for n in new_together)
                    # See if any appeared or disappeared
                    disappeared = old_together.difference(new_together)
                    appeared = new_together.difference(old_together)
                    for item in disappeared:
                        yield (
                            del_operation,
                            {
                                "model": self.old_orm[key],
                                "fields": [self.old_orm[key + ":" + x] for x in item],
                            },
                        )
                    for item in appeared:
                        yield (
                            add_operation,
                            {
                                "model": self.current_model_from_key(key),
                                "fields": [self.current_field_from_key(key, x) for x in item],
                            },
                        )

    @classmethod
    def is_triple(cls, triple):
        "Returns whether the argument is a triple."
        return (
            isinstance(triple, (list, tuple))
            and len(triple) == 3
            and isinstance(triple[0], string_types)
            and isinstance(triple[1], (list, tuple))
            and isinstance(triple[2], dict)
        )

    @classmethod
    def different_attributes(cls, old, new):
        """
        Backwards-compat comparison that ignores orm. on the RHS and not the left
        and which knows django.db.models.fields.CharField = models.CharField.
        Has a whole load of tests in tests/autodetection.py.
        """

        # If they're not triples, just do normal comparison
        if not cls.is_triple(old) or not cls.is_triple(new):
            return old != new

        # Expand them out into parts
        old_field, old_pos, old_kwd = old
        new_field, new_pos, new_kwd = new

        # Copy the positional and keyword arguments so we can compare them and pop off things
        old_pos, new_pos = old_pos[:], new_pos[:]
        old_kwd = dict(old_kwd.items())
        new_kwd = dict(new_kwd.items())

        # Remove comparison of the existence of 'unique', that's done elsewhere.
        # TODO: Make this work for custom fields where unique= means something else?
        if "unique" in old_kwd:
            del old_kwd["unique"]
        if "unique" in new_kwd:
            del new_kwd["unique"]

        # If the first bit is different, check it's not by dj.db.models...
        if old_field != new_field:
            if old_field.startswith("models.") and (
                new_field.startswith("django.db.models")
                or new_field.startswith("django.contrib.gis")
            ):
                if old_field.split(".")[-1] != new_field.split(".")[-1]:
                    return True
                else:
                    # Remove those fields from the final comparison
                    old_field = new_field = ""

        # If there's a positional argument in the first, and a 'to' in the second,
        # see if they're actually comparable.
        if (old_pos and "to" in new_kwd) and ("orm" in new_kwd["to"] and "orm" not in old_pos[0]):
            # Do special comparison to fix #153
            try:
                if old_pos[0] != new_kwd["to"].split("'")[1].split(".")[1]:
                    return True
            except IndexError:
                pass  # Fall back to next comparison
            # Remove those attrs from the final comparison
            old_pos = old_pos[1:]
            del new_kwd["to"]

        return old_field != new_field or old_pos != new_pos or old_kwd != new_kwd


class ManualChanges(BaseChanges):
    """
    Detects changes by reading the command line.
    """

    def __init__(self, migrations, added_models, added_fields, added_indexes):
        self.migrations = migrations
        self.added_models = added_models
        self.added_fields = added_fields
        self.added_indexes = added_indexes

    def suggest_name(self):
        bits = []
        for model_name in self.added_models:
            bits.append("add_model_%s" % model_name)
        for field_name in self.added_fields:
            bits.append("add_field_%s" % field_name)
        for index_name in self.added_indexes:
            bits.append("add_index_%s" % index_name)
        return "_".join(bits).replace(".", "_")

    def get_changes(self):
        # Get the model defs so we can use them for the yield later
        model_defs = freeze_apps([self.migrations.app_label()])
        # Make the model changes
        for model_name in self.added_models:
            model = models.get_model(self.migrations.app_label(), model_name)
            real_fields, meta, m2m_fields = self.split_model_def(
                model, model_defs[model_key(model)]
            )
            yield ("AddModel", {"model": model, "model_def": real_fields})
        # And the field changes
        for field_desc in self.added_fields:
            try:
                model_name, field_name = field_desc.split(".")
            except (TypeError, ValueError):
                raise ValueError("%r is not a valid field description." % field_desc)
            model = models.get_model(self.migrations.app_label(), model_name)
            real_fields, meta, m2m_fields = self.split_model_def(
                model, model_defs[model_key(model)]
            )
            yield (
                "AddField",
                {
                    "model": model,
                    "field": model._meta.get_field_by_name(field_name)[0],
                    "field_def": real_fields[field_name],
                },
            )
        # And the indexes
        for field_desc in self.added_indexes:
            try:
                model_name, field_name = field_desc.split(".")
            except (TypeError, ValueError):
                print ("%r is not a valid field description." % field_desc)
            model = models.get_model(self.migrations.app_label(), model_name)
            yield (
                "AddIndex",
                {"model": model, "fields": [model._meta.get_field_by_name(field_name)[0]]},
            )


class InitialChanges(BaseChanges):
    """
    Creates all models; handles --initial.
    """

    def suggest_name(self):
        return "initial"

    def __init__(self, migrations):
        self.migrations = migrations

    def get_changes(self):
        # Get the frozen models for this app
        model_defs = freeze_apps([self.migrations.app_label()])

        for model in models.get_models(models.get_app(self.migrations.app_label())):

            # Don't do anything for unmanaged, abstract or proxy models
            if (
                model._meta.abstract
                or getattr(model._meta, "proxy", False)
                or not getattr(model._meta, "managed", True)
            ):
                continue

            real_fields, meta, m2m_fields = self.split_model_def(
                model, model_defs[model_key(model)]
            )

            # Firstly, add the main table and fields
            yield ("AddModel", {"model": model, "model_def": real_fields})

            # Then, add any indexing/uniqueness that's around
            if meta:
                for attr, operation in (
                    ("unique_together", "AddUnique"),
                    ("index_together", "AddIndex"),
                ):
                    together = eval(meta.get(attr, "[]"))
                    if together:
                        # If it's only a single tuple, make it into the longer one
                        if isinstance(together[0], string_types):
                            together = [together]
                        # For each combination, make an action for it
                        for fields in together:
                            yield (
                                operation,
                                {
                                    "model": model,
                                    "fields": [model._meta.get_field_by_name(x)[0] for x in fields],
                                },
                            )

            # Finally, see if there's some M2M action
            for name, triple in m2m_fields.items():
                field = model._meta.get_field_by_name(name)[0]
                # But only if it's not through=foo (#120)
                if field.rel.through:
                    try:
                        # Django 1.1 and below
                        through_model = field.rel.through_model
                    except AttributeError:
                        # Django 1.2
                        through_model = field.rel.through
                if (not field.rel.through) or getattr(through_model._meta, "auto_created", False):
                    yield ("AddM2M", {"model": model, "field": field})
