import collections
from collections.abc import Sequence
from typing import Any, NamedTuple

import peewee as pw
from playhouse.reflection import Column as ColumnSerializer

from miggy.ext.fields import CharEnumField, IntEnumField
from miggy.utils import ModelIndex, get_default_constraint, get_default_constraint_value, indexes_state

from .types import ModelCls

INDENT = "    "
NEWLINE = "\n" + INDENT


class FieldComparer:
    TYPE_PARAMS = {
        pw.CharField: ["max_length"],
        pw.DecimalField: ["max_digits", "decimal_places"],
    }

    def __init__(self, field: pw.Field) -> None:
        self.field = field

    @property
    def field_type(self) -> type[pw.Field]:
        if isinstance(self.field, CharEnumField):
            return pw.CharField
        elif isinstance(self.field, IntEnumField):
            return pw.SmallIntegerField
        return type(self.field)

    def _get_default(self, field: pw.Field) -> Any:
        if field.default is not None and not callable(field.default):
            return field.default
        return None

    @staticmethod
    def fk_to_params(field: pw.ForeignKeyField) -> dict[str, Any]:
        params = {"model": field.rel_model._meta.name}
        if field.on_delete is not None:
            params["on_delete"] = "'%s'" % field.on_delete
        if field.on_update is not None:
            params["on_update"] = "'%s'" % field.on_update
        if field.constraint_name is not None:
            params["constraint_name"] = "'%s'" % field.constraint_name
        return params

    def get_type_params(self) -> dict[str, Any]:
        params = {}
        attributes = self.TYPE_PARAMS.get(self.field_type, [])
        for attribute in attributes:
            params[attribute] = getattr(self.field, attribute)
        return params

    def get_field_params(self) -> dict[str, Any]:
        params = self.get_type_params()
        if self.field_type is pw.ForeignKeyField:
            params.update(self.fk_to_params(self.field))
        return params

    def to_params(self) -> dict[str, Any]:
        field = self.field
        params = self.get_field_params()
        params["type"] = self.field_type
        params["null"] = field.null
        params["column_name"] = field.column_name
        params["default"] = self._get_default(field)
        params["default_constraint"] = get_default_constraint_value(field)
        params["index"] = field.index and not field.unique, field.unique
        return params

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, FieldComparer):
            return False
        return self.to_params() == other.to_params()

    @classmethod
    def not_equal(cls, field1: pw.Field, field2: pw.Field) -> bool:
        return cls(field1) != cls(field2)


class FieldSerializer(ColumnSerializer):
    FIELD_MODULES_MAP = {
        "ArrayField": "pw_pext",
        "BinaryJSONField": "pw_pext",
        "DateTimeTZField": "pw_pext",
        "HStoreField": "pw_pext",
        "IntervalField": "pw_pext",
        "JSONField": "pw_pext",
        "TSVectorField": "pw_pext",
    }

    def __init__(self, field: pw.Field) -> None:
        self.field_comparer = FieldComparer(field)
        super(FieldSerializer, self).__init__(
            field.name,
            self.field_comparer.field_type,
            field.field_type,
            field.null,
            primary_key=field.primary_key,
            column_name=field.column_name,
            index=field.index,
            unique=field.unique,
            extra_parameters={},
        )
        if field.default is not None and not callable(field.default):
            self.default = repr(field.default)

        self.extra_parameters.update(self.field_comparer.get_field_params())

        self.rel_model = None
        self.related_name = None
        self.to_field = None

        if isinstance(field, pw.ForeignKeyField):
            self.to_field = field.rel_field.name
            self.related_name = field.backref
            self.rel_model = "migrator.state['%s']" % field.rel_model._meta.name

    def get_field_parameters(self) -> dict[str, Any]:
        params = super(FieldSerializer, self).get_field_parameters()
        # original method put value from default in constraints so override this logic
        params.pop("constraints", None)
        field = self.field_comparer.field
        if field.constraints:
            default_constraint = get_default_constraint(field)
            if default_constraint is not None:
                params["constraints"] = '[pw.SQL("DEFAULT %s")]' % default_constraint.value.replace('"', '\\"')
        if self.default is not None:
            params["default"] = self.default
        return params

    def serialize(self, space=" ") -> str:
        # Generate the field definition for this column.
        field = self.get_field()
        module = self.FIELD_MODULES_MAP.get(self.field_class.__name__, "pw")
        name, _, field = [s and s.strip() for s in field.partition("=")]
        return "{name}{space}={space}{module}.{field}".format(name=name, field=field, space=space, module=module)

    @classmethod
    def to_code(cls, field, space=True) -> str:
        serializer = cls(field)
        return serializer.serialize(" " if space else "")


