"""Tests for coordination_db.py — schema, WAL mode, migration, and CRUD operations."""

from __future__ import annotations

import asyncio
import json
from typing import TYPE_CHECKING

import pytest_asyncio

from mcp_vm_blackbox.coordination_db import CoordinationDB, FleetVmJobsView, _reset_db_instance_for_testing, get_db
from mcp_vm_blackbox.models import JobRecord, JobStatus, JobStep, VMRegistryEntry

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

    import pytest

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


@pytest_asyncio.fixture
async def tmp_db(tmp_path: Path) -> AsyncIterator[CoordinationDB]:
    """Yield an initialised CoordinationDB backed by a tmp_path directory."""
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    yield db
    await db.close()


def _make_job_record(
    job_id: str = "abc123",
    vm_name: str = "test-vm",
    scenario_name: str = "smoke",
    agent_id: str = "agent-1",
    status: JobStatus = "active",
) -> JobRecord:
    """Build a minimal JobRecord for use in tests."""
    import uuid
    from datetime import UTC, datetime

    if job_id == "abc123":
        job_id = uuid.uuid4().hex[:12]
    now = datetime.now(UTC).isoformat()
    return JobRecord(
        job_id=job_id,
        vm_name=vm_name,
        scenario_name=scenario_name,
        created_by=agent_id,
        last_agent_id=agent_id,
        status=status,
        steps=[JobStep(agent_id=agent_id, action="initial-step", timestamp=now, outcome=None)],
        created_at=now,
        updated_at=now,
    )


def _write_json_job(jobs_dir: Path, record: JobRecord) -> Path:
    """Write a JobRecord as JSON under jobs_dir/{vm_name}/{job_id}.json."""
    vm_dir = jobs_dir / record.vm_name
    vm_dir.mkdir(parents=True, exist_ok=True)
    path = vm_dir / f"{record.job_id}.json"
    path.write_text(json.dumps(record.model_dump()), encoding="utf-8")
    return path


# ---------------------------------------------------------------------------
# Schema / connection tests
# ---------------------------------------------------------------------------


async def test_wal_mode_is_enabled(tmp_db: CoordinationDB) -> None:
    """WAL journal mode is set on connection init."""
    assert tmp_db._conn is not None
    async with tmp_db._conn.execute("PRAGMA journal_mode") as cur:
        row = await cur.fetchone()
    assert row is not None
    assert row[0] == "wal"


async def test_busy_timeout_is_set(tmp_db: CoordinationDB) -> None:
    """busy_timeout PRAGMA is applied during initialization."""
    assert tmp_db._conn is not None
    async with tmp_db._conn.execute("PRAGMA busy_timeout") as cur:
        row = await cur.fetchone()
    assert row is not None
    assert int(row[0]) == 5000


async def test_foreign_keys_are_enabled(tmp_db: CoordinationDB) -> None:
    """foreign_keys PRAGMA is ON after initialization."""
    assert tmp_db._conn is not None
    async with tmp_db._conn.execute("PRAGMA foreign_keys") as cur:
        row = await cur.fetchone()
    assert row is not None
    assert int(row[0]) == 1


async def test_all_tables_are_created(tmp_db: CoordinationDB) -> None:
    """jobs, job_steps, and vm_registry tables exist after initialization."""
    assert tmp_db._conn is not None
    async with tmp_db._conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") as cur:
        rows = await cur.fetchall()
    names = {row[0] for row in rows}
    assert "jobs" in names
    assert "job_steps" in names
    assert "vm_registry" in names


async def test_db_file_is_created(tmp_path: Path) -> None:
    """The coordination.db file is created on disk after initialize()."""
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    assert (tmp_path / "coordination.db").exists()
    await db.close()


async def test_gitignore_is_written(tmp_path: Path) -> None:
    """A .gitignore excluding *.db artefacts is written to root."""
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    gi = tmp_path / ".gitignore"
    assert gi.exists()
    content = gi.read_text(encoding="utf-8")
    assert "*.db" in content
    await db.close()


