"""
LibraryService — the GUI's device-aware door to the library backend.

Holds the device paths (with env overrides for dev/test), runs a safe first-run
index so `installed.json` exists, and resolves a game → launch command. Keeps the
screens ignorant of the /roms layout and of es_systems.cfg.

Env overrides (all optional; sane device defaults otherwise):
  AAHEAD_ROMS_ROOT   roms partition root          (default /roms)
  AAHEAD_STATE_DIR   library state dir            (default <roms>/ports/aahead/library)
  AAHEAD_GAMEDIR     where the .launch flag lands (default cwd = the port dir)
  AAHEAD_IMPORT_DIR  desktop-companion drop folder(default <roms>/import)
  AAHEAD_SCAN_ROOTS  extra scan roots, os.pathsep-separated
"""

from __future__ import annotations

import json
import os
import shutil

from . import ingest, launch
from .state import PlayState


def _abs(p: str) -> str:
    return os.path.abspath(os.path.expanduser(p))


class LibraryService:
    def __init__(self, catalog):
        self.catalog = catalog
        env = os.environ.get
        self.roms_root = _abs(env("AAHEAD_ROMS_ROOT") or "/roms")
        self.state_dir = _abs(env("AAHEAD_STATE_DIR")
                              or os.path.join(self.roms_root, "ports", "aahead", "library"))
        self.gamedir = _abs(env("AAHEAD_GAMEDIR") or os.getcwd())

        roots = [self.roms_root, _abs(env("AAHEAD_IMPORT_DIR")
                                      or os.path.join(self.roms_root, "import"))]
        for extra in (env("AAHEAD_SCAN_ROOTS") or "").split(os.pathsep):
            if extra.strip():
                roots.append(_abs(extra))
        seen: set[str] = set()
        self.scan_roots: list[str] = []
        for r in roots:                       # de-dupe, keep order
            if r not in seen:
                seen.add(r)
                self.scan_roots.append(r)

        self._es = None
        self._play_state: PlayState | None = None

    # ── launch flag ──────────────────────────────────────────────────
    @property
    def flag_launch(self) -> str:
        return os.path.join(self.gamedir, ".launch")

    # ── user play state (cached) ─────────────────────────────────────
    def play_state(self) -> PlayState:
        """Lazy init — owns `<state_dir>/play_state.json` (favorites, continue,
        playtime). On creation seeded from catalog.play_state so off-device
        demos still look populated; once on-device the disk file wins. Use
        this for ALL user-state reads/mutations from here on."""
        if self._play_state is None:
            self._play_state = PlayState(self.state_dir, seed=self.catalog.play_state)
        return self._play_state

    # ── es_systems (cached) ──────────────────────────────────────────
    def es(self) -> dict:
        if self._es is None:
            self._es = launch.load_es_systems()
        return self._es

    # ── indexing ─────────────────────────────────────────────────────
    def has_library(self) -> bool:
        """True only if the manifest is present AND readable. A zero-byte / corrupt
        file (fskit can leave one after a bad unmount) is NOT a library — returning
        False here makes ensure_indexed() re-scan the card and rebuild it, instead
        of trusting a phantom file and serving an empty shell forever."""
        path = os.path.join(self.state_dir, ingest.MANIFEST)
        if not os.path.exists(path):
            return False
        try:
            with open(path, encoding="utf-8") as fh:
                data = json.load(fh)
        except (OSError, ValueError):
            return False                       # unreadable/corrupt → treat as no library
        return isinstance(data, dict) and isinstance(data.get("items"), list)

    def analyze(self) -> dict:
        return ingest.analyze(self.scan_roots, self.catalog.games, self.roms_root)

    def index(self, do_moves: bool = False) -> dict:
        report = self.analyze()
        done = ingest.apply_report(report, self.state_dir, do_moves=do_moves)
        return {"report": report, "done": done}

    def ensure_indexed(self) -> dict | None:
        """First boot: catalog what's already on the card → installed.json,
        WITHOUT moving anything (no surprises). No-op once a library exists."""
        if self.has_library():
            return None
        try:
            return self.index(do_moves=False)
        except Exception as exc:                       # noqa: BLE001
            print(f"library index failed: {exc!r}", flush=True)
            return None

    # ── reads ────────────────────────────────────────────────────────
    def manifest(self) -> dict:
        return ingest.load_manifest(self.state_dir)

    def enriched(self) -> dict:
        """Game enrichment metadata as {gid: flat_dict}.

        Two on-disk formats coexist during the schema migration:

          NEW (`<shell>/dataset/enriched/<gid>.json`) — one file per game,
              bilingual (description_en/_ru), facts nested under `facts.*`,
              plus tags/mood/first_hour_tip/when_to_play/difficulty/
              recommendations/cheats curated. Written by `tools.enrich`.
          OLD (`<state_dir>/enriched.json`) — single big dict, flat
              fields (description / year / genre / developer / rating).
              Written by the legacy `tools/enrich_library.py`.

        Shell screens consume the OLD flat shape (`g.get("description")`,
        `g.get("year")`, etc.). So when reading NEW per-file entries we
        ALSO project them onto the old field names so the existing UI
        keeps working — and we copy the new fields through untouched so
        screens that gain bilingual / tags / mood support can read them
        without another adapter pass.

        Resolution order: NEW per-file dir first (it's the future format),
        OLD enriched.json second (until every catalogued game is in NEW
        format). For games present in BOTH, NEW wins. Empty NEW dir =
        OLD only — no behavioural regression."""
        out: dict = {}

        # OLD format first — populate the baseline so games NOT yet
        # re-enriched still show their legacy fields.
        old = ingest._load_json(
            os.path.join(self.state_dir, "enriched.json"), {})
        if isinstance(old, dict):
            out.update(old)

        # NEW per-file overlay. Path is shell-relative — `dataset/` lives
        # in the checked-in shell tree, NOT in the card's state dir.
        new_dir = self._new_enriched_dir()
        if os.path.isdir(new_dir):
            for entry in os.listdir(new_dir):
                if not entry.endswith(".json"):
                    continue
                full = os.path.join(new_dir, entry)
                try:
                    with open(full, encoding="utf-8") as fh:
                        new_entry = json.load(fh)
                except (OSError, ValueError):
                    continue
                gid = new_entry.get("game_id") or entry[:-5]
                # Merge: legacy fields first (so flattened-new overrides
                # any stale legacy values for the same gid), then carry
                # through every new field untouched.
                base = dict(out.get(gid) or {})
                base.update(_flatten_new_enriched(new_entry))
                out[gid] = base
        return out

    def _new_enriched_dir(self) -> str:
        """Location of the per-file new-format enrichment. Relative to
        the shell package, NOT to state_dir, because the dataset is part
        of the shell distribution (versioned in git), not per-device
        state."""
        here = os.path.dirname(os.path.abspath(__file__))
        shell_dir = os.path.dirname(here)            # .../shell/gui → .../shell
        # `gui/` is one level inside shell; this file is gui/library/.
        # Walk one more up to land on shell/, then dataset/enriched/.
        shell_dir = os.path.dirname(shell_dir)       # .../shell
        return os.path.join(shell_dir, "dataset", "enriched")

    def installed(self) -> dict:
        return ingest.load_installed(self.state_dir)

    def is_installed(self, game_id: str) -> bool:
        return game_id in self.installed()

    # ── launch ───────────────────────────────────────────────────────
    def launch_spec(self, game: dict):
        """Catalog game → LaunchSpec (or None if not installed / not configured)."""
        return launch.resolve_game(game, self.state_dir, es=self.es())

    # ── diagnostics ──────────────────────────────────────────────────
    def dump_diagnostics(self) -> None:
        """One-shot: copy the device's real es_systems.cfg + the installed-core
        listing into the state dir, so launch resolution can be verified/perfected
        off the device. On-device only; a harmless no-op on a dev box."""
        try:
            os.makedirs(self.state_dir, exist_ok=True)
            es_path = launch._es_path(None)
            if es_path and os.path.exists(es_path):
                shutil.copyfile(es_path, os.path.join(self.state_dir, "es_systems.dump.cfg"))
            cores = "/home/ark/.config/retroarch/cores"
            if os.path.isdir(cores):
                names = sorted(n for n in os.listdir(cores) if n.endswith(".so"))
                with open(os.path.join(self.state_dir, "cores.dump.txt"),
                          "w", encoding="utf-8") as fh:
                    fh.write("\n".join(names))
        except Exception as exc:                       # noqa: BLE001
            print(f"dump_diagnostics failed: {exc!r}", flush=True)


