#!/usr/bin/env python3
"""Plano de efeitos sonoros casado com a animação (references/som-e-trilha.md).

Recebe a lista de eventos visuais do vídeo (o instante em que cada animação "bate") e devolve o
JSON de mixagem: qual som, onde ele começa, com que ganho, com o PICO do som caindo no quadro do
evento. Tira o excesso (espaço mínimo entre sons, fica o mais forte) e respeita um máximo por vídeo.

Eventos (JSON, lista, {"eventos": [...]} ou o sons_sugeridos.json do modelo-projeto, com "sons" e "em"):
    [{"t": 1.23, "tipo": "pop"},
     {"t": 4.10, "tipo": "impacto", "nivel": "destaque"},
     {"t": 7.64, "tipo": "whoosh-grande", "forcar": true, "nota": "tela cheia vira moldura"},
     {"t": 9.00, "som": "woven:notification-ding", "nivel": -11},
     {"t": 12.0, "tipo": "digitar", "dur": 1.2}]
    t      segundos no vídeo, o quadro em que o movimento bate (pouso do pop, meio do deslize)
    tipo   um dos tipos de --tipos (aceita apelidos: "aparece", "deslize", "carimbo"...)
    som    opcional, troca o som do tipo: woven:ID, kit:NOME, bib:ID, bib:categoria[:nome] ou caminho
    nivel  opcional: "destaque" (-12), "medio" (-15), "discreto" (-18) ou número em dBFS de pico
    db     o nível que a peça pede no sons_sugeridos.json do build (dBFS de pico): vale quando não tem "nivel"
    forcar opcional: fica mesmo perto de outro som e mesmo passando do máximo
    dur    opcional: quanto tempo o som toca (digitação, aplauso)

Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
    uv run --directory "<SKILL>" python helpers/plano_sfx.py --projeto "<PROJ>" [eventos.json ...]
        o caminho do projeto-modelo: lê <PROJ>/sons_sugeridos.json (e <PROJ>/eventos.json, se existir, com os
        eventos das cenas próprias), monta o plano, copia os sons para <PROJ>/assets/sfx e escreve o campo "sons"
        do <PROJ>/projeto.json. Depois: python3 build.py completo (o estúdio toca os sons) e o render.
    uv run --directory "<SKILL>" python helpers/plano_sfx.py eventos.json [...] [--saida plano_sfx.json]
        [--video final.mp4 | --duracao 58.2] [--modo motion|fala] [--max N] [--espaco 0.65] [--variar]
        [--aplicar final.mp4 --video-saida final_com_sons.mp4]
    uv run --directory "<SKILL>" python helpers/plano_sfx.py --tipos            lista os tipos e os sons de cada um
    uv run --directory "<SKILL>" python helpers/plano_sfx.py --medir ARQ.wav    duração, pico e começo de um som

Máximo de sons: no modo motion (padrão) é um por animação, e o que decide é o espaço mínimo entre sons (0,65 s,
fica o mais forte). No modo fala, uns 5 ou 6 no vídeo (um a cada 15 s). --max N troca.

Onde os sons moram (tudo dentro da skill, menos a biblioteca):
    woven:ID  <SKILL>/assets/sfx/woven/ID.wav (35 sons Woven SFX, CC0)
    kit:NOME  <SKILL>/assets/sfx (índice sfx.json); outra pasta: variável SC_KIT_SFX
    bib:...   ~/.editor-video/efeitos (índice efeitos.json); outra pasta: SC_BIBLIOTECA_SFX ou EDITOR_VIDEO_EFEITOS
"""
from __future__ import annotations

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

try:
    import numpy as np
except ImportError:  # pragma: no cover
    sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/plano_sfx.py ...')

SKILL = Path(__file__).resolve().parent.parent
WOVEN = SKILL / "assets" / "sfx" / "woven"
KIT = Path(os.environ.get("SC_KIT_SFX") or SKILL / "assets" / "sfx")
BIB = Path(os.environ.get("SC_BIBLIOTECA_SFX") or os.environ.get("EDITOR_VIDEO_EFEITOS")
           or Path.home() / ".editor-video" / "efeitos").expanduser()

VOZ_REF_LUFS = -14.0      # os níveis da tabela valem com a voz perto de -14 LUFS
# pico de amostra de verdade, medido no arquivo. Calibrado no IMG_1498 v2 (aprovado): destaques perto de -12,
# pequenos perto de -18, com a voz em -14 LUFS.
NIVEIS = {"destaque": -12.0, "forte": -12.0, "medio": -15.0, "médio": -15.0, "discreto": -18.0, "fraco": -18.0,
          "baixo": -20.0}
GANHO_MAX_DB = 12.0