async def test_context_manager_opens_and_closes(tmp_path: Path) -> None:
    """async with CoordinationDB opens and closes without error."""
    async with CoordinationDB(root=tmp_path) as db:
        assert db._conn is not None
    assert db._conn is None


# ---------------------------------------------------------------------------
# Job CRUD tests
# ---------------------------------------------------------------------------


async def test_insert_and_get_job_returns_record(tmp_db: CoordinationDB) -> None:
    """insert_job followed by get_job returns the original record."""
    record = _make_job_record()
    await tmp_db.insert_job(record)
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    assert fetched.job_id == record.job_id
    assert fetched.vm_name == record.vm_name
    assert fetched.scenario_name == record.scenario_name


async def test_get_job_returns_none_for_missing(tmp_db: CoordinationDB) -> None:
    """get_job returns None when the job does not exist."""
    result = await tmp_db.get_job("nonexistent", "no-vm")
    assert result is None


async def test_insert_job_includes_steps(tmp_db: CoordinationDB) -> None:
    """Steps embedded in JobRecord are persisted and retrieved."""
    record = _make_job_record()
    await tmp_db.insert_job(record)
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    assert len(fetched.steps) == 1
    assert fetched.steps[0].action == "initial-step"


async def test_insert_step_appends_to_job(tmp_db: CoordinationDB) -> None:
    """insert_step adds a step; subsequent get_job returns it."""
    from datetime import UTC, datetime

    record = _make_job_record()
    await tmp_db.insert_job(record)
    new_step = JobStep(agent_id="agent-1", action="second-action", timestamp=datetime.now(UTC).isoformat())
    await tmp_db.insert_step(record.job_id, record.vm_name, new_step)
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    assert len(fetched.steps) == 2
    assert fetched.steps[1].action == "second-action"


async def test_update_job_status_changes_status(tmp_db: CoordinationDB) -> None:
    """update_job_status persists the new status value."""
    from datetime import UTC, datetime

    record = _make_job_record()
    await tmp_db.insert_job(record)
    now = datetime.now(UTC).isoformat()
    await tmp_db.update_job_status(record.job_id, record.vm_name, "completed", now)
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    assert fetched.status == "completed"


async def test_list_jobs_by_vm_returns_correct_jobs(tmp_db: CoordinationDB) -> None:
    """list_jobs_by_vm returns only records for the specified VM."""
    r1 = _make_job_record(vm_name="vm-a")
    r2 = _make_job_record(vm_name="vm-b")
    await tmp_db.insert_job(r1)
    await tmp_db.insert_job(r2)
    results = await tmp_db.list_jobs_by_vm("vm-a")
    assert len(results) == 1
    assert results[0].vm_name == "vm-a"


async def test_list_jobs_by_status_filters_correctly(tmp_db: CoordinationDB) -> None:
    """list_jobs_by_status returns only records matching the given status."""
    from datetime import UTC, datetime

    r1 = _make_job_record(status="active")
    r2 = _make_job_record(status="active")
    await tmp_db.insert_job(r1)
    await tmp_db.insert_job(r2)
    now = datetime.now(UTC).isoformat()
    await tmp_db.update_job_status(r2.job_id, r2.vm_name, "completed", now)
    active = await tmp_db.list_jobs_by_status("active")
    assert len(active) == 1
    assert active[0].job_id == r1.job_id


async def test_list_all_jobs_returns_all(tmp_db: CoordinationDB) -> None:
    """list_all_jobs returns every job record inserted."""
    records = [_make_job_record() for _ in range(3)]
    for r in records:
        await tmp_db.insert_job(r)
    all_jobs = await tmp_db.list_all_jobs()
    assert len(all_jobs) == 3


async def test_steps_preserved_in_insertion_order(tmp_db: CoordinationDB) -> None:
    """Steps are returned in insertion order (step_id ASC)."""
    from datetime import UTC, datetime

    record = _make_job_record()
    await tmp_db.insert_job(record)
    actions = ["alpha", "beta", "gamma"]
    for action in actions:
        step = JobStep(agent_id="agent-1", action=action, timestamp=datetime.now(UTC).isoformat())
        await tmp_db.insert_step(record.job_id, record.vm_name, step)
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    # first step is the initial-step from _make_job_record
    assert [s.action for s in fetched.steps[1:]] == actions


