"""Tests for validate_schema() in the VM Dashboard db module.

Validates that the dashboard schema validation detects version mismatches,
missing tables, and missing columns at startup time rather than mid-request.

All tests use direct aiosqlite connections against tmp_path databases to
simulate various schema states without requiring a live MCP server.

ADR-002 compliance: Only test_min_version_lte_schema_version imports from
mcp_vm_blackbox. All other tests import exclusively from vm_dashboard.db.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import aiosqlite
import pytest
from vm_dashboard.db import _MIN_SCHEMA_VERSION, _REQUIRED_COLUMNS, validate_schema

if TYPE_CHECKING:
    from pathlib import Path

# ---------------------------------------------------------------------------
# Full DDL for creating valid test databases (mirrors coordination_db._DDL)
# ---------------------------------------------------------------------------

_FULL_DDL = """\
CREATE TABLE IF NOT EXISTS jobs (
    job_id        TEXT NOT NULL,
    vm_name       TEXT NOT NULL,
    scenario_name TEXT NOT NULL,
    created_by    TEXT NOT NULL,
    last_agent_id TEXT NOT NULL,
    status        TEXT NOT NULL,
    created_at    TEXT NOT NULL,
    updated_at    TEXT NOT NULL,
    pilot_context_usage REAL,
    PRIMARY KEY (job_id, vm_name)
);

CREATE TABLE IF NOT EXISTS job_steps (
    step_id              INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id               TEXT NOT NULL,
    vm_name              TEXT NOT NULL,
    agent_id             TEXT NOT NULL,
    action               TEXT NOT NULL,
    timestamp            TEXT NOT NULL,
    outcome              TEXT,
    intent               TEXT,
    reason               TEXT,
    outcome_status       TEXT,
    outcome_detail       TEXT,
    issue_classification TEXT,
    resolution           TEXT,
    next                 TEXT,
    evidence             TEXT
);

CREATE TABLE IF NOT EXISTS vm_registry (
    vm_name        TEXT PRIMARY KEY,
    uuid           TEXT NOT NULL,
    running        INTEGER NOT NULL,
    last_refreshed TEXT NOT NULL,
    recording      INTEGER NOT NULL DEFAULT 0,
    recording_path TEXT,
    recording_started_at TEXT
);

