#!/usr/bin/env python3
"""Localizador GERAL de MISSED-MOMENTS (DO-ZERO, sem marcadores). Acha sozinho momentos que o corte perdeu:
emoção (rosto chorando/rindo numa cam coberta que a ativa não mostra), corredor (movimento clássico),
keymoment (resposta-sim/alianças/abraço). Pra cada, acha a SUB-PARTE ESTÁVEL (shake_windows) -> candidato.
O judge (subagente, prompt geral) confirma o conteúdo. Determinístico, thresholds gerais, qualquer casamento.
Saída: missed_candidates.json."""
import json, bisect, collections
import vlib as V

FPS = 30
face = V.load_face(); COV = V.coverage(); JERK = V.load_jerk()
# SYNC_END = início do maior BLOCO contíguo laranja (>=30s) — mesma regra do unified. Laranja
# ESPALHADO (pré/pós syncados, low-conf) não é zona dessincronizada; min() cru capava tudo em 0.
def _sync_end():
    try: oj = sorted(c["startFrame"] for c in json.load(open("placements.json"))["clips"] if c.get("orange"))
    except Exception: return 999999
    if not oj: return 999999
    runs = [[oj[0], oj[0]]]
    for f in oj[1:]:
        if f-runs[-1][1] <= 600: runs[-1][1] = f
        else: runs.append([f, f])
    big = [r[0] for r in runs if r[1]-r[0] >= 900]
    return min(big) if big else 999999
SYNC_END = _sync_end()
cps = sorted(json.load(open("versions/UNI.json"))["cutpoints"], key=lambda c: c["frame"])
cf = [c["frame"] for c in cps]
def active_at(f): return cps[max(0, bisect.bisect_right(cf, f)-1)]["angle"]

def stable_sub(cam, f0, f1, th):
    ws = V.shake_windows(JERK, cam, f0, f1, win=15)
    runs = []; cur = None
    for w0, w1, j in ws:
        if j is not None and j < th:
            if cur: cur[1] = w1
            else: cur = [w0, w1]
        else:
            if cur: runs.append(cur); cur = None
    if cur: runs.append(cur)
    runs = [r for r in runs if r[1]-r[0] >= int(2.0*FPS)]
    return max(runs, key=lambda r: r[1]-r[0]) if runs else None

def stable_sub_at(cam, peak, th, radius=int(2.0*FPS)):
    """sub-parte ESTÁVEL centrada no PICO do sinal (não o run mais longo qualquer): o strip mostra o
    momento de verdade. Expande do pico p/ os lados enquanto jerk<th, cap radius cada lado, piso 1.5s."""
    a = b = peak
    while peak-a < radius and (V.mean_jerk(JERK, cam, a-15, a) or 9) < th: a -= 15
    while b-peak < radius and (V.mean_jerk(JERK, cam, b, b+15) or 9) < th: b += 15
    if b-a < int(1.5*FPS): return None
    return [a, b]

def peak_face(w, key):
    """frame do PICO de `key` (sad/joy) onde HÁ rosto (mff>0.1). None se o pico não tem rosto real
    (emoção disparou em plateia/costas/ambíguo)."""
    import numpy as _np
    arr = _np.asarray(w.get(key, []), float); mff = _np.asarray(w.get("mff", []), float)
    ok = _np.where(mff > 0.05)[0]
    if not len(arr) or not len(ok): return None, 0
    idx = ok[_np.argmax(arr[ok])]
    return int(w["ts"][idx]*FPS), float(arr[idx])

cands = []

# 1. EMOÇÃO: rosto choro/riso forte numa cam COBERTA que a ATIVA não mostra (sad>0.7 ou joy>0.9), com
#    sub-parte estável. Varre em janelas de 2.5s.
W = int(2.5*FPS)
for f in range(0, (cf[-1] if cf else 0), W):
    if f >= SYNC_END: continue
    a = active_at(f)
    for c in V.CAMS:
        an = V.CAM_ANGLE[c]
        if an == a or not V.covers(COV, an, f, f+W): continue
        w = V.face_window(face, c, f, f+W)
        if not w: continue
        wa = V.face_window(face, V.ANGLE_CAM[a], f, f+W)
        sad_a = float(max(wa["sad"])) if wa and len(wa.get("sad", [])) else 0
        joy_a = float(max(wa["joy"])) if wa and len(wa.get("joy", [])) else 0
        # PICO com ROSTO REAL (mff): emoção sem rosto = plateia/costas/ambíguo -> descarta
        pf_sad, sad = peak_face(w, "sad"); pf_joy, joy = peak_face(w, "joy")
        kind = peak = strg = None
        if pf_sad and sad >= 0.55 and sad-sad_a >= 0.22: kind, peak, strg = "choro", pf_sad, sad+(sad-sad_a)
        elif pf_joy and joy >= 0.88 and joy-joy_a >= 0.3: kind, peak, strg = "riso", pf_joy, joy+(joy-joy_a)
        if kind:
            sub = stable_sub_at(c, peak, 0.6)   # CENTRADA no pico -> strip mostra o momento real
            if sub: cands.append({"f0": sub[0], "f1": sub[1], "cam": an, "type": kind, "src": "emocao", "strg": round(strg, 2)})
            break

