#!/usr/bin/env python3
"""MODELO DE SYNC — editor de sincronização por momento (passo 2 do harness).

Um perfil por MOMENTO (cerimonia ativo; entrada/buque/valsa = stubs c/ instruções
especializadas). Espinha = gravador de áudio CONTÍNUO do momento (fingerprint ref +
áudio bom do edit). Fingerprint (audalign) de cada clip de cam vs espinha → offset;
rank baixo → confirma com spectral-flux xcorr (sync_spec). READ-ONLY no original.

Saída: placements_<casamento>_<momento>.json  (offset s por clip vs espinha).
Uso:  ./venv/bin/python sync_ingest.py decupagem_nathalia.json --momento cerimonia [--dry]
"""
import os, re, sys, json, glob, wave, subprocess, argparse, warnings
warnings.filterwarnings("ignore")

# Volatile scratch: env override, else a stable per-user cache (macOS wipes /tmp; old session dies).
SCRATCH = os.path.join(
    os.environ.get("VIDEOTASK_SCRATCH", os.path.expanduser("~/Library/Caches/VideoTask/pipeline")),
    "sync",
)
os.makedirs(SCRATCH, exist_ok=True)
VIDEO_EXT = (".mp4", ".mov", ".mxf", ".avi")

# ── PERFIS POR MOMENTO (instruções especializadas — o "modelo" de cada parte) ──
# spine = papel da espinha; conf_min = rank audalign mínimo p/ aceitar sem confirmar;
# notas = craft específico que o sync daquele momento precisa respeitar.
PROFILES = {
    "cerimonia": {
        "spine_key": "audio_cerimonia",
        "conf_min": 40,
        "notas": "Espinha = gravador contínuo da cerimônia (votos/celebrante = áudio BOM). "
                 "Run-and-gun OK: cada clip curto sincado vs espinha longa. Espinha pode "
                 "vir em CHUNKS (limite de arquivo do gravador) — concatenar os chunks do "
                 "bloco em ordem de timestamp. Janela ampla (cerimônia = dezenas de min).",
    },
    "entrada": {  # stub futuro
        "spine_key": "audio_entrada",
        "conf_min": 40,
        "notas": "Entrada na festa: curta, música ALTA (sync por batida é fácil). Espinha = "
                 "gravador da pista/PA. Poucas cams, momento denso.",
    },
    "buque": {  # stub futuro
        "spine_key": "audio_buque",
        "conf_min": 40,
        "notas": "Buquê/valsa: música contínua. Espinha = PA. Sync curto, alta confiança.",
    },
}


def sh(cmd):
    return subprocess.run(cmd, capture_output=True, text=True)


def ffdur(path):
    r = sh(["ffprobe", "-v", "error", "-show_entries", "format=duration",
            "-of", "csv=p=0", path])
    try:
        return float(r.stdout.strip())
    except ValueError:
        return 0.0


def extract_wav(src, dst, ar=8000):
    """READ-ONLY no original: src só como -i; saída é arquivo NOVO em scratch."""
    if os.path.exists(dst) and ffdur(dst) > 1:
        return dst  # cache
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    r = sh(["ffmpeg", "-y", "-i", src, "-vn", "-ac", "1", "-ar", str(ar),
            "-c:a", "pcm_s16le", dst])
    if r.returncode != 0:
        print(f"  ! ffmpeg falhou {os.path.basename(src)}: {r.stderr.strip()[-200:]}")
        return None
    return dst


EXCLUDE_DIRS = ("proxy", "remux", ".trash", "_gsdata_", "avata", "drone")
EXCLUDE_DIRS_EXACT = ("sub",)  # pasta SUB = sub-stream/proxy da câmera (Canon/Sony: CLIP/Cxxxx.MP4
# + SUB/CxxxxS0N.MP4, mesmo conteúdo em baixa res). Nome EXATO (não substring — "sub" solto pegaria
# pastas legítimas tipo "Submissions").


def cam_folders(base, pasta_field):
    """`pasta` pode ser "|"-joined (merge_same_camera_folders, auto_decupagem.py, câmera física com
    2+ pastas/cartões) — devolve a lista de paths reais, um por pedaço."""
    return [os.path.join(base, p) for p in pasta_field.split("|")]


