#!/usr/bin/python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.

"""
SecuBox-Deb :: voicestudioctl — VoiceStudio dans un LXC natif (#1917, ex-#1649)
CyberMind — https://cybermind.fr

Seule surface privilégiée du module : le panneau (utilisateur `secubox`) l'appelle par
`sudo -n` ; chaque changement d'état est écrit dans /var/log/secubox/audit.log.

  client ──► <adresse publiée>:3900 (mandataire, activé par socket)
        └─ ExecStartPre : voicestudioctl wake   (démarre le LXC, attend /health)
        └─ systemd-socket-proxyd ─► 10.100.0.230:3900 (moteur, dans le LXC)
        └─ ExecStopPost : voicestudioctl sleep  (mode « demande » seulement)

  install            crée le LXC, installe le moteur (long : 10 à 25 min), pousse la clé
  reinstaller        rejoue l'installation du moteur (après changement de [source])
  migrer-conf        dépose le TOML par défaut ; convertit l'ancien schéma (podman)
  migrer-podman      reprend les données d'un ancien conteneur podman (une fois)
  faire-de-la-place  endort les autres conteneurs endormables si la mémoire manque pour une synthèse (JSON)
  interface          construit l'interface native dans le LXC et pose son vhost (administrateurs)
  pare-feu-ferme     retire la règle d'entrée du port 3900 (désinstallation)
  basculer           arrête l'ancien podman, reprend ses données, démarre le LXC (une fois)
  status [--json]    état du LXC, du moteur, de la configuration
  start | stop | restart
  wake | sleep       appelés par le mandataire (ne touchent pas à la socket)
  logs [N]           journal du moteur (dans le LXC)
  cle | init         affiche / crée la clé d'API
  cle-renouveler     nouvelle clé, poussée dans le LXC ; l'affiche UNE fois
  config <clé> <v>   asr | memoire | cpu_poids | mode | inactivite_s
  publier IP…        adresses de l'hôte où publier le port 3900
  appliquer          réécrit LXC + mandataire depuis le TOML (idempotent)
  sauvegarde         archive des données (sans les modèles téléchargeables)
  api                entrée du panneau : JSON sur stdin, JSON sur stdout (actions en liste blanche)
"""
from __future__ import annotations

import contextlib
import fcntl
import functools
import io
import ipaddress
import json
import os
import re
import secrets
import shlex
import shutil
import socket
import sqlite3
import subprocess
import sys
import tarfile
import tempfile
import time
import tomllib
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

CONF = Path(os.environ.get("SECUBOX_VS_CONF", "/etc/secubox/voicestudio.toml"))
CONF_DEFAUT = Path(os.environ.get("SECUBOX_VS_CONF_DEFAUT", "/usr/share/secubox/voicestudio/voicestudio.toml"))
CLE = Path(os.environ.get("SECUBOX_VS_CLE", "/etc/secubox/secrets/voicestudio/api_key"))
ETAT = Path(os.environ.get("SECUBOX_VS_ETAT", "/var/lib/secubox/voicestudio/etat.json"))
MARQUE_INSTALLE = Path(os.environ.get("SECUBOX_VS_MARQUE", "/var/lib/secubox/voicestudio/.lxc-provisioned"))
AUDIT = Path(os.environ.get("SECUBOX_VS_AUDIT", "/var/log/secubox/audit.log"))
INSTALL = os.environ.get("SECUBOX_VS_INSTALL", "/usr/lib/secubox/voicestudio/install-lxc.sh")
SAUVEGARDES = Path(os.environ.get("SECUBOX_VS_SAUVEGARDES", "/var/backups/secubox/voicestudio"))
DROPIN_DIR = Path(os.environ.get("SECUBOX_VS_DROPIN", "/etc/systemd/system"))
NFT_DIR = Path(os.environ.get("SECUBOX_VS_NFT_DIR", "/etc/nftables.d"))
MARQUE_RAPIDE = Path(os.environ.get("SECUBOX_VS_MARQUE_RAPIDE", "/var/lib/secubox/voicestudio/.voix-rapide"))
MARQUE_INTERFACE = Path(os.environ.get("SECUBOX_VS_MARQUE_INTERFACE", "/var/lib/secubox/voicestudio/.interface-native"))
NGINX_DISPO = Path(os.environ.get("SECUBOX_VS_NGINX_DISPO", "/etc/nginx/sites-available"))
NGINX_ACTIF = Path(os.environ.get("SECUBOX_VS_NGINX_ACTIF", "/etc/nginx/sites-enabled"))
SNIPPET_CLE = Path(os.environ.get("SECUBOX_VS_SNIPPET_CLE", "/etc/nginx/snippets/secubox-voicestudio-cle.conf"))
VHOST_FICHIER = "secubox-voicestudio.conf"
RE_DOMAINE = re.compile(r"^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$")
RE_CLE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
MEMINFO = Path(os.environ.get("SECUBOX_VS_MEMINFO", "/proc/meminfo"))
SWAPS = Path(os.environ.get("SECUBOX_VS_SWAPS", "/proc/swaps"))
CGROUP_LXC = Path(os.environ.get("SECUBOX_VS_CGROUP", "/sys/fs/cgroup"))
SOMMEILLEUX = Path(os.environ.get("SECUBOX_VS_SOMMEILLEUX", "/etc/secubox/health/sleepable-modules.json"))
PLACE_ETAT = Path(os.environ.get("SECUBOX_VS_PLACE", "/var/lib/secubox/voicestudio/place.json"))
PROFILECTL = "/usr/sbin/secubox-profilectl"
MAX_ENDORMIS = 4                      # au plus 4 conteneurs par salve : la requête de l'usager attend
NFT_FICHIER = "zz-secubox-voicestudio.nft"
NFT_ETIQUETTE = "secubox-voicestudio"
NFT_TABLES = ("filter", "secubox_filter")     # base unifiée (#1306) ; ancien nom encore chargé sur les box non redémarrées
SOURCES_ADMISES = "{ 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }"
VOLUME_PODMAN = Path("/var/lib/containers/storage/volumes/secubox-voicestudio-data/_data")
PORT = 3900
UID_BASE = int(os.environ.get("SECUBOX_LXC_ROOT_UID", "100000"))
DELAI_REVEIL = int(os.environ.get("SECUBOX_VS_DELAI", "240"))
SAUVEGARDES_GARDEES = 3
VERROU = Path(os.environ.get("SECUBOX_VS_VERROU", "/run/lock/voicestudioctl.lock"))
UNITE_PROVISION = "secubox-voicestudio-provision.service"
CACHE_TAILLE_S = 300

# Modèles de reconnaissance admis : liste blanche (la valeur finit dans un fichier
# d'environnement lu par systemd — jamais une chaîne libre).
ASR_ADMIS = frozenset({
    "Systran/faster-whisper-tiny", "Systran/faster-whisper-tiny.en",
    "Systran/faster-whisper-base", "Systran/faster-whisper-base.en",
    "Systran/faster-whisper-small", "Systran/faster-whisper-small.en",
    "Systran/faster-whisper-medium", "Systran/faster-whisper-large-v3",
})
MODES = ("permanent", "demande")
MARQUE_DEBUT = "# >>> secubox-voicestudio"
MARQUE_FIN = "# <<< secubox-voicestudio"
SOCKET_PUB = "secubox-voicestudio-pub.socket"
SERVICE_PUB = "secubox-voicestudio-pub.service"
ANCIENNE_UNITE = "secubox-voicestudio.service"      # l'exécuteur podman d'avant 0.2.0


def _run(*args, **kw):
    """subprocess.run résolu À L'APPEL (et non figé en valeur par défaut) : un test, ou un
    appelant, peut substituer subprocess.run sans toucher aux signatures."""
    return subprocess.run(*args, **kw)


class Erreur(Exception):
    """Erreur d'usage ou d'état : message pour l'opérateur, code de sortie 1."""


# ── exclusion mutuelle ───────────────────────────────────────────────────────
_PROFONDEUR = 0


def sous_verrou(bloquant: bool = False):
    """Une seule commande qui MODIFIE l'état à la fois (install, config, bascule, démarrage…).

    Sans cela, le bouton « Installer » (ou deux administrateurs) lançait un second `install-lxc.sh`
    pendant les 10 à 25 min du premier, et le TOML était réécrit par deux commandes à la fois.
    Non bloquant : la seconde commande échoue tout de suite en le disant. `wake` / `sleep` (appelés
    par le mandataire) attendent leur tour. Ré-entrant : install → basculer → start ne se bloquent
    pas eux-mêmes."""
    def deco(fn):
        @functools.wraps(fn)
        def enveloppe(*a, **kw):
            global _PROFONDEUR
            if _PROFONDEUR:
                return fn(*a, **kw)
            VERROU.parent.mkdir(parents=True, exist_ok=True)
            fd = os.open(VERROU, os.O_RDWR | os.O_CREAT, 0o600)
            try:
                try:
                    fcntl.flock(fd, fcntl.LOCK_EX | (0 if bloquant else fcntl.LOCK_NB))
                except BlockingIOError as e:
                    raise Erreur("une autre opération voicestudioctl est en cours — réessayez plus tard") from e
                _PROFONDEUR = 1
                try:
                    return fn(*a, **kw)
                finally:
                    _PROFONDEUR = 0
            finally:
                os.close(fd)
        return enveloppe
    return deco


# ── configuration ────────────────────────────────────────────────────────────
def adresse_publiable(a: str) -> str:
    """Une adresse de publication est une IPv4 NON publique (LAN, maillage, CGNAT) : « jamais le WAN »
    se vérifie ici, pas seulement dans la documentation. Le trafic y circule en HTTP clair (clé en
    Bearer) : réseau de confiance seulement."""
    try:
        ip = ipaddress.IPv4Address(a)
    except ValueError as e:
        raise Erreur(f"{a[:40]} n'est pas une adresse IPv4") from e
    if ip.is_global or ip.is_unspecified or ip.is_multicast:
        raise Erreur(f"{a} n'est pas une adresse privée (LAN, maillage) : jamais publiée sur le WAN")
    return str(ip)