# tipo: (quando usar, sons em ordem de preferência, nível do pico em dBFS, alinhar em, cauda s depois da âncora, apelidos)
TIPOS = {
    "pop": ("algo aparece: texto, ícone, cartão, balão", ["woven:pop-hand", "kit:pop"], -18.5, "pico", 0.8,
            ["aparece", "surge", "bolha", "icone", "cartao", "balao", "entra-texto"]),
    "whoosh": ("algo desliza, entra ou sai rápido da tela", ["woven:fast-whoosh", "kit:whoosh"], -17.5, "pico", 0.8,
               ["deslize", "desliza", "entra", "sai", "voa", "passa", "swipe", "transicao", "swoosh"]),
    "whoosh-grande": ("troca grande de cena: tela cheia vira moldura, a câmera gira, painel inteiro entra",
                      ["woven:swish-whoosh-large", "woven:deep-whoosh"], -12.5, "pico", 0.8,
                      ["troca-de-cena", "moldura", "tela-cheia", "virada", "giro", "whoosh-grave"]),
    "impacto": ("algo pousa com força: carimbo, número final, palavra grande que cai",
                ["woven:bass-impact", "kit:bass_drop"], -11.5, "pico", 0.8,
                ["carimbo", "pousa", "bate-forte", "numero", "titulo", "tremida", "boom"]),
    "revelacao": ("a grande revelação: logo, produto, placa final (1 ou 2 por vídeo)",
                  ["woven:base-drop", "kit:metal_hit"], -11.5, "pico", 1.2,
                  ["drop", "grande-revelacao", "logo", "marca", "placa", "produto"]),
    "batida": ("algo bate ou encaixa sem drama: item que empilha, peça que encaixa", ["woven:dull-thud"], -14.5, "pico",
               0.6, ["thud", "bate", "encaixa", "empilha", "encosta"]),
    "risco": ("algo é riscado, cancelado ou negado", ["woven:record-needle-scratch", "woven:sideways-record-needle"],
              -12, "pico", 0.8, ["riscar", "risca", "cancela", "x", "nao", "scratch", "anula"]),
    "notificacao": ("notificação chega, venda cai, celular recebe", ["woven:notification-ding", "kit:notification"],
                    -12.5, "pico", 0.9, ["ding", "chegou", "alerta", "venda-caiu", "pix"]),
    "mensagem": ("balão de conversa, botão final, resposta no chat", ["woven:message-pop-reply"], -15.5, "pico", 0.8,
                 ["botao", "resposta", "chat", "whatsapp", "balao-de-conversa"]),
    "glitch": ("tela liga, IA entra, falha digital", ["woven:glitch-logo", "kit:data"], -13.5, "pico", 0.7,
               ["tecnologia", "ia", "liga", "falha", "digital", "interferencia"]),
    "clique": ("cursor clica, dedo toca num botão", ["woven:mouse-click-2", "woven:mouse-click-1", "kit:mouse_click"],
               -18, "pico", 0.5, ["click", "cursor", "toque", "tap", "seleciona", "aperta"]),
    "digitar": ("texto sendo digitado na tela (use \"dur\" com o tempo da digitação)",
                ["woven:keyboard-typing-realistic", "kit:keyboard"], -22, "inicio", 1.0,
                ["teclado", "digitando", "escrevendo", "digitacao"]),
    "tique": ("item marcado, check, contagem", ["woven:tick-sound", "kit:tick"], -19, "pico", 0.4,
              ["tick", "check", "marca", "item", "contagem", "conta", "confere"]),
    "beep": ("bipe curto de interface", ["woven:beep-short"], -19, "pico", 0.5, ["bipe", "bip"]),
    "dinheiro": ("moedas, dinheiro, preço que aparece", ["woven:several-coins", "kit:cash", "woven:money-chalking"],
                 -15, "pico", 0.9, ["moeda", "moedas", "venda", "caixa", "preco", "grana"]),
    "camera": ("print, foto, captura de tela", ["woven:camera-shutter-1-shot", "kit:click"], -15, "pico", 0.5,
               ["print", "foto", "flash", "captura", "obturador"]),
    "subida": ("suspense que cresce até a revelação (o pico é o fim da subida)", ["kit:rise", "kit:metal_riser"],
               -15, "pico", 0.3, ["riser", "cresce", "sobe", "suspense", "tensao"]),
    "sucesso": ("deu certo, meta batida, tarefa concluída", ["kit:success"], -15, "pico", 0.9,
                ["certo", "deu-certo", "aprovado", "ok", "concluido"]),
    "erro": ("errado, o que não fazer", ["kit:error"], -15, "pico", 0.8, ["errado", "falhou", "negado"]),
    "surpresa": ("reação de espanto (usar pouco)", ["woven:female-gasp", "woven:male-gasp"], -17, "pico", 0.6,
                 ["gasp", "uau", "espanto"]),
    "aplauso": ("conquista, fechamento de história (usar pouco)", ["woven:cheers-and-applause", "woven:applause"],
                -22, "inicio", 2.0, ["palmas", "comemora", "conquista"]),
}


def norm(txt: str) -> str:
    t = unicodedata.normalize("NFKD", str(txt)).encode("ascii", "ignore").decode().lower().strip()
    return t.replace(" ", "-").replace("_", "-")


APELIDO = {}
for _tipo, _d in TIPOS.items():
    APELIDO[norm(_tipo)] = _tipo
    for _a in _d[5]:
        APELIDO.setdefault(norm(_a), _tipo)


def erro(msg: str, code: int = 2):
    print("ERRO: " + msg, file=sys.stderr)
    sys.exit(code)


def precisa_ffmpeg():
    for b in ("ffmpeg", "ffprobe"):
        if not shutil.which(b):
            erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")


# ------------------------------------------------------------------ onde estão os sons
def kit_indice() -> dict:
    p = KIT / "sfx.json"
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text())
    except Exception:
        return {}


def bib_indice() -> dict:
    p = BIB / "efeitos.json"
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text()).get("efeitos", {})
    except Exception:
        return {}


_BIB_GIRO: dict = {}


