#!/usr/bin/env python3
import collections
"""Offsets de sync -> timeline multicam (placements.json).

Lógica validada (Paula): âncora por confiança alta + preenche gaps por CONTINUIDADE
(prev_end), forward então backward; cam sem âncora = sequencial. Resolve run-and-gun.

MOLDE (qualquer casamento): lê a saída do sync_ingest (placements_<x>_<momento>.json) +
decupagem -> N cams. Back-compat: sem args, lê o tsv da Paula (/tmp/pf/synctable2.tsv).
Uso molde: placements.py placements_nathalia_cerimonia.json decupagem_nathalia.json
"""
import re, json, sys, os

FPS = 30
ANCHOR_RANK = 7   # confiança >= isto = posição absoluta confiável
WARN_RANK = 7     # < isto = orange


def num_of(name):
    m = re.search(r'(\d{3,5})', os.path.splitext(name.split("_")[-1] if "_" in name else name)[0])
    return int(m.group(1)) if m else 0


def place(rows, cams):
    """âncora+continuidade por cam. rows: [{name,cam,num,off,rank,dur}] -> adiciona 'pos'."""
    placed = []
    for cam in cams:
        clips = sorted([r for r in rows if r["cam"] == cam], key=lambda r: r["num"])
        for c in clips:
            c["pos"] = c["off"] if c["rank"] >= ANCHOR_RANK else None
        for i, c in enumerate(clips):
            if c["pos"] is None and i > 0 and clips[i-1]["pos"] is not None:
                c["pos"] = clips[i-1]["pos"] + clips[i-1]["dur"]
        for i in range(len(clips)-2, -1, -1):
            if clips[i]["pos"] is None and clips[i+1]["pos"] is not None:
                clips[i]["pos"] = clips[i+1]["pos"] - clips[i]["dur"]
        run = 0.0
        for c in clips:
            if c["pos"] is None:
                c["pos"] = run
            run = c["pos"] + c["dur"]
        placed += clips
    return placed


def emit(placed, out_path):
    minpos = min(c["pos"] for c in placed)
    shift = -minpos if minpos < 0 else 0.0
    out = [{
        "name": c["name"], "cam": c["cam"],
        "startFrame": round((c["pos"] + shift) * FPS),
        "durationFrames": round(c["dur"] * FPS),
        "confidence": round(c["rank"]/10.0, 2),
        "orange": c["rank"] < WARN_RANK,
    } for c in placed]
    master_start = round(shift * FPS)
    json.dump({"clips": out, "masterStartFrame": master_start}, open(out_path, "w"), indent=0)
    print(f"clips={len(out)} master@{master_start}f orange={sum(1 for c in out if c['orange'])}")
    for c in sorted(out, key=lambda x: (x['cam'], x['startFrame'])):
        print(f"{c['cam']} {c['name'][-20:]:20} @{c['startFrame']:6} dur{c['durationFrames']:6} "
              f"conf{c['confidence']} {'ORANGE' if c['orange'] else ''}")


def from_paula_tsv():
    rows = []
    for line in open("/tmp/pf/synctable2.tsv"):
        p = line.rstrip("\n").split("\t")
        if len(p) < 4:
            continue
        name, off, rk, dur = p[0], float(p[1]), int(float(p[2])), float(p[3])
        cam = "cam1" if name.startswith("cam1") else "cam2" if name.startswith("cam2") else "cam3"
        rows.append({"name": name, "cam": cam, "num": num_of(name),
                     "off": off, "rank": rk, "dur": dur})
    cams = ("cam1", "cam2", "cam3")
    return rows, cams, "/tmp/pf/placements.json"


