"""VM Fleet Dashboard — FastAPI application.

Serves the fleet API, screenshot proxy, WebSocket live updates,
and the static React client (when built).
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

import httpx
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from vm_dashboard.db import get_db_path, validate_schema
from vm_dashboard.store import FleetStore

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Store singleton
# ---------------------------------------------------------------------------

store = FleetStore()

# ---------------------------------------------------------------------------
# WebSocket manager
# ---------------------------------------------------------------------------


class ConnectionManager:
    """Track active WebSocket connections and broadcast updates."""

    def __init__(self) -> None:
        """Initialise an empty connection set."""
        self._connections: list[WebSocket] = []

    async def connect(self, ws: WebSocket) -> None:
        """Accept and register a WebSocket connection."""
        await ws.accept()
        self._connections.append(ws)
        _log.info("WebSocket connected (%d total)", len(self._connections))

    def disconnect(self, ws: WebSocket) -> None:
        """Remove a WebSocket connection."""
        self._connections = [c for c in self._connections if c is not ws]
        _log.info("WebSocket disconnected (%d remaining)", len(self._connections))

    async def broadcast(self, event_type: str, data: object) -> None:
        """Send a JSON message to all connected clients."""
        if not self._connections:
            return
        msg = json.dumps({"type": event_type, "data": data})
        stale: list[WebSocket] = []
        for ws in self._connections:
            try:
                await ws.send_text(msg)
            except Exception:  # noqa: BLE001
                stale.append(ws)
        for ws in stale:
            self.disconnect(ws)


manager = ConnectionManager()

# ---------------------------------------------------------------------------
# File watcher (background task)
# ---------------------------------------------------------------------------


async def _watch_stores() -> None:
    """Watch file stores for changes and broadcast updates via WebSocket.

    KNOWN REGRESSION: After the FleetStore rewrite to SQLite-backed async
    queries, this file watcher no longer triggers meaningful fleet_updated
    events.  SQLite WAL writes (-wal, -shm files) cause spurious watch events
    that do not correspond to actual data changes.  TanStack Query
    refetchInterval (15 s fleet, 10 s jobs) on the client provides a
    pull-based fallback.  A polling-based server-push replacement is tracked
    separately.
    """
    try:
        from watchfiles import awatch  # noqa: PLC0415
    except ImportError:
        _log.warning("watchfiles not installed — live updates disabled")
        return

    watch_paths = store.watch_paths
    if not watch_paths:
        _log.info("No store paths exist yet — watcher will retry in 10s")
        await asyncio.sleep(10)
        watch_paths = store.watch_paths
        if not watch_paths:
            _log.warning("Still no store paths — watcher disabled")
            return

    _log.info("Watching %s for changes", [str(p) for p in watch_paths])
    async for _changes in awatch(*watch_paths):
        # On any file change, broadcast the full fleet status.
        # NOTE: Due to the KNOWN REGRESSION above, these broadcasts will fire
        # on WAL file events, not on actual data changes.  This is accepted
        # behaviour; the client will simply receive a refresh trigger.
        try:
            fleet = await store.fleet_status()
            await manager.broadcast("fleet_updated", fleet)
        except Exception:  # noqa: BLE001
            _log.warning("Error broadcasting fleet update", exc_info=True)


# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------


@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
    """Start the file watcher on startup, cancel on shutdown."""
    await validate_schema(get_db_path())
    task = asyncio.create_task(_watch_stores())
    _log.info("Dashboard server starting on http://localhost:4821")
    yield
    task.cancel()
    with contextlib.suppress(asyncio.CancelledError):
        await task


app = FastAPI(title="VM Fleet Dashboard", version="0.1.0", lifespan=lifespan)
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]
)


# ---------------------------------------------------------------------------
# Pydantic request models
# ---------------------------------------------------------------------------


class SteerRequest(BaseModel):
    """Request body for POST /api/jobs/{job_id}/steer.

    Attributes:
        content: The instruction text to deliver to the pilot.
        priority: Message urgency — normal or urgent.
        sender: Who is sending — orchestrator, user, or scheduled_task.
    """

    content: str
    priority: Literal["normal", "urgent"] = "normal"
    sender: Literal["orchestrator", "user", "scheduled_task"] = "user"


class TodoCreateRequest(BaseModel):
    """Request body for POST /api/jobs/{job_id}/todo.

    Attributes:
        title: Short description of the task.
        description: Extended detail (optional).
        priority: Integer priority; lower value = higher priority.
        added_by: Who created the item.
        assigned_pilot: Agent ID responsible for this item (optional).
    """

    title: str
    description: str | None = None
    priority: int = 100
    added_by: Literal["orchestrator", "user", "scheduled_task"] = "user"
    assigned_pilot: str | None = None


class TodoPatchRequest(BaseModel):
    """Request body for PATCH /api/jobs/{job_id}/todo/{item_id}.

    All fields are optional; only supplied fields are updated.

    Attributes:
        title: New title (optional).
        description: New description (optional).
        status: New status value (optional).
        priority: New integer priority (optional).
        assigned_pilot: Reassign to another agent (optional).
        blocked_by: Free-text note on what is blocking this item (optional).
    """

    title: str | None = None
    description: str | None = None
    status: Literal["queued", "in_progress", "done", "blocked", "skipped"] | None = None
    priority: int | None = None
    assigned_pilot: str | None = None
    blocked_by: str | None = None


class ReorderRequest(BaseModel):
    """Request body for PUT /api/jobs/{job_id}/todo/reorder.

    Attributes:
        ordered_ids: Ordered list of item_id values defining the new priority sequence.
            Priorities are assigned as index * 10 (0, 10, 20, …).
    """

    ordered_ids: list[str] = Field(..., min_length=1)


# ---------------------------------------------------------------------------
# API routes
# ---------------------------------------------------------------------------


@app.get("/api/fleet")
async def api_fleet() -> JSONResponse:
    """Return fleet status: all VMs with their jobs."""
    return JSONResponse(await store.fleet_status())


@app.get("/api/vms")
async def api_vms() -> JSONResponse:
    """Return all known VMs from the registry."""
    return JSONResponse(await store.list_vms())


@app.get("/api/jobs")
async def api_jobs(vm_name: str | None = None) -> JSONResponse:
    """Return job records, optionally filtered by VM name."""
    return JSONResponse(await store.list_jobs(vm_name))


@app.get("/api/jobs/{job_id}/steps")
async def api_job_steps(job_id: str, limit: int = 50, offset: int = 0) -> JSONResponse:
    """Return a paginated page of steps for a job.

    Args:
        job_id: Job identifier.
        limit: Maximum steps to return (clamped to 200).
        offset: Number of steps to skip from the start.

    Returns:
        JSONResponse with items, total, limit, offset, has_more.
    """
    limit = min(limit, 200)
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    items, total = await store.get_steps_paginated(job_id, job["vm_name"], limit, offset)
    return JSONResponse({
        "items": items,
        "total": total,
        "limit": limit,
        "offset": offset,
        "has_more": offset + len(items) < total,
    })


@app.get("/api/jobs/{job_id}/issues")
async def api_job_issues(job_id: str) -> JSONResponse:
    """Return all issues registered for a job.

    Args:
        job_id: Job identifier.

    Returns:
        JSONResponse with items list and total count.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    issues = await store.list_issues(job_id, job["vm_name"])
    return JSONResponse({"items": issues, "total": len(issues)})