def resolver_som(ref: str) -> Path | None:
    """woven:ID, kit:NOME, bib:ID, bib:categoria[:nome], nome solto ou caminho -> arquivo (ou None)"""
    ref = str(ref).strip()
    p = Path(os.path.expanduser(ref))
    if p.suffix.lower() in (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aif", ".aiff") and p.exists():
        return p.resolve()
    fonte, _, nome = ref.partition(":")
    if not nome:
        fonte, nome = "", ref
    fonte = fonte.lower()
    if fonte in ("", "woven"):
        w = WOVEN / f"{nome}.wav"
        if w.exists():
            return w
        if fonte == "woven":
            return None
    if fonte in ("", "kit"):
        k = kit_indice().get(nome)
        if k and (KIT / k["file"]).exists():
            return KIT / k["file"]
        if fonte == "kit":
            return None
    if fonte in ("", "bib", "biblioteca"):
        ind = bib_indice()
        if nome in ind and (BIB / ind[nome]["arquivo"]).exists():
            return BIB / ind[nome]["arquivo"]
        cat, _, sub = nome.partition(":")
        achados = [k for k, v in sorted(ind.items()) if v.get("categoria") == cat
                   and (not sub or norm(sub) in norm(v.get("nome", "")))]
        if achados:   # cada chamada pega outra versão da mesma categoria
            i = _BIB_GIRO.get(nome, 0)
            _BIB_GIRO[nome] = i + 1
            return BIB / ind[achados[i % len(achados)]]["arquivo"]
    return None


def id_do_arquivo(p: Path) -> str:
    try:
        if p.parent == WOVEN:
            return "woven:" + p.stem
        if KIT in p.parents:
            for nome, v in kit_indice().items():
                if (KIT / v["file"]).resolve() == p.resolve():
                    return "kit:" + nome
        if BIB in p.parents:
            return "bib:" + p.stem
    except Exception:
        pass
    return str(p)


# ------------------------------------------------------------------ medir o som
_MEDIDAS: dict = {}


def ponto_forte(m: "np.ndarray", sr: int) -> int:
    """o "pico" que o ouvido sente: o 1º instante em que o som chega a 1 dB do máximo. Em som com vários picos
    quase iguais (glitch, risco de disco, impacto saturado) o máximo absoluto pode cair no fim do trecho forte;
    o começo do trecho forte é o quadro do movimento."""
    if not len(m):
        return 0
    w = max(1, int(sr * 0.005))
    n = len(m) // w
    if n == 0:
        return int(m.argmax())
    blocos = m[: n * w].reshape(n, w).max(axis=1)
    k = int(np.nonzero(blocos >= blocos.max() * 10 ** (-1 / 20))[0][0])
    lo = k * w
    return lo + int(m[lo: lo + 2 * w].argmax())


def medir(p: Path) -> dict:
    """duração, tempo e nível do pico (máximo entre canais, na taxa nativa) e começo do som"""
    key = str(p)
    if key in _MEDIDAS:
        return _MEDIDAS[key]
    info = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
                           "stream=sample_rate,channels", "-of", "csv=p=0", str(p)],
                          capture_output=True, text=True).stdout.strip().split(",")
    if len(info) < 2 or not info[0]:
        erro(f"não consegui ler o som {p}")
    sr, ch = int(info[0]), int(info[1])
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(p), "-f", "f32le", "-acodec", "pcm_f32le", "-"],
                         capture_output=True).stdout
    a = np.frombuffer(raw, dtype=np.float32)
    a = a[: len(a) // ch * ch].reshape(-1, ch)
    m = np.abs(a).max(axis=1) if len(a) else np.zeros(1)
    i = ponto_forte(m, sr)
    pk = float(m.max()) or 1e-6
    pico_db = 20 * math.log10(pk)
    w = max(1, int(sr * 0.005))            # começo: 1ª janela de 5 ms a menos de 35 dB do pico
    n = len(m) // w
    rms = np.sqrt((a[: n * w].astype(np.float64) ** 2).mean(axis=1).reshape(n, w).mean(axis=1)) if n else np.zeros(1)
    acima = np.nonzero(20 * np.log10(rms + 1e-12) > pico_db - 35)[0]
    inicio = float(acima[0] * w / sr) if len(acima) else 0.0
    pico_s, origem = i / sr, "medido"
    cur = ancora_curada(p)
    if cur is not None and 0 <= cur < len(a) / sr:   # kit da skill e biblioteca: o ponto forte já foi escolhido
        pico_s, origem = cur, "curado"
    nb = len(m) // w
    blocos = m[: nb * w].reshape(nb, w).max(axis=1) if nb else m[:1]
    _MEDIDAS[key] = dict(dur=round(len(a) / sr, 4), pico_s=round(pico_s, 4), pico_db=round(pico_db, 2),
                         inicio_s=round(min(inicio, pico_s), 4), ponto=origem, sr=sr, canais=ch,
                         _blocos=blocos, _bloco_s=w / sr)
    return _MEDIDAS[key]


def pico_no_trecho(md: dict, t0: float, t1: float) -> float:
    """pico (dBFS) só do pedaço do arquivo que vai tocar"""
    b, bs = md["_blocos"], md["_bloco_s"]
    i0, i1 = int(t0 / bs), max(int(t0 / bs) + 1, int(math.ceil(t1 / bs)))
    v = float(b[i0:i1].max()) if len(b[i0:i1]) else 10 ** (md["pico_db"] / 20)
    return 20 * math.log10(max(v, 1e-6))


def ancora_curada(p: Path):
    """anchor_s do kit da skill (assets/sfx/sfx.json) ou da biblioteca (efeitos.json), se o arquivo for de lá"""
    try:
        rp = p.resolve()
        if KIT.resolve() in rp.parents:
            for v in kit_indice().values():
                if (KIT / v["file"]).resolve() == rp:
                    return float(v["anchor_s"]) if v.get("anchor_s") is not None else None
        if BIB.resolve() in rp.parents:
            for v in bib_indice().values():
                if (BIB / v["arquivo"]).resolve() == rp:
                    return float(v["anchor_s"]) if v.get("anchor_s") is not None else None
    except (OSError, ValueError, KeyError):
        return None
    return None


def duracao_midia(p: str) -> float:
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", p],
                         capture_output=True, text=True).stdout.strip()
    try:
        return float(out)
    except ValueError:
        erro(f"não consegui ler a duração de {p}")


def loudness(p: str, af: str = "") -> dict:
    r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
                        af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
                       capture_output=True, text=True).stderr
    try:
        return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
    except ValueError:
        erro(f"não consegui medir o volume de {p}")


