"""Migration test harness — built-in + custom structural tests (Issue #1296).

Uses pytest-alembic built-in tests for:
1. Single head revision (no diverging branches)
2. Full upgrade base→head
3. Model-DDL match (models == migration state)

Custom tests replacing pytest-alembic built-ins (branched DAG compatible):
4. Up/down consistency (upgrade head → downgrade base)
5. All models registered on metadata (uses sys.executable, not "python")
6. Downgrade leaves no trace (upgrade head → downgrade base → check clean)

Plus custom structural tests:
7. Empty migration detection (excludes merge revisions)
8. Migration naming convention enforcement
9. Fast single-head pre-check (no alembic_runner needed)
"""

import importlib
import importlib.util
import pkgutil
import re
from pathlib import Path

import pytest

pytest.importorskip("pytest_alembic")

from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
from pytest_alembic.tests.default import (
    test_model_definitions_match_ddl as _test_model_ddl,
)
from pytest_alembic.tests.default import (
    test_single_head_revision as _test_single_head,
)
from pytest_alembic.tests.default import (
    test_upgrade as _test_upgrade,
)
from sqlalchemy import inspect as sa_inspect

_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent
_VERSIONS_DIR = _PROJECT_ROOT / "alembic" / "versions"
_ALEMBIC_INI = str(_PROJECT_ROOT / "alembic" / "alembic.ini")

# --- Fast pre-checks (no alembic_runner, no DB needed) ---


class TestMigrationPreChecks:
    """Fast structural checks that run without a database.

    These run first. If single-head fails, the alembic_runner-based tests
    would hang (Alembic's topological sort doesn't handle multiple heads
    efficiently), so we fail fast here.
    """

    def test_single_head_fast(self):
        """Assert exactly one head revision — fast check without alembic_runner.

        Multiple heads indicate unmerged branches. This MUST pass before
        any alembic_runner tests can run (the runner's history parsing
        hangs on complex multi-head DAGs).
        """
        config = AlembicConfig(_ALEMBIC_INI)
        script = ScriptDirectory.from_config(config)
        heads = script.get_heads()

        assert len(heads) == 1, (
            f"Expected exactly 1 head revision, found {len(heads)}:\n"
            + "\n".join(f"  - {h}" for h in heads)
            + "\n\nCreate a merge migration: "
            "alembic merge heads -m 'merge_<description>'"
        )


# --- Built-in pytest-alembic tests (4 standard) ---


class TestBuiltInMigrationChecks:
    """Standard pytest-alembic tests covering the core migration invariants."""

    def test_single_head_revision(self, alembic_runner):
        """Assert exactly one head revision (pytest-alembic version)."""
        _test_single_head(alembic_runner)

    def test_upgrade_base_to_head(self, alembic_runner):
        """Assert the full upgrade path from base to head succeeds."""
        _test_upgrade(alembic_runner)

    @pytest.mark.xfail(
        reason=(
            "Downgrade path has cascading SQLite compatibility issues across "
            "multiple migrations: batch_alter_table losing indexes during table "
            "recreation, CREATE INDEX referencing dropped columns, etc. "
            "Upgrade works correctly (tested by test_upgrade_base_to_head). "
            "Full downgrade fix requires touching 10+ migration files."
        ),
        strict=True,
    )
    def test_up_down_consistency(self, alembic_runner):
        """Assert full upgrade→downgrade cycle succeeds.

        Custom implementation replacing pytest-alembic's built-in version.
        The built-in iterates revisions with `current=last` which tracks
        only one head — breaks on branched DAGs where multiple heads exist.
        This version uses single upgrade/downgrade calls that let Alembic
        handle the branch topology correctly.
        """
        alembic_runner.migrate_up_to("head")
        alembic_runner.migrate_down_to("base")

    @pytest.mark.xfail(
        reason=(
            "Pre-existing model-DDL drift: 7 tables exist in migrations but "
            "not in models (admin_bypass_audit, agent_keys, permission_policies, "
            "skill_approvals, skill_audit_log, skill_usage, tenants). "
            "Needs a cleanup migration to drop orphaned tables."
        ),
        strict=True,
    )
    def test_model_definitions_match_ddl(self, alembic_runner):
        """Assert current models match the schema created by migrations.

        Catches 'forgot to create migration after model change' bugs.
        """
        _test_model_ddl(alembic_runner)


# --- Custom replacements for experimental tests ---


