# mypy: allow-untyped-defs, allow-untyped-calls, allow-incomplete-defs

from __future__ import annotations

from collections.abc import Collection
from collections.abc import Mapping
import logging
from typing import Any
from typing import cast
from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union

from sqlalchemy import schema as sa_schema
from sqlalchemy import text
from sqlalchemy.sql import expression
from sqlalchemy.sql.schema import ForeignKeyConstraint
from sqlalchemy.sql.schema import Index
from sqlalchemy.sql.schema import UniqueConstraint

from .util import _InspectorConv
from ... import util
from ...ddl._autogen import is_index_sig
from ...ddl._autogen import is_uq_sig
from ...operations import ops
from ...util import PriorityDispatchResult
from ...util import sqla_compat

if TYPE_CHECKING:
    from sqlalchemy.engine.interfaces import ReflectedForeignKeyConstraint
    from sqlalchemy.engine.interfaces import ReflectedIndex
    from sqlalchemy.engine.interfaces import ReflectedUniqueConstraint
    from sqlalchemy.sql.elements import quoted_name
    from sqlalchemy.sql.elements import TextClause
    from sqlalchemy.sql.schema import Column
    from sqlalchemy.sql.schema import Table

    from ...autogenerate.api import AutogenContext
    from ...ddl._autogen import _constraint_sig
    from ...ddl.impl import DefaultImpl
    from ...operations.ops import AlterColumnOp
    from ...operations.ops import ModifyTableOps
    from ...runtime.plugins import Plugin

_C = TypeVar("_C", bound=Union[UniqueConstraint, ForeignKeyConstraint, Index])


log = logging.getLogger(__name__)