class IndexMeta(NamedTuple):
    model: str
    fields: tuple[str, ...]
    name: str
    unique: bool = False
    where: str | None = None


def resolve_field(model_cls: pw.Model, field: str) -> pw.Field:
    _field = model_cls._meta.combined.get(field, None)
    if _field is None:
        raise ValueError(f"{model_cls} does not have '{field}' field.")
    return _field


class IndexMetaExtractor:
    def __init__(self, model_cls: ModelCls, index_obj: ModelIndex) -> None:
        self.model_cls = model_cls
        self.index_obj = index_obj

    def resolve_where(self, where: pw.Node | None) -> None | str:
        if isinstance(where, pw.SQL):
            if where.params is not None:
                raise NotImplementedError(
                    "SQL object with params for where condition is not suported. Use SQL object without params instead."
                )
            return where.sql
        if where is None:
            return None
        raise NotImplementedError(
            f"{type(where)} for where condition is not suported. Use SQL object without params instead."
        )

    def validate_fields(self, model_index: pw.ModelIndex) -> None:
        for f in model_index._expressions:
            if not isinstance(f, pw.Field):
                raise NotImplementedError(f"{type(f)} for ModelIndex.field is not suported. Use Field object instead.")

    def serialize(self) -> IndexMeta:
        model_index = self.index_obj
        self.validate_fields(model_index)
        return IndexMeta(
            self.model_cls._meta.name,
            tuple(f.name for f in model_index._expressions),
            unique=model_index._unique,
            where=self.resolve_where(model_index._where),
            name=model_index._name,
        )


def extract_index_meta(model_cls: ModelCls) -> list[IndexMeta]:
    rebuild_indexes(model_cls)
    indexes = indexes_state(model_cls).values()
    return [IndexMetaExtractor(model_cls, i).serialize() for i in indexes]


def rebuild_indexes(model_cls: ModelCls) -> None:
    def resolve_fields(fields: Sequence[Any]) -> tuple[str, ...]:
        _fields = []
        for field in fields:
            if isinstance(field, str):
                field = resolve_field(model_cls, field)
            if isinstance(field, pw.Field):
                _fields.append(field)
            else:
                raise NotImplementedError
        return tuple(_fields)

    for index_obj in model_cls._meta.indexes:
        # Advanced Indexes
        # https://docs.peewee-orm.com/en/latest/peewee/models.html#advanced-index-creation
        if isinstance(index_obj, pw.ModelIndex):
            indexes_state(model_cls)[index_obj._name] = index_obj

        # Multi-column indexes
        # https://docs.peewee-orm.com/en/latest/peewee/models.html#multi-column-indexes
        elif isinstance(index_obj, (list, tuple)):
            fields, unique = index_obj
            model_index = ModelIndex(model_cls, resolve_fields(fields), unique=unique)
            indexes_state(model_cls)[model_index._name] = model_index
        else:
            raise NotImplementedError(
                f"{type(index_obj)} as Index is not suported. Use ModelIndex, list or tuple instead."
            )
    model_cls._meta.indexes = []


def diff_indexes_from_meta(current: ModelCls, prev: ModelCls) -> tuple[list[str], list[str]]:
    create_changes = []
    drop_changes = []
    current_indexes = extract_index_meta(current)
    prev_indexes = extract_index_meta(prev)

    for index in set(current_indexes) - set(prev_indexes):
        create_changes.append(add_index(index))
    for index in set(prev_indexes) - set(current_indexes):
        drop_changes.append(drop_index(prev, index.name))
    return create_changes, drop_changes


def diff_one(current: ModelCls, prev: ModelCls) -> list[str]:
    """Find difference between given peewee models."""
    changes = []

    fields1 = current._meta.fields
    fields2 = prev._meta.fields

    if current._meta.table_name != prev._meta.table_name:
        changes.append(rename_table(prev, current._meta.table_name))

    create_index_changes, drop_index_changes = diff_indexes_from_meta(current, prev)

    # Drop non-field indexes before dropping and creating fields
    changes.extend(drop_index_changes)

    # Add fields
    names1 = set(fields1) - set(fields2)
    if names1:
        fields = [fields1[name] for name in names1]
        changes.append(add_fields(current, *fields))

    # Drop fields
    names2 = set(fields2) - set(fields1)
    if names2:
        changes.append(remove_fields(current, *names2))

    # Change fields
    fields_ = []
    for name in set(fields1) - names1 - names2:
        field1, field2 = fields1[name], fields2[name]
        if FieldComparer.not_equal(field1, field2):
            fields_.append(field1)

    if fields_:
        changes.append(change_fields(current, *fields_))

    # Create non-field indexes after dropping and creating fields
    changes.extend(create_index_changes)

    return changes