def from_sync(sync_json, decup_json):
    import sync_ingest as si
    P = json.load(open(sync_json))
    decup = json.load(open(decup_json))
    base = decup["base"]
    # dur + creation_time por clip via ffprobe (read-only); cam vem do sync_ingest.
    # find_videos RECURSIVO (não join flat) — arquivos vivem em subpasta (carao em 101PRETA/); path
    # flat não achava -> ffdur=0 -> dur=0 -> continuidade colapsava clip sem sync em cima da âncora
    # (0173 caía em cima do 0172). Mapa nome-do-arquivo -> path real por cam.
    # "pasta" pode ser "|"-joined (merge_same_camera_folders, câmera com 2+ pastas/cartões).
    _pathcache = {}
    def real_src(cam, fname):
        ci = decup["cameras"].get(cam)
        if not ci:
            return None
        if cam not in _pathcache:
            _pathcache[cam] = {os.path.basename(v): v for v in si.find_videos_multi(base, ci["pasta"])}
        return _pathcache[cam].get(fname)
    # duração da ESPINHA: um clip NÃO pode sincar além do fim dela (off > spine_dur = match falso num
    # REPEAT da música da festa — Nathália: off 11500s numa espinha de 116min=6960s). Esses viram
    # âncora bogus e esticam o escopo pra 3h. Rejeita: via=None (não confirmado) → laranja/cai no heal.
    spine_dur = (decup.get("audio_cerimonia", {}).get("dur_min") or 0) * 60
    rows = []
    n_bogus = 0
    for fname, c in P["clips"].items():
        cam = c["cam"]
        src = real_src(cam, fname)
        dur = si.ffdur(src) if src and os.path.exists(src) else 0.0
        off = c.get("offset_s") or 0.0
        via = c.get("via")
        if spine_dur and off > spine_dur:  # além do fim da espinha = impossível p/ clip de cerimônia
            via = None; n_bogus += 1
        rows.append({"name": f"{cam}_{os.path.splitext(fname)[0]}", "cam": cam,
                     "num": num_of(fname), "off": off,
                     "rank": c.get("rank") or 0, "via": via,
                     "ratio": c.get("ratio"), "dur": dur,
                     "ct": ffctime(src) if src and os.path.exists(src) else None})
    if n_bogus:
        print(f"  {n_bogus} clips com offset ALÉM da espinha ({spine_dur/60:.0f}min) rejeitados (repeat falso)")
    cams = tuple(sorted(decup["cameras"], key=lambda s: (len(s), s)))
    import wedslug
    name = wedslug.from_path(decup_json) or wedslug.slug(decup.get("casamento", "wedding"))
    ws = os.path.join("weddings", name)
    os.makedirs(ws, exist_ok=True)
    # CORROBORAÇÃO CRUZADA DE TEMPO (João): plano contínuo CONFIRMADO (via=fp, rank alto) de
    # QUALQUER cam define uma janela de tempo (spine-relative) que é cerimônia DE VERDADE — clip
    # de OUTRA cam cujo offset cai dentro dessa janela é cerimônia, mesmo que o judge (frame único)
    # tenha errado. Lição (Nathália): C8193/94/95 (off 2507/5946/5362s) e C8246/o beijo (off 6673s)
    # caem exatamente dentro das janelas dos planos contínuos confirmados do cam2 (C0056
    # [876-5489s] rank230 + C0057 [5498-6838s] rank62) — sinal mais forte que 1 frame de VLM.
    envelopes = [(r["off"], r["off"] + r["dur"]) for r in rows
                 if r["via"] == "fp" and r["rank"] >= CONF_MIN and r["dur"] > 0]
    def time_corroborated(r):
        # CORREÇÃO (João, Nathália): exigir confiança também no offset do PRÓPRIO clip, não só na
        # âncora — C8195 (rank4, via=spec, ratio~2.3, praticamente ruído) caiu por COINCIDÊNCIA
        # dentro da janela larga (~100min) e foi incluído errado (é making-of, confirmado por
        # João, não cerimônia). Janela larga + offset ruim = colisão por acaso é esperável, não
        # corrobora nada. Só conta se o PRÓPRIO clip também tem fp confiável.
        if r["via"] != "fp" or r["rank"] < CONF_MIN:
            return False
        return any(lo <= r["off"] <= hi for lo, hi in envelopes)
    # JUDGE decide O QUE é cerimônia (ANTES de posicionar — não polui ordem). Não-cerimônia
    # SAI, mesmo que tenha "sincado" (sync falso de clip não-cerimônia). Ver scene_judge.json.
    jf = os.path.join(ws, "scene_judge.json")
    if os.path.exists(jf):
        judge = json.load(open(jf))
        # DO-ZERO (João): achar as BORDAS da cerimônia por sinal AUTOMÁTICO, sem faixa manual da
        # decupagem (aquilo era hint hardcoded, fere a autonomia). NÚCLEO = clips com sinal FORTE
        # de cerimônia: convidados visíveis (VLM) OU corroboração de tempo (envelope do master
        # contínuo). O núcleo protege o MEIO (close-up mislabeled — ex. beijo real no altar — NÃO
        # sai). Judge só decide as BORDAS (antes do núcleo = chegada/making-of; depois = ensaio
        # pós-cerimônia). Ground-truth Nathália (só p/ validar, não hardcodar): último convidado
        # visível = C8231, ensaio-final começa C8233 — o método tem que achar isso sozinho.
        core = collections.defaultdict(list)
        for r in rows:
            if judge.get(r["name"], {}).get("guests") is True or time_corroborated(r):
                core[r["cam"]].append(r["num"])
        rng = {cam: (min(ns), max(ns)) for cam, ns in core.items() if ns}
        # categorias INEQUÍVOCAS em frame único (local/atividade totalmente diferente) — excluem
        # sempre (mesmo no meio), salvo corroboração de tempo (lição: C8198-209 eram ARRIVAL —
        # carro chegando — intercalados na numeração do cam1 dentro do range).
        UNAMBIGUOUS_NONCER = {"ARRIVAL", "MAKINGOF_BRIDE", "MAKINGOF_GROOM", "DECORATION",
                               "PARTY", "ENTRANCE"}
        def is_cer(r):
            if time_corroborated(r):
                return True  # master confirmado prova o horário — vence VLM de frame único
            v = judge.get(r["name"])
            scene = (v or {}).get("scene")
            if scene in UNAMBIGUOUS_NONCER:
                return False
            if r["cam"] in rng:
                lo, hi = rng[r["cam"]]
                if lo <= r["num"] <= hi:
                    return True  # MEIO do núcleo: cerimônia, ponto (protege close-up mislabeled)
                # BORDA (antes/depois do núcleo):
                # - judge EXPLÍCITO False → sai (making-of/ensaio/festa)
                # - NÃO classificado → MANTÉM (recall). Lição Nathália/Gleydson v0.4: scene_judge
                #   só rotula ~bordas densas; C8196-8206 (entradas/padrinhos na decupagem) ficavam
                #   NOT_JUDGED e caíam com `bool(v and is_ceremony)` → False, e o cascade leading
                #   engolia o head inteiro da cam1 (timeline começava em C8209). Unjudged ≠ não-cerimônia.
                if not v:
                    return True
                return bool(v.get("is_ceremony"))
            return v.get("is_ceremony", True) if v else True  # cam sem núcleo: mantém
        excl = set(id(r) for r in rows if not is_cer(r))
        # SUAVIZAÇÃO DE SEQUÊNCIA nas bordas (João: judge vê "o todo", não frame isolado): uma vez
        # que o ensaio-final começa (run contíguo de não-cerimônia DEPOIS do núcleo), tudo dali pra
        # frente sai — um blip solto de "CEREMONY" no meio do ensaio é erro de VLM, não resgata
        # (lição Nathália: C8242 no meio do ensaio 8233+ ficou CEREMONY por ruído).
        # HEAD (antes do núcleo): NÃO cascade em unjudged. Só tira is_ceremony=False explícito.
        # Cascade reverso engolia entradas/padrinhos quando 1-2 frames "ENSAIO/couple alone"
        # (close sem convidados no altar) marcavam False (C8207-08) e puxavam C8196-8206 fora.
        for cam in {r["cam"] for r in rows}:
            if cam not in rng:
                continue
            lo, hi = rng[cam]
            cam_rows = sorted((r for r in rows if r["cam"] == cam), key=lambda r: r["num"])
            trailing = [r for r in cam_rows if r["num"] > hi]
            cut = False
            for r in trailing:  # cronológico: do 1º não-cerimônia em diante, exclui
                if id(r) in excl:
                    cut = True
                if cut:
                    excl.add(id(r))
            # leading: só explícitos False (já em excl); NÃO propaga pra trás em unjudged
            # (is_cer já manteve unjudged; cascade reverso destruía o head)
        rows_excl = [r for r in rows if id(r) in excl]
        rows = [r for r in rows if id(r) not in excl]
        excl = rows_excl
        if excl:
            json.dump([{"name": r["name"], "cam": r["cam"],
                        "scene": judge.get(r["name"], {}).get("scene", "?"),
                        "why": judge.get(r["name"], {}).get("why", "")} for r in excl],
                      open(os.path.join(ws, "placements_excluidos.json"), "w"), indent=1, ensure_ascii=False)
            print(f"judge excluiu {len(excl)} não-cerimônia (antes de posicionar)")
    return rows, cams, os.path.join(ws, "placements.json")