CREATE TABLE IF NOT EXISTS handoffs (
    handoff_id             TEXT PRIMARY KEY,
    job_id                 TEXT NOT NULL,
    vm_name                TEXT NOT NULL,
    todo_position          TEXT NOT NULL,
    last_completed_step_id INTEGER,
    next_intended_action   TEXT NOT NULL,
    key_observations       TEXT NOT NULL,
    full_log_path          TEXT NOT NULL,
    created_at             TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS issues (
    issue_id         TEXT PRIMARY KEY,
    job_id           TEXT NOT NULL,
    vm_name          TEXT NOT NULL,
    classification   TEXT NOT NULL,
    severity         TEXT NOT NULL,
    summary          TEXT NOT NULL,
    detail           TEXT,
    resolution       TEXT,
    resolution_type  TEXT,
    evidence_paths   TEXT,
    step_id          INTEGER,
    created_at       TEXT NOT NULL,
    resolved_at      TEXT
);

CREATE TABLE IF NOT EXISTS steering_messages (
    message_id      TEXT PRIMARY KEY,
    target_pilot    TEXT,
    job_id          TEXT NOT NULL,
    vm_name         TEXT NOT NULL,
    sender          TEXT NOT NULL,
    content         TEXT NOT NULL,
    priority        TEXT NOT NULL,
    created_at      TEXT NOT NULL,
    acknowledged_at TEXT,
    acted_on        INTEGER NOT NULL DEFAULT 0,
    delivery        TEXT NOT NULL,
    channel_meta    TEXT
);

CREATE TABLE IF NOT EXISTS todos (
    item_id        TEXT PRIMARY KEY,
    job_id         TEXT NOT NULL,
    vm_name        TEXT NOT NULL,
    title          TEXT NOT NULL,
    description    TEXT,
    status         TEXT NOT NULL DEFAULT 'queued',
    priority       INTEGER NOT NULL DEFAULT 100,
    added_by       TEXT NOT NULL,
    assigned_pilot TEXT,
    created_at     TEXT NOT NULL,
    started_at     TEXT,
    completed_at   TEXT,
    blocked_by     TEXT
);
"""


async def _make_db(db_path: Path, ddl: str, user_version: int) -> None:
    """Create a SQLite database with the given DDL and user_version.

    Args:
        db_path: Path where the database file will be created.
        ddl: SQL DDL statements to execute via executescript.
        user_version: Integer to write into PRAGMA user_version.
    """
    async with aiosqlite.connect(db_path) as conn:
        await conn.executescript(ddl)
        await conn.executescript(f"PRAGMA user_version = {user_version};")


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestValidateSchemaHappyPath:
    """Tests for validate_schema() success cases.

    Scope: validate_schema returns silently when the database is valid or absent.
    Strategy: Create correctly structured tmp_path databases and assert no exception.
    """

    async def test_validate_schema_passes_on_valid_db(self, tmp_path: Path) -> None:
        """validate_schema accepts a database with full DDL and correct user_version.

        Tests: validate_schema happy path with a correctly versioned database.
        How: Create a database with _FULL_DDL and user_version=_MIN_SCHEMA_VERSION,
             call validate_schema, assert no exception is raised.
        Why: The primary success case must pass without error; any regression
             in this path blocks dashboard startup entirely.
        """
        # Arrange
        db_path = tmp_path / "coordination.db"
        await _make_db(db_path, _FULL_DDL, _MIN_SCHEMA_VERSION)

        # Act / Assert — no exception
        await validate_schema(db_path)

    async def test_validate_schema_returns_on_missing_db(self, tmp_path: Path) -> None:
        """validate_schema returns silently when the database file does not exist.

        Tests: Cold-start case where MCP server has not yet created the database.
        How: Call validate_schema with a non-existent path, assert no exception.
        Why: The dashboard may start before the MCP server creates coordination.db.
             A missing file is not an error — it is a valid cold-start state.
        """
        # Arrange
        db_path = tmp_path / "nonexistent.db"
        assert not db_path.exists()

        # Act / Assert — no exception
        await validate_schema(db_path)


class TestValidateSchemaVersionGate:
    """Tests for validate_schema() version enforcement.

    Scope: RuntimeError is raised when the database user_version is below minimum.
    Strategy: Create databases with low version values and assert RuntimeError with
              actionable message content.
    """

    async def test_validate_schema_rejects_low_version(self, tmp_path: Path) -> None:
        """validate_schema raises RuntimeError when user_version is below minimum.

        Tests: Version gate rejects a database at user_version=0.
        How: Create a database with _FULL_DDL but user_version=0 (SQLite default,
             meaning the MCP server has not stamped the version yet), call
             validate_schema, assert RuntimeError is raised.
        Why: user_version=0 means the MCP server has not run _apply_migrations() yet.
             The dashboard must not serve requests against an unvalidated schema.
        """
        # Arrange
        db_path = tmp_path / "coordination.db"
        await _make_db(db_path, _FULL_DDL, 0)

        # Act / Assert
        with pytest.raises(RuntimeError):
            await validate_schema(db_path)

    async def test_validate_schema_error_message_actionable(self, tmp_path: Path) -> None:
        """Error message from version mismatch contains version numbers and guidance.

        Tests: RuntimeError message is actionable — includes version numbers and
               instructs the operator to start the MCP server.
        How: Create a database with user_version=0, call validate_schema, capture
             the RuntimeError message, assert it mentions "0", the minimum version,
             and the string "MCP server".
        Why: A QA engineer seeing this error must understand immediately what went
             wrong and how to fix it — opaque error messages lead to wasted time.
        """
        # Arrange
        db_path = tmp_path / "coordination.db"
        await _make_db(db_path, _FULL_DDL, 0)

        # Act
        with pytest.raises(RuntimeError, match="MCP server") as exc_info:
            await validate_schema(db_path)

        # Assert — message contains version numbers
        error_message = str(exc_info.value)
        assert str(_MIN_SCHEMA_VERSION) in error_message
        assert "0" in error_message


class TestValidateSchemaStructuralChecks:
    """Tests for validate_schema() table and column presence detection.

    Scope: RuntimeError is raised when required tables or columns are missing.
    Strategy: Create databases with intentionally incomplete schemas, verify
              that validate_schema surfaces the exact missing element.
    """

    async def test_validate_schema_rejects_missing_table(self, tmp_path: Path) -> None:
        """validate_schema raises RuntimeError when a required table is absent.

        Tests: Table-level drift is detected when validate_schema inspects the schema.
        How: Create a database with user_version=_MIN_SCHEMA_VERSION but omitting
             the 'todos' table from the DDL, call validate_schema, assert RuntimeError.
        Why: A missing table causes AttributeError or query failures mid-request;
             fail-fast at startup surfaces the drift before any request is served.
        """
        # Arrange — DDL without the todos table
        partial_ddl = """\
CREATE TABLE IF NOT EXISTS jobs (
    job_id TEXT NOT NULL, vm_name TEXT NOT NULL, scenario_name TEXT NOT NULL,
    created_by TEXT NOT NULL, last_agent_id TEXT NOT NULL, status TEXT NOT NULL,
    created_at TEXT NOT NULL, updated_at TEXT NOT NULL, pilot_context_usage REAL, PRIMARY KEY (job_id, vm_name)
);
CREATE TABLE IF NOT EXISTS job_steps (
    step_id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL,
    vm_name TEXT NOT NULL, agent_id TEXT NOT NULL, action TEXT NOT NULL,
    timestamp TEXT NOT NULL, outcome TEXT, intent TEXT, reason TEXT,
    outcome_status TEXT, outcome_detail TEXT, issue_classification TEXT,
    resolution TEXT, next TEXT, evidence TEXT
);
CREATE TABLE IF NOT EXISTS vm_registry (
    vm_name TEXT PRIMARY KEY, uuid TEXT NOT NULL, running INTEGER NOT NULL,
    last_refreshed TEXT NOT NULL, recording INTEGER NOT NULL DEFAULT 0,
    recording_path TEXT, recording_started_at TEXT
);
CREATE TABLE IF NOT EXISTS handoffs (
    handoff_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, vm_name TEXT NOT NULL,
    todo_position TEXT NOT NULL, last_completed_step_id INTEGER,
    next_intended_action TEXT NOT NULL, key_observations TEXT NOT NULL,
    full_log_path TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issues (
    issue_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, vm_name TEXT NOT NULL,
    classification TEXT NOT NULL, severity TEXT NOT NULL, summary TEXT NOT NULL,
    detail TEXT, resolution TEXT, resolution_type TEXT, evidence_paths TEXT,
    step_id INTEGER, created_at TEXT NOT NULL, resolved_at TEXT
);
CREATE TABLE IF NOT EXISTS steering_messages (
    message_id TEXT PRIMARY KEY, target_pilot TEXT, job_id TEXT NOT NULL,
    vm_name TEXT NOT NULL, sender TEXT NOT NULL, content TEXT NOT NULL,
    priority TEXT NOT NULL, created_at TEXT NOT NULL, acknowledged_at TEXT,
    acted_on INTEGER NOT NULL DEFAULT 0, delivery TEXT NOT NULL, channel_meta TEXT
);
"""
        db_path = tmp_path / "coordination.db"
        await _make_db(db_path, partial_ddl, _MIN_SCHEMA_VERSION)

        # Act / Assert
        with pytest.raises(RuntimeError, match="todos"):
            await validate_schema(db_path)

    async def test_validate_schema_rejects_missing_column(self, tmp_path: Path) -> None:
        """validate_schema raises RuntimeError when a required column is absent.

        Tests: Column-level drift is detected when validate_schema inspects table_info.
        How: Create a database with full tables but omit the 'evidence' column from
             job_steps, set user_version=_MIN_SCHEMA_VERSION, call validate_schema,
             assert RuntimeError and that the error message mentions 'evidence'.
        Why: Column-level drift (a column added in a new migration but missing from
             an old database) causes mid-request AttributeError — the exact bug class
             that motivated this feature (P036 context). Fail-fast surfaces it at startup.
        """
        # Arrange — job_steps without the 'evidence' column
        ddl_missing_evidence = """\
CREATE TABLE IF NOT EXISTS jobs (
    job_id TEXT NOT NULL, vm_name TEXT NOT NULL, scenario_name TEXT NOT NULL,
    created_by TEXT NOT NULL, last_agent_id TEXT NOT NULL, status TEXT NOT NULL,
    created_at TEXT NOT NULL, updated_at TEXT NOT NULL, pilot_context_usage REAL, PRIMARY KEY (job_id, vm_name)
);
CREATE TABLE IF NOT EXISTS job_steps (
    step_id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL,
    vm_name TEXT NOT NULL, agent_id TEXT NOT NULL, action TEXT NOT NULL,
    timestamp TEXT NOT NULL, outcome TEXT, intent TEXT, reason TEXT,
    outcome_status TEXT, outcome_detail TEXT, issue_classification TEXT,
    resolution TEXT, next TEXT
    -- 'evidence' column intentionally omitted to simulate drift
);
CREATE TABLE IF NOT EXISTS vm_registry (
    vm_name TEXT PRIMARY KEY, uuid TEXT NOT NULL, running INTEGER NOT NULL,
    last_refreshed TEXT NOT NULL, recording INTEGER NOT NULL DEFAULT 0,
    recording_path TEXT, recording_started_at TEXT
);
CREATE TABLE IF NOT EXISTS handoffs (
    handoff_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, vm_name TEXT NOT NULL,
    todo_position TEXT NOT NULL, last_completed_step_id INTEGER,
    next_intended_action TEXT NOT NULL, key_observations TEXT NOT NULL,
    full_log_path TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issues (
    issue_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, vm_name TEXT NOT NULL,
    classification TEXT NOT NULL, severity TEXT NOT NULL, summary TEXT NOT NULL,
    detail TEXT, resolution TEXT, resolution_type TEXT, evidence_paths TEXT,
    step_id INTEGER, created_at TEXT NOT NULL, resolved_at TEXT
);
CREATE TABLE IF NOT EXISTS steering_messages (
    message_id TEXT PRIMARY KEY, target_pilot TEXT, job_id TEXT NOT NULL,
    vm_name TEXT NOT NULL, sender TEXT NOT NULL, content TEXT NOT NULL,
    priority TEXT NOT NULL, created_at TEXT NOT NULL, acknowledged_at TEXT,
    acted_on INTEGER NOT NULL DEFAULT 0, delivery TEXT NOT NULL, channel_meta TEXT
);
CREATE TABLE IF NOT EXISTS todos (
    item_id TEXT PRIMARY KEY, job_id TEXT NOT NULL, vm_name TEXT NOT NULL,
    title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'queued',
    priority INTEGER NOT NULL DEFAULT 100, added_by TEXT NOT NULL,
    assigned_pilot TEXT, created_at TEXT NOT NULL, started_at TEXT,
    completed_at TEXT, blocked_by TEXT
);
"""
        db_path = tmp_path / "coordination.db"
        await _make_db(db_path, ddl_missing_evidence, _MIN_SCHEMA_VERSION)

        # Act / Assert — error must mention the missing column name
        with pytest.raises(RuntimeError, match="evidence"):
            await validate_schema(db_path)


class TestValidateSchemaStaticInvariants:
    """Tests for static invariants in _REQUIRED_COLUMNS and version constants.

    Scope: Module-level constants satisfy cross-module consistency requirements.
    Strategy: Static assertions against imported constants — no database I/O needed.
    """

    def test_required_columns_covers_all_db_queries(self) -> None:
        """_REQUIRED_COLUMNS includes all columns referenced by db.py SQL queries.

        Tests: Completeness of _REQUIRED_COLUMNS against known query columns.
        How: Assert that each expected column set is a subset of the declared
             _REQUIRED_COLUMNS for its table. This is a static cross-reference against
             the SQL SELECT/INSERT/UPDATE statements documented in the architect spec.
        Why: _REQUIRED_COLUMNS that misses a queried column gives a false sense of
             safety — validate_schema passes but mid-request failures still occur.
        """
        # Arrange — expected column sets derived from architect spec Section 3
        expected: dict[str, set[str]] = {
            "vm_registry": {
                "vm_name",
                "uuid",
                "running",
                "last_refreshed",
                "recording",
                "recording_path",
                "recording_started_at",
            },
            "jobs": {
                "job_id",
                "vm_name",
                "scenario_name",
                "created_by",
                "last_agent_id",
                "status",
                "created_at",
                "updated_at",
            },
            "job_steps": {
                "step_id",
                "job_id",
                "vm_name",
                "agent_id",
                "action",
                "timestamp",
                "outcome",
                "intent",
                "reason",
                "outcome_status",
                "outcome_detail",
                "issue_classification",
                "resolution",
                "next",
                "evidence",
            },
            "issues": {
                "issue_id",
                "job_id",
                "vm_name",
                "classification",
                "severity",
                "summary",
                "detail",
                "resolution",
                "resolution_type",
                "evidence_paths",
                "step_id",
                "created_at",
                "resolved_at",
            },
            "steering_messages": {
                "message_id",
                "job_id",
                "vm_name",
                "content",
                "sender",
                "priority",
                "delivery",
                "created_at",
            },
            "todos": {
                "item_id",
                "job_id",
                "vm_name",
                "title",
                "description",
                "status",
                "priority",
                "added_by",
                "assigned_pilot",
                "created_at",
                "started_at",
                "completed_at",
                "blocked_by",
            },
        }

        # Act / Assert — each expected column set must be present in _REQUIRED_COLUMNS
        for table, cols in expected.items():
            assert table in _REQUIRED_COLUMNS, f"Table '{table}' missing from _REQUIRED_COLUMNS"
            declared = _REQUIRED_COLUMNS[table]
            missing_from_declared = cols - declared
            assert not missing_from_declared, (
                f"Table '{table}': columns {sorted(missing_from_declared)} are "
                f"referenced in db.py queries but absent from _REQUIRED_COLUMNS"
            )

    def test_min_version_lte_schema_version(self) -> None:
        """_MIN_SCHEMA_VERSION does not exceed coordination_db.SCHEMA_VERSION.

        Tests: Cross-module version invariant — dashboard cannot require a schema
               version that does not yet exist in the MCP server.
        How: Import SCHEMA_VERSION from mcp_vm_blackbox.coordination_db, compare
             with _MIN_SCHEMA_VERSION from vm_dashboard.db, assert <=.
        Why: If _MIN_SCHEMA_VERSION > SCHEMA_VERSION, every fresh MCP server
             database would be rejected by the dashboard — an impossible startup
             condition. This test catches that developer error at commit time.

        Note: This is the ONE test in this file that imports from mcp_vm_blackbox.
              ADR-002 applies to production code, not test assertions.
        """
        # Arrange — cross-module import (permitted in tests per ADR-002 note)
        from mcp_vm_blackbox.coordination_db import SCHEMA_VERSION

        # Act / Assert
        assert _MIN_SCHEMA_VERSION <= SCHEMA_VERSION, (
            f"_MIN_SCHEMA_VERSION ({_MIN_SCHEMA_VERSION}) exceeds "
            f"coordination_db.SCHEMA_VERSION ({SCHEMA_VERSION}). "
            "The dashboard requires a schema version that the MCP server does not provide."
        )