# ── new-schema → legacy-flat-dict adapter ─────────────────────────────


def _flatten_new_enriched(entry: dict) -> dict:
    """Project the bilingual unified-enrich schema (see
    `tools/enrich/schema.py::GAME_ENRICHMENT_SCHEMA`) onto the flat
    shape the shell screens already consume — while ALSO carrying every
    new field through untouched so screens can opt into them.

    Why a flat shim instead of refactoring every screen:
      - 20+ screens read `g.get("description")`, `g.get("year")`,
        `(g.get("genre") or [None])[0]`, `g.get("rating")` directly.
      - We're shipping enrichment data now; refactoring every screen to
        a structured-bilingual path is a separate, bigger workstream.
      - Carrying NEW fields through untouched (tags, mood, first_hour_*,
        when_to_play_*, time_to_complete, difficulty) means future screens
        can read them without another migration round.

    Locale: defaults to English on the flat keys. RU is available
    alongside under `description_ru` / `first_hour_tip_ru` / etc. —
    a future locale-aware screen swaps which key it reads.

    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 extras (title/system/
    reason_ru) stay on the rec for screens that want them.

    Series, cheats: pass through — schema already matches.
    """
    if not isinstance(entry, dict):
        return {}
    out: dict = {}

    # Legacy flat fields the shell already reads.
    out["description"] = entry.get("description_en") or ""
    facts = entry.get("facts") or {}
    if isinstance(facts, dict):
        if "year"       in facts: out["year"]       = facts["year"]
        if "genre"      in facts: out["genre"]      = facts["genre"]
        if "developer"  in facts: out["developer"]  = facts["developer"]
        if "publisher"  in facts: out["publisher"]  = facts["publisher"]
        if "rating"     in facts: out["rating"]     = facts["rating"]
        if "players"    in facts: out["players"]    = facts["players"]
        if "age_rating" in facts: out["age_rating"] = facts["age_rating"]

    if entry.get("series") is not None:
        out["series"] = entry["series"]

    # Recommendations: shell expects [{"id":..., "reason":...}].
    recs_new = entry.get("recommendations") or []
    if isinstance(recs_new, list):
        out["recommendations"] = [
            {
                "id":        r.get("game_id"),
                "reason":    r.get("reason_en") or "",
                # Keep the original rich fields so future screens can use them.
                "title":     r.get("title"),
                "system":    r.get("system"),
                "reason_ru": r.get("reason_ru"),
            }
            for r in recs_new
            if isinstance(r, dict)
        ]

    # Cheats: shape unchanged between schemas. PlayState consumes it as a
    # dict; passing the whole `cheats` block (with curated[] +
    # skipped_reason_*) lets future cheat-aware screens read more.
    if entry.get("cheats") is not None:
        out["cheats"] = entry["cheats"]

    # NEW fields — pass through. Screens that opt in (Discover filter
    # strip, Clerk first-hour-tip panel, RU locale, etc.) read these.
    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]

    return out
