"""Legendei - legenda automatica para video (servidor local).

Fluxo: upload -> ffprobe + transcricao (faster-whisper, com tempo por palavra)
       -> o navegador edita a legenda -> render com ffmpeg (ASS queimado,
       ruido de fundo com afftdn, cortes de silencio com trim/concat).
"""
import json
import os
import re
import shutil
import subprocess
import threading
import uuid
import webbrowser
from pathlib import Path

# o servidor vive em app/, mas fontes, exemplos e saidas sao relativos a RAIZ
# do projeto - e a raiz que vira o cwd do ffmpeg
APP = Path(__file__).resolve().parent
ROOT = APP.parent
JOBS = ROOT / "trabalhos"
os.environ.setdefault("HF_HOME", str(ROOT / "models"))

from fastapi import FastAPI, File, HTTPException, UploadFile  # noqa: E402
from fastapi.responses import FileResponse, JSONResponse  # noqa: E402
from fastapi.staticfiles import StaticFiles  # noqa: E402
from pydantic import BaseModel  # noqa: E402

STYLES = {s["id"]: s for s in json.loads((APP / "estilos.json").read_text("utf-8"))}
FONTS_META = json.loads((APP / "fontes.json").read_text("utf-8"))
FONTS_DIR = ROOT / "static" / "fonts"
import sys  # noqa: E402
sys.path.insert(0, str(APP))
from util import achar_ffmpeg  # noqa: E402

FFMPEG = achar_ffmpeg("ffmpeg") or "ffmpeg"
FFPROBE = achar_ffmpeg("ffprobe") or "ffprobe"
WHISPER_MODEL = os.environ.get("LEGENDEI_MODEL", "small")
WHISPER_DEVICE = os.environ.get("LEGENDEI_DEVICE", "cpu")
ENCODER = os.environ.get("LEGENDEI_ENCODER", "libx264")

app = FastAPI(title="Legendei")
jobs: dict[str, dict] = {}
renders: dict[str, dict] = {}
_model = None
_model_lock = threading.Lock()


def check_fonts():
    """As legendas dependem dos arquivos em static/fonts. Sem eles o ffmpeg troca
    a fonte em silencio e o video sai diferente da previa."""
    faltando = [m["file"] for m in FONTS_META.values() if not (FONTS_DIR / m["file"]).exists()]
    if faltando or not FONTS_META:
        raise SystemExit("Faltam as fontes das legendas. Rode o instalar.bat de novo.\n"
                         + ("Arquivos: " + ", ".join(faltando) if faltando else ""))


