"""Acciones rapidas: caminos predefinidos que se saltan el modelo.

Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo
de 4B razone, decida llamar a una herramienta, la ejecute y redacte la
respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`.
El catalogo empareja la frase con un comando y contesta con una plantilla, y
eso baja de segundos a decimas.

No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es
predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue
yendo al modelo como siempre.

Formato del catalogo (JSON):

    {
      "acciones": [
        {
          "id": "disco_libre",
          "frases": ["cuanto espacio queda", "espacio en disco"],
          "acuse": "Un momento, lo miro.",
          "comando": "df -h / | tail -1",
          "host": true,
          "respuesta": "Quedan {campo4} libres de {campo2}.",
          "confirmar": false
        }
      ]
    }

En `respuesta` se puede usar:
    {salida}     la salida completa, recortada
    {primera}    la primera linea
    {ultima}     la ultima linea no vacia
    {campoN}     la columna N de la ultima linea (1 en adelante)
    {lineas}     cuantas lineas ha devuelto
"""
import difflib
import json
import os
import re
import shlex
import subprocess
import urllib.parse
import unicodedata

UMBRAL_PARECIDO = 0.86      # por debajo empieza a confundir ordenes
TIEMPO_LIMITE = 20          # ningun atajo deberia tardar mas
SALIDA_MAXIMA = 600         # lo que se guarda para enseñar y para la plantilla


def normaliza(texto: str) -> str:
    """Minusculas, sin tildes, sin puntuacion y con los espacios apretados.

    Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto
    espacio queda". Sin normalizar no casaria ni una.

    Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por
    un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de
    coincidir con la frase del catalogo. El sintoma era un asistente que a
    veces no reacciona a una orden que ha entendido bien, segun donde el
    reconocedor decida poner una coma.
    """
    texto = unicodedata.normalize("NFD", texto.lower())
    texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
    return " ".join(re.sub(r"[^\w\s]", " ", texto).split())


# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la
# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme".
# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden
# se pierde entera.
#
# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que
# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran
# el mismo intento de poner una pelicula, dicho de cinco maneras.
CORTESIA = re.compile(
    r"^(por favor,?\s+)?"
    r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|"
    r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|"
    r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+",
    re.I)


class Accion:
    def __init__(self, datos: dict):
        self.id = datos.get("id", "")
        self.frases = [normaliza(f) for f in datos.get("frases", [])]
        self.acuse = datos.get("acuse", "Voy.")
        self.comando = datos.get("comando", "")
        self.host = bool(datos.get("host", True))
        self.respuesta = datos.get("respuesta", "{salida}")
        self.confirmar = bool(datos.get("confirmar", False))
        self.captura = bool(datos.get("captura", False))
        self.grupo = datos.get("grupo", "")

    def coincide(self, texto: str):
        """(longitud de la frase que encaja, lo que venia detras).

        Se devuelve la longitud y no un si/no para poder quedarse con la
        coincidencia mas especifica: "abre la carpeta de descargas" debe ganar
        a "abre la carpeta" cuando las dos encajan.

        El resto es el argumento: en "busca gatos en youtube" la frase es
        "busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la
        accion lo pide, para que una frase suelta no arrastre basura.
        """
        mejor, resto = 0, ""
        for f in self.frases:
            if not f or f not in texto:
                continue
            if len(f) > mejor:
                mejor = len(f)
                resto = texto.split(f, 1)[1].strip() if self.captura else ""
        return mejor, resto


def parecido(a: str, b: str) -> float:
    """Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido.

    Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis"
    por "oasis". Se usa difflib y no una distancia de edicion propia porque
    viene en la biblioteca estandar y aqui no hay que hilar tan fino.

    Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido.
    Muchos fallos del reconocedor no son de significado sino de ortografia
    —"Abre van Kamp" por "abre bandcamp", "poops" por "pubs"— y por escrito
    quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto
    de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada.

    Que sea el maximo y no la media importa: la media hundiria los aciertos
    normales, donde la letra ya casaba bien y el sonido no aporta.
    """
    literal = difflib.SequenceMatcher(None, a, b).ratio()
    if literal >= 0.98:
        return literal                     # ya casa: no hace falta el sonido
    from .fonetica import fonetica
    return max(literal,
               difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio())


