"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando.

Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el
motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un
script de shell que escribe un WAV. Todo lo demas sobraba.

## Lo que hay que hacer bien y en Newelle estaba mal

**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro
de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de
que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el
audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui
la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque
cuando el proceso termina el sonido todavia esta viajando hasta el microfono.

**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan.
Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel.

## La cache

Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por
frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con
cache eso es un `cp`.

Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera
`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo
lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro.
"""
import hashlib
import os
import re
import shutil
import subprocess
import threading
import time

RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG = os.path.join(RAIZ, "config")
MODELOS = os.path.join(RAIZ, "voz", "modelos")
CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                     "datos", "cache_voz")

# Cuanto se sigue considerando "hablando" despues de que el reproductor termine.
# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin
# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion.
#
# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350
# la frase entera dejaba de colarse pero seguia escapandose un fragmento.
COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700"))


def _piper(modelo):
    return {
        "orden": [os.path.join(CONFIG, "jarvis-piper.sh")],
        "entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"},
        "necesita": os.path.join(MODELOS, modelo + ".onnx"),
    }


# El orden es el de rotacion. davefx primero porque es la que se eligio a oido.
VOCES = {
    "davefx": _piper("es_ES-davefx-medium"),
    "clon": {
        "orden": [os.path.join(CONFIG, "jarvis-voz.sh")],
        "entorno": {},
        "necesita": os.path.join(CONFIG, "jarvis-voz.sh"),
    },
    "claude": _piper("es_MX-claude-high"),
    "ald": _piper("es_MX-ald-medium"),
    "sharvard": _piper("es_ES-sharvard-medium"),
}

POR_DEFECTO = "davefx"

# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es
# lo que hace falta para que el cancelador de eco tenga la referencia de lo que
# sale por los altavoces. aplay habla con ALSA directamente y se la salta.
REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"])


def _normaliza(texto: str) -> str:
    return re.sub(r"\s+", " ", texto or "").strip()


def _clave(voz: str, texto: str) -> str:
    return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16]


def _reproductor():
    for r in REPRODUCTORES:
        if shutil.which(r[0]):
            return r
    return None


class Boca:
    """Todo lo que suena sale de aqui, y de uno en uno."""

    def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None):
        self.voz = voz if voz in VOCES else POR_DEFECTO
        self._turno = threading.Lock()
        self._hablando = False
        self._hasta = 0.0            # instante hasta el que sigue contando como hablando
        self._al_cambiar = al_cambiar  # se avisa al oido para que se calle
        os.makedirs(CACHE, exist_ok=True)

    # ------------------------------------------------------------ el estado

    @property
    def hablando(self) -> bool:
        """Si esta sonando algo, incluida la cola.

        El oido consulta esto en cada trozo de audio. Tiene que ser barato y,
        sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de
        silencio que colar la propia voz en la transcripcion.
        """
        return self._hablando or time.monotonic() < self._hasta

    def _marca(self, hablando: bool):
        self._hablando = hablando
        if not hablando:
            self._hasta = time.monotonic() + COLA_MS / 1000.0
        if self._al_cambiar:
            try:
                self._al_cambiar(hablando)
            except Exception:
                pass

    # ------------------------------------------------------------- generar

    def _genera(self, texto: str) -> str | None:
        """Devuelve la ruta del WAV, de cache o recien hecho."""
        voz = VOCES[self.voz]
        destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav")
        if os.path.exists(destino) and os.path.getsize(destino) > 0:
            return destino

        entorno = {**os.environ, **voz["entorno"]}
        try:
            subprocess.run(voz["orden"] + [destino, texto], env=entorno,
                           capture_output=True, timeout=120)
        except (subprocess.TimeoutExpired, OSError):
            return None
        if not os.path.exists(destino) or os.path.getsize(destino) == 0:
            # Que no se quede un fichero vacio en la cache: el siguiente intento
            # lo daria por bueno y JARVIS se quedaria mudo para esa frase.
            if os.path.exists(destino):
                os.unlink(destino)
            return None
        return destino

    # -------------------------------------------------------------- hablar

    def di(self, texto: str) -> bool:
        """Habla, esperando turno. Devuelve si sono."""
        texto = _normaliza(texto)
        if not texto:
            return False
        with self._turno:
            return self._suelta(texto)

    def di_si_libre(self, texto: str) -> bool:
        """Habla solo si nadie tiene el turno. Para avisos que no merecen cola."""
        if not self._turno.acquire(blocking=False):
            return False
        try:
            return self._suelta(_normaliza(texto))
        finally:
            self._turno.release()

    def _suelta(self, texto: str) -> bool:
        wav = self._genera(texto)
        if not wav:
            return False
        orden = _reproductor()
        if not orden:
            return False

        # La bandera ANTES de lanzar el reproductor, no despues y no en otro
        # hilo. Este es exactamente el fallo de Newelle que causa el eco.
        self._marca(True)
        try:
            subprocess.run(orden + [wav], capture_output=True, timeout=300)
        except (subprocess.TimeoutExpired, OSError):
            return False
        finally:
            self._marca(False)
        return True

    # --------------------------------------------------------------- voces

    def disponibles(self) -> list:
        return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])]

    def cambia(self, nombre: str) -> bool:
        if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]):
            return False
        self.voz = nombre
        return True

    def siguiente(self) -> str:
        lista = self.disponibles()
        if not lista:
            return self.voz
        try:
            i = lista.index(self.voz)
        except ValueError:
            i = -1
        self.voz = lista[(i + 1) % len(lista)]
        return self.voz
