"""primo_voz — o ouvido e a boca do PrimoCode. Não tem cérebro.

    "As funções do modo voz serão implementadas na CLI; o modo voz será só
     para falar e ele responder em voz alta."

── O QUE ESTE PROCESSO FAZ ─────────────────────────────────────────────────
Ouve o microfone, transcreve o que a pessoa disse e entrega o TEXTO ao
terminal, que trata como se tivesse sido digitado: vai para a mesma fila, a
mesma caixa de recados (lib/session/runner/act.js), o mesmo roteador de conversa. Quando o
terminal manda falar, ele fala — e cala na hora se a pessoa falar por cima.

O que saiu (10.0): a orb (uma janela Tk ao lado do terminal), o "ei, primo"
(o Vosk, um download de 31 MB para um microfone que já está aberto) e o
cérebro paralelo — o /voice chamava o agente por conta própria, ao lado do
terminal, e dois agentes na mesma pasta escreviam um por cima do outro.

── O PROTOCOLO ─────────────────────────────────────────────────────────────
Uma linha de JSON por mensagem.

    para o Node:  {"tipo":"pronto"}                o microfone abriu
                  {"tipo":"ouvi","texto":"..."}    a pessoa disse isto
                  {"tipo":"erro","texto":"..."}
                  {"tipo":"fim"}
    do Node:      {"tipo":"falar","texto":"..."}   diga isto em voz alta
                  {"tipo":"calar"}                 pare de falar agora
                  {"tipo":"fim"}
"""

from __future__ import annotations

import json
import os
import queue
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

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

from primo_voz import conversa, escuta, fala, fala_pt, identidade, painel, transcrever  # noqa: E402
from primo_voz import tela as dados_tela  # noqa: E402
from primo_voz.luz import Luz  # noqa: E402
from primo_voz.sons import Sons  # noqa: E402


class _PonteHud:
    """No modo app o painel mora no processo-pai (app.py): as mensagens dele vão pelo stdout do protocolo."""

    class _Entrada:
        def write(self, linha: str) -> None:
            try:
                falar_para_o_node({"tipo": "hud", "msg": json.loads(linha)})
            except ValueError:
                pass

        def flush(self) -> None:
            pass

        def close(self) -> None:
            pass

    def __init__(self) -> None:
        self.stdin = self._Entrada()
        self.stdout = iter(())

    def poll(self):
        return None


class Bolinha:
    """A orb (orbe.py) num processo à parte: este só diz o estado a ela.

    Sem tkinter, ou se ela morrer, o modo voz segue igual — a bolinha é o
    rosto, não o ouvido. Mudanças repetidas não são reenviadas, e o nível da
    voz vai no máximo 15 vezes por segundo."""

    def __init__(self) -> None:
        self.p = None
        self.ultimo = None
        self.quando = 0.0
        if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
            return
        self.ao_acao = None
        if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
            self.modulo, self.p = "primo_voz.hud", _PonteHud()
            return
        # No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
        modulo = "primo_voz.orbe"
        if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
            try:
                import AppKit  # noqa: F401
                modulo = "primo_voz.hud"
            except ImportError:
                pass
        self.modulo = modulo
        try:
            cmd = [sys.executable, "-m", modulo]
            env_extra = {"PYTHONPATH": str(AQUI.parent)}
            if modulo.endswith("hud"):
                from primo_voz import appmac          # Primo.app: nome e ícone no Dock; cai no python se não der
                exe = appmac.preparar()
                if exe:
                    cmd = [exe, "-m", "primo_voz.hud"]
                    env_extra = {"PYTHONPATH": appmac.pythonpath()}
            self.p = subprocess.Popen(cmd, cwd=str(AQUI.parent),
                                      stdin=subprocess.PIPE,
                                      stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
                                      stderr=subprocess.DEVNULL,
                                      env={**os.environ, **env_extra}, text=True)
            if modulo.endswith("hud"):
                threading.Thread(target=self._ler_acoes, daemon=True).start()
        except Exception:
            self.p = None

    def _ler_acoes(self) -> None:
        """O HUD manda {"acao":"ptt"} quando a pessoa clica nos anéis."""
        try:
            for linha in self.p.stdout:
                try:
                    a = json.loads(linha).get("acao")
                except ValueError:
                    continue
                if a and self.ao_acao:
                    self.ao_acao(a)
        except Exception:
            pass

    def enviar(self, obj: dict) -> None:
        if not self.p or self.p.poll() is not None or not self.modulo.endswith("hud"):
            return
        try:
            self.p.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
            self.p.stdin.flush()
        except Exception:
            pass

    def estado(self, estado: str, nivel: float = None) -> None:
        if not self.p or self.p.poll() is not None:
            return
        agora = time.time()
        if estado == self.ultimo and (nivel is None or agora - self.quando < 1 / 15):
            return
        self.ultimo, self.quando = estado, agora
        if not self.modulo.endswith("hud"):
            estado = {"executando": "pensando", "interrompido": "ouvindo", "dormindo": "esperando"}.get(estado, estado)
        msg = {"estado": estado}
        if nivel is not None:
            msg["nivel"] = round(min(1.0, nivel), 3)
        try:
            self.p.stdin.write(json.dumps(msg) + "\n")
            self.p.stdin.flush()
        except Exception:
            pass

    def fechar(self) -> None:
        if self.p:
            try:
                self.p.stdin.close()
            except Exception:
                pass