class Catalogo:
    def __init__(self, rutas):
        self.acciones = []
        self.errores = []
        for ruta in rutas:
            if ruta and os.path.exists(ruta):
                self._carga(ruta)

    def _carga(self, ruta):
        try:
            with open(ruta) as f:
                datos = json.load(f)
        except (OSError, json.JSONDecodeError) as e:
            self.errores.append(f"{os.path.basename(ruta)}: {e}")
            return

        vistos = {a.id for a in self.acciones}
        for entrada in datos.get("acciones", []):
            accion = Accion(entrada)
            if not accion.id or not accion.frases:
                self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases")
                continue
            # el primero que se carga manda: asi el catalogo del usuario puede
            # redefinir uno del paquete sin tener que borrarlo
            if accion.id not in vistos:
                self.acciones.append(accion)
                vistos.add(accion.id)

    def busca(self, texto: str):
        """(accion mas especifica que encaje, argumento) o (None, "").

        Si no encaja nada, se reintenta quitando la cortesia de delante. Se
        hace como SEGUNDA pasada y no normalizando desde el principio a
        proposito: asi lo que ya funcionaba sigue funcionando igual, y esto
        solo añade aciertos donde antes no habia ninguno.
        """
        encontrada, argumento = self._busca_directa(normaliza(texto))
        if encontrada is not None:
            return encontrada, argumento
        sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip()
        if sin_cortesia and sin_cortesia != normaliza(texto):
            return self._busca_directa(sin_cortesia)
        return None, ""

    def _busca_directa(self, texto: str):
        mejor, puntos, argumento = None, 0, ""
        for accion in self.acciones:
            p, resto = accion.coincide(texto)
            if p > puntos:
                mejor, puntos, argumento = accion, p, resto

        # Nada exacto: se prueba por parecido. El reconocedor se come letras
        # ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El
        # umbral es alto a proposito: mas abajo empieza a confundir ordenes
        # distintas entre si, que es peor que no reaccionar.
        if mejor is None:
            self._resto_parecido = ""
            mejor, puntos = self._por_parecido(texto)
            if mejor is not None and mejor.captura:
                argumento = self._resto_parecido

        # una accion que captura y no recibe nada no sirve: "busca" a secas
        if mejor is not None and mejor.captura and not argumento:
            return None, ""
        return mejor, argumento

    def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO):
        """La accion cuya frase mas se parezca, si pasa del umbral."""
        palabras = texto.split()
        mejor, mejor_p = None, umbral
        for accion in self.acciones:
            if accion.captura:
                # Con argumento no vale comparar la frase entera: "busca en
                # youtube conciertos de techno" no se parece a "busca en
                # youtube". Se compara solo el arranque y lo demas es el
                # argumento. Se exigen tres palabras para que un disparador
                # corto no se coma media conversacion.
                for frase in accion.frases:
                    trozos = frase.split()
                    # Dos palabras, no tres. Con tres, disparadores cortos como
                    # "receta de" no entraban NUNCA en esta fase, y "receta de
                    # documentos" mal oido se perdia entero. El riesgo de bajar
                    # a dos lo cubre que la comparacion va anclada al principio:
                    # solo encaja si el arranque suena a la frase.
                    if len(trozos) < 2 or len(palabras) <= len(trozos):
                        continue
                    arranque = " ".join(palabras[:len(trozos)])
                    p = parecido(arranque, frase)
                    if p > mejor_p:
                        mejor, mejor_p = accion, p
                        self._resto_parecido = " ".join(palabras[len(trozos):])
                continue

            # Frase ENTERA contra frase entera, no por ventana deslizante. La
            # ventana encontraba un sub-trozo que puntuaba alto aunque la orden
            # fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo")
            # colaba en la accion del tiempo con 0.90, y se ejecutaba la accion
            # equivocada —lo peor que puede pasar en algo que lanza comandos—.
            # Una orden se dice completa; si va incrustada en una frase larga,
            # el emparejador exacto (por subcadena) ya la coge antes de llegar
            # aqui. Comparar el todo con el todo deja fuera esos falsos
            # positivos sin perder los casi-aciertos reales.
            propio = " ".join(palabras)
            for frase in accion.frases:
                if not frase:
                    continue
                p = parecido(propio, frase)
                if p > mejor_p:
                    mejor, mejor_p = accion, p
        return mejor, mejor_p

    def __len__(self):
        return len(self.acciones)


def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple:
    """Corre el comando y devuelve (texto para decir, salida cruda).

    prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa
    como argumento en vez de importarlo para poder probar esto sin flatpak.
    """
    if not accion.comando:
        return accion.acuse, ""

    # El argumento viene de un microfono, o sea de fuera: va entrecomillado con
    # shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm"
    # seria una orden ejecutable.
    comando = accion.comando.replace("{argumento}", shlex.quote(argumento))
    comando = comando.replace("{argumento_url}",
                              shlex.quote(urllib.parse.quote_plus(argumento)))

    orden = ["bash", "-lc", comando]
    if accion.host:
        orden = list(prefijo_host) + orden

    try:
        # errors="replace": un grep o un cat que toque un binario devuelve
        # bytes que no son utf-8, y sin esto la orden entera reventaria con
        # UnicodeDecodeError. Mejor un caracter raro que un fallo.
        r = subprocess.run(orden, capture_output=True, text=True,
                           errors="replace", timeout=TIEMPO_LIMITE)
        salida = (r.stdout or r.stderr or "").strip()
    except subprocess.TimeoutExpired:
        return "La orden ha tardado demasiado y la he cancelado.", ""
    except OSError as e:
        return f"No he podido ejecutarlo: {e}", ""

    return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA]


def formatea(plantilla: str, salida: str, argumento: str = "") -> str:
    lineas = [l for l in salida.splitlines() if l.strip()]
    ultima = lineas[-1] if lineas else ""
    campos = ultima.split()

    valores = {
        "argumento": argumento,
        "salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado",
        "primera": lineas[0] if lineas else "",
        "ultima": ultima,
        "lineas": len(lineas),
    }
    for i, campo in enumerate(campos, start=1):
        valores[f"campo{i}"] = campo

    try:
        return plantilla.format(**valores)
    except (KeyError, IndexError, ValueError):
        # plantilla que pide un campo que no existe: mejor decir la salida
        # cruda que soltar una excepcion en mitad de una conversacion
        return valores["salida"]