def run(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", **kw)


def probe(path: Path) -> dict:
    r = run([FFPROBE, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(path)])
    if r.returncode != 0:
        raise RuntimeError("Não consegui ler o vídeo. O arquivo está corrompido ou num formato que não abro.")
    info = json.loads(r.stdout)
    v = next((s for s in info["streams"] if s.get("codec_type") == "video"), None)
    a = next((s for s in info["streams"] if s.get("codec_type") == "audio"), None)
    if not v:
        raise RuntimeError("O arquivo não tem imagem. Envie um vídeo.")
    w, h = int(v["width"]), int(v["height"])
    rot = 0
    for sd in v.get("side_data_list", []) or []:
        if "rotation" in sd:
            rot = int(float(sd["rotation"]))
    rot = int(float(v.get("tags", {}).get("rotate", rot) or rot))
    if rot % 180 != 0:
        w, h = h, w
    num, den = (v.get("r_frame_rate") or "30/1").split("/")
    fps = float(num) / float(den or 1)
    dur = float(info["format"].get("duration") or v.get("duration") or 0)
    return {"width": w, "height": h, "fps": round(fps, 3), "duration": round(dur, 3), "has_audio": a is not None}


def detect_silences(wav: Path, noise_db=-35, min_len=0.7):
    r = run([FFMPEG, "-hide_banner", "-i", str(wav), "-af", f"silencedetect=noise={noise_db}dB:d={min_len}", "-f", "null", "-"])
    out, sil, start = r.stderr, [], None
    for m in re.finditer(r"silence_(start|end): ([\d.]+)", out):
        if m.group(1) == "start":
            start = float(m.group(2))
        elif start is not None:
            sil.append({"s": round(start, 3), "e": round(float(m.group(2)), 3)})
            start = None
    return sil


# Hesitacoes: sons de enrolar a fala. So pegamos formas alongadas ou repetidas
# ("éééé", "ahn", "hmm"), nunca palavras normais - "é" sozinho e verbo em
# portugues, e cortar todo "é" quebraria a frase.
HESITACAO = re.compile(r"^(?:a+h+n*|e+h+n*|u+h+m*|h+m+|ã+h*|hã+|hum+|ahn+|uhn+|m+h+|é{2,}|e{3,})$", re.I)
# "né", "tipo" e "então" tambem enrolam, mas cortar muda o sentido. Ficam.


def palavra_limpa(w: str) -> str:
    return re.sub(r"[^0-9A-Za-zÀ-ÿ]", "", w).lower()


def plan_cuts(phrases: list, duration: float, mode: str) -> dict:
    """Decide o que sai do video.

    `pausas`  tira so os silencios longos.
    `esperto` tira tambem hesitacoes ("ééé", "ahn") e gagueira (palavra repetida),
              e aperta mais as pausas.

    Trabalha em cima das palavras que o usuario editou, e nao do audio cru, para
    a legenda e o corte nunca discordarem sobre onde a fala comeca e termina.
    """
    plano = {"keep": [(0.0, duration)], "drop": set(),
             "stats": {"pausas": 0, "hesitacoes": 0, "repeticoes": 0,
                       "removido": 0.0, "duracao_final": round(duration, 3)}}
    if mode not in ("pausas", "esperto"):
        return plano

    esperto = mode == "esperto"
    # 0,55 s e o menor valor que ainda soa natural: fim de frase costuma ter
    # 0,4 s de respiro, e cortar isso deixa a fala afobada.
    min_gap = 0.55 if esperto else 0.85
    pad = 0.12      # folga nas beiradas do corte; protege de erro do timestamp
    borda = 0.15    # folga no comeco e no fim do video

    # 1. lista unica de palavras, com o endereco de cada uma na legenda
    palavras = []
    for pi, ph in enumerate(phrases):
        for wi, w in enumerate(ph):
            if w.get("w"):
                palavras.append({"pi": pi, "wi": wi, "s": float(w["s"]), "e": float(w["e"]), "w": w["w"]})
    palavras.sort(key=lambda w: w["s"])
    if not palavras:
        return plano

    # 2. quais palavras somem
    drop, fora = set(), []
    plano["fora"] = fora
    if esperto:
        anterior = None
        for w in palavras:
            limpa = palavra_limpa(w["w"])
            if limpa and HESITACAO.match(limpa):
                drop.add((w["pi"], w["wi"]))
                plano["stats"]["hesitacoes"] += 1
                fora.append(w["w"])
                continue
            # gagueira: mesma palavra colada de novo ("eu eu acho")
            if (anterior and limpa and limpa == anterior["limpa"] and len(limpa) <= 6
                    and w["s"] - anterior["e"] < 0.55):
                drop.add((anterior["pi"], anterior["wi"]))
                plano["stats"]["repeticoes"] += 1
                fora.append(anterior["w"])
            anterior = {"limpa": limpa, "pi": w["pi"], "wi": w["wi"], "e": w["e"], "w": w["w"]}

    vivas = [w for w in palavras if (w["pi"], w["wi"]) not in drop]
    if not vivas:
        return plano

    # 3. trechos a remover: bordas, palavras cortadas e pausas longas
    remover = []
    if vivas[0]["s"] - borda > 0.05:
        remover.append([0.0, vivas[0]["s"] - borda])
    if duration - (vivas[-1]["e"] + borda) > 0.05:
        remover.append([vivas[-1]["e"] + borda, duration])
    for w in palavras:
        if (w["pi"], w["wi"]) in drop:
            remover.append([w["s"] - 0.04, w["e"] + 0.04])
    for a, b in zip(vivas, vivas[1:]):
        if b["s"] - a["e"] > min_gap:
            remover.append([a["e"] + pad, b["s"] - pad])
            plano["stats"]["pausas"] += 1

    # 4. junta o que se sobrepoe ou fica quase colado (sobra de menos de 0,25 s
    #    entre dois cortes vira um pisca-pisca feio na tela)
    remover.sort()
    juntos = []
    for r in remover:
        r = [max(0.0, r[0]), min(duration, r[1])]
        if r[1] - r[0] < 0.08:
            continue
        if juntos and r[0] - juntos[-1][1] < 0.25:
            juntos[-1][1] = max(juntos[-1][1], r[1])
        else:
            juntos.append(r)

    # 5. o que fica e o inverso do que sai
    keep, cursor = [], 0.0
    for a, b in juntos:
        if a - cursor > 0.05:
            keep.append((round(cursor, 3), round(a, 3)))
        cursor = max(cursor, b)
    if duration - cursor > 0.05:
        keep.append((round(cursor, 3), round(duration, 3)))
    if not keep:
        return plano

    nova = sum(b - a for a, b in keep)
    if nova < 1.0:      # corte agressivo demais nao vira video; melhor nao cortar
        return plano
    plano["keep"] = keep
    plano["drop"] = drop
    plano["stats"]["removido"] = round(duration - nova, 2)
    plano["stats"]["duracao_final"] = round(nova, 3)
    return plano


def remap_time(t: float, keep: list) -> float:
    """Tempo no video original -> tempo no video cortado."""
    acc = 0.0
    for a, b in keep:
        if t < a:
            return acc
        if t <= b:
            return acc + (t - a)
        acc += b - a
    return acc


def get_model():
    global _model
    with _model_lock:
        if _model is None:
            from faster_whisper import WhisperModel
            compute = "float16" if WHISPER_DEVICE == "cuda" else "int8"
            _model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=compute)
        return _model