async def test_concurrent_insert_steps_no_data_loss(tmp_db: CoordinationDB) -> None:
    """Concurrent step inserts all survive — no last-write-wins data loss."""
    from datetime import UTC, datetime

    record = _make_job_record()
    await tmp_db.insert_job(record)

    async def _add(action: str) -> None:
        step = JobStep(agent_id="agent-1", action=action, timestamp=datetime.now(UTC).isoformat())
        await tmp_db.insert_step(record.job_id, record.vm_name, step)

    await asyncio.gather(_add("step-1"), _add("step-2"), _add("step-3"))
    fetched = await tmp_db.get_job(record.job_id, record.vm_name)
    assert fetched is not None
    # 1 initial + 3 concurrent
    assert len(fetched.steps) == 4


# ---------------------------------------------------------------------------
# VM Registry CRUD tests
# ---------------------------------------------------------------------------


async def test_upsert_and_get_vm_returns_entry(tmp_db: CoordinationDB) -> None:
    """upsert_vm followed by get_vm returns the entry."""
    from datetime import UTC, datetime

    entry = VMRegistryEntry(
        vm_name="test-vm", uuid="uuid-001", running=True, last_refreshed=datetime.now(UTC).isoformat()
    )
    await tmp_db.upsert_vm(entry)
    fetched = await tmp_db.get_vm("test-vm")
    assert fetched is not None
    assert fetched.vm_name == "test-vm"
    assert fetched.uuid == "uuid-001"
    assert fetched.running is True


async def test_get_vm_returns_none_for_missing(tmp_db: CoordinationDB) -> None:
    """get_vm returns None when the VM is not in the registry."""
    result = await tmp_db.get_vm("no-such-vm")
    assert result is None


async def test_upsert_vm_updates_existing_entry(tmp_db: CoordinationDB) -> None:
    """Second upsert_vm call updates the existing row."""
    from datetime import UTC, datetime

    entry = VMRegistryEntry(
        vm_name="test-vm", uuid="uuid-001", running=True, last_refreshed=datetime.now(UTC).isoformat()
    )
    await tmp_db.upsert_vm(entry)
    entry2 = entry.model_copy(update={"running": False})
    await tmp_db.upsert_vm(entry2)
    fetched = await tmp_db.get_vm("test-vm")
    assert fetched is not None
    assert fetched.running is False


async def test_upsert_vms_batch(tmp_db: CoordinationDB) -> None:
    """upsert_vms inserts multiple entries in a single transaction."""
    from datetime import UTC, datetime

    now = datetime.now(UTC).isoformat()
    entries = [
        VMRegistryEntry(vm_name=f"vm-{i}", uuid=f"uuid-{i}", running=False, last_refreshed=now) for i in range(3)
    ]
    await tmp_db.upsert_vms(entries)
    all_vms = await tmp_db.list_all_vms()
    assert len(all_vms) == 3


async def test_list_all_vms_empty_when_registry_unpopulated(tmp_db: CoordinationDB) -> None:
    """list_all_vms returns an empty list when nothing has been upserted."""
    result = await tmp_db.list_all_vms()
    assert result == []


# ---------------------------------------------------------------------------
# Migration tests
# ---------------------------------------------------------------------------


async def test_migration_imports_existing_json_jobs(tmp_path: Path) -> None:
    """Migration on first init imports JSON job files into SQLite."""
    record = _make_job_record(vm_name="test-vm")
    _write_json_job(tmp_path / "jobs", record)

    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    jobs = await db.list_all_jobs()
    assert any(j.job_id == record.job_id for j in jobs)
    await db.close()


async def test_migration_sentinel_prevents_rerun(tmp_path: Path) -> None:
    """sentinel file presence causes migration to be skipped on second init."""
    record = _make_job_record(vm_name="test-vm")
    _write_json_job(tmp_path / "jobs", record)

    # First init — migrates
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    first_count = len(await db.list_all_jobs())
    await db.close()

    # Write a second JSON file after first migration
    record2 = _make_job_record(vm_name="test-vm")
    _write_json_job(tmp_path / "jobs", record2)

    # Second init — sentinel exists, so only original record is present
    db2 = CoordinationDB(root=tmp_path)
    await db2.initialize()
    second_count = len(await db2.list_all_jobs())
    await db2.close()

    assert first_count == 1
    assert second_count == 1  # record2 not migrated because sentinel is present


