"""Dashboard SQL access layer.

All functions use short-lived aiosqlite connections. No connection pool,
no imports from mcp_vm_blackbox (ADR-002 compliance). Each function opens
and closes its own connection, relying on WAL-mode to handle concurrent
readers and writers safely.
"""

from __future__ import annotations

import json
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

import aiosqlite
import anyio

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator

# ---------------------------------------------------------------------------
# DB path resolution
# ---------------------------------------------------------------------------

# Anchor: store.py → vm_dashboard/ → server/ → dashboard/ → project root
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
_DEFAULT_ROOT = _PROJECT_ROOT / ".mcp-vm-blackbox"


def get_db_path() -> Path:
    """Return the path to the coordination SQLite database.

    Computes the path from the same anchor used by FleetStore so that the
    dashboard and the MCP server always refer to the same file.

    Returns:
        Absolute path to coordination.db.
    """
    return _DEFAULT_ROOT / "coordination.db"


# ---------------------------------------------------------------------------
# Schema version contract (ADR-002: no imports from mcp_vm_blackbox)
# ---------------------------------------------------------------------------

_MIN_SCHEMA_VERSION: int = 2

_REQUIRED_COLUMNS: 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",
        "pilot_context_usage",
    },
    "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",
    },
}


async def validate_schema(db_path: Path) -> None:
    """Raise RuntimeError if the database schema does not satisfy this module's requirements.

    Call from the FastAPI lifespan startup hook before accepting requests.
    Returns silently when the database file does not exist (cold-start case:
    MCP server has not run yet).

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

    Raises:
        RuntimeError: If user_version is below _MIN_SCHEMA_VERSION, a required
            table is missing, or a required column is absent.
    """
    if not await anyio.Path(db_path).exists():
        return

    async with aiosqlite.connect(db_path) as conn:
        cursor = await conn.execute("PRAGMA user_version")
        row = await cursor.fetchone()
        version: int = row[0] if row else 0

        if version < _MIN_SCHEMA_VERSION:
            msg = (
                f"Database schema version {version} is below the required minimum "
                f"{_MIN_SCHEMA_VERSION}. Start the MCP server to apply migrations."
            )
            raise RuntimeError(msg)

        for table, required_cols in _REQUIRED_COLUMNS.items():
            info_cursor = await conn.execute(f"PRAGMA table_info({table})")
            rows = await info_cursor.fetchall()
            if not rows:
                msg = (
                    f"Required table '{table}' not found in database "
                    f"(schema version {version}). "
                    "Start the MCP server to apply migrations."
                )
                raise RuntimeError(msg)
            actual_cols = {row[1] for row in rows}
            missing = required_cols - actual_cols
            if missing:
                msg = (
                    f"Table '{table}' is missing required columns: "
                    f"{sorted(missing)} "
                    f"(database schema version {version}, required minimum {_MIN_SCHEMA_VERSION}). "
                    "Start the MCP server to apply migrations."
                )
                raise RuntimeError(msg)


# ---------------------------------------------------------------------------
# Internal helper
# ---------------------------------------------------------------------------


@asynccontextmanager
async def _open(db_path: Path) -> AsyncGenerator[aiosqlite.Connection, None]:
    """Open a WAL-mode aiosqlite connection with a 5-second busy timeout.

    Yields an open ``aiosqlite.Connection`` with ``row_factory`` set to
    ``aiosqlite.Row``.  WAL mode and busy timeout are applied immediately
    after the connection is established.

    Using ``aiosqlite.connect()`` as an async context manager (without first
    awaiting it) avoids the "threads can only be started once" ``RuntimeError``
    that occurs when the connection's internal thread is started by ``await``
    and then ``__aenter__`` tries to start it a second time.

    Args:
        db_path: Path to the SQLite file.

    Yields:
        Open aiosqlite.Connection configured for WAL mode.
    """
    async with aiosqlite.connect(db_path) as conn:
        conn.row_factory = aiosqlite.Row
        await conn.execute("PRAGMA journal_mode = WAL")
        await conn.execute("PRAGMA busy_timeout = 5000")
        yield conn


