"""La cara: sirve el HUD y lo mantiene al dia por WebSocket.

## Por que un servidor y no una ventana

En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un
servidor local se gana mas de lo que parece:

  - el panel es una pagina normal: se abre con `chromium --app=`, en cualquier
    navegador, o desde el movil o una tablet en la misma red;
  - se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en
    el todo el tiempo;
  - desaparece GTK de la cara, que era la ultima atadura al framework.

El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por
evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es
`puente.js`, que suplanta `window.webkit` con el socket.

## Escucha solo en local

127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la
red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un
tunel ssh, que ademas obliga a autenticarse.
"""
import asyncio
import json
import mimetypes
import os
import threading

HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud")
PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790"))

FICHEROS = {
    "/": "index.html",
    "/index.html": "index.html",
    "/hud.js": "hud.js",
    "/puente.js": "puente.js",
    "/style.css": "style.css",
}


class Cara:
    """El panel: se le empujan eventos y devuelve lo que se pulsa."""

    # Segundos que se espera tras cerrarse la ultima pestaña antes de apagar.
    # Da margen a una RECARGA (que cierra el socket y lo reabre enseguida) para
    # no confundirla con cerrar la ventana de verdad.
    GRACIA = 4

    def __init__(self, al_pulsar=None, al_cerrar=None, puerto=PUERTO):
        self.al_pulsar = al_pulsar          # (canal, dato) -> None
        self.al_cerrar = al_cerrar          # se cerro la ultima pestaña -> apagar
        self.puerto = puerto
        self._clientes = set()
        self._hubo_cliente = False          # para no apagar antes de abrir el panel
        self._bucle = None
        self._hilo = None
        self._ultimo = {}                   # el estado, para el que llegue tarde

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

    def arranca(self):
        """Levanta el servidor en un hilo aparte y vuelve enseguida.

        Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya
        haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza
        de asyncio dentro de un hilo, con "address already in use" enterrado en
        la linea veinte, y el proceso seguia vivo sin panel.
        """
        listo = threading.Event()

        def corre():
            self._bucle = asyncio.new_event_loop()
            asyncio.set_event_loop(self._bucle)
            try:
                self._bucle.run_until_complete(self._sirve(listo))
            except OSError as e:
                self._error = e
                listo.set()
                return
            self._bucle.run_forever()

        self._error = None
        self._hilo = threading.Thread(target=corre, daemon=True)
        self._hilo.start()
        listo.wait(timeout=10)
        if self._error is not None:
            import errno
            if self._error.errno == errno.EADDRINUSE:
                print(f"  el puerto {self.puerto} ya esta cogido: ¿hay otro "
                      f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)")
            else:
                print(f"  no se pudo levantar el panel: {self._error}")
            return None
        return f"http://127.0.0.1:{self.puerto}/"

    async def _sirve(self, listo):
        import websockets
        from websockets.http11 import Response
        from websockets.datastructures import Headers

        async def http(conexion, peticion):
            """Sirve los ficheros del HUD; deja pasar el WebSocket."""
            ruta = peticion.path.split("?")[0]
            if ruta == "/puente":
                return None                 # que siga el apreton de manos
            nombre = FICHEROS.get(ruta)
            if not nombre:
                return Response(404, "Not Found", Headers(), b"no esta\n")
            try:
                with open(os.path.join(HUD, nombre), "rb") as f:
                    cuerpo = f.read()
            except OSError:
                return Response(404, "Not Found", Headers(), b"no esta\n")
            tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream"
            cabeceras = Headers({
                "Content-Type": tipo + ("; charset=utf-8" if "text" in tipo
                                        or "javascript" in tipo else ""),
                "Content-Length": str(len(cuerpo)),
                # Sin cache: el HUD se edita en caliente y recargar tiene que
                # traer lo nuevo, no lo de hace media hora.
                "Cache-Control": "no-store",
            })
            return Response(200, "OK", cabeceras, cuerpo)

        self._servidor = await websockets.serve(
            self._cliente, "127.0.0.1", self.puerto, process_request=http)
        listo.set()

    # -------------------------------------------------------------- clientes

    async def _cliente(self, ws):
        self._clientes.add(ws)
        self._hubo_cliente = True
        try:
            # Al conectar se le manda el estado que ya habia. Sin esto, abrir el
            # panel a media sesion enseña un HUD vacio hasta que algo cambie.
            for mensaje in self._ultimo.values():
                await ws.send(json.dumps(mensaje))
            async for crudo in ws:
                try:
                    m = json.loads(crudo)
                except json.JSONDecodeError:
                    continue
                if self.al_pulsar:
                    # En otro hilo: atender una pulsacion puede tardar (hablar,
                    # cambiar de voz, lanzar una tarea) y el bucle del socket no
                    # puede quedarse esperando o el panel se congela entero.
                    threading.Thread(
                        target=self.al_pulsar,
                        args=(m.get("canal", ""), m.get("dato", "")),
                        daemon=True).start()
        except Exception:
            pass
        finally:
            self._clientes.discard(ws)
            # Se cerro una pestaña. Si no queda ninguna, se espera un poco (por
            # si es una recarga) y, si sigue sin haber panel abierto, se apaga
            # JARVIS: cerrar la ventana suelta el microfono y la grafica.
            if self._hubo_cliente and not self._clientes and self.al_cerrar:
                try:
                    asyncio.get_running_loop().call_later(
                        self.GRACIA, self._quiza_cerrar)
                except RuntimeError:
                    pass

    def _quiza_cerrar(self):
        if not self._clientes and self.al_cerrar:
            threading.Thread(target=self.al_cerrar, daemon=True).start()

    # --------------------------------------------------------------- empujar

    def manda(self, tipo: str, datos):
        """Empuja un evento al panel. Se puede llamar desde cualquier hilo."""
        mensaje = {"tipo": tipo, "datos": datos}
        # Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante:
        # el pulso de la voz a 15 por segundo no tiene sentido reenviarlo.
        if tipo not in ("pulso",):
            self._ultimo[tipo] = mensaje
        if not self._bucle or not self._clientes:
            return
        crudo = json.dumps(mensaje, ensure_ascii=False)
        asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle)

    async def _reparte(self, crudo):
        for ws in list(self._clientes):
            try:
                await ws.send(crudo)
            except Exception:
                self._clientes.discard(ws)

    @property
    def conectados(self) -> int:
        return len(self._clientes)