def _compare_indexes_and_uniques(
    autogen_context: AutogenContext,
    modify_ops: ModifyTableOps,
    schema: str | None,
    tname: quoted_name | str,
    conn_table: Table | None,
    metadata_table: Table | None,
) -> PriorityDispatchResult:
    inspector = autogen_context.inspector
    is_create_table = conn_table is None
    is_drop_table = metadata_table is None
    impl = autogen_context.migration_context.impl

    # 1a. get raw indexes and unique constraints from metadata ...
    if metadata_table is not None:
        # unique constraints can't be set on columns, are all table bound.
        metadata_unique_constraints = {
            uq
            for uq in metadata_table.constraints
            if isinstance(uq, sa_schema.UniqueConstraint)
        }
        metadata_indexes = set(metadata_table.indexes)
    else:
        metadata_unique_constraints = set()
        metadata_indexes = set()

    conn_uniques: Collection[UniqueConstraint] = frozenset()
    conn_indexes: Collection[Index] = frozenset()

    supports_unique_constraints = False

    unique_constraints_duplicate_unique_indexes = False

    if conn_table is not None:
        conn_uniques_reflected: Collection[ReflectedUniqueConstraint] = (
            frozenset()
        )
        conn_indexes_reflected: Collection[ReflectedIndex] = frozenset()

        # 1b. ... and from connection, if the table exists
        try:
            conn_uniques_reflected = _InspectorConv(
                inspector
            ).get_unique_constraints(tname, schema=schema)

            supports_unique_constraints = True
        except NotImplementedError:
            pass
        except TypeError:
            # number of arguments is off for the base
            # method in SQLAlchemy due to the cache decorator
            # not being present
            pass
        else:
            conn_uniques_reflected = [
                uq
                for uq in conn_uniques_reflected
                if autogen_context.run_name_filters(
                    uq["name"],
                    "unique_constraint",
                    {"table_name": tname, "schema_name": schema},
                )
            ]
            for uq in conn_uniques_reflected:
                if uq.get("duplicates_index"):
                    unique_constraints_duplicate_unique_indexes = True
        try:
            conn_indexes_reflected = _InspectorConv(inspector).get_indexes(
                tname, schema=schema
            )
        except NotImplementedError:
            pass
        else:
            conn_indexes_reflected = [
                ix
                for ix in conn_indexes_reflected
                if autogen_context.run_name_filters(
                    ix["name"],
                    "index",
                    {"table_name": tname, "schema_name": schema},
                )
            ]

        # 2. convert conn-level objects from raw inspector records
        # into schema objects
        if is_drop_table:
            # for DROP TABLE uniques are inline, don't need them
            conn_uniques = set()
        else:
            conn_uniques = {
                _make_unique_constraint(impl, uq_def, conn_table)
                for uq_def in conn_uniques_reflected
            }

        conn_indexes = {
            index
            for index in (
                _make_index(impl, ix, conn_table)
                for ix in conn_indexes_reflected
            )
            if index is not None
        }

    # 2a. if the dialect dupes unique indexes as unique constraints
    # (mysql and oracle), correct for that

    if unique_constraints_duplicate_unique_indexes:
        _correct_for_uq_duplicates_uix(
            conn_uniques,
            conn_indexes,
            metadata_unique_constraints,
            metadata_indexes,
            autogen_context.dialect,
            impl,
        )

    # 3. give the dialect a chance to omit indexes and constraints that
    # we know are either added implicitly by the DB or that the DB
    # can't accurately report on
    impl.correct_for_autogen_constraints(
        conn_uniques,  # type: ignore[arg-type]
        conn_indexes,  # type: ignore[arg-type]
        metadata_unique_constraints,
        metadata_indexes,
    )

    # 4. organize the constraints into "signature" collections, the
    # _constraint_sig() objects provide a consistent facade over both
    # Index and UniqueConstraint so we can easily work with them
    # interchangeably
    metadata_unique_constraints_sig = {
        impl._create_metadata_constraint_sig(uq)
        for uq in metadata_unique_constraints
    }

    metadata_indexes_sig = {
        impl._create_metadata_constraint_sig(ix) for ix in metadata_indexes
    }

    conn_unique_constraints = {
        impl._create_reflected_constraint_sig(uq) for uq in conn_uniques
    }

    conn_indexes_sig = {
        impl._create_reflected_constraint_sig(ix) for ix in conn_indexes
    }

    # 5. index things by name, for those objects that have names
    metadata_names = {
        cast(str, c.md_name_to_sql_name(autogen_context)): c
        for c in metadata_unique_constraints_sig.union(metadata_indexes_sig)
        if c.is_named
    }

    conn_uniques_by_name: dict[
        sqla_compat._ConstraintName,
        _constraint_sig[sa_schema.UniqueConstraint],
    ]
    conn_indexes_by_name: dict[
        sqla_compat._ConstraintName, _constraint_sig[sa_schema.Index]
    ]

    conn_uniques_by_name = {c.name: c for c in conn_unique_constraints}
    conn_indexes_by_name = {c.name: c for c in conn_indexes_sig}
    conn_names = {
        c.name: c
        for c in conn_unique_constraints.union(conn_indexes_sig)
        if sqla_compat.constraint_name_string(c.name)
    }

    doubled_constraints = {
        name: (conn_uniques_by_name[name], conn_indexes_by_name[name])
        for name in set(conn_uniques_by_name).intersection(
            conn_indexes_by_name
        )
    }

    # 6. index things by "column signature", to help with unnamed unique
    # constraints.
    conn_uniques_by_sig = {uq.unnamed: uq for uq in conn_unique_constraints}
    metadata_uniques_by_sig = {
        uq.unnamed: uq for uq in metadata_unique_constraints_sig
    }
    unnamed_metadata_uniques = {
        uq.unnamed: uq
        for uq in metadata_unique_constraints_sig
        if not sqla_compat._constraint_is_named(
            uq.const, autogen_context.dialect
        )
    }

    # assumptions:
    # 1. a unique constraint or an index from the connection *always*
    #    has a name.
    # 2. an index on the metadata side *always* has a name.
    # 3. a unique constraint on the metadata side *might* have a name.
    # 4. The backend may double up indexes as unique constraints and
    #    vice versa (e.g. MySQL, Postgresql)

    def obj_added(
        obj: (
            _constraint_sig[sa_schema.UniqueConstraint]
            | _constraint_sig[sa_schema.Index]
        ),
    ):
        if is_index_sig(obj):
            if autogen_context.run_object_filters(
                obj.const, obj.name, "index", False, None
            ):
                modify_ops.ops.append(ops.CreateIndexOp.from_index(obj.const))
                log.info(
                    "Detected added index %r on '%s'",
                    obj.name,
                    obj.column_names,
                )
        elif is_uq_sig(obj):
            if not supports_unique_constraints:
                # can't report unique indexes as added if we don't
                # detect them
                return
            if is_create_table or is_drop_table:
                # unique constraints are created inline with table defs
                return
            if autogen_context.run_object_filters(
                obj.const, obj.name, "unique_constraint", False, None
            ):
                modify_ops.ops.append(
                    ops.AddConstraintOp.from_constraint(obj.const)
                )
                log.info(
                    "Detected added unique constraint %r on '%s'",
                    obj.name,
                    obj.column_names,
                )
        else:
            assert False

    def obj_removed(
        obj: (
            _constraint_sig[sa_schema.UniqueConstraint]
            | _constraint_sig[sa_schema.Index]
        ),
    ):
        if is_index_sig(obj):
            if obj.is_unique and not supports_unique_constraints:
                # many databases double up unique constraints
                # as unique indexes.  without that list we can't
                # be sure what we're doing here
                return

            if autogen_context.run_object_filters(
                obj.const, obj.name, "index", True, None
            ):
                modify_ops.ops.append(ops.DropIndexOp.from_index(obj.const))
                log.info("Detected removed index %r on %r", obj.name, tname)
        elif is_uq_sig(obj):
            if is_create_table or is_drop_table:
                # if the whole table is being dropped, we don't need to
                # consider unique constraint separately
                return
            if autogen_context.run_object_filters(
                obj.const, obj.name, "unique_constraint", True, None
            ):
                modify_ops.ops.append(
                    ops.DropConstraintOp.from_constraint(obj.const)
                )
                log.info(
                    "Detected removed unique constraint %r on %r",
                    obj.name,
                    tname,
                )
        else:
            assert False

    def obj_changed(
        old: (
            _constraint_sig[sa_schema.UniqueConstraint]
            | _constraint_sig[sa_schema.Index]
        ),
        new: (
            _constraint_sig[sa_schema.UniqueConstraint]
            | _constraint_sig[sa_schema.Index]
        ),
        msg: str,
    ):
        if is_index_sig(old):
            assert is_index_sig(new)

            if autogen_context.run_object_filters(
                new.const, new.name, "index", False, old.const
            ):
                log.info(
                    "Detected changed index %r on %r: %s", old.name, tname, msg
                )
                modify_ops.ops.append(ops.DropIndexOp.from_index(old.const))
                modify_ops.ops.append(ops.CreateIndexOp.from_index(new.const))
        elif is_uq_sig(old):
            assert is_uq_sig(new)

            if autogen_context.run_object_filters(
                new.const, new.name, "unique_constraint", False, old.const
            ):
                log.info(
                    "Detected changed unique constraint %r on %r: %s",
                    old.name,
                    tname,
                    msg,
                )
                modify_ops.ops.append(
                    ops.DropConstraintOp.from_constraint(old.const)
                )
                modify_ops.ops.append(
                    ops.AddConstraintOp.from_constraint(new.const)
                )
        else:
            assert False

    for removed_name in sorted(set(conn_names).difference(metadata_names)):
        conn_obj = conn_names[removed_name]
        if (
            is_uq_sig(conn_obj)
            and conn_obj.unnamed in unnamed_metadata_uniques
        ):
            continue
        elif removed_name in doubled_constraints:
            conn_uq, conn_idx = doubled_constraints[removed_name]
            if (
                all(
                    conn_idx.unnamed != meta_idx.unnamed
                    for meta_idx in metadata_indexes_sig
                )
                and conn_uq.unnamed not in metadata_uniques_by_sig
            ):
                obj_removed(conn_uq)
                obj_removed(conn_idx)
        else:
            obj_removed(conn_obj)

    for existing_name in sorted(set(metadata_names).intersection(conn_names)):
        metadata_obj = metadata_names[existing_name]

        if existing_name in doubled_constraints:
            conn_uq, conn_idx = doubled_constraints[existing_name]
            if is_index_sig(metadata_obj):
                conn_obj = conn_idx
            else:
                conn_obj = conn_uq
        else:
            conn_obj = conn_names[existing_name]

        if type(conn_obj) != type(metadata_obj):
            obj_removed(conn_obj)
            obj_added(metadata_obj)
        else:
            # TODO: for plugins, let's do is_index_sig / is_uq_sig
            # here so we know index or unique, then
            # do a sub-dispatch,
            # autogen_context.comparators.dispatch("index")
            # or
            # autogen_context.comparators.dispatch("unique_constraint")
            #
            comparison = metadata_obj.compare_to_reflected(conn_obj)

            if comparison.is_different:
                # constraint are different
                obj_changed(conn_obj, metadata_obj, comparison.message)
            elif comparison.is_skip:
                # constraint cannot be compared, skip them
                thing = (
                    "index" if is_index_sig(conn_obj) else "unique constraint"
                )
                log.info(
                    "Cannot compare %s %r, assuming equal and skipping. %s",
                    thing,
                    conn_obj.name,
                    comparison.message,
                )
            else:
                # constraint are equal
                assert comparison.is_equal

    for added_name in sorted(set(metadata_names).difference(conn_names)):
        obj = metadata_names[added_name]
        obj_added(obj)

    for uq_sig in unnamed_metadata_uniques:
        if uq_sig not in conn_uniques_by_sig:
            obj_added(unnamed_metadata_uniques[uq_sig])

    return PriorityDispatchResult.CONTINUE