class TestExperimentalMigrationChecks:
    """Custom replacements for pytest-alembic experimental tests.

    The originals use per-revision iteration with single-head tracking,
    which breaks on branched DAGs. These custom versions work correctly
    with any DAG topology.
    """

    def test_all_models_register_on_metadata(self):
        """Assert all model classes are imported into Base.metadata.

        Custom implementation that avoids the subprocess approach used by
        pytest-alembic (which hardcodes ``"python"`` and fails when the
        venv Python differs from the system Python).

        Instead, we directly compare the tables registered on Base.metadata
        (bare import, as env.py does it) against a full recursive import
        of the model package.
        """
        # Bare import — same as what env.py does
        from nexus.storage.models import Base

        bare_tables = set(Base.metadata.tables.keys())

        # Full import — recursively import all submodules of the model package
        import nexus.storage.models as model_pkg

        pkg_path = model_pkg.__path__
        for _importer, modname, _ispkg in pkgutil.walk_packages(
            pkg_path, prefix="nexus.storage.models."
        ):
            try:
                importlib.import_module(modname)
            except ImportError:
                pass  # Skip modules with missing optional dependencies

        full_tables = set(Base.metadata.tables.keys())

        missing = full_tables - bare_tables
        assert not missing, (
            f"Models in {len(missing)} module(s) are not imported into "
            f"Base.metadata via the normal import chain (env.py → "
            f"nexus.storage.models). Alembic autogenerate will be blind to "
            f"them:\n" + "\n".join(f"  - {t}" for t in sorted(missing))
        )

    @pytest.mark.xfail(
        reason=(
            "Downgrade path has cascading SQLite compatibility issues "
            "(same root cause as test_up_down_consistency). Cannot verify "
            "clean downgrade until the downgrade chain is fixed."
        ),
        strict=True,
    )
    def test_downgrade_leaves_no_trace(self, alembic_runner, alembic_engine):
        """Assert full upgrade→downgrade cycle leaves a clean database.

        Custom implementation replacing pytest-alembic's built-in version.
        The built-in uses per-revision iteration that breaks on branched DAGs.

        This version upgrades all the way to head, then downgrades to base,
        and verifies the resulting schema has no leftover tables (other than
        alembic_version).
        """
        alembic_runner.migrate_up_to("head")
        alembic_runner.migrate_down_to("base")

        inspector = sa_inspect(alembic_engine)
        remaining = set(inspector.get_table_names()) - {"alembic_version"}
        assert not remaining, (
            f"Downgrade from head to base left {len(remaining)} table(s) "
            f"behind:\n" + "\n".join(f"  - {t}" for t in sorted(remaining))
        )


# --- Custom structural tests ---


def _is_merge_revision(module, path: Path | None = None) -> bool:
    """Check if a migration module is a merge revision.

    Merge revisions have down_revision as a tuple (multiple parents).
    Also detects linearized merges via filename convention (contains 'merge')
    — these were originally multi-parent merges that were linearized to fix
    DAG topology issues (Issue #1296).
    """
    down_rev = getattr(module, "down_revision", None)
    if isinstance(down_rev, tuple | list):
        return True
    # Detect linearized merges by filename convention
    return path is not None and "merge" in path.stem.lower()


def _load_migration_module(path: Path):
    """Dynamically import a migration .py file."""
    spec = importlib.util.spec_from_file_location(path.stem, path)
    if spec is None or spec.loader is None:
        return None
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def _is_empty_function(func) -> bool:
    """Check if a function body is effectively empty (pass or docstring-only)."""
    import ast
    import inspect
    import textwrap

    try:
        source = textwrap.dedent(inspect.getsource(func))
        tree = ast.parse(source)
    except (OSError, TypeError):
        return False

    func_def = tree.body[0]
    if not isinstance(func_def, ast.FunctionDef):
        return False

    body = func_def.body
    if not body:
        return True

    # Filter out docstrings
    meaningful = [
        stmt
        for stmt in body
        if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
    ]
    # Only pass statements remain
    return all(isinstance(stmt, ast.Pass) for stmt in meaningful)


class TestMigrationStructure:
    """Custom structural checks on migration files."""

    def test_no_empty_non_merge_migrations(self):
        """Flag non-merge migrations where both upgrade() and downgrade() are no-ops.

        Merge migrations legitimately have empty bodies. Non-merge migrations
        with empty upgrade+downgrade indicate an accidental empty revision.
        """
        empty_migrations = []

        for path in sorted(_VERSIONS_DIR.glob("*.py")):
            module = _load_migration_module(path)
            if module is None:
                continue

            if _is_merge_revision(module, path):
                continue

            upgrade_fn = getattr(module, "upgrade", None)
            downgrade_fn = getattr(module, "downgrade", None)

            if (
                upgrade_fn
                and downgrade_fn
                and _is_empty_function(upgrade_fn)
                and _is_empty_function(downgrade_fn)
            ):
                empty_migrations.append(path.name)

        assert not empty_migrations, (
            f"Found {len(empty_migrations)} non-merge migration(s) with empty "
            f"upgrade() AND downgrade():\n" + "\n".join(f"  - {name}" for name in empty_migrations)
        )

    def test_recent_migrations_use_readable_names(self):
        """Warn if recent migrations use hash-based names instead of readable ones.

        Convention: new migrations should use descriptive names like
        'add_agent_keys_table' rather than '58d58578fce0_add_...'

        Only checks the 10 most recently modified files to avoid flagging
        the entire history. Older hash-based names are grandfathered in.
        """
        hash_pattern = re.compile(r"^[0-9a-f]{10,}_")

        # Sort by mtime descending, take 10 most recent
        recent_files = sorted(
            _VERSIONS_DIR.glob("*.py"),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )[:10]

        hash_named = [
            f.name
            for f in recent_files
            if hash_pattern.match(f.name) and not f.name.startswith("__")
        ]

        # This is a soft warning — using pytest.skip for advisory
        if hash_named:
            pytest.skip(
                f"Advisory: {len(hash_named)} recent migration(s) use hash-based names. "
                f"Prefer readable names like 'add_<entity>_<detail>.py':\n"
                + "\n".join(f"  - {name}" for name in hash_named)
            )
