"""`bggpipe review --web` — the review TUI's local web face.

FastAPI + one self-contained HTML page (inline CSS/JS, no build step),
served on localhost only. All decision logic and matches.csv writes go
through ReviewSession — this module is purely an interface. Also renders
data/unidentified.json as reshoot work-orders with a persisted dismiss
action (data/unidentified_dismissed.json survives extract rebuilds).

The page layout is shared-shell by design: a future "browse" view of
games.json mounts as a sibling section without touching the review code.
"""

from __future__ import annotations

import io
import json
import os
import threading
import warnings
import xml.etree.ElementTree as ET
from collections import Counter
from importlib import resources
from pathlib import Path

import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, Response
from pydantic import BaseModel
from rich.console import Console

from bggpipe.bgg_client import BGGClient, cached_paths
from bggpipe.config import DEFAULT_REVIEW_PORT, Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import (
    CONFIDENT_VERSION_STATUSES,
    RECOGNIZED_MATCH_STATUSES,
)
from bggpipe.review import ReviewSession


def load_thumbnails(cache_dir: Path) -> dict[int, str]:
    """bgg_id -> thumbnail URL, from cached /thing XML only (no live calls).
    Stub fixtures carry no thumbnails — the UI shows placeholders then."""
    thumbnails: dict[int, str] = {}
    if not cache_dir.is_dir():
        return thumbnails
    unreadable = []
    for path in cached_paths(cache_dir, "thing"):
        try:
            root = _safe_fromstring(path.read_text())
            for item in root.findall("item"):
                thumb = (item.findtext("thumbnail") or "").strip()
                if thumb and item.get("id"):
                    thumbnails[int(item.get("id"))] = thumb
        except (ET.ParseError, OSError, ValueError):
            # cosmetic degradation is fine — eating the evidence is not:
            # this same file will crash resolve/review later if served
            unreadable.append(path.name)
    if unreadable:
        warnings.warn(
            f"{len(unreadable)} unreadable cache file(s) — thumbnails "
            f"missing; delete to refetch: {', '.join(unreadable[:3])}",
            stacklevel=2,
        )
    return thumbnails


def _ix_of(rows: list[dict], row: dict) -> int:
    """Index by IDENTITY: list.index compares by ==, which returns the
    first of two equal duplicate rows for both — defeating the ordinal
    disambiguation row_ix exists to provide."""
    return next(i for i, r in enumerate(rows) if r is row)


def _sighting_key(photo: str, sighting: dict) -> str:
    return "|".join(
        [
            photo,
            sighting.get("location", ""),
            sighting.get("partial_text", ""),
            sighting.get("art_notes", ""),
        ]
    )


class DismissStore:
    """Dismissed reshoot sightings, kept apart from unidentified.json so
    extract's rebuilds can't resurrect them."""

    def __init__(self, path: Path) -> None:
        self.path = path
        self.keys: set[str] = set()
        if path.exists():
            try:
                self.keys = set(json.loads(path.read_text()))
            except (json.JSONDecodeError, OSError) as err:
                # a torn write must not brick the server; quarantine and go on
                quarantine = path.with_name(path.name + ".corrupt")
                path.rename(quarantine)
                warnings.warn(
                    f"{path} was unreadable ({err}) — moved to {quarantine}; "
                    "previously dismissed tickets will reappear",
                    stacklevel=2,
                )

    def add(self, key: str) -> None:
        # write first, mutate after: a failed write must leave the ticket
        # visible (memory never claims what disk doesn't hold)
        updated = sorted(self.keys | {key})
        atomic_write_text(self.path, json.dumps(updated, indent=2) + "\n")
        self.keys.add(key)


class DecisionBody(BaseModel):
    title_raw: str
    source_photos: str
    row_ix: int | None = None
    action: str  # "pick" | "manual" | "reject"
    bgg_id: int | None = None


class VersionBody(BaseModel):
    title_raw: str
    source_photos: str
    row_ix: int | None = None
    action: str  # "pick" | "unknown"
    version_id: int | None = None


class VetoBody(BaseModel):
    title_raw: str
    source_photos: str
    row_ix: int | None = None


class DismissBody(BaseModel):
    photo: str
    location: str = ""
    partial_text: str = ""
    art_notes: str = ""


