"""Migration drift detector — diff `Base.metadata` against live DB.

Stability tier: **experimental** (AP-11) — added in 0.4.0 to
close the A-1 federation row
(`mgf-common/docs/inprogress/pre_release_additions.md` §6.4).
PlasmaMapper + inthewords both ship many ORM models; both
need a CI-friendly drift detector.

What ships here
---------------

- :func:`detect_drift` — synchronous API. Takes
  ``target_metadata`` + a ``url``, returns a
  :class:`DriftReport` describing what's different between the
  model definitions and the live database schema. Pass a sync
  driver URL (drift detection is read-only; a sync connection is
  sufficient even for apps that run async at runtime).
- :class:`DriftReport` — frozen dataclass with categorised
  differences: missing tables, missing columns, type
  mismatches, missing indexes, missing foreign keys.

CI integration
--------------

The canonical use case is "fail the CI build when a model
changed but the migration was never autogenerated":

.. code-block:: python

    # In a CI-only test:
    def test_no_migration_drift():
        from myapp.db import Base
        report = detect_drift(
            target_metadata=Base.metadata,
            url=os.environ["DATABASE_URL"],
        )
        assert report.is_clean, report.summary()

The check is **alembic-aware**: it explicitly skips the
``alembic_version`` table (otherwise the drift report would
flag it every time, since it's not in `target_metadata`).

Cross-references
----------------

- **AP-11** — stability tiers.
- **A-1** federation row.
- `alembic.autogenerate` — what we wrap (alembic's own
  comparison machinery).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from sqlalchemy import create_engine

from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from mgf.alembic._env import _safe_error_context

if TYPE_CHECKING:
    from sqlalchemy import MetaData

_LOGGER = logging.getLogger(__name__)


__all__ = [
    "DriftReport",
    "detect_drift",
]


@dataclass(frozen=True)
class DriftReport:
    """Summary of differences between target metadata and live DB.

    Constructed by :func:`detect_drift`. Each field is a tuple
    of human-readable strings describing one drift item.

    Use ``report.is_clean`` to gate CI; iterate ``report.all_differences``
    to render a useful failure message.
    """

    missing_tables: tuple[str, ...] = field(default_factory=tuple)
    """Tables in metadata but NOT in DB. The most common drift —
    "I added a model class but forgot to autogenerate a migration."
    """

    extra_tables: tuple[str, ...] = field(default_factory=tuple)
    """Tables in DB but NOT in metadata. Indicates a removed
    model whose migration drop was missed OR an external
    schema-management tool wrote into the same DB.
    """

    missing_columns: tuple[str, ...] = field(default_factory=tuple)
    """Per-table-and-column entries for columns in metadata but
    not in DB (each formatted as ``"users.last_login_at"``).
    """

    extra_columns: tuple[str, ...] = field(default_factory=tuple)
    """Per-table-and-column entries for columns in DB but not
    in metadata.
    """

    type_mismatches: tuple[str, ...] = field(default_factory=tuple)
    """Per-table-and-column entries where the SQL type
    disagrees (e.g., model says ``Integer`` but DB has
    ``BIGINT``).
    """

    index_differences: tuple[str, ...] = field(default_factory=tuple)
    """Index-level differences (missing index, missing
    column on existing index, unique-ness mismatch).
    """

    foreign_key_differences: tuple[str, ...] = field(default_factory=tuple)
    """FK-level differences (missing FK, target mismatch,
    ondelete cascade differences).
    """

    other: tuple[str, ...] = field(default_factory=tuple)
    """Differences alembic reported but didn't categorise (rare;
    surfaces edge cases like check-constraint diffs).
    """

    @property
    def is_clean(self) -> bool:
        """True when no drift detected — every category is empty."""
        return (
            not self.missing_tables
            and not self.extra_tables
            and not self.missing_columns
            and not self.extra_columns
            and not self.type_mismatches
            and not self.index_differences
            and not self.foreign_key_differences
            and not self.other
        )

    @property
    def all_differences(self) -> tuple[tuple[str, str], ...]:
        """Flat tuple of ``(category, detail)`` for every drift item."""
        items: list[tuple[str, str]] = []
        for cat, entries in (
            ("missing_table", self.missing_tables),
            ("extra_table", self.extra_tables),
            ("missing_column", self.missing_columns),
            ("extra_column", self.extra_columns),
            ("type_mismatch", self.type_mismatches),
            ("index_diff", self.index_differences),
            ("foreign_key_diff", self.foreign_key_differences),
            ("other", self.other),
        ):
            for e in entries:
                items.append((cat, e))
        return tuple(items)

    def summary(self) -> str:
        """Human-readable multi-line summary. Designed for CI
        failure messages — pass to ``assert report.is_clean,
        report.summary()`` directly."""
        if self.is_clean:
            return "No drift detected (model metadata matches DB schema)."
        lines = [
            f"Drift detected — {len(self.all_differences)} difference(s) "
            f"between target_metadata and live DB:",
        ]
        for cat, detail in self.all_differences:
            lines.append(f"  [{cat}] {detail}")
        lines.append("")
        lines.append(
            "Run `alembic revision --autogenerate -m '<description>'` "
            "to generate the missing migration."
        )
        return "\n".join(lines)


_ALEMBIC_TABLES_TO_IGNORE = frozenset({"alembic_version"})


def _categorise_diff(diff: Any) -> tuple[str, str]:
    """Translate an alembic-autogenerate diff tuple into a
    ``(category, detail)`` pair.

    Alembic returns nested tuples describing each difference.
    The format varies by diff type — this is the canonical
    decoder.
    """
    # Alembic's compare_metadata returns nested lists / tuples.
    # Most diffs are 2-tuples: (kind, item).
    if isinstance(diff, list):
        # A grouped diff (e.g., column-level diffs for one table)
        # — we don't currently surface as a group; flatten.
        return "other", str(diff)

    if not isinstance(diff, tuple) or len(diff) < 2:
        return "other", str(diff)

    kind = diff[0]
    if kind == "add_table":
        # ("add_table", Table(...))
        return "missing_table", diff[1].name
    if kind == "remove_table":
        return "extra_table", diff[1].name
    if kind == "add_column":
        # ("add_column", schema_or_None, table_name, Column(...))
        return "missing_column", f"{diff[2]}.{diff[3].name}"
    if kind == "remove_column":
        return "extra_column", f"{diff[2]}.{diff[3].name}"
    if kind == "modify_type":
        # ("modify_type", schema, table, column, ..., existing, new)
        return "type_mismatch", f"{diff[2]}.{diff[3]}"
    if kind in {"add_index", "remove_index", "modify_index"}:
        # ("add_index", Index(...))
        return "index_diff", f"{kind}: {diff[1].name if hasattr(diff[1], 'name') else diff[1]}"
    if kind in {"add_fk", "remove_fk", "modify_fk"}:
        return "foreign_key_diff", f"{kind}: {diff[1]}"
    return "other", str(diff)


def _build_report(diffs: list[Any]) -> DriftReport:
    """Convert alembic's diff list into a categorised DriftReport."""
    by_cat: dict[str, list[str]] = {
        "missing_tables": [],
        "extra_tables": [],
        "missing_columns": [],
        "extra_columns": [],
        "type_mismatches": [],
        "index_differences": [],
        "foreign_key_differences": [],
        "other": [],
    }

    cat_to_field = {
        "missing_table": "missing_tables",
        "extra_table": "extra_tables",
        "missing_column": "missing_columns",
        "extra_column": "extra_columns",
        "type_mismatch": "type_mismatches",
        "index_diff": "index_differences",
        "foreign_key_diff": "foreign_key_differences",
        "other": "other",
    }

    for diff in diffs:
        # Alembic groups some diffs in lists (per-table column changes).
        # Unpack.
        items = diff if isinstance(diff, list) else [diff]
        for item in items:
            cat, detail = _categorise_diff(item)
            # Skip alembic's own bookkeeping table.
            if cat in ("missing_table", "extra_table") and detail in _ALEMBIC_TABLES_TO_IGNORE:
                continue
            by_cat[cat_to_field[cat]].append(detail)

    return DriftReport(
        missing_tables=tuple(by_cat["missing_tables"]),
        extra_tables=tuple(by_cat["extra_tables"]),
        missing_columns=tuple(by_cat["missing_columns"]),
        extra_columns=tuple(by_cat["extra_columns"]),
        type_mismatches=tuple(by_cat["type_mismatches"]),
        index_differences=tuple(by_cat["index_differences"]),
        foreign_key_differences=tuple(by_cat["foreign_key_differences"]),
        other=tuple(by_cat["other"]),
    )


