"""Narração de verdade, gerada aqui em Python.

── O PROBLEMA ────────────────────────────────────────────────────────────
A narração era feita no NAVEGADOR. Havia três caminhos lá: a voz neural do
Edge por WebSocket, o TTS do Google por este servidor, e o speechSynthesis
da própria máquina. O primeiro morreu — a Microsoft passou a responder 403
na síntese (a lista de vozes ainda responde; a síntese, não), e isso é uma
trava que eles impõem de propósito, não um bug para contornar. O terceiro é
o TTS do sistema: existe em todo computador e soa a computador. Resultado
prático: o usuário pedia vídeo institucional e ouvia voz de robô.

── A SAÍDA ───────────────────────────────────────────────────────────────
Trazer a síntese para cá, para o Python, onde ela não depende de navegador
nem de serviço de ninguém. O motor é o Piper (MIT, do projeto Rhasspy):
rede neural que roda na máquina, offline, sem chave, sem cota e sem limite.
Ele não vem no pacote — são ~90 MB entre programa e modelo — então é
baixado uma vez, sob demanda, e daí em diante a narração é instantânea e
funciona sem internet.

── AS VOZES ──────────────────────────────────────────────────────────────
As quatro vozes pt-BR do Piper foram sintetizadas e MEDIDAS: a frequência
fundamental de cada uma está na tabela abaixo, apurada por autocorrelação
sobre os quadros com tom. Não é adjetivo de catálogo, é medida. Por isso a
padrão é a Jeff, de 139 Hz — a mais grave, que é o que sustenta abertura de
vídeo. E por isso não há voz feminina listada: o Piper não tem nenhuma em
pt-BR, e inventar rótulo seria pior que admitir a falta.

── A CORRENTE ────────────────────────────────────────────────────────────
1. PIPER    neural, offline, ilimitado, com escolha de voz. O bom.
2. VOICEBOX se o usuário tiver o app instalado — voz clonável.
3. GOOGLE   uma voz só, robótica, mas responde na hora e sem instalar nada.

Cada um cai no seguinte quando falha. A narração nunca fica muda por falta
de motor: enquanto o Piper baixa, o Google cobre.
"""

import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import urllib.parse
import urllib.request
import wave
import zipfile

CASA = os.path.join(os.path.expanduser("~"), ".primocode", "vozes")

RELEASE = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/"
VOZES_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/"

# O pacote do Piper para cada sistema. Os cinco nomes foram conferidos no ar.
PACOTES = {
    ("Linux", "x86_64"): "piper_linux_x86_64.tar.gz",
    ("Linux", "aarch64"): "piper_linux_aarch64.tar.gz",
    ("Linux", "arm64"): "piper_linux_aarch64.tar.gz",
    ("Darwin", "x86_64"): "piper_macos_x64.tar.gz",
    ("Darwin", "arm64"): "piper_macos_aarch64.tar.gz",
    ("Windows", "AMD64"): "piper_windows_amd64.zip",
    ("Windows", "x86_64"): "piper_windows_amd64.zip",
}

# hz: fundamental medida, em Hz. Quanto menor, mais grave.
CATALOGO = [
    {"id": "jeff", "nome": "Jeff", "hz": 139, "grave": True, "padrao": True,
     "tom": "grave, de locução", "caminho": "pt/pt_BR/jeff/medium/pt_BR-jeff-medium", "mb": 60},
    {"id": "cadu", "nome": "Cadu", "hz": 140, "grave": True,
     "tom": "grave, mais solta", "caminho": "pt/pt_BR/cadu/medium/pt_BR-cadu-medium", "mb": 60},
    {"id": "faber", "nome": "Faber", "hz": 167,
     "tom": "clara, de leitura", "caminho": "pt/pt_BR/faber/medium/pt_BR-faber-medium", "mb": 60},
    {"id": "edresson", "nome": "Edresson", "hz": 151,
     "tom": "média, mais leve de baixar", "caminho": "pt/pt_BR/edresson/low/pt_BR-edresson-low", "mb": 21},
    {"id": "tugao", "nome": "Tugão", "hz": 0,
     "tom": "português de Portugal", "caminho": "pt/pt_PT/tugão/medium/pt_PT-tugão-medium", "mb": 60},
]

