"""Integration tests for the VM Fleet Dashboard API.

Exercises the full route → FleetStore → db.py → SQLite chain with no mocks
on the store or database layer.  These tests catch bugs like the H1 steering
INSERT wrong-column error that are invisible to mock-based tests.

Architecture note on aiosqlite + Python 3.14
---------------------------------------------
``aiosqlite 0.22.x`` on Python 3.14 has a regression: when anyio's
``from_thread`` portal (used by ``starlette.testclient.TestClient``) runs an
async coroutine that starts an aiosqlite thread, the thread's internal state
becomes un-restartable within the same portal session.  Every ``aiosqlite.connect()``
call within a ``TestClient`` context manager triggers
``RuntimeError: threads can only be started once``.

Workaround applied here
-----------------------
``aiosqlite.connect`` is patched in ``vm_dashboard.db`` to return
``_Sqlite3AsyncConn`` — an async context manager that wraps stdlib ``sqlite3``
and exposes the same async API (``execute`` / ``fetchone`` / ``fetchall`` /
``commit``) without spawning any threads.  The real SQL still runs against the
real SQLite file; only the threading layer is bypassed.

This means:
1. No aiosqlite threads are started inside the TestClient session.
2. ``validate_schema`` is patched to a no-op in the lifespan to avoid it
   opening its own aiosqlite connection before the shim is active.
3. ``test_validate_schema_runs_at_startup`` calls ``validate_schema`` directly
   in an isolated ``asyncio.run()`` before constructing the TestClient — so that
   aiosqlite connection opens and closes cleanly in a separate event loop.
4. All database seeding uses stdlib ``sqlite3`` (no aiosqlite).
5. Post-request verification (H1 steer test) uses stdlib ``sqlite3``.

The ``coordination_db.py`` schema DDL (``_DDL``) is imported to create the
schema synchronously, staying in sync with the canonical schema definition.
"""

from __future__ import annotations

import asyncio
import sqlite3
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Self
from unittest.mock import AsyncMock, patch

import pytest
import vm_dashboard.app as _app
import vm_dashboard.db as _db
from starlette.testclient import TestClient
from vm_dashboard.db import validate_schema
from vm_dashboard.store import FleetStore

from mcp_vm_blackbox.coordination_db import _DDL, SCHEMA_VERSION

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# aiosqlite shim — wraps stdlib sqlite3 with async API matching db.py usage
# ---------------------------------------------------------------------------


class _Sqlite3AsyncCursor:
    """Async cursor shim backed by a real ``sqlite3.Cursor``.

    Implements the subset of the ``aiosqlite.Cursor`` async API used by
    ``vm_dashboard.db``: ``fetchone`` and ``fetchall``.  Supports integer
    index access (``cursor[0]``) used by ``validate_schema``.

    Args:
        cursor: The underlying stdlib sqlite3 cursor.
    """

    def __init__(self, cursor: sqlite3.Cursor) -> None:
        self._cursor = cursor

    async def fetchone(self) -> sqlite3.Row | None:
        """Return the next row from the result set, or None."""
        return self._cursor.fetchone()

    async def fetchall(self) -> list[sqlite3.Row]:
        """Return all remaining rows from the result set."""
        return self._cursor.fetchall()

    def __getitem__(self, index: int) -> Any:
        """Allow cursor[0] access (used by validate_schema PRAGMA reads)."""
        row = self._cursor.fetchone()
        if row is None:
            return None
        return row[index]