def transcribe_job(job: dict):
    jdir = JOBS / job["id"]
    try:
        job["status"] = "transcribing"
        job["progress"] = 0.0
        wav = jdir / "audio.wav"
        r = run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", job["path"], "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(wav)])
        if r.returncode != 0:
            raise RuntimeError("Não consegui extrair o áudio do vídeo.")
        job["silences"] = detect_silences(wav)
        job["noise_floor"] = noise_floor(wav)
        model = get_model()
        segments, info = model.transcribe(str(wav), word_timestamps=True, vad_filter=True, beam_size=5)
        words = []
        for seg in segments:
            for w in seg.words or []:
                txt = w.word.strip()
                if txt:
                    words.append({"w": txt, "s": round(w.start, 3), "e": round(max(w.end, w.start + 0.05), 3)})
            job["progress"] = min(0.99, seg.end / max(job["duration"], 0.1))
        job["language"] = info.language
        job["words"] = words
        job["status"] = "ready" if words else "error"
        if not words:
            job["error"] = "Não encontrei fala nesse áudio. Confere se o vídeo tem alguém falando."
        job["progress"] = 1.0
    except Exception as e:  # noqa: BLE001
        job["status"] = "error"
        job["error"] = str(e)


# ---------- legenda (layout + ASS) ----------
def parse_color(c: str):
    """Aceita #RRGGBB e rgba(r,g,b,a). Devolve (r, g, b, alpha_ass 0=opaco)."""
    c = (c or "#FFFFFF").strip()
    m = re.match(r"rgba?\(([^)]+)\)", c)
    if m:
        parts = [p.strip() for p in m.group(1).split(",")]
        r, g, b = (int(float(parts[i])) for i in range(3))
        a = float(parts[3]) if len(parts) > 3 else 1.0
        return r, g, b, int(round((1 - a) * 255))
    h = c.lstrip("#")
    if len(h) == 3:
        h = "".join(ch * 2 for ch in h)
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 0


