"""Sincroniza gravações feitas ao mesmo tempo (duas ou mais câmeras e/ou um áudio de lapela) pela onda.

Para cada fonte acha o deslocamento contra a principal e grava um JSON que o render_cut.py usa no campo
`imagem_de` de um trecho (o som continua da fonte do trecho e a imagem vem da outra câmera no mesmo instante).

Como mede
1. Envelope: volume em janelas de 10 ms (8 kHz, estéreo com fase invertida vira o canal mais forte), em dB,
   com o chão de ruído aparado e a tendência lenta (1 s) tirada. Microfones diferentes (celular, câmera,
   lapela) têm o mesmo desenho de sílabas, mesmo com volume, timbre e ruído diferentes.
2. Grosso: correlação cruzada dos envelopes em todos os deslocamentos (FFT), dividida pela sobreposição.
   Dá o deslocamento com uns 10 ms de erro, a correlação (Pearson no trecho em comum) e o destaque do pico
   (quantos desvios acima da média das outras posições).
3. Fino: GCC-PHAT a 16 kHz em janelas de 6 s com fala, espalhadas pela gravação, procurando só 0,15 s em
   volta do grosso, com interpolação no pico. Cada janela dá um deslocamento; a nitidez do pico diz se ela
   vale (janela em silêncio não vale).
4. Deriva: com janelas boas espalhadas por 20 s ou mais, ajusta uma reta (deslocamento contra o tempo). Se
   ela anda 2 ms ou mais na gravação, o JSON leva a escala e o render corrige; de 10 ms para cima sai aviso.
   Janela que foge da reta mais de 15 ms é aviso de câmera que parou, perdeu quadros ou cortou a gravação.

Regra do JSON:  tempo na fonte = deslocamento + ajuste_s + escala x tempo na principal.
Deslocamento positivo: a outra fonte começou a gravar antes (o mesmo som aparece mais tarde no arquivo dela).
`ajuste_s` é para correção à mão (conferida no olho numa palma): fica guardado quando o arquivo é refeito.

Saída de avisos: correlação fraca, pico sem destaque (som repetitivo, música), pouca sobreposição, deriva,
sincronia que não é uma reta, fonte sem áudio. Sai com 1 quando alguma fonte ficou fraca (o JSON é gravado
mesmo assim, com "confianca": "fraca").

`--prova` grava, para cada câmera, em <pasta do JSON>/verify/: sincronia_<NOME>.mp4 (as duas imagens lado a
lado, som da principal, 3 s em volta da batida mais forte ou de --em) e sincronia_<NOME>.png (5 quadros
seguidos de cada câmera no instante da batida, principal em cima). Palma, batida na mesa ou claquete: a mão
fecha no mesmo quadro nas duas linhas.

Uso:
  uv run python helpers/sincronizar_cameras.py <principal> <outra> [...] -o <EDIT>/sincronia.json [--prova]
  uv run python helpers/sincronizar_cameras.py CAM1=/v/a.mp4 CAM2=/v/b.mp4 LAPELA=/v/l.wav -o ... --principal LAPELA
  uv run python helpers/sincronizar_cameras.py --edl <EDIT>/edl_manual.json [--principal CAM1] [--prova]
O nome de cada fonte é o nome do arquivo sem extensão (o mesmo da chave de "sources" no edl_manual.json), ou
o que vier antes do "=".
"""

from __future__ import annotations

import argparse
import json
import math
import os
import subprocess
import sys
import tempfile
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import file_signature, load_json, now_iso, probe_video, require_binary, save_json  # noqa: E402