POR_ID = {v["id"]: v for v in CATALOGO}
PADRAO = next(v["id"] for v in CATALOGO if v.get("padrao"))

# Estado do download, para a interface poder mostrar em vez de fingir que
# está tudo pronto. Um dicionário simples, protegido por tranca porque quem
# escreve é a thread do download e quem lê são as requisições.
_tranca = threading.Lock()
_baixando = {"ativo": False, "voz": None, "pct": 0, "etapa": "", "erro": None}
_thread = None


def _versao_pacote():
    return PACOTES.get((platform.system(), platform.machine()))


def pasta_piper():
    return os.path.join(CASA, "piper")


def binario():
    """O executável do Piper, se já foi baixado."""
    nome = "piper.exe" if platform.system() == "Windows" else "piper"
    p = os.path.join(pasta_piper(), nome)
    return p if os.path.exists(p) else None


def modelo(voz_id):
    v = POR_ID.get(voz_id or PADRAO)
    if not v:
        return None
    base = os.path.join(CASA, os.path.basename(v["caminho"]))
    return base if os.path.exists(base + ".onnx") and os.path.exists(base + ".onnx.json") else None


def instaladas():
    return [v["id"] for v in CATALOGO if modelo(v["id"])]


# O binário EXISTIR não prova que ele RODA. O pacote macos_aarch64 do Piper
# 2023.11.14-2 veio com executável x86_64 dentro — num Mac Apple Silicon sem
# Rosetta, spawná-lo morre com "Bad CPU type" (errno 86). Sem esta checagem,
# `pronta()` dizia sim, o servidor esperava a voz que nunca viria, e cada
# pedido de narração travava a corrente inteira de motores por minutos.
# A prova roda UMA vez e o veredito fica em arquivo: reiniciar não re-testa,
# e reinstalar (apagar()) limpa o veredito junto.
_MARCA_INCOMPATIVEL = os.path.join(CASA, "binario-incompativel")
_roda_cache = None


def marcar_incompativel(motivo):
    global _roda_cache
    _roda_cache = False
    try:
        os.makedirs(CASA, exist_ok=True)
        with open(_MARCA_INCOMPATIVEL, "w") as fh:
            fh.write(str(motivo))
    except Exception:
        pass


def bloqueado():
    """Por que o Piper nunca vai funcionar nesta máquina — ou None."""
    try:
        if os.path.exists(_MARCA_INCOMPATIVEL):
            with open(_MARCA_INCOMPATIVEL) as fh:
                return fh.read().strip() or "binário incompatível com esta máquina"
    except Exception:
        pass
    return None


def _binario_roda():
    global _roda_cache
    if _roda_cache is not None:
        return _roda_cache
    if bloqueado():
        _roda_cache = False
        return False
    exe = binario()
    if not exe:
        return None                     # ainda não baixou: sem veredito
    try:
        subprocess.run([exe, "--version"], stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL, timeout=10)
        _roda_cache = True
    except OSError as e:
        if getattr(e, "errno", None) == 86:      # Bad CPU type (macOS)
            marcar_incompativel("o pacote do Piper veio com binário de outra "
                                "arquitetura (x86_64 num Mac ARM sem Rosetta)")
        else:
            marcar_incompativel("o binário do Piper não executa: %s" % e)
    except Exception:
        _roda_cache = True              # demorou mas executa: não é errno 86
    return _roda_cache


def pronta(voz_id=None):
    return bool(binario() and modelo(voz_id or PADRAO) and _binario_roda())


# ------------------------------------------------------------------ baixar --

