#!/usr/bin/env python3
"""
Orquestrador do pipeline Balde C — modo "sem director" (default) ou "com director".

Default (recomendação c do Opus):
  1. Percepção: analyze_visual + shake_detect com RETOMADA automática (várias passes;
     curtos + masters longos; cache por arquivo; usuário não re-roda)
  2. Storyboard: storyboard_gen.py (beats da cerimônia via VLM)
  3. Motor pass1: unified.py (base + spike_candidates)
  4. Judges não-bloqueados: switch_judge_gen, stab_gen, occlusion_judge
     (variety/missed/content PULADOS — dependem de content_map_dense.json/SigLIP)
  5. Motor pass2: unified.py (reaplica com judges)
  5b. Fine-cut: finecut_judge.py (empty VLM) + pass3 se houver SWAP
  6. Verify: verify.py (observability)

Com --with-director (requer torch + transformers instalados):
  + Estágio 3: visual_index (SigLIP Apache-2.0) → director_moments → gen_content_map
    → build_director_version (N_director.json) → content_gen + variety + missed judges

Uso: ./venv/bin/python run_pipeline_no_director.py <workdir> \
         [--skip-perception] [--skip-judges] [--skip-storyboard] [--with-director]

  --skip-storyboard  reusa storyboard.json já no workdir (A/B de camadas content/variety
                     sem re-gastar VLM no stage 5; exige arquivo presente e não-vazio).

O workdir deve conter placements.json + media_paths.json (gerados pelo adapter Swift).
"""
import json, os, subprocess, sys, shutil, time

FPS = 30
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

