"""The local web app: six pages in a shared sidebar shell.

Pipeline (/), Photos, Titles, Review, Queue, Library, and Help — rendered
server-side from templates/shell.html plus one fragment per page, with
static/app.css as the design system and static/app.js as shared client
plumbing. No template engine and no build step; served on localhost only.

All decision logic and matches.csv writes go through ReviewSession —
this module is purely an interface. Reshoot work-orders come from
data/unidentified.json with a persisted dismiss action
(data/unidentified_dismissed.json survives extract rebuilds); stage runs
execute one at a time in a JobRunner.
"""

from __future__ import annotations

import io
import json
import os
import secrets
import socket
import threading
import time
import warnings
import webbrowser
import xml.etree.ElementTree as ET
from collections import Counter
from collections.abc import Callable
from functools import partial
from importlib import resources
from pathlib import Path
from urllib.parse import urlsplit

import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, 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.extract import (
    is_split,
    load_title_splits,
    record_title_edit,
    record_title_removal,
    record_title_split,
    replay_titles,
)
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
from bggpipe.jobs import JobRunner
from bggpipe.models import (
    CONFIDENT_VERSION_STATUSES,
    RECOGNIZED_MATCH_STATUSES,
)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import TitleEntry
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 SplitBody(BaseModel):
    title_raw: str
    source_photos: str
    row_ix: int | None = None


class EditBody(BaseModel):
    """A human correction to an extracted read. None = leave that field
    alone; for the cue fields, empty string = clear it (a corrected title
    may not be empty). `confirm` alone means "this read is right as-is" —
    it marks the line human-verified without changing anything."""

    title_raw: str
    source_photos: str = ""
    title_new: str | None = None
    publisher: str | None = None
    edition: str | None = None
    year: str | None = None
    language: str | None = None
    confirm: bool = False


class RemoveBody(BaseModel):
    title_raw: str
    source_photos: str = ""


class RunBody(BaseModel):
    dry_run: bool = True  # upload only; the safe direction is the default
    limit: int | None = None


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


def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
    """Lazy imports, same convention as the CLI: the dashboard buttons run
    exactly what the CLI commands run."""

    def extract() -> object:
        from bggpipe.extract import run_extract

        return run_extract(cfg)

    def resolve() -> object:
        from bggpipe.resolve import run_resolve

        return run_resolve(cfg)

    def diff() -> object:
        from bggpipe.diff import run_diff

        return run_diff(cfg)

    def upload(dry_run: bool = True, limit: int | None = None) -> object:
        from bggpipe.upload import run_upload

        return run_upload(cfg, dry_run=dry_run, limit=limit)

    def enrich() -> object:
        from bggpipe.enrich import run_enrich

        return run_enrich(cfg)

    return {
        "extract": extract,
        "resolve": resolve,
        "diff": diff,
        "upload": upload,
        "enrich": enrich,
    }


# (href, page-name, label, badge-name) — badge names match app.js
# ordered as the real workflow runs: photograph, proofread the reads
# (catalog), resolve + decide (review), then what ships (queue, library)
NAV_PAGES = (
    ("/", "pipeline", "Pipeline", ""),
    ("/photos", "photos", "Photos", "photos"),
    ("/titles", "titles", "Titles", "titles"),
    ("/review", "review", "Review", "review"),
    ("/queue", "queue", "Queue", "queue"),
    ("/library", "library", "Library", ""),
    ("/help", "help", "Help", ""),
)

PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"}


def _route_ip() -> str | None:
    """The IPv4 address of this machine's default outbound interface — the
    single best guess for the address a phone on the same network dials.
    Multi-homed machines (VM bridges, Ethernet + Wi-Fi) have several
    addresses; this is the one the OS actually routes through."""
    try:
        probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        probe.connect(("192.0.2.1", 80))  # never sent; just picks the route
        address = probe.getsockname()[0]
        probe.close()
        return address
    except OSError:
        return None


