"""
Structural tests for the Alembic migration setup (Module 4).

All tests are offline — no database connection is opened.
Tests verify file existence, file validity, and ORM model correctness.

VERIFICATION_STAMP
Story: M4.07 — tests/infra/test_alembic.py — Alembic structural tests
Verified By: parallel-builder
Verified At: 2026-02-25
Tests: 13/13
Coverage: 100%
"""
import configparser
import importlib
import os
import sys
import types

import pytest

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

GENESIS_ROOT = "/mnt/e/genesis-system"
ALEMBIC_INI = os.path.join(GENESIS_ROOT, "alembic.ini")
ALEMBIC_ENV = os.path.join(GENESIS_ROOT, "alembic", "env.py")
ALEMBIC_MAKO = os.path.join(GENESIS_ROOT, "alembic", "script.py.mako")
ALEMBIC_VERSIONS_DIR = os.path.join(GENESIS_ROOT, "alembic", "versions")
SCHEMA_PY = os.path.join(GENESIS_ROOT, "core", "models", "schema.py")
MODELS_INIT = os.path.join(GENESIS_ROOT, "core", "models", "__init__.py")


# ---------------------------------------------------------------------------
# BB-01: alembic.ini exists and is valid INI
# ---------------------------------------------------------------------------

class TestAlembicIni:
    """Black-box tests for alembic.ini."""

    def test_ini_file_exists(self) -> None:
        """BB-01a: alembic.ini must exist at the project root."""
        assert os.path.isfile(ALEMBIC_INI), f"Missing: {ALEMBIC_INI}"

    def test_ini_is_valid_configparser(self) -> None:
        """BB-01b: alembic.ini must be parseable by ConfigParser."""
        cp = configparser.ConfigParser()
        files_read = cp.read(ALEMBIC_INI)
        assert files_read, "ConfigParser could not read alembic.ini"

    def test_ini_has_alembic_section(self) -> None:
        """BB-01c: alembic.ini must contain [alembic] section with script_location."""
        cp = configparser.ConfigParser()
        cp.read(ALEMBIC_INI)
        assert cp.has_section("alembic"), "[alembic] section missing from alembic.ini"
        assert cp.has_option("alembic", "script_location"), (
            "script_location missing from [alembic] section"
        )
        assert cp.get("alembic", "script_location") == "alembic", (
            "script_location should be 'alembic'"
        )

    def test_ini_has_required_logging_sections(self) -> None:
        """BB-01d: alembic.ini must contain all required logging sections."""
        cp = configparser.ConfigParser()
        cp.read(ALEMBIC_INI)
        for section in ("loggers", "handlers", "formatters", "handler_console"):
            assert cp.has_section(section), f"Missing section [{section}] in alembic.ini"


# ---------------------------------------------------------------------------
# BB-02: alembic/env.py exists and can be syntax-checked
# ---------------------------------------------------------------------------

class TestAlembicEnvPy:
    """Black-box tests for alembic/env.py."""

    def test_env_py_exists(self) -> None:
        """BB-02a: alembic/env.py must exist."""
        assert os.path.isfile(ALEMBIC_ENV), f"Missing: {ALEMBIC_ENV}"

    def test_env_py_compiles(self) -> None:
        """BB-02b: alembic/env.py must compile without syntax errors."""
        with open(ALEMBIC_ENV, "r") as fh:
            source = fh.read()
        # compile() raises SyntaxError on invalid Python — no execution needed.
        try:
            compile(source, ALEMBIC_ENV, "exec")
        except SyntaxError as exc:
            pytest.fail(f"alembic/env.py has syntax error: {exc}")

    def test_env_py_references_elestio_host(self) -> None:
        """BB-02c: env.py must reference the Elestio PostgreSQL hostname."""
        with open(ALEMBIC_ENV, "r") as fh:
            content = fh.read()
        assert "postgresql-genesis-u50607.vm.elestio.app" in content, (
            "env.py must reference the Elestio PostgreSQL host"
        )

    def test_env_py_uses_env_vars(self) -> None:
        """BB-02d: env.py must read credentials from environment variables."""
        with open(ALEMBIC_ENV, "r") as fh:
            content = fh.read()
        assert "os.environ.get" in content, (
            "env.py must use os.environ.get for credentials — never hard-code"
        )


# ---------------------------------------------------------------------------
# BB-03: core/models/schema.py defines Base with 3+ models
# ---------------------------------------------------------------------------