def run(cmd, cwd, desc, may_fail=False, timeout=600):
    """Roda um script Python no workdir, capturando saída. Se may_fail=True, warning em vez de exit.
    `timeout` default 600s (10min) serve os estágios rápidos; percepção usa
    `run_perception_resumable` (várias passes) p/ curtos + masters longos sem o usuário re-rodar.
    """
    print(f"\n{'='*60}")
    print(f"  {desc}")
    print(f"  {cmd}")
    t0 = time.time()
    try:
        proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired as e:
        dt = time.time() - t0
        # TimeoutExpired é levantado pelo PRÓPRIO subprocess.run — não chega no check de returncode
        # abaixo. Sem isto, um estágio marcado may_fail=True (ex: QCACHE) ainda derrubava o script
        # INTEIRO com traceback não-tratado em vez de logar e seguir como o may_fail promete.
        stdout = (e.stdout or b"").decode("utf-8", "replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
        stderr = (e.stderr or b"").decode("utf-8", "replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
        print(stdout[-2000:] if stdout else "(sem stdout até o timeout)")
        if may_fail:
            print(f"  ⚠️  TIMEOUT ({timeout}s, rodou {dt:.0f}s) — continuando", flush=True)
            return subprocess.CompletedProcess(cmd, returncode=1, stdout=stdout, stderr=stderr)
        print(f"  ✗ TIMEOUT ({timeout}s, rodou {dt:.0f}s)", flush=True)
        sys.exit(1)
    dt = time.time() - t0
    print(proc.stdout[-2000:] if proc.stdout else "(sem stdout)")
    if proc.returncode != 0:
        if may_fail:
            print(f"  ⚠️  FALHOU (rc={proc.returncode}, {dt:.0f}s) — continuando", flush=True)
            return proc
        print(f"  ✗ FALHOU (rc={proc.returncode}, {dt:.0f}s)", flush=True)
        if proc.stderr:
            print(f"  stderr: {proc.stderr[-2000:]}")
        sys.exit(1)
    print(f"  ✓ OK ({dt:.0f}s)", flush=True)
    return proc


def _perception_wanted_keys(workdir):
    """Stems de footage que o cut precisa (placements → footage_key)."""
    sys.path.insert(0, SCRIPT_DIR)
    import vlib as V  # noqa: WPS433 — runtime no workdir do casamento
    pl_path = os.path.join(workdir, "placements.json")
    clips = json.load(open(pl_path)).get("clips") or []
    keys = set()
    for c in clips:
        k = V.footage_key(c.get("name") or "", cam=c.get("cam"))
        if k:
            keys.add(k)
    return keys


def _cached_stems(cache_dir):
    if not os.path.isdir(cache_dir):
        return set()
    return {
        os.path.splitext(f)[0]
        for f in os.listdir(cache_dir)
        if f.endswith(".json") and not f.endswith(".tmp")
    }


def run_perception_resumable(py, script_name, cache_subdir, desc_prefix, workdir,
                             timeout_pass=10800, max_passes=4):
    """Roda face/shake em PASSAGENS automáticas até cobrir todos os clips.

    Casamento real = muitos curtos + 1–2 masters de 40–80 min. Uma pass de 3h às vezes
    só fecha os curtos; o orquestrador RECOMEÇA sozinho (cache por arquivo), sem o usuário
    re-clicar Cut. Para se: completo | max_passes | pass sem progresso (decode impossível).
    """
    script = os.path.join(SCRIPT_DIR, script_name)
    wanted = _perception_wanted_keys(workdir)
    if not wanted:
        print(f"  {desc_prefix}: 0 sources no placements — skip", flush=True)
        return
    cache_dir = os.path.join(workdir, cache_subdir)
    os.makedirs(cache_dir, exist_ok=True)

    for pass_i in range(1, max_passes + 1):
        have = _cached_stems(cache_dir) & wanted
        missing = wanted - have
        pct = 100.0 * len(have) / len(wanted)
        print(f"\n{'='*60}", flush=True)
        print(f"  {desc_prefix}: pass {pass_i}/{max_passes} — "
              f"{len(have)}/{len(wanted)} caches ({pct:.0f}%), faltam {len(missing)}",
              flush=True)
        if not missing:
            print(f"  ✓ {desc_prefix}: completo", flush=True)
            return

        before = len(have)
        run(
            [py, script, "--all"],
            cwd=workdir,
            desc=f"{desc_prefix} pass {pass_i}/{max_passes} (faltam {len(missing)})",
            timeout=timeout_pass,
            may_fail=True,  # timeout desta pass → próxima pass retoma
        )
        have2 = _cached_stems(cache_dir) & wanted
        gained = len(have2) - before
        print(f"  {desc_prefix}: pass {pass_i} +{gained} "
              f"(agora {len(have2)}/{len(wanted)})", flush=True)

        if len(have2) >= len(wanted):
            print(f"  ✓ {desc_prefix}: completo após pass {pass_i}", flush=True)
            return
        if gained == 0:
            print(f"  ⚠️  {desc_prefix}: sem progresso (faltam {len(wanted - have2)}) — "
                  f"segue cut com cobertura parcial", flush=True)
            return

    have = _cached_stems(cache_dir) & wanted
    print(f"  ⚠️  {desc_prefix}: {len(have)}/{len(wanted)} após {max_passes} passes — "
          f"cut segue (re-run futuro preenche o resto se ainda faltar)", flush=True)

def main():
    if len(sys.argv) < 2:
        print(f"Uso: {sys.argv[0]} <workdir> [--skip-perception] [--skip-judges] "
              f"[--skip-storyboard] [--with-director]", file=sys.stderr)
        sys.exit(1)

    workdir = os.path.abspath(sys.argv[1])
    skip_perception = "--skip-perception" in sys.argv
    skip_judges = "--skip-judges" in sys.argv
    skip_storyboard = "--skip-storyboard" in sys.argv
    with_director = "--with-director" in sys.argv
    py = sys.executable

    # Verifica insumos mínimos
    if not os.path.exists(os.path.join(workdir, "placements.json")):
        print(f"ERRO: {workdir}/placements.json não encontrado. Rode o adapter Swift primeiro.",
              file=sys.stderr)
        sys.exit(1)
    if not os.path.exists(os.path.join(workdir, "media_paths.json")):
        print(f"ERRO: {workdir}/media_paths.json não encontrado.", file=sys.stderr)
        sys.exit(1)

    versions_dir = os.path.join(workdir, "versions")
    os.makedirs(versions_dir, exist_ok=True)

    # ── 1. PERCEPÇÃO (facecache + shakecache) ──
    # Automático p/ mix curtos + master 40–80 min: várias passes de até 3h cada, retoma
    # do cache (sem o usuário re-clicar Cut). Ver run_perception_resumable.
    if not skip_perception:
        run_perception_resumable(
            py, "analyze_visual.py", "facecache", "1a. PERCEPÇÃO facial",
            workdir, timeout_pass=10800, max_passes=4,
        )
        run_perception_resumable(
            py, "shake_detect.py", "shakecache", "1b. PERCEPÇÃO shake",
            workdir, timeout_pass=10800, max_passes=4,
        )

    # ── 2. STORYBOARD ──
    # Precisa de UNI.json (motor pass1 gera). Mas storyboard_gen usa active_fn() que
    # cai pra N_director ou G_whip. Se não existir nenhum, storyboard não roda.
    # Alternativa: roda o motor ANTES pra gerar um base cut.
    # Vamos rodar motor pass0 (sem judges) pra gerar uma base que o storyboard usa.

    # ── 2.5. QCACHE + AUTO_SWITCH (base cut por qualidade) ──
    # batch_scan gera qcache/ (quality metrics) que auto_switch usa.
    # auto_switch gera cuts.json que build_director_version.py usa como BASE.
    # Ambos são opcionais — se falharem (sem qcache), o pipeline segue com fallback mínimo.
    if os.path.exists(os.path.join(workdir, "media_paths.json")):
        run([py, os.path.join(SCRIPT_DIR, "batch_scan.py"), "--only", "placements.json"],
            cwd=workdir, desc="2.5a. QCACHE (quality scan)", may_fail=True, timeout=10800)
    run([py, os.path.join(SCRIPT_DIR, "auto_switch.py")],
        cwd=workdir, desc="2.5b. AUTO_SWITCH (base cut por qualidade)", may_fail=True)

    # ── 3. DIRECTOR (opcional, --with-director) ──
    if with_director:
        run([py, os.path.join(SCRIPT_DIR, "director_moments.py"), workdir],
            cwd=workdir, desc="3a. DIRECTOR: SigLIP money-shots (moments.json)", timeout=10800)
        # 3b: content map DENSO (v0.2) — reusa .search_cache; emite scene_map_dense.json +
        # content_map_dense.json flat p/ content_at. Fallback: gen_content_map (top-50 moments).
        dense_script = os.path.join(SCRIPT_DIR, "gen_scene_map_dense.py")
        if os.path.exists(dense_script):
            scene_out = os.path.join(workdir, "scene_map_dense.json")
            run([py, dense_script, "--workdir", workdir, "--out", scene_out,
                 "--emit-flat", os.path.join(workdir, "content_map_dense.json")],
                cwd=workdir, desc="3b. CONTENT MAP DENSO (z-score A/B → content_map_dense.json)",
                timeout=10800, may_fail=True)
            # v0.3: checa N hits (arquivo "[]" tem 2 bytes e passava como ok em size-only).
            _dense_path = os.path.join(workdir, "content_map_dense.json")
            _n_dense = 0
            if os.path.exists(_dense_path):
                try:
                    _d = json.load(open(_dense_path))
                    _n_dense = len(_d) if isinstance(_d, list) else 0
                except Exception:
                    _n_dense = 0
            if _n_dense < 8:
                print(f"  ⚠️  denso falhou/vazio (hits={_n_dense}) — fallback gen_content_map (top-50 moments)",
                      flush=True)
                run([py, os.path.join(SCRIPT_DIR, "gen_content_map.py")],
                    cwd=workdir, desc="3b'. CONTENT MAP fallback (moments top-50)")
            else:
                print(f"  ✓ content map denso: {_n_dense} hits (sem fallback top-50)", flush=True)
        else:
            run([py, os.path.join(SCRIPT_DIR, "gen_content_map.py")],
                cwd=workdir, desc="3b. CONTENT MAP (content_map[_dense].json)")
        run([py, os.path.join(SCRIPT_DIR, "build_director_version.py")],
            cwd=workdir, desc="3c. N_DIRECTOR (cam certa na batida certa)")

    # ── 4. BASE CUT (N_director.json) ──
    # unified.py hard-require N_director.json como base.
    # Com director: build_director_version.py já gerou.
    # Sem director: criamos um base cut mínimo (primeiro ângulo cobre tudo).
    n_dir = os.path.join(workdir, "versions", "N_director.json")
    if not os.path.exists(n_dir):
        P = json.load(open(os.path.join(workdir, "placements.json")))
        first_cam = P["clips"][0]["cam"]
        _cams = sorted({c["cam"] for c in P["clips"]})
        _CAM_ANGLE = {c: i+1 for i, c in enumerate(_cams)}
        first_angle = _CAM_ANGLE.get(first_cam, 1)
        dur = max(c["startFrame"] + c["durationFrames"] for c in P["clips"])
        base_cut = {"cutpoints": [{"frame": 0, "angle": first_angle}]}
        with open(n_dir, "w") as f:
            json.dump(base_cut, f)
        print(f"  N_director.json mínimo: cam{first_angle} cobre [0, {dur})", flush=True)

    for placeholder in ["missed_inserts.json", "content_inserts.json",
                        "emotion_inserts.json", "variety_inserts.json",
                        "luma_switches.json", "stab_switches.json",
                        "judge_switches_ok.json",
                        # storyboard.json TEM que existir antes do pass0 (unified.py:109
                        # abre direto sem guard). Placeholder [] permite o motor rodar sem
                        # storyboard real; storyboard_gen.py preenche antes do pass2.
                        "storyboard.json"]:
        ph_path = os.path.join(workdir, placeholder)
        if not os.path.exists(ph_path):
            with open(ph_path, "w") as f:
                f.write("[]")

    run([py, os.path.join(SCRIPT_DIR, "unified.py")],
        cwd=workdir, desc="4. MOTOR pass0 (base cut)", timeout=10800)

    # ── 5. STORYBOARD ──
    # Cerimônia ~90–110 min @ janela 25s ≈ 200+ calls VLM — default 600s mata o stage
    # no meio (Paula 2026-07-12: timeout aos 120 janelas, storyboard.json ficou []).
    _sb = os.path.join(workdir, "storyboard.json")
    if skip_storyboard:
        if not os.path.exists(_sb) or os.path.getsize(_sb) < 8:
            print("ERRO: --skip-storyboard exige storyboard.json não-vazio no workdir",
                  file=sys.stderr)
            sys.exit(1)
        print(f"\n  (skip-storyboard: reusando {_sb}, {os.path.getsize(_sb)} bytes)", flush=True)
    else:
        run([py, os.path.join(SCRIPT_DIR, "storyboard_gen.py")],
            cwd=workdir, desc="5. STORYBOARD (beats da cerimônia)", timeout=10800)

    # ── 6. JUDGES ──
    if not skip_judges:
        # switch_judge_gen: composição geral (segmentos >=5s) — depende de storyboard.json
        run([py, os.path.join(SCRIPT_DIR, "switch_judge_gen.py")],
            cwd=workdir, desc="6a. JUDGE: composição geral (switch)", timeout=10800)

        # stab_gen: switches por tremor com conteúdo — depende de shakecache + UNI
        run([py, os.path.join(SCRIPT_DIR, "stab_gen.py")],
            cwd=workdir, desc="6b. JUDGE: tremor com conteúdo (stab)", timeout=10800)

        # occlusion_judge: dodges de oclusão — consome spike_candidates.json do unified
        spike_path = os.path.join(workdir, "spike_candidates.json")
        if os.path.exists(spike_path) and os.path.getsize(spike_path) > 2:
            run([py, os.path.join(SCRIPT_DIR, "occlusion_judge.py")],
                cwd=workdir, desc="6c. JUDGE: oclusão", timeout=10800)
        else:
            print("\n  (sem spike_candidates — occlusion_judge pulado)")

        # Com director: content_map_dense.json existe → roda variety + missed + content
        if with_director and os.path.exists(os.path.join(workdir, "content_map_dense.json")):
            run([py, os.path.join(SCRIPT_DIR, "content_gen.py")],
                cwd=workdir, desc="6d. JUDGE: content-inserts (holds-flat)", timeout=10800)
            run([py, os.path.join(SCRIPT_DIR, "variety_cands.py")],
                cwd=workdir, desc="6e. VARIETY: localizador nativo")
            run([py, os.path.join(SCRIPT_DIR, "variety_judge.py")],
                cwd=workdir, desc="6f. VARIETY: judge b-roll", timeout=10800)
            run([py, os.path.join(SCRIPT_DIR, "missed_cands.py")],
                cwd=workdir, desc="6g. MISSED: localizador do-zero")
            run([py, os.path.join(SCRIPT_DIR, "missed_judge.py")],
                cwd=workdir, desc="6h. MISSED: judge momentos perdidos", timeout=10800)
        else:
            print("\n  ⚠️  variety/missed/content PULADOS (sem director/SigLIP — use --with-director)")

    # ── 7. MOTOR pass2 (reaplica com judges) ──
    run([py, os.path.join(SCRIPT_DIR, "unified.py")],
        cwd=workdir, desc="7. MOTOR pass2 (com judges)", timeout=10800)

    # ── 7b. FINE-CUT critic (v0.3.4) — empty-cands VLM; na dúvida KEEP ──
    # Nativo mff≈0 confunde mãos/wide/chão; finecut_judge decide; re-unified aplica
    # finecut_swaps.json no item6e. Sem key Kimi (skip_judges) → pula (zero swap, seguro).
    if not skip_judges:
        fc_script = os.path.join(SCRIPT_DIR, "finecut_judge.py")
        if os.path.exists(fc_script):
            run([py, fc_script],
                cwd=workdir, desc="7b. FINE-CUT: critic empty (VLM)", timeout=3600)
            swaps_path = os.path.join(workdir, "finecut_swaps.json")
            n_swaps = 0
            if os.path.exists(swaps_path):
                try:
                    n_swaps = len(json.load(open(swaps_path)) or [])
                except Exception:
                    n_swaps = 0
            if n_swaps > 0:
                run([py, os.path.join(SCRIPT_DIR, "unified.py")],
                    cwd=workdir, desc="7c. MOTOR pass3 (aplica finecut_swaps)", timeout=10800)
            else:
                print("\n  (finecut: 0 SWAP — UNI do pass2 permanece)", flush=True)
        else:
            print("\n  (finecut_judge.py ausente — polish VLM pulado)")
    else:
        print("\n  (skip-judges: finecut VLM pulado)")

    # ── 8. VERIFY ──
    verify_script = os.path.join(SCRIPT_DIR, "verify.py")
    if os.path.exists(verify_script):
        uni_path = os.path.join(workdir, "versions", "UNI.json")
        if os.path.exists(uni_path):
            run([py, verify_script, "UNI"],
                cwd=workdir, desc="8. VERIFY (observability)")

    # ── RESULTADO ──
    uni_path = os.path.join(workdir, "versions", "UNI.json")
    if os.path.exists(uni_path):
        uni = json.load(open(uni_path))
        cps = uni.get("cutpoints", [])
        print(f"\n{'='*60}")
        print(f"  PIPELINE CONCLUÍDO: {len(cps)} cutpoints em {uni_path}")
        metrics = uni.get("metrics", {})
        if metrics:
            print(f"  métricas: {json.dumps(metrics, indent=2)}")
        fc = os.path.join(workdir, "finecut_swaps.json")
        if os.path.exists(fc):
            try:
                print(f"  finecut_swaps: {len(json.load(open(fc)) or [])}")
            except Exception:
                pass
    else:
        print(f"\n  ✗ UNI.json não gerado em {uni_path}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
