"""GSI ingest + VOD timeline endpoints.

Game events exist to timestamp the recording: they become markers, chapters
and match bands on the VOD timeline. Single user on localhost, so no session
auth; the ingest endpoint still requires the per-install token since the
game POSTs to it."""
import asyncio
import hmac
import json
import os
import re
import shutil
import time

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse

from clipshelf import config
from clipshelf import db
from clipshelf import obs_client
from clipshelf.config import BASE_URL, GSI_TOKEN, HIGHLIGHTS_DIR
from clipshelf.cutting import cut_range, derive_matches, make_proxy, night_date
from clipshelf.plugin_host import registry


def adapters() -> dict:
    """Game adapters by slug — core built-ins and plugins, one registry."""
    return registry.game_adapters

router = APIRouter(tags=["gsi"])

# per-session diff state for snapshot-style adapters: {"game:client": {...}}
_SESSIONS = {}
_SESSION_TTL = 4 * 3600


def _token_ok(request: Request, payload) -> bool:
    supplied = request.headers.get("x-gsi-token") or request.query_params.get("token")
    if not supplied and isinstance(payload, dict):
        supplied = (payload.get("auth") or {}).get("token")
    return bool(supplied) and hmac.compare_digest(str(supplied), GSI_TOKEN)


def _normalize_generic(payload) -> list:
    """Games without a bespoke adapter POST pre-normalized events."""
    if isinstance(payload, dict) and isinstance(payload.get("events"), list):
        raw = payload["events"]
    elif isinstance(payload, dict) and (payload.get("type") or payload.get("event_type")):
        raw = [payload]
    else:
        return []
    events = []
    for e in raw:
        if not isinstance(e, dict):
            continue
        etype = str(e.get("type") or e.get("event_type") or "").strip()[:40]
        if not etype:
            continue
        title = str(e.get("title") or etype).strip()[:200]
        detail = e.get("detail") if isinstance(e.get("detail"), dict) else {}
        events.append({"type": etype, "title": title, "detail": detail,
                       "offset_ms": e.get("offset_ms")})
    return events


MAX_BACKDATE_MS = 3 * 3600 * 1000


def _offset_ms(ev) -> int:
    """How long ago an event happened, in ms — 0 for "just now".

    A pushed payload arrives at the moment of the play, so its offset is
    zero and nothing changes. A POLLED game hands us a log that is already
    a few seconds old, and an external binder may be reporting something
    from a minute ago; without this every one of those markers would land
    where the *poll* was rather than where the play was."""
    try:
        off = int(ev.get("offset_ms") or 0)
    except (TypeError, ValueError):
        return 0
    return max(0, min(MAX_BACKDATE_MS, off))


def record_events(game: str, events: list) -> int:
    """Stamp events onto the recording's clock and store them. Blocking —
    call it in a thread.

    One writer for every ingest path (the POST endpoint, the poll driver),
    so a marker means the same thing however the game chose to talk to us."""
    if not events:
        return 0
    vod_time_ms = obs_client.get_obs_timecode()
    epoch_ms = int(time.time() * 1000)
    stamped = []
    for ev in events:
        off = _offset_ms(ev)
        at = vod_time_ms - off
        # Off the front of the recording: it happened before OBS started, so
        # it has no place on THIS timeline. The (also back-dated) epoch is
        # kept, which is what links it to a recording it does belong to.
        stamped.append((ev, epoch_ms - off,
                        at if vod_time_ms > 0 and at >= 0 else None))

    conn = db.connect()
    try:
        for ev, ev_epoch_ms, ev_vod_ms in stamped:
            conn.execute(
                "INSERT INTO gsi_events (game, event_type, title, detail, epoch_ms, vod_time_ms)"
                " VALUES (?, ?, ?, ?, ?, ?)",
                (game, ev["type"], ev["title"], json.dumps(ev.get("detail") or {}),
                 ev_epoch_ms, ev_vod_ms))
        conn.commit()
    finally:
        conn.close()

    for ev, _, ev_vod_ms in stamped:
        print(f"[{time.strftime('%X')}] [GSI] {game}: {ev['type']} — "
              f"{ev['title']} @ {ev_vod_ms}ms", flush=True)
    return len(events)