def ffctime(path):
    """creation_time -> epoch s (relógio interno da câmera; consistente por cam)."""
    import subprocess
    from datetime import datetime
    r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                        "format_tags=creation_time", "-of", "csv=p=0", path],
                       capture_output=True, text=True)
    s = r.stdout.strip()
    if not s:
        return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
    except ValueError:
        return None


CONF_MIN = 40  # rank audalign fp >= isto = âncora confiável (espelha sync_ingest PROFILES)


def load_flux(name):
    """cam_sync_progressive output: {C8215:{spine_off,conf,via}} -> {C8215: (off,conf,via)}. Opcional."""
    import glob as _g
    fs = _g.glob(os.path.join("weddings", name, "*_flux.json"))
    out = {}
    for f in fs:
        for k, v in json.load(open(f)).items():
            out[k] = (v["spine_off"], v.get("conf", 0.5), v.get("via", ""))
    return out


def flux_trust(conf, via):
    """Confiar num placement flux como ÂNCORA? 'full' (busca global forte) exige conf>=4.
    'propNNN' = re-busca numa janela de NNN s entre 2 confirmados — janela PEQUENA é decisiva por
    física (ordem + bound de vizinhos), mesmo com conf baixa (o motor já passou pelo cascade). Só a
    prop de janela GRANDE (>400s) é incerta -> orange. Lição Nathália: voz-como-ref colocou os
    laranjas C8221-29 via prop de janela pequena, conf ~3 — confiáveis, não devem ficar laranja."""
    if via.startswith("full"):
        return conf >= 4.0
    m = re.search(r'prop(\d+)', via)
    if m:
        span = int(m.group(1))
        return span <= 400  # janela pequena = decisivo; grande = orange
    return conf >= 4.0