def diff_many(current_models, prev_models, reverse=False):
    """Calculate changes for migrations from models2 to models1."""
    if reverse:
        prev_models, current_models = current_models, prev_models
    current_models = pw.sort_models(current_models)
    prev_models = pw.sort_models(prev_models)

    if reverse:
        current_models = reversed(current_models)
        prev_models = reversed(prev_models)

    current_models = collections.OrderedDict([(m._meta.name, m) for m in current_models])
    prev_models = collections.OrderedDict([(m._meta.name, m) for m in prev_models])

    changes = []

    for name, current_model in current_models.items():
        # Add new models
        if name not in prev_models:
            index_meta = extract_index_meta(current_models[name])
            changes.append(create_model(current_models[name]))
            for i in index_meta:
                changes.append(add_index(i))
        # Change existing models
        else:
            prev_model = prev_models[name]
            changes += diff_one(current_model, prev_model)

    # Remove models
    for name in [m for m in prev_models if m not in current_models]:
        changes.append(remove_model(prev_models[name]))

    return changes


def model_to_code(Model) -> str:
    template = """class {classname}(pw.Model):
{fields}

{meta}
"""
    fields = INDENT + NEWLINE.join(
        [
            FieldSerializer.to_code(field)
            for field in Model._meta.sorted_fields
            if not (isinstance(field, pw.PrimaryKeyField) and field.name == "id")
        ]
    )
    meta = INDENT + NEWLINE.join(
        filter(
            None,
            [
                "class Meta:",
                INDENT + 'table_name = "%s"' % Model._meta.table_name,
                (INDENT + 'schema = "%s"' % Model._meta.schema) if Model._meta.schema else "",
                (INDENT + "primary_key = pw.CompositeKey{0}".format(Model._meta.primary_key.field_names))
                if isinstance(Model._meta.primary_key, pw.CompositeKey)
                else "",
            ],
        )
    )

    return template.format(classname=Model.__name__, fields=fields, meta=meta)


def create_model(model: ModelCls) -> str:
    return "@migrator.create_model\n" + model_to_code(model)


def remove_model(model: ModelCls) -> str:
    return "migrator.remove_model('%s')" % model._meta.name


def add_fields(model: ModelCls, *fields) -> str:
    return "migrator.add_fields(%s'%s', %s)" % (
        NEWLINE,
        model._meta.name,
        NEWLINE + ("," + NEWLINE).join([FieldSerializer.to_code(field, False) for field in fields]),
    )


def remove_fields(model: ModelCls, *fields) -> str:
    return "migrator.remove_fields('%s', %s)" % (model._meta.name, ", ".join(map(repr, fields)))


def change_fields(model: ModelCls, *fields) -> str:
    return "migrator.change_fields('%s', %s)" % (
        model._meta.name,
        ("," + NEWLINE).join([FieldSerializer.to_code(f, False) for f in fields]),
    )


def change_not_null(model: ModelCls, name, null) -> str:
    operation = "drop_not_null" if null else "add_not_null"
    return "migrator.%s('%s', %s)" % (operation, model._meta.name, repr(name))


def add_index(index_meta: IndexMeta) -> str:
    operation = "add_index"

    _where = ", where=pw.SQL(%s)" % repr(index_meta.where) if index_meta.where else ""
    _unique = f", unique={index_meta.unique}" if index_meta.unique else ""
    _fields = ", ".join(map(repr, index_meta.fields))
    return f"migrator.{operation}('{index_meta.model}', {_fields}, name='{index_meta.name}'{_unique}{_where})"


def drop_index(model: ModelCls, name: str) -> str:
    operation = "drop_index"
    return "migrator.%s('%s', '%s')" % (operation, model._meta.name, name)


def rename_table(model: ModelCls, new_name: str) -> str:
    operation = "rename_table"
    return "migrator.%s('%s', '%s')" % (operation, model._meta.name, new_name)
