"""Transcribir: arrancar whisper-server y mandarle los WAV.

Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir
entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de
ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el
disco: queda arrancar un proceso y hacer un POST.

## Los cuatro flags que Newelle no pasaba

Esto es lo importante de este fichero, y sale de leer el codigo de upstream:
`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada
mas. Los defectos de `whisper-server` no son los mismos que los de
`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia
whisper.cpp hace por defecto:

  --beam-size 5 --best-of 5   whisper-server trae beam-size -1, que es
                              decodificacion GREEDY. El CLI usa 5. Es la causa
                              mas probable de transcripciones como "esta con el
                              tanutusto" en un audio limpio.
  -sns                        suprime los tokens que no son habla. Es
                              literalmente el flag que evita los "[Musica]" y
                              "(aplausos)", que son el 16,2 % de las frases que
                              JARVIS no entendio.
  --prompt + --carry-initial-prompt
                              vocabulario del dominio. MEDIDO Y DESCARTADO por
                              defecto: ver abajo.

El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o
se queda viejo en cuanto se añada una accion.

## Lo que dijo la medida, que no fue lo que yo esperaba

40 frases del catalogo dichas por Piper con tres voces, transcritas con cada
configuracion y contando cuantas llegan a su accion:

    como lo hace Newelle (greedy, sin sns)   50,0 %   0,98 s
    + beam 5                                 55,0 %   1,01 s
    + beam 5 + sns                           55,0 %   1,01 s
    + beam 5 + sns + prompt                  52,5 %   1,12 s

Tres lecturas, por orden de importancia:

1. **El prompt EMPEORA.** Era el riesgo anotado —Whisper empieza a "oir" las
   palabras que le has puesto donde no las hay— y se cumplio. Va apagado por
   defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero
   hay que demostrarlo antes de encenderlo.
2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene
   silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque
   no cuesta nada y ataca un problema real que solo se ve con microfono: el
   16,2 % de "[Musica]" del registro de uso.
3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y
   aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del
   ruido.
"""
import atexit
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import time
import urllib.error
import urllib.request

RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# El binario de whisper-server: el build local del repo si esta compilado, y si
# no, el que haya en el PATH (asi vale un whisper.cpp instalado del sistema, en
# cualquier distro). Cada quien lo compila para su plataforma —CUDA, ROCm,
# Metal o CPU—; aqui solo se busca donde esta.
_LOCAL = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin",
                      "whisper-server")
BINARIO = _LOCAL if os.path.exists(_LOCAL) else (shutil.which("whisper-server") or _LOCAL)


def _dir_modelos():
    """Donde estan los ggml-*.bin. Se puede fijar con JARVIS_WHISPER_MODELOS;
    si no, se prueba junto al build, el sitio estandar del usuario, y por
    compatibilidad la ruta del antiguo flatpak. Gana el primero que TENGA un
    modelo de verdad —el dir del source de whisper.cpp existe pero suele estar
    vacio, asi que mirar solo si el dir existe elegiria mal."""
    import glob
    env = os.environ.get("JARVIS_WHISPER_MODELOS")
    if env:
        return os.path.expanduser(env)
    for c in (os.path.join(os.path.dirname(_LOCAL), "..", "..", "models"),
              "~/.local/share/jarvis/whisper/models",
              "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models"):
        ruta = os.path.normpath(os.path.expanduser(c))
        if glob.glob(os.path.join(ruta, "ggml-*.bin")):
            return ruta
    return os.path.expanduser("~/.local/share/jarvis/whisper/models")


MODELOS = _dir_modelos()

# Las libs de CUDA, solo si estan: en un equipo AMD o CPU no hay /usr/local/cuda,
# y meterlo en LD_LIBRARY_PATH a la fuerza no aporta nada. Se añade mas abajo
# solo si el directorio existe.
LIBS = "/usr/local/cuda/lib64"

# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las
# hay. Se corta.
TOPE_PROMPT = 220

# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre.
#
# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la
# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con
# Piper:
#
#     "Jarvis, cuanto espacio queda"  ->  "¿Sabeis cuanto espacio queda?"
#     "Yarvis, que hora es"           ->  "¿Ya viste que hora es?"
#     "Jarvis pon musica"             ->  "Carbis Pond Musica."
#
# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el
# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara.
#
# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque
# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO
# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a
# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero.
PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS."


def prompt_dominio(catalogo=None) -> str:
    """Vocabulario que JARVIS espera oir, sacado del catalogo.

    Se cogen las palabras poco comunes —las que un modelo de castellano general
    no espera— y no las frases enteras: el prompt es un sesgo, no una lista de
    ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en
    cualquier ruido.
    """
    comunes = {
        "abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime",
        "cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de",
        "del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al",
        "me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su",
    }
    # Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden,
    # el tope de caracteres se lo comian entero las acciones de disco —que van
    # primeras— y no llegaba ni una palabra de firefox, ventanas o pentest.
    por_grupo, vistas = {}, set()
    for a in (catalogo.acciones if catalogo else []):
        for frase in a.frases:
            for p in frase.split():
                if len(p) < 4 or p in comunes or p in vistas:
                    continue
                vistas.add(p)
                por_grupo.setdefault(a.grupo or "otras", []).append(p)

    palabras = []
    grupos = list(por_grupo.values())
    for i in range(max((len(g) for g in grupos), default=0)):
        for g in grupos:
            if i < len(g):
                palabras.append(g[i])
    # Los nombres propios importan mas que nada y no salen del catalogo tal cual
    fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal",
             "escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"]
    texto = ", ".join(fijas + palabras)
    return texto[:TOPE_PROMPT].rsplit(",", 1)[0]


