"""
ROM ingest — scan → identify → match → plan → apply (reversible) → undo.

The flow, all pure Python (no pygame, no device assumptions beyond paths):

  analyze(roots, games, roms_root)
      walk the drop locations, name each ROM's system & catalog game, and
      produce a JSON-serializable *report*: every file with a proposed action
      (move / keep / skip / conflict). Nothing on disk changes.

  apply_report(report, state_dir)
      execute the report's moves, writing a manifest (the resulting library),
      an installed-map (game id → path, the play layer reads this), and an
      undo log. Safe: never overwrites, never deletes, only renames.

  undo(state_dir)
      reverse the last apply using the undo log.

Identification is conservative on purpose (see systems.py): a loose file with an
ambiguous extension is left alone unless the catalog match resolves it, and a
multi-file disc set (.cue/.ccd/.m3u…) is never auto-split. The same report shape
is what the future desktop "iTunes" companion writes for offline users — the
device just ingests it.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import unicodedata
from dataclasses import dataclass, field
from datetime import datetime, timezone

from . import systems

# state files written under state_dir (e.g. /roms/ports/aahead/library/)
MANIFEST = "library.json"        # snapshot of the organized library
INSTALLED = "installed.json"     # {game_id: rom_path} — the play layer's source
UNDO = "library.undo.json"       # last apply's moves, for undo

# directories never worth walking for ROMs (saves/art/bios/ports/our own state)
_IGNORE_DIRS = frozenset({
    "savestates", "saves", "bios", "media", "images", "manuals", "cheats",
    "videos", "screenshots", "art", "music", "cores", "system", "backup",
    "ports",        # PortMaster ports aren't games — and their .md READMEs ≠ Mega Drive
    # PPSSPP user-data dirs that live UNDER /roms/psp/ on dArkOS images.
    # Their `.BIN` save files share an extension with PSX/Sega-CD ROMs and
    # the walking-up identify() will tag them as PSP; pruning at scan time
    # is cheaper and unambiguous. Casing varies on the card — match lower.
    "ppsspp", "ppsspp_state", "savedata",
})
# non-game files that share a ROM extension (e.g. README.md vs a Genesis .md).
# Matched on the leading token, so "LICENSE-idna" is caught but "Manual Samuel" isn't.
_IGNORE_NAMES = frozenset({
    "readme", "license", "licence", "copying", "copyright", "changelog",
    "contributing", "gamelist",
})

_BRACKETS = re.compile(r"\([^)]*\)|\[[^\]]*\]|\{[^}]*\}")
_ARTICLES = re.compile(r"\b(?:the|a|an)\b")
_NONALNUM = re.compile(r"[^a-z0-9]+")


# ── normalization (the matching key) ─────────────────────────────────
def _fold(s: str) -> str:
    """Drop accents so 'Pokémon' == 'Pokemon'."""
    return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")


def normalize_title(s: str) -> str:
    """Aggressively normalized match key: region/version tags, articles and
    all punctuation removed. 'Legend of Zelda, The (USA) [!].sfc' →
    'legendofzelda'. Used identically on catalog titles and ROM filenames."""
    s = _fold(s or "").lower()
    s = s.replace("&", " and ")
    s = _BRACKETS.sub(" ", s)
    s = _ARTICLES.sub(" ", s)
    return _NONALNUM.sub("", s)


def pretty_name(filename: str) -> str:
    """A readable display title for an unmatched ROM (keeps casing & words).

    Strips three families of decoration we see in real card layouts:
      • Region/version brackets:    `(USA) [!]`         → ``
      • Filename punctuation:       `Mega_Man.III`      → `Mega Man III`
      • Numbered-pack prefixes:     `01 - Diamond Rush` → `Diamond Rush`
        (canonical "Dream Pack" / freej2me organisation puts tracks in
        numeric order; that prefix is for sorting, not part of the title)
    """
    base = os.path.splitext(filename)[0]
    base = _BRACKETS.sub(" ", base)
    base = re.sub(r"[._]+", " ", base)
    # Numbered-pack prefix (e.g. "01 - ", "07  -  ", "12 — "). Constrained
    # to leading position + small int + dash so we never eat a legit
    # title like "1942" or "Tank 1990".
    base = re.sub(r"^\s*\d{1,3}\s*[-–—]\s+", "", base)
    base = re.sub(r"\s+", " ", base).strip()
    return base or os.path.splitext(filename)[0]


def local_id(system: str | None, name: str) -> str:
    """A stable id for a game we DON'T have in the catalog, in the same shape as
    catalog ids (e.g. 'GBA_My-Homebrew-Demo'). Lets unmatched games be fully
    first-class — listed, launched, tracked, enriched later — never second-class."""
    s = systems.by_name(system or "")
    prefix = s.id_prefix if s else "Game"
    slug = re.sub(r"[^A-Za-z0-9]+", "-", pretty_name(name)).strip("-") or "Untitled"
    return f"{prefix}_{slug}"


# ── scan ──────────────────────────────────────────────────────────────
@dataclass
class RomFile:
    path: str
    name: str
    ext: str
    size: int
    system: str | None = None
    identified_by: str = ""        # folder | extension | catalog | ""
    game_id: str | None = None
    title: str | None = None
    match_score: float = 0.0
    discs: list = field(default_factory=list)   # disc filenames for a multi-disc game (→ m3u)


# "(Disc 1)" / "(Disk 2)" / "(CD 3)" — multi-disc PS1 games are ONE game, not N
_DISC_RE = re.compile(r"\s*\((?:disc|disk|cd)\s*(\d+)\)", re.IGNORECASE)


def _disc_key(name: str):
    """(base-title, disc number) or None. Base = everything BEFORE the disc tag, so
    'C&C (USA) (Disc 1) (GDI)' and '(Disc 2) (NOD)' collapse to one game."""
    stem = os.path.splitext(name)[0]
    m = _DISC_RE.search(stem)
    if not m:
        return None
    return stem[:m.start()].strip(), int(m.group(1))


def scan(roots: list[str]) -> list[RomFile]:
    """Walk each drop location for files with a known ROM extension. Prunes
    hidden dirs and obvious non-ROM folders; de-dupes overlapping roots."""
    found: list[RomFile] = []
    seen: set[str] = set()
    for root in roots or []:
        if not root or not os.path.isdir(root):
            continue
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames
                           if not d.startswith(".") and d.lower() not in _IGNORE_DIRS]
            for fn in filenames:
                stem, ext = os.path.splitext(fn)
                ext = ext.lower().lstrip(".")
                if ext not in systems.ALL_EXTS:
                    continue
                if re.split(r"[^a-z0-9]", stem.strip().lower(), maxsplit=1)[0] in _IGNORE_NAMES:
                    continue                                     # README.md / LICENSE-idna etc.
                p = os.path.abspath(os.path.join(dirpath, fn))
                if p in seen:
                    continue
                seen.add(p)
                try:
                    size = os.path.getsize(p)
                except OSError:
                    size = 0
                found.append(RomFile(path=p, name=fn, ext=ext, size=size))
    return found


# ── identify (system) ─────────────────────────────────────────────────
def identify(rom: RomFile) -> RomFile:
    """Name the ROM's system from any ancestor folder, else its extension.
    Leaves `system` None when ambiguous (a later catalog match may still
    resolve it).

    Walks ALL ancestor directories, not just the immediate parent. This is
    what makes nested per-game disc layouts work — Sega CD / Saturn / PS2
    rips typically put each title in its own subdirectory:
        /roms/segacd/<game-dir>/<game>.ccd
    The immediate parent there is `<game-dir>`, NOT a known system folder;
    walking up one more level finds `segacd` and correctly identifies the
    system. Walk stops at root or at a folder named `roms` (the canonical
    container) so we don't accidentally inherit a system from grandparents
    further up the tree."""
    p = os.path.dirname(rom.path)
    while p:
        base = os.path.basename(p)
        if not base or base.lower() == "roms":
            break
        by_folder = systems.system_for_folder(base)
        if by_folder:
            rom.system, rom.identified_by = by_folder, "folder"
            return rom
        parent = os.path.dirname(p)
        if parent == p:
            break
        p = parent
    by_ext = systems.unique_system_for_ext(rom.ext)
    if by_ext:
        rom.system, rom.identified_by = by_ext, "extension"
    return rom


# ── match (catalog game) ──────────────────────────────────────────────
def build_index(games: list[dict]):
    """Two normalized-title indexes: per-system (authoritative) and global
    (used to resolve a loose, ambiguous file to one game/system)."""
    by_system: dict[str, dict[str, dict]] = {}
    global_idx: dict[str, list[dict]] = {}
    for g in games:
        norm = normalize_title(g.get("title", ""))
        if not norm:
            continue
        by_system.setdefault(g["system"], {}).setdefault(norm, g)
        global_idx.setdefault(norm, []).append(g)
    return by_system, global_idx


def match(rom: RomFile, by_system: dict, global_idx: dict) -> RomFile:
    """Attach a catalog game by exact normalized filename. When the system is
    known we only match within it; when unknown, a globally-unique title both
    matches the game and resolves the system."""
    norm = normalize_title(os.path.splitext(rom.name)[0])
    if not norm:
        return rom
    if rom.system:
        g = (by_system.get(rom.system) or {}).get(norm)
        if g:
            rom.game_id, rom.title, rom.match_score = g["id"], g["title"], 1.0
        return rom
    candidates = global_idx.get(norm) or []
    if len(candidates) == 1:                      # unambiguous → resolve system
        g = candidates[0]
        rom.game_id, rom.title, rom.match_score = g["id"], g["title"], 1.0
        rom.system, rom.identified_by = g["system"], "catalog"
    return rom


# ── plan ───────────────────────────────────────────────────────────────
def _group_discs(roms: list[RomFile], by_system: dict, global_idx: dict) -> list[RomFile]:
    """Collapse 'Game (Disc 1/2/3)' into ONE game whose ROM is an .m3u playlist
    (so emulators disc-swap). Reuses a user's existing <base>.m3u if present (no
    duplicate). Single-disc files pass through untouched."""
    groups: dict = {}
    m3u_by_base: dict = {}                     # (system, base) → existing .m3u rom
    others: list[RomFile] = []
    for rom in roms:
        key = _disc_key(rom.name)
        if key and rom.system:
            groups.setdefault((rom.system, key[0]), []).append((key[1], rom))
            continue
        if rom.ext == "m3u" and rom.system:
            m3u_by_base[(rom.system, os.path.splitext(rom.name)[0])] = rom
        others.append(rom)
    out: list[RomFile] = []
    backed = set()                             # existing .m3u that a disc group represents
    for (system, base), lst in groups.items():
        lst.sort(key=lambda x: x[0])
        first = lst[0][1]
        existing = m3u_by_base.get((system, base))
        if existing:
            backed.add((system, base))
        m3u = existing.path if existing else \
            os.path.join(os.path.dirname(first.path), base + ".m3u")
        merged = RomFile(path=m3u, name=base + ".m3u", ext="m3u",
                         size=sum(r.size for _, r in lst), system=system,
                         identified_by=first.identified_by,
                         discs=[r.name for _, r in lst])
        match(merged, by_system, global_idx)          # match the base title to the catalog
        out.append(merged)
    for rom in others:                         # drop a standalone .m3u a group already represents
        if rom.ext == "m3u" and (rom.system, os.path.splitext(rom.name)[0]) in backed:
            continue
        out.append(rom)
    return out


# Disc TRACK files — the data a .cue/.ccd/.gdi descriptor points at. They
# must never be organized independently: moving a `.bin` away from its `.cue`
# splits the set and breaks the game. (Single-file disc images — chd/pbp/cso/
# iso-as-content — are NOT here; they stand alone.)
_DISC_TRACK_EXTS = frozenset({"bin", "img", "sub", "raw", "mdf"})
# Descriptor files that reference sibling tracks.
_DISC_DESCRIPTOR_EXTS = frozenset({"cue", "ccd", "gdi", "toc", "m3u"})


def _has_sibling_descriptor(rom_path: str) -> bool:
    """True if a disc descriptor (.cue/.ccd/.gdi/.toc/.m3u) lives in the same
    directory as `rom_path`. Used to recognise a track file (.bin/.img/.sub)
    as a companion that belongs to that descriptor, not a standalone ROM."""
    d = os.path.dirname(rom_path)
    try:
        for entry in os.listdir(d):
            ext = os.path.splitext(entry)[1].lower().lstrip(".")
            if ext in _DISC_DESCRIPTOR_EXTS:
                return True
    except OSError:
        pass
    return False


def plan_move(rom: RomFile, roms_root: str) -> tuple[str, str | None, str]:
    """(action, target, reason) for one ROM. action ∈ move|keep|skip|conflict|disc."""
    if not rom.system:
        return "skip", None, "unidentified — ambiguous extension, loose location"
    if rom.discs:                                     # reuse user's .m3u; create only if missing
        return ("keep" if os.path.exists(rom.path) else "disc"), rom.path, ""
    # A disc track (.bin/.img/.sub) sitting next to its .cue/.ccd descriptor is
    # a companion — skip it so we never split a disc set. (The walk-up
    # identify() now resolves a system for such a file via its parent folder;
    # without this guard a loose `psx/FF7/game.bin` would be "moved" away from
    # its `game.cue`. Verified: protects real PS1/Sega CD .cue+.bin sets.)
    if rom.ext in _DISC_TRACK_EXTS and _has_sibling_descriptor(rom.path):
        return "skip", None, "disc track — organized via its .cue/.ccd descriptor"
    target_dir = os.path.join(roms_root, systems.folder_for_system(rom.system))
    target = os.path.join(target_dir, rom.name)
    if _norm(os.path.dirname(rom.path)) == _norm(target_dir):
        return "keep", rom.path, ""
    if systems.is_multifile_disc(rom.ext):
        return "skip", None, "multi-file disc set — organize via desktop companion"
    if os.path.exists(target) and not _same_path(rom.path, target):
        return "conflict", target, "a different file already exists at the target"
    return "move", target, ""


def analyze(roots: list[str], games: list[dict], roms_root: str,
            now: str | None = None) -> dict:
    """Full read-only pass → the JSON report. Nothing on disk changes."""
    by_system, global_idx = build_index(games)
    roms = scan(roots)
    for rom in roms:
        identify(rom)
        match(rom, by_system, global_idx)
    roms = _group_discs(roms, by_system, global_idx)    # merge multi-disc games → one .m3u
    items: list[dict] = []
    for rom in roms:
        action, target, reason = plan_move(rom, roms_root)
        items.append({
            "path": rom.path, "name": rom.name, "ext": rom.ext, "size": rom.size,
            "system": rom.system, "identified_by": rom.identified_by,
            "game_id": rom.game_id, "title": rom.title or pretty_name(rom.name),
            "match_score": rom.match_score, "discs": rom.discs,
            "action": action, "target": target, "reason": reason,
        })
    items.sort(key=lambda it: (it["system"] or "~", it["title"].lower()))
    return {
        "version": 1, "generated": now or _utcnow(),
        "roots": [os.path.abspath(r) for r in (roots or [])],
        "roms_root": os.path.abspath(roms_root),
        "found": len(items), "items": items, "summary": _summarize(items),
    }


def _summarize(items: list[dict]) -> dict:
    s = {"found": len(items), "matched": 0, "identified": 0, "unidentified": 0,
         "to_enrich": 0, "to_move": 0, "to_keep": 0, "conflicts": 0, "skipped": 0}
    for it in items:
        if it["game_id"]:
            s["matched"] += 1
        if it["system"]:
            s["identified"] += 1
        else:
            s["unidentified"] += 1
        s[{"move": "to_move", "keep": "to_keep", "disc": "to_keep",
           "conflict": "conflicts", "skip": "skipped"}[it["action"]]] += 1
    # identified-but-not-in-catalog → first-class games we owe metadata to
    s["to_enrich"] = s["identified"] - s["matched"]
    return s


# ── apply (reversible) ────────────────────────────────────────────────
def apply_report(report: dict, state_dir: str, mover=shutil.move,
                 now: str | None = None, do_moves: bool = True) -> dict:
    """Execute the report's moves and persist manifest + installed-map + undo
    log. Re-validates every move at apply time (no overwrite, no delete).

    do_moves=False → **index in place**: catalog every game at its current path
    without relocating anything (the safe first-run / refresh pass)."""
    os.makedirs(state_dir, exist_ok=True)
    moves: list[dict] = []
    installed = _load_json(os.path.join(state_dir, INSTALLED), {})
    library: list[dict] = []
    seen: set[str] = set()                                # one library entry per game id
    done = {"moved": 0, "kept": 0, "skipped": 0, "conflicts": 0, "failed": 0}

    for it in report.get("items", []):
        action, src, target = it.get("action"), it.get("path"), it.get("target")
        final: str | None = None
        if action == "move":
            if not src or not os.path.exists(src):
                done["failed"] += 1
                continue
            if not do_moves:
                final = src                                  # index in place, no relocation
                done["kept"] += 1
            elif os.path.exists(target) and not _same_path(src, target):
                done["conflicts"] += 1
                final = src                                  # left in place
            else:
                os.makedirs(os.path.dirname(target), exist_ok=True)
                mover(src, target)
                moves.append({"from": src, "to": target})
                done["moved"] += 1
                final = target
        elif action == "keep":
            final = src if (src and os.path.exists(src)) else None
            if final:
                done["kept"] += 1
        elif action == "conflict":
            done["conflicts"] += 1
            final = src
        elif action == "disc":                               # write the .m3u playlist
            discs = it.get("discs") or []
            if discs and target:
                try:
                    os.makedirs(os.path.dirname(target), exist_ok=True)
                    if not os.path.exists(target):
                        with open(target, "w", encoding="utf-8") as fh:
                            fh.write("\n".join(discs) + "\n")
                    final = target
                    done["kept"] += 1
                except OSError:
                    done["failed"] += 1
            else:
                done["failed"] += 1
        else:                                                # skip
            done["skipped"] += 1
            final = src if it.get("game_id") else None       # keep matched discs

        if final and (action in ("move", "keep", "conflict", "disc") or it.get("game_id")):
            gid = it.get("game_id")
            sid = gid or local_id(it.get("system"), it.get("name"))
            if sid in seen:                              # regional/version dup → keep one
                continue
            seen.add(sid)
            library.append({
                "id": sid,                       # stable, always present
                "game_id": gid,                  # catalog id, or None
                "catalog": bool(gid),            # in our editorial seed?
                "enrich": "catalog" if gid else "pending",  # metadata: have / fetch
                "system": it.get("system"),
                "title": it.get("title"), "name": it.get("name"), "path": final,
            })
            installed[sid] = final               # EVERY game is launchable, not just catalog

    stamp = now or _utcnow()
    _save_json(os.path.join(state_dir, MANIFEST),
               {"version": 1, "generated": stamp, "items": library})
    _save_json(os.path.join(state_dir, INSTALLED), installed)
    if moves:
        _save_json(os.path.join(state_dir, UNDO),
                   {"version": 1, "generated": stamp, "moves": moves})
    done["library"] = len(library)
    return done


def undo(state_dir: str, mover=shutil.move) -> dict:
    """Reverse the last apply. Consumes the undo log so it can't double-run."""
    path = os.path.join(state_dir, UNDO)
    log = _load_json(path, None)
    if not log:
        return {"reverted": 0}
    n = 0
    for mv in reversed(log.get("moves", [])):
        frm, to = mv.get("from"), mv.get("to")
        if to and frm and os.path.exists(to) and not os.path.exists(frm):
            os.makedirs(os.path.dirname(frm), exist_ok=True)
            mover(to, frm)
            n += 1
            d = os.path.dirname(to)
            try:
                if os.path.isdir(d) and not os.listdir(d):
                    os.rmdir(d)
            except OSError:
                pass
    try:
        os.remove(path)
    except OSError:
        pass
    return {"reverted": n}


# ── state readers (Phase 2 / GUI consume these) ──────────────────────
def load_manifest(state_dir: str) -> dict:
    return _load_json(os.path.join(state_dir, MANIFEST), {"version": 1, "items": []})


def load_installed(state_dir: str) -> dict:
    return _load_json(os.path.join(state_dir, INSTALLED), {})


# ── helpers ────────────────────────────────────────────────────────────
def _utcnow() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def _norm(p: str) -> str:
    return os.path.normcase(os.path.normpath(p))


def _same_path(a: str, b: str) -> bool:
    try:
        return os.path.samefile(a, b)
    except OSError:
        return _norm(a) == _norm(b)


def _load_json(path: str, default):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return default


def _save_json(path: str, data) -> None:
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
    os.replace(tmp, path)
