"""Session hotkeys — the two global marks, pressable mid-game.

CLIP ("shelf that!"): instantly drops a 📌 bookmark event on the timeline
at the OBS timecode and captures the surrounding buffer — OBS's replay
buffer when it is running, otherwise a DEFERRED cut of [mark - back_s,
mark + post_s] taken once the recording has settled (cutting a still-
growing OBS mp4 is unreliable: partial moov, drifting duration).

The capture lands in the INGEST TRAY (see ingest.py), tagged with the game
that was on screen, and waits there until you trim and name it. Pressing
the key mid-fight should not force a decision about where a clip starts;
that is what the tray is for. Turn `clip_ingest` off in settings to go back
to captures being filed straight into the archive.

CHAPTER: writes a manual chapter boundary (a vod_chapters row, same
mechanics as the automatic game detector) — for segment starts the
process detector can't see: "just chatting", a tournament block, a new
run. An optional name becomes the chapter tag; otherwise the currently
detected game (or "chapter") is used.

Both are callable three ways: the built-in global hotkeys (optional
`keyboard` package — global hooks, no admin needed on Windows; on Linux
it needs elevated input access, where compositor bindings are the better
tool), any HTTP binder via the token'd endpoints (Hyprland/Stream Deck/
AutoHotkey — copyable curl snippets live in the settings card), or the UI.
"""
import asyncio
import json
import os
import re
import threading
import time

from fastapi import APIRouter, HTTPException, Request

from clipshelf import problems
from clipshelf import config, db, obs_client
from clipshelf.config import BASE_URL, GSI_TOKEN, HIGHLIGHTS_DIR
from clipshelf.cutting import cut_range, make_proxy, night_date, vod_is_settled

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

PENDING_TTL_S = 48 * 3600     # marks that never match a recording expire


def _file_replay(path: str, game: str, name: str) -> str:
    """Move an OBS replay-buffer save straight into the saved-clips tree
    (the `clip_ingest` = off path). Returns rel."""
    import shutil
    date = night_date(time.time())
    hms = time.strftime("%H-%M-%S")
    ext = os.path.splitext(path)[1] or ".mp4"
    fname = f"c_{hms}" + (f"_{name}" if name else "") + ext
    out = os.path.join(HIGHLIGHTS_DIR, game, f"marked_{date}", fname)
    os.makedirs(os.path.dirname(out), exist_ok=True)
    shutil.move(path, out)
    make_proxy(out)
    return os.path.relpath(out, HIGHLIGHTS_DIR).replace(os.sep, "/")


def mark(back_s: float = None, post_s: float = None, name: str = "") -> dict:
    """The clip hotkey. Preferred path: OBS's replay buffer — an instant,
    full-quality file of the last N seconds, saved by OBS itself and moved
    into the saved-clips tree. Fallback (buffer off / OBS unreachable): a
    bookmark event + a deferred cut from the VOD once it settles."""
    from clipshelf import game_detect
    back_s = max(3.0, min(300.0, float(back_s if back_s is not None
                                       else config.CFG.get("clip_back_s") or 30)))
    post_s = max(0.0, min(60.0, float(post_s if post_s is not None else 5)))
    name = re.sub(r"[^\w-]+", "-", str(name or "")).strip("-")[:40]
    game = game_detect.current["game"] or "clips"
    vod_time_ms = obs_client.get_obs_timecode()
    epoch_ms = int(time.time() * 1000)

    from clipshelf import ingest
    replay_rel, tray_id = None, None
    try:
        saved = obs_client.save_replay_buffer()
        if saved:
            if ingest.enabled():
                tray_id = ingest.add(saved, game, "replay", label=name.replace("-", " "))
            else:
                replay_rel = _file_replay(saved, game, name)
    except Exception as e:
        print(f"[clipmark] replay-buffer path failed ({e}) — falling back to deferred cut",
              flush=True)

    conn = db.connect()
    try:
        cur = conn.execute(
            "INSERT INTO gsi_events (game, event_type, title, detail, epoch_ms, vod_time_ms)"
            " VALUES (?, 'bookmark', ?, ?, ?, ?)",
            (game, name.replace("-", " ") or "clip mark",
             json.dumps({"marked": True, "back_s": back_s, "post_s": post_s,
                         "replay": bool(replay_rel or tray_id),
                         "tray": tray_id}),
             epoch_ms, vod_time_ms if vod_time_ms > 0 else None))
        event_id = cur.lastrowid
        if tray_id:
            conn.execute("UPDATE ingest_clips SET event_id = ? WHERE id = ?",
                         (event_id, tray_id))
        if not (replay_rel or tray_id):
            conn.execute(
                "INSERT INTO pending_cuts (event_id, back_s, post_s, name) VALUES (?, ?, ?, ?)",
                (event_id, back_s, post_s, name))
        conn.commit()
    finally:
        conn.close()
    how = (f"replay buffer → ingest #{tray_id}" if tray_id
           else f"replay buffer → {replay_rel}" if replay_rel
           else f"last {back_s:.0f}s booked")
    print(f"[{time.strftime('%X')}] [clipmark] 📌 {game} @ {vod_time_ms}ms ({how})", flush=True)
    return {"status": "success", "event_id": event_id, "game": game,
            "replay": replay_rel, "ingest_id": tray_id,
            "pending": not (replay_rel or tray_id), "back_s": back_s}