def _mata_huerfanos():
    """whisper-server de arranques anteriores que se quedaron con la grafica.

    Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f
    whisper` casaria con el propio pkill y con cualquier cosa que lleve la
    palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en
    vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio.
    """
    yo = os.getpid()
    for pid in os.listdir("/proc"):
        if not pid.isdigit() or int(pid) == yo:
            continue
        try:
            with open(f"/proc/{pid}/cmdline", "rb") as f:
                orden = f.read().split(b"\0")
        except OSError:
            continue
        if orden and orden[0].decode(errors="ignore") == BINARIO:
            try:
                os.killpg(os.getpgid(int(pid)), signal.SIGTERM)
            except (OSError, ProcessLookupError):
                pass


def _puerto_libre() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


class Oido:
    """whisper-server vivo, y un metodo para transcribir un WAV."""

    def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE,
                 beam=5, gpu=True):
        self.modelo = modelo
        self.idioma = idioma
        self.hilos = hilos
        self.prompt = prompt
        self.beam = beam
        self.gpu = gpu
        self._proc = None
        self._puerto = None

    @property
    def ruta_modelo(self) -> str:
        return os.path.join(MODELOS, f"ggml-{self.modelo}.bin")

    def disponible(self) -> bool:
        return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo)

    # ------------------------------------------------------------- arrancar

    def arranca(self, espera=40) -> bool:
        if self._proc and self._proc.poll() is None:
            return True
        if not self.disponible():
            return False

        # Los huerfanos de arranques anteriores, antes de nada.
        #
        # whisper-server se lanza en su propio grupo de procesos para poder
        # matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere
        # de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se
        # queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya
        # no arranca y el sintoma es "whisper no arranco", que no apunta a
        # ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB.
        _mata_huerfanos()

        self._puerto = _puerto_libre()
        orden = [
            BINARIO,
            "-m", self.ruta_modelo,
            "-t", str(self.hilos),
            "--host", "127.0.0.1",
            "--port", str(self._puerto),
            "-l", self.idioma,
            # los cuatro que Newelle no pasaba
            "-sns",
            "--beam-size", str(self.beam),
            "--best-of", "5",
        ]
        if self.prompt:
            orden += ["--prompt", self.prompt, "--carry-initial-prompt"]
        if not self.gpu:
            orden.append("--no-gpu")

        # CUDA en LD_LIBRARY_PATH solo si existe; en AMD/CPU no estorba.
        entorno = dict(os.environ)
        if os.path.isdir(LIBS):
            entorno["LD_LIBRARY_PATH"] = LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")
        self._proc = subprocess.Popen(
            orden, env=entorno, cwd=os.path.dirname(BINARIO),
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            # grupo propio, para poder matarlo entero como hace tareas.py
            start_new_session=True)
        # Y que se vaya con nosotros: sin esto se queda con su parte de la
        # grafica hasta el siguiente reinicio.
        atexit.register(self.para)

        # Esperar a que escuche de verdad, no a que el proceso exista: cargar
        # el modelo en la grafica tarda, y un POST antes de tiempo da un error
        # de conexion que parece un fallo de configuracion.
        limite = time.monotonic() + espera
        while time.monotonic() < limite:
            if self._proc.poll() is not None:
                return False
            try:
                with socket.create_connection(("127.0.0.1", self._puerto), 0.5):
                    return True
            except OSError:
                time.sleep(0.3)
        self.para()
        return False

    def para(self):
        if not self._proc:
            return
        try:
            os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
            self._proc.wait(timeout=5)
        except Exception:
            try:
                os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
            except Exception:
                pass
        self._proc = None

    @property
    def vivo(self) -> bool:
        return bool(self._proc and self._proc.poll() is None)

    # ---------------------------------------------------------- transcribir

    def transcribe(self, wav: str) -> str:
        if not self.vivo and not self.arranca():
            return ""
        try:
            with open(wav, "rb") as f:
                datos = f.read()
        except OSError:
            return ""

        lim = "----jarvis"
        cuerpo = (
            f"--{lim}\r\n"
            f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n'
            f"Content-Type: audio/wav\r\n\r\n"
        ).encode() + datos + f"\r\n--{lim}--\r\n".encode()

        req = urllib.request.Request(
            f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo,
            headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                return json.loads(r.read().decode()).get("text", "").strip()
        except (urllib.error.URLError, json.JSONDecodeError, OSError):
            return ""


# --------------------------------------------------------------- el filtro

# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo:
# se entreno con subtitulos de YouTube, donde el silencio se anota asi.
SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$")
MULETILLAS = (
    "suscribete", "suscríbete", "gracias por ver", "hasta la proxima",
    "hasta la próxima", "no olvides suscribirte", "dale like",
)

# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar
# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten
# entre las frases cortas que no llegan a ninguna accion.
#
# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces
# sueltas, que es para lo que sirve esta lista.
ANOTACIONES = {
    "musica", "música", "motor", "risas", "risa", "aplausos", "cantando",
    "ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos",
    "ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay",
}

# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios",
# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo
# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas
# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo.


def es_ruido(texto: str) -> bool:
    """Si esto es Whisper alucinando en vez de alguien hablando.

    Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el
    16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la
    mejora nocturna acaba generando alias a partir de ellos.

    Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo;
    lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como
    que no funciona.
    """
    if not texto or not texto.strip():
        return True
    if SOLO_ETIQUETA.match(texto):
        return True
    limpio = texto.lower().strip(" .,!¡?¿*-—")
    if limpio in ANOTACIONES:
        return True
    return any(m in limpio for m in MULETILLAS)