def charger() -> dict:
    """TOML de la box par-dessus celui du paquet : une clé absente retombe sur le défaut."""
    base: dict = {}
    for f in (CONF_DEFAUT, CONF):
        try:
            with open(f, "rb") as h:
                for sec, vals in tomllib.load(h).items():
                    base.setdefault(sec, {}).update(vals)
        except FileNotFoundError:
            continue
        except OSError as e:
            raise Erreur(f"{f} illisible : {e.strerror}") from e
        except tomllib.TOMLDecodeError as e:
            # Retomber en silence sur les défauts effacerait les adresses publiées au prochain
            # `appliquer` — donc l'accès des autres box au moteur. On s'arrête et on le dit.
            raise Erreur(f"{f} invalide : {e}") from e
    return base


def lxc_nom(c: dict) -> str:
    return str(c.get("lxc", {}).get("nom", "voicestudio"))


def lxc_chemin(c: dict) -> str:
    return str(c.get("lxc", {}).get("chemin", "/data/lxc"))


def lxc_ip(c: dict) -> str:
    return str(c.get("lxc", {}).get("ip", "10.100.0.230"))


def donnees(c: dict) -> Path:
    return Path(str(c.get("lxc", {}).get("donnees", "/srv/secubox/voicestudio")))


def ecrire_toml(section: str, cle: str, valeur, chemin: Path | None = None) -> None:
    """Remplace `cle` dans `[section]` ; l'ajoute (ou crée la section) si absente. On ne réécrit
    que la ligne concernée : commentaires et ordre du fichier préservés."""
    chemin = chemin or CONF
    texte = chemin.read_text() if chemin.exists() else ""
    if isinstance(valeur, bool):             # AVANT int : bool en est un, et « False » n'est pas du TOML
        rendu = "true" if valeur else "false"
    elif isinstance(valeur, list):
        rendu = "[" + ", ".join(json.dumps(v) for v in valeur) + "]"
    elif isinstance(valeur, int):
        rendu = str(valeur)
    else:
        rendu = json.dumps(valeur)
    nouvelle = f"{cle} = {rendu}"
    lignes = texte.split("\n") if texte else []

    def entete(i: int) -> str | None:
        m = re.match(r"\s*\[([^\]]+)\]\s*$", lignes[i])
        return m.group(1) if m else None

    debut_s = next((i for i in range(len(lignes)) if entete(i) == section), None)
    if debut_s is None:
        lignes += ["", f"[{section}]", nouvelle]
    else:
        fin_s = next((i for i in range(debut_s + 1, len(lignes)) if entete(i) is not None), len(lignes))
        cible = next((i for i in range(debut_s + 1, fin_s)
                      if re.match(rf"\s*{re.escape(cle)}\s*=", lignes[i])), None)
        if cible is not None:
            lignes[cible] = nouvelle
        else:
            fin_utile = fin_s
            while fin_utile > debut_s + 1 and not lignes[fin_utile - 1].strip():
                fin_utile -= 1
            lignes.insert(fin_utile, nouvelle)
    chemin.parent.mkdir(parents=True, exist_ok=True)
    tmp = chemin.with_name(chemin.name + ".tmp")
    tmp.write_text("\n".join(lignes).rstrip("\n") + "\n")
    os.chmod(tmp, 0o640)
    os.replace(tmp, chemin)
    if chemin == CONF:
        _groupe_secubox(chemin)


# ── audit ────────────────────────────────────────────────────────────────────
def audit(action: str, detail: str = "") -> None:
    """Une décision de sécurité = une ligne (append-only). Un échec d'écriture ne bloque pas
    l'action mais le dit : un journal muet serait un trou de preuve."""
    qui = os.environ.get("SUDO_USER") or os.environ.get("USER") or "root"
    ligne = f"{datetime.now(timezone.utc).isoformat()} voicestudio {action} {detail} by={qui}\n"
    try:
        AUDIT.parent.mkdir(parents=True, exist_ok=True)
        with open(AUDIT, "a") as f:
            f.write(ligne)
    except OSError as e:
        print(f"voicestudioctl : audit non écrit ({e})", file=sys.stderr)


# ── LXC ──────────────────────────────────────────────────────────────────────
def lxc(run, c: dict, *args: str, **kw):
    return run(["lxc-" + args[0], "-P", lxc_chemin(c), "-n", lxc_nom(c), *args[1:]],
               capture_output=True, text=True, timeout=kw.get("timeout", 60))


def etat_lxc(c: dict, run=_run) -> str:
    r = lxc(run, c, "info", "-sH")
    s = (r.stdout or "").strip()
    return s if r.returncode == 0 and s else "ABSENT"


def sante(c: dict, delai: float = 3.0, opener=urllib.request.urlopen) -> dict:
    """/health du moteur. 503 pendant le chargement initial est un état, pas une panne."""
    url = f"http://{lxc_ip(c)}:{PORT}/health"
    try:
        with opener(url, timeout=delai) as r:
            return {"ok": r.status == 200, "http": r.status}
    except urllib.error.HTTPError as e:
        return {"ok": False, "http": e.code, "demarrage": e.code == 503}
    except (OSError, ValueError):
        return {"ok": False, "http": 0}


def contenu_env_moteur(c: dict) -> str:
    """Fichier d'environnement du moteur dans le LXC : la clé d'API ET les réglages mémoire (tous validés — ils finissent
    dans un EnvironmentFile systemd). Changer l'un d'eux relance le moteur (assurer_moteur compare le contenu)."""
    m = c.get("moteur", {})
    try:
        liberation = int(m.get("liberation_modele_s", 60))
    except (TypeError, ValueError):
        liberation = 60
    liberation = min(max(liberation, 10), 86400)
    dictee = "1" if m.get("precharger_dictee", False) is True else "0"
    filigrane = "1" if m.get("precharger_filigrane", False) is True else "0"
    return (f"OMNIVOICE_API_KEY={lire_cle()}\n"
            f"OMNIVOICE_IDLE_TIMEOUT_S={liberation}\n"
            f"OMNIVOICE_PRELOAD_CAPTURE_ASR={dictee}\n"
            f"OMNIVOICE_PRELOAD_WATERMARK={filigrane}\n")


def assurer_moteur(c: dict, run=_run, dormir=time.sleep) -> bool:
    """Dans le LXC : écrit la clé d'API si elle a changé (et redémarre le moteur), ou démarre le
    moteur s'il est arrêté. Un seul passage couvre : première mise en service, réveil d'un LXC qui
    dormait pendant un renouvellement de clé, moteur tombé. La clé voyage par stdin (jamais en
    argument : `ps` la montrerait). Rend True si le LXC a répondu."""
    creer_cle()
    script = ("umask 077; cat > /etc/voicestudio.env.new; "
              "if ! cmp -s /etc/voicestudio.env.new /etc/voicestudio.env; then "
              "mv /etc/voicestudio.env.new /etc/voicestudio.env; systemctl restart voicestudio.service; "
              "systemctl try-restart voicestudio-rapide.service; "
              "else rm -f /etc/voicestudio.env.new; "
              "systemctl is-active -q voicestudio.service || systemctl start voicestudio.service; fi; "
              # La voix rapide est un second service du même LXC : s'il est installé, il tourne (jamais d'erreur s'il ne l'est pas).
              "if [ -f /etc/systemd/system/voicestudio-rapide.service ]; then "
              "systemctl is-active -q voicestudio-rapide.service || systemctl start voicestudio-rapide.service; fi")
    for _ in range(10):                  # lxc-attach peut échouer quelques secondes après lxc-start
        r = run(["lxc-attach", "-P", lxc_chemin(c), "-n", lxc_nom(c), "--", "sh", "-c", script],
                input=contenu_env_moteur(c), capture_output=True, text=True, timeout=60)
        if r.returncode == 0:
            return True
        dormir(2)
    print("voicestudioctl : le LXC ne répond pas à lxc-attach — clé et moteur non vérifiés", file=sys.stderr)
    return False


def reveiller(c: dict, run=_run, sante_fn=None, dormir=time.sleep,
              maintenant=time.monotonic, delai: int | None = None, preparer=None) -> bool:
    sante_fn = sante_fn or sante
    preparer = preparer or assurer_moteur
    delai = DELAI_REVEIL if delai is None else delai
    if sante_fn(c)["ok"]:
        return True
    etat = etat_lxc(c, run)
    if etat == "ABSENT":
        print("voicestudioctl : LXC absent — voicestudioctl install", file=sys.stderr)
        return False
    if etat != "RUNNING":
        r = lxc(run, c, "start", "-d")
        if r.returncode != 0:
            print("voicestudioctl : lxc-start a échoué : " + (r.stderr or "")[:200], file=sys.stderr)
            return False
    # Le LXC tourne, mais le moteur n'est pas forcément lancé (première installation) ni à jour de
    # la clé (renouvelée pendant le sommeil) : on s'en assure AVANT d'attendre /health.
    preparer(c, run, dormir)
    debut = maintenant()
    while maintenant() - debut < delai:
        # /health répond 503 pendant le chargement, puis 200.
        if sante_fn(c)["ok"]:
            return True
        dormir(2)
    print(f"voicestudioctl : le moteur ne répond pas après {delai} s", file=sys.stderr)
    return False


def endormir(c: dict, run=_run) -> bool:
    if etat_lxc(c, run) != "RUNNING":
        return True
    return lxc(run, c, "stop", "-t", "30", timeout=60).returncode == 0


# ── clé d'API ────────────────────────────────────────────────────────────────
def lire_cle() -> str:
    try:
        return CLE.read_text().strip()
    except OSError:
        return ""


def creer_cle() -> bool:
    if lire_cle():
        return False
    CLE.parent.mkdir(parents=True, exist_ok=True)
    os.chmod(CLE.parent, 0o700)
    ecrire_cle(secrets.token_urlsafe(32))
    return True


def ecrire_cle(valeur: str) -> None:
    tmp = CLE.with_name(CLE.name + ".tmp")
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(valeur + "\n")
    os.replace(tmp, CLE)


