#!/usr/bin/env python3
"""Biblioteca compartilhada das VERSÕES do editor agêntico. Cada versão = lista de cortes
(f0,f1,angle) sobre a grade de fronteiras do M1. Camadas modulares e componíveis transformam a
lista; métricas automáticas avaliam; apply() empurra pro app. Ver build_versions.py / apply_version.py.
"""
import json, os, glob, collections, bisect
import numpy as np

TL_FPS = 30.0


def footage_key(name, cam=None):
    """Chave canônica em ``media_paths.json`` = stem do arquivo no disco.

    Contrato do pipeline (placements.py + Swift ``PlacedClip.basename``), **sem overfit
    de pasta/arquivo**:

      name  == f"{cam}_{fileStem}"     # cam pode ter ``_`` e hífen: cam1_marcelo, cam2_joao-silva
      key   == fileStem                # C8196, PRE_0172cerimonia, Wedding_Take_01, …

    - Com ``cam``: strip exato do prefixo (zero ambiguidade — use SEMPRE nos loaders).
    - Sem ``cam``: heurística só p/ call sites legados (não preferir).

    NUNCA ``[:-4]``/``dropLast(4)``: em names sem ``.MP4`` isso vira ``marcelo_C`` e colapsa
    N clips numa key — cut monótono (Nathália).
    """
    if not name:
        return ""
    import re
    stem = os.path.splitext(os.path.basename(str(name)))[0]
    if cam:
        prefix = f"{cam}_"
        if stem.startswith(prefix):
            return stem[len(prefix):]
        raw = str(name)
        if raw.startswith(prefix):
            return os.path.splitext(raw[len(prefix):])[0]
    # Heurística sem cam: camN_slug_FILESTEM (slug = 1 segmento, pode ter hífen)
    m = re.match(r"^(cam\d+)_([^_]+)_(.+)$", stem, re.I)
    if m:
        g2, g3 = m.group(2), m.group(3)
        # cam1_PRE_0172cerimonia → PRE é token do filename, não pasta
        if g2.isupper() and len(g2) <= 6 and (g3[:1].isdigit() or g3[:1].isupper()):
            return f"{g2}_{g3}"
        return g3
    m = re.match(r"^(cam\d+)_(.+)$", stem, re.I)
    if m:
        return m.group(2)
    return stem


def resolve_media_path(name, media_paths, cam=None):
    """Resolve path em media_paths.json a partir do name do placement (+ cam se houver)."""
    if not media_paths:
        return None
    import re
    candidates = []
    fk = footage_key(name, cam=cam)
    if fk:
        candidates.append(fk)
    stem = os.path.splitext(os.path.basename(str(name or "")))[0]
    if cam:
        prefix = f"{cam}_"
        if stem.startswith(prefix):
            candidates.append(stem[len(prefix):])
    # fallbacks (media_paths legado / wedding_paths)
    if "_" in stem:
        candidates.append(stem.rsplit("_", 1)[-1])
    m = re.search(r"C\d+\w*", stem, re.I)
    if m:
        candidates.append(m.group(0))
        candidates.append(m.group(0).upper())
    candidates.append(stem)
    seen = set()
    for k in candidates:
        if not k or k in seen:
            continue
        seen.add(k)
        p = media_paths.get(k) or media_paths.get(k.upper()) or media_paths.get(k.lower())
        if p:
            return p
    return None


def footage_key_norm(name):
    """Normaliza chave de footage p/ match índice SigLIP ↔ placements (v0.3).

    O `.search_cache` / `media_paths` às vezes truncam (`…cerim` vs `…cerimonia`) e os
    placements colam sufixo de cam (`…cerimoniacam2`). Sem normalizar, o mapa denso
    vira 0 segundos de timeline (Paula v0.2: 14776 frames, 0 labels mapeados).
    """
    import re
    s = footage_key(name) if name and ("/" in name or name.startswith("cam")) else (name or "")
    if not s:
        s = os.path.splitext(os.path.basename(name or ""))[0]
    s = s.strip().lower()
    # tira sufixo camN colado no fim do slug do placement
    s = re.sub(r"cam\d+$", "", s)
    # unifica truncamento cerim/cerimoni/cerimonia
    s = re.sub(r"cerimon\w*", "cerim", s)
    return s