# 1b. CLOSE-DE-ROSTO (insight do João): a vó é um CLOSE de rosto com POUCA emoção (só assistindo, sorrisinho)
#     — magnitude de emoção não pega, mas o TAMANHO DA FACE (mff) sim. Discrimina close-de-pessoa (mff alto)
#     de plateia (rostos pequenos, mff baixo). Cam coberta com close (mff>0.22) que a ATIVA (wide, mff<0.13)
#     não mostra -> candidato. Judge decide se é pessoa-chave ou convidado aleatório.
import numpy as _np2
for f in range(0, (cf[-1] if cf else 0), W):
    if f >= SYNC_END: continue
    a = active_at(f); wa = V.face_window(face, V.ANGLE_CAM[a], f, f+W)
    mff_a = float(_np2.median(wa["mff"])) if wa and len(wa.get("mff", [])) else 0
    if mff_a >= 0.13: continue   # ativa já é close -> não é "perdeu o close"
    for c in V.CAMS:
        an = V.CAM_ANGLE[c]
        if an == a or not V.covers(COV, an, f, f+W): continue
        w = V.face_window(face, c, f, f+W)
        if not w or not len(w.get("mff", [])): continue
        mff = _np2.asarray(w["mff"], float)
        if float(_np2.max(mff)) < 0.22: continue   # não é close de rosto
        peak = int(w["ts"][int(_np2.argmax(mff))]*FPS)
        sub = stable_sub_at(c, peak, 0.4)   # close-low-emoção exige MAIS estável (João): th 0.4, não 0.6
        if sub: cands.append({"f0": sub[0], "f1": sub[1], "cam": an, "type": "close_rosto", "src": "mff",
                              "strg": round(float(_np2.max(mff)), 2)})
        break

# 2. CONTEÚDO/CORREDOR/KEYMOMENT via SigLIP: cam coberta mostra algo de valor que a ativa não, sub-parte
#    estável. keymoment (resposta-sim/alianças/abraço) aceita tremor moderado (importante, th=1.0).
CMAP = json.load(open("content_map_dense.json"))
KEYS = {"corredor_movimento": (0.15, 0.6), "resposta_sim": (0.14, 1.0), "aliancas": (0.13, 1.0),
        "aliancas_entrada": (0.13, 1.0), "abraco": (0.14, 1.0), "vo_lagrima": (0.13, 0.6),
        "choro_emocao": (0.14, 0.6)}
hits = collections.defaultdict(list)
for h in CMAP:
    if h["key"] in KEYS and h["f"] < SYNC_END: hits[(h["cam"], h["key"])].append(h)
for (cam, key), hs in hits.items():
    minsc, th = KEYS[key]
    hs = [h for h in hs if h["score"] >= minsc]
    if not hs: continue
    hs.sort(key=lambda h: h["f"])
    cluster = [hs[0]]
    for h in hs[1:]+[None]:
        if h and h["f"]-cluster[-1]["f"] <= 4*FPS: cluster.append(h)
        else:
            f = int(sum(x["f"] for x in cluster)/len(cluster)); an = V.CAM_ANGLE[cam]
            if active_at(f) != an and V.covers(COV, an, f-int(1.5*FPS), f+int(1.5*FPS)):
                sub = stable_sub(cam, f-int(2*FPS), f+int(2*FPS), th)
                if sub: cands.append({"f0": sub[0], "f1": sub[1], "cam": an, "type": key, "src": "siglip",
                                      "strg": round(max(x["score"] for x in cluster)*5, 2)})
            if h: cluster = [h]

# EXCLUI o que os outros estágios JÁ pegaram (este localizador = só o que escapou). Carrega spans de todos
# os inserts/switches congelados + onde a ATIVA já mostra a mesma emoção (não é "missed" se já está no ar).
HANDLED = []
for fn in ("emotion_inserts.json", "keymoment_inserts.json", "content_inserts.json",
           "content_inserts_short.json", "variety_inserts.json", "stab_switches.json", "judge_switches_ok.json"):
    try:
        for v in json.load(open(fn)):
            if "f0" in v: HANDLED.append((v["f0"], v.get("f1", v["f0"]+1)))
    except Exception: pass
def already(f0, f1): return any(a < f1 and b > f0 for a, b in HANDLED)
def active_flat(cam_an, f0, f1):   # a ativa NÃO tem a emoção que o candidato tem -> é genuinamente missed
    wa = V.face_window(face, V.ANGLE_CAM[active_at((f0+f1)//2)], f0, f1)
    if not wa: return True
    sa = max((float(max(wa["sad"])) if len(wa.get("sad", [])) else 0), (float(max(wa["joy"])) if len(wa.get("joy", [])) else 0))
    return sa < 0.6   # ativa fraca de emoção -> missed real

# ordena por FORÇA do sinal (mais forte primeiro), dedup 20s, cap nos TOP-N -> judge-visão filtra os reais
cands.sort(key=lambda c: -c.get("strg", 0)); merged = []
for c in cands:
    c["f1"] = min(c["f1"], c["f0"]+int(4*FPS))   # cap 4s
    if already(c["f0"], c["f1"]): continue
    if c["src"] == "emocao" and not active_flat(c["cam"], c["f0"], c["f1"]): continue
    if not any(abs(c["f0"]-m["f0"]) < 20*FPS and c["cam"] == m["cam"] for m in merged):   # dedup SÓ same-cam
        c["tc"] = V.tc(c["f0"]/30)[:8]; c["jerk"] = round(V.mean_jerk(JERK, V.ANGLE_CAM[c["cam"]], c["f0"], c["f1"]) or 0, 2)
        merged.append(c)
merged.sort(key=lambda c: c["f0"])
json.dump(merged, open("missed_candidates.json", "w"), indent=1)
print(f"{len(merged)} candidatos missed-moment (DO-ZERO, sem marcadores)")
for c in merged: print(f"  {c['tc']} cam{c['cam']} {c['type']:18} ({c['src']}, j{c['jerk']}, {round((c['f1']-c['f0'])/30,1)}s)")