def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
    """-14 LUFS em 2 passadas, sempre linear (não mexe na dinâmica). Se o pico real passaria de -1 dBTP,
    um limitador com atraso compensado (latency=1, não desloca o áudio) segura só os picos antes."""
    j = loudness(wav)
    ganho = alvo - float(j["input_i"])
    pre = ""
    if float(j["input_tp"]) + ganho > tp - 0.2:
        lim = 10 ** ((tp - ganho - 1.0) / 20)
        pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
        j = loudness(wav, pre)
    af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
          f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
          ":linear=true:print_format=json,aresample=48000")
    return af, j

def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
    """junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
    passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
    picos; o volume integrado continua -14 LUFS)."""
    tp = -1.0
    for tentativa in range(2):
        af, j = cadeia_loudnorm(wav, tp=tp)
        r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
                            "-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
                            "-movflags", "+faststart", saida], capture_output=True, text=True)
        if r.returncode:
            erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
        tipo = "linear"
        try:
            tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
                                                                                           "linear")
        except ValueError:
            pass
        fim = loudness(saida)
        if float(fim["input_tp"]) <= -0.5:
            break
        tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
    return j, tipo, fim



def audio_mono(p: str, sr: int = 16000) -> "np.ndarray":
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
                          "-acodec", "pcm_f32le", "-"], capture_output=True).stdout
    return np.frombuffer(raw, dtype=np.float32)


def desvio_sincronia(ref: str, out: str, janelas: int = 10, sr: int = 16000) -> float:
    """maior deslocamento (ms) do áudio de saída em relação ao de entrada, medido em janelas de 1 s com fala"""
    a, b = audio_mono(ref, sr), audio_mono(out, sr)
    n = min(len(a), len(b))
    w, L = sr, int(0.05 * sr)
    if n < 3 * w:
        return 0.0
    en = [float((a[i:i + w] ** 2).mean()) for i in range(L, n - w - L, w)]
    idx = sorted(range(len(en)), key=lambda k: -en[k])[:janelas]
    pior = 0.0
    for k in idx:
        i = L + k * w
        x = a[i:i + w]
        seg = b[i - L:i + w + L]
        c = np.correlate(seg, x, mode="valid")
        lag = (int(c.argmax()) - L) / sr * 1000
        pior = max(pior, abs(lag))
    return round(pior, 2)


# ------------------------------------------------------------------ montar o plano
def nivel_do_evento(ev: dict, padrao: float) -> float:
    """o "nivel" do evento; sem ele, o "db" que a peça pede (sons_sugeridos.json do build); sem os dois, o do tipo"""
    nv = ev.get("nivel")
    if nv is None and ev.get("db") is not None:
        nv = ev["db"]
    if nv is None:
        return float(padrao)
    if isinstance(nv, (int, float)):
        return float(nv)
    k = norm(nv)
    for nome, v in NIVEIS.items():
        if norm(nome) == k:
            return v
    try:
        return float(str(nv).replace(",", "."))
    except ValueError:
        erro(f"nível '{nv}' não existe. Use destaque, medio, discreto ou um número em dBFS (ex.: -15).")