def _baixar(url, destino, fatia=(0, 100)):
    """Baixa mostrando progresso dentro da fatia que lhe cabe do total."""
    ini, fim = fatia
    with urllib.request.urlopen(url, timeout=60) as r:
        total = int(r.headers.get("Content-Length") or 0)
        feito = 0
        tmp = destino + ".parcial"
        with open(tmp, "wb") as fh:
            while True:
                p = r.read(262144)
                if not p:
                    break
                fh.write(p)
                feito += len(p)
                if total:
                    with _tranca:
                        _baixando["pct"] = int(ini + (fim - ini) * feito / total)
        # Só vira o arquivo de verdade quando terminou inteiro: assim uma
        # queda de rede não deixa um modelo pela metade que "existe".
        os.replace(tmp, destino)


def _instalar_piper():
    pacote = _versao_pacote()
    if not pacote:
        raise RuntimeError("não há Piper para %s/%s" % (platform.system(), platform.machine()))
    os.makedirs(CASA, exist_ok=True)
    with _tranca:
        _baixando["etapa"] = "baixando o motor de voz"
    alvo = os.path.join(CASA, pacote)
    _baixar(RELEASE + pacote, alvo, (0, 35))

    with _tranca:
        _baixando["etapa"] = "instalando o motor"
    if pacote.endswith(".zip"):
        with zipfile.ZipFile(alvo) as z:
            z.extractall(CASA)
    else:
        with tarfile.open(alvo) as t:
            t.extractall(CASA)
    os.remove(alvo)

    exe = binario()
    if not exe:
        raise RuntimeError("o pacote do Piper não trouxe o executável")
    if platform.system() != "Windows":
        os.chmod(exe, 0o755)


def _instalar_voz(voz_id):
    v = POR_ID[voz_id]
    os.makedirs(CASA, exist_ok=True)
    base = os.path.join(CASA, os.path.basename(v["caminho"]))
    with _tranca:
        _baixando["etapa"] = "baixando a voz %s" % v["nome"]
    # O nome tem "ã" no caso do tugão: a URL precisa disso escapado.
    url = VOZES_URL + urllib.parse.quote(v["caminho"])
    _baixar(url + ".onnx.json", base + ".onnx.json", (35, 40))
    _baixar(url + ".onnx", base + ".onnx", (40, 100))


def _rotina(voz_id):
    global _thread
    try:
        if not binario():
            _instalar_piper()
        if not modelo(voz_id):
            _instalar_voz(voz_id)
        with _tranca:
            _baixando.update(ativo=False, pct=100, etapa="pronta", erro=None)
    except Exception as e:
        sys.stderr.write("[voz] instalação falhou: %s\n" % e)
        with _tranca:
            _baixando.update(ativo=False, etapa="", erro=str(e))
    finally:
        _thread = None


def instalar(voz_id=None, esperar=0):
    """Começa (ou acompanha) a instalação. Não bloqueia, por padrão.

    Quem chama é a interface e o próprio servidor quando vê um documento com
    narração: preparar a voz enquanto o agente ainda escreve o roteiro é o
    que faz ela estar pronta na hora de tocar."""
    global _thread
    voz_id = voz_id if voz_id in POR_ID else PADRAO
    # Baixar de novo não conserta binário de arquitetura errada: viria o
    # mesmo pacote. Sem esta guarda, cada pedido de narração re-disparava
    # os ~90 MB de download para nada.
    if bloqueado():
        return estado()
    with _tranca:
        if _thread is None and not pronta(voz_id):
            _baixando.update(ativo=True, voz=voz_id, pct=0, etapa="começando", erro=None)
            _thread = threading.Thread(target=_rotina, args=(voz_id,), daemon=True)
            _thread.start()
        t = _thread
    if t and esperar:
        t.join(esperar)
    return estado()