def _correct_for_uq_duplicates_uix(
    conn_unique_constraints,
    conn_indexes,
    metadata_unique_constraints,
    metadata_indexes,
    dialect,
    impl,
):
    # dedupe unique indexes vs. constraints, since MySQL / Oracle
    # doesn't really have unique constraints as a separate construct.
    # but look in the metadata and try to maintain constructs
    # that already seem to be defined one way or the other
    # on that side.  This logic was formerly local to MySQL dialect,
    # generalized to Oracle and others. See #276

    # resolve final rendered name for unique constraints defined in the
    # metadata.   this includes truncation of long names.  naming convention
    # names currently should already be set as cons.name, however leave this
    # to the sqla_compat to decide.
    metadata_cons_names = [
        (sqla_compat._get_constraint_final_name(cons, dialect), cons)
        for cons in metadata_unique_constraints
    ]

    metadata_uq_names = {
        name for name, cons in metadata_cons_names if name is not None
    }

    unnamed_metadata_uqs = {
        impl._create_metadata_constraint_sig(cons).unnamed
        for name, cons in metadata_cons_names
        if name is None
    }

    metadata_ix_names = {
        sqla_compat._get_constraint_final_name(cons, dialect)
        for cons in metadata_indexes
        if cons.unique
    }

    # for reflection side, names are in their final database form
    # already since they're from the database
    conn_ix_names = {cons.name: cons for cons in conn_indexes if cons.unique}

    uqs_dupe_indexes = {
        cons.name: cons
        for cons in conn_unique_constraints
        if cons.info["duplicates_index"]
    }

    for overlap in uqs_dupe_indexes:
        if overlap not in metadata_uq_names:
            if (
                impl._create_reflected_constraint_sig(
                    uqs_dupe_indexes[overlap]
                ).unnamed
                not in unnamed_metadata_uqs
            ):
                conn_unique_constraints.discard(uqs_dupe_indexes[overlap])
        elif overlap not in metadata_ix_names:
            conn_indexes.discard(conn_ix_names[overlap])