@app.post("/api/jobs/{job_id}/steer")
async def api_job_steer(job_id: str, body: SteerRequest) -> JSONResponse:
    """Enqueue a steering message for a running pilot job.

    Args:
        job_id: Target job identifier.
        body: SteerRequest with content, priority, and sender.

    Returns:
        202 JSONResponse with status and message_id, or 404 if job not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    message_id = await store.insert_steering_message(
        job_id=job_id, vm_name=job["vm_name"], content=body.content, sender=body.sender, priority=body.priority
    )
    await manager.broadcast(
        "steering_message",
        {
            "job_id": job_id,
            "message_id": message_id,
            "content": body.content,
            "priority": body.priority,
            "sender": body.sender,
        },
    )
    return JSONResponse({"status": "queued", "message_id": message_id}, status_code=202)


@app.get("/api/jobs/{job_id}/todo")
async def api_job_todo_list(job_id: str) -> JSONResponse:
    """Return all TODO items for a job.

    Args:
        job_id: Job identifier.

    Returns:
        JSONResponse with items list and total count, or 404 if job not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    items = await store.list_todos(job_id, job["vm_name"])
    return JSONResponse({"items": items, "total": len(items)})


@app.post("/api/jobs/{job_id}/todo")
async def api_job_todo_create(job_id: str, body: TodoCreateRequest) -> JSONResponse:
    """Create a new TODO item for a job and broadcast todo_updated.

    Args:
        job_id: Job identifier.
        body: TodoCreateRequest with title, description, priority, added_by.

    Returns:
        201 JSONResponse with the created TodoItem, or 404 if job not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    vm_name = job["vm_name"]
    todo_fields = body.model_dump()
    item = await store.insert_todo(job_id, vm_name, todo_fields)
    # Broadcast full list after mutation (Q4 decision: full list replacement)
    all_items = await store.list_todos(job_id, vm_name)
    await manager.broadcast("todo_updated", {"job_id": job_id, "items": all_items, "total": len(all_items)})
    return JSONResponse(item, status_code=201)


@app.patch("/api/jobs/{job_id}/todo/{item_id}")
async def api_job_todo_update(job_id: str, item_id: str, body: TodoPatchRequest) -> JSONResponse:
    """Apply a partial update to a TODO item and broadcast todo_updated.

    Args:
        job_id: Job identifier.
        item_id: UUID of the TODO item to update.
        body: TodoPatchRequest with fields to update (all optional).

    Returns:
        200 JSONResponse with the updated TodoItem, 404 if job or item not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    vm_name = job["vm_name"]
    # Use exclude_unset so only fields the client sent are patched,
    # while explicit null values (e.g. clearing assigned_pilot) pass through.
    patch = body.model_dump(exclude_unset=True)
    updated = await store.update_todo(item_id, job_id, vm_name, patch)
    if updated is None:
        return JSONResponse({"error": f"Todo item not found: {item_id}"}, status_code=404)
    # Broadcast full list after mutation
    all_items = await store.list_todos(job_id, vm_name)
    await manager.broadcast("todo_updated", {"job_id": job_id, "items": all_items, "total": len(all_items)})
    return JSONResponse(updated)


