#!/usr/bin/env python3
"""Decide angle switches for the integral ceremony from per-camera quality scans.

Principle (WEDDING_KNOWLEDGE §3b/§4): the BASE camera (most stable, widest coverage)
is the default; only CUT AWAY to escape a bad stretch on the active camera (shake/blur
relative to that camera's own normal), respect a minimum dwell so the cut count stays
low, and never bounce back just because the active one recovered.

Quality is judged RELATIVE TO EACH CAMERA (z vs its own median) so different lenses/
framerates compare fairly — we're asking "is this camera worse than usual right now?",
not "which lens is sharper in absolute terms".
"""
import json, collections, numpy as np
import vlib as V

TL_FPS = 30.0
STEP = 0.5            # timeline sampling, seconds
DWELL_MIN = 5.0      # min seconds on an angle before another cut (contemplative ceremony)
BAD_MOT_Z = 2.0      # motion this many MADs above the camera's median => shaky now
BAD_FOC_Z = -2.0     # focus this far below => blurry now
MARGIN = 0.5         # base preference slack
LOOKAHEAD = 1.0      # v1: look ahead — cut BEFORE the active angle shakes
END_GUARD = 2.0      # v1: leave a take this many seconds before it ends (ends shake)
DETAIL_MARGIN = 1.0  # v2: only hop detail→other-detail if the other is clearly steadier (anti-ping-pong)

CAM_ANGLE = V.CAM_ANGLE

def src_name(placement_name, cam=None):
    # v0.4: contrato name=f"{cam}_{stem}" → stem (não [:-4])
    return V.footage_key(placement_name, cam=cam)

def load():
    P = json.load(open("placements.json"))
    clips = P["clips"]
    # per camera: list of (t0_tl, t1_tl, source_frames_arr)
    cams = collections.defaultdict(list)
    for c in clips:
        name = src_name(c["name"], cam=c.get("cam"))
        try:
            q = json.load(open(f"qcache/{name}.json"))
        except FileNotFoundError:
            continue
        fr = q.get("frames", [])
        if not fr: continue
        ts = np.array([f["t"] for f in fr])
        foc = np.array([f["focus"] for f in fr])
        mot = np.array([f["motion"] for f in fr])
        t0 = c["startFrame"] / TL_FPS
        cams[c["cam"]].append({"t0": t0, "dur": c["durationFrames"]/TL_FPS,
                               "ts": ts, "foc": foc, "mot": mot})
    return cams

def mad(a):
    m = np.median(a); return np.median(np.abs(a-m)) + 1e-6, m

def camera_stats(cams):
    # robust per-camera medians for z-scoring, plus coverage & global motion
    stats = {}
    for cam, segs in cams.items():
        foc = np.concatenate([s["foc"] for s in segs])
        mot = np.concatenate([s["mot"] for s in segs])
        fmad, fmed = mad(foc); mmad, mmed = mad(mot)
        coverage = sum(s["dur"] for s in segs)
        stats[cam] = {"fmed": fmed, "fmad": fmad, "mmed": mmed, "mmad": mmad,
                      "coverage": coverage, "mot_med": float(np.median(mot))}
    return stats

def sample(cams, cam, T):
    """(focus, motion) for camera at timeline-time T, or None if no clip covers T."""
    for s in cams[cam]:
        local = T - s["t0"]
        if 0 <= local <= s["dur"]:
            i = int(np.searchsorted(s["ts"], local))
            i = min(max(i, 0), len(s["ts"]) - 1)
            return float(s["foc"][i]), float(s["mot"][i])
    return None

def zscores(stats, cam, foc, mot):
    st = stats[cam]
    return (foc - st["fmed"]) / st["fmad"], (mot - st["mmed"]) / st["mmad"]

def remaining(cams, cam, T):
    """Seconds left in the clip of `cam` that covers T (None if none covers it)."""
    for s in cams[cam]:
        local = T - s["t0"]
        if 0 <= local <= s["dur"]:
            return s["dur"] - local
    return None

def decide(cams, stats):
    base = max(stats, key=lambda c: stats[c]["coverage"] / (1 + stats[c]["mot_med"]))
    tmax = max(s["t0"] + s["dur"] for segs in cams.values() for s in segs)

    def bad_now(c, t):
        r = sample(cams, c, t)
        if not r: return True
        fz, mz = zscores(stats, c, *r)
        return mz > BAD_MOT_Z or fz < BAD_FOC_Z
    def bad_soon(c, t):
        # cut BEFORE it shakes: bad now, or within the lookahead window, or the take is about to
        # end (ends of takes shake — WEDDING_KNOWLEDGE §3b).
        if any(bad_now(c, t + dt) for dt in (0.0, 0.5, 1.0, LOOKAHEAD)):
            return True
        rem = remaining(cams, c, t)
        return rem is not None and rem < END_GUARD
    def present(c, t): return sample(cams, c, t) is not None
    def mot_now(c, t):
        r = sample(cams, c, t)
        return zscores(stats, c, *r)[1] if r else 1e9

    details = [c for c in cams if c != base]

    state, last_cut = base, -1e9
    track = []
    T = 0.0
    while T <= tmax:
        # detalhe-first: prefer the steadiest DETAIL camera that's good now and stays good
        # (lookahead/end-guard); base only fills the gap when no detail serves.
        details_ok = [d for d in details if present(d, T) and not bad_soon(d, T)]
        if details_ok:
            desired = min(details_ok, key=lambda c: mot_now(c, T))
        elif present(base, T) and not bad_soon(base, T):
            desired = base
        else:
            pres = [c for c in cams if present(c, T)]
            desired = min(pres, key=lambda c: mot_now(c, T)) if pres else state

        state_detail_ok = state in details and present(state, T) and not bad_soon(state, T)
        if (T - last_cut) >= DWELL_MIN and not state_detail_ok and desired != state:
            # leave when the current angle is no longer a good detail — go to the desired one
            # (another good detail if any, else base). Staying put on a still-good detail avoids
            # frantic detail↔detail cuts.
            state, last_cut = desired, T
        track.append((round(T, 2), state))
        T += STEP
    return base, track

def segments(track):
    segs = []
    for t, cam in track:
        if segs and segs[-1]["cam"] == cam:
            segs[-1]["t1"] = t
        else:
            segs.append({"t0": t, "t1": t, "cam": cam})
    return segs

if __name__ == "__main__":
    cams = load()
    stats = camera_stats(cams)
    base, track = decide(cams, stats)
    segs = segments(track)
    cuts = [{"frame": int(round(s["t0"]*TL_FPS)), "cam": s["cam"],
             "angle": CAM_ANGLE.get(s["cam"]), "t0": s["t0"], "t1": s["t1"]}
            for s in segs]
    out = {"base": base,
           "stats": {c: {k: round(v, 2) for k, v in stats[c].items()} for c in stats},
           "n_cuts": len(segs),
           "avg_seg_s": round(sum(s["t1"]-s["t0"] for s in segs)/len(segs), 1),
           "segments": cuts}
    json.dump(out, open("cuts.json", "w"), indent=1)
    print(json.dumps({k: out[k] for k in ("base","stats","n_cuts","avg_seg_s")}, indent=1))
    print("first 12 segments:")
    for s in cuts[:12]:
        print(f"  f{s['frame']:>6}  {s['t0']:>6.1f}-{s['t1']:<6.1f}s  {s['cam']} (angle {s['angle']})")