def ass_color(c: str, extra_alpha: int | None = None) -> str:
    r, g, b, a = parse_color(c)
    if extra_alpha is not None:
        a = extra_alpha
    return f"&H{a:02X}{b:02X}{g:02X}{r:02X}"


def ass_time(t: float) -> str:
    t = max(0.0, t)
    return f"{int(t // 3600)}:{int((t % 3600) // 60):02d}:{t % 60:05.2f}"


_metrics: dict = {}


def font_metrics(key: str) -> dict:
    """Larguras dos glifos de uma fonte, para saber se a linha cabe na tela.
    O ASS nao quebra linha aqui (WrapStyle 2), entao sem isso uma frase longa
    sairia cortada pela borda do video."""
    if key not in _metrics:
        from fontTools.ttLib import TTFont
        f = TTFont(FONTS_DIR / FONTS_META[key]["file"], lazy=True)
        upem = f["head"].unitsPerEm
        cmap = f.getBestCmap()
        hmtx = f["hmtx"]
        adv = {}
        for cp, name in cmap.items():
            try:
                adv[cp] = hmtx[name][0] / upem
            except KeyError:
                pass
        _metrics[key] = {"adv": adv, "fallback": hmtx["space"][0] / upem if "space" in hmtx.metrics else 0.5}
        f.close()
    return _metrics[key]


def text_width(key: str, text: str, fs: float) -> float:
    m = font_metrics(key)
    return sum(m["adv"].get(ord(ch), m["fallback"]) for ch in text) * fs


def fit_size(key: str, text: str, fs: float, max_w: float) -> float:
    """Diminui a fonte da linha até ela caber. Nunca aumenta."""
    w = text_width(key, text, fs)
    return fs * (max_w / w) if w > max_w > 0 else fs


