#!/usr/bin/env python3
"""MASTER INGEST — monta as TRACKS DE ÁUDIO MASTER (gravadores externos da cerimônia) na
timeline, depois do sync. Porte NOVO da lógica do palmier (build_multicam._place_master +
loop masters, sync_ingest helpers de timestamp/audalign) — código próprio, sem dependência
do app GPL.

Cada gravador da cerimônia (masters_cerimonia na decupagem) vira 1 track de áudio:
os chunks do gravador são concatenados GAPLESS num WAV único NOVO no workdir e alinhados à
ESPINHA (mesma âncora do sync das câmeras). A espinha ancora em master_start_frame; os
outros masters recebem offset por TIMESTAMP do nome, refinado por audalign quando confiável.

REGRA #0 (ORIGINAIS READ-ONLY): concat SEMPRE gera arquivo NOVO no workdir; ffmpeg toca no
original só via `-i`. NUNCA escreve/move/apaga o original.

Resiliência (v0.4.1 — root cause Nathália: master sumia da timeline):
  - ESPINHA é obrigatória: concat + grava masters.json IMEDIATO (fsync) antes de extras.
  - Extras são best-effort: falha/timeout/tamanho → pula, NÃO derruba o job (exit 0 se
    há ≥1 master). Antes, audalign em som1 ~7h/2.2GB matava o processo DEPOIS da espinha
    pronta, masters.json nunca reescrito, Swift lia falha e timeline ficava sem master.
  - Nomes únicos quando a mesma pasta vira N grupos (3× "som 3").
  - WAV path único por índice (evita sobrescrever _master_som_3.wav entre grupos).

Saída: <workdir>/masters.json → {"masters":[{name,wavPath,startFrame,durationFrames,espinha}]}

Uso:
  master_ingest.py --decupagem <path> --base <pasta-raiz> --fps <int>
                   --master-start-frame <int> --workdir <dir>
"""
import os, re, sys, json, subprocess, argparse, concurrent.futures

FFMPEG = "/opt/homebrew/bin/ffmpeg"
FFPROBE = "/opt/homebrew/bin/ffprobe"

# Não-espinha maior que isto: NÃO concatena nem audalign — risco OOM/hang (Nathália som1
# ~420min → 2.2GB WAV + fingerprint da espinha matava o job). Decupagem já deveria ter
# trimado à janela cer; se ainda está enorme, é ambiente do dia, não master de cerimônia.
MAX_EXTRA_MIN = 150
# Teto de tempo pro audalign de 1 extra (thread). 180s era pouco: fingerprint de
# espinha ~2h + extra ~45–90min estourava no meio do align e o master BOM era
# descartado "só porque demorou" (Nathália som 3 (2) 47min → timeout). Preferir
# ESPERAR e decidir por rank/timestamp; timeout só pra hang de verdade.
# Não cancela o C-ext de forma hard (GIL), mas o main deixa de esperar e NÃO inclui.
AUDALIGN_TIMEOUT_S = 1200  # 20 min por extra


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, AttributeError):
        return 0.0


def _slug(name):
    return re.sub(r"\W+", "_", name)[:20]


def _ts_secs(name):
    """Segundo-do-dia do timestamp no nome (…_HHMMSS…). Igual ao palmier sync_ingest._ts_secs:
    delta entre gravadores do mesmo relógio dá o offset grosseiro (precisão de 1s)."""
    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 concat_wavs(abs_srcs, out):
    """Encadeia os chunks do gravador num WAV ÚNICO gapless (recorder corta por TAMANHO, não
    tempo -> chunks contíguos). Arquivo NOVO no workdir (READ-ONLY nos originais).
    Retorna path do WAV final ou None."""
    out = os.path.abspath(out)
    srcs = [s for s in abs_srcs if os.path.exists(s)]
    if not srcs:
        return None
    if len(srcs) == 1:
        # Só 1 chunk: NÃO escreve no original — copia/normaliza pra um WAV novo no workdir.
        r = sh([FFMPEG, "-y", "-i", srcs[0], "-ac", "1", "-ar", "44100", out])
        return out if (r.returncode == 0 and os.path.exists(out)) else srcs[0]
    lst = out + ".txt"
    with open(lst, "w") as f:
        for s in srcs:
            f.write(f"file '{s}'\n")
    sh([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", lst,
        "-ac", "1", "-ar", "44100", out])
    return out if os.path.exists(out) else srcs[0]


def _flush_masters(out_path, results):
    """Escreve masters.json com fsync — sobrevive a crash logo depois (espinha já materializável)."""
    tmp = out_path + ".tmp"
    with open(tmp, "w") as f:
        json.dump({"masters": results}, f, indent=1, ensure_ascii=False)
        f.write("\n")
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, out_path)


def _unique_display_name(base, used):
    """Evita 3× 'som 3' colidindo no mediaByName do Swift (último sobrescrevia)."""
    base = (base or "Master").strip() or "Master"
    if base not in used:
        used.add(base)
        return base
    n = 2
    while f"{base} ({n})" in used:
        n += 1
    name = f"{base} ({n})"
    used.add(name)
    return name