def estado():
    with _tranca:
        b = dict(_baixando)
    return {
        "motor": "piper",
        "pronta": pronta(),
        "instaladas": instaladas(),
        # "suportado" responde à pergunta de verdade: o Piper VAI funcionar
        # aqui? Pacote inexistente para a plataforma e binário que não roda
        # são o mesmo "não" — e `bloqueado` explica o porquê.
        "suportado": bool(_versao_pacote()) and not bloqueado(),
        "bloqueado": bloqueado(),
        "baixando": b,
        "vozes": [{
            "id": v["id"], "nome": v["nome"], "tom": v["tom"], "hz": v["hz"], "mb": v["mb"],
            "instalada": bool(modelo(v["id"])),
            "grave": bool(v.get("grave")),
            "padrao": bool(v.get("padrao")),
        } for v in CATALOGO],
    }


# --------------------------------------------------------------- sintetizar --

def _mais_grave(bruto, razao):
    """Abaixa o tom reescrevendo a taxa declarada do WAV.

    Não há reamostragem: os mesmos quadros, lidos mais devagar, soam mais
    graves na mesma proporção. Como isso também alonga o áudio, a síntese já
    foi feita acelerada pelo mesmo fator, e a duração volta ao lugar.
    Medido: a voz Jeff sai de 139 Hz para 125 Hz com razão 0.9."""
    with wave.open(bruto, "rb") as w:
        p = w.getparams()
        quadros = w.readframes(p.nframes)
    saida = bruto + ".grave.wav"
    with wave.open(saida, "wb") as s:
        s.setnchannels(p.nchannels)
        s.setsampwidth(p.sampwidth)
        s.setframerate(max(8000, int(p.framerate * razao)))
        s.writeframes(quadros)
    return saida


def sintetizar(texto, voz_id=None, grave=False, velocidade=1.0, timeout=120):
    """O texto vira WAV. None quando o Piper não está pronto — aí quem chamou
    cai para o próximo motor da corrente."""
    exe = binario()
    voz_id = voz_id if voz_id in POR_ID else PADRAO
    mod = modelo(voz_id)
    if not exe or not mod or not _binario_roda():
        return None

    # Sem isto, um texto com quebra de linha vira várias falas e o Piper
    # devolve só a última.
    limpo = re.sub(r"\s+", " ", str(texto or "")).strip()
    if not limpo:
        return None

    razao = 0.9 if grave else 1.0
    # length_scale > 1 é mais devagar. A razão do grave entra aqui para a
    # duração não crescer quando a taxa for reescrita lá embaixo.
    escala = (1.0 / max(0.5, min(2.0, velocidade))) * razao

    pasta = tempfile.mkdtemp(prefix="primo-voz-")
    try:
        saida = os.path.join(pasta, "voz.wav")
        try:
            r = subprocess.run(
                [exe, "--model", mod + ".onnx", "--config", mod + ".onnx.json",
                 "--length_scale", "%.3f" % escala, "--sentence_silence", "0.25",
                 "--output_file", saida],
                input=limpo.encode("utf-8"),
                stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout,
            )
        except OSError as e:
            # errno 86 = binário de outra arquitetura (macOS). Marca de vez:
            # a corrente cai para o próximo motor e ninguém trava de novo.
            if getattr(e, "errno", None) == 86:
                marcar_incompativel("o pacote do Piper veio com binário de "
                                    "outra arquitetura (x86_64 num Mac ARM sem Rosetta)")
                return None
            raise
        if r.returncode != 0 or not os.path.exists(saida):
            raise RuntimeError((r.stderr or b"").decode("utf-8", "replace")[-300:] or "piper falhou")
        if grave:
            saida = _mais_grave(saida, razao)
        with open(saida, "rb") as fh:
            return fh.read()
    finally:
        shutil.rmtree(pasta, ignore_errors=True)


def apagar():
    """Some com tudo que foi baixado. Existe para o usuário poder desfazer:
    são 90 MB na máquina dele, e ele tem direito de recuperá-los.
    O veredito de incompatibilidade vai junto (mora dentro de CASA): quem
    instalar de novo — num Mac com Rosetta, num pacote corrigido — merece
    uma prova nova, não a sentença antiga."""
    global _roda_cache
    _roda_cache = None
    if os.path.isdir(CASA):
        shutil.rmtree(CASA, ignore_errors=True)
    return {"ok": True}