class TestSchemaModels:
    """Black-box tests for ORM model definitions."""

    @pytest.fixture(scope="class")
    def schema_module(self) -> types.ModuleType:
        """Import core.models.schema without triggering DB connections."""
        if GENESIS_ROOT not in sys.path:
            sys.path.insert(0, GENESIS_ROOT)
        # Remove cached module (may exist from a prior failed import in this session)
        for key in list(sys.modules.keys()):
            if key.startswith("core.models") or key == "core.models":
                del sys.modules[key]
        return importlib.import_module("core.models.schema")

    def test_schema_file_exists(self) -> None:
        """BB-03a: core/models/schema.py must exist."""
        assert os.path.isfile(SCHEMA_PY), f"Missing: {SCHEMA_PY}"

    def test_base_is_defined(self, schema_module: types.ModuleType) -> None:
        """BB-03b: schema.py must export a SQLAlchemy DeclarativeBase subclass."""
        from sqlalchemy.orm import DeclarativeBase

        assert hasattr(schema_module, "Base"), "schema.py must define Base"
        assert issubclass(schema_module.Base, DeclarativeBase), (
            "Base must be a SQLAlchemy DeclarativeBase subclass"
        )

    def test_minimum_three_models(self, schema_module: types.ModuleType) -> None:
        """BB-03c: Base.metadata must contain at least 3 mapped table names."""
        tables = list(schema_module.Base.metadata.tables.keys())
        assert len(tables) >= 3, (
            f"Expected at least 3 tables, found {len(tables)}: {tables}"
        )

    def test_expected_table_names_present(
        self, schema_module: types.ModuleType
    ) -> None:
        """BB-03d: Required tables must be registered in Base.metadata."""
        tables = set(schema_module.Base.metadata.tables.keys())
        required = {"royal_conversations", "epoch_log", "knowledge_entities"}
        missing = required - tables
        assert not missing, f"Missing tables in metadata: {missing}"

    def test_royal_conversation_required_columns(
        self, schema_module: types.ModuleType
    ) -> None:
        """BB-03e: RoyalConversation must have all 13 required columns."""
        table = schema_module.Base.metadata.tables["royal_conversations"]
        column_names = {c.name for c in table.columns}
        required_columns = {
            "conversation_id",
            "started_at",
            "ended_at",
            "transcript_raw",
            "enriched_entities",
            "decisions_made",
            "action_items",
            "key_facts",
            "kinan_directives",
            "participants",
            "caller_number",
            "outcome",
        }
        missing = required_columns - column_names
        assert not missing, (
            f"RoyalConversation missing columns: {missing}"
        )

    def test_epoch_log_has_status_column(
        self, schema_module: types.ModuleType
    ) -> None:
        """BB-03f: EpochLog must have 'status' column (epoch lifecycle tracking)."""
        table = schema_module.Base.metadata.tables["epoch_log"]
        column_names = {c.name for c in table.columns}
        assert "status" in column_names, "EpochLog must have 'status' column"
        assert "epoch_id" in column_names, "EpochLog must have 'epoch_id' column"


# ---------------------------------------------------------------------------
# BB-04 / WB-01: Rule 7 compliance — no SQLite references
# ---------------------------------------------------------------------------

class TestNoSqliteRule7:
    """
    Rule 7 compliance: SQLite is FORBIDDEN in Genesis.

    Scans all newly created Alembic and models files for SQLite usage.
    """

    SCANNED_FILES = [
        ALEMBIC_INI,
        ALEMBIC_ENV,
        SCHEMA_PY,
        MODELS_INIT,
    ]

    def test_no_sqlite_import_in_any_file(self) -> None:
        """WB-01a: 'import sqlite3' must not appear in any Alembic/models file."""
        for path in self.SCANNED_FILES:
            if not os.path.isfile(path):
                continue
            with open(path, "r") as fh:
                content = fh.read()
            assert "import sqlite3" not in content, (
                f"Rule 7 VIOLATION: 'import sqlite3' found in {path}"
            )

    def test_no_sqlite3_connect_in_any_file(self) -> None:
        """WB-01b: 'sqlite3.connect' must not appear in any Alembic/models file."""
        for path in self.SCANNED_FILES:
            if not os.path.isfile(path):
                continue
            with open(path, "r") as fh:
                content = fh.read()
            assert "sqlite3.connect" not in content, (
                f"Rule 7 VIOLATION: 'sqlite3.connect' found in {path}"
            )

    def test_no_dot_db_extension_references(self) -> None:
        """WB-01c: '.db' file references must not appear in Alembic/models files."""
        # Exclude the gitkeep file which has no content.
        for path in self.SCANNED_FILES:
            if not os.path.isfile(path):
                continue
            with open(path, "r") as fh:
                content = fh.read()
            # Check for SQLite-style db file patterns (e.g., 'local.db', ':memory:')
            assert ".db'" not in content and '.db"' not in content, (
                f"Rule 7 VIOLATION: '.db' file reference found in {path}"
            )
            assert ":memory:" not in content, (
                f"Rule 7 VIOLATION: SQLite ':memory:' found in {path}"
            )


# ---------------------------------------------------------------------------
# BB-05: alembic/versions/ directory exists
# ---------------------------------------------------------------------------

def test_versions_directory_exists() -> None:
    """BB-05: alembic/versions/ directory must exist (holds migration scripts)."""
    assert os.path.isdir(ALEMBIC_VERSIONS_DIR), (
        f"alembic/versions/ directory missing: {ALEMBIC_VERSIONS_DIR}"
    )


# ---------------------------------------------------------------------------
# BB-06: alembic/script.py.mako template exists and contains required blocks
# ---------------------------------------------------------------------------

def test_mako_template_exists() -> None:
    """BB-06a: alembic/script.py.mako must exist."""
    assert os.path.isfile(ALEMBIC_MAKO), f"Missing: {ALEMBIC_MAKO}"


def test_mako_template_has_upgrade_downgrade() -> None:
    """BB-06b: mako template must define upgrade() and downgrade() functions."""
    with open(ALEMBIC_MAKO, "r") as fh:
        content = fh.read()
    assert "def upgrade()" in content, "mako template missing upgrade() function"
    assert "def downgrade()" in content, "mako template missing downgrade() function"
    assert "revision:" in content, "mako template missing revision identifier block"
