"""
Runtime cheat materialization — write the libretro .cht file next to the
ROM so RetroArch auto-loads it on launch.

Called from `App.launch_game` after `library.launch_spec(game)` returns a
LaunchSpec (which carries the absolute ROM path on the card). Idempotent:
re-runs overwrite the previous .cht with the latest toggle state.

Tolerance: write failures (read-only fs on a dev box, full SD card, perm
flap mid-write) do NOT crash the launcher. Same defaults-over-defeat policy
as `PlayState._save` — we log a warning and return None; the launcher then
runs the game without cheats applied.

The actual cheat application — RetroArch reading the file and toggling on
the enable=true entries at start — is driven by the override config that
App.launch_game appends to the command (sets `cheats_apply_at_start = true`).
"""

from __future__ import annotations

import os
import re
from typing import Optional


# Pure cheat-file emitter. Lives here (under gui/) so the runtime path —
# the only path that ships to the device — has no cross-tree imports. The
# tools/cheats/apply.py module re-exports this for the dev-time pipeline,
# keeping one source of truth without forcing tools/ onto the SD card.
def _quote(s: str) -> str:
    """libretro descs are double-quoted; strip internal quotes — libretro's
    parser is naive, escaping is brittle, strip is the safe choice."""
    return '"' + (s or "").replace('"', "'") + '"'


def build_cht_file(curated: list[dict], enabled_labels) -> str:
    """Return the libretro `.cht` body. Empty string when there's nothing to
    write (no curated entries OR every entry was dropped for missing fields).
    Each entry gets `_desc`/`_code`/`_enable` — enable=true only for labels
    in `enabled_labels` (any iterable)."""
    enabled = set(enabled_labels or ())
    valid = [c for c in (curated or [])
             if isinstance(c, dict) and c.get("label") and c.get("code")]
    if not valid:
        return ""
    lines: list[str] = [f"cheats = {len(valid)}", ""]
    for i, c in enumerate(valid):
        en = "true" if c["label"] in enabled else "false"
        lines.append(f"cheat{i}_desc = {_quote(c['label'])}")
        lines.append(f"cheat{i}_code = {_quote(c['code'])}")
        lines.append(f"cheat{i}_enable = {en}")
        lines.append("")
    return "\n".join(lines)


