#!/usr/bin/env python3
"""Entrena el reconocimiento solo, sin que nadie hable.

    python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py            # tanda normal
    python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --rapido   # una voz
    python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --aplicar  # guarda alias

Como funciona: por cada accion del catalogo se dice su frase en voz alta con
Piper, en varias voces y velocidades y con algo de ruido y sala, se transcribe
con el mismo whisper que usa el asistente, y se mira si la transcripcion llega
a la accion correcta. Lo que no llega se apunta como alias candidato.

Por que sirve: los fallos del reconocedor en castellano son sistematicos, no
aleatorios. "abre el correo" se oye "a ver el correo" siempre, y una tanda de
varios cientos de frases los saca casi todos de una sentada.

Que NO sustituye: esto es voz sintetica, no la suya. Sirve para las colisiones
foneticas del idioma —que son la mayoria de los fallos— pero no para su acento,
su microfono ni su habitacion. Para eso esta el registro de lo que no entiende
en el uso real, que se revisa con aprender.py. Las dos cosas se suman.

Un alias solo se propone si no choca con otra accion: si "a ver el correo"
pudiera ser dos cosas distintas, se descarta y se avisa.
"""
import argparse
import importlib.util
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict

CASA = os.path.expanduser("~")
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
APP = "io.github.qwersyk.Newelle//master"

PIPER = os.path.join(JARVIS, "voz/piper/piper")
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")

CATALOGO = os.path.expanduser(
    "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
    "active/files/share/newelle/acciones/acciones.json")
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
INFORME = os.path.join(JARVIS, "muestras/entrenamiento.json")

MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")

# Voces distintas para que no aprenda los defectos de una sola, y velocidades
# distintas porque uno no habla igual con prisa que sin ella.
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
VELOCIDADES = [1.0, 0.88, 1.15]

# Un poco de sala y de ruido de fondo: en seco el reconocedor acierta mas de lo
# que acertara nunca en una habitacion de verdad, y entrenar con eso da una
# falsa sensacion de que todo va bien.
RUIDOS = [
    "",                                              # limpio
    "aecho=0.8:0.7:22:0.12",                         # algo de sala
    "volume=0.75,highpass=f=120",                    # mas lejos del microfono
]


def carga_motor():
    spec = importlib.util.spec_from_file_location("acciones", MOTOR)
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)
    return m


def dentro(orden, limite=900):
    """Ejecuta algo dentro del flatpak, que es donde vive whisper."""
    return subprocess.run(
        ["flatpak", "run", "--command=bash", APP, "-c", orden],
        capture_output=True, text=True, timeout=limite)


def genera(texto, voz, velocidad, ruido, destino):
    crudo = tempfile.mktemp(suffix=".wav")
    try:
        r = subprocess.run(
            [PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
             "--length_scale", str(1 / velocidad), "--output_file", crudo],
            input=texto, capture_output=True, text=True, timeout=60)
        if r.returncode != 0 or not os.path.exists(crudo):
            return False
        filtros = "aresample=16000" + ("," + ruido if ruido else "")
        subprocess.run(
            ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
             "-ac", "1", "-af", filtros, "-y", destino],
            check=True, timeout=60)
        return os.path.exists(destino)
    except (OSError, subprocess.SubprocessError):
        return False
    finally:
        if os.path.exists(crudo):
            os.remove(crudo)


# Por tandas y no de una: mil frases son un cuarto de hora, y una sola llamada
# con limite de tiempo se corta a la mitad y se pierde todo lo transcrito. Por
# tandas ademas se puede ver el avance, que en algo que tarda tanto importa.
POR_TANDA = 40