def montar(eventos: list, dur_video: float, modo: str, max_sons: int | None, espaco: float, variar: bool,
           ajuste_db: float = 0.0) -> dict:
    giro: dict = {}
    itens, descartados = [], []
    for n, ev in enumerate(eventos, 1):
        if "t" not in ev:
            erro(f"o evento {n} não tem \"t\" (o segundo em que a animação bate): {ev}")
        t = float(ev["t"])
        tipo = APELIDO.get(norm(ev.get("tipo", ""))) if ev.get("tipo") else None
        if ev.get("tipo") and not tipo and not ev.get("som"):
            erro(f"tipo '{ev['tipo']}' (evento {n}) não existe. Tipos: {', '.join(TIPOS)}. "
                 "Rode com --tipos para ver quando usar cada um.")
        if not tipo and not ev.get("som"):
            erro(f"o evento {n} precisa de \"tipo\" ou \"som\": {ev}")
        base = TIPOS.get(tipo) if tipo else ("som escolhido", [], -14, "pico", 0.8, [])
        if t < 0 or t > dur_video:
            descartados.append(dict(t_evento=t, tipo=tipo or "", motivo="fora do vídeo"))
            continue
        if ev.get("som"):
            arq = resolver_som(ev["som"])
            if not arq:
                erro(f"não achei o som '{ev['som']}' (evento {n}). Use woven:ID, kit:NOME, bib:ID ou um caminho.")
        else:
            cands = [c for c in (resolver_som(s) for s in base[1]) if c]
            if not cands:
                erro(f"nenhum som do tipo '{tipo}' está instalado ({', '.join(base[1])}). "
                     "Confira as pastas assets/sfx/woven e assets/sfx (o kit) desta skill.")
            k = giro.get(tipo, 0) if variar else 0
            giro[tipo] = k + 1
            arq = cands[k % len(cands)]
        md = medir(arq)
        alinhar = ev.get("alinhar", base[3])
        ancora = md["pico_s"] if alinhar == "pico" else md["inicio_s"]
        inicio = t - ancora
        offset = 0.0
        if inicio < 0:                      # evento muito no começo: corta a cabeça do som
            offset, inicio = -inicio, 0.0
        cauda = float(ev["dur"]) - (ancora - md["inicio_s"]) if ev.get("dur") else base[4]
        fim_arq = min(md["dur"], ancora + max(0.1, cauda))
        dur = fim_arq - offset
        dur = min(dur, dur_video - inicio)
        if dur <= 0.02:
            descartados.append(dict(t_evento=t, tipo=tipo or "", motivo="sem espaço até o fim do vídeo"))
            continue
        cortado = offset + dur < md["dur"] - 0.01
        fade = round(min(0.35, max(0.05, (offset + dur - ancora) * 0.5)), 3) if cortado else 0.0
        nivel = nivel_do_evento(ev, base[2])
        pico_toca = pico_no_trecho(md, offset, offset + dur)   # o nível vale pro pico que realmente toca
        ganho = max(-60.0, min(GANHO_MAX_DB, nivel - pico_toca + ajuste_db))
        pico_tl = inicio + (md["pico_s"] - offset)
        if pico_tl > inicio + dur:          # alinhado no começo e o pico do arquivo ficou fora do trecho que toca
            pico_tl = t
        itens.append(dict(t_evento=round(t, 3), tipo=tipo or "livre", id=id_do_arquivo(arq), arquivo=str(arq),
                          inicio=round(inicio, 4), offset=round(offset, 4), dur=round(dur, 4),
                          pico=round(pico_tl, 4), pico_arquivo_s=md["pico_s"], pico_trecho_db=round(pico_toca, 2),
                          alinhar=alinhar, ponto=md["ponto"], nivel_db=round(nivel, 1), ganho_db=round(ganho, 2),
                          volume=round(10 ** (ganho / 20), 4), fade_out=fade, forcado=bool(ev.get("forcar")),
                          nota=str(ev.get("nota") or ev.get("peca") or "")))

    # densidade: forçados primeiro, depois do mais forte pro mais fraco; perto demais de um que ficou -> sai
    if max_sons is None:
        # com animação, cada animação tem o seu som (o André aprovou 23 sons em 56 s): só o espaço mínimo decide
        max_sons = max(5, round(dur_video / 15)) if modo == "fala" else max(5, len(itens))
    ordem = sorted(itens, key=lambda x: (not x["forcado"], -x["nivel_db"], x["t_evento"]))
    ficam = []
    for it in ordem:
        perto = [k for k in ficam if abs(k["t_evento"] - it["t_evento"]) < espaco]
        if it["forcado"]:
            if any(abs(k["t_evento"] - it["t_evento"]) < 0.05 for k in perto):
                descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"], motivo="repetido no mesmo quadro"))
                continue
            ficam.append(it)
            continue
        if perto:
            descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"],
                                    motivo=f"perto demais do som em {perto[0]['t_evento']:.2f} s (fica o mais forte)"))
            continue
        ficam.append(it)
    livres = [x for x in ficam if not x["forcado"]]
    sobra = len(ficam) - max_sons
    if sobra > 0:   # passou do máximo: saem os mais fracos (os forçados ficam)
        for it in sorted(livres, key=lambda x: (x["nivel_db"], -x["t_evento"]))[:sobra]:
            ficam.remove(it)
            descartados.append(dict(t_evento=it["t_evento"], tipo=it["tipo"],
                                    motivo=f"passou do máximo de {max_sons} sons (saem os mais fracos)"))
    ficam.sort(key=lambda x: x["t_evento"])
    for i, it in enumerate(ficam, 1):
        it["n"] = i
    descartados.sort(key=lambda x: x["t_evento"])
    return dict(versao=1, criado_por="helpers/plano_sfx.py (editor-video)", duracao_video=round(dur_video, 3),
                modo=modo, espaco_min_s=espaco, max_sons=max_sons, ajuste_voz_db=round(ajuste_db, 2),
                sons=[{k: it[k] for k in ["n"] + [c for c in it if c != "n"]} for it in ficam],
                descartados=descartados)


# ------------------------------------------------------------------ ffmpeg
def filtro_sons(sons: list, primeiro_input: int, arquivos: list) -> tuple[list, list]:
    """devolve (partes do filtro, rótulos) de cada som já no lugar, 48 kHz estéreo"""
    usos = {}
    for s in sons:
        usos[s["arquivo"]] = usos.get(s["arquivo"], 0) + 1
    rot_arq, partes = {}, []
    for a, qtd in usos.items():
        idx = primeiro_input + arquivos.index(a)
        if qtd == 1:
            rot_arq[a] = [f"[{idx}:a]"]
        else:
            labs = [f"[f{idx}_{k}]" for k in range(qtd)]
            partes.append(f"[{idx}:a]asplit={qtd}{''.join(labs)};")
            rot_arq[a] = labs
    labels = []
    for i, s in enumerate(sons):
        src = rot_arq[s["arquivo"]].pop(0)
        ms = int(round(s["inicio"] * 1000))
        fade = (f",afade=t=out:st={max(0.0, s['dur'] - s['fade_out']):.4f}:d={s['fade_out']:.4f}"
                if s["fade_out"] > 0 else "")
        partes.append(f"{src}atrim=start={s['offset']:.4f}:end={s['offset'] + s['dur']:.4f},asetpts=PTS-STARTPTS,"
                      f"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
                      f"volume={s['ganho_db']:.2f}dB{fade},adelay={ms}|{ms}[s{i}];")
        labels.append(f"[s{i}]")
    return partes, labels


def render_trilha_sfx(plano: dict, dur: float, saida_wav: str):
    """só os sons, sem voz (pra conferir onde caíram os picos)"""
    sons = plano["sons"]
    arquivos = sorted({s["arquivo"] for s in sons})
    partes, labels = filtro_sons(sons, 1, arquivos)
    fc = "".join(partes) + f"[0:a]{''.join(labels)}amix=inputs={len(labels) + 1}:normalize=0:duration=first[out]"
    cmd = ["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-t", f"{dur:.4f}", "-i",
           "anullsrc=r=48000:cl=stereo"]
    for a in arquivos:
        cmd += ["-i", a]
    cmd += ["-filter_complex", fc, "-map", "[out]", "-c:a", "pcm_f32le", saida_wav]
    subprocess.run(cmd, check=True)