# ---------------------------------------------------------------------------
# Fleet
# ---------------------------------------------------------------------------


async def fleet_status(db_path: Path) -> list[dict[str, Any]]:
    """Return aggregated fleet view: all registered VMs with their job summaries.

    All VMs in vm_registry are returned. Idle VMs have active_job_count=0
    and jobs=[]. Active jobs are included (unlike the JSON-file store which
    only captured terminal-state jobs).

    Args:
        db_path: Path to the SQLite database.

    Returns:
        List of dicts, each with shape:
        {
            vm_name, uuid, running, last_refreshed, recording,
            recording_path, recording_started_at,
            jobs: [{job_id, scenario_name, status, last_agent_id, updated_at, pilot_context_usage}],
            active_job_count, total_job_count
        }
    """
    async with _open(db_path) as conn:
        vm_cursor = await conn.execute(
            """
            SELECT vm_name, uuid, running, last_refreshed,
                   recording, recording_path, recording_started_at
            FROM vm_registry
            ORDER BY vm_name
            """
        )
        vms = await vm_cursor.fetchall()

        fleet: list[dict[str, Any]] = []
        for vm in vms:
            vm_name = vm["vm_name"]
            job_cursor = await conn.execute(
                """
                SELECT job_id, scenario_name, status, last_agent_id, updated_at, pilot_context_usage
                FROM jobs
                WHERE vm_name = ?
                ORDER BY updated_at DESC
                """,
                (vm_name,),
            )
            job_rows = await job_cursor.fetchall()
            jobs = [dict(r) for r in job_rows]
            active_count = sum(1 for j in jobs if j.get("status") == "active")
            fleet.append({
                "vm_name": vm_name,
                "uuid": vm["uuid"],
                "running": bool(vm["running"]),
                "last_refreshed": vm["last_refreshed"],
                "recording": bool(vm["recording"]),
                "recording_path": vm["recording_path"],
                "recording_started_at": vm["recording_started_at"],
                "jobs": jobs,
                "active_job_count": active_count,
                "total_job_count": len(jobs),
            })
        return fleet


# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------


async def get_job(db_path: Path, job_id: str) -> dict[str, Any] | None:
    """Look up a job by job_id only and include its step count.

    Both the job SELECT and the step COUNT run inside a single connection
    context to avoid opening two connections for one logical request.

    Args:
        db_path: Path to the SQLite database.
        job_id: The globally unique job identifier (UUID4).

    Returns:
        Dict with all jobs columns plus a step_count field, or None if not found.
    """
    async with _open(db_path) as conn:
        cursor = await conn.execute(
            """
            SELECT job_id, vm_name, scenario_name, created_by,
                   last_agent_id, status, created_at, updated_at
            FROM jobs
            WHERE job_id = ?
            LIMIT 1
            """,
            (job_id,),
        )
        row = await cursor.fetchone()
        if row is None:
            return None

        job = dict(row)

        count_cursor = await conn.execute("SELECT COUNT(*) FROM job_steps WHERE job_id = ?", (job_id,))
        count_row = await count_cursor.fetchone()
        job["step_count"] = count_row[0] if count_row else 0

        return job


# ---------------------------------------------------------------------------
# Steps
# ---------------------------------------------------------------------------


async def list_steps(
    db_path: Path, job_id: str, vm_name: str, limit: int, offset: int
) -> tuple[list[dict[str, Any]], int]:
    """Return a page of job steps and the total step count for the job.

    Args:
        db_path: Path to the SQLite database.
        job_id: Job identifier.
        vm_name: VM name (required for the composite FK lookup).
        limit: Maximum number of steps to return.
        offset: Number of steps to skip from the beginning.

    Returns:
        Tuple of (steps_list, total_count). steps_list contains dicts with
        all job_steps columns. total_count is the full count before pagination.
    """
    async with _open(db_path) as conn:
        count_cursor = await conn.execute(
            "SELECT COUNT(*) FROM job_steps WHERE job_id = ? AND vm_name = ?", (job_id, vm_name)
        )
        count_row = await count_cursor.fetchone()
        total = count_row[0] if count_row else 0

        cursor = await conn.execute(
            """
            SELECT step_id, agent_id, action, timestamp, outcome,
                   intent, reason, outcome_status, outcome_detail,
                   issue_classification, resolution, next, evidence
            FROM job_steps
            WHERE job_id = ? AND vm_name = ?
            ORDER BY step_id ASC
            LIMIT ? OFFSET ?
            """,
            (job_id, vm_name, limit, offset),
        )
        rows = await cursor.fetchall()
        return [dict(r) for r in rows], total


