"""Minting vision templates from your own footage.

The vision scanners find *you* by matching a picture of your name — the
killfeed line in Deadlock, the "<NAME> SCORED!" banner in Rocket League.
Those templates are per-account by nature: install a pack carrying someone
else's name and the scanner hunts for a stranger in your footage and finds
nothing, silently, forever. That is why two packs could never ship publicly.

So a pack declares how to mint one instead of shipping one:

    ENROLL = [{
        "id": "scorer",
        "label": "your name on the goal banner",
        "hint":  "seek to just after you scored",
        "vf":    "scale=1920:1080,crop=1920:75:0:338,format=rgb24",
        "width": 1920, "height": 75, "channels": 3,
        "mask":  {"op": "max_gt", "value": 215},   # or None for raw grays
        "file":  "rl_scorer_{name}.npz",
        "keys":  {"mask": "mask", "x0": "box_x0", "x1": "box_x1"},
    }]

The `vf` is the SCANNER'S OWN filter chain, not an approximation of it. A
template cropped from a differently-scaled frame would be a few pixels out
and match nothing, which is the hardest kind of failure to notice — so the
wizard shows the user exactly the strip the scanner will see, and they draw
a box on that.
"""
import io
import os
import re
import subprocess
from pathlib import Path

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

from clipshelf import config, db, fileops

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


def _steps() -> dict:
    """game slug -> (adapter, [step, …]) for every pack that supports this."""
    from clipshelf.plugin_host import registry
    out = {}
    for slug, mod in registry.game_adapters.items():
        steps = getattr(mod, "ENROLL", None)
        if steps:
            out[slug] = (mod, list(steps))
    return out


def _step(game: str, step_id: str):
    found = _steps().get(game)
    if not found:
        raise HTTPException(404, f"{game} has no enrollment steps")
    mod, steps = found
    for s in steps:
        if s.get("id") == step_id:
            return mod, s
    raise HTTPException(404, f"no enrollment step '{step_id}' for {game}")


def _templates_dir(mod) -> Path:
    """Where the pack keeps its templates. Written into the installed pack,
    so an update that replaces plugin code leaves the user's own templates
    alone (they live in plugins/, which the updater preserves)."""
    d = getattr(mod, "SCAN_DIR", None) or os.path.dirname(getattr(mod, "__file__", ""))
    p = Path(d)
    if p.name != "templates" and (p / "templates").is_dir():
        p = p / "templates"
    p.mkdir(parents=True, exist_ok=True)
    return p


def grab(path: str, t_s: float, step: dict) -> bytes:
    """One frame through the scanner's own filter chain, as raw bytes."""
    w, h, ch = int(step["width"]), int(step["height"]), int(step.get("channels", 1))
    r = subprocess.run(
        ["ffmpeg", "-v", "error", "-ss", f"{max(0.0, t_s):.2f}", "-i", path,
         "-frames:v", "1", "-vf", step["vf"], "-f", "rawvideo", "-"],
        capture_output=True, timeout=90)
    need = w * h * ch
    if len(r.stdout) < need:
        raise HTTPException(400, "could not read a frame there — try another moment")
    return r.stdout[:need]


def _as_array(raw: bytes, step: dict):
    import numpy as np
    w, h, ch = int(step["width"]), int(step["height"]), int(step.get("channels", 1))
    a = np.frombuffer(raw, dtype=np.uint8)[:w * h * ch]
    return a.reshape(h, w, ch) if ch > 1 else a.reshape(h, w)


def _png(arr) -> bytes:
    """PNG for the browser. Pillow ships with the app (the tray icon)."""
    from PIL import Image
    if arr.ndim == 3:
        img = Image.fromarray(arr, "RGB")
    else:
        img = Image.fromarray(arr, "L")
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


# ------------------------------------------------------------------ routes
def pending() -> list:
    """Packs that are installed but have not been taught your name yet.

    A vision pack with no template is not broken and does not complain: it
    scans, matches nobody, and finds nothing — silently, forever. That is
    indistinguishable from a quiet night, which is why this has to be
    surfaced somewhere rather than waited for."""
    from clipshelf import game_detect
    out = []
    for slug, (mod, steps) in sorted(_steps().items()):
        tdir = _templates_dir(mod)
        for st in steps:
            pattern = str(st.get("file") or "")
            if not pattern:
                continue
            if any(tdir.glob(pattern.replace("{name}", "*"))):
                continue
            out.append({"game": slug,
                        "game_label": game_detect.display_name(slug) or slug,
                        "step": st.get("id"),
                        "label": st.get("label") or st.get("id")})
    return out


