"""Tests for ``mgf.alembic._drift`` — migration drift detector (v0.4.0)."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from sqlalchemy import (
    Column,
    Integer,
    MetaData,
    String,
    Table,
    create_engine,
)

from mgf.alembic import DriftReport, detect_drift

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# DriftReport dataclass
# ---------------------------------------------------------------------------


class TestDriftReport:
    def test_empty_report_is_clean(self) -> None:
        report = DriftReport()
        assert report.is_clean is True
        assert report.all_differences == ()
        assert "No drift detected" in report.summary()

    def test_missing_tables_makes_report_dirty(self) -> None:
        report = DriftReport(missing_tables=("users",))
        assert report.is_clean is False
        assert report.all_differences == (("missing_table", "users"),)
        assert "1 difference" in report.summary()
        assert "users" in report.summary()
        assert "alembic revision --autogenerate" in report.summary()

    def test_multiple_categories(self) -> None:
        report = DriftReport(
            missing_tables=("a",),
            extra_columns=("users.legacy_field",),
            type_mismatches=("orders.total",),
        )
        assert len(report.all_differences) == 3
        cats = [c for c, _ in report.all_differences]
        assert "missing_table" in cats
        assert "extra_column" in cats
        assert "type_mismatch" in cats


# ---------------------------------------------------------------------------
# detect_drift — against in-memory SQLite
# ---------------------------------------------------------------------------


def _clean_metadata() -> MetaData:
    md = MetaData()
    Table(
        "users",
        md,
        Column("id", Integer, primary_key=True),
        Column("email", String(255), nullable=False),
    )
    Table(
        "orders",
        md,
        Column("id", Integer, primary_key=True),
        Column("user_id", Integer, nullable=False),
    )
    return md


class TestDetectDriftClean:
    def test_clean_when_db_matches_metadata(self, tmp_path: Path) -> None:
        db_url = f"sqlite:///{tmp_path / 'clean.db'}"
        md = _clean_metadata()

        # Bootstrap the DB to match the metadata.
        engine = create_engine(db_url)
        md.create_all(engine)
        engine.dispose()

        report = detect_drift(target_metadata=md, url=db_url)
        assert report.is_clean, report.summary()

    def test_ignores_alembic_version_table(self, tmp_path: Path) -> None:
        # The alembic_version bookkeeping table should NOT be reported
        # as drift, even though it's not in target_metadata.
        db_url = f"sqlite:///{tmp_path / 'with_alembic.db'}"
        md = _clean_metadata()

        engine = create_engine(db_url)
        md.create_all(engine)
        # Manually create the alembic_version table.
        with engine.begin() as conn:
            from sqlalchemy import text

            conn.execute(
                text(
                    "CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY)",
                )
            )
        engine.dispose()

        report = detect_drift(target_metadata=md, url=db_url)
        assert report.is_clean, report.summary()
        # No 'extra_tables' entry.
        assert "alembic_version" not in str(report.extra_tables)


class TestDetectDriftDirty:
    def test_missing_table_detected(self, tmp_path: Path) -> None:
        # DB has only `users`; metadata has `users` + `orders`.
        db_url = f"sqlite:///{tmp_path / 'missing_table.db'}"
        partial_md = MetaData()
        Table(
            "users",
            partial_md,
            Column("id", Integer, primary_key=True),
            Column("email", String(255), nullable=False),
        )
        engine = create_engine(db_url)
        partial_md.create_all(engine)
        engine.dispose()

        full_md = _clean_metadata()  # has users + orders
        report = detect_drift(target_metadata=full_md, url=db_url)
        assert not report.is_clean
        assert "orders" in report.missing_tables

    def test_missing_column_detected(self, tmp_path: Path) -> None:
        # DB has `users(id, email)`; metadata adds `users.last_login_at`.
        db_url = f"sqlite:///{tmp_path / 'missing_col.db'}"
        old_md = MetaData()
        Table(
            "users",
            old_md,
            Column("id", Integer, primary_key=True),
            Column("email", String(255), nullable=False),
        )
        engine = create_engine(db_url)
        old_md.create_all(engine)
        engine.dispose()

        new_md = MetaData()
        Table(
            "users",
            new_md,
            Column("id", Integer, primary_key=True),
            Column("email", String(255), nullable=False),
            Column("last_login_at", String(50)),
        )
        report = detect_drift(target_metadata=new_md, url=db_url)
        assert not report.is_clean
        # The drift report contains an entry for users.last_login_at.
        all_details = [d for _, d in report.all_differences]
        assert any("last_login_at" in d for d in all_details)

    def test_summary_includes_remediation(self, tmp_path: Path) -> None:
        db_url = f"sqlite:///{tmp_path / 'remediation.db'}"
        partial_md = MetaData()
        engine = create_engine(db_url)
        partial_md.create_all(engine)
        engine.dispose()

        full_md = _clean_metadata()
        report = detect_drift(target_metadata=full_md, url=db_url)
        summary = report.summary()
        # The summary tells the consumer how to fix.
        assert "alembic revision --autogenerate" in summary


class TestDetectDriftMasksCredentialsOnError:
    """SH-01: a connection/parse error must not leak the URL password.

    detect_drift wraps both engine construction and connect in
    _safe_error_context — so even though the exception TYPE propagates
    unchanged, the embedded password is masked in the message.
    """

    def test_password_masked_in_connection_error(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.alembic import _drift

        url = "postgresql://app:SUPERSECRETPW@db.internal:5432/prod"

        def _boom(_url: str, *a: object, **k: object) -> object:
            # Simulate SQLAlchemy surfacing the raw URL in the error.
            raise RuntimeError(f"could not connect using {_url}")

        monkeypatch.setattr(_drift, "create_engine", _boom)

        with pytest.raises(RuntimeError) as excinfo:
            detect_drift(target_metadata=MetaData(), url=url)

        msg = str(excinfo.value)
        assert "SUPERSECRETPW" not in msg, f"password leaked: {msg}"
        assert "***" in msg or "app:" not in msg  # masked form present