# ---------------------------------------------------------------------------
# Issues
# ---------------------------------------------------------------------------


async def list_issues(db_path: Path, job_id: str, vm_name: str) -> list[dict[str, Any]]:
    """Return all issues registered for a job.

    Args:
        db_path: Path to the SQLite database.
        job_id: Job identifier.
        vm_name: VM name (required for the composite FK lookup).

    Returns:
        List of issue dicts ordered by created_at ascending.
    """
    async with _open(db_path) as conn:
        cursor = await conn.execute(
            """
            SELECT issue_id, job_id, vm_name, classification, severity,
                   summary, detail, resolution, resolution_type,
                   evidence_paths, step_id, created_at, resolved_at
            FROM issues
            WHERE job_id = ? AND vm_name = ?
            ORDER BY created_at ASC
            """,
            (job_id, vm_name),
        )
        rows = await cursor.fetchall()
        results: list[dict[str, Any]] = []
        for r in rows:
            item = dict(r)
            # evidence_paths is stored as JSON text; decode to list
            raw = item.get("evidence_paths")
            if isinstance(raw, str):
                try:
                    item["evidence_paths"] = json.loads(raw)
                except (ValueError, TypeError):
                    item["evidence_paths"] = []
            elif item["evidence_paths"] is None:
                item["evidence_paths"] = []
            results.append(item)
        return results


# ---------------------------------------------------------------------------
# Steering messages
# ---------------------------------------------------------------------------


async def insert_steering_message(
    db_path: Path, job_id: str, vm_name: str, content: str, sender: str, priority: str
) -> str:
    """Insert a steering message into the pending queue.

    Args:
        db_path: Path to the SQLite database.
        job_id: Target job identifier.
        vm_name: Target VM name.
        content: The instruction text.
        sender: Who sent the message (orchestrator | user | scheduled_task).
        priority: Message priority (normal | urgent).

    Returns:
        The generated message_id (UUID4 string).
    """
    message_id = str(uuid.uuid4())
    created_at = datetime.now(UTC).isoformat()
    async with _open(db_path) as conn:
        await conn.execute(
            """
            INSERT INTO steering_messages
                (message_id, job_id, vm_name, content, sender, priority,
                 delivery, created_at)
            VALUES (?, ?, ?, ?, ?, ?, 'hook', ?)
            """,
            (message_id, job_id, vm_name, content, sender, priority, created_at),
        )
        await conn.commit()
    return message_id


# ---------------------------------------------------------------------------
# TODOs
# ---------------------------------------------------------------------------


async def list_todos(db_path: Path, job_id: str, vm_name: str) -> list[dict[str, Any]]:
    """Return all TODO items for a job, ordered by priority then created_at.

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

    Returns:
        List of todo dicts.
    """
    async with _open(db_path) as conn:
        cursor = await conn.execute(
            """
            SELECT item_id, job_id, vm_name, title, description,
                   status, priority, added_by, assigned_pilot,
                   created_at, started_at, completed_at, blocked_by
            FROM todos
            WHERE job_id = ? AND vm_name = ?
            ORDER BY priority ASC, created_at ASC
            """,
            (job_id, vm_name),
        )
        rows = await cursor.fetchall()
        return [dict(r) for r in rows]


