"""
Dataset loader — the read API the shell screens use.

Single source of truth: `dataset/enriched/<gid>.json` (per-game unified
enrichment written by `tools.enrich`). Assets are sourced from
`dataset/art/<gid>/` (one unified art store — catalog + user-added are
both shipped with the port, no second store).

If `dataset/enriched/` is missing or empty, the catalog is empty —
intentionally. The shell's empty-state UI handles that case by prompting
the user to populate ROMs and run the enrich pipeline. We do NOT fall
back to a hand-curated mock here; mocking real datasets is exactly the
data sprawl this cleanup retired.

Every accessor returns real catalog data; recommendations and collection
members are resolved to real games so the UI never points at a dead id.
"""

from __future__ import annotations

import glob
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone

from ..library.state import _format_last_played

_HERE = os.path.dirname(os.path.abspath(__file__))
_SHELL = os.path.dirname(os.path.dirname(_HERE))          # shell/
_DATASET_DIR = os.path.join(_SHELL, "dataset")


@dataclass
class Catalog:
    games: list[dict]
    by_id: dict[str, dict]
    systems: list[str]
    play_state: dict
    collections: list[dict]
    asset_root: str
    # `enriched_dir` is the per-game unified-enrich store
    # (<dataset_dir>/enriched/<gid>.json — see tools.enrich). `cheats_for`
    # reads `cheats.curated` out of those files; the legacy `cheats_dir`
    # store (<dataset_dir>/cheats/<gid>.json) was retired with the unified
    # pipeline and is no longer consulted (archived in .staging/legacy/).
    enriched_dir: str = ""
    cheats_dir: str = ""              # DEPRECATED, retained for back-compat — not read.
    meta: dict = field(default_factory=dict)
    # ── real library overlay (the card's games, set on device) ──────────
    # The catalog (games/by_id/systems/collections/recs) stays the EDITORIAL seed
    # used by Home/Discover/Add. The library overlay is "what's actually on the
    # card" — matched catalog games (enriched) ∪ the user's own games (synthesized,
    # metadata pending). Until set_library() runs (e.g. on a dev box) the overlay
    # falls back to the full catalog so screens behave as before.
    installed_ids: set = field(default_factory=set)
    library_order: list = field(default_factory=list)   # stable ids, card order
    enrich_state: dict = field(default_factory=dict)     # id → "catalog" | "pending"
    library_ready: bool = False
    _series_of: dict = field(default_factory=dict)       # gid → clean series name
    _series_members: dict = field(default_factory=dict)  # clean name → [games, chronological]

    # ── construction ─────────────────────────────────────────────────
    @classmethod
    def load(cls, dataset_dir: str | None = None) -> "Catalog":
        d = dataset_dir or _DATASET_DIR
        enriched_dir = os.path.join(d, "enriched")
        art_dir      = os.path.join(d, "art")

        # Read every <gid>.json in `dataset/enriched/`. Each file is a
        # catalog entry; assets are sourced from `dataset/art/<gid>/` by
        # directory listing. An empty (or missing) enriched dir yields an
        # empty catalog on purpose — the shell's empty-state UI prompts
        # the user to populate ROMs and run the enrich pipeline.
        games: list[dict] = []
        if os.path.isdir(enriched_dir):
            for path in sorted(glob.glob(os.path.join(enriched_dir, "*.json"))):
                try:
                    with open(path, encoding="utf-8") as fh:
                        entry = json.load(fh)
                except (OSError, ValueError) as exc:
                    # A single corrupt file (fskit can leave a present-but-
                    # empty JSON) must not blank the whole catalog. Skip
                    # it loudly and continue.
                    print(f"catalog: skipped {os.path.basename(path)} "
                          f"({type(exc).__name__})", flush=True)
                    continue
                g = _flatten_enriched_entry(entry, art_dir)
                if g and g.get("id"):
                    games.append(g)

        by_id = {g["id"]: g for g in games if g.get("id")}

        # asset root: env override → dataset/art (the canonical location)
        asset_root = (os.environ.get("AAHEAD_ASSET_ROOT")
                      or art_dir if os.path.isdir(art_dir) else "")

        # systems: derive from the actual games seen — keeps the list
        # honest as the catalog grows / shrinks, no manual update needed.
        systems = sorted({g["system"] for g in games
                          if g.get("system") and isinstance(g["system"], str)})

        # play_state lives in `.staging/` now (runtime, not shipped). We
        # still TRY to read a seed from dataset/ for the dev shell on a
        # fresh clone — but its absence is fine.
        play_state = _maybe(os.path.join(d, "play_state.json"),
                            {"continue": [], "favorites": []})
        coll = _maybe(os.path.join(d, "collections.json"),
                      {"collections": []})

        # keep only collections whose members exist (no dead tiles)
        collections = []
        for c in coll.get("collections", []):
            members = [m for m in c.get("members", []) if m in by_id]
            if members:
                collections.append({**c, "members": members})

        return cls(games=games, by_id=by_id, systems=systems,
                   play_state=play_state, collections=collections,
                   asset_root=asset_root,
                   enriched_dir=enriched_dir,
                   cheats_dir="")                    # legacy, unused now

    # ── lookups ──────────────────────────────────────────────────────
    def get(self, gid: str) -> dict | None:
        return self.by_id.get(gid)

    def by_system(self, system: str) -> list[dict]:
        return [g for g in self.games if g["system"] == system]

    def search(self, query: str) -> list[dict]:
        q = (query or "").strip().lower()
        if not q:
            return []
        return [g for g in self.games if q in g["title"].lower()]

    def games_by_filter(self, kind: str, value: str) -> list[dict]:
        """Filter the catalog by an enrichment attribute. `kind`:
           'tag'        → match in game['tags']        (list of vocab.TAGS)
           'mood'       → match in game['mood']        (list of vocab.MOOD)
           'when'       → match in game['when_to_play_categories'] (list)
           'difficulty' → equal to game['difficulty']  (string)

        Games without the attribute (older enrich, partial data, synthesized
        library entries) are skipped — empty result is honest, never crashes.
        Sorted by rating desc so the strongest examples lead each chip."""
        field_for = {
            "tag":        ("tags", True),                       # list match
            "mood":       ("mood", True),
            "when":       ("when_to_play_categories", True),
            "difficulty": ("difficulty", False),                # scalar match
        }
        key, is_list = field_for.get(kind, (None, False))
        if key is None or not value:
            return []
        out = []
        for g in self.games:
            field = g.get(key)
            if field is None:
                continue
            if is_list:
                if isinstance(field, list) and value in field:
                    out.append(g)
            elif field == value:
                out.append(g)
        return sorted(out, key=lambda g: (g.get("rating") or 0), reverse=True)

    def recommendations(self, gid: str) -> list[dict]:
        """Resolved recommendations: [{game, reason}], only in-catalog ids."""
        g = self.by_id.get(gid)
        if not g:
            return []
        out = []
        for r in g.get("recommendations", []):
            rid = r.get("id")                          # enriched recs may lack a resolved id
            tgt = self.by_id.get(rid) if rid else None
            if tgt:
                out.append({"game": tgt, "reason": r.get("reason", "")})
        return out

    # ── play state ───────────────────────────────────────────────────
    def continue_entries(self) -> list[dict]:
        """[{game, last_played, playtime, saves}] for games still in catalog.

        `last_played` is formatted at READ time from `last_played_at` (ISO
        timestamp) — so a "just now" entry naturally reads as "5 min ago"
        five minutes later, without record_session having to fire again.
        Legacy entries written before Step P0.F have no `last_played_at`;
        we fall back to the cached `last_played` string so on-device
        upgrades don't blank the rail until the next session refresh."""
        now = datetime.now(timezone.utc)
        out = []
        for e in self.play_state.get("continue", []):
            g = self.by_id.get(e["id"])
            if not g:
                continue
            lp = e.get("last_played")
            iso = e.get("last_played_at")
            if iso:
                try:
                    lp = _format_last_played(datetime.fromisoformat(iso), now=now)
                except (ValueError, TypeError):
                    pass                           # corrupt → keep cached string
            out.append({"game": g, "last_played": lp,
                        "playtime": e.get("playtime"),
                        "saves": e.get("saves", [])})
        return out

    def continue_entry(self, gid: str) -> dict | None:
        for e in self.continue_entries():
            if e["game"]["id"] == gid:
                return e
        return None

    def favorites(self) -> list[dict]:
        return [self.by_id[i] for i in self.play_state.get("favorites", [])
                if i in self.by_id]

    def is_favorite(self, gid: str) -> bool:
        return gid in set(self.play_state.get("favorites", []))

    # ── curated cheats (sourced from per-game enrichment) ────────────
    def cheats_for(self, gid: str) -> list[dict]:
        """Return the curated cheat list for `gid` — list of cheat dicts
        the Assist screen consumes (label_en/_ru, code, default_on,
        why_en/_ru, how_to_use_en/_ru). Empty list when no curation
        exists (pipeline not yet run for this game, no cheat source on
        the card, etc.) OR when the source file is corrupt (zero-byte
        fskit write) — same defaults-over-defeat tolerance as
        Catalog.load.

        Resolution order:
          1. The game's existing entry already carries `cheats.curated`
             (set by the merged enriched overlay in set_library). Use that.
          2. Otherwise read `dataset/enriched/<gid>.json` directly from
             disk. Catches games whose enrich landed after the catalog
             snapshot was loaded (rare; mostly Discover / wishlist).
          3. Legacy `dataset/cheats/<gid>.json` is NOT consulted any
             more — that store was retired when the unified enrich
             pipeline shipped (see .staging/legacy/cheats_v1/ for the
             archived copy)."""
        if not gid:
            return []
        # 1) in-catalog: the merged enriched payload already carried it.
        g = self.by_id.get(gid) or {}
        cheats_block = g.get("cheats")
        if isinstance(cheats_block, dict):
            curated = cheats_block.get("curated")
            if isinstance(curated, list):
                return [_normalize_cheat(c) for c in curated if isinstance(c, dict)]
        # 2) on-disk fallback: per-game enriched file directly.
        if self.enriched_dir:
            path = os.path.join(self.enriched_dir, f"{gid}.json")
            try:
                with open(path, encoding="utf-8") as fh:
                    data = json.load(fh)
            except (OSError, ValueError):
                return []
            block = (data or {}).get("cheats")
            if isinstance(block, dict):
                curated = block.get("curated")
                if isinstance(curated, list):
                    return [_normalize_cheat(c) for c in curated if isinstance(c, dict)]
        return []

    # ── real library overlay ─────────────────────────────────────────
    def set_library(self, items: list[dict], enriched: dict | None = None) -> None:
        """Merge the on-card library manifest (ingest's library.json items) into
        the catalog. Matched games reuse their rich catalog entry; the user's own
        games are synthesized as first-class and registered in by_id so they
        open/launch like any other game. They get, in priority order:
          1. enriched metadata + hero (from enriched.json, if generated), then
          2. the scraped on-card box cover (free — already on the SD), else
          3. a clean placeholder.
        """
        enriched = enriched or {}
        self.library_ready = True
        self.installed_ids = set()
        self.library_order = []
        self.enrich_state = {}
        for it in items:
            gid = it.get("game_id")
            sid = it.get("id") or gid
            if not sid:
                continue
            enr = enriched.get(sid)
            if gid and gid in self.by_id:
                # Catalog game — its mock entry carries the editorial
                # asset bundle (box/hero/screenshots/artworks).
                # Overlay enriched on top so the NEW per-game fields
                # (tags/mood/when_to_play_*/first_hour_tip_*/difficulty/
                # time_to_complete/_ru) become available on catalog games
                # too — without this, only synthesized library entries see
                # the new schema and Discover filters disappear on the
                # 182 most-curated games. Asset paths from the catalog
                # take precedence (`.update(enr)` doesn't touch assets
                # the catalog already set), but we don't let the enriched
                # `assets: {}` (the new schema doesn't carry assets) clobber
                # the catalog bundle — drop that key before merge.
                if enr:
                    overlay = {k: v for k, v in enr.items() if k != "assets"}
                    self.by_id[gid].update(overlay)
                state = "catalog"
            else:
                game = self._synth_game(it, sid)
                if enr:
                    game.update(enr)                   # generated facts/voice/hero
                # always ensure a box: the SD's scraped cover is free
                if not (game.get("assets") or {}).get("box"):
                    box = self._card_box(it.get("path"))
                    if box:
                        game.setdefault("assets", {})["box"] = box
                self.by_id.setdefault(sid, game)
                state = "rich" if enr else "pending"
            self.installed_ids.add(sid)
            self.library_order.append(sid)
            self.enrich_state[sid] = state
        self._compute_series()

    # Series: clean IGDB-franchise name + CHRONOLOGICAL ordering. No part numbers —
    # fabricated "N of M" is what was wrong, and there's no canonical installment
    # number to source at dataset scale; release order tells the story instead.
    def _compute_series(self) -> None:
        from collections import defaultdict
        members: dict = defaultdict(list)
        for gid in self.library_order:
            g = self.by_id.get(gid)
            name = _series_name(g) if g else None
            if name:
                members[name].append(g)
        self._series_members, self._series_of = {}, {}
        for name, gs in members.items():
            gs.sort(key=lambda g: (g.get("year") or 9999, (g.get("title") or "").lower()))
            self._series_members[name] = gs
            for g in gs:
                self._series_of[g["id"]] = name

    def game_series(self, gid: str) -> dict | None:
        """The game's series NAME (no part numbers). Library-aware; falls back to
        the game's own cleaned catalog series name off-device."""
        if gid in self._series_of:
            return {"name": self._series_of[gid]}
        s = (self.by_id.get(gid) or {}).get("series")
        nm = _clean_series_name(s.get("name")) if s else None
        return {"name": nm} if nm else None

    def library_series_groups(self, min_size: int = 2) -> dict[str, list[dict]]:
        return {n: gs for n, gs in sorted(self._series_members.items()) if len(gs) >= min_size}

    @staticmethod
    def _card_box(rom_path: str | None) -> str | None:
        """The ArkOS-scraped box cover sitting next to the ROM:
        /roms/<sys>/images/<rom-stem>-image.png — reused for free, no download."""
        if not rom_path:
            return None
        stem = os.path.splitext(os.path.basename(rom_path))[0]
        p = os.path.join(os.path.dirname(rom_path), "images", stem + "-image.png")
        return p if os.path.exists(p) else None

    @staticmethod
    def _synth_game(item: dict, sid: str) -> dict:
        """A first-class (not degraded) game dict for a card game we don't have in
        the catalog yet — readable now, ready to be enriched later."""
        return {
            "id": sid,
            "title": item.get("title") or item.get("name") or sid,
            "system": item.get("system") or "",
            "year": None, "genre": [], "players": None,
            "developer": None, "publisher": None, "age_rating": None,
            "rating": None, "rating_100": None, "rating_source": None,
            "series": None, "franchises": [], "collections_igdb": [],
            "game_modes": [], "recommendations": [],
            "description": "Added from your card. Full details coming soon.",
            "assets": {}, "enrich": "pending", "catalog": False,
        }

    def is_installed(self, gid: str) -> bool:
        # before the overlay is set (dev box), treat everything as available
        return True if not self.library_ready else (gid in self.installed_ids)

    def is_pending(self, gid: str) -> bool:
        return self.enrich_state.get(gid) == "pending"

    def library_games(self) -> list[dict]:
        if not self.library_ready:
            return self.games                          # dev fallback: the full catalog
        return [self.by_id[i] for i in self.library_order if i in self.by_id]

    def library_systems(self) -> list[str]:
        present = {g["system"] for g in self.library_games() if g.get("system")}
        ordered = [s for s in self.systems if s in present]
        ordered += [s for s in sorted(present) if s not in ordered]
        return ordered

    def library_by_system(self, system: str) -> list[dict]:
        return [g for g in self.library_games() if g.get("system") == system]

    # ── collections ──────────────────────────────────────────────────
    def collection(self, cid: str) -> dict | None:
        for c in self.collections:
            if c["id"] == cid:
                return c
        return None

    def collection_games(self, cid: str) -> list[dict]:
        c = self.collection(cid)
        return [self.by_id[m] for m in c["members"]] if c else []

    # ── series / franchises ──────────────────────────────────────────
    def series_groups(self, min_size: int = 2) -> dict[str, list[dict]]:
        """{series name → games} for series with ≥min_size. Over the LIBRARY (the
        card) once the overlay is set; over the editorial catalog otherwise."""
        if self.library_ready:
            return self.library_series_groups(min_size)
        from collections import defaultdict
        d = defaultdict(list)
        for g in self.games:
            s = g.get("series")
            if s and s.get("name"):
                d[_clean_series_name(s["name"])].append(g)
        out = {}
        for name, gs in d.items():
            if len(gs) >= min_size:
                out[name] = sorted(gs, key=lambda g: (g.get("year") or 9999, g["title"].lower()))
        return dict(sorted(out.items()))

    # ── assets ───────────────────────────────────────────────────────
    def asset_path(self, game: dict, kind: str = "box") -> str | None:
        """Absolute path to an asset. kind = box|hero|screenshot|artwork."""
        a = game.get("assets", {})
        if kind in ("box", "hero"):
            rel = a.get(kind)
        elif kind == "screenshot":
            ss = a.get("screenshots") or []
            rel = ss[0] if ss else None
        elif kind == "artwork":
            art = a.get("artworks") or []
            rel = art[0] if art else None
        else:
            rel = None
        if not rel:
            return None
        if os.path.isabs(rel):
            return rel                                    # enriched/on-card art (absolute)
        if not self.asset_root:
            return None
        return os.path.join(self.asset_root, rel)