def detect_drift(
    *,
    target_metadata: MetaData,
    url: str,
    include_object: Any = None,
) -> DriftReport:
    """Detect schema drift between target metadata and live DB.

    Synchronous API. For async URLs
    (``postgresql+asyncpg://`` etc.), use the equivalent
    asyncio shape — wrap with `asyncio.to_thread` if you
    must call from an async context.

    Parameters
    ----------
    target_metadata
        Your project's ``Base.metadata`` — the declarative-model
        truth.
    url
        The database URL. Sync-style (``postgresql+psycopg://``
        or ``sqlite:///...``). For async dialects use the
        ``+psycopg`` / sync variant for the detector pass.
    include_object
        Optional alembic ``include_object`` filter callable.
        Receives ``(object, name, type_, reflected, compare_to)``
        and returns True/False. Useful for excluding system
        tables / shadow tables. Same shape as
        ``configure_env(include_object=...)``.

    Returns
    -------
    :class:`DriftReport`
        Categorised differences. Inspect ``.is_clean`` to gate
        CI; ``.summary()`` for a multi-line failure message.

    Raises
    ------
    Exception
        Any DB connection / reflection error propagates unchanged in
        TYPE — but the embedded password is masked in the message
        first (SH-01, via :func:`_safe_error_context`); this is a
        diagnostic tool, not an error-translation seam.
    """
    # SH-01: wrap BOTH engine construction (URL-parse errors) and the
    # connect/reflect (connection errors — the common failure) so a
    # password-bearing URL never reaches logs/CI output via an exception.
    with _safe_error_context(url):
        engine = create_engine(url)
        try:
            with engine.connect() as conn:
                ctx = MigrationContext.configure(
                    connection=conn,
                    opts={
                        "compare_type": True,
                        "compare_server_default": True,
                        **({"include_object": include_object} if include_object else {}),
                    },
                )
                diffs = compare_metadata(ctx, target_metadata)
        finally:
            engine.dispose()

    report = _build_report(diffs)
    _LOGGER.info(
        "drift detection complete: %s",
        "clean" if report.is_clean else f"{len(report.all_differences)} diff(s)",
        extra={
            "event": "mgf.alembic.drift_detected",
            "is_clean": report.is_clean,
            "diff_count": len(report.all_differences),
        },
    )
    return report