def _estimate_min(m, abs_srcs):
    """Duração estimada do grupo: campo decupagem, senão soma ffprobe dos chunks (caro; fallback)."""
    if m.get("dur_min") is not None:
        try:
            return float(m["dur_min"])
        except (TypeError, ValueError):
            pass
    tot = 0.0
    for p in abs_srcs:
        if os.path.exists(p):
            tot += ffdur(p)
    return tot / 60.0


def _audalign_timeout_for(dur_s):
    """Timeout generoso proporcional à duração do extra (fingerprint escala com o áudio).
    Piso 10 min, teto 30 min — o default global (20 min) cobre o caso típico; extras
    muito longos (perto do MAX_EXTRA_MIN) ganham mais folga sem segurar o job pra sempre."""
    # ~8s de budget por minuto de áudio + 10 min de base (fp da espinha reusa cache às vezes,
    # mas o recognize no extra é o caro).
    est = 600 + int(max(0.0, dur_s) / 60.0 * 8)
    return int(min(1800, max(600, est, AUDALIGN_TIMEOUT_S)))


def _audalign_with_timeout(si_mod, spine_wav, clip_wav, timeout_s=None):
    """audalign em thread com timeout. Estourou → (None, 0, 'timeout')."""
    if timeout_s is None:
        timeout_s = AUDALIGN_TIMEOUT_S
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
        fut = ex.submit(si_mod.audalign_offset, spine_wav, clip_wav)
        try:
            off, rk = fut.result(timeout=timeout_s)
            return off, rk, None
        except concurrent.futures.TimeoutError:
            return None, 0, "timeout"
        except Exception as e:
            return None, 0, f"{type(e).__name__}"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--decupagem", required=True)
    ap.add_argument("--base", required=True)
    ap.add_argument("--fps", type=int, required=True)
    ap.add_argument("--master-start-frame", type=int, required=True)
    ap.add_argument("--workdir", required=True)
    args = ap.parse_args()

    fps = args.fps
    base = args.base
    workdir = os.path.abspath(args.workdir)
    os.makedirs(workdir, exist_ok=True)
    master_sf = args.master_start_frame
    out_path = os.path.join(workdir, "masters.json")

    decup = json.load(open(args.decupagem))
    masters = decup.get("masters_cerimonia") or []

    # Fallback limpo: sem masters -> masters.json vazio, sai 0.
    if not masters:
        _flush_masters(out_path, [])
        print("sem masters_cerimonia na decupagem — nada a montar")
        sys.exit(0)

    # Espinha primeiro (âncora exata), depois os outros.
    ordered = sorted(masters, key=lambda m: 0 if m.get("espinha") else 1)

    # ref de timestamp e wav da espinha (refino audalign dos outros masters).
    _sp = next((m for m in masters if m.get("espinha")), masters[0])
    sp_ref = _sp["arquivos"][0] if _sp.get("arquivos") else None

    # audalign é opcional: importado do sync_ingest (mesmo dir). Se falhar, só desabilita o refino.
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    try:
        import sync_ingest as _si
    except Exception as e:
        _si = None
        print(f"! sync_ingest indisponível ({type(e).__name__}) — sem refino audalign")

    results = []
    sp_wav = None  # wav da espinha, pra refinar os outros por audalign
    used_names = set()
    espinha_done = False

    try:
        for mi, m in enumerate(ordered):
            arqs = m.get("arquivos") or []
            pasta = m.get("pasta", "")
            base_name = (pasta.split("/")[-1].strip() or f"Master {mi+1}")
            name = _unique_display_name(base_name, used_names)
            espinha = bool(m.get("espinha"))
            # slug único por índice — 3 grupos "som 3" não sobrescrevem o mesmo WAV
            wav_out = os.path.join(workdir, f"_master_{mi:02d}_{_slug(base_name)}.wav")
            try:
                if not arqs:
                    print(f"! master {name}: sem arquivos — pulando")
                    continue
                abs_srcs = [a if os.path.isabs(a) else os.path.join(base, a) for a in arqs]
                missing = [p for p in abs_srcs if not os.path.exists(p)]
                if len(missing) == len(abs_srcs):
                    print(f"! master {name}: arquivos ausentes em disco — pulando")
                    continue
                if missing:
                    print(f"! master {name}: {len(missing)}/{len(abs_srcs)} chunk(s) ausentes — "
                          f"concat com os que existem")

                est_min = _estimate_min(m, abs_srcs)

                # Não-espinha enorme: NÃO tenta (concat+audalign de horas trava/OOM e, antes,
                # impedia a espinha de chegar na timeline). Espinha nunca é cortada por isto.
                if not espinha and est_min > MAX_EXTRA_MIN:
                    print(f"! master {name}: {est_min:.0f}min > {MAX_EXTRA_MIN}min — "
                          f"NÃO tenta audalign (best-effort; espinha já salva se pronta)")
                    continue

                # Reusa WAV já concatado no workdir (re-run após crash nos extras).
                if os.path.exists(wav_out) and ffdur(wav_out) > 1.0:
                    wav = wav_out
                    print(f"  [reuse] {os.path.basename(wav_out)}")
                else:
                    wav = concat_wavs(abs_srcs, wav_out)
                if not wav:
                    print(f"! master {name}: concat falhou — pulando")
                    continue

                dur_s = ffdur(wav)
                if dur_s <= 0:
                    print(f"! master {name}: duração 0 — pulando")
                    continue
                # Pós-concat: se o WAV ficou enorme (decupagem sem dur_min / trim falhou),
                # extras longos não entram.
                if not espinha and (dur_s / 60.0) > MAX_EXTRA_MIN:
                    print(f"! master {name}: WAV {dur_s/60:.0f}min > {MAX_EXTRA_MIN}min pós-concat "
                          f"— NÃO inclui")
                    continue

                dur_frames = int(round(dur_s * fps))

                if espinha:
                    start_frame = master_sf
                    sp_wav = wav
                    print(f"MASTER {name} @{start_frame} (espinha, {len(arqs)} chunk(s), "
                          f"{dur_s/60:.0f}min)")
                    results.append({
                        "name": name,
                        "wavPath": os.path.abspath(wav),
                        "startFrame": start_frame,
                        "durationFrames": dur_frames,
                        "espinha": True,
                    })
                    # CRÍTICO: flush da espinha ANTES de qualquer extra — se o job morrer
                    # no audalign do som1, masters.json já tem o que a timeline precisa.
                    _flush_masters(out_path, results)
                    espinha_done = True
                    print(f"  [flush] espinha em {out_path} ({len(results)} master(s))")
                    continue

                # Não-espinha: SÓ entra se audalign casar com a espinha (tentar antes de
                # desistir — João v0.4). Timestamp sozinho sem lock acústico = risco de
                # master fantasma dessync; rank baixo → pula (fica só a espinha).
                start_frame = master_sf
                o0 = _ts_secs(arqs[0])
                osp = _ts_secs(sp_ref) if sp_ref else None
                ts_frame = None
                if o0 is not None and osp is not None:
                    ts_frame = master_sf + int(round((o0 - osp) * fps))
                    start_frame = ts_frame
                if _si is None or not sp_wav or os.path.abspath(wav) == os.path.abspath(sp_wav):
                    print(f"! master {name}: sem audalign — NÃO inclui (só espinha confiável)")
                    continue
                to_s = _audalign_timeout_for(dur_s)
                print(f"  tentando audalign {name} ({dur_s/60:.0f}min, timeout {to_s}s)…",
                      flush=True)
                off, rk, err = _audalign_with_timeout(_si, sp_wav, wav, timeout_s=to_s)
                if err == "timeout":
                    print(f"! master {name}: audalign timeout {to_s}s (hang?) — NÃO inclui")
                    continue
                if err:
                    print(f"! master {name}: audalign falhou ({err}) — NÃO inclui")
                    continue
                # rank>=40 = mesmo limiar de âncora cam (CONF_MIN placements); coerente c/ espinha
                if off is None or not rk or rk < 40:
                    print(f"! master {name}: audalign rank{rk} fraco — NÃO inclui (não é espinha)")
                    continue
                refined = master_sf + int(round(off * fps))
                if ts_frame is not None and abs(refined - ts_frame) > 30 * fps:
                    print(f"! master {name}: audalign rank{rk} discorda timestamp "
                          f"({refined} vs {ts_frame} >30s) — NÃO inclui")
                    continue
                start_frame = refined
                print(f"MASTER {name} @{start_frame} (audalign rank{rk}, {len(arqs)}ch, "
                      f"{dur_s/60:.0f}min) — master extra OK")

                results.append({
                    "name": name,
                    "wavPath": os.path.abspath(wav),
                    "startFrame": start_frame,
                    "durationFrames": dur_frames,
                    "espinha": False,
                })
                # Flush após cada extra bem-sucedido (mesmo motivo: crash não perde o que já tem).
                _flush_masters(out_path, results)
            except Exception as e:
                print(f"! master {name}: erro ({type(e).__name__}: {e}) — pulando")
                continue
    except KeyboardInterrupt:
        print("! interrompido — gravando masters já prontos")
    finally:
        # Sempre grava o estado final (mesmo se só espinha, ou vazio).
        _flush_masters(out_path, results)
        print(f"-> {out_path}  ({len(results)} master(s)"
              f"{', espinha OK' if espinha_done else ', SEM espinha'})")

    # exit 0 se temos ≥1 master (timeline pode materializar). exit 0 também se lista vazia
    # (sem masters_cerimonia já saiu acima; aqui vazio = todos falharam — Swift trata [] limpo).
    sys.exit(0)


if __name__ == "__main__":
    main()