def place_flux(cl, fmap):
    """cam com âncoras FLUX (acústicas): usa o offset flux; orange empacotado EM ORDEM ANCORADO —
    antes da 1ª âncora = pra TRÁS (termina nela); entre âncoras = distribui; depois da última = frente.
    (creation_time do cam1 é lixo.) Sem overlap, monotônico.
    Lição (Nathália): âncoras flux NÃO são todas igualmente confiáveis — uma âncora fraca pode
    cair ANTES do fim da âncora forte anterior (fingerprint ruim). Resolve por confiança: a
    âncora mais fraca de cada par conflitante é REBAIXADA pra orange e reposicionada por
    continuidade, em vez de travar uma sobreposição física impossível."""
    # confiança no placement flux = flux_trust (full forte OU prop de janela pequena decisiva);
    # NÃO só conf>=4 (lição: prop de janela pequena tem conf baixa mas é decisiva — a voz-como-ref
    # colocou C8221-29 assim). E NÃO tratar prop de janela grande como âncora (C8193-95 falso-pos).
    cl.sort(key=lambda r: r["num"])
    def key(r): return next((k for k in fmap if k in r["name"] and flux_trust(fmap[k][1], fmap[k][2])), None)
    for r in cl:
        k = key(r)
        if k:
            off, conf, _via = fmap[k]
            r["pos"] = off; r["confirmed"] = True; r["orange"] = False; r["flux_conf"] = conf
        elif r.get("via") == "fp" and r["rank"] >= CONF_MIN:
            # lição (Nathália): C8196 tinha fingerprint audalign BOM (rank48, via=fp) mas o flux
            # map (subconjunto, 23/53 clips) não cobria esse clip específico — place_flux jogava
            # fora o sync bom e empacotava por continuidade, virando orange sem necessidade. Usar
            # a âncora audalign original quando o flux não tem entrada pra esse clip.
            r["pos"] = r["off"]; r["confirmed"] = True; r["orange"] = False
            r["flux_conf"] = min(1.0, r["rank"] / 100)
        else:
            r["pos"] = None; r["confirmed"] = False; r["orange"] = True; r["flux_conf"] = 0.0
    # resolve âncoras mutuamente conflitantes (ordem de num, mas pos não-monotônico) —
    # repete até estabilizar pois rebaixar uma pode revelar novo conflito com a próxima.
    changed = True
    while changed:
        changed = False
        anchors = [r for r in cl if r["confirmed"]]
        for a, b in zip(anchors, anchors[1:]):
            if b["pos"] < a["pos"] + a["dur"]:
                loser = a if a["flux_conf"] <= b["flux_conf"] else b
                loser["confirmed"] = False; loser["orange"] = True; loser["pos"] = None
                changed = True
                break
    idx_anchor = [i for i, r in enumerate(cl) if r["confirmed"]]
    if not idx_anchor:
        run = 0.0
        for r in cl:
            r["pos"] = run; run += r["dur"]
        return cl
    # antes da 1ª âncora: backward-fill (último orange termina onde a âncora começa)
    first = idx_anchor[0]
    end = cl[first]["pos"]
    for i in range(first - 1, -1, -1):
        cl[i]["pos"] = end - cl[i]["dur"]; end = cl[i]["pos"]
    # depois da última âncora: forward-fill
    last = idx_anchor[-1]
    run = cl[last]["pos"] + cl[last]["dur"]
    for i in range(last + 1, len(cl)):
        cl[i]["pos"] = run; run += cl[i]["dur"]
    # entre âncoras: forward-fill a partir da âncora anterior — mas CAPADO pelo início da
    # próxima âncora (lição Nathália: sem cap, soma de durations dos orange no meio podia
    # ESTOURAR a âncora seguinte — C8225 sobrepôs C8226 em 1697 frames assim). Se não cabe no
    # vão real, comprime proporcionalmente (mantém ordem, garante sem overlap nos dois lados).
    for a, b in zip(idx_anchor, idx_anchor[1:]):
        gap_start = cl[a]["pos"] + cl[a]["dur"]
        gap_end = cl[b]["pos"]
        mid = list(range(a + 1, b))
        sum_dur = sum(cl[i]["dur"] for i in mid)
        avail = gap_end - gap_start
        scale = min(1.0, avail / sum_dur) if sum_dur > 0 and avail > 0 else (1.0 if sum_dur <= 0 else 0.0)
        run = gap_start
        for i in mid:
            cl[i]["pos"] = run
            d = cl[i]["dur"] * scale
            if scale < 1.0:
                # não cabe no vão real -> dur exibida tb encolhe (não só o espaçamento), senão
                # a duração CHEIA de cada clip ainda estouraria o próximo (causa real do overlap
                # de 1697 frames: só comprimir a posição sem encolher a duração não resolve nada).
                cl[i]["dur_clamped"] = d
            run += d
    enforce_order(cl)
    return cl