async def test_migration_skips_corrupt_json(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
    """Corrupt JSON files are skipped; migration does not abort."""
    jobs_dir = tmp_path / "jobs" / "test-vm"
    jobs_dir.mkdir(parents=True)
    (jobs_dir / "bad.json").write_text("not json {{{{", encoding="utf-8")

    import logging

    with caplog.at_level(logging.WARNING, logger="mcp_vm_blackbox.coordination_db"):
        db = CoordinationDB(root=tmp_path)
        await db.initialize()  # must not raise

    assert "skipped corrupt file" in caplog.text
    await db.close()


async def test_migration_no_jobs_dir_writes_sentinel(tmp_path: Path) -> None:
    """When jobs/ dir is absent, sentinel is written without error."""
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    sentinel = tmp_path / "migration_v1.done"
    assert sentinel.exists()
    await db.close()


async def test_migration_empty_jobs_dir_writes_sentinel(tmp_path: Path) -> None:
    """When jobs/ dir exists but has no JSON files, sentinel is written."""
    (tmp_path / "jobs").mkdir()
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    sentinel = tmp_path / "migration_v1.done"
    assert sentinel.exists()
    await db.close()


async def test_migration_skips_when_db_already_exists(tmp_path: Path) -> None:
    """If coordination.db already exists (non-fresh start), migration is skipped."""
    # First init creates the DB and sentinel
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    await db.close()

    # Write a JSON job after the DB exists
    record = _make_job_record()
    _write_json_job(tmp_path / "jobs", record)
    # Remove sentinel to test that DB-file presence alone skips migration
    # Actually per spec: sentinel existence skips migration — test that
    assert (tmp_path / "migration_v1.done").exists()

    db2 = CoordinationDB(root=tmp_path)
    await db2.initialize()
    jobs = await db2.list_all_jobs()
    # The job written after the sentinel should NOT be migrated
    assert not any(j.job_id == record.job_id for j in jobs)
    await db2.close()


# ---------------------------------------------------------------------------
# get_db singleton tests
# ---------------------------------------------------------------------------


async def test_get_db_returns_same_instance(tmp_path: Path) -> None:
    """get_db returns the same CoordinationDB object on repeated calls."""
    _reset_db_instance_for_testing()
    try:
        db1 = await get_db(root=tmp_path)
        db2 = await get_db(root=tmp_path)
        assert db1 is db2
    finally:
        if db1._conn is not None:
            await db1.close()
        _reset_db_instance_for_testing()


async def test_get_db_initializes_on_first_call(tmp_path: Path) -> None:
    """get_db creates and initialises the singleton on first call."""
    _reset_db_instance_for_testing()
    try:
        db = await get_db(root=tmp_path)
        assert db._conn is not None
        assert (tmp_path / "coordination.db").exists()
    finally:
        await db.close()
        _reset_db_instance_for_testing()


# ---------------------------------------------------------------------------
# Fleet status test
# ---------------------------------------------------------------------------


async def test_get_fleet_status_aggregates_vms_and_jobs(tmp_db: CoordinationDB) -> None:
    """get_fleet_status returns VMs with their active jobs."""
    from datetime import UTC, datetime

    now = datetime.now(UTC).isoformat()
    entry = VMRegistryEntry(vm_name="fleet-vm", uuid="u1", running=True, last_refreshed=now)
    await tmp_db.upsert_vm(entry)

    record = _make_job_record(vm_name="fleet-vm", status="active")
    await tmp_db.insert_job(record)

    status = await tmp_db.get_fleet_status()
    assert "fleet-vm" in status
    vm_data: FleetVmJobsView = status["fleet-vm"]
    assert isinstance(vm_data, dict)
    assert vm_data["vm"]["vm_name"] == "fleet-vm"
    assert len(vm_data["jobs"]) == 1