def split_lines(words: list, n_lines: int) -> list:
    """Divide as palavras da frase em linhas. Igual no servidor e no navegador."""
    if n_lines <= 1 or len(words) < 2:
        return [words]
    cut = -(-len(words) // 2)  # teto: a primeira linha leva a sobra
    return [words[:cut], words[cut:]]


def apply_case(text: str, mode: str) -> str:
    return text.upper() if mode == "upper" else text.lower() if mode == "lower" else text


def word_color(st: dict, j: int, active: int, total: int, mode: str) -> str:
    pal = st.get("palette")
    if pal:
        chunk = max(1, -(-total // len(pal)))
        return pal[min(len(pal) - 1, j // chunk)]
    if mode == "karaoke":
        return st["hl"] if j == active else (st["color"] if j < active else st.get("dim", st["color"]))
    return st["hl"] if j == active else st["color"]


def build_ass(st: dict, fonts: dict, phrases: list, W: int, H: int, pos: dict, size_mult: float) -> str:
    base = min(W, H)
    fs = max(8, round(st["size"] * base * size_mult))
    box = st.get("box")
    main_font = fonts[st["font"]]["family"]
    if box:
        border_style, outline_w, shadow_d = 3, round(box["pad"] * fs * 0.5, 2), 0
        outline_c = back_c = ass_color(box["color"])
    else:
        border_style = 1
        outline_w = round(st["outline"]["w"] * fs, 2)
        shadow_d = round(st["shadow"]["d"] * fs, 2)
        outline_c, back_c = ass_color(st["outline"]["color"]), ass_color(st["shadow"]["color"])
    margin = int(W * 0.04)
    head = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {W}
PlayResY: {H}
WrapStyle: 2
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.709

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,{main_font},{fs},{ass_color(st['color'])},{ass_color(st['hl'])},{outline_c},{back_c},0,0,0,0,100,100,0,0,{border_style},{outline_w},{shadow_d},5,{margin},{margin},0,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    px, py = pos.get("x", 0.5) * W, pos.get("y", 0.82) * H
    glow = round(st.get("glow", 0) * fs, 1)
    mode, n_lines = st["mode"], st.get("lines", 1)
    scale = int(round(st.get("hlScale", 1) * 100))
    rot = st.get("rotate", 0)
    events = []
    for pi, ph in enumerate(phrases):
        words = [w for w in ph if w.get("w")]
        if not words:
            continue
        next_start = None
        for nxt in phrases[pi + 1:]:
            if nxt:
                next_start = nxt[0]["s"]
                break
        rows = split_lines(words, n_lines)
        # altura de cada linha para centralizar o bloco no ponto escolhido
        muls = [1.0 if i == 0 else st.get("line2", {}).get("sizeMul", 1.0) for i in range(len(rows))]
        # cada linha encolhe sozinha se nao couber na largura util
        max_w = (W - 2 * margin) * (0.88 if box else 1.0)  # a caixa adiciona respiro nas laterais
        for ri, row in enumerate(rows):
            line_def = st.get("line2", {}) if ri == 1 else {}
            txt = " ".join(apply_case(w["w"], line_def.get("case", st.get("case", "none"))) for w in row)
            muls[ri] = fit_size(line_def.get("font", st["font"]), txt, fs * muls[ri], max_w) / fs
        heights = [fs * m * 1.16 for m in muls]
        top = py - sum(heights) / 2
        centers, acc = [], 0.0
        for h in heights:
            centers.append(top + acc + h / 2)
            acc += h
        offset = 0  # indice global da palavra dentro da frase
        row_of = {}
        for ri, row in enumerate(rows):
            for k in range(len(row)):
                row_of[offset + k] = (ri, k)
            offset += len(row)
        for i, w in enumerate(words):
            start = w["s"]
            end = words[i + 1]["s"] if i + 1 < len(words) else w["e"] + 0.35
            if next_start is not None:
                end = min(end, next_start)
            end = max(end, start + 0.04)
            gi = 0
            for ri, row in enumerate(rows):
                line_def = st.get("line2", {}) if ri == 1 else {}
                fs_row = round(fs * muls[ri])
                font_row = fonts[line_def.get("font", st["font"])]["family"]
                case_row = line_def.get("case", st.get("case", "none"))
                parts = []
                for k, wj in enumerate(row):
                    j = gi + k
                    if mode == "single" and j != i:
                        continue
                    on = j == i
                    txt = apply_case(wj["w"], case_row).replace("{", "(").replace("}", ")")
                    color = word_color(st, j, i, len(words), mode)
                    alpha = 255 if (mode == "reveal" and j > i) else None
                    tag = f"\\c{ass_color(color)}&\\1a&H{(alpha if alpha is not None else 0):02X}&"
                    if box:
                        tag += f"\\3c{ass_color(box['hl'] if on else box['color'])}&"
                    else:
                        sc = scale if on else 100
                        tag += f"\\fscx{sc}\\fscy{sc}"
                    tag += "\\u1" if (on and st.get("underline")) else "\\u0"
                    parts.append(f"{{{tag}}}{txt}")
                gi += len(row)
                if not parts:
                    continue
                sign = -1 if ri % 2 else 1
                pre = f"{{\\an5\\pos({px:.0f},{centers[ri]:.0f})\\fn{font_row}\\fs{fs_row}"
                if rot:
                    pre += f"\\frz{rot * sign:.1f}"
                if glow:
                    pre += f"\\blur{glow}"
                pre += "}"
                events.append(f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Default,,0,0,0,,{pre}{' '.join(parts)}")
    return head + "\n".join(events) + "\n"


# ---------- render ----------
def audio_chain(nivel: str, piso_db: float | None) -> str:
    """Filtros de limpeza do audio, em tres intensidades.

    Tudo aqui e afftdn (reducao de ruido por FFT), calibrado pelo trecho mais
    silencioso do proprio video. Nada de rede neural: o `arnndn` deste ffmpeg
    cospe NaN no meio do audio e derruba o codificador AAC - testado com os tres
    modelos oficiais do rnnoise, falha em 9 de 10 execucoes, de forma
    intermitente. O afftdn passou em todas.
    """
    if nivel in (None, "", "off", "nao"):
        return "anull"
    # nf = piso de ruido. Medido do proprio video, com folga; sem medida, chuta.
    nf = max(-80.0, min(-20.0, (piso_db if piso_db is not None else -32.0) + 4))
    if nivel == "leve":
        return f"highpass=f=75,afftdn=nr=10:nf={nf:.0f}:tn=1"
    if nivel == "forte":
        # duas passadas mais uma porta: limpa bastante, ao custo de a voz
        # ficar um pouco metalica
        return (f"highpass=f=90,afftdn=nr=24:nf={nf:.0f}:tn=1,afftdn=nr=12:nf={nf:.0f}:tn=1,"
                f"agate=threshold=0.015:ratio=2:attack=10:release=250")
    return f"highpass=f=80,afftdn=nr=20:nf={nf:.0f}:tn=1,afftdn=nr=8:nf={nf:.0f}:tn=1"


def noise_floor(wav: Path) -> float | None:
    """Volume do trecho mais silencioso, em dB. Serve pra calibrar o filtro."""
    r = run([FFMPEG, "-hide_banner", "-i", str(wav), "-af", "astats=metadata=1:reset=0", "-f", "null", "-"])
    m = re.findall(r"RMS level dB:\s*(-?[\d.]+)", r.stderr)
    if not m:
        return None
    try:
        return float(m[-1]) - 6
    except ValueError:
        return None


class RenderReq(BaseModel):
    job: str
    style: str
    phrases: list
    pos: dict = {"x": 0.5, "y": 0.82}
    size: float = 1.0
    noise: str = "off"      # off | leve | medio | forte
    autocut: str = "off"    # off | pausas | esperto


def render_thread(rid: str, req: RenderReq):
    R = renders[rid]
    job = jobs[req.job]
    jdir = JOBS / job["id"]
    try:
        st = STYLES[req.style]
        R["stage"] = "prep"
        keep = None
        phrases = req.phrases
        plano = plan_cuts(req.phrases, job["duration"], req.autocut)
        stats = plano["stats"]
        total = job["duration"]
        if plano["drop"] or len(plano["keep"]) > 1:
            keep = plano["keep"]
            total = stats["duracao_final"]
            # as palavras cortadas somem tambem da legenda, senao ela mostraria
            # texto que ninguem fala mais
            phrases = []
            for pi, ph in enumerate(req.phrases):
                nova = [{**w, "s": remap_time(w["s"], keep), "e": remap_time(w["e"], keep)}
                        for wi, w in enumerate(ph) if (pi, wi) not in plano["drop"]]
                if nova:
                    phrases.append(nova)
        cuts = max(0, len(plano["keep"]) - 1)
        removed = stats["removido"]
        ass_path = jdir / f"{rid}.ass"
        ass_path.write_text(build_ass(st, FONTS_META, phrases, job["width"], job["height"], req.pos, req.size), encoding="utf-8")
        ass_rel = f"trabalhos/{job['id']}/{rid}.ass"

        has_audio = job["has_audio"]
        parts, n = [], 0
        if keep:
            n = len(keep)
            for i, (a, b) in enumerate(keep):
                parts.append(f"[0:v]trim=start={a}:end={b},setpts=PTS-STARTPTS[v{i}]")
                if has_audio:
                    # os 8 ms de fade evitam o "toc" que uma emenda seca deixa no audio
                    d = max(0.02, b - a)
                    parts.append(f"[0:a]atrim=start={a}:end={b},asetpts=PTS-STARTPTS,"
                                 f"afade=t=in:st=0:d=0.008,afade=t=out:st={d - 0.008:.3f}:d=0.008[a{i}]")
            ins = "".join(f"[v{i}][a{i}]" if has_audio else f"[v{i}]" for i in range(n))
            parts.append(f"{ins}concat=n={n}:v=1:a={1 if has_audio else 0}[vc]" + ("[ac]" if has_audio else ""))
            vsrc, asrc = "[vc]", "[ac]"
        else:
            vsrc, asrc = "[0:v]", "[0:a]"
        parts.append(f"{vsrc}ass=filename={ass_rel}:fontsdir=static/fonts,format=yuv420p[vo]")
        if has_audio:
            parts.append(f"{asrc}{audio_chain(req.noise, job.get('noise_floor'))}[ao]")
        out = jdir / f"{rid}.mp4"
        cmd = [FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-nostats", "-i", job["path"],
               "-filter_complex", ";".join(parts), "-map", "[vo]"]
        if has_audio:
            cmd += ["-map", "[ao]", "-c:a", "aac", "-b:a", "160k"]
        if ENCODER == "h264_nvenc":
            cmd += ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "23"]
        else:
            cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"]
        cmd += ["-movflags", "+faststart", "-progress", "pipe:1", str(out)]
        R["stage"] = "encode"
        log = open(jdir / f"{rid}.log", "w", encoding="utf-8")
        # guarda a linha inteira: quando o ffmpeg reclama, o filtro montado e a
        # primeira coisa que a gente precisa ver
        log.write("$ " + " ".join(f'"{c}"' if " " in c else c for c in cmd) + "\n\n")
        log.flush()
        p = subprocess.Popen(cmd, cwd=ROOT, stdout=subprocess.PIPE, stderr=log, text=True, encoding="utf-8", errors="replace")
        for line in p.stdout:
            m = re.match(r"out_time_us=(\d+)", line)
            if m:
                R["percent"] = min(0.99, int(m.group(1)) / 1e6 / max(total, 0.1))
        p.wait()
        log.close()
        if p.returncode != 0 or not out.exists():
            err = (jdir / f"{rid}.log").read_text("utf-8", errors="replace")[-600:]
            raise RuntimeError("O ffmpeg não conseguiu gerar o vídeo.\n" + err)
        R.update({"status": "done", "percent": 1.0, "stage": "done", "out": str(out),
                  "result": {"duration_before": job["duration"], "duration_after": round(total, 3),
                             "removed": round(removed, 2), "cuts": cuts, "size": out.stat().st_size,
                             "words": sum(len(ph) for ph in phrases),
                             "pausas": stats["pausas"], "hesitacoes": stats["hesitacoes"],
                             "repeticoes": stats["repeticoes"]}})
    except Exception as e:  # noqa: BLE001
        R.update({"status": "error", "error": str(e)})


# ---------- API ----------
@app.get("/api/styles")
def api_styles():
    return {"styles": list(STYLES.values()), "fonts": FONTS_META}


@app.post("/api/upload")
async def api_upload(file: UploadFile = File(...)):
    jid = uuid.uuid4().hex[:10]
    jdir = JOBS / jid
    jdir.mkdir(parents=True, exist_ok=True)
    ext = Path(file.filename or "video.mp4").suffix.lower() or ".mp4"
    dst = jdir / f"source{ext}"
    with open(dst, "wb") as f:
        while chunk := await file.read(4 * 1024 * 1024):
            f.write(chunk)
    try:
        meta = probe(dst)
    except RuntimeError as e:
        shutil.rmtree(jdir, ignore_errors=True)
        raise HTTPException(400, str(e))
    if not meta["has_audio"]:
        shutil.rmtree(jdir, ignore_errors=True)
        raise HTTPException(400, "Esse vídeo não tem áudio, então não dá pra legendar.")
    job = {"id": jid, "name": file.filename, "path": str(dst), "status": "queued", "progress": 0.0,
           "words": [], "silences": [], "language": None, "error": None, **meta}
    jobs[jid] = job
    threading.Thread(target=transcribe_job, args=(job,), daemon=True).start()
    return public(job)


def public(job: dict) -> dict:
    return {k: v for k, v in job.items() if k != "path"}


@app.get("/api/jobs/{jid}")
def api_job(jid: str):
    job = jobs.get(jid)
    if not job:
        raise HTTPException(404, "Vídeo não encontrado. Envie de novo.")
    return public(job)


@app.get("/media/{jid}")
def api_media(jid: str):
    job = jobs.get(jid)
    if not job:
        raise HTTPException(404)
    return FileResponse(job["path"], media_type="video/mp4")


@app.post("/api/render")
def api_render(req: RenderReq):
    if req.job not in jobs:
        raise HTTPException(404, "Vídeo não encontrado. Envie de novo.")
    if req.style not in STYLES:
        raise HTTPException(400, "Estilo desconhecido.")
    rid = uuid.uuid4().hex[:8]
    stages = ["Preparando a legenda"]
    if req.noise not in ("off", "", None):
        stages.append("Limpando o áudio")
    if req.autocut == "pausas":
        stages.append("Cortando as pausas")
    elif req.autocut == "esperto":
        stages.append("Fazendo a edição")
    stages += ["Aplicando a legenda", "Exportando o vídeo"]
    renders[rid] = {"id": rid, "status": "running", "stage": "prep", "percent": 0.0, "stages": stages, "error": None}
    threading.Thread(target=render_thread, args=(rid, req), daemon=True).start()
    return renders[rid]


class PlanReq(BaseModel):
    job: str
    phrases: list
    autocut: str = "off"


@app.post("/api/plan")
def api_plan(req: PlanReq):
    """Diz o que a edição automática vai tirar, antes de gerar o vídeo.

    A tela chama isto em vez de repetir a conta no navegador: uma conta só,
    num lugar só, então o que a tela promete é o que o vídeo entrega."""
    job = jobs.get(req.job)
    if not job:
        raise HTTPException(404, "Vídeo não encontrado. Envie de novo.")
    plano = plan_cuts(req.phrases, job["duration"], req.autocut)
    return {**plano["stats"], "cortes": max(0, len(plano["keep"]) - 1),
            "fora": plano.get("fora", [])[:12]}


@app.get("/api/render/{rid}")
def api_render_status(rid: str):
    R = renders.get(rid)
    if not R:
        raise HTTPException(404)
    return {k: v for k, v in R.items() if k != "out"}


@app.get("/api/output/{rid}")
def api_output(rid: str, download: int = 0):
    R = renders.get(rid)
    if not R or R.get("status") != "done":
        raise HTTPException(404)
    name = "legendado.mp4"
    if download:
        return FileResponse(R["out"], media_type="video/mp4", filename=name)
    return FileResponse(R["out"], media_type="video/mp4")


@app.get("/")
def index():
    # sem cache: a tela e um arquivo so, e quem editar precisa ver a mudanca
    # no primeiro F5, nao no segundo
    return FileResponse(ROOT / "static" / "index.html",
                        headers={"Cache-Control": "no-store"})


app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")

if __name__ == "__main__":
    import uvicorn
    check_fonts()
    JOBS.mkdir(exist_ok=True)
    # se a porta estiver ocupada (outra copia aberta, ou outro programa),
    # procura a proxima livre em vez de morrer com um erro de rede
    import socket
    port = int(os.environ.get("LEGENDEI_PORT", "8123"))
    for tentativa in range(port, port + 20):
        with socket.socket() as s:
            if s.connect_ex(("127.0.0.1", tentativa)) != 0:
                port = tentativa
                break
    else:
        raise SystemExit("Nao achei nenhuma porta livre entre 8123 e 8142.")
    url = f"http://localhost:{port}"
    print("\n" + "=" * 52)
    print("  Legendei esta no ar.")
    print("=" * 52)
    print(f"\n  Abra no navegador:   {url}\n")
    print(f"  (transcricao: modelo {WHISPER_MODEL} em {WHISPER_DEVICE})")
    print("  (para parar, feche esta janela)\n")
    if os.environ.get("LEGENDEI_ABRIR", "1") == "1":
        threading.Timer(1.5, lambda: webbrowser.open(url)).start()
    uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
