"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.

Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
Y no habia forma de arrepentirse a mitad.

Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:

- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
  que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y
  comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
  proceso estrena grupo y `killpg` se los lleva a todos.

- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
  intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
  No hace falta perseguir el PID del otro lado.

- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
  enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
  esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
  guardan las ultimas, que es lo que cabe en la tarjeta.
"""

import os
import shlex
import signal
import subprocess
import threading
import time
import urllib.parse

# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
# de un `find /` es meterse megas en memoria por nada.
LINEAS_VISTA = 6
SALIDA_MAXIMA = 600

# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
# como red para lo que se cuelgue de verdad y no vuelva nunca.
LIMITE_LARGO = 900          # 15 minutos

# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
# atasca dibujando texto que nadie llega a leer.
CADENCIA_AVISO = 0.25

CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
    "corriendo", "hecha", "parada", "fallo", "caducada")


class Tarea:
    """Una orden en marcha. Solo datos; quien la mueve es el gestor."""

    def __init__(self, ident, titulo, accion_id, comando):
        self.id = ident
        self.titulo = titulo
        self.accion_id = accion_id
        self.comando = comando
        # La accion de origen viaja con la tarea (y no en un diccionario
        # aparte del panel) porque una tarea sin comando termina DENTRO de
        # lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
        # aviso de "terminada" llegaria a un registro todavia vacio.
        self.accion = None
        self.argumento = ""
        self.estado = CORRIENDO
        self.inicio = time.monotonic()
        self.fin = None
        self.lineas = []          # las ultimas LINEAS_VISTA de salida
        self.salida = ""          # la salida acumulada, recortada
        self.codigo = None
        self._proc = None

    @property
    def segundos(self) -> float:
        return (self.fin or time.monotonic()) - self.inicio

    @property
    def viva(self) -> bool:
        return self.estado == CORRIENDO

    def resumen(self) -> dict:
        """Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
        return {
            "id": self.id,
            "titulo": self.titulo,
            "accion": self.accion_id,
            "estado": self.estado,
            "segundos": round(self.segundos, 1),
            "lineas": self.lineas[-LINEAS_VISTA:],
            "codigo": self.codigo,
        }