def find_videos_multi(base, pasta_field):
    """find_videos somado sobre TODAS as pastas de uma cam merged (ver cam_folders)."""
    out = {}
    for folder in cam_folders(base, pasta_field):
        for v in find_videos(folder):
            out.setdefault(os.path.splitext(os.path.basename(v))[0], v)
    return sorted(out.values())


def find_videos(folder):
    """Vídeos ORIGINAIS da pasta, RECURSIVO (clips podem estar em subpastas tipo 100PRETA/CLIP),
    EXCLUINDO cópias (Proxy/_PalmierRemux/SUB) e dedupe por stem (mesma clip em orig+proxy -> orig).
    Lição do-zero Paula: estrutura varia (subpastas + proxies); listdir flat perdia carao/joão.
    Lição do-zero Verônica: câmera com pasta SUB (sub-stream S03) NÃO cai na exclusão por dir
    (nome exato "sub" cobre isso) — defesa extra: sufixo S\\d+ no nome, mesmo clip-base já achado
    em outra pasta -> é sub-stream duplicado, pula (evita 2 placements pro mesmo footage real,
    cada um com offset de sync próprio, sobrepondo a timeline)."""
    out = {}
    for root, dirs, files in os.walk(folder):
        dirs[:] = [d for d in dirs if not any(x in d.lower() for x in EXCLUDE_DIRS)
                   and d.lower() not in EXCLUDE_DIRS_EXACT]
        for f in files:
            if f.lower().endswith(VIDEO_EXT):
                stem = os.path.splitext(f)[0]
                if stem not in out:  # 1º achado (não-proxy) vence
                    out[stem] = os.path.join(root, f)
    # dedup genérico por sufixo sub-stream (S\d+ no fim): "C9529S03" com "C9529" já achado -> pula.
    for stem in list(out):
        m = re.match(r"^(.+?)S\d{1,2}$", stem)
        if m and m.group(1) in out:
            del out[stem]
    return sorted(out.values())


def clips_in_range(folder, rng):
    """Vídeos originais da pasta (recursivo) selecionados pelo range 'C172-C176' (numérico)."""
    files = find_videos(folder)
    if not rng:
        return files
    m = re.findall(r"(\d+)", rng)
    if len(m) < 2:
        return files
    lo, hi = int(m[0]), int(m[1])

    def num(f):
        d = re.findall(r"(\d+)", os.path.splitext(os.path.basename(f))[0])
        return int(d[0]) if d else -1
    return [f for f in files if lo <= num(f) <= hi]


def spine_files(base, decup, profile, momento):
    """Espinha pode ser 1 arquivo ou vários chunks. Retorna lista de paths absolutos."""
    info = decup.get(profile["spine_key"])
    if not info:
        return []
    arq = info.get("arquivo")
    arqs = info.get("arquivos") or ([arq] if arq else [])
    out = []
    for a in arqs:
        p = a if os.path.isabs(a) else os.path.join(base, a)
        if os.path.exists(p):
            out.append(p)
        else:
            print(f"  ! espinha não encontrada: {p}")
    return out


_REC = {}  # basename -> recognizer (multi-espinha: principal + alternativas p/ clips fracos)