def derive_sync_end(clips=None, end_fallback=None):
    """Início da zona DESSINCRONIZADA (laranja), mesma regra do unified.py.

    Clips `orange` soltos/low-conf NÃO contam; só bloco contíguo laranja ≥30s.
    Sem bloco grande → end_fallback (timeline toda utilizável p/ inserts).
    variety_cands usava min(orange.start) e zerava com orange@f0 (bug v0.2/v0.3).
    """
    if clips is None:
        clips = json.load(open("placements.json"))["clips"]
    if end_fallback is None:
        try:
            end_fallback = max(int(c["startFrame"]) + int(c["durationFrames"]) for c in clips)
        except Exception:
            end_fallback = 10**9
    oj = sorted(int(c["startFrame"]) for c in clips if c.get("orange"))
    if not oj:
        return end_fallback
    runs = [[oj[0], oj[0]]]
    for f in oj[1:]:
        if f - runs[-1][1] <= int(20 * 30):
            runs[-1][1] = f
        else:
            runs.append([f, f])
    big = [r[0] for r in runs if r[1] - r[0] >= int(30 * 30)]
    return min(big) if big else end_fallback


def match_placement(footage, clips):
    """Resolve footage do índice visual → (cam, startFrame) no placements.

    Aceita clips = lista de dicts do placements.json. Retorna (None, None) se não achar.
    Estratégia (primeira que bater):
      1. igualdade exata em footage_key(name) ou name
      2. igualdade em footage_key_norm (cerim/camN)
      3. prefixo/sufixo com min 10 chars (índice truncado vs placement longo)
      4. substring mútua name/footage (legado director_moments)
    """
    if not footage or not clips:
        return None, None
    import re
    f = os.path.splitext(os.path.basename(str(footage)))[0]
    fn = footage_key_norm(f)
    # stubs do índice (ex. "william_C", "luis_DSC_") — sem id numérico do clip
    if not re.search(r"\d{3,}", f):
        return None, None

    # pass 1–2: exact / norm
    for c in clips:
        nm = c.get("name", "") or ""
        base = footage_key(nm)
        if f == base or f == nm or f == os.path.splitext(nm)[0]:
            return c.get("cam"), int(c["startFrame"])
        bn = footage_key_norm(base) or footage_key_norm(nm)
        if fn and bn and fn == bn:
            return c.get("cam"), int(c["startFrame"])

    # pass 3: prefix (índice truncado carao…cerim ⊂ placement …cerimonia)
    best = None  # (score, cam, start)
    for c in clips:
        nm = c.get("name", "") or ""
        base = footage_key(nm)
        bn = footage_key_norm(base) or footage_key_norm(nm)
        if not bn:
            continue
        if len(fn) >= 10 and (bn.startswith(fn) or fn.startswith(bn)):
            score = min(len(fn), len(bn))
            if best is None or score > best[0]:
                best = (score, c.get("cam"), int(c["startFrame"]))
        elif len(bn) >= 10 and (f.startswith(base) or base.startswith(f) or
                                nm.endswith(f) or f.endswith(base)):
            score = min(len(f), len(base) or 1)
            if best is None or score > best[0]:
                best = (score, c.get("cam"), int(c["startFrame"]))

    if best:
        return best[1], best[2]

    # pass 4: substring frouxo (director_moments legado) — exige id numérico já checado
    for c in clips:
        nm = c.get("name", "") or ""
        if len(f) >= 10 and (f in nm or (footage_key(nm) and footage_key(nm) in f)):
            return c.get("cam"), int(c["startFrame"])
    return None, None