_IndexColumnSortingOps: Mapping[str, Any] = util.immutabledict(
    {
        "asc": expression.asc,
        "desc": expression.desc,
        "nulls_first": expression.nullsfirst,
        "nulls_last": expression.nullslast,
        "nullsfirst": expression.nullsfirst,  # 1_3 name
        "nullslast": expression.nullslast,  # 1_3 name
    }
)


def _make_index(
    impl: DefaultImpl, params: ReflectedIndex, conn_table: Table
) -> Index | None:
    exprs: list[Column[Any] | TextClause] = []
    sorting = params.get("column_sorting")

    for num, col_name in enumerate(params["column_names"]):
        item: Column[Any] | TextClause
        if col_name is None:
            assert "expressions" in params
            name = params["expressions"][num]
            item = text(name)
        else:
            name = col_name
            item = conn_table.c[col_name]
        if sorting and name in sorting:
            for operator in sorting[name]:
                if operator in _IndexColumnSortingOps:
                    item = _IndexColumnSortingOps[operator](item)
        exprs.append(item)
    ix = sa_schema.Index(
        params["name"],
        *exprs,
        unique=params["unique"],
        _table=conn_table,
        **impl.adjust_reflected_dialect_options(params, "index"),
    )
    if "duplicates_constraint" in params:
        ix.info["duplicates_constraint"] = params["duplicates_constraint"]
    return ix