def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
    app = FastAPI(title="bggpipe review")
    session = ReviewSession(
        cfg,
        console=Console(file=io.StringIO()),
        input_fn=lambda prompt: "",
        client=client,
    )
    thumbnails = load_thumbnails(cfg.cache_dir)
    dismissed = DismissStore(cfg.dismissed_path)
    # FastAPI runs sync endpoints in a threadpool: without this, a polled
    # GET /api/state can freshen()-swap session.rows out from under a
    # concurrent decision POST, silently dropping the decision.
    lock = threading.Lock()
    revision = {"n": 0}  # bumped on every mutation and reload

    def freshen() -> None:
        """Serve every request from the current file state: an extract or
        resolve run in another terminal must show up without a restart."""
        nonlocal thumbnails
        if session.reload_if_changed():
            revision["n"] += 1
            thumbnails = load_thumbnails(cfg.cache_dir)

    def find_row(title_raw: str, source_photos: str, row_ix: int | None = None) -> dict:
        freshen()
        row = session.find_row(title_raw, source_photos, row_ix)
        if row is None:
            raise HTTPException(404, "row not found — matches.csv changed underneath?")
        return row

    def photo_names() -> set[str]:
        if not cfg.photos_dir.is_dir():
            return set()
        return {p.name for p in cfg.photos_dir.iterdir() if p.is_file()}

    def row_payload(row: dict) -> dict:
        entry = session.cues_for(row["title_raw"], row["source_photos"])
        available = photo_names()
        candidates = json.loads(row["candidates_json"] or "[]")
        for c in candidates:
            c["thumbnail"] = thumbnails.get(c.get("bgg_id"))
        return {
            "row_ix": _ix_of(session.rows, row),
            "title_raw": row["title_raw"],
            "source_photos": row["source_photos"],
            "match_status": row["match_status"],
            "photos": [p for p in row["source_photos"].split(";") if p in available],
            "cues": {
                "publisher": entry.publisher_hint if entry else "",
                "edition": entry.edition_hint if entry else "",
                "year": entry.year_hint if entry else None,
                "language": entry.language_hint if entry else "",
                "art_notes": entry.art_notes if entry else "",
            },
            "candidates": candidates,
        }

    def version_payload(row: dict) -> dict:
        return {
            "row_ix": _ix_of(session.rows, row),
            "title_raw": row["title_raw"],
            "source_photos": row["source_photos"],
            "bgg_name": row["bgg_name"],
            "candidates": json.loads(row["version_candidates_json"] or "[]"),
        }

    def state() -> dict:
        freshen()
        counts = Counter(row["match_status"] for row in session.rows)
        resolved_titles = {r["title_raw"] for r in session.rows}
        rows_by_title: dict[str, list[dict]] = {}
        for r in session.rows:
            rows_by_title.setdefault(r["title_raw"], []).append(r)
        title_seen: Counter[str] = Counter()
        catalog = []
        for entry in session.titles:
            same_title = rows_by_title.get(entry.title_raw, [])
            ix = title_seen[entry.title_raw]
            title_seen[entry.title_raw] += 1
            # positional pairing, same rule as run_resolve: the ix-th entry
            # of a title reports the ix-th row of that title
            row = same_title[ix] if ix < len(same_title) else None
            catalog.append(
                {
                    "title_raw": entry.title_raw,
                    "confidence": entry.confidence,
                    "photos": list(entry.source_photos),
                    "status": row["match_status"] if row else "awaiting_resolve",
                    "bgg_id": row["bgg_id"] if row else "",
                    "bgg_name": row["bgg_name"] if row else "",
                    "version_name": row["version_name"] if row else "",
                    "merged_into": row.get("merged_into", "") if row else "",
                }
            )
        unresolved_count = sum(
            1 for e in session.titles if e.title_raw not in resolved_titles
        )
        version_updates = sum(
            1
            for r in session.rows
            if r["version_status"] in CONFIDENT_VERSION_STATUSES and r["version_id"]
        )
        available = photo_names()
        sightings = []
        if cfg.unidentified_path.exists():
            for photo, entries in json.loads(cfg.unidentified_path.read_text()).items():
                for s in entries:
                    if _sighting_key(photo, s) in dismissed.keys:
                        continue
                    sightings.append(
                        {**s, "photo": photo, "photo_exists": photo in available}
                    )
        merges = [
            {
                "row_ix": _ix_of(session.rows, r),
                "title_raw": r["title_raw"],
                "source_photos": r["source_photos"],
                "merged_into": r.get("merged_into", ""),
                "bgg_name": r["bgg_name"],
                "bgg_id": r["bgg_id"],
            }
            for r in session.merged_rows()
        ]
        return {
            "revision": revision["n"],
            "warnings": session.warnings[-10:],
            "pending": [row_payload(r) for r in session.pending_rows()],
            "versions": [version_payload(r) for r in session.version_rows()],
            "merges": merges,
            "unidentified": sightings,
            "catalog": catalog,
            "decisions": session.decisions,
            "summary": {
                "recognized": sum(counts.get(s, 0) for s in RECOGNIZED_MATCH_STATUSES),
                "ambiguous": counts.get("ambiguous", 0),
                "unmatched": counts.get("unmatched", 0),
                "rejected": counts.get("rejected", 0),
                "version_updates": version_updates,
                "total": len(session.rows),
                "extracted": len(session.titles),
                "unresolved": unresolved_count,
            },
        }

    @app.get("/", response_class=HTMLResponse)
    def index() -> str:
        return (resources.files("bggpipe") / "templates" / "review.html").read_text()

    @app.get("/api/state")
    def api_state() -> dict:
        with lock:
            return state()

    @app.post("/api/decision")
    def api_decision(body: DecisionBody) -> dict:
        with lock:
            revision["n"] += 1
            return _decide(body)

    def _decide(body: DecisionBody) -> dict:
        row = find_row(body.title_raw, body.source_photos, body.row_ix)
        if body.action == "pick":
            candidates = json.loads(row["candidates_json"] or "[]")
            chosen = next(
                (c for c in candidates if c.get("bgg_id") == body.bgg_id), None
            )
            if chosen is None:
                raise HTTPException(400, f"bgg_id {body.bgg_id} is not a candidate")
            session.decide_pick(row, chosen)
        elif body.action == "manual":
            if not body.bgg_id:
                raise HTTPException(400, "manual decision needs a bgg_id")
            session.decide_manual(row, body.bgg_id)
        elif body.action == "reject":
            session.decide_reject(row)
        else:
            raise HTTPException(400, f"unknown action {body.action!r}")
        return state()

    @app.post("/api/version")
    def api_version(body: VersionBody) -> dict:
        with lock:
            revision["n"] += 1
            return _version(body)

    def _version(body: VersionBody) -> dict:
        row = find_row(body.title_raw, body.source_photos, body.row_ix)
        if body.action == "unknown":
            session.decide_version(row, None)
        elif body.action == "pick":
            try:
                session.decide_version(row, body.version_id)
            except ValueError as err:
                raise HTTPException(400, str(err)) from err
        else:
            raise HTTPException(400, f"unknown action {body.action!r}")
        return state()

    @app.post("/api/veto-merge")
    def api_veto_merge(body: VetoBody) -> dict:
        with lock:
            revision["n"] += 1
            row = find_row(body.title_raw, body.source_photos, body.row_ix)
            if row["match_status"] != "merged":
                raise HTTPException(400, "row is not merged")
            session.veto_merge(row)
            return state()

    @app.post("/api/dismiss")
    def api_dismiss(body: DismissBody) -> dict:
        with lock:
            revision["n"] += 1
            dismissed.add(_sighting_key(body.photo, body.model_dump(exclude={"photo"})))
            return state()

    @app.get("/photos/{name}")
    def photo(name: str) -> FileResponse:
        if name not in photo_names():  # also blocks any path traversal
            raise HTTPException(404, "no such photo")
        return FileResponse(cfg.photos_dir / name)

    _STATIC = {
        "logo.jpg": "image/jpeg",
        "logo-full.jpg": "image/jpeg",
        "favicon.png": "image/png",
    }

    @app.get("/static/{name}")
    def static_asset(name: str) -> Response:
        media_type = _STATIC.get(name)  # allowlist: no traversal possible
        if media_type is None:
            raise HTTPException(404, "no such asset")
        data = (resources.files("bggpipe") / "static" / name).read_bytes()
        return Response(content=data, media_type=media_type)

    return app