def pousser_cle(c: dict, run=_run) -> bool:
    """Pousse la clé dans le LXC et relance le moteur si elle a changé. LXC arrêté : remis à plus
    tard (le réveil suivant passe par assurer_moteur). Rend True si poussée."""
    if etat_lxc(c, run) != "RUNNING":
        return False
    if not lire_cle():
        raise Erreur("aucune clé d'API — voicestudioctl init")
    return assurer_moteur(c, run)


# ── appliquer la configuration ───────────────────────────────────────────────
def bloc_lxc(c: dict) -> str:
    sec = c.get("lxc", {})
    auto = 1 if sec.get("mode", "permanent") == "permanent" else 0
    return (f"{MARQUE_DEBUT}\nlxc.start.auto = {auto}\n"
            f"lxc.cgroup2.memory.max = {sec.get('memoire', '4G')}\n"
            f"lxc.cgroup2.cpu.weight = {int(sec.get('cpu_poids', 50))}\n{MARQUE_FIN}\n")


def appliquer_lxc(c: dict) -> bool:
    f = Path(lxc_chemin(c)) / lxc_nom(c) / "config"
    if not f.exists():
        return False
    t = f.read_text()
    bloc = bloc_lxc(c)
    if MARQUE_DEBUT in t and MARQUE_FIN in t:
        avant, reste = t.split(MARQUE_DEBUT, 1)
        _, apres = reste.split(MARQUE_FIN, 1)
        t = avant + bloc.rstrip("\n") + apres
    else:
        t = t.rstrip("\n") + "\n\n" + bloc
    f.write_text(t)
    return True


def contenu_socket(c: dict) -> str:
    hotes = []
    for h in c.get("reseau", {}).get("publier", []):
        try:
            hotes.append(adresse_publiable(str(h)))
        except Erreur as e:
            print(f"voicestudioctl : adresse publiée ignorée — {e}", file=sys.stderr)
    hotes.append("127.0.0.1")
    lignes = "".join(f"ListenStream={h}:{PORT}\n" for h in dict.fromkeys(hotes))
    return f"[Socket]\nListenStream=\n{lignes}FreeBind=true\n"


def contenu_service(c: dict) -> str:
    """Drop-in du mandataire : en « demande », il s'arrête au repos et rendort le LXC ; en
    « permanent », il tourne sans délai et ne touche jamais au LXC."""
    cible = f"{lxc_ip(c)}:{PORT}"
    proxy = "/lib/systemd/systemd-socket-proxyd"
    if c.get("lxc", {}).get("mode", "permanent") == "demande":
        idle = int(c["lxc"].get("inactivite_s", 900))
        return ("[Service]\nExecStart=\n"
                f"ExecStart={proxy} --exit-idle-time={idle}s {cible}\n"
                "ExecStopPost=\nExecStopPost=+/usr/sbin/voicestudioctl sleep\n")
    return f"[Service]\nExecStart=\nExecStart={proxy} {cible}\nExecStopPost=\n"


def ecrire_dropin(unite: str, nom: str, contenu: str) -> bool:
    d = DROPIN_DIR / f"{unite}.d"
    f = d / nom
    if f.exists() and f.read_text() == contenu:
        return False
    d.mkdir(parents=True, exist_ok=True)
    tmp = f.with_name(f.name + ".tmp")
    tmp.write_text(contenu)
    os.chmod(tmp, 0o644)
    os.replace(tmp, f)
    return True


def systemctl(run, *args: str):
    return run(["systemctl", *args], capture_output=True, text=True, timeout=60)


# ── pare-feu ─────────────────────────────────────────────────────────────────
# La chaîne d'entrée de la base est en DROP. L'ancien moteur passait par la redirection de podman
# (forward), qui CONTOURNE `input` ; le mandataire, lui, écoute sur l'hôte : sans règle explicite,
# les paquets du LAN et du maillage sont jetés (vu sur gk3 : gk2 n'atteignait plus le moteur).
def regle_nft(c: dict) -> str | None:
    """Une règle : seulement les adresses PUBLIÉES, seulement des sources privées (LAN, maillage,
    CGNAT exclu à dessein). Rien de publié = aucune règle (le loopback est déjà accepté)."""
    hotes = []
    for h in c.get("reseau", {}).get("publier", []):
        try:
            hotes.append(adresse_publiable(str(h)))
        except Erreur:
            continue                      # déjà signalée par contenu_socket
    if not hotes:
        return None
    return (f"ip daddr {{ {', '.join(sorted(set(hotes)))} }} ip saddr {SOURCES_ADMISES} "
            f'tcp dport {PORT} accept comment "{NFT_ETIQUETTE}"')


def contenu_nft(c: dict) -> str:
    """Fichier chargé au démarrage par /etc/nftables.conf (include /etc/nftables.d/*.nft, APRÈS la
    base). La table et la chaîne sont déclarées d'abord, de façon additive : le fichier ne peut pas
    faire échouer le chargement de tout le jeu de règles si la base n'est pas encore là."""
    regle = regle_nft(c)
    entete = ("# SecuBox VoiceStudio — GÉNÉRÉ par voicestudioctl (ne pas éditer : réécrit à chaque `appliquer`).\n"
              "# Ouvre le port 3900 aux seules adresses publiées, depuis le LAN et le maillage (jamais le WAN).\n")
    if regle is None:
        return entete + "# Aucune adresse publiée : rien à ouvrir.\n"
    return (entete + "table inet filter {\n  chain input {\n  }\n}\n"
            f"insert rule inet filter input {regle}\n")


def _table_entree(run) -> str | None:
    for t in NFT_TABLES:
        r = run(["nft", "list", "chain", "inet", t, "input"], capture_output=True, text=True, timeout=15)
        if r.returncode == 0:
            return t
    return None


def supprimer_regles_etiquetees(table: str, chaine: str, run=_run, etiquette: str = NFT_ETIQUETTE) -> int:
    """Retire les règles portant notre commentaire, par poignée (jamais un `flush` : la chaîne appartient
    à tout le monde). Rend le nombre de règles retirées."""
    r = run(["nft", "-a", "list", "chain", "inet", table, chaine], capture_output=True, text=True, timeout=15)
    if r.returncode != 0:
        return 0
    n = 0
    for ligne in (r.stdout or "").splitlines():
        m = re.search(rf'comment "{re.escape(etiquette)}".*# handle (\d+)\s*$', ligne)
        if m:
            run(["nft", "delete", "rule", "inet", table, chaine, "handle", m.group(1)],
                capture_output=True, text=True, timeout=15)
            n += 1
    return n


def appliquer_nft(c: dict, run=_run) -> dict:
    """Fichier persistant + règle vivante, idempotents : l'ancienne règle est retirée avant la nouvelle,
    donc changer `publier` ne laisse jamais d'adresse ouverte derrière soi."""
    res = {"fichier": ecrire_fichier(NFT_DIR / NFT_FICHIER, contenu_nft(c)), "vivante": False}
    try:
        table = _table_entree(run)
        if table is None:
            print("voicestudioctl : pas de chaîne d'entrée nft trouvée — règle vivante non posée "
                  "(le fichier sera chargé au prochain démarrage du pare-feu)", file=sys.stderr)
            return res
        supprimer_regles_etiquetees(table, "input", run)
        regle = regle_nft(c)
        if regle:
            # shlex : le commentaire garde ses guillemets d'un seul tenant ; jamais de shell.
            r = run(["nft", "insert", "rule", "inet", table, "input", *shlex.split(regle)],
                    capture_output=True, text=True, timeout=15)
            if r.returncode != 0:
                print("voicestudioctl : règle nft refusée : " + (r.stderr or "")[:200], file=sys.stderr)
                return res
        res["vivante"] = True
    except OSError as e:
        print(f"voicestudioctl : nft indisponible ({e.strerror}) — règle vivante non posée", file=sys.stderr)
    return res


def ecrire_fichier(chemin: Path, contenu: str) -> bool:
    if chemin.exists() and chemin.read_text() == contenu:
        return False
    chemin.parent.mkdir(parents=True, exist_ok=True)
    tmp = chemin.with_name(chemin.name + ".tmp")
    tmp.write_text(contenu)
    os.chmod(tmp, 0o644)
    os.replace(tmp, chemin)
    return True


@sous_verrou()
def cmd_interface_ferme(run=_run) -> int:
    """Retire le vhost de l'interface native, la clé nginx et le lien (désinstallation)."""
    existait = retirer_vhost(run)
    MARQUE_INTERFACE.unlink(missing_ok=True)
    audit("interface-ferme", "vhost-retire" if existait else "rien-a-retirer")
    print("voicestudioctl : interface native retirée du frontal")
    return 0


@sous_verrou()
def cmd_pare_feu_ferme(run=_run) -> int:
    """Retire règle vivante et fichier (désinstallation) : plus aucune ouverture du port 3900."""
    n = 0
    for t in NFT_TABLES:
        n += supprimer_regles_etiquetees(t, "input", run)
    (NFT_DIR / NFT_FICHIER).unlink(missing_ok=True)
    audit("pare-feu-ferme", f"{n}-regle(s)")
    print(f"voicestudioctl : {n} règle(s) retirée(s)")
    return 0


# ── interface native : vhost, clé posée par nginx, garde administrateur ───────────────────────────────────────────
# L'interface native (le studio complet) est servie par le moteur à la RACINE d'un domaine : elle suppose `/` et des
# chemins absolus (/assets, /v1, /ws). Elle a donc SON domaine : voicestudio.<domaine de la box>. nginx y pose :
#   1. une GARDE : auth_request vers /gate du module (require_jwt) — seul un administrateur réel passe ;
#   2. la CLÉ du moteur (Authorization: Bearer …), lue dans un snippet root 0600 : le navigateur ne la voit jamais,
#      et toute clé qu'il enverrait est écrasée ;
#   3. aucun cookie vers le moteur : celui de SecuBox ne doit jamais atteindre un processus tiers (un moteur compromis
#      ne pourrait pas rejouer une session d'administrateur).
def domaine_box(c: dict, run=_run) -> str:
    """Le nom de CETTE box pour ce service (`secubox-domaine`) ; vide si inconnu — alors rien n'est déclaré."""
    prefixe = str(c.get("interface", {}).get("domaine", "voicestudio"))
    try:
        r = run(["secubox-domaine", prefixe], capture_output=True, text=True, timeout=15)
    except OSError:
        return ""
    d = (r.stdout or "").strip().lower()
    return d if r.returncode == 0 and RE_DOMAINE.fullmatch(d) else ""