def _make_unique_constraint(
    impl: DefaultImpl, params: ReflectedUniqueConstraint, conn_table: Table
) -> UniqueConstraint:
    uq = sa_schema.UniqueConstraint(
        *[conn_table.c[cname] for cname in params["column_names"]],
        name=params["name"],
        **impl.adjust_reflected_dialect_options(params, "unique_constraint"),
    )
    if "duplicates_index" in params:
        uq.info["duplicates_index"] = params["duplicates_index"]

    return uq


def _make_foreign_key(
    params: ReflectedForeignKeyConstraint, conn_table: Table
) -> ForeignKeyConstraint:
    tname = params["referred_table"]
    if params["referred_schema"]:
        tname = "%s.%s" % (params["referred_schema"], tname)

    options = params.get("options", {})

    const = sa_schema.ForeignKeyConstraint(
        [conn_table.c[cname] for cname in params["constrained_columns"]],
        ["%s.%s" % (tname, n) for n in params["referred_columns"]],
        onupdate=options.get("onupdate"),
        ondelete=options.get("ondelete"),
        deferrable=options.get("deferrable"),
        initially=options.get("initially"),
        name=params["name"],
    )

    referred_schema = params["referred_schema"]
    referred_table = params["referred_table"]

    remote_table_key = sqla_compat._get_table_key(
        referred_table, referred_schema
    )
    if remote_table_key not in conn_table.metadata:
        # create a placeholder table
        sa_schema.Table(
            referred_table,
            conn_table.metadata,
            schema=(
                referred_schema
                if referred_schema is not None
                else sa_schema.BLANK_SCHEMA
            ),
            *[
                sa_schema.Column(remote, conn_table.c[local].type)
                for local, remote in zip(
                    params["constrained_columns"], params["referred_columns"]
                )
            ],
            info={"alembic_placeholder": True},
        )
    elif conn_table.metadata.tables[remote_table_key].info.get(
        "alembic_placeholder"
    ):
        # table exists and is a placeholder; ensure needed columns are present
        placeholder_table = conn_table.metadata.tables[remote_table_key]
        for local, remote in zip(
            params["constrained_columns"], params["referred_columns"]
        ):
            if remote not in placeholder_table.c:
                placeholder_table.append_column(
                    sa_schema.Column(remote, conn_table.c[local].type)
                )

    # needed by 0.7
    conn_table.append_constraint(const)
    return const


