#! /usr/bin/python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""SecuBox-Deb :: secubox-groupd — plusieurs modules dans UN interpreteur.

POURQUOI. Chaque module tourne aujourd'hui dans son propre uvicorn : 31 Mo en
moyenne, dont l'immense majorite est l'interpreteur, FastAPI, Starlette et
Pydantic — payes une fois PAR MODULE. Sur 116 demons cela fait 3,7 Go pour un
code metier qui en represente une fraction.

CE QUE CE PROGRAMME NE CHANGE PAS. Chaque module garde SA socket, a son chemin
habituel. nginx, l'agregateur et tout ce qui parle a `/run/secubox/<m>.sock`
continuent sans modification. On ne deplace pas la frontiere : on supprime la
duplication derriere elle.

LE PIEGE, ET SA PARADE. Les 132 modules ont tous un paquet nomme `api`. Les
importer naivement ferait que le second ecrase le premier dans `sys.modules`,
et l'on servirait le mauvais code sous le bon nom — silencieusement. Chaque
module est donc importe avec `sys.modules["api"]` mis de cote et restaure, et
son arborescence enregistree sous un alias unique pour que ses propres
sous-imports (`from api.x import y`) resolvent bien vers LUI.
"""
from __future__ import annotations

import argparse
import asyncio
import importlib
import importlib.util
import logging
import os
import sys
import time
from pathlib import Path
from typing import Any

RACINE = Path("/usr/lib/secubox")
# Chaque paquet de module depose ici un fichier a son nom pour se declarer
# groupable. C'est ce qui concilie un EMPAQUETAGE separe et modulaire avec une
# INSTALLATION rassemblee : le groupe ne tient pas une liste a la main, il
# constate ce que l'installation contient. Installer un module l'ajoute,
# le desinstaller le retire, sans qu'aucun fichier central ne soit edite.
DECLARATIONS = Path("/usr/share/secubox/groupable.d")
# Le groupe PRIVILEGIE a son propre repertoire. Deux repertoires et non un
# drapeau : un module ne doit pas pouvoir se retrouver dans le groupe root par
# inadvertance. Y deposer son nom est un geste distinct, visible a la revue.
DECLARATIONS_ROOT = Path("/usr/share/secubox/groupable-root.d")
SOCKETS = Path("/run/secubox")

log = logging.getLogger("groupd")


class ImportIsole:
    """Importe `api.main` d'un module sans polluer les suivants.

    On retire de `sys.modules` tout ce qui commence par `api` AVANT l'import,
    puis on le restaure APRES en gardant l'arborescence du module sous un
    prefixe qui lui est propre. Sans ce menage, le deuxieme module importe
    recupererait le `api` du premier — et servirait son code.
    """

    def __init__(self, mid: str, chemin: Path):
        self.mid = mid
        self.chemin = chemin
        self._sauve: dict[str, Any] = {}

    def __enter__(self):
        self._sauve = {n: m for n, m in sys.modules.items()
                       if n == "api" or n.startswith("api.")}
        for n in self._sauve:
            del sys.modules[n]
        sys.path.insert(0, str(self.chemin))
        return self

    def __exit__(self, *exc):
        # L'arborescence fraichement importee est conservee sous un alias : le
        # module garde ses references internes vivantes apres qu'on ait libere
        # le nom `api` pour le suivant.
        for n, m in list(sys.modules.items()):
            if n == "api" or n.startswith("api."):
                sys.modules[f"_sbx_{self.mid}_{n}"] = m
                del sys.modules[n]
        sys.modules.update(self._sauve)
        try:
            sys.path.remove(str(self.chemin))
        except ValueError:
            pass
        return False


def charge(mid: str, racine: Path = RACINE):
    """Rend l'application ASGI d'un module, ou leve.

    Le repertoire du module devient temporairement le repertoire courant : de
    nombreux modules ouvrent des fichiers en chemin RELATIF, et sans cela ils
    lisent ceux d'un voisin — une confusion silencieuse et difficile a voir.
    """
    d = racine / mid
    if not (d / "api" / "main.py").exists():
        raise FileNotFoundError(f"{mid}: api/main.py absent")
    avant = os.getcwd()
    try:
        os.chdir(d)
        with ImportIsole(mid, d):
            mod = importlib.import_module("api.main")
            app = getattr(mod, "app", None)
            if app is None:
                raise AttributeError(f"{mid}: api.main n'expose pas `app`")
            return app
    finally:
        os.chdir(avant)


class _Lifespan:
    """Pilote le lifespan d'une app ASGI a la demande (demarrer / arreter).

    Une app FastAPI/Starlette sert ses routes HTTP MEME sans lifespan : le
    lifespan ne fait que lancer les taches de fond (`run_forever`) et les hooks
    de startup. On les demarre donc a la 1re vraie requete et on les arrete
    apres inactivite, en pilotant nous-memes le protocole lifespan via des files.
    """

    def __init__(self, app):
        self.app = app
        self._tache: asyncio.Task | None = None
        self._vers_app: asyncio.Queue = asyncio.Queue()
        self._depuis_app: asyncio.Queue = asyncio.Queue()
        self._verrou = asyncio.Lock()
        self.actif = False

    async def _boucle(self):
        async def receive():
            return await self._vers_app.get()

        async def send(msg):
            await self._depuis_app.put(msg)

        try:
            await self.app({"type": "lifespan", "asgi": {"version": "3.0"}},
                           receive, send)
        except Exception as e:  # noqa: BLE001
            await self._depuis_app.put({"type": "_erreur", "exc": repr(e)})

    async def demarrer(self):
        async with self._verrou:
            if self.actif:
                return
            self._tache = asyncio.create_task(self._boucle())
            await self._vers_app.put({"type": "lifespan.startup"})
            msg = await self._depuis_app.get()
            if msg["type"] in ("lifespan.startup.failed", "_erreur"):
                log.error("veille: startup echoue: %s", msg)
                self._tache.cancel()
                self._tache = None
                return  # on sert quand meme, sans les taches de fond
            self.actif = True

    async def arreter(self):
        async with self._verrou:
            if not self.actif:
                return
            await self._vers_app.put({"type": "lifespan.shutdown"})
            try:
                await asyncio.wait_for(self._depuis_app.get(), timeout=15)
            except asyncio.TimeoutError:
                log.warning("veille: shutdown lent, on annule la tache")
            if self._tache:
                self._tache.cancel()
                try:
                    await self._tache
                except (asyncio.CancelledError, Exception):  # noqa: BLE001
                    pass
                self._tache = None
            self.actif = False


class VeilleAuto:
    """Enveloppe ASGI : lazy-lifespan + mise en veille apres inactivite.

    La socket reste a l'ecoute en permanence (aucun 502/504, pas de wake-proxy).
    Les taches de fond du module ne tournent QUE lorsqu'il est reveille — un
    module opt-in jamais consulte ne coute alors plus de CPU. Les SONDES de sante
    ne reveillent pas (sinon rien ne dort jamais : cf. conflit sleeper/prober).
    """

    def __init__(self, app, mid: str, idle: float):
        self.app = app
        self.mid = mid
        self.idle = max(30.0, float(idle))
        self.ls = _Lifespan(app)
        self._dernier = 0.0
        self._reaper: asyncio.Task | None = None

    @staticmethod
    def _est_sonde(scope) -> bool:
        chemin = scope.get("path", "") or ""
        if chemin.endswith("/health") or chemin.endswith("/healthz") \
           or chemin.endswith("/ping") or chemin == "/":
            return True
        for cle, val in (scope.get("headers") or []):
            if cle == b"user-agent" and (b"prober" in val.lower()
                                         or b"health" in val.lower()):
                return True
        return False

    async def _veilleur(self):
        pas = min(60.0, self.idle)
        while True:
            await asyncio.sleep(pas)
            if self.ls.actif and self._dernier \
               and (time.monotonic() - self._dernier) > self.idle:
                log.info("veille: %s s'endort (inactif %.0fs)", self.mid, self.idle)
                await self.ls.arreter()

    async def __call__(self, scope, receive, send):
        t = scope["type"]
        if t == "lifespan":
            # On repond nous-memes a uvicorn : le module ne demarre PAS ici.
            while True:
                msg = await receive()
                if msg["type"] == "lifespan.startup":
                    if self._reaper is None:
                        self._reaper = asyncio.create_task(self._veilleur())
                    await send({"type": "lifespan.startup.complete"})
                elif msg["type"] == "lifespan.shutdown":
                    if self._reaper:
                        self._reaper.cancel()
                    await self.ls.arreter()
                    await send({"type": "lifespan.shutdown.complete"})
                    return
        else:
            # http / websocket : on reveille sauf pour les sondes.
            if not (t == "http" and self._est_sonde(scope)):
                await self.ls.demarrer()
                self._dernier = time.monotonic()
            await self.app(scope, receive, send)


async def sert(mid: str, app, dossier_sockets: Path = SOCKETS, veille: float = 0):
    """Sert un module sur SA socket, dans la boucle partagee.

    Si `veille > 0`, le module est enveloppe de `VeilleAuto` : ses taches de
    fond ne tournent que lorsqu'il est reveille. Sinon, comportement historique
    (lifespan demarre au boot, taches de fond permanentes).
    """
    import uvicorn

    if veille > 0:
        app = VeilleAuto(app, mid, veille)
        log.info("veille auto activee pour %s (idle=%.0fs)", mid, veille)

    chemin = dossier_sockets / f"{mid}.sock"
    # La socket perimee est retiree AVANT le bind : /run/secubox est partage et
    # colle, et une socket laissee par un autre utilisateur rendrait le
    # demarrage impossible — en boucle et en silence.
    try:
        chemin.unlink()
    except FileNotFoundError:
        pass
    cfg = uvicorn.Config(app, uds=str(chemin), log_level="warning",
                         access_log=False, lifespan="on")
    srv = uvicorn.Server(cfg)
    srv.install_signal_handlers = lambda: None  # la boucle est partagee
    await srv.serve()


async def principal(modules: list[str], racine: Path, dossier_sockets: Path,
                    veille: float = 0, veille_modules: set[str] | None = None) -> int:
    charges, echecs = [], []
    for mid in modules:
        try:
            charges.append((mid, charge(mid, racine)))
        except Exception as e:
            # UN MODULE QUI NE S'IMPORTE PAS N'EMPECHE PAS LES AUTRES. C'est
            # tout l'interet d'un groupe : la panne d'un module reste la sienne.
            echecs.append((mid, f"{type(e).__name__}: {e}"))
            log.error("%s non charge : %s", mid, e)
    log.warning("groupe : %d module(s) charges, %d en echec", len(charges), len(echecs))
    if not charges:
        return 1
    # Double garde : la veille ne s'applique qu'avec `--veille > 0` ET au module
    # explicitement opt-in (`--veille-modules`). Sinon, comportement historique.
    vm = veille_modules or set()
    await asyncio.gather(*(
        sert(m, a, dossier_sockets, veille=veille if (veille > 0 and m in vm) else 0)
        for m, a in charges))
    return 0


def modules_declares(repertoire: Path = DECLARATIONS) -> list[str]:
    """Rend les modules qui se sont declares groupables, par leur paquet.

    Un fichier vide suffit : c'est son NOM qui porte l'information. Un paquet
    peut y ecrire des options plus tard sans casser cette lecture.
    """
    if not repertoire.is_dir():
        return []
    return sorted(f.name for f in repertoire.iterdir()
                  if f.is_file() and not f.name.startswith("."))


def modules_depuis(fichier: Path) -> list[str]:
    """Lit la liste d'un fichier : un module par ligne, `#` en commentaire."""
    out = []
    for ligne in fichier.read_text().splitlines():
        ligne = ligne.split("#", 1)[0].strip()
        if ligne:
            out.append(ligne)
    return out