def conferir_picos(plano: dict, dur: float) -> dict:
    if not plano["sons"]:
        return dict(conferidos=0)
    with tempfile.TemporaryDirectory() as td:
        w = os.path.join(td, "sfx.wav")
        render_trilha_sfx(plano, dur, w)
        raw = subprocess.run(["ffmpeg", "-v", "error", "-i", w, "-f", "f32le", "-acodec", "pcm_f32le", "-"],
                             capture_output=True).stdout
    a = np.abs(np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)).max(axis=1)
    sr = 48000
    desvios = []
    for s in plano["sons"]:
        if s["alinhar"] != "pico" or s.get("ponto") == "curado":
            continue
        lo, hi = int(max(0, (s["pico"] - 0.08)) * sr), int(min(len(a), (s["pico"] + 0.08) * sr))
        if hi <= lo:
            continue
        j = lo + ponto_forte(a[lo:hi], sr)
        desvios.append((abs(j / sr - s["pico"]) * 1000, s["t_evento"]))
    pior = max(desvios) if desvios else (0.0, 0.0)
    return dict(conferidos=len(desvios), desvio_max_ms=round(pior[0], 1), no_evento=pior[1])


def duracao_video(p: str) -> float:
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration",
                          "-of", "csv=p=0", p], capture_output=True, text=True).stdout.strip()
    try:
        return float(out)
    except ValueError:
        return duracao_midia(p)


def aplicar(plano: dict, video: str, saida: str):
    sons = plano["sons"]
    arquivos = sorted({s["arquivo"] for s in sons})
    partes, labels = filtro_sons(sons, 1, arquivos)
    dv = duracao_video(video)   # o áudio sai com a duração exata da imagem (nem 1 amostra a mais)
    fc = ("".join(partes) + "[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
          f"apad,atrim=end={dv:.6f}[voz];"
          f"[voz]{''.join(labels)}amix=inputs={len(labels) + 1}:normalize=0:duration=first[out]")
    with tempfile.TemporaryDirectory() as td:
        tmp = os.path.join(td, "mix.wav")
        cmd = ["ffmpeg", "-v", "error", "-y", "-i", video]
        for a in arquivos:
            cmd += ["-i", a]
        cmd += ["-filter_complex", fc, "-map", "[out]", "-c:a", "pcm_f32le", tmp]
        subprocess.run(cmd, check=True)
        j, tipo, fim = finalizar(video, tmp, saida)
    return j, tipo, fim