@router.get("/api/gsi/games")
def gsi_games():
    conn = db.connect()
    try:
        counts = {r["game"]: {"total": r["n"], "last_epoch_ms": r["last"]} for r in conn.execute(
            "SELECT game, COUNT(*) AS n, MAX(epoch_ms) AS last FROM gsi_events GROUP BY game"
        ).fetchall()}
    finally:
        conn.close()

    games = []
    for slug, mod in sorted(adapters().items()):
        games.append({
            "game": slug,
            "label": getattr(mod, "LABEL", slug),
            "endpoint": f"{BASE_URL}/api/gsi/{slug}",
            "has_config": callable(getattr(mod, "config_text", None)),
            "config_filename": getattr(mod, "CONFIG_FILENAME", None),
            "config_hint": getattr(mod, "CONFIG_HINT", None),
            **counts.pop(slug, {"total": 0, "last_epoch_ms": None}),
        })
    for slug, c in sorted(counts.items()):
        games.append({"game": slug, "label": slug, "endpoint": f"{BASE_URL}/api/gsi/{slug}",
                      "has_config": False, "config_filename": None, "config_hint": None,
                      **c})
    return {"status": "success", "token": GSI_TOKEN, "games": games,
            "generic_endpoint": f"{BASE_URL}/api/gsi/<game>"}


@router.get("/api/gsi/setup/{game}")
def gsi_setup_file(game: str):
    """Download the ready-to-drop-in config file for a supported game."""
    mod = adapters().get(game.lower())
    if not mod or not callable(getattr(mod, "config_text", None)):
        raise HTTPException(status_code=404, detail="no config generator for this game")
    text = mod.config_text(f"{BASE_URL}/api/gsi/{mod.GAME}", GSI_TOKEN)
    fname = getattr(mod, "CONFIG_FILENAME", f"gsi_{mod.GAME}.cfg")
    return PlainTextResponse(text, headers={
        "Content-Disposition": f'attachment; filename="{fname}"'})


@router.get("/api/status")
def status():
    """Home-page health card."""
    from clipshelf import game_detect
    conn = db.connect()
    try:
        vods = conn.execute("SELECT COUNT(*) AS n FROM vods").fetchone()["n"]
        ev = conn.execute("SELECT COUNT(*) AS n, MAX(epoch_ms) AS last FROM gsi_events").fetchone()
    finally:
        conn.close()
    from clipshelf.workers import transcribe_worker
    return {
        "obs": obs_client.connected(),
        "ffmpeg": bool(shutil.which("ffmpeg")),
        "node": bool(shutil.which(os.getenv("NODE", "node"))),
        "whisper": transcribe_worker.available(),
        "vod_dir": config.VOD_DIR, "vod_dir_ok": bool(config.VOD_DIR and os.path.isdir(config.VOD_DIR)),
        "vods": vods, "events": ev["n"], "last_event_ms": ev["last"],
        "game": game_detect.current["game"],
        "game_label": game_detect.display_name(game_detect.current["game"]),
        "game_since": game_detect.current["since"],
    }


@router.post("/api/vods/rescan")
async def vods_rescan():
    """Re-scan the recordings folder and re-link orphan events/chapters now
    instead of waiting for the next background pass."""
    await asyncio.to_thread(db.sync_vod_library)
    return {"status": "success"}


