"""Local game detector → chapter marks.

Polls the process list every few seconds; when a known game appears, a
chapter row is written with the OBS timecode (or wall clock as fallback,
linked to a VOD later by sync_vod_library — same mechanics as GSI events).

Chapters answer "which part of last night's VOD is which game" — the hook
the vision scanners (Rocket League scoreboard / Deadlock killfeed) need for
their scan windows, and generally useful for multi-game nights.

Three sources, highest priority first:
  1. config.json "games": {"somegame.exe": "sometag"} — the user's word
  2. game packs (adapter PROCESS_NAMES via the plugin registry) — their
     slugs must win over the bundled DB so chapters line up with the pack's
     GSI events and scan windows
  3. the bundled database (data/games_db.json.gz, ~10k exe names built from
     Discord's public detectable-applications dataset) — so ~every game
     chapters itself with zero setup
"""
import gzip
import json
import os
import re
import subprocess
import threading
import time

from clipshelf import db
from clipshelf import obs_client
from clipshelf.config import CFG, PKG_DIR

# process basename (lowercase, extension kept on Windows) -> game slug.
GAMES = {}
_CONFIG_OVERRIDES = {str(k).lower(): str(v).lower() for k, v in (CFG.get("games") or {}).items()}
GAMES.update(_CONFIG_OVERRIDES)

_DB_PATH = PKG_DIR / "data" / "games_db.json.gz"
_DB = None          # exe basename -> [slug, display name]
_LABELS: dict = {}  # slug -> display name (db + adapter LABELs)
_STEAM: dict = {}   # slug -> [steam appid, …], for cover art


def _games_db() -> dict:
    global _DB
    if _DB is None:
        try:
            with gzip.open(_DB_PATH, "rb") as fh:
                raw = json.loads(fh.read())
            # newer builds are {"exes": …, "steam": …}; older ones are the
            # flat exe map, and an install can be updated with either
            if isinstance(raw, dict) and "exes" in raw:
                _DB = raw["exes"]
                _STEAM.update({k: ([v] if isinstance(v, int) else list(v))
                               for k, v in (raw.get("steam") or {}).items()})
            else:
                _DB = raw
            _LABELS.update({slug: name for slug, name in _DB.values()})
        except Exception as e:
            print(f"[chapters] games db unavailable ({e}) — packs/config only", flush=True)
            _DB = {}
    return _DB


def steam_appids(slug) -> list:
    """Steam app ids that might carry cover art for a game slug."""
    _games_db()
    slug = str(slug or "").lower()
    if slug in _STEAM:
        return list(_STEAM[slug])
    # a plugin's own tag ("cs2") will not be a db slug, but its display name
    # usually resolves to one ("counter-strike-2")
    name = _LABELS.get(slug)
    if name:
        alt = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:40]
        return list(_STEAM.get(alt) or [])
    return []


def register_processes(mapping: dict, label: str = ""):
    """Game adapters (core or plugin) contribute process names via the plugin
    registry. The user's config.json "games" entries always win."""
    for k, v in (mapping or {}).items():
        k = str(k).lower()
        if k not in _CONFIG_OVERRIDES:
            GAMES[k] = str(v).lower()
        if label:
            _LABELS[str(v).lower()] = label


def display_name(slug) -> str:
    """Pretty name for a game slug, falling back to the slug itself.

    Hand-typed chapter names arrive here as slugs ("just-chatting"), so a
    separator is a strong hint that the words were once words."""
    _games_db()
    slug = str(slug or "")
    known = _LABELS.get(slug.lower())
    if known:
        return known
    if "-" in slug or "_" in slug:
        return slug.replace("-", " ").replace("_", " ").strip().title()
    return slug


POLL_S = 10
current = {"game": None, "since": 0}


def _running_procs() -> set:
    try:
        if os.name == "nt":
            r = subprocess.run(["tasklist", "/fo", "csv", "/nh"],
                               capture_output=True, text=True, timeout=15, encoding="utf-8", errors="replace")
            return {line.split('","')[0].strip('"').lower()
                    for line in r.stdout.splitlines() if line.startswith('"')}
        r = subprocess.run(["ps", "-e", "-o", "comm="],
                           capture_output=True, text=True, timeout=15, encoding="utf-8", errors="replace")
        return {os.path.basename(line.strip()).lower()
                for line in r.stdout.splitlines() if line.strip()}
    except Exception:
        return set()


# Processes that mean "the launcher is open", not "a game is running".
#
# The bulk database maps these to the same slug as the game itself — it
# answers "what is this exe", and for Discord's purposes a client counts.
# For chapters it does not: a chapter is written when the detected game
# CHANGES, and a client that stays open all evening never changes. League
# of Legends was the case that showed it. Its client (leagueclientux.exe)
# runs from the moment you open the launcher, so the slug was already
# 'league-of-legends' before the first match; every actual game then
# started and ended with the slug unchanged, and not one match got a
# chapter. Steam titles hid the bug, because there you launch and quit the
# game itself.
LAUNCHERS = {
    "leagueclientux.exe", "leagueclient.exe", "riotclientservices.exe",
    "riotclientux.exe", "riotclient.exe",
    "steam.exe", "steamwebhelper.exe", "epicgameslauncher.exe",
    "battle.net.exe", "battle.net launcher.exe", "galaxyclient.exe",
    "eadesktop.exe", "origin.exe", "upc.exe", "ubisoftconnect.exe",
    "bethesda.net_launcher.exe", "rockstarservice.exe", "launcher.exe",
    "_launcher.exe",
}


def _detect() -> str | None:
    procs = _running_procs() - LAUNCHERS
    # config + pack entries first — curated slugs beat the bulk DB
    for proc, tag in GAMES.items():
        if proc in procs:
            return tag
    dbmap = _games_db()
    # sorted, because a set iterates in whatever order it likes and two
    # known games running at once would otherwise pick a different winner
    # from one poll to the next — which reads as a game flickering on and
    # off, and writes a chapter every time it does
    for proc in sorted(procs):
        hit = dbmap.get(proc)
        if hit:
            return hit[0]
    return None


def _mark_chapter(game: str):
    vod_time_ms = obs_client.get_obs_timecode()
    epoch_ms = int(time.time() * 1000)
    conn = db.connect()
    try:
        conn.execute(
            "INSERT INTO vod_chapters (game_tag, start_time_ms, epoch_ms) VALUES (?, ?, ?)",
            (game, vod_time_ms if vod_time_ms > 0 else None, epoch_ms))
        conn.commit()
    finally:
        conn.close()
    print(f"[{time.strftime('%X')}] [chapters] {game} started @ "
          f"{vod_time_ms}ms" if vod_time_ms > 0 else
          f"[{time.strftime('%X')}] [chapters] {game} started (clock mark)", flush=True)


def detect_loop():
    while True:
        try:
            game = _detect()
            if game != current["game"]:
                current.update(game=game, since=int(time.time()))
                if game:
                    _mark_chapter(game)
                else:
                    print(f"[{time.strftime('%X')}] [chapters] game closed", flush=True)
        except Exception as e:
            print(f"[chapters] {e}", flush=True)
        time.sleep(POLL_S)


def start():
    threading.Thread(target=detect_loop, daemon=True).start()