def _derive_cams():
    # molde: nº de cams vem do placements (qualquer casamento), não fixo em 3.
    try:
        cl = json.load(open("placements.json"))["clips"]
        cams = sorted({c["cam"] for c in cl if c.get("cam")},
                      key=lambda s: (len(s), s))
        if cams:
            return {i + 1: c for i, c in enumerate(cams)}
    except Exception:
        pass
    return {1: "cam1", 2: "cam2", 3: "cam3"}
ANGLE_CAM = _derive_cams()
CAM_ANGLE = {v: k for k, v in ANGLE_CAM.items()}
CAMS = tuple(ANGLE_CAM.values())
# PORT VideoTask: o motor roda 100% OFFLINE/file-based. A antiga `mcp()` (cliente HTTP pro servidor
# MCP do app PalmierPro, GPLv3) foi REMOVIDA — o VideoTask (sistema comercial próprio, clean-room)
# não pode depender do app GPL em runtime. Toda entrada vem de arquivo (get_timeline/coverage) e a
# saída (cutpoints em versions/UNI.json) é aplicada pela camada nativa do VideoTask, não por
# multicam_cut do app GPL. Este stub garante que nenhum caminho volte a chamar o app externo.
def mcp(name, args, _retries=3):
    raise RuntimeError(f"mcp('{name}') indisponível: motor roda offline (sem app PalmierPro GPL). "
                       "Toda I/O é file-based — ver BALDE_C_PORT_PLAN.md.")

# ---------- estado do app ----------
# PORT VideoTask: offline/file-based (sem MCP vivo). Prefere um `timeline.json` local (o bridge do
# app escreve no MESMO formato de timeline do palmier); se não houver, SINTETIZA a timeline a partir
# do `placements.json` (cam+startFrame+durationFrames → clip com angleIndex=CAM_ANGLE[cam]). Assim
# `coverage()`/`m1_grid()`/`group_id()` rodam VERBATIM (zero mudança de lógica) — a fidelidade do
# motor depende de a cobertura ser idêntica, e post-build a timeline espelha o placements 1:1.
def _timeline_from_placements():
    clips = json.load(open("placements.json"))["clips"]
    tracks = {}
    for c in clips:
        cam = c.get("cam")
        ai = CAM_ANGLE.get(cam)
        if ai is None:
            continue
        tracks.setdefault(cam, {"name": cam, "type": "video", "clips": []})["clips"].append({
            "startFrame": c["startFrame"], "durationFrames": c["durationFrames"],
            "angleIndex": ai, "angleGroupId": "group",
        })
    return {"tracks": list(tracks.values())}

def get_timeline():
    if os.path.exists("timeline.json"):
        return json.load(open("timeline.json"))
    if os.path.exists("placements.json"):
        return _timeline_from_placements()
    raise RuntimeError("get_timeline: sem timeline.json nem placements.json no cwd. Motor roda "
                       "offline — o bridge do VideoTask escreve esses arquivos antes de rodar.")

def group_id(tl=None):
    tl = tl or get_timeline()
    return next(c["angleGroupId"] for t in tl["tracks"] for c in t.get("clips", []) if c.get("angleGroupId"))

def m1_grid(tl=None):
    """Grade canônica [(f0,f1)] ordenada. Só usada p/ END=grid[-1][1] no unified.
    M1 físico (fluxo antigo) quando existe; senão deriva do GRUPO multicam (do-zero =
    corte na multicam colapsada, sem lane M1 física) via união da cobertura por ângulo."""
    tl = tl or get_timeline()
    m1 = next((t for t in tl["tracks"] if t.get("name") == "M1" and t.get("type") != "audio"
               and any(c.get("angleIndex") for c in t.get("clips", []))), None)
    if m1 is not None:
        return sorted([(c["startFrame"], c["startFrame"]+c["durationFrames"]) for c in m1["clips"]])
    spans = sorted(s for v in coverage(tl).values() for s in v)  # grupo: união dos ângulos
    if not spans:
        raise RuntimeError("m1_grid: sem M1 e sem grupo multicam na timeline (vazia?)")
    return spans