def contenu_snippet_cle() -> str | None:
    cle = lire_cle()
    if not RE_CLE.fullmatch(cle):
        return None                       # jamais une valeur inattendue dans un fichier nginx
    return ("# GÉNÉRÉ par voicestudioctl (root 0600) : la clé du moteur, posée par nginx. Ne jamais l'afficher ni la copier.\n"
            f'proxy_set_header Authorization "Bearer {cle}";\n')


def contenu_vhost(c: dict, domaine: str, domaine_admin: str) -> str:
    ip = lxc_ip(c)
    if not re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", ip):
        raise Erreur(f"[lxc] ip invalide : {ip[:40]}")
    connexion = f"https://{domaine_admin}/login.html" if domaine_admin else "la page de connexion de l'administration"
    return f"""# GÉNÉRÉ par voicestudioctl — ne pas éditer (réécrit à chaque `appliquer`). Voir packages/secubox-voicestudio.
# Interface native de VoiceStudio, réservée aux administrateurs. nginx: 9080, derrière HAProxy puis le WAF.
map $http_upgrade $sbx_vs_connexion {{ default upgrade; '' close; }}
# Le moteur compare l'Origin du navigateur à SON schéma (http, derrière HAProxy) : sans cela, les WebSocket (dictée,
# événements) sont refusés en 403. Seule l'origine EXACTE de ce vhost est ramenée à http ; toute autre reste telle quelle
# (donc refusée) : la protection contre une page tierce qui utiliserait la session de l'administrateur est conservée.
map $http_origin $sbx_vs_origine {{ default $http_origin; "https://{domaine}" "http://{domaine}"; }}

server {{
    listen 9080;
    server_name {domaine};

    location ^~ /.well-known/acme-challenge/ {{ root /usr/share/secubox/www; try_files $uri =404; }}

    # RÉSEAU LOCAL SEULEMENT. Le studio natif fait des traitements de plusieurs minutes (le WAF public coupe à 120 s)
    # et ses envois sont volumineux : il n'a rien à faire sur Internet. $lan_client (secubox-hub) se fie à l'adresse
    # réelle du client — jamais à un en-tête que le client peut écrire — et vaut 0 pour tout ce qui vient du maillage
    # ou d'Internet (donc du relais de gk2). La garde administrateur ci-dessous s'applique EN PLUS.
    set $sbx_vs_lan $lan_client;
    if ($sbx_vs_lan != 1) {{ return 403 "Interface réservée au réseau local.\n"; }}

    # GARDE : le module répond 204 à un administrateur réel (require_jwt), 401/403 sinon. Les en-têtes de la requête
    # d'origine (cookie de session, Authorization) sont transmis à CE sous-appel, pas au moteur.
    location = /__sbx_voicestudio_garde {{
        internal;
        proxy_pass http://unix:/run/secubox/voicestudio.sock:/gate;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header Host $host;
        proxy_set_header X-SecuBox-LAN $lan_client;
        proxy_set_header X-Original-URI $request_uri;
    }}

    location @voicestudio_refus {{
        default_type text/plain;
        return 401 "Connexion administrateur requise : {connexion}\n";
    }}

    location / {{
        auth_request /__sbx_voicestudio_garde;
        error_page 401 403 = @voicestudio_refus;

        # La clé du moteur : posée ICI, après la garde. Écrase tout en-tête Authorization du navigateur.
        include {SNIPPET_CLE};
        # Aucun cookie vers le moteur (celui de SecuBox ne sort pas de la box ni n'atteint un processus tiers).
        proxy_set_header Cookie "";

        proxy_pass http://{ip}:{PORT};
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Origin $sbx_vs_origine;
        proxy_set_header Connection $sbx_vs_connexion;

        # Doublage, clonage, bibliothèque : envois audio/vidéo volumineux, traitements longs, flux d'événements.
        client_max_body_size 2g;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }}

    access_log /var/log/nginx/voicestudio_access.log;
    error_log  /var/log/nginx/voicestudio_error.log;
}}
"""


def recharger_nginx(run=_run) -> bool:
    """`nginx -t` AVANT de recharger : une configuration fautive ne doit jamais être chargée (ni laisser le frontal en panne)."""
    try:
        t = run(["nginx", "-t"], capture_output=True, text=True, timeout=30)
        if t.returncode != 0:
            print("voicestudioctl : nginx -t échoue, configuration NON rechargée : " + (t.stderr or "")[-300:], file=sys.stderr)
            return False
        return run(["systemctl", "reload", "nginx"], capture_output=True, text=True, timeout=30).returncode == 0
    except OSError as e:
        print(f"voicestudioctl : nginx indisponible ({e.strerror})", file=sys.stderr)
        return False


def retirer_vhost(run=_run) -> bool:
    lien = NGINX_ACTIF / VHOST_FICHIER
    existait = lien.is_symlink() or lien.exists()
    lien.unlink(missing_ok=True)
    (NGINX_DISPO / VHOST_FICHIER).unlink(missing_ok=True)
    SNIPPET_CLE.unlink(missing_ok=True)
    if existait:
        recharger_nginx(run)
    return existait


def appliquer_vhost(c: dict, run=_run) -> dict:
    """Pose (ou retire) le vhost de l'interface native. Idempotent ; n'écrit rien tant que l'interface n'est pas
    construite, que le domaine de la box est inconnu, ou que la clé n'a pas la forme attendue."""
    res = {"actif": False, "domaine": ""}
    if not c.get("interface", {}).get("activer", True) or not MARQUE_INTERFACE.exists():
        retirer_vhost(run)
        return res
    domaine = domaine_box(c, run)
    if not domaine:
        print("voicestudioctl : domaine de la box inconnu (secubox-domaine) — vhost de l'interface native non posé",
              file=sys.stderr)
        return res
    snippet = contenu_snippet_cle()
    if snippet is None:
        print("voicestudioctl : clé du moteur absente ou de forme inattendue — vhost non posé (voicestudioctl init)", file=sys.stderr)
        return res
    admin = ""
    try:
        r = run(["secubox-domaine", "admin"], capture_output=True, text=True, timeout=15)
        admin = (r.stdout or "").strip().lower() if r.returncode == 0 else ""
        admin = admin if RE_DOMAINE.fullmatch(admin) else ""
    except OSError:
        pass
    SNIPPET_CLE.parent.mkdir(parents=True, exist_ok=True)
    cle_changee = not (SNIPPET_CLE.exists() and SNIPPET_CLE.read_text() == snippet)
    if cle_changee:
        tmp = SNIPPET_CLE.with_name(SNIPPET_CLE.name + ".tmp")
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(snippet)
        os.replace(tmp, SNIPPET_CLE)
    vhost_change = ecrire_fichier(NGINX_DISPO / VHOST_FICHIER, contenu_vhost(c, domaine, admin))
    lien = NGINX_ACTIF / VHOST_FICHIER
    NGINX_ACTIF.mkdir(parents=True, exist_ok=True)
    if not lien.is_symlink():
        lien.unlink(missing_ok=True)
        lien.symlink_to(os.path.relpath(NGINX_DISPO / VHOST_FICHIER, NGINX_ACTIF))
        vhost_change = True
    if cle_changee or vhost_change:
        if not recharger_nginx(run):
            # Jamais de vhost fautif laissé en place : on le retire, le frontal reste sain.
            lien.unlink(missing_ok=True)
            res["erreur"] = "nginx a refusé la configuration"
            return res
    res.update(actif=True, domaine=domaine)
    return res


def appliquer(run=_run) -> dict:
    """Réécrit les réglages du LXC et les drop-ins du mandataire depuis le TOML (idempotent).

    Le socket n'est relancé que s'il est DÉJÀ actif : l'installer ouvrirait le port 3900 sans moteur
    derrière, ou échouerait tant que l'ancien moteur (podman) tient le port. Le mandataire, lui,
    est relancé quand le mode change : sans délai d'inactivité (permanent) il ne s'arrête jamais
    de lui-même, et garderait l'ancien réglage."""
    c = charger()
    res = {"lxc": appliquer_lxc(c)}
    adresses = ecrire_dropin(SOCKET_PUB, "adresses.conf", contenu_socket(c))
    mode = ecrire_dropin(SERVICE_PUB, "mode.conf", contenu_service(c))
    res["mandataire"] = adresses or mode
    res["pare_feu"] = appliquer_nft(c, run)
    res["interface"] = appliquer_vhost(c, run)
    # Moteur déjà en marche : ses réglages (clé, libération du modèle, préchargements) sont poussés MAINTENANT — assurer_moteur
    # compare le contenu et ne relance le moteur que s'il a changé. Sans cela une montée de version laissait l'ancien
    # environnement jusqu'au prochain démarrage.
    if etat_lxc(c, run) == "RUNNING" and lire_cle():
        res["moteur_a_jour"] = assurer_moteur(c, run)
    if adresses or mode:
        systemctl(run, "daemon-reload")
        if adresses and systemctl(run, "is-active", SOCKET_PUB).stdout.strip() == "active":
            r = systemctl(run, "restart", SOCKET_PUB)
            if r.returncode != 0:
                print("voicestudioctl : le mandataire n'a pas pu être relancé — port 3900 tenu par un autre "
                      "processus ? " + (r.stderr or "")[:160], file=sys.stderr)
                res["mandataire_ok"] = False
        if mode:
            systemctl(run, "try-restart", SERVICE_PUB)
    return res


# ── état ─────────────────────────────────────────────────────────────────────
def taille_mo(chemin: Path) -> float:
    total = 0
    try:
        for racine, _, fichiers in os.walk(chemin):
            for f in fichiers:
                try:
                    total += os.lstat(os.path.join(racine, f)).st_size
                except OSError:
                    continue
    except OSError:
        return 0.0
    return round(total / 1048576, 1)


