#!/usr/bin/env python3
"""Base comum dos judges VLM do-zero: frame do SOURCE (read-only) por (angle, frame-de-timeline),
parse JSON da resposta, ativa via UNI. Usada por missed/occlusion/switch/content/variety/stab/emotion."""
import json, os, re, subprocess, bisect, threading

# vlib.ANGLE_CAM / CAM_ANGLE: nomes reais de cam no placements (cam1_carao1fabr, …), NÃO "cam1".
# BUG 2026-07-12: frame_of usava f"cam{angle}" e NUNCA casava clipe → todos os judges VLM
# saíam [] (storyboard 1 beat "pre", stab/switch/content vazios) com cache reutilizando no-op.
import vlib as V

FPS = 30
FFMPEG = os.environ.get("FFMPEG", "/opt/homebrew/bin/ffmpeg")
if not os.path.isfile(FFMPEG):
    FFMPEG = "ffmpeg"  # fallback PATH
SCRATCH = os.path.join(os.environ.get("VIDEOTASK_SCRATCH",
          os.path.expanduser("~/Library/Caches/VideoTask/pipeline")), "judges")
os.makedirs(SCRATCH, exist_ok=True)

P = json.load(open("placements.json"))["clips"]
PATHS = json.load(open("media_paths.json"))

def prompt(name):
    return open(os.path.join(os.path.dirname(__file__), "prompts", name)).read()


def _cam_name(angle):
    """angle int (1..N) ou string de cam do placements → nome canônico no placements."""
    if isinstance(angle, int):
        return V.ANGLE_CAM.get(angle)
    if isinstance(angle, str):
        if angle in V.CAM_ANGLE:
            return angle
        # legado / typo: "cam1" → tenta ANGLE_CAM[1]
        if angle.startswith("cam") and angle[3:].isdigit():
            return V.ANGLE_CAM.get(int(angle[3:]))
    return None


def _source_path(clip):
    """Resolve path no media_paths a partir do name do clip (v0.4: sem [:-4])."""
    name = clip.get("name") or ""
    cam = clip.get("cam")
    try:
        p = V.resolve_media_path(name, PATHS, cam=cam)
        if p and os.path.exists(p):
            return p
    except Exception:
        pass
    # fallback: qualquer chave de media_paths contida no name (sufixo de footage)
    for key, path in PATHS.items():
        if key and key in name and os.path.exists(path):
            return path
    return None


def frame_of(angle, f, h=270):
    """Extrai 1 frame SOURCE (read-only) no scratch. `angle` = índice 1..N do motor (UNI) ou
    nome de cam do placements. Devolve path do jpg ou None."""
    cam = _cam_name(angle)
    if not cam:
        return None
    clip = next((c for c in P if c["cam"] == cam
                 and c["startFrame"] <= f < c["startFrame"] + c["durationFrames"]), None)
    if not clip:
        return None
    src = _source_path(clip)
    if not src:
        return None
    tag = angle if isinstance(angle, int) else V.CAM_ANGLE.get(cam, angle)
    dst = os.path.join(SCRATCH, f"a{tag}_f{f}_{h}.jpg")
    if not os.path.exists(dst):
        # extrai p/ tmp único + rename atômico: sob par_map dois threads podem pedir o MESMO frame;
        # `-y` no dst direto deixaria um leitor ver o jpg meio-escrito. os.replace é atômico.
        tmp = f"{dst}.{threading.get_ident()}.tmp"
        # -f image2: o tmp termina em .tmp e o ffmpeg NÃO infere JPEG pela extensão desconhecida
        # ("Unable to choose an output format") -> tmp nunca criado -> frame_of None -> judge sem
        # frame recusa/pula TUDO (INTEGRIDADE 0%). Força o muxer explicitamente.
        # REGRA #0: só -i no original; saída no scratch.
        # Path absoluto do ffmpeg: o app Swift lança Python com PATH mínimo — "ffmpeg" no PATH
        # falhava → finecut "sem frame" / judges vazios (Nathália v0.4).
        ss = max(0.0, (f - int(clip["startFrame"])) / float(FPS))
        # sourceInPoint se existir (clip não começa no frame 0 do arquivo)
        src_in = int(clip.get("sourceInPoint") or clip.get("source_in") or 0)
        ss = max(0.0, ss + src_in / float(FPS))
        subprocess.run([FFMPEG, "-v", "error", "-ss", f"{ss:.3f}",
                        "-i", src, "-frames:v", "1", "-vf", f"scale=-2:{h}",
                        "-f", "image2", "-y", tmp], capture_output=True)
        if os.path.exists(tmp) and os.path.getsize(tmp) > 100:
            os.replace(tmp, dst)
        else:
            try:
                if os.path.exists(tmp):
                    os.remove(tmp)
            except OSError:
                pass
    return dst if os.path.exists(dst) else None

def jparse(txt):
    m = re.search(r'\{.*\}', txt, re.S)
    try: return json.loads(m.group(0)) if m else {}
    except Exception: return {}

def tc(f): return f"{f//108000:02d}:{(f//1800)%60:02d}:{(f//30)%60:02d}"

def uni_segments(min_dur_s=0):
    cps = sorted(json.load(open("versions/UNI.json"))["cutpoints"], key=lambda c: c["frame"])
    end = max(c["startFrame"]+c["durationFrames"] for c in P)
    segs = []
    for i, c in enumerate(cps):
        f1 = cps[i+1]["frame"] if i+1 < len(cps) else end
        if (f1-c["frame"])/FPS >= min_dur_s: segs.append({"f0": c["frame"], "f1": f1, "angle": c["angle"]})
    return segs

def active_fn(path=None):
    # UNI é o alvo pós-unified; mas quem roda ANTES do unified (storyboard do-zero) não tem UNI ainda.
    # Cai pra melhor versão já materializada NESTE run (diretor -> motor). Sem isso, do-zero absoluto
    # (versions/ sem UNI) crashava — dependência da pilha frozen.
    if path is None:
        path = next((c for c in ("versions/UNI.json", "versions/N_director.json",
                                  "versions/G_whip.json") if os.path.exists(c)), None)
        if path is None:
            raise FileNotFoundError("active_fn: nenhuma versão (UNI/N_director/G_whip) em versions/")
    cps = sorted(json.load(open(path))["cutpoints"], key=lambda c: c["frame"])
    cf = [c["frame"] for c in cps]
    return lambda f: cps[max(0, bisect.bisect_right(cf, f)-1)]["angle"]

def beat_fn():
    """(beat, importance) no frame f, do storyboard.json (tc HH:MM:SS:FF)."""
    def tcf(t):
        p = [int(x) for x in t.split(":")]; return p[0]*108000 + p[1]*1800 + p[2]*30 + (p[3] if len(p) > 3 else 0)
    try:
        sb = sorted([b for b in json.load(open("storyboard.json")) if b.get("tc_start")], key=lambda b: tcf(b["tc_start"]))
    except Exception:
        sb = []
    fs = [tcf(b["tc_start"]) for b in sb]
    def at(f):
        if not sb: return ("officiant", 0.5)
        i = max(0, bisect.bisect_right(fs, f)-1)
        return (sb[i]["beat"], float(sb[i].get("importance", 0.5)))
    return at