def mark_chapter(name: str = "") -> dict:
    """Write a manual chapter boundary right now (hotkey #2)."""
    from clipshelf import game_detect
    tag = re.sub(r"[^a-z0-9_-]", "",
                 str(name or "").lower().strip().replace(" ", "-"))[:40] \
        or game_detect.current["game"] or "chapter"
    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 (?, ?, ?)",
            (tag, vod_time_ms if vod_time_ms > 0 else None, epoch_ms))
        conn.commit()
    finally:
        conn.close()
    print(f"[{time.strftime('%X')}] [clipmark] 📖 chapter '{tag}' @ {vod_time_ms}ms",
          flush=True)
    return {"status": "success", "chapter": tag}


def _do_cut(conn, row) -> str:
    """Execute one settled pending cut. Returns the rel path, or an
    "ingest:<id>" marker when the capture went to the tray instead."""
    ev = conn.execute(
        """SELECT ge.*, v.filename, v.start_epoch, v.duration_ms
           FROM gsi_events ge JOIN vods v ON v.id = ge.vod_id
           WHERE ge.id = ?""", (row["event_id"],)).fetchone()
    vt = ev["vod_time_ms"]
    from_ms = max(0, int(vt - row["back_s"] * 1000))
    to_ms = min(ev["duration_ms"], int(vt + row["post_s"] * 1000))
    if to_ms - from_ms < 500:
        raise ValueError("mark landed outside the recording")
    src = os.path.join(config.VOD_DIR, ev["filename"])
    from clipshelf import ingest
    if ingest.enabled():
        # the buffer, not a finished clip: cut generously and let the tray
        # be where in/out actually get decided
        tmp = os.path.join(config.INGEST_DIR, f"_pending_{row['id']}.mp4")
        if not cut_range(src, from_ms / 1000, to_ms / 1000, tmp):
            raise ValueError("cut failed")
        cid = ingest.add(tmp, ev["game"], "vod", event_id=row["event_id"],
                         vod_id=ev["vod_id"], vod_time_ms=vt,
                         label=str(row["name"] or "").replace("-", " "))
        return f"ingest:{cid}"

    date = night_date(ev["start_epoch"])
    hms = time.strftime("%H-%M-%S", time.localtime(ev["start_epoch"] + from_ms / 1000))
    fname = f"c_{hms}" + (f"_{row['name']}" if row["name"] else "") + ".mp4"
    out = os.path.join(HIGHLIGHTS_DIR, ev["game"], f"marked_{date}", fname)
    rel = os.path.relpath(out, HIGHLIGHTS_DIR).replace(os.sep, "/")
    if not os.path.exists(out):
        if not cut_range(src, from_ms / 1000, to_ms / 1000, out):
            raise ValueError("cut failed")
        make_proxy(out)
    return rel