@app.put("/api/jobs/{job_id}/todo/reorder")
async def api_job_todo_reorder(job_id: str, body: ReorderRequest) -> JSONResponse:
    """Reorder TODO items for a job and broadcast todo_updated.

    Reassigns priorities as ``index * 10`` (0, 10, 20, …) for each item_id
    in ``body.ordered_ids``. Items not present in the list are left unchanged.

    Args:
        job_id: Job identifier.
        body: ReorderRequest with ordered_ids list (minimum 1 item).

    Returns:
        200 JSONResponse with reordered items list and total count, or 404 if job not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    vm_name = job["vm_name"]
    current_items = await store.list_todos(job_id, vm_name)
    current_order = [item["item_id"] for item in current_items]
    if body.ordered_ids == current_order:
        return JSONResponse({"items": current_items, "total": len(current_items)})
    await store.reorder_todos(job_id, vm_name, body.ordered_ids)
    all_items = await store.list_todos(job_id, vm_name)
    await manager.broadcast("todo_updated", {"job_id": job_id, "items": all_items, "total": len(all_items)})
    return JSONResponse({"items": all_items, "total": len(all_items)})


@app.get("/api/jobs/{job_id}")
async def api_job_detail(job_id: str) -> JSONResponse:
    """Return a single job record by job_id.

    Args:
        job_id: Globally unique job identifier (UUID4).

    Returns:
        JSONResponse with job metadata and step_count, or 404 if not found.
    """
    job = await store.get_job(job_id)
    if job is None:
        return JSONResponse({"error": f"Job not found: {job_id}"}, status_code=404)
    return JSONResponse(job)


@app.get("/api/vms/{vm_name}/screenshots/latest", response_model=None)
async def api_vm_screenshot_latest(vm_name: str) -> Response:
    """Serve the latest screenshot PNG for a VM.

    Args:
        vm_name: VM name to look up the screenshot for.

    Returns:
        FileResponse with image/png, or 404 JSON if no screenshot found.
    """
    path = store.latest_screenshot(vm_name)
    if path is None:
        return JSONResponse({"error": f"No screenshot found for VM: {vm_name}"}, status_code=404)
    return FileResponse(path, media_type="image/png")


@app.get("/api/stats")
async def api_stats() -> JSONResponse:
    """Return summary statistics for the dashboard."""
    fleet = await store.fleet_status()
    total_vms = len(fleet)
    running_vms = sum(1 for v in fleet if v.get("running"))
    all_jobs = await store.list_jobs()
    active_jobs = sum(1 for j in all_jobs if j.get("status") == "active")
    paused_jobs = sum(1 for j in all_jobs if j.get("status") == "paused")

    return JSONResponse({
        "total_vms": total_vms,
        "running_vms": running_vms,
        "total_jobs": len(all_jobs),
        "active_jobs": active_jobs,
        "paused_jobs": paused_jobs,
        "completed_jobs": sum(1 for j in all_jobs if j.get("status") == "completed"),
        "failed_jobs": sum(1 for j in all_jobs if j.get("status") == "failed"),
        "ws_connections": len(manager._connections),  # noqa: SLF001
    })


@app.get("/api/timeline")
async def api_timeline() -> JSONResponse:
    """Return timeline records for all discovered videos."""
    return JSONResponse(store.get_timeline_records())


@app.get("/api/video/{vm_name}/{job_id}", response_model=None)
async def api_video(vm_name: str, job_id: str) -> Response:
    """Serve the MP4 video for a specific VM and job."""
    # Scan evidence_dir/{vm_name}/*/{job_id}/render_staging/*.mp4 (first match)
    evidence_dir = store._evidence_dir  # noqa: SLF001
    if not evidence_dir.exists():
        return JSONResponse({"error": f"No evidence directory found for VM '{vm_name}'"}, status_code=404)

    # Pattern: {vm_name}/*/{job_id}/render_staging/*.mp4
    pattern = f"{vm_name}/*/{job_id}/render_staging/*.mp4"
    try:
        matches = list(evidence_dir.glob(pattern))
    except OSError:
        return JSONResponse({"error": f"Failed to scan video directory for VM '{vm_name}'"}, status_code=404)

    if not matches:
        return JSONResponse({"error": f"No video found for VM '{vm_name}' job '{job_id}'"}, status_code=404)

    # Return the first match
    return FileResponse(matches[0], media_type="video/mp4")


# ---------------------------------------------------------------------------
# Event ingest (MCP tool → WebSocket bridge)
# ---------------------------------------------------------------------------


class IngestEventType(StrEnum):
    """Event types accepted by the ingest endpoint."""

    job_created = "job_created"
    job_updated = "job_updated"
    step_added = "step_added"
    screenshot_captured = "screenshot_captured"
    vm_state_changed = "vm_state_changed"
    fleet_status = "fleet_status"
    inspector_report = "inspector_report"
    todo_updated = "todo_updated"
    steering_message = "steering_message"


class IngestEventPayload(BaseModel):
    """Payload for POST /api/events/ingest.

    Attributes:
        event_type: Discriminator for the event kind.
        vm_name: VM name associated with the event, if any.
        job_id: Job identifier associated with the event, if any.
        data: Arbitrary event-specific payload.
        timestamp: ISO-8601 timestamp; empty string means server will use receipt time.
    """

    event_type: IngestEventType
    vm_name: str = ""
    job_id: str = ""
    data: dict[str, Any] = Field(default_factory=dict)
    timestamp: str = ""


@app.post("/api/events/ingest", status_code=202)
async def ingest_event(event: IngestEventPayload) -> JSONResponse:
    """Receive an event from MCP tools and broadcast to WebSocket clients."""
    await manager.broadcast(event.event_type, event.model_dump())
    return JSONResponse(
        {
            "status": "accepted",
            "event_type": event.event_type,
            "clients": len(manager._connections),  # noqa: SLF001
        },
        status_code=202,
    )


class AnTokenRequest(BaseModel):
    """Optional body for the /api/an-token endpoint.

    Attributes:
        agent: Agent slug to scope the token to.
        userId: Optional user identifier forwarded to the relay.
    """

    agent: str | None = None
    userId: str | None = None


@app.post("/api/an-token")
async def api_an_token(body: AnTokenRequest | None = None) -> JSONResponse:
    """Exchange API_KEY_21ST for a short-lived JWT via the 21st Agents relay.

    Args:
        body: Optional request body with agent slug and userId.

    Returns:
        JSONResponse with the relay token payload on success, or an error
        payload with status 500 on failure.
    """
    api_key = os.environ.get("API_KEY_21ST")
    if not api_key:
        return JSONResponse({"error": "API_KEY_21ST not configured"}, status_code=500)

    agents = [body.agent] if body and body.agent else None
    user_id = body.userId if body else None

    payload: dict[str, Any] = {"expiresIn": "1h"}
    if agents is not None:
        payload["agents"] = agents
    if user_id is not None:
        payload["userId"] = user_id

    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://relay.an.dev/v1/tokens",
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=payload,
        )

    if not response.is_success:
        try:
            error_data = response.json()
            error_msg = error_data.get("error") or error_data.get("message") or response.text
        except Exception:  # noqa: BLE001
            error_msg = response.text
        return JSONResponse({"error": error_msg}, status_code=500)

    return JSONResponse(response.json())


# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------


@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
    """WebSocket endpoint for live dashboard updates."""
    await manager.connect(ws)
    try:
        # Send initial fleet status on connect
        fleet = await store.fleet_status()
        await ws.send_text(json.dumps({"type": "fleet_updated", "data": fleet}))
        # Keep alive — client can send pings
        while True:
            await ws.receive_text()
    except WebSocketDisconnect:
        manager.disconnect(ws)


# ---------------------------------------------------------------------------
# Static file serving (React client build)
# ---------------------------------------------------------------------------

_CLIENT_BUILD = Path(__file__).parent.parent.parent / "client" / "dist"
if _CLIENT_BUILD.exists():
    app.mount("/assets", StaticFiles(directory=_CLIENT_BUILD / "assets"), name="assets")

    @app.get("/{full_path:path}")
    async def serve_spa(full_path: str) -> FileResponse:
        """Serve the React SPA for all non-API routes."""
        return FileResponse(_CLIENT_BUILD / "index.html")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> None:
    """Run the dashboard server."""
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s: %(message)s")
    uvicorn.run(
        "vm_dashboard.app:app",
        host="0.0.0.0",  # noqa: S104
        port=4821,
        reload=False,
        log_level="info",
    )


if __name__ == "__main__":
    main()
