"""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/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 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 escuta, transcrever  # noqa: E402


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
        try:
            self.p = subprocess.Popen([sys.executable, "-m", "primo_voz.orbe"], cwd=str(AQUI.parent),
                                      stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                      env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
        except Exception:
            self.p = None

    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
        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."""

    def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
        self.servidor = servidor
        self.token = token
        self.voz = voz_escolhida
        # O Whisper local carrega em segundo plano desde já: senão a primeira
        # frase esperaria o modelo inteiro subir.
        self.local = transcrever.disponivel()
        if self.local:
            transcrever.aquecer()
        self.gravador = escuta.Gravador()
        self.corte = escuta.Corte()
        self.bolinha = Bolinha()
        self._blocos: "queue.Queue" = queue.Queue()
        self._falas: "queue.Queue" = queue.Queue()
        self._vivo = True
        self._falando = False
        self._parar_fala = threading.Event()

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

    def _ao_receber(self, bloco) -> None:
        if not self._vivo:
            return
        nivel = escuta.rms(bloco)
        # Enquanto ele fala, o microfone só serve para uma coisa: saber se a
        # pessoa falou POR CIMA. O que entra aqui é a voz dele saindo da
        # caixa de som, e não pode virar pedido.
        if self._falando:
            self.corte.ele_falou(nivel)
            if self.corte.alguem_cortou(nivel):
                self._parar_fala.set()
            return
        self._blocos.put(bloco)

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

    def _ouvir(self) -> None:
        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.parar()
            return
        falar_para_o_node({"tipo": "pronto"})
        self.bolinha.estado("esperando")
        while self._vivo:
            self.gravador.comecar()
            acabou = False
            while self._vivo and not acabou:
                try:
                    bloco = self._blocos.get(timeout=0.3)
                except queue.Empty:
                    continue
                acabou = self.gravador.alimentar(bloco)
                # A bolinha acende com a voz da pessoa, no tamanho da voz.
                if self.gravador._falando and not self._falando:
                    lim = max(1e-6, self.gravador._limiar())
                    self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
            if not self._vivo:
                break
            if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
                continue
            wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
            if not wav:
                continue
            self.bolinha.estado("pensando")
            texto = transcrever.limpar(self._transcrever(wav))
            if texto:
                # Pensando até o terminal dizer o contrário (a resposta em
                # voz, ou {"tipo":"estado"} quando ele termina sem falar).
                falar_para_o_node({"tipo": "ouvi", "texto": texto})
            else:
                self.bolinha.estado("esperando")
        mic.fechar()

    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 ───────────────────────────────────────────────────────────

    def _falar_em_fila(self) -> None:
        while self._vivo:
            try:
                texto = self._falas.get(timeout=0.3)
            except queue.Empty:
                continue
            self._falar(texto)

    def _falar(self, texto: str) -> None:
        limpo = " ".join(str(texto).split())
        if not limpo:
            return
        if len(limpo) > 900:
            limpo = limpo[:880].rsplit(" ", 1)[0] + "…"
        mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
        narrar = AQUI.parent.parent / "studio" / "narrar.py"
        r = subprocess.run(
            [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
            capture_output=True, timeout=180)
        if r.returncode != 0 or not mp3.exists():
            avisar(motivo_da_narracao(r))
            return
        self._falando = True
        self._parar_fala.clear()
        self.bolinha.estado("falando", 0.6)
        try:
            self._tocar(mp3)
        finally:
            self._falando = False
            self.bolinha.estado("esperando")
            self.corte.nivel_dele = 0.0
            # O que o microfone pegou enquanto ele falava é a voz dele.
            self._esvaziar_blocos()
            try:
                mp3.unlink()
            except OSError:
                pass

    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)],
        ]
        for cmd in tentativas:
            try:
                p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            except (FileNotFoundError, OSError):
                continue
            while p.poll() is None:
                if self._parar_fala.is_set() or not self._vivo:
                    try:
                        p.terminate()
                    except Exception:
                        pass
                    return
                time.sleep(0.05)
            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":
                self._falas.put(str(msg.get("texto") or ""))
            elif tipo == "calar":
                self._parar_fala.set()
                while not self._falas.empty():
                    try:
                        self._falas.get_nowait()
                    except queue.Empty:
                        break
            elif tipo == "estado":
                self.bolinha.estado(str(msg.get("estado") or "esperando"))
            elif tipo == "fim":
                break
        self.parar()

    def parar(self) -> None:
        if not self._vivo:
            return
        self._vivo = False
        self._parar_fala.set()
        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())