def _atomic_write(path: str, content: str) -> None:
    """tmp + rename — same pattern as PlayState._save. Patched in tests to
    simulate a read-only filesystem."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        fh.write(content)
    os.replace(tmp, path)


def cht_path_for(rom_path: str) -> str:
    """The most-likely-canonical .cht location: same directory as the ROM,
    same basename, .cht extension. Exposed for diagnostics; the multi-path
    write below also drops the same file at libretro-conventional paths."""
    stem, _ = os.path.splitext(rom_path)
    return stem + ".cht"


_CORE_SO_RE = re.compile(r"-L\s+(\S+?)(?:_libretro)?\.so\b")


def _core_basename(retroarch_command: str | None) -> Optional[str]:
    """Extract the libretro core's bare name from `-L <path>/<core>.so` in
    a retroarch command (handles paths and quoted args). Returns the bare
    core name without `_libretro.so` suffix: `genesis_plus_gx`, `snes9x`,
    `mgba`, etc. This is the KEY into CORE_LIBRARY_NAMES below."""
    if not retroarch_command:
        return None
    m = _CORE_SO_RE.search(retroarch_command)
    if not m:
        return None
    return os.path.basename(m.group(1))


# RetroArch's auto-load cheat path uses the core's HUMAN library_name —
# the string set via `info->library_name = "Genesis Plus GX";` in each
# core's `retro_get_system_info`. NOT the .so filename. Reading the
# RetroArch source (cheat_manager.c — `cheat_manager_get_game_specific_cheats`)
#
#   <path_cheat_database>/<library_name>/<rom_basename>.cht
#
# is unconditionally loaded on every game start, no config flag gates it.
# These names are pulled from libretro-core-info — fetched at refactor
# time, stable across RA versions. Adding a new core = one entry.
CORE_LIBRARY_NAMES: dict[str, str] = {
    "snes9x":              "Snes9x",
    "genesis_plus_gx":     "Genesis Plus GX",
    "mgba":                "mGBA",
    "gambatte":            "Gambatte",
    "nestopia":            "Nestopia",
    "mupen64plus_next":    "Mupen64Plus-Next",
    "pcsx_rearmed":        "PCSX-ReARMed",
    "mednafen_pce_fast":   "Beetle PCE Fast",
    "picodrive":           "PicoDrive",
    "ppsspp":              "PPSSPP",
    "fbneo":               "FinalBurn Neo",
}


def _library_name(core_basename: str | None) -> Optional[str]:
    """Map the core's .so basename to RetroArch's `library_name` string
    (the folder name RA's cheat auto-loader actually walks into)."""
    if not core_basename:
        return None
    return CORE_LIBRARY_NAMES.get(core_basename)


_CHEAT_DB_ROOT = "/roms/cheats"             # dArkOS standard cheat_database_path


def _candidate_paths(rom_path: str, core_basename: str | None) -> list[str]:
    """All the paths we drop the same .cht into. First entry is the
    canonical RA auto-load location (returned to the caller for logging).

    Per RetroArch source, the auto-loader looks at exactly one path:
        <cheat_database>/<library_name>/<rom_basename>.cht
    Everything else is a belt-and-suspenders fallback for older RA forks
    or non-standard dArkOS rebuilds — costs nothing to also write."""
    rom_dir = os.path.dirname(rom_path)
    rom_basename = os.path.splitext(os.path.basename(rom_path))[0]
    out: list[str] = []
    library_name = _library_name(core_basename)
    if library_name:
        # 1. CANONICAL — what RA's cheat_manager_load_game_specific_cheats reads
        out.append(os.path.join(_CHEAT_DB_ROOT, library_name,
                                rom_basename + ".cht"))
    # 2. Next-to-ROM — pre-RA-1.10 builds & some custom forks fall back here
    out.append(os.path.join(rom_dir, rom_basename + ".cht"))
    if core_basename:
        # 3. Legacy snake-case core dir — some dArkOS rebuilds invert RA's
        #    naming and use the .so basename as the folder. Cheap to also write.
        out.append(os.path.join(_CHEAT_DB_ROOT, core_basename,
                                rom_basename + ".cht"))
    # 4. Flat under cheat_database_path — very old configs
    out.append(os.path.join(_CHEAT_DB_ROOT, rom_basename + ".cht"))
    return out


def materialize_for_game(rom_path: str, curated: list[dict],
                         enabled_labels,
                         retroarch_command: str | None = None
                         ) -> Optional[str]:
    """Write the curated .cht to every plausible RetroArch auto-load path.

    The .cht contains the FULL curated list, with `enable=true` only for
    `enabled_labels` — so RetroArch's in-game cheat menu shows every option
    and the player can flip them in-emulator without re-launching through
    the shell.

    Returns the FIRST path written (next-to-ROM, by convention), or None
    when nothing was written (no curated cheats at all, every candidate
    write failed). Tolerant — a single path failing is not fatal; we move
    on. The override cfg the launcher appends references the canonical
    path via `cheat_settings_path` so a config-driven RA still finds it."""
    if not rom_path or not curated:
        return None
    content = build_cht_file(curated, enabled_labels)
    if not content:
        return None

    core = _core_basename(retroarch_command)
    candidates = _candidate_paths(rom_path, core)
    canonical: Optional[str] = None
    for path in candidates:
        try:
            _atomic_write(path, content)
            if canonical is None:
                canonical = path
        except OSError as exc:                            # noqa: BLE001
            # Some candidates (e.g. /roms/cheats/snes9x/) may not exist as a
            # directory and the on-device user lacks perms to mkdir. Skip
            # silently — the other paths still get the file.
            print(f"cheat_apply: skipped {path} ({exc!r})", flush=True)
    return canonical