def transcribe_lote(carpeta, ficheros, modelo="base", hilos=8):
    """Transcribe una lista de wav. Devuelve {fichero: texto}."""
    salida = {}
    ini = time.time()
    for i in range(0, len(ficheros), POR_TANDA):
        tanda = ficheros[i:i + POR_TANDA]
        lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
        orden = (
            f'for f in {lista}; do '
            f'  echo "### $(basename "$f")"; '
            f'  {WHISPER} -m {MODELOS_STT}/ggml-{modelo}.bin -l es -nt -t {hilos} '
            f'    -f "$f" 2>/dev/null | tr "\\n" " "; echo; '
            f'done')
        try:
            r = dentro(orden)
        except subprocess.SubprocessError as e:
            print(f"  tanda {i//POR_TANDA + 1} fallo: {e}", flush=True)
            continue

        actual = None
        for linea in r.stdout.splitlines():
            if linea.startswith("### "):
                actual = linea[4:].strip()
            elif actual:
                salida[actual] = linea.strip()
                actual = None

        hechas = min(i + POR_TANDA, len(ficheros))
        queda = (time.time() - ini) / hechas * (len(ficheros) - hechas)
        print(f"  transcritas {hechas}/{len(ficheros)}  "
              f"({queda/60:.0f} min restantes)", flush=True)
    return salida


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--rapido", action="store_true", help="una voz y una velocidad")
    ap.add_argument("--aplicar", action="store_true", help="guardar los alias encontrados")
    ap.add_argument("--tope", type=int, default=0, help="limitar acciones (para probar)")
    ap.add_argument("--reanudar", action="store_true",
                    help="usar los wav ya generados en vez de rehacerlos")
    args = ap.parse_args()

    for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
        if not os.path.exists(ruta):
            print(f"falta {que}: {ruta}")
            return 1

    catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
    with open(catalogo) as f:
        acciones = json.load(f)["acciones"]
    if args.tope:
        acciones = acciones[:args.tope]

    voces = VOCES[:1] if args.rapido else VOCES
    velocidades = VELOCIDADES[:1] if args.rapido else VELOCIDADES
    ruidos = RUIDOS[:1] if args.rapido else RUIDOS

    motor = carga_motor()
    cat = motor.Catalogo([catalogo])

    # Las acciones que esperan argumento hay que decirlas CON argumento. Sin el,
    # el motor las rechaza a proposito —"busca el fichero" a secas no es una
    # orden— y contarlo como fallo del reconocedor es medir mal: en la primera
    # tanda hundio el resultado del 48 %, con una cuarta parte de las frases
    # condenadas de antemano.
    RELLENOS = ["notas", "el informe de ayer", "documentos"]

    # Solo la frase principal de cada accion: las variantes ya estan escritas a
    # mano y lo que se busca aqui es como se oye la que uno diria de verdad.
    trabajos = []
    for a in acciones:
        if not a["frases"]:
            continue
        frase = a["frases"][0]
        if a.get("captura"):
            frase = frase + " " + random.choice(RELLENOS)
        for voz in voces:
            for vel in velocidades:
                trabajos.append((a["id"], frase, voz, vel,
                                 random.choice(ruidos)))

    total = len(trabajos)
    print(f"{len(acciones)} acciones · {len(voces)} voces · "
          f"{len(velocidades)} velocidades = {total} frases habladas")
    print("esto tarda; se puede dejar corriendo\n")

    carpeta = os.path.join(CONFIG, "entrenamiento")

    indice = {f"{n:05d}.wav": (ident, frase, voz, vel)
              for n, (ident, frase, voz, vel, _) in enumerate(trabajos, 1)}

    if args.reanudar and os.path.isdir(carpeta):
        indice = {n: v for n, v in indice.items()
                  if os.path.exists(os.path.join(carpeta, n))}
        print(f"reanudando con {len(indice)} frases ya generadas\n", flush=True)
    else:
        shutil.rmtree(carpeta, ignore_errors=True)
        os.makedirs(carpeta, exist_ok=True)
        vivos, ini = {}, time.time()
        for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
            nombre = f"{n:05d}.wav"
            if genera(frase, voz, vel, ruido, os.path.join(carpeta, nombre)):
                vivos[nombre] = (ident, frase, voz, vel)
            if n % 25 == 0 or n == total:
                queda = (time.time() - ini) / n * (total - n)
                print(f"  generadas {n}/{total}  ({queda/60:.0f} min restantes)",
                      flush=True)
        indice = vivos

    print(f"\ntranscribiendo {len(indice)} frases...", flush=True)
    textos = transcribe_lote(carpeta, sorted(indice))
    print(f"transcritas {len(textos)}\n", flush=True)

    aciertos = 0
    candidatos = defaultdict(lambda: defaultdict(int))
    for nombre, (ident, frase, voz, vel) in indice.items():
        oido = textos.get(nombre, "").strip()
        if not oido:
            continue
        encontrada, _ = cat.busca(oido)
        if encontrada is not None and encontrada.id == ident:
            aciertos += 1
        else:
            candidatos[ident][motor.normaliza(oido)] += 1

    evaluadas = sum(1 for n in indice if textos.get(n))
    print(f"aciertos: {aciertos}/{evaluadas} "
          f"({100*aciertos/max(1,evaluadas):.0f} %)\n")

    # Un alias solo vale si no puede ser otra cosa. Si la misma frase mal oida
    # aparece en dos acciones, elegir una seria peor que no hacer nada.
    duenos = defaultdict(set)
    for ident, frases in candidatos.items():
        for f in frases:
            duenos[f].add(ident)

    propuestos, descartados = defaultdict(list), []
    for ident, frases in candidatos.items():
        for f, veces in sorted(frases.items(), key=lambda x: -x[1]):
            if len(duenos[f]) > 1:
                descartados.append((f, sorted(duenos[f])))
                continue
            if len(f.split()) < 2:          # una palabra suelta engancha de todo
                continue
            # Si lo oido no se parece a lo dicho, es que el reconocedor se
            # perdio del todo, no que haya una confusion fonetica estable.
            # Guardarlo como alias meteria una frase arbitraria en el catalogo
            # y algun dia enganchara una conversacion.
            original = next((a["frases"][0] for a in acciones if a["id"] == ident), "")
            if motor.parecido(f, motor.normaliza(original)) < 0.55:
                descartados.append((f, [ident + " (irreconocible)"]))
                continue
            if cat.busca(f)[0] is not None:  # ya lo coge otra cosa
                continue
            propuestos[ident].append((f, veces))

    if propuestos:
        print("alias que faltan (frase mal oida -> accion):")
        for ident, lista in sorted(propuestos.items()):
            for f, veces in lista[:3]:
                print(f'  "{f}"  ->  {ident}  ({veces}x)')
    else:
        print("no falta ningun alias: todo lo dicho llega a su accion")

    if descartados:
        print(f"\n{len(descartados)} descartados por ambiguos "
              f"(la misma frase encaja en dos acciones):")
        for f, ids in descartados[:5]:
            print(f'  "{f}" -> {", ".join(ids)}')

    os.makedirs(os.path.dirname(INFORME), exist_ok=True)
    with open(INFORME, "w") as f:
        json.dump({
            "frases": evaluadas, "aciertos": aciertos,
            "propuestos": {k: v for k, v in propuestos.items()},
            "ambiguos": descartados,
        }, f, indent=2, ensure_ascii=False)
    print(f"\ninforme en {INFORME}")

    if args.aplicar and propuestos:
        guarda(propuestos, catalogo)
    elif propuestos:
        print("para guardarlos: vuelve a lanzarlo con --aplicar")

    print(f"los wav se quedan en {carpeta} por si hay que repasar algo")
    return 0


def guarda(propuestos, catalogo):
    with open(catalogo) as f:
        original = {a["id"]: a for a in json.load(f)["acciones"]}
    try:
        with open(PROPIO) as f:
            propio = json.load(f)
    except (OSError, json.JSONDecodeError):
        propio = {"version": 1, "acciones": []}

    porid = {a["id"]: a for a in propio.get("acciones", [])}
    n = 0
    for ident, lista in propuestos.items():
        entrada = porid.get(ident)
        if entrada is None:
            entrada = dict(original[ident])
            porid[ident] = entrada
            propio.setdefault("acciones", []).append(entrada)
        for frase, _ in lista:
            if frase not in entrada["frases"]:
                entrada["frases"].append(frase)
                n += 1

    with open(PROPIO, "w") as f:
        json.dump(propio, f, indent=2, ensure_ascii=False)
    print(f"{n} alias guardados en {PROPIO}")
    print("se aplican al abrir la pestaña JARVIS otra vez")


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