def coverage(tl=None):
    tl = tl or get_timeline()
    cov = collections.defaultdict(list)
    for t in tl["tracks"]:
        if t.get("name") == "M1" or t.get("type") == "audio": continue
        for c in t["clips"]:
            ai = c.get("angleIndex")
            if ai: cov[ai].append((c["startFrame"], c["startFrame"]+c["durationFrames"]))
    # merge ranges adjacentes/sobrepostas (clips back-to-back do mesmo cam = cobertura contínua;
    # gap <= 3 frames conta como contínuo). Sem isso, segmento cruzando 2 arquivos vira "preto" falso.
    for k in cov:
        cov[k].sort()
        merged = []
        for s, e in cov[k]:
            if merged and s <= merged[-1][1] + 3: merged[-1] = (merged[-1][0], max(merged[-1][1], e))
            else: merged.append((s, e))
        cov[k] = merged
    return cov

def covers(cov, angle, f0, f1):
    return any(s <= f0 and f1 <= e for s, e in cov.get(angle, []))

# ---------- baseline motor ----------
def motor_fn():
    seg = json.load(open("cuts.json"))["segments"]
    frames = [s["frame"] for s in seg]
    def at(f):
        i = bisect.bisect_right(frames, f) - 1
        return seg[max(0, i)]["angle"]
    return at

# ---------- TIER1 conteúdo ----------
def tier1_map():
    t1 = {}
    if os.path.exists("override_tier1.txt"):
        for a in open("override_tier1.txt").read().split():
            t, ang = a.split(":"); t1[int(round(float(t)*TL_FPS))] = int(ang)
    return t1

# ---------- jerk (shake verdadeiro) ----------
def load_jerk():
    P = json.load(open("placements.json"))["clips"]
    cams = collections.defaultdict(lambda: ([], []))
    for c in P:
        name = footage_key(c["name"], cam=c.get("cam"))
        f = f"shakecache/{name}.json"
        if not os.path.exists(f):
            continue
        sc = json.load(open(f))
        t0 = int(c["startFrame"]) / TL_FPS
        ts, js = cams[c["cam"]]
        for fr in sc["frames"]:
            ts.append(t0 + fr["t"]); js.append(fr["jerk"])
    out = {}
    for cam, (ts, js) in cams.items():
        a = np.argsort(ts); out[cam] = (np.array(ts)[a], np.array(js)[a])
    return out

def mean_jerk(jerk, cam, f0, f1):
    if cam not in jerk: return None
    ts, js = jerk[cam]; T0, T1 = f0/TL_FPS, f1/TL_FPS
    i0, i1 = int(np.searchsorted(ts, T0)), int(np.searchsorted(ts, T1))
    if i1 <= i0: return None
    return float(np.mean(js[i0:i1]))

def shake_by_cam(jerk, cov, f0, f1, cams=None):
    cams = cams or CAMS
    """jerk médio (tremor) de cada cam COBERTA no span -> {cam: jerk}. RELATIVO: o judge compara a cam
    candidata com as alternativas (outlier = tremida) sem threshold hardcoded. + most_stable (menor jerk)."""
    out = {}
    for c in cams:
        if not covers(cov, CAM_ANGLE[c], f0, f1): continue
        j = mean_jerk(jerk, c, f0, f1)
        if j is not None: out[c] = round(j, 2)
    most_stable = min(out, key=out.get) if out else None
    return out, most_stable