def spine_recognizer(spine_wav):
    """Fingerprinta a espinha UMA vez por arquivo (cache em disco + memória). Recognize vira ~0.4s.
    Multi-espinha: cacheia cada gravador-espinha separado (o fallback tenta clips fracos em outro)."""
    import audalign as ad
    bn = os.path.basename(spine_wav)
    if bn in _REC:
        return _REC[bn], bn
    rec = ad.FingerprintRecognizer()
    # macOS multiprocessing = SPAWN → o worker RE-IMPORTA o main module. Se o caller roda tudo em
    # nível-módulo SEM `if __name__=="__main__"` (ex: build_multicam), o worker re-executa o corpo
    # (lock+build+audalign) → cada um respawna → FORK BOMB (deu 100k procs, RAM 64GB no Paula quando
    # o cache recorder_offsets estava ausente). Single-process é SEGURO p/ qualquer caller; a espinha
    # é fingerprintada 1x e cacheada (fp.json), então o custo é marginal.
    rec.config.multiprocessing = False
    cache = os.path.splitext(spine_wav)[0] + ".fp.json"
    manifest = os.path.splitext(spine_wav)[0] + ".fp_src.json"
    # invalidação por CONTEÚDO: o .fp.json não tinha NENHUMA verificação — só "existe? usa" — mas
    # spine_wav é REGERADO a cada run (-y) e o conteúdo pode MUDAR (espinha troca de gravador entre
    # runs, decupagem tem variabilidade). Fingerprint de 2 dias atrás sendo usado pra espinha de HOJE
    # = matches sutilmente ERRADOS (offset perto mas não exato — sentido como "eco"/leve dessinc),
    # não um erro grosseiro que se nota fácil. Assinatura = mtime+size do spine_wav atual.
    st = os.stat(spine_wav)
    sig = {"mtime": st.st_mtime, "size": st.st_size}
    stale = True
    if os.path.exists(cache) and os.path.exists(manifest):
        try:
            old = json.load(open(manifest))
            stale = not (old.get("mtime") == sig["mtime"] and old.get("size") == sig["size"])
        except Exception:
            stale = True
    if not stale:
        rec.load_fingerprinted_files(cache)
    else:
        if os.path.exists(cache):
            print(f"  [fp cache] {bn}: espinha mudou desde o último fingerprint — recalculando (não reusar stale)")
        rec.fingerprint_file(spine_wav)
        rec.save_fingerprinted_files(cache)
        json.dump(sig, open(manifest, "w"))
    _REC[bn] = rec
    return rec, bn


def _ts_secs(name):
    """Segundo-do-dia do timestamp no nome (…_HHMMSS…). Delta entre espinhas de gravadores do mesmo
    relógio (DJI) converte o offset medido numa espinha alternativa de volta pra referência da principal."""
    m = re.search(r'_(\d{6})(?=\D|$)', os.path.basename(name))
    if not m:
        return None
    h = m.group(1)
    return int(h[:2]) * 3600 + int(h[2:4]) * 60 + int(h[4:6])


def audalign_offset(spine_wav, clip_wav):
    rec, sn = spine_recognizer(spine_wav)
    r = rec.recognize(clip_wav)
    mi = r.get("match_info", {}).get(sn)
    if not mi or not mi.get("offset_seconds"):
        return None, 0
    return mi["offset_seconds"][0], mi["confidence"][0]