@router.get("/api/vods")
def vods_list():
    """VOD library for the viewer: every recording, newest first, with its
    chapter tags, event tallies, delivery assets and transcript status."""
    from clipshelf import vod_assets
    conn = db.connect()
    try:
        vods = [dict(r) for r in conn.execute(
            "SELECT id, filename, start_epoch, duration_ms FROM vods ORDER BY start_epoch DESC")]
        chaps = {}
        for r in conn.execute("SELECT vod_id, game_tag FROM vod_chapters"
                              " WHERE vod_id IS NOT NULL ORDER BY start_time_ms"):
            g = chaps.setdefault(r["vod_id"], [])
            if r["game_tag"] not in g:
                g.append(r["game_tag"])
        stats = {r["vod_id"]: dict(r) for r in conn.execute(
            """SELECT vod_id, COUNT(*) AS events,
                      SUM(event_type = 'kill') AS kills
               FROM gsi_events WHERE vod_id IS NOT NULL GROUP BY vod_id""")}
        tstat = {r["vod_id"]: r["status"] for r in conn.execute(
            "SELECT vod_id, status FROM transcripts")}
    finally:
        conn.close()
    for v in vods:
        s = stats.get(v["id"], {})
        v["games"] = chaps.get(v["id"], [])
        v["events"] = s.get("events", 0) or 0
        v["kills"] = s.get("kills", 0) or 0
        v["transcript"] = tstat.get(v["id"])
        pp = vod_assets.proxy_path(v["filename"])
        v["proxy"] = vod_assets.rel_url(pp) if os.path.exists(pp) else None
        bi = vod_assets.board_info(v["filename"])
        v["board"] = dict(bi, url=vod_assets.rel_url(
            vod_assets.board_path(v["filename"]))) if bi else None
    return {"status": "success", "vods": vods}


@router.get("/api/vods/{vod_id}/transcript")
def vod_transcript(vod_id: int):
    conn = db.connect()
    try:
        rows = [{"t_ms": r["t_ms"], "dur_ms": r["dur_ms"], "text": r["text"]}
                for r in conn.execute(
                    "SELECT t_ms, dur_ms, text FROM transcript_fts"
                    " WHERE vod_id = ? ORDER BY t_ms", (vod_id,))]
        st = conn.execute("SELECT status FROM transcripts WHERE vod_id = ?",
                          (vod_id,)).fetchone()
    finally:
        conn.close()
    return {"status": "success", "transcript": st["status"] if st else None,
            "segments": rows}


@router.get("/api/transcripts/search")
def transcript_search(q: str = "", limit: int = 40):
    q = q.strip()
    if not q:
        return {"status": "success", "results": []}
    conn = db.connect()
    try:
        try:
            rows = conn.execute(
                """SELECT f.vod_id, f.t_ms, f.dur_ms,
                          snippet(transcript_fts, 0, '[', ']', '…', 12) AS snip,
                          v.filename, v.start_epoch
                   FROM transcript_fts f JOIN vods v ON v.id = f.vod_id
                   WHERE transcript_fts MATCH ?
                   ORDER BY v.start_epoch DESC, f.t_ms LIMIT ?""",
                (q, max(1, min(200, limit)))).fetchall()
        except Exception:   # user typed FTS syntax that doesn't parse — quote it
            rows = conn.execute(
                """SELECT f.vod_id, f.t_ms, f.dur_ms,
                          snippet(transcript_fts, 0, '[', ']', '…', 12) AS snip,
                          v.filename, v.start_epoch
                   FROM transcript_fts f JOIN vods v ON v.id = f.vod_id
                   WHERE transcript_fts MATCH ?
                   ORDER BY v.start_epoch DESC, f.t_ms LIMIT ?""",
                ('"' + q.replace('"', '""') + '"', max(1, min(200, limit)))).fetchall()
    finally:
        conn.close()
    return {"status": "success", "results": [dict(r) for r in rows]}