class _Sqlite3AsyncConn:
    """Async connection shim backed by a real ``sqlite3.Connection``.

    Wraps a stdlib sqlite3 connection and exposes the async context manager
    and method API used by ``vm_dashboard.db._open`` and all db functions.
    Rows are returned as ``sqlite3.Row`` objects (dict-like access by column
    name), matching the ``aiosqlite.Row`` behaviour set by ``_open``.

    Args:
        db_path: Path to the SQLite file to open.
    """

    def __init__(self, db_path: Path) -> None:
        self._db_path = db_path
        self._conn: sqlite3.Connection | None = None

    async def __aenter__(self) -> Self:
        self._conn = sqlite3.connect(self._db_path)
        self._conn.row_factory = sqlite3.Row
        self._conn.execute("PRAGMA journal_mode = WAL")
        self._conn.execute("PRAGMA busy_timeout = 5000")
        return self

    async def __aexit__(self, *_: object) -> None:
        if self._conn is not None:
            self._conn.close()
            self._conn = None

    def __await__(self) -> Any:
        """Support ``await _open(db_path)`` which returns the connection directly."""

        async def _resolve() -> _Sqlite3AsyncConn:
            await self.__aenter__()
            return self

        return _resolve().__await__()

    async def execute(self, sql: str, params: tuple[Any, ...] = ()) -> _Sqlite3AsyncCursor:
        """Execute a SQL statement and return an async cursor shim.

        Args:
            sql: SQL statement to execute.
            params: Bound parameters.

        Returns:
            Async cursor shim over the result.
        """
        assert self._conn is not None
        cursor = self._conn.execute(sql, params)
        return _Sqlite3AsyncCursor(cursor)

    async def commit(self) -> None:
        """Commit the current transaction."""
        assert self._conn is not None
        self._conn.commit()

    async def close(self) -> None:
        """Close the connection."""
        if self._conn is not None:
            self._conn.close()
            self._conn = None


# ---------------------------------------------------------------------------
# Synchronous seeding helpers (sqlite3 only — no aiosqlite)
# ---------------------------------------------------------------------------


def _now() -> str:
    """Return current UTC time as ISO-8601 string.

    Returns:
        ISO-8601 formatted UTC timestamp string.
    """
    return datetime.now(UTC).isoformat()


def _init_db(db_path: Path) -> sqlite3.Connection:
    """Create the coordination.db schema synchronously and return the connection.

    Uses the canonical ``_DDL`` from ``coordination_db`` so the schema is
    identical to what CoordinationDB creates.  Stamps ``PRAGMA user_version``
    so ``validate_schema`` in the FastAPI lifespan passes.

    Args:
        db_path: Path to the SQLite file to create.

    Returns:
        Open :class:`sqlite3.Connection` with WAL mode and foreign keys enabled.
    """
    conn = sqlite3.connect(db_path)
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA foreign_keys = ON")
    conn.executescript(_DDL)
    conn.executescript(f"PRAGMA user_version = {SCHEMA_VERSION};")
    conn.commit()
    return conn


def _seed_vm(conn: sqlite3.Connection, vm_name: str) -> None:
    """Insert a VM registry entry synchronously.

    Args:
        conn: Open sqlite3 connection.
        vm_name: VM name to register.
    """
    conn.execute(
        """
        INSERT OR REPLACE INTO vm_registry
            (vm_name, uuid, running, last_refreshed, recording,
             recording_path, recording_started_at)
        VALUES (?, ?, 0, ?, 0, NULL, NULL)
        """,
        (vm_name, str(uuid.uuid4()), _now()),
    )
    conn.commit()


def _seed_job(conn: sqlite3.Connection, job_id: str, vm_name: str, status: str = "active") -> None:
    """Insert a job record synchronously.

    Args:
        conn: Open sqlite3 connection.
        job_id: Job identifier.
        vm_name: VM name.
        status: Job status (default "active").
    """
    now = _now()
    conn.execute(
        """
        INSERT INTO jobs
            (job_id, vm_name, scenario_name, created_by, last_agent_id,
             status, created_at, updated_at)
        VALUES (?, ?, 'integration-test', 'test-agent', 'test-agent', ?, ?, ?)
        """,
        (job_id, vm_name, status, now, now),
    )
    conn.commit()


def _seed_step(conn: sqlite3.Connection, job_id: str, vm_name: str) -> None:
    """Insert a job step synchronously.

    Args:
        conn: Open sqlite3 connection.
        job_id: Job identifier.
        vm_name: VM name.
    """
    conn.execute(
        """
        INSERT INTO job_steps
            (job_id, vm_name, agent_id, action, timestamp)
        VALUES (?, ?, 'test-agent', 'integration-test-step', ?)
        """,
        (job_id, vm_name, _now()),
    )
    conn.commit()


def _query_steering(db_path: Path, job_id: str, vm_name: str) -> dict[str, Any] | None:
    """Query the oldest pending steering message synchronously.

    Args:
        db_path: Path to the SQLite file.
        job_id: Job identifier.
        vm_name: VM name.

    Returns:
        Dict of row values, or None if no pending message found.
    """
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    cursor = conn.execute(
        """
        SELECT * FROM steering_messages
        WHERE job_id = ? AND vm_name = ? AND acknowledged_at IS NULL
        ORDER BY created_at ASC
        LIMIT 1
        """,
        (job_id, vm_name),
    )
    row = cursor.fetchone()
    conn.close()
    if row is None:
        return None
    return dict(row)


# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------


@pytest.fixture
def db_root(tmp_path: Path) -> Path:
    """Return a per-test temporary root directory for the database.

    Tests: Each test gets an isolated SQLite file so they do not interfere.
    How: pytest's ``tmp_path`` fixture provides a unique directory per test.
    Why: Isolation prevents data cross-contamination between parallel workers.

    Args:
        tmp_path: Per-test temporary directory provided by pytest.

    Returns:
        Path to a fresh db root directory.
    """
    root = tmp_path / "db"
    root.mkdir()
    return root


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


class TestDashboardIntegration:
    """Integration tests for the dashboard API — no mocks on store or DB layer.

    Scope: Exercises the full route → FleetStore → db.py → SQLite chain.
    Strategy: Synchronous test functions seed data using stdlib ``sqlite3``
        (no aiosqlite), then construct a synchronous ``TestClient`` that runs
        the ASGI app in its own thread.  This is the only combination that
        works reliably on Python 3.14 where aiosqlite 0.22.x cannot restart
        a thread object once it has been used in a prior event loop.
    """

    def _patches(self, db_path: Path) -> tuple[Any, Any, Any, Any]:
        """Return four context-manager patches for use in a parenthesised with-block.

        Patches applied:
        - ``vm_dashboard.app.store``: real FleetStore backed by the temp db so
          route handlers use real SQLite.
        - ``vm_dashboard.db.get_db_path``: returns the temp db path so any
          other caller resolves to the same file.
        - ``vm_dashboard.app.validate_schema``: replaced with a no-op AsyncMock
          to prevent the lifespan from opening an aiosqlite connection before the
          shim below is active.  ``test_validate_schema_runs_at_startup`` tests
          ``validate_schema`` directly via ``asyncio.run()`` outside any TestClient.
        - ``aiosqlite.connect`` in ``vm_dashboard.db``: replaced with a factory
          that returns ``_Sqlite3AsyncConn`` — a stdlib sqlite3 wrapper with the
          same async interface.  This eliminates all aiosqlite thread creation
          inside the TestClient session, which is incompatible with Python 3.14's
          threading model when used inside anyio's ``from_thread`` portal.

        Args:
            db_path: Path to the coordination.db SQLite file.

        Returns:
            Tuple of four patch context managers.
        """
        real_store = FleetStore(db_path=db_path)
        app_patch = patch.object(_app, "store", real_store)
        db_patch = patch.object(_db, "get_db_path", return_value=db_path)
        vs_patch = patch.object(_app, "validate_schema", new=AsyncMock(return_value=None))
        aio_patch = patch.object(_db.aiosqlite, "connect", side_effect=lambda p, **_kw: _Sqlite3AsyncConn(p))
        return app_patch, db_patch, vs_patch, aio_patch

    def test_validate_schema_runs_at_startup(self, db_root: Path) -> None:
        """Verify the schema satisfies the dashboard column contract.

        Tests: validate_schema() passes on a database initialised synchronously.
        How:
            1. Create the schema with _init_db() using sqlite3 (no aiosqlite).
            2. Call validate_schema() directly in an isolated asyncio.run() so
               the aiosqlite connection opens and closes completely before any
               TestClient is constructed.
            3. Confirm the app serves requests after schema validation succeeds
               by GETting /api/fleet via TestClient (validate_schema is patched
               to a no-op in the lifespan to avoid the Python 3.14 aiosqlite
               thread-restart error described in the module docstring).
        Why: Catches schema version mismatches between coordination_db.py and db.py.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"

        # Arrange — create schema with stdlib sqlite3
        conn = _init_db(db_path)
        conn.close()

        # Assert — validate_schema must not raise on a correctly-initialised db.
        # asyncio.run() completes fully; all aiosqlite threads finish before
        # the TestClient below starts its own event loop.
        asyncio.run(validate_schema(db_path))

        # Confirm the app is alive and serving requests post-validation.
        # validate_schema is patched to no-op in the lifespan (see _patches).
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.get("/api/fleet")

        assert response.status_code == 200

    def test_fleet_endpoint_returns_seeded_vm(self, db_root: Path) -> None:
        """Verify GET /api/fleet returns a VM seeded via synchronous sqlite3.

        Tests: Fleet endpoint reads from real SQLite via FleetStore.fleet_status().
        How:
            1. Seed a VM via _init_db() + _seed_vm().
            2. GET /api/fleet via synchronous TestClient.
            3. Assert 200 and vm_name present in the fleet list.
        Why: Confirms the full read chain (route → FleetStore → db.py → SQLite).
            Mock-based tests bypass the real SQL query entirely.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"
        vm_name = f"fleet-vm-{uuid.uuid4().hex[:8]}"

        # Arrange — seed with stdlib sqlite3 (no aiosqlite)
        conn = _init_db(db_path)
        _seed_vm(conn, vm_name)
        conn.close()

        # Act
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.get("/api/fleet")

        # Assert
        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)
        vm_names = [v["vm_name"] for v in data]
        assert vm_name in vm_names

    def test_job_detail_returns_seeded_job(self, db_root: Path) -> None:
        """Verify GET /api/jobs/{job_id} returns a job seeded via synchronous sqlite3.

        Tests: Job detail endpoint reads from real SQLite via FleetStore.get_job().
        How:
            1. Seed a VM + job via sqlite3.
            2. GET /api/jobs/{job_id} via synchronous TestClient.
            3. Assert 200, job_id matches, status matches.
        Why: Confirms the job lookup route resolves job_id against real SQLite.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"
        vm_name = f"job-detail-vm-{uuid.uuid4().hex[:8]}"
        job_id = str(uuid.uuid4())

        # Arrange
        conn = _init_db(db_path)
        _seed_vm(conn, vm_name)
        _seed_job(conn, job_id, vm_name)
        conn.close()

        # Act
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.get(f"/api/jobs/{job_id}")

        # Assert
        assert response.status_code == 200
        data: dict[str, Any] = response.json()
        assert data["job_id"] == job_id
        assert data["vm_name"] == vm_name
        assert data["status"] == "active"

    def test_job_steps_pagination(self, db_root: Path) -> None:
        """Verify GET /api/jobs/{job_id}/steps?limit=2 returns correct pagination.

        Tests: Steps pagination via FleetStore.get_steps_paginated() → db.list_steps().
        How:
            1. Seed a job with 3 steps via sqlite3.
            2. GET /api/jobs/{job_id}/steps?limit=2&offset=0.
            3. Assert items has 2 entries, total is 3, has_more is True.
        Why: Confirms LIMIT/OFFSET SQL and has_more arithmetic work end-to-end.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"
        vm_name = f"steps-vm-{uuid.uuid4().hex[:8]}"
        job_id = str(uuid.uuid4())

        # Arrange — seed job and 3 steps
        conn = _init_db(db_path)
        _seed_vm(conn, vm_name)
        _seed_job(conn, job_id, vm_name)
        for _ in range(3):
            _seed_step(conn, job_id, vm_name)
        conn.close()

        # Act
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.get(f"/api/jobs/{job_id}/steps?limit=2&offset=0")

        # Assert
        assert response.status_code == 200
        data: dict[str, Any] = response.json()
        assert data["total"] == 3
        assert len(data["items"]) == 2
        assert data["has_more"] is True
        assert data["limit"] == 2
        assert data["offset"] == 0

    def test_screenshot_404_for_missing_vm(self, db_root: Path) -> None:
        """Verify GET /api/vms/{vm_name}/screenshots/latest returns 404 for unknown VM.

        Tests: Screenshot 404 path via store.latest_screenshot() → file scan.
        How:
            1. Initialise an empty database via sqlite3.
            2. GET /api/vms/nonexistent-vm/screenshots/latest.
            3. Assert 404 and response body has "error" key.
        Why: Confirms the 404 error shape contract without touching SQLite.
            The screenshots directory does not exist so latest_screenshot() → None.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"

        # Arrange — just initialise the schema
        conn = _init_db(db_path)
        conn.close()

        # Act
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.get("/api/vms/nonexistent-vm-xyz/screenshots/latest")

        # Assert
        assert response.status_code == 404
        data = response.json()
        assert "error" in data

    def test_todo_crud(self, db_root: Path) -> None:
        """Verify TODO create, list, and update via the real db.py chain.

        Tests: POST + GET + PATCH /api/jobs/{job_id}/todo → FleetStore → db.py → SQLite.
        How:
            1. Seed a job via sqlite3.
            2. POST /api/jobs/{job_id}/todo to create an item.
            3. GET /api/jobs/{job_id}/todo and verify the item appears.
            4. PATCH /api/jobs/{job_id}/todo/{item_id} and verify status updated.
        Why: Confirms the full TODO mutation chain writes and reads from real SQLite.
            Mocks bypass the real INSERT and UPDATE SQL.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"
        vm_name = f"todo-vm-{uuid.uuid4().hex[:8]}"
        job_id = str(uuid.uuid4())

        # Arrange
        conn = _init_db(db_path)
        _seed_vm(conn, vm_name)
        _seed_job(conn, job_id, vm_name)
        conn.close()

        # Act + Assert (multi-step CRUD within one client context)
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            # Step 1 — create a TODO item
            create_response = client.post(
                f"/api/jobs/{job_id}/todo", json={"title": "Integration test TODO", "priority": 10, "added_by": "user"}
            )
            assert create_response.status_code == 201
            created: dict[str, Any] = create_response.json()
            assert created["title"] == "Integration test TODO"
            item_id = created["item_id"]

            # Step 2 — list and verify the item appears
            list_response = client.get(f"/api/jobs/{job_id}/todo")
            assert list_response.status_code == 200
            list_data: dict[str, Any] = list_response.json()
            item_ids = [item["item_id"] for item in list_data["items"]]
            assert item_id in item_ids

            # Step 3 — patch status to in_progress
            patch_response = client.patch(f"/api/jobs/{job_id}/todo/{item_id}", json={"status": "in_progress"})
            assert patch_response.status_code == 200
            updated: dict[str, Any] = patch_response.json()
            assert updated["status"] == "in_progress"

    def test_steer_endpoint_inserts_steering_message(self, db_root: Path) -> None:
        """Verify POST /api/jobs/{job_id}/steer writes a real row to steering_messages.

        Tests: Steer endpoint → FleetStore.insert_steering_message() →
            db.insert_steering_message() → real SQL INSERT → real SQLite row.
        How:
            1. Seed a job via sqlite3.
            2. POST /api/jobs/{job_id}/steer via synchronous TestClient.
            3. Assert 202 with message_id and status="queued".
            4. Verify the row via synchronous sqlite3 query (no aiosqlite).
        Why: This is the H1 bug reproducer.  The H1 bug was a wrong-column INSERT in
            db.insert_steering_message.  Mock-based tests cannot catch this because
            mocks bypass the real SQL.  If the INSERT uses wrong column names, it
            raises and the route returns 500 — failing assertion 3.  Assertion 4 is
            the direct DB verification confirming the row content is correct.

        Args:
            db_root: Per-test temp directory fixture.
        """
        db_path = db_root / "coordination.db"
        vm_name = f"steer-vm-{uuid.uuid4().hex[:8]}"
        job_id = str(uuid.uuid4())

        # Arrange
        conn = _init_db(db_path)
        _seed_vm(conn, vm_name)
        _seed_job(conn, job_id, vm_name)
        conn.close()

        steer_content = "Redirect the pilot to the login page"

        # Act — POST steer via TestClient
        app_patch, db_patch, vs_patch, aio_patch = self._patches(db_path)
        with app_patch, db_patch, vs_patch, aio_patch, TestClient(_app.app, raise_server_exceptions=True) as client:
            response = client.post(
                f"/api/jobs/{job_id}/steer", json={"content": steer_content, "priority": "normal", "sender": "user"}
            )

        # Assert route response — 500 here means the INSERT raised (H1 bug path)
        assert response.status_code == 202
        data: dict[str, Any] = response.json()
        assert data["status"] == "queued"
        assert "message_id" in data

        # Assert via direct DB query — H1 bug reproducer
        # If the INSERT used wrong column names, the row would not be found here.
        pending = _query_steering(db_path, job_id, vm_name)

        assert pending is not None, (
            "Steering message was not written to SQLite — this indicates a wrong-column INSERT bug (H1 regression)"
        )
        assert pending["content"] == steer_content
        assert pending["sender"] == "user"
        assert pending["priority"] == "normal"
        assert pending["acknowledged_at"] is None
