#!/usr/bin/env python3
"""Classifica CENA (VLM Kimi) dos clips da BORDA da cerimônia -> weddings/<nome>/scene_judge.json.
O núcleo sincado é cerimônia por construção (travou na música) — NÃO precisa VLM. Só as BORDAS
(antes da 1ª âncora / depois da última) são ambíguas: SAIDA (recessional, é cerimônia) vs PARTY/
ENSAIO (não é). O `from_sync` (placements.py) já consome scene_judge.json com suavização de
sequência. Barato: ~poucos clips por cam. READ-ONLY.
Uso: scene_judge.py decupagem_<x>.json placements_<x>_cerimonia.json [--pad 6]
"""
import os, sys, re, json, subprocess, argparse, time
import sync_ingest as si
from vlm import ask_image
from sync_judge import PROMPT

CER = {"CEREMONY", "SAIDA"}

# PORT VideoTask: usa caminho absoluto para ffmpeg (subprocess do Swift não herda PATH)
FFMPEG = "/opt/homebrew/bin/ffmpeg"


def num_of(fn):
    m = re.findall(r'(\d{3,5})', os.path.splitext(os.path.basename(fn))[0])
    return int(m[-1]) if m else 0


def classify(path, bias=None):
    """bias='tail': clip vem LOGO DEPOIS do núcleo (ex-saída/recessional) — a transição (ainda
    cerimônia) mora PERTO DO INÍCIO do clip (a pessoa sai andando animada e só DEPOIS passa a
    posar/cumprimentar). bias='head': clip vem LOGO ANTES do núcleo — transição mora perto do FIM.
    Amostragem fixa (25/50/75%) perde essa borda em clips longos (o recessional real pode durar só
    os 1os segundos de um clip de 50s+, o resto já é ensaio/cumprimento) — pesa a amostra pro lado
    que encosta no núcleo (onde a transição física realmente está), qualquer casamento."""
    jpg = os.path.join("/tmp/scene_judge", os.path.basename(path) + ".jpg")
    os.makedirs("/tmp/scene_judge", exist_ok=True)
    dur = si.ffdur(path)
    votes = []
    fracs = {"tail": (0.05, 0.20, 0.75), "head": (0.25, 0.80, 0.95)}.get(bias, (0.25, 0.5, 0.75))
    for frac in fracs:  # 3 frames/clip: single-frame erra na borda (memória)
        subprocess.run([FFMPEG, "-v", "error", "-ss", f"{dur*frac:.1f}", "-i", path,
                        "-frames:v", "1", "-vf", "scale=320:-1", "-y", jpg], capture_output=True)
        if not os.path.exists(jpg):
            continue
        v = None
        for _try in range(2):  # RETRY: Kimi rate-limit/rede/vazio é transiente
            txt, _ = ask_image(jpg, PROMPT)
            m = re.search(r'\{.*\}', txt, re.S)
            try:
                cand = json.loads(m.group(0)) if m else {}
            except Exception:
                cand = {}
            if cand.get("scene"):
                v = cand; break
            time.sleep(2)
        if v:
            votes.append(v)
    from collections import Counter
    sc = Counter(v.get("scene") for v in votes if v.get("scene")).most_common(1)
    # VLM FALHO (vazio/"?") = DESCONHECIDO, NÃO não-cerimônia. Retorna None → clip fica
    # UNCLASSIFICADO (núcleo, mantido). Tratar "?" como cer=False fazia a máquina de estado CORTAR a
    # cerimônia quando o Kimi falhava (Nathália: rate-limit derrubou 28 clips). VLM falho ≠ "corta".
    if not sc:
        return None
    scene = sc[0][0]
    guests = sum(1 for v in votes if v.get("guests")) > len(votes) / 2
    return {"scene": scene, "guests": guests, "is_ceremony": scene in CER,
            "why": next((v.get("why", "") for v in votes if v.get("scene") == scene), "")}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("decupagem"); ap.add_argument("sync")
    ap.add_argument("--pad", type=int, default=6, help="quantos clips de cada lado do núcleo classificar")
    a = ap.parse_args()
    decup = json.load(open(a.decupagem)); base = decup["base"]
    P = json.load(open(a.sync))["clips"]
    import wedslug
    name = wedslug.from_path(a.decupagem) or wedslug.slug(decup.get("casamento", "w"))
    ws = os.path.join("weddings", name); os.makedirs(ws, exist_ok=True)
    out_path = os.path.join(ws, "scene_judge.json")

    # cache-manifest: cada clip já julgado (POR-CLIP, não por-run — igual ao merge do
    # media_paths.json) NUNCA rejulga entre runs, a menos que o PRÓPRIO scene_judge.py tenha
    # mudado desde o último run — nesse caso os vereditos antigos foram feitos com um "critério"
    # (prompt/lógica) velho, então reseta TUDO pra forçar rejulgamento com o critério novo.
    import cache_manifest as cm
    script_changed = cm.script_hash(__file__) != (
        cm.load_manifest(".").get("scene_judge", {}).get("script_hash")
    )
    if script_changed and os.path.exists(out_path):
        print(f"[scene_judge] script mudou desde o último run — resetando {out_path} "
              f"(vereditos antigos usavam critério velho, rejulgando tudo)")
    if not script_changed and os.path.exists(out_path):
        out = json.load(open(out_path))
        print(f"[scene_judge] script inalterado — reusando {len(out)} veredito(s) já julgado(s), "
              f"só clips novos entram no VLM")
    else:
        out = {}
    for cam, ci in decup["cameras"].items():
        vids = {os.path.basename(v): v for v in si.find_videos_multi(base, ci["pasta"])}
        # âncoras fortes (fp) dessa cam definem o núcleo; a borda é fora do [min,max] das âncoras.
        # SÓ âncoras MONOTÔNICAS (num↔offset consistente) — 4008 (festa) fp-matched num repeat da
        # música ANTES do 3990 (num maior, offset menor) = FALSA, esticava o hi pra 4008 e a cauda
        # (3991+ = saída/festa) virava "núcleo" e não era classificada. Rejeita o offset que recua.
        cam_clips = [(fn, c) for fn, c in P.items() if c.get("cam") == cam]
        fps = sorted([(num_of(fn), c.get("offset_s") or 0) for fn, c in cam_clips
                      if c.get("via") == "fp" and (c.get("rank") or 0) >= 40])
        anchors, mx_off = [], -1
        for num, off in fps:
            if off < mx_off - 5:  # num sobe mas o tempo VOLTA = repeat falso da música
                continue
            mx_off = max(mx_off, off); anchors.append(num)
        # o núcleo é o RUN CONSECUTIVO de âncoras (3979-3990 seguidos). Uma âncora ISOLADA depois de
        # um buraco de nums (4008 pula 18 sem âncora no meio) é um clip de FESTA que fp-matched por
        # acaso no spine (mesmo lixo que o 3992+) — não faz parte do núcleo. NÃO é raciocínio de
        # "clips cabem no tempo" (clip curto cabe); é cluster de âncoras: o núcleo é contíguo.
        core = [anchors[0]] if anchors else []
        for n in anchors[1:]:
            if n - core[-1] > 6:
                break
            core.append(n)
        anchors = core
        if not anchors:
            continue
        lo, hi = min(anchors), max(anchors)
        border = sorted([fn for fn, c in cam_clips
                         if not (lo <= num_of(fn) <= hi)], key=num_of)
        # só ~pad clips de cada lado (recessional/entrada ficam colados no núcleo)
        head = [fn for fn in border if num_of(fn) < lo][-a.pad:]
        tail = [fn for fn in border if num_of(fn) > hi][:a.pad]
        for fn, bias in [(fn, "head") for fn in head] + [(fn, "tail") for fn in tail]:
            key = f"{cam}_{os.path.splitext(fn)[0]}"
            if key in out:
                continue  # já julgado num run anterior e o script não mudou — pula o VLM
            src = vids.get(fn)
            if not src:
                continue
            r = classify(src, bias=bias)
            if r:
                out[key] = r
                print(f"  {key[-28:]:28} {r['scene']:9} cer={r['is_ceremony']} {r['why']}")
                json.dump(out, open(out_path, "w"), indent=1, ensure_ascii=False)  # incremental
    json.dump(out, open(out_path, "w"), indent=1, ensure_ascii=False)
    print(f"\n→ {out_path}  ({len(out)} clips de borda classificados)")
    cm.mark_done(".", "scene_judge", __file__, {})


if __name__ == "__main__":
    main()