@router.post("/api/vods/{vod_id}/build_assets")
async def vod_build_assets(vod_id: int):
    """Build this VOD's playback proxy + storyboard now (the background
    worker gets to it eventually; this is the 'build now' button)."""
    from clipshelf import vod_assets
    conn = db.connect()
    try:
        vod = conn.execute("SELECT filename FROM vods WHERE id = ?", (vod_id,)).fetchone()
    finally:
        conn.close()
    if not vod:
        raise HTTPException(404, "no such vod")
    res = await asyncio.to_thread(vod_assets.build_all, vod["filename"])
    return {"status": "success", **res}


@router.post("/api/transcripts/run")
async def transcripts_run(request: Request):
    """Transcribe one VOD now (or queue a re-transcribe)."""
    from clipshelf.workers import transcribe_worker
    if not transcribe_worker.available():
        raise HTTPException(503, "transcription needs faster-whisper: pip install faster-whisper")
    body = await request.json()
    try:
        vod_id = int(body["vod_id"])
    except (KeyError, TypeError, ValueError):
        raise HTTPException(400, "need vod_id")
    res = await asyncio.to_thread(transcribe_worker.transcribe_vod, vod_id)
    if "error" in res:
        raise HTTPException(500, res["error"])
    return {"status": "success", **res}


@router.post("/api/settings")
async def set_settings(request: Request):
    """A few user-tunable knobs persisted into config.json."""
    from clipshelf import config
    body = await request.json()
    changed = {}
    if "voice_track" in body:
        changed["voice_track"] = max(1, min(8, int(body["voice_track"])))
    if "whisper_model" in body:
        m = re.sub(r"[^a-z0-9.\-]", "", str(body["whisper_model"]).lower())[:24]
        if m:
            changed["whisper_model"] = m
    if "deadlock_accounts" in body:
        # Whatever the user pasted: a profile URL, a steam64, a bare account
        # id. Nobody knows which one they have, so normalise rather than
        # demand a format — and store the ids, so a bad paste fails HERE,
        # in front of the person who can fix it, not silently in a worker
        # thread half an hour later.
        from clipshelf.games import deadlock_record as dl
        ids, why = [], None
        for part in str(body["deadlock_accounts"] or "").replace(",", " ").split():
            try:
                ids.append(str(dl.account_id_of(part)))
            except ValueError as e:
                # account_id_of explains a vanity URL properly; a generic
                # "that didn't work" would send the user off to guess
                why = str(e) if "custom URL" in str(e) else \
                    f"'{part[:40]}' is not a Steam ID or profile link"
                break
        if why:
            raise HTTPException(400, why)
        changed["deadlock_accounts"] = " ".join(dict.fromkeys(ids))
    if not changed:
        raise HTTPException(400, "nothing to set")
    config.CFG.update(changed)
    config.save(config.CFG)
    return {"status": "success", **changed}


@router.get("/api/vods/{vod_id}/timeline")
def vod_timeline(vod_id: int):
    """Everything the viewer draws on one VOD's seekbar: chapter marks,
    GSI events, and derived match spans."""
    conn = db.connect()
    try:
        vod = conn.execute("SELECT id, filename, start_epoch, duration_ms FROM vods WHERE id = ?",
                           (vod_id,)).fetchone()
        if not vod:
            raise HTTPException(404, "no such vod")
        from clipshelf import game_detect
        chapters = [dict(r, label=game_detect.display_name(r["game_tag"]))
                    for r in conn.execute(
            """SELECT id, game_tag, start_time_ms FROM vod_chapters
               WHERE vod_id = ? AND start_time_ms IS NOT NULL ORDER BY start_time_ms""", (vod_id,))]
        events = []
        games = set()
        for r in conn.execute(
                """SELECT id, game, event_type, title, detail, vod_time_ms FROM gsi_events
                   WHERE vod_id = ? AND vod_time_ms IS NOT NULL ORDER BY vod_time_ms""", (vod_id,)):
            ev = dict(r)
            try:
                ev["detail"] = json.loads(ev["detail"] or "{}")
            except Exception:
                ev["detail"] = {}
            events.append(ev)
            games.add(ev["game"])
        matches = []
        for game in sorted(games):
            matches += [m for m in derive_matches(conn, game) if m["vod_id"] == vod_id]
        matches.sort(key=lambda m: m["from_ms"])
    finally:
        conn.close()
    try:
        from clipshelf import gameart
        for c in chapters:
            gameart.want(c["game_tag"])     # queued, never awaited
    except Exception:
        pass
    return {"status": "success", "vod": dict(vod), "chapters": chapters,
            "events": events, "matches": matches}


