#!/usr/bin/env python3
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.

    python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py            # una tanda
    python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6  # toda la noche
    python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar  # guardar mejoras

La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
Esto los junta en una conversacion de punta a punta y por AUDIO:

  Examinador  ->  dice una tarea en voz alta (Piper), con ruido y sala
  Jarvis      ->  la oye (Whisper), decide que accion es, la ejecuta,
                  y contesta en voz alta
  Examinador  ->  comprueba tres cosas:
                    1. ¿oyo la tarea correcta? (reconocimiento)
                    2. ¿hizo la accion que tocaba? (emparejamiento)
                    3. ¿el resultado tiene sentido? (ejecucion y respuesta)
                  y pone nota

Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.

Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—.

Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
disparar ninguna accion, como control negativo.
"""
import argparse
import importlib.util
import json
import os
import random
import re
import shlex
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")
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")

SALA = os.path.join(JARVIS, "muestras/autoescuela")
DIALOGO = os.path.join(SALA, "dialogo.log")
INFORME = os.path.join(SALA, "informe.json")
WAVS = os.path.join(CONFIG, "autoescuela_wavs")

VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
VELOCIDADES = [0.9, 1.0, 1.12]
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
          "highpass=f=150,lowpass=f=6500"]

# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
# ejecuta. Igual que en probar_acciones.
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
           "brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
           "import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]

RELLENOS = ["notas", "el informe", "documentos", "reactor"]


def carga(ruta, nombre):
    spec = importlib.util.spec_from_file_location(nombre, ruta)
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)
    return m


def log(msg, a_dialogo=False):
    linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
    print(linea, flush=True)
    fichero = DIALOGO if a_dialogo else DIALOGO
    try:
        with open(fichero, "a") as f:
            f.write((msg if a_dialogo else linea) + "\n")
    except OSError:
        pass


def dice(texto, voz, vel, ruido, destino):
    """El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
    crudo = tempfile.mktemp(suffix=".wav")
    try:
        r = subprocess.run(
            [PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
             "--length_scale", str(1 / vel), "--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 True
    except (OSError, subprocess.SubprocessError):
        return False
    finally:
        if os.path.exists(crudo):
            os.remove(crudo)


def oye(wavs, hilos=8, por_tanda=40):
    """Jarvis oye: transcribe los wav. {fichero: texto}."""
    salida = {}
    ficheros = sorted(wavs)
    for i in range(0, len(ficheros), por_tanda):
        tanda = ficheros[i:i + por_tanda]
        lista = " ".join(f'"{WAVS}/{n}"' for n in tanda)
        orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
                 f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
                 f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
        try:
            r = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
                               capture_output=True, text=True, errors="replace", timeout=1200)
        except subprocess.SubprocessError:
            continue
        actual = None
        for linea in r.stdout.splitlines():
            if linea.startswith("### "):
                actual = linea[4:].strip()
            elif actual:
                salida[actual] = linea.strip()
                actual = None
    return salida


def tiene_efecto(comando):
    return any(m in comando for m in EFECTOS)


def ejecuta_lectura(comando, argumento=""):
    """Ejecuta un comando de solo lectura y devuelve su salida."""
    c = comando.replace("{argumento}", shlex.quote(argumento))
    c = c.replace("{argumento_url}", shlex.quote(argumento))
    try:
        r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
                           errors="replace", timeout=15)
        return (r.stdout or r.stderr or "").strip(), r.returncode
    except subprocess.SubprocessError:
        return "", 1


