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.utils import ModelIndex, get_default_constraint, get_default_constraint_value, indexes_state

from .types import ModelCls

INDENT = "    "
NEWLINE = "\n" + INDENT
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 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


TYPE_PARAMS = {
    pw.CharField: lambda f: {"max_length": f.max_length},
    pw.DecimalField: lambda f: {
        "max_digits": f.max_digits,
        "decimal_places": f.decimal_places,
    },
}


def get_type_params(field: pw.Field) -> dict[str, Any]:
    field_type = type(field)
    params = TYPE_PARAMS.get(field_type, lambda f: {})(field)
    return params


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


def field_to_code(field, space=True) -> str:
    serializer = FieldSerializer(field)
    return serializer.serialize(" " if space else "")


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


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


def fields_not_equal(field1: pw.Field, field2: pw.Field) -> bool:
    return field_to_params(field1) != field_to_params(field2)


class FieldSerializer(ColumnSerializer):
    def __init__(self, field: pw.Field) -> None:
        self.field = field
        super(FieldSerializer, self).__init__(
            field.name,
            type(field),
            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(get_field_params(field))

        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.orm['%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)
        if self.field.constraints:
            default_constraint = get_default_constraint(self.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 = 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)


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 fields_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, model1 in current_models.items():
        if name not in prev_models:
            continue
        changes += diff_one(model1, prev_models[name])

    # Add models
    for name in [m for m in current_models if m 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))

    # 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(
        [
            field_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([field_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([field_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)