def _dev_app() -> FastAPI:
    """uvicorn factory for --dev code reload (config path via env var,
    since uvicorn re-imports this module in a fresh process)."""
    from bggpipe.config import load_config

    path = os.environ.get("BGGPIPE_CONFIG") or None
    return create_app(load_config(Path(path) if path else None))


def run_web_review(
    cfg: Config,
    *,
    port: int = DEFAULT_REVIEW_PORT,
    dev: bool = False,
    config_path: Path | None = None,
) -> None:
    import uvicorn

    typer.echo(
        f"Review UI: http://127.0.0.1:{port}/  (localhost only; every "
        "decision saves to matches.csv immediately — Ctrl-C anytime)"
    )
    if dev:
        # Code hot-reload. Watch only the package source: uvicorn's default
        # (cwd, recursive) would restart the server on every matches.csv
        # write — i.e. on every review decision.
        if config_path:
            os.environ["BGGPIPE_CONFIG"] = str(config_path)
        typer.echo("  --dev: restarting on source changes")
        uvicorn.run(
            "bggpipe.webreview:_dev_app",
            factory=True,
            reload=True,
            reload_dirs=[str(Path(__file__).parent)],
            host="127.0.0.1",
            port=port,
            log_level="warning",
        )
    else:
        uvicorn.run(create_app(cfg), host="127.0.0.1", port=port, log_level="warning")
