#!/usr/bin/env python3
"""
replay.py — replay a dumped embedding HDF5 through the real KPN downstream nodes.

TRACES: VR-002 | PR-002

Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an
EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++
face_tracker → identity_matcher → scene_tracker, and returns the same presence-window
JSON that scene_analyze's result_sink produces (minimal schema). No decode, no GPU
embedding — only the cheap downstream tail runs, so a sweep can vary Config knobs
freely. See [[kpn-python-replay-optimizer]].

CLI:
  python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \
      --out replayed.json [--prob-threshold 0.99] [--anneal 10] ...
"""
from __future__ import annotations

import argparse
import json
import sys
import time
from pathlib import Path

import h5py
import numpy as np

REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from sae_stamp import verify_gallery_stamp  # noqa: E402


def dump_embedder_stamp(dump_path: str) -> dict:
    """The GR-004 embedder stamp recorded in an embedding dump.

    A replay has no live embedder — the dump IS the embedder as far as the gallery
    is concerned, so the dump's stamp is what the gallery must be checked against.
    Dumps written before GR-004 have no attributes and yield an empty stamp, which
    the check reports as unverifiable rather than silently accepting."""
    with h5py.File(dump_path, "r") as f:
        name = f.attrs.get("embedder_model", "")
        sha = f.attrs.get("embedder_sha256", "")
    dec = lambda v: v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
    return {"model_name": dec(name), "model_sha256": dec(sha), "embed_dim": 512}


def load_frames(dump_path: str, min_conf: float = 0.0):
    """Yield EmbeddedSceneFrame dicts from the HDF5 dump, then a trailing EOF.

    `min_conf` drops detections below that detector confidence before they reach the
    matcher — an UPWARD-only detector_conf sweep on already-dumped faces (the dump was
    made at detector_conf=0.5, so 0.5 is the floor). Lets us test whether near-threshold
    detections are real faces (raising min_conf hurts recall) or phantoms (it helps
    precision at no recall cost)."""
    with h5py.File(dump_path, "r") as f:
        ts   = f["frames/timestamp_sec"][:]
        fidx = f["frames/frame_idx"][:]
        cut  = f["frames/is_cut"][:]
        off  = f["frames/face_offset"][:]
        cnt  = f["frames/face_count"][:]
        emb  = f["faces/embedding"][:]
        bbox = f["faces/bbox"][:]
        lmk  = f["faces/landmarks"][:]
        conf = f["faces/confidence"][:]
        movie = f.attrs.get("movie", "")
        fps   = float(f.attrs.get("sample_fps", 1.0))

    frames = []
    for i in range(len(ts)):
        s, n = int(off[i]), int(cnt[i])
        keep = slice(s, s + n)
        c = np.ascontiguousarray(conf[keep], dtype=np.float32)
        if min_conf > 0.0 and n:
            m = c >= min_conf
            sel = np.where(m)[0]
            frames.append({
                "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
                "is_cut": bool(cut[i]), "eof": False,
                "bbox":       np.ascontiguousarray(bbox[keep][sel], dtype=np.float32),
                "landmarks":  np.ascontiguousarray(lmk[keep][sel],  dtype=np.float32),
                "confidence": np.ascontiguousarray(c[sel],          dtype=np.float32),
                "embeddings": np.ascontiguousarray(emb[keep][sel],  dtype=np.float32),
            })
        else:
            frames.append({
                "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
                "is_cut": bool(cut[i]), "eof": False,
                "bbox":       np.ascontiguousarray(bbox[keep], dtype=np.float32),
                "landmarks":  np.ascontiguousarray(lmk[keep],  dtype=np.float32),
                "confidence": c,
                "embeddings": np.ascontiguousarray(emb[keep],  dtype=np.float32),
            })
    last_ts = float(ts[-1]) if len(ts) else 0.0
    frames.append({"timestamp_sec": last_ts, "eof": True})
    return frames, str(movie), fps