# ── mémoire de la box : faire de la place avant une synthèse ─────────────────────────────────────────────────────────
def lire_meminfo() -> dict:
    """MemAvailable / SwapFree / SwapTotal en Mo, lus dans /proc/meminfo (lecture seule, sans privilège)."""
    out: dict = {}
    try:
        for ligne in MEMINFO.read_text().splitlines():
            cle, _, reste = ligne.partition(":")
            if cle in ("MemAvailable", "SwapFree", "SwapTotal", "MemTotal"):
                out[cle] = int(reste.split()[0]) // 1024
    except (OSError, ValueError, IndexError):
        pass
    return out


def memoire_cgroup_mo(nom: str) -> int:
    """Mémoire courante d'un conteneur LXC (cgroup v2), en Mo ; 0 si illisible."""
    try:
        return int((CGROUP_LXC / f"lxc.payload.{nom}" / "memory.current").read_text()) // 1048576
    except (OSError, ValueError):
        return 0


def conteneurs_endormables(c: dict, run=_run) -> list[tuple[str, int]]:
    """Les AUTRES conteneurs en marche que le sleeper a le droit d'endormir, du plus gros au plus petit.

    La liste vient de /etc/secubox/health/sleepable-modules.json (écrite par secubox-wakectl health-sync : seuls les
    manifestes lifecycle on-demand / eager y figurent, jamais un module protégé ni always-on) croisée avec les
    conteneurs réellement en marche. Aucune liste à nous : si le parc change, ceci suit."""
    try:
        sommeilleux = set(json.loads(SOMMEILLEUX.read_text()))
    except (OSError, ValueError, TypeError):
        return []
    r = run(["lxc-ls", "-P", lxc_chemin(c), "--running"], capture_output=True, text=True, timeout=30)
    if r.returncode != 0:
        return []
    en_marche = {n for n in (r.stdout or "").split() if re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,62}", n)}
    soi = lxc_nom(c)
    cands = [(n, memoire_cgroup_mo(n)) for n in en_marche if n in sommeilleux and n != soi]
    return sorted(cands, key=lambda t: t[1], reverse=True)


def swap_disque_libre_mo() -> int:
    """Swap libre HORS zram, en Mo (/proc/swaps : Filename Type Size Used Priority, en Kio). Le zram est de la RAM comprimée : il se
    remplit et ne rend rien — le compter comme du swap disque était exactement l'erreur qui laissait gk3 sans filet."""
    libre = 0
    try:
        for ligne in SWAPS.read_text().splitlines()[1:]:
            champs = ligne.split()
            if len(champs) >= 4 and not champs[0].startswith("/dev/zram"):
                libre += (int(champs[2]) - int(champs[3])) // 1024
    except (OSError, ValueError):
        return 0
    return max(0, libre)


