"""Stage 3 — the review decision engine, worn by two faces.

ReviewSession owns all decision logic and every matches.csv write: the
decision API (decide_*/veto_merge/pending_rows/version_rows) serves both
this module's rich TUI prompt loop and webreview's FastAPI endpoints, and
reload_if_changed() lets either face follow external rewrites (a resolve
run in another terminal). Every decision rewrites matches.csv atomically,
so quitting mid-review loses nothing. The version pass is optional and
skippable: version review must never block getting games uploaded.
"""

from __future__ import annotations

import json
from collections.abc import Callable
from pathlib import Path

import httpx
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for
from bggpipe.config import Config
from bggpipe.models import (
    PENDING_MATCH_STATUSES,
    UNDECIDED_MATCH_STATUSES,
    BGGResponseError,
)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import (
    MatchRow,
    TitleEntry,
    load_titles,
    read_matches,
    resolve_version,
    write_matches,
)

# input_fn(prompt) -> user's response; tests inject a scripted one
InputFn = Callable[[str], str]

_BGG_ERRORS = (
    BGGAuthError,
    BGGQueueTimeout,
    BGGResponseError,
    httpx.HTTPError,
    OSError,
)


class _Quit(Exception):
    """User asked to leave the review (q / Ctrl-C / end of input)."""