def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool = True,
          raw_out: str | None = None) -> dict:
    """Run the dump through the real KPN chain; return minimal-schema presence JSON.

    cfg may include "detector_conf" to prune dumped detections below that confidence
    (upward-only from the 0.5 dump floor) before matching.

    raw_out: if set, also write the raw per-frame annotations (timestamp, actor_idx,
    name, bbox, similarity — one entry per input frame, before merging into windows)
    as JSON lines to this path. Needed to draw bounding boxes on extracted frames;
    the merged window schema returned by this function has no per-frame bbox."""
    sys.path.insert(0, build_dir)
    import sae_kpn

    # TRACES: GR-004 | SR-001 — checked here, before any network is built, so a
    # cross-model replay dies with one readable error instead of producing a
    # plausible-looking score. add_identity_matcher re-checks it C++-side below;
    # that is the backstop for any other caller of the binding.
    stamp = dump_embedder_stamp(dump_path)
    verify_gallery_stamp(gallery, stamp=stamp,
                         embedder_desc=f"embedding dump {Path(dump_path).name}",
                         require_stamp=bool(cfg.get("require_gallery_stamp", False)))

    frames, movie, fps = load_frames(dump_path, min_conf=float(cfg.get("detector_conf", 0.0)))

    net = sae_kpn.Network()
    sae_kpn._register_types(net)

    idx = [0]
    eof = {"timestamp_sec": frames[-1]["timestamp_sec"], "eof": True}

    def source():
        # A no-input source node's run_loop calls this in a tight loop. Once frames
        # are exhausted we must NOT hot-spin returning EOF — that pegs a core and
        # floods the downstream channel with EOFs (livelock that wedged DE). Sleep
        # briefly after the single real EOF so net.stop() can tear the thread down.
        i = idx[0]
        idx[0] += 1
        if i < len(frames):
            return frames[i]
        time.sleep(0.05)
        return eof

    # Channel capacity must exceed the frame count so the fast source can't overflow
    # a downstream FIFO before the serial reader drains it — PyNode DROPS on overflow,
    # which would silently truncate the replay. Size to the whole film + slack.
    # Every channel gets capacity ≥ the whole film so NOTHING can ever overflow-drop:
    # the source can push all frames before any downstream node has drained, and a
    # dropped frame silently corrupts the score. Memory is cheap (a few k pointers);
    # correctness is not. Generous slack on top.
    cap = len(frames) * 2 + 64
    sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], cap)
    sae_kpn.add_face_tracker(net, "tracker", cfg, cap)
    sae_kpn.add_identity_matcher(net, "matcher", gallery, cfg, cap,
                                 stamp["model_name"], stamp["model_sha256"])
    sae_kpn.add_scene_tracker(net, "scene", cfg, cap)
    net.connect("replay", 0, "tracker", 0)
    net.connect("tracker", 0, "matcher", 0)
    net.connect("matcher", 0, "scene", 0)
    net.build()
    net.start()

    # Read exactly one annotation per input frame. The source emits EOF as an ordinary
    # value AFTER the last frame, but the concurrent pipeline lets that EOF OVERTAKE
    # the last few real frames still flowing tracker→matcher→scene. Breaking on the
    # first eof therefore dropped a random tail (~0.5–1%, race-dependent). Instead we
    # keep reading past eof until we've collected all n_frames annotations (or hit a
    # run of consecutive eofs meaning the pipeline is genuinely drained).
    n_expected = len(frames) - 1   # excludes the trailing eof frame
    annotations = []
    eof_streak = 0
    max_reads = n_expected * 2 + 32
    for _ in range(max_reads):
        sa = net.read("scene", 0)
        if sa.get("eof"):
            eof_streak += 1
            # stragglers can still arrive after an eof; only stop once we've either
            # got everything or seen several eofs in a row (truly drained).
            if len(annotations) >= n_expected or eof_streak >= 8:
                break
            continue
        eof_streak = 0
        annotations.append(sa)
        if len(annotations) >= n_expected:
            break

    if raw_out:
        with open(raw_out, "w") as f:
            for sa in annotations:
                f.write(json.dumps(sa) + "\n")

    result = build_minimal(annotations, movie, fps, cfg)
    if stop:
        net.stop()
    return result