def _compare_foreign_keys(
    autogen_context: AutogenContext,
    modify_table_ops: ModifyTableOps,
    schema: str | None,
    tname: quoted_name | str,
    conn_table: Table,
    metadata_table: Table,
) -> PriorityDispatchResult:
    # if we're doing CREATE TABLE, all FKs are created
    # inline within the table def

    if conn_table is None or metadata_table is None:
        return PriorityDispatchResult.CONTINUE

    inspector = autogen_context.inspector
    # fk constraints can be set on columns, but they appear also on
    # the table constraint list
    metadata_fks = {
        fk
        for fk in metadata_table.constraints
        if isinstance(fk, sa_schema.ForeignKeyConstraint)
    }

    conn_fks_list = [
        fk
        for fk in _InspectorConv(inspector).get_foreign_keys(
            tname, schema=schema
        )
        if autogen_context.run_name_filters(
            fk["name"],
            "foreign_key_constraint",
            {"table_name": tname, "schema_name": schema},
        )
    ]

    conn_fks = {
        _make_foreign_key(const, conn_table) for const in conn_fks_list
    }

    impl = autogen_context.migration_context.impl

    # give the dialect a chance to correct the FKs to match more
    # closely
    autogen_context.migration_context.impl.correct_for_autogen_foreignkeys(
        conn_fks, metadata_fks
    )

    metadata_fks_sig = {
        impl._create_metadata_constraint_sig(fk) for fk in metadata_fks
    }

    conn_fks_sig = {
        impl._create_reflected_constraint_sig(fk) for fk in conn_fks
    }

    # check if reflected FKs include options, indicating the backend
    # can reflect FK options
    if conn_fks_list and "options" in conn_fks_list[0]:
        conn_fks_by_sig = {c.unnamed: c for c in conn_fks_sig}
        metadata_fks_by_sig = {c.unnamed: c for c in metadata_fks_sig}
    else:
        # otherwise compare by sig without options added
        conn_fks_by_sig = {c.unnamed_no_options: c for c in conn_fks_sig}
        metadata_fks_by_sig = {
            c.unnamed_no_options: c for c in metadata_fks_sig
        }

    metadata_fks_by_name = {
        c.name: c for c in metadata_fks_sig if c.name is not None
    }
    conn_fks_by_name = {c.name: c for c in conn_fks_sig if c.name is not None}

    def _add_fk(obj, compare_to):
        if autogen_context.run_object_filters(
            obj.const, obj.name, "foreign_key_constraint", False, compare_to
        ):
            modify_table_ops.ops.append(
                ops.CreateForeignKeyOp.from_constraint(const.const)
            )

            log.info(
                "Detected added foreign key (%s)(%s) on table %s%s",
                ", ".join(obj.source_columns),
                ", ".join(obj.target_columns),
                "%s." % obj.source_schema if obj.source_schema else "",
                obj.source_table,
            )

    def _remove_fk(obj, compare_to):
        if autogen_context.run_object_filters(
            obj.const, obj.name, "foreign_key_constraint", True, compare_to
        ):
            modify_table_ops.ops.append(
                ops.DropConstraintOp.from_constraint(obj.const)
            )
            log.info(
                "Detected removed foreign key (%s)(%s) on table %s%s",
                ", ".join(obj.source_columns),
                ", ".join(obj.target_columns),
                "%s." % obj.source_schema if obj.source_schema else "",
                obj.source_table,
            )

    # so far it appears we don't need to do this by name at all.
    # SQLite doesn't preserve constraint names anyway

    for removed_sig in set(conn_fks_by_sig).difference(metadata_fks_by_sig):
        const = conn_fks_by_sig[removed_sig]
        if removed_sig not in metadata_fks_by_sig:
            compare_to = (
                metadata_fks_by_name[const.name].const
                if const.name and const.name in metadata_fks_by_name
                else None
            )
            _remove_fk(const, compare_to)

    for added_sig in set(metadata_fks_by_sig).difference(conn_fks_by_sig):
        const = metadata_fks_by_sig[added_sig]
        if added_sig not in conn_fks_by_sig:
            compare_to = (
                conn_fks_by_name[const.name].const
                if const.name and const.name in conn_fks_by_name
                else None
            )
            _add_fk(const, compare_to)

    return PriorityDispatchResult.CONTINUE


def _compare_nullable(
    autogen_context: AutogenContext,
    alter_column_op: AlterColumnOp,
    schema: str | None,
    tname: quoted_name | str,
    cname: quoted_name | str,
    conn_col: Column[Any],
    metadata_col: Column[Any],
) -> PriorityDispatchResult:
    metadata_col_nullable = metadata_col.nullable
    conn_col_nullable = conn_col.nullable
    alter_column_op.existing_nullable = conn_col_nullable

    if conn_col_nullable is not metadata_col_nullable:
        if (
            sqla_compat._server_default_is_computed(
                metadata_col.server_default, conn_col.server_default
            )
            and sqla_compat._nullability_might_be_unset(metadata_col)
            or (
                sqla_compat._server_default_is_identity(
                    metadata_col.server_default, conn_col.server_default
                )
            )
        ):
            log.info(
                "Ignoring nullable change on identity column '%s.%s'",
                tname,
                cname,
            )
        else:
            alter_column_op.modify_nullable = metadata_col_nullable
            log.info(
                "Detected %s on column '%s.%s'",
                "NULL" if metadata_col_nullable else "NOT NULL",
                tname,
                cname,
            )
            # column nullablity changed, no further nullable checks needed
            return PriorityDispatchResult.STOP

    return PriorityDispatchResult.CONTINUE


def setup(plugin: Plugin) -> None:
    plugin.add_autogenerate_comparator(
        _compare_indexes_and_uniques,
        "table",
        "indexes",
    )
    plugin.add_autogenerate_comparator(
        _compare_foreign_keys,
        "table",
        "foreignkeys",
    )
    plugin.add_autogenerate_comparator(
        _compare_nullable,
        "column",
        "nullable",
    )