@router.post("/api/vods/{vod_id}/cut")
async def vod_cut(vod_id: int, request: Request):
    """Manual clip: cut [from_ms, to_ms] of a VOD into the saved-clips tree
    (<game>/<map>_<date>/c_<HH-MM-SS>[_name].mp4 — browsable from the
    editor's Highlights tab). This is the product's clip-creation flow."""
    body = await request.json()
    try:
        from_ms = int(body["from_ms"])
        to_ms = int(body["to_ms"])
    except (KeyError, TypeError, ValueError):
        raise HTTPException(400, "need from_ms and to_ms")
    if not 500 <= to_ms - from_ms <= 15 * 60000:
        raise HTTPException(400, "range must be 0.5s – 15min")
    name = re.sub(r"[^\w-]+", "-", str(body.get("name") or "")).strip("-")[:40]

    conn = db.connect()
    try:
        vod = conn.execute("SELECT * FROM vods WHERE id = ?", (vod_id,)).fetchone()
        if not vod:
            raise HTTPException(404, "no such vod")
        # game = the chapter in effect at the cut point, else the nearest
        # event's game, else a generic bucket
        ch = conn.execute(
            """SELECT game_tag FROM vod_chapters WHERE vod_id = ?
               AND start_time_ms IS NOT NULL AND start_time_ms <= ?
               ORDER BY start_time_ms DESC LIMIT 1""", (vod_id, from_ms)).fetchone()
        game = ch["game_tag"] if ch else None
        ev = conn.execute(
            """SELECT game, detail FROM gsi_events WHERE vod_id = ?
               AND vod_time_ms BETWEEN ? AND ? LIMIT 1""",
            (vod_id, from_ms, to_ms)).fetchone()
        if not game:
            game = ev["game"] if ev else "clips"
        map_ = "unknown"
        if ev:
            try:
                map_ = re.sub(r"[^a-z0-9_-]", "",
                              str(json.loads(ev["detail"] or "{}").get("map") or "unknown").lower()) or "unknown"
            except Exception:
                pass
    finally:
        conn.close()

    date = night_date(vod["start_epoch"])
    hms = time.strftime("%H-%M-%S", time.localtime(vod["start_epoch"] + from_ms / 1000))
    fname = f"c_{hms}" + (f"_{name}" if name else "") + ".mp4"
    out = os.path.join(HIGHLIGHTS_DIR, game, f"{map_}_{date}", fname)
    rel = os.path.relpath(out, HIGHLIGHTS_DIR).replace(os.sep, "/")
    src = os.path.join(config.VOD_DIR, vod["filename"])
    if not os.path.exists(out):
        ok = await asyncio.to_thread(cut_range, src, from_ms / 1000, to_ms / 1000, out)
        if not ok:
            raise HTTPException(500, "cut failed")
        await asyncio.to_thread(make_proxy, out)
    return {"status": "success", "rel": rel,
            "url": "/api/editor/highlights/file/" + rel,
            "size": os.path.getsize(out)}