_CMAP = None
def content_at(f0, f1, top=3, win=45):
    """tags SigLIP por cam no span (content_map_dense.json): {cam: [keys]}. Diz O QUE cada cam mostra
    (músicos/plateia/corredor/oficiante...) pro judge decidir variedade/b-roll. RELATIVO ao conteúdo, não
    threshold. win = folga p/ pegar hit próximo."""
    global _CMAP
    if _CMAP is None:
        try: _CMAP = json.load(open("content_map_dense.json"))
        except Exception: _CMAP = []
    agg = {}
    for h in _CMAP:
        if f0 - win <= h["f"] <= f1 + win:
            d = agg.setdefault(h["cam"], {})
            d[h["key"]] = max(d.get(h["key"], 0), h["score"])
    out = {}
    for cam, d in agg.items():
        out[cam] = [k for k, _ in sorted(d.items(), key=lambda x: -x[1])[:top]]
    return out

def shake_windows(jerk, cam, f0, f1, win=15):
    """jerk por sub-janela de `win` frames -> [(wf0,wf1,jerk)]. Mostra ONDE treme dentro do take pro judge
    aparar pro PEDAÇO estável (não pegar o rabo tremido). None nas janelas sem dado."""
    out = []
    for w in range(int(f0), int(f1), win):
        we = min(w + win, int(f1))
        j = mean_jerk(jerk, cam, w, we)
        out.append((w, we, round(j, 2) if j is not None else None))
    return out

def jerk_series(jerk, cam, f0, f1):
    """(times_tl, jerk) da cam no span [f0,f1] em frames de timeline."""
    if cam not in jerk: return None, None
    ts, js = jerk[cam]; T0, T1 = f0/TL_FPS, f1/TL_FPS
    i0, i1 = int(np.searchsorted(ts, T0)), int(np.searchsorted(ts, T1))
    if i1 <= i0: return None, None
    return ts[i0:i1] * TL_FPS, js[i0:i1]

def tc(seconds):
    """segundos -> timecode HH:MM:SS:FF (30fps), igual o sistema mostra."""
    tf = int(round(seconds * TL_FPS))
    return f"{tf//108000:02d}:{(tf//1800)%60:02d}:{(tf//30)%60:02d}:{tf%30:02d}"

# ---------- facecache (closeness / emoção / luma) ----------
def load_face():
    """Por cam: dict de arrays sortados por tempo-de-timeline: ts, mff(closeness), cx, luma, joy, sad."""
    P = json.load(open("placements.json"))["clips"]
    acc = collections.defaultdict(lambda: collections.defaultdict(list))
    for c in P:
        name = footage_key(c["name"], cam=c.get("cam"))
        f = f"facecache/{name}.json"
        if not os.path.exists(f):
            continue
        fc = json.load(open(f)); t0 = int(c["startFrame"]) / TL_FPS
        for fr in fc["frames"]:
            d = acc[c["cam"]]
            d["ts"].append(t0 + fr["t"]); d["mff"].append(fr["mff"]); d["cx"].append(fr["cx"])
            d["luma"].append(fr["luma"])
            d["joy"].append(fr["joy"] if fr["joy"] is not None else 0.0)
            d["sad"].append(fr["sad"] if fr["sad"] is not None else 0.0)
            d["cy"].append(fr.get("cy", 0.5))                                  # campo novo (sharpcache)
            d["sharp"].append(fr["sharp"] if fr.get("sharp") is not None else 0.0)
    out = {}
    for cam, d in acc.items():
        order = np.argsort(d["ts"])
        out[cam] = {k: np.array(v)[order] for k, v in d.items()}
    return out

def face_window(face, cam, f0, f1):
    """slices dos arrays de face da cam no span [f0,f1] (frames de timeline). None se sem cobertura."""
    if cam not in face: return None
    d = face[cam]; T0, T1 = f0/TL_FPS, f1/TL_FPS
    i0, i1 = int(np.searchsorted(d["ts"], T0)), int(np.searchsorted(d["ts"], T1))
    if i1 <= i0: return None
    return {k: d[k][i0:i1] for k in d}