VERSION = 1
HOP_S = 0.01
ENV_SR = 8000
FINE_SR = 16000
FINE_WIN_S = 6.0
FINE_MARGIN_S = 0.15
FINE_BAND_HZ = (150.0, 3800.0)
DETREND_S = 1.0
MIN_OVERLAP_S = 5.0
MIN_OVERLAP_SHARE = 0.2
GOOD_SHARPNESS = 6.0         # pico do GCC-PHAT pelo RMS das outras posições: abaixo disso a janela não vale
DRIFT_MIN_SPAN_S = 20.0
DRIFT_APPLY_S = 0.002        # a reta anda pelo menos isso na gravação: a escala entra no JSON
DRIFT_WARN_S = 0.010
NONLINEAR_WARN_S = 0.015
OUTLIER_S = 0.004
CORR_BOA, CORR_MEDIA = 0.5, 0.3
DESTAQUE_BOM, DESTAQUE_MIN = 6.0, 5.0
PEAK_EXCLUDE_S = 1.0


class SyncError(Exception):
    pass


def utf8_console() -> None:
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
        except (AttributeError, ValueError, OSError):
            pass


# ------------------------------------------------------------------ leitura do áudio


def audio_facts(path: Path) -> dict:
    try:
        info = probe_video(path)
    except Exception as exc:  # noqa: BLE001
        raise SyncError(f"não consegui ler {path.name}: {exc}")
    if not info.get("has_audio"):
        raise SyncError(f"{path.name} não tem áudio: sem som não dá para sincronizar pela onda. Sincronize no olho "
                        "(numa palma) e escreva o deslocamento à mão no JSON")
    return info


def _decode_cmd(path: Path, sr: int, channels: int, start: float | None = None, dur: float | None = None,
                pan: str | None = None) -> list[str]:
    cmd = ["ffmpeg", "-v", "error", "-nostdin"]
    if start is not None:
        cmd += ["-ss", f"{max(0.0, start):.6f}"]
    if dur is not None:
        cmd += ["-t", f"{dur:.6f}"]
    # first_pts=0: o áudio que começa depois do vídeo (AAC com atraso) ganha silêncio na frente, então o tempo 0
    # é o começo do arquivo, o mesmo que o -ss do render_cut usa
    af = f"aresample={sr}:async=1:first_pts=0"
    if pan:
        af += f",{pan}"
    cmd += ["-i", str(path), "-vn", "-map", "0:a:0", "-af", af]
    if not pan:
        cmd += ["-ac", str(channels)]
    cmd += ["-f", "f32le", "-"]
    return cmd