_SERIES_PAREN = re.compile(r"\s*\([^)]*\)\s*$")


def _clean_series_name(name: str | None) -> str | None:
    """Strip trailing parentheticals so verbose names group cleanly:
    'Castlevania (GBA trilogy)' → 'Castlevania'."""
    if not name:
        return None
    return _SERIES_PAREN.sub("", name).strip() or None


def _series_name(game: dict) -> str | None:
    """A game's series name: its series.name, else its primary IGDB franchise."""
    s = game.get("series") or {}
    name = s.get("name")
    if not name:
        fr = game.get("franchises") or []
        name = fr[0] if fr else None
    return _clean_series_name(name)


def _normalize_cheat(c: dict) -> dict:
    """Bridge the unified-enrich cheat schema to the keys every consumer
    (AssistScreen, QuickMenu, game-card preview, cheat_apply.build_cht_file)
    reads. The enrichment writes `label_en` / `why_en` / `how_to_use_en`
    (bilingual), but those consumers read the plain `label` / `why` /
    `how_to_use`. Without this bridge EVERY cheat fails the
    `if c.get("label")` guard in build_cht_file → an empty .cht →
    cheats silently never apply on launch (caught by the launch test).

    We ADD the plain keys (English) and KEEP the _en/_ru originals so a
    future locale-aware UI can switch. Default to English; a missing _en
    falls back to any pre-existing plain key (legacy cheats_v1 files used
    plain `label`/`why` directly)."""
    out = dict(c)
    out.setdefault("label", c.get("label_en") or c.get("label") or "")
    out.setdefault("why", c.get("why_en") or c.get("why") or "")
    out.setdefault("how_to_use", c.get("how_to_use_en") or c.get("how_to_use") or "")
    return out


