#!/usr/bin/env python3
"""narrar.py — a narração, em Python, sem servidor.

    "Narração: gerar em Python, com biblioteca de TTS instalada no projeto. A
     IA escolhe a voz e o estilo da narração."

── POR QUE UM SCRIPT, E NÃO UM SERVIDOR ─────────────────────────────────────
O Studio antigo subia um servidor Python em localhost:7777 e falava com ele
por HTTP — inclusive para gerar narração. Aquilo existia porque havia uma
interface web para servir. A interface morreu; o servidor não tem mais razão
de ser, e com ele some uma classe inteira de defeito ("o estúdio não está
rodando", porta ocupada, processo zumbi entre sessões).

Aqui é um comando: entra texto, sai um mp3 e os tempos de cada fala.

── OS TEMPOS SÃO MEDIDOS, NÃO ESTIMADOS ─────────────────────────────────────
O edge-tts escreve as legendas junto com o áudio (`--write-media` +
`--write-subtitles`). Delas sai o instante exato de cada frase.

Isso é o que faz a cena caber na fala. Estimar duração por número de palavras
erra em segundos, e o erro aparece como imagem trocando antes de a frase
acabar — o defeito de vídeo automático mais fácil de reconhecer.

── A CORRENTE ───────────────────────────────────────────────────────────────
1. ELEVENLABS  se houver chave com crédito (melhor entonação, custa).
2. EDGE TTS    neural, grátis, sem chave, sem conta.   ← o padrão
3. diz que não deu, com o comando de instalar.

Nunca uma voz de robô como "reserva": entregar um vídeo com voz dos anos 90
é pior que entregar sem narração e dizer o que falta.

Uso:
    python3 narrar.py falar --texto "..." --saida narracao.mp3 [--voz antonio]
                           [--ritmo -8%] [--tom +2Hz]
    python3 narrar.py vozes
"""

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

AQUI = Path(__file__).resolve().parent
sys.path.insert(0, str(AQUI))

# ── catálogo ────────────────────────────────────────────────────────────────
# As vozes neurais de português do Brasil que existem no serviço da Microsoft.
# Não há por que inventar mais: são estas três, e o `estilo` é o que a IA
# escolhe para dar registro diferente à mesma voz.
VOZES = [
    {"id": "antonio", "voz": "pt-BR-AntonioNeural", "nome": "Antonio",
     "genero": "masculina", "serve": "locução, abertura, institucional", "padrao": True},
    {"id": "francisca", "voz": "pt-BR-FranciscaNeural", "nome": "Francisca",
     "genero": "feminina", "serve": "conversa, explicação, tutorial"},
    {"id": "thalita", "voz": "pt-BR-ThalitaMultilingualNeural", "nome": "Thalita",
     "genero": "feminina", "serve": "jovem, anúncio, redes sociais"},
]

# O "estilo" não existe como parâmetro no edge-tts: ele é ritmo + tom. Estes
# quatro cobrem o que se pede num vídeo, e são o ponto de partida — a IA pode
# mandar ritmo e tom na mão quando quiser outra coisa.
ESTILOS = {
    "normal":    {"ritmo": "+0%",  "tom": "+0Hz"},
    "anuncio":   {"ritmo": "+8%",  "tom": "+2Hz"},   # mais rápido e mais alto: energia
    "calmo":     {"ritmo": "-8%",  "tom": "-2Hz"},   # mais devagar e mais grave: explicação
    "dramatico": {"ritmo": "-14%", "tom": "-4Hz"},   # pausa e peso
}


def voz_de(apelido):
    a = (apelido or "").strip().lower()
    for v in VOZES:
        if a in (v["id"], v["voz"].lower(), v["nome"].lower()):
            return v
    return VOZES[0]


def tempo_vtt(marca):
    """"00:00:03.480" → 3.48"""
    h, m, s = marca.split(":")
    return int(h) * 3600 + int(m) * 60 + float(s.replace(",", "."))