def process_pending() -> dict:
    """Cut every bookable mark whose recording has settled; expire orphans."""
    out = {"cut": 0, "waiting": 0, "errors": 0}
    conn = db.connect()
    try:
        rows = conn.execute(
            """SELECT pc.*, ge.vod_id, ge.vod_time_ms, ge.epoch_ms, v.filename
               FROM pending_cuts pc
               JOIN gsi_events ge ON ge.id = pc.event_id
               LEFT JOIN vods v ON v.id = ge.vod_id
               WHERE pc.status = 'pending'""").fetchall()
        for row in rows:
            if not row["vod_id"] or row["vod_time_ms"] is None:
                if time.time() * 1000 - row["epoch_ms"] > PENDING_TTL_S * 1000:
                    conn.execute("UPDATE pending_cuts SET status='error',"
                                 " error='never matched a recording' WHERE id=?",
                                 (row["id"],))
                    conn.commit()
                    out["errors"] += 1
                else:
                    out["waiting"] += 1
                continue
            if not vod_is_settled(os.path.join(config.VOD_DIR, row["filename"])):
                out["waiting"] += 1
                continue
            try:
                rel = _do_cut(conn, row)
                conn.execute("UPDATE pending_cuts SET status='done', rel=? WHERE id=?",
                             (rel, row["id"]))
                conn.commit()
                out["cut"] += 1
                print(f"[{time.strftime('%X')}] [clipmark] ✂ {rel}", flush=True)
            except Exception as e:
                problems.record("clip mark", e, "the marked clip could not be cut")
                conn.execute("UPDATE pending_cuts SET status='error', error=? WHERE id=?",
                             (str(e)[:300], row["id"]))
                conn.commit()
                out["errors"] += 1
    finally:
        conn.close()
    return out


def _loop():
    time.sleep(60)
    while True:
        try:
            process_pending()
            from clipshelf import fileops, ingest
            ingest.sweep()
            fileops.sweep_pending()   # files Windows would not let go of
        except Exception as e:
            print(f"[clipmark] {e}", flush=True)
        time.sleep(60)


# ------------------------------------------------------------------ hotkeys
_hotkey_state = {"active": [], "errors": []}


def _keyboard_available() -> bool:
    from clipshelf import hotkeys
    return hotkeys.available()


def _bind_hotkey():
    """(Re)bind both global hotkeys from config."""
    from clipshelf import hotkeys
    res = hotkeys.bind([
        (str(config.CFG.get("clip_hotkey") or "").strip(), lambda: mark()),
        (str(config.CFG.get("chapter_hotkey") or "").strip(), lambda: mark_chapter()),
    ])
    _hotkey_state.update(active=res["active"], errors=res["errors"])
    if res["active"]:
        print(f"[clipmark] global hotkeys bound: {res['active']}", flush=True)
    for e in res["errors"]:
        print(f"[clipmark] hotkey problem: {e}", flush=True)


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


# ------------------------------------------------------------------ routes
def _token_ok(request: Request) -> bool:
    import hmac as _hmac
    supplied = (request.headers.get("x-gsi-token")
                or request.query_params.get("token") or "")
    return bool(supplied) and _hmac.compare_digest(str(supplied), GSI_TOKEN)


@router.post("/api/clipmark")
async def clipmark(request: Request):
    """The hotkey endpoint: mark now, clip the last N seconds once the
    recording settles. Token-authed so button boxes on the LAN can call it."""
    if not _token_ok(request):
        raise HTTPException(401, "bad or missing token")
    body = {}
    try:
        body = await request.json()
    except Exception:
        pass
    return mark(body.get("back_s"), body.get("post_s"), body.get("name") or "")


@router.post("/api/chaptermark")
async def chaptermark(request: Request):
    """Hotkey #2: write a manual chapter boundary right now."""
    if not _token_ok(request):
        raise HTTPException(401, "bad or missing token")
    body = {}
    try:
        body = await request.json()
    except Exception:
        pass
    return mark_chapter(body.get("name") or "")