class GestorTareas:
    """Lanza, vigila y para tareas. Un unico sitio donde vive el registro.

    al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
    nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
    interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
    levantar la aplicacion.
    """

    def __init__(self, al_cambiar=None, prefijo_host=()):
        self._al_cambiar = al_cambiar or (lambda tarea: None)
        self._prefijo_host = _con_watch_bus(prefijo_host)
        self._tareas = {}
        self._siguiente = 0
        self._cerrojo = threading.Lock()

    # -------------------------------------------------------------- registro

    def _nueva(self, titulo, accion_id, comando) -> Tarea:
        with self._cerrojo:
            self._siguiente += 1
            ident = f"t{self._siguiente}"
            tarea = Tarea(ident, titulo, accion_id, comando)
            self._tareas[ident] = tarea
        return tarea

    def get(self, ident):
        with self._cerrojo:
            return self._tareas.get(ident)

    def activas(self) -> list:
        with self._cerrojo:
            return [t for t in self._tareas.values() if t.viva]

    def todas(self) -> list:
        with self._cerrojo:
            return list(self._tareas.values())

    def _avisa(self, tarea):
        try:
            self._al_cambiar(tarea)
        except Exception as e:                      # el HUD no tumba la tarea
            print(f"JARVIS: aviso de tarea fallido: {e}")

    # ---------------------------------------------------------------- lanzar

    def lanza(self, accion, argumento="", titulo=None) -> Tarea:
        """Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
        comando = accion.comando or ""
        if comando:
            comando = comando.replace("{argumento}", shlex.quote(argumento))
            comando = comando.replace(
                "{argumento_url}",
                shlex.quote(urllib.parse.quote_plus(argumento)))

        tarea = self._nueva(titulo or _titulo_de(accion, argumento),
                            accion.id, comando)
        tarea.accion = accion
        tarea.argumento = argumento
        self._avisa(tarea)

        if not comando:
            # Acciones que solo hablan (no tienen comando): nacen terminadas.
            self.termina(tarea.id, HECHA, "")
            return tarea

        orden = ["bash", "-lc", comando]
        if accion.host:
            orden = self._prefijo_host + orden

        threading.Thread(target=self._corre, args=(tarea, orden),
                         daemon=True).start()
        return tarea

    def adopta(self, titulo, accion_id="") -> Tarea:
        """Una tarjeta para trabajo que se ejecuta fuera del gestor.

        Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
        su contraseña), pero se merece tarjeta igual: si no, pedir algo con
        sudo hace desaparecer la señal de que hay algo en marcha.
        """
        tarea = self._nueva(titulo, accion_id, "")
        self._avisa(tarea)
        return tarea

    def _corre(self, tarea, orden):
        try:
            # start_new_session: el proceso estrena grupo, para poder matar el
            # arbol entero y no solo el bash de arriba.
            proc = subprocess.Popen(
                orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, errors="replace", start_new_session=True)
        except OSError as e:
            self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
            return

        tarea._proc = proc
        limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
        limite.daemon = True
        limite.start()

        ultimo_aviso = 0.0
        acumulado = []
        try:
            for linea in proc.stdout:
                linea = linea.rstrip("\n")
                if not linea.strip():
                    continue
                acumulado.append(linea)
                tarea.lineas = acumulado[-LINEAS_VISTA:]
                ahora = time.monotonic()
                if ahora - ultimo_aviso >= CADENCIA_AVISO:
                    ultimo_aviso = ahora
                    self._avisa(tarea)
        except Exception as e:
            print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
        finally:
            limite.cancel()

        codigo = proc.wait()
        salida = "\n".join(acumulado).strip()

        if tarea.estado == PARADA:            # la paro alguien: no se toca
            self._avisa(tarea)
            return
        if tarea.estado == CADUCADA:
            self._avisa(tarea)
            return
        self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)

    # ------------------------------------------------------------- terminar

    def termina(self, ident, estado, salida="", codigo=None):
        tarea = self.get(ident)
        if tarea is None:
            return
        tarea.estado = estado
        tarea.fin = time.monotonic()
        tarea.codigo = codigo
        if salida:
            tarea.salida = salida[:SALIDA_MAXIMA]
            if not tarea.lineas:
                tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
        else:
            tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
        self._avisa(tarea)

    def para(self, ident) -> bool:
        """Corta la tarea. Primero por las buenas, luego a la fuerza."""
        tarea = self.get(ident)
        if tarea is None or not tarea.viva:
            return False

        tarea.estado = PARADA
        proc = tarea._proc
        if proc is not None and proc.poll() is None:
            _mata_grupo(proc)
        tarea.fin = time.monotonic()
        self._avisa(tarea)
        return True

    def _caduca(self, ident):
        tarea = self.get(ident)
        if tarea is None or not tarea.viva:
            return
        tarea.estado = CADUCADA
        if tarea._proc is not None and tarea._proc.poll() is None:
            _mata_grupo(tarea._proc)
        tarea.fin = time.monotonic()
        self._avisa(tarea)

    def cierra(self, ident) -> bool:
        """Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
        tarea = self.get(ident)
        if tarea is None:
            return False
        if tarea.viva:
            self.para(ident)
        with self._cerrojo:
            self._tareas.pop(ident, None)
        return True

    def para_todas(self):
        for tarea in self.activas():
            self.para(tarea.id)


def _con_watch_bus(prefijo) -> list:
    """Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.

    Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
    es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
    lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
    sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
    de bus de quien lo lanzo, que es justo lo que se quiere.
    """
    prefijo = list(prefijo)
    if not prefijo:
        return prefijo
    if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
        # detras del propio flatpak-spawn, antes de sus argumentos
        for i, p in enumerate(prefijo):
            if "flatpak-spawn" in p:
                prefijo.insert(i + 1, "--watch-bus")
                break
    return prefijo


def _mata_grupo(proc):
    """SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.

    Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
    escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
    """
    try:
        grupo = os.getpgid(proc.pid)
    except OSError:
        return
    try:
        os.killpg(grupo, signal.SIGTERM)
    except OSError:
        return

    def remata():
        if proc.poll() is None:
            try:
                os.killpg(grupo, signal.SIGKILL)
            except OSError:
                pass

    t = threading.Timer(3.0, remata)
    t.daemon = True
    t.start()


def _titulo_de(accion, argumento) -> str:
    """El rotulo de la tarjeta: corto, y con el argumento si lo hay.

    El id de la accion con guiones bajos ("buscar_fichero") es de programador;
    en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
    lo que distingue dos busquedas a la vez.
    """
    base = (accion.id or "accion").replace("_", " ")
    if argumento:
        arg = argumento.strip()
        if len(arg) > 22:
            arg = arg[:21] + "…"
        return f"{base}: {arg}"
    return base