def falar_para_o_node(obj: dict) -> None:
    """Uma linha de JSON no stdout. Nada mais pode sair por aí."""
    sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
    sys.stdout.flush()


def avisar(texto: str) -> None:
    """Recado para o terminal, na saída de ERRO — o stdout é do protocolo."""
    sys.stderr.write(texto.rstrip() + "\n")
    sys.stderr.flush()


def motivo_da_narracao(r) -> str:
    """O que o narrar.py disse que houve — em vez do código de saída."""
    try:
        d = json.loads((r.stdout or b"").decode("utf-8", "replace"))
        if isinstance(d, dict) and d.get("error"):
            como = d.get("comoResolver") or ""
            return "não deu para gerar a voz: " + str(d["error"]) + (" — " + str(como) if como else "")
    except Exception:
        pass
    erro = (r.stderr or b"").decode("utf-8", "replace").strip()
    ultima = erro.splitlines()[-1] if erro else ""
    return "não deu para gerar a voz" + (f": {ultima[:300]}" if ultima else f" (o narrar.py saiu com {r.returncode})")


# O PowerShell que toca MP3 no Windows. MediaPlayer é assíncrono: Play()
# devolve na hora, então é preciso esperar a duração — com teto.
PS_TOCAR_MP3 = (
    "Add-Type -AssemblyName PresentationCore; "
    "$p = New-Object System.Windows.Media.MediaPlayer; "
    "$p.Open([uri]'{arquivo}'); "
    "$n = 0; "
    "while (-not $p.NaturalDuration.HasTimeSpan -and $n -lt 100) "
    "{{ Start-Sleep -Milliseconds 50; $n++ }}; "
    "if (-not $p.NaturalDuration.HasTimeSpan) {{ exit 1 }}; "
    "$p.Play(); "
    "Start-Sleep -Seconds $p.NaturalDuration.TimeSpan.TotalSeconds; "
    "$p.Close()"
)