def place_meta(rows, cams, fmaps=None):
    fmaps = fmaps or {}
    """Posiciona por creation_time ancorado: 1 âncora acústica boa por cam trava a cam na
    espinha; demais clips = âncora.off + (ct - âncora.ct). Resolve run-and-gun (gaps vêm do
    metadata). orange = sem confirmação acústica própria (posicionado, usuário confere/ajusta)."""
    for r in rows:
        r["confirmed"] = (r.get("via") == "fp" and r["rank"] >= CONF_MIN)
    # rejeita ÂNCORA ISOLADA: uma fp isolada depois de um buraco de nums (4008 pula 18 do núcleo
    # 3979-3990) é clip de FESTA que fp-matched por acaso — não é âncora real. Senão vira bracket
    # falso pra cauda: a SAÍDA (3991) interpolava entre 3990 e 4008 (festa) e virava VERDE indevido.
    # Mesma lógica do scene_judge (núcleo = run consecutivo de âncoras).
    for cam in {r["cam"] for r in rows}:
        anc = sorted([r for r in rows if r["cam"] == cam and r["confirmed"]], key=lambda r: r["num"])
        core_last = anc[0]["num"] if anc else None
        for r in anc[1:]:
            if r["num"] - core_last > 6:
                r["confirmed"] = False  # isolada depois do buraco = falsa
            else:
                core_last = r["num"]
    placed = []
    for cam in cams:
        cl = sorted([r for r in rows if r["cam"] == cam], key=lambda r: (r["num"]))
        fmap = fmaps.get(cam)
        if fmap and any(any(k in r["name"] for k in fmap) for r in cl):
            placed += place_flux(cl, fmap)  # âncoras acústicas flux (cam1 run-and-gun)
            continue
        anchors = [r for r in cl if r["confirmed"] and r.get("ct") is not None]
        anchors.sort(key=lambda x: x["ct"])
        # GATE FÍSICO (relógio da câmera): fp "confirmed" (rank alto) pode ter casado no CHUNK
        # ERRADO da espinha (música se repete entre chunks) — offset errado por ~a duração de 1
        # chunk, MESMO com rank alto. rank alto ≠ offset certo. off-ct é ~constante pra uma câmera
        # (mesmo relógio interno, drift desprezível); outlier nesse valor É o chunk-off. Mediana
        # robusta a 1-2 anomalias entre >=3 âncoras. TOL=45s é física (folga generosa de drift de
        # relógio), não calibrada no vídeo. Demovido cai na interpolação/bracket entre vizinhos
        # bons (mesmo código de baixo) em vez de travar posição fisicamente impossível.
        if len(anchors) >= 3:
            b_sorted = sorted(a["off"] - a["ct"] for a in anchors)
            b_med = b_sorted[len(b_sorted)//2]
            TOL = 45.0
            for a in anchors:
                if abs((a["off"] - a["ct"]) - b_med) > TOL:
                    a["confirmed"] = False
                    print(f"  ⚠️ {a['name']}: fp rank{a['rank']} descartado por relógio "
                          f"(off-ct diverge {abs((a['off']-a['ct'])-b_med):.0f}s da mediana da cam — chunk-off)")
            anchors = [a for a in anchors if a["confirmed"]]
        for r in cl:
            if r["confirmed"]:
                # CONFIRMADO (fp forte): usa o offset audalign DIRETO como posição — VERDE. Não
                # depende de creation_time (lição do-zero Paula: cams PRE_/DSC_ sem creation_time
                # caíam no else e viravam laranja mesmo com fp rank 555). O sync já deu a posição.
                r["pos"] = r["off"]; r["orange"] = False
            elif anchors and r.get("ct") is not None:
                t = r["ct"]
                # INTERPOLAÇÃO LINEAR entre as 2 âncoras que cercam (corrige DRIFT de relógio).
                lo = [a for a in anchors if a["ct"] <= t]
                hi = [a for a in anchors if a["ct"] >= t]
                if lo and hi and lo[-1] is not hi[0]:
                    a, b = lo[-1], hi[0]
                    r["pos"] = a["off"] + (t - a["ct"]) * (b["off"] - a["off"]) / (b["ct"] - a["ct"])
                    # BRACKETED entre 2 âncoras firmes (uma antes, uma depois): a posição está presa
                    # dos DOIS lados e o creation_time só interpola drift DENTRO do intervalo — o erro
                    # não escapa do bracket. Confiável mesmo sem sync próprio → VERDE. (0173 entre
                    # 0172✅ e 0174✅.) Só interpolação real (âncoras distintas cercando), não
                    # extrapolação. João aprovou. Generaliza p/ qualquer cam que pare entre takes.
                    r["orange"] = False
                    r["bracketed"] = True
                else:  # fora do span: extrapola da âncora da ponta (slope 1) — 1 lado só = incerto
                    a = (lo or hi)[-1 if lo else 0]
                    r["pos"] = a["off"] + (t - a["ct"])
                    r["orange"] = True
            else:
                r["pos"] = None  # sem âncora/metadata: continuidade abaixo
                r["orange"] = True
        # fallback continuidade p/ quem ficou sem pos (sem ct ou cam sem âncora)
        run = cl[0]["pos"] if cl and cl[0]["pos"] is not None else 0.0
        for r in cl:
            if r["pos"] is None:
                r["pos"] = run
            run = r["pos"] + r["dur"]
        enforce_order(cl)
        # VERDE = só sync CONFIÁVEL: fp-confirmado (âncora própria) OU bracketed (entre 2 âncoras,
        # posição travada dos DOIS lados). Adjacência de 1 LADO (encosta numa âncora por
        # continuidade) NÃO é verde — a posição só está presa de um lado, o outro é palpite. Ex: a
        # SAÍDA (aplauso sem landmark) fica logo após a última âncora, mas pode ter gap de câmera →
        # posição incerta → LARANJA (usuário confere). João: "se não é confiável, por que sem
        # laranja?". Continuidade = orange, ponto (a menos que bracketed nos 2 lados).
        placed += cl
    return placed


def enforce_order(cl):
    """Restrição FÍSICA: clips de uma câmera são sequenciais e NÃO se sobrepõem. Âncoras
    (confirmed) ficam fixas; orange clampa pra frente pra não invadir o anterior. Em ordem de nº."""
    cl.sort(key=lambda r: r["num"])
    prev_end = None
    for r in cl:
        if not r.get("confirmed") and prev_end is not None and r["pos"] < prev_end:
            r["pos"] = prev_end
        prev_end = r["pos"] + r.get("dur_clamped", r["dur"])


def emit2(placed, out_path, judge=None):
    """emit usando pos + orange. judge (scene_judge.json) FILTRA não-cerimônia: clip
    NÃO-sincado + NÃO-cerimônia = EXCLUI (vai p/ placements_excluidos.json taggeado).
    Sincado sempre fica (lock acústico manda sobre frame único)."""
    judge = judge or {}
    def scene(c): return judge.get(c["name"], {})
    def keep(c):
        j = scene(c)
        if not j:
            return True
        if not c["orange"]:           # sincado -> mantém (acústico > frame)
            return True
        return j.get("is_ceremony", True)  # orange: só cerimônia fica
    keepers = [c for c in placed if keep(c)]
    excl = [c for c in placed if not keep(c)]
    minpos = min(c["pos"] for c in keepers)
    shift = -minpos if minpos < 0 else 0.0
    out = [{
        "name": c["name"], "cam": c["cam"],
        "startFrame": round((c["pos"] + shift) * FPS),
        # dur_clamped: vão real menor que a duração do arquivo (clip espremido entre 2 âncoras
        # confirmadas) -> usa a duração comprimida, senão a CHEIA ainda estouraria a próxima
        # âncora mesmo com a posição certa (lição Nathália, overlap de 1697 frames).
        "durationFrames": round(c.get("dur_clamped", c["dur"]) * FPS),
        # confidence alimenta set_sync_confidence NATIVO (app pinta laranja < 0.7). Tem que refletir
        # "sincou de forma confiável?", NÃO rank cru — lição (Nathália): C8196 é âncora CONFIRMADA
        # (rank48 fp) mas rank/100=0.48 < 0.7 pintava laranja um clip perfeitamente sincado. Agora:
        # confirmado/não-orange = 0.9 (verde), orange = baixo proporcional ao rank (fica laranja).
        "confidence": 0.9 if not c["orange"] else round(min(c["rank"], 65) / 100.0, 2),
        "orange": c["orange"], "scene": scene(c).get("scene", "CEREMONY"),
        # _anchor = sincado de VERDADE (fp/spec forte). Bracketed/adjacência ficam verdes no display
        # mas NÃO são âncora: definem só a própria confiança, não o ESCOPO. O heal usa _anchor pro
        # span/filtro (senão greenjar a cauda estica o span e inclui a festa — desacopla escopo de
        # confiança).
        "_anchor": bool(c.get("confirmed")),
    } for c in keepers]

    # HEAL cerimônia-only (span dos greens + monotonicidade nome↔timeline). Pega o que "sincado
    # sempre fica" deixa passar: clip de festa/pré que travou num REPEAT da música (rank alto, GREEN,
    # mas posição contradiz a ordem-de-nome). Regra do João: dentro de uma cam, startFrame que cai
    # ANTES do máximo-até-agora (por ordem de nome) = não-cerimônia. Decupagem generosa + heal
    # preciso. Ver [[project-multicam-build-cerimonia-only]].
    # ESCOPO vem das âncoras REAIS de sync (_anchor = fp/spec forte), NÃO do verde de display.
    # Bracketed/adjacência são verdes mas não âncora — greenjá-los NÃO pode esticar o span (senão a
    # cauda de festa que ficou verde por bracket puxava o span pra 127min e incluía o pós). Fallback:
    # se nada tem _anchor (cam toda por continuidade), usa os greens.
    anchors = [c for c in out if c.get("_anchor")] or [c for c in out if not c["orange"]]
    # scene_judge (VLM) classifica as BORDAS: SAIDA=cerimônia (fica), PARTY/ENSAIO/DECORATION=fora.
    # Núcleo (não classificado) = cerimônia por construção. Máquina de estado por cam decide as
    # bordas (mantém a SAÍDA que o sync não pega — aplauso sem landmark).
    _sjp = os.path.join(os.path.dirname(out_path), "scene_judge.json")
    scene_applied = os.path.exists(_sjp)
    _sj = json.load(open(_sjp)) if scene_applied else {}
    def sj_cer(name):
        v = _sj.get(name)
        return v.get("is_ceremony") if v else None  # None = não classificado (núcleo)
    dropped = []
    if anchors:
        glo = min(c["startFrame"] for c in anchors)
        ghi = max(c["startFrame"] for c in anchors)
        # Head generoso: processional/padrinhos costuma ser 5–15 min antes da 1ª âncora fp
        # forte (tripé/handheld que só "trava" no miolo). Tail menor (pós vira festa).
        # v0.4 (geral BR): 2min no head era curto demais → head laranja caía como pré.
        M_HEAD = 15 * 60 * FPS
        M_TAIL = 5 * 60 * FPS
        lo, hi = glo - M_HEAD, ghi + M_TAIL
        # CAM sem NENHUMA âncora REAL de cerimônia NÃO é ângulo de multicam — é cobertura solta
        # (festa/entrada, ex. fabricio) ou cam duplicada. Sem âncora seus clips ficam empilhados no
        # master_start (sem sync) e o áudio linkado órfão vira N tracks "Master" fantasma. João:
        # "cam com 1 clip / de festa já sairia daí". Vira b-roll (placements_excluidos), não ângulo.
        anchored = {c["cam"] for c in anchors}
        kept = []
        for cam in sorted({c["cam"] for c in out}):
            if cam not in anchored:
                for c in [x for x in out if x["cam"] == cam]:
                    dropped.append((c["name"], "cam-sem-ancora"))
                continue
            cs = sorted([c for c in out if c["cam"] == cam], key=lambda c: num_of(c["name"]))
            mx = -1
            seen_cer = False  # já entrou na cerimônia?
            ended = False     # já começou a festa/ensaio depois da cerimônia?
            for c in cs:
                sf = c["startFrame"]
                if sf < mx - 1:
                    dropped.append((c["name"], "fora-de-ordem")); continue
                if scene_applied:
                    cer = sj_cer(c["name"])
                    if ended:
                        dropped.append((c["name"], "pos-cerimonia")); continue
                    if not seen_cer:
                        if cer is False:  # borda de ENTRADA (making-of/decor/ensaio) antes do núcleo
                            dropped.append((c["name"], "pre-cerimonia")); continue
                        seen_cer = True   # cer True (ex SAIDA no início? raro) ou None (núcleo) = entrou
                    elif cer is False:    # 1ª não-cerimônia DEPOIS do núcleo = acabou (festa) → corta o resto
                        ended = True
                        dropped.append((c["name"], "pos-cerimonia")); continue
                else:  # sem scene: span-por-âncora conservador
                    if sf < lo:
                        dropped.append((c["name"], "pre-cerimonia")); continue
                    if sf > hi:
                        dropped.append((c["name"], "pos-cerimonia")); continue
                mx = max(mx, sf)
                kept.append(c)
        out = kept
        if dropped:
            noanchor = sorted({n.split('_')[0]+'_'+n.split('_')[1] for n, r in dropped if r == 'cam-sem-ancora'}) \
                if any(r == 'cam-sem-ancora' for _, r in dropped) else []
            print(f"HEAL cerimônia-only: -{len(dropped)} clips "
                  f"(pré={sum(1 for _,r in dropped if r=='pre-cerimonia')} "
                  f"pós={sum(1 for _,r in dropped if r=='pos-cerimonia')} "
                  f"fora-de-ordem={[n for n,r in dropped if r=='fora-de-ordem']} "
                  f"cam-sem-ancora={noanchor})")

    master_start = round(shift * FPS)
    for c in out:
        c.pop("_anchor", None)  # campo interno de escopo — não vai pro placements final
    json.dump({"clips": out, "masterStartFrame": master_start}, open(out_path, "w"), indent=0)
    # HEAL "dropped" (cam-sem-ancora/fora-de-ordem/pré/pós-cerimônia) só era IMPRESSO, nunca salvo —
    # cam inteira sumindo (ex: cam4 run-and-gun sem nenhum rank>=40 nem na espinha nem na alt) ficava
    # invisível pro usuário (só investigando log dava pra achar). Junta no MESMO excluidos.json —
    # visibilidade real de tudo que caiu, com o motivo (João: "áudio não pode ficar desyncado" —
    # melhor EXCLUIR claro do que incluir posição incerta; mas tem que ser visível, não silencioso).
    eo = ([{"name": c["name"], "cam": c["cam"], "scene": scene(c).get("scene", "?"),
            "why": scene(c).get("why", "")} for c in excl] if excl else [])
    eo += [{"name": n, "cam": n.split("_")[0] + "_" + n.split("_")[1] if "_" in n else "?",
            "scene": "?", "why": reason} for n, reason in dropped]
    if eo:
        json.dump(eo, open(os.path.join(os.path.dirname(out_path), "placements_excluidos.json"), "w"),
                  indent=1, ensure_ascii=False)
        print(f"placements_excluidos.json: {len(eo)} total ({len(dropped)} do heal + {len(excl) if excl else 0} do scene_judge)")
    oc = sum(1 for c in out if c["orange"])
    print(f"clips={len(out)} master@{master_start}f orange(usuário confere)={oc}")
    for c in sorted(out, key=lambda x: (x['cam'], x['startFrame'])):
        print(f"{c['cam']} {c['name'][-20:]:20} @{c['startFrame']:6} dur{c['durationFrames']:6} "
              f"conf{c['confidence']} {'ORANGE' if c['orange'] else 'ok'}")


if __name__ == "__main__":
    if len(sys.argv) >= 3:
        rows, cams, out = from_sync(sys.argv[1], sys.argv[2])
        import wedslug
        name = wedslug.from_path(sys.argv[2]) or wedslug.slug(json.load(open(sys.argv[2])).get("casamento", "w"))
        flux = load_flux(name)
        fmaps = {cam: flux for cam in cams} if flux else {}
        emit2(place_meta(rows, cams, fmaps), out)  # judge já filtrou em from_sync
    else:
        rows, cams, out = from_paula_tsv()
        emit(place(rows, cams), out)