def etat_memoire(c: dict) -> dict:
    """Mémoire de la box. `effective_mo` = MemAvailable + la moitié du swap DISQUE libre (plafonnée par `swap_compte_mo`) : le noyau
    y range les pages froides des autres services pour faire de la place au modèle. Sans swap disque (zram seul, plein), rien
    n'est ajouté."""
    mi = lire_meminfo()
    plafond = int(c.get("memoire", {}).get("swap_compte_mo", 2048))
    disque = swap_disque_libre_mo()
    return {"disponible_mo": mi.get("MemAvailable", 0), "swap_libre_mo": mi.get("SwapFree", 0), "swap_total_mo": mi.get("SwapTotal", 0),
            "swap_disque_mo": disque, "effective_mo": mi.get("MemAvailable", 0) + min(disque // 2, plafond),
            "moteur_mo": memoire_cgroup_mo(lxc_nom(c))}


@sous_verrou(bloquant=True)      # deux synthèses simultanées font la queue au lieu de s'échouer l'une l'autre
def cmd_faire_de_la_place(run=_run, dormir=time.sleep, maintenant=time.time) -> int:
    """Rend la mémoire de la box suffisante pour UNE synthèse, ou dit pourquoi elle ne l'est pas.

    1. Le moteur a déjà son modèle (mémoire du LXC au-dessus de `modele_residente_mo`) → rien de plus à demander.
    2. Assez de MemAvailable → rien à faire.
    3. Sinon : endormir un à un les autres conteneurs endormables (secubox-profilectl apply --only <id> --yes --json,
       la voie officielle : snapshot 4R, audit, réveil automatique au premier accès), le plus gros d'abord, en
       revérifiant la mémoire après chacun ; s'arrêter dès que c'est suffisant.
    Au plus une salve toutes les `delai_repetition_s` : une rafale de demandes n'endort pas le parc en boucle."""
    c = charger()
    m = c.get("memoire", {})
    besoin = int(m.get("besoin_synthese_mo", 3800))
    residente = int(m.get("modele_residente_mo", 2500))
    delai = int(m.get("delai_repetition_s", 120))
    etat = etat_memoire(c)
    res = {"ok": True, "suffisante": True, "besoin_mo": besoin, "endormis": [], **etat}
    if etat["moteur_mo"] >= residente or etat["effective_mo"] >= besoin:
        print(json.dumps(res))
        return 0
    try:
        dernier = json.loads(PLACE_ETAT.read_text())
    except (OSError, ValueError):
        dernier = {}
    if maintenant() - float(dernier.get("ts", 0)) < delai:
        res["suffisante"] = False
        res["raison"] = "tentative récente : les conteneurs endormis n'ont pas encore rendu leur mémoire ou n'y suffisent pas"
        res["endormis"] = dernier.get("endormis", [])
        print(json.dumps(res))
        return 0
    endormis = []
    for nom, mo in conteneurs_endormables(c, run)[:MAX_ENDORMIS]:
        r = run([PROFILECTL, "apply", "--only", nom, "--yes", "--json"], capture_output=True, text=True, timeout=120)
        if r.returncode != 0:
            print(f"voicestudioctl : {nom} non endormi (rc={r.returncode}) : " + (r.stderr or r.stdout or "")[:160], file=sys.stderr)
            continue
        endormis.append({"id": nom, "mo": mo})
        dormir(3)                                   # le noyau rend la mémoire de façon asynchrone
        if etat_memoire(c)["effective_mo"] >= besoin:
            break
    etat = etat_memoire(c)
    res.update(etat, endormis=endormis, suffisante=etat["effective_mo"] >= besoin)
    if not res["suffisante"]:
        res["raison"] = ("mémoire insuffisante même après avoir endormi les conteneurs endormables"
                         if endormis else "aucun conteneur endormable en marche")
    try:
        PLACE_ETAT.parent.mkdir(parents=True, exist_ok=True)
        PLACE_ETAT.write_text(json.dumps({"ts": maintenant(), "endormis": endormis}))
    except OSError as e:
        print(f"voicestudioctl : état de libération non mémorisé ({e.strerror})", file=sys.stderr)
    audit("faire-de-la-place", f"suffisante={res['suffisante']} disponible={etat['disponible_mo']}Mo endormis=" +
          (",".join(e["id"] for e in endormis) or "aucun"))
    print(json.dumps(res))
    return 0


def etat_complet(run=_run, sante_fn=None) -> dict:
    sante_fn = sante_fn or sante
    c = charger()
    cache = {}
    try:
        cache = json.loads(ETAT.read_text())
    except (OSError, ValueError):
        pass
    lxc_s = etat_lxc(c, run)
    source = "direct"
    if lxc_s == "ABSENT" and cache.get("dernier_etat"):
        lxc_s, source = cache["dernier_etat"], "cache"
    sante_r = sante_fn(c) if lxc_s == "RUNNING" else {"ok": False, "http": 0}
    src = c.get("source", {})
    e = {
        "installe": MARQUE_INSTALLE.exists(),
        "lxc": lxc_s,
        "ip": lxc_ip(c),
        "port": PORT,
        "moteur": sante_r["ok"],
        "http": sante_r.get("http", 0),
        "demarrage": bool(sante_r.get("demarrage")),
        "version": src.get("version", ""),
        "commit": str(src.get("commit", ""))[:12],
        "mode": c.get("lxc", {}).get("mode", "permanent"),
        "inactivite_s": int(c.get("lxc", {}).get("inactivite_s", 900)),
        "memoire": c.get("lxc", {}).get("memoire", "4G"),
        "cpu_poids": int(c.get("lxc", {}).get("cpu_poids", 50)),
        "asr": c.get("moteur", {}).get("asr", ""),
        "publier": list(c.get("reseau", {}).get("publier", [])),
        "donnees_mo": taille_en_cache(c, cache),
        "cle_presente": bool(lire_cle()),
        "mandataire": systemctl(run, "is-active", SOCKET_PUB).stdout.strip() or "inactive",
        "provisionnement": systemctl(run, "is-active", UNITE_PROVISION).stdout.strip() or "inactive",
        "memoire_hote": etat_memoire(c),
        "interface_native": MARQUE_INTERFACE.exists(),
        "interface_vhost": (NGINX_ACTIF / VHOST_FICHIER).exists(),
        "source": source,
        "ts": datetime.now(timezone.utc).isoformat(),
    }
    sauv = sorted(SAUVEGARDES.glob("voicestudio-*.tar.gz")) if SAUVEGARDES.exists() else []
    e["sauvegarde"] = sauv[-1].name if sauv else None
    return e


def _ecrit(cles: dict) -> None:
    """Fusionne dans l'état (lu sans privilège par le panneau). Un échec est dit, jamais fatal."""
    try:
        ETAT.parent.mkdir(parents=True, exist_ok=True)
        try:
            d = json.loads(ETAT.read_text())
        except (OSError, ValueError):
            d = {}
        d.update(cles)
        tmp = ETAT.with_name(ETAT.name + ".tmp")
        tmp.write_text(json.dumps(d))
        os.replace(tmp, ETAT)
    except OSError as e:
        print(f"voicestudioctl : état non mémorisé ({e.strerror})", file=sys.stderr)


def memoriser_etat(etat_lxc_valeur: str) -> None:
    _ecrit({"dernier_etat": etat_lxc_valeur, "ts": datetime.now(timezone.utc).isoformat()})


def taille_en_cache(c: dict, cache: dict) -> float:
    """Taille des données : un os.walk de plusieurs centaines de Mo ne se refait pas à chaque
    affichage du panneau. Recalculée au plus toutes les CACHE_TAILLE_S secondes."""
    vu = cache.get("taille")
    if isinstance(vu, dict) and time.time() - vu.get("ts", 0) < CACHE_TAILLE_S:
        return float(vu.get("mo", 0.0))
    mo = taille_mo(donnees(c))
    _ecrit({"taille": {"mo": mo, "ts": time.time()}})
    return mo


# ── commandes ────────────────────────────────────────────────────────────────
@sous_verrou()
def cmd_install(run=_run) -> int:
    creer_cle()
    r = run(["bash", INSTALL], timeout=5400)
    if r.returncode != 0:
        return r.returncode
    c = charger()
    appliquer(run)
    audit("install", f"commit={str(c.get('source', {}).get('commit', ''))[:12]}")
    if c.get("interface", {}).get("activer", True) and interface_construite(c, run):
        MARQUE_INTERFACE.parent.mkdir(parents=True, exist_ok=True)
        MARQUE_INTERFACE.write_text(datetime.now(timezone.utc).isoformat() + "\n")
        appliquer(run)
    if VOLUME_PODMAN.is_dir():
        return cmd_basculer(run)
    return cmd_start(run)


def interface_construite(c: dict, run=_run) -> bool:
    r = run(["lxc-attach", "-P", lxc_chemin(c), "-n", lxc_nom(c), "--", "test", "-s", "/app/frontend/dist/index.html"],
            capture_output=True, text=True, timeout=30)
    return r.returncode == 0


@sous_verrou()
def cmd_interface(run=_run) -> int:
    """Construit l'interface native dans le LXC (bun épinglé, installation figée) puis pose son vhost. Idempotent."""
    c = charger()
    if not c.get("interface", {}).get("activer", True):
        print("voicestudioctl : interface native désactivée ([interface] activer = false)")
        appliquer(run)
        return 0
    if etat_lxc(c, run) == "ABSENT":
        raise Erreur("LXC absent — voicestudioctl install d'abord")
    r = run(["bash", INSTALL, "--interface"], timeout=3600)
    if r.returncode != 0:
        audit("interface", f"echec-rc{r.returncode}")
        return r.returncode
    # Un code de sortie 0 ne prouve PAS que l'interface existe : le script sort aussi en 0 quand il conclut « désactivée »
    # (vu sur gk3 : configuration mal lue → marqueur posé, rien construit, et l'unité ne retentait plus jamais).
    if not interface_construite(c, run):
        audit("interface", "echec-dist-absent")
        print("voicestudioctl : le script a rendu 0 mais frontend/dist est absent du LXC — marqueur NON posé", file=sys.stderr)
        return 5
    MARQUE_INTERFACE.parent.mkdir(parents=True, exist_ok=True)
    MARQUE_INTERFACE.write_text(datetime.now(timezone.utc).isoformat() + "\n")
    audit("interface", "construite")
    appliquer(run)
    return 0


@sous_verrou()
def cmd_voix_rapide(run=_run) -> int:
    """Installe (ou réinstalle) la voix rapide dans le LXC : modèle vérifié par sha256, serveur, unité. Idempotent."""
    c = charger()
    if not c.get("rapide", {}).get("activer", True):
        print("voicestudioctl : voix rapide désactivée ([rapide] activer = false)")
        return 0
    if etat_lxc(c, run) == "ABSENT":
        raise Erreur("LXC absent — voicestudioctl install d'abord")
    r = run(["bash", INSTALL, "--voix-rapide"], timeout=1800)
    if r.returncode != 0:
        audit("voix-rapide", f"echec-rc{r.returncode}")
        return r.returncode
    # Comme pour l'interface : un code 0 ne prouve rien. On exige que le serveur réponde AVANT de poser le marqueur.
    port = int(c.get("rapide", {}).get("port", 3901))
    ok = False
    for _ in range(15):
        v = run(["curl", "-fsS", "-m", "4", f"http://{lxc_ip(c)}:{port}/health"], capture_output=True, text=True, timeout=10)
        if v.returncode == 0:
            ok = True
            break
        time.sleep(2)
    if not ok:
        audit("voix-rapide", "echec-ne-repond-pas")
        print("voicestudioctl : la voix rapide ne répond pas après installation — marqueur NON posé", file=sys.stderr)
        return 5
    MARQUE_RAPIDE.parent.mkdir(parents=True, exist_ok=True)
    MARQUE_RAPIDE.write_text(datetime.now(timezone.utc).isoformat() + "\n")
    audit("voix-rapide", "installee")
    return 0


@sous_verrou()
def cmd_reinstaller(run=_run) -> int:
    try:
        MARQUE_INSTALLE.unlink()
    except OSError:
        pass
    audit("reinstaller")
    return cmd_install(run)


@sous_verrou()
def cmd_start(run=_run) -> int:
    c = charger()
    creer_cle()
    systemctl(run, "start", SOCKET_PUB)
    if etat_lxc(c, run) == "ABSENT":
        raise Erreur("LXC absent — voicestudioctl install")
    appliquer_lxc(c)
    if not reveiller(c, run):
        return 1
    memoriser_etat("RUNNING")
    audit("start")
    return 0


@sous_verrou()
def cmd_stop(run=_run) -> int:
    c = charger()
    systemctl(run, "stop", SERVICE_PUB)
    systemctl(run, "stop", SOCKET_PUB)
    ok = endormir(c, run)
    memoriser_etat("STOPPED")
    audit("stop")
    return 0 if ok else 1


@sous_verrou()
def cmd_restart(run=_run) -> int:
    c = charger()
    if etat_lxc(c, run) == "RUNNING":
        r = run(["lxc-attach", "-P", lxc_chemin(c), "-n", lxc_nom(c), "--",
                 "systemctl", "restart", "voicestudio.service"],
                capture_output=True, text=True, timeout=60)
        if r.returncode != 0:
            print("voicestudioctl : redémarrage du moteur refusé : " + (r.stderr or "")[:200], file=sys.stderr)
            return 1
        audit("restart")
        return 0 if reveiller(c, run) else 1
    return cmd_start(run)


@sous_verrou(bloquant=True)
def cmd_wake(run=_run) -> int:
    c = charger()
    if not reveiller(c, run):
        return 1
    memoriser_etat("RUNNING")
    return 0


@sous_verrou(bloquant=True)
def cmd_sleep(run=_run) -> int:
    c = charger()
    ok = endormir(c, run)
    memoriser_etat("STOPPED")
    return 0 if ok else 1


def cmd_logs(n: int, run=_run) -> int:
    """Journal du moteur. SOURCE : `omnivoice.log` dans le dossier de données (lisible côté hôte, LXC
    arrêté compris). `journalctl` dans le LXC ne sert à rien : systemd-journald y échoue (seccomp, LXC non
    privilégié) et n'a donc jamais d'entrée. Repli sur le journal si le fichier n'existe pas encore."""
    c = charger()
    n = max(1, min(n, 500))
    f = donnees(c) / "omnivoice.log"
    try:
        with open(f, "rb") as h:
            h.seek(0, os.SEEK_END)
            h.seek(max(0, h.tell() - 262144))        # les 256 derniers Kio suffisent à 500 lignes
            fin = h.read().decode("utf-8", "replace").splitlines()
        sys.stdout.write("\n".join(fin[-n:]) + "\n")
        return 0
    except FileNotFoundError:
        pass
    except OSError as e:
        raise Erreur(f"journal illisible ({e.strerror})") from e
    if etat_lxc(c, run) != "RUNNING":
        print("voicestudioctl : pas encore de journal (le moteur n'a jamais démarré)", file=sys.stderr)
        return 2
    r = run(["lxc-attach", "-P", lxc_chemin(c), "-n", lxc_nom(c), "--",
             "journalctl", "-u", "voicestudio.service", "-n", str(n), "--no-pager", "-o", "short-iso"],
            capture_output=True, text=True, timeout=30)
    sys.stdout.write(r.stdout)
    return r.returncode


@sous_verrou()
def cmd_cle_renouveler(run=_run) -> int:
    c = charger()
    nouvelle = secrets.token_urlsafe(32)
    ecrire_cle(nouvelle)
    poussee = pousser_cle(c, run)         # relance le moteur si la clé a changé ; sinon au réveil
    appliquer_vhost(c, run)               # nginx pose la clé du moteur : elle doit suivre (sinon 401 sur l'interface native)
    audit("cle-renouvelee", f"poussee={'oui' if poussee else 'au-prochain-demarrage'}")
    print(nouvelle)
    return 0


def valider_config(cle: str, valeur: str) -> tuple[str, str | int]:
    """(section, valeur typée). Toute valeur est validée par liste blanche ou motif ENTIER."""
    if cle == "asr":
        if valeur not in ASR_ADMIS:
            raise Erreur("modèle inconnu : " + ", ".join(sorted(ASR_ADMIS)))
        return "moteur", valeur
    if cle == "memoire":
        m = re.fullmatch(r"([1-9][0-9]{0,4})([GM])", valeur)
        if not m or (m.group(2) == "G" and not 1 <= int(m.group(1)) <= 32) \
                or (m.group(2) == "M" and not 1024 <= int(m.group(1)) <= 32768):
            raise Erreur("memoire : de 1G à 32G (ex. 4G)")
        return "lxc", valeur
    if cle == "cpu_poids":
        if not re.fullmatch(r"[0-9]{1,5}", valeur) or not 1 <= int(valeur) <= 10000:
            raise Erreur("cpu_poids : entier de 1 à 10000")
        return "lxc", int(valeur)
    if cle == "mode":
        if valeur not in MODES:
            raise Erreur("mode : " + " | ".join(MODES))
        return "lxc", valeur
    if cle == "inactivite_s":
        if not re.fullmatch(r"[0-9]{2,5}", valeur) or not 60 <= int(valeur) <= 86400:
            raise Erreur("inactivite_s : entier de 60 à 86400")
        return "lxc", int(valeur)
    raise Erreur("clé inconnue : asr | memoire | cpu_poids | mode | inactivite_s")


@sous_verrou()
def cmd_config(cle: str, valeur: str, run=_run) -> int:
    section, typee = valider_config(cle, valeur)
    ecrire_toml(section, cle, typee)
    c = charger()
    appliquer(run)
    if etat_lxc(c, run) == "RUNNING":
        if cle == "asr":
            run(["lxc-attach", "-P", lxc_chemin(c), "-n", lxc_nom(c), "--", "sh", "-c",
                 "sed -i '/^ASR_MODEL_FASTER=/d' /etc/voicestudio.env.base && "
                 f"echo ASR_MODEL_FASTER={typee} >> /etc/voicestudio.env.base && "
                 "systemctl restart voicestudio.service"],
                capture_output=True, text=True, timeout=60)
        elif cle == "memoire":
            lxc(run, c, "cgroup", "memory.max", str(typee))
        elif cle == "cpu_poids":
            lxc(run, c, "cgroup", "cpu.weight", str(typee))
    audit("config", f"{cle}={typee}")
    print(f"voicestudioctl : {cle} = {typee}")
    return 0


@sous_verrou()
def cmd_publier(adresses: list[str], run=_run) -> int:
    """Chaque valeur doit être une IPv4 de CETTE box : jamais une adresse arbitraire (le
    mandataire écouterait ailleurs ou ne démarrerait pas)."""
    locales = run(["ip", "-4", "-o", "addr", "show"], capture_output=True, text=True, timeout=10).stdout
    adresses = [adresse_publiable(a) for a in adresses]
    for a in adresses:
        if not re.search(rf" {re.escape(a)}/", locales):
            raise Erreur(f"{a} n'est pas une adresse de cette box")
    ecrire_toml("reseau", "publier", adresses)
    appliquer(run)
    audit("publier", ",".join(adresses) or "127.0.0.1-seulement")
    print("voicestudioctl : port 3900 publié sur : " + (" ".join(adresses) or "127.0.0.1 seulement"))
    return 0


LEGACY = {"publier": ("reseau", "publier"), "memoire": ("lxc", "memoire"), "asr": ("moteur", "asr")}


def _groupe_secubox(chemin: Path) -> None:
    """Le panneau (secubox) lit les limites dans ce fichier : root:secubox 0640. Sans secret dedans."""
    try:
        shutil.chown(chemin, group="secubox")
    except (LookupError, OSError):
        pass
    os.chmod(chemin, 0o640)


@sous_verrou()
def _completer_depuis_les_defauts(actuel: dict) -> int:
    """Ajoute à la configuration de la box les sections et clés NOUVELLES du paquet (une montée de version qui apporte
    `[interface]` ne doit pas laisser les scripts sans valeur). Ne touche jamais à une clé existante : le choix de
    l'opérateur l'emporte. Rend le nombre de clés ajoutées."""
    try:
        with open(CONF_DEFAUT, "rb") as h:
            defauts = tomllib.load(h)
    except (OSError, tomllib.TOMLDecodeError):
        return 0
    n = 0
    # Ancienne valeur PAR DÉFAUT (120 s) de `[limites] delai_s` : trop juste pour une synthèse à froid (≈ 110 s mesurés). On ne
    # remplace QUE cette valeur exacte — un délai choisi par l'opérateur n'est jamais touché.
    if actuel.get("limites", {}).get("delai_s") == 120:
        ecrire_toml("limites", "delai_s", 300)
        n += 1
    for section, valeurs in defauts.items():
        for cle, valeur in valeurs.items():
            if cle not in actuel.get(section, {}):
                ecrire_toml(section, cle, valeur)
                n += 1
    if n:
        audit("migrer-conf", f"cles-ajoutees={n}")
        print(f"voicestudioctl : {n} clé(s) nouvelle(s) ajoutée(s) à la configuration")
    return n


def cmd_migrer_conf() -> int:
    """Dépose le TOML par défaut s'il manque ; convertit l'ANCIEN schéma (podman : image, empreinte,
    publier/memoire/asr à plat) vers les sections. Les adresses publiées sont reprises telles
    quelles : gk2 joint le moteur de gk3 par l'une d'elles, les perdre couperait Lexie."""
    if not CONF.exists():
        if not CONF_DEFAUT.exists():
            raise Erreur(f"ni {CONF} ni le défaut du paquet ({CONF_DEFAUT})")
        CONF.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(CONF_DEFAUT, CONF)
        _groupe_secubox(CONF)
        print("voicestudioctl : configuration créée")
        return 0
    with open(CONF, "rb") as h:
        ancien = tomllib.load(h)
    # L'ancien schéma (podman) a des clés À PLAT (`memoire = "4g"`) ; le nouveau a des SECTIONS (`[memoire]`) qui portent
    # parfois le même nom : seule une valeur qui n'est PAS une table est un reste de l'ancien schéma.
    if not any(k in ancien and not isinstance(ancien[k], dict) for k in ("image", "empreinte", *LEGACY)):
        _completer_depuis_les_defauts(ancien)
        _groupe_secubox(CONF)
        return 0
    sauvegarde = CONF.with_name(CONF.name + ".avant-lxc")
    if not sauvegarde.exists():
        shutil.copyfile(CONF, sauvegarde)
    shutil.copyfile(CONF_DEFAUT, CONF)
    for cle, (section, nom) in LEGACY.items():
        if cle not in ancien or isinstance(ancien[cle], dict):
            continue
        valeur = ancien[cle]
        if cle == "memoire":        # podman acceptait « 4g » ; le plafond cgroup attend « 4G »
            try:
                _, valeur = valider_config("memoire", str(valeur).upper())
            except Erreur:
                continue            # valeur inexploitable : le défaut du paquet reste
        elif cle == "asr" and valeur not in ASR_ADMIS:
            continue
        elif cle == "publier" and not (isinstance(valeur, list) and all(isinstance(a, str) for a in valeur)):
            continue
        ecrire_toml(section, nom, valeur)
    _groupe_secubox(CONF)
    audit("migrer-conf", f"publier={','.join(ancien.get('publier', [])) or '-'}")
    print(f"voicestudioctl : ancienne configuration reprise (copie : {sauvegarde.name})")
    return 0


@sous_verrou()
def cmd_sauvegarde() -> int:
    c = charger()
    src = donnees(c)
    if not src.is_dir():
        raise Erreur(f"pas de données à sauvegarder ({src})")
    SAUVEGARDES.mkdir(parents=True, exist_ok=True)
    os.chmod(SAUVEGARDES, 0o700)
    horodatage = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    cible = SAUVEGARDES / f"voicestudio-{horodatage}-{secrets.token_hex(2)}.tar.gz"

    def sans_modeles(ti: tarfile.TarInfo):
        # Les modèles se re-téléchargent (plusieurs Go) ; voix, historique et base ne se retrouvent pas.
        # La base vivante est exclue : on archive une COPIE cohérente (API de sauvegarde SQLite).
        parties = ti.name.split("/")[1:2]
        if parties == ["huggingface"] or ti.name.split("/")[-1] in ("omnivoice.db", "omnivoice.db-wal", "omnivoice.db-shm") \
                and len(ti.name.split("/")) == 2:
            return None
        return ti

    try:
        with tempfile.TemporaryDirectory(prefix="vs-sauv-") as tmp:
            base = src / "omnivoice.db"
            copie = Path(tmp) / "omnivoice.db"
            if base.exists():
                with contextlib.closing(sqlite3.connect(f"file:{base}?mode=ro", uri=True, timeout=30)) as origine, \
                        contextlib.closing(sqlite3.connect(copie)) as dest:
                    origine.backup(dest)
            fd = os.open(cible, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, "wb") as brut, tarfile.open(fileobj=brut, mode="w:gz") as t:
                t.add(src, arcname="omnivoice_data", filter=sans_modeles)
                if copie.exists():
                    t.add(copie, arcname="omnivoice_data/omnivoice.db")
    except (OSError, sqlite3.Error) as e:
        cible.unlink(missing_ok=True)
        raise Erreur(f"sauvegarde impossible : {e}") from e
    for vieille in sorted(SAUVEGARDES.glob("voicestudio-*.tar.gz"))[:-SAUVEGARDES_GARDEES]:
        vieille.unlink()
    audit("sauvegarde", f"{cible.name} {cible.stat().st_size}o")
    print(cible)
    return 0


MARQUE_MIGRATION = ".migre-de-podman"


def _decaler_proprietes(racine: Path) -> None:
    """uid ET gid décalés sur l'idmap du LXC non privilégié (uid 0 du conteneur = UID_BASE sur l'hôte),
    chacun pour son compte : un uid déjà décalé ne dispense pas son gid."""
    for dossier, dirs, fichiers in os.walk(racine):
        for nom in [*dirs, *fichiers, "."]:
            p = os.path.join(dossier, nom)
            st = os.lstat(p)
            u = st.st_uid + UID_BASE if st.st_uid < UID_BASE else st.st_uid
            g = st.st_gid + UID_BASE if st.st_gid < UID_BASE else st.st_gid
            if (u, g) != (st.st_uid, st.st_gid):
                os.lchown(p, u, g)


@sous_verrou()
def cmd_migrer_podman(run=_run) -> int:
    """Reprend les données de l'ancien conteneur podman, UNE fois, de façon ATOMIQUE : copie vers un
    répertoire voisin, propriétés décalées, puis renommage — une copie interrompue ne laisse jamais
    un `omnivoice.db` à moitié là qui ferait croire à une migration faite. Un marqueur de fin la fige.
    Le volume d'origine n'est jamais supprimé : on ne détruit rien tant que le LXC n'a pas servi."""
    c = charger()
    cible = donnees(c)
    if not VOLUME_PODMAN.is_dir():
        print("voicestudioctl : pas de volume podman à migrer")
        return 0
    if (cible / MARQUE_MIGRATION).exists():
        print("voicestudioctl : données déjà migrées — migration ignorée")
        return 0
    actif = run(["systemctl", "is-active", ANCIENNE_UNITE], capture_output=True, text=True, timeout=30)
    if (actif.stdout or "").strip() == "active":
        raise Erreur("l'ancien moteur tourne encore : voicestudioctl basculer l'arrête avant de copier")
    if etat_lxc(c, run) == "RUNNING":
        raise Erreur("le LXC tourne : arrêtez-le avant de lui remplacer ses données")
    temp = cible.with_name(cible.name + ".migration")
    shutil.rmtree(temp, ignore_errors=True)
    r = run(["cp", "-a", "--no-target-directory", str(VOLUME_PODMAN), str(temp)],
            capture_output=True, text=True, timeout=1800)
    if r.returncode != 0:
        shutil.rmtree(temp, ignore_errors=True)
        raise Erreur("copie du volume échouée : " + (r.stderr or "")[:200])
    _decaler_proprietes(temp)
    (temp / MARQUE_MIGRATION).write_text(datetime.now(timezone.utc).isoformat() + "\n")
    # La cible existe déjà si le LXC a démarré une fois (base vierge) : on la met de côté, on ne l'écrase pas.
    ecartee = None
    if cible.exists():
        ecartee = cible.with_name(cible.name + ".avant-migration")
        shutil.rmtree(ecartee, ignore_errors=True)
        os.replace(cible, ecartee)
    os.replace(temp, cible)
    if ecartee is not None:                # le point de montage vide laissé par le LXC : inutile de le garder
        try:
            ecartee.rmdir()
        except OSError:
            pass                           # non vide (base vierge, voix) : on la garde, rien n'est perdu
    os.chmod(cible, 0o750)               # voix clonées et historique : pas lisibles de tout l'hôte
    audit("migrer-podman", f"{taille_mo(cible)}Mo" + (" ancienne-cible-gardee" if ecartee else ""))
    print(f"voicestudioctl : {taille_mo(cible)} Mo repris de {VOLUME_PODMAN} (volume conservé)")
    return 0


def _port_libre(port: int = PORT) -> bool:
    try:
        with socket.create_connection(("127.0.0.1", port), timeout=1):
            return False
    except OSError:
        return True


@sous_verrou()
def cmd_basculer(run=_run) -> int:
    """Passage de l'ancien conteneur podman au LXC, UNE fois. L'ancien moteur a servi pendant toute
    l'installation du LXC (10 à 25 min) ; il n'est arrêté qu'ICI, LXC prêt, pour que la coupure se
    réduise à la copie des données et au démarrage du moteur. Rien n'est supprimé : le volume podman
    reste intact. En cas d'échec, on tente de relancer l'ancienne unité (si son fichier existe encore)."""
    c = charger()
    if etat_lxc(c, run) == "ABSENT":
        raise Erreur("LXC absent — voicestudioctl install d'abord ; l'ancien moteur n'a pas été touché")
    if not VOLUME_PODMAN.is_dir():
        print("voicestudioctl : pas d'ancien moteur podman à basculer")
        return cmd_start(run)
    audit("basculer", "debut")
    run(["systemctl", "stop", ANCIENNE_UNITE], capture_output=True, text=True, timeout=90)
    if not _port_libre():
        audit("basculer", "echec-port-tenu")
        raise Erreur("l'ancien moteur tient encore le port 3900 (unité introuvable ?) — arrêtez-le à la main ; "
                     "rien n'a été copié ni démarré")
    endormir(c, run)                      # le moteur du LXC ne doit pas tourner pendant la copie
    try:
        cmd_migrer_podman(run)
        rc = cmd_start(run)
    except Erreur:
        rc = 1
        print("voicestudioctl : migration des données échouée (voir ci-dessus).", file=sys.stderr)
    if rc != 0:
        run(["systemctl", "start", ANCIENNE_UNITE], capture_output=True, text=True, timeout=60)
        print("voicestudioctl : bascule en échec. L'ancien volume est INTACT "
              f"({VOLUME_PODMAN}) ; l'ancienne unité a été relancée si elle existe encore.", file=sys.stderr)
        audit("basculer", "echec")
        return rc
    for t in NFT_TABLES:                  # règles de transfert de l'ancien exécuteur podman (même étiquette)
        supprimer_regles_etiquetees(t, "forward", run)
    audit("basculer", "ok")
    print("voicestudioctl : bascule faite — le moteur sert depuis le LXC")
    return 0


def cmd_etat(json_sortie: bool, run=_run) -> int:
    e = etat_complet(run)
    if json_sortie:
        print(json.dumps(e))
        return 0
    print(f"LXC {e['lxc']} — moteur {'répond' if e['moteur'] else ('démarre' if e['demarrage'] else 'ne répond pas')}"
          f" ({e['ip']}:{e['port']}) — mode {e['mode']}")
    print(f"VoiceStudio {e['version']} ({e['commit']}) — ASR {e['asr']} — mémoire {e['memoire']}")
    print("publié sur : " + (", ".join(e["publier"]) or "127.0.0.1 seulement") + f" — mandataire {e['mandataire']}")
    return 0


ACTIONS_API = ("status", "start", "stop", "restart", "logs", "config", "publier",
               "cle-apercu", "cle-renouveler", "cle-interne", "sauvegarde", "installer", "faire-de-la-place")


def _capter(fn, *args, **kw) -> tuple[int, str]:
    """Exécute une commande en capturant sa sortie standard : le panneau reçoit du JSON, pas du texte."""
    sortie = io.StringIO()
    with contextlib.redirect_stdout(sortie):
        rc = fn(*args, **kw)
    return rc, sortie.getvalue().strip()


def cmd_api(entree: str) -> int:
    """Seule porte du panneau (sudoers : un argv exact, sans joker). La requête arrive en JSON sur
    stdin ; l'action est vérifiée contre une liste blanche et chaque valeur par motif entier."""
    try:
        req = json.loads(entree)
        act = req["action"]
    except (ValueError, KeyError, TypeError):
        print(json.dumps({"ok": False, "erreur": "requête illisible"}))
        return 1
    par = str(req.get("par", ""))
    if re.fullmatch(r"[A-Za-z0-9_.@-]{1,64}", par):
        os.environ["SUDO_USER"] = par          # l'audit nomme l'administrateur, pas « secubox »
    if act not in ACTIONS_API:
        print(json.dumps({"ok": False, "erreur": "action inconnue"}))
        return 1
    try:
        if act == "status":
            print(json.dumps(etat_complet()))
            return 0
        if act == "cle-apercu":
            k = lire_cle()
            print(json.dumps({"presente": bool(k), "apercu": (k[:4] + "…" + k[-4:]) if len(k) > 12 else ""}))
            return 0
        if act == "faire-de-la-place":
            rc, txt = _capter(cmd_faire_de_la_place)
            print(txt if txt.startswith("{") else json.dumps({"ok": False, "raison": txt[:200]}))
            return 0 if rc == 0 else 1
        if act == "cle-interne":
            print(json.dumps({"cle": lire_cle()}))
            return 0
        if act == "logs":
            n = req.get("n", 100)
            rc, txt = _capter(cmd_logs, n if isinstance(n, int) else 100)
            print(json.dumps({"ok": rc == 0, "lignes": txt.splitlines()[-500:]}))
            return 0
        if act == "config":
            rc, txt = _capter(cmd_config, str(req.get("cle", "")), str(req.get("valeur", "")))
        elif act == "publier":
            adresses = req.get("adresses", [])
            if not isinstance(adresses, list) or not all(isinstance(a, str) for a in adresses):
                raise Erreur("adresses : liste de chaînes")
            rc, txt = _capter(cmd_publier, adresses)
        elif act == "cle-renouveler":
            rc, nouvelle = _capter(cmd_cle_renouveler)
            print(json.dumps({"ok": rc == 0, "cle": nouvelle}))
            return 0
        elif act == "sauvegarde":
            rc, txt = _capter(cmd_sauvegarde)
        elif act == "installer":
            rc, txt = _capter(cmd_install)
        else:
            rc, txt = _capter({"start": cmd_start, "stop": cmd_stop, "restart": cmd_restart}[act])
    except Erreur as e:
        print(json.dumps({"ok": False, "erreur": str(e)}))
        return 1
    print(json.dumps({"ok": rc == 0, "detail": txt}))
    return 0


def main(argv: list[str]) -> int:
    cmd = argv[0] if argv else "status"
    a = argv[1:]
    try:
        if cmd in ("status", "etat"):
            return cmd_etat("--json" in a)
        if cmd == "install":
            return cmd_install()
        if cmd == "reinstaller":
            return cmd_reinstaller()
        if cmd == "migrer-podman":
            return cmd_migrer_podman()
        if cmd == "start":
            return cmd_start()
        if cmd == "stop":
            return cmd_stop()
        if cmd == "restart":
            return cmd_restart()
        if cmd == "wake":
            return cmd_wake()
        if cmd == "sleep":
            return cmd_sleep()
        if cmd == "logs":
            return cmd_logs(int(a[0]) if a and a[0].isdigit() else 100)
        if cmd == "init":
            print("clé créée" if creer_cle() else "clé déjà présente")
            return 0
        if cmd == "cle":
            print(lire_cle())
            return 0
        if cmd == "cle-renouveler":
            return cmd_cle_renouveler()
        if cmd == "config":
            if len(a) != 2:
                raise Erreur("usage : config <clé> <valeur>")
            return cmd_config(a[0], a[1])
        if cmd == "publier":
            return cmd_publier(a)
        if cmd == "appliquer":
            print(json.dumps(appliquer()))
            return 0
        if cmd == "sauvegarde":
            return cmd_sauvegarde()
        if cmd == "faire-de-la-place":
            return cmd_faire_de_la_place()
        if cmd == "interface-ferme":
            return cmd_interface_ferme()
        if cmd == "interface":
            return cmd_interface()
        if cmd == "voix-rapide":
            return cmd_voix_rapide()
        if cmd == "pare-feu-ferme":
            return cmd_pare_feu_ferme()
        if cmd == "basculer":
            return cmd_basculer()
        if cmd == "migrer-conf":
            return cmd_migrer_conf()
        if cmd == "api":
            return cmd_api(sys.stdin.read(65536))
    except Erreur as e:
        print(f"voicestudioctl : {e}", file=sys.stderr)
        return 1
    sys.stderr.write(__doc__ or "")
    return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