class Voz:
    """Microfone → transcrição → terminal. Terminal → voz. O Primo.

    A conversa (estados, frases, resumo, comandos) é conversa.py, puro e testado;
    o painel é hud.py; luz.py e sons.py são o resto do corpo."""

    def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
        self.servidor = servidor
        self.token = token
        self.voz = voz_escolhida
        self.cfg = identidade.ler()
        self.nome = self.cfg["nome"]
        # Sem palavra de ativação: o microfone só abre no clique do Primo (ptt) e fecha no clique ou ao fim da conversa.
        self._mic = None
        self._mic_aberto = False
        self.local = transcrever.disponivel()
        if self.local:
            transcrever.aquecer()
        self.edge = fala.Edge()
        self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
        self._t_acabou = None
        self._pend = ""
        self._pend_ate = 0.0
        threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
        self.gravador = escuta.Gravador()
        self.gravador.vad = escuta.criar_vad()
        self.corte = escuta.Corte()
        self.bolinha = Bolinha()
        self.bolinha.ao_acao = self._acao_do_painel
        self.luz = Luz(bool(self.cfg.get("luz", True)))
        self.sons = Sons(bool(self.cfg.get("sons", True)))
        self.maquina = conversa.Maquina("ouvindo")
        self._blocos: "queue.Queue" = queue.Queue()
        self._falas: "queue.Queue" = queue.Queue()      # frases de texto
        self._audios: "queue.Queue" = queue.Queue(maxsize=2)   # mp3 já pronto
        self._vivo = True
        self._falando = False
        self._parar_fala = threading.Event()
        self._acordado_ate = 0.0       # janela de conversa: sem repetir o nome
        self._ultima_conf = 0.0
        self._ultimo_ok = ""
        self.bolinha.enviar({"nome": self.nome})

    def lat(self, texto: str) -> None:
        if os.environ.get("PRIMOCODE_LAT") == "1":
            avisar("lat: " + texto)

    # ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────

    def _estado(self, estado: str, nivel: float = None) -> None:
        mudou = estado != self.bolinha.ultimo
        self.bolinha.estado(estado, nivel)
        self.luz.mudar(estado, nivel)
        if mudou and estado in ("ouvindo", "pensando"):
            self.sons.tocar(estado, falando=self._falando)

    def _acao_do_painel(self, acao: str) -> None:
        if acao == "ptt":                      # clique no Primo: abre o modo voz; clicar de novo fecha
            if self._mic_aberto:
                self._fechar_mic()
            else:
                self._abrir_mic()

    def _abrir_mic(self) -> bool:
        if self._mic_aberto or not self._vivo:
            return self._mic_aberto
        mic = escuta.Microfone(self._ao_receber)
        try:
            mic.abrir()
        except Exception as e:
            falar_para_o_node({"tipo": "erro", "texto": f"não consegui abrir o microfone: {e}"})
            self._erro("Não consegui abrir o microfone.")
            return False
        self._mic, self._mic_aberto = mic, True
        self._esvaziar_blocos()
        self.gravador.comecar()
        self._acordado_ate = time.time() + 25
        self._estado("ouvindo")
        self.sons.tocar("acordou", falando=self._falando)
        return True

    def _fechar_mic(self) -> None:
        """Desliga o microfone e volta a dormir: nada é gravado nem transcrito até o próximo clique."""
        mic, self._mic, self._mic_aberto = self._mic, None, False
        if mic:
            mic.fechar()
        self._acordado_ate = 0.0
        self._pend = ""
        self.gravador.espera = escuta.ESPERA_COMECAR
        self._esvaziar_blocos()
        if not self._falando:
            self._estado("dormindo")

    def _conversa_acabou(self) -> bool:
        """O mesmo critério de antes para voltar a dormir: a janela de conversa passou e ninguém está pensando/falando."""
        return (self._mic_aberto and not self._falando and not self._pend
                and self.maquina.estado == "ouvindo" and time.time() > self._acordado_ate)

    def _erro(self, texto: str) -> None:
        self.luz.erro()
        self.sons.tocar("erro")
        self.bolinha.enviar({"resposta": texto})

    # ── o ouvido ─────────────────────────────────────────────────────────

    def _ao_receber(self, bloco) -> None:
        if not self._vivo or not self._mic_aberto:
            return
        nivel = escuta.rms(bloco)
        if not self._falando and self.bolinha.ultimo:      # o volume REAL do mic vai para as barras da tela
            self.bolinha.estado(self.bolinha.ultimo, dados_tela.nivel_do_mic(nivel))
        # Enquanto ele fala, o microfone só serve para saber se a pessoa falou
        # POR CIMA (interrupção). A voz dele na caixa de som não vira pedido.
        if self._falando:
            self.corte.ele_falou(nivel)
            if self.maquina.pode_cortar and self.corte.alguem_cortou(nivel):
                self._interromper()
            return
        self._blocos.put(bloco)

    def _interromper(self) -> None:
        self.maquina.mandar("cortou")
        self._parar_fala.set()
        self._limpar_falas()
        self._acordado_ate = time.time() + 20   # falou por cima: já está conversando
        self.maquina.mandar("escutar")
        self._estado("ouvindo")

    def _limpar_falas(self) -> None:
        for fila in (self._falas, self._audios):
            while True:
                try:
                    fila.get_nowait()
                except queue.Empty:
                    break

    def _esvaziar_blocos(self) -> None:
        while True:
            try:
                self._blocos.get_nowait()
            except queue.Empty:
                return

    def _parcial(self) -> None:
        """Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
        if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
            return
        copia = list(self.gravador._blocos)
        if not copia:
            return
        self._parcial_rodando = True

        def trabalho():
            try:
                import numpy as np
                import wave as _w
                arq = Path(tempfile.gettempdir()) / "primo-voz-parcial.wav"
                with _w.open(str(arq), "wb") as w:
                    w.setnchannels(1)
                    w.setsampwidth(2)
                    w.setframerate(escuta.TAXA)
                    w.writeframes(np.concatenate(copia).astype(np.int16).tobytes())
                t = transcrever.limpar(transcrever.transcrever(arq))
                if t and self.gravador._falando:
                    self.bolinha.enviar({"transcricao": t, "parcial": True})
            except Exception:
                pass
            finally:
                self._parcial_rodando = False
        threading.Thread(target=trabalho, daemon=True).start()

    def _ouvir(self) -> None:
        # O microfone NÃO abre aqui: só no clique (_acao_do_painel). Sem clique, nada grava nem transcreve.
        falar_para_o_node({"tipo": "pronto"})
        self._estado("dormindo")
        while self._vivo:
            self.gravador.comecar()
            acabou = False
            ultimo_parcial = 0.0
            while self._vivo and not acabou:
                try:
                    bloco = self._blocos.get(timeout=0.3)
                except queue.Empty:
                    if self._conversa_acabou():
                        self._fechar_mic()
                    continue
                acabou = self.gravador.alimentar(bloco)
                if self.gravador._falando and not self._falando:
                    lim = max(1e-6, self.gravador._limiar())
                    self.luz.mudar("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
                    self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
                    if time.time() - ultimo_parcial > 1.6 and self.gravador.segundos > 1.0:
                        ultimo_parcial = time.time()
                        self._parcial()
            if not self._vivo:
                break
            if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
                if self._pend and time.time() >= self._pend_ate:      # ninguém continuou: agora sim vira pedido
                    pend, self._pend = self._pend, ""
                    self.gravador.espera = escuta.ESPERA_COMECAR
                    self.lat(f"frase completa após a pausa extra: {pend!r}")
                    self._tratar(pend)
                continue
            wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
            if not wav:
                continue
            self._t_acabou = time.time()
            texto = transcrever.limpar(self._transcrever(wav))
            self.lat(f"transcrição {time.time() - self._t_acabou:.2f}s (a frase fechou após {escuta.RABO_VAD if self.gravador.vad else escuta.RABO_SILENCIO:.1f}s de silêncio) -> {texto!r}")
            self._juntar_e_tratar(texto)
        self._fechar_mic()

    # "espera": a pessoa segura a vez. Palavras que, sozinhas no fim, prometem continuação.
    _SEGURA = re.compile(r"(?:^|[\s,.])(?:espera|espere|pera ai|perai|aguarda|aguarde|calma|deixa eu pensar)[\s.!?,]*$", re.I)
    _CONTINUA = re.compile(r"\b(?:e|ou|mas|ent[aã]o|que|tipo|porque|pois|a[ií]|pra|para|de|do|da|no|na|com|como|se|quando|ent[aã]o|tamb[eé]m|a|o|um|uma|meu|minha)\W*$", re.I)

    def _incompleta(self, texto: str) -> bool:
        t = texto.strip()
        if not t:
            return False
        if t[-1] in ".?!":
            return False
        if t[-1] in ",;:…-" or self._CONTINUA.search(t):
            return True
        return len(t.split()) >= 5    # sem pontuação de fim numa frase já longa: provavelmente vem mais; comando curto não espera

    def _juntar_e_tratar(self, texto: str) -> None:
        """Fim de fala ADAPTATIVO: 1,1 s de silêncio já fechou; se a frase soa incompleta ("e…", "então…", sem ponto)
        ou a pessoa disse "espera", ele segura mais um pouco e junta com o que vier. Nunca corta no meio."""
        if self._pend:
            texto = (self._pend + " " + texto).strip()
            self._pend = ""
        if not texto:
            self.gravador.espera = escuta.ESPERA_COMECAR
            self._tratar(texto)
            return
        if self._SEGURA.search(texto):
            resto = self._SEGURA.sub("", texto).strip(" ,")
            self._pend, self._pend_ate = resto or " ", time.time() + 10
            self.gravador.espera = 10.0
            self.lat("a pessoa pediu para esperar: segurando até 10 s")
            self.bolinha.enviar({"transcricao": (resto + " …") if resto else "…", "parcial": True})
            self._estado("ouvindo")
            return
        if self._incompleta(texto):
            self._pend, self._pend_ate = texto, time.time() + 1.6
            self._acordado_ate = max(self._acordado_ate, time.time() + 30)
            self.gravador.espera = 1.6
            self.lat(f"frase incompleta, esperando mais 1,6 s: {texto!r}")
            self.bolinha.enviar({"transcricao": texto + " …", "parcial": True})
            self._estado("ouvindo")
            return
        self.gravador.espera = escuta.ESPERA_COMECAR
        self._tratar(texto)

    def _tratar(self, texto: str) -> None:
        """O que foi dito (o microfone só está aberto se a pessoa clicou): apresentação, comando local ou pedido ao terminal."""
        if not texto:
            self._estado(self.maquina.estado if self.maquina.estado != "pensando" else "ouvindo")
            return
        agora = time.time()
        self._acordado_ate = agora + 25
        self.bolinha.enviar({"transcricao": texto, "parcial": False})
        novo = identidade.aprender(texto)
        if novo:
            self.cfg = identidade.salvar(**novo)
            if "nome" in novo:
                self.nome = novo["nome"]
                self.bolinha.enviar({"nome": self.nome})
                self._dizer(f"Fechado. Agora eu me chamo {self.nome}.")
            else:
                self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
            return
        nova = fala.voz_pedida(texto, self.voz_id)
        if nova:
            self.voz_id = nova
            self.cfg = identidade.salvar(voz=nova)
            self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
            return
        cmd = conversa.comando(texto)
        if cmd == "esconder":
            self.bolinha.enviar({"painel": "esconder"})
            self.sons.tocar("painel_fecha")
            return
        if cmd == "mostrar":
            self.bolinha.enviar({"painel": "mostrar"})
            self.sons.tocar("painel_abre")
            return
        if cmd == "parar":
            self._parar_fala.set()
            self._limpar_falas()
            self.maquina.mandar("parar")
            falar_para_o_node({"tipo": "parar"})
            self._estado("ouvindo")
            return
        if cmd == "olhar":
            texto = "Tire um print da minha tela agora, olhe e me diga em uma ou duas frases o que está acontecendo."
        elif cmd == "resumo_dia":
            texto = ("Faça o resumo do meu dia no projeto atual, em poucas frases faladas: PRs abertos (gh pr list), "
                     "se os testes passam e o que mudou hoje no git. O detalhe vai no painel.")
        self.sons.tocar("entendeu", falando=self._falando)
        self.maquina.mandar("ouviu")
        self._estado("pensando")
        falar_para_o_node({"tipo": "ouvi", "texto": texto})

    def _dizer(self, texto: str) -> None:
        """Fala uma frase curta já pronta (sem passar pelo resumo)."""
        self._ultimo_ok = texto
        self.bolinha.enviar({"resposta": texto})
        self._falas.put(texto)

    def _transcrever(self, wav: Path) -> str:
        """Primeiro aqui, com o Whisper local. O servidor só entra se o local
        não estiver instalado ou falhar — e diz que caiu."""
        if self.local:
            try:
                return transcrever.transcrever(wav)
            except Exception as e:
                avisar(f"a transcrição local falhou ({e}); tentando pelo servidor.")
        return self._transcrever_no_servidor(wav)

    def _transcrever_no_servidor(self, wav: Path) -> str:
        import base64
        import urllib.request
        dados = base64.b64encode(wav.read_bytes()).decode("ascii")
        corpo = json.dumps({"audio": dados, "nome": "fala.wav", "idioma": "pt"}).encode("utf-8")
        pedido = urllib.request.Request(
            self.servidor.rstrip("/") + "/api/transcrever",
            data=corpo,
            headers={"Content-Type": "application/json", "x-primocode-token": self.token},
        )
        try:
            with urllib.request.urlopen(pedido, timeout=90) as r:
                j = json.loads(r.read().decode("utf-8"))
            return str(j.get("texto") or "").strip()
        except Exception as e:
            avisar(f"não deu para transcrever: {e}")
            return ""

    # ── a boca: frase a frase, a próxima já sendo gerada enquanto a atual toca ──

    def _falar_em_fila(self) -> None:
        """Sintetiza a próxima frase enquanto a anterior toca (o 1º áudio sai
        depois de UMA frase, não do texto inteiro)."""
        threading.Thread(target=self._tocar_em_fila, daemon=True).start()
        while self._vivo:
            try:
                texto = self._falas.get(timeout=0.3)
            except queue.Empty:
                continue
            for frase in conversa.dividir_frases(texto):
                mp3 = self._sintetizar(frase)
                if mp3:
                    while self._vivo:
                        try:
                            self._audios.put(mp3, timeout=0.3)
                            break
                        except queue.Full:
                            continue

    def _cache_de(self, frase: str, hora: bool = False) -> "Path | None":
        """Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
        if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
            return None
        import hashlib
        pasta = Path.home() / ".primocode" / "voz-cache"
        pasta.mkdir(parents=True, exist_ok=True)
        if hora:
            return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
        return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")

    def _cache_horas(self) -> None:
        agora = time.time()
        for dm in (0, 1, 2):
            f = " ".join(fala_pt.normalizar(fala.hora_falada(time.localtime(agora + 60 * dm))).split())
            c = self._cache_de(f, hora=True)
            if c is not None and not c.exists():
                self.edge.sintetizar(f, self.voz_id, c)

    def _pre_aquecer_frases(self) -> None:
        """As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
        self.edge.aquecer()
        self._cache_horas()
        for f in ("Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
                  "Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
            c = self._cache_de(f)
            if c is not None and not c.exists():
                self.edge.sintetizar(f, self.voz_id, c)
        while self._vivo:                       # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
            self._cache_horas()
            for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"):     # só os minutos que já passaram
                try:
                    if time.time() - velho.stat().st_mtime > 600:
                        velho.unlink()
                except OSError:
                    pass
            time.sleep(20)

    def _sintetizar(self, frase: str) -> "Path | None":
        limpo = " ".join(fala_pt.normalizar(frase).split())[:900]      # números, unidades, siglas: por extenso, como se fala
        if not limpo:
            return None
        t0 = time.time()
        self.lat(f"vai falar: {limpo[:140]!r}")
        mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
        # 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
        guardada = self._cache_de(limpo)
        if guardada is not None and not guardada.exists():
            guardada = self._cache_de(limpo, hora=True) if self._cache_de(limpo, hora=True).exists() else guardada
        if guardada is not None and guardada.exists():
            shutil.copyfile(guardada, mp3)
            self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
            return mp3
        # 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
        if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
            self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
            if guardada is not None:
                try:
                    shutil.copyfile(mp3, guardada)
                except OSError:
                    pass
            return mp3
        # 2. ElevenLabs, só se a pessoa pediu
        narrar = AQUI.parent.parent / "studio" / "narrar.py"
        r = None
        if self.cfg.get("tts") == "elevenlabs":
            try:
                r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
                                   capture_output=True, timeout=60)
            except Exception as e:
                avisar(f"não deu para gerar a voz: {e}")
        if r is not None and r.returncode == 0 and mp3.exists():
            return mp3
        # 3. offline: a voz do sistema
        if sys.platform == "darwin" and shutil.which("say"):
            aiff = mp3.with_suffix(".aiff")
            if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
                    subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
                self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
                return aiff
        avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
        self._erro("Não consegui gerar a voz. O texto está no painel.")
        return None

    def _tocar_em_fila(self) -> None:
        while self._vivo:
            try:
                arq = self._audios.get(timeout=0.3)
            except queue.Empty:
                if self._falando and self._falas.empty():
                    self._acabou_de_falar()
                continue
            if not self._falando:
                self._falando = True
                self._parar_fala.clear()
                self.maquina.mandar("resposta")
                self._estado("falando", 0.6)
            try:
                self._tocar(arq)
            finally:
                try:
                    arq.unlink()
                except OSError:
                    pass
                if self._parar_fala.is_set():
                    self._acabou_de_falar()

    def _acabou_de_falar(self) -> None:
        if not self._falando:
            return
        self._falando = False
        self.maquina.mandar("fim_fala")
        self._estado("ouvindo" if self._mic_aberto else "dormindo")
        if self._mic_aberto:
            self._acordado_ate = max(self._acordado_ate, time.time() + 90)    # conversa em andamento: 90 s até voltar a dormir
        self.sons.tocar("pronto")
        self.corte.nivel_dele = 0.0
        self._esvaziar_blocos()      # o que o microfone pegou era a voz dele

    def _tocar(self, arquivo: Path) -> None:
        """Toca o mp3 com o que a máquina tiver — interrompível, porque falar
        por cima precisa calar a voz na hora."""
        tentativas = []
        if sys.platform == "darwin":
            tentativas.append(["afplay", str(arquivo)])
        elif sys.platform == "win32":
            tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
        tentativas += [
            ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
            ["mpg123", "-q", str(arquivo)],
            ["mpv", "--no-video", "--really-quiet", str(arquivo)],
            ["cvlc", "--play-and-exit", "--intf", "dummy", str(arquivo)],
        ]
        env = dados_tela.envelope(arquivo)        # volume real da fala dele: barras da tela e luz do teclado seguem
        for cmd in tentativas:
            try:
                p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            except (FileNotFoundError, OSError):
                continue
            t0 = time.time()
            if self._t_acabou:
                self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
                self._t_acabou = None
            if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
                avisar(f"falando ({cmd[0]}): {arquivo.name}")
            while p.poll() is None:
                if env:
                    i = int((time.time() - t0) / 0.05)
                    nv = env[i] if i < len(env) else 0.0
                    self.bolinha.estado("falando", nv)
                    self.luz.mudar("falando", nv)
                if self._parar_fala.is_set() or not self._vivo:
                    try:
                        p.terminate()
                    except Exception:
                        pass
                    return
                time.sleep(0.05)
            if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
                avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
            if p.returncode == 0:
                return
            # INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
            # próximo da fila é tentado.
            continue
        avisar("não achei um tocador de áudio que funcione neste computador. Instale o ffmpeg"
               + (": winget install ffmpeg" if sys.platform == "win32"
                  else ": brew install ffmpeg" if sys.platform == "darwin"
                  else ": sudo apt install ffmpeg"))

    # ── o terminal ───────────────────────────────────────────────────────

    def ouvir_o_node(self) -> None:
        for linha in sys.stdin:
            linha = linha.strip()
            if not linha:
                continue
            try:
                msg = json.loads(linha)
            except json.JSONDecodeError:
                continue
            tipo = msg.get("tipo")
            if tipo == "falar":
                texto = str(msg.get("texto") or "")
                if msg.get("painel"):                       # o cérebro já mandou o texto FALADO e o ESCRITO separados
                    self.bolinha.enviar({"resposta": str(msg["painel"])[:1500]})
                    self._acordado_ate = time.time() + 90
                elif msg.get("bruto"):
                    # a voz resume; o texto inteiro (código, listas) vai ao painel
                    fala, tela = conversa.resumo(texto)
                    self.bolinha.enviar({"resposta": tela[:700]})
                    texto = conversa.frase_final(fala)
                    self._acordado_ate = time.time() + 25
                self._falas.put(texto)
            elif tipo == "ferramenta":
                self.maquina.mandar("ferramenta")
                self._estado("executando")
                conf = conversa.confirmacao(msg.get("nome", ""), msg.get("args"))
                if conf and time.time() - self._ultima_conf > 8 and not self._falando:
                    self._ultima_conf = time.time()
                    self._falas.put(conf)
            elif tipo == "calar":
                self._parar_fala.set()
                self._limpar_falas()
            elif tipo == "estado":
                est = str(msg.get("estado") or "esperando")
                if est == "erro":
                    self._erro("Deu ruim. O detalhe está no terminal.")
                    est = "ouvindo"
                self.maquina.mandar("sem_fala")
                self._estado("ouvindo" if est == "esperando" and self._mic_aberto else
                             ("dormindo" if est == "esperando" else est))
                if est == "esperando":
                    self.sons.tocar("pronto", falando=self._falando)
            elif tipo == "acao":
                self._acao_do_painel(str(msg.get("acao") or ""))
            elif tipo == "painel":
                self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
            elif tipo == "fim":
                break
        self.parar()

    def parar(self) -> None:
        if not self._vivo:
            return
        self._vivo = False
        self._parar_fala.set()
        self.luz.fechar()
        self.bolinha.fechar()
        falar_para_o_node({"tipo": "fim"})

    def rodar(self) -> None:
        threading.Thread(target=self._ouvir, daemon=True).start()
        threading.Thread(target=self._falar_em_fila, daemon=True).start()
        self.ouvir_o_node()


def main() -> int:
    servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
    token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
    voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
    if not servidor:
        avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
        return 2
    try:
        import numpy  # noqa: F401
        import sounddevice  # noqa: F401
    except ImportError as e:
        avisar(f"falta um pacote de áudio ({e.name}). Rode: {sys.executable} -m pip install sounddevice numpy")
        return 3
    Voz(servidor, token, voz).rodar()
    return 0


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