def face_sharp(face, cam, f0, f1):
    """nitidez mediana do rosto (var-Laplaciano) no span. None se sem rosto/sinal. Usar RELATIVO
    cross-cam (qual das 3 syncadas tem rosto mais nítido), não threshold absoluto."""
    w = face_window(face, cam, f0, f1)
    if not w or "sharp" not in w: return None
    s = np.asarray(w["sharp"], float); s = s[s > 0]
    return float(np.median(s)) if len(s) else None

# ---------- segmentos ----------
def base_segments(grid, at):
    """Segmento por fronteira, ângulo = motor."""
    return [{"f0": f0, "f1": f1, "angle": at(f0)} for f0, f1 in grid]

def dedup_logical(segs):
    """Funde vizinhos de mesmo ângulo -> segmentos lógicos [(angle,f0,f1)]."""
    log = []
    for s in sorted(segs, key=lambda x: x["f0"]):
        if log and log[-1][0] == s["angle"]: log[-1][2] = s["f1"]
        else: log.append([s["angle"], s["f0"], s["f1"]])
    return log

# ---------- aplicar no app ----------
def apply_segments(segs, cov=None, gid=None):
    cov = cov or coverage()
    gid = gid or group_id()
    cuts = []
    for s in sorted(segs, key=lambda x: x["f0"]):
        a = s["angle"]
        if not covers(cov, a, s["f0"], s["f1"]):
            # guarda: cai pro motor; se nem ele cobre, mantém o que cobrir
            for alt in (1, 2, 3):
                if covers(cov, alt, s["f0"], s["f1"]): a = alt; break
        cuts.append({"frame": s["f0"], "angle": a})
    return mcp("multicam_cut", {"groupId": gid, "cuts": cuts})

# ---------- métricas ----------
def metrics(segs, cov, jerk=None):
    log = dedup_logical(segs)
    durs = [(b-f0)/TL_FPS for _, f0, b in log]
    total = sum(durs)
    pp = sum(1 for i in range(2, len(log)) if log[i][0] == log[i-2][0] and log[i][0] != log[i-1][0]
             and (log[i-1][2]-log[i-1][1])/TL_FPS < 4 and (log[i][2]-log[i][1])/TL_FPS < 4)
    sub2 = sum(1 for d in durs if d < 2.0)
    holds = sum(1 for d in durs if d > 25.0)
    # preto VERDADEIRO = nenhuma câmera cobre o span (o apply-guard cai pra qq cam que cobrir;
    # só é preto inevitável quando nem 1/2/3 cobre). `forcados` = ângulo pedido não coberto (vira
    # fallback no apply, não preto).
    blacks = sum(1 for s in segs if not any(covers(cov, a, s["f0"], s["f1"]) for a in (1, 2, 3)))
    forced = sum(1 for s in segs if not covers(cov, s["angle"], s["f0"], s["f1"]))
    camtime = collections.Counter()
    for a, f0, b in log: camtime[a] += (b-f0)/TL_FPS
    m = {"segs_logicos": len(log), "dur_media": round(total/len(log), 1) if log else 0,
         "dur_mediana": round(sorted(durs)[len(durs)//2], 1) if durs else 0,
         "ping_pong": pp, "sub2s": sub2, "holds_25s+": holds, "pretos": blacks, "forcados": forced,
         "cortes_min": round(len(log)/(total/60), 1) if total else 0,
         "dist": {f"cam{a}": f"{camtime[a]/total*100:.0f}%" for a in sorted(camtime)}}
    if jerk:
        # shake médio ponderado por duração da câmera ativa
        sj, sw = 0.0, 0.0
        for a, f0, b in log:
            mj = mean_jerk(jerk, ANGLE_CAM[a], f0, b)
            if mj is not None: sj += mj*(b-f0); sw += (b-f0)
        m["shake_medio"] = round(sj/sw, 2) if sw else None
        m["segs_shaky>1.0"] = sum(1 for a, f0, b in log
                                  if (mean_jerk(jerk, ANGLE_CAM[a], f0, b) or 0) > 1.0)
    return m