def lan_hosts() -> set[str]:
    """This machine's names and addresses on the local network — what a
    phone's browser will put in the Host header. An allowlist (never a
    wildcard): DNS-rebinding attacks arrive under the attacker's hostname,
    which is exactly what an allowlist rejects."""
    hosts: set[str] = set()
    hostname = socket.gethostname()
    hosts.add(hostname.lower())
    hosts.add(hostname.split(".")[0].lower() + ".local")
    if route := _route_ip():
        hosts.add(route)
    try:
        for info in socket.getaddrinfo(hostname, None):
            address = info[4][0]
            if ":" not in address:  # the guard compares hostnames, not v6 forms
                hosts.add(address)
    except OSError:
        pass
    return hosts


def create_app(
    cfg: Config,
    *,
    client: BGGClient | None = None,
    stages: dict[str, Callable[..., object]] | None = None,
    jobs: JobRunner | None = None,
    allowed_hosts: set[str] | None = None,
    lan_token: str | None = None,
) -> FastAPI:
    app = FastAPI(title="bggpipe")
    stages = stages or _default_stages(cfg)
    jobs = jobs or JobRunner()
    session = ReviewSession(
        cfg,
        console=Console(file=io.StringIO()),
        input_fn=lambda prompt: "",
        client=client,
    )
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        thumbnails = load_thumbnails(cfg.cache_dir)
        dismissed = DismissStore(cfg.dismissed_path)
    startup_notes = [str(w.message) for w in caught]
    # 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
    boot = time.time()  # clients detect restarts (revision resets with us)
    # server-side degradations, shown in-browser (quarantined dismiss file,
    # unreadable thumbnails, torn artifacts)
    app_warnings: list[str] = list(startup_notes)

    ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} | (allowed_hosts or set())
    LAN_COOKIE = "bggpipe_key"

    @app.middleware("http")
    async def origin_guard(request, call_next):
        # A hostile webpage can fire preflight-free cross-origin POSTs at a
        # localhost server (bodyless run triggers, multipart photo posts) —
        # and via DNS rebinding it can read GETs too. url.hostname parses
        # the Host header properly (ports, IPv6 brackets, lowercase).
        host = (request.url.hostname or "").lower()
        origin = request.headers.get("origin")
        origin_host = urlsplit(origin).hostname if origin else None
        client_ip = request.client.host if request.client else ""
        loopback = client_ip.startswith("127.") or client_ip == "::1"
        # Same-machine browsing stays keyless under --lan: a network peer
        # cannot arrive with a loopback CLIENT address, and these requests
        # fall through to the localhost guard below, which still blocks
        # rebinding and cross-origin pages by their foreign Host/Origin.
        if lan_token is not None and not (loopback and host in ALLOWED_HOSTS):
            # --lan has no login, so EVERY request — reads included: shelf
            # photos and pipeline state are private — needs the per-run
            # key from the printed URL; a cookie carries it afterwards.
            supplied = (
                request.query_params.get("k") or request.cookies.get(LAN_COOKIE) or ""
            )
            if not secrets.compare_digest(supplied, lan_token):
                typer.echo(
                    f"refused {request.method} {request.url.path} "
                    f"(host {host!r}): missing or wrong access key",
                    err=True,
                )
                return JSONResponse(
                    {
                        "detail": "missing or wrong access key — open the "
                        "exact URL printed where the server started "
                        "(it ends in ?k=...)"
                    },
                    status_code=403,
                )
            if request.method not in ("GET", "HEAD", "OPTIONS") and (
                origin_host is not None and origin_host != host
            ):
                return JSONResponse(
                    {"detail": "cross-origin request refused"}, status_code=403
                )
            response = await call_next(request)
            if request.query_params.get("k"):
                # the key came in the typed URL: hand it to the browser so
                # navigation and fetches keep working without it
                response.set_cookie(
                    LAN_COOKIE, lan_token, httponly=True, samesite="lax"
                )
            return response
        if request.method not in ("GET", "HEAD", "OPTIONS") and (
            host not in ALLOWED_HOSTS
            or (origin_host is not None and origin_host not in ALLOWED_HOSTS)
        ):
            typer.echo(
                f"refused {request.method} {request.url.path}: host {host!r}"
                f" / origin {origin_host!r} not on this server's allowlist",
                err=True,
            )
            return JSONResponse(
                {"detail": "cross-origin request refused"}, status_code=403
            )
        return await call_next(request)

    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 open_sightings() -> list[dict]:
        available = photo_names()
        sightings = []
        if cfg.unidentified_path.exists():
            try:
                data = json.loads(cfg.unidentified_path.read_text())
            except (json.JSONDecodeError, OSError) as err:
                # one torn file must not 500 every page and badge at once
                note = (
                    f"{cfg.unidentified_path.name} unreadable ({err}) — "
                    "reshoot list unavailable"
                )
                if note not in app_warnings:
                    app_warnings.append(note)
                return []
            for photo, entries in data.items():
                for s in entries:
                    if _sighting_key(photo, s) in dismissed.keys:
                        continue
                    sightings.append(
                        {**s, "photo": photo, "photo_exists": photo in available}
                    )
        return sightings

    def read_games() -> dict:
        if not cfg.games_path.exists():
            return {}
        try:
            return json.loads(cfg.games_path.read_text())
        except (json.JSONDecodeError, OSError) as err:
            note = f"{cfg.games_path.name} unreadable ({err}) — library unavailable"
            if note not in app_warnings:
                app_warnings.append(note)
            return {}

    def _find_entry(title_raw: str, source_photos: str) -> TitleEntry | None:
        photos = [p for p in source_photos.split(";") if p]
        return next(
            (
                e
                for e in session.titles
                if e.title_raw == title_raw
                and (not photos or list(e.source_photos) == photos)
            ),
            None,
        )

    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() and p.suffix.lower() in PHOTO_SUFFIXES
        }

    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 = []

        def catalog_line(entry, row) -> dict:
            cue_entry = entry or (
                _find_entry(row["title_raw"], row["source_photos"]) if row else None
            )
            return {
                "title_raw": entry.title_raw if entry else row["title_raw"],
                "confidence": entry.confidence if entry else "",
                "photos": (
                    list(entry.source_photos)
                    if entry
                    else [p for p in row["source_photos"].split(";") if p]
                ),
                "status": row["match_status"] if row else "awaiting_resolve",
                "type": row["type"] if row else "",
                "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 "",
                "row_ix": _ix_of(session.rows, row) if row else None,
                "cues": {
                    "publisher": cue_entry.publisher_hint if cue_entry else "",
                    "edition": cue_entry.edition_hint if cue_entry else "",
                    "year": cue_entry.year_hint if cue_entry else None,
                    "language": cue_entry.language_hint if cue_entry else "",
                },
                "can_split": bool(
                    row
                    and row["match_status"] in RECOGNIZED_MATCH_STATUSES
                    and not row.get("dedupe_veto")
                    and len(row["source_photos"].split(";")) > 1
                )
                # no matches row yet (title still awaiting resolve): the
                # split is a titles.json decision, no BGG data at stake
                or bool(not row and entry and len(entry.source_photos) > 1),
                "split_copy": bool(row and row.get("dedupe_veto")),
                # same predicate as /api/pipeline's shaky_reads badge: the
                # model wasn't sure, and neither a match nor a human has
                # verified the read yet
                "shaky": bool(entry and not row and entry.confidence != "high"),
            }

        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: the ix-th entry of a title reports the
            # ix-th row of that title (run_resolve pairs photo-overlap
            # first, but split entries and split rows both keep sorted
            # photo order, so the ordinals line up)
            row = same_title[ix] if ix < len(same_title) else None
            # a split row's photo set is narrower than its entry's — show
            # the row's own photos for split copies
            catalog.append(
                catalog_line(None if row and row.get("dedupe_veto") else entry, row)
                if row
                else catalog_line(entry, None)
            )
        # surplus rows beyond the entry count — split copies — are real
        # physical games and get their own catalog lines
        for title, rows_for_title in rows_by_title.items():
            for row in rows_for_title[title_seen[title] :]:
                catalog.append(catalog_line(None, row))
        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"]
        )
        sightings = open_sightings()
        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"],
            "boot": boot,
            "warnings": (session.warnings + app_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,
            },
        }

    def render_page(name: str, active: str | None = None) -> str:
        """Server-side shell: shared sidebar + nav with aria-current, page
        fragment substituted in. No template engine — three placeholders.
        `active` highlights a nav entry other than the fragment's own name
        (a detail page keeps its section lit)."""
        active = active or name
        templates = resources.files("bggpipe") / "templates"
        shell = (templates / "shell.html").read_text()
        fragment = (templates / "pages" / f"{name}.html").read_text()
        nav = "\n".join(
            f'    <a href="{href}"'
            + (' aria-current="page"' if page == active else "")
            + f">{label}"
            + (f'<span class="navbadge" data-badge="{badge}"></span>' if badge else "")
            + "</a>"
            for href, page, label, badge in NAV_PAGES
        )
        return (
            shell.replace("<!--TITLE-->", f"bggpipe — {name}")
            .replace("<!--NAV-->", nav)
            .replace("<!--PAGE-->", fragment)
        )

    @app.get("/", response_class=HTMLResponse)
    def index() -> str:
        return render_page("pipeline")

    @app.get("/photos", response_class=HTMLResponse)
    def photos_page() -> str:
        return render_page("photos")

    @app.get("/photos/view/{name}", response_class=HTMLResponse)
    def photo_detail_page(name: str) -> str:
        # the fragment reads the photo name from its own URL; the server
        # never interpolates it (nothing to escape here)
        return render_page("photo", active="photos")

    @app.get("/review", response_class=HTMLResponse)
    def review_page() -> str:
        return render_page("review")

    @app.get("/titles", response_class=HTMLResponse)
    def titles_page() -> str:
        return render_page("titles")

    @app.get("/catalog")
    def catalog_redirect() -> Response:
        # the page's old name; bookmarks keep working
        return Response(status_code=307, headers={"Location": "/titles"})

    @app.get("/queue", response_class=HTMLResponse)
    def queue_page() -> str:
        return render_page("queue")

    @app.get("/library", response_class=HTMLResponse)
    def library_page() -> str:
        return render_page("library")

    @app.get("/help", response_class=HTMLResponse)
    def help_page() -> str:
        return render_page("help")

    @app.get("/api/photos-list")
    def api_photos_list() -> list[dict]:
        out = []
        for name in sorted(photo_names()):
            raw_path = cfg.extract_raw_dir / f"{name}.json"
            titles = unidentified = 0
            extracted = raw_path.exists()
            if extracted:
                try:
                    raw = json.loads(raw_path.read_text())
                    if isinstance(raw, list):  # bare-array shape
                        titles, unidentified = len(raw), 0
                    else:
                        titles = len(raw.get("titles", []))
                        unidentified = len(raw.get("unidentified", []))
                except (json.JSONDecodeError, OSError):
                    extracted = False  # torn cache: shown as needing extraction
            out.append(
                {
                    "name": name,
                    "extracted": extracted,
                    "titles": titles,
                    "unidentified": unidentified,
                }
            )
        return out

    @app.get("/api/queue")
    def api_queue() -> dict:
        def rows(path: Path) -> list[dict]:
            if not path.exists():
                return []
            with path.open(newline="") as f:
                import csv as _csv

                return list(_csv.DictReader(f))

        return {
            "to_add": rows(cfg.to_add_path),
            "to_update": rows(cfg.to_update_path),
            "log": rows(cfg.upload_log_path),
        }

    @app.get("/api/library")
    def api_library() -> list[dict]:
        games = read_games()
        return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold())

    def _csv_count(path: Path) -> int:
        if not path.exists():
            return 0
        with path.open(newline="") as f:
            return max(0, sum(1 for _ in f) - 1)  # minus header

    @app.get("/api/pipeline")
    def api_pipeline() -> dict:
        with lock:
            freshen()
            match_counts = Counter(row["match_status"] for row in session.rows)
            log_counts: Counter[str] = Counter()
            log_path = cfg.upload_log_path
            if log_path.exists():
                with log_path.open(newline="") as f:
                    import csv as _csv

                    log_counts = Counter(row["status"] for row in _csv.DictReader(f))
            games = len(read_games())
            return {
                "boot": boot,
                # key NAMES only — never values (credentials stay out of
                # every payload and log)
                "env": {
                    key: bool(os.environ.get(key))
                    for key in (
                        "ANTHROPIC_API_KEY",
                        "BGG_API_TOKEN",
                        "BGG_USERNAME",
                        "BGG_PASSWORD",
                    )
                },
                "stub_data": any(m.exists() for m in cfg.stub_marker_paths),
                "photos": len(photo_names()),
                "reshoot": len(open_sightings()),
                "titles": len(session.titles),
                # unresolved shaky reads: the proofread-before-resolve
                # signal (an edit marks the entry high — human-verified)
                "shaky_reads": sum(
                    1
                    for e in session.titles
                    if e.confidence != "high"
                    and e.title_raw not in {r["title_raw"] for r in session.rows}
                ),
                "matches": dict(match_counts),
                "pending_review": len(session.pending_rows())
                + len(session.version_rows()),
                "to_add": _csv_count(cfg.to_add_path),
                "to_update": _csv_count(cfg.to_update_path),
                "upload_log": dict(log_counts),
                "games": games,
                "job": jobs.snapshot(),
            }

    @app.post("/api/run/{stage}")
    def api_run(stage: str, body: RunBody | None = None) -> dict:
        if stage not in stages:
            raise HTTPException(404, f"unknown stage {stage!r}")
        body = body or RunBody()
        if stage == "upload":
            fn = partial(stages["upload"], dry_run=body.dry_run, limit=body.limit)
        else:
            fn = stages[stage]
        if not jobs.start(stage, fn):
            raise HTTPException(409, "a stage is already running — wait for it")
        return jobs.snapshot()

    @app.get("/api/job")
    def api_job() -> dict:
        return jobs.snapshot()

    @app.post("/api/photos")
    async def api_photos(files: list[UploadFile]) -> dict:
        saved = []
        cfg.photos_dir.mkdir(parents=True, exist_ok=True)
        for upload_file in files:
            name = Path(upload_file.filename or "").name  # strips any path
            if not name or Path(name).suffix.lower() not in PHOTO_SUFFIXES:
                raise HTTPException(400, f"not a photo: {name or '(unnamed)'}")
            target = cfg.photos_dir / name
            data = await upload_file.read()
            # a re-uploaded photo means "re-extract this one": the raw cache
            # goes FIRST, so a crash can never leave the new photo paired
            # with the old photo's extraction
            stale = cfg.extract_raw_dir / f"{name}.json"
            if stale.exists():
                stale.unlink()
            atomic_write_bytes(target, data)
            saved.append(name)
        return {"saved": saved, "photos": len(photo_names())}

    @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 _refuse_if_rewriting() -> None:
        snap = jobs.snapshot()
        if snap["status"] == "running" and snap["stage"] in ("extract", "resolve"):
            # the job holds a start-of-run snapshot and will atomically
            # rewrite matches.csv/titles.json at the end — a decision saved
            # now would be silently reverted by that rewrite
            raise HTTPException(
                409,
                f"a {snap['stage']} run is rewriting the pipeline data — "
                "wait for it to finish, then decide",
            )

    def _decide(body: DecisionBody) -> dict:
        _refuse_if_rewriting()
        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:
        _refuse_if_rewriting()
        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/split")
    def api_split(body: SplitBody) -> dict:
        with lock:
            revision["n"] += 1
            _refuse_if_rewriting()
            freshen()
            row = session.find_row(body.title_raw, body.source_photos, body.row_ix)
            if row is not None:
                try:
                    copies = session.split_row(row)
                except ValueError as err:
                    raise HTTPException(400, str(err)) from err
                if not copies:
                    # split_row adopted nothing: matches.csv was rewritten
                    # underneath — recording the store now would report a
                    # success that didn't happen
                    raise HTTPException(
                        409,
                        "matches.csv changed while you split — nothing "
                        "changed; retry from the refreshed page",
                    )
            else:
                # no matches row yet — the title is still awaiting resolve;
                # splitting is purely a titles.json (extraction) decision
                entry = _find_entry(body.title_raw, body.source_photos)
                if entry is None:
                    raise HTTPException(
                        404, "title not found — titles.json changed underneath?"
                    )
                if len(entry.source_photos) < 2:
                    raise HTTPException(
                        400, "only a multi-photo title can be split into copies"
                    )
            # persist the decision so every future extract rebuild and
            # resolve dedupe keeps the copies apart, then re-derive
            # titles.json so each copy carries its own photo's cues
            record_title_split(
                cfg.title_splits_path,
                body.title_raw,
                [p for p in body.source_photos.split(";") if p],
            )
            replay_titles(cfg)
            return state()

    @app.post("/api/edit-title")
    def api_edit_title(body: EditBody) -> dict:
        with lock:
            revision["n"] += 1
            _refuse_if_rewriting()
            freshen()
            entry = _find_entry(body.title_raw, body.source_photos)
            if entry is None:
                raise HTTPException(
                    404, "title not found — titles.json changed underneath?"
                )
            record: dict = {"match": body.title_raw}
            photos = [p for p in body.source_photos.split(";") if p]
            norm = normalize_title(body.title_raw)
            # scope by NORMALIZED title: stored edits apply by normalized
            # match, so raw-equality counting here would let an edit bleed
            # onto a differently-cased sighting of another edition
            same_norm = [
                e for e in session.titles if normalize_title(e.title_raw) == norm
            ]
            if photos and len(same_norm) > 1:
                # several copies/editions share this title: the fix targets
                # only the copy the human was looking at
                record["photos"] = photos
            if (
                sum(
                    1
                    for e in same_norm
                    if list(e.source_photos) == list(entry.source_photos)
                )
                > 1
            ):
                # two same-named editions seen in the same photo(s): the
                # store can only target (title, photos), so an edit would
                # hit both — refuse rather than corrupt the sibling
                raise HTTPException(
                    409,
                    "two entries share this exact title and photo set — an "
                    "edit cannot target just one; resolve or reject one of "
                    "them first",
                )
            if body.title_new is not None:
                corrected = body.title_new.strip()
                if not corrected:
                    raise HTTPException(400, "corrected title cannot be empty")
                if corrected != body.title_raw:
                    record["title_raw"] = corrected
            for key, value in (
                ("publisher_hint", body.publisher),
                ("edition_hint", body.edition),
                ("language_hint", body.language),
            ):
                if value is not None:
                    record[key] = value.strip()
            if body.year is not None:
                year = body.year.strip()
                if year and not year.isdigit():
                    raise HTTPException(400, "year must be a number")
                record["year_hint"] = int(year) if year else None
            changed = bool(record.keys() - {"match", "photos"})
            if not changed and not body.confirm:
                raise HTTPException(400, "nothing to change")
            record["confidence"] = "high"  # a human verified this line
            # write order is crash-safety: drop the stale rows first (worst
            # case on a crash: resolve recreates them from the uncorrected
            # entry), then the durable record (replayed by every future
            # rebuild), then the titles.json replay. A confirm-only save
            # changed no data, so nothing is stale — no re-queue.
            if changed:
                session.drop_rows(
                    body.title_raw,
                    record.get("photos"),
                    new_title=record.get("title_raw"),
                )
            if "title_raw" in record and is_split(
                norm,
                entry.source_photos,
                load_title_splits(cfg.title_splits_path),
            ):
                # a renamed split copy must stay under split protection —
                # the old record keys the OLD normalized title
                record_title_split(
                    cfg.title_splits_path,
                    record["title_raw"],
                    list(entry.source_photos),
                )
            record_title_edit(cfg.title_edits_path, record)
            replay_titles(cfg)
            return state()

    @app.post("/api/remove-title")
    def api_remove_title(body: RemoveBody) -> dict:
        with lock:
            revision["n"] += 1
            _refuse_if_rewriting()
            freshen()
            photos = [p for p in body.source_photos.split(";") if p]
            entry = _find_entry(body.title_raw, body.source_photos)
            norm = normalize_title(body.title_raw)
            has_rows = any(
                normalize_title(r["title_raw"]) == norm
                and (
                    not photos
                    or {p for p in r["source_photos"].split(";") if p} & set(photos)
                )
                for r in session.rows
            )
            # surplus rows without an entry (a split copy whose entry is
            # gone) are removable too — rows alone are enough to act on
            if entry is None and not has_rows:
                raise HTTPException(
                    404, "title not found — titles.json changed underneath?"
                )
            # removal is the human explicitly discarding the line: veto'd
            # rows go too, then the durable record, then the replay
            session.drop_rows(body.title_raw, photos or None, even_vetoed=True)
            record_title_removal(
                cfg.title_removals_path, body.title_raw, photos or None
            )
            replay_titles(cfg)
            return state()

    @app.post("/api/veto-merge")
    def api_veto_merge(body: VetoBody) -> dict:
        with lock:
            revision["n"] += 1
            _refuse_if_rewriting()
            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 = {
        "app.css": "text/css",
        "app.js": "text/javascript",
        "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
    token = os.environ.get("BGGPIPE_LAN_TOKEN") or None
    return create_app(load_config(Path(path) if path else None), lan_token=token)


def run_web_review(
    cfg: Config,
    *,
    port: int = DEFAULT_REVIEW_PORT,
    dev: bool = False,
    lan: bool = False,
    config_path: Path | None = None,
    landing: str = "/review",
    open_browser: bool = False,
) -> None:
    import uvicorn

    url = f"http://127.0.0.1:{port}{landing}"
    # 16 bytes = 128-bit key: standard session-credential strength. The
    # cost is a longer one-time URL to get onto the phone — copy it, or
    # type it once; the cookie remembers it from then on.
    token = secrets.token_urlsafe(16) if lan else None
    if lan:
        primary = _route_ip()
        spares = sorted(
            h for h in lan_hosts() if h != primary and not h.startswith("127.")
        )
        typer.echo(f"bggpipe web UI: {url}  (this machine needs no key)")
        if primary:
            typer.echo(f"  on your phone, open: http://{primary}:{port}/?k={token}")
            if spares:
                typer.echo(
                    "  (several network interfaces here — if that address "
                    "doesn't answer, try: "
                    + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares)
                    + ")"
                )
        else:
            typer.echo(
                "  couldn't determine this machine's network address — find "
                "it in your network settings and open "
                f"http://<that-ip>:{port}/?k={token}"
                + (
                    "  (or try: "
                    + ", ".join(f"http://{h}:{port}/?k={token}" for h in spares)
                    + ")"
                    if spares
                    else ""
                )
            )
        typer.echo(
            "  --lan: no login beyond that key — anyone who has it can run "
            "stages, change your data, and (once unlocked) drive uploads "
            "to your BGG account. Use only on a network you trust."
        )
    else:
        typer.echo(
            f"bggpipe web UI: {url}  (localhost only; dashboard at /, review "
            "at /review; every decision saves immediately — Ctrl-C anytime)"
        )
    if open_browser:
        # give uvicorn a beat to bind before the tab loads
        threading.Timer(0.8, webbrowser.open, args=(url,)).start()
    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)
        if token:
            os.environ["BGGPIPE_LAN_TOKEN"] = token
        typer.echo("  --dev: restarting on source changes")
        uvicorn.run(
            "bggpipe.webreview:_dev_app",
            factory=True,
            reload=True,
            reload_dirs=[str(Path(__file__).parent)],
            host="0.0.0.0" if lan else "127.0.0.1",  # noqa: S104 — opted in
            port=port,
            log_level="warning",
        )
    else:
        uvicorn.run(
            create_app(cfg, lan_token=token),
            host="0.0.0.0" if lan else "127.0.0.1",  # noqa: S104 — opted in
            port=port,
            log_level="warning",
        )