def ler_legendas(caminho):
    """As falas com início e fim, lidas do VTT que o edge-tts escreveu."""
    falas = []
    try:
        bruto = Path(caminho).read_text(encoding="utf-8")
    except OSError:
        return falas
    bloco = None
    for linha in bruto.splitlines():
        linha = linha.strip()
        if "-->" in linha:
            de, ate = [p.strip() for p in linha.split("-->")[:2]]
            bloco = {"de": tempo_vtt(de), "ate": tempo_vtt(ate.split(" ")[0]), "texto": ""}
        elif bloco is not None and linha:
            bloco["texto"] = (bloco["texto"] + " " + linha).strip()
        elif bloco is not None and not linha:
            if bloco["texto"]:
                falas.append(bloco)
            bloco = None
    if bloco and bloco["texto"]:
        falas.append(bloco)
    return falas


def edge_disponivel():
    try:
        subprocess.run([sys.executable, "-m", "edge_tts", "--help"],
                       capture_output=True, timeout=25, check=True)
        return True
    except Exception:
        return False


# Por que uma única tentativa de pip não serve — e falha CALADA.
#
# Em Debian e Ubuntu, e no Python do Homebrew, o `pip install` no Python do
# sistema é recusado de saída: "externally-managed-environment" (PEP 668). Em
# máquina sem pip, o `-m pip` nem existe. Uma tentativa só, com a saída jogada
# fora, é exatamente a falha que ninguém vê: a instalação "não deu erro", o
# edge-tts continua ausente, e a narração cai calada na voz de reserva — o
# vídeo sai com outra voz e ninguém sabe por quê.
#
# Então são várias tentativas, em ordem da menos invasiva para a mais, e o
# erro da última fica guardado para a resposta poder contar o que houve.
ESTRATEGIAS = [
    ["-m", "pip", "install", "--user", "--quiet", "edge-tts"],
    ["-m", "pip", "install", "--user", "--quiet", "--break-system-packages", "edge-tts"],
    ["-m", "pip", "install", "--quiet", "edge-tts"],
    ["-m", "pip", "install", "--quiet", "--break-system-packages", "edge-tts"],
]

ultimo_erro_pip = ""


def instalar_edge():
    """Traz o edge-tts com pip. Uma vez, e o usuário vê acontecer."""
    global ultimo_erro_pip

    # Sem pip, primeiro o ensurepip — é o que o python.org e a Apple trazem
    # justamente para este caso.
    try:
        r = subprocess.run([sys.executable, "-m", "pip", "--version"],
                           capture_output=True, timeout=30)
        if r.returncode != 0:
            subprocess.run([sys.executable, "-m", "ensurepip", "--default-pip"],
                           capture_output=True, timeout=120)
    except Exception as e:
        ultimo_erro_pip = str(e)

    for args in ESTRATEGIAS:
        try:
            r = subprocess.run([sys.executable] + args, capture_output=True, timeout=240)
            if r.returncode == 0 and edge_disponivel():
                return True
            saida = (r.stderr or b"").decode("utf-8", "replace").strip()
            if saida:
                ultimo_erro_pip = saida.splitlines()[-1][:300]
        except Exception as e:
            ultimo_erro_pip = str(e)[:300]
    return False


def falar_edge(texto, saida, voz, ritmo, tom):
    with tempfile.TemporaryDirectory() as tmp:
        vtt = str(Path(tmp) / "legenda.vtt")
        cmd = [
            sys.executable, "-m", "edge_tts",
            "--voice", voz,
            "--text", texto,
            "--write-media", str(saida),
            "--write-subtitles", vtt,
        ]
        if ritmo and ritmo != "+0%":
            cmd += ["--rate", ritmo]
        if tom and tom != "+0Hz":
            cmd += ["--pitch", tom]

        r = subprocess.run(cmd, capture_output=True, timeout=300)
        if r.returncode != 0 or not Path(saida).exists():
            return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-400:]}

        falas = ler_legendas(vtt)
        return {
            "ok": True,
            "arquivo": str(saida),
            "motor": "edge",
            "voz": voz,
            "segundos": round(falas[-1]["ate"], 3) if falas else None,
            "falas": [{"de": round(f["de"], 3), "ate": round(f["ate"], 3), "texto": f["texto"]}
                      for f in falas],
        }