class Ejecutor:
    """La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
    del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""

    def __init__(self, motor, seleccion):
        self.motor = motor
        self.sel = seleccion
        self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
                                  or [CATALOGO_FUENTE])
        self.lista = []
        self.fichero = None

    def recarga(self):
        self.cat = self.motor.Catalogo(
            [p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])

    def procesa(self, oido):
        """Devuelve (tipo, id_o_dato, salida). tipo:
        'seleccion' | 'dentro' | 'accion' | 'modelo'."""
        norm = self.motor.normaliza(oido)

        s = self.sel.orden_de_seleccion(norm)
        if s is not None and self.lista:
            que, pos = s
            idx = len(self.lista) - 1 if pos == -1 else pos - 1
            if 0 <= idx < len(self.lista):
                self.fichero = self.lista[idx]
                return "seleccion", f"{que}:{pos}", self.fichero
            return "seleccion", "fuera_de_rango", ""

        termino = self.sel.orden_buscar_dentro(norm)
        if termino and self.fichero:
            r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
                                   f"{shlex.quote(self.fichero)} | head", "")
            return "dentro", termino, r

        accion, arg = self.cat.busca(oido)
        if accion is None:
            return "modelo", "", ""

        # las de lista alimentan self.lista
        if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
            salida, _ = ejecuta_lectura(accion.comando, arg)
            rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
                     for l in salida.splitlines() if l.strip()]
            self.lista = rutas
            if rutas:
                self.fichero = rutas[0]
            return "accion", accion.id, salida

        if accion.confirmar or tiene_efecto(accion.comando):
            return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"

        salida, _ = ejecuta_lectura(accion.comando, arg)
        return "accion", accion.id, salida


def escenarios(acciones):
    """Construye las tareas de examen. Cada una: (texto, comprobacion)."""
    tareas = []

    # un turno por accion: la tarea es su frase, se espera su id
    for a in acciones:
        if not a["frases"]:
            continue
        texto = a["frases"][0]
        if a.get("captura"):
            texto += " " + random.choice(RELLENOS)
        tareas.append({
            "tipo": "accion", "texto": texto, "espera_id": a["id"],
            "turnos": [texto],
        })

    # multi-turno: probar el estado (lista -> elegir -> buscar dentro)
    tareas.append({
        "tipo": "multi", "texto": "buscar y elegir",
        "turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
        "espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
    })

    # control negativo: charla que NO debe disparar accion
    for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
                  "estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
        tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})

    return tareas


def una_tanda(ejecutor, motor, ronda, muestras):
    acciones = ejecutor.cat.acciones
    base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
                                   else CATALOGO_FUENTE))["acciones"]
    tareas = escenarios(base_acciones)

    # sintetizar todos los turnos
    shutil.rmtree(WAVS, ignore_errors=True)
    os.makedirs(WAVS, exist_ok=True)
    plan = []      # (fichero, tarea_idx, turno_idx, texto)
    n = 0
    for ti, tarea in enumerate(tareas):
        for tj, texto in enumerate(tarea["turnos"]):
            n += 1
            nombre = f"{n:05d}.wav"
            if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
                    random.choice(RUIDOS), os.path.join(WAVS, nombre)):
                plan.append((nombre, ti, tj, texto))

    log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
    oidos = oye([p[0] for p in plan])

    # correr los escenarios en orden (el estado importa en los multi-turno)
    resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
                  "ejecuta_mal": 0, "charla_colada": 0}
    candidatos = defaultdict(lambda: defaultdict(int))
    por_tarea = defaultdict(list)
    for nombre, ti, tj, texto in plan:
        por_tarea[ti].append((tj, nombre, texto))

    for ti in sorted(por_tarea):
        tarea = tareas[ti]
        ejecutor.lista = []          # cada escenario empieza limpio
        ejecutor.fichero = None
        for tj, nombre, texto in sorted(por_tarea[ti]):
            oido = oidos.get(nombre, "").strip()
            if not oido:
                continue
            tipo, dato, salida = ejecutor.procesa(oido)

            # dejar la conversacion en el log, legible
            log(f"  examinador: «{texto}»", a_dialogo=True)
            log(f"  jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)

            veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
                                 motor, candidatos)
            log(f"  veredicto: {veredicto}\n", a_dialogo=True)
            resultados[veredicto] = resultados.get(veredicto, 0) + 1

    total = sum(resultados.values())
    bien = resultados["bien"]
    log(f"ronda {ronda}: {bien}/{total} bien · " +
        " · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
    return resultados, candidatos


def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
    """Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
    if tarea["tipo"] == "charla":
        return "bien" if tipo == "modelo" else "charla_colada"

    if tarea["tipo"] == "multi":
        esperado = tarea["espera"][tj]
        actual = f"{tipo}:{dato}"
        if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
               (e == actual or e.split(":")[0] == tipo)
               for e in esperado.split("|")):
            # para multi basta con que el TIPO sea el correcto
            if tipo in [e.split(":")[0] for e in esperado.split("|")]:
                return "bien"
        return "accion_mal"

    # tarea de un turno: se espera un id concreto
    espera_id = tarea["espera_id"]
    if tipo != "accion":
        # no reconocio la orden: candidato a alias si conserva sentido
        candidatos[espera_id][motor.normaliza(oido)] += 1
        return "reconoce_mal"
    if dato != espera_id:
        return "accion_mal"
    # emparejo bien; ¿la ejecucion dio algo con sentido?
    if salida and ("command not found" in salida or salida == "TIMEOUT"):
        return "ejecuta_mal"
    return "bien"


# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
          "tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}


def contenido(frase):
    return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}


def alias_seguros(candidatos, acciones, motor, cat):
    porfrase = defaultdict(set)
    for ident, frases in candidatos.items():
        for f in frases:
            porfrase[f].add(ident)
    porid = {a["id"]: a for a in acciones}
    propuestos = defaultdict(list)
    for ident, frases in candidatos.items():
        a = porid.get(ident)
        if a is None or a.get("captura"):
            continue
        orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
        claves = contenido(orig)
        for f, veces in frases.items():
            if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
                continue
            if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
                continue
            pal = set(f.split())
            if claves and not any(
                    c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
                    for c in claves):
                continue
            propuestos[ident].append((f, veces))
    return propuestos


def guarda_alias(propuestos, acciones):
    try:
        propio = json.load(open(PROPIO))
    except (OSError, json.JSONDecodeError):
        propio = {"version": 1, "acciones": []}
    porid = {a["id"]: a for a in propio.get("acciones", [])}
    orig = {a["id"]: a for a in acciones}
    n = 0
    for ident, lista in propuestos.items():
        e = porid.get(ident)
        if e is None:
            e = dict(orig[ident]); porid[ident] = e
            propio.setdefault("acciones", []).append(e)
        for f, _ in lista:
            if f not in e["frases"]:
                e["frases"].append(f); n += 1
    json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
    return n


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--horas", type=float, default=0)
    ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
    ap.add_argument("--muestras", type=int, default=1)
    args = ap.parse_args()

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

    os.makedirs(SALA, exist_ok=True)
    motor = carga(MOTOR, "acciones")
    sel = carga(SELECCION, "seleccion")
    ejecutor = Ejecutor(motor, sel)

    limite = time.time() + args.horas * 3600 if args.horas else None
    log("=" * 56)
    log("arranca la autoescuela (examinador + jarvis)")

    historial = []
    ronda = 0
    total_alias = 0
    while True:
        ronda += 1
        ini = time.time()
        try:
            resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
        except Exception as e:
            log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
            time.sleep(5); continue

        # aprender de los fallos de reconocimiento
        base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
                              else CATALOGO_FUENTE))["acciones"]
        propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
        nuevos = sum(len(v) for v in propuestos.values())
        if nuevos and args.aplicar:
            total_alias += guarda_alias(propuestos, base)
            ejecutor.recarga()
            log(f"ronda {ronda}: +{nuevos} alias aplicados")
        elif nuevos:
            log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")

        total = sum(resultados.values())
        historial.append({
            "ronda": ronda,
            "acierto": round(100 * resultados["bien"] / max(1, total), 1),
            "fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
            "minutos": round((time.time() - ini) / 60, 1),
        })
        json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
                   "rondas": ronda, "alias_aplicados": total_alias,
                   "historial": historial[-30:]},
                  open(INFORME, "w"), indent=2, ensure_ascii=False)

        if not args.horas:
            break
        if time.time() > limite:
            log("se acabo el tiempo. Fin."); break

    shutil.rmtree(WAVS, ignore_errors=True)
    log(f"terminado tras {ronda} rondas · ultimo acierto "
        f"{historial[-1]['acierto'] if historial else '?'} %")
    return 0


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