@router.post("/api/vods/{vod_id}/chapter")
async def vod_add_chapter(vod_id: int, request: Request):
    """Add a chapter at a position in an EXISTING recording (the hotkey adds
    one live; this is for marking up a night afterwards)."""
    from clipshelf import game_detect
    body = await request.json()
    try:
        at_ms = max(0, int(body["at_ms"]))
    except (KeyError, TypeError, ValueError):
        raise HTTPException(400, "need at_ms")
    tag = re.sub(r"[^a-z0-9_-]", "",
                 str(body.get("name") or "").lower().strip().replace(" ", "-"))[:40]
    conn = db.connect()
    try:
        vod = conn.execute("SELECT id, start_epoch FROM vods WHERE id = ?",
                           (vod_id,)).fetchone()
        if not vod:
            raise HTTPException(404, "no such vod")
        if not tag:
            cur = conn.execute(
                """SELECT game FROM gsi_events WHERE vod_id = ?
                   ORDER BY ABS(vod_time_ms - ?) LIMIT 1""", (vod_id, at_ms)).fetchone()
            tag = (cur["game"] if cur else None) or game_detect.current["game"] or "chapter"
        conn.execute(
            "INSERT INTO vod_chapters (vod_id, game_tag, start_time_ms, epoch_ms)"
            " VALUES (?, ?, ?, ?)",
            (vod_id, tag, at_ms, int(vod["start_epoch"] * 1000 + at_ms)))
        conn.commit()
    finally:
        conn.close()
    return {"status": "success", "chapter": tag,
            "label": game_detect.display_name(tag)}


@router.post("/api/scan/run")
async def scan_run(request: Request):
    """Run the vision-scan pass now (optionally one game / one vod) instead
    of waiting for the background loop."""
    body = {}
    try:
        body = await request.json()
    except Exception:
        pass
    from clipshelf import scanning
    res = await asyncio.to_thread(
        scanning.scan_pass, body.get("game"), body.get("vod_id"),
        body.get("limit"), bool(body.get("force")))
    return {"status": "success", **res}


@router.get("/api/chapters")
def chapters(vod_id: int = 0, limit: int = 100):
    """Chapter marks from the game detector, newest first."""
    conn = db.connect()
    try:
        q = """SELECT c.id, c.vod_id, c.game_tag, c.start_time_ms, c.epoch_ms, v.filename
               FROM vod_chapters c LEFT JOIN vods v ON v.id = c.vod_id"""
        params = []
        if vod_id:
            q += " WHERE c.vod_id = ?"
            params.append(vod_id)
        q += " ORDER BY c.epoch_ms DESC LIMIT ?"
        params.append(max(1, min(500, limit)))
        rows = [dict(r) for r in conn.execute(q, params).fetchall()]
    finally:
        conn.close()
    return {"status": "success", "chapters": rows}


# NOTE: registered LAST on purpose — this catch-all would otherwise shadow
# every literal POST above it (Starlette matches in declaration order).
@router.post("/api/gsi/{game}")
async def gsi_ingest(game: str, request: Request):
    """Ingest endpoint the game POSTs to while playing. Events are stamped
    with the current OBS timecode so they land on the recording's own clock;
    the epoch is kept as a fallback for clock-window matching."""
    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="body must be JSON")

    if not _token_ok(request, payload):
        raise HTTPException(status_code=401, detail="bad or missing GSI token")

    game = game.lower().strip()[:32]
    adapter = adapters().get(game)
    if adapter:
        client = ((payload.get("provider") or {}).get("steamid")
                  or (request.client.host if request.client else "?"))
        key = f"{game}:{client}"
        now = time.time()
        for k in [k for k, v in _SESSIONS.items() if now - v.get("_seen", 0) > _SESSION_TTL]:
            _SESSIONS.pop(k, None)
        state = _SESSIONS.setdefault(key, {})
        state["_seen"] = now
        try:
            events = adapter.parse(payload, state)
        except Exception as e:
            print(f"[{time.strftime('%X')}] [GSI] {game} adapter error: {type(e).__name__}: {e}", flush=True)
            events = []
    else:
        events = _normalize_generic(payload)

    n = await asyncio.to_thread(record_events, game, events)
    return {"status": "ok", "events": n}