class ReviewSession:
    def __init__(
        self,
        cfg: Config,
        *,
        console: Console | None = None,
        input_fn: InputFn | None = None,
        client: BGGClient | None = None,
    ) -> None:
        self.cfg = cfg
        self.console = console or Console()
        self.input_fn = input_fn or (lambda prompt: self.console.input(prompt))
        self.client = client or client_for(cfg)
        self.decisions = 0
        self.warnings: list[str] = []
        self._load()

    # -- plumbing -------------------------------------------------------

    def _data_mtimes(self) -> tuple[int | None, int | None]:
        def mtime(path: Path) -> int | None:
            try:
                return path.stat().st_mtime_ns
            except FileNotFoundError:
                return None

        return (mtime(self.cfg.matches_path), mtime(self.cfg.titles_path))

    def _load(self) -> None:
        self.rows = read_matches(self.cfg.matches_path)
        try:
            self.titles = load_titles(self.cfg.titles_path)
        except FileNotFoundError:
            self.titles = []
        self._titles = {e.title_raw: e for e in self.titles}
        self._loaded_mtimes = self._data_mtimes()

    def reload_if_changed(self) -> bool:
        """Re-read matches.csv/titles.json when another process (extract,
        resolve, a git pull) rewrote them, so the web UI never serves — or
        saves decisions over — stale rows. Returns True when it reloaded."""
        if self._data_mtimes() == self._loaded_mtimes:
            return False
        self._load()
        return True

    def _ask(self, prompt: str) -> str:
        try:
            answer = self.input_fn(prompt).strip()
        except (EOFError, KeyboardInterrupt) as err:
            raise _Quit from err
        if answer.lower() == "q":
            raise _Quit
        return answer

    def _warn(self, message: str) -> None:
        """Degradations must be visible in BOTH faces: the console for the
        TUI, and self.warnings for the web UI (whose console is a StringIO)."""
        self.warnings.append(message)
        self.console.print(f"[yellow]{message}[/yellow]")

    def _adopt(self, row: dict) -> bool:
        """Swap `row` (possibly an orphaned reference from before a reload)
        back into self.rows by identity or key. Key collisions (duplicate
        two-edition rows) prefer a still-undecided slot. False = row is gone
        from the file entirely."""
        if any(r is row for r in self.rows):
            return True
        key = (row["title_raw"], row["source_photos"])
        candidates = [
            i
            for i, r in enumerate(self.rows)
            if (r["title_raw"], r["source_photos"]) == key
        ]
        if not candidates:
            return False
        undecided = [
            i
            for i in candidates
            if self.rows[i]["match_status"] in UNDECIDED_MATCH_STATUSES
        ]
        if not undecided:
            # a VERSION decision targets an approved row: prefer the slot
            # still awaiting its edition, not the sibling already versioned
            undecided = [
                i
                for i in candidates
                if self.rows[i]["version_status"] == "version_ambiguous"
            ]
        self.rows[(undecided or candidates)[0]] = row
        return True

    def _save(self, row: dict | None = None) -> None:
        """Atomic write of the in-memory rows. A TUI loop iterates row
        references snapshotted before any reload, and reload_if_changed can
        swap self.rows at every save — so the decided row must always be
        re-adopted into the CURRENT list, or the decision would be counted
        but never written."""
        self.reload_if_changed()
        if row is not None and not self._adopt(row):
            self._warn(
                f"{row['title_raw']!r} disappeared from matches.csv while "
                "you decided — decision NOT saved"
            )
            return
        self._write_rows()

    def _write_rows(self) -> None:
        """Atomic write of self.rows exactly as they stand — no reload, so
        a caller that just derived self.rows from a fresh reload can't have
        its result silently replaced before the write."""
        try:
            own_mtime = write_matches(self.cfg.matches_path, self.rows)
        except OSError:
            self._load()  # memory must never claim what disk doesn't hold
            raise
        # record the mtime write_matches itself observed: re-statting later
        # could adopt a foreign rewrite landing in the gap as "own write"
        self._loaded_mtimes = (own_mtime, self._data_mtimes()[1])
        self.decisions += 1

    def _apply_choice(self, row: dict, candidate: dict) -> None:
        row["match_status"] = "approved"
        row["bgg_id"] = str(candidate.get("bgg_id") or "")
        row["bgg_name"] = candidate.get("name") or ""
        row["year"] = str(candidate.get("year") or "")
        row["type"] = candidate.get("type") or "boardgame"
        self._fill_version(row)
        self._save(row)

    def _fill_version(self, row: dict) -> None:
        """Try version resolution for a just-approved row. Degrades gracefully:
        no cues, no token, or API trouble all leave version_unknown."""
        # photo-aware lookup: two same-title entries are two EDITIONS with
        # different cues — the title-only dict would hand every row the last
        # edition's cues and score the wrong version to version_auto
        entry = self.cues_for(row["title_raw"], row["source_photos"])
        if entry is None or not row["bgg_id"]:
            row["version_status"] = row["version_status"] or "version_unknown"
            return
        shim = MatchRow(title_raw=row["title_raw"], bgg_id=int(row["bgg_id"]))
        try:
            resolve_version(self.client, entry, shim)
        except _BGG_ERRORS as err:
            self._warn(
                f"version lookup unavailable ({err}) — re-approve this row "
                "to retry the edition lookup"
            )
            # distinct from version_unknown ("no cues"): a transient failure
            # stays visibly retryable instead of terminal
            row["version_status"] = "version_error"
            return
        row["version_status"] = shim.version_status
        row["version_id"] = str(shim.version_id or "")
        row["version_name"] = shim.version_name
        row["version_candidates_json"] = json.dumps(
            shim.version_candidates, ensure_ascii=False
        )

    # -- decision API (shared by the TUI and the web UI) ----------------

    def pending_rows(self) -> list[dict]:
        return [r for r in self.rows if r["match_status"] in ("ambiguous", "unmatched")]

    def version_rows(self) -> list[dict]:
        return [
            r
            for r in self.rows
            if r["version_status"] == "version_ambiguous"
            and r["match_status"] != "merged"
        ]

    def merged_rows(self) -> list[dict]:
        return [r for r in self.rows if r["match_status"] == "merged"]

    def split_row(self, row: dict) -> list[dict]:
        """The human says one multi-photo row is actually N physical copies
        (one per photo). Replace it with per-photo rows, each veto-flagged
        so no future dedupe re-merges them, each with its own edition slot
        (different copies are usually different editions)."""
        photos = [p for p in row["source_photos"].split(";") if p]
        if len(photos) < 2:
            raise ValueError("only a multi-photo row can be split into copies")
        # same concurrent-rewrite discipline as every decision: re-adopt the
        # (possibly orphaned) row into the current list before mutating
        self.reload_if_changed()
        if not self._adopt(row):
            self._warn(
                f"{row['title_raw']!r} disappeared from matches.csv while "
                "you split — nothing changed"
            )
            return []
        has_candidates = (row.get("version_candidates_json") or "[]") != "[]"
        ix = next(i for i, r in enumerate(self.rows) if r is row)
        copies = []
        for photo in photos:
            copy = dict(row)
            copy["source_photos"] = photo
            copy["dedupe_veto"] = "1"
            copy["merged_into"] = ""
            # each physical copy picks its OWN edition in the version pass
            copy["version_id"] = ""
            copy["version_name"] = ""
            copy["version_status"] = (
                "version_ambiguous" if has_candidates else "version_unknown"
            )
            copies.append(copy)
        self.rows[ix : ix + 1] = copies
        self._save(copies[0])
        return copies

    def drop_rows(
        self,
        title_raw: str,
        photos: list[str] | None = None,
        new_title: str | None = None,
        even_vetoed: bool = False,
    ) -> int:
        """An edit invalidated these rows — the BGG match was made against
        the uncorrected read. Remove them so resolve re-queries with the
        fix; `photos` narrows the cull to one copy of a split title.
        Rows carrying a human veto (dedupe_veto) are never dropped — the
        match itself was human-vetted, and dropping would erase the veto;
        a rename updates their title in place so they follow the entry.
        `even_vetoed` is for title REMOVAL, where the human is explicitly
        discarding the line, vetted or not."""
        self.reload_if_changed()
        norm = normalize_title(title_raw)
        keep: list[dict] = []
        dropped = renamed = 0
        for row in self.rows:
            targeted = normalize_title(row["title_raw"]) == norm and (
                photos is None
                or bool({p for p in row["source_photos"].split(";") if p} & set(photos))
            )
            if not targeted:
                keep.append(row)
            elif row.get("dedupe_veto") and not even_vetoed:
                if new_title and row["title_raw"] != new_title:
                    row["title_raw"] = new_title
                    renamed += 1
                keep.append(row)
            else:
                dropped += 1
        if dropped or renamed:
            self.rows = keep
            self._write_rows()
        return dropped

    def veto_merge(self, row: dict) -> None:
        """The human says these are NOT the same physical game: restore the
        row as a distinct, human-confirmed match."""
        row["match_status"] = "approved"
        row["merged_into"] = ""
        row["dedupe_veto"] = "1"  # persists: resolve re-runs must not re-merge
        self._save(row)

    def find_row(
        self, title_raw: str, source_photos: str, row_ix: int | None = None
    ) -> dict | None:
        """Locate a row by ordinal (duplicate two-edition rows) or by key,
        preferring a still-undecided slot on key collisions. None = gone."""
        if row_ix is not None and 0 <= row_ix < len(self.rows):
            row = self.rows[row_ix]
            if row["title_raw"] == title_raw and row["source_photos"] == source_photos:
                return row
        matches = [
            row
            for row in self.rows
            if row["title_raw"] == title_raw and row["source_photos"] == source_photos
        ]
        undecided = [
            row for row in matches if row["match_status"] in UNDECIDED_MATCH_STATUSES
        ]
        if undecided or matches:
            return (undecided or matches)[0]
        return None

    def cues_for(
        self, title_raw: str, source_photos: str | None = None
    ) -> TitleEntry | None:
        """The extraction entry behind a row. Same-title entries (two
        editions of one game) are told apart by their photo set when given."""
        if source_photos is not None:
            for entry in self.titles:
                if (
                    entry.title_raw == title_raw
                    and ";".join(entry.source_photos) == source_photos
                ):
                    return entry
        return self._titles.get(title_raw)

    def decide_pick(self, row: dict, candidate: dict) -> None:
        self._apply_choice(row, candidate)

    def decide_manual(self, row: dict, bgg_id: int) -> None:
        self._manual_id(row, bgg_id)

    def decide_reject(self, row: dict) -> None:
        row["match_status"] = "rejected"
        self._save(row)

    def decide_version(self, row: dict, version_id: int | None) -> None:
        """Pick a version from the row's stored candidates, or None -> unknown."""
        if version_id is None:
            row["version_status"] = "version_unknown"
            row["version_id"] = ""
            row["version_name"] = ""
        else:
            candidates = json.loads(row["version_candidates_json"] or "[]")
            chosen = next(
                (v for v in candidates if v.get("version_id") == version_id), None
            )
            if chosen is None:
                raise ValueError(f"version {version_id} is not a stored candidate")
            row["version_status"] = "version_approved"
            row["version_id"] = str(version_id)
            row["version_name"] = chosen.get("name") or ""
        self._save(row)

    # -- displays -------------------------------------------------------

    def _show_item(self, row: dict, candidates: list[dict]) -> None:
        self.console.print(
            Panel(
                f"[bold]{row['title_raw']}[/bold]\n"
                f"photos: {row['source_photos'] or '-'}   "
                f"status: {row['match_status']}",
                expand=False,
            )
        )
        if candidates:
            table = Table()
            for col in ("#", "name", "year", "type", "owned", "rank"):
                table.add_column(col)
            for i, c in enumerate(candidates, start=1):
                table.add_row(
                    str(i),
                    str(c.get("name", "")),
                    str(c.get("year") or "-"),
                    str(c.get("type") or "-"),
                    str(c.get("owned") if c.get("owned") is not None else "-"),
                    str(c.get("rank") if c.get("rank") is not None else "-"),
                )
            self.console.print(table)

    # -- match pass -----------------------------------------------------

    def _review_match_row(self, row: dict) -> None:
        candidates = json.loads(row["candidates_json"] or "[]")
        while True:
            self._show_item(row, candidates)
            prompt = (
                "[1-N] pick   (s)kip   (r)eject   (m <id>) manual BGG id   "
                "(f <text>) re-search   (q)uit > "
                if row["match_status"] == "unmatched"
                else "[1-N] pick   (s)kip   (r)eject   (q)uit > "
            )
            answer = self._ask(prompt)
            lowered = answer.lower()
            if lowered == "s":
                return
            if lowered == "r":
                self.decide_reject(row)
                return
            if answer.isdigit() and 1 <= int(answer) <= len(candidates):
                self.decide_pick(row, candidates[int(answer) - 1])
                return
            if lowered.startswith("m ") and answer[2:].strip().isdigit():
                self.decide_manual(row, int(answer[2:].strip()))
                return
            if lowered.startswith("f ") and answer[2:].strip():
                candidates = self._research(answer[2:].strip()) or candidates
                continue
            self.console.print("[yellow]didn't understand that — try again[/yellow]")

    def _manual_id(self, row: dict, bgg_id: int) -> None:
        candidate: dict = {"bgg_id": bgg_id}
        try:
            things = self.client.things([bgg_id])
        except _BGG_ERRORS as err:
            self._warn(
                f"couldn't look up id {bgg_id} ({err}) — recording the id with no name"
            )
        else:
            if things:
                thing = things[0]
                candidate.update(name=thing.name, year=thing.year, type=thing.type)
            else:
                self._warn(
                    f"BGG knows no game with id {bgg_id} — recording it "
                    "with no name (typo?)"
                )
        self._apply_choice(row, candidate)

    def _research(self, query: str) -> list[dict]:
        try:
            results = self.client.search(query)
        except _BGG_ERRORS as err:
            self.console.print(f"[yellow]search unavailable: {err}[/yellow]")
            return []
        if not results:
            self.console.print("[yellow]no results[/yellow]")
        return [
            {"bgg_id": r.bgg_id, "name": r.name, "year": r.year, "type": r.type}
            for r in results
        ]

    # -- version pass ---------------------------------------------------

    def _review_version_row(self, row: dict) -> None:
        candidates = json.loads(row["version_candidates_json"] or "[]")
        table = Table(title=f"{row['title_raw']} — which edition?")
        for col in ("#", "version", "year", "publishers", "languages", "score"):
            table.add_column(col)
        for i, v in enumerate(candidates, start=1):
            table.add_row(
                str(i),
                str(v.get("name", "")),
                str(v.get("year") or "-"),
                ", ".join(v.get("publishers") or []),
                ", ".join(v.get("languages") or []),
                str(v.get("score", "-")),
            )
        self.console.print(table)
        while True:
            answer = self._ask("[1-N] pick   (u)nknown   (s)kip   (q)uit > ")
            lowered = answer.lower()
            if lowered == "s":
                return
            if lowered == "u":
                self.decide_version(row, None)
                return
            if answer.isdigit() and 1 <= int(answer) <= len(candidates):
                self.decide_version(row, candidates[int(answer) - 1].get("version_id"))
                return
            self.console.print("[yellow]didn't understand that — try again[/yellow]")

    # -- entry point ----------------------------------------------------

    def run(self) -> None:
        try:
            for row in self.pending_rows():
                self._review_match_row(row)
            versions = self.version_rows()
            if versions:
                answer = self._ask(
                    f"{len(versions)} game(s) have ambiguous editions. "
                    "Review them now? [y/N] > "
                )
                if answer.lower() == "y":
                    for row in versions:
                        self._review_version_row(row)
        except _Quit:
            self.console.print("[dim]stopping — progress is saved[/dim]")

        remaining = sum(
            1 for r in self.rows if r["match_status"] in PENDING_MATCH_STATUSES
        )
        self.console.print(
            f"Recorded {self.decisions} decision(s); "
            f"{remaining} item(s) still need review."
        )


def run_review(
    cfg: Config,
    *,
    console: Console | None = None,
    input_fn: InputFn | None = None,
    client: BGGClient | None = None,
) -> ReviewSession:
    session = ReviewSession(cfg, console=console, input_fn=input_fn, client=client)
    if not session.rows:
        session.console.print(
            f"{cfg.matches_path} is empty — run `bggpipe resolve` first."
        )
        raise typer.Exit(code=1)
    session.run()
    return session