def envelope(path: Path, info: dict) -> tuple[np.ndarray, str]:
    """dB em janelas de 10 ms (índice 0 = tempo 0 do arquivo) e o jeito de ler os canais para o fino."""
    ch = 2 if int(info.get("audio_channels") or 1) >= 2 else 1
    w = int(ENV_SR * HOP_S)
    proc = subprocess.Popen(_decode_cmd(path, ENV_SR, ch), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    block = w * ch * 4 * 2000
    sums: list[np.ndarray] = []
    rest = b""
    assert proc.stdout is not None
    while True:
        chunk = proc.stdout.read(block)
        if not chunk:
            break
        data = rest + chunk
        usable = len(data) - len(data) % (w * ch * 4)
        rest = data[usable:]
        if not usable:
            continue
        x = np.frombuffer(data[:usable], dtype=np.float32).astype(np.float64).reshape(-1, w, ch)
        if ch == 2:
            left, right = x[:, :, 0], x[:, :, 1]
            mid = 0.5 * (left + right)
            sums.append(np.stack([(left ** 2).mean(1), (right ** 2).mean(1), (mid ** 2).mean(1)], axis=1))
        else:
            sums.append((x[:, :, 0] ** 2).mean(1)[:, None])
    err = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
    proc.wait()
    if proc.returncode != 0 or not sums:
        raise SyncError(f"não consegui ler o áudio de {path.name}: {err.strip()[-400:]}")
    p = np.concatenate(sums, axis=0)
    how = "mix"
    if ch == 2:
        total = p.sum(axis=0) + 1e-20
        best = int(np.argmax(total[:2]))
        if 10 * math.log10(total[2] / total[best]) < -6:   # L+R some: fase invertida, usa o canal mais forte
            how = "esquerdo" if best == 0 else "direito"
            p = p[:, best]
        else:
            p = p[:, 2]
    else:
        p = p[:, 0]
    return (10 * np.log10(p + 1e-12)).astype(np.float64), how


def pan_for(how: str) -> str | None:
    return {"esquerdo": "pan=mono|c0=c0", "direito": "pan=mono|c0=c1"}.get(how)


def read_window(path: Path, how: str, start: float, dur: float, sr: int = FINE_SR) -> np.ndarray:
    pan = pan_for(how)
    proc = subprocess.run(_decode_cmd(path, sr, 1, start, dur, pan), capture_output=True)
    if proc.returncode != 0:
        raise SyncError(f"não consegui ler {path.name} em {start:.2f} s: "
                        f"{proc.stderr.decode('utf-8', 'replace').strip()[-300:]}")
    x = np.frombuffer(proc.stdout, dtype=np.float32).astype(np.float64)
    need = int(round(dur * sr))
    if start < 0:   # antes do começo do arquivo: silêncio
        pad = int(round(-start * sr))
        x = np.concatenate([np.zeros(pad), x])
    if len(x) < need:
        x = np.concatenate([x, np.zeros(need - len(x))])
    return x[:need]


# ------------------------------------------------------------------ medida


def feature(db: np.ndarray) -> np.ndarray:
    """Envelope que casa entre microfones: chão aparado, tendência de 1 s tirada, média 0 e desvio 1."""
    floor = float(np.percentile(db, 10))
    loud = float(np.percentile(db, 95))
    e = np.clip(db, max(floor + 3.0, loud - 45.0), None)
    k = max(1, int(round(DETREND_S / HOP_S)))
    kernel = np.ones(k) / k
    trend = np.convolve(np.pad(e, (k // 2, k - 1 - k // 2), mode="edge"), kernel, mode="valid")
    f = e - trend
    sd = float(f.std())
    return (f - f.mean()) / sd if sd > 1e-9 else np.zeros_like(f)


def coarse_offset(fp: np.ndarray, fx: np.ndarray) -> dict:
    """Deslocamento (s) de x contra p pelos envelopes: evento em p no tempo t aparece em x em t + d."""
    n, m = len(fp), len(fx)
    size = 1 << int(math.ceil(math.log2(n + m)))
    spec = np.conj(np.fft.rfft(fp, size)) * np.fft.rfft(fx, size)
    raw = np.fft.irfft(spec, size)
    # lag L de -(n-1) a m-1: r[L] = sum p[t] x[t+L]
    lags = np.arange(-(n - 1), m)
    vals = np.concatenate([raw[size - (n - 1):], raw[:m]])
    overlap = np.minimum(n, m - lags) - np.maximum(0, -lags)
    overlap = np.minimum(overlap, np.minimum(n, m))
    need = max(int(MIN_OVERLAP_S / HOP_S), int(MIN_OVERLAP_SHARE * min(n, m)))
    ok = overlap >= need
    if not ok.any():
        raise SyncError("as gravações são curtas demais para comparar (menos de 5 s em comum)")
    curve = np.full(len(vals), -np.inf)
    curve[ok] = vals[ok] / overlap[ok]
    best = int(np.argmax(curve))
    lag = int(lags[best])
    # destaque: o pico contra as outras posições (fora de 1 s em volta dele)
    far = ok & (np.abs(lags - lag) > int(PEAK_EXCLUDE_S / HOP_S))
    others = curve[far]
    destaque = float((curve[best] - others.mean()) / (others.std() + 1e-9)) if others.size > 10 else 0.0
    segundo = float(others.max()) if others.size else 0.0
    # interpolação no pico (parábola)
    frac = 0.0
    if 0 < best < len(curve) - 1 and np.isfinite(curve[best - 1]) and np.isfinite(curve[best + 1]):
        y0, y1, y2 = curve[best - 1], curve[best], curve[best + 1]
        den = y0 - 2 * y1 + y2
        if abs(den) > 1e-12:
            frac = float(np.clip(0.5 * (y0 - y2) / den, -0.5, 0.5))
    # correlação de verdade (Pearson) no trecho em comum
    a0, a1 = max(0, -lag), min(n, m - lag)
    pa, xa = fp[a0:a1], fx[a0 + lag:a1 + lag]
    corr = float(np.corrcoef(pa, xa)[0, 1]) if len(pa) > 10 and pa.std() > 0 and xa.std() > 0 else 0.0
    return {"deslocamento": (lag + frac) * HOP_S, "correlacao": corr, "destaque": destaque,
            "segundo_pico": round(segundo / curve[best], 3) if curve[best] > 0 else None,
            "sobreposicao_s": float(overlap[best]) * HOP_S}


def gcc_phat(a: np.ndarray, b: np.ndarray, max_k: int, sr: int = FINE_SR) -> tuple[float, float]:
    """(k, nitidez): b[i + k] ~ a[i], k em [0, max_k] com fração de amostra."""
    size = 1 << int(math.ceil(math.log2(len(a) + len(b))))
    win_a = a * np.hanning(len(a))
    A = np.fft.rfft(win_a, size)
    B = np.fft.rfft(b, size)
    R = np.conj(A) * B
    R /= np.abs(R) + 1e-12
    freqs = np.fft.rfftfreq(size, 1.0 / sr)
    R[(freqs < FINE_BAND_HZ[0]) | (freqs > FINE_BAND_HZ[1])] = 0
    r = np.fft.irfft(R, size)[: max_k + 1]
    mag = np.abs(r)
    k = int(np.argmax(mag))
    rest = np.concatenate([mag[: max(0, k - 32)], mag[k + 33:]])
    sharp = float(mag[k] / (np.sqrt(np.mean(rest ** 2)) + 1e-12)) if rest.size else 0.0
    frac = 0.0
    if 0 < k < len(mag) - 1:
        y0, y1, y2 = mag[k - 1], mag[k], mag[k + 1]
        den = y0 - 2 * y1 + y2
        if abs(den) > 1e-12:
            frac = float(np.clip(0.5 * (y0 - y2) / den, -0.5, 0.5))
    return k + frac, sharp


def window_centers(env_p: np.ndarray, t0: float, t1: float) -> list[float]:
    """Centros das janelas finas: uma por fatia da parte em comum, onde a principal tem mais fala."""
    span = t1 - t0
    if span < FINE_WIN_S + 0.5:
        return [0.5 * (t0 + t1)] if span > 1.0 else []
    k = int(min(9, max(3, span // 20)))
    k = max(1, min(k, int(span // FINE_WIN_S)))
    lin = 10 ** (env_p / 10)
    win = int(FINE_WIN_S / HOP_S)
    csum = np.concatenate([[0.0], np.cumsum(lin)])
    out = []
    for i in range(k):
        a = t0 + span * i / k + FINE_WIN_S / 2
        b = t0 + span * (i + 1) / k - FINE_WIN_S / 2
        if b < a:
            a = b = t0 + span * (i + 0.5) / k
        ia, ib = int(a / HOP_S), int(b / HOP_S)
        best, best_e = 0.5 * (a + b), -1.0
        for c in range(ia, ib + 1, 25):
            lo, hi = max(0, c - win // 2), min(len(lin), c + win // 2)
            if hi <= lo:
                continue
            e = (csum[hi] - csum[lo]) / (hi - lo)
            if e > best_e:
                best, best_e = c * HOP_S, e
        out.append(best)
    return out


def fine_offsets(p: dict, x: dict, coarse: float, dur_p: float, dur_x: float) -> list[dict]:
    t0 = max(0.0, -coarse) + 0.2
    t1 = min(dur_p, dur_x - coarse) - 0.2
    rows = []
    max_k = int(round(2 * FINE_MARGIN_S * FINE_SR))
    for tc in window_centers(p["env"], t0, t1):
        start_p = tc - FINE_WIN_S / 2
        a = read_window(p["path"], p["how"], start_p, FINE_WIN_S)
        start_x = start_p + coarse - FINE_MARGIN_S
        b = read_window(x["path"], x["how"], start_x, FINE_WIN_S + 2 * FINE_MARGIN_S)
        if np.sqrt(np.mean(a ** 2)) < 1e-5 or np.sqrt(np.mean(b ** 2)) < 1e-5:
            continue
        k, sharp = gcc_phat(a, b, max_k)
        d = start_x - start_p + k / FINE_SR
        rows.append({"em": round(tc, 3), "deslocamento": d, "nitidez": round(sharp, 1)})
    return rows


def fit_line(rows: list[dict]) -> tuple[float, float, list[dict], float]:
    """(a, b, janelas usadas, maior resíduo) de d = a + b t, tirando o que foge mais de OUTLIER_S."""
    use = [r for r in rows if r["nitidez"] >= GOOD_SHARPNESS]
    if not use:
        return float("nan"), 0.0, [], 0.0
    for _ in range(3):
        t = np.array([r["em"] for r in use])
        d = np.array([r["deslocamento"] for r in use])
        wts = np.array([r["nitidez"] for r in use])
        if len(use) >= 2 and t.max() - t.min() >= DRIFT_MIN_SPAN_S:
            A = np.stack([np.ones_like(t), t], axis=1) * np.sqrt(wts)[:, None]
            a, b = np.linalg.lstsq(A, d * np.sqrt(wts), rcond=None)[0]
        else:
            a, b = float(np.median(d)), 0.0
        res = d - (a + b * t)
        worst = int(np.argmax(np.abs(res)))
        if abs(res[worst]) <= OUTLIER_S or len(use) <= 3:
            break
        use = [r for i, r in enumerate(use) if i != worst]
    t = np.array([r["em"] for r in use])
    d = np.array([r["deslocamento"] for r in use])
    all_t = np.array([r["em"] for r in rows if r["nitidez"] >= GOOD_SHARPNESS])
    all_d = np.array([r["deslocamento"] for r in rows if r["nitidez"] >= GOOD_SHARPNESS])
    worst_all = float(np.max(np.abs(all_d - (a + b * all_t)))) if all_t.size else 0.0
    return float(a), float(b), use, worst_all


def measure_pair(p: dict, x: dict, fps: float) -> dict:
    coarse = coarse_offset(p["feat"], x["feat"])
    d0 = coarse["deslocamento"]
    rows = fine_offsets(p, x, d0, p["dur"], x["dur"])
    a, b, used, worst = fit_line(rows)
    avisos: list[str] = []
    span_t = (max(r["em"] for r in used) - min(r["em"] for r in used)) if len(used) >= 2 else 0.0
    t_lo = max(0.0, -d0)
    t_hi = min(p["dur"], x["dur"] - d0)
    over = max(0.0, t_hi - t_lo)
    if not used:
        desloc, escala = d0, 1.0
        avisos.append("não achei janela com fala nítida nas duas para o ajuste fino: ficou só a medida grossa "
                      "(uns 10 ms de erro). Confira numa palma")
    else:
        desloc, escala = a, 1.0 + b
        total = abs(b) * over
        if span_t < DRIFT_MIN_SPAN_S or total < DRIFT_APPLY_S:
            desloc, escala = float(np.median([r["deslocamento"] for r in used])), 1.0
        if abs(desloc - d0) > 0.05:
            avisos.append(f"a medida fina ({desloc:+.3f} s) ficou longe da grossa ({d0:+.3f} s): confira numa palma")
    deriva_ms_min = (escala - 1.0) * 60_000.0
    deriva_total = abs(escala - 1.0) * over
    if deriva_total >= DRIFT_WARN_S:
        avisos.append(f"deriva de relógio: a fonte anda {deriva_ms_min:+.1f} ms por minuto contra a principal "
                      f"({deriva_total * 1000:.0f} ms ao longo de {over / 60:.1f} min). O render corrige pela reta "
                      "(escala no JSON); confira a sincronia no começo e no fim")
    if worst > NONLINEAR_WARN_S:
        far = max((r for r in rows if r["nitidez"] >= GOOD_SHARPNESS),
                  key=lambda r: abs(r["deslocamento"] - (desloc + (escala - 1.0) * r["em"])))
        avisos.append(f"a diferença entre as fontes não é uma reta (foge até {worst * 1000:.0f} ms, perto de "
                      f"{far['em']:.1f} s da principal): a câmera pode ter parado, perdido quadros ou cortado a "
                      "gravação. Confira no olho ali e, se for isso, sincronize cada pedaço à parte")
    corr, dest = coarse["correlacao"], coarse["destaque"]
    if corr >= CORR_BOA and dest >= DESTAQUE_BOM and used and 2 * len(used) >= len(rows):
        conf = "boa"
    elif corr >= CORR_MEDIA and dest >= DESTAQUE_MIN and used:
        conf = "media"
    else:
        conf = "fraca"
        avisos.append(f"correlação fraca (envelope {corr:.2f}, destaque {dest:.1f}): o som das duas quase não se "
                      "parece. Não confie; confira numa palma e, se precisar, escreva o deslocamento à mão")
    if coarse["segundo_pico"] is not None and coarse["segundo_pico"] > 0.8 and conf != "fraca":
        avisos.append("há outro deslocamento quase tão bom (som repetitivo, música de fundo): confira numa palma")
    if over < 30:
        avisos.append(f"só {over:.0f} s em comum entre as duas gravações")
    step = 1.0 / fps if fps else 1 / 30
    return {
        "deslocamento": round(desloc, 5), "escala": round(escala, 9), "ajuste_s": 0.0,
        "deriva_ms_por_min": round(deriva_ms_min, 2), "confianca": conf,
        "correlacao": round(corr, 3), "destaque": round(dest, 1), "grosso": round(d0, 4),
        "em_comum_s": round(over, 2), "em_comum_na_principal": [round(t_lo, 3), round(t_hi, 3)],
        "janelas": [{"em": r["em"], "deslocamento": round(r["deslocamento"], 5), "nitidez": r["nitidez"],
                     "usada": r in used} for r in rows],
        "maior_desvio_ms": round(worst * 1000, 1), "quadro_s": round(step, 5), "avisos": avisos,
    }


# ------------------------------------------------------------------ prova no olho


def strongest_hit(env: np.ndarray, t0: float, t1: float) -> float:
    """A batida mais forte (maior subida de volume em 30 ms) entre t0 e t1 da principal."""
    i0, i1 = max(3, int((t0 + 1.6) / HOP_S)), min(len(env), int((t1 - 1.6) / HOP_S))
    if i1 <= i0:
        return 0.5 * (t0 + t1)
    rise = env[i0:i1] - env[i0 - 3:i1 - 3]
    return (i0 + int(np.argmax(rise))) * HOP_S


def make_proof(doc: dict, name: str, t_p: float, out_dir: Path) -> list[Path]:
    fontes = doc["fontes"]
    p, x = fontes[doc["principal"]], fontes[name]
    pinfo, xinfo = probe_video(p["arquivo"]), probe_video(x["arquivo"])
    if not pinfo.get("has_video") or not xinfo.get("has_video"):
        return []
    t_x = x["deslocamento"] + x.get("ajuste_s", 0.0) + x["escala"] * t_p
    out_dir.mkdir(parents=True, exist_ok=True)
    mp4, png = out_dir / f"sincronia_{name}.mp4", out_dir / f"sincronia_{name}.png"
    fps = float(pinfo.get("fps") or 30.0)
    half = 1.5
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-ss", f"{max(0.0, t_p - half):.4f}", "-t", f"{2 * half}",
                    "-i", p["arquivo"], "-ss", f"{max(0.0, t_x - half):.4f}", "-t", f"{2 * half}", "-i", x["arquivo"],
                    "-filter_complex", "[0:v]scale=-2:640,setsar=1[a];[1:v]scale=-2:640,setsar=1[b];[a][b]hstack[v]",
                    "-map", "[v]", "-map", "0:a:0?", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
                    "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", str(mp4)], capture_output=True)
    with tempfile.TemporaryDirectory() as tmp:
        rows = []
        for i, (src, t) in enumerate(((p["arquivo"], t_p), (x["arquivo"], t_x))):
            row = Path(tmp) / f"linha{i}.png"
            subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-ss", f"{max(0.0, t - 2 / fps):.4f}", "-i", src,
                            "-frames:v", "5", "-vf", "scale=-2:300,setsar=1,tile=5x1:padding=4:color=white",
                            "-frames:v", "1", str(row)], capture_output=True)
            if row.exists():
                rows.append(row)
        if len(rows) == 2:
            subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-i", str(rows[0]), "-i", str(rows[1]),
                            "-filter_complex", "[0:v]pad=iw:ih+8:0:0:white[a];[1:v][a]scale2ref=iw:ow/mdar[b][a2];"
                            "[a2][b]vstack", "-frames:v", "1", str(png)], capture_output=True)
            if not png.exists():
                subprocess.run(["ffmpeg", "-v", "error", "-y", "-nostdin", "-i", str(rows[0]), "-i", str(rows[1]),
                                "-filter_complex", "[1:v][0:v]scale2ref=iw:ih[b][a];[a][b]vstack", "-frames:v", "1",
                                str(png)], capture_output=True)
    return [f for f in (mp4, png) if f.exists()]


# ------------------------------------------------------------------ main


def parse_sources(items: list[str]) -> dict[str, Path]:
    out: dict[str, Path] = {}
    for it in items:
        name, sep, rest = it.partition("=")
        if sep and rest and "/" not in name and "\\" not in name:
            path = Path(rest).expanduser()
        else:
            path = Path(it).expanduser()
            name = path.stem
        if name in out:
            raise SyncError(f"dois arquivos com o nome {name!r}: use NOME=caminho para dar nomes diferentes")
        out[name] = Path(os.path.abspath(path))
    return out


def sources_from_edl(edl_path: Path) -> dict[str, Path]:
    edl = load_json(edl_path)
    if not isinstance(edl, dict) or not isinstance(edl.get("sources"), dict) or not edl["sources"]:
        raise SyncError(f"{edl_path} não tem \"sources\"")
    base = edl_path.parent
    out = {}
    for k, v in edl["sources"].items():
        p = Path(str(v)).expanduser()
        if not p.is_absolute():
            cands = [base / p, base.parent / p]
            p = next((c for c in cands if c.exists()), cands[0])
        out[str(k)] = Path(os.path.abspath(p))
    return out


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Sincroniza câmeras e áudio de lapela gravados ao mesmo tempo pela onda.")
    ap.add_argument("fontes", nargs="*", help="arquivos (ou NOME=arquivo); o primeiro é a principal, se não houver --principal")
    ap.add_argument("--edl", type=Path, help="edl_manual.json: sincroniza as fontes de \"sources\"")
    ap.add_argument("--principal", help="nome da fonte principal (padrão: a primeira)")
    ap.add_argument("-o", "--saida", type=Path, help="JSON de saída (padrão com --edl: sincronia.json na pasta do EDL)")
    ap.add_argument("--prova", action="store_true", help="grava verify/sincronia_<NOME>.mp4 e .png para conferir no olho")
    ap.add_argument("--em", type=float, help="com --prova: segundo da principal para a prova (padrão: a batida mais forte)")
    args = ap.parse_args(argv)
    utf8_console()
    try:
        require_binary("ffmpeg")
        require_binary("ffprobe")
        sources = sources_from_edl(args.edl.expanduser().absolute()) if args.edl else {}
        sources.update(parse_sources(args.fontes))
        if len(sources) < 2:
            raise SyncError("preciso de pelo menos dois arquivos gravados ao mesmo tempo")
        missing = [f"{k} ({v})" for k, v in sources.items() if not v.is_file()]
        if missing:
            raise SyncError("não encontrei: " + ", ".join(missing))
        principal = args.principal or next(iter(sources))
        if principal not in sources:
            raise SyncError(f"--principal {principal!r} não é uma das fontes ({', '.join(sources)})")
        saida = args.saida or (args.edl.expanduser().absolute().parent / "sincronia.json" if args.edl else None)
        if saida is None:
            raise SyncError("diga onde gravar: -o <EDIT>/sincronia.json")
        saida = Path(os.path.abspath(saida.expanduser()))

        data: dict[str, dict] = {}
        for name, path in sources.items():
            info = audio_facts(path)
            env, how = envelope(path, info)
            data[name] = {"path": path, "info": info, "env": env, "how": how, "feat": feature(env),
                          "dur": len(env) * HOP_S}
        old = load_json(saida, default={}) or {}
        old_fontes = old.get("fontes") if isinstance(old, dict) and isinstance(old.get("fontes"), dict) else {}
        p = data[principal]
        fps = float(p["info"].get("fps") or 0) or 30.0
        fontes: dict[str, dict] = {}
        for name, d in data.items():
            base = {"arquivo": str(d["path"]), "assinatura": file_signature(d["path"]),
                    "tem_imagem": bool(d["info"].get("has_video")), "canal": d["how"]}
            if name == principal:
                fontes[name] = {**base, "deslocamento": 0.0, "escala": 1.0, "ajuste_s": 0.0, "confianca": "principal"}
                continue
            res = measure_pair(p, d, fps)
            prev = old_fontes.get(name) if isinstance(old_fontes.get(name), dict) else None
            if prev and prev.get("arquivo") == str(d["path"]) and old.get("principal") == principal:
                try:
                    res["ajuste_s"] = float(prev.get("ajuste_s") or 0.0)
                except (TypeError, ValueError):
                    pass
            fontes[name] = {**base, **res}
        doc = {"versao": VERSION, "criado_em": now_iso(), "principal": principal,
               "regra": "tempo na fonte = deslocamento + ajuste_s + escala x tempo na principal",
               "fontes": fontes}
        save_json(saida, doc)
    except SyncError as exc:
        print(f"erro: {exc}", file=sys.stderr)
        return 2

    print(f"Sincronia contra {principal} ({Path(fontes[principal]['arquivo']).name}):")
    fraca = False
    for name, f in fontes.items():
        if name == principal:
            continue
        frames = f["deslocamento"] * fps
        extra = f", ajuste à mão {f['ajuste_s']:+.3f} s" if f.get("ajuste_s") else ""
        drift = f", deriva {f['deriva_ms_por_min']:+.1f} ms/min" if f["escala"] != 1.0 else ""
        print(f"  {name}: {f['deslocamento']:+.3f} s ({frames:+.1f} quadros){drift}{extra} · confiança {f['confianca']} "
              f"(correlação {f['correlacao']:.2f}, destaque {f['destaque']:.0f}, "
              f"{sum(1 for j in f['janelas'] if j['usada'])}/{len(f['janelas'])} janelas finas)")
        for a in f["avisos"]:
            print(f"    aviso: {a}")
        fraca = fraca or f["confianca"] == "fraca"
    print(f"  gravado em {saida}")
    if args.prova:
        for name, f in fontes.items():
            if name == principal:
                continue
            lo, hi = f["em_comum_na_principal"]
            t = args.em if args.em is not None else strongest_hit(data[principal]["env"], lo, hi)
            made = make_proof(doc, name, t, saida.parent / "verify")
            if made:
                print(f"  prova de {name} em {t:.2f} s da principal: " + ", ".join(str(m) for m in made))
            else:
                print(f"  prova de {name}: uma das duas não tem imagem; confira de ouvido")
    return 1 if fraca else 0


if __name__ == "__main__":
    sys.exit(main())