def _maybe(path: str, default):
    """Load JSON, or the default if the file is missing OR corrupt (fskit can
    leave a present-but-empty file after a bad unmount — that must not crash)."""
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return default


# ── per-game-enrich → catalog-entry adapter ──────────────────────────


# Files in `dataset/art/<gid>/` map to assets by filename convention.
# Anything not in these patterns is ignored (so stray .DS_Store / .tmp
# files don't leak into the UI as broken tiles).
_BOX_NAMES = {"box.jpg", "box.png", "box.webp"}
_HERO_NAMES = {"hero.jpg", "hero.png", "hero.webp"}
_SCREENSHOT_PREFIXES = ("snap", "title", "screenshot", "ss")
_ARTWORK_PREFIXES = ("art_",)


def _derive_assets(art_dir: str, gid: str) -> dict | None:
    """Walk `<art_dir>/<gid>/` and assemble the assets dict the shell
    consumes (box / hero / screenshots[] / artworks[]). Returns None if
    the dir doesn't exist or holds no recognised files."""
    sub = os.path.join(art_dir, gid)
    if not os.path.isdir(sub):
        return None
    box = hero = None
    screenshots: list[str] = []
    artworks: list[str] = []
    for entry in sorted(os.listdir(sub)):
        low = entry.lower()
        if low in _BOX_NAMES and box is None:
            box = f"{gid}/{entry}"
        elif low in _HERO_NAMES and hero is None:
            hero = f"{gid}/{entry}"
        elif any(low.startswith(p) for p in _SCREENSHOT_PREFIXES):
            screenshots.append(f"{gid}/{entry}")
        elif any(low.startswith(p) for p in _ARTWORK_PREFIXES):
            artworks.append(f"{gid}/{entry}")
    out: dict = {}
    if box:         out["box"] = box
    if hero:        out["hero"] = hero
    if screenshots: out["screenshots"] = screenshots
    if artworks:    out["artworks"] = artworks
    return out or None