def spec_confirm(spine_wav, clip_wav):
    """2º sinal: spectral-flux xcorr (sync_spec.py). Retorna (lag_s, ratio).
    Path ABSOLUTO ao script (sys.executable + dirname(__file__)) — cwd deste processo é o
    workdir de trabalho (scratch), não a pasta scripts/; "./venv/bin/python" relativo quebrava
    (FileNotFoundError) rodando fora do repo."""
    script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sync_spec.py")
    r = sh([sys.executable, script, spine_wav, clip_wav])
    m = re.search(r"lag_s=([-\d.]+).*ratio=([-\d.]+)", r.stdout)
    return (float(m.group(1)), float(m.group(2))) if m else (None, None)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("decupagem")
    ap.add_argument("--momento", default="cerimonia")
    ap.add_argument("--dry", action="store_true", help="só planeja (lista clips/espinha), sem fingerprint")
    args = ap.parse_args()

    decup = json.load(open(args.decupagem))
    base = decup["base"]
    momento = args.momento
    profile = PROFILES[momento]
    # slug ÚNICO (wedslug) do nome do arquivo decupagem — bate com run_dozero/scene_judge/placements/build.
    import wedslug
    name = wedslug.from_path(args.decupagem) or wedslug.slug(decup.get("casamento", "wedding"))
    print(f"== MODELO DE SYNC :: {momento} :: {decup.get('casamento')} ==")
    print(f"   {profile['notas']}\n")

    # 1. espinha
    sp = spine_files(base, decup, profile, momento)
    if not sp:
        print("ABORT: sem espinha de áudio p/ esse momento.")
        sys.exit(1)
    tot = sum(ffdur(p) for p in sp)
    print(f"ESPINHA ({len(sp)} chunk(s), {tot/60:.1f}min):")
    for p in sp:
        print(f"   {ffdur(p)/60:5.1f}min  {os.path.basename(p)}")
    # GUARD DE MEMÓRIA: audalign fingerprinta a espinha INTEIRA na RAM. Espinha gigante (ex:
    # tracks paralelas não-dedupadas, ou gravador errado do dia todo) estoura a memória e TRAVA A
    # MÁQUINA do usuário (Nathália: 559min → freeze). Nenhuma cerimônia tem >3h de espinha contínua.
    # Aborta LOUD em vez de crashar. Ver [[feedback-otimizacao-maxima]].
    MAX_SPINE_MIN = 180
    if tot / 60 > MAX_SPINE_MIN:
        print(f"\nABORT: espinha de {tot/60:.0f}min > {MAX_SPINE_MIN}min — audalign estouraria a RAM.")
        print("  Causa provável: TRACKS PARALELAS não dedupadas (mesmo timestamp, _Tr1_2/_LR/_Mic)")
        print("  ou gravador ERRADO selecionado (dia todo, não a cerimônia). Confira os arquivos acima.")
        sys.exit(1)
    if tot / 60 > 120:
        print(f"  ⚠️ espinha longa ({tot/60:.0f}min) — pode ser gravador de festa, não cerimônia.")

    # 2. clips por câmera no range do momento
    cams = {}
    for cam, ci in decup.get("cameras", {}).items():
        # merge_same_camera_folders (auto_decupagem.py) junta pastas da MESMA câmera física com "|"
        # (ex "carao 1 fabricio cam 1|fabricio") — somar clips de cada pedaço via cam_folders, senão
        # os.path.join cria um path literal com "|" que não existe -> 0 clips -> câmera some do sync.
        rng = ci.get(momento)
        cl = []
        for folder in cam_folders(base, ci["pasta"]):
            cl.extend(clips_in_range(folder, rng))
        cams[cam] = cl
        print(f"\n{cam} [{ci.get('estilo','')}] range={rng} → {len(cl)} clip(s)")
        for f in cl[:4]:
            print(f"   {os.path.basename(f)}")
        if len(cl) > 4:
            print(f"   … +{len(cl)-4}")

    if args.dry:
        print("\n[DRY] plano ok. Sem fingerprint. Rode sem --dry p/ sincronizar.")
        return

    # cache-manifest: pula o estágio INTEIRO (espinha + fingerprint de todo clip) se nem o
    # script (sync_ingest.py) nem os inputs (decupagem.json + espinha) mudaram desde o último
    # run bem-sucedido NESTE workdir, e o output já existe. Complementa (não substitui) o resume
    # per-clip de baixo (linha ~348) — aquele cobre "run foi interrompido no meio"; este cobre
    # "run já terminou, nada mudou, nem abre o audalign".
    import cache_manifest as cm
    out = f"placements_{name}_{momento}.json"
    _input_sig = cm.input_signature([args.decupagem] + sp)
    if cm.is_fresh(".", "sync_ingest", __file__, _input_sig) and os.path.exists(out):
        print(f"\n[cache] sync_ingest: script+input (decupagem+espinha) inalterados — "
              f"pulando fingerprint, reusando {out}")
        return

    # 3. extrai espinha → scratch (concatena chunks se vários)
    os.makedirs(SCRATCH, exist_ok=True)
    spine_wav = os.path.join(SCRATCH, f"{name}_{momento}_spine.wav")
    if len(sp) == 1:
        extract_wav(sp[0], spine_wav)
    else:
        parts = []
        for i, p in enumerate(sp):
            w = extract_wav(p, os.path.join(SCRATCH, f"{name}_{momento}_sp{i}.wav"))
            if w:
                parts.append(w)
        lst = os.path.join(SCRATCH, f"{name}_{momento}_concat.txt")
        open(lst, "w").write("".join(f"file '{w}'\n" for w in parts))
        sh(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst,
            "-c", "copy", spine_wav])
    print(f"\nespinha extraída: {ffdur(spine_wav)/60:.1f}min")

    # espinhas ALTERNATIVAS (João): clip que não fecha firme na espinha 1 pode fechar em OUTRO gravador
    # da cerimônia (posição na sala capta melhor um mic que outro). LAZY: extrai/fingerprinta só quando
    # um clip fraco precisar (não paga RAM/tempo se todos fecham na espinha 1). delta = timestamp da
    # espinha alt − principal → converte o offset medido de volta pra referência da principal.
    _sp0 = sp[0] if sp else None
    alt_specs = []  # [paths[], delta_s, wav_or_None]
    for m in (decup.get("masters_cerimonia") or []):
        if m.get("espinha"):
            continue
        arqs = m.get("arquivos") or []
        apaths = [a if os.path.isabs(a) else os.path.join(base, a) for a in arqs]
        apaths = [p for p in apaths if os.path.exists(p)]
        if not apaths:
            continue
        d0, dsp = _ts_secs(arqs[0]), (_ts_secs(_sp0) if _sp0 else None)
        delta = (d0 - dsp) if (d0 is not None and dsp is not None) else 0.0
        alt_specs.append([apaths, delta, None])
    if alt_specs:
        print(f"  {len(alt_specs)} espinha(s) alternativa(s) disponível(is) p/ clips fracos")

    def _alt_wav(idx):
        paths, _delta, wav = alt_specs[idx]
        if wav:
            return wav
        aw = os.path.join(SCRATCH, f"{name}_{momento}_alt{idx}.wav")
        if not os.path.exists(aw):
            if len(paths) == 1:
                extract_wav(paths[0], aw)
            else:
                parts = [extract_wav(p, os.path.join(SCRATCH, f"{name}_{momento}_alt{idx}_{i}.wav"))
                         for i, p in enumerate(paths)]
                parts = [w for w in parts if w]
                lst = aw + ".txt"
                open(lst, "w").write("".join(f"file '{w}'\n" for w in parts))
                sh(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", aw])
        alt_specs[idx][2] = aw if os.path.exists(aw) else None
        return alt_specs[idx][2]

    # 4. fingerprint cada clip vs espinha
    placements = {"casamento": decup.get("casamento"), "momento": momento,
                  "espinha": [os.path.basename(p) for p in sp], "clips": {}}
    if os.path.exists(out):  # retoma run anterior
        placements = json.load(open(out))
    for cam, cl in cams.items():
        for f in cl:
            if os.path.basename(f) in placements["clips"]:
                continue  # já sincado
            cw = extract_wav(f, os.path.join(SCRATCH, f"{name}_{cam}_{os.path.basename(f)}.wav"))
            if not cw:
                continue
            try:
                off, rk = audalign_offset(spine_wav, cw)
            except Exception as e:
                print(f"  ! audalign {os.path.basename(f)}: {type(e).__name__}")
                off, rk = None, 0
            conf, ratio = "fp", None
            if rk is None or rk < profile["conf_min"]:
                slag, sratio = spec_confirm(spine_wav, cw)
                if slag is not None:
                    off, conf, ratio = slag, "spec", sratio
            # ainda fraco? tenta as espinhas ALTERNATIVAS (outros gravadores da cerimônia). Um match
            # audalign FIRME noutra espinha ganha do spec incerto. Offset convertido pra referência da
            # principal (+delta). Grava via="fp" (é audalign confirmado; downstream trata como confiável)
            # + alt_spine=i p/ rastreio de qual gravador fechou.
            alt_used = None
            if (off is None or conf == "spec") and (rk is None or rk < profile["conf_min"]) and alt_specs:
                for ai in range(len(alt_specs)):
                    aw = _alt_wav(ai)
                    if not aw:
                        continue
                    try:
                        aoff, ark = audalign_offset(aw, cw)
                    except Exception:
                        aoff, ark = None, 0
                    if aoff is not None and ark and ark >= profile["conf_min"]:
                        off, rk, conf, ratio, alt_used = aoff + alt_specs[ai][1], ark, "fp", None, ai
                        break
            _rec = {"cam": cam, "offset_s": round(off, 3) if off is not None else None,
                    "rank": rk, "via": conf, "ratio": ratio}
            if alt_used is not None:
                _rec["alt_spine"] = alt_used
            placements["clips"][os.path.basename(f)] = _rec
            print(f"  {cam} {os.path.basename(f):14} off={off if off is None else round(off,2):>8} "
                  f"rank={rk} via={conf}{f' (espinha alt{alt_used})' if alt_used is not None else ''}", flush=True)
            json.dump(placements, open(out, "w"), indent=1, ensure_ascii=False)  # incremental
    print(f"\n→ {out}  ({len(placements['clips'])} clips)")
    cm.mark_done(".", "sync_ingest", __file__, _input_sig)


if __name__ == "__main__":
    main()