def main() -> int:
    p = argparse.ArgumentParser(description="Sert plusieurs modules SecuBox dans un interpreteur")
    p.add_argument("--modules", help="liste separee par des virgules")
    p.add_argument("--fichier", type=Path, help="fichier listant les modules, un par ligne")
    p.add_argument("--racine", type=Path, default=RACINE)
    p.add_argument("--sockets", type=Path, default=SOCKETS)
    p.add_argument("--declarations", action="store_true",
                   help="prendre les modules qui se sont declares groupables")
    p.add_argument("--repertoire-declarations", type=Path, default=DECLARATIONS,
                   dest="repertoire_declarations")
    p.add_argument("--veille", type=float, default=0,
                   help="mise en veille auto apres N secondes d'inactivite "
                        "(0 = desactive ; n'agit que sur --veille-modules)")
    p.add_argument("--veille-modules", default="",
                   help="liste (virgules) des modules opt-in a la veille auto")
    a = p.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

    mods = []
    if a.declarations:
        mods += modules_declares(a.repertoire_declarations)
    if a.fichier and a.fichier.exists():
        mods += modules_depuis(a.fichier)
    if a.modules:
        mods += [m.strip() for m in a.modules.split(",") if m.strip()]
    # Un module declare deux fois ne doit etre servi qu'une : deux serveurs sur
    # la meme socket, c'est le second qui echoue et un module qui parait mort.
    vus, uniques = set(), []
    for m in mods:
        if m not in vus:
            vus.add(m)
            uniques.append(m)
    mods = uniques
    if not mods:
        p.error("aucun module : ni declaration, ni --fichier, ni --modules")
    veille_modules = {m.strip() for m in a.veille_modules.split(",") if m.strip()}
    return asyncio.run(principal(mods, a.racine, a.sockets,
                                 veille=a.veille, veille_modules=veille_modules))


if __name__ == "__main__":
    raise SystemExit(main())