def ler_projeto(pasta: Path) -> dict:
    """o projeto-modelo: projeto.json, a pasta edit do corte, a duração do corte e a cauda (fim parado e cartão)"""
    pj = pasta / "projeto.json"
    if not pj.exists():
        erro(f"não achei {pj}. O --projeto é a pasta do projeto (a cópia do modelo-projeto, com o projeto.json).")
    try:
        cfg = json.loads(pj.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        erro(f"{pj} não é um JSON válido: {e}")
    edit = (pasta / cfg.get("edit", "../edit")).resolve()
    vdur = None
    tl = edit / "cut_timeline.json"
    if tl.exists():
        try:
            vdur = float(json.loads(tl.read_text(encoding="utf-8"))["duration"])
        except (ValueError, KeyError):
            vdur = None
    cf = cfg.get("cartao_final") or {}
    cauda = float(cfg.get("segura_fim") or 0) + (float(cf.get("segundos", 1.8)) if cf.get("ligado") else 0.0)
    return dict(cfg=cfg, arquivo=pj, edit=edit, vdur=vdur, cauda=cauda, video=edit / cfg.get("video", "cut.mp4"))


def licenca_do_som(src: Path) -> tuple[str, Path | None]:
    """(linha de licença, arquivo de licença para ir junto) do som copiado para o projeto"""
    try:
        rp = src.resolve()
        if rp.parent == WOVEN.resolve():
            return "Woven SFX, CC0 1.0 (domínio público); LICENCA-woven.md", WOVEN / "LICENCA.md"
        if KIT.resolve() in rp.parents:
            for v in kit_indice().values():
                if (KIT / v["file"]).resolve() == rp:
                    arq = KIT / v["license_file"] if v.get("license_file") else None
                    return (f"{v.get('license', '?')} ({v.get('source', 'kit do editor-video')})",
                            arq if arq and arq.exists() else None)
        if BIB.resolve() in rp.parents:
            for v in bib_indice().values():
                if (BIB / v["arquivo"]).resolve() == rp:
                    return f"{v.get('licenca', '?')} ({v.get('fonte', 'biblioteca de efeitos')})", None
    except (OSError, KeyError):
        pass
    return "arquivo trazido para o projeto: confira se pode usar em anúncio", None


def aplicar_no_projeto(plano: dict, pasta: Path, proj: dict, janela: list | None) -> list:
    """copia os sons para PASTA/assets/sfx e escreve o campo "sons" do PASTA/projeto.json: o build.py põe cada
    um no index.html (o estúdio toca e o render sai com eles). Troca só os sons que vieram do plano ("plano": true)
    dentro da janela do sons_sugeridos.json; os escritos à mão ficam."""
    dest = pasta / "assets" / "sfx"
    dest.mkdir(parents=True, exist_ok=True)
    usados: dict = {}
    licencas: dict = {}
    novos = []
    for s in plano["sons"]:
        src = Path(s["arquivo"])
        nome = src.name
        if nome in usados and usados[nome] != src.resolve():    # dois sons diferentes com o mesmo nome
            nome = f"{id_do_arquivo(src).split(':')[0]}_{src.name}"
        usados[nome] = src.resolve()
        alvo = dest / nome
        if not alvo.exists() or alvo.stat().st_size != src.stat().st_size:
            shutil.copy2(src, alvo)
        lic, arq_lic = licenca_do_som(src)
        licencas[nome] = f"{nome}: {id_do_arquivo(src)} · {lic}"
        if arq_lic is not None:
            copia = dest / ("LICENCA-woven.md" if arq_lic.parent == WOVEN else f"licencas/{arq_lic.name}")
            copia.parent.mkdir(parents=True, exist_ok=True)
            if not copia.exists():
                shutil.copy2(arq_lic, copia)
        em = round(s["pico"], 3)
        novos.append({"arquivo": f"assets/sfx/{nome}", "em": em,
                      "pico_no_arquivo": round(em - s["inicio"] + s["offset"], 4),
                      "inicio_no_arquivo": s["offset"], "dur": s["dur"], "volume": s["volume"],
                      "db": round(s["nivel_db"] + plano.get("ajuste_voz_db", 0.0), 1), "fade": s["fade_out"],
                      "tipo": s["tipo"], "nota": s["nota"], "plano": True})
    (dest / "LICENCAS.txt").write_text(
        "Sons deste projeto (escrito pelo helpers/plano_sfx.py do editor-video)\n\n"
        + "\n".join(licencas[k] for k in sorted(licencas)) + "\n", encoding="utf-8")

    def tempo(x):
        try:
            return float(x.get("em"))
        except (TypeError, ValueError):
            return None

    def fica(x):
        if not x.get("plano"):
            return True                      # som escrito à mão: fica
        t = tempo(x)
        return janela is not None and t is not None and not (janela[0] - 0.01 <= t <= janela[1] + 0.01)
    antigos = proj["cfg"].get("sons") or []
    mantidos = [x for x in antigos if fica(x)]
    cfg = dict(proj["cfg"])
    cfg["sons"] = sorted(mantidos + novos, key=lambda x: (tempo(x) is None, tempo(x) or 0.0))
    proj["arquivo"].write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return mantidos


# ------------------------------------------------------------------ comandos
def cmd_tipos():
    print("Tipos de evento (tipo: quando usar | sons | nível do pico)\n")
    for tipo, d in TIPOS.items():
        ok = [s for s in d[1] if resolver_som(s)]
        falta = [s for s in d[1] if s not in ok]
        print(f"- {tipo}: {d[0]}")
        print(f"    sons: {', '.join(ok) or 'nenhum instalado'}" + (f" (faltando: {', '.join(falta)})" if falta else ""))
        print(f"    nível: {d[2]} dBFS · alinha no {'pico' if d[3] == 'pico' else 'começo'} · apelidos: "
              f"{', '.join(d[5])}")
    print("\nNíveis prontos: destaque -12, medio -15, discreto -18 (dBFS de pico, com a voz perto de -14 LUFS).")


def cmd_medir(arq: str):
    p = resolver_som(arq)
    if not p:
        erro(f"não achei o som {arq}")
    md = {k: v for k, v in medir(p).items() if not k.startswith("_")}
    print(json.dumps(dict(arquivo=str(p), id=id_do_arquivo(p), **md), ensure_ascii=False, indent=1))


def main(argv=None):
    try:
        sys.stdout.reconfigure(line_buffering=True)   # mensagens aparecem na hora, mesmo em log
    except AttributeError:
        pass
    ap = argparse.ArgumentParser(description="Plano de efeitos sonoros com o pico de cada som no quadro da animação.")
    ap.add_argument("eventos", nargs="*", help="JSON com os eventos visuais [{\"t\":1.23,\"tipo\":\"pop\"}, ...] "
                    "(pode passar mais de um; com --projeto, o padrão é o sons_sugeridos.json e o eventos.json do projeto)")
    ap.add_argument("--saida", help="JSON de mixagem (padrão: plano_sfx.json ao lado dos eventos, ou na pasta do projeto)")
    ap.add_argument("--video", help="vídeo final (pega a duração e acerta o nível pela voz)")
    ap.add_argument("--duracao", type=float, help="duração do vídeo em segundos (se não passar --video)")
    ap.add_argument("--modo", choices=["motion", "fala"], default="motion",
                    help="motion: um som por animação (padrão). fala: vídeo sem motion, uns 5 ou 6 sons no total")
    ap.add_argument("--max", type=int, help="máximo de sons no vídeo (padrão: motion um por animação, só o espaço "
                    "mínimo tira; fala 1 a cada 15 s)")
    ap.add_argument("--espaco", type=float, default=0.65, help="espaço mínimo entre picos em segundos (padrão 0.65)")
    ap.add_argument("--variar", action="store_true", help="alterna entre os sons do mesmo tipo")
    ap.add_argument("--aplicar", metavar="VIDEO", help="junta os sons neste vídeo (voz intacta, -14 LUFS no fim)")
    ap.add_argument("--video-saida", help="arquivo de saída do --aplicar")
    ap.add_argument("--projeto", metavar="PASTA", help="pasta do projeto (modelo-projeto): copia os sons pra "
                    "PASTA/assets/sfx e escreve o campo \"sons\" do PASTA/projeto.json")
    ap.add_argument("--html", metavar="PASTA", help=argparse.SUPPRESS)
    ap.add_argument("--tipos", action="store_true", help="lista os tipos e os sons")
    ap.add_argument("--medir", metavar="SOM", help="mede um som (duração, pico, começo)")
    a = ap.parse_args(argv)
    precisa_ffmpeg()
    if a.tipos:
        return cmd_tipos()
    if a.medir:
        return cmd_medir(a.medir)
    if a.html:
        erro("o --html saiu: use --projeto PASTA_DO_PROJETO. Ele escreve os sons no projeto.json, o build.py põe no "
             "index.html (o estúdio toca e o render sai com eles). Nada de colar tag na composição.")
    proj = ler_projeto(Path(a.projeto).expanduser().resolve()) if a.projeto else None
    arquivos = [Path(x) for x in a.eventos]
    if proj and not arquivos:
        pasta = proj["arquivo"].parent
        arquivos = [x for x in (pasta / "sons_sugeridos.json", pasta / "eventos.json") if x.exists()]
        if not arquivos:
            erro(f"não achei {pasta / 'sons_sugeridos.json'}. Rode antes no projeto: python3 build.py completo")
    if not arquivos:
        ap.error("passe o arquivo de eventos, ou --projeto PASTA (ou --tipos / --medir)")
    eventos, janela = [], None
    for arq in arquivos:
        try:
            dados = json.loads(arq.read_text(encoding="utf-8"))
        except FileNotFoundError:
            erro(f"não achei {arq}")
        except json.JSONDecodeError as e:
            erro(f"{arq} não é um JSON válido: {e}")
        # Aceita também o sons_sugeridos.json do modelo-projeto: {"janela": [...], "sons": [{"tipo", "em", ...}]}
        lista = (dados.get("eventos") or dados.get("sons") or []) if isinstance(dados, dict) else dados
        if isinstance(dados, dict) and dados.get("janela"):
            janela = [float(x) for x in dados["janela"]]
        if not isinstance(lista, list):
            erro(f"{arq}: formato não reconhecido. Use [{{\"t\": 1.23, \"tipo\": \"pop\"}}, ...]")
        for e in lista:
            if isinstance(e, dict) and "t" not in e and "em" in e:
                e["t"] = e["em"]
        eventos += lista
        if proj:
            print(f"eventos de {arq.name}: {len(lista)}")
    if not eventos:
        erro("o arquivo de eventos está vazio. Formato: [{\"t\": 1.23, \"tipo\": \"pop\"}, ...]")
    video_ref = a.video or a.aplicar or (str(proj["video"]) if proj and proj["video"].exists() else None)
    ajuste = 0.0
    if video_ref:
        dur = duracao_midia(video_ref)
        lv = float(loudness(video_ref)["input_i"])
        if lv > -70:
            ajuste = lv - VOZ_REF_LUFS
    elif a.duracao:
        dur = a.duracao
    else:
        dur = max(float(e.get("t", 0)) for e in eventos) + 2.0
        print(f"aviso: sem --video nem --duracao, considerei o vídeo com {dur:.1f} s")
    if proj and not a.duracao:
        # o vídeo do projeto é o corte mais o fim parado (segura_fim) e o cartão final
        dur = (proj["vdur"] or dur) + proj["cauda"]
    if proj and janela and proj["vdur"] and (janela[0] > 0.05 or janela[1] < proj["vdur"] - 0.05):
        print(f"aviso: o sons_sugeridos.json é de uma amostra ({janela[0]:.2f} a {janela[1]:.2f} s). Os sons do plano "
              f"fora desse trecho ficam como estavam. Para o vídeo todo: python3 build.py completo e rode de novo.")
    plano = montar(eventos, dur, a.modo, a.max, a.espaco, a.variar, ajuste)
    chk = conferir_picos(plano, dur)
    plano["conferencia"] = chk
    if a.saida:
        saida = Path(a.saida)
    elif proj:
        saida = proj["arquivo"].parent / "plano_sfx.json"
    else:
        saida = arquivos[0].resolve().parent / "plano_sfx.json"
    saida.write_text(json.dumps(plano, ensure_ascii=False, indent=1))
    maximo = "um por animação" if a.modo == "motion" and a.max is None else str(plano["max_sons"])
    print(f"{len(plano['sons'])} sons no plano, {len(plano['descartados'])} descartados "
          f"(modo {plano['modo']}, máximo {maximo}, espaço {plano['espaco_min_s']} s)")
    if ajuste:
        print(f"voz do vídeo em {VOZ_REF_LUFS + ajuste:.1f} LUFS: ganhos ajustados em {ajuste:+.1f} dB")
    for s in plano["sons"]:
        print(f"  {s['n']:>2}. {s['t_evento']:7.2f} s  {s['tipo']:<13} {s['id']:<32} pico {s['nivel_db']:>5.1f} dBFS"
              f"  começa {s['inicio']:.3f}" + ("  (forçado)" if s["forcado"] else ""))
    for d in plano["descartados"]:
        print(f"  fora: {d['t_evento']:7.2f} s  {d['tipo']:<13} {d['motivo']}")
    if chk.get("conferidos"):
        print(f"conferência: {chk['conferidos']} picos medidos, maior desvio {chk['desvio_max_ms']} ms "
              f"(evento {chk['no_evento']} s)")
    print(f"plano salvo em {saida}")
    if proj:
        mantidos = aplicar_no_projeto(plano, proj["arquivo"].parent, proj, janela)
        print(f"projeto.json: {len(plano['sons'])} sons do plano no campo \"sons\" (arquivos em assets/sfx, licenças em "
              f"assets/sfx/LICENCAS.txt)" + (f"; {len(mantidos)} som(ns) que já estavam ficaram" if mantidos else ""))
        print("próximo: python3 build.py completo (o estúdio toca os sons), render e, por último, a trilha "
              "(helpers/mixar_trilha.py)")
    if a.aplicar:
        vs = a.video_saida or str(Path(a.aplicar).with_name(Path(a.aplicar).stem + "_sfx.mp4"))
        if Path(vs).resolve() == Path(a.aplicar).resolve():
            erro("a saída não pode ser o mesmo arquivo do vídeo de entrada")
        j, tipo, fim = aplicar(plano, a.aplicar, vs)
        d_in, d_out = duracao_video(a.aplicar), duracao_midia(vs)
        sinc = desvio_sincronia(a.aplicar, vs)
        print(f"vídeo com sons: {vs}")
        print(f"  duração {d_out:.3f} s (entrada {d_in:.3f} s) · volume final {fim['input_i']} LUFS, pico "
              f"{fim['input_tp']} dBTP (ajuste {tipo}) · voz no lugar: desvio {sinc} ms")
        if tipo != "linear":
            print("  aviso: o ajuste de volume saiu dinâmico; confira se a voz ficou natural")


if __name__ == "__main__":
    main()