@router.get("/api/enroll")
def enroll_state():
    """Which installed packs can be taught your name, and what they have."""
    from clipshelf import game_detect
    out = []
    for slug, (mod, steps) in sorted(_steps().items()):
        tdir = _templates_dir(mod)
        for s in steps:
            pattern = str(s.get("file") or "")
            glob = pattern.replace("{name}", "*")
            have = sorted(p.name for p in tdir.glob(glob)) if glob else []
            out.append({
                "game": slug,
                "game_label": game_detect.display_name(slug) or slug,
                "step": s.get("id"),
                "label": s.get("label") or s.get("id"),
                "hint": s.get("hint") or "",
                "width": s.get("width"), "height": s.get("height"),
                "enrolled": have,
                "dir": str(tdir),
            })
    return {"status": "success", "steps": out}


@router.get("/api/enroll/frame")
def enroll_frame(game: str, step: str, vod_id: int, t_ms: int):
    """The exact strip the scanner will see, at this moment, as a PNG."""
    mod, st = _step(game, step)
    conn = db.connect()
    try:
        row = conn.execute("SELECT filename FROM vods WHERE id = ?", (vod_id,)).fetchone()
    finally:
        conn.close()
    if not row:
        raise HTTPException(404, "no such recording")
    path = os.path.join(config.VOD_DIR, row["filename"])
    if not os.path.exists(path):
        raise HTTPException(404, "that recording is not on disk")
    arr = _as_array(grab(path, t_ms / 1000, st), st)
    return Response(_png(arr), media_type="image/png",
                    headers={"Cache-Control": "no-store"})


@router.post("/api/enroll/save")
async def enroll_save(request: Request):
    """Crop the box the user drew and write it as the pack's template."""
    import numpy as np
    body = await request.json()
    mod, st = _step(str(body.get("game") or ""), str(body.get("step") or ""))

    name = re.sub(r"[^a-z0-9_]+", "_", str(body.get("name") or "").lower()).strip("_")[:32]
    if not name:
        raise HTTPException(400, "give the template a name (your gamertag)")

    conn = db.connect()
    try:
        row = conn.execute("SELECT filename FROM vods WHERE id = ?",
                           (int(body.get("vod_id") or 0),)).fetchone()
    finally:
        conn.close()
    if not row:
        raise HTTPException(404, "no such recording")
    path = os.path.join(config.VOD_DIR, row["filename"])

    box = body.get("box") or {}
    x0, y0 = int(box.get("x", 0)), int(box.get("y", 0))
    x1, y1 = x0 + int(box.get("w", 0)), y0 + int(box.get("h", 0))
    W, H = int(st["width"]), int(st["height"])
    x0, x1 = max(0, min(x0, W)), max(0, min(x1, W))
    y0, y1 = max(0, min(y0, H)), max(0, min(y1, H))
    if x1 - x0 < 6 or y1 - y0 < 4:
        raise HTTPException(400, "that box is too small to match anything")

    arr = _as_array(grab(path, float(body.get("t_ms") or 0) / 1000, st), st)
    crop = arr[y0:y1, x0:x1]
    gray = crop.max(axis=2) if crop.ndim == 3 else crop

    spec = st.get("mask")
    payload = {}
    for key, kind in (st.get("keys") or {}).items():
        if kind == "gray":
            payload[key] = gray.astype(np.uint8)
        elif kind == "mask" and spec and spec.get("op") == "max_gt":
            # the scanner masks the WHOLE strip and slices columns x0:x1, so
            # the template must be the full-height strip mask, not the crop
            full = arr.max(axis=2) if arr.ndim == 3 else arr
            payload[key] = (full[:, x0:x1] > int(spec["value"]))
        elif kind == "mask_gt128":
            payload[key] = (gray > 128)
        elif kind == "box_x0":
            payload[key] = np.array(x0)
        elif kind == "box_x1":
            payload[key] = np.array(x1)
        else:
            raise HTTPException(500, f"pack asked for an unknown key kind '{kind}'")

    tdir = _templates_dir(mod)
    out = tdir / str(st["file"]).replace("{name}", name)
    np.savez_compressed(out, **payload)
    print(f"[enroll] wrote {out.name} ({', '.join(payload)}) from {row['filename']}",
          flush=True)
    return {"status": "success", "file": out.name, "dir": str(tdir),
            "box": {"x": x0, "y": y0, "w": x1 - x0, "h": y1 - y0},
            "keys": {k: list(getattr(v, "shape", ())) for k, v in payload.items()}}


@router.delete("/api/enroll/template")
def enroll_delete(game: str, step: str, file: str):
    mod, st = _step(game, step)
    tdir = _templates_dir(mod)
    p = (tdir / os.path.basename(file)).resolve()
    if not str(p).startswith(str(tdir.resolve())) or not p.is_file():
        raise HTTPException(404, "no such template")
    if not fileops.remove(p):
        raise HTTPException(409, "that file is in use — try again in a moment")
    return {"status": "success"}
