"""Stage 6 — enrich: full game + version metadata into games.json.

The seed data for the future web frontend, keyed by bgg_id (or
"bgg_id:version_id" when a version is settled). Cheap by design:
- ids are batched (~20 per /thing call) and sorted so batch cache keys
  stay stable across runs;
- already-enriched keys are skipped entirely (no request, no cache read)
  unless --refresh, which bypasses the cache read because ranks and
  ratings drift over time;
- the chosen version's details come from matches.csv's stored version
  candidates — no extra API calls.
Degrades gracefully without BGG_API_TOKEN: whatever is cached enriches,
the rest waits for the token.
"""

from __future__ import annotations

import json

import typer

from bggpipe.bgg_client import (
    BGGAuthError,
    BGGClient,
    BGGQueueTimeout,
    client_for,
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized, parse_version_dims
from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches

BATCH_SIZE = 20

_AXES = ("width_in", "length_in", "depth_in")
_AGREE_IN = 0.5  # per-axis tolerance for calling versionless dims unanimous


def _entry_dims(entry: dict, records: list[dict]) -> dict:
    """The box-dimensions verdict for one game, from its versions' data.

    Known version: that exact version's numbers or nothing. No chosen
    version: only a chorus that AGREES (within 0.5" per axis, all axes
    present) counts — the maximum per axis is kept, since the planning
    question is "will it fit", and disagreement beyond tolerance stores
    nulls rather than a guess."""
    version_id = (entry.get("version") or {}).get("version_id")
    if version_id:
        rec = next((r for r in records if r["version_id"] == version_id), None)
        if rec and all(rec[a] for a in _AXES):
            return {
                **{a: rec[a] for a in _AXES},
                "weight_lb": rec["weight_lb"],
                "source": "version",
            }
        return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
    complete = [r for r in records if all(r[a] for a in _AXES)]
    if not complete:
        return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
    dims: dict = {}
    for axis in _AXES:
        values = [r[axis] for r in complete]
        if max(values) - min(values) > _AGREE_IN:
            return dict.fromkeys((*_AXES, "weight_lb"), None) | {
                "source": "conflicting"
            }
        dims[axis] = max(values)
    weights = [r["weight_lb"] for r in complete if r["weight_lb"]]
    dims["weight_lb"] = (
        max(weights) if weights and max(weights) - min(weights) <= 0.5 else None
    )
    return dims | {"source": "unanimous"}


def _dims_pass(games: dict, client: BGGClient, *, refresh: bool) -> tuple[int, bool]:
    """Fill entry["dims"] for every BGG-backed game from versions=1 data
    (physical dimensions live on VERSIONS, not games). Cached XML makes
    re-runs free; rpgitems and local games get source "absent"."""
    need_ids = sorted(
        {
            e["bgg_id"]
            for e in games.values()
            if e.get("type") in ("boardgame", "boardgameexpansion")
            and e.get("bgg_id")
            and (refresh or "dims" not in e)
        }
    )
    by_game: dict[int, list[dict]] = {}
    blocked = False
    for start in range(0, len(need_ids), BATCH_SIZE):
        batch = need_ids[start : start + BATCH_SIZE]
        try:
            xml = client.get_xml(
                "thing",
                {"id": ",".join(str(i) for i in batch), "versions": "1"},
                refresh=refresh,
            )
        except (BGGAuthError, BGGQueueTimeout):
            blocked = True
            break
        by_game.update(parse_version_dims(xml))
    filled = 0
    for entry in games.values():
        if not (refresh or "dims" not in entry):
            continue
        if entry.get("bgg_id") in by_game:
            entry["dims"] = _entry_dims(entry, by_game[entry["bgg_id"]])
            if entry["dims"]["source"] == "version" and entry.get("version"):
                entry["version"].update(
                    {k: entry["dims"][k] for k in (*_AXES, "weight_lb")}
                )
            filled += 1
        elif entry.get("bgg_id") is None or entry.get("type") not in (
            "boardgame",
            "boardgameexpansion",
        ):
            entry["dims"] = dict.fromkeys((*_AXES, "weight_lb"), None) | {
                "source": "absent"
            }
    return filled, blocked


def _version_info(row: dict) -> dict | None:
    if not is_confident_version(row):
        return None
    version_id = int(row["version_id"])
    for cand in json.loads(row["version_candidates_json"] or "[]"):
        if cand.get("version_id") == version_id:
            return {
                "version_id": version_id,
                "name": cand.get("name", ""),
                "year": cand.get("year"),
                "publishers": cand.get("publishers") or [],
                "languages": cand.get("languages") or [],
            }
    # candidates were pruned (e.g. manual review) — keep what the row knows
    return {
        "version_id": version_id,
        "name": row["version_name"],
        "year": None,
        "publishers": [],
        "languages": [],
    }


def run_enrich(
    cfg: Config, *, refresh: bool = False, client: BGGClient | None = None
) -> dict:
    rows = read_matches(cfg.matches_path)
    if not rows:
        typer.echo(f"{cfg.matches_path} is empty — run `bggpipe resolve` first.")
        raise typer.Exit(code=1)

    targets: list[tuple[str, int, dict | None]] = []
    for row in rows:
        if not is_recognized(row):
            continue
        version = _version_info(row)
        key = f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"]
        targets.append((key, int(row["bgg_id"]), version))

    games_path = cfg.games_path
    games: dict = json.loads(games_path.read_text()) if games_path.exists() else {}

    need = sorted({bgg_id for key, bgg_id, _ in targets if refresh or key not in games})
    client = client or client_for(cfg)

    fetched: dict[int, dict] = {}
    blocked = False
    for start in range(0, len(need), BATCH_SIZE):
        batch = need[start : start + BATCH_SIZE]
        try:
            for thing in client.things_full(batch, refresh=refresh):
                fetched[thing["bgg_id"]] = thing
        except (BGGAuthError, BGGQueueTimeout):
            blocked = True
            break

    updated = 0
    for key, bgg_id, version in targets:
        if bgg_id in fetched:
            games[key] = {**fetched[bgg_id], "version": version}
            updated += 1

    # games the human ruled off-BGG: library entries built from their own
    # photo reads — no API involved, so a blocked run still lands them
    local_keys: set[str] = set()
    local_rows = [r for r in rows if r["match_status"] == "local"]
    # hand-written metadata wins over the photo reads: for an off-BGG game
    # it is the only real source there is
    hand: dict = {}
    if cfg.local_games_path.exists():
        try:
            hand = json.loads(cfg.local_games_path.read_text())
        except json.JSONDecodeError as err:
            raise ValueError(
                f"{cfg.local_games_path} is corrupt ({err}) — it holds "
                "hand-written game data, so check git history before deleting"
            ) from err
    if local_rows:
        try:
            cues = {
                (e.title_raw, ";".join(e.source_photos)): e
                for e in load_titles(cfg.titles_path)
            }
        except FileNotFoundError:
            cues = {}
        for row in local_rows:
            key = f"local:{normalize_title(row['title_raw'])}:{row['source_photos']}"
            local_keys.add(key)
            entry = cues.get((row["title_raw"], row["source_photos"]))
            games[key] = {
                "bgg_id": None,
                "name": row["title_raw"],
                "year": entry.year_hint if entry else None,
                "type": "localgame",
                "publishers": [entry.publisher_hint]
                if entry and entry.publisher_hint
                else [],
                "source_photos": [p for p in row["source_photos"].split(";") if p],
            }
            games[key].update(
                {k: v for k, v in (hand.get(key) or {}).items() if v not in (None, "")}
            )
        # the key embeds the photo list, so a new sighting or a title edit
        # strands hand data under a key no row produces anymore. Same
        # normalized title + exactly one candidate = unambiguous: migrate.
        # Anything else is reported, never silently dropped.
        orphans = [k for k in hand if k.startswith("local:") and k not in local_keys]
        migrated = {}
        for old_key in orphans:
            title_part = old_key.split(":", 2)[1]
            candidates = [k for k in local_keys if k.split(":", 2)[1] == title_part]
            if len(candidates) == 1 and candidates[0] not in hand:
                new_key = candidates[0]
                hand[new_key] = hand.pop(old_key)
                migrated[old_key] = new_key
                games[new_key].update(
                    {k: v for k, v in hand[new_key].items() if v not in (None, "")}
                )
            else:
                typer.echo(
                    f"  warning: hand-written data for {old_key!r} matches "
                    "no current catalog line — the facts are safe in "
                    f"{cfg.local_games_path.name} but will not show in the "
                    "library until the key matches again"
                )
        if migrated:
            atomic_write_text(
                cfg.local_games_path,
                json.dumps(hand, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
            )
            for old_key, new_key in migrated.items():
                typer.echo(
                    f"  migrated hand-written data {old_key!r} -> {new_key!r} "
                    "(photo set changed; same title)"
                )

    # prune keys no current target claims: a row whose version was approved
    # after a bare-key run (or was later rejected) must not leave an orphan
    # entry in the frontend seed data. Only safe when nothing was blocked —
    # a token-less run knows too little to declare anything stale.
    if not blocked:
        current = {key for key, _, _ in targets} | local_keys
        stale = [k for k in games if k not in current]
        for k in stale:
            del games[k]
        if stale:
            typer.echo(
                f"  pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
            )

    # containment is row-level curation: carry it onto entries every run
    # so the library and the shelf math both know what shares a box
    stored_by_key = {}
    for row in rows:
        if is_recognized(row) and row.get("stored_in"):
            version = _version_info(row)
            key = (
                f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"]
            )
            stored_by_key[key] = row["stored_in"]
    for key, entry in games.items():
        entry["stored_in"] = stored_by_key.get(key)

    # box dimensions live on VERSIONS, not games: a second cached pass
    # (read-only; upload is untouched) fills entry["dims"] for shelf math
    dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
    blocked = blocked or dims_blocked

    games_path.parent.mkdir(parents=True, exist_ok=True)
    atomic_write_text(
        games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
    )

    # count the two populations separately: local entries are built from
    # photo reads and have no fetch target, so folding them into the API
    # tally would drive "already present or waiting" negative
    waiting = len(targets) - updated
    parts = [f"{updated} fetched from BGG"]
    if dims_filled:
        parts.append(f"box dims checked for {dims_filled}")
    if local_keys:
        parts.append(f"{len(local_keys)} local-only")
    if waiting:
        parts.append(f"{waiting} already present or waiting")
    typer.echo(
        f"games.json: {len(games)} entr{'y' if len(games) == 1 else 'ies'} "
        f"({'; '.join(parts)})."
    )
    if blocked:
        remaining = [i for i in need if i not in fetched]
        typer.echo(
            f"\n{len(remaining)} game(s) are waiting on the BGG API "
            "(set BGG_API_TOKEN and re-run enrich — everything fetched "
            "so far is saved)."
        )
    return games