def _flatten_enriched_entry(entry: dict, art_dir: str) -> dict | None:
    """Project a `dataset/enriched/<gid>.json` payload onto the flat
    catalog-entry shape the shell already consumes.

    Maps the new bilingual schema to legacy field names (description ←
    description_en, year ← facts.year, etc.) AND carries every NEW field
    through (description_ru, tags, mood, first_hour_tip_*,
    when_to_play_*, difficulty, time_to_complete) so future screens read
    them without another adapter pass.

    Recommendations: the new schema gives `{game_id, title, system,
    reason_en, reason_ru}`; the shell consumes `{id, reason, ...}`. We
    map game_id → id and reason_en → reason; the originals stay too for
    screens that want them.

    Assets: derived from disk (`dataset/art/<gid>/`), NOT from the entry —
    enrichment is metadata-only, art lives in its own dir."""
    if not isinstance(entry, dict):
        return None
    gid = entry.get("game_id") or entry.get("id")
    if not gid:
        return None

    facts = entry.get("facts") or {}
    if not isinstance(facts, dict):
        facts = {}

    out: dict = {
        "id":     gid,
        "title":  entry.get("title"),
        "system": entry.get("system"),
        "description": entry.get("description_en") or "",
    }

    # Facts → flat fields the shell already reads.
    for k in ("year", "genre", "developer", "publisher", "rating",
              "players", "age_rating"):
        if k in facts:
            out[k] = facts[k]

    # Series (same shape between schemas).
    if entry.get("series") is not None:
        out["series"] = entry["series"]

    # Recommendations: shell expects `{"id", "reason", ...}` — keep extras.
    recs = entry.get("recommendations") or []
    if isinstance(recs, list):
        out["recommendations"] = [
            {
                "id":        r.get("game_id"),
                "reason":    r.get("reason_en") or "",
                "title":     r.get("title"),
                "system":    r.get("system"),
                "reason_ru": r.get("reason_ru"),
            }
            for r in recs if isinstance(r, dict)
        ]

    # Cheats block (curated[] + skipped_reason_*) — passes through.
    if entry.get("cheats") is not None:
        out["cheats"] = entry["cheats"]

    # NEW fields screens can opt into.
    for k in ("description_ru",
              "first_hour_tip_en", "first_hour_tip_ru",
              "when_to_play_en", "when_to_play_ru",
              "when_to_play_categories",
              "tags", "mood", "difficulty",
              "time_to_complete"):
        if k in entry:
            out[k] = entry[k]

    # Assets — read from disk, not from the entry.
    assets = _derive_assets(art_dir, gid)
    if assets:
        out["assets"] = assets

    return out
