"""Tests for schema versioning in coordination_db.py.

Covers PRAGMA user_version stamping and _apply_migrations() behaviour across
all four code paths: fresh database, up-to-date, newer-than-code, and
pending migrations.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import aiosqlite
import pytest_asyncio

from mcp_vm_blackbox.coordination_db import (
    _MIGRATIONS,
    SCHEMA_VERSION,
    CoordinationDB,
    _apply_migrations,
    _reset_db_instance_for_testing,
)

if TYPE_CHECKING:
    from collections.abc import AsyncIterator
    from pathlib import Path

    import pytest


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


async def _read_user_version(conn: aiosqlite.Connection) -> int:
    """Return the current PRAGMA user_version from *conn*."""
    async with conn.execute("PRAGMA user_version") as cur:
        row = await cur.fetchone()
    return int(row[0]) if row else 0


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def tmp_db(tmp_path: Path) -> AsyncIterator[CoordinationDB]:
    """Yield an initialised CoordinationDB backed by a tmp_path directory.

    Resets the global singleton so each test starts clean.
    """
    _reset_db_instance_for_testing()
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    yield db
    await db.close()
    _reset_db_instance_for_testing()


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


async def test_fresh_db_sets_user_version(tmp_db: CoordinationDB) -> None:
    """Fresh database is stamped with SCHEMA_VERSION after initialization.

    Tests: PRAGMA user_version is set to SCHEMA_VERSION on a brand-new database.
    How: Initialize a CoordinationDB against a temp directory, then read
         PRAGMA user_version directly from the open connection.
    Why: Ensures every new installation is immediately versioned so the
         dashboard can gate on a known minimum schema version at startup.
    """
    # Arrange — tmp_db fixture has already called initialize()
    # Act — read user_version from the live connection
    version = await _read_user_version(tmp_db._c)

    # Assert
    assert version == SCHEMA_VERSION


async def test_existing_v0_db_stamped_to_current(tmp_path: Path) -> None:
    """A database that existed before schema versioning is stamped to SCHEMA_VERSION.

    Tests: Legacy databases (user_version=0) are upgraded on initialize().
    How: Create a raw aiosqlite database at the expected path, apply only the
         DDL (so tables exist), leave user_version at 0, then instantiate
         CoordinationDB and call initialize().  Afterwards verify user_version
         equals SCHEMA_VERSION.
    Why: All coordination databases created before this feature ship with
         user_version=0.  They must be stamped without data loss so the
         dashboard does not reject them.
    """
    from mcp_vm_blackbox.coordination_db import _DDL

    # Arrange — create a pre-existing DB with user_version=0 (SQLite default)
    db_path = tmp_path / "coordination.db"
    conn = await aiosqlite.connect(db_path)
    await conn.executescript(_DDL)
    await conn.commit()
    pre_version = await _read_user_version(conn)
    await conn.close()
    assert pre_version == 0, "pre-condition: raw DB should have user_version=0"

    # Act — let CoordinationDB discover and stamp the database
    _reset_db_instance_for_testing()
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    version = await _read_user_version(db._c)
    await db.close()
    _reset_db_instance_for_testing()

    # Assert
    assert version == SCHEMA_VERSION


async def test_migrations_applied_in_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """Migrations execute in ascending version order and each stamps user_version.

    Tests: _apply_migrations() runs multiple pending migrations sequentially.
    How: Use monkeypatch to inject two test migrations (versions 2 and 3) into
         _MIGRATIONS and raise SCHEMA_VERSION to 3.  Open a raw connection,
         apply _DDL, stamp user_version=1 (simulate a v1 DB), then call
         _apply_migrations().  Verify both columns exist and user_version=3.
    Why: Guards against silent re-ordering bugs that would apply version 3 SQL
         before version 2, potentially referencing columns that do not yet exist.
    """
    import mcp_vm_blackbox.coordination_db as _mod
    from mcp_vm_blackbox.coordination_db import _DDL

    # Arrange — patch _MIGRATIONS and SCHEMA_VERSION with test data
    test_migrations: dict[int, str] = {
        2: "ALTER TABLE vm_registry ADD COLUMN _test_col_a TEXT;",
        3: "ALTER TABLE vm_registry ADD COLUMN _test_col_b INTEGER;",
    }
    monkeypatch.setattr(_mod, "_MIGRATIONS", test_migrations)
    monkeypatch.setattr(_mod, "SCHEMA_VERSION", 3)

    db_path = tmp_path / "coordination.db"
    conn = await aiosqlite.connect(db_path)
    conn.row_factory = aiosqlite.Row
    await conn.executescript(_DDL)
    # Simulate a v1 database (already stamped at baseline version 1)
    await conn.executescript("PRAGMA user_version = 1;")
    await conn.commit()

    # Act
    await _apply_migrations(conn)

    # Assert — both columns must exist
    async with conn.execute("PRAGMA table_info(vm_registry)") as cur:
        rows = await cur.fetchall()
    col_names = {row[1] for row in rows}
    assert "_test_col_a" in col_names, "migration v2 column missing"
    assert "_test_col_b" in col_names, "migration v3 column missing"

    # Assert — user_version advanced to the target version
    version = await _read_user_version(conn)
    await conn.close()
    assert version == 3


async def test_migrations_idempotent(tmp_path: Path) -> None:
    """Calling initialize() twice on the same database is safe.

    Tests: _apply_migrations() does not double-apply when user_version already
           matches SCHEMA_VERSION.
    How: Instantiate CoordinationDB and call initialize() once, record the
         user_version, then call initialize() again on the same path via a
         second instance and re-read user_version.
    Why: Ground-control may restart the MCP server while a job is in progress.
         A double-initialization must not corrupt schema state.
    """
    # Arrange / Act — first initialization
    _reset_db_instance_for_testing()
    db1 = CoordinationDB(root=tmp_path)
    await db1.initialize()
    version_after_first = await _read_user_version(db1._c)
    await db1.close()
    _reset_db_instance_for_testing()

    # Act — second initialization on the same database file
    db2 = CoordinationDB(root=tmp_path)
    await db2.initialize()
    version_after_second = await _read_user_version(db2._c)
    await db2.close()
    _reset_db_instance_for_testing()

    # Assert — version is stable across multiple initializations
    assert version_after_first == SCHEMA_VERSION
    assert version_after_second == SCHEMA_VERSION


async def test_no_downgrade_on_newer_db(tmp_path: Path) -> None:
    """A database whose user_version exceeds SCHEMA_VERSION is left untouched.

    Tests: _apply_migrations() does not modify a newer database.
    How: Open a raw connection, apply _DDL, manually set user_version to
         SCHEMA_VERSION + 1, then call _apply_migrations() and verify the
         version is unchanged.
    Why: A pilot may run against a forward-deployed MCP server that has already
         been upgraded.  The older code must not silently downgrade the schema
         header, which would cause the newer server to reject the database on
         its next start.
    """
    from mcp_vm_blackbox.coordination_db import _DDL

    future_version = SCHEMA_VERSION + 1

    # Arrange — database with a version newer than SCHEMA_VERSION
    db_path = tmp_path / "coordination.db"
    conn = await aiosqlite.connect(db_path)
    conn.row_factory = aiosqlite.Row
    await conn.executescript(_DDL)
    await conn.executescript(f"PRAGMA user_version = {future_version};")
    await conn.commit()

    # Act
    await _apply_migrations(conn)

    # Assert — version must still be the future version
    version = await _read_user_version(conn)
    await conn.close()
    assert version == future_version


def test_schema_version_constant_matches_migrations() -> None:
    """SCHEMA_VERSION is at least as large as the highest migration key.

    Tests: The module-level invariant ``max(_MIGRATIONS.keys(), default=1) <=
           SCHEMA_VERSION`` holds.
    How: Read both module-level names and assert the invariant directly.
    Why: A developer who adds a migration entry for version N+1 without
         incrementing SCHEMA_VERSION would leave the database stuck at N,
         never reaching the new migration — a silent data-loss bug.
    """
    # Act — read module-level constants (no setup required)
    max_migration_key = max(_MIGRATIONS.keys(), default=1)

    # Assert
    assert max_migration_key <= SCHEMA_VERSION, (
        f"SCHEMA_VERSION={SCHEMA_VERSION} must be >= max(_MIGRATIONS key)={max_migration_key}"
    )


def test_migrations_dict_has_integer_keys() -> None:
    """All keys in _MIGRATIONS are plain Python integers.

    Tests: Type safety of the _MIGRATIONS dict keys.
    How: Iterate over _MIGRATIONS.keys() and assert each is an int instance.
    Why: _apply_migrations() uses f-string interpolation
         ``f"PRAGMA user_version = {version};"`` with keys from _MIGRATIONS.
         Non-integer keys (e.g. strings or floats) would produce malformed SQL
         and are rejected by this guard.
    """
    # Act
    non_int_keys = [k for k in _MIGRATIONS if not isinstance(k, int)]

    # Assert
    assert non_int_keys == [], f"All _MIGRATIONS keys must be int; found non-int keys: {non_int_keys}"