@router.post("/api/clipmark/process")
async def clipmark_process():
    """Run the deferred cutter now instead of waiting for the next pass."""
    return {"status": "success", **await asyncio.to_thread(process_pending)}


def _bindings() -> dict:
    """Ready-to-paste commands per shell. On Windows, PowerShell aliases
    `curl` to Invoke-WebRequest (different syntax entirely), which is why a
    plain curl line fails there — offer curl.exe and a native call, plus a
    .bat any hotkey manager or Stream Deck can run directly."""
    out = {}
    for name, path in (("clip", "clipmark"), ("chapter", "chaptermark")):
        url = f"{BASE_URL}/api/{path}?token={GSI_TOKEN}"
        out[name] = {
            "url": url,
            "curl": f"curl -X POST '{url}'",
            "windows_curl": f'curl.exe -s -X POST "{url}"',
            "powershell": f'Invoke-RestMethod -Method Post -Uri "{url}"',
            "bat": f"/api/clipmark/bat/{name}.bat",
        }
    return out


@router.get("/api/clipmark/bat/{which}.bat")
def clipmark_bat(which: str):
    """A one-line .bat: point Stream Deck, AutoHotkey or a shortcut at it."""
    from fastapi.responses import PlainTextResponse
    which = "chaptermark" if which.startswith("chapter") else "clipmark"
    url = f"{BASE_URL}/api/{which}?token={GSI_TOKEN}"
    body = ("@echo off\r\n"
            "rem Fires the " + ("chapter" if which == "chaptermark" else "clip") +
            " mark. Bind this file to a key, a Stream Deck\r\n"
            "rem button, or anything that can run a program.\r\n"
            f'curl.exe -s -X POST "{url}" >nul 2>&1\r\n')
    return PlainTextResponse(body, headers={
        "Content-Disposition": f'attachment; filename="{which}.bat"'})


@router.get("/api/clipmark/info")
def clipmark_info():
    from clipshelf import ingest
    conn = db.connect()
    try:
        recent = [dict(r) for r in conn.execute(
            "SELECT id, name, status, rel, error, created_at FROM pending_cuts"
            " ORDER BY id DESC LIMIT 8")]
    finally:
        conn.close()
    return {
        "clip_hotkey": str(config.CFG.get("clip_hotkey") or ""),
        "chapter_hotkey": str(config.CFG.get("chapter_hotkey") or ""),
        "back_s": float(config.CFG.get("clip_back_s") or 30),
        "listener_active": _hotkey_state["active"],
        "listener_error": "; ".join(_hotkey_state["errors"]),
        "keyboard_available": _keyboard_available(),
        "ingest": ingest.enabled(),
        "ingest_waiting": ingest.waiting(),
        "ingest_usage": ingest.usage(),
        "replay_buffer": obs_client.connected(),
        "curl_clip": f"curl -X POST '{BASE_URL}/api/clipmark?token={GSI_TOKEN}'",
        "curl_chapter": f"curl -X POST '{BASE_URL}/api/chaptermark?token={GSI_TOKEN}'",
        "bindings": _bindings(),
        "recent": recent,
    }


@router.post("/api/clipmark/settings")
async def clipmark_settings(request: Request):
    body = await request.json()
    for key, cfgk in (("clip_hotkey", "clip_hotkey"), ("chapter_hotkey", "chapter_hotkey")):
        if key in body:
            config.CFG[cfgk] = re.sub(r"[^\w+ -]", "", str(body[key]))[:40]
    if "back_s" in body:
        config.CFG["clip_back_s"] = max(3, min(300, int(body["back_s"])))
    if "ingest" in body:
        config.CFG["clip_ingest"] = bool(body["ingest"])
    if "ingest_cap_gb" in body:
        config.CFG["ingest_cap_gb"] = max(0, min(2000, float(body["ingest_cap_gb"] or 0)))
    config.save(config.CFG)
    _bind_hotkey()
    return {"status": "success", "listener_active": _hotkey_state["active"],
            "listener_error": "; ".join(_hotkey_state["errors"])}