def falar_elevenlabs(texto, saida, voz):
    """Só quando há chave. Sem ela, nem se tenta — e não é erro."""
    # A chave mora na variável OU em ~/.primocode/elevenlabs.key — antes só a
    # variável era lida, e a função chamada (elevenlabs.falar) nem existia:
    # a ElevenLabs nunca narrou nada, mesmo com a chave no lugar.
    try:
        sys.path.insert(0, str(AQUI))
        import elevenlabs
        if not elevenlabs.configurado():
            return None
        dados, segundos = elevenlabs.sintetizar(texto, {"voz": voz})
        if not dados:
            return None
        Path(saida).write_bytes(dados)
        return {"ok": True, "arquivo": str(saida), "motor": "elevenlabs", "segundos": segundos,
                "falas": [{"de": 0, "ate": segundos, "texto": texto}]}
    except Exception:
        return None


def comando_falar(args):
    texto = args.texto
    if args.arquivo:
        texto = Path(args.arquivo).read_text(encoding="utf-8")
    texto = re.sub(r"\s+", " ", (texto or "")).strip()
    if not texto:
        return {"ok": False, "error": "não há texto para narrar"}

    v = voz_de(args.voz)
    estilo = ESTILOS.get((args.estilo or "normal").lower(), ESTILOS["normal"])
    ritmo = args.ritmo or estilo["ritmo"]
    tom = args.tom or estilo["tom"]

    premium = falar_elevenlabs(texto, args.saida, v["id"])
    if premium:
        return premium

    if not edge_disponivel() and not instalar_edge():
        return {
            "ok": False,
            "error": "a narração precisa do pacote edge-tts, e não deu para instalá-lo aqui.",
            "comoResolver": f"{sys.executable} -m pip install edge-tts"
                            + (" (no Debian/Ubuntu ou no Homebrew, acrescente --break-system-packages)"
                               if "externally-managed" in ultimo_erro_pip else ""),
            "erroDoPip": ultimo_erro_pip or None,
        }

    return falar_edge(texto, args.saida, v["voz"], ritmo, tom)


# ── o roteiro inteiro, com entonação por parte ─────────────────────────────
# "A voz está sem entonação." (25/09/2026). Uma chamada só, com o texto todo,
# sai no mesmo ritmo e no mesmo tom do começo ao fim. Aqui cada parte do
# roteiro é falada com o registro dela — o gancho mais aceso, o número mais
# devagar, o fecho mais alto — e as partes são costuradas com um respiro. O
# tempo de CADA PALAVRA volta junto (WordBoundary do edge-tts): é o que
# sincroniza a tipografia com a voz.
REGISTRO = {
    # 10.0: "os vídeos estão muito lentos" (29/09). Ritmo de anúncio por
    # padrão — ninguém fala devagar num reels — e o respiro entre as partes
    # caiu para o de uma vírgula: o corte seco é que dá energia.
    "gancho":   {"ritmo": "+18%", "tom": "+4Hz", "respiro": 0.12},
    "numero":   {"ritmo": "+8%",  "tom": "+1Hz", "respiro": 0.18},
    "contraste": {"ritmo": "+14%", "tom": "+2Hz", "respiro": 0.15},
    "lista":    {"ritmo": "+14%", "tom": "+2Hz", "respiro": 0.12},
    "citacao":  {"ritmo": "+6%",  "tom": "+0Hz", "respiro": 0.2},
    "cta":      {"ritmo": "+16%", "tom": "+4Hz", "respiro": 0.1},
    "normal":   {"ritmo": "+14%", "tom": "+2Hz", "respiro": 0.12},
}