async def insert_todo(db_path: Path, todo: dict[str, Any]) -> dict[str, Any]:
    """Insert a new TODO item.

    The caller must supply all required fields. item_id and created_at are
    generated here if not present in todo.

    Args:
        db_path: Path to the SQLite database.
        todo: Dict containing todo fields. Required: job_id, vm_name, title,
            added_by. Optional: description, priority, assigned_pilot,
            blocked_by, item_id, created_at.

    Returns:
        The inserted record as a dict (including generated item_id and timestamps).
    """
    item_id = todo.get("item_id") or str(uuid.uuid4())
    created_at = todo.get("created_at") or datetime.now(UTC).isoformat()
    async with _open(db_path) as conn:
        await conn.execute(
            """
            INSERT INTO todos
                (item_id, job_id, vm_name, title, description,
                 status, priority, added_by, assigned_pilot,
                 created_at, started_at, completed_at, blocked_by)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
            """,
            (
                item_id,
                todo["job_id"],
                todo["vm_name"],
                todo["title"],
                todo.get("description"),
                todo.get("status", "queued"),
                todo.get("priority", 100),
                todo["added_by"],
                todo.get("assigned_pilot"),
                created_at,
                todo.get("blocked_by"),
            ),
        )
        await conn.commit()
        cursor = await conn.execute(
            """
            SELECT item_id, job_id, vm_name, title, description,
                   status, priority, added_by, assigned_pilot,
                   created_at, started_at, completed_at, blocked_by
            FROM todos
            WHERE item_id = ?
            """,
            (item_id,),
        )
        row = await cursor.fetchone()
    if row is None:
        msg = f"Failed to retrieve inserted todo item_id={item_id}"
        raise RuntimeError(msg)
    return dict(row)


async def update_todo(db_path: Path, item_id: str, job_id: str, patch: dict[str, Any]) -> dict[str, Any] | None:
    """Apply a partial update to a TODO item.

    Only fields present in patch are updated. Allowed patch fields:
    title, description, status, priority, assigned_pilot, blocked_by,
    started_at, completed_at.

    Status transition timestamps are applied automatically:
    - started_at is set to now on the first transition to in_progress
      (only if started_at is not already set).
    - completed_at is set to now on transition to done or skipped
      (only if completed_at is not already set).

    Args:
        db_path: Path to the SQLite database.
        item_id: UUID of the todo item to update.
        job_id: Job identifier (used to scope the lookup).
        patch: Dict of fields to update.

    Returns:
        Updated record as a dict, or None if item_id not found for this job.
    """
    allowed = frozenset({
        "title",
        "description",
        "status",
        "priority",
        "assigned_pilot",
        "blocked_by",
        "started_at",
        "completed_at",
    })
    updates = {k: v for k, v in patch.items() if k in allowed}

    async with _open(db_path) as conn:
        # Fetch existing row to check existence and apply transition timestamps
        cursor = await conn.execute(
            """
            SELECT item_id, job_id, vm_name, title, description,
                   status, priority, added_by, assigned_pilot,
                   created_at, started_at, completed_at, blocked_by
            FROM todos
            WHERE item_id = ? AND job_id = ?
            """,
            (item_id, job_id),
        )
        existing = await cursor.fetchone()
        if existing is None:
            return None

        now = datetime.now(UTC).isoformat()
        new_status = updates.get("status")

        # Auto-set started_at on first in_progress transition
        if new_status == "in_progress" and existing["started_at"] is None:
            updates.setdefault("started_at", now)

        # Auto-set completed_at on done/skipped transition
        if new_status in {"done", "skipped"} and existing["completed_at"] is None:
            updates.setdefault("completed_at", now)

        if not updates:
            return dict(existing)

        set_clause = ", ".join(f"{col} = ?" for col in updates)
        values = [*list(updates.values()), item_id, job_id]
        await conn.execute(
            f"UPDATE todos SET {set_clause} WHERE item_id = ? AND job_id = ?",  # noqa: S608
            values,
        )
        await conn.commit()

        cursor = await conn.execute(
            """
            SELECT item_id, job_id, vm_name, title, description,
                   status, priority, added_by, assigned_pilot,
                   created_at, started_at, completed_at, blocked_by
            FROM todos
            WHERE item_id = ? AND job_id = ?
            """,
            (item_id, job_id),
        )
        updated = await cursor.fetchone()

    return dict(updated) if updated is not None else None
