"""Async access layer wrapping db.py for the VM dashboard.

All data-access methods delegate to :mod:`vm_dashboard.db` which uses
short-lived aiosqlite connections against the MCP server's coordination.db.
No JSON file I/O remains — the JSON-backed registry and job files served
the old read-only store; this rewrite uses the live SQLite database.

ADR-002 compliance: this module does NOT import from ``mcp_vm_blackbox``.
"""

from __future__ import annotations

import logging
import operator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from vm_dashboard import db as _db

_log = logging.getLogger(__name__)

# Anchor to the project root (3 levels up from this file:
# store.py → vm_dashboard/ → server/ → dashboard/ → project root)
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
_DEFAULT_DB_PATH = _PROJECT_ROOT / ".mcp-vm-blackbox" / "coordination.db"
_SCRATCH_DIR = _PROJECT_ROOT / "scratch" / "screenshots"


class FleetStore:
    """Async view of the VM registry and job store backed by SQLite.

    All previously synchronous data-access methods (list_vms, list_jobs,
    get_job, fleet_status) are now ``async def`` and delegate to
    :mod:`vm_dashboard.db`.

    Screenshot and timeline scanning remain synchronous because they are
    file-system glob operations, not DB queries.

    KNOWN REGRESSION: The ``_watch_stores()`` file watcher in app.py no
    longer triggers meaningful updates after this rewrite.  SQLite WAL
    writes (``-wal``, ``-shm`` files) cause spurious watch events; actual
    data changes are not represented as meaningful file-system events.
    TanStack Query ``refetchInterval`` (15 s fleet, 10 s jobs) on the
    client provides the pull-based fallback.  A polling-based server-push
    replacement is tracked separately.
    """

    def __init__(
        self, db_path: Path = _DEFAULT_DB_PATH, screenshots_dir: Path = _SCRATCH_DIR, evidence_dir: Path | None = None
    ) -> None:
        """Initialise store with database and file-scan paths.

        Args:
            db_path: Path to the coordination SQLite database.
            screenshots_dir: Directory scanned for pilot screenshot PNGs.
            evidence_dir: Root of the evidence tree used for video discovery.
                Defaults to ``<db_path.parent>/evidence``.
        """
        self._db_path = db_path
        self._screenshots_dir = screenshots_dir
        # Default evidence dir sits next to the DB file in .mcp-vm-blackbox/
        self._evidence_dir = evidence_dir if evidence_dir is not None else db_path.parent / "evidence"

    # ------------------------------------------------------------------
    # VM Registry
    # ------------------------------------------------------------------

    async def list_vms(self) -> list[dict[str, Any]]:
        """Return all registered VMs from the coordination database.

        Returns:
            List of VM dicts, each with at minimum ``vm_name``, ``uuid``,
            ``running``, and ``last_refreshed`` keys.
        """
        fleet = await _db.fleet_status(self._db_path)
        # Return VM-level fields only (strip the embedded jobs list)
        return [
            {
                "vm_name": entry["vm_name"],
                "uuid": entry["uuid"],
                "running": entry["running"],
                "last_refreshed": entry["last_refreshed"],
                "recording": entry.get("recording", False),
                "recording_path": entry.get("recording_path"),
                "recording_started_at": entry.get("recording_started_at"),
            }
            for entry in fleet
        ]

    # ------------------------------------------------------------------
    # Job Store
    # ------------------------------------------------------------------

    async def list_jobs(self, vm_name: str | None = None) -> list[dict[str, Any]]:
        """Return job records, optionally filtered by VM name.

        Args:
            vm_name: If supplied, only return jobs for this VM.

        Returns:
            List of job dicts ordered by ``updated_at`` descending.
        """
        fleet = await _db.fleet_status(self._db_path)
        results: list[dict[str, Any]] = []
        for entry in fleet:
            if vm_name and entry["vm_name"] != vm_name:
                continue
            results.extend(entry["jobs"])
        return results

    async def get_job(self, job_id: str) -> dict[str, Any] | None:
        """Return a single job record by job_id, or None if not found.

        Args:
            job_id: The globally unique job identifier (UUID4).

        Returns:
            Job dict with all columns plus ``step_count``, or None.
        """
        return await _db.get_job(self._db_path, job_id)

    # ------------------------------------------------------------------
    # Fleet Status (aggregated view)
    # ------------------------------------------------------------------

    async def fleet_status(self) -> list[dict[str, Any]]:
        """Return fleet status: each VM with its associated jobs.

        All VMs in the registry are returned.  Idle VMs have
        ``active_job_count=0`` and ``jobs=[]``.

        Returns:
            List of fleet-entry dicts matching the PRD fleet shape.
        """
        return await _db.fleet_status(self._db_path)

    # ------------------------------------------------------------------
    # Steps (paginated)
    # ------------------------------------------------------------------

    async def get_steps_paginated(
        self, 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.

        Args:
            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.

        Returns:
            Tuple of (steps_list, total_count).
        """
        return await _db.list_steps(self._db_path, job_id, vm_name, limit, offset)

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

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

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            List of issue dicts ordered by ``created_at`` ascending.
        """
        return await _db.list_issues(self._db_path, job_id, vm_name)

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

    async def insert_steering_message(self, job_id: str, vm_name: str, content: str, sender: str, priority: str) -> str:
        """Enqueue a steering message for a pilot job.

        Args:
            job_id: Target job identifier.
            vm_name: Target VM name.
            content: The instruction text to deliver to the pilot.
            sender: Who is sending the message (orchestrator | user | scheduled_task).
            priority: Message priority (normal | urgent).

        Returns:
            The generated ``message_id`` (UUID4 string).
        """
        return await _db.insert_steering_message(self._db_path, job_id, vm_name, content, sender, priority)

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

    async def list_todos(self, job_id: str, vm_name: str) -> list[dict[str, Any]]:
        """Return all TODO items for a job.

        Args:
            job_id: Job identifier.
            vm_name: VM name.

        Returns:
            List of todo dicts ordered by priority ascending then created_at.
        """
        return await _db.list_todos(self._db_path, job_id, vm_name)

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

        Args:
            job_id: Job identifier.
            vm_name: VM name.
            todo: Todo fields dict.  Must include ``title`` and ``added_by``.
                ``job_id`` and ``vm_name`` are injected from the method arguments.

        Returns:
            The inserted record as a dict (includes generated ``item_id``).
        """
        todo_with_scope = {**todo, "job_id": job_id, "vm_name": vm_name}
        return await _db.insert_todo(self._db_path, todo_with_scope)

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

        Args:
            item_id: UUID of the todo item to update.
            job_id: Job identifier (used to scope the lookup).
            vm_name: VM name (unused in the DB call but required for
                consistency with the two-query pattern — callers must already
                have resolved vm_name before calling this method).
            patch: Dict of fields to update (allowed: title, description,
                status, priority, assigned_pilot, blocked_by, started_at,
                completed_at).

        Returns:
            Updated record as a dict, or None if not found.
        """
        return await _db.update_todo(self._db_path, item_id, job_id, patch)

    async def reorder_todos(self, job_id: str, vm_name: str, ordered_ids: list[str]) -> None:
        """Reorder TODO items by assigning sequential priority values.

        Priority is assigned as ``index * 10`` (0, 10, 20, …) matching the
        MCP tool convention in coordination_db.py. Items not present in
        ``ordered_ids`` are left unchanged.

        Args:
            job_id: Job identifier.
            vm_name: VM name.
            ordered_ids: List of ``item_id`` values in the desired order.
        """
        for index, item_id in enumerate(ordered_ids):
            await _db.update_todo(self._db_path, item_id, job_id, {"priority": index * 10})

    # ------------------------------------------------------------------
    # Screenshots (sync — file scan only)
    # ------------------------------------------------------------------

    def latest_screenshot(self, vm_name: str) -> Path | None:
        """Return the path to the most recent screenshot for a VM, or None.

        Scans ``scratch/screenshots/{vm_name}-*.png`` by mtime descending
        (Q1 decision: scratch directory only).

        Args:
            vm_name: VM name to search for.

        Returns:
            Path to the most recent PNG, or None if none found.
        """
        if not self._screenshots_dir.exists():
            return None
        with_stat = [(p, p.stat()) for p in self._screenshots_dir.glob(f"{vm_name}-*.png")]
        if not with_stat:
            return None
        with_stat.sort(key=lambda ps: ps[1].st_mtime, reverse=True)
        return with_stat[0][0]

    def list_screenshots(self, vm_name: str, limit: int = 10) -> list[dict[str, Any]]:
        """Return metadata for recent screenshots of a VM.

        Args:
            vm_name: VM name to search for.
            limit: Maximum number of results to return.

        Returns:
            List of dicts with filename, path, size_bytes, and modified_at.
        """
        if not self._screenshots_dir.exists():
            return []
        with_stat = [(p, p.stat()) for p in self._screenshots_dir.glob(f"{vm_name}-*.png")]
        with_stat.sort(key=lambda ps: ps[1].st_mtime, reverse=True)
        return [
            {
                "filename": p.name,
                "path": str(p),
                "size_bytes": s.st_size,
                "modified_at": datetime.fromtimestamp(s.st_mtime, tz=UTC).isoformat(),
            }
            for p, s in with_stat[:limit]
        ]

    # ------------------------------------------------------------------
    # Timeline / Video Discovery (sync — file scan only)
    # ------------------------------------------------------------------

    # Expected path depth: {vm_name}/{vm_name}/{job_id}/render_staging/{filename}.mp4
    _TIMELINE_PATH_MIN_DEPTH = 5

    def get_timeline_records(self) -> list[dict[str, Any]]:
        """Scan evidence directories for MP4 files and return timeline records.

        Looks for files matching pattern:
        ``evidence/{vm_name}/{vm_name}/{job_id}/render_staging/*.mp4``

        Returns:
            Records sorted by ``created_at`` descending.
        """
        if not self._evidence_dir.exists():
            return []

        records: list[dict[str, Any]] = []

        try:
            mp4_files = list(self._evidence_dir.glob("*/*/*/render_staging/*.mp4"))
        except OSError:
            _log.warning("Failed to scan evidence directory %s", self._evidence_dir, exc_info=True)
            return []

        for mp4_path in mp4_files:
            try:
                rel_path = mp4_path.relative_to(self._evidence_dir)
                parts = rel_path.parts

                if len(parts) < self._TIMELINE_PATH_MIN_DEPTH:
                    _log.warning("Unexpected MP4 path structure: %s", mp4_path)
                    continue

                vm_name = parts[0]
                job_id = parts[2]

                scenario = job_id.split(":")[0] if ":" in job_id else job_id

                stat = mp4_path.stat()
                created_at = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()

                render_staging_path = str(Path(*parts[:4]))

                records.append({
                    "job_id": job_id,
                    "vm_name": vm_name,
                    "scenario": scenario,
                    "created_at": created_at,
                    "video_path": str(mp4_path),
                    "render_staging_path": render_staging_path,
                    "duration_seconds": None,
                    "step_count": 0,
                })
            except (OSError, ValueError):
                _log.warning("Failed to process MP4 file %s", mp4_path, exc_info=True)
                continue

        records.sort(key=operator.itemgetter("created_at"), reverse=True)
        return records

    # ------------------------------------------------------------------
    # Watch paths (for file watcher)
    # ------------------------------------------------------------------

    @property
    def watch_paths(self) -> list[Path]:
        """Return directories the file watcher should monitor.

        Includes the DB directory so the watcher is at least aware of WAL
        file events.  Note the KNOWN REGRESSION documented in the class
        docstring: SQLite WAL writes do not map cleanly to data-change events.

        Returns:
            List of existing Path objects to watch.
        """
        paths: list[Path] = []
        db_dir = self._db_path.parent
        if db_dir.exists():
            paths.append(db_dir)
        if self._screenshots_dir.exists():
            paths.append(self._screenshots_dir)
        return paths