def build_minimal(annotations, movie, fps, cfg) -> dict:
    """Reproduce result_sink's minimal schema: per-actor annealed [start,end] windows.

    Mirrors ResultSinkFunc::build_actor_windows — merge each actor's detection
    timestamps into windows, bridging gaps shorter than anneal_sec.
    """
    anneal = float(cfg.get("anneal_sec", 10.0))
    info = {}           # actor_idx -> identity fields
    times = {}          # actor_idx -> [timestamps]
    for sa in annotations:
        for a in sa["visible_actors"]:
            if a["actor_idx"] < 0:
                continue
            info[a["actor_idx"]] = a
            times.setdefault(a["actor_idx"], []).append(sa["timestamp_sec"])

    actors = []
    for idx, ts in times.items():
        ts.sort()
        scenes = []
        ws = we = ts[0]
        for t in ts[1:]:
            if t - we > anneal:
                scenes.append([ws, we])
                ws = t
            we = t
        scenes.append([ws, we])
        a = info[idx]
        actors.append({
            "name": a["name"], "imdb_id": a["imdb_id"], "tmdb_id": a["tmdb_id"],
            "jellyfin_id": a["jellyfin_id"], "scenes": scenes,
        })

    return {"schema_version": 1, "movie": movie, "sample_fps": fps,
            "anneal_sec": anneal, "actors": actors}


CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", "match_threshold", "match_ratio",
            "match_ratio_ceil", "track_alpha", "track_min_iou", "track_max_embed_dist",
            "track_max_frames_missing", "cut_revive_sim", "cut_inactive_max_frames",
            "extinction_sec", "anneal_sec"]


def main():
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--dump", required=True, help="embedding HDF5 dump")
    p.add_argument("--gallery", required=True)
    p.add_argument("--out", required=True, help="output presence JSON")
    p.add_argument("--raw-out", help="also write raw per-frame annotations (JSONL, with bboxes) here")
    p.add_argument("--build-dir", default=str(REPO / "build"))
    for k in CFG_KEYS:
        p.add_argument(f"--{k.replace('_','-')}", type=float, default=None)
    # per-film gallery expansion: promotes pose-varied views of confidently-identified
    # actors into an in-memory annex, recovering ~+4 recall at no precision cost.
    p.add_argument("--expand-gallery", action="store_true")
    # TRACES: GR-004 | SR-001 — promote an unprovable gallery/dump binding from a
    # loud warning to a hard error. Measurement sweeps should set this (or
    # SAE_REQUIRE_GALLERY_STAMP=1) so no number comes from an unbound pair.
    p.add_argument("--require-gallery-stamp", action="store_true")
    args = p.parse_args()

    cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None}
    if args.expand_gallery:
        cfg["expand_gallery"] = True
    if args.require_gallery_stamp:
        cfg["require_gallery_stamp"] = True
    # stop=True: PyNode::stop() sets stop_flag_ before joining, so the source
    # thread's run_loop actually exits. stop=False skips that, leaving stop_flag_
    # false forever — the PyNode destructor's jthread.join() then blocks forever
    # (verified via gdb: stuck in the source node's run_loop, not the GEMM path).
    result = replay(args.dump, args.gallery, cfg, args.build_dir, stop=True,
                    raw_out=args.raw_out)
    Path(args.out).write_text(json.dumps(result, indent=2))
    print(f"[replay] {len(result['actors'])} actors → {args.out}", file=sys.stderr)


if __name__ == "__main__":
    main()