def _parte_edge(texto, voz, ritmo, tom, saida):
    import asyncio
    import edge_tts

    async def rodar():
        c = edge_tts.Communicate(texto, voz, rate=ritmo, pitch=tom, boundary="WordBoundary")
        palavras = []
        with open(saida, "wb") as fh:
            async for ch in c.stream():
                if ch["type"] == "audio":
                    fh.write(ch["data"])
                elif ch["type"] == "WordBoundary":
                    de = ch["offset"] / 1e7
                    palavras.append({"de": round(de, 3), "ate": round(de + ch["duration"] / 1e7, 3),
                                     "texto": ch["text"]})
        return palavras
    return asyncio.run(rodar())


def _duracao(arquivo):
    r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                        "-of", "csv=p=0", str(arquivo)], capture_output=True, text=True, timeout=30)
    try:
        return float(r.stdout.strip())
    except ValueError:
        return 0.0


def comando_roteiro(args):
    partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
    if not partes:
        return {"ok": False, "error": "o roteiro veio vazio"}
    if not edge_disponivel() and not instalar_edge():
        return {"ok": False, "error": "a narração precisa do pacote edge-tts",
                "comoResolver": f"{sys.executable} -m pip install edge-tts"}
    v = voz_de(args.voz)
    with tempfile.TemporaryDirectory() as tmp:
        lista, trechos, palavras, t = [], [], [], 0.0
        for i, parte in enumerate(partes):
            texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
            if not texto:
                trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
                continue
            reg = REGISTRO.get(parte.get("registro") or "normal", REGISTRO["normal"])
            mp3 = Path(tmp) / f"p{i:02d}.mp3"
            try:
                ps = _parte_edge(texto, v["voz"], reg["ritmo"], reg["tom"], mp3)
            except Exception as e:
                return {"ok": False, "error": f"a voz falhou na parte {i + 1}: {str(e)[:200]}"}
            dur = _duracao(mp3)
            wav = Path(tmp) / f"p{i:02d}.wav"
            # Cada parte vira wav com o respiro no fim: costurar mp3 direto
            # deixa um clique e um silêncio de enchimento entre as partes.
            subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp3), "-af",
                            f"apad=pad_dur={reg['respiro']}", "-ar", "44100", "-ac", "1", str(wav)],
                           capture_output=True, timeout=60)
            lista.append(wav)
            ws = [{"de": round(t + w["de"], 3), "ate": round(t + w["ate"], 3), "texto": w["texto"]} for w in ps]
            palavras += ws
            trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
            t += dur + reg["respiro"]
        roteiro_txt = Path(tmp) / "lista.txt"
        roteiro_txt.write_text("".join(f"file '{w}'\n" for w in lista), encoding="utf-8")
        r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
                            "-i", str(roteiro_txt), "-af", "loudnorm=I=-14:TP=-1.5:LRA=9",
                            "-c:a", "libmp3lame", "-q:a", "3", str(args.saida)],
                           capture_output=True, timeout=180)
        if r.returncode != 0:
            return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
    return {"ok": True, "arquivo": str(args.saida), "motor": "edge", "voz": v["voz"],
            "segundos": round(t, 3), "partes": trechos, "palavras": palavras}


def main():
    p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
    sub = p.add_subparsers(dest="comando", required=True)

    f = sub.add_parser("falar")
    f.add_argument("--texto", default="")
    f.add_argument("--arquivo", default="")
    f.add_argument("--saida", required=True)
    f.add_argument("--voz", default="antonio")
    f.add_argument("--estilo", default="normal")
    f.add_argument("--ritmo", default="")
    f.add_argument("--tom", default="")

    r = sub.add_parser("roteiro")
    r.add_argument("--arquivo", required=True)
    r.add_argument("--saida", required=True)
    r.add_argument("--voz", default="thalita")

    sub.add_parser("vozes")

    args = p.parse_args()
    if args.comando == "vozes":
        saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
    elif args.comando == "roteiro":
        saida = comando_roteiro(args)
    else:
        saida = comando_falar(args)

    print(json.dumps(saida, ensure_ascii=False))
    sys.exit(0 if saida.get("ok") else 1)


if __name__ == "__main__":
    main()
