#!/usr/bin/bash
# =============================================================================
#  overtchat-setup — version dans $VERSION (ne pas dupliquer ici)
#  Installateur officiel + Panel de gestion Service-Overtchat
#  Services : Eggdrop / IriX / Anope / Lea
#  Serveurs  : UnrealIRCd / ircd-Hybrid / ircu / Overtchat-ircd
#  Sécurité  : SHA256 + access.json chiffré
# =============================================================================
# --debug est cherché dans TOUS les arguments, et non dans le seul premier.
# « $0 --root --debug » n'activait donc pas la trace, alors que c'est la
# combinaison qu'on utilise justement quand l'installation en root échoue.
for _a in "$@"; do [[ "$_a" == "--debug" ]] && { set -x; break; }; done
unset _a
set -Eeuo pipefail

# =============================================================================
# CONSTANTES JSON
# =============================================================================
# Version — LUE dans le fichier VERSION de la racine, jamais recopiée ici.
#
# Elle l'était, et le résultat était prévisible : `VERSION` annonçait 3.5.2 pendant
# que Config/access.json et Config/eggdrop.json en étaient restés à 1.0. Aucun de
# ces fichiers n'étant dérivé d'un autre, chacun devait être mis à jour à la main —
# donc aucun ne l'était.
#
# Le chemin est déduit de l'emplacement de ce script (Bootstrap/ → racine), et non
# de BASE_DIR, qui n'est pas encore défini ici. La valeur en dur reste comme repli :
# un fichier absent ou corrompu ne doit pas empêcher le panel de démarrer, et le
# format est validé pour qu'un fichier abîmé ne se propage pas dans les JSON.
_read_version() {
    local f v
    f="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/VERSION"
    [ -r "$f" ] || { printf '4.0.0'; return; }
    v="$(tr -d '[:space:]' < "$f" 2>/dev/null)"
    case "$v" in
        [0-9]*.[0-9]*) printf '%s' "$v" ;;
        *)             printf '4.0.0'   ;;
    esac
}
readonly VERSION="$(_read_version)"
readonly SCRIPT_NAME="overtchat-setup"

# -----------------------------------------------------------------------------
# LE CHEMIN DE CE SCRIPT — celui qui tourne, pas celui qu'on suppose
#
# Le module de mise à jour écrit dans "$BASE_DIR/Bootstrap/overtchat-setup.sh".
# Rien ne garantit que ce soit le fichier en cours d'exécution : lancé depuis un
# clone posé ailleurs, ou depuis /tmp après un téléchargement, le script se
# serait mis à jour SANS SE TOUCHER — il aurait annoncé « à jour » et rejoué la
# même version au lancement suivant. Le plus trompeur des succès.
#
# `readlink -f` suit les liens symboliques ; à défaut on garde le chemin brut,
# qui vaut mieux que rien. `$0` ne suffit pas : sourcé ou appelé par un lien, il
# ne désigne pas le fichier.
# -----------------------------------------------------------------------------
_resolve_script_path() {
    local p="${BASH_SOURCE[0]:-$0}"
    if command -v readlink >/dev/null 2>&1; then
        readlink -f "$p" 2>/dev/null || printf '%s' "$p"
    elif command -v realpath >/dev/null 2>&1; then
        realpath "$p" 2>/dev/null || printf '%s' "$p"
    else
        printf '%s' "$p"
    fi
}

readonly SCRIPT_PATH="$(_resolve_script_path)"

# ⚠⚠ `INIT_BASE` et `INIT_DEFAULT` NE SONT PLUS DÉCLARÉS ICI — point 39.
#
# Ils vivaient dans `/tmp`, et cette place les tuait : un tmpfs est vidé à
# chaque redémarrage de la VM, et `systemd-tmpfiles-clean` purge à 10 jours.
# Ils sont désormais sous `$BASE_DIR/Runtime/`, et cela impose de les déclarer
# APRÈS `BASE_DIR` — c'est-à-dire après `_resolve_project_name`, puisque le nom
# de l'installation se choisit au premier lancement.
#
# ⚠ Les déclarer ici avec `$BASE_DIR` TUERAIT le script : sous `set -u`, la
# variable n'existe pas encore à cette ligne. Elles sont donc plus bas, juste
# après `BASE_DIR`. Ne pas les remonter.

# ── 🔴 LE JOURNAL N'EST PLUS DANS /tmp — audit du 29/09/2026, constat #25 ──
#
# `/tmp/overtchat-setup.log` était un nom FIXE dans un dossier ouvert à tous.
# N'importe quel compte local pouvait le créer avant l'installateur : root y
# ajoutait alors son journal (lisible par ce compte), ou échouait en silence
# sous `fs.protected_regular`. Il vit désormais chez celui qui lance :
# `/var/log/overtchat-setup.log` pour root, `~/.local/state/overtchat/` sinon,
# créé en 600 dans un dossier à soi ; un lien à sa place est refusé, et le
# journal part alors dans /dev/null plutôt qu'ailleurs.
_journal_chemin() {
    local d f
    if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
        d="/var/log"
    else
        d="${XDG_STATE_HOME:-${HOME:-/nonexistent}/.local/state}/overtchat"
    fi
    f="$d/overtchat-setup.log"
    ( umask 077; mkdir -p "$d" ) 2>/dev/null
    if [[ -d "$d" && ! -L "$d" && -O "$d" && ! -L "$f" ]] \
       && ( umask 077; : >> "$f" ) 2>/dev/null; then
        printf '%s' "$f"
    else
        printf '%s' "/dev/null"
    fi
}
readonly LOG_FILE="$(_journal_chemin)"

# ── 🔴 UN DOSSIER TEMPORAIRE À SOI, POUR TOUT LE LANCEMENT — audit #25 ──
#
# Le manifeste (`overtchat-manifeste.$$.json`) et l'outil d'allègement Tcl
# (`overtchat-depouiller.$$.tcl`) vivaient dans /tmp sous un nom DEVINABLE (le
# PID) : un compte local pouvait les poser avant root, et l'outil d'allègement
# est ensuite EXÉCUTÉ par tclsh. `mktemp -d` rend un dossier 700 au nom tiré au
# sort ; il s'efface à la sortie. Déclaré ici, au niveau du script, parce que
# les fonctions qui s'en servent tournent souvent dans un `$(…)` : un dossier
# créé là mourrait avec le sous-shell.
_OVT_TMP="$(mktemp -d "${TMPDIR:-/tmp}/overtchat-setup.XXXXXXXX" 2>/dev/null || true)"
# Effacé à la sortie, ET avant chaque `exec` qui relance le script (un `exec`
# ne passe pas par le piège EXIT : le dossier resterait, vide, à chaque relance).
_ovt_tmp_menage() { [[ -n "${_OVT_TMP:-}" && -d "$_OVT_TMP" && ! -L "$_OVT_TMP" ]] && rm -rf "$_OVT_TMP"; return 0; }

# ── LES REPÈRES MACHINE — pour le Lanceur Windows (08/10/2026) ──────────────
#
# Le Lanceur (Lanceur/, « Overtchat Setup ») suivait l'installation en lisant
# le TEXTE de la console : une frise d'avancement tirée de phrases faites pour
# un humain se casse à la première retouche d'une phrase, ou à sa traduction.
# Il pose donc OVERTCHAT_PROGRES=1, et ce script émet alors, sur la sortie
# standard, des lignes faites pour une MACHINE — stables et versionnées :
#
#     @@OVERTCHAT 1 DEBUT <nombre d'étapes>
#     @@OVERTCHAT 1 ETAPE <n>/<total> <titre>
#     @@OVERTCHAT 1 RESERVE <texte>
#     @@OVERTCHAT 1 ECHEC <texte>
#     @@OVERTCHAT 1 FIN <code de sortie>
#
# ⚠⚠ LE « 1 » EST LA VERSION DU FORMAT. Changer la forme d'une ligne, c'est
#   passer à 2 — jamais modifier le 1 en place : un Lanceur déjà distribué
#   ignore (par construction) une version qu'il ne connaît pas, au lieu de
#   mal lire une ligne qu'il croit connaître. Lecteur : Lanceur/src/coeur/reperes.js.
# ⚠ Seulement si OVERTCHAT_PROGRES vaut EXACTEMENT « 1 » : sans elle, la sortie
#   d'un terminal ordinaire est inchangée, octet pour octet (banc :
#   Bootstrap/tests/test-reperes-progres.sh). Une fin de ligne dans le texte
#   est aplatie : un repère tient sur UNE ligne, sinon la suite serait lue
#   comme du texte.
# 📌 FIN passe par le piège EXIT : il couvre TOUTES les sorties (refus, `_die`,
#   `set -e`, Ctrl+C via le piège INT/TERM) et non la seule fin d'`_auto_installer`.
#   Il porte le code réel — c'est lui que le Lanceur croit, parce que le code
#   d'un processus se perd en route (un `a || b` par SSH rendait 1 pour 3).
_progres() {
    [[ "${OVERTCHAT_PROGRES:-}" == "1" ]] || return 0
    local l="@@OVERTCHAT 1 $*"
    l="${l//$'\n'/ }"
    l="${l//$'\r'/ }"
    printf '%s\n' "$l"
    return 0
}
_ovt_a_la_sortie() {
    local rc=$?
    _ovt_tmp_menage
    _progres FIN "$rc"
    return "$rc"
}
trap _ovt_a_la_sortie EXIT

# Le dossier privé, s'il est sain : à nous, pas un lien. Rend 1 sinon, et
# l'appelant renonce plutôt que de retomber sur /tmp.
_ovt_tmp() {
    [[ -n "${_OVT_TMP:-}" && -d "$_OVT_TMP" && ! -L "$_OVT_TMP" && -O "$_OVT_TMP" ]] || return 1
    printf '%s' "$_OVT_TMP"
}
readonly SERVERS_KEY="servers"

# -----------------------------------------------------------------------------
# RACINE D'INSTALLATION
#
# Résolue AVANT toute constante dérivée. Utiliser "$HOME" directement était un
# piège : lancé via sudo, $HOME vaut /root et toute l'arborescence (sources
# compilées, Config/*.json, instance IriX) atterrissait dans /root, invisible
# pour l'utilisateur et inutilisable une fois repassé en compte normal.
#
# On installe donc toujours dans le home de l'utilisateur RÉEL — celui de
# $SUDO_USER si on est sous sudo — et jamais dans celui de root.
# -----------------------------------------------------------------------------
# ── 🔴 LE COMPTE DES SERVICES N'EST JAMAIS ROOT — audit du 29/09/2026, #8 ──
#
# Lancé en root SANS `SUDO_USER` (le Lanceur Windows le faisait : `wsl -u
# root`), tout partait sous /root et TOUS les services tournaient en root — le
# site, le serveur IRC, IriX. `OVERTCHAT_COMPTE=<compte>` désigne alors le
# compte ordinaire qui portera l'installation ; sans lui, l'installateur REFUSE
# (voir `main`). Le compte doit exister et ne pas être root (uid 0).
_compte_ordinaire() {
    local c="${1:-}" uid
    [[ "$c" =~ ^[a-z_][a-z0-9_.-]{0,31}$ ]] || return 1
    uid="$(getent passwd "$c" 2>/dev/null | cut -d: -f3)"
    [[ -n "$uid" && "$uid" != "0" ]]
}
_compte_designe() {
    [[ "${EUID:-$(id -u)}" -eq 0 ]] || return 1
    [[ -z "${SUDO_USER:-}" || "$SUDO_USER" == "root" ]] || return 1
    _compte_ordinaire "${OVERTCHAT_COMPTE:-}" || return 1
    printf '%s' "$OVERTCHAT_COMPTE"
}

_resolve_base_home() {
    local h="" c=""
    if [[ "${EUID:-$(id -u)}" -eq 0 && -n "${SUDO_USER:-}" && "$SUDO_USER" != "root" ]]; then
        h=$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6 || true)
        [[ -z "$h" ]] && h="/home/$SUDO_USER"
    elif c="$(_compte_designe)"; then
        h=$(getent passwd "$c" 2>/dev/null | cut -d: -f6 || true)
        [[ -z "$h" ]] && h="/home/$c"
    else
        h="${HOME:-$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f6 || echo "/tmp")}"
    fi
    printf '%s' "$h"
}

readonly TARGET_HOME="$(_resolve_base_home)"

# -----------------------------------------------------------------------------
# NOM DE L'INSTALLATION
#
# « Service-Overtchat » est le nom du réseau d'origine et reste la propriété de
# son auteur. Une installation tierce doit porter le sien : c'est ce nom qui
# donne le répertoire racine, et il apparaît ensuite dans les chemins que
# l'exploitant lit tous les jours. Deux installations qui s'appellent pareil sur
# la même machine, ou sur deux machines qu'on administre ensemble, se
# confondent — et une sauvegarde restaurée au mauvais endroit ne prévient pas.
#
# Résolu ICI, avant toute constante dérivée : $BASE_DIR et la vingtaine de
# chemins qui en découlent sont figés en `readonly` juste en dessous. Un nom
# décidé plus tard ne les rattraperait pas — c'est pourquoi l'assistant, quand
# il en obtient un nouveau, relance le script plutôt que de tenter de corriger
# des variables déjà écrites.
#
# Ordre de résolution, du plus explicite au plus déductible :
#
#   1. --projet=NOM sur la ligne de commande (c'est ainsi que l'assistant se
#      relance lui-même)
#   2. OVERTCHAT_PROJET dans l'environnement, pour une installation scriptée
#   3. le nom enregistré lors d'un lancement précédent, dans l'état /tmp
#   4. « Service-Overtchat », le nom historique
#
# Le point 4 n'est PAS un contournement du refus : il vaut pour l'installation
# d'origine, qui existe et doit continuer de fonctionner sans rien changer.
# C'est à l'assistant, sur une installation NEUVE, de réclamer un autre nom —
# voir `_projet_demander`.
# -----------------------------------------------------------------------------
readonly PROJET_HISTORIQUE="Service-Overtchat"

# Positionne deux variables plutôt que d'écrire sur la sortie standard : la
# SOURCE du nom compte autant que le nom lui-même. Un nom donné explicitement
# ne doit pas être redemandé — sans quoi l'assistant, qui se relance avec
# `--projet=`, reposerait sa question au lancement suivant, et l'exploitant
# répondrait deux fois à la même chose sans comprendre pourquoi. Une
# substitution `$( )` s'exécuterait dans une sous-shell, d'où rien ne remonte.
PROJET_NOM=""
PROJET_SOURCE="defaut"

# -----------------------------------------------------------------------------
# LE MODE AUTO — point 48, premier morceau
#
# « Auto : site + serveur + services, sans question. » L'installation complète
# demande aujourd'hui une trentaine de réponses et une quinzaine de pauses :
# c'est ce qui rend son épreuve laborieuse, et c'est aussi ce qui interdit de
# la lancer depuis un script.
#
# Ce que le mode AUTO répond à la place :
#
#   · une question de VALEUR      → la valeur par défaut déjà proposée ;
#   · une question de CONSENTEMENT → oui, quand la question porte sur ce que
#     l'installation vient faire — c'est le sens du drapeau. Les gestes qui
#     DÉTRUISENT (reset, désinstallation, purge de journal, remise à zéro des
#     empreintes) ne sont sur aucun chemin d'installation et n'y entrent pas ;
#   · une question SANS défaut     → personne. C'est pourquoi `_auto_prevol`
#     les exige AVANT que quoi que ce soit ne soit écrit.
#
# ⚠⚠ Ce que le mode AUTO ne fait PAS : lire l'entrée standard. Un `read` sans
# personne devant ne rend pas une réponse, il rend une chaîne vide obtenue par
# EOF — c'est-à-dire une réponse fabriquée, que rien ne distingue d'une vraie.
# C'est exactement le motif que ce projet appelle une confirmation mensongère.
#
# Deux façons de l'armer, comme pour le nom de l'installation :
#   --auto                sur la ligne de commande
#   OVERTCHAT_AUTO=1      dans l'environnement
# L'édition demandée en ligne de commande. Vide = « on ne demande rien ».
# ⚠ Sous `set -u`, une variable jamais posée fait mourir le script à sa
# première lecture — et ce serait au milieu de l'installation.
EDITION_CHOISIE=""
# L'édition VISÉE par une montée (--monter-edition=). Vide = pas de montée.
MONTER_CIBLE=""
MODE_AUTO=0
case "${OVERTCHAT_AUTO:-}" in 1|oui|yes|true) MODE_AUTO=1 ;; esac

_resolve_project_name() {
    local a nom=""

    for a in "$@"; do
        case "$a" in
            --projet=*) nom="${a#--projet=}"; PROJET_SOURCE="argument" ;;
        esac
    done

    if [[ -z "$nom" && -n "${OVERTCHAT_PROJET:-}" ]]; then
        nom="$OVERTCHAT_PROJET"; PROJET_SOURCE="environnement"
    fi

    # ⚠⚠ L'ÉTAT DU LANCEMENT PRÉCÉDENT NE SE LIT PLUS DANS /tmp — 29/09/2026
    #   (audit, #25). Ce bloc lisait le nom du projet dans
    #   `/tmp/.service-overtchat.json`, un nom FIXE qu'un compte local pouvait
    #   poser : root installait alors dans le dossier que ce compte avait
    #   choisi. Cet état vit sous `$BASE_DIR/Runtime/` depuis le point 39 ; le
    #   fichier de /tmp n'est plus écrit par personne (CHANTIERS). Le nom vient
    #   donc de l'argument, de l'environnement, ou du défaut — jamais de /tmp.

    if [[ -z "$nom" ]]; then
        nom="$PROJET_HISTORIQUE"; PROJET_SOURCE="defaut"
    fi

    # Une valeur venue de l'environnement ou d'un fichier n'est pas une valeur
    # sûre : elle sert à construire un chemin. Tout ce qui pourrait en sortir
    # est refusé, et on retombe sur le nom historique plutôt que d'écrire dans
    # un répertoire que personne n'a demandé.
    if [[ ! "$nom" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$ ]] || [[ "$nom" == *..* ]]; then
        nom="$PROJET_HISTORIQUE"; PROJET_SOURCE="defaut"
    fi

    PROJET_NOM="$nom"
}

_resolve_project_name "$@"
readonly PROJET_NOM PROJET_SOURCE

BASE_DIR="$TARGET_HOME/$PROJET_NOM"

# ── ⚠⚠ L'ÉTAT D'INSTALLATION — sorti de /tmp le 20/08/2026 (point 39) ───────
#
# Ce fichier ne porte PAS que les empreintes SHA256 des configurations. Il
# porte `.install` — la mémoire que l'installation a eu lieu —, `.folders` et
# ses 18 chemins, la langue, le nom du projet, les 32 modules Tcl à déployer,
# et la liste des fichiers surveillés par la mise à jour.
#
# ⚠ Sa perte ne cassait donc pas seulement la vérification d'intégrité : elle
# faisait croire à l'installateur qu'il n'avait jamais tourné. Constaté le
# 20/08/2026 — les deux fichiers étaient ABSENTS de la production.
#
# ⚠ `Runtime/` et non `Config/` : le chemin `Runtime/state.json` était DÉJÀ
# réservé dans `_upd_exclu` pour un fichier qui n'existait nulle part. Une des
# deux listes était donc faite d'avance ; l'autre — `.gitignore` — a été
# complétée dans le même commit, parce qu'un fichier d'état neuf entre dans les
# DEUX listes ou dans aucune.
#
# ⚠ Ces deux lignes DOIVENT rester ici, sous `BASE_DIR`. Les remonter là où
# elles étaient tuerait le script : `set -u` sur une variable qui n'existe pas
# encore.
readonly INIT_BASE="$BASE_DIR/Runtime/state.json"
readonly INIT_DEFAULT="$BASE_DIR/Runtime/state.default.json"

readonly SERVERS_DIR="$BASE_DIR/Servers"

# -----------------------------------------------------------------------------
# IriX — principe "comme Eggdrop" :
#   - SOURCE (géré par Service-Overtchat : fichiers .tcl, cmds json, versions)
#     => $HOME/Service-Overtchat/IriX
#   - INSTANCE (là où IriX tourne réellement, au même titre qu'un bot Eggdrop)
#     => $HOME/IriX par défaut, personnalisable par l'utilisateur
# -----------------------------------------------------------------------------
readonly IRIX_SOURCE_DIR="$BASE_DIR/IriX"
readonly IRIX_DEFAULT_INSTANCE_DIR="$TARGET_HOME/IriX"
readonly IRIX_FILE="$BASE_DIR/Config/irix.json"

# Sous-arborescence réelle d'une instance IriX (cf. Markdown/ARCHITECTURE.md).
# Les .tcl vivent dans cmds/, la conf Eggdrop et les fichiers d'état dans
# runtime/. Le script déposait auparavant tout à plat à la racine de l'instance.
readonly IRIX_CMDS_SUBDIR="cmds"
readonly IRIX_RUNTIME_SUBDIR="runtime"

# -----------------------------------------------------------------------------
# OverOp — les robots de prêt (cahier : Markdown/PRET-ROBOT.md, §3 et §11).
#
# Même découpage qu'IriX : une SOURCE synchronisée depuis le dépôt, et des
# INSTANCES qui tournent. La différence tient en un mot — il y en a PLUSIEURS.
# Un seul code Tcl, trois instances pour commencer (§3.1) : chacune dans son
# dossier, avec son pseudonyme, son port de telnet et son propre userfile.
#
# Ce ne sont PAS des modules d'IriX (§14), et ils ne sont donc ni dans
# `git.irix.files` ni déployés dans `cmds/` : ce sont des Eggdrop autonomes,
# connectés comme des clients ordinaires. Ils n'ouvrent aucun lien, ne touchent
# jamais la base, et n'ont aucun privilège réseau. Un robot qui tombe est une
# déconnexion parmi d'autres — c'est toute l'isolation du §4.
# -----------------------------------------------------------------------------
readonly OVEROP_SOURCE_DIR="$BASE_DIR/Robots/overop"
readonly OVEROP_DEFAULT_BASE_DIR="$TARGET_HOME/OverOp"
readonly OVEROP_FILE="$BASE_DIR/Config/overop.json"

# Port du telnet d'administration du premier robot ; les suivants prennent le
# premier libre au-dessus. 3341 laisse la place au 3333 d'IriX sans collision.
readonly OVEROP_PORT_BASE=3341

# ⚠ Le serveur n'accepte que TROIS connexions par adresse IP — `class clients`
# et le bloc `allow` d'overtchat.conf —, IriX comprise. Le robot de trop est
# refusé, ET LE MESSAGE N'APPARAÎT QUE CÔTÉ ROBOT : rien ne le signale côté
# exploitant, qui voit seulement un bot qui ne se connecte pas.
#
# Cet installateur AVERTIT quand le seuil est atteint. Il ne modifie PAS
# overtchat.conf : la configuration du serveur n'est pas à lui, et un bloc
# `allow` posé automatiquement relèverait une limite de sécurité sans que
# personne l'ait décidé.
readonly OVEROP_MAXPERIP=3

# -----------------------------------------------------------------------------
# Constantes du module
# -----------------------------------------------------------------------------

readonly UPDATE_BACKUP_DIR="$BASE_DIR/.backups/update"
readonly UPDATE_LOG="$BASE_DIR/Runtime/Logs/update.log"

# Fichiers noyau toujours surveillés (chemins relatifs à la racine du dépôt).
# Doit correspondre EXACTEMENT à .git.setup.
#
# C'est le SCRIPT qui est surveillé, plus le binaire compilé. Ce dernier a
# cessé d'être distribué le 29/07/2026 : shc repasse le script à bash comme un
# argument unique, or le noyau plafonne un argument à 131 072 octets et le
# script en fait plus du double. Le binaire ne pouvait donc plus être
# régénéré, et celui qui circulait datait de trois mois.
#
# Surveiller les deux aurait de toute façon posé problème : le scan des .sh
# ajoutait déjà overtchat-setup.sh, si bien que l'installateur était suivi deux
# fois sous deux noms, et une mise à jour pouvait réécrire l'un sans l'autre.
# ⚠ `VERSION` et `VERSION.md` sont surveillés ICI, et nulle part ailleurs :
# n'ayant pas d'extension pour le premier, ni d'extension surveillée pour le
# second, le scan dynamique ne les voyait pas. Le fichier `VERSION` n'était
# donc JAMAIS mis à jour — le script se remplaçait lui-même, redémarrait, et
# relisait l'ancien numéro. Il se serait cru éternellement à jour après
# s'être mis à jour, ce qui est le pire des deux mondes : la mise à jour a
# bien eu lieu, et plus rien ne le sait.
#
# ⚠⚠ LES TROIS `.py` SERVIS DE `Tools/`, ajoutés le 30/09/2026 — même trou,
# autre extension. `reserver-robot.sh` et `proteger-irix.sh` dépendent de
# `ecrire_sur.py` et `empreinte-mdp.py` depuis l'audit du 29/09 (et
# `proteger-irix.sh` d'`irix-conf-serverpass.py`), et `Web/inc/distrib.php` les
# sert ; mais le scan ne prend que sh/json/conf/tcl. Un tiers qui met à jour
# recevait donc `reserver-robot.sh` NEUF sans ses briques — et un tiers installé
# avant le 29/09 ne les a pas du tout : la réservation d'un robot échouait sur
# « empreinte non calculée ».
# 📌 Écrits ICI et non en ajoutant `py` au scan, pour deux raisons mesurées :
#   · un fichier de cette liste est suivi même ABSENT du disque (il passe
#     « en attente » et se télécharge) — le scan, lui, ne voit que ce qui existe ;
#   · `Tools/` porte cinq autres `.py` que le site NE SERT PAS
#     (`voix-serveur.py`, `nudite-serveur.py`…) : les scanner, c'est un 404
#     à chaque vérification, donc un courriel d'alerte toutes les six heures.
# ⚠ Liste RECOPIÉE de l'inventaire : `Web/tests/test-distrib.php` la croise
#   avec `distrib_inventaire()` — tout `.py` servi y est, aucun autre.
readonly -a CORE_WATCHED=(
    "Bootstrap/overtchat-setup.sh"
    "VERSION"
    "VERSION.md"
    "Tools/ecrire_sur.py"
    "Tools/empreinte-mdp.py"
    "Tools/irix-conf-serverpass.py"
)

# Extensions scannées dynamiquement dans BASE_DIR (tcl inclus pour IriX)
readonly -a WATCHED_EXTENSIONS=( "sh" "json" "conf" "tcl" )

readonly UPDATE_STATE="$BASE_DIR/Config/update_state.json"

readonly ACCESS_FILE="$BASE_DIR/Config/access.json"
readonly SERVICES_FILE="$BASE_DIR/Config/services.json"
readonly SERVERS_FILE="$BASE_DIR/Config/servers.json"
readonly EGGDROP_FILE="$BASE_DIR/Config/eggdrop.json"

# =============================================================================
# VERSIONS ÉPINGLÉES DES COMPOSANTS TIERS
# =============================================================================
#
# Un seul endroit. Les numéros vivaient auparavant à trois : le JSON écrit par
# _servers_init, celui de _services_init, et un `--branch v1.10.1` en dur au
# milieu de _install_eggdrop. Trois copies d'un même fait, donc deux occasions
# de mentir — et c'est toujours celle qu'on lit qui a tort.
#
# Ces valeurs ont été RELEVÉES le 30/07/2026, pas recopiées : code HTTP et
# taille pour les archives, tags de l'API GitHub pour les dépôts.
#
# Épingler plutôt que suivre « latest » est un choix : une URL versionnée se
# vérifie par empreinte et se rejoue à l'identique dans six mois, là où
# « latest » installe un jour autre chose sans qu'aucune ligne n'ait changé.
# Le prix est qu'il faut monter ces numéros à la main — c'est le but.
readonly VER_UNREALIRCD="6.2.6"        # unrealircd.org — archive directe
readonly VER_EGGDROP="1.10.2rc1"       # ftp.eggheads.org — voir l'avertissement ci-dessous
readonly VER_ANOPE="2.1.25"            # dernier tag de la branche 2.1 (module de protocole renommé)
readonly VER_HYBRID="8.2.47"           # ircd-hybrid.org renvoie vers l'archive GitHub
readonly VER_IRCU="u2.10.12.19"        # dernier tag d'undernetirc/ircu2 (2019)
readonly VER_INSPIRCD="4.11.0"         # inspircd.org, publiée le 06/06/2026 — compilée ici le 31/07/2026
readonly VER_BAHAMUT="2.2.4"           # DALnet/bahamut, dernier tag — compilé ici le 01/08/2026

# `1.10.2rc1` est une RELEASE CANDIDATE, choisie délibérément le 30/07/2026.
# La dernière version publiée est la 1.10.1 (16/08/2025). IriX tourne sur
# Eggdrop en production : si une régression apparaît, c'est la première chose
# à remettre en cause, et il suffit de reposer 1.10.1 ci-dessus.
readonly EGGDROP_EST_RC="oui"

readonly URL_UNREALIRCD="https://www.unrealircd.org/downloads/unrealircd-${VER_UNREALIRCD}.tar.gz"
# ⚠ En https depuis le 23/09/2026 : le miroir sert les MÊMES octets (empreinte
# identique mesurée), et l'http laissait n'importe quel intermédiaire les changer.
readonly URL_EGGDROP="https://ftp.eggheads.org/pub/eggdrop/source/1.10/eggdrop-${VER_EGGDROP}.tar.gz"
readonly URL_ANOPE="https://github.com/anope/anope/archive/refs/tags/${VER_ANOPE}.tar.gz"
readonly URL_HYBRID="https://github.com/ircd-hybrid/ircd-hybrid/archive/${VER_HYBRID}.tar.gz"
readonly URL_IRCU="https://github.com/undernetirc/ircu2/archive/refs/tags/${VER_IRCU}.tar.gz"
# Le « v » du tag est dans l'URL et pas dans le numéro : inspircd.org publie
# « v4.11.0 », mais le binaire s'annonce « InspIRCd-4.11.0 » et c'est ce
# dernier que le site affiche.
readonly URL_INSPIRCD="https://github.com/inspircd/inspircd/archive/refs/tags/v${VER_INSPIRCD}.tar.gz"
# Même convention de tag que pour InspIRCd — « v2.2.4 » —, vérifiée sur la
# liste des tags du dépôt DALnet plutôt que devinée. Bahamut n'a pas de site
# de téléchargement à lui : ses publications vivent sur GitHub.
readonly URL_BAHAMUT="https://github.com/DALnet/bahamut/archive/refs/tags/v${VER_BAHAMUT}.tar.gz"

# ── ⚠⚠ LES EMPREINTES SHA-256 ATTENDUES (23/09/2026, point 90 §12.B.12) ────
#
# Jusqu'ici seul l'en-tête gzip était contrôlé : une archive remplacée en
# chemin, ou chez l'éditeur, se compilait et s'installait sans un mot. Chaque
# empreinte a été MESURÉE le 23/09/2026 sur DEUX téléchargements identiques.
#
# ⚠ ELLES VONT AVEC LA VERSION : monter un VER_* sans remesurer son empreinte
#   fait REFUSER le téléchargement, en le disant — c'est voulu. Remesurer :
#     curl -fsSL "<URL>" | sha256sum
# ⚠ Les archives GitHub (`archive/refs/tags/…`) sont fabriquées à la volée ;
#   GitHub les garde stables depuis 2023. Si une empreinte GitHub change sans
#   que la version ait bougé, c'est ELLE qu'il faut examiner avant de remesurer.
readonly SHA256_UNREALIRCD="6eaf0f8963c2dc8c28ce580afa0239922e26c7b2df55aed7ec5f73daa307fc9b"
readonly SHA256_EGGDROP="a73cd7bb0499f0f81166f2909ff9fb3b8ae27ce261e020ce4473ac8c27a247b1"
readonly SHA256_ANOPE="d0837d752ced2cc3ad4a1fe84c7eb89ef2c6185634c4548b4eeb5ac77ae29621"
readonly SHA256_HYBRID="eb30d034f3934680bdf7b8db8e2126a422c5a965f9fc0b7b9f0b1b58ccddbad1"
readonly SHA256_IRCU="4f125876af2195b7ad9ebfa41664d2084bf42aab753ff1b1579a5ecd79e46fe1"
readonly SHA256_INSPIRCD="7f2ea0731a7e337e6ebaa1335bb22d576cba779baefaa311e15558624b5ca31a"
readonly SHA256_BAHAMUT="a629d6a54f0c1b1f3e505792d79a92d18fd51330525154a00ddbf97b38718092"
# mysqltcl (miroir Debian) et tcltls (révision FIGÉE du tronc, voir _tcltls_build).
readonly SHA256_MYSQLTCL="5b6e04430b80fd4af54599551503bae681232be0bae3c55c1a93adeb66702007"
readonly SHA256_TCLTLS="cf18a2dfe6743cb7a8626d5636136d8835593e63aef1b070cd505bac00d2cebf"


# =============================================================================
# TIMERS & TRAPS
# =============================================================================
_start_time=$(date +%s)
_end_time() { date -u -d "0 $1 sec" +"%Hh %Mm %Ss"; }
trap 'printf "\n%s\n" "Script interrompu. Nettoyage en cours..." >&2; exit 1' INT TERM

load_debug="0.5"

# =============================================================================
# COULEURS & AFFICHAGE
# =============================================================================
# ── ⚠⚠ LA PALETTE VIENT DU SITE, ELLE N'EST PAS INVENTÉE ICI ────────────────
#
# Demandé par SerVuS le 20/08/2026 : « adapter visuellement le panel
# correspondant au site ». Les valeurs ci-dessous sont celles des variables CSS
# de `Web/public/assets/site.css` — `--accent`, `--ok`, `--warn`, `--err`… —
# pour que le panel et le site se reconnaissent au premier coup d'œil.
#
# ⚠ Elles sont RECOPIÉES, et une copie dérive : `Web/tests/test-palette.php`
# croise les deux et rougit dès qu'une couleur change d'un côté sans l'autre.
# C'est la seule parade quand deux langages ne peuvent pas lire la même source.
#
# ⚠ Le repli n'est pas décoratif. Les couleurs 24 bits exigent un terminal qui
# les annonce (`$COLORTERM`) ; ailleurs — une console de secours, un `ssh` sec,
# un `cron` — les séquences s'afficheraient en clair et rendraient le panel
# ILLISIBLE. On retombe alors sur les huit couleurs ANSI d'origine.
if [[ -t 1 ]]; then
    if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then
        _rgb() { printf '\033[38;2;%d;%d;%dm' "$1" "$2" "$3"; }

        RED=$(_rgb 242  98 107)     # --err       #f2626b
        GREEN=$(_rgb  63 208 122)   # --ok        #3fd07a
        ORANGE=$(_rgb 232 185  63)  # --warn      #e8b93f
        CYAN=$(_rgb  53 208 192)    # --accent    #35d0c0  ← la couleur du réseau
        PURPLE=$(_rgb 169 132 255)  # --purple    #a984ff
        BLUE=$(_rgb  51  68  90)    # --border-hi #33445a  ← les cadres, discrets
        WHITE=$(_rgb 216 226 238)   # --text      #d8e2ee
        DIM=$(_rgb   85  99 122)    # --dim       #55637a

        # ⚠ `DIM` cesse d'être l'attribut ANSI « faible » pour devenir une
        # VRAIE couleur : l'attribut est rendu différemment d'un terminal à
        # l'autre, parfois pas du tout. Une couleur, elle, est la même partout.
        BOLD=$'\033[1m'; RESET=$'\033[0m'
        BG_BLUE=$'\033[48;2;19;27;36m'   # --surface #131b24
    else
        RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; ORANGE=$'\033[0;33m'
        BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'; PURPLE=$'\033[0;35m'
        BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m'
        BG_BLUE=$'\033[44m'; WHITE=$'\033[1;37m'
    fi
else
    RED=""; GREEN=""; ORANGE=""; BLUE=""; CYAN=""; PURPLE=""
    BOLD=""; DIM=""; RESET=""; BG_BLUE=""; WHITE=""
fi

# -----------------------------------------------------------------------------
# LA TRADUCTION — le socle. Voir la section « LANGUE » pour le catalogue.
# -----------------------------------------------------------------------------
#
# ⚠⚠ POSÉ ICI, AVANT LES HELPERS QUI S'EN SERVENT, ET PAS PLUS BAS.
#
# `_info` et ses frères appellent `_t`. En bash, l'ordre ne gêne que si un appel
# a lieu ENTRE les deux définitions — et vérifier qu'il n'y en a aucun dans
# 12 000 lignes est un raisonnement qu'il faudrait refaire à chaque ajout.
# 📌 *Mieux vaut supprimer la question que la trancher* : défini avant, il n'y
# a plus d'ordre à tenir.
#
# ⚠ `LANG_CODE` n'est connu que plus bas : `${LANG_CODE:-fr}` évite que `set -u`
#   fasse échouer un message affiché avant elle.
_TRAD_DISPO=0
if declare -A _MSG 2>/dev/null; then _TRAD_DISPO=1; fi

# ⚠⚠ RIEN NE PEUT DISPARAÎTRE ICI. Sans table, sans entrée, ou en français, on
#   rend le texte reçu — la traduction ne peut qu'AJOUTER, jamais retrancher.
#   C'est ce qui permet de traduire par petits lots sans casser l'écran entre
#   deux, et ce qui fait qu'une clé oubliée ne se voit pas comme un trou.
_t() {
    local s="$*"
    [[ "${LANG_CODE:-fr}" == "fr" || "$_TRAD_DISPO" -ne 1 ]] && { printf '%s' "$s"; return 0; }
    printf '%s' "${_MSG[$s]:-$s}"
    return 0
}

_log() { printf "[%s] [%s] %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "${FUNCNAME[1]:-main}" "$*" >> "$LOG_FILE"; }

_info() { printf " %b\n" "${CYAN}•${RESET} $(_t "$*")"; _log "INFO $*"; }
_ok()   { printf " %b\n" "${GREEN}✔${RESET} $(_t "$*")"; _log "OK $*"; }
_warn() { printf " %b\n" "${ORANGE}⚠${RESET} $(_t "$*")"; _log "WARN $*"; }
_err()  { printf " %b\n" "${RED}✖${RESET} $(_t "$*")" >&2; _log "ERROR $*"; }

# -----------------------------------------------------------------------------
# LES MESSAGES À VARIABLE — `_info_f` et ses frères
# -----------------------------------------------------------------------------
#
# ⚠⚠ POURQUOI ILS EXISTENT. `_info "Pose de $paquet…"` arrive à `_t` DÉJÀ
# ASSEMBLÉ : la clé porte le nom du paquet, et aucune entrée de catalogue ne
# peut correspondre. Mesuré le 06/09/2026 : **302 des 662 appels** sont dans ce
# cas — la moitié du travail.
#
#     _info_f "Pose de %s…" "$paquet"
#
# Le GABARIT est traduit, puis assemblé. La clé est stable, elle ne dépend plus
# de ce que la variable contient ce jour-là.
#
# ── ⚠⚠ LA GARDE QU'ON N'ATTEND PAS : COMPTER LES « % » ──
#
# Une traduction qui perdrait un `%s` — ou en ajouterait un — produirait un
# message tronqué, ou un `printf` qui consomme un argument qui n'existe pas.
# 🔴 Et ce serait SILENCIEUX : `printf` ne se plaint pas d'un argument en trop.
# On compare donc les spécificateurs, et **au moindre écart on retombe sur le
# français** — qui, lui, est forcément juste.
_specs() { printf '%s' "$1" | grep -oE '%[-0-9.]*[a-zA-Z]' | grep -cv '^%%$' || true; }

# Le gabarit FRANÇAIS assemblé — ce qui part au journal, quelle que soit la
# langue de l'écran.
_t_f_source() {
    local gab="$1"; shift
    # ⚠⚠ LE « -- » N'EST PAS DÉCORATIF. Un gabarit qui commence par un tiret —
    #   « --auto s'applique à l'installation… » — serait lu par `printf` comme
    #   une OPTION, et la ligne disparaîtrait de l'écran en silence. Trouvé le
    #   07/09/2026 par le banc qui rejoue les 208 conversions, pas à l'œil.
    # shellcheck disable=SC2059
    printf -- "$gab" "$@"
}

_t_f() {
    local gab="$1"; shift
    local trad; trad="$(_t "$gab")"
    # ⚠ Même gabarit rendu : rien à vérifier, c'est le français.
    if [[ "$trad" != "$gab" ]] && [[ "$(_specs "$trad")" != "$(_specs "$gab")" ]]; then
        # ⚠ On le DIT au journal : une traduction inutilisable doit se voir,
        #   sinon elle reste en place des années sans que personne le sache.
        _log "TRAD gabarit incompatible, retour au francais : $gab"
        trad="$gab"
    fi
    # ⚠ Même raison qu'au-dessus : un gabarit qui commence par un tiret.
    # shellcheck disable=SC2059
    printf -- "$trad" "$@"
}

# ⚠⚠ ILS N'APPELLENT PAS `_info` ET SES FRÈRES, ET C'EST DÉLIBÉRÉ.
#
# 🔴 Le premier jet faisait `_info "$(_t_f "$@")"`. Le message serait alors
# arrivé à `_info` DÉJÀ TRADUIT — et `_info` journalise ce qu'il reçoit. Le
# journal serait donc passé en anglais pour ces messages-là, et en français
# pour les autres : deux langues dans le même fichier, ce qui est pire que
# n'importe laquelle des deux.
#
# 📌 Ils composent donc les deux versions : le FRANÇAIS pour le journal, la
# TRADUITE pour l'écran. C'est trois lignes de plus, et la règle reste vraie
# partout — le journal ne dépend jamais de la langue de qui installe.
_info_f() {
    local fr; fr="$(_t_f_source "$@")"
    printf " %b\n" "${CYAN}•${RESET} $(_t_f "$@")"; _log "INFO $fr"
}
_ok_f() {
    local fr; fr="$(_t_f_source "$@")"
    printf " %b\n" "${GREEN}✔${RESET} $(_t_f "$@")"; _log "OK $fr"
}
_warn_f() {
    local fr; fr="$(_t_f_source "$@")"
    printf " %b\n" "${ORANGE}⚠${RESET} $(_t_f "$@")"; _log "WARN $fr"
}
_err_f() {
    local fr; fr="$(_t_f_source "$@")"
    printf " %b\n" "${RED}✖${RESET} $(_t_f "$@")" >&2; _log "ERROR $fr"
}

_die() { _err "$*"; exit 1; }

# -----------------------------------------------------------------------------
# RENDU — cadres, badges, jauges
#
# Tout l'affichage passe par ces primitives, pour une raison précise : aligner
# une colonne en Bash est un piège à trois détentes, et chacune s'est refermée
# ici avant d'être traitée.
#
#   1. `printf '%-32s'` rembourre en OCTETS. « Installé » fait 8 caractères mais
#      9 octets en UTF-8, donc chaque accent décalait la colonne d'un cran.
#      ${#chaine} compte les CARACTÈRES — à condition que la locale soit UTF-8,
#      ce qu'on ne peut pas supposer (voir _ui_locale plus bas).
#   2. Les séquences de couleur comptent dans ${#chaine} sans occuper de colonne
#      à l'écran. Une ligne colorée débordait donc systématiquement du cadre.
#      _ui_vlen les retire avant de mesurer.
#   3. La largeur du terminal n'est pas 80. _ui_width la lit, et la borne :
#      en dessous de 64 les cadres se replient sur eux-mêmes, au-dessus de 100
#      l'œil ne suit plus la ligne jusqu'à sa valeur.
#
# Les lignes de remplissage sont calculées UNE fois par largeur et découpées
# ensuite ; les recalculer à chaque ligne coûtait un sous-shell par ligne
# affichée, soit une centaine par écran de panel.
# -----------------------------------------------------------------------------
shopt -s extglob

# Sans locale UTF-8, ${#chaine} recompte en octets et tout l'alignement saute.
# On n'impose rien à l'utilisateur : on cherche une locale UTF-8 utilisable et,
# à défaut, on retombe sur C.UTF-8 que la glibc fournit toujours.
_ui_locale() {
    case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
        *UTF-8*|*utf8*|*UTF8*) return 0 ;;
    esac
    if locale -a 2>/dev/null | grep -qix 'C.UTF-8'; then
        export LC_CTYPE="C.UTF-8"
    fi
}
_ui_locale

UI_W=80; UI_SP=""; UI_LN=""; UI_DL=""
_ui_width() {
    local c
    c=$(tput cols 2>/dev/null || echo "${COLUMNS:-80}")
    [[ "$c" =~ ^[0-9]+$ ]] || c=80
    (( c < 64  )) && c=64
    (( c > 100 )) && c=100
    UI_W=$c
    printf -v UI_SP '%*s' "$UI_W" ''
    UI_LN="${UI_SP// /─}"
    UI_DL="${UI_SP// /═}"
}
_ui_width

# Longueur visible : sans les séquences ANSI, en caractères et non en octets.
_ui_vlen() { local s="${1//$'\033'\[*([0-9;])[a-zA-Z]/}"; printf '%s' "${#s}"; }

# Rembourrage à droite pour atteindre la colonne voulue.
_ui_pad() {
    local n=$(( $2 - $(_ui_vlen "$1") ))
    (( n < 0 )) && n=0
    printf '%s' "${UI_SP:0:n}"
}

_ui_top()   { printf "${BLUE}┌%s┐${RESET}\n" "${UI_LN:0:UI_W-2}"; }
_ui_mid()   { printf "${BLUE}├%s┤${RESET}\n" "${UI_LN:0:UI_W-2}"; }
_ui_bot()   { printf "${BLUE}└%s┘${RESET}\n" "${UI_LN:0:UI_W-2}"; }
_ui_blank() { printf "${BLUE}│%s│${RESET}\n" "${UI_SP:0:UI_W-2}"; }

# Coupe un texte à la largeur VISIBLE demandée, et le DIT par une ellipse.
#
# ⚠⚠ AJOUTÉ LE 28/08/2026, SUR UN DÉFAUT MESURÉ. `_ui_pad` borne son
# rembourrage à zéro — donc une ligne plus longue que le cadre ne se replie pas :
# elle POUSSE LA BORDURE DROITE HORS DU CADRE. Mesuré sur un cadre de 40
# colonnes : une ligne de 64 caractères sort de 24, le cadre se déchire sur
# cette ligne et se referme sur la suivante.
#
# Ce n'est pas un cas d'école. `_ui_width` a pour plancher **64 colonnes**, et le
# sous-titre du bandeau porte `version · user@host · BASE_DIR` — sur une
# installation dans `/home/quelquun/service-overtchat`, il dépasse largement.
#
# ⚠ LA COUPE RETIRE LES COULEURS de la ligne coupée, et c'est délibéré : couper
# au milieu d'une séquence d'échappement laisserait un fragment à l'écran, ou
# pire, une couleur qui ne se referme jamais et déteint sur tout ce qui suit.
# Perdre la couleur d'une ligne trop longue est le moindre mal.
#
# ⚠ Et elle DIT qu'elle coupe. Une ligne tronquée en silence fait lire un chemin
# faux — c'est la même règle que `LISTE_MAX` et que la liste des présents.
_ui_couper() {
    local t="$1" max="$2" vis
    vis=$(_ui_vlen "$t")
    (( vis <= max )) && { printf '%s' "$t"; return 0; }
    (( max < 1 )) && return 0
    local nu="${t//$'\033'\[*([0-9;])[a-zA-Z]/}"
    printf '%s…' "${nu:0:max-1}"
}

# Ligne de contenu dans un cadre.
_ui_row() {
    local t
    t="$(_ui_couper "$1" $(( UI_W - 4 )))"
    printf "${BLUE}│${RESET} %b%s ${BLUE}│${RESET}\n" "$t" "$(_ui_pad "$t" $(( UI_W - 4 )))"
}

# Intertitre à l'intérieur d'un cadre.
_ui_head() { _ui_mid; _ui_row "${BOLD}$1${RESET}"; _ui_mid; }

# Bandeau de titre. Le sous-titre porte le contexte (qui, où) : sans lui, deux
# panels de machines différentes sont impossibles à distinguer sur une capture.
_ui_title() {
    local txt sub
    # ⚠ Le sous-titre est le PREMIER à déborder : il porte le chemin
    # d'installation, qui n'a aucune longueur prévisible.
    txt="$(_ui_couper "$1" $(( UI_W - 4 )))"
    sub="$(_ui_couper "${2:-}" $(( UI_W - 4 )))"
    printf "\n${BLUE}╔%s╗${RESET}\n" "${UI_DL:0:UI_W-2}"
    printf "${BLUE}║${RESET} ${BOLD}${WHITE}%b${RESET}%s ${BLUE}║${RESET}\n" \
        "$txt" "$(_ui_pad "$txt" $(( UI_W - 4 )))"
    [[ -n "$sub" ]] && printf "${BLUE}║${RESET} ${DIM}%b${RESET}%s ${BLUE}║${RESET}\n" \
        "$sub" "$(_ui_pad "$sub" $(( UI_W - 4 )))"
    printf "${BLUE}╚%s╝${RESET}\n" "${UI_DL:0:UI_W-2}"
}

# Pastille d'état. Cinq états seulement, et un symbole distinct pour chacun :
# la couleur seule ne suffit pas — un terminal monochrome, une capture en noir
# et blanc ou un daltonisme rendraient « actif » et « en erreur » identiques.
_ui_badge() {
    local c s t="${2:-}"
    case "$1" in
        ok)   c="$GREEN";  s="●"; t="${t:-actif}"     ;;
        run)  c="$GREEN";  s="▶"; t="${t:-en marche}" ;;
        warn) c="$ORANGE"; s="◐"; t="${t:-partiel}"   ;;
        err)  c="$RED";    s="✖"; t="${t:-en erreur}" ;;
        off)  c="$DIM";    s="○"; t="${t:-absent}"    ;;
        *)    c="$DIM";    s="·"; t="${t:-?}"         ;;
    esac
    printf '%s' "${c}${s} ${t}${RESET}"
}

# Jauge de progression. Le compte chiffré reste affiché à côté : une barre
# seule ne dit pas si « à moitié pleine » signifie 2/4 ou 500/1000.
_ui_bar() {
    local n="$1" tot="$2" w="${3:-20}" f c pct
    (( tot <= 0 )) && tot=1
    (( n < 0 )) && n=0
    (( n > tot )) && n=tot
    f=$(( n * w / tot )); pct=$(( n * 100 / tot ))
    if   (( pct >= 100 )); then c="$GREEN"
    elif (( pct >= 50  )); then c="$ORANGE"
    else                        c="$RED"; fi
    local plein="${UI_SP:0:f}" vide="${UI_SP:0:w-f}"
    printf '%s' "${c}${plein// /█}${RESET}${DIM}${vide// /░}${RESET} ${BOLD}${n}/${tot}${RESET}"
}

# Couple clé/valeur aligné dans un cadre.
_ui_kv() { _ui_row "${DIM}${1}${RESET}$(_ui_pad "$1" 26)${BOLD}${2}${RESET}"; }

# Entrée de menu. Une entrée verrouillée reste VISIBLE, en grisé, avec la raison
# du verrou en dessous : la faire disparaître laissait l'utilisateur chercher
# une option qu'il avait vue la veille sans comprendre ce qui l'avait retirée.
_ui_item() {
    if [[ -n "${3:-}" ]]; then
        _ui_row "${DIM}[${1}]${RESET} ${DIM}${2}${RESET}"
        _ui_row "     ${ORANGE}↳${RESET} ${DIM}${3}${RESET}"
    else
        _ui_row "${CYAN}${BOLD}[${1}]${RESET} ${2}"
    fi
}

# Invite de saisie, uniforme dans tout le panel.
_ui_prompt() { printf "\n  ${BOLD}${CYAN}▸${RESET} "; }

# -----------------------------------------------------------------------------
# _logo — écran d'accueil
#
# C'est une FONCTION, appelée depuis main(), et non un bloc exécuté à la
# première ligne du fichier. L'ancienne version s'affichait avant même que
# VERSION soit lu, ce qui l'obligeait à annoncer un numéro en dur : elle
# proclamait « V3.X.X » pendant que le fichier VERSION disait 4.6.0 — le
# défaut que l'en-tête de ce script interdit explicitement.
#
# Le `clear` est conditionné au terminal : appliqué sans condition, il purgeait
# l'écran de qui redirigeait la sortie vers un fichier ou lisait `--help` dans
# un tube, et brouillait la sortie des tests automatisés.
# -----------------------------------------------------------------------------
# Ligne centrée dans un cadre : _ui_center <texte> [couleur]
_ui_center() {
    local t="$1" col="${2:-}" vis g d
    vis=$(_ui_vlen "$t")
    g=$(( (UI_W - 2 - vis) / 2 )); (( g < 0 )) && g=0
    d=$(( UI_W - 2 - vis - g ));   (( d < 0 )) && d=0
    printf "${BLUE}║${RESET}%s%b%s${BLUE}║${RESET}\n" \
        "${UI_SP:0:g}" "${col}${t}${RESET}" "${UI_SP:0:d}"
}

_logo() {
    [[ -t 1 ]] || return 0
    clear
    _ui_width
    printf "\n${BLUE}╔%s╗${RESET}\n" "${UI_DL:0:UI_W-2}"
    _ui_center "SERVICE OVERTCHAT" "${BOLD}${WHITE}"
    _ui_center "New Generation"    "${DIM}"
    printf "${BLUE}║${RESET}%s${BLUE}║${RESET}\n" "${UI_SP:0:UI_W-2}"
    _ui_center "version ${VERSION}" "${CYAN}${BOLD}"
    printf "${BLUE}╠%s╣${RESET}\n" "${UI_DL:0:UI_W-2}"
    _ui_center "Copyright @SerVuS@"        "${DIM}"
    _ui_center "support.overtchat@free.fr" "${DIM}"
    printf "${BLUE}╚%s╝${RESET}\n" "${UI_DL:0:UI_W-2}"
}

# -----------------------------------------------------------------------------
# Compatibilité : les anciennes primitives restent, redirigées vers le nouveau
# rendu. Une centaine d'appels à _title/_kv/_sep existent dans ce fichier —
# les réécrire tous d'un coup aurait mélangé la refonte visuelle et le risque
# de casser un panel qui marchait.
# -----------------------------------------------------------------------------
# Le reste de ligne est borné à zéro : sur un terminal étroit, un titre long
# rendait la longueur négative, et bash abandonne sur « substring expression < 0 ».
_section() {
    local n=$(( UI_W - 5 - $(_ui_vlen "$*") ))
    (( n < 0 )) && n=0
    printf "\n${BOLD}${BLUE}══ %s ${RESET}${BLUE}%s${RESET}\n" "$*" "${UI_LN:0:n}"
}
_title()   { _ui_width; _ui_title "$*"; }
_kv()      { printf " ${DIM}%s${RESET}%s ${BOLD}%b${RESET}\n" "$1" "$(_ui_pad "$1" 30)" "$2"; }
_sep()     { printf "${DIM}%s${RESET}\n" "${UI_LN:0:UI_W}"; }
_pause() {
    # ⚠ En AUTO, ce `read` ne trouverait personne : il rendrait immédiatement
    # sur EOF si l'entrée est fermée, ou attendrait sans fin si elle ne l'est
    # pas — un blocage muet, au milieu d'une installation. On saute la pause,
    # et on le DIT : une pause sautée en silence ferait croire à une pause
    # honorée.
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        printf '\n  %s\n' "${DIM}[auto — on continue sans attendre]${RESET}"
        return 0
    fi
    read -rp $'\n  '"${DIM}[Entrée pour continuer]${RESET}" _ || true
}

# =============================================================================
# LANGUE
# =============================================================================
# LANG_CODE sert encore : il est écrit dans le JSON de configuration et relu
# par le site. En revanche la fonction msg(), qui devait choisir entre une
# chaîne française et une anglaise, n'a jamais été appelée nulle part — les
# 5 600 lignes de ce script sont écrites en français en dur. Elle a été
# retirée le 29/07/2026 : une traduction qui n'existe pas ne doit pas laisser
# croire, à la lecture, que le script est bilingue.
_detect_lang() { case "${LANG:-fr}" in fr*|FR*) echo "fr";; *) echo "en";; esac; }
LANG_CODE="$(_detect_lang)"

[[ -f "$INIT_BASE" ]] && LANG_CODE=$(jq -r '.language // "fr"' "$INIT_BASE" 2>/dev/null || echo "$LANG_CODE")

# -----------------------------------------------------------------------------
# LA TRADUCTION — posée le 06/09/2026, à la demande de SerVuS
# -----------------------------------------------------------------------------
#
# « si site = Fr alors tout en Fr et pareil pour overtchat-setup.sh sinon par
#   défaut en anglais … gère au mieux »
#
# ── ⚠⚠ LE FRANÇAIS EST LA SOURCE, PAS UNE TRADUCTION PARMI D'AUTRES ──
#
# `_t` prend le TEXTE FRANÇAIS et rend sa traduction si elle existe. Il n'y a
# donc **aucune clé à inventer**, aucun fichier `fr` à tenir, et surtout :
# **une traduction manquante rend le français**, jamais une chaîne vide ni un
# identifiant technique. C'est ce qui permet de traduire par petits lots sans
# jamais casser l'écran entre deux.
#
# 📌 Et le gain tient en quelques lignes : les messages passent déjà tous par
# une poignée de helpers. En traduisant DANS les helpers, les ~1 000 sites
# d'appel n'ont pas une virgule à changer.
#
# ── ⚠ LE JOURNAL RESTE EN FRANÇAIS, ET C'EST DÉLIBÉRÉ ──
#
# `_log` reçoit le texte SOURCE, jamais le traduit. Un journal écrit dans la
# langue de l'exploitant du jour serait illisible pour quiconque l'aide ensuite,
# et deux installations ne se compareraient plus. L'écran s'adresse à l'humain
# présent ; le journal, à celui qui viendra après.

# Un catalogue qu'on peut SOURCER sans danger (audit #6, 29/09/2026) :
#   · ni lui ni son dossier ne sont des liens ;
#   · ils appartiennent à root, à qui lance, ou — sous sudo — à SUDO_USER, qui
#     pourrait de toute façon lancer n'importe quoi en root ;
#   · personne d'AUTRE ne peut les modifier : jamais d'écriture pour « les
#     autres » (un dossier sticky comme /tmp en est : refusé) ; l'écriture de
#     GROUPE n'est admise que pour le groupe PRIVÉ du propriétaire (même nom,
#     aucun autre membre) — le `umask 002` d'Ubuntu, sans quoi un clone
#     ordinaire y perdrait sa traduction.
_catalogue_sur() {
    local f="$1" d x uid gid mode moi sudo_uid="" nom_p nom_g membres
    moi="${EUID:-$(id -u)}"
    if [[ "$moi" -eq 0 && -n "${SUDO_USER:-}" ]]; then
        sudo_uid="$(id -u -- "$SUDO_USER" 2>/dev/null || true)"
    fi
    [[ -f "$f" && ! -L "$f" ]] || return 1
    d="$(dirname -- "$f")"
    [[ -d "$d" && ! -L "$d" ]] || return 1
    for x in "$f" "$d"; do
        read -r uid gid mode < <(stat -c '%u %g %a' -- "$x" 2>/dev/null) || return 1
        [[ "$uid" == 0 || "$uid" == "$moi" || ( -n "$sudo_uid" && "$uid" == "$sudo_uid" ) ]] || return 1
        [[ "$mode" =~ ^[0-7]+$ ]] || return 1
        (( (8#$mode & 8#002) == 0 )) || return 1
        if (( (8#$mode & 8#020) != 0 )); then
            nom_p="$(getent passwd "$uid" 2>/dev/null | cut -d: -f1)"
            IFS=: read -r nom_g _ _ membres < <(getent group "$gid" 2>/dev/null) || return 1
            [[ -n "$nom_p" && "$nom_g" == "$nom_p" && -z "$membres" ]] || return 1
        fi
    done
    return 0
}

# Charge le catalogue de la langue courante, s'il existe. Un catalogue absent
# n'est pas une erreur : c'est le cas du français, qui n'en a pas besoin.
_charger_langue() {
    [[ "$_TRAD_DISPO" -eq 1 ]] || return 0
    [[ "$LANG_CODE" == "fr" ]] && return 0
    local f="${SCRIPT_DIR:-$(dirname "${BASH_SOURCE[0]}")}/langues/${LANG_CODE}.sh"
    # ⚠ On ne SOURCE que ce qui existe et n'est pas vide : un `source` d'un
    #   fichier absent ferait rougir `set -u` bien plus loin, sur une ligne sans
    #   rapport.
    if [[ -s "$f" ]]; then
        # 🔴 ET SEULEMENT S'IL EST SÛR — audit du 29/09/2026, constat #6.
        #   Lancé depuis /tmp (le Lanceur y déposait l'installateur), le
        #   catalogue était `/tmp/langues/en.sh` : n'importe quel compte local
        #   pouvait le poser, et root l'EXÉCUTAIT. On le SAUTE donc, en le
        #   disant, s'il n'appartient ni à root ni à qui lance, ou si lui ou
        #   son dossier sont modifiables par d'autres.
        if ! _catalogue_sur "$f"; then
            _log "TRAD catalogue IGNORÉ (propriétaire ou droits non sûrs) : $f"
            # ⚠ En ANGLAIS, comme le message d'absence plus bas : le catalogue
            #   n'est pas chargé, `_t` ne traduirait rien.
            printf ' %s\n' "Language catalogue IGNORED — it is not owned by root or by you, or it (or its folder) is writable by others. Continuing in French." >&2
            printf ' %s\n' "   file: $f" >&2
            return 0
        fi
        source "$f"
        return 0
    fi
    # ⚠⚠ ET ON LE DIT. Un exploitant qui télécharge le seul script n'a pas le
    #   dossier `langues/` : tout resterait en français, proprement, et il
    #   n'aurait AUCUN moyen de savoir pourquoi. Le repli silencieux est bon
    #   pour une phrase manquante, pas pour un catalogue entier.
    _log "TRAD catalogue introuvable pour la langue $LANG_CODE : $f"
    # ⚠ En ANGLAIS : celui qui a choisi une autre langue ne lit peut-être pas
    #   le français, et ce message-ci est justement celui qu'il doit comprendre.
    printf ' %s\n' "Language catalogue not found for ${LANG_CODE} — continuing in French." >&2
    printf ' %s\n' "   expected: $f" >&2
    return 0
}

_charger_langue

# =============================================================================
# DÉPENDANCES
# =============================================================================
# -----------------------------------------------------------------------------
# COUCHE PRIVILÈGES
#
# Le script s'utilise en utilisateur simple : tout est installé sous $HOME et
# rien ne doit appartenir à root. Seule l'installation de paquets système exige
# une élévation. Trois modes possibles :
#
#   user  — utilisateur simple, aucune élévation disponible (ni root, ni sudo)
#           → les paquets manquants sont listés, pas installés
#   sudo  — utilisateur simple avec sudo utilisable
#           → élévation ponctuelle pour les paquets uniquement
#   root  — script lancé en root
#           → gestion automatisée ; les fichiers de $HOME sont réattribués à
#             l'utilisateur d'origine via $SUDO_USER pour ne pas créer de
#             fichiers root dans un home utilisateur
# -----------------------------------------------------------------------------
PRIV_MODE="user"
PRIV_USER=""
PRIV_HOME=""

_priv_detect() {
    if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
        PRIV_MODE="root"
        # Sous sudo, on retient le compte appelant pour ne pas semer du root
        # dans son $HOME.
        if [[ -n "${SUDO_USER:-}" && "$SUDO_USER" != "root" ]]; then
            PRIV_USER="$SUDO_USER"
            PRIV_HOME=$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6 || true)
            [[ -z "$PRIV_HOME" ]] && PRIV_HOME="/home/$SUDO_USER"
        elif PRIV_USER="$(_compte_designe)"; then
            # OVERTCHAT_COMPTE (audit #8) : traité comme SUDO_USER — les
            # fichiers de son foyer lui sont rendus (`_priv_fix_owner`).
            PRIV_HOME=$(getent passwd "$PRIV_USER" 2>/dev/null | cut -d: -f6 || true)
            [[ -z "$PRIV_HOME" ]] && PRIV_HOME="/home/$PRIV_USER"
        else
            PRIV_USER="root"
            PRIV_HOME="${HOME:-/root}"
        fi
    else
        PRIV_USER="$(id -un)"
        PRIV_HOME="${HOME:-$(getent passwd "$PRIV_USER" | cut -d: -f6)}"
        if command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then
            PRIV_MODE="sudo"
        elif command -v sudo &>/dev/null; then
            # sudo présent mais demandera un mot de passe : utilisable, à
            # confirmer au moment de l'appel.
            PRIV_MODE="sudo"
        else
            PRIV_MODE="user"
        fi
    fi
    export PRIV_MODE PRIV_USER PRIV_HOME
}

# Exécute une commande avec les privilèges nécessaires. Retourne 1 si aucune
# élévation n'est possible — l'appelant doit gérer le cas, jamais l'ignorer.
_as_root() {
    case "$PRIV_MODE" in
        root) "$@" ;;
        sudo)
            # ⚠⚠ `sudo` sans `-n` ouvre une invite de mot de passe. Devant un
            # humain c'est ce qu'il faut ; en AUTO, personne ne la remplira et
            # le script attendrait là, sans fin et sans message — la panne la
            # plus pénible à diagnostiquer, parce qu'elle ne dit rien. `-n`
            # échoue tout de suite, et l'échec se lit.
            # ⚠ `_auto_prevol` a déjà exigé que `sudo -n` passe : arriver ici
            # en échec signifie que le droit a été retiré en cours de route.
            if [[ "${MODE_AUTO:-0}" == "1" ]]; then
                sudo -n "$@"
            else
                sudo "$@"
            fi
            ;;
        *)    _err "Élévation impossible (ni root ni sudo) : $*"; return 1 ;;
    esac
}

_priv_can_elevate() { [[ "$PRIV_MODE" == "root" || "$PRIV_MODE" == "sudo" ]]; }

# Réattribue un chemin à l'utilisateur d'origine quand on tourne en root.
_priv_fix_owner() {
    local path="${1:-}"
    [[ -n "$path" && -e "$path" ]] || return 0
    [[ "$PRIV_MODE" == "root" && "$PRIV_USER" != "root" ]] || return 0
    chown -R "$PRIV_USER":"$(id -gn "$PRIV_USER" 2>/dev/null || echo "$PRIV_USER")" "$path" 2>/dev/null || true
}

_priv_label() {
    case "$PRIV_MODE" in
        root) echo "root (gestion automatisée)" ;;
        sudo) echo "$PRIV_USER + sudo" ;;
        *)    echo "$PRIV_USER (sans élévation)" ;;
    esac
}

# -----------------------------------------------------------------------------
# COUCHE PAQUETS — détection de distribution et correspondance des noms
# -----------------------------------------------------------------------------
PKG_FAMILY=""
PKG_MGR=""

_pkg_detect() {
    if   command -v apt-get &>/dev/null; then PKG_FAMILY="debian"; PKG_MGR="apt-get"
    elif command -v dnf     &>/dev/null; then PKG_FAMILY="rhel";   PKG_MGR="dnf"
    elif command -v yum     &>/dev/null; then PKG_FAMILY="rhel";   PKG_MGR="yum"
    elif command -v pacman  &>/dev/null; then PKG_FAMILY="arch";   PKG_MGR="pacman"
    elif command -v zypper  &>/dev/null; then PKG_FAMILY="suse";   PKG_MGR="zypper"
    elif command -v apk     &>/dev/null; then PKG_FAMILY="alpine"; PKG_MGR="apk"
    else PKG_FAMILY="inconnu"; PKG_MGR=""
    fi
    export PKG_FAMILY PKG_MGR
}

# Chemin par défaut du socket MariaDB, selon la distribution. Debian/Ubuntu
# posent le socket sous /var/run/mysqld/, les autres (Fedora, RHEL, Arch, SUSE)
# sous /var/lib/mysql/. ⚠ Mesuré sur Fedora 44 : un chemin Debian codé en dur y
# désigne un socket inexistant → le site retombe en TCP et, en WSL mirrored,
# tombe sur la MariaDB de la prod (mauvais compte). Le défaut inconnu reste
# Debian, pour ne rien changer à une installation existante.
_mariadb_sock_defaut() {
    case "${PKG_FAMILY:-}" in
        rhel|arch|suse|alpine) echo "/var/lib/mysql/mysql.sock" ;;
        *)                     echo "/var/run/mysqld/mysqld.sock" ;;
    esac
}

# _pkg_name <nom-générique> — traduit vers le nom réel du paquet.
# Une chaîne vide signifie « fourni par la base du système », donc rien à faire.
_pkg_name() {
    local g="$1"
    # ⚠ awk est présumé présent (Debian/Ubuntu le préinstallent) mais MANQUE sur
    # une Fedora WSL nue — d'où « awk: command not found » à l'init des services.
    # Le paquet s'appelle « gawk » sur toutes les familles.
    [[ "$g" == "awk" ]] && { echo "gawk"; return; }
    case "$PKG_FAMILY" in
        debian) case "$g" in
            build)     echo "build-essential" ;; gcc)   echo "gcc" ;;
            make)      echo "make" ;;            cmake) echo "cmake" ;;
            git)       echo "git" ;;             curl)  echo "curl" ;;
            jq)        echo "jq" ;;              openssl) echo "openssl" ;;
            tcl)       echo "tcl" ;;             tcl-dev) echo "tcl-dev" ;;
            ssl-dev)   echo "libssl-dev" ;;      zlib-dev) echo "zlib1g-dev" ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            # ⚠⚠ « mariadb-client » ET NON « default-mysql-client » — 24/08/2026.
            # Ce métapaquet CHANGE DE SENS selon la distribution : sur Debian il
            # mène à MariaDB, sur Ubuntu il tire MySQL 8 — qui entre en conflit
            # avec mariadb-server et rend « held broken packages ». Or la famille
            # `debian` couvre les deux : `_pkg_family` rend « debian » pour Ubuntu
            # aussi, par ID_LIKE. Un exploitant Ubuntu qui suivait l'installateur
            # obtenait donc un client MySQL et ne pouvait plus poser le serveur que
            # tout le reste du projet suppose — l'écran de configuration demande
            # littéralement « Hôte MariaDB ».
            #
            # ⚠ Les quatre autres familles nommaient DÉJÀ MariaDB explicitement
            # (`mariadb`, `mariadb-clients`, `mariadb-client`). `debian` était la
            # seule à passer par un métapaquet ambigu : ceci l'aligne.
            #
            # ⚠ Mesuré sur les deux : le paquet est installé en production (Debian
            # 13, 1:11.8.6) et disponible sur Ubuntu 24.04 (1:10.11.14). Rien ne
            # régresse ici.
            pkgconfig) echo "pkg-config" ;;      mysql-client) echo "mariadb-client" ;;
            tcl-mysql) echo "mysqltcl" ;;        tcllib) echo "tcllib" ;;
            tcl-tls)   echo "tcl-tls" ;;
            curl-dev)  echo "libcurl4-openssl-dev" ;; pcre2-dev) echo "libpcre2-dev" ;;
            argon2-dev) echo "libargon2-dev" ;;  sodium-dev) echo "libsodium-dev" ;;
            jansson-dev) echo "libjansson-dev" ;;
            tar) echo "tar" ;;                  php) echo "php-cli" ;;
            php-mysql) echo "php-mysql" ;;      php-mbstring) echo "php-mbstring" ;;
            # « php-gd » : le MÉTAPAQUET, comme php-mysql et php-mbstring — il
            # tire php8.3-gd sur Ubuntu 24.04, php8.4-gd sur Debian 13. Nommer
            # « php8.x-gd » lierait l'installateur à une version de PHP.
            php-gd) echo "php-gd" ;;
            # Le métapaquet aussi (php8.4-fpm sur Debian 13) : pour servir
            # phpMyAdmin sans mod_php, et libérer le MPM event (05/10/2026).
            php-fpm) echo "php-fpm" ;;
            mysql-server) echo "mariadb-server" ;; postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            python-venv) echo "python3-venv" ;;  python-pip) echo "python3-pip" ;;
            # 17/10/2026 (lot J2) : Node.js pour OverCore — le paquet de la distribution,
            # JAMAIS un dépôt tiers (NodeSource : décision du 17/10). Mesuré : Debian 13
            # rend 20.19.2 ; une distribution qui rend moins que le plancher (voir
            # `_overcore_node_pret`) garde Eggdrop + IriX, et le DIT.
            nodejs) echo "nodejs" ;;
            *) echo "$g" ;; esac ;;
        rhel) case "$g" in
            build)     echo "gcc" ;;             gcc)   echo "gcc" ;;
            make)      echo "make" ;;            cmake) echo "cmake" ;;
            git)       echo "git" ;;             curl)  echo "curl" ;;
            jq)        echo "jq" ;;              openssl) echo "openssl" ;;
            tcl)       echo "tcl" ;;             tcl-dev) echo "tcl-devel" ;;
            ssl-dev)   echo "openssl-devel" ;;
            # ⚠ Fedora 40+ est passé à zlib-ng : « zlib-devel » n'y existe plus
            # (remplacé par zlib-ng-compat-devel). RHEL/Alma/Oracle gardent le nom
            # classique — d'où la détection, la famille « rhel » couvrant les deux.
            zlib-dev)  if grep -qs '^ID=fedora' /etc/os-release; then
                           echo "zlib-ng-compat-devel"
                       else echo "zlib-devel"; fi ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            pkgconfig) echo "pkgconf-pkg-config" ;; mysql-client) echo "mariadb" ;;
            tcl-mysql) echo "tcl-mysqltcl" ;;                tcllib) echo "tcllib" ;;
            # ⚠ Fedora n'empaquette PAS tcl-tls (absent des dépôts). Le nommer
            # ferait échouer TOUTE la transaction dnf (groupée) — donc rien ne
            # s'installerait. On le déclare indisponible sous Fedora (générique
            # vide => _pkg_install avertit et continue) : IriX perd le TLS Tcl
            # là-bas, mais le reste s'installe. RHEL/Alma le fournissent.
            tcl-tls)   if grep -qs '^ID=fedora' /etc/os-release; then
                           echo ""
                       else echo "tcl-tls"; fi ;;
            curl-dev)  echo "libcurl-devel" ;;       pcre2-dev) echo "pcre2-devel" ;;
            argon2-dev) echo "libargon2-devel" ;; sodium-dev) echo "libsodium-devel" ;;
            jansson-dev) echo "jansson-devel" ;;
            tar) echo "tar" ;;                  php) echo "php-cli" ;;
            php-mysql) echo "php-mysqlnd" ;;    php-mbstring) echo "php-mbstring" ;;
            php-gd) echo "php-gd" ;;            php-fpm) echo "php-fpm" ;;
            mysql-server) echo "mariadb-server" ;; postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            # ⚠ venv est fourni par la base python3 sur Fedora/RHEL (générique
            # vide = rien à installer) ; seul pip est un paquet séparé.
            python-venv) echo "" ;;              python-pip) echo "python3-pip" ;;
            nodejs) echo "nodejs" ;;   # 17/10/2026 : nom probable, non mesuré
            *) echo "$g" ;; esac ;;
        arch) case "$g" in
            build)     echo "base-devel" ;;      gcc)   echo "gcc" ;;
            make)      echo "make" ;;            cmake) echo "cmake" ;;
            git)       echo "git" ;;             curl)  echo "curl" ;;
            jq)        echo "jq" ;;              openssl) echo "openssl" ;;
            tcl)       echo "tcl" ;;             tcl-dev) echo "tcl" ;;
            ssl-dev)   echo "openssl" ;;         zlib-dev) echo "zlib" ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            pkgconfig) echo "pkgconf" ;;         mysql-client) echo "mariadb-clients" ;;
            tcl-mysql) echo "" ;;                tcllib) echo "tcllib" ;;
            tcl-tls)   echo "tcltls" ;;
            curl-dev)  echo "curl" ;;                pcre2-dev) echo "pcre2" ;;
            argon2-dev) echo "argon2" ;;          sodium-dev) echo "libsodium" ;;
            jansson-dev) echo "jansson" ;;
            tar) echo "tar" ;;                  php) echo "php" ;;
            php-mysql) echo "php" ;;            php-mbstring) echo "php" ;;
            # ⚠ GD est un paquet À PART sur Arch (pas dans « php »), et son
            # extension reste à décommenter dans php.ini — non mesuré ici.
            php-gd) echo "php-gd" ;;            php-fpm) echo "php-fpm" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            python-venv) echo "" ;;              python-pip) echo "" ;;
            nodejs) echo "nodejs" ;;   # 17/10/2026 : nom probable, non mesuré
            *) echo "$g" ;; esac ;;
        suse) case "$g" in
            build)     echo "patterns-devel-base-devel_basis" ;; gcc) echo "gcc" ;;
            make)      echo "make" ;;            cmake) echo "cmake" ;;
            git)       echo "git" ;;             curl)  echo "curl" ;;
            jq)        echo "jq" ;;              openssl) echo "openssl" ;;
            tcl)       echo "tcl" ;;             tcl-dev) echo "tcl-devel" ;;
            ssl-dev)   echo "libopenssl-devel" ;; zlib-dev) echo "zlib-devel" ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            pkgconfig) echo "pkg-config" ;;      mysql-client) echo "mariadb-client" ;;
            tcl-mysql) echo "" ;;                tcllib) echo "tcllib" ;;
            tcl-tls)   echo "tcl-tls" ;;
            curl-dev)  echo "libcurl-devel" ;;       pcre2-dev) echo "pcre2-devel" ;;
            argon2-dev) echo "libargon2-devel" ;; sodium-dev) echo "libsodium-devel" ;;
            jansson-dev) echo "libjansson-devel" ;;
            tar) echo "tar" ;;                  php) echo "php8-cli" ;;
            php-mysql) echo "php8-mysql" ;;     php-mbstring) echo "php8-mbstring" ;;
            php-gd) echo "php8-gd" ;;           php-fpm) echo "php8-fpm" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            python-venv) echo "" ;;              python-pip) echo "python3-pip" ;;
            nodejs) echo "nodejs" ;;   # 17/10/2026 : non mesuré (zypper résout la capacité « nodejs »)
            *) echo "$g" ;; esac ;;
        alpine) case "$g" in
            build)     echo "build-base" ;;      gcc)   echo "gcc" ;;
            make)      echo "make" ;;            cmake) echo "cmake" ;;
            git)       echo "git" ;;             curl)  echo "curl" ;;
            jq)        echo "jq" ;;              openssl) echo "openssl" ;;
            tcl)       echo "tcl" ;;             tcl-dev) echo "tcl-dev" ;;
            ssl-dev)   echo "openssl-dev" ;;     zlib-dev) echo "zlib-dev" ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            pkgconfig) echo "pkgconf" ;;         mysql-client) echo "mariadb-client" ;;
            tcl-mysql) echo "" ;;                tcllib) echo "tcllib" ;;
            tcl-tls)   echo "tcl-tls" ;;
            curl-dev)  echo "curl-dev" ;;            pcre2-dev) echo "pcre2-dev" ;;
            argon2-dev) echo "argon2-dev" ;;      sodium-dev) echo "libsodium-dev" ;;
            jansson-dev) echo "jansson-dev" ;;
            tar) echo "tar" ;;                  php) echo "php83-cli" ;;
            php-mysql) echo "php83-pdo_mysql" ;; php-mbstring) echo "php83-mbstring" ;;
            php-gd) echo "php83-gd" ;;          php-fpm) echo "php83-fpm" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            python-venv) echo "" ;;              python-pip) echo "py3-pip" ;;
            nodejs) echo "nodejs" ;;   # 17/10/2026 : nom probable, non mesuré
            *) echo "$g" ;; esac ;;
        *) echo "$g" ;;
    esac
}

# _pkg_installed <nom-réel> — 0 si présent
_pkg_installed() {
    local p="${1:-}"
    [[ -n "$p" ]] || return 0
    case "$PKG_FAMILY" in
        debian) dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q "install ok installed" ;;
        rhel)   rpm -q "$p" &>/dev/null ;;
        arch)   pacman -Qi "$p" &>/dev/null ;;
        suse)   rpm -q "$p" &>/dev/null ;;
        alpine) apk info -e "$p" &>/dev/null ;;
        *)      return 1 ;;
    esac
}

# Repli quand le gestionnaire est inconnu : on teste la commande elle-même.
# -----------------------------------------------------------------------------
# _mysqltcl_build — compile le lien Tcl↔MySQL là où il n'est pas empaqueté
#
#   mysqltcl existe en paquet sur Debian, Arch et RHEL (« tcl-mysqltcl »), mais
#   nulle part sur SUSE ni Alpine — recherche faite sur opensuse/leap 15.5 et
#   alpine 3.19. Sans lui, IriX démarre sans accès à la base : le bot vit, et
#   rien ne fonctionne.
#
#   L'archive vient du miroir Debian, seule source encore servie : SourceForge
#   et le dépôt GitHub d'origine répondent 404.
#
#   ── Les deux chemins que configure ne devine pas ────────────
#
#   Sur SUSE, il cherche libmysqlclient.so dans /usr/lib/mysql, où elle n'est
#   pas — et il ÉCHOUE EN RENVOYANT 0, sans produire de Makefile. Le make
#   suivant meurt alors sur « no targets specified », à mille lieues de la
#   cause. On lui passe donc explicitement la bibliothèque et les en-têtes,
#   détectés au préalable. Alpine, lui, s'en sort sans rien.
# -----------------------------------------------------------------------------
_mysqltcl_build() {
    local url="https://deb.debian.org/debian/pool/main/m/mysqltcl/mysqltcl_3.052.orig.tar.gz"
    local tmp dir tclcfg lib inc

    _info "Compilation de mysqltcl (absent des dépôts de cette distribution)…"

    for o in gcc make curl tar tclsh; do
        command -v "$o" >/dev/null 2>&1 || { _err "Outil requis absent : $o"; return 1; }
    done

    tclcfg=$(find /usr/lib /usr/lib64 /usr/local/lib -maxdepth 3 -name tclConfig.sh 2>/dev/null | head -1)
    [[ -n "$tclcfg" ]] || { _err "tclConfig.sh introuvable — installe les en-têtes de Tcl."; return 1; }

    lib=$(find /usr/lib64 /usr/lib -maxdepth 3 \
              \( -name "libmysqlclient*.so*" -o -name "libmariadb.so*" \) 2>/dev/null | head -1)
    inc=$(find /usr/include -maxdepth 3 -name mysql.h 2>/dev/null | head -1)
    [[ -n "$lib" && -n "$inc" ]] || {
        _err "En-têtes ou bibliothèque MySQL introuvables — installe le paquet de développement."
        return 1; }

    tmp=$(mktemp -d) || return 1
    if ! curl -fsSL --retry 3 "$url" -o "${tmp}/m.tar.gz"; then
        rm -rf "$tmp"; _err "Téléchargement de mysqltcl impossible."; return 1
    fi
    # Empreinte épinglée (23/09/2026) : rien n'était vérifié ici, pas même gzip.
    if ! _verifier_empreinte "${tmp}/m.tar.gz" "$url" "mysqltcl" "$SHA256_MYSQLTCL"; then
        rm -rf "$tmp"; return 1
    fi
    tar xzf "${tmp}/m.tar.gz" -C "$tmp" || { rm -rf "$tmp"; _err "Archive illisible."; return 1; }
    dir=$(find "$tmp" -maxdepth 1 -type d -name "mysqltcl*" | head -1)
    [[ -n "$dir" ]] || { rm -rf "$tmp"; _err "Sources mysqltcl introuvables."; return 1; }

    local sudo_cmd=""
    [[ "$PRIV_MODE" == "sudo" ]] && sudo_cmd="sudo"

    (
        cd "$dir" || exit 1
        ./configure --prefix=/usr \
                    --with-tcl="$(dirname "$tclcfg")" \
                    --with-mysql-lib="$(dirname "$lib")" \
                    --with-mysql-include="$(dirname "$inc")" >/dev/null 2>&1
        # configure d'un vieux TEA renvoie 0 même quand il abandonne : c'est la
        # présence du Makefile qui fait foi, pas son code de sortie.
        [[ -f Makefile ]] || exit 1
        make >/dev/null 2>&1 && $sudo_cmd make install >/dev/null 2>&1
    ) || { rm -rf "$tmp"; _err "Compilation de mysqltcl échouée."; return 1; }

    rm -rf "$tmp"

    if _tcl_a_paquet mysqltcl; then
        _ok "mysqltcl compilé et installé."
        return 0
    fi
    _err "mysqltcl installé mais introuvable par Tcl — vérifie ton auto_path."
    return 1
}

# tcltls (le « package require tls ») compilé depuis les sources, pour les
# distributions qui ne l'empaquettent PAS — mesuré sur Fedora 44, qui n'a pas
# tcl-tls et n'a QUE Tcl 9. ⚠ La release publiée (1.7.22) ne compile pas sur
# Tcl 9 (API Tcl_Size) : on prend le TRONC fossil, qui porte les correctifs Tcl 9.
# ⚠ Deux pièges de distribution attrapés ici : les libs OpenSSL sont dans
# /usr/lib64 (pas /usr/lib) sur Fedora, et le paquet doit atterrir dans le
# dossier des paquets Tcl (…/tcl<version>) pour être sur l'auto_path.
_tcltls_build() {
    # ⚠ Révision FIGÉE du tronc (225266f7674091df, 05/09/2026) et non plus
    #   « trunk » : le tronc bouge sans prévenir, et une empreinte ne peut pas
    #   garder une cible mobile. C'était la tête du tronc le 23/09/2026, donc ce
    #   que l'installateur prenait déjà. Pour la monter : nouvelle révision ici,
    #   nouvelle empreinte en tête du script (SHA256_TCLTLS).
    local url="https://core.tcl-lang.org/tcltls/tarball/225266f7674091df/tcltls.tar.gz"
    local tmp tclcfg libdir tclver pkgdir ssllib

    _info "Compilation de tcltls (absent des dépôts de cette distribution)…"

    for o in gcc make curl tar tclsh; do
        command -v "$o" >/dev/null 2>&1 || { _err "Outil requis absent : $o"; return 1; }
    done

    tclcfg=$(find /usr/lib /usr/lib64 /usr/local/lib -maxdepth 3 -name tclConfig.sh 2>/dev/null | head -1)
    [[ -n "$tclcfg" ]] || { _err "tclConfig.sh introuvable — installe les en-têtes de Tcl."; return 1; }
    libdir=$(dirname "$tclcfg")
    tclver=$(. "$tclcfg"; echo "$TCL_VERSION")
    pkgdir="${libdir}/tcl${tclver}"      # sur l'auto_path de Tcl

    # En-têtes + bibliothèque OpenSSL : le -devel doit être là (ssl.h + libssl.so).
    ssllib=$(find /usr/lib64 /usr/lib -maxdepth 4 -name 'libssl.so' 2>/dev/null | head -1)
    [[ -n "$ssllib" && -f /usr/include/openssl/ssl.h ]] || {
        _err "OpenSSL de développement absent (libssl.so / openssl/ssl.h) — installe openssl-devel."
        return 1; }

    tmp=$(mktemp -d) || return 1
    if ! curl -fsSL --retry 3 --max-time 60 "$url" -o "${tmp}/t.tar.gz"; then
        rm -rf "$tmp"; _err "Téléchargement de tcltls impossible."; return 1
    fi
    if ! _verifier_empreinte "${tmp}/t.tar.gz" "$url" "tcltls" "$SHA256_TCLTLS"; then
        rm -rf "$tmp"; return 1
    fi
    tar xzf "${tmp}/t.tar.gz" -C "$tmp" || { rm -rf "$tmp"; _err "Archive tcltls illisible."; return 1; }
    local dir; dir=$(find "$tmp" -maxdepth 1 -type d -name 'tcltls*' | head -1)
    [[ -n "$dir" ]] || { rm -rf "$tmp"; _err "Sources tcltls introuvables."; return 1; }

    local sudo_cmd=""
    [[ "$PRIV_MODE" == "sudo" ]] && sudo_cmd="sudo"

    (
        cd "$dir" || exit 1
        ./configure --prefix=/usr --exec-prefix=/usr --libdir="$pkgdir" \
                    --with-tcl="$libdir" --with-ssl=openssl \
                    --with-openssl-dir=/usr --with-openssl-libdir="$(dirname "$ssllib")" \
                    >/dev/null 2>&1
        [[ -f Makefile ]] || exit 1
        make >/dev/null 2>&1 && $sudo_cmd make install >/dev/null 2>&1
    ) || { rm -rf "$tmp"; _err "Compilation de tcltls échouée."; return 1; }

    rm -rf "$tmp"

    if _tcl_a_paquet tls; then
        _ok "tcltls compilé et installé (package require tls)."
        return 0
    fi
    _err "tcltls installé mais introuvable par Tcl — vérifie ton auto_path."
    return 1
}

# Ce Tcl dispose-t-il du paquet demandé ?
#
# Passe par un FICHIER, et non par un pipe. « echo … | tclsh » renvoie
# toujours 0, même quand le paquet manque : le code de sortie est celui du
# pipe, pas celui de l'interpréteur. Vérifié dans les deux sens sur une Debian
# nue — avec le pipe, un paquet absent était rapporté présent.
_tcl_a_paquet() {
    command -v tclsh >/dev/null 2>&1 || return 1
    local f; f=$(mktemp) || return 1
    printf 'exit [catch {package require %s}]\n' "$1" > "$f"
    tclsh "$f" >/dev/null 2>&1
    local rc=$?
    rm -f "$f"
    return $rc
}

_pkg_present() {
    local generic="$1" real
    real=$(_pkg_name "$generic")

    # Aucun paquet connu pour cette famille : on ne peut pas conclure par le
    # gestionnaire, mais répondre « présent » — ce que faisait ce code — est le
    # pire des choix. Sur SUSE et Alpine, où mysqltcl n'est empaqueté nulle
    # part, le panel annonçait « complet » et IriX démarrait ensuite sans son
    # lien MySQL, sans un mot.
    #
    # On teste donc la CAPACITÉ plutôt que le nom du paquet : c'est ce qui
    # compte, et cela vaut quelle que soit la façon dont elle a été obtenue —
    # dépôt, compilation manuelle ou autre.
    if [[ -z "$real" ]]; then
        case "$generic" in
            tcl-mysql) _tcl_a_paquet mysqltcl ;;
            tcllib)    _tcl_a_paquet cmdline ;;
            # ⚠ Seul le nom Debian/Ubuntu a été MESURÉ ; les quatre autres sont
            # probables. Si l'un est faux, ce repli répond quand même juste :
            # il demande à Tcl s'il sait charger `tls`, ce qui est la seule
            # question qui compte.
            tcl-tls)   _tcl_a_paquet tls ;;
            *) return 1 ;;   # inconnu et non testable : manquant, pas présent
        esac
        return
    fi
    if [[ "$PKG_FAMILY" == "inconnu" ]]; then
        case "$generic" in
            build|ssl-dev|zlib-dev|tcl-dev) return 1 ;;
            tcl-mysql|tcllib|tcl-tls) return 1 ;;
            pkgconfig) command -v pkg-config &>/dev/null ;;
            mysql-client) command -v mysql &>/dev/null ;;
            *) command -v "$generic" &>/dev/null ;;
        esac
        return
    fi
    _pkg_installed "$real"
}

# _pkg_install_cmd — affiche la commande d'installation pour les noms donnés
_pkg_install_cmd() {
    case "$PKG_FAMILY" in
        debian) echo "apt-get install -y $*" ;;
        rhel)   echo "$PKG_MGR install -y $*" ;;
        arch)   echo "pacman -S --needed --noconfirm $*" ;;
        suse)   echo "zypper install -y $*" ;;
        alpine) echo "apk add $*" ;;
        *)      echo "(gestionnaire de paquets non reconnu) $*" ;;
    esac
}

# _pkg_install <générique...> — installe ce qui manque. 0 si tout est présent
# à l'arrivée, 1 sinon (y compris quand l'élévation est impossible).
_pkg_install() {
    local generics=( "$@" ) missing_real=() g real

    for g in ${generics[@]+"${generics[@]}"}; do
        real=$(_pkg_name "$g")

        # Générique sans paquet sur cette distribution. Il était ignoré en
        # silence — d'où un IriX installé sans lien MySQL sur SUSE et Alpine,
        # que rien ne signalait. Ce qui se compile se compile ; le reste est
        # nommé, pour ne pas disparaître entre deux lignes.
        if [[ -z "$real" ]]; then
            _pkg_present "$g" && continue
            case "$g" in
                tcl-mysql) _mysqltcl_build || return 1 ;;
                tcl-tls)   _tcltls_build   || return 1 ;;
                *) _warn "Pas de paquet « $g » pour ${PKG_FAMILY} — à installer à la main." ;;
            esac
            continue
        fi

        _pkg_present "$g" || missing_real+=("$real")
    done

    [[ ${#missing_real[@]} -eq 0 ]] && return 0

    _info "Paquets manquants : ${missing_real[*]}"

    if ! _priv_can_elevate; then
        _warn "Aucune élévation disponible — installe-les manuellement :"
        printf "    %b\n" "${BOLD}$(_pkg_install_cmd "${missing_real[@]}")${RESET}"
        _warn "Relance ensuite ce panel, ou redémarre-le en root."
        return 1
    fi

    case "$PKG_FAMILY" in
        debian) _as_root apt-get update -y -qq || true
                _as_root apt-get install -y "${missing_real[@]}" || return 1 ;;
        rhel)   _as_root "$PKG_MGR" install -y "${missing_real[@]}" || return 1 ;;
        arch)   _as_root pacman -Sy --needed --noconfirm "${missing_real[@]}" || return 1 ;;
        suse)   _as_root zypper --non-interactive install "${missing_real[@]}" || return 1 ;;
        alpine) _as_root apk add "${missing_real[@]}" || return 1 ;;
        *)      _err "Gestionnaire de paquets non reconnu — installation manuelle requise."
                printf "    %s\n" "$(_pkg_install_cmd "${missing_real[@]}")"
                return 1 ;;
    esac

    # Re-vérification : un gestionnaire peut sortir 0 sans avoir tout posé.
    local still=()
    for g in ${generics[@]+"${generics[@]}"}; do
        _pkg_present "$g" || still+=("$(_pkg_name "$g")")
    done
    if [[ ${#still[@]} -gt 0 ]]; then
        _err "Toujours manquants après installation : ${still[*]}"
        return 1
    fi
    return 0
}

# -----------------------------------------------------------------------------
# EXIGENCES PAR COMPOSANT
# -----------------------------------------------------------------------------
# Dépendances de base du panel lui-même
readonly REQ_CORE=(jq curl git openssl)

_req_for() {
    case "$1" in
        # Les sources arrivent par `git clone` pour la plupart des composants,
        # et un dépôt ne contient pas de `configure` — il faut le générer.
        # autoconf/automake/libtool étaient bien installés, mais EN DUR au
        # moment de compiler (voir _install_hybrid) : le panel des dépendances
        # les ignorait, affichait « complet », et l'installation posait les
        # paquets en silence — ou échouait sans élévation, après le
        # téléchargement des sources.
        eggdrop)     echo "build gcc make tcl-dev ssl-dev zlib-dev autoconf automake libtool patch" ;;
        # ⚠⚠ `tcl-tls` A MANQUÉ À CETTE LISTE JUSQU'AU 24/08/2026, et IriX ne
        # démarrait donc sur AUCUNE installation neuve : `IriX-sql.tcl` fait
        # `package require tls` à sa dixième ligne, et le chargement mourait sur
        # « can't find package tls » — « FICHIER DE CONFIGURATION NON CHARGÉ ».
        # La production ne pouvait pas le voir : le paquet y avait été posé à la
        # main. Constaté en montant une Ubuntu 24.04 vierge. Point 45.
        irix)        echo "tcl tcllib tcl-mysql tcl-tls mysql-client" ;;
        # Un OverOp n'a besoin de RIEN de plus qu'Eggdrop : il ne parle pas à
        # MySQL (§3), et son Tcl est celui qu'Eggdrop embarque. `tcl` figure
        # ici pour le banc `Robots/overop/test-overop.tcl`, qui tourne sous
        # tclsh, sans bot ni réseau.
        overop)      echo "tcl" ;;
        anope)       echo "build gcc make cmake ssl-dev" ;;
        lea)          echo "build gcc make autoconf automake libtool" ;;
        unrealircd)  echo "build gcc make ssl-dev zlib-dev pkgconfig curl-dev pcre2-dev argon2-dev sodium-dev" ;;
        hybrid)      echo "build gcc make ssl-dev zlib-dev bison flex jansson-dev autoconf automake libtool" ;;
        # pkgconfig n'est pas décoratif ici : ircu n'a pas de `configure` dans
        # son dépôt, et l'autogen.sh qui le génère utilise la macro
        # PKG_PROG_PKG_CONFIG. Sans le fichier pkg.m4 que fournit pkg-config,
        # le configure produit est invalide et meurt sur
        # « PKG_PROG_PKG_CONFIG: command not found », puis sur une erreur de
        # syntaxe — constaté en compilant.
        ircu)        echo "build gcc make autoconf automake libtool pkgconfig" ;;
        # Le `configure` d'InspIRCd est un script PERL, pas un produit d'autotools :
        # perl n'est donc pas une commodité ici, rien ne se configure sans lui.
        # pcre2 et argon2 ne sont pas exigés — ils activent des modules
        # supplémentaires quand ils sont là, et leur absence ne fait rien échouer.
        inspircd)    echo "build gcc make perl pkgconfig ssl-dev" ;;
        # `openssl` en plus du reste : Bahamut REFUSE de démarrer sans
        # certificat, même sans port TLS déclaré — « Can't open ircd.crt! ssl
        # failed! », et aucun port n'est ouvert. Le binaire de génération fait
        # donc partie des dépendances, pas des agréments.
        bahamut)     echo "build gcc make openssl ssl-dev zlib-dev" ;;
        overtchat_ircd) echo "build gcc make ssl-dev zlib-dev autoconf automake libtool" ;;
        # Le serveur COMPILÉ que sert le site (29/09/2026) : rien à construire,
        # donc ni gcc ni en-têtes. Il ne dépend que de libc, libssl.so.3 et
        # libcrypto.so.3 (contrôlé par Tools/faire-binaire-hybrid.sh) ; le
        # paquet `openssl` les tire, et il est déjà du socle (REQ_CORE).
        # ⚠ Pseudo-composant : il n'a pas d'entrée dans servers.json, seul
        #   `_overtchat_binaire` le demande.
        overtchat_ircd_bin) echo "openssl" ;;
        # Le site et son panel. Ce composant n'existait pas : l'installateur
        # ne posait pas le site, il n'avait donc rien à en déclarer. Chacune
        # de ces entrées correspond à un usage constaté dans le code —
        #   php          Web/start.sh, Tools/panel-account.php
        #   php-mysql    inc/db.php (PDO)
        #   php-mbstring mb_substr / mb_strimwidth, partout
        #   php-gd       inc/image.php (`image_reencoder` : photo de profil,
        #                captures des rapports OverScript)
        #   mysql-client Web/sql/apply.sh
        #   tar, curl    téléchargement et extraction de l'archive
        # `postfix` en fait partie : sans relais local, le formulaire de
        # contact et les demandes de salon n'envoient rien — c'est le défaut
        # qui a motivé l'onglet Courriel.
        # ⚠ `php-gd` MANQUAIT jusqu'au 03/10/2026 (lot 9-A) : mesuré sur une
        #   installation tierce (Ubuntu 24.04, `--auto`), GD absent → toute photo
        #   de profil REFUSÉE (`image_reencoder` : « traitement d'image
        #   indisponible sur ce serveur (GD) », sans rien décoder).
        #   La prod l'avait, posé à la main : elle ne pouvait pas le voir.
        site)        echo "php php-mysql php-mbstring php-gd mysql-client tar curl postfix" ;;
        # 17/10/2026 (lot J2, décision ① de SerVuS) : OverCore, le successeur
        # d'IriX, n'a AUCUNE dépendance npm (pas de package.json) — rien que
        # `node`. La présence du paquet ne suffit pas : `_overcore_node_pret`
        # mesure le PLANCHER, et c'est elle qui décide du chemin de `--auto`.
        overcore)    echo "nodejs" ;;
        *)           echo "" ;;
    esac
}

# -----------------------------------------------------------------------------
# _overcore_node_pret — 0 si le `node` de la machine peut faire tourner OverCore.
#
# 17/10/2026 (lot J2). Le plancher est MESURÉ dans le code d'OverCore, pas
# supposé (Robots/overcore, overcore.js + lib/*.js, relevé ce jour) :
#   · 123 `??` (Node 14.0) et aucun `?.` ni `??=` / `||=` (15) ;
#   · `fs.rmSync` (Node 14.14) dans lib/base.js : c'est lui qui EFFACE le
#     dossier privé où vit le fichier d'identifiants de la base. Sous 14.14 il
#     n'existe pas, l'appel lève dans un `catch` muet, et le fichier qui porte
#     le mot de passe RESTE — d'où un plancher à 14.14 et non à 14 ;
#   · `crypto.X509Certificate` (Node 15.6) : seulement `--certificat` (la
#     console, fermée par défaut) — hors du plancher, dit ici pour ne pas
#     l'oublier.
# ⚠ On ne lit pas `node --version` pour le comparer : on demande à `node`
#   lui-même d'évaluer un `??` et de trouver `rmSync`. Un Node trop vieux rend
#   une SyntaxError (code ≠ 0) — la capacité, pas son étiquette.
# ⚠ `OVERTCHAT_NODE` désigne un autre binaire (banc : Bootstrap/tests/
#   test-deps-overcore.sh) ; sinon le `node` du PATH.
# -----------------------------------------------------------------------------
_overcore_node_pret() {
    local node="${OVERTCHAT_NODE:-}"
    [[ -n "$node" ]] || node="$(command -v node 2>/dev/null)" || node=""
    [[ -n "$node" && -x "$node" ]] || return 1
    "$node" -e 'process.exit(typeof require("fs").rmSync === "function" ? (0 ?? 1) : 1)' >/dev/null 2>&1
}

_req_label() {
    case "$1" in
        eggdrop) echo "Eggdrop" ;;   irix) echo "IriX" ;;
        overop)  echo "OverOp" ;;
        anope)   echo "Anope" ;;     lea)  echo "Lea" ;;
        unrealircd) echo "UnrealIRCd" ;; hybrid) echo "ircd-Hybrid" ;;
        ircu) echo "ircu" ;;         overtchat_ircd) echo "Overtchat-ircd" ;;
        inspircd) echo "InspIRCd" ;;
        bahamut)  echo "Bahamut" ;;
        site) echo "Site et panel" ;;
        overcore) echo "OverCore (Node.js)" ;;
        *) echo "$1" ;;
    esac
}

# 0 si toutes les dépendances système du composant sont satisfaites
_req_satisfied() {
    local comp="$1" g
    for g in $(_req_for "$comp"); do
        _pkg_present "$g" || return 1
    done
    return 0
}

_req_missing_list() {
    local comp="$1" g out=()
    for g in $(_req_for "$comp"); do
        _pkg_present "$g" || out+=("$(_pkg_name "$g")")
    done
    printf '%s' "${out[*]:-}"
}

# -----------------------------------------------------------------------------
# _check_deps — dépendances minimales du panel
# -----------------------------------------------------------------------------
_check_deps() {
    # Détection seulement si main ne l'a pas déjà faite : la rappeler ici
    # écrasait le forçage de --user (PRIV_MODE repassait à "sudo"), et le
    # panel proposait une élévation que l'utilisateur venait de refuser.
    [[ -n "${PKG_FAMILY:-}" ]] || _pkg_detect
    [[ -n "${PRIV_USER:-}"  ]] || _priv_detect

    # `tar` et `sha256sum` s'ajoutent à la liste d'origine : le panel extrait
    # l'archive du site et vérifie les empreintes. Ils manquaient à la
    # déclaration alors qu'ils étaient déjà utilisés — sur une Debian nue,
    # l'installation s'arrêtait une fois de plus que nécessaire, et sur un
    # message qui ne nommait que la dépendance suivante.
    local missing=()
    for dep in jq curl git openssl tar sha256sum awk; do
        command -v "$dep" &>/dev/null || missing+=("$dep")
    done

    [[ ${#missing[@]} -eq 0 ]] && return 0

    _err "Dépendances manquantes : ${missing[*]}"

    local real=() m
    for m in "${missing[@]}"; do real+=("$(_pkg_name "$m")"); done

    if _priv_can_elevate; then
        _info "Installation automatique…"
        if _pkg_install "${missing[@]}"; then
            _ok "Dépendances de base installées."
            return 0
        fi
        _die "Installation des dépendances de base échouée."
    fi

    _info "Installe-les puis relance :"
    printf "    %b\n\n" "${BOLD}$(_pkg_install_cmd "${real[@]}")${RESET}"
    exit 1
}

# -----------------------------------------------------------------------------
# _panel_deps — scan des paquets requis, par composant
# -----------------------------------------------------------------------------
_panel_deps() {
    while true; do
        local comp missing lbl prets=0 total=0
        # Comptés d'abord : la jauge doit résumer l'écran avant de le détailler.
        for comp in eggdrop irix anope lea unrealircd hybrid ircu inspircd bahamut overtchat_ircd site; do
            total=$(( total + 1 ))
            [[ -z "$(_req_missing_list "$comp")" ]] && prets=$(( prets + 1 ))
        done

        _ui_width
        _ui_title "Dépendances système" "paquets requis par chaque composant"

        _ui_top
        _ui_kv "Distribution" "${PKG_FAMILY} ${DIM}(${PKG_MGR:-aucun gestionnaire})${RESET}"
        _ui_kv "Privilèges"   "$(_priv_label)"
        _ui_kv "Composants prêts" "$(_ui_bar "$prets" "$total")"

        _ui_head "Détail"
        for comp in eggdrop irix anope lea unrealircd hybrid ircu inspircd bahamut overtchat_ircd site; do
            missing=$(_req_missing_list "$comp")
            lbl=$(_req_label "$comp")
            if [[ -z "$missing" ]]; then
                _ui_row "  ${lbl}$(_ui_pad "$lbl" 20)$(_ui_badge ok 'complet')"
            else
                _ui_row "  ${lbl}$(_ui_pad "$lbl" 20)$(_ui_badge warn 'incomplet')"
                _ui_row "     ${ORANGE}↳${RESET} ${DIM}${missing}${RESET}"
            fi
        done

        _ui_head "Actions"
        if _priv_can_elevate; then
            _ui_item 1 "Installer toutes les dépendances manquantes"
        else
            _ui_item 1 "Installer toutes les dépendances manquantes" "aucune élévation disponible"
        fi
        _ui_item 2 "Afficher les commandes d'installation"
        _ui_item 0 "Retour"
        _ui_bot

        local c; _ui_prompt; read -r c || c=0
        case "$c" in
            1)  if _priv_can_elevate; then
                    local all=()
                    for comp in eggdrop irix anope lea unrealircd hybrid ircu inspircd bahamut overtchat_ircd site; do
                        # shellcheck disable=SC2206
                        all+=( $(_req_for "$comp") )
                    done
                    _pkg_install "${all[@]}" && _ok "Dépendances installées." || _warn "Installation incomplète."
                else
                    _warn "Relance le panel en root, ou installe manuellement (choix 2)."
                fi
                _pause ;;
            2)  local all=() g
                for comp in eggdrop irix anope lea unrealircd hybrid ircu inspircd bahamut overtchat_ircd site; do
                    for g in $(_req_for "$comp"); do
                        _pkg_present "$g" || all+=("$(_pkg_name "$g")")
                    done
                done
                if [[ ${#all[@]} -eq 0 ]]; then
                    _ok "Rien à installer."
                else
                    printf "\n    %b\n" "${BOLD}$(_pkg_install_cmd "${all[@]}")${RESET}"
                fi
                _pause ;;
            0) return ;;
            *) _warn "Choix invalide" || true ;;
        esac
    done
}

# -----------------------------------------------------------------------------
# ÉTAT D'INSTALLATION
#
# Source de vérité : les JSON de Config/. Ces fonctions ne parlent jamais au
# système de fichiers directement pour décider — sauf pour les dossiers, qui
# sont la seule chose que `.install` garantissait auparavant.
# -----------------------------------------------------------------------------
_st_json_flag() {
    local file="$1" filter="$2"
    [[ -f "$file" ]] || return 1
    [[ "$(jq -r "$filter" "$file" 2>/dev/null || echo false)" == "true" ]]
}

# Les variantes _st_svc_enabled / _st_srv_enabled ont été retirées le
# 29/07/2026 : jamais appelées. L'état « activé » se lit là où il sert
# réellement, dans _service_status_icon et _server_status_icon, qui ont besoin
# de le croiser avec « installé » et « en cours » pour choisir leur pastille.
_st_svc_installed() { _st_json_flag "$SERVICES_FILE" ".services.\"$1\".installed"; }
_st_srv_installed() { _st_json_flag "$SERVERS_FILE"  ".servers.\"$1\".installed";  }

_st_irix_installed() {
    [[ -f "$IRIX_FILE" ]] || return 1
    local d
    d=$(jq -r '.irix.install_dir // empty' "$IRIX_FILE" 2>/dev/null || true)
    [[ -n "$d" && -d "$d" ]]
}

_st_folders_ready() { [[ -d "$BASE_DIR/Config" ]]; }

_st_any_server_installed() {
    local s
    for s in unrealircd hybrid ircu inspircd bahamut overtchat_ircd; do
        _st_srv_installed "$s" && return 0
    done
    return 1
}

_st_count_servers() {
    [[ -f "$SERVERS_FILE" ]] || { echo 0; return; }
    jq -r '[.servers|to_entries[]|select(.value.installed==true)]|length' "$SERVERS_FILE" 2>/dev/null || echo 0
}

_st_count_services() {
    [[ -f "$SERVICES_FILE" ]] || { echo 0; return; }
    jq -r '[.services|to_entries[]|select(.value.installed==true)]|length' "$SERVICES_FILE" 2>/dev/null || echo 0
}

# Prérequis LOGIQUES entre composants (indépendants des paquets système).
# Retourne 0 si le composant peut être installé, 1 sinon + raison sur stdout.
_st_blocker() {
    case "$1" in
        irix)
            _st_svc_installed eggdrop || { echo "Eggdrop doit être installé d'abord — IriX est un module Tcl qu'Eggdrop héberge"; return 1; }
            ;;
        # Un OverOp est un Eggdrop de plus, pas un module : il lui faut le même
        # binaire, les mêmes modules compilés (*.so) et les mêmes répertoires
        # de messages. Sans Eggdrop, il n'y a rien à lancer.
        overop)
            _st_svc_installed eggdrop || { echo "Eggdrop doit être installé d'abord — un OverOp est un Eggdrop de plus"; return 1; }
            ;;
        anope|lea)
            _st_any_server_installed || { echo "aucun serveur IRC installé"; return 1; }
            ;;
    esac
    return 0
}

# -----------------------------------------------------------------------------
# RÉCONCILIATION ÉTAT JSON <-> DISQUE
#
# Les JSON de Config/ sont la source de vérité du verrouillage, mais ils ne
# décrivent que ce que CE panel a installé. Sur un déploiement où Eggdrop,
# UnrealIRCd ou IriX ont été posés autrement (installirix.sh, compilation à la
# main, paquet système), tout restait à installed=false : le panel verrouillait
# IriX en réclamant Eggdrop alors qu'Eggdrop tournait déjà.
#
# On sonde donc le disque et on corrige le JSON avant d'afficher un menu.
# Sens unique : on ne passe jamais installed de true à false ici — l'absence
# d'un binaire à un endroit attendu peut venir d'un chemin personnalisé, et ce
# n'est pas à un scan d'affichage de désinstaller quoi que ce soit.
# -----------------------------------------------------------------------------

# Cherche un binaire exécutable parmi plusieurs emplacements candidats.
_st_find_bin() {
    local c
    for c in "$@"; do
        [[ -n "$c" && -x "$c" && ! -d "$c" ]] && { printf '%s' "$c"; return 0; }
    done
    return 1
}

# Emplacements candidats d'un composant. On ne se limite PAS à install_dir :
# il vaut souvent null (JSON jamais initialisé) ou pointe vers l'arborescence
# que le panel *aurait* utilisée, alors que le composant a été posé ailleurs.
_st_candidates() {
    local comp="$1" dir="${2:-}" c=()
    case "$comp" in
        eggdrop) c=( "$dir/eggdrop"
                     "$TARGET_HOME/IriX/eggdrop"
                     "$BASE_DIR/Components/Service/eggdrop/eggdrop"
                     "$(command -v eggdrop 2>/dev/null || true)" ) ;;
        # « bin/anope » d'abord : c'est le nom de la 2.1, la branche installée
        # ici. Ne chercher que « bin/services » laissait start/stop/status sans
        # binaire sur une installation pourtant réussie.
        anope)   c=( "$dir/bin/anope" "$dir/src/build/bin/anope"
                     "$BASE_DIR/Components/Service/anope/bin/anope"
                     "$dir/bin/services" "$dir/src/build/bin/services"
                     "$BASE_DIR/Components/Service/anope/bin/services" ) ;;
        lea)     c=( "$dir/lea" "$dir/bin/lea"
                     "$BASE_DIR/Components/Service/lea/lea" ) ;;
        unrealircd) c=( "$dir/bin/unrealircd" "$dir/unrealircd"
                     "$BASE_DIR/Components/Serveur/bin/unrealircd/bin/unrealircd"
                     "$TARGET_HOME/unrealircd/bin/unrealircd" ) ;;
        hybrid)  c=( "$dir/bin/ircd" "$dir/ircd"
                     "$BASE_DIR/Components/Serveur/bin/hybrid/bin/ircd" ) ;;
        ircu)    c=( "$dir/bin/ircd" "$dir/ircd"
                     "$BASE_DIR/Components/Serveur/bin/ircu/bin/ircd" ) ;;
        inspircd) c=( "$dir/bin/inspircd"
                     "$BASE_DIR/Components/Serveur/bin/inspircd/bin/inspircd" ) ;;
        # Bahamut ne range pas son binaire dans `bin/` : `make install` le
        # dépose à la racine du répertoire d'installation.
        bahamut)  c=( "$dir/ircd" "$dir/bin/ircd"
                     "$BASE_DIR/Components/Serveur/bin/bahamut/ircd" ) ;;
        overtchat_ircd) c=( "$dir/bin/overtchat" "$dir/overtchat"
                     "$BASE_DIR/Components/Serveur/bin/overtchat-ircd/bin/overtchat" ) ;;
    esac
    printf '%s\n' ${c[@]+"${c[@]}"}
}

# Enregistre une découverte : met à jour install_dir ET binary, de sorte que
# start/stop/status retrouvent le composant là où il est réellement.
_st_record_found() {
    local kind="$1" comp="$2" found="$3" root rel

    # On remonte au dossier racine plausible : <root>/bin/<x> ou <root>/<x>
    root=$(dirname "$found")
    [[ "$(basename "$root")" == "bin" ]] && root=$(dirname "$root")
    rel="${found#"$root"/}"

    if [[ "$kind" == "svc" ]]; then
        _svc_set "$comp" \
            '.services[$s].installed = true | .services[$s].binary = $b | .services[$s].install_dir = $d' \
            --arg b "$rel" --arg d "$root"
    else
        _srv_set "$comp" \
            '.servers[$s].installed = true | .servers[$s].binary = $b | .servers[$s].install_dir = $d' \
            --arg b "$rel" --arg d "$root"
    fi
    _info "Détecté hors panel : $(_req_label "$comp") → $found"
}

_st_reconcile_services() {
    [[ -f "$SERVICES_FILE" ]] || return 0
    local svc dir found
    for svc in eggdrop anope lea; do
        _st_svc_installed "$svc" && continue
        dir=$(_svc_get "$svc" "install_dir" 2>/dev/null || true)
        [[ "$dir" == "null" ]] && dir=""
        # shellcheck disable=SC2046
        found=$(_st_find_bin $(_st_candidates "$svc" "$dir")) || continue
        _st_record_found svc "$svc" "$found"
    done
}

_st_reconcile_servers() {
    [[ -f "$SERVERS_FILE" ]] || return 0
    local srv dir found
    for srv in unrealircd hybrid ircu inspircd bahamut overtchat_ircd; do
        _st_srv_installed "$srv" && continue
        dir=$(_srv_get "$srv" "install_dir" 2>/dev/null || true)
        [[ "$dir" == "null" ]] && dir=""
        # shellcheck disable=SC2046
        found=$(_st_find_bin $(_st_candidates "$srv" "$dir")) || continue
        _st_record_found srv "$srv" "$found"
    done
}

# Passe complète, silencieuse si rien à corriger.
_st_reconcile() {
    _st_reconcile_servers  || true
    _st_reconcile_services || true
}

# -----------------------------------------------------------------------------
# MENUS À OPTIONS VERROUILLABLES
#
# _menu_reset puis _menu_add / _menu_lock pour construire, _menu_has pour
# valider la saisie. Une option verrouillée s'affiche grisée avec sa raison :
# l'utilisateur voit ce qui existe et ce qui lui manque pour y accéder, au lieu
# d'une option qui échoue après coup.
# -----------------------------------------------------------------------------
MENU_KEYS=()

_menu_reset() { MENU_KEYS=(); }

# Les entrées de menu s'affichent DANS un cadre : tout appelant doit avoir
# ouvert un _ui_top et le refermer par _ui_bot.
_menu_add() {
    MENU_KEYS+=("$1")
    _ui_item "$1" "$2"
}

_menu_lock() { _ui_item "$1" "$2" "$3"; }

# _menu_gate <clé> <libellé> <raison-si-verrouillé>
#   raison vide => option disponible
_menu_gate() {
    if [[ -z "${3:-}" ]]; then _menu_add "$1" "$2"; else _menu_lock "$1" "$2" "$3"; fi
}

_menu_has() {
    local k
    for k in ${MENU_KEYS[@]+"${MENU_KEYS[@]}"}; do
        [[ "$k" == "$1" ]] && return 0
    done
    return 1
}

# Raison de verrouillage d'un composant : prérequis logique, puis paquets.
_menu_reason() {
    local comp="$1" blocker missing
    blocker=$(_st_blocker "$comp") || { printf '%s' "$blocker"; return; }
    missing=$(_req_missing_list "$comp")
    if [[ -n "$missing" ]]; then
        if _priv_can_elevate; then
            printf 'paquets manquants — installables depuis le menu Dépendances'
        else
            printf 'paquets manquants : %s' "$missing"
        fi
        return
    fi
    printf ''
}

# =============================================================================
# SHA256 — INTÉGRITÉ
# =============================================================================
_sha256() {
    local file="$1"
    [[ -f "$file" ]] || { _err "Fichier absent pour hash : $file"; return 1; }
    if command -v sha256sum &>/dev/null; then
        sha256sum "$file" | awk '{print $1}'
    elif command -v openssl &>/dev/null; then
        openssl dgst -sha256 "$file" | awk '{print $2}'
    else
        _die "Aucun outil SHA256 (sha256sum / openssl)"
    fi
}

# ════════════════════════════════════════════════════════════════
# CE QU'ON HACHE N'EST PAS TOUJOURS LE FICHIER ENTIER — 25/08/2026.
#
# ⚠⚠ `access.json` porte `last_login`, écrit à CHAQUE connexion au panel — et
# c'est la seule de ses six écritures qui ne recalculait pas l'empreinte. La
# référence devenait donc fausse après chaque ouverture de session, et la
# vérification annonçait « HASH INVALIDE — fichier corrompu ! » sur une
# installation parfaitement saine. Trouvé par SerVuS en posant les références.
#
# ⚠ Une alarme toujours rouge est une alarme que plus personne ne lit. C'est le
# pire des deux mondes : elle coûte l'attention et ne protège rien.
#
# ⚠⚠ ON N'A PAS RECALCULÉ L'EMPREINTE APRÈS `last_login`, et le choix est
# délibéré : cela aurait ouvert un chemin de BLANCHIMENT. Qui parviendrait à
# s'ajouter un compte n'aurait eu qu'à se connecter pour que son altération
# devienne la nouvelle référence. On retire donc l'horodatage de ce qu'on
# hache : l'empreinte continue de protéger ce qu'elle doit — utilisateurs,
# grades, permissions, empreintes de mots de passe — et cesse de réagir à une
# date.
#
# ⚠ La projection est la MÊME à la pose et à la vérification, parce que toutes
# deux passent par ici. Deux projections différentes, et le contrôle rougirait
# à tous les coups.
#
# ⚠ Les autres fichiers gardent `_sha256` sur le fichier brut : leurs
# empreintes déjà posées restent valides. Seule celle d'`access` doit être
# reposée une fois.
_integrity_hash() {
    local file="$1"

    if [[ "$file" == "$ACCESS_FILE" ]] && command -v jq &>/dev/null; then
        local proj
        # ⚠ `del` sur un chemin absent ne lève pas : un fichier sans
        # `last_login` — celui d'une première pose — donne le même résultat.
        proj=$(jq -S 'del(.users[]?.last_login)' "$file" 2>/dev/null) || proj=""
        if [[ -n "$proj" ]]; then
            printf '%s' "$proj" | sha256sum | awk '{print $1}'
            return 0
        fi
        # ⚠ Repli délibéré : si `jq` échoue, on hache le fichier ENTIER plutôt
        # que de rendre une empreinte vide. Une empreinte fausse se voit ; une
        # empreinte vide passerait pour un contrôle réussi.
    fi

    _sha256 "$file"
}

_sha256_store() {
    local file="$1" json_file="${2:-$INIT_BASE}" key="${3:-bootstrap}"
    local hash ts
    hash=$(_integrity_hash "$file") || return 1
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$json_file" \
        --arg h "$hash" --arg ts "$ts" --arg f "$file" --arg k "$key" \
        '.[$k] = {"hash_algo":"sha256","expected_hash":$h,"file":$f,"last_verified":$ts,"verified":true}'
    # _log et non _ok : _sha256_store est appelée à chaque écriture JSON, ce
    # qui noyait la sortie du panel sous une ligne de hash par changement d'état.
    _log "SHA256 [$key] : $hash"
}

_sha256_verify() {
    local file="$1" json_file="${2:-$INIT_BASE}" key="${3:-bootstrap}"
    local stored current
    stored=$(jq -r --arg k "$key" '.[$k].expected_hash // empty' "$json_file" 2>/dev/null || true)

    # ⚠⚠ ELLE ENREGISTRAIT L'ÉTAT EN PLACE — point 39, corrigé le 20/08/2026.
    #
    # Quand la référence manquait, cette fonction appelait `_sha256_store` et
    # adoptait le fichier tel qu'il était, quel qu'il soit. Le passage suivant
    # le déclarait alors sain. **Une détection qui ne détecte pas est pire que
    # pas de détection : elle rassure.**
    #
    # Et ce n'était pas théorique : la référence vivait dans `/tmp`, elle avait
    # déjà disparu, et le panel aurait ré-enregistré les cinq fichiers sans que
    # rien ne le distingue d'un contrôle réussi.
    #
    # ⚠ On REFUSE désormais, et on le dit. L'enregistrement légitime — celui du
    # premier lancement — passe par `_sha256_store`, appelée à chaque écriture
    # de configuration : il n'y a donc rien à rattraper ici.
    if [[ -z "$stored" || "$stored" == "null" ]]; then
        _err_f "AUCUNE RÉFÉRENCE [%s] — impossible de vérifier quoi que ce soit." "$key"
        printf "  ${DIM}%s${RESET}\n" \
            "L'empreinte de référence est absente. Elle n'est PAS ré-enregistrée :" \
            "adopter l'état en place reviendrait à valider une éventuelle altération." \
            "Fichier concerné : $file" \
            "Reposer une référence : régler ce fichier depuis le panel, ou « --reset »."
        return 1
    fi
    # ⚠ La MÊME projection qu'à la pose — voir `_integrity_hash`. Hacher ici le
    # fichier entier ferait rougir `access` à chaque connexion.
    current=$(_integrity_hash "$file") || return 2
    if [[ "$stored" == "$current" ]]; then
        _ok_f "SHA256 OK [%s]" "$key"
        _jq_update "$json_file" \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg k "$key" \
            '.[$k].last_verified = $ts | .[$k].verified = true'
        return 0
    else
        _err_f "HASH INVALIDE [%s] — fichier corrompu !" "$key"
        printf "Attendu : ${GREEN}%s${RESET}\n" "$stored"
        printf "Obtenu  : ${RED}%s${RESET}\n"   "$current"
        _jq_update "$json_file" --arg k "$key" '.[$k].verified = false'
        return 2
    fi
}

# safe_encrypt — SUPPRIMÉE le 29/07/2026.
#
# Elle n'avait qu'un appelant, _run_bootstrap, elle-même retirée le même jour :
# le flux qui téléchargeait ce script depuis GitHub puis faisait `exec` dessus
# a été abandonné parce qu'il n'atteignait jamais le panel au premier
# lancement, et que son « contrôle d'intégrité » enregistrait le hash du
# fichier qu'il venait lui-même de télécharger — il ne validait donc rien.
#
# Ses deux modes utiles survivent ailleurs : _sha256_store et _sha256_verify
# sont appelées directement, et la mise à jour des fichiers passe désormais
# par le module _upd_* et son menu dédié.

# =============================================================================
# JSON — UTILITAIRES
# =============================================================================
_jq_update() {
    local file="$1"; shift
    local tmp; tmp=$(mktemp)
    if jq "$@" "$file" > "$tmp" 2>/dev/null; then
        mv "$tmp" "$file"
    else
        rm -f "$tmp"; _die "Erreur jq sur : $file"
    fi
}

_jq_read() { jq -r "${1}" "${2:-$INIT_BASE}" 2>/dev/null || echo "${3:-}"; }

# -----------------------------------------------------------------------------
# _version_installee — LA VERSION SE LIT, ELLE NE SE RECOPIE PAS.
#
# Elle a longtemps ete FIGEE dans `state.json` a sa premiere ecriture, et
# `_merge_configs` ne remplace jamais une chaine deja posee : l'ecran d'accueil
# annoncait donc la version du JOUR DE L'INSTALLATION, pour toujours.
# Mesure le 26/08/2026 sur la production : 4.19.30 affiche face a un VERSION a
# 4.23.2 — perime de quatre versions, et personne ne pouvait le voir.
#
# ⚠⚠ ELLE LIT `$BASE_DIR/VERSION`, PAS `$VERSION`.
#
# `$VERSION` est derive du chemin du SCRIPT (`_read_version`, plus haut). Chez un
# tiers, l'installateur se telecharge SEUL : il n'a aucun `VERSION` a cote de lui
# et retombe sur le repli `4.0.0`. Afficher `$VERSION` ici remplacerait donc un
# mensonge par un autre — celui-la meme plus difficile a voir, puisqu'il serait
# juste sur le poste de developpement. C'est `$BASE_DIR/VERSION`, celui de
# l'INSTALLATION, qui dit la verite partout.
#
# ⚠ « N/A » tant qu'il n'y a rien a lire : avant la pose de l'arborescence, il
# n'y a pas de version installee, et l'ecrire serait encore une invention.
_version_installee() {
    local f="$BASE_DIR/VERSION" v
    [ -r "$f" ] || { printf 'N/A'; return; }
    v="$(tr -d '[:space:]' < "$f" 2>/dev/null)"
    case "$v" in
        [0-9]*.[0-9]*) printf '%s' "$v" ;;
        *)             printf 'N/A'     ;;
    esac
}

# -----------------------------------------------------------------------------
# _write_default_config — gabarit de la configuration initiale.
#
# ATTENTION : le programme jq ci-dessous est écrit entre quotes SIMPLES. Toute
# apostrophe ajoutée à l'intérieur — y compris dans un commentaire jq — referme
# la chaîne, et bash tente alors d'exécuter le JSON comme du code shell. Les
# remarques sur ce bloc se placent donc ici, au-dessus de la fonction.
#
# Builder/, Builder/Anope, Builder/Overhub et Installer/ ont été retirés du
# gabarit le 29/07/2026, en même temps que les dossiers correspondants du
# dépôt. Ils ne contenaient rien : chaque installation les créait, ils
# restaient vides, et le rapport les affichait « présents » — des dossiers de
# décor que personne ne remplissait.
#
# Les modules Tcl sont désormais réclamés sous IriX/cmds/, leur emplacement
# réel, et non sous Components/Service/lib/Tcl/Stable/ où dormait une copie
# incomplète. Seul IriX-std.tcl garde son ancien chemin : c'est le seul module
# qui n'ait aucun homologue dans IriX/cmds/.
#
# « Bootstrap/installirix » a quitté la liste `import` le 29/07/2026. Ce champ
# alimente les fichiers surveillés par la mise à jour, et ce fichier n'existait
# NULLE PART : ni dans le dépôt, ni dans une arborescence installée. Chaque
# vérification demandait donc une URL inexistante et rapportait une erreur
# réseau — un rapport rouge en permanence, où les vraies pannes se noyaient.
#
# L'installateur qu'il désignait est de toute façon périmé : il génère une
# configuration Eggdrop qui charge un unique « cmds/irix.tcl », alors qu'IriX
# est modulaire depuis longtemps — la conf réelle charge IriX-sql.tcl, qui
# source à son tour dix-sept fichiers voisins.
# -----------------------------------------------------------------------------
_write_default_config() {
    # `--arg home` reçoit $TARGET_HOME et non $HOME. Sous sudo, $HOME vaut
    # /root : toute l'arborescence décrite ci-dessous partait donc dans /root —
    # invisible pour l'exploitant, inutilisable une fois repassé en compte
    # normal. C'est le piège que `_resolve_base_home` documente avoir corrigé
    # pour $BASE_DIR ; ce bloc-ci l'avait gardé, si bien que les deux racines
    # divergeaient dès qu'on lançait avec sudo — et c'est celle-ci que
    # `_create_folders` suit.
    jq -n \
        --arg lang "$LANG_CODE" \
        --arg home "$TARGET_HOME" \
        --arg init "$INIT_BASE" \
        --arg base "$BASE_DIR" \
\
        --arg path0 "${BASH_SOURCE[0]:-$0}" \
        --arg projet "$PROJET_NOM" \
    '
    # Racine de linstallation, batie sur le nom du projet et non sur une
    # constante : deux installations sur la meme machine doivent pouvoir
    # coexister sans se marcher dessus. Le nom est valide bien plus haut,
    # dans _resolve_project_name, avant que quoi que ce soit nen derive.
    # (Sans apostrophe dans ce commentaire : voir plus bas, elles referment
    # la chaine qui porte tout ce bloc.)
    {
        # La cle `version` a ete RETIREE le 26/08/2026 : un fichier detat
        # qui recopie le numero du projet se perime, et cest ce quil a fait
        # pendant des semaines. Lecran daccueil le derive desormais de
        # `$BASE_DIR/VERSION`. Mesure avant de retirer : aucun lecteur sur
        # state.default.json, un seul sur state.json, len-tete du menu.
        # (Sans apostrophe ici non plus : elles refermeraient la chaine.)
        install: false, language: $lang,
        projet: $projet,
        folders: {
            overtchat:  { path: ($home+"/"+$projet),           created: false },
            bootstrap:  { path: ($home+"/"+$projet+"/Bootstrap"), created: false },
            irix:       { path: ($home+"/"+$projet+"/IriX"),       created: false },
            components: {
                home:    { path: ($home+"/"+$projet+"/Components"), created: false },
                service: {
                    home: { path: ($home+"/"+$projet+"/Components/Service"),      created: false },
                    bin:  { path: ($home+"/"+$projet+"/Components/Service/bin"),  created: false },
                    conf: { path: ($home+"/"+$projet+"/Components/Service/conf"), created: false },
                    lib:  { path: ($home+"/"+$projet+"/Components/Service/lib"),  created: false }
                },
                serveur: {
                    home: { path: ($home+"/"+$projet+"/Components/Serveur"),      created: false },
                    bin:  { path: ($home+"/"+$projet+"/Components/Serveur/bin"),  created: false },
                    conf: { path: ($home+"/"+$projet+"/Components/Serveur/conf"), created: false },
                    lib:  { path: ($home+"/"+$projet+"/Components/Serveur/lib"),  created: false }
                }
            },
            config:    { path: ($home+"/"+$projet+"/Config"),       created: false },
            servers:   { path: ($home+"/"+$projet+"/Servers"),      created: false },
            runtime: {
                home: { path: ($home+"/"+$projet+"/Runtime"),       created: false },
                logs: { path: ($home+"/"+$projet+"/Runtime/Logs"),  created: false }
            }
        },
        git: {
            repo: "SerVuS033/Overtchat",
            branch: "main",
            brutegit: "https://service-overtchat.hd.free.fr/dl",   # le dépôt GitHub est privé : 404 sur chaque fichier
            api: "https://api.github.com/repos/SerVuS033/Overtchat/contents",
            setup: "Bootstrap/overtchat-setup.sh",
            crypt: "shc -sha256 > +sha512",
            compile: "gcc -o overtchat-setup overtchat-setup.c -lssl -lcrypto -D_LARGEFILE64_SOURCE -DVERSION=\\\"$VERSION\\\"",
            irix: {
                default_mode: "sql",
                # Alleger les modules au deploiement : retirer commentaires et
                # lignes vides des .tcl installes. Le depot, lui, garde tout —
                # les commentaires portent la memoire des incidents, pas la
                # description du code. 40 % de lignes en moins sur la machine.
                # Mettre a false pour installer les fichiers complets ; la
                # variable OVERTCHAT_SANS_DEPOUILLE=1 fait de meme sans toucher
                # a la configuration.
                # (Sans apostrophe : voir plus haut, elles referment la chaine.)
                depouiller: true,
                files: [
                    { repo: "IriX/cmds/IriX-src.conf",         local: "IriX-src.conf",         role: "module_sql" },
                    # console-tls.conf est source par irix.conf (point 127), AVEC garde :
                    # absent, IriX demarre sans console TLS. Il se deploie quand meme.
                    { repo: "IriX/cmds/console-tls.conf",      local: "console-tls.conf",      role: "module_sql" },
                    { repo: "IriX/cmds/ecoute-sure.conf",      local: "ecoute-sure.conf",      role: "module_sql" },
                    { repo: "IriX/cmds/IriX-sql.tcl",          local: "IriX-sql.tcl",          role: "entry_sql"  },
                    # irix-proto.tcl a manqué à cette liste du 29/07 au 30/07/2026, et
                    # une installation neuve ne demarrait pas : IriX-sql.tcl le source
                    # SANS GARDE a sa ligne 47, si bien que Eggdrop mourait au
                    # chargement sur un fichier absent. Toute la composition S2S vit
                    # dedans : le fichier le moins facultatif de la liste.
                    # (Sans apostrophe : voir plus haut, elles referment la chaine.)
                    { repo: "IriX/cmds/irix-proto.tcl",        local: "irix-proto.tcl",        role: "module_sql" },
                    { repo: "IriX/cmds/IriX-table.tcl",        local: "IriX-table.tcl",        role: "module_sql" },
                    { repo: "IriX/cmds/IriX-binds.tcl",        local: "IriX-binds.tcl",        role: "module_sql" },
                    { repo: "IriX/cmds/IriX-svc.tcl",    local: "IriX-svc.tcl",    role: "module_sql" },
                    { repo: "IriX/cmds/irix-partyline.tcl", local: "irix-partyline.tcl", role: "module_sql" },
                    { repo: "IriX/cmds/IriX-sql-IA.tcl",       local: "IriX-sql-IA.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-register.tcl",     local: "irix-register.tcl",     role: "module_sql" },
                    { repo: "IriX/cmds/irix-reset-ircop.tcl",  local: "irix-reset-ircop.tcl",  role: "module_sql" },
                    { repo: "IriX/cmds/irix-oper.tcl",         local: "irix-oper.tcl",         role: "module_sql" },
                    { repo: "IriX/cmds/irix-auth.tcl",         local: "irix-auth.tcl",         role: "module_sql" },
                    { repo: "IriX/cmds/irix-nickguard.tcl",    local: "irix-nickguard.tcl",    role: "module_sql" },
                    # irix-nickexpire.tcl et irix-chatalerte.tcl ont manqué à cette liste
                    # du 30/07 au 31/07/2026 — meme defaut que irix-proto.tcl plus haut,
                    # et meme consequence : IriX-sql.tcl les source SANS GARDE a ses
                    # lignes 462 et 470, donc une installation neuve mourait au
                    # chargement. Tout module ajoute a IriX-sql.tcl doit entrer ici DANS
                    # LE MEME COMMIT, sans quoi le depot fonctionne et pas une installation.
                    { repo: "IriX/cmds/irix-nickexpire.tcl",   local: "irix-nickexpire.tcl",   role: "module_sql" },
                    { repo: "IriX/cmds/irix-chatalerte.tcl",   local: "irix-chatalerte.tcl",   role: "module_sql" },
                    { repo: "IriX/cmds/irix-online.tcl",       local: "irix-online.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-modq.tcl",         local: "irix-modq.tcl",         role: "module_sql" },
                    { repo: "IriX/cmds/irix-operjoin.tcl",     local: "irix-operjoin.tcl",     role: "module_sql" },
                    { repo: "IriX/cmds/irix-modtrace.tcl",     local: "irix-modtrace.tcl",     role: "module_sql" },
                    { repo: "IriX/cmds/irix-chanbans.tcl",     local: "irix-chanbans.tcl",     role: "module_sql" },
                    { repo: "IriX/cmds/irix-chanq.tcl",        local: "irix-chanq.tcl",        role: "module_sql" },
                    { repo: "IriX/cmds/irix-logrot.tcl",       local: "irix-logrot.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-botreg.tcl",       local: "irix-botreg.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-mandat.tcl",       local: "irix-mandat.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-botnet.tcl",       local: "irix-botnet.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-alerte.tcl",       local: "irix-alerte.tcl",       role: "module_sql" },
                    { repo: "IriX/cmds/irix-chanaccess.tcl",   local: "irix-chanaccess.tcl",   role: "module_sql" },
                    { repo: "IriX/cmds/irix-edition.tcl",      local: "irix-edition.tcl",      role: "module_sql" },
                    { repo: "Components/Service/lib/Tcl/Stable/IriX-std.tcl",          local: "IriX-std.tcl",          role: "entry_std"  }
                ],
                modes: {
                    sql: {
                        entry: "IriX-sql.tcl",
                        modules: ["IriX-src.conf","console-tls.conf","ecoute-sure.conf","irix-proto.tcl","IriX-table.tcl","irix-partyline.tcl","IriX-binds.tcl","IriX-svc.tcl","IriX-sql-IA.tcl","irix-register.tcl","irix-reset-ircop.tcl","irix-oper.tcl","irix-auth.tcl","irix-nickguard.tcl","irix-nickexpire.tcl","irix-chatalerte.tcl","irix-online.tcl","irix-modq.tcl","irix-operjoin.tcl","irix-modtrace.tcl","irix-chanbans.tcl","irix-chanq.tcl","irix-logrot.tcl","irix-botreg.tcl","irix-mandat.tcl","irix-botnet.tcl","irix-alerte.tcl","irix-chanaccess.tcl","irix-edition.tcl"],
                        label: "SQL (complet)"
                    },
                    std: { entry: "IriX-std.tcl", modules: [],                    label: "Standard (sans SQL)" }
                }
            },
            # Les robots de prêt — cahier Markdown/PRET-ROBOT.md, §3 et §11.
            #
            # Liste SÉPARÉE de git.irix.files, et le choix est délibéré : ces
            # fichiers ne sont pas des modules de IriX. Les inscrire dans la
            # liste ci-dessus les déploierait dans cmds/, donc chargés PAR
            # IriX — exactement le couplage que le §4 cherche à couper, un
            # robot en panne ne devant jamais remonter comme un défaut de IriX.
            #
            # Le banc test-overop.tcl reste HORS de cette liste : il vit dans
            # le dépôt, pas dans une installation, comme IriX/tests et
            # Web/tests que _upd_exclu écarte pour la même raison.
            #
            # (Sans apostrophe dans ce commentaire : voir plus haut, elles
            # referment la chaine qui porte tout ce bloc.)
            overop: {
                files: [
                    { repo: "Robots/overop/overop.tcl",          local: "overop.tcl",          role: "script" },
                    { repo: "Robots/overop/overop.conf.exemple", local: "overop.conf.exemple", role: "modele" }
                ]
            }
        },
        import: [
            "Bootstrap/overtchat-setup.sh"
        ],
        export: { INIT_BASE: $init, INIT_PATH: $path0, BASE_DIR: $base, INIT_LANG: $lang },
        bootstrap: { hash_algo:"sha256", expected_hash:null, file:null, last_verified:null, verified:false }
    }' > "$INIT_DEFAULT"

    # ⚠ Le gabarit prend le MÊME mode que l'état, et pour la même raison.
    # Constaté le 20/08/2026 : le `chmod 600` du point 39 ne portait que sur
    # `INIT_BASE`, si bien que son jumeau restait en 644 — et il porte la même
    # structure, les mêmes chemins, la même arborescence. Deux fichiers écrits
    # par la même fonction n'ont aucune raison d'avoir deux protections.
    chmod 600 "$INIT_DEFAULT" 2>/dev/null || true
}

_merge_configs() {
    local tmp; tmp=$(mktemp)
    jq -s '
        def deep_merge(base; patch):
            reduce (patch | to_entries[]) as $e (base;
                if .[$e.key] == null then .[$e.key] = $e.value
                elif (.[$e.key]|type)=="object" and ($e.value|type)=="object"
                then .[$e.key] = deep_merge(.[$e.key]; $e.value)
                else . end);
        deep_merge(.[0]; .[1])

        # MIGRATION, 26/08/2026 : les etats poses avant cette date portent une
        # cle `version` figee au jour de leur PREMIERE ecriture. deep_merge ne
        # la remplace jamais, sa branche `else . end` conservant toute chaine
        # deja posee : elle ne peut donc que se perimer, et elle la fait
        # pendant des semaines sans que rien ne le dise.
        #
        # Cest ici quon la retire, parce que cest le seul endroit ou tout etat
        # DEJA EN SERVICE repasse. Une installation neuve, elle, ne la recoit
        # plus du tout : _write_default_config ne lecrit plus.
        # (Sans apostrophe : elles refermeraient la chaine qui porte ce bloc.)
        | del(.version)

        # MIGRATION, 23/09/2026 : Runtime/cmds_irix.json nest plus distribue
        # (decision de SerVuS, point 82 : aucun code ne le lisait). deep_merge
        # ne remplace JAMAIS un tableau deja pose, si bien quun etat ancien
        # garderait cette entree, la demanderait a chaque synchronisation,
        # prendrait un 404 et declarerait la synchronisation incomplete. On la
        # retire donc ici, au seul endroit ou tout etat en service repasse.
        | if (.git.irix.files | type) == "array"
          then .git.irix.files |= map(select(.repo != "Runtime/cmds_irix.json"))
          else . end
    ' "$INIT_BASE" "$INIT_DEFAULT" > "$tmp" && mv "$tmp" "$INIT_BASE"
}

# =============================================================================
# CRÉATION DOSSIERS
# =============================================================================
_create_folders() {
    _section "Création des dossiers"
    local all_ok=true count=0 rel
    while read -r path; do
        [[ -z "$path" ]] && continue
        count=$((count + 1))
        # Le chemin RELATIF, et non le seul basename : l'arborescence contient
        # deux « bin », deux « conf » et deux « lib », un jeu par composant.
        # Affichés par leur dernier segment, ils donnaient six lignes
        # identiques, sans moyen de savoir laquelle avait échoué.
        rel="${path#"$BASE_DIR"}"; rel="${rel#/}"; [[ -z "$rel" ]] && rel="${BASE_DIR##*/}"
        if [[ -d "$path" ]] || mkdir -p "$path" 2>/dev/null; then
            _ok "$rel"
            _jq_update "$INIT_BASE" --arg p "$path" \
                '(.. | objects | select(.path? == $p)).created = true'
        else
            _err "Impossible : $path"; all_ok=false
            _jq_update "$INIT_BASE" --arg p "$path" \
                '(.. | objects | select(.path? == $p)).created = false'
        fi
    done < <(jq -r '.. | objects | select(has("path") and (.path|type=="string")) | .path' "$INIT_BASE" | sort -u)

    # Un jq muet (JSON corrompu, clé .path absente) ne faisait entrer dans
    # aucune itération : all_ok restait true et .install passait à true sans
    # qu'un seul dossier existe. Au lancement suivant, le panel démarrait sur
    # une arborescence fantôme.
    if (( count == 0 )); then
        _err_f "Aucun chemin trouvé dans %s — arborescence non créée." "$INIT_BASE"
        _warn "Vérifie le fichier de configuration, ou lance « Réinitialiser configuration »."
        return 1
    fi

    # Contrôle de réalité : les dossiers indispensables doivent exister.
    local d
    for d in "$BASE_DIR" "$BASE_DIR/Config" "$BASE_DIR/Runtime/Logs"; do
        [[ -d "$d" ]] || { _err "Dossier requis absent : $d"; all_ok=false; }
    done

    if $all_ok; then
        _jq_update "$INIT_BASE" '.install = true'
        _ok_f "Arborescence validée (%s dossiers)" "$count"
        return 0
    fi
    _warn "Certains dossiers ont échoué — installation non validée."
    return 1
}

_export_vars() {
    while IFS="=" read -r key value; do
        [[ "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
        case "$key" in INIT_BASE|PATH|HOME|PWD|SHELL|UID) continue;; esac
        printf -v "$key" '%s' "$value" && export "${key:-}"
    done < <(jq -r '.export // {} | to_entries[] | "\(.key)=\(.value)"' "$INIT_BASE")
}

# -----------------------------------------------------------------------------
# L'EMPREINTE PUBLIÉE — point 26 de Markdown/CHANTIERS.md, phase 2
#
# Le site publie une empreinte SHA-256 pour tout ce qu'il distribue, à
# `${brutegit}/manifeste`. Jusqu'au 16/08/2026, UNE SEULE de ces empreintes
# était lue par un client : celle de l'archive du site, dans `_site_install`.
# Les cinquante autres étaient calculées, servies, et consultées par personne.
#
# Pire, ce que faisaient `_irix_sync_source` et `_overop_sync_source` n'était
# pas une vérification mais son contraire : elles ENREGISTRAIENT l'empreinte du
# fichier qu'elles venaient de recevoir. C'est mot pour mot ce que les trois
# commentaires-stèles de ce fichier reprochent à `safe_encrypt`, supprimée le
# 29/07/2026 pour cette raison. Le précédent n'avait pas été évité : il avait
# été déplacé.
#
# ⚠⚠ CE QUE CELA GARANTIT, ET CE QUE CELA NE GARANTIT PAS.
# L'empreinte vient du même serveur que le fichier. Elle attrape donc une
# coupure de transfert, un cache qui sert une version périmée, un miroir qui
# répond 200 avec une page d'erreur — pas un serveur compromis, qui servirait
# les deux. Seule une signature avec une clé hors serveur y répondrait, et
# elle n'est pas au programme : voir le point 26, qui dit pourquoi.
# -----------------------------------------------------------------------------

_brutegit() { jq -r '.git.brutegit // empty' "$INIT_BASE" 2>/dev/null; }

# Le manifeste, téléchargé UNE FOIS pour toute la durée du script.
# ⚠ Rend 1 sans un mot si le site est injoignable : l'absence de manifeste ne
# doit jamais faire échouer une installation — voir `_download`.
_manifeste_fichier() {
    # 🔴 Dans le dossier PRIVÉ du lancement, plus sous un nom devinable de /tmp
    #   (audit #25, 29/09/2026) : un manifeste posé là d'avance aurait été cru.
    local d f
    d="$(_ovt_tmp)" || return 1
    f="$d/manifeste.json"
    if [[ ! -s "$f" ]]; then
        local base; base=$(_brutegit)
        [[ -n "$base" ]] || return 1
        curl -fsSL --max-time 20 "${base}/manifeste" -o "$f" 2>/dev/null || { rm -f "$f"; return 1; }
    fi
    # ⚠⚠ ON VALIDE À CHAQUE FOIS, PAS SEULEMENT APRÈS TÉLÉCHARGEMENT.
    #
    # La première version ne contrôlait le JSON qu'au moment de le récupérer,
    # et rendait ensuite le cache sur le seul critère « non vide ». Un fichier
    # corrompu — disque plein pendant l'écriture, page d'erreur servie en 200 —
    # était donc accepté pour toute la durée du script, et `_manifeste_sha256`
    # rendait une empreinte vide **avec un code de succès** : tous les
    # téléchargements suivants seraient passés en « SANS empreinte publiée »,
    # sans que rien ne dise pourquoi.
    #
    # ⚠ Trouvé par `Bootstrap/tests/test-integrite.sh` le 16/08/2026, dans
    # l'heure qui a suivi son écriture. Un fichier non vide ne prouve rien.
    if ! jq -e '.fichiers' "$f" >/dev/null 2>&1; then
        rm -f "$f"
        return 1
    fi
    printf '%s' "$f"
}

# L'empreinte publiée pour un chemin du dépôt, ou vide.
#
# ⚠ Le manifeste est indexé par CLÉ d'inventaire (`setup`, `irix/IriX-sql.tcl`),
# pas par chemin — mais chaque entrée porte son `chemin`. On cherche donc sur la
# valeur, jamais sur la clé : `Bootstrap/overtchat-setup.sh` vit sous la clé
# `setup`, et une recherche par clé ne le trouverait pas.
_manifeste_sha256() {
    local rel="$1" f trouve
    f=$(_manifeste_fichier) || return 1

    trouve=$(jq -r --arg c "$rel" \
        '.fichiers | to_entries[] | select(.value.chemin == $c) | .value.sha256' \
        "$f" 2>/dev/null | head -1)
    if [[ -n "$trouve" ]]; then printf '%s' "$trouve"; return 0; fi

    # ⚠⚠ EN DERNIER RESSORT, PAR LE NOM DE FICHIER — parce que le site résout
    # ainsi, et qu'autrement la vérification serait muette là où elle sert le
    # plus.
    #
    # Les modules d'IriX sont posés À PLAT par `_irix_sync_source` :
    # `IriX/irix-alerte.tcl` sur le disque, quand le manifeste les indexe sous
    # `IriX/cmds/irix-alerte.tcl`. La recherche par chemin rendait donc vide
    # pour **tous les modules du bot** — c'est-à-dire pour l'essentiel de ce
    # que la mise à jour touche —, et l'on retombait sur la comparaison
    # circulaire avec un simple avertissement. Mesuré le 16/08/2026.
    #
    # Le téléchargement, lui, aboutit : `distrib_cle_depuis_chemin()` côté site
    # résout en dernier ressort par `basename`. On fait donc ici exactement ce
    # que le serveur a fait pour choisir quoi servir — sans quoi les deux
    # extrémités ne parlent pas du même fichier.
    #
    # ⚠ ET SEULEMENT SI LE NOM EST UNIQUE dans le manifeste. Deux entrées de
    # même nom rendraient l'empreinte ambiguë : on préfère alors ne rien
    # affirmer, et le repli le dira.
    local base n
    base="${rel##*/}"
    n=$(jq -r --arg b "$base" \
        '[.fichiers[] | select((.chemin | split("/") | last) == $b)] | length' \
        "$f" 2>/dev/null)
    [[ "$n" == "1" ]] || return 0

    jq -r --arg b "$base" \
        '.fichiers[] | select((.chemin | split("/") | last) == $b) | .sha256' \
        "$f" 2>/dev/null | head -1
}

_download() {
    local url="$1" dest="$2"
    local tmp; tmp=$(mktemp)
    _info "Téléchargement : $(basename "$url")"
    local http_code
    http_code=$(curl -sSL --retry 3 --retry-delay 2 \
            -H "Cache-Control: no-cache" \
            -w '%{http_code}' \
            "${url}?nocache=$(date +%s)" -o "$tmp" 2>/dev/null)
    if [[ "$http_code" != "200" || ! -s "$tmp" ]]; then
        rm -f "$tmp"
        _err "Échec : $url (HTTP ${http_code:-inconnu})"
        return 1
    fi

    # ── L'empreinte, AVANT de poser le fichier ────────────────────────────
    #
    # ⚠⚠ ICI, ET NULLE PART AILLEURS. C'est le dernier instant où le fichier
    # est encore BRUT. Juste après, `_irix_sync_source` fait passer les `.tcl`
    # par le dépouilleur, qui retire commentaires et lignes vides : le fichier
    # posé sur le disque fait une fraction de sa taille d'origine et porte une
    # empreinte tout autre. Vérifier plus loin rejetterait 100 % des modules
    # sains. Et comme le dépouillement dépend de la présence de `tclsh`, deux
    # installations légitimes n'auraient même pas la même empreinte sur disque.
    #
    # ⚠ Les tailles exactes ne sont plus écrites ici : elles l'étaient, sur
    # `irix-alerte.tcl`, et elles annonçaient 19 261 octets pour un fichier qui
    # en faisait 28 996 le 19/08/2026 — la moitié de moins que la réalité. Un
    # chiffre écrit à la main dans un commentaire ne survit pas à la séance qui
    # l'écrit. `wc -c` le donne, et lui ne se périme pas.
    local rel base attendu obtenu
    base=$(_brutegit)
    rel="$url"
    [[ -n "$base" && "$url" == "$base"/* ]] && rel="${url#"$base"/}"
    attendu=$(_manifeste_sha256 "$rel" 2>/dev/null || true)

    if [[ -n "$attendu" ]]; then
        obtenu=$(sha256sum "$tmp" 2>/dev/null | awk '{print $1}')
        if [[ "$obtenu" != "$attendu" ]]; then
            rm -f "$tmp"
            _err_f "Empreinte incorrecte — fichier refusé : %s" "$rel"
            _err_f "  attendu : %s" "$attendu"
            _err "  obtenu  : ${obtenu:-vide}"
            return 1
        fi
        mv "$tmp" "$dest"
        _ok "$(basename "$dest") — empreinte vérifiée"
        return 0
    fi

    # ⚠ AUCUNE EMPREINTE PUBLIÉE : ON POSE, ET ON LE DIT.
    #
    # Refuser ici casserait tout ce que le manifeste ne couvre pas — une
    # installation hors ligne, un site injoignable au mauvais moment, un
    # composant tiers. Le manifeste ne connaît ni `Servers/`, ni `Builder/` :
    # mesuré, zéro entrée. Un garde-fou qui transforme un oubli d'inventaire en
    # panne d'installation est pire que le défaut qu'il prétend couvrir.
    #
    # ⚠ Mais il ne se tait pas : « posé » n'est pas « vérifié », et la ligne
    # doit dire lequel des deux a eu lieu. C'est précisément ce que l'ancienne
    # coche verte laissait croire.
    mv "$tmp" "$dest"
    _warn "$(basename "$dest") — posé SANS empreinte publiée"
}

# _run_bootstrap — SUPPRIMÉE le 29/07/2026, avec safe_encrypt qu'elle seule
# appelait. Plus rien ne l'invoquait depuis que main() entre directement dans
# le panel au premier lancement ; voir le commentaire de main() pour le détail
# de ce changement de flux.

# =============================================================================
# RAPPORT D'ÉTAT
# =============================================================================
# -----------------------------------------------------------------------------
# _status_report — tableau de bord
#
# L'ancienne version listait les dossiers triés par le DERNIER segment de leur
# chemin. Sur cette arborescence, cela donnait « bin, bin, conf, conf, lib,
# lib » : six lignes indiscernables, alors que ces dossiers appartiennent à
# deux composants différents. On trie désormais sur le chemin COMPLET et on
# l'affiche relatif à la racine, ce qui rétablit l'ordre hiérarchique.
#
# Le rapport ne se limite plus à l'arborescence : il répond aussi à « qu'est-ce
# qui tourne » et « reste-t-il de la place », les deux questions qu'on se pose
# avant d'installer quoi que ce soit.
# -----------------------------------------------------------------------------
_status_report() {
    _ui_width
    _ui_title "Rapport d'état système" "$(date '+%d/%m/%Y %H:%M') · $(hostname 2>/dev/null || echo '?')"

    local n_srv n_svc
    n_srv=$(_st_count_servers)
    n_svc=$(_st_count_services)

    _ui_top
    _ui_row "${BOLD}Installation${RESET}"
    _ui_mid
    # ⚠ Derivee du fichier VERSION, plus lue dans l'etat : voir
    # `_version_installee`. ⚠⚠ Et le repli ne pouvait PAS venir de `_jq_read` —
    # il ne joue que si jq sort en code non nul, alors qu'une cle ABSENTE fait
    # sortir jq en 0 en imprimant « null ». Retirer la cle sans toucher cette
    # ligne aurait affiche « null » a l'ecran d'accueil.
    _ui_kv "Version"      "$(_version_installee)"
    _ui_kv "Racine"       "$BASE_DIR"
    _ui_kv "Arborescence" "$([[ "$(_jq_read '.install' "$INIT_BASE" "false")" == "true" ]] \
                              && _ui_badge ok 'prête' || _ui_badge off 'absente')"
    _ui_kv "Journal"      "$LOG_FILE"

    _ui_head "Composants"
    _ui_kv "Serveurs IRC installés" "$(_ui_bar "$n_srv" 4)"
    _ui_kv "Services installés"     "$(_ui_bar "$n_svc" 4)"

    # Les identifiants sont ceux de servers.json / services.json — « overtchat »
    # au lieu de « overtchat_ircd » faisait lire une entrée inexistante, dont
    # tous les champs valaient null : le serveur s'affichait « désactivé » alors
    # qu'il n'était simplement pas installé.
    local id nom
    for id in unrealircd hybrid ircu inspircd bahamut overtchat_ircd; do
        nom=$(_srv_get "$id" "name" 2>/dev/null || echo "$id")
        [[ -z "$nom" || "$nom" == "null" ]] && nom="$id"
        _ui_row "  ${nom}$(_ui_pad "$nom" 22)$(_server_status_icon "$id" 2>/dev/null || _ui_badge off)"
    done
    for id in eggdrop anope lea; do
        nom=$(_svc_get "$id" "name" 2>/dev/null || echo "$id")
        [[ -z "$nom" || "$nom" == "null" ]] && nom="$id"
        _ui_row "  ${nom}$(_ui_pad "$nom" 22)$(_service_status_icon "$id" 2>/dev/null || _ui_badge off)"
    done
    _ui_row "  IriX$(_ui_pad 'IriX' 22)$(_irix_status_icon 2>/dev/null || _ui_badge off)"

    _ui_head "Système"
    _ui_kv "Distribution" "${PKG_FAMILY} ${DIM}(${PKG_MGR:-aucun})${RESET}"
    _ui_kv "Privilèges"   "$(_priv_label)"
    local libre
    libre=$(df -h "$BASE_DIR" 2>/dev/null | awk 'NR==2 {print $4" libres sur "$2}')
    _ui_kv "Espace disque" "${libre:-inconnu}"
    _ui_bot

    # ── Arborescence, triée par chemin complet et affichée en relatif ────────
    _ui_top
    _ui_row "${BOLD}Arborescence${RESET}   ${DIM}chemins relatifs à la racine${RESET}"
    _ui_mid
    local path rel etat
    while IFS=$'\t' read -r path; do
        [[ -n "$path" ]] || continue
        rel="${path#"$BASE_DIR"}"; rel="${rel#/}"; [[ -z "$rel" ]] && rel="."
        if [[ -d "$path" ]]; then etat="$(_ui_badge ok 'présent')"
        else                      etat="$(_ui_badge err 'manquant')"; fi
        _ui_row "  $(printf '%-44s' "$rel")${etat}"
    done < <(jq -r '
        try(.. | objects | select(has("path") and (.path|type=="string")) | .path) catch empty
    ' "$INIT_BASE" | sort -u)
    _ui_bot
}

# =============================================================================
# ══════════════════════════════════════════════
#  ACCESS MANAGER — users / grades / passwords
# ══════════════════════════════════════════════
# =============================================================================

_hash_password() {
    local password="$1"
    local salt
    salt=$(openssl rand -hex 16)
    local hash
    hash=$(printf '%s' "${salt}${password}" | openssl dgst -sha256 | awk '{print $2}')
    printf '%s:%s' "$salt" "$hash"
}

_verify_password() {
    local password="$1" stored="$2"
    local salt="${stored%%:*}" hash="${stored##*:}"
    local check
    check=$(printf '%s' "${salt}${password}" | openssl dgst -sha256 | awk '{print $2}')
    [[ "$hash" == "$check" ]]
}

_access_init() {
    [[ -f "$ACCESS_FILE" ]] && return 0
    _section "Initialisation Access Manager"
    local admin_pass salt_hash
    printf "${BOLD}Mot de passe admin :${RESET}"; read -rs admin_pass || true; echo ""
    salt_hash=$(_hash_password "$admin_pass")
    mkdir -p "$(dirname "$ACCESS_FILE")"
    jq -n \
        --arg ts "$( date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --arg ph "$salt_hash" \
    '{
        meta: { created: $ts, version: "1.0", algo: "sha256+salt" },
        users: [{
            id: "admin", login: "admin", password_hash: $ph,
            grade: "owner", active: true, created: $ts,
            last_login: null, permissions: ["all"]
        }],
        grades: {
            owner: { level: 100, permissions: ["all"] },
            admin: { level: 80,  permissions: ["manage_services","manage_servers","view_logs","manage_users"] },
            coadmin: { level: 60,  permissions: ["manage_services","view_logs"] },
            op: { level: 40,  permissions: ["view_logs","service_toggle"] },
            viewer: { level: 10,  permissions: ["view_logs"] }
        },
        eggdrop: { network_admins: [], channels: {} }
    }' > "$ACCESS_FILE"
    _sha256_store "$ACCESS_FILE" "$INIT_BASE" "access"
    _ok "Access Manager initialisé"
}

_access_list_users() {
    _section "Utilisateurs"
    jq -r '.users[] |
        " \(.login)\t[\(.grade)]\t\(if .active then "actif" else "inactif" end)\t\(.last_login // "jamais")"
    ' "$ACCESS_FILE" | column -t
}

_access_add_user() {
    _section "Ajouter un utilisateur"
    local login grade pass hash ts
    read -rp " Login : " login || true
    echo " Grades : owner / admin / coadmin / op / viewer"
    read -rp " Grade : " grade || true
    printf " Password : "; read -rs pass || true; echo ""
    hash=$(_hash_password "$pass")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$ACCESS_FILE" \
        --arg l "$login" --arg g "$grade" --arg h "$hash" --arg ts "$ts" \
        '.users += [{"id":$l,"login":$l,"password_hash":$h,"grade":$g,
                     "active":true,"created":$ts,"last_login":null,"permissions":[]}]'
    _sha256_store "$ACCESS_FILE" "$INIT_BASE" "access"
    _ok_f "Utilisateur ajouté : %s [%s]" "$login" "$grade"
}

_access_change_password() {
    local login="${1:-}"
    [[ -z "$login" ]] && read -rp "  Login : " login || true
    printf "Nouveau mot de passe : "; read -rs pass || true; echo ""
    local hash ts
    hash=$(_hash_password "$pass"); ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$ACCESS_FILE" \
        --arg l "$login" --arg h "$hash" --arg ts "$ts" \
        '(.users[] | select(.login==$l)) |= (.password_hash=$h | .updated=$ts)'
    _sha256_store "$ACCESS_FILE" "$INIT_BASE" "access"
    _ok_f "Mot de passe mis à jour : %s" "$login"
}

_access_toggle_user() {
    read -rp "  Login : " login || true
    local current new_state
    current=$(jq -r --arg l "$login" '.users[] | select(.login==$l) | .active' "$ACCESS_FILE")
    [[ "$current" == "true" ]] && new_state=false || new_state=true
    _jq_update "$ACCESS_FILE" \
        --arg l "$login" --argjson s "$new_state" \
        '(.users[] | select(.login==$l)).active = $s'
    _sha256_store "$ACCESS_FILE" "$INIT_BASE" "access"
    _ok_f "%s → actif=%s" "$login" "$new_state"
}

_access_manager_menu() {
    _access_init
    while true; do
        _ui_width
        _ui_title "Access Manager" "comptes du panel, grades et salons Eggdrop"

        local n_users n_actifs
        n_users=$(jq -r '.users | length' "$ACCESS_FILE" 2>/dev/null || echo 0)
        n_actifs=$(jq -r '[.users[] | select(.active)] | length' "$ACCESS_FILE" 2>/dev/null || echo 0)

        _ui_top
        _ui_kv "Comptes déclarés" "$n_users"
        _ui_kv "Comptes actifs"   "$(_ui_bar "$n_actifs" "$(( n_users > 0 ? n_users : 1 ))")"
        [[ -n "${CURRENT_USER:-}" ]] && \
            _ui_kv "Session en cours" "${CURRENT_USER} ${DIM}[${CURRENT_GRADE:-?}]${RESET}"

        _ui_head "Actions"
        _ui_item 1 "Lister les utilisateurs"
        _ui_item 2 "Ajouter un utilisateur"
        _ui_item 3 "Changer un mot de passe"
        _ui_item 4 "Activer / désactiver un compte"
        _ui_item 5 "Gérer Eggdrop  ${DIM}(grades et salons)${RESET}"
        _ui_item 0 "Retour"
        _ui_bot

        _ui_prompt
        read -r c || c=0
        case "$c" in
            1) _access_list_users || true ;;
            2) _access_add_user || true ;;
            3) _access_change_password || true ;;
            4) _access_toggle_user || true ;;
            5) _eggdrop_grades_menu || true ;;
            0) return ;;
            *) _warn "Choix invalide" || true ;;
        esac
        _pause
    done
}

# -----------------------------------------------------------------------------
# _grade_level <grade>
#   Retourne le niveau numérique d'un grade Access Manager (0 si inconnu).
# -----------------------------------------------------------------------------
_grade_level() {
    local g="${1:-}"
    [[ -z "$g" || ! -f "$ACCESS_FILE" ]] && { echo 0; return; }
    jq -r --arg g "$g" '.grades[$g].level // 0' "$ACCESS_FILE" 2>/dev/null
}

# -----------------------------------------------------------------------------
# _require_grade <niveau_min> [libellé]
#   Bloque l'action si l'utilisateur connecté n'a pas le niveau requis.
#   Sans session active (pas d'access.json / pas encore de login), l'accès
#   reste ouvert : c'est le comportement d'installation initiale.
# -----------------------------------------------------------------------------
_require_grade() {
    local min="$1" label="${2:-Cette action}"
    [[ -z "${CURRENT_USER:-}" ]] && return 0
    local lvl; lvl=$(_grade_level "${CURRENT_GRADE:-}")
    if (( lvl < min )); then
        _err "$label nécessite un grade ≥ ${min} (votre grade : ${CURRENT_GRADE:-aucun}, niveau ${lvl})."
        return 1
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _access_login_gate
#   Authentifie l'utilisateur avant l'accès au panel principal, en s'appuyant
#   sur _hash_password/_verify_password et access.json. 3 tentatives max.
#   CURRENT_USER / CURRENT_GRADE sont exportées pour _require_grade.
# -----------------------------------------------------------------------------
_access_login_gate() {
    [[ -f "$ACCESS_FILE" ]] || return 0

    local login pass stored active tries=0 max_tries=3
    _section "Authentification"

    while (( tries < max_tries )); do
        read -rp " Login : " login || { echo ""; return 1; }
        printf " Password : "; read -rs pass || { echo ""; return 1; }; echo ""

        stored=$(jq -r --arg l "$login" '.users[]? | select(.login==$l) | .password_hash // empty' "$ACCESS_FILE" 2>/dev/null)
        active=$(jq -r --arg l "$login" '.users[]? | select(.login==$l) | .active' "$ACCESS_FILE" 2>/dev/null)

        if [[ -n "$stored" && "$active" == "true" ]] && _verify_password "$pass" "$stored"; then
            CURRENT_USER="$login"
            CURRENT_GRADE=$(jq -r --arg l "$login" '.users[]? | select(.login==$l) | .grade' "$ACCESS_FILE" 2>/dev/null)
            _jq_update "$ACCESS_FILE" --arg l "$login" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
                '(.users[] | select(.login==$l)).last_login = $ts'
            _log_entry "access" "login" "$login"
            _ok_f "Bienvenue, %s [%s]" "${login}" "${CURRENT_GRADE}"
            return 0
        fi

        tries=$(( tries + 1 ))
        _log_entry "access" "login_failed" "${login:-?}"
        _err_f "Identifiants invalides. (%s/%s)" "${tries}" "${max_tries}"
    done

    _err "Trop de tentatives échouées."
    return 1
}

# =============================================================================
# ════════════════════════════════════════════════
#  EGGDROP — Grades / Channels / Réseau autonome
# ════════════════════════════════════════════════
# =============================================================================

_eggdrop_init() {
    [[ -f "$EGGDROP_FILE" ]] && return 0
    mkdir -p "$(dirname "$EGGDROP_FILE")"
    jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{
        meta: { created: $ts, version: "1.0" },
        network: { name: "OvertChat", owner: "", admins: [], coadmins: [], xops: [] },
        bots: [],
        channels: {}
    }' > "$EGGDROP_FILE"
    _sha256_store "$EGGDROP_FILE" "$INIT_BASE" "eggdrop"
    _ok "Eggdrop config initialisée"
}

_eggdrop_add_bot() {
    _eggdrop_init; _section "Ajouter un bot Eggdrop"
    local name nick server port ts
    read -rp " Nom bot : " name || true
    read -rp " Nickname : " nick || true
    read -rp " Serveur IRC : " server || true
    read -rp " Port : " port || true
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$EGGDROP_FILE" \
        --arg n "$name" --arg nick "$nick" --arg s "$server" --arg p "$port" --arg ts "$ts" \
        '.bots += [{"name":$n,"nick":$nick,"server":$s,"port":$p,"active":true,"created":$ts}]'
    _sha256_store "$EGGDROP_FILE" "$INIT_BASE" "eggdrop"
    _ok_f "Bot : %s (%s) → %s:%s" "$name" "$nick" "$server" "$port"
}

_eggdrop_add_channel() {
    _eggdrop_init; _section "Ajouter un salon IRC"
    local chan ts
    read -rp "  Salon (ex: #general) : " chan || true
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$EGGDROP_FILE" \
        --arg c "$chan" --arg ts "$ts" \
        '.channels[$c] = {"created":$ts,"active":true,"ops":[],"halfops":[],"voices":[],"bans":[],"flags":"+nt"}'
    _sha256_store "$EGGDROP_FILE" "$INIT_BASE" "eggdrop"
    _ok_f "Salon ajouté : %s" "$chan"
}

_eggdrop_set_grade() {
    _eggdrop_init; _section "Grade sur un salon"
    local chan nick grade field
    read -rp " Salon : " chan || true
    read -rp " Nick : " nick || true
    echo " Grades salon : op / halfop / voice  (pour owner/admin/coadmin/xop, utilisez [5] Grade réseau)"
    read -rp " Grade : " grade || true

    case "$grade" in
        op)     field="ops" ;;
        halfop) field="halfops" ;;
        voice)  field="voices" ;;
        *) _warn "Grade invalide. Attendu : op / halfop / voice."; return 1 ;;
    esac

    _jq_update "$EGGDROP_FILE" \
        --arg c "$chan" --arg n "$nick" --arg f "$field" \
        '.channels[$c][$f] = ((.channels[$c][$f] // []) + [$n] | unique)'

    # Sync access.json
    if [[ -f "$ACCESS_FILE" ]]; then
        _jq_update "$ACCESS_FILE" \
            --arg n "$nick" --arg c "$chan" --arg g "$grade" \
            '.eggdrop.channels[$c] = ((.eggdrop.channels[$c] // {}) + {($n): $g})'
        _sha256_store "$ACCESS_FILE" "$INIT_BASE" "access"
    fi

    _sha256_store "$EGGDROP_FILE" "$INIT_BASE" "eggdrop"
    _ok_f "%s → %s sur %s" "$nick" "$grade" "$chan"
}

_eggdrop_network_grade() {
    _eggdrop_init; _section "Grade réseau Eggdrop"
    local nick grade
    read -rp " Nick : " nick || true
    echo " Grades réseau : owner / admin / coadmin / xop"
    read -rp " Grade : " grade || true
    case "$grade" in
        owner)   _jq_update "$EGGDROP_FILE" --arg n "$nick" '.network.owner = $n' ;;
        admin)   _jq_update "$EGGDROP_FILE" --arg n "$nick" '.network.admins = ((.network.admins//[]) + [$n] | unique)' ;;
        coadmin) _jq_update "$EGGDROP_FILE" --arg n "$nick" '.network.coadmins = ((.network.coadmins//[]) + [$n] | unique)' ;;
        xop)     _jq_update "$EGGDROP_FILE" --arg n "$nick" '.network.xops = ((.network.xops//[]) + [$n] | unique)' ;;
        *) _warn "Grade invalide. Attendu : owner / admin / coadmin / xop."; return 1 ;;
    esac
    _sha256_store "$EGGDROP_FILE" "$INIT_BASE" "eggdrop"
    _ok_f "%s → %s (réseau)" "$nick" "$grade"
}

_eggdrop_status() {
    _eggdrop_init; _section "État Eggdrop"
    _kv "Réseau" "$(jq -r '.network.name' "$EGGDROP_FILE")"
    _kv "Owner" "$(jq -r '.network.owner // "non défini"' "$EGGDROP_FILE")"
    _kv "Admins" "$(jq -r '.network.admins | join(", ")' "$EGGDROP_FILE")"
    _kv "Coadmins" "$(jq -r '.network.coadmins | join(", ")' "$EGGDROP_FILE")"
    _kv "Xops" "$(jq -r '.network.xops | join(", ")' "$EGGDROP_FILE")"
    _kv "Bots" "$(jq -r '.bots | length' "$EGGDROP_FILE")"
    echo ""
    _section "Salons configurés"
    jq -r '
        .channels | to_entries[] |
        "  \(.key)\tops:\(.value.ops|length)\tho:\(.value.halfops|length)\tv:\(.value.voices|length)\t\(if .value.active then "✔" else "✖" end)"
    ' "$EGGDROP_FILE" | column -t
}

_eggdrop_grades_menu() {
    _eggdrop_init
    while true; do
        # ⚠ Ce menu dessinait ses entrées par « cat <<-MENU », donc HORS de
        # tout cadre : le bandeau se fermait proprement et les six lignes
        # flottaient en dessous, sans bordure, sans alignement et sans
        # troncature. Ce n'était pas un débordement de largeur — la plus longue
        # entrée tient en 59 colonnes pour un cadre qui en fait au moins 64 —
        # mais l'absence pure et simple de cadre. Repris le 30/08/2026.
        _ui_width
        _ui_title "Eggdrop — Grades & Réseau" "bots, salons et grades du réseau"
        _ui_top
        _ui_item 1 "Vue d'ensemble réseau"
        _ui_item 2 "Ajouter un bot"
        _ui_item 3 "Ajouter un salon"
        _ui_item 4 "Grade sur un salon      ${DIM}(op/halfop/voice)${RESET}"
        _ui_item 5 "Grade réseau            ${DIM}(owner/admin/coadmin/xop)${RESET}"
        _ui_item 0 "Retour"
        _ui_bot

        _ui_prompt
        read -r c || c=0
        case "$c" in
            1) _eggdrop_status || true ;;
            2) _eggdrop_add_bot || true ;;
            3) _eggdrop_add_channel || true ;;
            4) _eggdrop_set_grade || true ;;
            5) _eggdrop_network_grade || true ;;
            0) return ;;
            *) _warn "Choix invalide" || true ;;
        esac
        _pause
    done
}

# =============================================================================
# ════════════════════════════════════
# SERVICES — Eggdrop/IriX/Anope/Lea
# ════════════════════════════════════
# =============================================================================

# -----------------------------------------------------------------------------
# _svc_repair_dirs
#   Repose l'install_dir des services que _services_init sait créer, quand le
#   fichier vient d'un schéma antérieur qui n'avait pas ce champ.
#
#   Ce n'est pas une précaution théorique. Le services.json en service le
#   30/07/2026 portait « install_dir: null » pour anope et lea : _install_anope
#   a donc compilé dans un répertoire littéralement nommé « null », créé
#   RELATIVEMENT au répertoire courant — 876 Mo de sources et d'objets déposés
#   hors de l'arborescence prévue, et un « binaire introuvable » pour seule
#   explication à l'arrivée.
#
#   eggdrop n'est jamais touché : son install_dir pointe vers l'instance IriX,
#   ce qui est délibéré et n'a rien d'un champ manquant.
# -----------------------------------------------------------------------------
_svc_repair_dirs() {
    local f="$SERVICES_FILE" svc actuel voulu n=0
    [[ -f "$f" ]] || return 0

    for svc in anope lea; do
        actuel=$(jq -r --arg s "$svc" '.services[$s].install_dir // "null"' "$f" 2>/dev/null)
        [[ "$actuel" != "null" && -n "$actuel" ]] && continue

        voulu="$BASE_DIR/Components/Service/$svc"
        jq --arg s "$svc" --arg d "$voulu" '.services[$s].install_dir = $d' \
           "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" || { rm -f "${f}.tmp"; continue; }
        _warn_f "install_dir manquant pour %s — reposé sur %s" "${svc}" "${voulu}"
        (( n++ ))
    done

    (( n > 0 )) && _info "Fichier de services issu d'un schéma antérieur : ${n} chemin(s) réparé(s)."
    return 0
}

# -----------------------------------------------------------------------------
# _dir_exploitable <étiquette> <chemin>
#   Un répertoire d'installation doit être ABSOLU. Vide, « null » ou relatif, il
#   fait travailler mkdir, tar, cmake et make dans le répertoire courant — qui
#   n'a aucune raison d'être le bon, et qui fut le dépôt lui-même le 30/07/2026.
#   La garde est ici et non dans chaque appelant : elle vaut pour les services
#   comme pour les serveurs.
# -----------------------------------------------------------------------------
_dir_exploitable() {
    local quoi="$1" dir="${2:-}"
    if [[ -z "$dir" || "$dir" == "null" || "$dir" != /* ]]; then
        _err "Répertoire d'installation inexploitable pour ${quoi} : « ${dir:-vide} »."
        _info "Attendu : un chemin absolu. Un chemin nul ou relatif ferait compiler"
        _info "dans le répertoire courant, hors de l'arborescence du projet."
        return 1
    fi
    return 0
}

_services_init() {
    if [[ -f "$SERVICES_FILE" ]]; then
        _svc_repair_dirs
        _sources_sync
        return 0
    fi
    mkdir -p "$(dirname "$SERVICES_FILE")"
    jq -n \
        --arg ts   "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --arg comp "$BASE_DIR/Components/Service" \
        --arg repo "https://github.com/$(jq -r '.git.repo' "$INIT_DEFAULT" 2>/dev/null)" \
        --arg ve   "$VER_EGGDROP" --arg ue "$URL_EGGDROP" \
        --arg va   "$VER_ANOPE"   --arg ua "$URL_ANOPE" \
    '{
        meta: { created: $ts },
        services: {
            eggdrop: {
                name: "Eggdrop", enabled: false, installed: false, running: false,
                install_dir: ($comp+"/eggdrop"), binary: null, config: null,
                build: "configure_make",
                version: $ve,
                source_url: $ue,
                log: { enabled: true, path: null, level: "info" },
                sha256: { verified: false, expected_hash: null, last_checked: null },
                errors: [], last_install: null, last_start: null, last_stop: null
            },
            anope: {
                name: "Anope Services", enabled: false, installed: false, running: false,
                install_dir: ($comp+"/anope"), binary: null, config: null,
                build: "cmake",
                version: $va,
                source_url: $ua,
                log: { enabled: true, path: null, level: "info" },
                sha256: { verified: false, expected_hash: null, last_checked: null },
                errors: [], last_install: null, last_start: null, last_stop: null
            },
            lea: {
                name: "Lea Service", enabled: false, installed: false, running: false,
                install_dir: ($comp+"/lea"), binary: null, config: null,
                build: "configure_make",
                source_url: $repo,
                log: { enabled: true, path: null, level: "info" },
                sha256: { verified: false, expected_hash: null, last_checked: null },
                errors: [], last_install: null, last_start: null, last_stop: null
            }
        }
    }' > "$SERVICES_FILE"
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
    _ok "Services config initialisée"
}

# -----------------------------------------------------------------------------
# _svc_get <service> <champ> / _svc_set <service> <filtre_jq> [args...]
#   Mêmes conventions que _srv_get / _srv_set (module Serveurs IRC).
# -----------------------------------------------------------------------------
_svc_get() {
    local svc="$1" field="$2"
    jq -r --arg s "$svc" ".services[\$s].${field}" "$SERVICES_FILE"
}

_svc_set() {
    local svc="$1"; shift
    local filter="$1"; shift
    _jq_update "$SERVICES_FILE" --arg s "$svc" "$filter" "$@"
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
}

# -----------------------------------------------------------------------------
# _svc_paths <service>
#   Affiche sur stdout : <run_dir> <pid_file> <log_file>
# -----------------------------------------------------------------------------
_svc_paths() {
    local svc="$1" base
    base=$(_svc_get "$svc" "install_dir")
    printf '%s/run %s/run/service.pid %s/run/service.log\n' "$base" "$base" "$base"
}

_service_status_icon() {
    local svc="$1"
    local installed enabled running has_error icon suffix=""
    installed=$(_svc_get "$svc" "installed")
    enabled=$(_svc_get "$svc" "enabled")
    running=$(_service_status "$svc")
    has_error=$(jq -r --arg s "$svc" '.services[$s].errors | length > 0' "$SERVICES_FILE")

    if   [[ "$installed" != "true"    ]]; then icon="$(_ui_badge off  'non installé')"
    elif [[ "$running"   == "running" ]]; then icon="$(_ui_badge run  'en marche')"
    elif [[ "$enabled"   == "true"    ]]; then icon="$(_ui_badge warn 'arrêté')"
    else                                       icon="$(_ui_badge err  'désactivé')"
    fi
    [[ "$has_error" == "true" ]] && suffix+=" ${RED}⚑${RESET}${DIM} erreurs${RESET}"
    printf '%s' "${icon}${suffix}"
}

# =============================================================================
# Cycle de vie : démarrage / arrêt / redémarrage (par PID, comme les serveurs IRC)
# =============================================================================

_service_start() {
    local svc="$1" install_dir bin run_dir pid_file log_file pid
    install_dir=$(_svc_get "$svc" "install_dir")
    bin=$(_svc_get "$svc" "binary")
    read -r run_dir pid_file log_file < <(_svc_paths "$svc")

    [[ -z "$bin" || "$bin" == "null" ]] && { _err "Aucun binaire défini pour $svc. Installez-le d'abord ([1])."; return 1; }
    [[ ! -x "${install_dir}/${bin}" ]] && { _err "Binaire introuvable ou non exécutable : ${install_dir}/${bin}"; return 1; }

    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        _warn_f "%s est déjà en cours d'exécution." "$svc"
        return 0
    fi

    mkdir -p "$run_dir"
    _info_f "Démarrage de %s…" "$svc"
    # `( cd … && nohup … & echo $! )` capturait le PID du SOUS-SHELL, pas celui
    # du processus : le & porte sur la liste entière. Le sous-shell mourant
    # aussitôt, kill -0 échouait et le panel annonçait « terminé immédiatement »
    # alors que le service tournait — devenu orphelin et impossible à arrêter.
    ( cd "$install_dir" && exec nohup "./${bin}" > "$log_file" 2>&1 ) &
    pid=$!
    echo "$pid" > "$pid_file"
    sleep 1

    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
        _svc_set "$svc" '.services[$s].running = true | .services[$s].last_start = $ts' \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        _log_entry "$svc" "start" "PID=$pid"
        _ok_f "%s démarré (PID : %s)." "$svc" "$pid"
    else
        rm -f "$pid_file"
        _service_report_error "$svc" "Échec du démarrage"
        _err_f "Le processus %s s'est terminé immédiatement (voir %s)." "$svc" "$log_file"
        return 1
    fi
}

_service_stop() {
    local svc="$1" pid_file pid
    read -r _ pid_file _ < <(_svc_paths "$svc")

    if [[ ! -f "$pid_file" ]]; then
        _warn_f "%s : aucun PID trouvé, probablement déjà arrêté." "$svc"
        _svc_set "$svc" '.services[$s].running = false'
        return 0
    fi

    pid=$(cat "$pid_file")
    if kill -0 "$pid" 2>/dev/null; then
        _info_f "Envoi de SIGTERM au PID %s…" "$pid"
        kill "$pid"
        sleep 2
        if kill -0 "$pid" 2>/dev/null; then
            _warn "Le processus ne répond pas, envoi de SIGKILL…"
            kill -9 "$pid"
            sleep 1
        fi
    else
        _warn_f "%s : PID %s introuvable dans les processus actifs." "$svc" "$pid"
    fi

    rm -f "$pid_file"
    _svc_set "$svc" '.services[$s].running = false | .services[$s].last_stop = $ts' \
        --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _log_entry "$svc" "stop" "PID=$pid"
    _ok_f "%s arrêté." "$svc"
}

_service_restart() {
    local svc="$1"
    _info_f "Redémarrage de %s…" "$svc"
    _service_stop "$svc"
    sleep 1
    _service_start "$svc"
}

_service_status() {
    local svc="$1" pid_file
    read -r _ pid_file _ < <(_svc_paths "$svc")
    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        echo "running"
    else
        echo "stopped"
    fi
}

_service_logs() {
    local svc="$1" log_file
    read -r _ _ log_file < <(_svc_paths "$svc")
    [[ -f "$log_file" ]] || { _warn "Aucun fichier de log trouvé pour $svc."; return 1; }
    _info_f "Logs de %s (Ctrl+C pour quitter) :" "$svc"
    tail -f "$log_file"
}

# =============================================================================
# Installation par compilation — configure/make (Eggdrop, Lea) / CMake (Anope)
# =============================================================================

# -----------------------------------------------------------------------------
# _make_jobs — nombre de compilations parallèles.
#
# Appeler nproc directement dans « make -j"$(nproc)" » donne « make -j » tout
# court quand nproc est absent — le cas sur une image minimale dépourvue de
# coreutils complet. Et « make -j » sans
# argument ne veut pas dire « un job » : il veut dire « autant de jobs que de
# règles », soit une fourche massive qui met à genoux une petite machine, sans
# qu'aucune erreur ne soit signalée. On borne, et on se rabat sur 1.
# -----------------------------------------------------------------------------
_make_jobs() {
    local n
    n=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
    [[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1 )) || n=1
    (( n > 16 )) && n=16
    printf '%s' "$n"
}

# -----------------------------------------------------------------------------
# _safe_rmrf <chemin>
#   Suppression récursive avec garde-fous. `_svc_get`/`_srv_get` renvoient la
#   chaîne "null" ou une chaîne vide quand la clé JSON est absente : un
#   `rm -rf "$dir"` nu supprimait alors un dossier "null" du répertoire courant,
#   ou pire échouait silencieusement. On refuse aussi tout chemin hors de
#   l'arborescence du projet.
# -----------------------------------------------------------------------------
_safe_rmrf() {
    local path="${1:-}"

    [[ -n "$path" && "$path" != "null" ]] || { _err "Suppression refusée : chemin vide"; return 1; }
    case "$path" in
        /|/home|/root|/usr|/etc|/var|/tmp|"$TARGET_HOME"|"$BASE_DIR")
            _err "Suppression refusée : chemin protégé ($path)"; return 1 ;;
    esac
    [[ "$path" == /* ]] || { _err "Suppression refusée : chemin relatif ($path)"; return 1; }
    case "$path" in
        "$BASE_DIR"/*|"$TARGET_HOME"/*) : ;;
        *) _err "Suppression refusée : hors arborescence projet ($path)"; return 1 ;;
    esac

    [[ -e "$path" ]] || return 0
    rm -rf "$path"
}

# -----------------------------------------------------------------------------
# _service_install_fail <svc> [raison]
#   raison = "deps" → PAS de rollback destructif. Un échec d'installation de
#   paquets (sudo refusé, dépôt injoignable) n'est pas une installation
#   corrompue : détruire le dossier faisait perdre une install fonctionnelle.
# -----------------------------------------------------------------------------
_service_install_fail() {
    local svc="$1" reason="${2:-build}" dir
    dir=$(_svc_get "$svc" "install_dir")

    if [[ "$reason" == "deps" ]]; then
        _err "Dépendances non satisfaites — installation abandonnée, rien n'a été supprimé."
        _service_report_error "$svc" "Dépendances système manquantes"
        return 0
    fi

    # 🔴 17/10/2026 — LA RECONSTRUCTION NE DÉTRUIT RIEN (`anope-construire`,
    #   lancée depuis le panel). Le rollback efface `$dir` ENTIER : sur une
    #   installation en place, c'est sa configuration (anope.conf, le mot de
    #   passe du lien) ET sa base de comptes (data/anope.json) — une compilation
    #   ratée aurait coûté tous les comptes NickServ. Mesuré le 17/10 en prod :
    #   conf/anope.conf et data/anope.json existent. Ce drapeau n'est posé que
    #   par `_anope_construire_cli`.
    if [[ "${_SANS_ROLLBACK:-0}" == "1" ]]; then
        _err "Construction échouée — RIEN n'a été supprimé : l'installation en place (configuration, base de comptes) est gardée telle quelle."
        _service_report_error "$svc" "Reconstruction échouée"
        return 0
    fi

    _err "Installation échouée → rollback."
    _safe_rmrf "$dir"
    _svc_set "$svc" '.services[$s].installed = false'
    _service_report_error "$svc" "Installation échouée"
}

_install_eggdrop() {
    local svc="eggdrop" dir
    dir=$(_svc_get "$svc" "install_dir")
    _dir_exploitable "Eggdrop" "$dir" || { _service_install_fail "$svc" deps; return 1; }

    # Le découpage en mots est VOULU : `_req_for` rend une liste de paquets
    # séparée par des espaces, et `_pkg_install` en attend plusieurs.
    # ⚠ La directive ne portait pas : elle était écrite « disable=SC2046 -- … »,
    #   et ce « -- » n'est pas une syntaxe reconnue. shellcheck ne la comprenait
    #   pas (SC1072/SC1073), donc il n'appliquait PAS la suppression et cessait
    #   d'analyser la suite. Un commentaire se met après un « # », pas après un
    #   tiret. Trouvé le 13/09/2026, au premier passage de shellcheck.
    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$svc") || { _service_install_fail "$svc" deps; return 1; }

    mkdir -p "$(dirname "$dir")"

    # Épinglé par VER_EGGDROP, en tête de ce fichier. Le plancher reste 1.10.0 :
    # les versions 1.9.x ont un bug documenté de lecture réseau TLS qui bloque la
    # connexion jusqu'au timeout d'enregistrement côté serveur IRC (corrigé dans
    # le changelog officiel 1.10.0). Voir PROJET.md / MODULES.md.
    _info_f "Récupération d'Eggdrop %s…" "${VER_EGGDROP}"
    if [[ "$EGGDROP_EST_RC" == "oui" ]]; then
        _warn_f "Eggdrop %s est une VERSION CANDIDATE, retenue délibérément." "${VER_EGGDROP}"
        _info "La dernière version publiée est la 1.10.1. En cas de comportement"
        _info "inattendu d'IriX, c'est la première chose à remettre en cause."
    fi

    local rc
    _fetch_sources "$(_svc_get "$svc" "source_url")" "$dir" "eggdrop*.tar.gz" "$svc"; rc=$?
    (( rc == 2 )) && { _service_install_fail "$svc" sources; return 1; }
    (( rc != 0 )) && { _service_install_fail "$svc"; return 1; }

    # `make` seul, puis `make install` — et surtout PAS « make eggdrop ».
    #
    # Chez Eggdrop, « make eggdrop » ne construit que le binaire ; ce sont
    # « make modules » et « make install » qui produisent puis rassemblent les
    # greffons .so. L'ancienne forme « make eggdrop || make » s'arrêtait au
    # premier succès, donc au binaire seul : l'installation n'avait aucun
    # modules/, IriX ne pouvait charger ni channels, ni server, ni irc, et son
    # premier « bind msg » échouait sur « bad type ». Constaté sur une
    # installation neuve menée de bout en bout le 29/07/2026.
    #
    # « make install » place binaire, modules/, language/ et help/ dans DEST.
    # ⚠⚠ EGGDROP REFUSE D'INSTALLER DANS SON PROPRE RÉPERTOIRE DE SOURCES, et
    # c'est son Makefile qui le dit — « You are trying to install into the source
    # directory. That will not work. » Or `_fetch_sources` extrait justement dans
    # `$dir` (`tar -C "$dir" --strip-components=1`), et l'on y faisait ensuite
    # `make install DEST="$dir"`. La compilation réussissait — binaire et modules
    # produits — puis l'installation mourait sur `install-start`, et le rollback
    # effaçait les traces derrière lui.
    #
    # ⚠ Ce n'était NI une affaire de distribution, NI de droits : cela échouait
    # partout. Personne ne l'avait vu parce que la production n'a jamais repassé
    # par ce chemin. Constaté le 23/08/2026 sur une Ubuntu 24.04 vierge, et sans
    # Eggdrop l'écran IriX reste verrouillé — donc l'installateur ne pouvait poser
    # AUCUN produit du projet sur une machine neuve. Point 45.
    #
    # On compile donc à côté, et l'on installe dans `$dir`, que la suite attend.
    (
        srcdir="${dir}/.src-build"
        rm -rf "$srcdir"; mkdir -p "$srcdir" || exit 1
        shopt -s dotglob
        for f in "$dir"/*; do
            [[ "$f" == "$srcdir" ]] && continue
            mv "$f" "$srcdir"/ || exit 1
        done
        shopt -u dotglob
        cd "$srcdir" || exit 1
        ./configure                       || exit 1
        make config                       || exit 1
        make -j"$(_make_jobs)"            || exit 1
        make install DEST="$dir"          || exit 1
    ) || { _service_install_fail "$svc"; return 1; }

    [[ -x "${dir}/eggdrop" ]] || { _err "Compilation Eggdrop : binaire introuvable."; _service_install_fail "$svc"; return 1; }

    # Les modules sont vérifiés séparément : un binaire sans eux se lance, puis
    # refuse toute configuration qui utilise un bind msg, pub ou join — c'est-à-dire
    # celle d'IriX.
    if compgen -G "${dir}/modules/*.so" >/dev/null 2>&1; then
        _ok "Modules Eggdrop compilés : $(ls "${dir}"/modules/*.so | wc -l)"
    else
        _err_f "Eggdrop compilé, mais aucun module .so dans %s/modules/." "${dir}"
        _info "IriX ne pourra pas charger sans eux (bind msg, pub, join)."
        _service_install_fail "$svc"
        return 1
    fi

    mkdir -p "${dir}/run"; touch "${dir}/.installed"
    _svc_set "$svc" \
        '.services[$s].binary = $b | .services[$s].installed = true | .services[$s].last_install = $ts' \
        --arg b "eggdrop" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _ok "Eggdrop compilé (configure/make) et installé."
}

_install_anope() {
    local svc="anope" dir
    dir=$(_svc_get "$svc" "install_dir")
    _dir_exploitable "Anope" "$dir" || { _service_install_fail "$svc" deps; return 1; }

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$svc") || { _service_install_fail "$svc" deps; return 1; }

    # Épinglé par VER_ANOPE. La branche 2.1 est retenue plutôt que la 2.0 —
    # c'est elle qui a servi à l'essai de liaison du 29/07/2026, et c'est elle
    # qui a renommé le module de protocole (`unreal` → `unrealircd`), ce dont
    # le panel tient compte depuis le lot 8. Voir PROTOCOLES.md §6.
    local rc
    _info_f "Récupération d'Anope %s…" "${VER_ANOPE}"
    _fetch_sources "$(_svc_get "$svc" "source_url")" "${dir}/src" "anope*.tar.gz" "$svc"; rc=$?
    (( rc == 2 )) && { _service_install_fail "$svc" sources; return 1; }
    (( rc != 0 )) && { _service_install_fail "$svc"; return 1; }

    (
        mkdir -p "${dir}/src/build" && cd "${dir}/src/build" || exit 1
        cmake .. -DCMAKE_INSTALL_PREFIX="$dir" && make -j"$(_make_jobs)" && make install
    ) || { _service_install_fail "$svc"; return 1; }

    # Le binaire a changé de nom entre les deux branches : « bin/services » en
    # 2.0, « bin/anope » en 2.1 (son README : « Run ./bin/anope to start Anope »).
    # Ce nom était écrit en dur, et l'installation de la 2.1.25 échouait donc sur
    # « binaire introuvable » APRÈS une compilation et un make install réussis —
    # puis le rollback effaçait tout. Même famille de défaut que le module de
    # protocole renommé au §6 de PROTOCOLES.md : la 2.1 renomme, on lit.
    local bin=""
    local cand
    for cand in "bin/anope" "bin/services"; do
        [[ -x "${dir}/${cand}" ]] && { bin="$cand"; break; }
    done
    if [[ -z "$bin" ]]; then
        _err "Compilation Anope (CMake) : aucun binaire trouvé (cherché : bin/anope, bin/services)."
        _service_install_fail "$svc"
        return 1
    fi

    mkdir -p "${dir}/run"; touch "${dir}/.installed"
    _svc_set "$svc" \
        '.services[$s].binary = $b | .services[$s].installed = true | .services[$s].last_install = $ts' \
        --arg b "$bin" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _ok_f "Anope compilé (CMake) et installé (%s)." "${bin}"

    # Compiler ne suffit pas : Anope refuse de démarrer sans sa configuration,
    # et ce fichier porte le mot de passe du lien vers le serveur IRC. Le
    # déposer ici évite l'installation « réussie » qui ne démarre jamais — le
    # même défaut que celui corrigé pour Eggdrop et pour overtchatd.
    #
    # Le nom de ce fichier dépend de la branche — conf/anope.conf en 2.1,
    # conf/services.conf en 2.0 —, d'où le renvoi à la fonction plutôt qu'un
    # nom écrit ici.
    _anope_configure "$dir" || {
        _warn "Anope est compilé, mais sa configuration reste à écrire."
        _info "Sans elle, il refusera de démarrer : relancer l'installation d'Anope."
        # 17/10/2026 : sans clavier (`anope-construire`), une configuration
        # refusée est un ÉCHEC de la construction — le panel doit le lire.
        [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]] && return 1
    }
    return 0
}

# -----------------------------------------------------------------------------
# _sed_escape <valeur>
#
# Rend une valeur utilisable en PARTIE DROITE d'un « s|…|…| ». Trois caractères
# y sont des instructions et non des lettres : la barre oblique inverse, le
# séparateur, et « & » — qui vaut la correspondance entière. Un mot de passe
# contenant « & » produisait donc silencieusement un autre mot de passe.
# -----------------------------------------------------------------------------
_sed_escape() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; }

# Les modules de protocole des deux branches d'Anope, en une alternance ERE.
# Réunis ici parce qu'ils servent à trois endroits — substituer, compter le
# total, contrôler qu'il n'en reste qu'un — et que trois copies divergent.
readonly _ANOPE_PROTOS='(unreal[a-z0-9]*|inspircd[0-9]*|hybrid|ngircd|plexus|ratbox|charybdis|solanum|bahamut)'

# -----------------------------------------------------------------------------
# _anope_srv_conf
#
# Le fichier de configuration du serveur IRC, ou rien.
#
# ⚠ Le registre des serveurs ne suffit pas : son champ « config » vaut null, et
# la configuration en service vit HORS du dépôt — l'installation d'origine la
# pose dans ~/overtchatd/conf. On cherche donc, dans l'ordre, ce que le registre
# dit, puis les emplacements où l'installateur et l'exploitation la posent
# réellement. Le premier fichier lisible gagne.
# -----------------------------------------------------------------------------
_anope_srv_conf() {
    local f dir

    f=$(_srv_get "overtchat_ircd" "config" 2>/dev/null || true)
    [[ -n "$f" && "$f" != "null" && -r "$f" ]] && { printf '%s' "$f"; return 0; }

    dir=$(_srv_get "overtchat_ircd" "install_dir" 2>/dev/null || true)
    if [[ -n "$dir" && "$dir" != "null" && -r "${dir}/conf/overtchat.conf" ]]; then
        printf '%s' "${dir}/conf/overtchat.conf"; return 0
    fi

    [[ -r "${HOME}/overtchatd/conf/overtchat.conf" ]] && \
        { printf '%s' "${HOME}/overtchatd/conf/overtchat.conf"; return 0; }

    return 0
}

# -----------------------------------------------------------------------------
# _anope_link_noms
#
# Les noms de tous les blocs « link » du serveur IRC, un par ligne.
#
# ⚠ ILS SONT LA SEULE SOURCE DU NOM À DONNER À ANOPE. Le nom proposé se
# fabriquait depuis l'identité du réseau — « services.${reseau,,} », soit
# « services.service-overtchat » sur cette installation. Le bloc link, lui,
# s'appelle « services.overtchat.fr ». Anope se serait annoncé sous un nom que
# le serveur ne connaît pas : lien refusé, et le refus ne dit pas pourquoi.
# Constaté le 11/08/2026, avant qu'Anope ait démarré une seule fois.
# -----------------------------------------------------------------------------
_anope_link_noms() {
    local f; f=$(_anope_srv_conf)
    [[ -n "$f" ]] || return 0
    awk '$1 == "link" && $2 != "" { gsub(/[{;]/, "", $2); if ($2 != "") print $2 }' \
        "$f" 2>/dev/null
}

# -----------------------------------------------------------------------------
# _anope_srv_port
#
# Le port sur lequel le serveur IRC attend ses SERVEURS, ou rien.
#
# ⚠ CE PORT NE SE DEVINE PAS, ET 7000 EST UN MAUVAIS DÉFAUT ICI. C'est celui
# d'UnrealIRCd, et la configuration d'Overtchat-Hybrid dit en toutes lettres
# pourquoi elle ne l'emploie pas : « 6900, et non le 7000 d'UnrealIRCd —
# l'outil d'essai ne sait réécrire que 6667/6697/6900 ».
#
# Le 11/08/2026, `anope.conf` a été écrit avec 7000 pendant que le serveur
# écoutait sur 6900. Rien n'écoutait au bout : le lien n'aurait pas monté, et
# **une connexion refusée ne dit pas qu'on s'est trompé de port** — elle dit
# qu'elle a été refusée. Le nom et le mot de passe étaient lus dans la
# configuration du serveur ; le port, lui, restait fabriqué. Une valeur sur
# trois suffit à tout casser.
#
# ⚠ Le bloc `listen` porte son `port` AVANT ses `options` : on accumule donc le
# bloc entier, et on ne décide qu'à l'accolade fermante. Lire le port dès qu'on
# le voit rendrait celui du premier `listen` venu — 6667, les clients.
# -----------------------------------------------------------------------------
_anope_srv_port() {
    local f; f=$(_anope_srv_conf)
    [[ -n "$f" ]] || return 0

    awk '
        /^[[:space:]]*listen[[:space:]]*\{/ { dans = 1; p = ""; srv = 0; next }
        dans && /^[[:space:]]*port[[:space:]]+[0-9]+/ {
            if (match($0, /[0-9]+/)) p = substr($0, RSTART, RLENGTH)
        }
        dans && /serversonly/ { srv = 1 }
        dans && /^[[:space:]]*\}/ {
            if (srv && p != "") { print p; exit }
            dans = 0
        }
    ' "$f" 2>/dev/null
}

# -----------------------------------------------------------------------------
# _anope_srv_hote
#
# L'adresse du listener « serversonly » du serveur IRC, telle qu'Anope doit la
# joindre, ou rien.
#
# 17/10/2026 : ajoutée pour la configuration SANS CLAVIER (`anope-construire`),
# qui ne prend que des valeurs LUES. Mesuré en prod le 17/10 : `ip 127.0.0.1;`
# sur le port 6900. Une écoute sur toutes les adresses (`*`, `0.0.0.0`, `::`)
# comprend la boucle locale : on rend alors 127.0.0.1, ce que l'invite propose
# déjà. Même lecture par bloc entier que `_anope_srv_port` (le `ip` précède les
# `options`).
# -----------------------------------------------------------------------------
_anope_srv_hote() {
    local f h; f=$(_anope_srv_conf)
    [[ -n "$f" ]] || return 0

    h=$(awk '
        /^[[:space:]]*listen[[:space:]]*\{/ { dans = 1; ip = ""; srv = 0; next }
        dans && /^[[:space:]]*ip[[:space:]]+/ {
            ip = $2; gsub(/[;"]/, "", ip)
        }
        dans && /serversonly/ { srv = 1 }
        dans && /^[[:space:]]*\}/ {
            if (srv) { print ip; exit }
            dans = 0
        }
    ' "$f" 2>/dev/null)
    case "$h" in
        ''|'*'|0.0.0.0|'::') [[ -n "$h" ]] && printf '127.0.0.1' ;;
        *) [[ "$h" =~ ^[A-Za-z0-9.:-]+$ ]] && printf '%s' "$h" ;;
    esac
    return 0
}

# -----------------------------------------------------------------------------
# _anope_valeur <libellé> <défaut>
#
# Une valeur de la configuration d'Anope : posée en question à l'invite, ou —
# SANS CLAVIER (`_ANOPE_SANS_CLAVIER=1`, posé par `anope-construire`) — prise
# dans le défaut LU. 🔴 Sans clavier, un défaut vide est un REFUS (rend 1, le
# dit sur stderr) : jamais une valeur inventée pour remplir le trou.
# ⚠ Appelée dans un `$( )` : seule la valeur part sur stdout.
# -----------------------------------------------------------------------------
_anope_valeur() {
    local libelle="$1" defaut="${2:-}"
    if [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]]; then
        if [[ -z "$defaut" ]]; then
            printf '  ✗ %s : illisible dans la configuration du serveur IRC — REFUS, aucune valeur inventée.\n' "$libelle" >&2
            return 1
        fi
        printf '  %s : %s (lu)\n' "$libelle" "$defaut" >&2
        printf '%s' "$defaut"
        return 0
    fi
    _site_ask "$libelle" "$defaut"
}

# -----------------------------------------------------------------------------
# _anope_link_sid <nom du serveur de services>
#
# Le SID que le bloc « link <nom> » réserve à ce serveur, ou rien.
#
# ⚠ QUATRIÈME VALEUR DU MÊME BLOC, et la dernière qui manquait. Le modèle
# d'Anope livre son identifiant **commenté**, avec une valeur d'exemple :
#
#     serverinfo { … #id = "00A" … }
#
# Laissé tel quel, Anope se présente sans SID — ou avec celui de l'exemple — et
# le serveur répond « SID does not match the configured one », puis ferme.
# Constaté le 11/08/2026, juste après avoir réglé le port et le mot de passe :
# **chaque valeur fabriquée donnait un refus qui ne disait pas pourquoi**, et il
# a fallu trois démarrages pour les trouver une par une.
#
# ⚠ Ancré sur le NOM du bloc, comme le mot de passe : la configuration en porte
# plusieurs, et celui d'IriX réserve un autre SID. Prendre « le premier sid
# venu » donnerait à Anope l'identifiant du bot — deux serveurs pour un même
# SID, ce que le réseau refuse par construction.
# -----------------------------------------------------------------------------
_anope_link_sid() {
    local nom="$1" f
    [[ -n "$nom" ]] || return 0
    f=$(_anope_srv_conf)
    [[ -n "$f" ]] || return 0

    awk -v n="$nom" '
        $1 == "link" && $2 == n { dans = 1; next }
        dans && /^[[:space:]]*}/ { exit }
        dans && $1 == "sid" {
            if (match($0, /"[^"]*"/)) {
                print substr($0, RSTART + 1, RLENGTH - 2); exit
            }
        }' "$f" 2>/dev/null
}

# Le fichier où le bloc « link » a été lu, pour pouvoir le NOMMER à l'invite.
_ANOPE_LINK_SRC=""

# -----------------------------------------------------------------------------
# _anope_link_pass <nom du serveur de services>
#
# Rend, sur la sortie standard, le mot de passe du bloc « link <nom> » de la
# configuration du serveur IRC — vide si on ne le trouve pas.
#
# ⚠ POURQUOI LE LIRE PLUTÔT QUE LE DEMANDER. Ce mot de passe n'est pas un
# choix : il doit être identique à celui du bloc « link », sinon le serveur
# répond « no matching link » et rien d'autre. Une saisie recopiée à la main
# est donc le seul endroit où une divergence peut naître, pour un réglage qui
# n'avait aucune raison de varier. Tranché par SerVuS le 11/08/2026 : on le
# propose par défaut, il reste remplaçable à l'invite.
#
# ⚠ Il n'est jamais AFFICHÉ, ni ici ni à l'invite — seul le fichier d'où il
# vient est nommé. Un défaut qu'on montre est un secret qu'on publie.
#
# ⚠ Ancré sur le NOM du bloc, et il le faut : la configuration en porte
# plusieurs, et celui d'IriX a son propre secret. Lire « le premier password
# venu » aurait donné à Anope celui du bot — un lien refusé, pour une raison
# que rien n'aurait laissé voir.
# -----------------------------------------------------------------------------
_anope_link_pass() {
    local nom="$1" f pass=""

    _ANOPE_LINK_SRC=""
    [[ -n "$nom" ]] || return 0

    f=$(_anope_srv_conf)
    [[ -n "$f" ]] || return 0

    # Du « link <nom> » jusqu'à l'accolade fermante, la première ligne
    # « password ». Le « = » est facultatif : les deux écritures se
    # rencontrent, et une seule des deux aurait laissé le cas silencieux.
    pass=$(awk -v n="$nom" '
        $1 == "link" && $2 == n { dans = 1; next }
        dans && /^[[:space:]]*}/ { exit }
        dans && $1 == "password" {
            if (match($0, /"[^"]*"/)) {
                print substr($0, RSTART + 1, RLENGTH - 2); exit
            }
        }' "$f" 2>/dev/null)

    if [[ -n "$pass" ]]; then
        _ANOPE_LINK_SRC="$f"
        printf '%s' "$pass"
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _anope_configure <répertoire d'installation>
#
# Écrit la configuration d'Anope à partir du modèle livré par la version
# installée, en y injectant l'identité du réseau et le lien vers le serveur IRC.
#
# On PART DU MODÈLE fourni par la version installée plutôt que d'écrire une
# configuration de zéro : Anope change ses directives d'une version majeure à
# l'autre, et une conf écrite à la main pour la 2.0 ne se charge pas en 2.1.
# Le modèle, lui, accompagne toujours la version qu'on vient de compiler.
#
# ⚠ Quatre choses diffèrent entre les deux branches, et les quatre étaient
# fausses ici tant qu'on ne s'était pas mesuré à une 2.1 réellement installée
# (constaté le 31/07/2026 sur l'Anope 2.1.25 de ce dépôt) :
#
#   1. le MODÈLE s'appelle conf/anope.example.conf en 2.1, conf/example.conf en
#      2.0 : aucun des quatre noms cherchés ici n'existait, et la fonction
#      sortait en « modèle introuvable » avant d'écrire quoi que ce soit ;
#   2. le FICHIER LU par le binaire est conf/anope.conf en 2.1 — la chaîne
#      « services.conf » n'apparaît nulle part dans bin/anope. Y déposer un
#      services.conf donnait une installation « configurée » qui ne démarre pas,
#      exactement le défaut que la détection de bin/anope corrige plus haut ;
#   3. le NOM DU SERVEUR ne se pose plus dans serverinfo mais dans un
#      define { name = "services.host" ; value = … } dont serverinfo ET les
#      hôtes des pseudo-clients tirent leur valeur. Substituer ce « name »
#      renommait la VARIABLE, et ${services.host} ne se résolvait plus ;
#   4. le MODULE DE PROTOCOLE vaut « inspircd » dans le modèle. Overtchat-Hybrid
#      parle le protocole d'UnrealIRCd : laissé tel quel, le lien ne monte pas.
#
# Le mot de passe du lien est le point sensible : c'est le même que celui du
# bloc « link » du serveur IRC. Un mot de passe qui diverge donne un lien
# refusé sans autre explication qu'un « no matching link » côté serveur.
#
# Rien n'est annoncé qui n'ait été relu DANS le fichier produit : chaque valeur
# injectée y est recherchée après coup, et un écart se dit au lieu de passer
# pour une configuration réussie.
# -----------------------------------------------------------------------------
_anope_configure() {
    local dir="$1"
    local conf="" cible="" modele="" bin=""
    local c m

    mkdir -p "${dir}/conf"

    # ── Quel fichier ce binaire ouvrira-t-il ? ──
    # Déduit du nom du binaire — « bin/services » en 2.0, « bin/anope » en 2.1,
    # comme à la compilation — puis CONTRÔLÉ dans le binaire lui-même, qui porte
    # la chaîne qu'il ouvrira. Un désaccord vaut mieux dit qu'ignoré.
    for c in anope services; do
        [[ -x "${dir}/bin/${c}" ]] && { bin="${dir}/bin/${c}"; break; }
    done
    case "$bin" in
        */anope)    cible="anope.conf"    ;;
        */services) cible="services.conf" ;;
        *) _err "Aucun binaire Anope sous ${dir}/bin — compiler avant de configurer."
           return 1 ;;
    esac
    grep -aqF "$cible" "$bin" || \
        _warn "« ${cible} » ne figure pas dans ${bin##*/} : nom de configuration supposé."
    conf="${dir}/conf/${cible}"

    # 17/10/2026 : SANS CLAVIER (`anope-construire`, le bouton du panel), une
    # configuration existante est RÉÉCRITE d'après le bloc link — mais gardée
    # d'abord en `<conf>.avant-<date>`, juste avant le remplacement (plus bas),
    # et seulement si la nouvelle a passé sa relecture. À l'invite, rien ne change.
    local garder_avant=0
    if [[ -f "$conf" ]]; then
        if [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]]; then
            garder_avant=1
            _info_f "%s existe déjà — il sera gardé en %s.avant-<date> avant d'être réécrit." "${cible}" "${cible}"
        else
            _info_f "%s existe déjà — conservé tel quel." "${cible}"
            return 0
        fi
    fi

    for m in "${dir}/conf/anope.example.conf" "${dir}/conf/example.conf" \
             "${dir}/conf/services.conf.example" "${dir}/docs/services.conf.example" \
             "${dir}/src/docs/services.conf.example"; do
        [[ -f "$m" ]] && { modele="$m"; break; }
    done
    if [[ -z "$modele" ]]; then
        _err_f "Modèle de configuration Anope introuvable sous %s." "${dir}"
        return 1
    fi
    _info "Modèle utilisé : ${modele#"$dir"/} → ${cible}"

    # ── Module de protocole ──
    # Anope ne devine pas le serveur d'en face. On ne propose que des modules
    # RÉELLEMENT compilés : un nom absent de modules/ fait échouer le démarrage
    # sur « unable to load module », après une configuration pourtant annoncée
    # réussie. La 2.1 ne livre plus bahamut — la 2.0, si.
    local dispo=() f
    for f in "${dir}/modules"/*.so; do
        [[ -e "$f" ]] || continue
        f="${f##*/}"; f="${f%.so}"
        case "$f" in
            unreal*|inspircd*|hybrid|ngircd|plexus|ratbox|charybdis|solanum|bahamut)
                dispo+=("$f") ;;
        esac
    done

    local proto proto_defaut=""
    for c in unrealircd unreal4 unreal; do
        [[ " ${dispo[*]} " == *" ${c} "* ]] && { proto_defaut="$c"; break; }
    done
    [[ -z "$proto_defaut" && ${#dispo[@]} -gt 0 ]] && proto_defaut="${dispo[0]}"

    if ((${#dispo[@]})); then
        _info "Modules de protocole compilés : ${dispo[*]}"
    else
        _warn_f "Aucun module de protocole sous %s/modules — Anope ne pourra pas se lier." "${dir}"
    fi
    proto=$(_anope_valeur "Protocole du serveur IRC (module Anope)" "$proto_defaut") || return 1
    if [[ -z "$proto" ]]; then
        _err "Aucun module de protocole choisi — Anope refuserait de démarrer."
        return 1
    fi
    if ((${#dispo[@]})) && [[ " ${dispo[*]} " != *" ${proto} "* ]]; then
        _err_f "« %s » n'est pas compilé ici — Anope ne saurait pas le charger." "${proto}"
        return 1
    fi
    # Ce nom part dans une expression sed sans être échappé : on le tient à ce
    # qu'un nom de module peut réellement contenir.
    if [[ ! "$proto" =~ ^[A-Za-z0-9_]+$ ]]; then
        _err_f "« %s » n'est pas un nom de module recevable." "${proto}"
        return 1
    fi

    # Valeurs reprises de l'identité du réseau, comme pour IriX : le site, le
    # bot et les services doivent annoncer le même réseau.
    local ident="${BASE_DIR}/Config/identity.json"
    local reseau nom_serv desc mail
    if [[ -f "$ident" ]]; then
        reseau=$(jq -r '.name   // "Overtchat"' "$ident" 2>/dev/null)
        desc=$(jq -r   '.tagline // "Services"' "$ident" 2>/dev/null)
        mail=$(jq -r   '.mail.from // empty'    "$ident" 2>/dev/null)
    else
        reseau="Overtchat"; desc="Services"; mail=""
    fi
    # ── Le nom du serveur de services ──
    #
    # ⚠ IL DOIT ÊTRE CELUI D'UN BLOC « link » DU SERVEUR, pas un nom fabriqué.
    # Le défaut se calculait sur l'identité du réseau et donnait ici
    # « services.service-overtchat » quand le bloc s'appelle
    # « services.overtchat.fr » : Anope se serait présenté sous un nom inconnu
    # du serveur, et le refus n'aurait rien expliqué.
    #
    # On propose donc le bloc qui parle de services, à défaut le seul autre que
    # celui d'IriX, et on ne retombe sur le nom fabriqué que si le serveur n'a
    # aucun bloc lisible — cas d'une installation neuve, où c'est l'exploitant
    # qui écrira le bloc ensuite.
    local nom_defaut="" n
    while IFS= read -r n; do
        [[ -n "$n" ]] || continue
        case "$n" in
            *service*|*svc*) nom_defaut="$n"; break ;;
        esac
    done < <(_anope_link_noms)
    # ⚠ 17/10/2026 : SANS CLAVIER, le nom fabriqué n'est PAS un défaut — c'est
    #   précisément la valeur inventée qu'on refuse (le serveur rejetterait le
    #   lien sans dire pourquoi). Seul un bloc link LU vaut.
    if [[ -z "$nom_defaut" && "${_ANOPE_SANS_CLAVIER:-0}" != "1" ]]; then
        nom_defaut="services.${reseau,,}"
    fi

    nom_serv=$(_anope_valeur "Nom du serveur de services" "$nom_defaut") || return 1

    # ⚠ Le contrôle vaut pour TOUS les chemins, y compris un nom saisi à la
    # main : c'est lui qui attrape la divergence, pas le défaut proposé.
    if [[ -n "$(_anope_srv_conf)" ]] && ! _anope_link_noms | grep -qxF "$nom_serv"; then
        _warn_f "Aucun bloc « link %s » dans la configuration du serveur IRC." "${nom_serv}"
        _info "Blocs présents : $(_anope_link_noms | tr '\n' ' ')"
        _info "Sans bloc à ce nom, le serveur refusera le lien sans dire pourquoi."
    fi
    local up_host up_port up_pass
    # 17/10/2026 : SANS CLAVIER, l'hôte est LU sur le listener « serversonly »
    # (`_anope_srv_hote`) ; à l'invite, le défaut reste 127.0.0.1.
    local hote_defaut="127.0.0.1"
    [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]] && hote_defaut=$(_anope_srv_hote)
    up_host=$(_anope_valeur "Serveur IRC à rejoindre (hôte)" "$hote_defaut") || return 1

    # ⚠ Le port du listener « serversonly » du serveur, et non 7000 en dur :
    # voir `_anope_srv_port`. Sur cette installation, le serveur écoute 6900 et
    # le défaut fabriqué aurait envoyé Anope sur un port où rien n'écoute.
    local port_defaut; port_defaut=$(_anope_srv_port)
    if [[ -n "$port_defaut" ]]; then
        _info_f "Le serveur IRC attend ses serveurs sur le port %s." "${port_defaut}"
    elif [[ "${_ANOPE_SANS_CLAVIER:-0}" != "1" ]]; then
        # ⚠ Sans clavier, pas de 7000 fabriqué : `_anope_valeur` refusera le vide.
        port_defaut="7000"
        _warn "Port du lien introuvable dans la configuration du serveur — 7000 proposé."
    fi
    up_port=$(_anope_valeur "Port du lien" "$port_defaut") || return 1
    # Le mot de passe du bloc « link » du serveur, s'il est déjà posé. Proposé
    # par défaut, jamais affiché : l'invite ne nomme que le fichier.
    local pass_defaut
    pass_defaut=$(_anope_link_pass "$nom_serv")

    if [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]]; then
        # 🔴 17/10/2026 : SANS CLAVIER, aucun `read` (rien ne répondrait) et le
        #   mot de passe n'est jamais affiché ni journalisé : seul le FICHIER où
        #   il a été lu est nommé. Absent, c'est un refus.
        if [[ -z "$pass_defaut" ]]; then
            _err_f "Aucun mot de passe dans un bloc « link %s » du serveur IRC — REFUS, aucune valeur inventée." "${nom_serv}"
            return 1
        fi
        # ⚠ `_ANOPE_LINK_SRC` est posé dans le `$( )` de `_anope_link_pass`, donc
        #   PERDU ici (sous-shell) : le fichier se relit par `_anope_srv_conf`.
        _info_f "Mot de passe du lien : celui du bloc « link %s » de %s (non affiché)." "${nom_serv}" "$(_anope_srv_conf)"
        up_pass="$pass_defaut"
    else
        if [[ -n "$pass_defaut" ]]; then
            _info_f "Bloc « link %s » trouvé dans %s." "${nom_serv}" "${_ANOPE_LINK_SRC}"
            printf "  Mot de passe du lien ${DIM}[Entrée = celui de ce bloc]${RESET} : "
        else
            _warn_f "Aucun bloc « link %s » trouvé : le serveur IRC en voudra un." "${nom_serv}"
            printf "  Mot de passe du lien (identique au bloc link du serveur) : "
        fi
        read -rs up_pass || true; echo ""
        [[ -z "$up_pass" ]] && up_pass="$pass_defaut"
    fi

    if [[ -z "$up_pass" ]]; then
        _err "Mot de passe du lien vide — Anope ne pourrait pas se connecter."
        return 1
    fi
    if [[ ! "$up_port" =~ ^[0-9]+$ ]]; then
        _err_f "Port « %s » : un nombre est attendu." "${up_port}"
        return 1
    fi

    local tmp; tmp="$(mktemp)" || return 1
    cp "$modele" "$tmp" || { rm -f "$tmp"; return 1; }

    local e_host e_pass e_nom e_desc
    e_host=$(_sed_escape "$up_host"); e_pass=$(_sed_escape "$up_pass")
    e_nom=$(_sed_escape "$nom_serv"); e_desc=$(_sed_escape "$desc")

    # Substitutions ANCRÉES SUR LEUR BLOC. « host », « port » et « password »
    # n'apparaissent qu'une fois chacun dans le modèle d'aujourd'hui, mais rien
    # ne le garantit demain : une plage /uplink/,/}/ coûte le même sed et ne
    # peut pas aller frapper un bloc « mail » ou « db_sql » ajouté plus tard.
    # Séparateur « | » : les valeurs portent des points et des barres obliques.
    #
    # 🔴 LE SCRIPT sed PASSE PAR UN FICHIER, PLUS PAR SES ARGUMENTS — 29/09/2026
    #   (audit, #18). L'expression porte le mot de passe du lien : en `-e`, il
    #   était lisible par tout compte de la machine dans `ps`. `printf` est
    #   interne à bash ; `mktemp` crée en 600.
    local sedf; sedf="$(mktemp)" || { rm -f "$tmp"; return 1; }
    printf '%s\n' \
        "/^[[:space:]]*uplink[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
              s|^([[:space:]]*host[[:space:]]*=).*|\1 \"${e_host}\"|
              s|^([[:space:]]*port[[:space:]]*=).*|\1 ${up_port}|
              s|^([[:space:]]*password[[:space:]]*=).*|\1 \"${e_pass}\"|
            }" \
        "/^[[:space:]]*serverinfo[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
              s|^([[:space:]]*description[[:space:]]*=).*|\1 \"${e_desc}\"|
            }" > "$sedf"
    if ! sed -Ei -f "$sedf" "$tmp"; then
        rm -f "$tmp" "$sedf"; _err "Substitution impossible."; return 1
    fi
    rm -f "$sedf"

    # Le nom du serveur de services : dans la 2.1 il vient d'un define dont
    # serverinfo et les hôtes des pseudo-clients tirent leur valeur. On pose
    # donc sa « value », et on ne retombe sur serverinfo que si ce define
    # n'existe pas — c'est le cas de la 2.0.
    if grep -qE '^[[:space:]]*name[[:space:]]*=[[:space:]]*"services\.host"' "$tmp"; then
        sed -Ei "/^[[:space:]]*name[[:space:]]*=[[:space:]]*\"services\.host\"/,/^[[:space:]]*\}/ {
                   s|^([[:space:]]*value[[:space:]]*=).*|\1 \"${e_nom}\"|
                 }" "$tmp"
    else
        sed -Ei "/^[[:space:]]*serverinfo[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
                   s|^([[:space:]]*name[[:space:]]*=).*|\1 \"${e_nom}\"|
                 }" "$tmp"
    fi

    # ── Le SID, quatrième valeur du bloc « link » ───────────────
    #
    # ⚠ LE MODÈLE LE LIVRE COMMENTÉ, avec une valeur d'exemple :
    # « #id = "00A" ». Laissé tel quel, Anope se présente sans SID et le
    # serveur répond « SID does not match the configured one », puis ferme le
    # lien. Constaté le 11/08/2026, après avoir réglé le port et le mot de
    # passe : chaque valeur fabriquée donnait un refus qui ne disait pas
    # pourquoi, et il a fallu les trouver une par une.
    #
    # ⚠ Le motif accepte le « # » de tête — c'est tout l'objet : il faut
    # DÉCOMMENTER la ligne, pas seulement changer sa valeur. Un `s|^id|` aurait
    # laissé le modèle intact sans rien signaler.
    #
    # ⚠ Ancré sur `serverinfo` : « id » est un mot trop court pour être
    # cherché dans un fichier de mille lignes.
    #
    # Un bloc « link » sans `sid` ne fait pas échouer la configuration : tous
    # les serveurs n'en exigent pas. On le dit, et on laisse la ligne comme le
    # modèle l'a écrite.
    local sid_lien
    sid_lien=$(_anope_link_sid "$nom_serv")
    if [[ -n "$sid_lien" ]]; then
        if [[ ! "$sid_lien" =~ ^[0-9][0-9A-Z]{2}$ ]]; then
            rm -f "$tmp"
            _err_f "SID « %s » illisible dans le bloc link — un chiffre puis deux caractères sont attendus." "${sid_lien}"
            return 1
        fi
        sed -Ei "/^[[:space:]]*serverinfo[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
                   s|^([[:space:]]*)#?[[:space:]]*id[[:space:]]*=.*|\1id = \"${sid_lien}\"|
                 }" "$tmp"
        _info_f "SID %s, repris du bloc « link %s »." "${sid_lien}" "${nom_serv}"
    elif [[ "${_ANOPE_SANS_CLAVIER:-0}" == "1" ]]; then
        # 17/10/2026 : sans clavier, le SID d'exemple du modèle serait une
        # valeur inventée — refus, comme pour les trois autres.
        rm -f "$tmp"
        _err_f "Aucun SID dans le bloc « link %s » — REFUS, aucune valeur inventée." "${nom_serv}"
        return 1
    else
        _warn_f "Aucun SID dans le bloc « link %s » — la ligne « id » du modèle est laissée telle quelle." "${nom_serv}"
        _info "Si le serveur en exige un, il refusera le lien sur « SID does not match »."
    fi

    # ⚠ Séparateur « ~ » et non « | » : ce motif porte une ALTERNANCE, et sed
    # termine la commande au premier séparateur qu'il rencontre. Écrite avec
    # « | », la substitution était refusée (« option inconnue pour s ») — mais
    # un sed en échec ne fait pas échouer la fonction, et le repli ci-dessous
    # ajoutait alors une SECONDE déclaration de module : Anope chargeait les
    # deux, rejetait la nôtre (« Service SASL::ProtocolInterface already
    # exists ») et gardait celle du modèle. Constaté le 31/07/2026 en faisant
    # lire la configuration produite par le binaire, pas en la relisant.
    sed -Ei "s~^([[:space:]]*module[[:space:]]*\{[[:space:]]*name[[:space:]]*=[[:space:]]*)\"${_ANOPE_PROTOS}\"~\1\"${proto}\"~" "$tmp"

    local n_total n_proto
    n_total=$(grep -cE "^[[:space:]]*module[[:space:]]*\{[[:space:]]*name[[:space:]]*=[[:space:]]*\"${_ANOPE_PROTOS}\"" "$tmp" || true)
    n_proto=$(grep -cE "^[[:space:]]*module[[:space:]]*\{[[:space:]]*name[[:space:]]*=[[:space:]]*\"${proto}\"" "$tmp" || true)

    # Le modèle de la 2.0 laisse cette ligne commentée : la substitution ne
    # trouve alors rien, et l'absence de module de protocole ne se voit qu'au
    # démarrage. On l'ajoute plutôt que de laisser le trou.
    if ((n_total == 0)); then
        printf '\n/* Module de protocole posé par l'\''installateur Overtchat. */\nmodule { name = "%s" }\n' "$proto" >> "$tmp"
        n_total=1; n_proto=1
    fi

    # ── Relire ce qu'on vient d'écrire ──
    # Un sed qui ne trouve pas son motif ne dit rien et rend 0. Sans cette
    # relecture, une directive renommée par une version future donnerait une
    # configuration à moitié substituée, annoncée comme écrite.
    local manque=()
    grep -qF "\"${up_host}\""  "$tmp" || manque+=("hôte du lien")
    grep -qE "^[[:space:]]*port[[:space:]]*=[[:space:]]*${up_port}([[:space:]]|$)" "$tmp" \
                               || manque+=("port du lien")
    # ⚠ Le motif arrive par un descripteur (`printf` interne), pas en argument :
    #   c'est le mot de passe du lien (audit #18, 29/09/2026).
    grep -qF -f <(printf '"%s"\n' "$up_pass") "$tmp" || manque+=("mot de passe du lien")
    grep -qF "\"${nom_serv}\"" "$tmp" || manque+=("nom du serveur")
    # ⚠ Relu DÉCOMMENTÉ : un « #id = … » substitué mais laissé commenté est
    # exactement le cas qu'on corrige ici, et un `grep` sur la seule valeur
    # l'aurait trouvé sans rien prouver.
    if [[ -n "$sid_lien" ]]; then
        grep -qE "^[[:space:]]*id[[:space:]]*=[[:space:]]*\"${sid_lien}\"" "$tmp" \
            || manque+=("SID du serveur de services")
    fi
    # Un seul module de protocole, et ce doit être le nôtre : compter les deux
    # séparément est ce qui manquait quand la substitution laissait « inspircd »
    # en place et qu'on se contentait de constater la présence du bon.
    ((n_proto == 1 && n_total == 1)) || \
        manque+=("module de protocole (${n_proto} fois ${proto}, ${n_total} au total)")
    if ((${#manque[@]})); then
        rm -f "$tmp"
        _err "Configuration NON écrite — introuvable dans le fichier produit : ${manque[*]}."
        _info "Le modèle ${modele##*/} a probablement renommé ces directives."
        return 1
    fi
    grep -qF "\"${desc}\"" "$tmp" || \
        _warn "Description du serveur laissée telle quelle dans le modèle."

    # 17/10/2026 : la configuration en place, gardée AVANT d'être remplacée —
    # en 600 comme elle (elle porte le mot de passe du lien). Une copie ratée
    # arrête tout : on ne remplace pas ce qu'on n'a pas su garder.
    if [[ "$garder_avant" == "1" && -f "$conf" ]]; then
        local avant; avant="${conf}.avant-$(date +%Y%m%d-%H%M%S)"
        if ! cp -p "$conf" "$avant"; then
            rm -f "$tmp"
            _err_f "Impossible de garder %s — configuration NON remplacée." "${conf}"
            return 1
        fi
        chmod 600 "$avant"
        _ok_f "Ancienne configuration gardée : %s" "${avant}"
    fi

    mv "$tmp" "$conf" || { rm -f "$tmp"; return 1; }
    chmod 600 "$conf"
    _ok_f "Configuration écrite : %s %s(droits 600)%s" "${conf}" "${DIM}" "${RESET}"
    _ok_f "Protocole : %s — lien vers %s:%s, serveur %s." "${proto}" "${up_host}" "${up_port}" "${nom_serv}"

    _svc_set "anope" '.services[$s].config_file = $c | .services[$s].configured = true' \
        --arg c "$conf" 2>/dev/null || true

    # Le modèle d'Anope est long et porte bien d'autres réglages. On le dit
    # plutôt que de laisser croire que tout est réglé.
    _warn_f "Le modèle a été repris tel quel pour le reste : relis %s." "${conf}"
    _info "À vérifier en priorité : le bloc oper, et — côté SERVEUR IRC — un bloc"
    _info_f "link pour %s et son inscription dans les ulines." "${nom_serv}"
    _log_entry "anope" "configure" "${cible} écrit pour ${nom_serv} (protocole ${proto})"
}

_install_lea() {
    local svc="lea" dir repo_dir lea_src bin
    dir=$(_svc_get "$svc" "install_dir")
    _dir_exploitable "Lea" "$dir" || { _service_install_fail "$svc" deps; return 1; }

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$svc") || { _service_install_fail "$svc" deps; return 1; }

    mkdir -p "$dir"
    repo_dir="${dir}/src"
    _info "Clonage du dépôt Service-Overtchat (Lea Service)…"
    git clone --depth=1 "$(_svc_get "$svc" "source_url")" "$repo_dir" || { _service_install_fail "$svc"; return 1; }

    lea_src=$(find "$repo_dir" -maxdepth 2 -type d -iname "lea*" 2>/dev/null | head -1)
    if [[ -z "$lea_src" || ! -f "${lea_src}/configure" ]]; then
        _warn "Sources Lea introuvables ou script 'configure' absent dans le dépôt cloné."
        _warn "Structure minimale créée — installation manuelle requise (comme ircu)."
        mkdir -p "${dir}/run"; touch "${dir}/.installed"
        _svc_set "$svc" \
            '.services[$s].installed = true | .services[$s].binary = null | .services[$s].last_install = $ts' \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        return 0
    fi

    (
        cd "$lea_src" || exit 1
        ./configure --prefix="$dir" && make -j"$(_make_jobs)" && make install
    ) || { _service_install_fail "$svc"; return 1; }

    mkdir -p "${dir}/run"; touch "${dir}/.installed"
    bin=$(find "$dir" -maxdepth 2 -type f -iname "lea*" -executable 2>/dev/null | head -1)
    _svc_set "$svc" \
        '.services[$s].binary = $b | .services[$s].installed = true | .services[$s].last_install = $ts' \
        --arg b "${bin#"$dir"/}" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _ok "Lea Service compilé (configure/make) et installé."
}

_service_verify() {
    local svc="$1" install_dir bin
    install_dir=$(_svc_get "$svc" "install_dir")
    bin=$(_svc_get "$svc" "binary")
    [[ -z "$bin" || "$bin" == "null" ]] && return 1
    [[ -x "${install_dir}/${bin}" ]] || return 1
    return 0
}

# -----------------------------------------------------------------------------
# _service_configure_si_besoin <service>
#
# Rattrape le service installé mais jamais configuré. Ne fait RIEN si sa
# configuration est là : on ne repasse pas sur un fichier que l'exploitant a pu
# retoucher, et `_anope_configure` refuse elle-même d'écraser.
#
# ⚠ Anope seul pour l'instant, et par constat plutôt que par principe : c'est
# le seul des trois services dont on ait vérifié qu'il refuse de démarrer sans
# configuration. Eggdrop en écrit une à l'installation, Lea n'a pas été menée
# jusque-là. Étendre la liste demande de le mesurer d'abord, pas de le
# supposer — un service rattrapé à tort poserait des questions à l'exploitant
# pour un fichier dont il n'a pas besoin.
# -----------------------------------------------------------------------------
_service_configure_si_besoin() {
    local svc="$1" dir cible

    [[ "$svc" == "anope" ]] || return 0

    dir=$(_svc_get "$svc" "install_dir")
    # Même déduction que dans `_anope_configure` : le nom du fichier dépend de
    # la branche. On ne le devine pas ici, on le lui laisse dire.
    for cible in anope.conf services.conf; do
        [[ -f "${dir}/conf/${cible}" ]] && return 0
    done

    _warn "Anope est installé mais n'a pas de configuration — il ne démarrerait pas."
    _anope_configure "$dir" || {
        _warn "Configuration non écrite : Anope reste inutilisable."
        return 1
    }
}

# -----------------------------------------------------------------------------
# _anope_tourne <répertoire d'installation>
#
# Anope tourne-t-il ? Par son unité, puis par un processus dont l'exécutable est
# CE binaire (le chemin complet de /proc/<pid>/exe, jamais un motif de nom).
# -----------------------------------------------------------------------------
_anope_tourne() {
    local dir="$1" p e
    if command -v systemctl >/dev/null 2>&1 \
       && [[ "$(systemctl is-active anope.service 2>/dev/null)" =~ ^(active|activating|reloading)$ ]]; then
        return 0
    fi
    for p in /proc/[0-9]*; do
        e="$(readlink "$p/exe" 2>/dev/null)" || continue
        [[ "$e" == "${dir}/bin/anope" || "$e" == "${dir}/bin/services" ]] && return 0
    done
    return 1
}

# -----------------------------------------------------------------------------
# _anope_construire_cli [archive]
#
# « anope-construire » : récupère, compile et configure Anope SANS CLAVIER, et
# NE LE DÉMARRE PAS. C'est la commande du bouton « Installer / reconstruire
# Anope » du panel, lancée par l'unité oneshot `overtchat-anope-construire.service`
# (Config/systemd/, User=servus) au travers de `Tools/anope-construire.sh`.
#
# Décision de SerVuS, 17/10/2026 — en deux temps : CONSTRUIRE (ici, rien ne
# démarre) puis, à part et sous conditions, BASCULER (le panel). Ce qu'elle fait :
#   1. refuse root (on compile en compte ordinaire) et refuse si Anope TOURNE —
#      `make install` réécrirait sous lui son binaire et ses modules chargés ;
#   2. l'archive : celle donnée (`--archive=CHEMIN`), ou une archive locale
#      d'Anope, CONTRÔLÉES À L'EMPREINTE `SHA256_ANOPE` avant usage (refus sinon) ;
#      à défaut, le téléchargement de `_fetch_sources`, qui la contrôle aussi ;
#   3. compile par `_install_anope` — sans élévation (les paquets manquants sont
#      NOMMÉS, pas installés) et SANS ROLLBACK destructif (`_SANS_ROLLBACK`) ;
#   4. configure par `_anope_configure` sans clavier : valeurs LUES (bloc link,
#      listener serversonly, SID, protocole compilé), refus si l'une manque, mot
#      de passe jamais affiché ; l'ancienne configuration gardée en `.avant-<date>` ;
#   5. services.json : installed/configured posés, `enabled` INCHANGÉ.
# Elle n'appelle JAMAIS `systemctl start` (Bootstrap/tests/test-anope-construire.sh).
# -----------------------------------------------------------------------------
_anope_construire_cli() {
    local archive="${1:-}" dir obtenu enabled_avant

    if [[ "$(id -u)" == "0" ]]; then
        _err "anope-construire se lance en compte ORDINAIRE (celui de l'installation), jamais en root."
        return 1
    fi
    # Pas d'élévation pendant la construction : ni apt, ni sudo.
    PRIV_MODE="user"

    dir=$(_svc_get "anope" "install_dir")
    _dir_exploitable "Anope" "$dir" || return 1

    if _anope_tourne "$dir"; then
        _err "Anope TOURNE : la construction réécrirait son binaire et ses modules sous lui — REFUS."
        _info "Arrêtez-le d'abord (panel, « Arrêter »), puis relancez la construction."
        return 1
    fi

    # ── L'archive, contrôlée à l'empreinte AVANT tout usage ──
    if [[ -z "$archive" ]]; then
        archive="$(_find_local_archive "anope*.tar.gz" 2>/dev/null || true)"
    fi
    if [[ -n "$archive" ]]; then
        [[ -f "$archive" ]] || { _err_f "Archive introuvable : %s" "$archive"; return 1; }
        obtenu="$(_sha256 "$archive")"
        if [[ "$obtenu" != "$SHA256_ANOPE" ]]; then
            _err_f "Archive %s : EMPREINTE DIFFÉRENTE de celle d'Anope %s — REFUSÉE." "$archive" "$VER_ANOPE"
            _info_f "  attendue : %s" "$SHA256_ANOPE"
            _info_f "  obtenue  : %s" "$obtenu"
            return 1
        fi
        _ok_f "Archive %s : empreinte SHA-256 d'Anope %s vérifiée." "$archive" "$VER_ANOPE"
    fi

    enabled_avant="$(_svc_get "anope" "enabled")"

    local rc=0
    _ARCHIVE_IMPOSEE="$archive" _SANS_ROLLBACK=1 _ANOPE_SANS_CLAVIER=1 _install_anope || rc=$?

    # `enabled` est le choix de l'exploitant : une construction n'y touche pas.
    # On le RELIT pour le prouver, et on le remet s'il a bougé.
    if [[ "$(_svc_get "anope" "enabled")" != "$enabled_avant" ]]; then
        _svc_set "anope" '.services[$s].enabled = ($e == "true")' --arg e "$enabled_avant" 2>/dev/null || true
    fi

    if (( rc != 0 )); then
        _err "Construction d'Anope ÉCHOUÉE — Anope n'a pas été démarré."
        return 1
    fi
    _ok "Construction d'Anope terminée. Anope n'a PAS été démarré : la bascule se fait au panel, à part."
    return 0
}

_service_install() {
    local svc="$1" dir
    dir=$(_svc_get "$svc" "install_dir")
    _section "Installation : $(_svc_get "$svc" "name")"

    if [[ -f "${dir}/.installed" ]]; then
        _info "Déjà installé → vérification du binaire…"
        if _service_verify "$svc"; then
            _ok "Installation existante valide."
            # ⚠ VALIDE NE VEUT PAS DIRE CONFIGURÉ, et ce chemin-ci rendait 0
            # sans jamais atteindre la configuration. Anope était compilé
            # depuis le 30/07/2026, son conf/ ne portait que les modèles, et
            # relancer l'installation ne changeait rien : le service refusait
            # de démarrer, et rien ne disait pourquoi.
            #
            # C'est le défaut que le commentaire d'`_install_anope` dit vouloir
            # éviter — « l'installation réussie qui ne démarre jamais » —,
            # revenu par l'autre porte. `_service_verify` ne regarde que le
            # binaire, et c'est très bien : un binaire absent et une
            # configuration absente ne se réparent pas de la même façon.
            # Constaté le 11/08/2026.
            _service_configure_si_besoin "$svc"
            return 0
        fi
        _warn "Installation corrompue → réinstallation."
        _safe_rmrf "$dir"
    fi

    case "$svc" in
        eggdrop) _install_eggdrop ;;
        anope)   _install_anope   ;;
        lea)     _install_lea     ;;
        *) _err "Service inconnu : $svc"; return 1 ;;
    esac
}

_service_uninstall() {
    local svc="$1" dir name rep
    name=$(_svc_get "$svc" "name")
    dir=$(_svc_get "$svc" "install_dir")

    [[ -d "$dir" ]] || { _warn "$name n'est pas installé."; return 0; }

    read -rp "  Désinstaller ${name} (${dir}) ? [y/N] : " rep || true
    [[ "$rep" =~ ^[YyOo]$ ]] || { _info "Annulé."; return 0; }

    [[ "$(_service_status "$svc")" == "running" ]] && _service_stop "$svc"
    _safe_rmrf "$dir"
    _svc_set "$svc" \
        '.services[$s].installed=false | .services[$s].enabled=false | .services[$s].running=false |
         .services[$s].binary=null |
         .services[$s].sha256={verified:false,expected_hash:null,last_checked:null}'
    _log_entry "$svc" "uninstall" "$name désinstallé"
    _ok_f "%s désinstallé." "$name"
}

_service_verify_sha256() {
    local svc="$1" install_dir bin path stored current ts
    install_dir=$(_svc_get "$svc" "install_dir")
    bin=$(_svc_get "$svc" "binary")
    [[ -z "$bin" || "$bin" == "null" ]] && { _warn "Aucun binaire défini pour $svc."; return 1; }
    path="${install_dir}/${bin}"
    [[ -f "$path" ]] || { _warn "Binaire introuvable : $path"; return 1; }

    stored=$(_svc_get "$svc" "sha256.expected_hash")
    current=$(_sha256 "$path")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    if [[ -z "$stored" || "$stored" == "null" ]]; then
        _svc_set "$svc" \
            '.services[$s].sha256.expected_hash = $h | .services[$s].sha256.verified = true | .services[$s].sha256.last_checked = $ts' \
            --arg h "$current" --arg ts "$ts"
        _ok "SHA-256 enregistré comme référence (première vérification)."
        return 0
    fi

    if [[ "$stored" == "$current" ]]; then
        _svc_set "$svc" '.services[$s].sha256.verified = true | .services[$s].sha256.last_checked = $ts' --arg ts "$ts"
        _ok "SHA-256 valide."
    else
        _svc_set "$svc" '.services[$s].sha256.verified = false | .services[$s].sha256.last_checked = $ts' --arg ts "$ts"
        _err "SHA-256 INVALIDE !"
        printf "  Attendu : ${GREEN}%s${RESET}\n" "$stored"
        printf "  Obtenu  : ${RED}%s${RESET}\n"   "$current"
        _service_report_error "$svc" "SHA-256 invalide"
        return 1
    fi
}

# =============================================================================
# NOTE — sémantique du bouton [2] Activer / Désactiver (_service_toggle) :
#   Même principe que pour IriX : "enabled" est l'intention. Activer installe
#   (si besoin) puis démarre le processus ; désactiver l'arrête. Les options
#   dédiées Démarrer/Arrêter/Redémarrer restent disponibles pour un contrôle
#   ponctuel sans changer cette intention.
# =============================================================================

_service_toggle() {
    local svc="$1" current new_state ts name
    name=$(_svc_get "$svc" "name")
    current=$(_svc_get "$svc" "enabled")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    if [[ "$current" == "true" ]]; then
        new_state=false
        [[ "$(_service_status "$svc")" == "running" ]] && _service_stop "$svc"
        _svc_set "$svc" '.services[$s].enabled = false | .services[$s].last_stop = $ts' --arg ts "$ts"
        _ok_f "%s désactivé (processus arrêté)." "$name"
    else
        new_state=true
        if [[ "$(_svc_get "$svc" "installed")" != "true" ]]; then
            _info "Non installé → installation automatique."
            _service_install "$svc" || { _err "Activation annulée : installation échouée."; return 1; }
        fi
        _svc_set "$svc" '.services[$s].enabled = true | .services[$s].last_start = $ts' --arg ts "$ts"
        _service_start "$svc"
        _ok_f "%s activé (processus démarré)." "$name"
    fi

    _log_entry "$svc" "toggle" "enabled=${new_state}"
}

_service_log_toggle() {
    local svc="$1" current new_state
    current=$(jq -r --arg s "$svc" '.services[$s].log.enabled' "$SERVICES_FILE")
    [[ "$current" == "true" ]] && new_state=false || new_state=true
    _jq_update "$SERVICES_FILE" --arg s "$svc" --argjson v "$new_state" '.services[$s].log.enabled = $v'
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
    _ok_f "%s logs → %s" "$svc" "$new_state"
}

_service_report_error() {
    local svc="$1" errmsg="$2" ts
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _jq_update "$SERVICES_FILE" \
        --arg s "$svc" --arg m "$errmsg" --arg ts "$ts" \
        '.services[$s].errors += [{"ts":$ts,"msg":$m}]'
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
    _log_entry "$svc" "error" "$errmsg"
}

_service_clear_errors() {
    local svc="$1"
    _jq_update "$SERVICES_FILE" --arg s "$svc" '.services[$s].errors = []'
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
    _ok_f "%s : erreurs effacées" "$svc"
}

_service_show_errors() {
    local svc="$1"
    _section "Erreurs — $svc"
    local count; count=$(jq -r --arg s "$svc" '.services[$s].errors | length' "$SERVICES_FILE")
    if [[ "$count" -eq 0 ]]; then
        _ok "Aucune erreur"
    else
        jq -r --arg s "$svc" '.services[$s].errors[] | "  [\(.ts)] \(.msg)"' "$SERVICES_FILE"
    fi
}

_service_set_binary() {
    local svc="$1" bin
    _info "Pointe manuellement vers un binaire déjà compilé/installé (n'exécute aucune compilation)."
    read -rp "  Chemin binaire $svc : " bin || true
    [[ -f "$bin" ]] || { _warn "Fichier absent : $bin"; return 1; }
    _jq_update "$SERVICES_FILE" \
        --arg s "$svc" --arg b "$bin" \
        '.services[$s].binary = $b | .services[$s].installed = true'
    _sha256_store "$SERVICES_FILE" "$INIT_BASE" "services"
    _ok_f "%s binary : %s" "$svc" "$bin"
}

# S'affiche DANS un cadre déjà ouvert par l'appelant.
_services_overview() {
    local svc name icon
    for svc in eggdrop anope lea; do
        name=$(_svc_get "$svc" "name")
        [[ -z "$name" || "$name" == "null" ]] && name="$svc"
        icon=$(_service_status_icon "$svc")
        _ui_row "  ${BOLD}${name}${RESET}$(_ui_pad "$name" 22)${icon}"
    done
    _irix_init
    _ui_row "  ${BOLD}IriX Services${RESET}$(_ui_pad 'IriX Services' 22)$(_irix_status_icon)"
    # Les robots de prêt tiennent sur une ligne : ils sont plusieurs, et un
    # aperçu qui en listerait trois pousserait Eggdrop et Anope hors du cadre.
    # Le détail est dans leur propre panel.
    _overop_init
    _ui_row "  ${BOLD}OverOp (prêt)${RESET}$(_ui_pad 'OverOp (prêt)' 22)$(_overop_apercu_court)"
}

_panel_service_detail() {
    local svc="$1" name choice
    while true; do
        name=$(_svc_get "$svc" "name")

        # ⚠⚠ CET ÉCRAN EMPILAIT TROIS DIALECTES, et c'est ce qui le rendait
        # illisible bien plus que la largeur : un bandeau fermé, puis les cinq
        # `_ui_row` de `_services_overview` — dont le contrat écrit exige un
        # cadre DÉJÀ OUVERT — qui sortaient donc en bordures « │ … │ » sans
        # toit ni plancher, puis des `_kv` hérités sans aucune bordure, puis
        # les entrées en texte brut. Repris le 30/08/2026.
        _ui_width
        _ui_title "Service : $name" "état, actions et journaux"
        _ui_top
        _services_overview
        _ui_mid
        _ui_kv "Installé"    "$(_svc_get "$svc" "installed")"
        _ui_kv "Activé"      "$(_svc_get "$svc" "enabled")"
        _ui_kv "En cours"    "$(_service_status "$svc")"
        _ui_kv "Compilation" "$(_svc_get "$svc" "build")"
        _ui_kv "SHA-256 OK"  "$(_svc_get "$svc" "sha256.verified")"
        _ui_kv "Erreurs"     "$(jq -r --arg s "$svc" '.services[$s].errors | length' "$SERVICES_FILE")"

        _ui_head "Actions"
        _ui_item 1  "Installer               ${DIM}(compile depuis les sources)${RESET}"
        _ui_item 2  "Activer / Désactiver    ${DIM}(installe+démarre / arrête)${RESET}"
        _ui_item 3  "Démarrer"
        _ui_item 4  "Arrêter"
        _ui_item 5  "Redémarrer"
        _ui_item 6  "Logs en direct"
        _ui_item 7  "Activer / Désactiver les logs"
        _ui_item 8  "Vérifier SHA-256 du binaire"
        _ui_item 9  "Voir les erreurs"
        _ui_item 10 "Effacer les erreurs"
        _ui_item 11 "Définir un binaire existant   ${DIM}(sans compiler)${RESET}"
        _ui_item 12 "Désinstaller"
        _ui_item 13 "Gérer Eggdrop           ${DIM}(grades/salons — Eggdrop seul)${RESET}"
        _ui_item 0  "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        case "$choice" in
            1) _service_install "$svc" || true ;;
            2) _service_toggle "$svc" || true ;;
            3) _service_start "$svc" || true ;;
            4) _service_stop "$svc" || true ;;
            5) _service_restart "$svc" || true ;;
            6) _service_logs "$svc" || true ;;
            7) _service_log_toggle "$svc" || true ;;
            8) _service_verify_sha256 "$svc" || true ;;
            9) _service_show_errors "$svc" || true ;;
            10) _service_clear_errors "$svc" || true ;;
            11) _service_set_binary "$svc" || true ;;
            12) _service_uninstall "$svc" || true ;;
            13) if [[ "$svc" == "eggdrop" ]]; then
                    _eggdrop_grades_menu
                else
                    _warn "Uniquement pour Eggdrop."
                fi ;;
            0)  return ;;
            *) _warn "Choix invalide." || true ;;
        esac
        _pause
    done
}

_panel_services() {
    _services_init
    _st_reconcile
    local c
    while true; do
        _ui_width
        _ui_title "Panel Services" "Eggdrop · IriX · Anope · Lea · OverOp"

        _ui_top
        _ui_row "${BOLD}État des services${RESET}"
        _ui_mid
        _services_overview

        _menu_reset
        _ui_head "Gérer"
        # Eggdrop est le socle : toujours accessible dès que ses paquets sont là.
        _menu_gate 1 "Eggdrop         ${DIM}(bot hôte)${RESET}"             "$(_menu_reason eggdrop)"
        _menu_gate 2 "IriX Services   ${DIM}(module Tcl d'Eggdrop)${RESET}" "$(_menu_reason irix)"
        _menu_gate 3 "Anope Services" "$(_menu_reason anope)"
        _menu_gate 4 "Lea Service"    "$(_menu_reason lea)"
        # Les robots de prêt : des Eggdrop de plus, pas des modules d'IriX.
        # Ils ont donc leur propre panel, et le même verrou qu'IriX — sans le
        # binaire Eggdrop, il n'y a rien à poser.
        _menu_gate 5 "OverOp           ${DIM}(robots de prêt — un par lot de salons)${RESET}" "$(_menu_reason overop)"
        _menu_add  0 "Retour"
        _ui_bot

        _ui_prompt
        read -r c || c=0
        _menu_has "$c" || { _warn "Choix indisponible ou verrouillé."; continue; }
        case "$c" in
            1) _panel_service_detail "eggdrop" || true ;;
            2) _panel_irix || true ;;
            3) _panel_service_detail "anope" || true ;;
            4) _panel_service_detail "lea" || true ;;
            5) _panel_overop || true ;;
            0) return ;;
        esac
    done
}

# =============================================================================
# ════════════════════════════════════════════════════
#  SERVERS — UnrealIRCd / Hybrid / ircu / Overtchat
# ════════════════════════════════════════════════════
# =============================================================================
# -----------------------------------------------------------------------------
# _srv_file
#   Retourne le chemin du fichier JSON des serveurs.
# -----------------------------------------------------------------------------
_srv_file() {
    echo "${SERVERS_FILE}"
}

# -----------------------------------------------------------------------------
# _srv_get <serveur> <champ>
#   Lecture d'un champ JSON pour un serveur donné.
# -----------------------------------------------------------------------------
_srv_get() {
    local srv="$1" field="$2"
    jq -r --arg s "$srv" ".servers[\$s].${field}" "$(_srv_file)"
}

# -----------------------------------------------------------------------------
# _srv_set <serveur> <filtre_jq> [args jq...]
#   Mise à jour atomique du fichier JSON via _jq_update.
# -----------------------------------------------------------------------------
_srv_set() {
    local srv="$1"; shift
    local filter="$1"; shift
    _jq_update "$(_srv_file)" --arg s "$srv" "$filter" "$@"
    _sha256_store "$(_srv_file)" "$INIT_BASE" "$SERVERS_KEY"
}

# -----------------------------------------------------------------------------
# _srv_paths <serveur>
#   Affiche sur stdout : <run_dir> <pid_file> <log_file>
# -----------------------------------------------------------------------------
_srv_paths() {
    local srv="$1"
    local base
    base=$(_srv_get "$srv" "install_dir")
    printf '%s/run %s/run/server.pid %s/run/server.log\n' "$base" "$base" "$base"
}

# =============================================================================
# Initialisation
# =============================================================================

# -----------------------------------------------------------------------------
# _sources_sync
#   Aligne les source_url et versions des JSON DÉJÀ ÉCRITS sur les constantes
#   épinglées en tête de ce script.
#
#   Sans ça, l'épinglage ne vaudrait que pour une installation neuve : les deux
#   _init ci-dessous ne réécrivent jamais un fichier existant, et ces fichiers
#   ne sont pas suivis par git. Une installation en service serait donc restée
#   sur ses anciennes URL — dont deux ne fonctionnaient plus le 30/07/2026
#   (une page HTML pour ircu, un compte GitHub abandonné pour Overtchat-Hybrid),
#   pendant que le dépôt affirmerait le contraire.
#
#   Ce que la fonction NE fait pas : toucher à l'état d'installation. Elle ne
#   change que la provenance des sources et le numéro annoncé. Un composant
#   installé le reste, avec son binaire — c'est à la réinstallation que la
#   nouvelle version sera prise.
# -----------------------------------------------------------------------------
_sources_sync() {
    local f cle url ver actuel n=0

    f="$(_srv_file)"
    if [[ -f "$f" ]]; then
        while read -r cle url ver; do
            actuel=$(jq -r --arg s "$cle" '.servers[$s].source_url // ""' "$f" 2>/dev/null)
            [[ "$actuel" == "$url" ]] && continue
            jq --arg s "$cle" --arg u "$url" --arg v "$ver" \
               '.servers[$s].source_url = $u | .servers[$s].version = $v' \
               "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" || { rm -f "${f}.tmp"; continue; }
            _info_f "Source mise à jour : %s → %s" "${cle}" "${ver}"
            (( n++ ))
        done <<-LISTE
		unrealircd $URL_UNREALIRCD $VER_UNREALIRCD
		hybrid $URL_HYBRID $VER_HYBRID
		ircu $URL_IRCU $VER_IRCU
		inspircd $URL_INSPIRCD $VER_INSPIRCD
		bahamut $URL_BAHAMUT $VER_BAHAMUT
		LISTE

        # Overtchat-Hybrid n'a pas de `source_url` : ses sources sont dans le
        # projet, et, depuis le 28/09/2026, le SITE d'où vient l'installateur
        # sert son archive — adresse DÉRIVÉE à l'exécution (`_brutegit`), avec
        # l'empreinte du manifeste (voir `_install_overtchat`). Le source_url
        # qui traînait pointait vers un compte GitHub abandonné : un champ faux
        # reste un champ qu'on finira par croire.
        # Le test porte sur le TYPE, pas sur le texte : `jq -r … // ""` rend la
        # chaîne vide pour un null JSON, ce qui faisait réécrire le fichier à
        # chaque passage — une non-idempotence invisible, attrapée au banc.
        if ! jq -e '.servers.overtchat_ircd.source_url == null
                    and .servers.overtchat_ircd.sources_locales == "Servers/overtchat-hybrid"' \
                 "$f" >/dev/null 2>&1; then
            jq '.servers.overtchat_ircd.source_url = null
              | .servers.overtchat_ircd.sources_locales = "Servers/overtchat-hybrid"
              | .servers.overtchat_ircd.name = "Overtchat-Hybrid"' \
               "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" || rm -f "${f}.tmp"
            _info "Source mise à jour : overtchat_ircd → sources du projet"
            (( n++ ))
        fi
    fi

    f="$SERVICES_FILE"
    if [[ -f "$f" ]]; then
        while read -r cle url ver; do
            actuel=$(jq -r --arg s "$cle" '.services[$s].source_url // ""' "$f" 2>/dev/null)
            [[ "$actuel" == "$url" ]] && continue
            jq --arg s "$cle" --arg u "$url" --arg v "$ver" \
               '.services[$s].source_url = $u | .services[$s].version = $v' \
               "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" || { rm -f "${f}.tmp"; continue; }
            _info_f "Source mise à jour : %s → %s" "${cle}" "${ver}"
            (( n++ ))
        done <<-LISTE
		eggdrop $URL_EGGDROP $VER_EGGDROP
		anope $URL_ANOPE $VER_ANOPE
		LISTE
    fi

    (( n > 0 )) && _ok "${n} source(s) alignée(s) sur les versions épinglées."
    return 0
}

# -----------------------------------------------------------------------------
# _servers_migrate
#
# Ajoute au servers.json EXISTANT les serveurs qu'il ne connaît pas encore.
#
# _servers_init n'écrit qu'un fichier NEUF : une installation en service ne
# verrait jamais un serveur ajouté après elle — celui de production, au
# 31/07/2026, n'en déclare que quatre. _sources_sync, lui, aurait bien créé
# l'entrée, mais réduite à `source_url` et `version` : jq crée le chemin qu'on
# lui donne. Or une entrée sans `install_dir` est pire que pas d'entrée du
# tout — un install_dir nul fait compiler dans le répertoire courant, et c'est
# ce défaut qui a déposé 876 Mo dans un dossier littéralement nommé « null »
# au sein du dépôt.
#
# Ce qui existe l'emporte sur les valeurs par défaut, sauf les champs nuls :
# une entrée à moitié écrite par _sources_sync se complète sans perdre ce
# qu'elle portait, et un serveur déjà installé n'est jamais rétrogradé en
# « installed: false ».
# -----------------------------------------------------------------------------
_servers_migrate() {
    local f srv nom dossier ver url ssl n=0
    f="$(_srv_file)"
    [[ -f "$f" ]] || return 0

    while read -r srv nom dossier ver url ssl; do
        [[ -z "$srv" ]] && continue
        jq -e --arg s "$srv" '(.servers[$s].install_dir // "") != ""' "$f" >/dev/null 2>&1 && continue

        jq --arg s "$srv" --arg n "$nom" --arg d "${SERVERS_DIR}/${dossier}" \
           --arg v "$ver" --arg u "$url" --argjson ssl "$ssl" \
           '.servers[$s] = ({
                name:        $n,
                version:     $v,
                installed:   false,
                enabled:     false,
                running:     false,
                install_dir: $d,
                binary:      null,
                config:      null,
                port:        6667,
                port_ssl:    $ssl,
                log:         { enabled: true, path: null, level: "info" },
                sha256:      { verified: false, expected_hash: null, last_checked: null },
                errors:      [],
                last_start:  null,
                last_stop:   null,
                source_url:  $u
            } + ((.servers[$s] // {}) | with_entries(select(.value != null))))' \
           "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" || { rm -f "${f}.tmp"; continue; }

        _info_f "Serveur ajouté à la configuration : %s" "${nom}"
        (( n++ ))
    done <<LISTE
inspircd InspIRCd inspircd $VER_INSPIRCD $URL_INSPIRCD 6697
bahamut Bahamut bahamut $VER_BAHAMUT $URL_BAHAMUT 6697
LISTE

    (( n > 0 )) && _ok "${n} serveur(s) ajouté(s) à servers.json."

    # La version du serveur maison, rattrapée à chaque passage.
    #
    # Elle était écrite « 1.0.0 » en dur à la création du fichier ; le serveur
    # est passé en 2.0.0 le 31/07/2026 et aucune installation ne l'a su. Ici,
    # contrairement aux entrées ci-dessus, on ÉCRASE ce qui existe : cette
    # version n'est pas une préférence d'exploitant, c'est un fait lu dans les
    # sources livrées. Les autres serveurs gardent la leur — la nôtre est la
    # seule dont le projet porte le code.
    local vo ancienne
    vo="$(_overtchat_version)"
    if [[ -n "$vo" ]]; then
        ancienne="$(jq -r '.servers.overtchat_ircd.version // ""' "$f" 2>/dev/null)"
        if [[ "$ancienne" != "$vo" ]]; then
            if jq --arg v "$vo" '.servers.overtchat_ircd.version = $v' \
                  "$f" > "${f}.tmp" 2>/dev/null && mv "${f}.tmp" "$f"; then
                _info "Overtchat-Hybrid : version corrigée (${ancienne:-vide} → ${vo})."
            else
                rm -f "${f}.tmp"
            fi
        fi
    fi

    return 0
}

# -----------------------------------------------------------------------------
# _servers_init
#   Crée le fichier JSON de configuration si absent.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# _overtchat_version — la version du serveur maison, LUE dans ses sources.
#
# Elle était écrite « 1.0.0 » en dur dans le fichier neuf ci-dessous, et y est
# restée quand le serveur est passé en 2.0.0 le 31/07/2026 : toute installation
# faite depuis déclare une version que le binaire dément. Les autres serveurs
# ont leur `VER_*` épinglé — celui-ci n'en a pas besoin, ses sources sont
# livrées avec le projet et `ircd_defs.h` fait autorité (ARCHITECTURE.md le dit
# déjà pour le numéro que les clients IRC reçoivent en 351).
#
# Repli sur une chaîne vide plutôt que sur un numéro inventé : une version
# absente se voit, une version fausse se croit.
#
# ⚠ (29/09/2026) Le serveur COMPILÉ que sert le site n'a PAS d'`ircd_defs.h` :
#   son archive porte un fichier `VERSION` d'une ligne (Tools/faire-binaire-
#   hybrid.sh), déballé à la racine du dossier installé. Sans ce repli, une
#   installation par le binaire déclarait une version VIDE.
# -----------------------------------------------------------------------------
_overtchat_version() {
    local f v
    for f in "$BASE_DIR/Servers/overtchat-hybrid/include/ircd_defs.h" \
             "$SERVERS_DIR/overtchat-ircd/include/ircd_defs.h"; do
        [[ -r "$f" ]] || continue
        v="$(sed -n 's/^#define[[:space:]]*OVERTCHAT_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | head -1)"
        [[ -n "$v" ]] && { printf '%s' "$v"; return 0; }
    done
    # ⚠ Le motif borne ce qu'on accepte : un fichier corrompu ou une page
    #   d'erreur ne doit pas devenir une « version » dans servers.json.
    f="$SERVERS_DIR/overtchat-ircd/VERSION"
    if [[ -r "$f" ]]; then
        v="$(head -1 "$f" 2>/dev/null | tr -d '[:space:]')"
        [[ "$v" =~ ^[0-9][0-9A-Za-z.+-]{0,31}$ ]] && { printf '%s' "$v"; return 0; }
    fi
    return 0
}

_servers_init() {
    if [[ -f "$(_srv_file)" ]]; then
        # La migration d'abord : _sources_sync poserait sinon une version et une
        # URL sur une entrée sans install_dir, et la croirait complète.
        _servers_migrate
        _sources_sync
        return 0
    fi

    mkdir -p "$(dirname "$(_srv_file)")"

    jq -n \
        --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --arg d  "$SERVERS_DIR" \
        --arg vu "$VER_UNREALIRCD" --arg uu "$URL_UNREALIRCD" \
        --arg vh "$VER_HYBRID"     --arg uh "$URL_HYBRID" \
        --arg vi "$VER_IRCU"       --arg ui "$URL_IRCU" \
        --arg vp "$VER_INSPIRCD"   --arg up "$URL_INSPIRCD" \
        --arg vb "$VER_BAHAMUT"    --arg ub "$URL_BAHAMUT" \
        --arg vo "$(_overtchat_version)" \
        '{
            meta: { created: $ts },
            servers: {
                unrealircd: {
                    name:         "UnrealIRCd",
                    version:      $vu,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/unrealircd"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     6697,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:   $uu
                },
                hybrid: {
                    name:         "ircd-Hybrid",
                    version:      $vh,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/hybrid"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     6697,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:   $uh
                },
                ircu: {
                    name:         "ircu",
                    version:      $vi,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/ircu"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     null,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:   $ui
                },
                inspircd: {
                    name:         "InspIRCd",
                    version:      $vp,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/inspircd"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     6697,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:   $up
                },
                bahamut: {
                    name:         "Bahamut",
                    version:      $vb,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/bahamut"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     6697,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:   $ub
                },
                overtchat_ircd: {
                    name:         "Overtchat-Hybrid",
                    version:      $vo,
                    installed:    false,
                    enabled:      false,
                    running:      false,
                    install_dir:  ($d+"/overtchat-ircd"),
                    binary:       null,
                    config:       null,
                    port:         6667,
                    port_ssl:     6697,
                    log:          { enabled: true, path: null, level: "info" },
                    sha256:       { verified: false, expected_hash: null, last_checked: null },
                    errors:       [],
                    last_start:   null,
                    last_stop:    null,
                    source_url:      null,
                    sources_locales: "Servers/overtchat-hybrid"
                }
            }
        }' > "$(_srv_file)"

    _sha256_store "$(_srv_file)" "$INIT_BASE" "$SERVERS_KEY"
    _ok "Configuration des serveurs initialisée."
}

# =============================================================================
# Cycle de vie : démarrage / arrêt / redémarrage
# =============================================================================

# -----------------------------------------------------------------------------
# _server_start <serveur>
# -----------------------------------------------------------------------------
_server_start() {
    local srv="$1"
    local install_dir bin run_dir pid_file log_file pid

    install_dir=$(_srv_get "$srv" "install_dir")
    bin=$(_srv_get         "$srv" "binary")
    read -r run_dir pid_file log_file < <(_srv_paths "$srv")

    [[ -z "$bin" || "$bin" == "null" ]] && { _err "Aucun binaire défini pour $srv."; return 1; }
    [[ ! -x "${install_dir}/${bin}" ]]  && { _err "Binaire introuvable ou non exécutable : ${install_dir}/${bin}"; return 1; }

    # Déjà en cours ?
    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        _warn_f "%s est déjà en cours d'exécution." "$srv"
        return 0
    fi

    mkdir -p "$run_dir"
    _info_f "Démarrage de %s…" "$srv"

    nohup "${install_dir}/${bin}" > "$log_file" 2>&1 &
    pid=$!
    echo "$pid" > "$pid_file"
    sleep 1

    if kill -0 "$pid" 2>/dev/null; then
        _srv_set "$srv" \
            '.servers[$s].running = true | .servers[$s].last_start = $ts' \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        _log_entry "$srv" "start" "PID=$pid"
        _ok_f "%s démarré (PID : %s)." "$srv" "$pid"
    else
        rm -f "$pid_file"
        _server_report_error "$srv" "Échec du démarrage"
        _err_f "Le processus %s s'est terminé immédiatement." "$srv"
        return 1
    fi
}

# -----------------------------------------------------------------------------
# _server_stop <serveur>
# -----------------------------------------------------------------------------
_server_stop() {
    local srv="$1"
    local pid_file pid

    read -r _ pid_file _ < <(_srv_paths "$srv")

    if [[ ! -f "$pid_file" ]]; then
        _warn_f "%s : aucun PID trouvé, processus probablement déjà arrêté." "$srv"
        return 0
    fi

    pid=$(cat "$pid_file")

    if kill -0 "$pid" 2>/dev/null; then
        _info_f "Envoi de SIGTERM au PID %s…" "$pid"
        kill "$pid"
        sleep 2

        if kill -0 "$pid" 2>/dev/null; then
            _warn "Le processus ne répond pas, envoi de SIGKILL…"
            kill -9 "$pid"
            sleep 1
        fi
    else
        _warn_f "%s : PID %s introuvable dans les processus actifs." "$srv" "$pid"
    fi

    rm -f "$pid_file"

    _srv_set "$srv" \
        '.servers[$s].running = false | .servers[$s].last_stop = $ts' \
        --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _log_entry "$srv" "stop" "PID=$pid"
    _ok_f "%s arrêté." "$srv"
}

# -----------------------------------------------------------------------------
# _server_restart <serveur>
# -----------------------------------------------------------------------------
_server_restart() {
    local srv="$1"
    _info_f "Redémarrage de %s…" "$srv"
    _server_stop "$srv"
    sleep 1
    _server_start "$srv"
}

# =============================================================================
# Statut
# =============================================================================

# -----------------------------------------------------------------------------
# _server_status <serveur>
#   Affiche "running" ou "stopped".
# -----------------------------------------------------------------------------
_server_status() {
    local srv="$1"
    local pid_file

    read -r _ pid_file _ < <(_srv_paths "$srv")

    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        echo "running"
    else
        echo "stopped"
    fi
}

# -----------------------------------------------------------------------------
# _server_status_icon <serveur>
#   Affiche une icône colorée résumant l'état du serveur.
# -----------------------------------------------------------------------------
_server_status_icon() {
    local srv="$1"
    local installed enabled running has_error sha_ok icon suffix=""

    installed=$(_srv_get "$srv" "installed")
    enabled=$(_srv_get   "$srv" "enabled")
    running=$(_srv_get   "$srv" "running")
    has_error=$(jq -r --arg s "$srv" '.servers[$s].errors | length > 0' "$(_srv_file)")
    sha_ok=$(_srv_get    "$srv" "sha256.verified")

    if   [[ "$installed" == "false" ]]; then icon="$(_ui_badge off  'non installé')"
    elif [[ "$running"   == "true"  ]]; then icon="$(_ui_badge run  'en marche')"
    elif [[ "$enabled"   == "true"  ]]; then icon="$(_ui_badge warn 'arrêté')"
    else                                     icon="$(_ui_badge err  'désactivé')"
    fi

    [[ "$sha_ok"    == "false" && "$installed" == "true" ]] && suffix+=" ${ORANGE}⚠${RESET}${DIM} sha256${RESET}"
    [[ "$has_error" == "true"                            ]] && suffix+=" ${RED}⚑${RESET}${DIM} erreurs${RESET}"

    printf '%s' "${icon}${suffix}"
}

# -----------------------------------------------------------------------------
# _servers_overview
#   Tableau récapitulatif de tous les serveurs.
# -----------------------------------------------------------------------------
# Ce que chaque serveur implique — la question qu'on se pose avant d'installer,
# et à laquelle cet écran ne répondait pas : il n'affichait que des noms.
#
# Le point décisif n'est pas la performance, c'est la COMPATIBILITÉ. IriX parle
# à son serveur par un lien ULine et lui adresse SVSNICK, SVSJOIN, SVS2MODE ;
# le chat du site, lui, passe par le module websocket d'UnrealIRCd. Choisir
# autre chose, c'est renoncer à une partie de l'ensemble — autant le savoir
# avant, plutôt que de le découvrir une fois le serveur compilé.
_srv_resume() {
    case "$1" in
        unrealircd)
            echo "tout fonctionne : services IriX, chat du site, modération" ;;
        hybrid)
            echo "léger et robuste — pas de chat sur le site (aucun websocket)" ;;
        ircu)
            echo "conçu pour les très grands réseaux — austère, services limités" ;;
        inspircd)
            echo "modulaire, très répandu — SVSNICK, SVSJOIN et SVSPART présents" ;;
        overtchat_ircd)
            echo "variante maison d'UnrealIRCd" ;;
        *) echo "" ;;
    esac
}

# S'affiche DANS un cadre déjà ouvert par l'appelant.
_servers_overview() {
    local servers=( unrealircd hybrid ircu inspircd bahamut overtchat_ircd )
    local srv name icon resume

    for srv in "${servers[@]}"; do
        name=$(_srv_get "$srv" "name")
        [[ -z "$name" || "$name" == "null" ]] && name="$srv"
        icon=$(_server_status_icon "$srv")
        resume=$(_srv_resume "$srv")
        _ui_row "  ${BOLD}${name}${RESET}$(_ui_pad "$name" 24)${icon}"
        [[ -n "$resume" ]] && _ui_row "    ${DIM}${resume}${RESET}"
    done

    _ui_mid
    _ui_row "${DIM}Un seul serveur suffit. IriX et le chat du site sont écrits pour${RESET}"
    _ui_row "${DIM}UnrealIRCd : les autres s'installent, mais n'en offrent qu'une partie.${RESET}"
}

# =============================================================================
# Logs
# =============================================================================

# -----------------------------------------------------------------------------
# _server_logs <serveur>
#   Suit le fichier de log en temps réel.
# -----------------------------------------------------------------------------
_server_logs() {
    local srv="$1"
    local log_file

    read -r _ _ log_file < <(_srv_paths "$srv")

    if [[ ! -f "$log_file" ]]; then
        _warn_f "Aucun fichier de log trouvé pour %s." "$srv"
        return 1
    fi

    _info_f "Logs de %s (Ctrl+C pour quitter) :" "$srv"
    tail -f "$log_file"
}

# =============================================================================
# Toggle
# =============================================================================

# -----------------------------------------------------------------------------
# _server_toggle <serveur>
#   Active ou désactive un serveur (champ enabled).
# -----------------------------------------------------------------------------
_server_toggle() {
    local srv="$1"
    local current new_state ts

    current=$(_srv_get "$srv" "enabled")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    if [[ "$current" == "true" ]]; then
        new_state=false
        _srv_set "$srv" \
            '.servers[$s].enabled = false | .servers[$s].running = false | .servers[$s].last_stop = $ts' \
            --arg ts "$ts"
    else
        new_state=true
        _srv_set "$srv" \
            '.servers[$s].enabled = true | .servers[$s].last_start = $ts' \
            --arg ts "$ts"
    fi

    _log_entry "$srv" "toggle" "enabled=${new_state}"
    _ok_f "%s → enabled=%s" "$srv" "${new_state}"
}

# -----------------------------------------------------------------------------
# _server_log_toggle <serveur>
#   Active ou désactive la journalisation d'un serveur.
# -----------------------------------------------------------------------------
_server_log_toggle() {
    local srv="$1"
    local current new_state

    current=$(_srv_get "$srv" "log.enabled")
    [[ "$current" == "true" ]] && new_state=false || new_state=true

    _srv_set "$srv" '.servers[$s].log.enabled = $v' --argjson v "$new_state"
    _ok_f "%s : logs → %s" "$srv" "${new_state}"
}

# =============================================================================
# Ports
# =============================================================================

# -----------------------------------------------------------------------------
# _server_set_port <serveur>
#   Modifie les ports IRC et SSL d'un serveur.
# -----------------------------------------------------------------------------
_server_set_port() {
    local srv="$1"
    local cur_port cur_ssl port ssl_port

    cur_port=$(_srv_get "$srv" "port")
    cur_ssl=$(_srv_get  "$srv" "port_ssl")
    [[ "$cur_ssl" == "null" ]] && cur_ssl="aucun"

    read -rp "  Port IRC   [actuel : ${cur_port}]  : " port || true
    read -rp "  Port SSL   [actuel : ${cur_ssl}]   : " ssl_port || true

    # --argjson injecte la saisie telle quelle dans jq : une frappe comme
    # "6667a" produisait un JSON invalide → jq en erreur → _die → fermeture du
    # panel. On valide avant, et on refuse proprement.
    _valid_port() {
        [[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 ))
    }

    local changed=0
    if [[ -n "$port" ]]; then
        if _valid_port "$port"; then
            _srv_set "$srv" '.servers[$s].port = $p' --argjson p "$port"; changed=1
        else
            _err_f "Port IRC invalide : « %s » (attendu : 1-65535). Inchangé." "$port"
        fi
    fi
    if [[ -n "$ssl_port" ]]; then
        if _valid_port "$ssl_port"; then
            _srv_set "$srv" '.servers[$s].port_ssl = $p' --argjson p "$ssl_port"; changed=1
        else
            _err_f "Port SSL invalide : « %s » (attendu : 1-65535). Inchangé." "$ssl_port"
        fi
    fi

    (( changed )) && _ok "$srv : ports mis à jour." || _info "Aucun changement."
    return 0
}

# =============================================================================
# Gestion des erreurs
# =============================================================================

# -----------------------------------------------------------------------------
# _server_report_error <serveur> <message>
# -----------------------------------------------------------------------------
_server_report_error() {
    local srv="$1" msg="$2" ts
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    _srv_set "$srv" \
        '.servers[$s].errors += [{ ts: $ts, msg: $msg }]' \
        --arg ts "$ts" --arg msg "$msg"

    _log_entry "$srv" "error" "$msg"
}

# -----------------------------------------------------------------------------
# _server_show_errors <serveur>
# -----------------------------------------------------------------------------
_server_show_errors() {
    local srv="$1"
    local count

    _section "Erreurs — $(_srv_get "$srv" "name")"
    count=$(jq -r --arg s "$srv" '.servers[$s].errors | length' "$(_srv_file)")

    if [[ "$count" -eq 0 ]]; then
        _ok "Aucune erreur enregistrée."
    else
        jq -r --arg s "$srv" \
            '.servers[$s].errors[] | "  [\(.ts)]  \(.msg)"' \
            "$(_srv_file)"
    fi
}

# -----------------------------------------------------------------------------
# _server_clear_errors <serveur>
# -----------------------------------------------------------------------------
_server_clear_errors() {
    local srv="$1"
    _srv_set "$srv" '.servers[$s].errors = []'
    _ok_f "%s : historique des erreurs effacé." "$srv"
}

# =============================================================================
# SHA-256
# =============================================================================

# -----------------------------------------------------------------------------
# _server_verify_sha256 <serveur>
# -----------------------------------------------------------------------------
_server_verify_sha256() {
    local srv="$1"
    local install_dir archive stored current ts

    install_dir=$(_srv_get "$srv" "install_dir")
    archive=$(find "$install_dir" -maxdepth 1 \( -name "*.tar.gz" -o -name "*.tgz" \) 2>/dev/null | head -1)

    if [[ -z "$archive" ]]; then
        _warn_f "Aucune archive source trouvée dans %s." "${install_dir}"
        return 2   # 2 = archive absente (distinct d'un hash invalide, qui rend 1)
    fi

    stored=$(_srv_get "$srv" "sha256.expected_hash")
    current=$(_sha256 "$archive")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    # Première vérification : on ENREGISTRE la référence au lieu de la comparer.
    #
    # Cette branche manquait — elle existe pourtant dans le jumeau
    # _service_verify_sha256, dont cette fonction est la copie côté serveurs.
    # `expected_hash` est initialisé à null par _servers_init et n'était écrit
    # nulle part : _srv_get rendait donc la chaîne "null", jamais égale à un
    # hash. Résultat, « Vérifier SHA-256 » répondait INVALIDE à tous les coups,
    # empilait une erreur dans servers.json et marquait le serveur en défaut
    # de façon permanente — sur une installation parfaitement saine.
    if [[ -z "$stored" || "$stored" == "null" ]]; then
        _srv_set "$srv" \
            '.servers[$s].sha256.expected_hash = $h | .servers[$s].sha256.verified = true | .servers[$s].sha256.last_checked = $ts' \
            --arg h "$current" --arg ts "$ts"
        _ok "SHA-256 enregistré comme référence (première vérification)."
        return 0
    fi

    if [[ "$stored" == "$current" ]]; then
        _srv_set "$srv" \
            '.servers[$s].sha256.verified = true | .servers[$s].sha256.last_checked = $ts' \
            --arg ts "$ts"
        _ok "SHA-256 valide."
    else
        _srv_set "$srv" \
            '.servers[$s].sha256.verified = false | .servers[$s].sha256.last_checked = $ts' \
            --arg ts "$ts"
        _err "SHA-256 INVALIDE !"
        printf "  Attendu : ${GREEN}%s${RESET}\n" "$stored"
        printf "  Obtenu  : ${RED}%s${RESET}\n"   "$current"
        _server_report_error "$srv" "SHA-256 invalide"
        return 1
    fi
}

# =============================================================================
# Installation
# =============================================================================

# -----------------------------------------------------------------------------
# _install_deps — SUPPRIMÉE le 19/07/2026, remplacée par _pkg_install.
#
# L'ancienne version était liée à Debian (`dpkg -s`, `sudo apt-get`) : sur
# RHEL/Arch/SUSE/Alpine, `dpkg -s` étant absent, TOUS les paquets étaient jugés
# manquants, puis `apt-get` introuvable faisait retourner 1 — ce qui déclenchait
# `_install_fail` et donc un `rm -rf` du dossier d'installation. Une simple
# erreur de distribution détruisait une installation valide.
#
# `_pkg_install` gère apt/dnf/yum/pacman/zypper/apk, traduit les noms de paquets
# par famille, et n'élève les privilèges que si c'est possible.
# -----------------------------------------------------------------------------

# -----------------------------------------------------------------------------
# _server_verify <serveur>
#   Vérifie que le binaire du serveur est présent et exécutable.
# -----------------------------------------------------------------------------
_server_verify() {
    local srv="$1"
    local install_dir bin

    install_dir=$(_srv_get "$srv" "install_dir")
    bin=$(_srv_get         "$srv" "binary")

    [[ -z "$bin" || "$bin" == "null" ]] && return 1
    [[ -x "${install_dir}/${bin}"    ]] || return 1
    return 0
}

# -----------------------------------------------------------------------------
# _install_fail <serveur>
#   Rollback après une installation échouée.
# -----------------------------------------------------------------------------
_install_fail() {
    local srv="$1"
    local dir

    local reason="${2:-build}"
    dir=$(_srv_get "$srv" "install_dir")

    # Un échec de dépendances ou de sources n'est pas une installation
    # corrompue : parler de « rollback » et supprimer le dossier induisait en
    # erreur, et détruisait une installation valide sur simple sudo refusé.
    case "$reason" in
        deps)
            _err "Dépendances non satisfaites — installation abandonnée, rien n'a été supprimé."
            _server_report_error "$srv" "Dépendances système manquantes"
            return 0 ;;
        sources)
            _err "Sources introuvables — installation abandonnée, rien n'a été supprimé."
            _info "Dépose l'archive dans Components/Serveur/lib/ ou renseigne source_url."
            _server_report_error "$srv" "Archive source manquante"
            return 0 ;;
        # ⚠ Overtchat-Hybrid hors clone, depuis le 29/09/2026 : il ne vient QUE
        #   du serveur compilé que sert le site (ses sources ne sont plus
        #   servies). Rien n'a été déballé dans <dir> : rien à défaire.
        binaire)
            _err "Serveur compilé indisponible ou refusé — installation abandonnée, rien n'a été supprimé."
            _server_report_error "$srv" "Serveur compilé indisponible"
            return 0 ;;
        machine)
            _err "Cette machine ne peut pas faire tourner le serveur compilé — installation abandonnée, rien n'a été supprimé."
            _server_report_error "$srv" "Machine incompatible avec le serveur compilé"
            return 0 ;;
    esac

    _err "Installation échouée → rollback."
    _safe_rmrf "$dir"
    _srv_set "$srv" '.servers[$s].installed = false'
    _server_report_error "$srv" "Installation échouée"
}

# -----------------------------------------------------------------------------
# _install_unrealircd
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# _find_local_archive <motif>
#   Cherche une archive fournie avec le projet, la plus récente d'abord.
#   Permet une installation hors ligne et évite de dépendre d'une URL amont.
# -----------------------------------------------------------------------------
_find_local_archive() {
    local pattern="${1:-*.tar.gz}" d f
    for d in "$BASE_DIR/Components/Serveur/lib" \
             "$BASE_DIR/Components/Service/lib" \
             "$BASE_DIR/Servers/lib"; do
        [[ -d "$d" ]] || continue
        f=$(find "$d" -maxdepth 1 -name "$pattern" -type f -printf '%T@ %p\n' 2>/dev/null \
            | sort -rn | head -1 | cut -d' ' -f2-)
        [[ -n "$f" ]] && { printf '%s' "$f"; return 0; }
    done
    return 1
}

# -----------------------------------------------------------------------------
# _srv_fetch_sources <srv> <dir> <motif-archive>
#   Récupère et déballe les sources dans <dir> : archive locale d'abord,
#   téléchargement en repli, dépôt git si source_url pointe vers un dépôt.
#   Retour : 0 = sources prêtes, 2 = sources introuvables (pas un échec de build)
# -----------------------------------------------------------------------------
_srv_fetch_sources() {
    _fetch_sources "$(_srv_get "$1" "source_url")" "$2" "$3" "$1"
}

# -----------------------------------------------------------------------------
# _fetch_sources <url> <dir> <motif-archive> <étiquette>
#   Le même geste, mais pour une URL quelconque — les SERVICES (Eggdrop, Anope)
#   n'y avaient pas droit : ils clonaient en dur, ce qui interdisait d'épingler
#   une version par archive et rendait l'installation hors ligne impossible pour
#   eux seuls. Une seule récupération pour les deux familles.
# -----------------------------------------------------------------------------
_fetch_sources() {
    # ⚠ 5ᵉ argument (28/09/2026) : l'empreinte ATTENDUE, quand elle ne vient pas
    #   des constantes par adresse — celle que le MANIFESTE du site publie pour
    #   l'archive d'Overtchat-Hybrid. Vide : la règle par adresse s'applique.
    local url="$1" dir="$2" pattern="$3" srv="${4:-composant}" attendu="${5:-}"
    local archive local_archive

    mkdir -p "$dir"

    # 17/10/2026 : une archive IMPOSÉE par l'appelant (`anope-construire
    # --archive=…`), qui l'a VÉRIFIÉE à l'empreinte avant de la poser ici — une
    # archive locale trouvée par `_find_local_archive`, elle, n'est contrôlée
    # que sur sa signature gzip.
    if [[ -n "${_ARCHIVE_IMPOSEE:-}" ]]; then
        local_archive="$_ARCHIVE_IMPOSEE"
    else
        local_archive=$(_find_local_archive "$pattern")
    fi
    if [[ -n "$local_archive" ]]; then
        _ok "Archive locale : $(basename "$local_archive")"
        archive="${dir}/src.tar.gz"
        cp "$local_archive" "$archive"
        _verify_gzip "$archive" || return 2
        tar -xzf "$archive" -C "$dir" --strip-components=1 || return 1
        return 0
    fi

    if [[ -z "$url" || "$url" == "null" ]]; then
        _err_f "Aucune archive locale et aucun source_url défini pour %s." "$srv"
        _info_f "Dépose une archive dans Components/Serveur/lib/ (motif : %s)" "$pattern"
        return 2
    fi

    # Dépôt git : on clone dans un temporaire puis on déplace, car $dir existe
    # déjà (git clone refuse un dossier non vide).
    if [[ "$url" == *.git || "$url" == https://github.com/* && "$url" != *.tar.gz ]]; then
        local tmpc
        tmpc=$(mktemp -d)
        _info_f "Clonage depuis %s…" "$url"
        if ! git clone --depth=1 "$url" "$tmpc/src" 2>/dev/null; then
            rm -rf "$tmpc"
            _err_f "Clonage impossible : %s" "$url"
            return 2
        fi
        # Contenu déplacé dans $dir sans écraser ce qui s'y trouve déjà
        (shopt -s dotglob; mv "$tmpc"/src/* "$dir"/ 2>/dev/null) || true
        rm -rf "$tmpc"
        return 0
    fi

    _info_f "Téléchargement depuis %s…" "$url"
    archive="${dir}/src.tar.gz"
    curl -fsSL --retry 3 --connect-timeout 10 "$url" -o "$archive" || return 2
    _verify_gzip "$archive" || return 2
    # ⚠ AVANT tar : une archive refusée ne doit rien avoir déposé.
    if ! _verifier_empreinte "$archive" "$url" "$srv" "$attendu"; then
        rm -f "$archive"
        return 2
    fi
    tar -xzf "$archive" -C "$dir" --strip-components=1 || return 1
    return 0
}

# L'empreinte SHA-256 attendue pour une adresse, ou rien.
#
# ⚠ Par l'adresse EXACTE : une empreinte ne vaut que pour les octets qu'on a
#   mesurés. Une `source_url` changée à la main dans servers.json/services.json
#   n'a pas d'empreinte connue — voir `_verifier_empreinte`.
_empreinte_attendue() {
    case "$1" in
        "$URL_UNREALIRCD") printf '%s' "$SHA256_UNREALIRCD" ;;
        "$URL_EGGDROP")    printf '%s' "$SHA256_EGGDROP" ;;
        "$URL_ANOPE")      printf '%s' "$SHA256_ANOPE" ;;
        "$URL_HYBRID")     printf '%s' "$SHA256_HYBRID" ;;
        "$URL_IRCU")       printf '%s' "$SHA256_IRCU" ;;
        "$URL_INSPIRCD")   printf '%s' "$SHA256_INSPIRCD" ;;
        "$URL_BAHAMUT")    printf '%s' "$SHA256_BAHAMUT" ;;
        *)                 printf '' ;;
    esac
}

# Compare l'empreinte d'une archive téléchargée à celle attendue.
#   <fichier> <url> <étiquette> [empreinte attendue, sinon lue par l'adresse]
# Rend 0 si elle concorde, 1 si elle DIFFÈRE (l'archive est à refuser).
#
# ⚠⚠ Une adresse SANS empreinte connue (source changée par l'exploitant)
#   passe, mais en le DISANT, avec l'empreinte obtenue : refuser casserait un
#   choix délibéré, se taire ferait croire à une vérification qui n'a pas eu lieu.
_verifier_empreinte() {
    local fichier="$1" url="$2" etiquette="${3:-composant}" attendu="${4:-}" obtenu
    [[ -n "$attendu" ]] || attendu=$(_empreinte_attendue "$url")
    obtenu=$(_sha256 "$fichier")
    if [[ -z "$attendu" ]]; then
        _warn_f "%s : adresse sans empreinte connue (%s) — seule la signature gzip est contrôlée. Empreinte obtenue : %s" \
            "$etiquette" "$url" "$obtenu"
        return 0
    fi
    if [[ "$obtenu" != "$attendu" ]]; then
        _err_f "%s : EMPREINTE DIFFÉRENTE — l'archive téléchargée n'est pas celle qui a été vérifiée. REFUSÉE." "$etiquette"
        _info_f "  adresse  : %s" "$url"
        _info_f "  attendue : %s" "$attendu"
        _info_f "  obtenue  : %s" "$obtenu"
        _info "  Si la version vient d'être montée, remesurez son empreinte en tête du script."
        return 1
    fi
    _ok_f "%s : empreinte SHA-256 vérifiée." "$etiquette"
    return 0
}

# -----------------------------------------------------------------------------
# _srv_finalize <srv> <dir> <binaire-relatif...>
#   Ne déclare installed=true QUE si un binaire exécutable existe réellement.
#   L'ancien code posait installed=true avec binary=null, ce que _server_verify
#   contredisait aussitôt : le panel affichait « installé » et refusait de
#   démarrer.
# -----------------------------------------------------------------------------
_srv_finalize() {
    local srv="$1" dir="$2"; shift 2
    local cand found=""
    for cand in "$@"; do
        [[ -x "${dir}/${cand}" ]] && { found="$cand"; break; }
    done

    if [[ -z "$found" ]]; then
        _err "Compilation terminée mais aucun binaire trouvé (cherché : $*)."
        _install_fail "$srv"
        return 1
    fi

    mkdir -p "${dir}/run"
    touch "${dir}/.installed"
    _srv_set "$srv" \
        '.servers[$s].binary = $b | .servers[$s].installed = true | .servers[$s].last_install = $ts' \
        --arg b "$found" --arg ts "$(date -Iseconds)"
    _ok "$(_req_label "$srv") installé ($found)."
    return 0
}

# -----------------------------------------------------------------------------
# _verify_gzip <fichier>
#   `curl -f` n'échoue que sur un code HTTP >= 400. Un miroir qui répond 200
#   avec une page d'erreur HTML, un portail captif ou une redirection vers une
#   page de téléchargement produisent donc un fichier « valide » que tar
#   recrache ensuite en "gzip: stdin: not in gzip format" — message illisible,
#   suivi d'un faux « rollback ». On contrôle la signature avant d'extraire.
# -----------------------------------------------------------------------------
_verify_gzip() {
    local f="${1:-}"
    [[ -s "$f" ]] || { _err "Archive vide ou absente : $f"; return 1; }

    # Signature gzip : 1f 8b
    local magic
    magic=$(head -c2 "$f" | od -An -tx1 2>/dev/null | tr -d ' \n')
    if [[ "$magic" == "1f8b" ]]; then return 0; fi

    _err "Le fichier téléchargé n'est pas une archive gzip (signature: ${magic:-vide})."
    if head -c200 "$f" | grep -qiE "<html|<!doctype"; then
        _warn "Le serveur a renvoyé une page HTML — URL de téléchargement probablement obsolète."
    fi
    _info "URL utilisée : ${url:-inconnue}"
    _info "Corrige source_url dans Config/servers.json, ou dépose l'archive à la main."
    rm -f "$f"
    return 1
}

_install_unrealircd() {
    local srv="unrealircd" dir
    dir=$(_srv_get "$srv" "install_dir")

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    # Sources dans <dir>/source, installation dans <dir>.
    #
    # Tout mettre au même endroit provoquait une collision : l'archive contient
    # déjà un doc/, et DOCDIR pointant sur ce même doc/ faisait échouer
    # `make install` sur « identifient le même fichier » — après une
    # compilation pourtant réussie et un binaire correctement posé.
    # Nom volontairement distinct de "source" : UnrealIRCd crée lui-même un
    # lien de ce nom dans son scriptdir pendant `make install`, ce qui faisait
    # échouer l'installation sur « rm: ... source : est un dossier ».
    local src_dir="${dir}/_build-src" rc
    _srv_fetch_sources "$srv" "$src_dir" "unrealircd*.tar.gz"; rc=$?
    (( rc == 2 )) && { _install_fail "$srv" sources; return 1; }
    (( rc != 0 )) && { _install_fail "$srv"; return 1; }

    # './Config -quick' réutilise un config.settings existant et ÉCHOUE s'il
    # n'y en a pas — ce qui est toujours le cas sur une installation neuve
    # ("cannot be used because your 'config.settings' file either does not
    # exist..."). Sans ce fichier, la seule voie est le questionnaire
    # interactif, impossible depuis un panel. On génère donc des réponses par
    # défaut cohérentes avec l'arborescence du projet.
    if [[ ! -f "${src_dir}/config.settings" ]]; then
        _info "Génération de config.settings (installation non interactive)…"
        cat > "${src_dir}/config.settings" <<-SETTINGS
		BASEPATH="${dir}"
		BINDIR="${dir}/bin"
		DATADIR="${dir}/data"
		CONFDIR="${dir}/conf"
		MODULESDIR="${dir}/modules"
		LOGDIR="${dir}/logs"
		CACHEDIR="${dir}/cache"
		DOCDIR="${dir}/doc"
		TMPDIR="${dir}/tmp"
		PRIVATELIBDIR="${dir}/lib"
		SSLDIR=""
		REMOTEINC="1"
		MAXCONNECTIONS_REQUEST="auto"
		NICKNAMEHISTORYLENGTH="2000"
		DEFPERM="0600"
		SSLDIR=""
		EXTRAPARA=""
		SETTINGS_VERSION="1"
		SETTINGS_MAJOR_VERSION="6"
		SETTINGS_MINOR_VERSION="0"
		SETTINGS_PATCH_VERSION="0"
		SETTINGS
    fi

    (
        cd "$src_dir" || exit 1
        ./Config -quick && make -j"$(_make_jobs)" && make install
    ) || { _install_fail "$srv"; return 1; }

    # 'make install' place le binaire dans BINDIR ; source/src/ircd est le repli
    # si l'installation ne s'est pas faite. _srv_finalize crée run/ et la
    # sentinelle .installed, mais uniquement si un binaire existe réellement.
    _srv_finalize "$srv" "$dir" "bin/unrealircd" "_build-src/src/ircd"
}

# -----------------------------------------------------------------------------
# _install_hybrid
# -----------------------------------------------------------------------------
_install_hybrid() {
    local srv="hybrid" dir rc
    dir=$(_srv_get "$srv" "install_dir")

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    # ⚠ « ircd-hybrid* » ET NON « *hybrid* », resserré le 12/08/2026.
    #
    # Le motif large captait `overtchat-hybrid-<version>.tar.gz` — l'archive de
    # NOTRE serveur, que Tools/faire-archive-hybrid.sh déposait dans le même
    # dossier (outil retiré le 29/09/2026, avec les sources servies ; le
    # serveur COMPILÉ, `overtchat-hybrid-bin-*`, porte toujours « hybrid »). `_find_local_archive` rendant la plus RÉCENTE, ircd-Hybrid se
    # serait installé à partir des sources d'Overtchat-Hybrid, en annonçant
    # simplement « Archive locale : overtchat-hybrid-2.0.0.tar.gz ».
    #
    # Deux serveurs différents portent « hybrid » dans leur nom : le motif doit
    # donc nommer celui-ci, pas une famille. Un dépôt manuel s'appelle
    # `ircd-hybrid-8.2.47.tar.gz` — le seul cas que ce motif ait à couvrir,
    # puisqu'un téléchargement passe par `src.tar.gz` et ne le consulte pas.
    _srv_fetch_sources "$srv" "$dir" "ircd-hybrid*.tar.gz"; rc=$?
    (( rc == 2 )) && { _install_fail "$srv" sources; return 1; }
    (( rc != 0 )) && { _install_fail "$srv"; return 1; }

    # autoconf/automake/libtool n'étaient réclamés QUE parce qu'on l'a supposé.
    # L'archive du tag 8.2.47 porte un `configure` déjà généré (vérifié le
    # 30/07/2026) : exiger ces trois paquets faisait échouer l'installation sur
    # « dépendances non satisfaites » là où rien n'en avait besoin — et sur une
    # machine sans élévation, l'échec était définitif. On ne les demande donc
    # que si le `configure` manque vraiment.
    if [[ ! -x "${dir}/configure" ]]; then
        _info "Aucun ./configure dans les sources — génération par autotools."
        # shellcheck disable=SC2046
        _pkg_install autoconf automake libtool || { _install_fail "$srv" deps; return 1; }
    fi

    # --prefix="$dir" : sans lui, configure vise /usr/local, donc root — alors
    # que tout le projet s'installe dans le home. Et `make` seul ne pose aucun
    # binaire dans bin/ : il faut `make install`, sinon on déclarait bin/ircd
    # sans qu'il existe jamais.
    (
        cd "$dir" || exit 1
        [[ -x ./autogen.sh && ! -f ./configure ]] && ./autogen.sh
        ./configure --prefix="$dir" && make -j"$(_make_jobs)" && make install
    ) || { _install_fail "$srv"; return 1; }

    _srv_finalize "$srv" "$dir" "bin/ircd" "src/ircd"
}

# -----------------------------------------------------------------------------
# _install_ircu
# -----------------------------------------------------------------------------
_install_ircu() {
    local srv="ircu" dir rc
    dir=$(_srv_get "$srv" "install_dir")

    # L'ancienne version ne compilait RIEN : elle créait deux dossiers et
    # posait installed=true avec binary=null. _server_verify contredisait
    # aussitôt cet état — le panel affichait « installé » puis refusait de
    # démarrer, sans expliquer pourquoi. On tente une vraie compilation, et
    # on refuse honnêtement si les sources manquent.
    #
    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    # Le source_url valait « https://coder-com.undernet.org/ » — une page, jamais
    # une archive : l'installation ne pouvait pas réussir, et le message ci-dessous
    # renvoyait l'exploitant vers cette même page. Depuis le 30/07/2026 elle vise
    # le tag u2.10.12.19 d'undernetirc/ircu2, dernier publié (2019) et vérifié.
    _srv_fetch_sources "$srv" "$dir" "*ircu*.tar.gz"; rc=$?
    if (( rc == 2 )); then
        _warn_f "Sources d'ircu2 indisponibles (réseau, ou tag %s retiré)." "${VER_IRCU}"
        _info "Repli : récupère l'archive sur https://github.com/undernetirc/ircu2/tags"
        _info "et dépose-la dans Components/Serveur/lib/ (motif : *ircu*.tar.gz)."
        _install_fail "$srv" sources
        return 1
    fi
    (( rc != 0 )) && { _install_fail "$srv"; return 1; }

    # L'archive du tag u2.10.12.19 porte un `configure` déjà généré, ainsi que
    # configure.in et Makefile.in (vérifié le 30/07/2026). Le repli autotools
    # ci-dessous n'est donc PAS le chemin normal : c'est un filet pour le jour
    # où l'on partirait d'un clone du dépôt, qui lui n'a pas de configure.
    # --with-maxcon est INDISPENSABLE, et son absence ne se voit qu'au
    # démarrage. Sans lui, configure retient la limite DURE de descripteurs du
    # système — 1 048 576 sous WSL — et compile un binaire qui refuse ensuite de
    # se lancer : « MAXCLIENTS (or MAXCONNECTIONS) is (at least) 786432 too
    # large ». L'installation paraissait pourtant réussie, binaire posé compris.
    #
    # 4096 est très au-dessous de la borne du protocole lui-même : un numérique
    # P10 n'adresse que 262 144 clients par serveur (NN_MAX_CLIENT), et rien ne
    # sert de compiler au-delà de ce qu'on peut nommer.
    (
        cd "$dir" || exit 1
        [[ -x ./configure ]] || { autoreconf -i 2>/dev/null || autoconf 2>/dev/null; }
        [[ -x ./configure ]] || { echo "Aucun ./configure, et autoconf n'a pas pu le générer." >&2; exit 1; }
        ./configure --prefix="$dir" --with-maxcon=4096 \
            && make -j"$(_make_jobs)" && make install
    ) || { _install_fail "$srv"; return 1; }

    _srv_finalize "$srv" "$dir" "bin/ircd" "ircd"
}

# -----------------------------------------------------------------------------
# _install_inspircd
#
# InspIRCd 4.11.0, compilé ici le 31/07/2026 — une minute sur cette machine,
# sans un avertissement, avec les modules argon2, ldap, pcre2, gnutls et
# openssl activés d'eux-mêmes parce que leurs bibliothèques étaient là.
#
# Trois écarts avec les autres serveurs du projet, tous constatés en compilant :
#
#   1. le `configure` est un script PERL, pas un produit d'autotools : ni
#      autoreconf ni autogen.sh n'ont de sens ici, et son absence ne se
#      rattrape pas ;
#   2. il n'est PAS interactif tant qu'on lui passe des options, mais il le
#      devient sans — d'où le `</dev/null`, qui fait échouer franchement une
#      version future qui poserait une question plutôt que d'attendre pour
#      toujours dans une installation non surveillée ;
#   3. `make install` pose le binaire dans bin/, et rien d'autre ne le pose :
#      un `make` seul laisse une compilation réussie sans binaire installé.
#
# Le serveur n'a pas de configuration par défaut : `conf/` ne reçoit que
# `examples/`. Un démarrage sans `conf/inspircd.conf` s'arrête aussitôt, et
# c'est ce que le serveur dit lui-même — on ne devance pas ce message.
# -----------------------------------------------------------------------------
_install_inspircd() {
    local srv="inspircd" dir rc
    dir=$(_srv_get "$srv" "install_dir")

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    _srv_fetch_sources "$srv" "$dir" "*inspircd*.tar.gz"; rc=$?
    if (( rc == 2 )); then
        _warn_f "Sources d'InspIRCd indisponibles (réseau, ou tag v%s retiré)." "${VER_INSPIRCD}"
        _info "Repli : récupère l'archive sur https://github.com/inspircd/inspircd/releases"
        _info "et dépose-la dans Components/Serveur/lib/ (motif : *inspircd*.tar.gz)."
        _install_fail "$srv" sources
        return 1
    fi
    (( rc != 0 )) && { _install_fail "$srv"; return 1; }

    if [[ ! -f "${dir}/configure" ]]; then
        _err "Aucun ./configure dans les sources d'InspIRCd — archive incomplète."
        _install_fail "$srv"
        return 1
    fi

    (
        cd "$dir" || exit 1
        ./configure --prefix="$dir" </dev/null && make -j"$(_make_jobs)" install
    ) || { _install_fail "$srv"; return 1; }

    _srv_finalize "$srv" "$dir" "bin/inspircd"
}

# -----------------------------------------------------------------------------
# _install_bahamut
#
# Bahamut 2.2.4 (DALnet), compilé ici le 01/08/2026. C'est le serveur des
# réseaux qui n'ont jamais migré vers les identifiants internes : ses clients
# se désignent par leur PSEUDONYME et ses serveurs par leur NOM. IriX sait lui
# parler depuis le 01/08 — c'est le sixième et dernier dialecte écrit.
#
# ⚠ UN CERTIFICAT EST OBLIGATOIRE, MÊME SANS TLS DÉCLARÉ, et c'est le piège de
# ce serveur. Sans `ircd.crt`, il affiche « Can't open ircd.crt! ssl failed! »
# puis n'ouvre AUCUN port — pas même les ports en clair. On le génère donc ici,
# au moment de l'installation, plutôt que de laisser quelqu'un chercher
# pourquoi un serveur « démarré » n'écoute nulle part.
#
# Le script `make-cert.sh` livré avec les sources est INTERACTIF : openssl y
# pose ses questions une à une, et une installation non surveillée s'y
# arrêterait pour toujours. On appelle donc openssl directement, en `-batch`.
#
# Deux autres écarts, constatés en compilant :
#
#   1. `make install` dépose le binaire À LA RACINE du préfixe, sous le nom
#      `ircd` — pas dans `bin/`, contrairement à tous les autres serveurs du
#      projet. Les candidats de `_st_candidates` en tiennent compte ;
#   2. il n'y a pas de configuration par défaut : `reference.conf` et
#      `template.conf` sont des EXEMPLES. Un démarrage sans fichier à soi
#      s'arrête en le disant, et on ne devance pas ce message.
# -----------------------------------------------------------------------------
_install_bahamut() {
    local srv="bahamut" dir rc

    dir=$(_srv_get "$srv" "install_dir")

    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    _srv_fetch_sources "$srv" "$dir" "*bahamut*.tar.gz"; rc=$?
    if (( rc == 2 )); then
        _warn_f "Sources de Bahamut indisponibles (réseau, ou tag v%s retiré)." "${VER_BAHAMUT}"
        _info "Repli : récupère l'archive sur https://github.com/DALnet/bahamut/releases"
        _info "et dépose-la dans Components/Serveur/lib/ (motif : *bahamut*.tar.gz)."
        _install_fail "$srv" sources
        return 1
    fi
    (( rc != 0 )) && { _install_fail "$srv"; return 1; }

    if [[ ! -f "${dir}/configure" ]]; then
        _err "Aucun ./configure dans les sources de Bahamut — archive incomplète."
        _install_fail "$srv"
        return 1
    fi

    (
        cd "$dir" || exit 1
        ./configure --prefix="$dir" </dev/null && make -j"$(_make_jobs)" && make install
    ) || { _install_fail "$srv"; return 1; }

    # Le certificat, sans lequel le serveur refuse d'ouvrir ses ports. Généré
    # seulement s'il manque : une réinstallation ne doit pas remplacer celui
    # que l'exploitant aurait posé lui-même.
    if [[ ! -f "${dir}/ircd.crt" ]]; then
        if command -v openssl &>/dev/null; then
            if openssl req -new -x509 -days 1825 -nodes -batch \
                    -subj "/CN=$(hostname -f 2>/dev/null || echo bahamut.local)" \
                    -out "${dir}/ircd.crt" -keyout "${dir}/ircd.key" >/dev/null 2>&1; then
                chmod 600 "${dir}/ircd.key" 2>/dev/null || true
                _ok "Certificat auto-signé généré (ircd.crt) — sans lui, aucun port n'écoute."
            else
                _warn "Certificat non généré : le serveur refusera d'ouvrir ses ports."
                _info "À faire à la main : openssl req -new -x509 -days 1825 -nodes -batch \\"
                _info_f "  -subj '/CN=votre.serveur' -out %s/ircd.crt -keyout %s/ircd.key" "${dir}" "${dir}"
            fi
        else
            _warn "openssl absent : pas de certificat, donc aucun port ouvert au démarrage."
        fi
    fi

    _srv_finalize "$srv" "$dir" "ircd"
}

# -----------------------------------------------------------------------------
# _overtchat_bin_compatible [avant]
#   0 si cette machine peut faire tourner le serveur COMPILÉ que sert le site ;
#   sinon 1, en disant POURQUOI. Hors clone, il n'y a plus d'autre voie : les
#   sources ne sont plus servies (29/09/2026) — l'appelant refuse, ou `--auto`
#   se rabat sur UnrealIRCd en le disant (`_auto_serveur_choisir`).
#
# ⚠⚠ `avant` : le contrôle du PRÉVOL de `--auto`, avant toute écriture — donc
#   avant `_check_deps`, qui pose `openssl` (et avec lui libssl.so.3). Sur une
#   machine nue (image Debian 12 minimale), libssl.so.3 peut manquer alors
#   qu'OpenSSL 3 est à un paquet près : exiger la bibliothèque à ce moment
#   ferait écarter notre serveur d'une machine qui le porte. En `avant`, seule
#   une OpenSSL INSTALLÉE et plus ancienne que 3 refuse ; l'absence est notée,
#   et l'étape 3 refait le contrôle strict (appel sans argument).
#
# ⚠ Ce sont les exigences MESURÉES dans le binaire par
#   Tools/faire-binaire-hybrid.sh (`objdump -T`, écrites dans son ARCHIVE-INFO) :
#   x86_64, glibc ≥ 2.34, libssl.so.3. Contrôlées AVANT tout téléchargement :
#   un binaire qui ne peut pas démarrer ne doit coûter ni le transfert, ni un
#   faux « installé ».
# ⚠ `pipefail` est actif : on ne branche JAMAIS `ldconfig -p` sur `grep -q`
#   (grep sort au premier résultat, ldconfig meurt de SIGPIPE, et le tube rend
#   un échec sur une machine conforme). La sortie est lue, puis comparée.
# ⚠ `ldconfig` vit dans /sbin, que le PATH d'un compte ordinaire n'a pas sous
#   Debian : on le cherche aussi là.
# -----------------------------------------------------------------------------
_overtchat_bin_compatible() {
    local moment="${1:-}" glibc_min="2.34" arch glibc ldc="" c libs vssl
    arch=$(uname -m 2>/dev/null) || arch=""
    if [[ "$arch" != "x86_64" ]]; then
        _warn_f "Serveur compilé : architecture %s — il n'est fabriqué que pour x86_64." "${arch:-inconnue}"
        return 1
    fi
    glibc=$(getconf GNU_LIBC_VERSION 2>/dev/null) || glibc=""
    glibc="${glibc##* }"
    if [[ ! "$glibc" =~ ^[0-9]+\.[0-9]+ ]] \
       || [[ "$(printf '%s\n%s\n' "$glibc_min" "$glibc" | LC_ALL=C sort -V | head -1)" != "$glibc_min" ]]; then
        _warn_f "Serveur compilé : glibc %s sur cette machine — il exige glibc ≥ %s." "${glibc:-introuvable}" "$glibc_min"
        return 1
    fi
    for c in "$(command -v ldconfig 2>/dev/null || true)" /usr/sbin/ldconfig /sbin/ldconfig; do
        [[ -n "$c" && -x "$c" ]] && { ldc="$c"; break; }
    done
    libs=""
    [[ -n "$ldc" ]] && { libs=$("$ldc" -p 2>/dev/null) || libs=""; }
    if [[ "$libs" != *"libssl.so.3 "* ]]; then
        if [[ "$moment" == avant ]]; then
            vssl=""
            if command -v openssl >/dev/null 2>&1; then
                vssl=$(openssl version 2>/dev/null) || vssl=""
                vssl="${vssl#OpenSSL }"; vssl="${vssl%% *}"
            fi
            if [[ "$vssl" =~ ^[0-2]\. ]]; then
                _warn_f "Serveur compilé : OpenSSL %s sur cette machine — il exige OpenSSL 3." "$vssl"
                return 1
            fi
            _info "Serveur compilé : OpenSSL 3 pas encore présent — posé avec les dépendances, contrôle refait avant la pose du serveur."
            return 0
        fi
        _warn "Serveur compilé : libssl.so.3 (OpenSSL 3) introuvable sur cette machine."
        return 1
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_arbre_local
#   0 si CETTE installation porte l'arbre des sources d'Overtchat-Hybrid (un
#   clone du dépôt) : il se compile alors tel quel, sans rien demander au site.
#   Un seul test, pour `_install_overtchat` ET le prévol de `--auto` : deux
#   copies divergeraient, et l'un croirait à un clone que l'autre ne voit pas.
# -----------------------------------------------------------------------------
_overtchat_arbre_local() {
    [[ -f "${BASE_DIR}/Servers/overtchat-hybrid/Makefile" && -d "${BASE_DIR}/Servers/overtchat-hybrid/src" ]]
}

# -----------------------------------------------------------------------------
# _overtchat_binaire <srv> <dir>
#   Pose le serveur COMPILÉ (Tools/faire-binaire-hybrid.sh) : archive locale
#   `overtchat-hybrid-bin-*.tar.gz`, sinon celle que sert le site
#   (/dl/overtchat-hybrid-bin), SEULEMENT avec l'empreinte du manifeste.
#   Retour : 0 = installé · 1 = échec définitif, déjà rapporté (archive
#            absente, refusée, empreinte manquante) ·
#            2 = CETTE MACHINE ne peut pas le faire tourner (dit) — contrôle de
#                la machine, ou binaire qui ne s'annonce pas à l'essai.
#
# ⚠⚠ Décision de SerVuS, 28/09/2026 : le site sert un serveur compilé plutôt
#   que ses sources. ⚠⚠ Et depuis le 29/09 au soir (« retire les sources et
#   mets notre serveur par défaut ») : les sources ne sont PLUS servies — sans
#   clone, ce binaire est la SEULE voie. Il n'y a donc plus de « repli sur les
#   sources » : le code 2 dit « pas sur cette machine », et c'est l'appelant
#   qui décide (refus, ou `--auto` qui se rabat sur UnrealIRCd en le disant).
# ⚠ Déballé dans un dossier d'ESSAI, puis recopié une fois éprouvé : un binaire
#   qui ne démarre pas ne laisse rien dans <dir>.
# -----------------------------------------------------------------------------
_overtchat_binaire() {
    local srv="$1" dir="$2" rc
    local essai="${dir}/.binaire-essai"

    _overtchat_bin_compatible || return 2

    # Les dépendances d'EXÉCUTION seules : pas de gcc pour un binaire.
    # shellcheck disable=SC2046
    _pkg_install $(_req_for overtchat_ircd_bin) || { _install_fail "$srv" deps; return 1; }

    # 🔴 Même règle que les sources : sans empreinte valide au MANIFESTE, AUCUNE
    #   adresse n'est formée — donc aucun téléchargement. L'adresse vient du site
    #   d'où vient CET installateur (`_brutegit`), jamais d'une adresse en dur.
    local url_bin="" sha_bin="" base_bin mf_bin
    base_bin=$(_brutegit)
    if [[ -n "$base_bin" ]] && mf_bin=$(_manifeste_fichier); then
        sha_bin=$(jq -r '.fichiers["overtchat-hybrid-bin"].sha256 // empty' "$mf_bin" 2>/dev/null)
        if [[ "$sha_bin" =~ ^[0-9a-f]{64}$ ]]; then url_bin="${base_bin}/overtchat-hybrid-bin"; else sha_bin=""; fi
    fi

    rm -rf "$essai"
    # ⚠ Motif PROPRE au binaire (`overtchat-hybrid-bin-*`) : une archive de
    #   SOURCES déposée à la main (`overtchat-hybrid-<version>.tar.gz`, l'ancien
    #   format de Tools/faire-archive-hybrid.sh) ne doit jamais être prise
    #   pour un serveur compilé — `_find_local_archive` rendrait la plus récente.
    rc=0
    _fetch_sources "$url_bin" "$essai" "overtchat-hybrid-bin-*.tar.gz" "$srv" "$sha_bin" || rc=$?
    if (( rc != 0 )); then
        rm -rf "$essai"
        _warn "Serveur compilé indisponible ou refusé (ni archive locale, ni archive servie avec l'empreinte du manifeste)."
        _info "Les sources d'Overtchat-Hybrid ne sont plus servies : sans clone du dépôt, ce serveur ne vient que du binaire servi par le site (/dl/overtchat-hybrid-bin)."
        _install_fail "$srv" binaire
        return 1
    fi
    rm -f "${essai}/src.tar.gz"

    # L'ESSAI : le binaire doit démarrer ici et annoncer la version de son
    # archive. Un binaire qui ne se lance pas (bibliothèque absente malgré les
    # contrôles) ne doit pas être déclaré installé.
    local ver out=""
    ver=$(head -1 "${essai}/VERSION" 2>/dev/null | tr -d '[:space:]') || ver=""
    if [[ -x "${essai}/bin/overtchatd" && "$ver" =~ ^[0-9][0-9A-Za-z.+-]{0,31}$ ]]; then
        out=$(timeout 10 "${essai}/bin/overtchatd" -v 2>&1) || true
    fi
    if [[ -z "$ver" || "$out" != *"Overtchat-Hybrid ${ver}"* ]]; then
        _warn_f "Le serveur compilé ne s'est pas annoncé (attendu : Overtchat-Hybrid %s) — il ne démarre pas sur cette machine." "${ver:-?}"
        rm -rf "$essai"
        return 2
    fi
    # ⚠ La première ligne dans une variable, pas dans l'appel : le banc de
    #   traduction compte les arguments d'un gabarit, et un `printf '%s'`
    #   imbriqué s'y lisait comme un second (mesuré le 29/09).
    local annonce="${out%%$'\n'*}"
    _ok_f "Serveur compilé éprouvé : %s" "$annonce"

    mkdir -p "$dir"
    cp -a "${essai}/." "${dir}/" || { rm -rf "$essai"; _install_fail "$srv"; return 1; }
    rm -rf "$essai"
    _srv_finalize "$srv" "$dir" "bin/overtchatd" || return 1
    return 0
}

# -----------------------------------------------------------------------------
# _conf_sans_commentaires <fichier>
#   Le texte d'une conf d'Overtchat-Hybrid privé de ses trois formes de
#   commentaire (`/* … */` sur plusieurs lignes, `#`, `//`) — pour MESURER ce
#   qui est réellement actif. Grossier sur un `//` pris dans une chaîne : il
#   retire alors trop, jamais trop peu, ce qui convient à une vérification.
# -----------------------------------------------------------------------------
_conf_sans_commentaires() {
    awk '{
        l = $0; out = ""
        while (length(l)) {
            if (inc) { i = index(l, "*/"); if (!i) { l = ""; break }; l = substr(l, i + 2); inc = 0 }
            else     { i = index(l, "/*"); if (!i) { out = out l; l = ""; break }
                       out = out substr(l, 1, i - 1); l = substr(l, i + 2); inc = 1 }
        }
        sub(/(#|\/\/).*/, "", out); print out
    }' "$1"
}

# -----------------------------------------------------------------------------
# _overtchat_personnaliser <conf>
#   Donne à une conf FRAÎCHEMENT posée depuis le modèle l'identité de CETTE
#   installation (Config/identity.json, écrit par le site à l'étape 2).
#
# ⚠⚠ LICENSE §2b : l'identité du réseau d'origine est RÉSERVÉE — un tiers ne se
#   présente pas sous notre nom. Or le modèle porte la nôtre : mesuré le
#   29/09/2026 sur une installation tierce, le serveur répondait
#   « :hybrid.overtchat.net 001 … Welcome to the Overtchat IRC Network ».
# ⚠ Chaque remplacement vise la valeur EXACTE du modèle (nom de directive ET
#   valeur d'origine) : une ligne déjà changée n'est jamais touchée, et un
#   modèle qui aurait bougé se voit au compte (12 attendus), qui se DIT.
# ⚠ Les trois clés de masquage du modèle sont PUBLIQUES (« changez-cette-… ») :
#   tirées au hasard ici, jamais affichées. Elles doivent être les mêmes sur
#   tous les serveurs d'un réseau — un tiers qui en ajoute un les recopie.
# ⚠ Le nom du lien des services (`services.overtchat.net`) attend le pseudo du
#   robot, connu seulement à l'étape 5 : voir `_overtchat_lien_du_bot`.
# -----------------------------------------------------------------------------
_overtchat_personnaliser() {
    local f="$1" ident="${BASE_DIR}/Config/identity.json" nom court domaine contact tmp n=0 reste
    if [[ ! -r "$ident" ]]; then
        _warn "Config/identity.json absent : la conf du serveur garde l'identité du MODÈLE — à changer avant toute ouverture."
        return 0
    fi
    nom=$(jq -r '.name // empty' "$ident" 2>/dev/null) || nom=""
    court=$(jq -r '.short // empty' "$ident" 2>/dev/null) || court=""
    domaine=$(jq -r '.domain // empty' "$ident" 2>/dev/null) || domaine=""
    contact=$(jq -r '.mail.to // empty' "$ident" 2>/dev/null) || contact=""
    # Une valeur entre guillemets dans la conf : ni guillemet ni barre oblique
    # inverse. NETWORK est un jeton (ISUPPORT 005) : pas d'espace non plus.
    nom="${nom//[\"\\]/}"; court="${court//[\"\\ ]/}"
    if [[ -z "$nom" || -z "$court" || ! "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]]; then
        _warn "Identité incomplète dans Config/identity.json : la conf du serveur garde celle du MODÈLE."
        return 0
    fi
    [[ "$contact" =~ ^[^[:space:]\"\\]+@[^[:space:]\"\\]+$ ]] || contact="contact@${domaine}"
    tmp="$(mktemp)" || return 0
    # Les valeurs passent par l'ENVIRONNEMENT d'awk, jamais par son texte ni par
    # ses arguments : aucun échappement à tenir, et les clés ne sont pas dans argv.
    if ! NOM="$nom" COURT="$court" DOM="$domaine" MEL="$contact" \
         K1="$(openssl rand -hex 24)" K2="$(openssl rand -hex 24)" K3="$(openssl rand -hex 24)" \
         awk -v compte="${tmp}.n" '
        function val(v,   i) { i = index($0, "\""); $0 = substr($0, 1, i - 1) "\"" v "\";"; n++ }
        /^[[:space:]]*name[[:space:]]+"hybrid\.overtchat\.net";/        { val(ENVIRON["DOM"]) }
        /^[[:space:]]*info[[:space:]]+"Overtchat-Hybrid";/              { val(ENVIRON["NOM"]) }
        /^[[:space:]]*line1[[:space:]]+"Overtchat";/                    { val(ENVIRON["NOM"]) }
        /^[[:space:]]*email[[:space:]]+"admin@overtchat\.net";/         { val(ENVIRON["MEL"]) }
        /^[[:space:]]*NETWORK[[:space:]]+"Overtchat";/                  { val(ENVIRON["COURT"]) }
        /^[[:space:]]*NETWORK_DESC[[:space:]]+"Réseau Overtchat";/      { val(ENVIRON["NOM"]) }
        /^[[:space:]]*cloak-prefix[[:space:]]+"Overtchat";/             { val(ENVIRON["COURT"]) }
        /^[[:space:]]*"changez-cette-premiere-cle-par-du-hasard";/      { val(ENVIRON["K1"]) }
        /^[[:space:]]*"changez-cette-deuxieme-cle-par-du-hasard";/      { val(ENVIRON["K2"]) }
        /^[[:space:]]*"changez-cette-troisieme-cle-par-du-hasard";/     { val(ENVIRON["K3"]) }
        /^[[:space:]]*HIDDEN_HOST_SUFFIX[[:space:]]+"users\.overtchat\.net";/ { val("users." ENVIRON["DOM"]) }
        /^[[:space:]]*websocket-origins[[:space:]]+"chat\.exemple\.fr";/ { val(ENVIRON["DOM"]) }
        { print }
        END { print n > compte }' "$f" > "$tmp"; then
        rm -f "$tmp" "${tmp}.n"
        _warn "Identité du réseau NON écrite dans la conf du serveur (awk a échoué)."
        return 0
    fi
    n=$(cat "${tmp}.n" 2>/dev/null) || n=0
    cat "$tmp" > "$f"
    rm -f "$tmp" "${tmp}.n"
    # Ce qui porterait ENCORE notre nom HORS commentaire. ⚠ Les blocs `/* … */`
    #   du modèle contiennent des lignes sans astérisque (le bloc oper
    #   « moderateur », le second lien) : un filtre ligne à ligne les comptait.
    #   Le lien des services est renommé plus tard (`_overtchat_lien_du_bot`).
    reste=$(_conf_sans_commentaires "$f" | grep -v 'services\.overtchat\.net' \
            | grep -ciE 'overtchat\.net|"Overtchat"') || reste=0
    if [[ "$n" == 12 && "$reste" == 0 ]]; then
        _ok_f "Identité du réseau écrite dans la conf du serveur : %s <%s> (clés de masquage tirées au hasard)." "$nom" "$domaine"
    else
        _warn_f "Identité du réseau : %s remplacement(s) sur 12, %s ligne(s) portent encore l'identité d'origine — à relire." "$n" "$reste"
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_certificat <dossier conf>
#   Un certificat AUTO-SIGNÉ propre à CETTE installation (jamais un fichier de
#   la production), puis les deux directives TLS du modèle décommentées.
#
# ⚠ Mesuré le 29/09/2026 sur une installation tierce : « listen *:6697 demande
#   TLS, mais aucun certificat n'est chargé — port non ouvert ». Le modèle
#   laisse `tls-certificate`/`tls-key` en commentaire, et rien ne posait de
#   certificat : le port chiffré n'ouvrait chez aucun tiers.
# ⚠ Auto-signé veut dire : chiffré, mais non reconnu par les clients (qui
#   avertiront). L'installateur n'a AUCUN chemin Let's Encrypt/certbot (vérifié
#   le 29/09 : zéro occurrence) ; pour le remplacer, déposer le vôtre sous les
#   mêmes noms (conf/server.pem, conf/server.key) puis recharger le serveur. Un
#   certificat déjà présent n'est JAMAIS remplacé.
# ⚠ La réécriture vise la forme EXACTE du modèle — `/* TLS.` … les deux
#   directives … `*/` — et ne s'écrit que si les DEUX gestes ont eu lieu :
#   à moitié faite, elle laisserait un commentaire ouvert, donc une conf morte.
# -----------------------------------------------------------------------------
_overtchat_certificat() {
    local c="$1" f="$1/overtchat.conf" domaine tmp n
    domaine=$(jq -r '.domain // empty' "${BASE_DIR}/Config/identity.json" 2>/dev/null) || domaine=""
    [[ "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || domaine="localhost"
    if [[ -e "${c}/server.pem" || -e "${c}/server.key" ]]; then
        _info "Certificat du serveur existant conservé (conf/server.pem)."
    elif ! command -v openssl >/dev/null 2>&1; then
        _warn "openssl absent : aucun certificat, le port TLS (6697) restera fermé."
        return 0
    elif ( umask 077; openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
             -subj "/CN=${domaine}" -addext "subjectAltName=DNS:${domaine}" \
             -keyout "${c}/server.key" -out "${c}/server.pem" ) >/dev/null 2>&1; then
        chmod 600 "${c}/server.key"; chmod 644 "${c}/server.pem"
        _ok_f "Certificat AUTO-SIGNÉ généré pour %s (conf/server.pem, 825 jours) — remplaçable par le vôtre, mêmes noms." "$domaine"
    else
        _warn "Certificat NON généré : le port TLS (6697) restera fermé."
        return 0
    fi
    tmp="$(mktemp)" || return 0
    awk -v compte="${tmp}.n" '
        /\/\* TLS\./ { dans = 1 }
        dans && /^[[:space:]]*tls-certificate[[:space:]]+"conf\/server\.pem";/ { print "     */"; n++ }
        dans && /^[[:space:]]*tls-key[[:space:]]+"conf\/server\.key";/ { print; attente = 1; next }
        attente && /^[[:space:]]*\*\/[[:space:]]*$/ { attente = 0; dans = 0; n++; next }
        { attente = 0; print }
        END { print n + 0 > compte }' "$f" > "$tmp"
    n=$(cat "${tmp}.n" 2>/dev/null) || n=0
    if [[ "$n" == 2 ]]; then
        cat "$tmp" > "$f"
        _ok "Directives TLS activées dans la conf (tls-certificate, tls-key)."
    else
        _warn "Directives TLS NON activées (forme du modèle inattendue) : décommentez tls-certificate et tls-key à la main."
    fi
    rm -f "$tmp" "${tmp}.n"
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_lien_du_bot <conf> <IriX.json>
#   Nomme le lien des services d'après le ROBOT : `link <botnick>.<domaine>`,
#   et la même entrée dans `ulines {}`. 0 = prêt (déjà nommé ou renommé),
#   1 = rien fait (dit).
#
# ⚠ `Tools/uline-bootstrap.sh` cherche le bloc `link <botnick>.…` : le modèle
#   porte `services.overtchat.net`, qui ne s'appelle pas ainsi — l'amorçage ne
#   pouvait rien extraire (« bloc link introuvable »). Le pseudo du robot n'est
#   connu qu'à l'étape 5, d'où ce geste à part, fait à l'étape 6.
# ⚠ Seul le nom d'ORIGINE du modèle est renommé : une conf dont l'exploitant a
#   nommé le lien n'est pas touchée.
# -----------------------------------------------------------------------------
_overtchat_lien_du_bot() {
    local f="$1" ij="$2" bn domaine nouveau tmp n
    bn=$(jq -r '.botnick // empty' "$ij" 2>/dev/null) || bn=""
    domaine=$(jq -r '.domain // empty' "${BASE_DIR}/Config/identity.json" 2>/dev/null) || domaine=""
    [[ "$bn" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || { _warn "Pseudo du robot illisible dans IriX.json : lien des services non nommé."; return 1; }
    [[ "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || domaine="local"
    if grep -qiE "^[[:space:]]*link[[:space:]]+${bn}\." "$f"; then
        return 0
    fi
    nouveau="${bn}.${domaine}"
    tmp="$(mktemp)" || return 1
    NOUV="$nouveau" awk -v compte="${tmp}.n" '
        /^[[:space:]]*link[[:space:]]+services\.overtchat\.net[[:space:]]*\{/ { sub(/services\.overtchat\.net/, ENVIRON["NOUV"]); n++ }
        /^[[:space:]]*services\.overtchat\.net;/ { sub(/services\.overtchat\.net/, ENVIRON["NOUV"]); n++ }
        { print }
        END { print n + 0 > compte }' "$f" > "$tmp"
    n=$(cat "${tmp}.n" 2>/dev/null) || n=0
    if [[ "$n" == 2 ]]; then
        cat "$tmp" > "$f"
        rm -f "$tmp" "${tmp}.n"
        _ok_f "Lien des services nommé d'après le robot : %s (bloc link et ulines)." "$nouveau"
        return 0
    fi
    rm -f "$tmp" "${tmp}.n"
    _warn_f "Lien des services NON nommé : aucun bloc « link %s.… » ni le lien du modèle (services.overtchat.net)." "$bn"
    return 1
}

# -----------------------------------------------------------------------------
# _overtchat_lien_overcore <conf>
#   Déclare le lien d'OVERCORE dans la conf du serveur : un bloc
#   `link OverCore.<domaine> { sid "004"; password …; uline }` et son nom dans
#   `ulines {}`. 0 = prêt (déjà déclaré, ou ajouté), 1 = rien fait (dit).
#
# 17/10/2026 (lot J2, décision ① de SerVuS : OverCore avec NOTRE serveur). Le
#   modèle `overtchat.conf.example` ne porte que le lien 002 (`services.…`) :
#   aucun bloc d'OverCore n'était écrit nulle part — la prod a le sien posé à
#   la main (`OverCore.overtchat.fr`, sid 004).
# ⚠⚠ LE SID EST « 004 », EXACTEMENT : le serveur refuse un pair dont le SID
#   annoncé diffère de celui du bloc (« SID does not match » — mesuré sur une
#   installation neuve, fiche install-neuve-amorcage-lien-uline). Un SID déjà
#   pris par un autre bloc : REFUS, rien d'écrit.
# 🔴 LE MOT DE PASSE est tiré au hasard ICI, jamais un défaut, jamais affiché,
#   jamais dans argv (awk le lit par ENVIRON). `password` (en clair) et non une
#   empreinte : `Tools/uline-bootstrap.sh --overcore` le LIT dans ce bloc pour
#   le poser en base, d'où OverCore l'envoie ; le bloc n'a pas d'« outgoing »,
#   comme celui du modèle (voir son en-tête, « Les TROIS écritures »).
# 🔴 ET LE LIEN 002 DU MODÈLE perd son mot de passe PUBLIC
#   (« CHANGEZ-CE-MOT-DE-PASSE », dans notre dépôt) : sur le chemin IriX,
#   `uline-secrets.sh` le tourne ; sans IriX, personne ne le ferait, et
#   n'importe quel compte de la machine pourrait se lier en ULine (masque
#   127.0.0.1) — SVSNICK, SVSKILL… Il reçoit lui aussi un secret tiré au hasard.
# -----------------------------------------------------------------------------
_overtchat_lien_overcore() {
    local f="$1" domaine nom mdp mdp2 tmp n
    if [[ ! -r "$f" || ! -w "$f" ]]; then
        _warn_f "Conf du serveur illisible : %s — lien d'OverCore NON déclaré." "$f"
        return 1
    fi
    if grep -qiE '^[[:space:]]*link[[:space:]]+OverCore\.' "$f"; then
        _info "Lien d'OverCore déjà déclaré dans la conf du serveur — conservé tel quel."
        return 0
    fi
    if grep -qE '^[[:space:]]*sid[[:space:]]+"004"[[:space:]]*;' "$f"; then
        _warn "Le SID 004 est déjà pris par un autre bloc de la conf du serveur : lien d'OverCore NON déclaré."
        return 1
    fi
    domaine=$(jq -r '.domain // empty' "${BASE_DIR}/Config/identity.json" 2>/dev/null) || domaine=""
    [[ "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || domaine="local"
    nom="OverCore.${domaine}"
    mdp="$(openssl rand -base64 32 2>/dev/null | tr -d '/+=\n' | cut -c1-28)" || mdp=""
    mdp2="$(openssl rand -base64 32 2>/dev/null | tr -d '/+=\n' | cut -c1-28)" || mdp2=""
    if [[ ! "$mdp" =~ ^[A-Za-z0-9]{20,}$ || ! "$mdp2" =~ ^[A-Za-z0-9]{20,}$ ]]; then
        _warn "Mot de passe du lien d'OverCore non tiré (openssl) : lien NON déclaré — aucun défaut n'est posé à sa place."
        return 1
    fi
    tmp="$(mktemp)" || return 1
    NOM="$nom" MDP="$mdp" MDP2="$mdp2" DATE_POSE="$(date '+%d/%m/%Y')" awk -v compte="${tmp}.n" '
        /^[[:space:]]*password[[:space:]]+"CHANGEZ-CE-MOT-DE-PASSE";/ {
            i = index($0, "\""); $0 = substr($0, 1, i - 1) "\"" ENVIRON["MDP2"] "\";"; d++ }
        /^ulines[[:space:]]*\{/ {
            u++
            print "/* OverCore — le cœur des services (Node, SID 004), déclaré par l'"'"'installateur le " ENVIRON["DATE_POSE"] "."
            print " * Mot de passe tiré au hasard, jamais affiché : le même vit en base (ligne « OverCore »"
            print " * de <robot>_ulines_sockets), d'"'"'où OverCore le lit et l'"'"'envoie. Pas d'"'"'« outgoing » :"
            print " * ce lien ne se connecte jamais lui-même, c'"'"'est OverCore qui vient. */"
            print "link " ENVIRON["NOM"] " {"
            print "    sid      \"004\";"
            print "    password \"" ENVIRON["MDP"] "\";"
            print "    class    servers;"
            print ""
            print "    incoming {"
            print "        mask 127.0.0.1;"
            print "    };"
            print ""
            print "    options { uline; };"
            print "};"
            print ""
            print
            print "    " ENVIRON["NOM"] ";"
            next
        }
        { print }
        END { print (u + 0) " " (d + 0) > compte }' "$f" > "$tmp"
    n=$(cat "${tmp}.n" 2>/dev/null) || n="0 0"
    rm -f "${tmp}.n"
    # Une seule entrée `ulines {` (sinon on ne sait pas où écrire), et le secret
    # présent UNE fois — compté par l'entrée standard, jamais par argv.
    if [[ "${n%% *}" != 1 || "$(printf '"%s"\n' "$mdp" | grep -c -F -f - "$tmp")" != 1 ]]; then
        rm -f "$tmp"
        _warn "Conf du serveur de forme inattendue (bloc « ulines » absent ou multiple) : lien d'OverCore NON déclaré."
        return 1
    fi
    cat "$tmp" > "$f"
    rm -f "$tmp"
    _ok_f "Lien d'OverCore déclaré : %s (SID 004, mot de passe tiré au hasard, jamais affiché)." "$nom"
    if [[ "${n##* }" != 0 ]]; then
        _ok "Le mot de passe PUBLIC du lien des services du modèle a été remplacé par un secret tiré au hasard."
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _overcore_configurer <IriX.json>
#   Écrit Robots/overcore/overcore.json depuis son modèle, pour une installation
#   SANS IriX (chemin OverCore de `--auto`). N'écrase JAMAIS une configuration
#   existante. 0 = prête, 1 = impossible (dit).
#
# 17/10/2026 (lot J2). Ce que le modèle laisse à corriger chez un tiers, LU dans
#   overcore.js (`lireConfig`) et lib/base.js :
#   · `irixJson` : relatif au dossier d'OverCore (`../../IriX/cmds/IriX.json`),
#     soit le dépôt de la prod — chez un tiers, l'instance vit ailleurs
#     (`_irix_install_dir`). Écrit en ABSOLU, contrôlé ;
#   · `secrets` : défaut `/home/servus/.overtchat/secrets.conf` (base.js) — le
#     foyer de la prod. Écrit en absolu vers CE foyer (absent : OverCore garde
#     le mot de passe d'IriX.json, comme IriX) ;
#   · `robot` : le pseudo du robot = le PRÉFIXE des tables, lu dans IriX.json ;
#   · `console.port` 0 : la console reste FERMÉE ;
#   · 🔴 LES DRAPEAUX DE CESSION à vrai. Chacun dit « faux : IriX fait » —
#     sans IriX, faux voudrait dire « PERSONNE ne fait » : un réseau neuf dont
#     OverNick répondrait « pas encore servi » à REGISTER et IDENTIFY. Règle :
#     tout booléen des sections-objets passe à vrai (gardes, nick, hub, chan…),
#     y compris ceux qu'ajoutera une cession future ; SAUF `console` (fermée),
#     `dnsbl` et `protection` (des modes en texte, livrés « eteint » : envoyer
#     l'adresse des visiteurs à un tiers est une décision de l'exploitant, après
#     sa charte) et `commandes.orientation` (la notice des commandes abandonnées
#     d'IriX : rien à orienter sur un réseau qui ne l'a jamais eu).
# -----------------------------------------------------------------------------
_overcore_configurer() {
    local ij="$1" oc="${BASE_DIR}/Robots/overcore" ex dest bn tmp sec
    ex="${oc}/overcore.json.exemple"; dest="${oc}/overcore.json"
    if [[ ! -r "${oc}/overcore.js" || ! -r "$ex" ]]; then
        _err_f "OverCore absent de cette installation (%s) : l'archive du site ne l'a pas apporté." "$oc"
        return 1
    fi
    if [[ "$ij" != /* || ! -r "$ij" ]]; then
        _err_f "Accès à la base introuvable (%s) : OverCore ne peut pas être configuré." "$ij"
        return 1
    fi
    mkdir -p "${oc}/logs" "${oc}/cache" "${BASE_DIR}/Runtime" || return 1
    if [[ -f "$dest" ]]; then
        _info_f "Configuration d'OverCore existante conservée : %s" "$dest"
        return 0
    fi
    bn=$(jq -r '.botnick // empty' "$ij" 2>/dev/null) || bn=""
    if [[ ! "$bn" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]]; then
        _err "Pseudo du robot illisible dans IriX.json : OverCore ne peut pas être configuré."
        return 1
    fi
    sec="${TARGET_HOME}/.overtchat/secrets.conf"
    tmp="$(mktemp)" || return 1
    if ! jq --arg ij "$ij" --arg sec "$sec" --arg bn "$bn" '
            .irixJson = $ij | .secrets = $sec | .robot = $bn
            | .console.port = 0
            | with_entries(
                if (((.key | IN("console", "dnsbl", "protection")) | not)
                    and ((.value | type) == "object"))
                then .value |= with_entries(if (.value | type) == "boolean" then .value = true else . end)
                else . end)
            | .commandes.orientation = false' "$ex" > "$tmp" 2>/dev/null \
       || ! jq -e --arg ij "$ij" '(.irixJson == $ij) and (.irixJson | startswith("/"))
                and (.secrets | startswith("/")) and (.console.port == 0)
                and (.gardes.secureops == true) and (.gardes.autoop == true)
                and (.nick.actions == true)' "$tmp" >/dev/null 2>&1; then
        rm -f "$tmp"
        _err "Configuration d'OverCore NON écrite (modèle illisible ou de forme inattendue)."
        return 1
    fi
    if ! install -m 600 "$tmp" "$dest"; then
        rm -f "$tmp"
        _err_f "Configuration d'OverCore NON écrite : %s" "$dest"
        return 1
    fi
    rm -f "$tmp"
    _ok_f "Configuration d'OverCore écrite : %s %s(600 ; accès base en absolu, services servis par OverCore)%s" "$dest" "${DIM}" "${RESET}"
    return 0
}

# -----------------------------------------------------------------------------
# _overcore_etat_lien <IriX.json>
#   Écrit `socket_state` de la ligne « OverCore » de <bot>_ulines_sockets (vide
#   si la ligne manque). 0 = la base a RÉPONDU, 1 = elle n'a pas répondu — c'est
#   le plancher de l'attente de `--auto` (« jamais répondu » ≠ « pas encore »).
#
# 17/10/2026 (lot J2). Les identifiants : ceux d'IriX.json, `dbpass=` de
#   secrets.conf passant devant (l'ordre de Tools/uline-bootstrap.sh et de
#   lib/base.js). 🔴 Le mot de passe va dans un fichier 600 (`mktemp`), lu par
#   `--defaults-extra-file`, jamais dans argv ; la requête par l'entrée standard.
# -----------------------------------------------------------------------------
_overcore_etat_lien() {
    local ij="$1" bn db u p s sec cnf sortie rc=0
    bn=$(jq -r '.botnick // empty' "$ij" 2>/dev/null) || return 1
    db=$(jq -r '.database // empty' "$ij" 2>/dev/null) || return 1
    [[ "$bn" =~ ^[A-Za-z][A-Za-z0-9_-]*$ && "$db" =~ ^[A-Za-z0-9_]+$ ]] || return 1
    u=$(jq -r '.login // empty' "$ij" 2>/dev/null)
    p=$(jq -r '.pass // empty' "$ij" 2>/dev/null)
    s=$(jq -r '.sock // empty' "$ij" 2>/dev/null)
    sec="${OVERTCHAT_SECRETS:-${TARGET_HOME}/.overtchat/secrets.conf}"
    if [[ -r "$sec" ]]; then
        local _d; _d=$(sed -n 's/^dbpass=//p' "$sec" 2>/dev/null | head -n 1)
        [[ -n "$_d" ]] && p="$_d"
    fi
    cnf="$(mktemp)" || return 1
    printf '[client]\nuser=%s\npassword=%s\n' "$u" "$p" > "$cnf"
    [[ -n "$s" && "$s" != null ]] && printf 'socket=%s\n' "$s" >> "$cnf"
    sortie=$(printf "SELECT socket_state FROM \`%s_ulines_sockets\` WHERE label='OverCore' LIMIT 1;\n" "$bn" \
             | mysql --defaults-extra-file="$cnf" -N "$db" 2>/dev/null) || rc=1
    rm -f "$cnf"
    (( rc == 0 )) || return 1
    printf '%s' "$sortie"
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_oper_admin <conf>
#   L'opérateur « admin » d'une conf FRAÎCHE reçoit un mot de passe tiré au
#   hasard, posé en EMPREINTE ; le mot de passe va au propriétaire dans
#   ~/.overtchat/oper-admin.txt (600). Jamais affiché, jamais dans argv.
#
# 🔴🔴 29/09/2026 : le modèle porte `password "CHANGEZ-MOI"` — tous privilèges
#   (`privileges { all; }`), masque *@127.0.0.1. Chez chaque tiers, n'importe
#   quel compte de la machine devenait donc IRCop avec un mot de passe publié
#   dans notre dépôt.
# ⚠ La FORME : `$sha256$<itérations>$<sel>$<empreinte>` — celle de
#   `passwd_hash()` (Servers/overtchat-hybrid/src/passwd.c) : état = SHA-256(sel
#   ‖ mot de passe), puis (itérations − 1) fois SHA-256(sel ‖ état brut), sel =
#   16 hexadécimaux. `conf_warn_plaintext_passwords()` (src/conf.c) signale
#   tout mot de passe d'oper en clair ; l'empreinte est la forme voulue.
# ⚠ Calculée par PHP (présent : le site l'exige), le mot de passe passant par
#   l'ENVIRONNEMENT. `overtchatd -H <mdp>` l'aurait mis dans argv, lisible par
#   tout compte de la machine le temps de l'appel.
# ⚠ Un sel de 16 CHIFFRES décimaux est refusé par `passwd_verify` (pris pour
#   un nombre d'itérations) : on en retire un autre.
# -----------------------------------------------------------------------------
OPER_ADMIN_FICHIER=""
_overtchat_oper_admin() {
    local f="$1" d="${TARGET_HOME}/.overtchat" fic mdp="" emp="" tmp n
    fic="${d}/oper-admin.txt"
    if ! command -v php >/dev/null 2>&1; then
        _warn "php absent : l'opérateur « admin » garde le mot de passe du MODÈLE — changez-le avant toute ouverture."
        return 0
    fi
    mdp="$(openssl rand -base64 32 | tr -d '/+=\n' | cut -c1-24)" || mdp=""
    [[ ${#mdp} -ge 20 ]] || { _warn "Mot de passe de l'opérateur « admin » non tiré — le modèle reste en place."; return 0; }
    emp="$(MDP="$mdp" php -r '
        $p = getenv("MDP"); $it = 100000;
        do { $sel = bin2hex(random_bytes(8)); } while (preg_match("/^[0-9]+$/", $sel));
        $e = hash("sha256", $sel . $p, true);
        for ($i = 1; $i < $it; $i++) { $e = hash("sha256", $sel . $e, true); }
        echo "\$sha256\$" . $it . "\$" . $sel . "\$" . bin2hex($e);
    ' 2>/dev/null)" || emp=""
    if [[ ! "$emp" =~ ^\$sha256\$100000\$[0-9a-f]{16}\$[0-9a-f]{64}$ ]]; then
        _warn "Empreinte de l'opérateur « admin » non calculée — le mot de passe du modèle reste en place."
        return 0
    fi
    tmp="$(mktemp)" || return 0
    EMP="$emp" awk -v compte="${tmp}.n" '
        /^[[:space:]]*oper[[:space:]]+admin[[:space:]]*\{/ { dans = 1 }
        dans && /^[[:space:]]*password[[:space:]]+"CHANGEZ-MOI";/ {
            i = index($0, "\""); $0 = substr($0, 1, i - 1) "\"" ENVIRON["EMP"] "\";"; n++ }
        dans && /^[[:space:]]*\};/ { dans = 0 }
        { print }
        END { print n + 0 > compte }' "$f" > "$tmp"
    n=$(cat "${tmp}.n" 2>/dev/null) || n=0
    if [[ "$n" != 1 ]]; then
        rm -f "$tmp" "${tmp}.n"
        _warn "Opérateur « admin » du modèle introuvable (password \"CHANGEZ-MOI\") : rien changé — relisez les blocs oper."
        return 0
    fi
    cat "$tmp" > "$f"
    rm -f "$tmp" "${tmp}.n"
    if ! ( umask 077; mkdir -p "$d" && printf '%s\n' \
            "# Opérateur IRC « admin » du serveur — tiré par l'installateur le $(date '+%d/%m/%Y %H:%M')." \
            "# Depuis CETTE machine seulement (masque *@127.0.0.1) : /OPER admin <mot de passe>" \
            "# Le serveur n'en garde que l'EMPREINTE (conf/overtchat.conf) : ce fichier est le seul" \
            "# endroit où il existe en clair. Pour le changer : overtchatd -H, puis recharger." \
            "motdepasse=${mdp}" > "$fic" ); then
        _warn_f "Le mot de passe de l'opérateur « admin » n'a pas pu être écrit dans %s." "$fic"
        return 0
    fi
    chmod 700 "$d"; chmod 600 "$fic"
    _priv_fix_owner "$d"
    OPER_ADMIN_FICHIER="$fic"
    _ok_f "Opérateur « admin » : mot de passe tiré au hasard, posé en empreinte — il est dans %s (600)." "$fic"
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_adresse_salons <salons-interdits.conf>
#   Le motif `*` du modèle renvoie vers la page de demande de salon du site
#   d'ORIGINE ; une liste fraîchement posée reçoit celle de CETTE installation.
# -----------------------------------------------------------------------------
_overtchat_adresse_salons() {
    local f="$1" domaine
    domaine=$(jq -r '.domain // empty' "${BASE_DIR}/Config/identity.json" 2>/dev/null) || domaine=""
    [[ "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || return 0
    sed -i "s#https://service-overtchat\.hd\.free\.fr/#https://${domaine}/#g" "$f"
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_chemin_regles <overtchat.conf> <dir>
#   Le fichier que le serveur sert à /RULES : la directive `rules "…"` de sa
#   conf (relative à <dir>, comme tous ses chemins), sinon `conf/charte.txt`
#   — la valeur du modèle, relue le 02/10/2026.
# -----------------------------------------------------------------------------
_overtchat_chemin_regles() {
    local f="$1" dir="$2" r=""
    if [[ -r "$f" ]]; then
        r=$(sed -n 's/^[[:space:]]*rules[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | head -n 1) || r=""
    fi
    [[ -n "$r" ]] || r="conf/charte.txt"
    if [[ "$r" == /* ]]; then printf '%s' "$r"; else printf '%s/%s' "$dir" "$r"; fi
}

# -----------------------------------------------------------------------------
# _overtchat_nom_dans_charte <charte>
#   La charte du modèle cite le serveur d'ORIGINE (`/server
#   service-overtchat.hd.free.fr 6697 …`, relu dans sa conf servie le
#   02/10/2026). Chez un tiers, le nom de son serveur est son domaine
#   (`_overtchat_personnaliser` le pose dans `me { name }`).
# -----------------------------------------------------------------------------
_overtchat_nom_dans_charte() {
    local f="$1" domaine
    domaine=$(jq -r '.domain // empty' "${BASE_DIR}/Config/identity.json" 2>/dev/null) || domaine=""
    [[ "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || return 0
    sed -i "s#service-overtchat\.hd\.free\.fr#${domaine}#g" "$f"
    return 0
}

# -----------------------------------------------------------------------------
# _overtchat_poser_conf <srv> <dir>
#   La configuration d'une installation NEUVE d'Overtchat-Hybrid, tirée des
#   modèles livrés avec le serveur (`conf/*.example`, dans l'archive compilée
#   comme dans les sources), puis l'unité systemd. N'écrase JAMAIS un fichier
#   existant. Toujours 0 : ce qui manque se DIT, rien n'interrompt la pose.
#
# ⚠⚠ Mesuré le 29/09/2026, première installation tierce COMPLÈTE avec notre
#   serveur (conteneur Ubuntu 24.04, `--auto`, OVERTCHAT_SERVEUR=overtchat_ircd,
#   installateur tiré du site) : « Overtchat-ircd installé (bin/overtchatd) »,
#   mais AUCUN `conf/overtchat.conf`, aucune liste de politique, aucune unité
#   `overtchatd.service` (`LoadState=not-found`, alors que huit unités posées
#   la citent), `config: null` au registre. Le serveur ne pouvait pas démarrer,
#   et l'amorçage du lien de services cherchait sa conf dans ~/overtchatd — où
#   rien ne l'avait mise. Le seul à poser tout cela était
#   `Servers/overtchat-hybrid/contrib/install-service.sh`, que l'installateur
#   n'appelle pas et que l'archive compilée ne contient pas.
# ⚠ Les listes de politique ne sont pas optionnelles : sans elles, `-t` rend
#   « FATAL Configuration inutilisable » (mesuré le même jour, 3 erreurs).
#   `pseudos-proteges.conf` EXIGE 600 (`nickprotect_load` refuse tout bit groupe
#   ou autre — mesuré : « lisible au-delà de son propriétaire (mode 0644) —
#   non chargé », puis FATAL) ; les autres 640, comme install-service.sh.
# ⚠ Le contrôle `-t` se lance DEPUIS <dir> : la conf ne porte que des chemins
#   relatifs (conf/…, logs/…), comme le rappelle install-service.sh.
# -----------------------------------------------------------------------------
_overtchat_poser_conf() {
    local srv="$1" dir="$2" c pol mode sortie="" unite tmp compte neuve=0
    c="${dir}/conf"

    if [[ ! -f "${c}/overtchat.conf.example" ]]; then
        _warn "Aucun modèle conf/overtchat.conf.example : configuration du serveur NON posée."
        return 0
    fi
    if [[ -f "${c}/overtchat.conf" ]]; then
        _info "Configuration du serveur existante conservée : ${c}/overtchat.conf"
    elif install -m 640 "${c}/overtchat.conf.example" "${c}/overtchat.conf"; then
        _ok "Configuration du serveur posée depuis le modèle : ${c}/overtchat.conf"
        neuve=1
    else
        _warn "Configuration du serveur NON posée (copie du modèle refusée)."
        return 0
    fi
    # ⚠ Identité et certificat SEULEMENT sur une conf qu'on vient de poser : une
    #   conf existante est celle de l'exploitant, on n'y écrit rien.
    if [[ "$neuve" == 1 ]]; then
        _overtchat_personnaliser "${c}/overtchat.conf"
        _overtchat_certificat "$c"
        _overtchat_oper_admin "${c}/overtchat.conf"
    fi
    local dest
    for pol in charte pseudos-interdits pseudos-proteges salons-interdits filtre-messages; do
        dest="${c}/${pol}.conf"
        # ⚠⚠ 02/10/2026 : la charte était posée en `conf/charte.conf`, quand
        #   la conf du serveur lit `rules "conf/charte.txt"` (modèle relu ce
        #   jour) : /RULES ne trouvait rien chez un tiers. Le chemin est LU
        #   dans la conf, pas supposé.
        if [[ "$pol" == charte ]]; then dest="$(_overtchat_chemin_regles "${c}/overtchat.conf" "$dir")"; fi
        [[ -f "${c}/${pol}.example" && ! -f "$dest" ]] || continue
        mode=640
        if [[ "$pol" == pseudos-proteges ]]; then mode=600; fi
        if install -m "$mode" "${c}/${pol}.example" "$dest"; then
            # ⚠ 29/09/2026 : le refus de création de salon renvoyait les
            #   visiteurs d'un tiers vers NOTRE site
            #   (« voir https://service-overtchat.hd.free.fr/?p=salon »).
            if [[ "$pol" == salons-interdits ]]; then _overtchat_adresse_salons "$dest"; fi
            # ⚠ 02/10/2026 : la charte donne l'exemple `/server <nom> 6697` —
            #   avec le nom de CE serveur, pas celui du réseau d'origine.
            if [[ "$pol" == charte ]]; then _overtchat_nom_dans_charte "$dest"; fi
        else
            _warn_f "Liste de politique NON posée : %s" "$dest"
        fi
    done
    if [[ -f "${c}/protection.conf.example" && ! -f "${c}/protection.conf" ]]; then
        # ⚠⚠ SANS LES NOMS DE NOS ROBOTS — 29/09/2026. Le modèle déclare IriX,
        #   OverOp-A, OverOp-B et OverBot : ceux de la production. Chez un tiers,
        #   le serveur avertissait « déclaré protégé, mais son pseudonyme n'est
        #   réservé nulle part — il n'accorde AUCUNE immunité » (4 sur 4, mesuré),
        #   et un nom déclaré NON réservé protège le NOM, pas le robot : qui le
        #   prend et se pose +B hérite de ses immunités. On garde les réglages
        #   (`services = …`, `robots = …`) et les commentaires ; les robots se
        #   déclarent au panel (« Protection ») une fois leur pseudo réservé.
        if ( umask 027; awk '/^[[:space:]]*$/ || /^[[:space:]]*#/ || /=/ { print }' \
                 "${c}/protection.conf.example" > "${c}/protection.conf" ); then
            chmod 640 "${c}/protection.conf"
        else
            _warn "Liste de politique NON posée : conf/protection.conf"
        fi
    fi
    # ⚠ Le dossier du JOURNAL — 29/09/2026. La conf écrit `logfile
    #   "logs/overtchatd.log"`, relatif à <dir>, et ni l'archive compilée ni
    #   `make install` ne créent `logs/` : mesuré sur l'installation tierce du
    #   29/09, le dossier manquait (le serveur journalisait quand même par
    #   systemd, mais son propre fichier n'existait pas). Au compte de
    #   l'installation, comme la conf : le serveur tourne sous ce compte.
    if mkdir -p "${dir}/logs" 2>/dev/null; then
        _priv_fix_owner "${dir}/logs"
    else
        _warn_f "Dossier du journal NON créé : %s/logs" "$dir"
    fi
    _priv_fix_owner "$c"
    _srv_set "$srv" '.servers[$s].config = $c' --arg c "${c}/overtchat.conf"

    if [[ -x "${dir}/bin/overtchatd" ]]; then
        if sortie=$(cd "$dir" && timeout 15 "${dir}/bin/overtchatd" -t -c "${c}/overtchat.conf" 2>&1); then
            _ok "Configuration du serveur valide (overtchatd -t)."
        else
            _warn "La configuration du serveur n'est pas utilisable en l'état (overtchatd -t) :"
            # ⚠ `|| true` : sous `pipefail`, un grep sans ligne rendrait 1, et
            #   `set -e` couperait l'installation sur un simple AVIS.
            printf '%s\n' "$sortie" | grep -E 'ERROR|FATAL' | head -8 | sed 's/^/      /' >&2 || true
        fi
    fi

    # L'unité : celle que livre le serveur (contrib/), ADAPTÉE comme le fait
    # install-service.sh — son préfixe `/home/servus/overtchatd` devient <dir>,
    # puis les substitutions communes (`_sysd_substituer`). Posée, pas activée :
    # même doctrine que `_sysd_activer` (« installé » n'est pas « en service »).
    unite="${dir}/contrib/overtchatd.service"
    if [[ ! -r "$unite" ]]; then
        _warn "Aucune unité contrib/overtchatd.service livrée : le serveur n'aura pas d'unité systemd."
    elif ! _priv_can_elevate; then
        _warn "L'unité overtchatd.service n'est PAS posée : il faut root ou sudo."
    else
        compte="$(_sysd_compte)" || { _sysd_compte_refus; _warn "L'unité overtchatd.service n'est PAS posée."; return 0; }
        tmp="$(mktemp)" || return 0
        sed -e "s#/home/servus/overtchatd#${dir}#g" "$unite" > "${tmp}.pre"
        _sysd_substituer "${tmp}.pre" "$compte" > "$tmp"
        if _as_root install -m 644 "$tmp" /etc/systemd/system/overtchatd.service 2>/dev/null; then
            _as_root systemctl daemon-reload 2>/dev/null || true
            _ok "Unité posée : /etc/systemd/system/overtchatd.service (non activée)."
        else
            _warn "Pose refusée : /etc/systemd/system/overtchatd.service"
        fi
        rm -f "$tmp" "${tmp}.pre"
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _install_overtchat
#   Deux voies, et deux seulement (29/09/2026, décision de SerVuS : « retire
#   les sources ») :
#     · un CLONE du dépôt (l'arbre Servers/overtchat-hybrid est là) → il est
#       compilé tel quel ;
#     · sinon le serveur COMPILÉ (archive locale `overtchat-hybrid-bin-*`, puis
#       celle du site, sous l'empreinte du manifeste).
#   Retour : 0 installé · 1 échec (dit) · 2 CETTE MACHINE ne peut pas faire
#   tourner le serveur compilé (dit) — `_auto_installer` s'en sert pour se
#   rabattre sur UnrealIRCd quand le serveur n'a pas été demandé explicitement.
#
# ⚠⚠ Il n'y a plus de TROISIÈME voie. Jusqu'au 29/09/2026 au soir, le site
#   servait aussi l'archive de SOURCES (`/dl/overtchat-hybrid`,
#   Tools/faire-archive-hybrid.sh), compilée ici quand le binaire ne
#   convenait pas. Elle est retirée : le binaire a été posé, configuré et
#   démarré par le script seul sur une machine neuve (7 passages, CHANTIERS
#   du 29/09). Une archive de sources déposée à la main n'est plus lue.
# -----------------------------------------------------------------------------
_install_overtchat() {
    local srv="overtchat_ircd" dir rc
    dir=$(_srv_get "$srv" "install_dir")

    # Les sources sont LIVRÉES AVEC LE PROJET, dans Servers/overtchat-hybrid.
    # C'est le serveur qui tourne en production depuis le 28/07/2026 : aller le
    # chercher au dehors serait aussi faux qu'inutile, et le source_url qui
    # traînait ici pointait vers un compte GitHub abandonné — un 404 constaté
    # le 30/07/2026, donc une installation qui échouait sans qu'on sache
    # qu'elle ne pouvait pas réussir.
    local locales="$BASE_DIR/Servers/overtchat-hybrid"

    # ── Sans clone : le serveur COMPILÉ, et lui seul ─────────────────────────
    # ⚠ Un clone porte les sources exactes de son commit, et les compiler reste
    #   la vérité de ce clone : le binaire n'est demandé que SANS l'arbre.
    if ! _overtchat_arbre_local; then
        rc=0
        _overtchat_binaire "$srv" "$dir" || rc=$?
        if (( rc == 0 )); then _overtchat_poser_conf "$srv" "$dir"; return 0; fi
        if (( rc == 2 )); then
            _warn "Overtchat-Hybrid ne peut pas être installé sur cette machine : sans clone du dépôt, il ne vient que du serveur compilé (x86_64, glibc ≥ 2.34, OpenSSL 3)."
            _info "Choisissez un autre serveur IRC (UnrealIRCd), ou installez depuis un clone du dépôt, qui compile ses propres sources."
            _install_fail "$srv" machine
            return 2
        fi
        return 1
    fi

    # L'ancienne version clonait https://github.com/SerVuS033/Overtchat — le
    # dépôt du PROJET, pas un serveur IRC — puis déclarait binary="overtchat"
    # sans rien compiler. Le binaire n'existait donc jamais.
    # shellcheck disable=SC2046
    _pkg_install $(_req_for "$srv") || { _install_fail "$srv" deps; return 1; }

    # ── Un clone : l'arbre du dépôt, compilé ici ────────────────────────────
    # ⚠ Plus de branche « archive de sources » (locale ou servie) depuis le
    #   29/09/2026 au soir : voir l'en-tête. Arrivé ici, l'arbre est là.
    _ok "Sources d'Overtchat-Hybrid trouvées dans le projet."
    mkdir -p "$dir"
    # On COPIE plutôt que de compiler sur place : l'arbre du dépôt reste
    # propre, et un `make clean` malheureux ne touche pas les sources
    # suivies par git.
    tar -C "$locales" -cf - --exclude=build --exclude=build-asan . \
        | tar -C "$dir" -xf - || { _install_fail "$srv"; return 1; }

    # Chaîne de build inconnue à l'avance : on tente configure/make, puis un
    # make simple si le projet n'a pas de configure.
    #
    # L'échec de `make install` n'est plus avalé par un « || true ». Ce garde
    # existait pour une raison réelle — un projet sans ./configure peut n'avoir
    # aucune cible « install », et refuser l'installation dans ce cas serait
    # faux. Mais le « || true » ne distinguait pas ce cas légitime d'un échec
    # véritable (mauvais PREFIX, cible cassée, droits) : les deux passaient en
    # silence, et _srv_finalize posait ensuite installed=true en se contentant
    # d'un binaire trouvé dans l'ARBRE DE COMPILATION. On demande donc à make
    # si la cible existe, avant de juger son résultat.
    (
        cd "$dir" || exit 1
        if [[ -x ./configure ]]; then
            ./configure --prefix="$dir" && make -j"$(_make_jobs)" && make install
        elif [[ -f ./Makefile ]]; then
            make -j"$(_make_jobs)" || exit 1
            if make -n install >/dev/null 2>&1; then
                make install PREFIX="$dir" || {
                    echo "make install a échoué (cible présente mais en erreur)." >&2
                    exit 1
                }
            else
                echo "Ce projet n'a pas de cible « install » : le binaire est pris sur place." >&2
            fi
        else
            echo "Aucun configure ni Makefile à la racine des sources." >&2
            exit 1
        fi
    ) || { _install_fail "$srv"; return 1; }

    # `bin/overtchatd` d'abord : c'est ce que pose le `make install` du Makefile
    # d'Overtchat-Hybrid (cible `install`, PREFIX/bin/overtchatd). `build/overtchatd`
    # est l'arbre de compilation, le repli quand `make install` n'a pas tourné.
    # Les deux manquaient à cette liste, qui ne connaissait que des noms hérités :
    # la compilation réussissait et l'installation se déclarait quand même en échec.
    _srv_finalize "$srv" "$dir" "bin/overtchatd" "build/overtchatd" \
                                "bin/overtchat" "overtchat" "src/ircd" "bin/ircd" || return 1
    # La conf et l'unité, sur ce chemin-ci aussi : les sources portent les
    # mêmes modèles (conf/*.example, contrib/overtchatd.service).
    _overtchat_poser_conf "$srv" "$dir"
}

# -----------------------------------------------------------------------------
# _server_install <serveur>
#   Point d'entrée générique d'installation.
# -----------------------------------------------------------------------------
_server_install() {
    local srv="$1"
    local dir

    dir=$(_srv_get "$srv" "install_dir")

    _section "Installation : $(_srv_get "$srv" "name")"

    # Même garde que côté services : un install_dir nul ou relatif ferait
    # compiler dans le répertoire courant. Ici plutôt que dans les quatre
    # _install_*, qui passent tous par ce point d'entrée.
    _dir_exploitable "$(_srv_get "$srv" "name")" "$dir" || return 1

    # Idempotence
    if [[ -f "${dir}/.installed" ]]; then
        _info "Déjà installé → vérification du binaire…"
        if _server_verify "$srv"; then
            _ok "Installation existante valide."
            return 0
        fi
        _warn "Installation corrompue → réinstallation."
        _safe_rmrf "$dir"
    fi

    case "$srv" in
        unrealircd)    _install_unrealircd ;;
        hybrid)        _install_hybrid     ;;
        ircu)          _install_ircu       ;;
        inspircd)      _install_inspircd   ;;
        bahamut)       _install_bahamut    ;;
        overtchat_ircd) _install_overtchat ;;
        *)             _err "Serveur inconnu : $srv"; return 1 ;;
    esac
}

# =============================================================================
# Mise à jour
# =============================================================================

# -----------------------------------------------------------------------------
# _server_update <serveur>
# -----------------------------------------------------------------------------
_server_update() {
    local srv="$1"
    local dir backup

    dir=$(_srv_get "$srv" "install_dir")
    backup="${dir}_backup_$(date +%s)"

    _section "Mise à jour : $(_srv_get "$srv" "name")"

    if [[ ! -d "$dir" ]]; then
        _warn "Non installé → lancement de l'installation."
        _server_install "$srv"
        return
    fi

    # La sauvegarde est VÉRIFIÉE avant que quoi que ce soit soit détruit.
    #
    # `cp -a` n'était pas testé, et la suite détruit l'installation par un
    # _safe_rmrf avant de réinstaller. Disque plein, permission refusée sur un
    # arbre compilé, chemin devenu illisible : la copie échouait en silence,
    # le dossier était supprimé, la réinstallation échouait à son tour (elle a
    # besoin du réseau), et le `mv "$backup" "$dir"` de secours ne trouvait
    # rien à restaurer. Une mise à jour ratée effaçait donc l'installation ET
    # sa sauvegarde.
    _info_f "Sauvegarde vers %s…" "${backup}"
    if ! cp -a "$dir" "$backup"; then
        _err "Sauvegarde impossible — mise à jour ANNULÉE, rien n'a été modifié."
        _info "Vérifie l'espace disque et les droits sur $(dirname "$backup")."
        _safe_rmrf "$backup"
        return 1
    fi
    if [[ ! -d "$backup" ]]; then
        _err "Sauvegarde introuvable après copie — mise à jour ANNULÉE."
        return 1
    fi
    _ok_f "Sauvegarde en place : %s" "$backup"

    # Restaure la sauvegarde et le dit franchement si la restauration échoue :
    # laisser croire au retour à l'état antérieur serait pire que la panne.
    _server_update_restaure() {
        _safe_rmrf "$dir"
        if mv "$backup" "$dir"; then
            _ok "Installation précédente restaurée."
        else
            _err "RESTAURATION ÉCHOUÉE — la sauvegarde est conservée dans :"
            printf "    ${BOLD}%s${RESET}\n" "$backup"
            _info_f "Remets-la en place à la main : mv '%s' '%s'" "$backup" "$dir"
        fi
    }

    if [[ -d "${dir}/.git" ]]; then
        if ! (cd "$dir" && git pull); then
            _err "git pull échoué → restauration de la sauvegarde."
            _server_update_restaure
            unset -f _server_update_restaure
            return 1
        fi
    else
        _warn "Pas de dépôt git → réinstallation complète."
        _safe_rmrf "$dir"
        if ! _server_install "$srv"; then
            _err "Réinstallation échouée → restauration de la sauvegarde."
            _server_update_restaure
            unset -f _server_update_restaure
            return 1
        fi
    fi

    unset -f _server_update_restaure
    _safe_rmrf "$backup"
    _ok "Mise à jour terminée."
}

# =============================================================================
# Interface TUI
# =============================================================================

# -----------------------------------------------------------------------------
# _panel_server_detail <serveur>
#   Sous-menu de gestion d'un serveur spécifique.
# -----------------------------------------------------------------------------
_panel_server_detail() {
    local srv="$1"
    local name choice

    while true; do
        name=$(_srv_get "$srv" "name")

        # ⚠ Même reprise que `_panel_service_detail` — voir le commentaire qui
        # y est écrit. `_servers_overview` émet elle aussi des `_ui_row`, et
        # jusqu'ici un `_ui_mid` au milieu de nulle part, faute de cadre ouvert.
        _ui_width
        _ui_title "Serveur : ${name}" "état, ports, actions et journaux"
        _ui_top
        _servers_overview
        _ui_mid
        _ui_kv "Installé"   "$(_srv_get "$srv" "installed")"
        _ui_kv "Activé"     "$(_srv_get "$srv" "enabled")"
        _ui_kv "En cours"   "$(_server_status "$srv")"
        _ui_kv "Port IRC"   "$(_srv_get "$srv" "port")"
        _ui_kv "Port SSL"   "$(
            local v; v=$(_srv_get "$srv" "port_ssl")
            [[ "$v" == "null" ]] && echo "N/A" || echo "$v"
        )"
        _ui_kv "SHA-256 OK" "$(_srv_get "$srv" "sha256.verified")"
        _ui_kv "Erreurs"    "$(jq -r --arg s "$srv" '.servers[$s].errors | length' "$(_srv_file)")"

        _ui_head "Actions"
        _ui_item 1  "Installer"
        _ui_item 2  "Activer / Désactiver"
        _ui_item 3  "Activer / Désactiver les logs"
        _ui_item 4  "Vérifier SHA-256"
        _ui_item 5  "Voir les erreurs"
        _ui_item 6  "Effacer les erreurs"
        _ui_item 7  "Configurer les ports"
        _ui_item 8  "Démarrer"
        _ui_item 9  "Arrêter"
        _ui_item 10 "Redémarrer"
        _ui_item 11 "Logs en direct"
        _ui_item 12 "Mettre à jour"
        _ui_item 0  "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        case "$choice" in
            1) _server_install      "$srv" || true ;;
            2) _server_toggle       "$srv" || true ;;
            3) _server_log_toggle   "$srv" || true ;;
            4) _server_verify_sha256 "$srv" || true ;;
            5) _server_show_errors  "$srv" || true ;;
            6) _server_clear_errors "$srv" || true ;;
            7) _server_set_port     "$srv" || true ;;
            8) _server_start        "$srv" || true ;;
            9) _server_stop         "$srv" || true ;;
            10) _server_restart      "$srv" || true ;;
            11) _server_logs         "$srv" || true ;;
            12) _server_update       "$srv" || true ;;
            0)  return ;;
            *) _warn "Choix invalide." || true ;;
        esac
        _pause
    done
}

# -----------------------------------------------------------------------------
# _panel_servers
#   Menu principal du module serveurs.
# -----------------------------------------------------------------------------
_panel_servers() {
    local choice

    _servers_init
    _st_reconcile

    while true; do
        _ui_width
        _ui_title "Panel Serveurs IRC" "un seul serveur à la fois — ils se disputent les mêmes ports"

        _ui_top
        _ui_row "${BOLD}Serveurs disponibles${RESET}"
        _ui_mid
        _servers_overview

        _menu_reset
        _ui_head "Gérer"
        # ⚠ « recommandé » a changé de ligne le 29/09/2026 (décision de SerVuS :
        #   notre serveur par défaut, comme `--auto`). Les NUMÉROS ne bougent
        #   pas : une habitude, un tutoriel ou un banc peut s'y fier.
        _menu_gate 1 "UnrealIRCd      ${DIM}(serveur tiers — sources de son éditeur)${RESET}" "$(_menu_reason unrealircd)"
        _menu_gate 2 "ircd-Hybrid"     "$(_menu_reason hybrid)"
        _menu_gate 3 "ircu"            "$(_menu_reason ircu)"
        _menu_gate 4 "InspIRCd"        "$(_menu_reason inspircd)"
        _menu_gate 5 "Bahamut          ${DIM}(DALnet — sans UID ni SID)${RESET}" "$(_menu_reason bahamut)"
        _menu_gate 6 "Overtchat-ircd  ${DIM}(recommandé — le serveur du projet)${RESET}" "$(_menu_reason overtchat_ircd)"
        _menu_add  0 "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        _menu_has "$choice" || { _warn "Choix indisponible ou verrouillé."; continue; }
        case "$choice" in
            1) _panel_server_detail "unrealircd" || true ;;
            2) _panel_server_detail "hybrid" || true ;;
            3) _panel_server_detail "ircu" || true ;;
            4) _panel_server_detail "inspircd" || true ;;
            5) _panel_server_detail "bahamut" || true ;;
            6) _panel_server_detail "overtchat_ircd" || true ;;
            0) return ;;
        esac
    done
}

# =============================================================================
# ════════════════════════════════════════════════════════════
#  IRIX — Installation façon Eggdrop (source ≠ instance)
#  Source (géré par Service-Overtchat) : $HOME/Service-Overtchat/IriX
#  Instance (là où IriX tourne)        : $HOME/IriX (par défaut, modifiable)
# ════════════════════════════════════════════════════════════
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_file
# -----------------------------------------------------------------------------
_irix_file() { echo "$IRIX_FILE"; }

# -----------------------------------------------------------------------------
# _irix_resolve_entry <mode>
#   Retourne le fichier d'entrée (tclsh) associé à un mode moteur (sql|std),
#   d'après git.irix.modes dans la configuration principale.
# -----------------------------------------------------------------------------
_irix_resolve_entry() {
    local mode="$1"
    jq -r --arg m "$mode" '.git.irix.modes[$m].entry // empty' "$INIT_BASE" 2>/dev/null
}

# -----------------------------------------------------------------------------
# _irix_init
#   Crée le fichier JSON de configuration IriX si absent.
# -----------------------------------------------------------------------------
_irix_init() {
    [[ -f "$(_irix_file)" ]] && return 0

    mkdir -p "$(dirname "$(_irix_file)")"
    mkdir -p "$IRIX_SOURCE_DIR"

    local mode entry
    mode=$(jq -r '.git.irix.default_mode // "sql"' "$INIT_BASE" 2>/dev/null)
    [[ -z "$mode" || "$mode" == "null" ]] && mode="sql"
    entry=$(_irix_resolve_entry "$mode")
    [[ -z "$entry" || "$entry" == "null" ]] && entry="IriX-sql.tcl"

    jq -n \
        --arg ts    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --arg src   "$IRIX_SOURCE_DIR" \
        --arg inst  "$IRIX_DEFAULT_INSTANCE_DIR" \
        --arg mode  "$mode" \
        --arg entry "$entry" \
        --arg sock  "$(_mariadb_sock_defaut)" \
        '{
            meta: { created: $ts, version: "1.0" },
            source: {
                dir: $src,
                cloned: false,
                last_sync: null,
                sha256: {}
            },
            instance: {
                install_dir:   $inst,
                eggdrop_bin:   null,
                mode:          $mode,
                entry_script:  $entry,
                cmds_file:     "cmds_irix.json",
                config_file:   null,
                port:          3333,
                installed:     false,
                configured:    false,
                enabled:       false,
                running:       false,
                log:           { enabled: true, path: null, level: "info" },
                last_deploy:   null,
                last_start:    null,
                last_stop:     null
            },
            irc: {
                botnick:   "IriX",
                altnick:   "IriX_",
                username:  "IriX",
                realname:  null,
                admin:     null,
                email:     null,
                network:   null,
                host:      "127.0.0.1",
                port:      6667,
                owner:     null
            },
            sql: {
                host:     "127.0.0.1",
                sock:     $sock,
                database: null,
                login:    null,
                logs:     1
            },
            errors: []
        }' > "$(_irix_file)"

    _sha256_store "$(_irix_file)" "$INIT_BASE" "irix"
    _ok "Configuration IriX initialisée."
}

# -----------------------------------------------------------------------------
# _irix_get <champ>   (ex: "instance.install_dir", "source.cloned")
# -----------------------------------------------------------------------------
_irix_get() {
    local field="$1"
    jq -r ".${field} // empty" "$(_irix_file)" 2>/dev/null
}

# -----------------------------------------------------------------------------
# _irix_set <filtre_jq> [args jq...]
# -----------------------------------------------------------------------------
_irix_set() {
    local filter="$1"; shift
    _jq_update "$(_irix_file)" "$filter" "$@"
    _sha256_store "$(_irix_file)" "$INIT_BASE" "irix"
}

# -----------------------------------------------------------------------------
# _irix_install_dir
#   Retourne le dossier d'instance actif (avec valeur par défaut de secours).
# -----------------------------------------------------------------------------
_irix_install_dir() {
    local dir; dir=$(_irix_get "instance.install_dir")
    [[ -z "$dir" || "$dir" == "null" ]] && dir="$IRIX_DEFAULT_INSTANCE_DIR"
    printf '%s\n' "$dir"
}

# -----------------------------------------------------------------------------
# _irix_paths
#   Affiche sur stdout : <install_dir> <pid_file> <log_file>
# -----------------------------------------------------------------------------
# Chemins de l'instance : racine, fichier PID, journal.
# Le PID va dans runtime/, avec les autres fichiers d'état d'Eggdrop, et non à
# la racine de l'instance — c'est là qu'irix.conf le déclare (set pidfile).
_irix_paths() {
    local dir; dir=$(_irix_install_dir)
    printf '%s %s/%s/IriX.pid %s/logs/IriX.log\n' \
        "$dir" "$dir" "$IRIX_RUNTIME_SUBDIR" "$dir"
}

# =============================================================================
# Gestion du dossier d'instance (comme le dossier d'un bot Eggdrop)
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_set_install_dir
#   Change le dossier d'instance ; propose de déplacer les données existantes.
# -----------------------------------------------------------------------------
_irix_set_install_dir() {
    _irix_init
    local current new_dir rep
    current=$(_irix_install_dir)

    _section "Dossier d'instance IriX"
    _kv "Actuel" "$current"
    read -rp "  Nouveau dossier [Entrée = conserver] : " new_dir || true
    [[ -z "$new_dir" ]] && { _info "Inchangé."; return 0; }

    # $TARGET_HOME et non $HOME : sous sudo, « ~/IriX » se serait développé en
    # « /root/IriX », alors que tout le reste du script installe dans le home
    # de l'utilisateur réel. C'est le piège que _resolve_base_home existe
    # précisément pour éviter.
    new_dir="${new_dir/#\~/$TARGET_HOME}"

    # Un chemin relatif serait interprété depuis le répertoire courant, qui
    # n'est pas celui qu'on croit une fois le panel relancé par « exec ».
    if [[ "$new_dir" != /* ]]; then
        _err_f "Chemin absolu attendu (commençant par /) — reçu : %s" "$new_dir"
        return 1
    fi

    if [[ -d "$current" && "$current" != "$new_dir" ]]; then
        read -rp "  Déplacer les fichiers existants vers ${new_dir} ? [o/N] : " rep || true
        if [[ "$rep" =~ ^[YyOo]$ ]]; then
            mkdir -p "$(dirname "$new_dir")"
            if mv "$current" "$new_dir"; then
                _ok_f "Instance déplacée : %s → %s" "$current" "$new_dir"
            else
                _err "Échec du déplacement — le dossier d'instance n'a pas été modifié."
                return 1
            fi
        else
            # Refuser le déplacement laisse le nouveau dossier VIDE : l'instance
            # n'y est plus déployée, et le prétendre bloquait tout. _irix_toggle
            # ne redéploie que si installed vaut false, et _irix_start échouait
            # donc sur « script introuvable » sans que rien ne propose de
            # remettre les fichiers en place.
            _irix_set '.instance.installed = false'
            _warn_f "Fichiers laissés dans %s — le nouveau dossier est vide." "${current}"
            _info "L'instance est marquée « non déployée » : relance un déploiement."
        fi
    fi

    _irix_set '.instance.install_dir = $d' --arg d "$new_dir"
    _ok_f "Dossier d'instance IriX → %s" "$new_dir"
}

# =============================================================================
# Source — synchronisation depuis le dépôt (géré par Service-Overtchat)
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_sync_source
#   Télécharge chaque fichier listé dans git.irix.files (repo → local)
#   depuis brutegit vers IRIX_SOURCE_DIR. Piloté entièrement par la config,
#   aucun nom de fichier n'est plus figé en dur ici.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# _irix_depouilleur_preparer
#   Prépare l'outil qui allège les modules à l'installation, et pose son chemin
#   dans IRIX_DEPOUILLEUR (vide si l'on ne dépouille pas).
#
#   POURQUOI. Quarante et un pour cent des lignes des modules d'IriX sont des
#   commentaires ou des lignes vides. Ils ne décrivent pas le code : ils portent
#   la mémoire des incidents — « ce catch avale l'erreur », « ce commentaire
#   entre deux branches a coûté trois netsplits ». Les retirer DU DÉPÔT
#   effacerait la seule trace de ces raisons ; les retirer À L'INSTALLATION ne
#   coûte rien, puisque le dépôt reste consultable. D'où ce découpage, demandé
#   par SerVuS le 03/08/2026.
#
#   ⚠ TOUT ÉCHEC EST ANNONCÉ, JAMAIS SILENCIEUX. tclsh absent, outil
#   introuvable, dépouillement refusé : on le DIT et l'on garde le fichier
#   complet. Un module lourd fonctionne ; un module tronqué, non — et sauter en
#   silence laisserait croire au contrôle.
# -----------------------------------------------------------------------------
_irix_depouilleur_preparer() {
    IRIX_DEPOUILLEUR=""

    # ⚠ PAS de `// true` ici, et ce n'est pas un détail de style : en jq,
    # l'opérateur `//` rend sa partie droite quand la gauche vaut `null` MAIS
    # AUSSI quand elle vaut `false`. Écrit `.git.irix.depouiller // true`, un
    # réglage posé à `false` répondait `true` — le seul réglage qui compte était
    # le seul que jq ne savait pas lire. Trouvé en appelant la fonction, jamais
    # en la relisant.
    local actif
    actif=$(jq -r '.git.irix.depouiller' "$INIT_BASE" 2>/dev/null || echo null)
    if [[ -z "$actif" || "$actif" == "null" ]]; then actif="true"; fi
    if [[ "${OVERTCHAT_SANS_DEPOUILLE:-}" == "1" ]]; then actif="false"; fi
    if [[ "$actif" != "true" ]]; then
        _info "Allègement désactivé : les modules garderont leurs commentaires."
        return 0
    fi

    # tclsh n'est pas garanti : Eggdrop se lie à la bibliothèque Tcl, pas au
    # binaire. Sans lui, on installe les modules tels quels.
    if ! command -v tclsh >/dev/null 2>&1; then
        _warn "tclsh absent : les modules seront installés avec leurs commentaires."
        return 0
    fi

    local brutegit cible
    brutegit=$(jq -r '.git.brutegit' "$INIT_BASE")
    # 🔴 Cet outil est EXÉCUTÉ par tclsh : il ne vit plus sous un nom devinable
    #   de /tmp (audit #25, 29/09/2026), mais dans le dossier privé du lancement.
    local prive
    if ! prive="$(_ovt_tmp)"; then
        _warn "Dossier temporaire privé indisponible : les modules resteront complets."
        return 0
    fi
    cible="$prive/depouiller-tcl.tcl"

    if ! _download "${brutegit}/Tools/depouiller-tcl.tcl" "$cible"; then
        _warn "Outil d'allègement introuvable : les modules resteront complets."
        return 0
    fi

    IRIX_DEPOUILLEUR="$cible"
    return 0
}

# -----------------------------------------------------------------------------
# _irix_depouiller_fichier <chemin>
#   Allège un module en place. Ne touche QUE les .tcl, et laisse le fichier
#   intact au moindre doute.
#
#   ⚠ Les .conf sont exclus alors qu'ils sont eux aussi du Tcl : un fichier de
#   configuration est fait pour être lu et modifié à la main sur la machine.
#   L'alléger reviendrait à retirer sa documentation à celui qui doit l'éditer.
# -----------------------------------------------------------------------------
_irix_depouiller_fichier() {
    local f="$1"
    [[ -n "$IRIX_DEPOUILLEUR" ]] || return 0
    [[ "$f" == *.tcl ]]          || return 0
    [[ -f "$f" ]]                || return 0

    local avant apres
    avant=$(wc -l < "$f" 2>/dev/null || echo 0)

    # L'outil ne réécrit le fichier QUE si le résultat est équilibré
    # (`info complete`). En cas de refus, il sort non nul et le fichier
    # d'origine est encore là — c'est le comportement voulu.
    if ! tclsh "$IRIX_DEPOUILLEUR" "$f" "$f" >/dev/null 2>&1; then
        _warn "$(basename "$f") : allègement refusé, le fichier complet est conservé."
        return 0
    fi

    apres=$(wc -l < "$f" 2>/dev/null || echo 0)
    _log "DEPOUILLE $(basename "$f") ${avant} -> ${apres}"
    return 0
}

_irix_sync_source() {
    _irix_init
    _section "Synchronisation des sources IriX"

    local brutegit
    brutegit=$(jq -r '.git.brutegit' "$INIT_BASE")
    if [[ -z "$brutegit" || "$brutegit" == "null" ]]; then
        _err "URL du dépôt (git.brutegit) introuvable dans la configuration."
        return 1
    fi

    local files_count
    files_count=$(jq -r '.git.irix.files | length' "$INIT_BASE" 2>/dev/null || echo 0)
    if [[ -z "$files_count" || "$files_count" == "0" || "$files_count" == "null" ]]; then
        _err "Aucun fichier source IriX défini (git.irix.files) dans la configuration."
        return 1
    fi

    mkdir -p "$IRIX_SOURCE_DIR"

    _irix_depouilleur_preparer

    local ok=true i repo local_name hash
    local lignes_avant=0 lignes_apres=0
    for (( i=0; i<files_count; i++ )); do
        repo=$(jq -r ".git.irix.files[$i].repo"   "$INIT_BASE")
        local_name=$(jq -r ".git.irix.files[$i].local" "$INIT_BASE")

        if [[ -z "$repo" || "$repo" == "null" || -z "$local_name" || "$local_name" == "null" ]]; then
            _err_f "Entrée git.irix.files[%s] invalide (repo/local manquant)." "$i"
            ok=false; continue
        fi

        if _download "${brutegit}/${repo}" "${IRIX_SOURCE_DIR}/${local_name}"; then
            if [[ "$local_name" == *.tcl && -n "$IRIX_DEPOUILLEUR" ]]; then
                lignes_avant=$(( lignes_avant + $(wc -l < "${IRIX_SOURCE_DIR}/${local_name}" 2>/dev/null || echo 0) ))
                _irix_depouiller_fichier "${IRIX_SOURCE_DIR}/${local_name}"
                lignes_apres=$(( lignes_apres + $(wc -l < "${IRIX_SOURCE_DIR}/${local_name}" 2>/dev/null || echo 0) ))
            fi

            # ⚠ LE HASH EST PRIS APRÈS L'ALLÈGEMENT, et cet ordre n'est pas
            # indifférent : `_irix_verify_source_sha256` compare le fichier
            # présent sur le disque à ce qui a été enregistré ici. Hasher avant
            # ferait échouer chaque vérification d'intégrité ultérieure, sur des
            # fichiers pourtant parfaitement sains.
            hash=$(_sha256 "${IRIX_SOURCE_DIR}/${local_name}" 2>/dev/null || echo "")
            _irix_set '.source.sha256[$k] = $h' --arg k "$local_name" --arg h "$hash"
        else
            ok=false
        fi
    done

    if (( lignes_avant > 0 && lignes_apres < lignes_avant )); then
        local gain=$(( (lignes_avant - lignes_apres) * 100 / lignes_avant ))
        _ok_f "Modules allégés : %s → %s lignes (%s %% de moins). La version commentée reste au dépôt." "${lignes_avant}" "${lignes_apres}" "${gain}"
    fi
    [[ -n "$IRIX_DEPOUILLEUR" ]] && rm -f "$IRIX_DEPOUILLEUR"

    if ! $ok; then
        _irix_report_error "Échec de synchronisation des sources"
        _err "Synchronisation incomplète."
        return 1
    fi

    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _irix_set '.source.cloned = true | .source.last_sync = $ts' --arg ts "$ts"

    _log_entry "irix" "sync" "sources synchronisées"
    _ok_f "Sources IriX synchronisées dans %s" "${IRIX_SOURCE_DIR}"
}

# -----------------------------------------------------------------------------
# _irix_verify_source_sha256
#   Vérifie l'intégrité des sources locales par rapport au hash enregistré.
# -----------------------------------------------------------------------------
_irix_verify_source_sha256() {
    _irix_init
    _section "Vérification SHA-256 — sources IriX"

    local files_count
    files_count=$(jq -r '.git.irix.files | length' "$INIT_BASE" 2>/dev/null || echo 0)
    if [[ -z "$files_count" || "$files_count" == "0" || "$files_count" == "null" ]]; then
        _err "Aucun fichier source IriX défini (git.irix.files) dans la configuration."
        return 1
    fi

    local ok=true i local_name path stored current
    for (( i=0; i<files_count; i++ )); do
        local_name=$(jq -r ".git.irix.files[$i].local" "$INIT_BASE")
        [[ -z "$local_name" || "$local_name" == "null" ]] && continue
        path="${IRIX_SOURCE_DIR}/${local_name}"

        [[ -f "$path" ]] || { _warn "$local_name : fichier absent."; ok=false; continue; }

        stored=$(_irix_get "source.sha256[\"${local_name}\"]")
        current=$(_sha256 "$path")

        if [[ -z "$stored" ]]; then
            _warn_f "%s : pas de hash de référence." "$local_name"
            continue
        fi

        if [[ "$stored" == "$current" ]]; then
            _ok_f "%s : SHA-256 valide." "$local_name"
        else
            _err_f "%s : SHA-256 INVALIDE !" "$local_name"
            printf "  Attendu : ${GREEN}%s${RESET}\n" "$stored"
            printf "  Obtenu  : ${RED}%s${RESET}\n"   "$current"
            _irix_report_error "SHA-256 invalide : $local_name"
            ok=false
        fi
    done

    $ok
}

# =============================================================================
# Mode moteur — SQL (complet) vs Std (sans SQL)
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_set_mode <sql|std>
#   Change le moteur IriX actif. Refusé si l'instance tourne actuellement.
#   Force un redéploiement (installed=false) pour éviter de faire tourner
#   un mélange incohérent d'anciens fichiers déployés.
# -----------------------------------------------------------------------------
_irix_set_mode() {
    _irix_init
    local mode="$1"

    if [[ "$mode" != "sql" && "$mode" != "std" ]]; then
        _err_f "Mode invalide : '%s' (attendu : sql | std)" "$mode"
        return 1
    fi

    if [[ "$(_irix_status)" == "running" ]]; then
        _err "IriX est en cours d'exécution. Arrêtez-le avant de changer de mode."
        return 1
    fi

    local entry
    entry=$(_irix_resolve_entry "$mode")
    if [[ -z "$entry" || "$entry" == "null" ]]; then
        _err_f "Aucune entrée définie pour le mode '%s' (git.irix.modes)." "$mode"
        return 1
    fi

    _irix_set \
        '.instance.mode = $m | .instance.entry_script = $e | .instance.installed = false' \
        --arg m "$mode" --arg e "$entry"

    _log_entry "irix" "mode" "mode changé -> $mode ($entry)"
    _ok_f "Mode IriX -> %s (%s). Redéploiement requis avant de redémarrer." "$mode" "$entry"
}

# -----------------------------------------------------------------------------
# _irix_choose_mode
#   Prompt interactif pour le panel TUI.
# -----------------------------------------------------------------------------
_irix_choose_mode() {
    local current label_sql label_std c
    current=$(_irix_get "instance.mode")
    label_sql=$(jq -r '.git.irix.modes.sql.label // "sql"' "$INIT_BASE")
    label_std=$(jq -r '.git.irix.modes.std.label // "std"' "$INIT_BASE")

    printf '\n'
    _kv "Mode actuel" "${current:-sql}"
    printf "  [1] sql  — %s\n" "$label_sql"
    printf "  [2] std  — %s\n" "$label_std"
    # ⚠ ÉCHAPPATOIRE AJOUTÉE le 28/08/2026. C'était le seul menu du fichier sans
    # sortie annoncée : pour en ressortir SANS changer de mode, il fallait taper
    # n'importe quoi d'invalide, ou faire Ctrl-D. Or ce menu-ci change le mode de
    # chargement d'IriX — c'est-à-dire quels modules le bot lira au prochain
    # démarrage. Sortir par erreur d'un menu pareil ne doit pas dépendre d'une
    # faute de frappe.
    printf "  [0] Retour — ne rien changer\n"
    read -rp $'\n  Mode > ' c || return 0
    case "$c" in
        1|sql) _irix_set_mode "sql" ;;
        2|std) _irix_set_mode "std" ;;
        0|'')  return 0 ;;
        *) _warn "Choix invalide." ;;
    esac
}

# =============================================================================
# Déploiement — source → instance (comme copier un script dans un dossier Eggdrop)
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_deploy
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# _irix_eggdrop_bin — retrouve le binaire Eggdrop qui hébergera IriX.
#
# IriX n'est pas un programme : c'est un jeu de scripts Tcl qu'Eggdrop charge.
# Sans ce binaire, il n'y a rien à démarrer — d'où la recherche explicite, et
# le message qui renvoie vers le panel Services quand elle échoue.
# -----------------------------------------------------------------------------
_irix_eggdrop_bin() {
    local b
    # 1. Valeur déjà enregistrée
    b=$(_irix_get "instance.eggdrop_bin")
    [[ -n "$b" && "$b" != "null" && -x "$b" ]] && { printf '%s' "$b"; return 0; }
    # 2. Eggdrop installé par le panel
    b="$(_svc_get eggdrop install_dir 2>/dev/null)/eggdrop"
    [[ -x "$b" ]] && { printf '%s' "$b"; return 0; }
    # 3. Instance IriX voisine, ou PATH
    for b in "$(_irix_install_dir)/eggdrop" "$TARGET_HOME/IriX/eggdrop" "$(command -v eggdrop 2>/dev/null)"; do
        [[ -n "$b" && -x "$b" ]] && { printf '%s' "$b"; return 0; }
    done
    return 1
}

# Répertoire des traductions d'Eggdrop. `_install_eggdrop` compile sans faire
# « make install » : le dossier language/ reste donc dans l'arbre de
# compilation. Sans EGG_LANGDIR, addlang ne trouve rien et se tait — les
# messages du bot repartent en anglais sans qu'aucune erreur ne le signale.
_irix_langdir() {
    local d
    for d in "$(dirname "$(_irix_eggdrop_bin 2>/dev/null || echo /nonexistent)")/language" \
             "$(_svc_get eggdrop install_dir 2>/dev/null)/language" \
             "$TARGET_HOME/IriX/language"; do
        [[ -d "$d" ]] && { printf '%s' "$d"; return 0; }
    done
    return 1
}

# -----------------------------------------------------------------------------
# _irix_moddir — répertoire des modules compilés d'Eggdrop (*.so).
#
# Le chemin doit être ABSOLU dans la configuration. Le défaut d'Eggdrop est
# « modules/ », résolu depuis le répertoire courant : comme l'instance IriX
# n'est pas le répertoire de compilation d'Eggdrop, ce défaut ne désigne rien,
# tous les loadmodule échouent, et le premier « bind msg » d'IriX s'arrête sur
# « bad type » — msg étant fourni par le module server. La configuration de
# production porte la même remarque, apprise de la même façon.
# -----------------------------------------------------------------------------
_irix_moddir() {
    local d
    for d in "$(dirname "$(_irix_eggdrop_bin 2>/dev/null || echo /nonexistent)")/modules" \
             "$(_svc_get eggdrop install_dir 2>/dev/null)/modules" \
             "$TARGET_HOME/IriX/modules"; do
        [[ -d "$d" ]] && compgen -G "$d/*.so" >/dev/null 2>&1 && { printf '%s' "${d%/}/"; return 0; }
    done
    return 1
}

# -----------------------------------------------------------------------------
# _irix_handle_valide — un pseudonyme qu'Eggdrop accepte comme handle.
#
# Lettres, chiffres et les spéciaux IRC ; ni chiffre ni tiret en tête ; 32 au
# plus (HANDLEN d'Eggdrop 1.8 et suivants). Séparée pour que
# `Bootstrap/tests/test-proprietaire-irix.sh` l'éprouve seule.
# -----------------------------------------------------------------------------
_irix_handle_valide() {
    local h="$1"
    local motif='^[][A-Za-z_\`^{}|\\][][A-Za-z0-9_\`^{}|\\-]{0,31}$'
    [[ "$h" =~ $motif ]]
}

# -----------------------------------------------------------------------------
# _base_acces_ecrire <cmds> <hôte> <compte> <socket> <base> <ip-serveur> <botnick>
#                    <courriel> <admin>
#   Écrit <cmds>/IriX.json — l'ACCÈS À LA BASE de tout le projet — en 600.
#   Le mot de passe arrive par l'ENVIRONNEMENT (`OVT_JQ_SECRET`), jamais en
#   argument : un argument se lit dans `ps` par tout compte de la machine.
#
# 17/10/2026 (lot J2) : sortie de `_irix_configure`, MÊME contenu, mêmes droits.
#   Le nom trompe : ce fichier n'est pas qu'à IriX. Le lisent `Web/sql/apply.sh`
#   (les migrations), `Tools/uline-bootstrap.sh`, `Tools/uline-secrets.sh` et
#   OverCore (`irixJson` d'overcore.json, lib/base.js). Sur le chemin OverCore de
#   `--auto`, sans aucun Eggdrop, il doit donc être écrit quand même — c'est
#   pour ça qu'il a sa fonction.
# -----------------------------------------------------------------------------
_base_acces_ecrire() {
    local cmds="$1" tmp
    mkdir -p "$cmds" || { _err_f "Dossier %s impossible à créer." "$cmds"; return 1; }
    tmp="$(mktemp)" || return 1
    # 🔴 Le mot de passe SQL arrive à jq par l'ENVIRONNEMENT (`$ENV`, jq ≥ 1.6),
    #   plus par `--arg p` : un argument se lit dans `ps` par tout compte de la
    #   machine, l'environnement d'un processus seulement par lui et root
    #   (audit #18, 29/09/2026).
    jq -n --arg h "$2" --arg l "$3" \
          --arg s "$4" --arg d "$5" --arg ip "$6" \
          --arg b "$7" --arg e "$8" --arg a "$9" \
        '{host:$h, login:$l, pass:$ENV.OVT_JQ_SECRET, sock:$s, database:$d,
          "ip-server":$ip, botnick:$b, email:$e, admin:$a, logs:1}' > "$tmp" \
        || { rm -f "$tmp"; _err "Écriture de IriX.json impossible."; return 1; }
    # ⚠ Le 600 AVANT le déplacement : `mktemp` le donne déjà, on ne le suppose pas.
    chmod 600 "$tmp" && mv "$tmp" "${cmds}/IriX.json" && chmod 600 "${cmds}/IriX.json" \
        || { rm -f "$tmp"; _err "Écriture de IriX.json impossible."; return 1; }
    _ok_f "Accès à la base écrit : %s/IriX.json %s(droits 600)%s" "${cmds}" "${DIM}" "${RESET}"
}

# -----------------------------------------------------------------------------
# _irix_configure — identité du bot et accès à la base.
#
# Les valeurs sont REPRISES de Config/identity.json quand il existe : c'est le
# fichier que le panel du site renseigne, et le bot doit annoncer le même
# réseau que le site. Les redemander une seconde fois, c'est prendre le risque
# de deux réponses différentes — et un realname de bot qui contredit le nom du
# réseau affiché sur le site.
# -----------------------------------------------------------------------------
_irix_configure() {
    _irix_init
    _ui_width
    if [[ "${_OVT_SANS_EGGDROP:-0}" == "1" ]]; then
        _ui_title "Accès à la base et identité des services" "sans Eggdrop : IriX.json est lu par le site, les migrations et OverCore"
    else
        _ui_title "Configuration d'IriX" "identité du bot, serveur IRC et base de données"
    fi

    local ident="${BASE_DIR}/Config/identity.json"
    local d_nom d_real d_mail d_dom
    if [[ -f "$ident" ]]; then
        d_nom=$(jq -r '.name          // empty' "$ident" 2>/dev/null)
        d_real=$(jq -r '.irc.bot_realname // empty' "$ident" 2>/dev/null)
        d_mail=$(jq -r '.mail.from     // empty' "$ident" 2>/dev/null)
        d_dom=$(jq -r '.domain         // empty' "$ident" 2>/dev/null)
        _info "Identité reprise de Config/identity.json — le bot annoncera le même réseau que le site."
    else
        _warn "Config/identity.json absent : installe le site d'abord (menu 12) pour lier les deux."
    fi
    # Le repli est le nom de CETTE installation, pas celui du réseau d'origine.
    # Proposé tel quel à un exploitant tiers, « Service-Overtchat » se serait
    # retrouvé dans le realname de son bot — c'est-à-dire dans un /WHOIS lu par
    # tous ses membres.
    [[ -z "${d_real:-}" ]] && d_real="${d_nom:-$PROJET_NOM}"

    local nick alt user real admin mail net host port owner d_owner
    nick=$(_site_ask  "Pseudonyme du bot"        "$(_irix_get 'irc.botnick')")
    alt=$(_site_ask   "Pseudonyme de repli"      "${nick}_")
    user=$(_site_ask  "Identifiant (username)"   "$nick")
    real=$(_site_ask  "Nom réel affiché"         "$d_real")

    # ── 🔴 LE PROPRIÉTAIRE DU ROBOT (« set owner ») — une PERSONNE ──────────
    #
    # Jusqu'au 26/09/2026, aucune question ne le demandait : `_irix_ecrire_conf`
    # reprenait l'« Administrateur », dont le défaut était le NOM DU RÉSEAU. Le
    # robot avait donc pour propriétaire permanent « Service-Overtchat », un
    # handle que personne ne porte — et, avec `must-be-owner 1`, les commandes
    # réservées (.tcl, .set) étaient fermées à tous.
    #
    # Le site désigne bien le propriétaire (irix-partyline.tcl, point 127) ; mais
    # `set owner` reste le repli quand la base se tait, et la seule liste
    # qu'Eggdrop consulte pour `must-be-owner`. Il doit nommer quelqu'un.
    #
    # Ordre des défauts : le choix explicite (OVERTCHAT_PROPRIETAIRE), ce qui
    # est déjà enregistré, puis le premier compte owner du site — qui n'existe
    # que sur une RECONFIGURATION : à la première installation, il est créé
    # après, et doit être vérifié sur IRC.
    d_owner="${OVERTCHAT_PROPRIETAIRE:-}"
    # 17/10/2026 (lot J2) : SANS EGGDROP (`_OVT_SANS_EGGDROP=1`, chemin OverCore ou
    #   Anope de `--auto`), « set owner » n'a pas de lecteur : la question et sa
    #   règle de handle Eggdrop sont SAUTÉES — elles refusaient sinon un nom
    #   valable pour une contrainte d'un programme qui n'est pas installé. Le
    #   propriétaire donné reste noté (irix.json), sans contrôle Eggdrop.
    if [[ "${_OVT_SANS_EGGDROP:-0}" == "1" ]]; then
        owner="$d_owner"
    else
    if [[ -z "$d_owner" ]]; then
        d_owner=$(_irix_get 'irc.owner')
        [[ "$d_owner" == "null" ]] && d_owner=""
    fi
    if [[ -z "$d_owner" ]] && command -v php >/dev/null 2>&1 \
       && [[ -f "${BASE_DIR}/Tools/salons-proprietaire.php" ]]; then
        d_owner=$(php "${BASE_DIR}/Tools/salons-proprietaire.php" --responsable 2>/dev/null | head -1)
    fi
    owner=$(_site_ask "Propriétaire du robot (votre pseudonyme IRC)" "$d_owner")
    if [[ -n "$owner" ]] && ! _irix_handle_valide "$owner"; then
        _err "« ${owner} » n'est pas un pseudonyme utilisable par Eggdrop (lettres, chiffres, - _ [ ] { } | ^ \`, 32 au plus, pas de chiffre ni de tiret en tête) — rien n'a été écrit."
        return 1
    fi
    if [[ -z "$owner" ]]; then
        if [[ "${MODE_AUTO:-0}" == "1" ]]; then
            _warn "Aucun propriétaire du robot (OVERTCHAT_PROPRIETAIRE) : il démarrera, mais personne n'aura les commandes réservées (.tcl, .set) tant que « set owner » d'irix.conf ne nomme pas une personne."
        else
            _err "Un propriétaire est indispensable : c'est lui qui tient le robot (« set owner »). Rien n'a été écrit."
            return 1
        fi
    fi
    fi   # fin du « sans Eggdrop » (17/10/2026) — le corps garde son retrait d'avant

    # L'administrateur n'est qu'un texte affiché (« set admin ») ; son défaut
    # est désormais le propriétaire, plus le nom du réseau.
    admin=$(_site_ask "Administrateur"           "${owner:-${d_nom:-$PROJET_NOM}}")
    mail=$(_site_ask  "Courriel de contact"      "${d_mail:-contact@${d_dom:-localhost}}")
    net=$(_site_ask   "Nom du réseau"            "${d_nom:-Overtchat}")
    host=$(_site_ask  "Serveur IRC (hôte)"       "$(_irix_get 'irc.host')")
    port=$(_site_ask  "Serveur IRC (port)"       "$(_irix_get 'irc.port')")

    _section "Base de données"
    printf "  ${DIM}IriX et le site partagent la MÊME base : le bot y écrit ce que le site\n"
    printf "  affiche. Une base distincte donnerait un site qui ne voit rien.${RESET}\n\n"

    # La base du site fait référence : c'est elle que les migrations ont créée.
    local d_base d_user
    # ── ⚠⚠ NE RIEN « DÉTECTER » DANS UNE COPIE DU MODÈLE ────────────────────
    #
    # 🔴 Sur une installation neuve, `config.php` vient d'être copié depuis
    # `config.example.php` : il ne porte donc que des REPLIS — `'database' =>
    # 'IriX'`, `'login' => 'overtchat'` — qui ne décrivent aucune installation
    # réelle. Les lire et annoncer « détecté » présente une valeur inventée
    # comme un fait mesuré, et l'installation part avec une base et un compte
    # qui n'existent nulle part.
    #
    # 📌 Constaté le 05/09/2026, et par ma faute : en réparant le motif de
    # détection du compte — il visait une clé `'user'` qui n'existe pas — j'ai
    # rendu la détection efficace... sur le fichier de repli. *Un `grep` qui ne
    # trouvait jamais rien protégeait par accident ; le réparer a révélé que la
    # SOURCE était mauvaise.*
    #
    # On compare donc au modèle : identiques, c'est une copie fraîche et il n'y
    # a rien à détecter.
    local _copie_du_modele=0
    if [[ -f "${BASE_DIR}/Web/inc/config.php" && -f "${BASE_DIR}/Web/inc/config.example.php" ]] \
       && cmp -s "${BASE_DIR}/Web/inc/config.php" "${BASE_DIR}/Web/inc/config.example.php"; then
        _copie_du_modele=1
        _info "config.php est une copie du modèle — rien à détecter, on part des valeurs par défaut."
    fi
    if [[ "$_copie_du_modele" == "0" && -f "${BASE_DIR}/Web/inc/config.php" ]]; then
        d_base=$(grep -oP "'database'\s*=>\s*'\K[^']+" "${BASE_DIR}/Web/inc/config.php" 2>/dev/null | head -1)
        # ⚠⚠ `'login'`, PAS `'user'` — et cette lettre a coûté cher.
        #
        # `config.php` n'écrit nulle part de clé `'user'` : son tableau de repli
        # porte `'login' => …`. Le motif ne trouvait donc JAMAIS rien, `$d_user`
        # restait vide, et le défaut `irix` de la ligne plus bas tenait lieu de
        # réponse. Mesuré sur une installation tierce le 05/09/2026 : ce que
        # l'écran présentait comme « détecté » était en fait un repli.
        #
        # 📌 D'où l'asymétrie qu'on voyait sans la comprendre : la base était
        # bien détectée — son motif, lui, vise la bonne clé — et l'utilisateur
        # jamais. *Deux `grep` côte à côte, un seul juste, et rien ne le disait.*
        d_user=$(grep -oP "'login'\s*=>\s*'\K[^']+"    "${BASE_DIR}/Web/inc/config.php" 2>/dev/null | head -1)
        [[ -n "$d_base" ]] && _info "Base du site détectée : ${BOLD}${d_base}${RESET}"
        [[ -n "$d_user" ]] && _info "Utilisateur du site détecté : ${BOLD}${d_user}${RESET}"
    fi

    local sql_host sql_base sql_user sql_pass sql_sock
    sql_host=$(_site_ask "Hôte MariaDB"    "$(_irix_get 'sql.host')")
    sql_base=$(_site_ask "Base de données" "${d_base:-Service_Overtchat}")
    sql_user=$(_site_ask "Utilisateur SQL" "${d_user:-irix}")
    sql_sock=$(_site_ask "Socket MariaDB"  "$(_irix_get 'sql.sock')")
    # ⚠⚠ LE SEUL SECRET DE TOUTE L'INSTALLATION QUI N'A AUCUN DÉFAUT, et le
    # trou par lequel un mode AUTO naïf écrirait un IriX.json avec `pass:""` :
    # le bot serait installé, l'écran dirait « configuré », et il ne joindrait
    # jamais la base. `_auto_prevol` exige donc OVERTCHAT_SQL_PASS avant que
    # quoi que ce soit ne soit écrit — ici on ne fait que le reprendre.
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        sql_pass="${OVERTCHAT_SQL_PASS:-}"
        printf "  Mot de passe SQL : %s\n" "${DIM}(auto — repris de OVERTCHAT_SQL_PASS)${RESET}"
    else
        printf "  Mot de passe SQL : "; read -rs sql_pass || true; echo ""
    fi

    if [[ -z "$nick" || -z "$sql_base" || -z "$sql_user" ]]; then
        _err "Pseudonyme, base et utilisateur SQL sont indispensables — rien n'a été écrit."
        return 1
    fi

    _irix_set '.irc.botnick=$n | .irc.altnick=$a | .irc.username=$u | .irc.realname=$r
               | .irc.admin=$ad | .irc.owner=$ow | .irc.email=$m | .irc.network=$net
               | .irc.host=$h | .irc.port=($p|tonumber? // 6667)
               | .sql.host=$sh | .sql.database=$sb | .sql.login=$su | .sql.sock=$ss
               | .instance.configured=true' \
        --arg n "$nick" --arg a "$alt" --arg u "$user" --arg r "$real" \
        --arg ad "$admin" --arg ow "$owner" --arg m "$mail" --arg net "$net" \
        --arg h "$host" --arg p "$port" \
        --arg sh "$sql_host" --arg sb "$sql_base" --arg su "$sql_user" --arg ss "$sql_sock"

    # Le mot de passe ne va PAS dans irix.json (Config/, lisible) mais dans le
    # IriX.json de l'instance, écrit en 600 — c'est le fichier que les modules
    # Tcl lisent, et lui seul a besoin du secret.
    local dir cmds
    dir=$(_irix_install_dir); cmds="${dir}/${IRIX_CMDS_SUBDIR}"
    # 17/10/2026 (lot J2) : l'écriture vit dans `_base_acces_ecrire`, appelée
    #   aussi sans Eggdrop. Le secret y voyage par l'ENVIRONNEMENT, comme avant.
    OVT_JQ_SECRET="$sql_pass" \
    _base_acces_ecrire "$cmds" "$sql_host" "$sql_user" "$sql_sock" "$sql_base" \
                       "$host" "$nick" "$mail" "$admin" || return 1

    # Paire RSA de la file d'identification web (web_authq, défaut B1) : le site
    # CHIFFRE le mot de passe avec la clé PUBLIQUE, le bot le DÉCHIFFRE avec la
    # PRIVÉE. Générée ici si absente (idempotent). Privée à côté de IriX.json
    # (cmds/, 400) — irix-auth.tcl la cherche là en premier ; publique dans
    # Config/ où le site la lit (ROOT/Config/authq_pub.pem).
    local authq_priv="${cmds}/authq_priv.pem" authq_pub="${BASE_DIR}/Config/authq_pub.pem"
    if [ ! -f "$authq_priv" ]; then
        mkdir -p "${BASE_DIR}/Config"
        if ( umask 077; openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
                 -out "$authq_priv" 2>/dev/null ) \
           && openssl rsa -in "$authq_priv" -pubout -out "$authq_pub" 2>/dev/null; then
            chmod 400 "$authq_priv"; chmod 444 "$authq_pub"
            _ok_f "Clés d'identification web générées : %s %s(privée 400)%s + Config/authq_pub.pem" \
                  "$authq_priv" "${DIM}" "${RESET}"
        else
            _warn_f "Génération des clés web_authq impossible (openssl) — l'identification depuis le site restera indisponible tant qu'elles manquent."
        fi
    fi

    _log_entry "irix" "configure" "botnick=$nick base=$sql_base"
    if [[ "${_OVT_SANS_EGGDROP:-0}" == "1" ]]; then
        _ok "Accès à la base et identité enregistrés (aucun Eggdrop à déployer)."
    else
        _ok "Configuration enregistrée. Déploie l'instance pour l'appliquer."
    fi
}

# -----------------------------------------------------------------------------
# _irix_ecrire_conf <destination> <racine instance> <script d'entrée>
#
# Écrit une VRAIE configuration Eggdrop.
#
# Ce que le panel produisait avant tenait en cinq lignes « set irix(...) » : un
# fichier de métadonnées maison, qu'aucun programme ne sait lire. Eggdrop, lui,
# attend ses propres directives — set nick, set username, listen, userfile — et
# les modules d'IriX attendent en plus une poignée de variables globales
# ($::botnick, $::database, $::sock…) qu'ils lisent au chargement.
#
# La ligne décisive est la dernière : c'est elle qui charge le point d'entrée,
# lequel source à son tour ses quinze modules voisins. Le `die` en cas
# d'absence est délibéré — un bot qui tourne sans ses modules se connecte au
# réseau, répond à personne, et ne signale rien.
#
# Un fichier existant n'est JAMAIS écrasé : il porte des réglages faits à la
# main (salons, exceptions, mots de passe oper). Il est sauvegardé et complété.
# -----------------------------------------------------------------------------
# _irix_console_certificat <dossier runtime>
# Le certificat de la console TLS (point 127), comme Tools/irix-console-certificat.sh :
# EC P-256 auto-signé, clé née en 0600, JAMAIS écrasé (un certificat remplacé casse
# l'épinglage d'OverScript). Un échec n'arrête pas l'installation : sans certificat,
# IriX démarre sans console TLS (garde de cmds/console-tls.conf).
_irix_console_certificat() {
    local dossier="$1"
    local cert="$dossier/irix-console.cert.pem" cle="$dossier/irix-console.cle.pem"
    if [[ -e "$cert" || -e "$cle" ]]; then
        _info "Certificat de la console TLS déjà présent — conservé."
        return 0
    fi
    if ! command -v openssl > /dev/null; then
        _warn "openssl absent : console TLS d'IriX sans certificat (elle ne s'ouvrira pas)."
        return 0
    fi
    if ( umask 077; openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
            -days 3650 -subj "/CN=IriX console" -keyout "$cle" -out "$cert" > /dev/null 2>&1 ) \
       && [[ -s "$cert" && -s "$cle" ]]; then
        chmod 600 "$cle"; chmod 644 "$cert"
        [[ $EUID -eq 0 ]] && chown "$(stat -c '%U:%G' "$dossier")" "$cert" "$cle"
        # L'empreinte d'abord dans une variable : un `$(…)` à guillemets simples
        # passé en argument se comptait DEUX fois pour test-traduction.sh.
        local empreinte
        empreinte="$(openssl x509 -in "$cert" -noout -fingerprint -sha256 | sed -e 's/^[^=]*=//')"
        _ok_f "Console TLS d'IriX — empreinte à épingler dans OverScript : %s" "$empreinte"
    else
        rm -f "$cert" "$cle"
        _warn "Certificat de la console TLS non créé : elle ne s'ouvrira pas (IriX démarre quand même)."
    fi
}

_irix_ecrire_conf() {
    local dest="$1" racine="$2" entry="$3"
    local nick alt user real admin mail net host port owner sqlh sqlb sqlu sqls lang

    nick=$(_irix_get 'irc.botnick');   alt=$(_irix_get 'irc.altnick')
    user=$(_irix_get 'irc.username');  real=$(_irix_get 'irc.realname')
    admin=$(_irix_get 'irc.admin');    mail=$(_irix_get 'irc.email')
    net=$(_irix_get 'irc.network');    host=$(_irix_get 'irc.host')
    port=$(_irix_get 'irc.port');      owner=$(_irix_get 'irc.owner')
    sqlh=$(_irix_get 'sql.host');      sqlb=$(_irix_get 'sql.database')
    sqlu=$(_irix_get 'sql.login');     sqls=$(_irix_get 'sql.sock')
    [[ -z "$owner" || "$owner" == "null" ]] && owner="$admin"
    lang=$(_irix_langdir 2>/dev/null || true)
    local moddir; moddir=$(_irix_moddir 2>/dev/null || true)

    if [[ -f "$dest" ]]; then
        cp -p "$dest" "${dest}.avant-$(date +%Y%m%d-%H%M%S)" \
            && _info "Configuration précédente sauvegardée." \
            || { _err "Sauvegarde de la configuration impossible — rien n'a été écrit."; return 1; }
        _warn "irix.conf existe : il est CONSERVÉ tel quel (réglages faits à la main)."
        _kv "À vérifier" "$dest"
        return 0
    fi

    local tmp; tmp="$(mktemp)" || { _err "mktemp échoué."; return 1; }
    {
        printf '# Configuration IriX — générée par Service-Overtchat le %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        printf '# Instance : %s\n#\n' "$racine"
        printf '# Ce fichier est une configuration EGGDROP : IriX est un jeu de scripts Tcl\n'
        printf '# qu%s Eggdrop héberge. Il se lance par « eggdrop -n %s ».\n\n' "'" "$dest"

        printf '##### IDENTITÉ #####\n'
        printf 'set username   "%s"\n' "$user"
        printf 'set admin      "%s"\n' "$admin"
        printf 'set email      "%s"\n' "$mail"
        printf 'set nick       "%s"\n' "$nick"
        printf 'set altnick    "%s"\n' "$alt"
        printf 'set realname   "%s"\n' "$real"
        printf 'set network    "%s"\n\n' "$net"

        printf '##### SERVEUR IRC #####\n'
        printf 'set host          "%s"\n' "$host"
        printf 'set default-port  %s\n' "$port"
        printf 'set type_serv     "unreal"\n'
        printf 'set net-type      "efnet"\n\n'

        printf '##### BASE DE DONNÉES #####\n'
        printf '# Les identifiants complets, mot de passe compris, sont dans\n'
        printf '# %s/IriX.json (droits 600). Ici ne figure que ce dont\n' "$IRIX_CMDS_SUBDIR"
        printf '# la configuration a besoin pour situer la base.\n'
        printf 'set database   "%s"\n' "$racine"
        printf 'set dbuser     "%s"\n' "$sqlu"
        printf 'set dbhost     "%s"\n' "$sqlh"
        printf 'set dbname     "%s"\n' "$sqlb"
        printf 'set sock       "%s"\n' "$sqls"
        printf 'set logsql     1\n\n'

        printf '##### FICHIERS #####\n'
        printf 'set userfile   "%s/%s/IriX.user"\n' "$racine" "$IRIX_RUNTIME_SUBDIR"
        printf 'set chanfile   "%s/%s/IriX.chan"\n' "$racine" "$IRIX_RUNTIME_SUBDIR"
        printf 'set notefile   "%s/%s/IriX.notes"\n' "$racine" "$IRIX_RUNTIME_SUBDIR"
        printf 'set pidfile    "%s/%s/IriX.pid"\n' "$racine" "$IRIX_RUNTIME_SUBDIR"
        printf 'set userfile-perm 0600\n'
        # Accolades et non guillemets : entre guillemets, Tcl voit dans
        # « [%d/%m %H:%M:%S] » une substitution de commande et tente d'exécuter
        # « %d/%m » — la configuration est alors refusée sur « invalid command
        # name ». Les accolades suspendent toute substitution.
        printf 'set timestamp-format {[%%d/%%m %%H:%%M:%%S]}\n'
        printf 'logfile mco * "%s/logs/IriX.log"\n\n' "$racine"

        printf '##### DROITS #####\n'
        printf 'set owner        "%s"\n' "$owner"
        printf 'set must-be-owner 1\n'
        printf 'set require-p     1\n\n'

        printf '##### MODULES EGGDROP #####\n'
        # Guillemets doubles pour ces lignes : elles contiennent des apostrophes,
        # qui refermeraient une chaîne printf en quotes simples.
        printf '%s\n' "# mod-path est ABSOLU, et non le « modules/ » par défaut : ce dernier est"
        printf '%s\n' "# résolu depuis le répertoire courant, qui n'est pas celui où Eggdrop a"
        printf '%s\n' "# été compilé. Sans lui, tous les loadmodule échouent, et IriX s'arrête"
        printf '%s\n' "# au premier « bind msg » — msg étant fourni par le module server."
        if [[ -n "$moddir" ]]; then
            printf 'set mod-path "%s"\n' "$moddir"
            printf 'loadmodule pbkdf2\n'
            printf 'loadmodule channels\n'
            printf 'loadmodule server\n'
            printf 'loadmodule irc\n'
            printf 'loadmodule ctcp\n\n'

            # « server add » N EST écrit QUE si le module server est chargé :
            # cette commande vient de lui. Écrite sans, elle fait échouer toute
            # la configuration sur « invalid command name "server" », et le bot
            # ne démarre pas — y compris quand tout le reste est correct.
            printf '##### CONNEXION AU SERVEUR #####\n'
            printf 'server add "%s" %s\n\n' "$host" "$port"

            printf '##### COMMANDES RENDUES À IriX #####\n'
            printf '# Eggdrop lie « ident » et « addhost » par défaut ; IriX fournit sa\n'
            printf '# propre authentification et les reprendrait en double.\n'
            printf 'unbind msg - ident   *msg:ident\n'
            printf 'unbind msg - addhost *msg:addhost\n\n'
        else
            printf '# ATTENTION : aucun répertoire de modules (*.so) trouvé au déploiement.\n'
            printf '# Renseigne mod-path, décommente les cinq loadmodule ET le bloc\n'
            printf '# « server add » plus bas : sans les modules, IriX ne chargera pas.\n'
            printf '# set mod-path "/chemin/vers/eggdrop/modules/"\n'
            printf '# loadmodule pbkdf2\n# loadmodule channels\n# loadmodule server\n'
            printf '# loadmodule irc\n# loadmodule ctcp\n'
            printf '# server add "%s" %s\n' "$host" "$port"
            printf '# unbind msg - ident   *msg:ident\n'
            printf '# unbind msg - addhost *msg:addhost\n\n'
        fi

        # ── ⚠ LA PARTYLINE N'ÉCOUTE QUE SUR LA BOUCLE LOCALE ────────
        #
        # Elle écoutait sur TOUTES les interfaces (`listen <port> all`). C'est
        # l'accès d'administration du bot : qui l'atteint et connaît un compte
        # du userfile prend la main sur les services du réseau. Constaté le
        # 04/08/2026 en relevant l'exposition — fermée depuis Internet, mais
        # ouverte à tout le réseau local, Wi-Fi invité compris s'il n'est pas
        # isolé.
        #
        # ⚠ LA SYNTAXE EST « listen <ip> <port> <type> », AVEC UN ESPACE.
        # `listen 127.0.0.1:3333 all` — la forme qu'on écrirait d'instinct —
        # est refusée par Eggdrop 1.10 : « invalid IP address argument », et le
        # bot NE DÉMARRE PAS. Vérifié en le cassant, puis lu dans
        # `doc/tcl-commands.doc`, ligne 1922.
        #
        # ⚠ `instance.listen_ip` permet d'ouvrir volontairement — notamment sous
        # Docker, où le port publié arrive par l'interface du conteneur et non
        # par sa boucle locale : y laisser 127.0.0.1 rendrait la console
        # inatteignable malgré le mapping. Le défaut, lui, est le choix sûr.
        _lip="$(_irix_get 'instance.listen_ip')"
        [[ -z "$_lip" || "$_lip" == "null" ]] && _lip="127.0.0.1"

        printf '##### PARTYLINE #####\n'
        printf '# Portée de la console. 127.0.0.1 = cette machine seule.\n'
        printf '# Sous Docker, poser 0.0.0.0 (instance.listen_ip) sans quoi le\n'
        printf '# port publié ne l'"'"'atteint pas.\n'
        # AVANT la ligne listen : un listen raté (port pris) fait refuser TOUTE
        # la configuration — IriX ne démarre pas (mesuré le 27/09/2026). Le
        # fichier sourcé rend « listen » inoffensif sans toucher à la ligne,
        # que le panel et la réserve de robots lisent par « ^listen ».
        printf 'if {[file readable "$::database/%s/ecoute-sure.conf"]} { source "$::database/%s/ecoute-sure.conf" }\n' \
            "$IRIX_CMDS_SUBDIR" "$IRIX_CMDS_SUBDIR"
        printf 'listen %s %s all\n\n' "$_lip" "$(_irix_get 'instance.port')"

        # La console TLS (point 127) : APRÈS l'écoute claire — le panel
        # (`eggdrop_port_de_conf`) et la réserve de robots lisent la PREMIÈRE
        # ligne listen. Le fichier sourcé porte la garde : sans certificat
        # lisible, pas d'écoute TLS, et IriX démarre quand même (mesuré : un
        # certificat absent déclaré en dur TUE Eggdrop au démarrage).
        printf '# Console TLS (point 127) : voir %s/console-tls.conf.\n' "$IRIX_CMDS_SUBDIR"
        printf 'set irix_console_tls_port 3335\n'
        printf 'if {[file readable "$::database/%s/console-tls.conf"]} { source "$::database/%s/console-tls.conf" }\n\n' \
            "$IRIX_CMDS_SUBDIR" "$IRIX_CMDS_SUBDIR"

        printf '##### COMPORTEMENT #####\n'
        printf 'set keep-nick    1\n'
        printf 'set quiet-reject 1\n'
        printf 'set flood-msg    5:60\n'
        printf 'set flood-ctcp   3:60\n'
        printf 'set max-notes    50\n'
        printf 'set notify-onjoin 1\n\n'

        printf '##### LANGUE #####\n'
        if [[ -n "$lang" ]]; then
            printf '# addlang ne trouve rien sans EGG_LANGDIR : les messages du bot\n'
            printf '# repartent alors en anglais, sans qu%saucune erreur ne le signale.\n' "'"
            printf 'set env(EGG_LANGDIR) "%s"\n' "$lang"
            printf 'addlang "french"\n\n'
        else
            printf '# Répertoire de traductions introuvable : addlang est laissé de côté,\n'
            printf '# il échouerait silencieusement. Le bot répondra en anglais.\n\n'
        fi

        printf '##### CHARGEMENT DES MODULES IriX #####\n'
        printf '# $::database pointe la racine de l%sinstance ; les modules vivent dans %s/.\n' "'" "$IRIX_CMDS_SUBDIR"
        printf 'foreach code {%s} {\n' "$entry"
        printf '    if {[file exists "$::database/%s/$code"]} {\n' "$IRIX_CMDS_SUBDIR"
        printf '        source "$::database/%s/$code"\n' "$IRIX_CMDS_SUBDIR"
        printf '    } else {\n'
        printf '        die "Fichier $code manquant dans $::database/%s/"\n' "$IRIX_CMDS_SUBDIR"
        printf '    }\n'
        printf '}\n\n'

        printf '##### SALONS #####\n'
        printf 'foreach chan [channels] {\n'
        printf '    catch { putquick "JOIN $chan" }\n'
        printf '}\n'
    } > "$tmp" || { rm -f "$tmp"; _err "Écriture de la configuration impossible."; return 1; }

    mv "$tmp" "$dest" || { rm -f "$tmp"; _err "Installation de la configuration impossible."; return 1; }
    chmod 640 "$dest"
    _ok_f "Configuration Eggdrop écrite : %s" "$dest"
    _irix_console_certificat "$racine/$IRIX_RUNTIME_SUBDIR"

    # L'avertissement est REMONTÉ à l'écran, et pas seulement déposé en
    # commentaire dans un fichier que personne n'ouvrira : sans les modules,
    # le déploiement se termine bien mais le bot ne démarrera pas.
    if [[ -z "$moddir" ]]; then
        _warn "Modules Eggdrop (*.so) introuvables — IriX ne pourra pas démarrer en l'état."
        _info "Ils sont produits par la compilation d'Eggdrop. Vérifie le Panel Services,"
        _info_f "puis renseigne mod-path dans : %s" "$dest"
    else
        _ok_f "Modules Eggdrop : %s" "$moddir"
    fi
}

_irix_deploy() {
    _irix_init
    _section "Déploiement de l'instance IriX"

    local mode entry_script
    mode=$(_irix_get "instance.mode")
    [[ -z "$mode" || "$mode" == "null" ]] && mode="sql"
    entry_script=$(_irix_resolve_entry "$mode")
    if [[ -z "$entry_script" || "$entry_script" == "null" ]]; then
        _err_f "Mode IriX inconnu ou non configuré : %s" "$mode"
        return 1
    fi

    if [[ "$(_irix_get "source.cloned")" != "true" ]] \
        || [[ ! -f "${IRIX_SOURCE_DIR}/${entry_script}" ]]; then
        _info "Sources absentes → synchronisation préalable…"
        _irix_sync_source || return 1
    fi

    local files_count
    files_count=$(jq -r '.git.irix.files | length' "$INIT_BASE" 2>/dev/null || echo 0)
    if [[ -z "$files_count" || "$files_count" == "0" || "$files_count" == "null" ]]; then
        _err "Aucun fichier source IriX défini (git.irix.files) dans la configuration."
        return 1
    fi

    # Le déploiement exige une configuration : sans identifiants de base ni
    # pseudonyme, la conf produite serait un gabarit inerte que le bot
    # refuserait au démarrage.
    if [[ "$(_irix_get 'instance.configured')" != "true" ]]; then
        _err "IriX n'est pas configuré — lance d'abord « Configurer » dans ce panel."
        return 1
    fi

    # Eggdrop est le socle. IriX est un jeu de scripts Tcl qu'il charge : sans
    # le binaire, il n'y a rien à démarrer une fois les fichiers copiés.
    local egg
    if ! egg=$(_irix_eggdrop_bin); then
        _err "Binaire Eggdrop introuvable — IriX ne peut pas tourner sans lui."
        _info "Installe Eggdrop depuis le Panel Services, puis relance ce déploiement."
        return 1
    fi
    _ok_f "Eggdrop trouvé : %s" "$egg"

    # L'arborescence suit celle décrite dans Markdown/ARCHITECTURE.md et
    # employée en production : les modules dans cmds/, la configuration et les
    # fichiers d'état dans runtime/. Le panel déposait tout à plat à la racine
    # de l'instance — les constantes IRIX_CMDS_SUBDIR et IRIX_RUNTIME_SUBDIR
    # existaient déjà pour cela, mais aucun code ne s'en servait.
    local dir cmds runtime
    dir=$(_irix_install_dir)
    cmds="${dir}/${IRIX_CMDS_SUBDIR}"
    runtime="${dir}/${IRIX_RUNTIME_SUBDIR}"
    mkdir -p "$cmds" "$runtime" "${dir}/logs" "${dir}/.backup" \
        || { _err "Impossible de créer l'arborescence dans ${dir}"; return 1; }

    # Ne déploie que les fichiers pertinents pour le mode actif (+ cmds, toujours utile).
    local i local_name role n=0
    for (( i=0; i<files_count; i++ )); do
        local_name=$(jq -r ".git.irix.files[$i].local" "$INIT_BASE")
        role=$(jq -r ".git.irix.files[$i].role // empty" "$INIT_BASE")
        [[ -z "$local_name" || "$local_name" == "null" ]] && continue

        case "$role" in
            cmds|entry_"$mode"|module_"$mode") ;;
            *) continue ;;
        esac

        [[ -f "${IRIX_SOURCE_DIR}/${local_name}" ]] || { _err "Source manquante : ${local_name}"; return 1; }

        if [[ -f "${cmds}/${local_name}" ]]; then
            cp -p "${cmds}/${local_name}" "${dir}/.backup/${local_name}.$(date +%s)" 2>/dev/null || true
        fi

        cp -p "${IRIX_SOURCE_DIR}/${local_name}" "${cmds}/${local_name}" \
            || { _err "Copie ${local_name} échouée."; return 1; }
        n=$(( n + 1 ))
    done
    _ok_f "%s fichier(s) déployé(s) dans %s" "${n}" "${cmds}"

    _irix_ecrire_conf "$runtime/irix.conf" "$dir" "$entry_script" || return 1

    # ── 🔴 LE USERFILE, AU PREMIER DÉPLOIEMENT — 29/09/2026 ─────────────────
    #
    # Mesuré sur une installation tierce : IriX lancé par son unité (sans `-m`)
    # mourait sur « LISTE DES UTILISATEURS NON TROUVÉE ! (essayez './eggdrop
    # -m …') ». La production a le sien depuis toujours ; aucune installation
    # neuve n'en avait. On pose un userfile réduit à son EN-TÊTE (aucun compte) :
    # mesuré le même jour, Eggdrop l'accepte et démarre. ⚠ Pas `-m` : ce mode
    # attend qu'un inconnu se déclare propriétaire par /msg — la porte qu'OverOp
    # referme en ne l'employant qu'au premier lancement, sous les yeux de
    # l'exploitant. Un userfile existant n'est JAMAIS touché.
    local uf="${runtime}/IriX.user" v=""
    if [[ ! -f "$uf" ]]; then
        v="$("$egg" -v 2>/dev/null | grep -oE 'v[0-9][0-9A-Za-z.+-]*' | head -1)" || v=""
        ( umask 077
          printf '#4v: eggdrop %s -- %s -- written %s\n' "${v:-v1.10}" \
              "$(_irix_get 'irc.botnick')" "$(LC_ALL=C date '+%a %b %e %H:%M:%S %Y')" > "$uf" )
        _ok "Userfile d'IriX créé, vide (aucun compte) : sans lui, Eggdrop refuse de démarrer."
    fi

    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _irix_set \
        '.instance.installed = true | .instance.mode = $m | .instance.config_file = $c
         | .instance.entry_script = $e | .instance.eggdrop_bin = $b | .instance.last_deploy = $ts' \
        --arg m "$mode" --arg c "${runtime}/irix.conf" --arg e "$entry_script" \
        --arg b "$egg" --arg ts "$ts"

    _log_entry "irix" "deploy" "instance déployée dans $dir (mode=$mode)"
    _ok_f "Instance IriX déployée dans : %s (mode : %s)" "$dir" "$mode"
}

# =============================================================================
# Cycle de vie : démarrage / arrêt / redémarrage
# =============================================================================

# -----------------------------------------------------------------------------
# _irix_start
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# _irix_start — lance IriX, c'est-à-dire EGGDROP avec la configuration d'IriX.
#
# Le panel lançait « tclsh <script d'entrée> ». C'était sans issue : les
# modules d'IriX sont écrits pour Eggdrop et emploient ses commandes — 23 bind,
# 246 putlog, 25 botnick, sans compter putquick et chanlist. tclsh ne connaît
# aucune d'elles, et l'exécution s'arrêtait à la sixième ligne du premier
# module sur « invalid command name "bind" ». Aucune instance déployée par le
# panel n'a donc jamais pu démarrer.
# -----------------------------------------------------------------------------
_irix_start() {
    _irix_init
    local dir egg conf pid_file log_file pid

    if [[ "$(_irix_get "instance.installed")" != "true" ]]; then
        _err "IriX n'est pas déployé. Utilise d'abord « Déployer »."
        return 1
    fi

    if ! egg=$(_irix_eggdrop_bin); then
        _err "Binaire Eggdrop introuvable — IriX ne peut pas démarrer sans lui."
        _info "Installe Eggdrop depuis le Panel Services, puis redéploie."
        return 1
    fi

    read -r dir pid_file log_file < <(_irix_paths)
    conf=$(_irix_get "instance.config_file")
    [[ -z "$conf" || "$conf" == "null" ]] && conf="${dir}/${IRIX_RUNTIME_SUBDIR}/irix.conf"
    [[ -f "$conf" ]] || { _err "Configuration introuvable : $conf — redéploie l'instance."; return 1; }

    local entry="${dir}/${IRIX_CMDS_SUBDIR}/$(_irix_get "instance.entry_script")"
    [[ -f "$entry" ]] || { _err "Point d'entrée introuvable : $entry — redéploie l'instance."; return 1; }

    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file" 2>/dev/null)" 2>/dev/null; then
        _warn "IriX est déjà en cours d'exécution."
        return 0
    fi

    mkdir -p "$(dirname "$pid_file")" "$(dirname "$log_file")"
    _info_f "Démarrage d'IriX via %s…" "${egg}"

    # « -n » garde Eggdrop au premier plan : sans cette option il se détache
    # lui-même, et le PID relevé serait celui d'un processus déjà terminé.
    #
    # Le fichier de configuration est un argument POSITIONNEL. Ne pas le faire
    # précéder de « -c » : chez Eggdrop, -c ne désigne pas une configuration,
    # il demande l'affichage des statistiques de salon toutes les dix secondes,
    # qui viendraient s'intercaler dans le journal.
    ( cd "$dir" && exec nohup "$egg" -n "$conf" > "$log_file" 2>&1 ) &
    pid=$!
    echo "$pid" > "$pid_file"

    # Eggdrop lit sa configuration, ouvre ses fichiers et se connecte : deux
    # secondes avant de conclure, sinon on constate « démarré » sur un
    # processus qui n'a pas encore eu le temps d'échouer.
    sleep 2

    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
        _irix_set '.instance.running = true | .instance.last_start = $ts' \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        _log_entry "irix" "start" "PID=$pid"
        _ok_f "IriX démarré (PID : %s)." "$pid"
    else
        rm -f "$pid_file"
        _irix_report_error "Échec du démarrage"
        _err "IriX s'est arrêté immédiatement. Dernières lignes du journal :"
        [[ -f "$log_file" ]] && tail -8 "$log_file" | sed 's/^/    /'
        return 1
    fi
}

# -----------------------------------------------------------------------------
# _irix_stop
# -----------------------------------------------------------------------------
_irix_stop() {
    local pid_file pid
    read -r _ pid_file _ < <(_irix_paths)

    if [[ ! -f "$pid_file" ]]; then
        _warn "IriX : aucun PID trouvé, probablement déjà arrêté."
        _irix_set '.instance.running = false'
        return 0
    fi

    pid=$(cat "$pid_file")

    # Le contenu du fichier PID est validé avant tout usage : un fichier
    # tronqué ou corrompu passait tel quel à `kill`, dont le comportement
    # devient alors imprévisible.
    if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
        _warn_f "Fichier PID illisible (%s) — il est supprimé." "$pid_file"
        rm -f "$pid_file"
        _irix_set '.instance.running = false'
        return 0
    fi

    if kill -0 "$pid" 2>/dev/null; then
        _info_f "Envoi de SIGTERM au PID %s…" "$pid"
        kill "$pid" 2>/dev/null || true
        sleep 2
        if kill -0 "$pid" 2>/dev/null; then
            _warn "Le processus ne répond pas, envoi de SIGKILL…"
            kill -9 "$pid" 2>/dev/null || true
            sleep 1
        fi
        # On REVÉRIFIE avant d'annoncer l'arrêt. Sans ce contrôle, un PID
        # devenu la propriété d'un autre utilisateur — fichier PID périmé,
        # numéro réutilisé par le système — faisait échouer les deux `kill`
        # en silence, puis le panel supprimait le fichier PID et affichait
        # « IriX arrêté ». Un IriX pouvait donc continuer à tourner, désormais
        # hors de portée du panel qui venait de perdre sa trace.
        if kill -0 "$pid" 2>/dev/null; then
            _err_f "Le PID %s survit à SIGKILL — IriX n'a PAS été arrêté." "$pid"
            _info "Le fichier PID est conservé : le processus reste identifiable."
            _irix_report_error "Arrêt impossible du PID $pid"
            return 1
        fi
    else
        _warn_f "IriX : PID %s introuvable dans les processus actifs." "$pid"
    fi

    rm -f "$pid_file"
    _irix_set '.instance.running = false | .instance.last_stop = $ts' \
        --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _log_entry "irix" "stop" "PID=$pid"
    _ok "IriX arrêté."
}

# -----------------------------------------------------------------------------
# _irix_restart
# -----------------------------------------------------------------------------
_irix_restart() {
    _info "Redémarrage de IriX…"
    _irix_stop
    sleep 1
    _irix_start
}

# -----------------------------------------------------------------------------
# _irix_status
# -----------------------------------------------------------------------------
_irix_status() {
    local pid_file
    read -r _ pid_file _ < <(_irix_paths)
    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
        echo "running"
    else
        echo "stopped"
    fi
}

# -----------------------------------------------------------------------------
# _irix_status_icon
# -----------------------------------------------------------------------------
_irix_status_icon() {
    local installed enabled running has_error icon suffix=""

    installed=$(_irix_get "instance.installed")
    enabled=$(_irix_get   "instance.enabled")
    running=$(_irix_status)
    has_error=$(jq -r '(.errors | length) > 0' "$(_irix_file)" 2>/dev/null)

    if   [[ "$installed" != "true"    ]]; then icon="$(_ui_badge off  'non déployé')"
    elif [[ "$running"   == "running" ]]; then icon="$(_ui_badge run  'en marche')"
    elif [[ "$enabled"   == "true"    ]]; then icon="$(_ui_badge warn 'arrêté')"
    else                                       icon="$(_ui_badge err  'désactivé')"
    fi

    [[ "$has_error" == "true" ]] && suffix+=" ${RED}⚑${RESET}${DIM} erreurs${RESET}"
    printf '%s' "${icon}${suffix}"
}

# -----------------------------------------------------------------------------
# _irix_logs
# -----------------------------------------------------------------------------
_irix_logs() {
    local log_file
    read -r _ _ log_file < <(_irix_paths)
    [[ -f "$log_file" ]] || { _warn "Aucun fichier de log trouvé pour IriX."; return 1; }
    _info "Logs de IriX (Ctrl+C pour quitter) :"
    tail -f "$log_file"
}

# -----------------------------------------------------------------------------
# _irix_toggle / _irix_log_toggle
# -----------------------------------------------------------------------------
_irix_toggle() {
    local current new_state ts
    current=$(_irix_get "instance.enabled")
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    # Le message final décrit ce qui s'est RÉELLEMENT passé, et l'état n'est
    # écrit qu'ensuite. La version précédente annonçait « IriX activé
    # (processus démarré) » sans jamais tester _irix_start : un interpréteur
    # absent, un script d'entrée introuvable ou un processus mort dans la
    # seconde produisaient la même confirmation verte, avec enabled=true déjà
    # enregistré. Le panel affichait ensuite « Activé : true / En cours :
    # stopped » sans que rien n'ait expliqué pourquoi.
    if [[ "$current" == "true" ]]; then
        # Désactivation : on coupe réellement le processus s'il tourne.
        new_state=false
        if [[ "$(_irix_status)" == "running" ]]; then
            if _irix_stop; then
                _irix_set '.instance.enabled = false | .instance.last_stop = $ts' --arg ts "$ts"
                _ok "IriX désactivé — processus arrêté."
            else
                _err "Arrêt du processus impossible — IriX reste marqué actif."
                return 1
            fi
        else
            _irix_set '.instance.enabled = false | .instance.last_stop = $ts' --arg ts "$ts"
            _ok "IriX désactivé — aucun processus ne tournait."
        fi
    else
        # Activation : on déploie si besoin, puis on démarre réellement le processus.
        new_state=true
        if [[ "$(_irix_get "instance.installed")" != "true" ]]; then
            _info "Instance non déployée → déploiement automatique."
            _irix_deploy || { _err "Activation annulée : déploiement échoué."; return 1; }
        fi
        if _irix_start; then
            _irix_set '.instance.enabled = true | .instance.last_start = $ts' --arg ts "$ts"
            _ok "IriX activé — processus démarré."
        else
            _err "Démarrage échoué — IriX n'a pas été activé."
            _info "Consulte les journaux (panel IriX, choix 8) pour la cause."
            return 1
        fi
    fi

    _log_entry "irix" "toggle" "enabled=${new_state}"
}

_irix_log_toggle() {
    local current new_state
    current=$(_irix_get "instance.log.enabled")
    [[ "$current" == "true" ]] && new_state=false || new_state=true
    _irix_set '.instance.log.enabled = $v' --argjson v "$new_state"
    _ok_f "IriX : logs → %s" "${new_state}"
}

# =============================================================================
# NOTE — sémantique du bouton [4] Activer / Désactiver (_irix_toggle) :
#   • "enabled" représente l'intention : IriX doit-il tourner ou non.
#   • Activer  -> déploie l'instance si nécessaire, puis démarre le processus.
#   • Désactiver -> arrête le processus s'il tourne, et mémorise l'intention.
#   Les options [5]/[6]/[7] (Démarrer/Arrêter/Redémarrer) restent disponibles
#   pour un contrôle ponctuel du processus sans changer cette intention
#   (ex : redémarrage rapide en cas de plantage, sans toucher au statut
#   "activé" affiché dans les rapports).
# =============================================================================

# =============================================================================
# Gestion des erreurs
# =============================================================================
_irix_report_error() {
    local msg="$1" ts
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _irix_set '.errors += [{ ts: $ts, msg: $msg }]' --arg ts "$ts" --arg msg "$msg"
    _log_entry "irix" "error" "$msg"
}

_irix_show_errors() {
    _section "Erreurs — IriX"
    local count; count=$(jq -r '.errors | length' "$(_irix_file)" 2>/dev/null)
    if [[ "${count:-0}" -eq 0 ]]; then
        _ok "Aucune erreur enregistrée."
    else
        jq -r '.errors[] | "  [\(.ts)]  \(.msg)"' "$(_irix_file)"
    fi
}

_irix_clear_errors() {
    _irix_set '.errors = []'
    _ok "IriX : historique des erreurs effacé."
}

# =============================================================================
# Mise à jour (sync + redéploiement, avec redémarrage si actif)
# =============================================================================
_irix_update() {
    _section "Mise à jour IriX"
    local was_running=false
    [[ "$(_irix_status)" == "running" ]] && was_running=true

    _irix_sync_source || return 1
    _irix_deploy       || return 1

    # Le redémarrage fait partie de la mise à jour : s'il échoue, elle n'est
    # pas « terminée ». Le cas n'a rien de théorique — une erreur de syntaxe
    # Tcl dans la source fraîchement récupérée empêche IriX de repartir. Sans
    # ce test, le panel laissait IriX ARRÊTÉ en annonçant « ✔ Mise à jour IriX
    # terminée. », et c'est le réseau qui l'apprenait à sa place.
    if $was_running; then
        _info "IriX était en cours d'exécution → redémarrage."
        if ! _irix_restart; then
            _err "Sources à jour, mais IriX n'a pas redémarré — il est ARRÊTÉ."
            _info "Consulte les journaux (panel IriX, choix 8) avant de relancer."
            return 1
        fi
    fi

    _ok "Mise à jour IriX terminée."
}

# =============================================================================
# Interface TUI
# =============================================================================
_panel_irix() {
    _irix_init
    local choice

    while true; do
        _ui_width
        _ui_title "IriX Services" "module Tcl hébergé par Eggdrop — source et instance séparées"

        local n_err
        n_err=$(jq -r '.errors | length' "$(_irix_file)" 2>/dev/null || echo 0)

        _ui_top
        _ui_kv "État" "$(_irix_status_icon)"
        _ui_mid
        _ui_kv "Dossier source"   "$IRIX_SOURCE_DIR"
        _ui_kv "Dossier instance" "$(_irix_install_dir)"
        _ui_kv "Moteur"           "$(_irix_get "instance.mode")"
        _ui_kv "Sources synchro." "$([[ "$(_irix_get 'source.cloned')" == "true" ]] \
                                      && _ui_badge ok 'oui' || _ui_badge off 'non')"
        _ui_kv "Déployé"          "$([[ "$(_irix_get 'instance.installed')" == "true" ]] \
                                      && _ui_badge ok 'oui' || _ui_badge off 'non')"
        _ui_kv "Erreurs"          "$([[ "${n_err:-0}" -gt 0 ]] \
                                      && _ui_badge err "$n_err" || _ui_badge ok 'aucune')"

        _ui_head "Sources"
        _ui_item 1  "Synchroniser depuis le dépôt"
        _ui_item 10 "Vérifier le SHA-256 des sources"
        _ui_item 14 "Changer de moteur  ${DIM}(SQL / Standard)${RESET}"

        _ui_head "Instance"
        _ui_item 15 "Configurer  ${DIM}(identité du bot, serveur, base)${RESET}"
        _ui_item 2  "Déployer / redéployer"
        _ui_item 3  "Changer le dossier d'instance"
        _ui_item 13 "Mettre à jour  ${DIM}(synchro + redéploiement)${RESET}"

        _ui_head "Cycle de vie"
        _ui_item 4 "Activer / désactiver"
        _ui_item 5 "Démarrer"
        _ui_item 6 "Arrêter"
        _ui_item 7 "Redémarrer"

        _ui_head "Diagnostic"
        _ui_item 8  "Journaux en direct"
        _ui_item 9  "Activer / désactiver les journaux"
        _ui_item 11 "Voir les erreurs"
        _ui_item 12 "Effacer les erreurs"
        _ui_item 0  "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        case "$choice" in
            1) _irix_sync_source || true ;;
            2) _irix_deploy || true ;;
            3) _irix_set_install_dir || true ;;
            4) _irix_toggle || true ;;
            5) _irix_start || true ;;
            6) _irix_stop || true ;;
            7) _irix_restart || true ;;
            8) _irix_logs || true ;;
            9) _irix_log_toggle || true ;;
            10) _irix_verify_source_sha256 || true ;;
            11) _irix_show_errors || true ;;
            12) _irix_clear_errors || true ;;
            13) _irix_update || true ;;
            14) _irix_choose_mode || true ;;
            15) _irix_configure || true ;;
            0)  return ;;
            *) _warn "Choix invalide." || true ;;
        esac
        _pause
    done
}

# =============================================================================
# ══════════════════════════════════════════════════════════════
#  OVEROP — les robots de prêt (cahier : Markdown/PRET-ROBOT.md)
# ══════════════════════════════════════════════════════════════
# =============================================================================
#
# Ce que pose cette section : un ou plusieurs Eggdrop AUTONOMES, connectés au
# réseau comme des clients ordinaires, qui rendent l'op à un robot enregistré
# sur présentation d'un secret (§3). Ils n'ouvrent aucun lien serveur-à-serveur,
# ne touchent jamais MySQL, et n'ont aucun privilège réseau : ils posent une
# question à OverBot, et agissent sur la réponse.
#
# Un seul code Tcl, plusieurs instances (§3.1). Ce qui change d'un robot à
# l'autre tient en trois valeurs — le pseudonyme, le port du telnet, le dossier
# de travail. Une correction du script vaut donc pour tous.
#
# ── Ce que l'essai réel du 03/08/2026 a appris ──────────────────────────────
#
# Chacun des huit points ci-dessous a coûté un échec de démarrage, et chacun
# est traité par une ligne précise de cette section. Ils sont rappelés ici
# parce qu'aucun ne se devine, et — c'est le pire — parce qu'aucun ne produit
# un message d'erreur qui désigne sa cause.
#
#   1. `set mod-path` doit être ABSOLU et désigner les modules de l'Eggdrop
#      installé. Sans lui, AUCUN module ne charge, et le message qui suit est
#      trompeur : « bad type » sur un `bind msg`, alors que la table `msg` est
#      simplement créée par le module `irc`, qui n'a jamais chargé.
#   2. `loadmodule pbkdf2` EN PREMIER, et il n'est pas facultatif : sans module
#      de chiffrement, Eggdrop refuse de créer son userfile.
#   3. PAS de `loadmodule dns` : l'Eggdrop de cette machine est compilé avec le
#      cœur DNS threadé, et le module refuse alors de se charger.
#   4. Eggdrop cherche `language/`, `help/` et `text/` RELATIVEMENT au
#      répertoire courant. Sans liens vers ceux de l'installation, il perd la
#      table de ses propres messages et journalise des codes — « MSG536 » —,
#      y compris à la ligne qui dirait pourquoi il s'arrête.
#   5. `set owner` est obligatoire : sans lui, Eggdrop refuse de démarrer.
#   6. Premier lancement avec `-m`, qui crée le userfile ; les suivants SANS —
#      `-m` l'écraserait, et le robot perdrait ses propriétaires.
#   7. ⚠ Ne JAMAIS tuer par `pkill -f overop.conf` : Eggdrop est lancé avec un
#      chemin de configuration relatif, le motif absolu ne le trouve donc pas,
#      l'ancienne instance survit et garde le port du telnet — la nouvelle
#      meurt sur « Address already in use ». On tue par le PID du pidfile, et
#      on revérifie que le processus a bien disparu.
#   8. ⚠ `maxperip 3` : au-delà de trois connexions depuis la même adresse IP,
#      IriX comprise, le serveur refuse — et le message n'apparaît que côté
#      robot. On AVERTIT ; on ne touche pas à overtchat.conf.
#
# Les points 1 à 5 sont vérifiés sur le fichier PRODUIT, par
# `_overop_verifier_conf` : un déploiement qui annonce « configuration écrite »
# sans dire si elle est démarrable serait exactement la confirmation
# mensongère que ce projet traque partout ailleurs.
# =============================================================================

_overop_file() { echo "$OVEROP_FILE"; }

# -----------------------------------------------------------------------------
# _overop_init — crée Config/overop.json si absent.
#
# `robots` part VIDE, et c'est délibéré : y inscrire OverOp-H, -Q et -S d'avance
# ferait afficher trois robots là où aucun n'est posé. Le registre ne contient
# que ce qui a été réellement déployé — même doctrine que le §4.4, où l'état
# jamais observé vaut « inconnu » et non « en ligne ».
# -----------------------------------------------------------------------------
_overop_init() {
    [[ -f "$(_overop_file)" ]] && return 0

    mkdir -p "$(dirname "$(_overop_file)")" "$OVEROP_SOURCE_DIR"

    jq -n \
        --arg ts   "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --arg src  "$OVEROP_SOURCE_DIR" \
        --arg base "$OVEROP_DEFAULT_BASE_DIR" \
        '{
            meta:   { created: $ts, version: "1.0" },
            source: { dir: $src, cloned: false, last_sync: null, sha256: {} },
            defaults: {
                base_dir: $base,
                host:     "127.0.0.1",
                port:     6667,
                owner:    null,
                network:  null,
                admin:    null,
                realname: null
            },
            robots: {}
        }' > "$(_overop_file)"

    # ⚠ 600, comme les cinq autres fichiers d'état de `Config/` — `access`,
    # `eggdrop`, `irix.log`, `services`, `update_state`. Il naissait en 644,
    # constaté le 20/08/2026 en le voyant apparaître pour la première fois.
    # Il ne porte aucun secret aujourd'hui, mais il portera le propriétaire, le
    # réseau et l'administrateur de chaque robot prêté : la protection se pose
    # AVANT que le contenu la mérite, pas après.
    chmod 600 "$(_overop_file)" 2>/dev/null || true

    _sha256_store "$(_overop_file)" "$INIT_BASE" "overop"
    _ok "Registre des robots OverOp initialisé."
}

# -----------------------------------------------------------------------------
# _overop_get <champ> / _overop_bot_get <robot> <champ>
#
# ⚠ PAS de « // empty ». L'opérateur d'alternative de jq considère `false`
# comme une absence, au même titre que `null` : `.installed // empty` rend une
# chaîne VIDE sur un robot explicitement marqué non déployé. Le déclaré
# « non » devenait alors indiscernable du jamais renseigné — exactement la
# confusion que le §4.4 interdit pour l'état des robots, et un banc l'a
# attrapée sur `running=false` lu comme « rien ».
#
# `if . == null then empty else . end` ne laisse tomber que le vrai vide.
# -----------------------------------------------------------------------------
_overop_get() {
    jq -r ".${1} | if . == null then empty else . end" "$(_overop_file)" 2>/dev/null || true
}

_overop_set() {
    local filter="$1"; shift
    _jq_update "$(_overop_file)" "$filter" "$@"
    _sha256_store "$(_overop_file)" "$INIT_BASE" "overop"
}

# Champ d'un robot. Le nom passe par `--arg` et jamais par interpolation dans
# le filtre : un pseudonyme est une donnée saisie, et un filtre jq bâti par
# concaténation se détourne comme n'importe quelle requête.
_overop_bot_get() {
    jq -r --arg n "$1" --arg f "$2" \
       '.robots[$n][$f] | if . == null then empty else . end' \
       "$(_overop_file)" 2>/dev/null || true
}

_overop_bot_set() {
    local nom="$1"; shift
    local filter="$1"; shift
    _jq_update "$(_overop_file)" --arg n "$nom" "$filter" "$@"
    _sha256_store "$(_overop_file)" "$INIT_BASE" "overop"
}

_overop_noms()   { jq -r '.robots | keys[]' "$(_overop_file)" 2>/dev/null || true; }
_overop_compte() { jq -r '.robots | length' "$(_overop_file)" 2>/dev/null || echo 0; }

# -----------------------------------------------------------------------------
# _overop_connu <nom> — le robot est-il au registre ?
#
# ⚠ Un robot INCONNU est refusé, et non traité comme un robot sans instance.
# Les deux se ressemblent — ni dossier, ni PID — et seule cette question les
# sépare. Le cahier relève le même piège au §8.1bis : sans elle, un `OPBOTDEL`
# sur un nom inventé répondait « retiré », et un ordre sans effet passait pour
# une réussite.
# -----------------------------------------------------------------------------
_overop_connu() {
    local nom="${1:-}"
    [[ -n "$nom" ]] || { _err "Aucun robot indiqué."; return 1; }
    if [[ "$(jq -r --arg n "$nom" '(.robots // {}) | has($n)' "$(_overop_file)" 2>/dev/null)" != "true" ]]; then
        _err_f "Robot inconnu : %s" "$nom"
        _info "Les robots déclarés : $(_overop_noms | tr '\n' ' ')"
        return 1
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _overop_nom_valide <nom>
#
# Le nom sert à deux choses qui ne pardonnent pas la même faute : un pseudonyme
# IRC, et un nom de dossier. On refuse donc tout ce qui pourrait sortir du
# second — un « ../ » glissé dans un pseudonyme ferait écrire l'instance
# ailleurs que sous la racine choisie.
#
# La règle n'impose PAS le préfixe `OverOp-` : le cahier (§6) dit le nom
# réversible d'un `sed`, et un exploitant tiers nomme ses robots comme il veut.
# C'est la proposition par défaut qui suit le thème, pas le contrôle.
# -----------------------------------------------------------------------------
_overop_nom_valide() {
    local nom="${1:-}"
    [[ "$nom" == *..* ]] && return 1
    [[ "$nom" =~ ^[A-Za-z][A-Za-z0-9_-]{1,29}$ ]] || return 1
    return 0
}

# -----------------------------------------------------------------------------
# _overop_nom_suivant — le nom proposé pour le prochain robot.
#
# Thème `OverOp-<lettre>` (§7.6). `H`, `Q` et `S` d'abord — les trois initiales
# retenues au cahier —, puis la première lettre libre de l'alphabet. Une règle
# mécanique est indispensable dès lors qu'un nom doit être engendré sans
# humain : un nom « choisi avec goût » ne s'automatise pas.
# -----------------------------------------------------------------------------
_overop_nom_suivant() {
    local l pris
    pris=" $(_overop_noms | tr '\n' ' ') "
    for l in H Q S A B C D E F G I J K L M N O P R T U V W X Y Z; do
        if [[ "$pris" != *" OverOp-$l "* ]]; then
            printf 'OverOp-%s' "$l"
            return 0
        fi
    done
    _err "Plus une seule lettre libre dans le thème OverOp-<lettre>."
    return 1
}

_overop_base_dir() {
    local d; d=$(_overop_get 'defaults.base_dir')
    [[ -z "$d" || "$d" == "null" ]] && d="$OVEROP_DEFAULT_BASE_DIR"
    printf '%s' "$d"
}

# Dossier d'une instance. LU dans le registre quand il y figure : un robot posé
# avant un changement de racine doit rester joignable là où il est réellement.
_overop_dir() {
    local nom="$1" d
    d=$(_overop_bot_get "$nom" dir)
    if [[ -z "$d" ]]; then
        d="$(_overop_base_dir)/$(printf '%s' "$nom" | tr '[:upper:]' '[:lower:]')"
    fi
    printf '%s' "$d"
}

# -----------------------------------------------------------------------------
# _overop_paths <nom> — affiche : <dossier> <pidfile> <journal>
#
# ⚠ LE PIDFILE EST LA SEULE PRISE SUR LE PROCESSUS. Eggdrop est lancé depuis le
# dossier de l'instance, avec « overop.conf » en chemin RELATIF : aucun motif
# absolu ne le retrouve dans la table des processus. C'est ainsi qu'un
# `pkill -f overop.conf` a laissé survivre une instance qui gardait le port du
# telnet, et que la suivante est morte sur « Address already in use ».
#
# Les trois chemins suivent `set pidfile` et `logfile` de la configuration
# produite, eux aussi relatifs au dossier de l'instance.
# -----------------------------------------------------------------------------
_overop_paths() {
    local d; d="$(_overop_dir "$1")"
    printf '%s %s/overop.pid %s/logs/overop.log\n' "$d" "$d" "$d"
}

# -----------------------------------------------------------------------------
# _overop_port_libre — le premier port de telnet non attribué.
#
# « Non attribué », et non « libre sur la machine » : c'est le registre qui
# fait foi, deux robots ne devant pas se disputer un port même à l'arrêt. Le
# port d'IriX est écarté au passage — il est du même ordre de grandeur.
# -----------------------------------------------------------------------------
_overop_port_libre() {
    local p="$OVEROP_PORT_BASE" pris irixp
    pris=" $(jq -r '[.robots[].port // empty] | join(" ")' "$(_overop_file)" 2>/dev/null || true) "
    irixp="$(_irix_get 'instance.port' 2>/dev/null || true)"
    while (( p <= OVEROP_PORT_BASE + 200 )); do
        if [[ "$pris" != *" $p "* && "$p" != "$irixp" ]]; then
            printf '%s' "$p"
            return 0
        fi
        p=$(( p + 1 ))
    done
    printf '%s' "$OVEROP_PORT_BASE"
    return 1
}

# -----------------------------------------------------------------------------
# _overop_connexions_ip — combien de clients cette machine ouvre vers le serveur.
#
# Les robots DÉCLARÉS, plus IriX s'il est déployé ici. C'est un déclaré, pas un
# observé : rien ne compte les connexions réelles côté serveur, et l'écran doit
# le dire ainsi plutôt que de laisser croire à une mesure.
# -----------------------------------------------------------------------------
_overop_connexions_ip() {
    local n irix
    n=$(_overop_compte)
    irix=$(_irix_get 'instance.installed' 2>/dev/null || true)
    if [[ "$irix" == "true" ]]; then n=$(( n + 1 )); fi
    printf '%s' "$n"
}

# -----------------------------------------------------------------------------
# _overop_avertir_maxperip [robots à venir]
#
# ⚠ L'AVERTISSEMENT QUE PERSONNE D'AUTRE NE DONNERA. Le serveur limite à trois
# connexions par adresse IP (`class clients` d'overtchat.conf), IriX comprise.
# Le robot de trop se voit refuser la connexion, ET LE MESSAGE N'APPARAÎT QUE
# CÔTÉ ROBOT : côté exploitant, rien — seulement un bot qui ne se connecte pas.
#
# Cet installateur ne corrige PAS overtchat.conf. Relever une limite de
# sécurité du serveur au nom de quelqu'un qui installait un robot serait une
# décision prise à sa place, sur un fichier qui n'appartient pas à ce panel.
# On dit quoi faire, et où.
# -----------------------------------------------------------------------------
_overop_avertir_maxperip() {
    local ajout="${1:-0}" total
    total=$(( $(_overop_connexions_ip) + ajout ))

    if (( total <= OVEROP_MAXPERIP )); then
        _info_f "Connexions déclarées depuis cette machine : %s/%s (IriX comprise)." "${total}" "${OVEROP_MAXPERIP}"
        return 0
    fi

    _warn_f "⚠ %s connexions déclarées depuis cette adresse IP, pour un plafond de %s." "${total}" "${OVEROP_MAXPERIP}"
    _warn "Le serveur REFUSERA les connexions au-delà — et il ne le dira qu'au robot."
    _info "Le remède est un bloc « allow » dédié à l'adresse des robots dans overtchat.conf,"
    _info "avec sa propre classe et un maxperip relevé. Les blocs allow sont évalués dans"
    _info "l'ordre : le premier qui correspond gagne."
    _info "Cet installateur n'y touche pas — c'est la configuration du serveur, pas la sienne."
    return 1
}

# -----------------------------------------------------------------------------
# _overop_racine_eggdrop — le dossier qui porte language/, help/ et text/.
#
# `_install_eggdrop` compile sans « make install » : ces trois répertoires
# restent dans l'arbre de compilation, à côté du binaire. On part donc du
# binaire, puis on retombe sur les emplacements connus — même chaîne de secours
# que `_irix_langdir`, qui ne cherche que `language/`.
# -----------------------------------------------------------------------------
_overop_racine_eggdrop() {
    local d
    for d in "$(dirname "$(_irix_eggdrop_bin 2>/dev/null || echo /nonexistent)")" \
             "$(_svc_get eggdrop install_dir 2>/dev/null || true)" \
             "$TARGET_HOME/IriX"; do
        [[ -n "$d" && "$d" != "null" && -d "$d/language" ]] && { printf '%s' "$d"; return 0; }
    done
    return 1
}

# -----------------------------------------------------------------------------
# _overop_lier_ressources <dossier d'instance>
#
# ⚠ LES LIENS SANS LESQUELS EGGDROP PARLE EN « MSG536 ».
#
# Eggdrop résout `language/`, `help/` et `text/` depuis le RÉPERTOIRE COURANT,
# et non depuis celui de son binaire. Une instance posée dans son propre
# dossier ne les trouve donc pas et perd la table de ses propres messages : le
# journal se remplit de codes, y compris à la ligne qui dirait pourquoi le bot
# s'arrête. C'est la panne la plus longue à diagnostiquer de l'essai du
# 03/08/2026, précisément parce que le diagnostic était illisible.
#
# `ln -sfn` et non `ln -s` : un redéploiement ne doit ni échouer sur « File
# exists », ni créer un lien À L'INTÉRIEUR du lien précédent — ce que fait
# `ln -sf` sur une cible qui est déjà un lien vers un répertoire.
# -----------------------------------------------------------------------------
_overop_lier_ressources() {
    local dir="$1" racine sous manque=0
    if ! racine="$(_overop_racine_eggdrop)"; then
        _warn "language/, help/ et text/ d'Eggdrop introuvables."
        _warn "Sans eux, le robot journalise en codes (« MSG536 ») — y compris ses erreurs."
        return 1
    fi
    for sous in language help text; do
        if [[ -d "$racine/$sous" ]]; then
            ln -sfn "$racine/$sous" "${dir}/${sous}" 2>/dev/null \
                || { _err "Lien impossible : ${dir}/${sous}"; manque=$(( manque + 1 )); }
        else
            _warn_f "%s/%s absent — le robot pourrait journaliser en codes." "$racine" "$sous"
            manque=$(( manque + 1 ))
        fi
    done
    if (( manque == 0 )); then
        _ok_f "Messages d'Eggdrop liés : language, help, text %s(depuis %s)%s" "${DIM}" "${racine}" "${RESET}"
        return 0
    fi
    return 1
}

# -----------------------------------------------------------------------------
# _overop_sync_source — télécharge git.overop.files depuis le dépôt.
#
# Même mécanique que `_irix_sync_source`, sur une liste DISTINCTE : le code des
# robots ne se déploie pas dans `cmds/` et ne doit pas suivre le sort des
# modules d'IriX (§4).
# -----------------------------------------------------------------------------
_overop_sync_source() {
    _overop_init
    _section "Synchronisation des sources OverOp"

    local brutegit
    brutegit=$(jq -r '.git.brutegit' "$INIT_BASE")
    if [[ -z "$brutegit" || "$brutegit" == "null" ]]; then
        _err "URL du dépôt (git.brutegit) introuvable dans la configuration."
        return 1
    fi

    local files_count
    files_count=$(jq -r '.git.overop.files | length' "$INIT_BASE" 2>/dev/null || echo 0)
    if [[ -z "$files_count" || "$files_count" == "0" || "$files_count" == "null" ]]; then
        _err "Aucun fichier source OverOp défini (git.overop.files) dans la configuration."
        return 1
    fi

    mkdir -p "$OVEROP_SOURCE_DIR"

    local ok=true i repo local_name hash
    for (( i=0; i<files_count; i++ )); do
        repo=$(jq -r ".git.overop.files[$i].repo"  "$INIT_BASE")
        local_name=$(jq -r ".git.overop.files[$i].local" "$INIT_BASE")

        if [[ -z "$repo" || "$repo" == "null" || -z "$local_name" || "$local_name" == "null" ]]; then
            _err_f "Entrée git.overop.files[%s] invalide (repo/local manquant)." "$i"
            ok=false; continue
        fi

        if _download "${brutegit}/${repo}" "${OVEROP_SOURCE_DIR}/${local_name}"; then
            hash=$(_sha256 "${OVEROP_SOURCE_DIR}/${local_name}" 2>/dev/null || echo "")
            _overop_set '.source.sha256[$k] = $h' --arg k "$local_name" --arg h "$hash"
        else
            ok=false
        fi
    done

    if ! $ok; then
        _err "Synchronisation incomplète — les robots ne seront pas déployés à partir de ces sources."
        return 1
    fi

    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _overop_set '.source.cloned = true | .source.last_sync = $ts' --arg ts "$ts"
    _log_entry "overop" "sync" "sources synchronisées"
    _ok_f "Sources OverOp synchronisées dans %s" "${OVEROP_SOURCE_DIR}"
}

# -----------------------------------------------------------------------------
# _overop_reglages — les valeurs communes à tous les robots.
#
# Communes parce qu'elles le sont vraiment : même machine, même serveur, même
# propriétaire. Les redemander à chaque robot serait trois occasions de
# répondre trois choses différentes.
#
# Les valeurs par défaut sont REPRISES d'IriX et de Config/identity.json quand
# ils existent : un robot du réseau doit annoncer le même réseau que le site et
# se connecter au même serveur — sinon le premier /WHOIS le contredit.
# -----------------------------------------------------------------------------
_overop_reglages() {
    _overop_init
    _ui_width
    _ui_title "Réglages communs des robots OverOp" "serveur, propriétaire, racine des instances"

    local ident="${BASE_DIR}/Config/identity.json"
    local d_nom="" d_real=""
    if [[ -f "$ident" ]]; then
        d_nom=$(jq -r '.name // empty' "$ident" 2>/dev/null || true)
        d_real=$(jq -r '.irc.bot_realname // empty' "$ident" 2>/dev/null || true)
    fi

    # IriX est déjà configuré sur cette machine dans le cas courant : son
    # serveur et son propriétaire sont les bons, et ils ont déjà été saisis.
    local i_host i_port i_owner i_net
    i_host=$(_irix_get 'irc.host'    2>/dev/null || true)
    i_port=$(_irix_get 'irc.port'    2>/dev/null || true)
    i_owner=$(_irix_get 'irc.owner'  2>/dev/null || true)
    i_net=$(_irix_get 'irc.network'  2>/dev/null || true)
    [[ -z "$i_owner" ]] && i_owner=$(_irix_get 'irc.admin' 2>/dev/null || true)

    # Les valeurs proposées, dans l'ordre : ce qui est déjà enregistré ici, ce
    # qu'IriX porte sur cette machine, puis l'identité du site. Chaque cran est
    # une source plus lointaine, jamais une invention.
    local d_host d_port d_net d_realname d_owner
    d_host="$(_overop_get 'defaults.host')";      [[ -z "$d_host"  ]] && d_host="${i_host:-127.0.0.1}"
    d_port="$(_overop_get 'defaults.port')";      [[ -z "$d_port"  ]] && d_port="${i_port:-6667}"
    d_net="$(_overop_get 'defaults.network')";    [[ -z "$d_net"   ]] && d_net="${i_net:-${d_nom:-$PROJET_NOM}}"
    d_realname="$(_overop_get 'defaults.realname')"
    [[ -z "$d_realname" ]] && d_realname="${d_real:-Robot de prêt — ${d_net}}"
    d_owner="$(_overop_get 'defaults.owner')";    [[ -z "$d_owner" ]] && d_owner="${i_owner:-}"

    local base host port owner net real
    base=$(_site_ask  "Racine des instances"   "$(_overop_base_dir)")
    host=$(_site_ask  "Serveur IRC (hôte)"     "$d_host")
    port=$(_site_ask  "Serveur IRC (port)"     "$d_port")
    net=$(_site_ask   "Nom du réseau"          "$d_net")
    real=$(_site_ask  "Nom réel affiché"       "$d_realname")

    # ⚠ SANS `set owner`, EGGDROP REFUSE DE DÉMARRER. Ce n'est pas un agrément :
    # c'est la cinquième cause d'échec de l'essai du 03/08/2026, et le message
    # d'Eggdrop est en anglais, dans un journal que les liens de messages ne
    # rendent lisible qu'après coup.
    owner=$(_site_ask "Propriétaire (pseudonyme IRC)" "$d_owner")
    if [[ -z "$owner" ]]; then
        _err "Un propriétaire est indispensable : sans « set owner », Eggdrop refuse de démarrer."
        _info "Rien n'a été enregistré."
        return 1
    fi

    if [[ "$base" != /* ]]; then
        _err_f "Chemin absolu attendu pour la racine des instances — reçu : %s" "$base"
        return 1
    fi

    _overop_set '.defaults.base_dir=$b | .defaults.host=$h
                 | .defaults.port=($p|tonumber? // 6667)
                 | .defaults.owner=$o | .defaults.network=$n
                 | .defaults.realname=$r | .defaults.admin=$a' \
        --arg b "$base" --arg h "$host" --arg p "$port" \
        --arg o "$owner" --arg n "$net"  --arg r "$real" --arg a "${d_nom:-$PROJET_NOM}"

    _log_entry "overop" "reglages" "serveur=${host}:${port} owner=${owner}"
    _ok "Réglages communs enregistrés."
}

# -----------------------------------------------------------------------------
# _overop_ecrire_conf <destination> <nom> <port telnet>
#
# Écrit une VRAIE configuration Eggdrop, celle que l'essai du 03/08/2026 a
# fini par valider. `Robots/overop/overop.conf.exemple` en est la version
# commentée, lue par un humain qui pose un robot à la main ; celle-ci est la
# même chose produite par une machine, avec les valeurs de CETTE installation.
# Les deux doivent rester en phase — d'où `_overop_verifier_conf`, qui contrôle
# le fichier produit plutôt que de faire confiance à cette fonction.
#
# Un fichier existant n'est JAMAIS écrasé : il porte les `channel add` du salon
# servi, et sans doute des réglages faits à la main. Il est sauvegardé, conservé
# et signalé — exactement ce que fait `_irix_ecrire_conf`.
# -----------------------------------------------------------------------------
_overop_ecrire_conf() {
    local dest="$1" nom="$2" port="$3"
    local host sport owner net real admin moddir

    host=$(_overop_get 'defaults.host');    sport=$(_overop_get 'defaults.port')
    owner=$(_overop_get 'defaults.owner');  net=$(_overop_get 'defaults.network')
    real=$(_overop_get 'defaults.realname'); admin=$(_overop_get 'defaults.admin')
    [[ -z "$host"  ]] && host="127.0.0.1"
    [[ -z "$sport" ]] && sport="6667"
    [[ -z "$net"   ]] && net="$PROJET_NOM"
    [[ -z "$real"  ]] && real="Robot de prêt — ${net}"
    [[ -z "$admin" ]] && admin="$net"
    moddir=$(_irix_moddir 2>/dev/null || true)

    if [[ -z "$owner" ]]; then
        _err "Aucun propriétaire enregistré — sans « set owner », Eggdrop refuse de démarrer."
        _info "Renseigne les réglages communs avant de déployer."
        return 1
    fi

    if [[ -f "$dest" ]]; then
        cp -p "$dest" "${dest}.avant-$(date +%Y%m%d-%H%M%S)" \
            && _info "Configuration précédente sauvegardée." \
            || { _err "Sauvegarde de la configuration impossible — rien n'a été écrit."; return 1; }
        _warn "overop.conf existe : il est CONSERVÉ tel quel (channel add, réglages manuels)."
        _kv "À vérifier" "$dest"
        return 0
    fi

    local tmp; tmp="$(mktemp)" || { _err "mktemp échoué."; return 1; }
    # `printf '%s\n' "…"` et non un format en quotes simples : ces lignes sont
    # pleines d'apostrophes, qui refermeraient le format. Seule la ligne
    # `init-server` reste en quotes simples — elle porte un `$botnick` qui doit
    # arriver littéralement dans le fichier, et non être développé par bash.
    {
        printf '%s\n' "# Configuration OverOp — écrite par ${SCRIPT_NAME} le $(date -u +%Y-%m-%dT%H:%M:%SZ)"
        printf '%s\n' "# Robot : ${nom}"
        printf '%s\n' "# Cahier : Markdown/PRET-ROBOT.md — modèle commenté : Robots/overop/overop.conf.exemple"
        printf '%s\n' "#"
        printf '%s\n' "# Un Eggdrop ORDINAIRE, connecté comme un client : ni lien serveur-à-serveur,"
        printf '%s\n' "# ni SID, ni IRCop. Il n'obtient l'op que parce qu'on le lui donne."
        printf '%s\n' "#"
        printf '%s\n' "# Il se lance DEPUIS SON DOSSIER : « cd ${dest%/*} && eggdrop -n overop.conf »."
        printf '%s\n' "# userfile, pidfile, chanfile et logfile sont relatifs à ce dossier."
        printf '%s\n' ""

        printf '%s\n' "##### IDENTITÉ #####"
        printf '%s\n' "set username \"overop\""
        printf '%s\n' "set admin    \"${admin}\""
        printf '%s\n' "set network  \"${net}\""
        printf '%s\n' "set nick     \"${nom}\""
        printf '%s\n' "set altnick  \"${nom}_\""
        printf '%s\n' "set realname \"${real}\""
        printf '%s\n' ""
        printf '%s\n' "# ⚠ SANS owner, Eggdrop REFUSE DE DÉMARRER. Ce n'est pas un agrément."
        printf '%s\n' "set owner    \"${owner}\""
        printf '%s\n' ""

        printf '%s\n' "##### SERVEUR IRC #####"
        printf '%s\n' "set servers {"
        printf '%s\n' "  ${host}:${sport}"
        printf '%s\n' "}"
        printf '%s\n' ""

        printf '%s\n' "##### FICHIERS #####"
        printf '%s\n' "set userfile \"overop.user\""
        printf '%s\n' "set pidfile  \"overop.pid\""
        printf '%s\n' "set chanfile \"overop.chan\""
        printf '%s\n' "set userfile-perm 0600"
        printf '%s\n' "set max-logs 5"
        printf '%s\n' "set max-logsize 1024"
        printf '%s\n' "set keep-all-logs 0"
        printf '%s\n' "logfile mkco * \"logs/overop.log\""
        printf '%s\n' ""

        printf '%s\n' "##### TELNET D'ADMINISTRATION #####"
        printf '%s\n' "# Sur la boucle locale uniquement : un robot de prêt n'a aucune raison"
        printf '%s\n' "# d'être joignable de l'extérieur."
        printf '%s\n' "# listen-addr plutôt que my-ip / my-hostname, dépréciés depuis Eggdrop 1.9."
        printf '%s\n' "set listen-addr \"127.0.0.1\""
        printf '%s\n' "listen ${port} all"
        printf '%s\n' "set console \"mkcobxs\""
        printf '%s\n' ""

        printf '%s\n' "##### MODULES #####"
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ mod-path est ABSOLU, et non le « modules/ » par défaut : ce dernier est"
        printf '%s\n' "# résolu depuis le répertoire courant, qui n'est pas celui où Eggdrop a été"
        printf '%s\n' "# compilé. Sans lui AUCUN module ne charge, et le message qui suit est"
        printf '%s\n' "# trompeur — « bad type » sur bind msg, la table msg étant créée par irc."
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ pbkdf2 EN PREMIER : sans module de chiffrement, Eggdrop refuse de créer"
        printf '%s\n' "# son userfile et s'arrête là."
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ PAS de « loadmodule dns » : l'Eggdrop compilé ici porte le cœur DNS"
        printf '%s\n' "# threadé, avec lequel le module refuse de se charger."
        if [[ -n "$moddir" ]]; then
            printf '%s\n' "set mod-path \"${moddir}\""
            printf '%s\n' "loadmodule pbkdf2"
            printf '%s\n' "loadmodule channels"
            printf '%s\n' "loadmodule server"
            printf '%s\n' "loadmodule ctcp"
            printf '%s\n' "loadmodule irc"
            printf '%s\n' "loadmodule notes"
            printf '%s\n' "loadmodule console"
        else
            printf '%s\n' "# ATTENTION : aucun répertoire de modules (*.so) trouvé au déploiement."
            printf '%s\n' "# Renseigne mod-path ci-dessous ET décommente les loadmodule : en l'état,"
            printf '%s\n' "# ce robot ne démarrera pas."
            printf '%s\n' "# set mod-path \"/chemin/vers/eggdrop/modules/\""
            printf '%s\n' "# loadmodule pbkdf2"
            printf '%s\n' "# loadmodule channels"
            printf '%s\n' "# loadmodule server"
            printf '%s\n' "# loadmodule ctcp"
            printf '%s\n' "# loadmodule irc"
            printf '%s\n' "# loadmodule notes"
            printf '%s\n' "# loadmodule console"
        fi
        printf '%s\n' ""

        printf '%s\n' "##### COMPORTEMENT #####"
        printf '%s\n' "# Ni service, ni opérateur : aucun privilège réseau, et il n'en demande aucun."
        printf '%s\n' "set default-flags \"-\""
        printf '%s\n' "# \$botnick et non \$nick : dans un init-server, \$nick n'existe pas et le"
        printf '%s\n' "# mode partirait sur une chaîne vide."
        printf '%s\n' "#"
        printf '%s\n' "# « B » signale un robot au réseau : le WHOIS répond « is a bot on this"
        printf '%s\n' "# network », le WHO porte le drapeau B. C'est de la franchise, pas un"
        printf '%s\n' "# privilège — n'importe quel client peut se le poser."
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ IL NE PROTÈGE DE RIEN À LUI SEUL. Le serveur n'épargne un robot du"
        printf '%s\n' "# KICK, du KILL et des bans réseau que s'il porte « +B » ET que son"
        printf '%s\n' "# pseudonyme figure dans conf/protection.conf, écrit par le panel."
        printf '%s\n' 'set init-server { putserv "MODE $botnick +iB" }'
        printf '%s\n' ""

        printf '%s\n' "##### SALONS SERVIS #####"
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ CHAQUE ROBOT NE SIÈGE QUE SUR SES SALONS (§3.3). Trois robots sur chaque"
        printf '%s\n' "# salon rendraient la répartition inutile, et deux d'entre eux poseraient"
        printf '%s\n' "# l'op sans raison. Cette liste doit correspondre à la colonne « pooshy » de"
        printf '%s\n' "# \${t}_botreg — c'est OverBot qui fait foi, pas ce fichier."
        printf '%s\n' "#"
        printf '%s\n' "# channel add #salon"
        printf '%s\n' ""

        printf '%s\n' "##### ⚠⚠ LE SALON GARDIEN — PAS FACULTATIF #####"
        printf '%s\n' "#"
        printf '%s\n' "# « #!Bots! » est le salon où les robots s'annoncent, et c'est par lui que"
        printf '%s\n' "# soc::botreg::salons_attendus les compte. Un robot qui n'y siège pas est"
        printf '%s\n' "# posé, vivant, et invisible du parc."
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ AJOUTÉ LE 25/08/2026, ET IL MANQUAIT AUX DEUX ÉCRIVAINS. Il avait été"
        printf '%s\n' "# posé à la main sur les robots en service le 15/08, sans jamais être"
        printf '%s\n' "# reporté ni ici ni dans overop.conf.exemple. Le premier robot né du"
        printf '%s\n' "# modèle seul est arrivé sans salon gardien et sans nom de botnet."
        printf '%s\n' "channel add #!Bots!"
        printf '%s\n' ""

        printf '%s\n' "##### LE NOM SUR LE LIEN DE BOTNET #####"
        printf '%s\n' "#"
        printf '%s\n' "# Le nom sous lequel ce bot se présente aux AUTRES EGGDROP, sur le lien"
        printf '%s\n' "# direct — distinct du pseudonyme IRC, qui peut changer (altnick) sans que"
        printf '%s\n' "# l'identité du lien bouge."
        printf '%s\n' "#"
        printf '%s\n' "# ⚠ Il ne suffit PAS à monter un lien : il faut aussi la déclaration du"
        printf '%s\n' "# pair et le secret partagé. Sans les trois, déclarer un pair dans le"
        printf '%s\n' "# panel donne un lien qui échoue, et l'échec s'affiche dans l'activité du"
        printf '%s\n' "# botnet — on apprend le manque par le journal, pas par un silence."
        printf '%s\n' "set botnet-nick \"${nom}\""
        printf '%s\n' ""

        printf '%s\n' "##### LE SCRIPT #####"
        printf '%s\n' "# Chemin relatif : overop.tcl est déposé à côté de ce fichier."
        printf '%s\n' "source overop.tcl"
    } > "$tmp" || { rm -f "$tmp"; _err "Écriture de la configuration impossible."; return 1; }

    mv "$tmp" "$dest" || { rm -f "$tmp"; _err "Installation de la configuration impossible."; return 1; }
    chmod 640 "$dest"
    _ok_f "Configuration Eggdrop écrite : %s" "$dest"

    if [[ -z "$moddir" ]]; then
        _warn_f "Modules Eggdrop (*.so) introuvables — %s ne pourra pas démarrer en l'état." "${nom}"
        _info "Ils sont produits par la compilation d'Eggdrop. Vérifie le Panel Services,"
        _info_f "puis renseigne mod-path dans : %s" "$dest"
    fi
}

# -----------------------------------------------------------------------------
# _overop_verifier_conf <configuration>
#
# Le banc de l'installateur. Il relit le fichier PRODUIT et vérifie les cinq
# conditions sans lesquelles Eggdrop ne démarre pas — celles que l'essai réel a
# révélées une par une, chacune au prix d'un démarrage manqué.
#
# Pourquoi relire au lieu de faire confiance à `_overop_ecrire_conf` : parce
# qu'une configuration existante est CONSERVÉE (réglages manuels), et qu'elle
# peut donc dater d'avant un déménagement d'Eggdrop. Le fichier sur le disque
# est le seul qui compte, et c'est lui qu'on interroge.
# -----------------------------------------------------------------------------
_overop_verifier_conf() {
    local conf="${1:-}" dir modp premier own ok=true
    [[ -f "$conf" ]] || { _err "Configuration absente : $conf"; return 1; }
    dir="$(dirname "$conf")"

    _section "Contrôle de la configuration : $(basename "$dir")"

    # 1 — mod-path absolu et présent.
    modp="$(sed -n 's/^[[:space:]]*set mod-path[[:space:]]*"\(.*\)".*/\1/p' "$conf" | head -n 1)"
    if [[ -z "$modp" ]]; then
        _err "set mod-path absent : aucun module ne chargera, et le bot se plaindra"
        _err "  d'un « bad type » sur bind msg — un message qui ne désigne pas la cause."
        ok=false
    elif [[ "$modp" != /* ]]; then
        _err_f "set mod-path relatif (%s) : résolu depuis le répertoire courant, donc introuvable." "$modp"
        ok=false
    elif [[ ! -d "$modp" ]]; then
        _err_f "set mod-path pointe un dossier absent : %s" "$modp"
        ok=false
    else
        _ok_f "mod-path absolu et présent : %s" "$modp"
    fi

    # 2 — pbkdf2, et EN PREMIER.
    premier="$(awk '/^[[:space:]]*loadmodule[[:space:]]/{print $2; exit}' "$conf")"
    if [[ -z "$premier" ]]; then
        _err "Aucun loadmodule : le bot n'aura ni serveur, ni salons, ni bind msg."
        ok=false
    elif [[ "$premier" != "pbkdf2" ]]; then
        _err_f "Premier module chargé : « %s » au lieu de pbkdf2." "$premier"
        _err "  Sans module de chiffrement, Eggdrop refuse de créer son userfile."
        ok=false
    else
        _ok "pbkdf2 chargé en premier."
    fi

    # 3 — pas de module dns.
    if grep -qE '^[[:space:]]*loadmodule[[:space:]]+dns' "$conf"; then
        _err "loadmodule dns présent : l'Eggdrop compilé ici porte le cœur DNS threadé,"
        _err "  et le module refuse alors de se charger. L'avertissement passe inaperçu."
        ok=false
    else
        _ok "Pas de loadmodule dns."
    fi

    # 4 — owner renseigné.
    own="$(sed -n 's/^[[:space:]]*set owner[[:space:]]*"\(.*\)".*/\1/p' "$conf" | head -n 1)"
    if [[ -z "$own" ]]; then
        _err "set owner absent ou vide : Eggdrop refuse de démarrer."
        ok=false
    else
        _ok_f "Propriétaire déclaré : %s" "$own"
    fi

    # 5 — le script, et les messages d'Eggdrop.
    if grep -qE '^[[:space:]]*source[[:space:]]+overop\.tcl' "$conf" && [[ -f "${dir}/overop.tcl" ]]; then
        _ok "overop.tcl chargé et présent."
    else
        _err "overop.tcl absent du dossier ou non chargé : le robot se connecterait sans répondre."
        ok=false
    fi

    local sous manque=""
    for sous in language help text; do
        [[ -e "${dir}/${sous}" ]] || manque+="${sous} "
    done
    if [[ -n "$manque" ]]; then
        _warn_f "Répertoires de messages manquants : %s— le journal sortira en codes" "${manque}"
        _warn "  (« MSG536 »), y compris la ligne qui dirait pourquoi le bot s'arrête."
        ok=false
    else
        _ok "language/, help/ et text/ accessibles."
    fi

    if $ok; then
        _ok "Configuration démarrable."
        return 0
    fi
    _warn "Configuration INCOMPLÈTE — corrige les points ci-dessus avant de démarrer."
    return 1
}

# -----------------------------------------------------------------------------
# _overop_ircd_dir — le dossier du serveur Overtchat-Hybrid de CETTE machine,
# celui où l'on réserve le nom d'un robot ; rend 1 s'il n'y en a pas.
#
# ⚠ 03/10/2026 : `_overop_deploy` ne regardait que ~/overtchatd/bin/overtchatd
#   (contrib/install-service.sh, la production). Chez un tiers, le serveur est
#   installé par CET installateur dans <projet>/Servers/overtchat-ircd et
#   déclaré au registre (`_srv_finalize` : install_dir, binary « bin/
#   overtchatd », RELATIF à install_dir ; `_st_record_found` aussi) : il n'était
#   jamais vu, et aucun robot n'était réservé. On lit donc le registre comme
#   `_anope_srv_conf` (`_srv_get overtchat_ircd …`), APRÈS ~/overtchatd — la
#   production garde exactement son chemin (sa ligne du registre porte
#   « binary: null » et un install_dir qui n'est pas celui en service).
#   `binary` absent : bin/overtchatd ; absolu : tel quel.
# -----------------------------------------------------------------------------
_overop_ircd_dir() {
    local d b x
    if [[ -x "${TARGET_HOME:?}/overtchatd/bin/overtchatd" ]]; then
        printf '%s' "${TARGET_HOME}/overtchatd"; return 0
    fi
    d=$(_srv_get "overtchat_ircd" "install_dir" 2>/dev/null || true)
    b=$(_srv_get "overtchat_ircd" "binary" 2>/dev/null || true)
    [[ -n "$d" && "$d" != "null" ]] || return 1
    [[ -n "$b" && "$b" != "null" ]] || b="bin/overtchatd"
    case "$b" in /*) x="$b" ;; *) x="${d%/}/${b}" ;; esac
    [[ -x "$x" ]] || return 1
    printf '%s' "${d%/}"
}

# -----------------------------------------------------------------------------
# _config_chemins_ircd <config.php> — 07/10/2026
#
# `config.example.php` écrit en dur le dossier du serveur IRC du réseau
# d'ORIGINE (`/home/servus/overtchatd`) dans `ircd_conf` et `ircd_protection`.
# L'installateur l'adaptait dans l'UNITÉ (`_overtchat_poser_conf`), jamais ici :
# chez un tiers, la page Admin montrait un chemin faux, la page Protection
# n'écrivait rien, le changement du mot de passe des liens (`ulinepass.php`)
# visait un fichier absent, et le dossier des jetons `/ws` (`ws-jeton.php`,
# dérivé de `ircd_conf`) n'était pas celui du serveur — en `websocket-jeton
# exige`, le chat du site était refusé.
#
# Le dossier : `install_dir` du serveur s'il est déclaré, sinon
# `$TARGET_HOME/overtchatd` (l'emplacement par défaut, le même que
# `_overop_ircd_dir`). Appelée SEULEMENT sur un `config.php` qui vient d'être
# copié du modèle : un fichier existant est celui de l'exploitant.
# -----------------------------------------------------------------------------
_config_chemins_ircd() {
    local conf="$1" d
    [[ -f "$conf" ]] || return 1
    d=$(_srv_get "overtchat_ircd" "install_dir" 2>/dev/null || true)
    [[ -n "$d" && "$d" != "null" ]] || d="${TARGET_HOME:-$HOME}/overtchatd"
    d="${d%/}"
    [[ "$d" == "/home/servus/overtchatd" ]] && return 0
    # ⚠ Le chemin entre dans un `sed` ET dans une chaîne PHP entre apostrophes :
    #   on n'accepte que ce qu'aucun des deux n'interprète.
    if [[ ! "$d" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
        _warn_f "Dossier du serveur IRC « %s » : caractères inattendus — ircd_conf et ircd_protection restent à corriger dans config.php." "$d"
        return 1
    fi
    sed -i -E "s#^([[:space:]]*'(ircd_conf|ircd_protection)'[[:space:]]*=>[[:space:]]*')/home/servus/overtchatd/#\1${d}/#" "$conf"
    if grep -qE "'(ircd_conf|ircd_protection)'[[:space:]]*=>[[:space:]]*'/home/servus/overtchatd/" "$conf"; then
        _warn "ircd_conf ou ircd_protection désigne encore /home/servus/overtchatd dans config.php : à corriger à la main."
        return 1
    fi
    _ok_f "config.php : dossier du serveur IRC = %s (ircd_conf, ircd_protection)." "$d"
    return 0
}

# -----------------------------------------------------------------------------
# _overop_deploy <nom> — pose (ou repose) l'instance d'un robot.
#
# Idempotente : relancée, elle resynchronise le script, refait les liens de
# messages, conserve la configuration existante et ne touche ni au userfile ni
# au chanfile. Un robot en marche continue de tourner sur l'ancien script —
# c'est dit, et c'est au redémarrage que le nouveau prend effet.
# -----------------------------------------------------------------------------
_overop_deploy() {
    local nom="${1:-}"
    _overop_init
    _overop_connu "$nom" || return 1

    _section "Déploiement du robot ${nom}"

    local dir port
    dir="$(_overop_dir "$nom")"
    port="$(_overop_bot_get "$nom" port)"
    [[ -n "$port" ]] || port="$(_overop_port_libre)"

    if [[ ! -f "${OVEROP_SOURCE_DIR}/overop.tcl" ]]; then
        _info "Sources absentes → synchronisation préalable…"
        _overop_sync_source || return 1
    fi

    local egg
    if ! egg=$(_irix_eggdrop_bin); then
        _err "Binaire Eggdrop introuvable — un OverOp est un Eggdrop, il ne tourne pas sans."
        _info "Installe Eggdrop depuis le Panel Services, puis relance ce déploiement."
        return 1
    fi
    _ok_f "Eggdrop trouvé : %s" "$egg"

    mkdir -p "${dir}/logs" "${dir}/.backup" \
        || { _err "Impossible de créer l'arborescence dans ${dir}"; return 1; }

    # Le script en marche est sauvegardé avant d'être remplacé : une régression
    # dans overop.tcl doit pouvoir se défaire sans attendre une resynchronisation.
    if [[ -f "${dir}/overop.tcl" ]]; then
        cp -p "${dir}/overop.tcl" "${dir}/.backup/overop.tcl.$(date +%s)" 2>/dev/null || true
    fi
    cp -p "${OVEROP_SOURCE_DIR}/overop.tcl" "${dir}/overop.tcl" \
        || { _err "Copie de overop.tcl échouée."; return 1; }
    _ok_f "Script déployé : %s/overop.tcl" "${dir}"

    # Le modèle commenté part avec, pour qui ouvrira ce dossier dans six mois.
    [[ -f "${OVEROP_SOURCE_DIR}/overop.conf.exemple" ]] \
        && cp -p "${OVEROP_SOURCE_DIR}/overop.conf.exemple" "${dir}/overop.conf.exemple" 2>/dev/null || true

    _overop_lier_ressources "$dir" || true
    _overop_ecrire_conf "${dir}/overop.conf" "$nom" "$port" || return 1

    # ── Point 126 B (23/09/2026) : le nom se RÉSERVE À LA NAISSANCE ─────────
    #
    # Un robot dont le nom n'est pas réservé n'a AUCUNE immunité sur le
    # serveur (verrou du point 10), et ce nom est à qui le prend. On réserve
    # donc avant tout premier démarrage : le robot n'est connecté nulle part,
    # rien ne peut être coupé. Sans réservation, il n'est PAS marqué déployé —
    # et `_overop_start` refuse un robot non déployé.
    #
    # ⚠ La condition n'est pas « conf neuve » : `_overop_ecrire_conf` CONSERVE
    #   une conf existante. Un redéploiement après un échec ne réserverait
    #   jamais. On regarde donc si le robot a déjà tourné (overop.user) et si
    #   un mot de passe figure déjà dans `set servers`.
    # ⚠ TARGET_HOME n'est pas exporté, et l'outil retombe sinon sur
    #   /home/servus : on le lui passe, et l'on exige qu'il soit défini.
    # ⚠ Serveur IRC sur une AUTRE machine : l'empreinte doit être posée là où
    #   il tourne. On avertit, on ne bloque pas.
    # ⚠ 03/10/2026 : le serveur était cherché dans ~/overtchatd SEULEMENT. Chez
    #   un tiers dont le serveur vit au registre (<projet>/Servers/overtchat-
    #   ircd), aucun robot n'était JAMAIS réservé — « Serveur IRC absent de
    #   cette machine » sur la machine qui le fait tourner. `_overop_ircd_dir`.
    local _conf="${dir}/overop.conf"
    local _outil="${BASE_DIR}/Tools/reserver-robot.sh"
    : "${TARGET_HOME:?}"
    local _ircd; _ircd="$(_overop_ircd_dir)" || _ircd=""
    if [[ ! -f "${dir}/overop.user" ]] \
       && ! awk '/set[[:space:]]+servers/{b=1;next} b&&/\}/{b=0} b&&/:[^:[:space:]]+:[^[:space:]]+/{t=1} END{exit !t}' "$_conf"; then
        if [[ -z "$_ircd" ]]; then
            _warn_f "Serveur IRC absent de cette machine : réservez le nom de %s là où il tourne (Tools/reserver-robot.sh)." "${nom}"
        elif [[ ! -f "$_outil" ]]; then
            _err_f "Outil de réservation introuvable (%s) : %s n'est PAS marqué déployé." "$_outil" "${nom}"
            return 1
        else
            _info_f "Réservation du nom %s (mot de passe + empreinte)…" "${nom}"
            if ! TARGET_HOME="${TARGET_HOME:?}" bash "$_outil" "$nom" --naissance overop "$_conf"; then
                _err_f "Réservation de %s ratée : le robot n'est PAS marqué déployé." "${nom}"
                _info "Sans elle il n'aurait aucune immunité. Corrigez, puis relancez « Déployer »."
                return 1
            fi
        fi
    elif [[ -f "${dir}/overop.user" && -n "$_ircd" ]] && ! cut -d: -f1 \
            "${_ircd}/conf/pseudos-proteges.conf" 2>/dev/null | grep -qix -- "${nom}"; then
        _warn_f "%s a déjà tourné sans nom réservé — réservez-le : bash %s %s" "${nom}" "$_outil" "${nom}"
    fi

    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _overop_bot_set "$nom" \
        '.robots[$n].installed = true | .robots[$n].dir = $d
         | .robots[$n].port = ($p|tonumber? // 0) | .robots[$n].last_deploy = $ts' \
        --arg d "$dir" --arg p "$port" --arg ts "$ts"

    _log_entry "overop" "deploy" "${nom} déployé dans ${dir} (telnet ${port})"
    _ok_f "Robot %s déployé dans : %s" "${nom}" "${dir}"

    # Le contrôle est fait ICI, pas laissé à l'exploitant : un déploiement qui
    # annonce « écrit » sans dire si c'est démarrable ne vaut rien.
    _overop_verifier_conf "${dir}/overop.conf" || true

    if [[ "$(_overop_status "$nom")" == "running" ]]; then
        _warn_f "%s tourne encore sur l'ANCIEN script : redémarre-le pour appliquer." "${nom}"
    fi
    return 0
}

# -----------------------------------------------------------------------------
# _overop_ajouter — déclare un robot, puis le déploie.
# -----------------------------------------------------------------------------
_overop_ajouter() {
    _overop_init
    _ui_width
    _ui_title "Ajouter un robot de prêt" "un Eggdrop de plus, client ordinaire du réseau"

    if [[ -z "$(_overop_get 'defaults.owner')" ]]; then
        _info "Réglages communs non renseignés — ils le sont d'abord."
        _overop_reglages || return 1
    fi

    # L'avertissement AVANT la saisie : apprendre après avoir tout rempli que
    # le robot sera refusé par le serveur est une découverte trop tardive.
    _overop_avertir_maxperip 1 || true

    local nom defaut port
    defaut="$(_overop_nom_suivant 2>/dev/null || true)"
    nom=$(_site_ask "Pseudonyme du robot" "$defaut")
    if ! _overop_nom_valide "$nom"; then
        _err "Pseudonyme refusé : lettres, chiffres, tiret et souligné, de 2 à 30 caractères,"
        _err "  commençant par une lettre. Il sert aussi de nom de dossier."
        return 1
    fi
    if [[ "$(jq -r --arg n "$nom" '(.robots // {}) | has($n)' "$(_overop_file)" 2>/dev/null)" == "true" ]]; then
        _err_f "%s est déjà déclaré. « Déployer » le repose ; il n'y a rien à ajouter." "${nom}"
        return 1
    fi

    port=$(_site_ask "Port du telnet d'administration" "$(_overop_port_libre)")
    if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1024 || port > 65535 )); then
        _err_f "Port invalide : %s" "$port"
        return 1
    fi

    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _overop_set '.robots[$n] = { dir: $d, port: ($p|tonumber), installed: false,
                                 running: false, created: $ts, last_deploy: null,
                                 last_start: null, last_stop: null }' \
        --arg n "$nom" --arg p "$port" --arg ts "$ts" \
        --arg d "$(_overop_base_dir)/$(printf '%s' "$nom" | tr '[:upper:]' '[:lower:]')"

    _log_entry "overop" "add" "${nom} déclaré (telnet ${port})"
    _ok_f "Robot %s déclaré." "${nom}"

    # ⚠ Déclarer n'est pas poser, et poser n'est pas connecter. Le registre ne
    # doit pas laisser croire à un robot en service — c'est la règle 1 du §4.4,
    # transposée à l'installateur.
    _info "Déclaré, pas encore posé : le déploiement suit."
    _overop_deploy "$nom"
}

# -----------------------------------------------------------------------------
# _overop_retirer <nom> — retire un robot du registre.
#
# L'instance est arrêtée d'abord : la retirer du registre sans l'arrêter
# laisserait un Eggdrop connecté au réseau que plus rien ne sait piloter — et
# qui continuerait à consommer une des trois connexions autorisées par IP.
# -----------------------------------------------------------------------------
_overop_retirer() {
    local nom="${1:-}" dir rep
    _overop_init
    _overop_connu "$nom" || return 1

    dir="$(_overop_dir "$nom")"

    if [[ "$(_overop_status "$nom")" == "running" ]]; then
        _info_f "%s tourne — il est arrêté avant tout retrait." "${nom}"
        _overop_stop "$nom" || { _err "Arrêt impossible : le retrait est abandonné."; return 1; }
    fi

    read -rp "  Supprimer aussi le dossier ${dir} ? [o/N] : " rep || true
    if [[ "$rep" =~ ^[YyOo]$ ]]; then
        # Le userfile et le chanfile partent avec : c'est irréversible, et
        # c'est pourquoi la question est posée séparément du retrait.
        _safe_rmrf "$dir" && _ok "Dossier supprimé : $dir" || _warn "Dossier non supprimé."
    else
        _info_f "Dossier conservé : %s" "$dir"
    fi

    _overop_set 'del(.robots[$n])' --arg n "$nom"
    _log_entry "overop" "remove" "${nom} retiré du registre"
    _ok_f "Robot %s retiré du registre." "${nom}"
}

# =============================================================================
# OverOp — cycle de vie
# =============================================================================

_overop_status() {
    local pid_file
    read -r _ pid_file _ < <(_overop_paths "$1")
    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file" 2>/dev/null)" 2>/dev/null; then
        echo "running"
    else
        echo "stopped"
    fi
}

_overop_status_icon() {
    local nom="$1" installed running
    installed=$(_overop_bot_get "$nom" installed)
    running=$(_overop_status "$nom")
    if   [[ "$installed" != "true"    ]]; then _ui_badge off  'non déployé'
    elif [[ "$running"   == "running" ]]; then _ui_badge run  'en marche'
    else                                       _ui_badge warn 'arrêté'
    fi
}

# -----------------------------------------------------------------------------
# _overop_start <nom>
#
# ⚠ `-m` AU PREMIER LANCEMENT SEULEMENT. Il crée le userfile ; posé une seconde
# fois, il l'ÉCRASE — et le robot perd ses propriétaires, donc le droit de
# quiconque à l'administrer par le telnet.
#
# Le témoin retenu est la PRÉSENCE DU USERFILE, et non un drapeau du registre :
# un dossier recopié, une remise à zéro du JSON ou une instance posée à la main
# feraient mentir le drapeau, jamais le fichier.
#
# `-n` garde Eggdrop au premier plan, comme pour IriX : sans cette option il se
# détache lui-même, et le PID relevé serait celui d'un processus déjà terminé.
# -----------------------------------------------------------------------------
_overop_start() {
    local nom="${1:-}" dir pid_file log_file egg pid opt_m=""
    _overop_init
    _overop_connu "$nom" || return 1

    if [[ "$(_overop_bot_get "$nom" installed)" != "true" ]]; then
        _err_f "%s n'est pas déployé. Utilise d'abord « Déployer »." "${nom}"
        return 1
    fi

    if ! egg=$(_irix_eggdrop_bin); then
        _err_f "Binaire Eggdrop introuvable — %s ne peut pas démarrer sans lui." "${nom}"
        return 1
    fi

    read -r dir pid_file log_file < <(_overop_paths "$nom")
    [[ -f "${dir}/overop.conf" ]] || { _err "Configuration introuvable : ${dir}/overop.conf — redéploie."; return 1; }
    [[ -f "${dir}/overop.tcl"  ]] || { _err "Script introuvable : ${dir}/overop.tcl — redéploie."; return 1; }

    if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file" 2>/dev/null)" 2>/dev/null; then
        _warn_f "%s est déjà en cours d'exécution." "${nom}"
        return 0
    fi
    # Un pidfile périmé ferait échouer tous les démarrages suivants. Il n'est
    # retiré qu'APRÈS avoir constaté que le processus n'existe plus.
    rm -f "$pid_file"

    mkdir -p "${dir}/logs"

    if [[ ! -f "${dir}/overop.user" ]]; then
        opt_m="-m"
        _info_f "Premier démarrage de %s : création du userfile (option -m)." "${nom}"
    fi

    _info_f "Démarrage de %s via %s…" "${nom}" "${egg}"
    # Le lancement se fait DEPUIS le dossier de l'instance, avec un chemin de
    # configuration relatif : c'est ce qu'attendent set userfile, set pidfile et
    # logfile, relatifs eux aussi. C'est aussi ce qui rend `pkill -f` inopérant
    # — voir _overop_stop.
    ( cd "$dir" && exec nohup "$egg" -n $opt_m overop.conf > "$log_file" 2>&1 ) &
    pid=$!
    echo "$pid" > "$pid_file"

    # Eggdrop lit sa configuration, ouvre ses fichiers et se connecte : deux
    # secondes avant de conclure, sinon on constate « démarré » sur un processus
    # qui n'a pas encore eu le temps d'échouer.
    sleep 2

    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
        _overop_bot_set "$nom" '.robots[$n].running = true | .robots[$n].last_start = $ts' \
            --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        _log_entry "overop" "start" "${nom} PID=${pid}"
        _ok_f "%s démarré (PID : %s)." "${nom}" "${pid}"
        _overop_avertir_maxperip 0 || true
    else
        rm -f "$pid_file"
        _err_f "%s s'est arrêté immédiatement. Dernières lignes du journal :" "${nom}"
        [[ -f "$log_file" ]] && tail -8 "$log_file" | sed 's/^/    /'
        _info "Si ces lignes sont des codes (« MSG536 »), ce sont les liens language/,"
        _info "help/ et text/ qui manquent : « Vérifier la configuration » le dira."
        return 1
    fi
}

# -----------------------------------------------------------------------------
# _overop_stop <nom>
#
# ⚠ PAR LE PID DU PIDFILE, JAMAIS PAR `pkill -f`. Eggdrop est lancé avec un
# chemin de configuration relatif : le motif absolu ne le trouve pas dans la
# table des processus, `pkill` rend 0 sans avoir rien tué, l'ancienne instance
# survit et garde le port du telnet — et la suivante meurt sur « Address already
# in use », une erreur qui ne parle ni du pkill ni de l'instance survivante.
#
# L'arrêt est REVÉRIFIÉ après SIGKILL : un PID devenu la propriété d'un autre
# utilisateur fait échouer les deux `kill` en silence, et supprimer le pidfile
# à ce moment-là ferait perdre la trace d'un robot toujours connecté.
# -----------------------------------------------------------------------------
_overop_stop() {
    local nom="${1:-}" pid_file pid
    _overop_init
    _overop_connu "$nom" || return 1
    read -r _ pid_file _ < <(_overop_paths "$nom")

    if [[ ! -f "$pid_file" ]]; then
        _warn_f "%s : aucun PID trouvé, probablement déjà arrêté." "${nom}"
        _overop_bot_set "$nom" '.robots[$n].running = false'
        return 0
    fi

    pid=$(cat "$pid_file")
    if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
        _warn_f "Fichier PID illisible (%s) — il est supprimé." "${pid_file}"
        rm -f "$pid_file"
        _overop_bot_set "$nom" '.robots[$n].running = false'
        return 0
    fi

    if kill -0 "$pid" 2>/dev/null; then
        _info_f "Envoi de SIGTERM au PID %s…" "${pid}"
        kill "$pid" 2>/dev/null || true
        sleep 2
        if kill -0 "$pid" 2>/dev/null; then
            _warn "Le processus ne répond pas, envoi de SIGKILL…"
            kill -9 "$pid" 2>/dev/null || true
            sleep 1
        fi
        if kill -0 "$pid" 2>/dev/null; then
            _err_f "Le PID %s survit à SIGKILL — %s n'a PAS été arrêté." "${pid}" "${nom}"
            _info "Le fichier PID est conservé : le processus reste identifiable."
            return 1
        fi
    else
        _warn_f "%s : PID %s introuvable dans les processus actifs." "${nom}" "${pid}"
    fi

    rm -f "$pid_file"
    _overop_bot_set "$nom" '.robots[$n].running = false | .robots[$n].last_stop = $ts' \
        --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _log_entry "overop" "stop" "${nom} PID=${pid}"
    _ok_f "%s arrêté." "${nom}"
}

_overop_restart() {
    local nom="${1:-}"
    _info_f "Redémarrage de %s…" "${nom}"
    _overop_stop "$nom" || return 1
    sleep 1
    _overop_start "$nom"
}

_overop_logs() {
    local nom="${1:-}" log_file
    _overop_connu "$nom" || return 1
    read -r _ _ log_file < <(_overop_paths "$nom")
    [[ -f "$log_file" ]] || { _warn "Aucun journal pour ${nom} — il n'a jamais démarré ici."; return 1; }
    _info_f "Journal de %s (Ctrl+C pour quitter) :" "${nom}"
    tail -f "$log_file"
}

# =============================================================================
# OverOp — panel
# =============================================================================

# Une ligne par robot : le DÉCLARÉ (port, dossier) et l'OBSERVÉ (le processus).
_overop_apercu() {
    local nom n=0
    while read -r nom; do
        [[ -z "$nom" ]] && continue
        n=$(( n + 1 ))
        _ui_row "  ${BOLD}${nom}${RESET}$(_ui_pad "$nom" 18)$(_overop_status_icon "$nom")${DIM}  telnet $(_overop_bot_get "$nom" port)${RESET}"
    done < <(_overop_noms)
    (( n == 0 )) && _ui_row "  ${DIM}Aucun robot déclaré — « Ajouter un robot » en pose un.${RESET}"
    return 0
}

# Résumé d'une ligne, pour l'aperçu du Panel Services.
#
# Le corps de boucle se termine par un `if` complet et non par un `&&` : sous
# `set -e`, une boucle `while` rend le code de sa DERNIÈRE commande, et un
# dernier robot à l'arrêt aurait fait sortir le script au `done`.
_overop_apercu_court() {
    local nom total=0 vivants=0
    while read -r nom; do
        [[ -z "$nom" ]] && continue
        total=$(( total + 1 ))
        if [[ "$(_overop_status "$nom")" == "running" ]]; then
            vivants=$(( vivants + 1 ))
        fi
    done < <(_overop_noms)
    if (( total == 0 )); then
        _ui_badge off 'aucun robot'
    elif (( vivants == total )); then
        _ui_badge run "${vivants}/${total} en marche"
    elif (( vivants == 0 )); then
        _ui_badge warn "0/${total} en marche"
    else
        _ui_badge warn "${vivants}/${total} en marche"
    fi
}

# Demande sur quel robot agir. La liste est rappelée : un nom mal orthographié
# est le refus le plus fréquent, et il coûte un aller-retour de menu.
#
# ⚠ TOUT CE QUI N'EST PAS LE NOM PART SUR LA SORTIE D'ERREUR. L'appelant écrit
# `nom=$(_overop_choisir)` : la moindre ligne d'information laissée sur la
# sortie standard entrerait dans `$nom`, et le robot s'appellerait « • Robots
# déclarés : OverOp-H OverOp-H ». Le prompt de `_site_ask` n'a pas ce défaut —
# `read -p` écrit sur l'erreur standard.
_overop_choisir() {
    local nom liste
    liste="$(_overop_noms | tr '\n' ' ')"
    if [[ -z "${liste// /}" ]]; then
        _warn "Aucun robot déclaré." >&2
        return 1
    fi
    _info "Robots déclarés : ${liste}" >&2
    nom=$(_site_ask "Robot" "$(printf '%s' "$liste" | awk '{print $1}')")
    _overop_connu "$nom" >&2 || return 1
    printf '%s' "$nom"
}

_panel_overop() {
    _overop_init
    local c nom
    while true; do
        _ui_width
        _ui_title "Robots de prêt OverOp" "Eggdrop autonomes, clients ordinaires — un code, plusieurs instances"

        _ui_top
        _ui_kv "Dossier source"    "$OVEROP_SOURCE_DIR"
        _ui_kv "Racine instances"  "$(_overop_base_dir)"
        _ui_kv "Sources synchro."  "$([[ "$(_overop_get 'source.cloned')" == "true" ]] \
                                       && _ui_badge ok 'oui' || _ui_badge off 'non')"
        _ui_kv "Propriétaire"      "$([[ -n "$(_overop_get 'defaults.owner')" ]] \
                                       && printf '%s' "$(_overop_get 'defaults.owner')" \
                                       || _ui_badge err 'non renseigné')"
        # Déclaré, pas mesuré : personne ne compte les connexions côté serveur.
        _ui_kv "Connexions/IP"     "$(_overop_connexions_ip)/${OVEROP_MAXPERIP} ${DIM}déclarées, IriX comprise${RESET}"
        _ui_mid
        _overop_apercu
        _ui_bot

        _menu_reset
        _ui_head "Sources"
        _menu_add 1 "Synchroniser depuis le dépôt"
        _menu_add 2 "Réglages communs  ${DIM}(serveur, propriétaire, racine)${RESET}"

        _ui_head "Robots"
        _menu_add 3 "Ajouter un robot"
        _menu_add 4 "Déployer / redéployer"
        _menu_add 5 "Retirer un robot"

        _ui_head "Cycle de vie"
        _menu_add 6 "Démarrer"
        _menu_add 7 "Arrêter"
        _menu_add 8 "Redémarrer"

        _ui_head "Diagnostic"
        _menu_add 9  "Vérifier la configuration"
        _menu_add 10 "Journaux en direct"
        _menu_add 0  "Retour"
        _ui_bot

        _ui_prompt
        read -r c || c=0
        _menu_has "$c" || { _warn "Choix indisponible ou verrouillé."; continue; }
        case "$c" in
            1) _overop_sync_source || true ;;
            2) _overop_reglages || true ;;
            3) _overop_ajouter || true ;;
            4) nom=$(_overop_choisir) && _overop_deploy  "$nom" || true ;;
            5) nom=$(_overop_choisir) && _overop_retirer "$nom" || true ;;
            6) nom=$(_overop_choisir) && _overop_start   "$nom" || true ;;
            7) nom=$(_overop_choisir) && _overop_stop    "$nom" || true ;;
            8) nom=$(_overop_choisir) && _overop_restart "$nom" || true ;;
            9) nom=$(_overop_choisir) && _overop_verifier_conf "$(_overop_dir "$nom")/overop.conf" || true ;;
            10) nom=$(_overop_choisir) && _overop_logs   "$nom" || true ;;
            0) return ;;
            *) _warn "Choix invalide." || true ;;
        esac
        _pause
    done
}

# =============================================================================
# ══════════════════════════
#  LOGS — Panel centralisé
# ══════════════════════════
# =============================================================================

_log_entry() {
    local source="$1" event="$2" detail="${3:-}"
    # $BASE_DIR, comme le panel qui relit ce fichier. Avec "$HOME/…", une
    # session sudo écrivait dans /root/Service-Overtchat : le journal était
    # bien alimenté, mais dans une arborescence que plus rien ne consultait,
    # et qui restait la propriété de root.
    local log_dir="$BASE_DIR/Runtime/Logs"
    mkdir -p "$log_dir"
    printf "[%s] [%-16s] [%-16s] %s\n" \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$source" "$event" "$detail" \
        >> "$log_dir/overtchat.log"
}

# Liste les erreurs enregistrées dans un fichier d'état, en distinguant les
# trois cas que l'ancien code confondait : fichier absent, fichier illisible,
# et absence réelle d'erreur.
_panel_logs_erreurs() {
    local fichier="$1" filtre="$2" nom="$3" out rc=0

    [[ -f "$fichier" ]] || { _warn "${nom} absent — rien à lire."; return 0; }

    out=$(jq -r "$filtre" "$fichier" 2>&1) || rc=$?
    if (( rc != 0 )); then
        _err_f "%s illisible — l'état des erreurs est INCONNU, pas vide." "${nom}"
        printf "  ${DIM}%s${RESET}\n" "$out"
        return 1
    fi

    if [[ -n "$out" ]]; then
        printf '%s\n' "$out"
    else
        _ok "Aucune erreur enregistrée."
    fi
}

_panel_logs() {
    # $BASE_DIR et non "$HOME/Service-Overtchat" : sous sudo, $HOME vaut /root
    # alors que l'arborescence est restée dans le home de l'utilisateur réel.
    # Le panel lisait donc un chemin qui n'existe pas — et annonçait « log
    # absent » sur une installation dont les journaux se remplissaient
    # normalement, deux répertoires plus loin.
    local log_dir="$BASE_DIR/Runtime/Logs"
    local main_log="$log_dir/overtchat.log"
    local c rep

    # Taille et date d'un fichier, ou « absent » — pour savoir quoi ouvrir
    # avant de l'ouvrir.
    _log_meta() {
        [[ -f "$1" ]] || { _ui_badge off 'absent'; return; }
        printf '%s' "$(_ui_badge ok "$(du -h "$1" 2>/dev/null | cut -f1) · $(date -r "$1" '+%d/%m %H:%M' 2>/dev/null)")"
    }

    while true; do
        _ui_width
        _ui_title "Panel Logs" "journaux, erreurs enregistrées et rapport global"

        local n_es n_esrv
        n_es=$(jq -r '[.services[]?.errors[]?] | length' "$SERVICES_FILE" 2>/dev/null || echo 0)
        n_esrv=$(jq -r '[.servers[]?.errors[]?] | length' "$SERVERS_FILE" 2>/dev/null || echo 0)

        _ui_top
        _ui_row "${BOLD}Sources${RESET}"
        _ui_mid
        _ui_kv "Journal du panel"  "$(_log_meta "$LOG_FILE")"
        _ui_kv "Journal principal" "$(_log_meta "$main_log")"
        _ui_kv "Erreurs services"  "$([[ "${n_es:-0}" -gt 0 ]] && _ui_badge err "$n_es enregistrée(s)" || _ui_badge ok 'aucune')"
        _ui_kv "Erreurs serveurs"  "$([[ "${n_esrv:-0}" -gt 0 ]] && _ui_badge err "$n_esrv enregistrée(s)" || _ui_badge ok 'aucune')"

        _ui_head "Consulter"
        _ui_item 1 "Journal du panel        ${DIM}(50 dernières lignes)${RESET}"
        _ui_item 2 "Journal principal       ${DIM}(50 dernières lignes)${RESET}"
        _ui_item 3 "Erreurs des services"
        _ui_item 4 "Erreurs des serveurs"
        _ui_item 5 "Rapport global          ${DIM}(bugs et état)${RESET}"
        _ui_item 6 "Vider le journal principal"
        _ui_item 0 "Retour"
        _ui_bot

        _ui_prompt
        read -r c || c=0
        case "$c" in
            1)
                _section "Setup Log"
                tail -50 "$LOG_FILE" 2>/dev/null || _warn "Pas de log setup" ;;
            2)
                _section "Log principal"
                tail -50 "$main_log" 2>/dev/null || _warn "Log absent" ;;
            3)
                # Le code de retour de jq est LU avant de conclure. Auparavant,
                # seule la vacuité de la sortie était testée : un JSON tronqué,
                # une clé `errors` absente ou un fichier illisible faisaient
                # échouer jq, la sortie était vide, et le panel de diagnostic
                # répondait « ✔ Aucune erreur » — au moment précis où il aurait
                # dû alerter.
                _section "Erreurs des services"
                _panel_logs_erreurs "$SERVICES_FILE" \
                    '.services | to_entries[] | .key as $s | .value.errors[]? | "  [\(.ts)] [\($s)] \(.msg)"' \
                    "services.json" ;;
            4)
                _section "Erreurs des serveurs"
                _panel_logs_erreurs "$SERVERS_FILE" \
                    '.servers | to_entries[] | .key as $s | .value.errors[]? | "  [\(.ts)] [\($s)] \(.msg)"' \
                    "servers.json" ;;
            5)
                _title "Rapport Global"
                echo ""
                if [[ -f "$SERVICES_FILE" ]]; then
                    printf "${BOLD}Services :${RESET}\n"
                    jq -r '.services | to_entries[] |
                        "  \(.key)\tinstalled:\(.value.installed)\tenabled:\(.value.enabled)\terrors:\(.value.errors|length)"
                    ' "$SERVICES_FILE" | column -t
                fi
                echo ""
                if [[ -f "$SERVERS_FILE" ]]; then
                    printf "${BOLD}Serveurs IRC :${RESET}\n"
                    jq -r '.servers | to_entries[] |
                        "  \(.key)\tinstalled:\(.value.installed)\tenabled:\(.value.enabled)\tsha256:\(.value.sha256.verified)\terrors:\(.value.errors|length)"
                    ' "$SERVERS_FILE" | column -t
                fi
                echo ""
                if [[ -f "$ACCESS_FILE" ]]; then
                    printf "${BOLD}Utilisateurs :${RESET}\n"
                    jq -r '.users[] | "  \(.login)\t[\(.grade)]\t\(if .active then "actif" else "inactif" end)"' \
                        "$ACCESS_FILE" | column -t
                fi
                echo ""
                if [[ -f "$EGGDROP_FILE" ]]; then
                    printf "${BOLD}Eggdrop :${RESET}\n"
                    _kv "  Bots"    "$(jq -r '.bots | length' "$EGGDROP_FILE")"
                    _kv "  Salons"  "$(jq -r '.channels | length' "$EGGDROP_FILE")"
                    _kv "  Owner"   "$(jq -r '.network.owner // "N/A"' "$EGGDROP_FILE")"
                fi
                echo ""
                if [[ -f "$IRIX_FILE" ]]; then
                    printf "${BOLD}IriX :${RESET}\n"
                    _kv "  Sources"          "$IRIX_SOURCE_DIR"
                    _kv "  Instance"         "$(jq -r '.instance.install_dir' "$IRIX_FILE")"
                    _kv "  Déployé"          "$(jq -r '.instance.installed' "$IRIX_FILE")"
                    _kv "  En cours"         "$(_irix_status 2>/dev/null || echo "N/A")"
                fi ;;
            6)
                read -rp " Vider le log principal ? [y/N] : " rep || true
                if [[ "$rep" =~ ^[YyOo]$ ]]; then
                    : > "$main_log"
                    _ok "Log vidé"
                else
                    _info "Annulé"
                fi ;;
            0) return ;;
            *) _warn "Choix invalide" || true ;;
        esac
        _pause
    done
}

# =============================================================================
# INTÉGRITÉ GLOBALE
# =============================================================================
# Les six fichiers dont on garde une empreinte. ⚠ Une seule liste, lue par la
# vérification ET par la pose : deux listes divergeraient, et c'est ainsi que la
# clé `overop` était restée écrite-jamais-relue.
_integrity_fichiers() {
    printf '%s\n' \
        "services:$SERVICES_FILE" \
        "servers:$SERVERS_FILE" \
        "access:$ACCESS_FILE" \
        "eggdrop:$EGGDROP_FILE" \
        "irix:$IRIX_FILE" \
        "overop:$OVEROP_FILE"
}

# ── ⚠⚠ POSER LES RÉFÉRENCES — un ACTE, pas un effet de bord ─────────────────
#
# Depuis le point 39, `_sha256_verify` ne pose plus rien toute seule : quand la
# référence manque, elle refuse. C'était le correctif, et il est juste — mais il
# laisse une question ouverte, que la migration du 20/08/2026 a rendue concrète
# en une minute : le fichier d'état neuf n'avait AUCUNE empreinte, donc plus
# rien n'était vérifiable, et rien ne permettait de repartir.
#
# ⚠ La réponse n'est pas de re-poser automatiquement — ce serait exactement le
# défaut qu'on vient de corriger. C'est d'en faire un geste EXPLICITE, que
# l'exploitant décide, en sachant ce qu'il fait.
#
# ⚠⚠ Ce geste ADOPTE l'état en place. Il ne doit être fait que sur une
# installation dont on sait qu'elle est saine — après une installation, ou
# après une migration comme celle du 20/08. Le faire sur une machine
# soupçonnée d'altération graverait l'altération dans la référence.
_integrity_reposer() {
    _section "Poser les empreintes de référence"

    _warn "Ce geste ADOPTE les fichiers TELS QU'ILS SONT, ici et maintenant."
    printf "  ${DIM}%s${RESET}\n" \
        "Les empreintes actuelles seront remplacées par celles des fichiers en place." \
        "À ne faire que si vous savez cette installation saine — après une pose," \
        "ou après un déplacement du fichier d'état." \
        "⚠ Sur une machine soupçonnée d'altération, ceci GRAVERAIT l'altération."

    local rep
    read -rp $'\n  Taper « oui » pour poser les références : ' rep || rep=""
    if [[ "$rep" != "oui" ]]; then
        _info "Rien n'a été posé."
        return 1
    fi

    local n=0 absents=0 ligne cle f
    while IFS= read -r ligne; do
        cle="${ligne%%:*}"; f="${ligne#*:}"
        if [[ -f "$f" ]]; then
            _sha256_store "$f" "$INIT_BASE" "$cle" && n=$(( n + 1 ))
            printf "  ${GREEN}✔${RESET} %-10s %s\n" "$cle" "$(basename "$f")"
        else
            printf "  ${DIM}%-10s : absent${RESET}\n" "$cle"
            absents=$(( absents + 1 ))
        fi
    done < <(_integrity_fichiers)

    _ok_f "%s empreinte(s) posée(s), %s fichier(s) absent(s)." "$n" "$absents"
    _log "INTEGRITE : $n empreinte(s) reposée(s) par l'exploitant"
    return 0
}

_integrity_check_all() {
    _section "Vérification SHA256 — tous les fichiers"

    # ⚠⚠ LA LISTE VIENT DE `_integrity_fichiers`, ET D'ELLE SEULE. Elle était
    # écrite en dur ici, et c'est ainsi que la clé `overop` est restée
    # ÉCRITE-JAMAIS-RELUE : trois endroits enregistraient son empreinte, et
    # cette liste-ci ne la contenait pas. Deux listes pour une même chose, c'est
    # toujours la seconde qu'on oublie.
    #
    # ⚠ Elle est donc partagée avec `_integrity_reposer` : ce qu'on pose et ce
    # qu'on vérifie ne peuvent plus diverger.

    # ⚠⚠ LE CODE DE RETOUR ÉTAIT AVALÉ À DEUX ÉTAGES — corrigé le 20/08/2026.
    #
    # Chaque itération finissait par `|| true`, donc la boucle rendait 0 ; et
    # comme c'était la dernière instruction, la fonction rendait 0 elle aussi.
    # **Aucun appelant ne pouvait distinguer « tout va bien » de « les cinq
    # fichiers sont corrompus ».**
    #
    # ⚠ C'est la régression déjà consignée dans `PROJET.md` — une branche de
    # refus rendue inatteignable —, reproduite ici un étage plus haut.
    local corrompus=0 sans_reference=0 absents=0 ligne key f

    while IFS= read -r ligne; do
        key="${ligne%%:*}"; f="${ligne#*:}"
        if [[ ! -f "$f" ]]; then
            printf "${DIM}%-12s${RESET} : absent\n" "$key"
            absents=$(( absents + 1 ))
            continue
        fi
        # ⚠ Le `|| true` est REMPLACÉ par une collecte : on garde le code, on
        # ne l'efface pas. 1 = pas de référence, 2 = empreinte invalide.
        _sha256_verify "$f" "$INIT_BASE" "$key"
        case "$?" in
            1) sans_reference=$(( sans_reference + 1 )) ;;
            2) corrompus=$(( corrompus + 1 )) ;;
        esac
    done < <(_integrity_fichiers)

    printf "\n"
    if [[ $corrompus -gt 0 ]]; then
        _err_f "%s fichier(s) NE CORRESPONDENT PAS à leur empreinte." "$corrompus"
        return 2
    fi
    if [[ $sans_reference -gt 0 ]]; then
        _warn_f "%s fichier(s) sans empreinte de référence — rien n'a pu être vérifié." "$sans_reference"
        return 1
    fi
    _ok_f "Tous les fichiers présents correspondent à leur empreinte (%s absent(s))." "$absents"
    return 0
}

# =============================================================================
# MODULE : UPDATE / UPGRADE — Service-Overtchat
# =============================================================================

# =============================================================================
# UTILITAIRES VERSION & URL
# =============================================================================

# Un numéro de version, d'où qu'il vienne — fichier `VERSION` nu, ou source
# qui porte encore un `VERSION="x.y.z"`.
#
# ⚠ Ces deux fonctions ne cherchaient QUE le second motif. Il a disparu du
# script à la 4.0.0, quand le fichier `VERSION` est devenu la source unique :
# il ne reste que `readonly VERSION="$(_read_version)"`, que ce grep ne
# reconnaît pas. Résultat, les deux rendaient une chaîne vide depuis, et
# `_fmt_version_diff` retombait systématiquement sur son affichage par
# empreintes — « [3f2a1b9c → 7d4e0a52] », qui a l'air d'une comparaison de
# versions sans en être une. Un affichage dégradé qui ressemble au nominal
# est un défaut qu'on ne voit jamais.
#
# L'ordre compte : un fichier `VERSION` ne contient qu'un numéro, on le lit
# donc en entier d'abord ; le motif n'est cherché qu'à défaut, pour les
# fichiers qui portent encore une constante.
_extract_version() {
    local brut="$1" v
    v="$(printf '%s' "$brut" | tr -d '[:space:]')"
    if [[ "$v" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
        printf '%s' "$v"
        return 0
    fi
    printf '%s' "$brut" \
        | grep -Eo 'VERSION="?([0-9]+\.[0-9]+\.[0-9]+)"?' \
        | head -n1 \
        | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+'
}

_get_local_version() {
    local file="$1"
    [[ -f "$file" ]] || return 1
    _extract_version "$(cat "$file" 2>/dev/null)"
}

_get_remote_version() {
    local url="$1"
    _extract_version "$(curl -fsSL --connect-timeout 10 "$url" 2>/dev/null)"
}

# Compare deux versions. Rend 0 si égales, 1 si la première est la plus
# récente, 2 si c'est la seconde. Une version vide perd toujours.
#
# Une comparaison textuelle placerait « 4.10 » AVANT « 4.9 » : les mises à
# jour seraient annoncées à l'envers, et rien ne le signalerait. Trois
# entiers comparés à la main se relisent sans documentation — même logique
# que `update_comparer()` côté site.
_version_cmp() {
    local a="$1" b="$2" i
    [[ "$a" == "$b" ]] && return 0
    [[ -z "$a" ]] && return 2
    [[ -z "$b" ]] && return 1

    local -a pa pb
    IFS='.' read -r -a pa <<< "$a"
    IFS='.' read -r -a pb <<< "$b"

    for i in 0 1 2; do
        local x="${pa[$i]:-0}" y="${pb[$i]:-0}"
        x="${x//[^0-9]/}"; y="${y//[^0-9]/}"
        (( 10#${x:-0} > 10#${y:-0} )) && return 1
        (( 10#${x:-0} < 10#${y:-0} )) && return 2
    done
    return 0
}

_fmt_version_diff() {
    local v_local="$1" v_remote="$2" h_local="$3" h_remote="$4"

    if   [[ -n "$v_local"  && -n "$v_remote"  ]]; then
        printf '[%s → %s]' "$v_local" "$v_remote"
    elif [[ -n "$h_local"  && -n "$h_remote"  ]]; then
        printf '[%.8s → %.8s]' "$h_local" "$h_remote"
    else
        printf '[?]'
    fi
}

# =============================================================================
# LOGS
# =============================================================================

# Compteurs de la session courante.
#
# Ils vivent ICI, dans le journal, et non aux vingt points d'échec du module :
# chacun de ces points appelle déjà `_upd_log "ERROR"`, et un compteur posé à
# côté de chaque appel aurait fini par en oublier un — précisément celui qu'on
# aurait voulu voir dans le récapitulatif. Compter là où l'on écrit, c'est
# compter tout ce qui est écrit.
#
# `UPD_INCIDENTS` garde le TEXTE des erreurs : un récapitulatif qui annonce
# « 3 erreurs » sans dire lesquelles oblige à ouvrir le journal, ce qui est
# exactement ce qu'on ne fait pas quand une mise à jour échoue à distance.
UPD_ERREURS=0
UPD_AVERTS=0
declare -a UPD_INCIDENTS=()

_upd_log() {
    local level="$1" msg="$2"
    case "$level" in
        ERROR) UPD_ERREURS=$(( UPD_ERREURS + 1 )); UPD_INCIDENTS+=( "$msg" ) ;;
        WARN)  UPD_AVERTS=$(( UPD_AVERTS + 1 )) ;;
    esac
    mkdir -p "$(dirname "$UPDATE_LOG")"
    printf '[%s] [%-5s] %s\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$msg" \
        >> "$UPDATE_LOG"
}

# Remet les compteurs à zéro au début d'une opération.
#
# Sans cela, une session TUI qui vérifie puis applique puis vérifie encore
# cumulerait les erreurs des trois passes, et alerterait sur des incidents
# déjà réglés.
_upd_compteurs_reset() {
    UPD_ERREURS=0
    UPD_AVERTS=0
    UPD_INCIDENTS=()
}

# =============================================================================
# ALERTE PAR COURRIEL — quand une mise à jour échoue
# =============================================================================
#
# ── Pourquoi le script envoie, et pas le site ───────────────
#
# Une mise à jour qui échoue échoue souvent LOIN : sur une installation qu'on
# ne regarde pas, lancée par une tâche planifiée. Le journal la consigne, et
# personne ne l'ouvre — c'est la définition d'un incident silencieux. Le seul
# moment où l'on sait quoi dire est celui où cela casse, et le seul qui le
# sache alors est le script.
#
# ── Le transport ────────────────────────────────────────────
#
# `sendmail -t` est fourni par Postfix, que l'installateur configure lui-même
# (voir `_postfix_configure`). Rien d'autre n'est exigé : ni `mailutils`, ni
# `mailx`, absents de la liste des paquets et qu'il aurait fallu y ajouter
# pour une seule ligne. `-t` lit le destinataire dans les en-têtes du message,
# ce qui évite de composer une ligne de commande avec une adresse venue d'un
# fichier de configuration.
#
# Un envoi impossible n'est PAS une erreur de plus : on le journalise et l'on
# passe. Une alerte qui fait échouer davantage la chose sur laquelle elle
# alerte n'a aucun intérêt.

# ⚠ Sous `Runtime/` de l'installation, plus dans /tmp (audit #25, 29/09/2026) :
#   un nom fixe de /tmp se pose d'avance par n'importe quel compte, et celui-ci
#   décidait du SILENCE des alertes de mise à jour.
readonly UPD_ALERTE_STAMP="$BASE_DIR/Runtime/.update-alerte"
readonly UPD_ALERTE_SILENCE=21600   # 6 h entre deux alertes identiques

# L'adresse d'alerte : celle de l'installation, sinon celle du projet.
#
# `.mail.to` d'identity.json est l'adresse d'exploitation — la même que le
# panel utilise pour son message de test. Sur une installation neuve, ou dont
# l'identité n'a pas encore été écrite, on retombe sur l'adresse du projet :
# mieux vaut que l'auteur reçoive une alerte orpheline que personne.
_upd_mail_dest() {
    local f="${BASE_DIR}/Config/identity.json" a=""
    if [[ -r "$f" ]] && command -v jq >/dev/null 2>&1; then
        a="$(jq -re '.mail.to // empty' "$f" 2>/dev/null || true)"
    fi
    [[ "$a" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$ ]] || a="service.overtchat@free.fr"
    printf '%s' "$a"
}

# ════════════════════════════════════════════════════════════════
#  LA DOCTRINE DU RAPPORT, EN BASH — point 9, phase 5.
#
#  ⚠⚠ CE CHEMIN EST RESTÉ HORS DU FILET DU 27 AU 28/08/2026, et il était nommé
#  au banc comme tel. Une mise à jour qui échoue le fait souvent LOIN, sur une
#  installation qu'on ne regarde pas — et ce qu'elle emporte alors, personne ne
#  le relit avant qu'il parte. ⚠ Pire : quand `.mail.to` n'est pas encore écrit,
#  l'adresse de repli est celle du PROJET. Le rapport d'un tiers arrive donc
#  chez l'auteur, avec ce qu'il transporte.
#
#  ⚠ Le texte de référence, les mesures et les pièges vivent dans
#  `Web/inc/doctrine-rapport.php`. On ne les recopie pas : deux proses divergent
#  encore plus vite que deux codes.
#
#  ⚠⚠ LA PARADE À LA DIVERGENCE — il y a TROIS implémentations (PHP, Tcl, bash)
#  et elles sont éprouvées sur un seul fichier de cas,
#  `Web/tests/corpus-rapport.txt`, par trois bancs distincts. Toute correction
#  apportée ici commence par une ligne ajoutée là-bas.
#
#  ── ⚠ POURQUOI `sed` ET RIEN D'AUTRE ────────────────────────
#
#  Pas d'appel à PHP : cette alerte part précisément quand l'installation va
#  mal, et faire dépendre le nettoyage d'un interpréteur que la panne peut avoir
#  emporté reviendrait à n'avoir aucun nettoyage le jour où il sert. `sed` est
#  là avant tout le reste.
#
#  ⚠ Les classes sont écrites `A-Za-z0-9` en toutes lettres, jamais
#  `[[:alpha:]]` : celles-ci dépendent de la locale, et l'installateur tourne
#  sous celle de l'exploitant, pas sous la nôtre.
#
#  ⚠⚠ ET LE PIÈGE QUE LE CORPUS A ATTRAPÉ LE JOUR MÊME : la première écriture
#  de l'étape IPv6 exigeait DEUX deux-points. Elle caviardait donc tous les
#  horodatages — `14:00:00` en a deux. La frontière est à TROIS, et les deux cas
#  jumeaux qui la gardent sont au corpus.
# ════════════════════════════════════════════════════════════════
_rapport_sans_secret() {
    printf '%s' "$1" | sed -E \
        -e 's/\b(pass|passe|passwd|password|motdepasse|pwd|secret|token|jeton|apikey|api_key)[[:space:]]*[=:][[:space:]]*[^[:space:]]+/\1=[retiré:secret]/Ig' \
        -e 's/(^|[^A-Za-z0-9_-])OPER[[:space:]]+([^[:space:]]+)[[:space:]]+[^[:space:]]+/\1OPER \2 [retiré:secret]/g' \
        -e 's/(^|[^A-Za-z0-9_-])OPER[[:space:]]+[^[:space:]]+[[:space:]]*$/\1OPER [retiré:secret]/g' \
        -e 's/(^|[^A-Za-z0-9_-])(--PASS2|PASS|IDENTIFY|REGISTER|AUTH|SASL)[[:space:]]+[^[:space:]]+/\1\2 [retiré:secret]/g' \
        -e 's/\b[0-9a-fA-F]{32,}\b/[retiré:empreinte]/g' \
        -e 's/\b[]A-Za-z0-9_[{}\\|^`-]+![~A-Za-z0-9._-]+@[A-Za-z0-9.:-]+/[retiré:masque]/g' \
        -e 's/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/[retiré:courriel]/g' \
        -e 's/(^|[^0-9A-Za-z:])(::[0-9A-Fa-f:]+|[0-9A-Fa-f:]+::[0-9A-Fa-f:]*|[0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){3,})($|[^0-9A-Za-z:])/\1[retiré:ip]\4/g' \
        -e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/[retiré:ip]/g'
}

# Combien de marques ce texte déjà filtré porte-t-il ? Sert à PRÉVENIR le
# lecteur : un rapport amputé se lit comme un rapport complet, et l'on cherche
# alors la panne dans ce qui reste.
_rapport_retires() {
    printf '%s' "$1" | grep -o '\[retiré:' 2>/dev/null | wc -l
}

# La CLÉ d'une alerte : ce qui distingue une cause d'une autre.
#
# ⚠ Elle dérive de l'étape en échec, pas du détail. Deux échecs de la même étape
# sont « la même chose qui recommence » et doivent se taire ; deux étapes
# différentes sont deux pannes, et la seconde ne doit jamais être avalée par le
# silence de la première.
#
# ⚠⚠ Le détail est délibérément EXCLU. Une clé qui porterait le message d'erreur
# changerait à chaque tour — un numéro de port, un horodatage, un identifiant de
# processus suffisent — et le silence ne se déclencherait jamais. On aurait
# rétabli les vingt-quatre courriels par jour en croyant affiner.
_upd_alerte_cle() {
    local brut
    # ⚠ Une seule ligne, sans espace : le fichier d'état est en « date clé », et
    # une clé qui contiendrait une espace couperait la lecture en deux champs.
    brut="$(printf '%s' "${1:-}" | tr -c 'A-Za-z0-9_-' '-')"
    brut="${brut##-}"; brut="${brut%%-}"
    [[ -n "$brut" ]] || brut="inconnu"
    printf '%s' "${brut:0:60}"
}

# Cette cause doit-elle se taire ? Rend 0 si OUI (déjà signalée dans la fenêtre),
# 1 si NON (cause neuve, ou silence expiré).
#
# ⚠⚠ ELLE EST À PART POUR UNE RAISON DE BANC, ET C'EST DÉLIBÉRÉ. Tant que cette
# décision vivait au milieu de `_upd_alerte_mail`, on ne pouvait l'éprouver
# qu'en laissant la fonction aller jusqu'à `sendmail` — c'est-à-dire en
# **envoyant un vrai courriel** depuis la machine de banc, qui a Postfix. Une
# garde qu'on ne peut pas éprouver sans nuire n'est jamais éprouvée.
_upd_alerte_silencieuse() {
    local cle="$1" maintenant="$2" d c
    [[ -f "$UPD_ALERTE_STAMP" ]] || return 1
    while IFS=' ' read -r d c; do
        [[ "$c" == "$cle" ]] || continue
        d="${d//[^0-9]/}"
        (( maintenant - ${d:-0} < UPD_ALERTE_SILENCE )) && return 0
    done < "$UPD_ALERTE_STAMP"
    return 1
}

# Note qu'une alerte vient de partir pour cette clé.
#
# ⚠ Le fichier se PURGE en s'écrivant : on ne garde que les clés encore dans la
# fenêtre de silence. Sans cela il grossirait d'une ligne par cause, pour
# toujours, sur une machine que personne ne regarde — le genre de fuite qui met
# deux ans à se voir.
_upd_alerte_marquer() {
    local cle="$1" quand="$2" tmp
    tmp="$(mktemp "${UPD_ALERTE_STAMP}.XXXXXX" 2>/dev/null)" || return 0
    {
        printf '%s %s\n' "$quand" "$cle"
        if [[ -f "$UPD_ALERTE_STAMP" ]]; then
            local d c
            while IFS=' ' read -r d c; do
                [[ -n "$c" && "$c" != "$cle" ]] || continue
                d="${d//[^0-9]/}"
                (( quand - ${d:-0} < UPD_ALERTE_SILENCE )) && printf '%s %s\n' "$d" "$c"
            done < "$UPD_ALERTE_STAMP"
        fi
    } > "$tmp" 2>/dev/null
    mv -f "$tmp" "$UPD_ALERTE_STAMP" 2>/dev/null || rm -f "$tmp" 2>/dev/null
    return 0
}

# Envoie l'alerte. `$1` = ce qui a échoué, en une ligne.
_upd_alerte_mail() {
    local sujet_court="$1"
    local dest exp hote maintenant sendmail_bin=""
    local v_loc v_dist

    # ── Ne pas alerter en boucle ────────────────────────────────────────
    #
    # Une tâche planifiée horaire qui échoue enverrait vingt-quatre messages
    # par jour, tous identiques. Au bout du troisième on ne les lit plus, et
    # l'alerte devient exactement ce qu'elle voulait éviter.
    #
    # ⚠⚠ CE SILENCE A ÉTÉ GLOBAL JUSQU'AU 28/08/2026, ET SON COMMENTAIRE MENTAIT
    # SUR SON PROPRE CODE. Il disait « 6 h entre deux alertes IDENTIQUES » —
    # mais `sujet_court` n'entrait nulle part dans la décision : une seule date
    # était retenue, pour toutes les causes confondues.
    #
    # Ce que cela coûtait, et ce n'est pas théorique : une mise à jour qui
    # échoue à 03:00 sur un dépôt injoignable **fait taire pendant six heures**
    # l'alerte de 04:00 qui dirait que la base de données a disparu. La seconde
    # panne est plus grave que la première, et c'est précisément celle qu'on
    # n'apprend jamais. *Un silence qui avale ce qu'il ne devrait pas est pire
    # que pas de silence du tout.*
    #
    # ⚠ La parade est celle qu'`irix-alerte.tcl` applique depuis le 16/08 : on
    # compte **par CLÉ**, pas en bloc. Une cause nouvelle passe toujours ; une
    # cause déjà signalée se tait. C'est la même doctrine, portée au voisin qui
    # ne l'avait jamais reçue.
    maintenant="$(date +%s)"
    local cle_alerte
    cle_alerte="$(_upd_alerte_cle "$sujet_court")"

    if _upd_alerte_silencieuse "$cle_alerte" "$maintenant"; then
        _upd_log "INFO" "Alerte courriel passée — « $cle_alerte » déjà signalée il y a moins de 6 h"
        return 0
    fi

    for sendmail_bin in /usr/sbin/sendmail /usr/lib/sendmail sendmail; do
        command -v "$sendmail_bin" >/dev/null 2>&1 && break
        sendmail_bin=""
    done
    if [[ -z "$sendmail_bin" ]]; then
        _upd_log "WARN" "Alerte courriel impossible : aucun sendmail sur cette machine"
        return 0
    fi

    dest="$(_upd_mail_dest)"
    hote="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo inconnu)"
    exp="overtchat-setup@${hote}"
    v_loc="$VERSION"
    v_dist="$(_upd_version_publiee 2>/dev/null || true)"

    # ── Ce qui passe par le filet, et ce qui n'y passe pas ───────────────
    #
    # ⚠⚠ LA MATIÈRE BRUTE, c'est la liste des incidents : des lignes rendues
    # telles quelles par apt, git, systemctl ou le serveur. C'est là que se
    # trouvent les masques d'hôte, les adresses et, le jour où cela arrive, un
    # secret qu'aucun humain n'a écrit — comme le `421 … motdepasse=<secret>`
    # du 09/08/2026, que le serveur avait mis lui-même dans sa réponse.
    #
    # ⚠ Les CHEMINS y passent aussi. Non parce qu'un chemin serait sensible —
    # le filet n'y touche pas, et c'est éprouvé au corpus — mais parce qu'un
    # chemin peut en contenir un, et qu'on ne saurait pas dire lequel d'avance.
    #
    # ⚠⚠ CE QUI N'Y PASSE PAS, ET C'EST DÉLIBÉRÉ : `dest` et `exp` sont des
    # EN-TÊTES. Les filtrer produirait un message sans destinataire, donc rien
    # du tout — la façon la plus sûre de ne rien protéger. Et le nom de machine
    # reste : c'est le SUJET du rapport, pas sa matière ; l'effacer laisserait
    # l'exploitant devant une panne sans savoir où elle a eu lieu.
    local i_filtres=() incident_f
    if (( ${#UPD_INCIDENTS[@]} > 0 )); then
        local inc
        for inc in "${UPD_INCIDENTS[@]}"; do
            incident_f="$(_rapport_sans_secret "$inc")"
            i_filtres+=("$incident_f")
        done
    fi
    local sujet_f log_f etat_f sauv_f base_f script_f retires
    sujet_f="$(_rapport_sans_secret "$sujet_court")"
    log_f="$(_rapport_sans_secret "$UPDATE_LOG")"
    etat_f="$(_rapport_sans_secret "$UPDATE_STATE")"
    sauv_f="$(_rapport_sans_secret "$UPDATE_BACKUP_DIR")"
    base_f="$(_rapport_sans_secret "$BASE_DIR")"
    script_f="$(_rapport_sans_secret "${SCRIPT_PATH:-inconnu}")"
    retires="$(_rapport_retires "${i_filtres[*]}
${sujet_f} ${log_f} ${etat_f} ${sauv_f} ${base_f} ${script_f}")"

    {
        printf 'From: overtchat-setup <%s>\n' "$exp"
        printf 'To: %s\n' "$dest"
        printf 'Subject: [Overtchat] Mise a jour en echec sur %s (%s incident(s))\n' \
               "$hote" "$UPD_ERREURS"
        printf 'Content-Type: text/plain; charset=utf-8\n'
        printf 'X-Mailer: %s v%s\n' "$SCRIPT_NAME" "$VERSION"
        printf '\n'
        printf 'Une mise a jour a echoue.\n\n'
        printf '  Machine      : %s\n' "$hote"
        printf '  Date         : %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        printf '  Version      : %s\n' "${v_loc:-inconnue}"
        [[ -n "$v_dist" ]] && printf '  Publiee      : %s\n' "$v_dist"
        printf '  Script       : %s\n' "$script_f"
        printf '  Installation : %s\n' "$base_f"
        printf '  Etape        : %s\n' "$sujet_f"
        printf '  Erreurs      : %s\n' "$UPD_ERREURS"
        printf '  Avertissem.  : %s\n' "$UPD_AVERTS"
        printf '\n'
        if (( ${#i_filtres[@]} > 0 )); then
            printf 'Incidents rencontres, dans l ordre :\n\n'
            local i
            for i in "${i_filtres[@]}"; do
                printf '  - %s\n' "$i"
            done
            printf '\n'
        fi
        printf 'Journal complet : %s\n' "$log_f"
        printf 'Etat des fichiers : %s\n' "$etat_f"
        printf 'Sauvegardes : %s\n' "$sauv_f"
        printf '\n'
        if (( retires > 0 )); then
            printf '%s element(s) ont ete retires de ce rapport avant envoi\n' "$retires"
            printf '(doctrine du rapport — Web/inc/doctrine-rapport.php).\n'
            printf 'Le journal ci-dessus, lui, est reste sur la machine et les garde.\n'
            printf '\n'
        fi
        printf 'Retour arriere possible depuis le panel (menu 8, entree 6),\n'
        printf 'ou en ligne : %s rollback\n' "$SCRIPT_NAME"
        printf '\n-- \n%s v%s\n' "$SCRIPT_NAME" "$VERSION"
    } | "$sendmail_bin" -t 2>/dev/null && {
        _upd_alerte_marquer "$cle_alerte" "$maintenant"
        _upd_log "INFO" "Alerte courriel remise au relais local pour $dest (clé « $cle_alerte »)"
        return 0
    }

    _upd_log "WARN" "Alerte courriel non remise au relais (sendmail a refusé)"
    return 0
}

# Le récapitulatif de fin, affiché ET journalisé, suivi de l'alerte s'il y a
# lieu. Un seul endroit décide d'alerter : deux en auraient divergé.
_upd_bilan() {
    local etape="$1" rc="${2:-0}"

    _kv "Erreurs" "$UPD_ERREURS"
    _kv "Avertissements" "$UPD_AVERTS"

    if (( UPD_ERREURS > 0 )); then
        _upd_alerte_mail "$etape"
    fi
    return "$rc"
}

# =============================================================================
# CONSTRUCTION D'URL RAW GITHUB
# =============================================================================

# _upd_brutegit_url <chemin_relatif>
#
#   Lit `.git.brutegit` depuis INIT_BASE (ex: "https://raw.githubusercontent.com/user/repo/main")
#   et concatène proprement le chemin relatif.
#
#   Retourne 1 si la clé est absente, 2 si le chemin est invalide.

_upd_brutegit_url() {
    local rel="$1"
    local base

    # Lecture de la base — échoue proprement si absente
    base="$(jq -re '.git.brutegit // empty' "$INIT_BASE" 2>/dev/null)" || {
        _upd_log "ERROR" "Clé .git.brutegit absente dans $INIT_BASE"
        return 1
    }

    # Normalisation du chemin relatif
    rel="${rel#./}"
    rel="${rel#/}"

    # Sécurité : refuse traversée de répertoire
    if [[ "$rel" == *".."* || -z "$rel" ]]; then
        _upd_log "ERROR" "Chemin relatif invalide : '$rel'"
        return 2
    fi

    # Construction propre : base sans slash final + / + chemin sans slash initial
    printf '%s/%s\n' "${base%/}" "$rel"
}

# La version PUBLIÉE par le site, ou une chaîne vide.
#
# Le fichier `VERSION` est servi par la même base que les composants — c'est
# `/dl/VERSION` sur l'installation de référence. Une seule requête, un seul
# numéro : de quoi répondre à « ai-je du retard ? » sans télécharger les
# 300 Ko du script pour le comparer.
#
# Mise en cache dans la variable, pas sur disque : le numéro doit être frais
# à chaque lancement, et une même exécution ne le demande jamais deux fois
# pour deux réponses différentes.
UPD_VERSION_DISTANTE=""
_upd_version_publiee() {
    if [[ -n "$UPD_VERSION_DISTANTE" ]]; then
        printf '%s' "$UPD_VERSION_DISTANTE"
        return 0
    fi
    local url v
    url="$(_upd_brutegit_url "VERSION")" || return 1
    v="$(_get_remote_version "$url")" || return 1
    [[ -z "$v" ]] && return 1
    UPD_VERSION_DISTANTE="$v"
    printf '%s' "$v"
}

# =============================================================================
# HASHES LOCAUX ET DISTANTS
# =============================================================================

_upd_local_sha256() {
    local file="$1"
    [[ -f "$file" ]] || { printf ''; return 0; }
    sha256sum "$file" 2>/dev/null | awk '{print $1}'
}

_upd_remote_sha256() {
    local url="$1"
    local tmp

    tmp="$(mktemp)" || return 1
    # ${tmp:-} et non $tmp : un trap RETURN reste défini GLOBALEMENT après le
    # retour de la fonction qui l'a posé, et se redéclenche au retour des
    # fonctions suivantes — où $tmp n'existe plus. Sous set -u, cela tuait le
    # panel ("tmp : variable sans liaison") au retour de _upd_check_all.
    trap 'rm -f "${tmp:-}"' RETURN

    if ! curl -fsSL --connect-timeout 10 --retry 2 "$url" -o "$tmp" 2>/dev/null; then
        return 2
    fi
    [[ -s "$tmp" ]] || return 3

    sha256sum "$tmp" | awk '{print $1}'
}

# =============================================================================
# ÉTAT PERSISTANT : update_state.json
# =============================================================================

_upd_state_init() {
    if [[ -f "$UPDATE_STATE" ]]; then
        _upd_state_purge_exclus
        return 0
    fi

    mkdir -p "$(dirname "$UPDATE_STATE")"
    jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        '{
            meta: { created: $ts, last_check: null, last_update: null },
            files: {},
            update_history: []
        }' > "$UPDATE_STATE"

    _upd_log "INFO" "Fichier d'état initialisé : $UPDATE_STATE"
}

# -----------------------------------------------------------------------------
# _upd_state_purge_exclus — retire de l'état ce que le balayage n'accepte plus.
#
# `_upd_exclu` filtre ce qui ENTRE dans la liste, jamais ce qui y est déjà. Les
# exclusions ont été ajoutées au fil des incidents ; les fichiers indexés avant
# elles y sont restés, sans que rien ne les enlève jamais.
#
# Constaté le 31/07/2026 : sept copies de `IriX/cmds/*.tcl` prises dans des
# `.backup-chantier-*` de juillet occupaient l'état, éternellement « pending »
# puisqu'aucune n'existe dans le dépôt. `status` les affichait en tête de liste,
# par ordre alphabétique — sept lignes orange au-dessus des vrais fichiers, à
# chaque consultation. Un écran d'état qui commence par du bruit finit par
# n'être plus lu, et c'est là que les vrais écarts se cachent.
#
# La purge tourne à chaque `_upd_state_init`, donc avant `check`, `status`,
# `history` et le panneau : l'état ne peut plus contenir un fichier que le
# balayage refuserait aujourd'hui. Ajouter demain une exclusion nettoiera du
# même coup ce qu'elle vise, sans geste manuel.
#
# `update_history` n'est PAS touché : c'est un journal de ce qui a eu lieu, et
# une mise à jour appliquée à un fichier aujourd'hui exclu a bien eu lieu.
# -----------------------------------------------------------------------------
_upd_state_purge_exclus() {
    [[ -f "$UPDATE_STATE" ]] || return 0

    local rel tmp
    local -a morts=()

    while IFS= read -r rel; do
        [[ -z "$rel" ]] && continue
        _upd_exclu "$rel" && morts+=( "$rel" )
    done < <(jq -r '.files | keys[]?' "$UPDATE_STATE" 2>/dev/null)

    [[ ${#morts[@]} -eq 0 ]] && return 0

    tmp="$(mktemp)" || return 1
    # Les clés passent par --args et non par une expression construite : un nom
    # de fichier contenant un guillemet ou un crochet casserait le filtre jq, et
    # l'état serait réécrit vide — c'est-à-dire perdu, sans message.
    if jq '.files |= with_entries(select(.key as $k | ($ARGS.positional | index($k)) | not))' \
           "$UPDATE_STATE" --args "${morts[@]}" > "$tmp" 2>/dev/null; then
        mv "$tmp" "$UPDATE_STATE"
        _upd_log "INFO" "État purgé : ${#morts[@]} fichier(s) exclu(s) retiré(s)"
    else
        rm -f "$tmp"
        _upd_log "WARN" "Purge de l'état échouée — état laissé intact"
    fi
}

_upd_state_get() {
    local rel="$1" field="$2"
    jq -r --arg r "$rel" ".files[\$r].${field} // empty" \
        "$UPDATE_STATE" 2>/dev/null || printf ''
}

# status : "ok" | "pending" | "error"
_upd_state_set_file() {
    local rel="$1" hl="$2" hr="$3" st="$4"
    local ts tmp

    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    tmp="$(mktemp)" || { _upd_log "ERROR" "mktemp échoué (_upd_state_set_file)"; return 1; }

    jq  --arg r  "$rel" \
        --arg hl "$hl" \
        --arg hr "$hr" \
        --arg st "$st" \
        --arg ts "$ts" \
        '.files[$r] = { hash_local: $hl, hash_remote: $hr, status: $st, last_checked: $ts }' \
        "$UPDATE_STATE" > "$tmp" 2>/dev/null \
    && mv "$tmp" "$UPDATE_STATE" \
    || { rm -f "$tmp"; _upd_log "ERROR" "Écriture état échouée : $rel"; return 1; }
}

_upd_state_record_update() {
    local rel="$1" before="$2" after="$3"
    local ts tmp

    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    tmp="$(mktemp)" || return 1

    jq  --arg r  "$rel" \
        --arg b  "$before" \
        --arg a  "$after" \
        --arg ts "$ts" \
        '.update_history += [{ file: $r, ts: $ts, hash_before: $b, hash_after: $a }]
         | .meta.last_update = $ts' \
        "$UPDATE_STATE" > "$tmp" 2>/dev/null \
    && mv "$tmp" "$UPDATE_STATE" \
    || { rm -f "$tmp"; return 1; }
}

_upd_state_set_last_check() {
    local ts tmp

    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    tmp="$(mktemp)" || return 1

    jq --arg ts "$ts" '.meta.last_check = $ts' \
        "$UPDATE_STATE" > "$tmp" 2>/dev/null \
    && mv "$tmp" "$UPDATE_STATE" \
    || { rm -f "$tmp"; return 1; }
}

# =============================================================================
# COLLECTE DES FICHIERS SURVEILLÉS
# =============================================================================

_upd_collect_watched() {
    local -A seen=()
    local f rel abs ext

    # sécurité environnement
    [[ -z "$BASE_DIR" ]] && {
        echo "[ERROR] BASE_DIR non défini" >&2
        return 1
    }

    # --- 1. CORE_WATCHED ---
    if declare -p CORE_WATCHED &>/dev/null; then
        for f in "${CORE_WATCHED[@]}"; do
            [[ -z "$f" || -n "${seen[$f]:-}" ]] && continue
            seen["$f"]=1
            printf '%s\n' "$f"
        done
    else
        echo "[WARN] CORE_WATCHED non défini" >&2
    fi

    # --- 2. INIT_BASE ---
    if [[ -f "$INIT_BASE" ]]; then
        while IFS= read -r rel; do
            [[ -z "$rel" || -n "${seen[$rel]:-}" ]] && continue
            seen["$rel"]=1
            printf '%s\n' "$rel"
        done < <(jq -r '.import[]? // empty' "$INIT_BASE" 2>/dev/null)
    fi

    # --- 3. scan dynamique ---
    if [[ ${#WATCHED_EXTENSIONS[@]} -eq 0 ]]; then
        echo "[WARN] WATCHED_EXTENSIONS vide" >&2
        return 0
    fi

    for ext in "${WATCHED_EXTENSIONS[@]}"; do
        while IFS= read -r abs; do
            rel="${abs#"${BASE_DIR}/"}"
            [[ -z "$rel" || -n "${seen[$rel]:-}" ]] && continue
            _upd_exclu "$rel" && continue
            seen["$rel"]=1
            printf '%s\n' "$rel"
        done < <(find "$BASE_DIR" -type f -name "*.${ext}" 2>/dev/null)
    done
}

# -----------------------------------------------------------------------------
# _upd_exclu <chemin relatif> — vrai si le fichier NE DOIT PAS être surveillé.
#
# Sans ce filtre, « Mise à jour fichiers » se retournait contre l'installation
# qu'il devait entretenir. Le scan ramassait tout `*.json` sous la racine, donc
# aussi les fichiers d'ÉTAT que le panel réécrit lui-même en permanence :
# Config/identity.json, irix.json, eggdrop.json, config.json. Leur contenu
# local diffère forcément de celui du dépôt — ils sont propres à cette
# installation. Ils étaient donc classés « en attente » à chaque passage, puis
# écrasés par la version de l'auteur amont : le nom du réseau, son domaine et
# les adresses de courriel saisis par l'exploitant disparaissaient, remplacés
# par des valeurs d'exemple, et l'écran affichait « MIS À JOUR » en vert.
#
# Deuxième motif : les fichiers d'état qui n'existent PAS dans le dépôt
# (access.json, servers.json, services.json, update_state.json) donnaient un
# 404 à chaque vérification. Le rapport était donc rouge en permanence, ce qui
# noyait les vraies erreurs parmi le bruit.
#
# Troisième motif : Servers/ et Components/ contiennent les sources compilées
# d'UnrealIRCd et d'Eggdrop — des centaines de .conf et de .sh qui
# n'appartiennent pas à ce projet. Les scanner déclenchait plusieurs milliers
# de requêtes HTTP pour un simple « Vérifier les mises à jour ».
# -----------------------------------------------------------------------------
_upd_exclu() {
    case "$1" in
        # ── Tout ce qui commence par un point ────────────────────────────
        #
        # Règle générale plutôt que liste : `.git/`, `.claude/`, `.backups/`,
        # `.backup-chantier-*` et tout ce qu'un outil déposera demain. Un
        # répertoire caché à la racine d'une installation n'appartient jamais
        # au projet distribué — il appartient à l'outil qui l'a créé.
        #
        # `.claude/settings.json` passait le filtre et entrait dans la liste :
        # extension surveillée, chemin non prévu. Les deux motifs qui suivaient
        # ne couvraient que ce qu'on avait déjà vu passer.
        # Le second motif couvre les répertoires cachés IMBRIQUÉS — il exige un
        # « /. » quelque part, ce qu'aucun fichier du projet n'a : les points de
        # `IriX/cmds/IriX.json` ou de `Web/sql/001-web-users.sql` suivent une
        # lettre, jamais une barre oblique.
        .*|*/.*)                                         return 0 ;;
        # Journaux, où qu'ils soient
        Runtime/Logs/*|*.log)                            return 0 ;;
        # État propre à CETTE installation — jamais amont
        Config/access.json|Config/services.json)         return 0 ;;
        Config/servers.json|Config/update_state.json)    return 0 ;;
        Config/irix.json|Config/eggdrop.json)            return 0 ;;
        Config/identity.json|Config/config.json)         return 0 ;;
        Config/irix.log.json)                            return 0 ;;
        # Le référentiel des commandes, RETIRÉ de la distribution le 23/09/2026
        # (point 82 : aucun code ne le lisait). Une installation plus ancienne
        # en garde des copies ; sans cette ligne, la veille les redemanderait à
        # `/dl/` à chaque passage — 404 permanent, et un courriel d'alerte.
        Runtime/cmds_irix.json|IriX/cmds_irix.json)      return 0 ;;
        # L'outil de l'archive de SOURCES d'Overtchat-Hybrid, RETIRÉ le 29/09/2026
        # avec les sources servies (décision de SerVuS). `site.tar.gz` l'a
        # emporté du 28 au 29/09 : une installation de ces deux jours le garde,
        # et le redemanderait à `/dl/` — même 404 permanent que la ligne du dessus.
        Tools/faire-archive-hybrid.sh)                   return 0 ;;
        # ⚠ Repéré le 20/08/2026 en instruisant le point 39 : il n'était dans
        # AUCUNE des deux listes. Le défaut dormait parce que le fichier
        # n'existe pas encore — il se serait réveillé au premier robot de prêt
        # configuré, exactement comme `Config/travaux.json` ci-dessous.
        Config/overop.json)                              return 0 ;;
        # Configuration d'OverCore (lien, SID, socket), faite depuis
        # `overcore.json.exemple` et gitignorée. Arrivée le 27/09/2026 SANS
        # être exclue ici — même oubli que `Config/overop.json` au-dessus ;
        # `test-json.php` l'a relevé le 28/09 dans le clone de prod.
        Robots/overcore/overcore.json)                   return 0 ;;
        # Avis de travaux : état du moment, propre à la machine qui sert, et
        # gitignoré. Ajouté le 31/07/2026 SANS être exclu ici — le premier
        # `check` qui a suivi a rendu un 404 et une erreur réseau permanente,
        # juste sous le commentaire qui décrit ce piège. Un fichier d'état neuf
        # entre dans les DEUX listes, ou dans aucune.
        Config/travaux.json)                             return 0 ;;
        # ⚠ IriX.json porte les identifiants MySQL en clair. Il n'est pas
        # distribué — le site sert son exemple — et il ne doit jamais entrer
        # dans la liste des fichiers remplaçables : une mise à jour qui
        # l'écraserait couperait le bot de sa base, sans rien dire.
        IriX/cmds/IriX.json)                             return 0 ;;
        # Configuration des daemons : versionnée comme modèle, ADAPTÉE sur
        # chaque machine — domaine, ports, chemins de certificats.
        # ⚠ `Config/unrealircd/*` figurait déjà ici. Le dossier a été RETIRÉ du
        # dépôt le 02/09/2026 — mesuré inerte : aucun de ses trois fichiers
        # n'était lu par quoi que ce soit. Le motif est CONSERVÉ à dessein :
        # une installation d'avant cette version le porte encore, et l'exclure
        # évite qu'une mise à jour l'écrase ou s'en plaigne.
        Config/apache/*|Config/unrealircd/*)             return 0 ;;
        Config/systemd/*|Config/polkit/*)                return 0 ;;
        Config/logrotate.d/*|Config/sudoers.d/*)         return 0 ;;
        # Le pare-feu par plage (05/10/2026) : un MODÈLE dont les seuils
        # s'adaptent à chaque machine, comme les unités et les vhosts. Le pool
        # PHP-FPM de phpMyAdmin suit la même règle.
        Config/nftables/*|Config/php-fpm/*)              return 0 ;;
        # Bancs d'essai : ils vivent dans le dépôt, pas dans une installation.
        # `Robots/*/test-*.tcl` suit la même règle : les bancs des robots de
        # prêt ne sont pas rangés dans un dossier `tests/` — ils sont posés à
        # côté du code qu'ils éprouvent, et les deux motifs ci-dessus les
        # auraient laissés passer.
        #
        # ⚠⚠ LES TROIS PREMIERS MOTIFS N'ANCRAIENT QUE LA RACINE — ce commentaire
        # décrivait donc une règle qui n'était pas appliquée. `Bootstrap/tests/*`
        # et `Tools/tests/*` passaient au travers : surveillés, demandés à
        # `/dl/`, jamais servis. **404 permanent à chaque vérification**, un
        # échec de plus à chaque mise à jour, donc un courriel d'alerte — la
        # régression exacte que le commentaire de `distrib.php` raconte pour les
        # dix scripts de `Tools/`.
        #
        # ⚠ Mesuré le 16/08/2026, et **aggravé le jour même** : en ajoutant
        # `Bootstrap/tests/test-integrite.sh`, j'en ai créé un second sans le
        # voir. Le motif générique couvre les niveaux quelconques ; les trois
        # ancrés restent, ils ne coûtent rien et disent ce qu'on visait d'abord.
        IriX/tests/*|Web/tests/*|tests/*|*/tests/*)      return 0 ;;
        Robots/*/test-*.tcl)                             return 0 ;;
        # Cache et état du site, réécrits par le site lui-même
        # ⚠⚠ `Runtime/state.default.json` A ÉTÉ OUBLIÉ ICI, et le banc
        # `test-json.php` l'a dit — en production seulement. Il n'existe pas
        # dans un clone : c'est l'installateur qui l'écrit. Le banc était donc
        # vert sur le poste où l'on travaille et ROUGE là où le fichier vit,
        # ce qui est exactement le motif qui fait passer un défaut au travers.
        #
        # Il porte les chemins absolus de LA machine (`/home/…`), il est écrit
        # par `_init_default`, et `state.json` — son jumeau, même structure,
        # même fonction — était exclu depuis toujours. Surveillé, il aurait été
        # demandé au dépôt amont, qui ne l'a pas : **404 permanent à chaque
        # vérification**, un échec de plus à chaque mise à jour, donc un
        # courriel d'alerte — la panne exacte de `Config/travaux.json` le
        # 31/07/2026 et de `Config/overop.json` le 20/08.
        #
        # ⚠ Écrit en toutes lettres plutôt qu'en `Runtime/state*.json` : un
        # motif large aurait couvert d'un coup tout fichier futur commençant
        # par `state`, y compris un modèle qui, lui, DOIT être distribué. Ce
        # fichier range en deux familles ; un motif qui devine range tout
        # seul dans l'une des deux, et personne ne s'en aperçoit.
        Web/.run/*|Runtime/state.json|Runtime/version.json) return 0 ;;
        Runtime/state.default.json)                      return 0 ;;
        # ── 🔴 LES DEUX FICHIERS D'ÉTAT D'OverPur — 19/09/2026 ───────────
        #
        # `test-json.php` les a signalés le jour même de leur naissance :
        # surveillés, donc demandés au dépôt amont, qui ne les a pas — les
        # deux sont dans `.gitignore`. **404 permanent à chaque vérification**,
        # et un courriel d'alerte avec.
        #
        # ⚠⚠ C'est la QUATRIÈME fois que ce défaut se produit : `travaux.json`
        # le 31/07, `overop.json` le 20/08, `state.default.json` ensuite, et
        # ceux-ci. *Un fichier d'état neuf est surveillé par défaut, et rien
        # ne le rappelle à celui qui l'écrit — seul ce banc le dit.*
        #
        # `overpur.json` porte la configuration du parc (écrite par le panel)
        # et `overpur-etat.json` ce que le superviseur a retenu de son parc.
        # Ni l'un ni l'autre ne s'installe : ils naissent sur la machine.
        Robots/overpur/overpur.json|Runtime/overpur-etat.json) return 0 ;;
        # Sources tierces compilées sur place
        Servers/*|Components/*|Builder/*)                return 0 ;;
        # Instance IriX : conf et état d'exécution, pas du code distribué
        IriX/runtime/*)                                  return 0 ;;
        # Copies horodatées laissées par le panel lui-même
        *.avant-*|*.bak|*.tmp)                           return 0 ;;
    esac
    return 1
}

# =============================================================================
# VÉRIFICATION D'UN FICHIER (retour : 0=pending, 1=ok, 2=erreur)
# =============================================================================

_upd_check_file() {
    local rel="$1"
    local url local_path tmp http_code size
    local hash_local="" hash_remote=""

    url="$(_upd_brutegit_url "$rel")" || {
        _upd_log "ERROR" "URL non construite pour : $rel"
        return 2
    }

    local_path="${BASE_DIR}/${rel}"

    [[ -f "$local_path" ]] && hash_local="$(_upd_local_sha256 "$local_path")"

    tmp="$(mktemp)" || { _upd_log "ERROR" "mktemp échoué (_upd_check_file)"; return 2; }
    # ${tmp:-} et non $tmp : un trap RETURN reste défini GLOBALEMENT après le
    # retour de la fonction qui l'a posé, et se redéclenche au retour des
    # fonctions suivantes — où $tmp n'existe plus. Sous set -u, cela tuait le
    # panel ("tmp : variable sans liaison") au retour de _upd_check_all.
    trap 'rm -f "${tmp:-}"' RETURN

    http_code="$(curl -fsSL -w '%{http_code}' \
        --connect-timeout 10 --retry 2 \
        -o "$tmp" "$url" 2>/dev/null)" || http_code="000"

    if [[ "$http_code" != "200" ]]; then
        _upd_log "ERROR" "HTTP $http_code pour $rel — URL : $url"
        return 2
    fi

    if [[ ! -s "$tmp" ]]; then
        _upd_log "ERROR" "Réponse distante vide : $rel"
        return 2
    fi

    # Un seuil en octets attrape la page d'erreur qui répond 200 et le
    # transfert coupé net. Mais TOUS les fichiers n'ont pas de volume : le
    # fichier `VERSION` en fait six — « 4.9.0 » et un saut de ligne — et
    # tombait donc en erreur permanente à chaque vérification, alors que le
    # site le servait correctement. La conséquence n'était pas cosmétique :
    # `update` aurait compté une erreur à chaque passage et envoyé une alerte
    # par courriel toutes les six heures, sur un fichier parfaitement sain.
    #
    # Le seuil s'efface donc quand le fichier LOCAL est lui-même court : on
    # sait alors à quoi s'attendre, et le garde-fou n'a rien à garder.
    size="$(stat -c '%s' "$tmp" 2>/dev/null || echo 0)"
    local seuil=10
    if [[ -f "$local_path" ]]; then
        local taille_locale
        taille_locale="$(stat -c '%s' "$local_path" 2>/dev/null || echo 0)"
        (( taille_locale > 0 && taille_locale < seuil )) && seuil=1
    fi
    if [[ "$size" -lt "$seuil" ]]; then
        _upd_log "ERROR" "Contenu suspect (${size} octets) : $rel"
        return 2
    fi

    hash_remote="$(sha256sum "$tmp" | awk '{print $1}')"

    if [[ -z "$hash_local" ]]; then
        _upd_state_set_file "$rel" "" "$hash_remote" "pending" || true
        return 0
    fi

    if [[ "$hash_local" != "$hash_remote" ]]; then
        _upd_state_set_file "$rel" "$hash_local" "$hash_remote" "pending" || true
        return 0
    fi

    _upd_state_set_file "$rel" "$hash_local" "$hash_remote" "ok" || true
    return 1
}

# =============================================================================
# VÉRIFICATION DE TOUS LES FICHIERS (dry-run)
# =============================================================================

_upd_check_all() {
    _upd_state_init

    local -a files=()
    mapfile -t files < <(_upd_collect_watched 2>/dev/null)

    if [[ "${#files[@]}" -eq 0 ]]; then
        _warn "Aucun fichier surveillé trouvé."
        _upd_log "WARN" "Aucun fichier surveillé trouvé lors de la collecte."
        return 0
    fi

    _section "Vérification des mises à jour"

    local total=0 ok_count=0 pending_count=0 error_count=0
    local -a pending_files=() error_files=()
    local rel rc url local_path v_local v_remote h_local h_remote diff

    for rel in "${files[@]}"; do
        [[ -z "$rel" ]] && continue
        total=$(( total + 1 ))
        printf '  %-52s ' "$rel"

        url="$(_upd_brutegit_url "$rel" 2>/dev/null)" || url=""
        local_path="${BASE_DIR}/${rel}"
        h_local=""
        h_remote=""
        v_local=""
        v_remote=""

        [[ -f "$local_path" ]] && h_local="$(_upd_local_sha256 "$local_path")"

        if [[ -n "$url" ]]; then
            h_remote="$(_upd_remote_sha256 "$url" 2>/dev/null)" || h_remote=""
        fi

        v_local="$(_get_local_version "$local_path" 2>/dev/null || true)"
        [[ -n "$url" ]] && v_remote="$(_get_remote_version "$url" 2>/dev/null || true)"

        diff="$(_fmt_version_diff "$v_local" "$v_remote" "$h_local" "$h_remote")"

        rc=0; _upd_check_file "$rel" || rc=$?

        case "$rc" in
            0)
                printf '%s %b[MISE À JOUR DISPO]%b\n' "$diff" "${ORANGE}" "${RESET}"
                pending_count=$(( pending_count + 1 ))
                pending_files+=( "$rel" )
                ;;
            1)
                printf '%s %b[À JOUR]%b\n' "$diff" "${GREEN}" "${RESET}"
                ok_count=$(( ok_count + 1 ))
                ;;
            2)
                printf '%s %b[ERREUR RÉSEAU]%b\n' "$diff" "${RED}" "${RESET}"
                error_count=$(( error_count + 1 ))
                error_files+=( "$rel" )
                ;;
            *)
                printf '%s %b[CODE INCONNU : %s]%b\n' "$diff" "${RED}" "$rc" "${RESET}"
                error_count=$(( error_count + 1 ))
                error_files+=( "$rel" )
                ;;
        esac
    done

    _upd_state_set_last_check || true

    printf '\n'
    _kv "Total surveillés" "$total"
    _kv "À jour" "$ok_count"
    _kv "À mettre à jour" "$pending_count"
    _kv "Erreurs réseau" "$error_count"

    if [[ "$pending_count" -gt 0 ]]; then
        printf '\n  Fichiers en attente :\n'
        local f; for f in "${pending_files[@]}"; do
            printf ' %b→%b %s\n' "${ORANGE}" "${RESET}" "$f"
        done
    fi

    if [[ "$error_count" -gt 0 ]]; then
        printf '\n  Fichiers en erreur :\n'
        local f; for f in "${error_files[@]}"; do
            printf ' %b✖%b %s\n' "${RED}" "${RESET}" "$f"
        done
    fi

    printf '\n'
    _upd_log "INFO" \
        "check_all : total=${total} ok=${ok_count} pending=${pending_count} errors=${error_count}"

    # Convention Unix : 0 = la vérification s'est déroulée correctement.
    #
    # Elle était inversée — 0 signifiait « des mises à jour attendent », 1
    # « tout est à jour ». Le menu masquait le contre-sens par un `|| true`,
    # mais tout appel non gardé (script de supervision, tâche cron) concluait
    # à un ÉCHEC sur un système parfaitement à jour. Le nombre de fichiers en
    # attente se lit dans update_state.json, il n'a pas à voyager dans le code
    # de retour.
    (( error_count > 0 )) && return 1
    return 0
}

# =============================================================================
# BACKUP
# =============================================================================

# -----------------------------------------------------------------------------
# SESSION DE SAUVEGARDE
#
# Une mise à jour = UNE session = un seul répertoire sous .backups/update/.
#
# L'horodatage était calculé dans _upd_backup_file, donc à chaque FICHIER.
# Appliquer cinquante fichiers prend plusieurs secondes : les sauvegardes se
# répartissaient sur autant de répertoires « 20260729_114233_1234 » distincts
# qu'il s'était écoulé de secondes. Or le retour arrière propose de restaurer
# UNE session — il n'en remettait donc qu'une fraction en place, en annonçant
# un succès complet. C'est le pire cas de figure pour un rollback : il paraît
# avoir fonctionné, et l'installation reste à moitié dans un état, à moitié
# dans l'autre.
#
# La session est ouverte par l'appelant (_upd_apply_pending, _upd_apply_all)
# et refermée à la fin. _upd_backup_file l'ouvre au besoin, pour qu'un appel
# isolé reste correct.
# -----------------------------------------------------------------------------
UPD_SESSION=""

_upd_session_ouvrir() {
    [[ -n "$UPD_SESSION" ]] && return 0
    UPD_SESSION="$(date +%Y%m%d_%H%M%S)_$$"
    _upd_log "INFO" "Session de sauvegarde ouverte : $UPD_SESSION"
}

_upd_session_fermer() {
    [[ -n "$UPD_SESSION" ]] && _upd_log "INFO" "Session de sauvegarde fermée : $UPD_SESSION"
    UPD_SESSION=""
}

_upd_backup_file() {
    local rel="$1"
    local src="${BASE_DIR}/${rel}"
    local dest

    [[ -f "$src" ]] || return 0

    _upd_session_ouvrir
    dest="${UPDATE_BACKUP_DIR}/${UPD_SESSION}/${rel}"

    mkdir -p "$(dirname "$dest")" || {
        _upd_log "ERROR" "Impossible de créer le répertoire backup pour : $rel"
        return 1
    }

    cp -p "$src" "$dest" || {
        _upd_log "ERROR" "Backup échoué : $rel → $dest"
        return 1
    }

    _upd_log "INFO" "Backup : $rel → $dest"
    printf '%s\n' "$dest"
}

# =============================================================================
# APPLICATION D'UNE MISE À JOUR
# =============================================================================

# Résout l'URL remote d'un fichier.
# Pour l'installateur (chemin local ≠ chemin repo), on cherche d'abord le chemin
# tel quel, puis son équivalent sous Bootstrap/ si la première tentative échoue,
# sans jamais préfixer deux fois.

_upd_resolve_download_url() {
    local rel="$1"
    local url tmp http_code size

    # ⚠⚠ LE MÊME SEUIL ADAPTATIF QUE `_upd_check_file`, ET SON ABSENCE ICI
    # RENDAIT `VERSION` DÉFINITIVEMENT INAPPLICABLE.
    #
    # Le seuil en octets attrape la page d'erreur servie en 200 et le transfert
    # coupé net. Mais tous les fichiers n'ont pas de volume : `VERSION` en fait
    # sept. `_upd_check_file` porte l'adaptation depuis longtemps — le
    # commentaire y explique même le symptôme —, et cette fonction-ci ne l'a
    # jamais eue. Résultat mesuré le 16/08/2026 : `check` annonçait « MISE À
    # JOUR DISPO », puis `apply` refusait le téléchargement pour cause de
    # taille, tentait le repli `Bootstrap/VERSION` (404), et échouait sur
    # « Aucune URL valide trouvée ». `VERSION` est l'un des trois fichiers
    # cœur : **chaque mise à jour comptait donc au moins un échec**, donc un
    # courriel d'alerte, sur un fichier parfaitement sain.
    #
    # ⚠ Deux fonctions qui jugent le même fichier doivent le juger pareil.
    # C'est le défaut « la même règle écrite à deux endroits » du projet, dans
    # sa forme la plus coûteuse : les deux étaient justes séparément.
    local seuil=10 local_path="${BASE_DIR}/${rel}"
    if [[ -f "$local_path" ]]; then
        local taille_locale
        taille_locale="$(stat -c '%s' "$local_path" 2>/dev/null || echo 0)"
        (( taille_locale > 0 && taille_locale < seuil )) && seuil=1
    fi

    url="$(_upd_brutegit_url "$rel")" || return 1

    tmp="$(mktemp)" || return 1
    # ${tmp:-} et non $tmp : un trap RETURN reste défini GLOBALEMENT après le
    # retour de la fonction qui l'a posé, et se redéclenche au retour des
    # fonctions suivantes — où $tmp n'existe plus. Sous set -u, cela tuait le
    # panel ("tmp : variable sans liaison") au retour de _upd_check_all.
    trap 'rm -f "${tmp:-}"' RETURN

    http_code="$(curl -fsSL -w '%{http_code}' \
        --connect-timeout 10 --retry 2 \
        -o "$tmp" "$url" 2>/dev/null)" || http_code="000"

    if [[ "$http_code" == "200" && -s "$tmp" ]]; then
        size="$(stat -c '%s' "$tmp" 2>/dev/null || echo 0)"
        if [[ "$size" -ge "$seuil" ]]; then
            printf '%s\n' "$url"
            return 0
        fi
    fi

    # Fallback : Bootstrap/<rel> — uniquement si pas déjà sous Bootstrap/
    if [[ "$rel" != Bootstrap/* ]]; then
        local alt_rel="Bootstrap/${rel}"
        local alt_url

        alt_url="$(_upd_brutegit_url "$alt_rel")" || return 1

        http_code="$(curl -fsSL -w '%{http_code}' \
            --connect-timeout 10 --retry 2 \
            -o "$tmp" "$alt_url" 2>/dev/null)" || http_code="000"

        if [[ "$http_code" == "200" && -s "$tmp" ]]; then
            size="$(stat -c '%s' "$tmp" 2>/dev/null || echo 0)"
            if [[ "$size" -ge "$seuil" ]]; then
                _upd_log "INFO" "Fallback Bootstrap utilisé pour : $rel → $alt_rel"
                printf '%s\n' "$alt_url"
                return 0
            fi
        fi
    fi

    _upd_log "ERROR" "Aucune URL valide trouvée pour : $rel"
    return 1
}

_upd_apply_file() {
    local rel="$1"
    local local_path="${BASE_DIR}/${rel}"
    local url tmp hash_before hash_dl hash_expected hash_after perms

    # ── Résolution URL (primaire + fallback Bootstrap/) ────────────────────
    url="$(_upd_resolve_download_url "$rel")" || {
        _upd_state_set_file "$rel" "$(_upd_local_sha256 "$local_path")" "" "error" || true
        return 1
    }

    hash_before="$(_upd_local_sha256 "$local_path")" || hash_before=""

    # ── Backup ─────────────────────────────────────────────────────────────
    _upd_backup_file "$rel" > /dev/null \
        || _upd_log "WARN" "Backup non effectué pour : $rel"

    mkdir -p "$(dirname "$local_path")" || {
        _upd_log "ERROR" "Impossible de créer le répertoire : $local_path"
        return 1
    }

    # ── Téléchargement atomique ────────────────────────────────────────────
    tmp="$(mktemp)" || { _upd_log "ERROR" "mktemp échoué (_upd_apply_file)"; return 1; }
    # ${tmp:-} et non $tmp : un trap RETURN reste défini GLOBALEMENT après le
    # retour de la fonction qui l'a posé, et se redéclenche au retour des
    # fonctions suivantes — où $tmp n'existe plus. Sous set -u, cela tuait le
    # panel ("tmp : variable sans liaison") au retour de _upd_check_all.
    trap 'rm -f "${tmp:-}"' RETURN

    if ! curl -fsSL --retry 3 --connect-timeout 10 "$url" -o "$tmp" 2>/dev/null; then
        _upd_log "ERROR" "Téléchargement échoué : $url"
        _upd_state_set_file "$rel" "$hash_before" "" "error" || true
        return 1
    fi

    if [[ ! -s "$tmp" ]]; then
        _upd_log "ERROR" "Fichier téléchargé vide : $rel"
        _upd_state_set_file "$rel" "$hash_before" "" "error" || true
        return 1
    fi

    # ── Vérification d'intégrité ───────────────────────────────────────────
    hash_dl="$(sha256sum "$tmp" 2>/dev/null | awk '{print $1}')" || {
        _upd_log "ERROR" "sha256sum échoué sur le fichier téléchargé : $rel"
        _upd_state_set_file "$rel" "$hash_before" "" "error" || true
        return 1
    }

    # ⚠⚠ CE BLOC S'APPELAIT DÉJÀ « Vérification d'intégrité », ET IL N'EN ÉTAIT
    # PAS UNE — point 26 de `Markdown/CHANTIERS.md`, phase 3.
    #
    # Il comparait `hash_dl` à `hash_remote`, valeur écrite par `_upd_check_file`
    # en hachant **un téléchargement antérieur de la même URL sur le même
    # serveur**. Deux fois le même fichier, au même endroit : cela attrape une
    # coupure de transfert entre les deux requêtes, et rien d'autre. Un serveur
    # qui sert deux fois le même contenu falsifié passait sans une réserve —
    # et le fichier était installé, avec `chmod +x` s'il finissait en `.sh`.
    #
    # C'est **le motif exact** que trois commentaires-stèles de ce fichier
    # reprochent à `safe_encrypt`, supprimée le 29/07/2026 : « son contrôle
    # d'intégrité enregistrait le hash du fichier qu'il venait lui-même de
    # télécharger — il ne validait donc rien ».
    #
    # ── La référence INDÉPENDANTE, d'abord ────────────────────────────────
    #
    # Le manifeste publié est une source distincte du téléchargement : il dit
    # publiquement à quoi le fichier authentique doit ressembler, et il a été
    # écrit avant qu'on demande quoi que ce soit.
    #
    # ⚠ ON NE L'INTERROGE QUE SI L'URL EST BIEN CELLE DU CHEMIN. Le module a un
    # repli qui va chercher certains fichiers sous une autre adresse : comparer
    # alors l'empreinte du chemin à un fichier venu d'ailleurs accuserait à
    # tort. Sans cette garde, le correctif créerait un faux refus.
    local hash_manif="" base_bg
    base_bg="$(_brutegit)"
    if [[ -n "$base_bg" && "$url" == "${base_bg}/${rel}" ]]; then
        hash_manif="$(_manifeste_sha256 "$rel" 2>/dev/null || true)"
    fi

    if [[ -n "$hash_manif" ]]; then
        if [[ "$hash_dl" != "$hash_manif" ]]; then
            _upd_log "ERROR" \
                "Intégrité KO (manifeste) : $rel — attendu=$hash_manif obtenu=$hash_dl"
            _upd_state_set_file "$rel" "$hash_before" "$hash_manif" "error" || true
            return 1
        fi
        _upd_log "INFO" "Empreinte vérifiée au manifeste : $rel"
    else
        # ── Repli : l'ancienne comparaison, et elle est NOMMÉE pour ce
        #    qu'elle est. Certains fichiers surveillés ne sont pas au
        #    manifeste ; refuser ici casserait leur mise à jour.
        hash_expected="$(_upd_state_get "$rel" "hash_remote")" || hash_expected=""

        if [[ -n "$hash_expected" && "$hash_dl" != "$hash_expected" ]]; then
            _upd_log "ERROR" \
                "Intégrité KO : $rel — attendu=$hash_expected obtenu=$hash_dl"
            _upd_state_set_file "$rel" "$hash_before" "$hash_expected" "error" || true
            return 1
        fi

        # ⚠⚠ ET SURTOUT : LE SILENCE EST ROMPU. Quand aucune empreinte n'est
        # connue, l'ancien code sautait le contrôle **sans un mot** — pas même
        # un avertissement. C'est le cas de toute première application, d'un
        # `update_state.json` effacé, ou d'un appel direct hors du parcours
        # normal. Un contrôle qu'on peut sauter en silence n'est pas un
        # contrôle : c'en est l'apparence.
        if [[ -z "$hash_expected" ]]; then
            _upd_log "WARN" "Aucune empreinte de référence pour $rel — posé sans vérification"
        else
            _upd_log "WARN" "Empreinte non publiée pour $rel — comparaison au précédent téléchargement seulement"
        fi
    fi

    # ── Préservation des permissions ───────────────────────────────────────
    perms=""
    [[ -f "$local_path" ]] && perms="$(stat -c '%a' "$local_path" 2>/dev/null)" || true

    # ── Remplacement atomique ──────────────────────────────────────────────
    mv "$tmp" "$local_path" || {
        _upd_log "ERROR" "mv échoué : $rel"
        _upd_state_set_file "$rel" "$hash_before" "" "error" || true
        return 1
    }

    # ── Permissions ────────────────────────────────────────────────────────
    [[ -n "$perms" ]] && chmod "$perms" "$local_path" 2>/dev/null || true

    if [[ "$rel" == *.sh || "$rel" == *overtchat-setup* ]]; then
        chmod +x "$local_path" 2>/dev/null || true
    fi

    # ── Enregistrement ─────────────────────────────────────────────────────
    hash_after="$(_upd_local_sha256 "$local_path")" || hash_after=""
    _upd_state_set_file "$rel" "$hash_after" "$hash_after" "ok" || true
    _upd_state_record_update "$rel" "$hash_before" "$hash_after" || true
    _upd_log "INFO" "Mis à jour avec succès : $rel"

    # ⚠ Le script vient d'être remplacé DANS L'INSTALLATION — pas forcément
    # là où il s'exécute. Lancé depuis un clone posé ailleurs, ou depuis
    # /tmp après un téléchargement, il se serait mis à jour sans se toucher :
    # « à jour » à l'écran, et la même version au lancement suivant.
    #
    # On écrit quand même dans BASE_DIR, et on le DIT. Écrire à la place dans
    # le fichier en cours reviendrait à modifier un chemin arbitraire —
    # éventuellement un dépôt de travail, éventuellement en lecture seule —
    # sur la foi d'un `readlink`. Un avertissement exact vaut mieux qu'une
    # écriture surprenante.
    if [[ "$rel" == "Bootstrap/overtchat-setup.sh" ]]; then
        local en_place="${BASE_DIR}/Bootstrap/overtchat-setup.sh"
        if [[ -n "${SCRIPT_PATH:-}" && "$SCRIPT_PATH" != "$en_place" ]]; then
            _upd_log "WARN" \
                "Script mis à jour dans $en_place, mais celui qui s'exécute est $SCRIPT_PATH — il reste à l'ancienne version"
            _warn "Le script mis à jour n'est pas celui que vous exécutez."
            _ui_kv "Mis à jour" "$en_place" 2>/dev/null || true
            _ui_kv "En cours"   "$SCRIPT_PATH" 2>/dev/null || true
        fi
    fi

    return 0
}

# =============================================================================
# APPLICATION DES MISES À JOUR EN ATTENTE
# =============================================================================

_upd_apply_pending() {
    _upd_state_init
    _section "Application des mises à jour en attente"

    local pending_n
    pending_n="$(jq '[.files[]? | select(.status=="pending")] | length' \
        "$UPDATE_STATE" 2>/dev/null)" || pending_n=0

    if [[ "$pending_n" -eq 0 ]]; then
        _info "Aucune mise à jour en attente. Lancez d'abord l'option [1]."
        return 0
    fi

    local -a files=()
    mapfile -t files < <(
        jq -r '.files | to_entries[] | select(.value.status=="pending") | .key' \
            "$UPDATE_STATE" 2>/dev/null
    )

    # Toutes les sauvegardes de cette application iront dans le MÊME
    # répertoire de session, pour qu-un retour arrière les reprenne ensemble.
    _upd_session_ouvrir

    local updated=0 failed=0 rel
    local -a failed_files=()

    for rel in "${files[@]}"; do
        [[ -z "$rel" ]] && continue
        printf ' %-52s ' "$rel"

        if _upd_apply_file "$rel"; then
            printf '%b[OK]%b\n' "${GREEN}" "${RESET}"
            updated=$(( updated + 1 ))
        else
            printf '%b[ÉCHEC]%b\n' "${RED}" "${RESET}"
            failed=$(( failed + 1 ))
            failed_files+=( "$rel" )
        fi
    done

    printf '\n'
    _kv "Mis à jour" "$updated"
    _kv "Échecs" "$failed"

    if [[ "$failed" -gt 0 ]]; then
        printf '\n Fichiers en échec :\n'
        local f; for f in "${failed_files[@]}"; do
            printf ' %b✖%b %s\n' "${RED}" "${RESET}" "$f"
        done
    fi

    printf '\n'
    [[ -n "$UPD_SESSION" ]] && _kv "Session de sauvegarde" "$UPD_SESSION"
    _upd_log "INFO" "apply_pending : mis_a_jour=${updated} echecs=${failed}"
    _upd_session_fermer
}

# =============================================================================
# VÉRIFICATION + APPLICATION EN UNE PASSE
# =============================================================================

_upd_apply_all() {
    _upd_state_init
    _section "Mise à jour complète (vérification + application)"

    local -a files=()
    mapfile -t files < <(_upd_collect_watched 2>/dev/null)

    if [[ "${#files[@]}" -eq 0 ]]; then
        _warn "Aucun fichier surveillé trouvé."
        _upd_log "WARN" "Aucun fichier surveillé trouvé lors de la collecte."
        return 0
    fi

    # Une seule session de sauvegarde pour toute la passe (voir
    # _upd_session_ouvrir), afin que le retour arrière restaure l'ensemble.
    _upd_session_ouvrir

    local updated=0 skipped=0 failed=0 rel rc
    local -a failed_files=()

    for rel in "${files[@]}"; do
        [[ -z "$rel" ]] && continue
        printf '  %-52s ' "$rel"

        rc=0; _upd_check_file "$rel" || rc=$?

        case "$rc" in
            1)
                printf '%b[À JOUR]%b\n' "${GREEN}" "${RESET}"
                skipped=$(( skipped + 1 ))
                ;;
            0)
                if _upd_apply_file "$rel"; then
                    printf '%b[MIS À JOUR]%b\n' "${GREEN}" "${RESET}"
                    updated=$(( updated + 1 ))
                else
                    printf '%b[ÉCHEC]%b\n' "${RED}" "${RESET}"
                    failed=$(( failed + 1 ))
                    failed_files+=( "$rel" )
                fi
                ;;
            2)
                printf '%b[ERREUR RÉSEAU]%b\n' "${RED}" "${RESET}"
                failed=$(( failed + 1 ))
                failed_files+=( "$rel" )
                ;;
            *)
                printf '%b[CODE INCONNU : %s]%b\n' "${RED}" "$rc" "${RESET}"
                failed=$(( failed + 1 ))
                failed_files+=( "$rel" )
                ;;
        esac
    done

    printf '\n'
    _kv "Inchangés" "$skipped"
    _kv "Mis à jour" "$updated"
    _kv "Échecs" "$failed"

    if [[ "$failed" -gt 0 ]]; then
        printf '\n Fichiers en échec :\n'
        local f; for f in "${failed_files[@]}"; do
            printf ' %b✖%b %s\n' "${RED}" "${RESET}" "$f"
        done
    fi

    printf '\n'
    [[ -n "$UPD_SESSION" ]] && _kv "Session de sauvegarde" "$UPD_SESSION"
    _upd_log "INFO" \
        "apply_all : inchanges=${skipped} mis_a_jour=${updated} echecs=${failed}"
    _upd_session_fermer

    if [[ "$failed" -eq 0 ]]; then
        _upd_bilan "verification et application" 0
        return 0
    else
        _upd_log "ERROR" "apply_all : échecs détectés"
        _upd_bilan "verification et application" 1
        return 1
    fi
}

# =============================================================================
# ROLLBACK
# =============================================================================

_upd_rollback_session() {
    local session="$1"
    local session_dir="${UPDATE_BACKUP_DIR}/${session}"

    [[ -d "$session_dir" ]] || { _err "Session introuvable : $session"; return 1; }

    _section "Rollback → $session"

    local count=0 echecs=0 total=0 bak_file rel dest

    while IFS= read -r bak_file; do
        rel="${bak_file#"${session_dir}"/}"
        dest="${BASE_DIR}/${rel}"
        total=$(( total + 1 ))

        mkdir -p "$(dirname "$dest")" || {
            _warn_f "Impossible de créer le répertoire pour : %s" "$rel"
            echecs=$(( echecs + 1 ))
            continue
        }

        if cp -p "$bak_file" "$dest"; then
            printf ' %b✔%b Restauré : %s\n' "${GREEN}" "${RESET}" "$rel"
            _upd_log "INFO" "Rollback $session : restauré $rel"
            count=$(( count + 1 ))
        else
            _warn_f "Restauration échouée : %s" "$rel"
            _upd_log "ERROR" "Rollback $session : échec restauration $rel"
            echecs=$(( echecs + 1 ))
        fi
    done < <(find "$session_dir" -type f 2>/dev/null | sort)

    # Un rollback partiel n'est PAS un rollback. La fonction affichait
    # « ✔ N fichier(s) restauré(s) » et rendait 0 dans tous les cas — y compris
    # avec N à zéro, et y compris quand la moitié des copies avaient échoué.
    # Or c'est précisément après un retour arrière qu'on a besoin de savoir si
    # l'installation est revenue à son état antérieur, ou si elle est
    # désormais à cheval entre deux versions.
    if (( total == 0 )); then
        _err_f "Session %s vide — aucun fichier à restaurer." "${session}"
        return 1
    fi
    if (( echecs > 0 )); then
        _err_f "Rollback INCOMPLET : %s/%s restauré(s), %s en échec." "${count}" "${total}" "${echecs}"
        _warn "L'installation mélange deux versions — corrige avant de la relancer."
        _kv "Sauvegardes" "$session_dir"
        return 1
    fi

    _ok_f "%s fichier(s) restauré(s) depuis la session %s." "${count}" "${session}"
    return 0
}

_upd_rollback_interactive() {
    local confirm
    [[ -d "$UPDATE_BACKUP_DIR" ]] || { _warn "Aucun backup disponible."; _upd_log "WARN" "Aucun backup disponible."; return 0; }

    local -a sessions=()
    mapfile -t sessions < <(
        find "$UPDATE_BACKUP_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
            | sort -r \
            | xargs -I{} basename {}
    )

    [[ "${#sessions[@]}" -eq 0 ]] && { _warn "Aucune session de backup trouvée."; _upd_log "WARN" "Aucune session de backup trouvée."; return 0; }

    _section "Rollback — Choisissez une session"

    local i=1 s
    for s in "${sessions[@]}"; do
        printf '  [%d] %s\n' "$i" "$s"
        i=$(( i + 1 ))
    done
    printf ' [0] Annuler\n'

    local choice
    read -rp $'\n > ' choice || choice=0

    if [[ "$choice" == "0" || -z "$choice" ]]; then
        _info "Annulé."
        return 0
    fi

    if ! [[ "$choice" =~ ^[0-9]+$ ]] \
        || [[ "$choice" -lt 1 ]]     \
        || [[ "$choice" -gt "${#sessions[@]}" ]]; then
        _warn "Choix invalide."
        _upd_log "WARN" "Choix invalide."
        return 1
    fi

    local selected="${sessions[$(( choice - 1 ))]}"

    printf '\n'
    read -rp "Confirmer le rollback vers ${ORANGE}${selected}${RESET} ? [o/N] " confirm || true
    [[ "${confirm,,}" != "o" ]] && { _info "Annulé."; return 0; }

    _upd_rollback_session "$selected"
}

# =============================================================================
# AFFICHAGE DE L'ÉTAT
# =============================================================================

_upd_show_status() {
    _upd_state_init
    _section "État des fichiers surveillés"

    local last_check last_upd count
    last_check="$(jq -r '.meta.last_check  // "jamais"' "$UPDATE_STATE" 2>/dev/null)"
    last_upd="$(jq -r '.meta.last_update // "jamais"' "$UPDATE_STATE" 2>/dev/null)"
    count="$(jq '.files | length' "$UPDATE_STATE" 2>/dev/null)" \
        || count=0

    _kv "Dernière vérification" "$last_check"
    _kv "Dernière mise à jour"  "$last_upd"
    printf '\n'

    if [[ "$count" -eq 0 ]]; then
        _info "Aucun fichier indexé. Lancez d'abord l'option [1]."
        _upd_log "WARN" "Aucun fichier indexé lors de l'affichage de l'état."
        return 0
    fi

    printf ' %-52s %-10s %s\n' "FICHIER" "STATUT" "VÉRIFIÉ LE"
    printf ' %s\n' "$(printf '─%.0s' {1..80})"

    jq -r '.files | to_entries[] | "\(.key)\t\(.value.status)\t\(.value.last_checked // "—")"' \
        "$UPDATE_STATE" 2>/dev/null \
    | sort \
    | while IFS=$'\t' read -r file status checked; do
        local color
        case "$status" in
            ok) color="${GREEN}" ;;
            pending) color="${ORANGE}" ;;
            error) color="${RED}" ;;
            *) color="${DIM}" ;;
        esac
        printf ' %-52s %b%-10s%b %s\n' \
            "$file" "$color" "$status" "${RESET}" "$checked"
    done
}

_upd_show_history() {
    local n="${1:-20}"
    _upd_state_init
    _section "Historique des mises à jour (${n} dernières)"

    local count
    count="$(jq '.update_history | length' "$UPDATE_STATE" 2>/dev/null)" || count=0

    if [[ "$count" -eq 0 ]]; then
        _info "Aucune mise à jour enregistrée."
        return 0
    fi

    jq -r --argjson n "$n" \
        '.update_history | reverse | .[:$n][] |
         " [\(.ts)]  \(.file)\n avant : \(.hash_before[:12])… après : \(.hash_after[:12])…"' \
        "$UPDATE_STATE" 2>/dev/null
}

# =============================================================================
# PANNEAU TUI
# =============================================================================

_panel_update() {
    _upd_state_init

    local choice last_check pending_n error_n

    while true; do
        last_check="$(jq -r '.meta.last_check // "jamais"' "$UPDATE_STATE" 2>/dev/null)"
        pending_n="$(jq '[.files[]? | select(.status=="pending")] | length' \
            "$UPDATE_STATE" 2>/dev/null)" || pending_n=0
        error_n="$(jq   '[.files[]? | select(.status=="error")] | length' \
            "$UPDATE_STATE" 2>/dev/null)" || error_n=0

        _ui_width
        _ui_title "Mise à jour des fichiers" "vérification, application, retour arrière"

        _ui_top
        _ui_kv "Dernière vérification" "$last_check"
        _ui_kv "En attente" "$([[ "${pending_n:-0}" -gt 0 ]] \
            && _ui_badge warn "$pending_n fichier(s)" || _ui_badge ok 'aucun')"
        _ui_kv "En erreur"  "$([[ "${error_n:-0}" -gt 0 ]] \
            && _ui_badge err "$error_n fichier(s)" || _ui_badge ok 'aucun')"

        _ui_head "Actions"
        _ui_item 1 "Vérifier les mises à jour  ${DIM}(sans rien modifier)${RESET}"
        _ui_item 2 "Appliquer les mises à jour en attente"
        _ui_item 3 "Vérifier ET appliquer      ${DIM}(tout en un)${RESET}"
        _ui_item 4 "État de tous les fichiers surveillés"
        _ui_item 5 "Historique des mises à jour"
        _ui_item 6 "Retour arrière             ${DIM}(restaurer une session)${RESET}"
        _ui_item 0 "Retour"
        _ui_bot

        read -rp $'\n  > ' choice || choice=0
        case "$choice" in
            1) _upd_check_all || true ;;
            2) _upd_apply_pending || true ;;
            3) _upd_apply_all || true ;;
            4) _upd_show_status || true ;;
            5) _upd_show_history 20 || true ;;
            6) _upd_rollback_interactive || true ;;
            0) return 0 ;;
            *) _warn "Choix invalide." || true ;;
        esac
        _pause
    done
}

# =============================================================================
# RESET SYSTEM CONFIGURATION
# =============================================================================
_reset_config() {
    local rep
    _warn "Cette action supprime TOUTE la configuration, y compris les comptes Access Manager."
    read -rp " Confirmer en tapant RESET : " rep || true
    [[ "$rep" == "RESET" ]] || { _info "Annulé."; return 0; }
    rm -f "$INIT_BASE" "$INIT_DEFAULT" "$SERVICES_FILE" "$SERVERS_FILE" \
          "$EGGDROP_FILE" "$IRIX_FILE" "$ACCESS_FILE" "$UPDATE_STATE"
    _ok "Configuration supprimée. Relancez le script."
    exit 0
}

# =============================================================================
# MENU PRINCIPAL
# =============================================================================
# =============================================================================
# ASSISTANT D'INSTALLATION GUIDÉE
#
# Ordre imposé par les dépendances réelles :
#   paquets système → serveur IRC → Eggdrop → IriX → services optionnels
# Chaque étape ne se débloque que si la précédente est satisfaite ; les étapes
# verrouillées restent visibles avec leur raison, pour que l'utilisateur sache
# ce qu'il lui reste à faire plutôt que de buter sur un échec.
# =============================================================================
# =============================================================================
# UNITÉS SYSTEMD — les poser, et n'activer que ce qui peut marcher
# =============================================================================
#
# ── ⚠⚠ POURQUOI CETTE SECTION EXISTE — le point 41 ──────────
#
# Jusqu'au 20/08/2026, cet installateur ne posait AUCUNE unité systemd. Deux
# `systemctl` en 8 600 lignes, tous deux sur Postfix, et la seule mention de
# `Config/systemd/` était une EXCLUSION de mise à jour. Le dépôt en portait
# quatorze, `/etc/systemd/system/` n'en recevait aucune — et `desinstaller.sh`
# savait pourtant toutes les RETIRER. **On savait désinstaller ce qu'on ne
# savait pas installer.**
#
# Sur la machine de développement tout marchait, parce que les unités y avaient
# été posées à la main, une par une, au fil des mois. Sur une installation
# neuve : pas de sauvegardes, pas d'opérateurs appliqués, pas de robots posés,
# et la veille sur IriX ne veillait pas. C'est la règle « où vit la chose »
# dans sa forme la plus pure, et aucun des 91 bancs ne la voyait.
#
# ── Les trois règles, et elles ne se négocient pas ──────────
#
# 1. **LA LISTE EST DÉRIVÉE** de `Config/systemd/`, jamais énumérée. Une liste
#    écrite à la main oublie ce qu'on ajoute ensuite — `desinstaller.sh:159` le
#    dit déjà, et c'est ce qui a périmé le tableau d'`INSTALL.md` CINQ fois.
# 2. **ON POSE TOUT, ON N'ACTIVE QUE LE SÛR.** Poser un fichier ne démarre
#    rien ; activer un timer dont le prérequis manque produit une unité en
#    échec, et une unité en échec est une unité qu'on apprend à ignorer.
# 3. **CE QUI EST SÛR SE DÉDUIT DE L'UNITÉ ELLE-MÊME** : son `ExecStart` doit
#    exister, et sa ligne `# PREREQUIS:` doit être satisfaite. Aucune table
#    « unité → condition » n'est tenue ici — elle se périmerait au premier ajout.
#
# ⚠ **Cette pose est RÉ-EXÉCUTABLE, et ce n'est pas un confort.** La mise à
# jour n'atteint jamais `/etc/systemd/system/` : toute sa machinerie opère sur
# `$BASE_DIR`, et `WATCHED_EXTENSIONS` ne contient ni `.service` ni `.timer`.
# Une unité corrigée en amont n'arriverait donc par AUCUN chemin. Sans ce point
# d'entrée, la première pose serait la dernière.

_sysd_dir() { printf '%s' "$BASE_DIR/Config/systemd"; }

# Le compte qui doit faire tourner les unités.
#
# ⚠ Mesuré sur le disque — le PROPRIÉTAIRE de la racine —, et non déduit de
# `$PRIV_USER` : sous `sudo`, celui-ci vaut « root », et des unités en root
# feraient tourner le bot et le site avec tous les droits.
#
# 🔴🔴 ET JAMAIS ROOT — audit du 29/09/2026, #8. Le dernier repli était
#   `id -un` : lancé en root sans compte d'origine, il rendait « root », et
#   TOUTES les unités (site, serveur, IriX, robots) tournaient en root. Il n'y
#   a plus de repli : sans compte ordinaire, la fonction rend 1 et RIEN ; ses
#   appelants refusent de poser (`_sysd_poser`, `_sys_poser`, l'unité du
#   serveur). Le remède se dit : `sudo` depuis un compte, ou OVERTCHAT_COMPTE.
_sysd_compte() {
    local u
    u="$(stat -c '%U' "$BASE_DIR" 2>/dev/null || true)"
    if [[ -n "$u" && "$u" != "root" && "$u" != "UNKNOWN" ]]; then printf '%s' "$u"; return 0; fi
    if [[ -n "${PRIV_USER:-}" && "$PRIV_USER" != "root" ]]; then printf '%s' "$PRIV_USER"; return 0; fi
    u="$(id -un 2>/dev/null || true)"
    if [[ -n "$u" && "$u" != "root" && "$(id -u)" != "0" ]]; then printf '%s' "$u"; return 0; fi
    return 1
}

# Le refus, dit une fois et avec son remède. Rend 1 (à propager).
_sysd_compte_refus() {
    _err "Aucun compte ordinaire pour faire tourner les services : ils ne tourneront JAMAIS en root."
    _info "Lancez l'installateur par « sudo » depuis le compte qui portera l'installation,"
    _info "ou désignez-le : OVERTCHAT_COMPTE=<compte> (un compte existant, autre que root)."
    return 1
}

# Adapte une unité livrée à cette installation.
#
# ⚠ On vise `^User=servus$` et `^Group=servus$` NOMMÉMENT, jamais un
# `s/servus/…/g` global : celui-ci réécrirait les commentaires et les chemins
# déjà substitués. Le motif vient de `install-service.sh:141-145`, qui le fait
# déjà pour `overtchatd.service` et dit pourquoi.
#
# ⚠ Les deux substitutions de chemin sont ORDONNÉES : la racine complète
# d'abord, le foyer ensuite. `overop@.service` porte `/home/servus/overop-%i`
# et `/home/servus/IriX/eggdrop`, qui ne contiennent PAS « Service-Overtchat » —
# sans la seconde, ils resteraient en dur.
_sysd_substituer() {
    local src="$1" compte="$2"
    sed -e "s#/home/servus/Service-Overtchat#${BASE_DIR}#g" \
        -e "s#/home/servus#${TARGET_HOME}#g" \
        -e "s#^User=servus\$#User=${compte}#" \
        -e "s#^Group=servus\$#Group=${compte}#" \
        "$src"
}

# La ligne `# PREREQUIS:` d'une unité, ou « aucun » si elle n'en porte pas.
# ⚠ `Web/tests/test-systemd.php` exige que chacune la porte : une unité sans
# cette ligne est une unité dont personne ne sait si on peut l'activer.
_sysd_prerequis() {
    local v
    v="$(sed -n 's/^# PREREQUIS:[[:space:]]*//p' "$1" 2>/dev/null | head -1)"
    [[ -z "$v" ]] && v="aucun"
    printf '%s' "$v"
}

# Le prérequis est-il satisfait ? La valeur nomme un chemin, ou dit « aucun ».
_sysd_prerequis_ok() {
    local v="$1" chemin
    [[ "$v" == aucun* ]] && return 0
    chemin="${v%% *}"
    chemin="${chemin/#\~/$TARGET_HOME}"
    [[ -e "$chemin" ]]
}

# Le programme lancé par une unité existe-t-il ?
#
# ⚠ Quand `ExecStart` commence par un interpréteur — php, bash, tclsh —, c'est
# le SCRIPT qui compte : `/usr/bin/php` existe toujours, et une unité qui
# pointe vers un script absent démarrerait pour échouer aussitôt.
_sysd_execstart_ok() {
    local ligne bin arg
    ligne="$(sed -n 's/^ExecStart=//p' "$1" 2>/dev/null | head -1)"
    [[ -n "$ligne" ]] || return 1

    read -r bin arg _ <<< "$ligne"
    [[ -e "$bin" ]] || return 1

    case "$bin" in
        */php|*/bash|*/sh|*/tclsh|*/python3)
            [[ -n "$arg" && -e "$arg" ]] || return 1 ;;
    esac
    return 0
}

# L'unité d'IriX, adaptée à CETTE installation — sur l'unité déjà substituée.
#
# ⚠⚠ Mesuré le 29/09/2026 sur une installation tierce : `irix.service` lançait
#   `~/IriX/eggdrop -n <racine>/IriX/runtime/irix.conf`, deux chemins ABSENTS.
#   Ce sont ceux de la production, posés à la main : Eggdrop y est installé
#   dans ~/IriX et la conf du bot vit dans le dépôt. L'installateur, lui, compile
#   Eggdrop sous <racine>/Components/Service/eggdrop et déploie l'instance dans
#   ~/IriX (conf en ~/IriX/runtime/irix.conf). IriX ne pouvait donc démarrer par
#   son unité chez AUCUN tiers.
# 🔴 LA RÈGLE, qui garde la production intacte : on ne remplace QUE ce qui
#   n'existe pas, et seulement par ce qui existe. En production les deux
#   chemins existent — l'unité posée reste octet pour octet celle d'avant
#   (comparée le 29/09 à `systemctl cat irix`).
# ⚠ `After=` suit le serveur installé : `overtchatd.service` à la place
#   d'`unrealircd.service` quand le registre dit Overtchat-Hybrid installé et
#   UnrealIRCd non. La production porte les deux « installés » : inchangée.
# ⚠ `ReadWritePaths` : un chemin absent fait échouer `ProtectSystem=strict` au
#   démarrage — préfixé d'un « - » (systemd l'ignore alors) ; l'instance réelle,
#   si elle n'y figure pas, y est ajoutée (IriX écrit sous runtime/ et cmds/).
_sysd_adapter_irix() {
    local f="$1" ligne bin conf egg cf inst wd rw nouveau p modifie=0
    ligne="$(sed -n 's/^ExecStart=//p' "$f" | head -1)"
    bin="${ligne%% *}"; conf="${ligne##* }"
    if [[ -n "$bin" && ! -e "$bin" ]] && egg="$(_irix_eggdrop_bin 2>/dev/null)" && [[ -x "$egg" ]]; then
        sed -i "s#^ExecStart=${bin} #ExecStart=${egg} #" "$f"; modifie=1
    fi
    inst="$(_irix_install_dir 2>/dev/null)"
    if [[ -n "$conf" && ! -e "$conf" ]]; then
        cf="$(_irix_get instance.config_file 2>/dev/null)"
        [[ -z "$cf" || "$cf" == "null" ]] && cf="${inst}/${IRIX_RUNTIME_SUBDIR}/irix.conf"
        if [[ -f "$cf" ]]; then
            sed -i "s#^\(ExecStart=.* -n \)${conf}\$#\1${cf}#" "$f"; modifie=1
            # Le dossier de travail suit la conf : c'est d'où `_irix_start` lance.
            wd="$(sed -n 's/^WorkingDirectory=//p' "$f" | head -1)"
            [[ -d "$inst" && "$wd" != "$inst" ]] && sed -i "s#^WorkingDirectory=.*#WorkingDirectory=${inst}#" "$f"
        fi
    fi
    rw="$(sed -n 's/^ReadWritePaths=//p' "$f" | head -1)"
    if [[ -n "$rw" ]]; then
        nouveau=""
        for p in $rw; do
            if [[ "$p" != -* && ! -e "$p" ]]; then nouveau+=" -${p}"; modifie=1; else nouveau+=" ${p}"; fi
        done
        if [[ -n "$inst" && -d "$inst" && " $rw " != *" ${inst} "* ]]; then nouveau+=" ${inst}"; modifie=1; fi
        sed -i "s#^ReadWritePaths=.*#ReadWritePaths=${nouveau# }#" "$f"
    fi
    if [[ "$(_srv_get overtchat_ircd installed 2>/dev/null)" == "true" \
          && "$(_srv_get unrealircd installed 2>/dev/null)" != "true" ]] \
       && grep -q '^After=.*unrealircd\.service' "$f"; then
        sed -i '/^After=/s/unrealircd\.service/overtchatd.service/' "$f"; modifie=1
    fi
    # 19/10/2026 (reste du lot J2) : sur le chemin OverCore, l'unité est posée
    #   aussi (`_sysd_poser` les pose toutes) mais AUCUN Eggdrop n'existe — vu dans
    #   l'essai tiers du 19/10 : « adaptée (chemins d'Eggdrop…) » laissait croire
    #   à un IriX installé. Le message dit alors ce qui est vrai : une unité inerte.
    if [[ "$modifie" == 1 ]]; then
        if _irix_pose; then
            _info "irix.service adaptée à cette installation (chemins d'Eggdrop et de l'instance, serveur IRC)."
        else
            _info "irix.service posée mais inerte : aucun Eggdrop sur cette machine (les services sont OverCore)."
        fi
    fi
    return 0
}

# IriX est-il POSÉ sur cette machine ? — 17/10/2026 (lot J2).
# ⚠ Pas « irix.service posée » : `_sysd_poser` pose TOUTES les unités livrées,
#   celle d'IriX comprise, chez qui n'a pas d'Eggdrop. Ce qui distingue, c'est le
#   PROGRAMME : un Eggdrop exécutable (registre, panel, instance, PATH — l'ordre
#   de `_irix_eggdrop_bin`). En prod il existe (~/IriX/eggdrop) : vrai.
_irix_pose() {
    _irix_eggdrop_bin >/dev/null 2>&1
}

# L'unité d'OverCore, adaptée à CETTE installation — 17/10/2026 (lot J2).
#
# La livrée est celle de la prod, où IriX tourne : `After=… irix.service` et
# `ExecStartPre=/bin/sleep 60` (la minute qui laisse IriX se relier d'abord —
# décision de SerVuS du 28/09, gardée par Web/tests/test-systemd.php).
# 🔴 SANS IRIX, cette minute n'attend PERSONNE et ne fait que retarder le seul
#   service du réseau : retirée, avec `irix.service` d'`After=`. Avec IriX posé,
#   l'unité reste OCTET POUR OCTET celle du dépôt (la prod ne change pas).
# ⚠ `node` : `/usr/bin/node` est celui du paquet Debian/Ubuntu ; ailleurs, celui
#   du PATH remplace le chemin (même règle que `_sysd_adapter_irix` : on ne
#   remplace que ce qui n'existe pas, et seulement par ce qui existe).
# ⚠ `ReadWritePaths` : un chemin absent fait échouer `ProtectSystem` au
#   démarrage — préfixé d'un « - » (systemd l'ignore alors).
_sysd_adapter_overcore() {
    local f="$1" ligne bin node rw nouveau p modifie=0
    if ! _irix_pose; then
        if grep -q '^ExecStartPre=/bin/sleep ' "$f"; then
            sed -i '/^ExecStartPre=\/bin\/sleep /d' "$f"; modifie=1
        fi
        if grep -qE '^After=.*[[:space:]]irix\.service' "$f"; then
            sed -i -E '/^After=/s/[[:space:]]+irix\.service//' "$f"; modifie=1
        fi
    fi
    ligne="$(sed -n 's/^ExecStart=//p' "$f" | head -1)"
    bin="${ligne%% *}"
    if [[ -n "$bin" && ! -e "$bin" ]] && node="$(command -v node 2>/dev/null)" && [[ -x "$node" ]]; then
        sed -i "s#^ExecStart=${bin} #ExecStart=${node} #" "$f"; modifie=1
    fi
    rw="$(sed -n 's/^ReadWritePaths=//p' "$f" | head -1)"
    if [[ -n "$rw" ]]; then
        nouveau=""
        for p in $rw; do
            if [[ "$p" != -* && ! -e "$p" ]]; then nouveau+=" -${p}"; modifie=1; else nouveau+=" ${p}"; fi
        done
        sed -i "s#^ReadWritePaths=.*#ReadWritePaths=${nouveau# }#" "$f"
    fi
    if [[ "$modifie" == 1 ]]; then
        _info "overcore.service adaptée à cette installation (sans IriX : ni attente, ni ordre après lui)."
    fi
    return 0
}

# L'unité qui ouvre les salons approuvés, adaptée à CETTE installation — 19/10/2026.
#
# 🔴 Vu dans l'essai tiers n° 3 : « overtchat-salons.timer — prérequis non
#   satisfait ». Le prérequis commençait par « conf/salons-interdits.conf »,
#   relatif, que `_sysd_prerequis_ok` testait tel quel : le minuteur n'était
#   activé chez AUCUN tiers, et un salon approuvé au panel n'était jamais ouvert.
#   La livrée porte désormais le chemin de la prod (~/overtchatd/conf/…) ; chez
#   un tiers le serveur vit ailleurs (<racine>/Servers/overtchat-ircd) : on
#   reporte le prérequis sur la liste du serveur POSÉ, trouvée comme
#   `_site_ouvrir_salons` la trouve. Même règle que `_sysd_adapter_irix` : on ne
#   remplace que ce qui n'existe pas, et seulement par ce qui existe — sans
#   Overtchat-Hybrid, le prérequis reste absent et le minuteur au repos.
_sysd_adapter_salons() {
    local f="$1" pre chemin c fichier
    pre="$(_sysd_prerequis "$f")"
    chemin="${pre%% *}"
    [[ "$chemin" == /* && ! -e "$chemin" ]] || return 0
    for c in "$(_anope_srv_conf)" \
             "${TARGET_HOME:-}/overtchatd/conf/overtchat.conf" \
             "${BASE_DIR}/Servers/overtchat-hybrid/conf/overtchat.conf"; do
        [[ -n "$c" && -r "$c" ]] || continue
        fichier="$(dirname "$c")/salons-interdits.conf"
        if [[ -f "$fichier" ]]; then
            sed -i "s#^\(\# PREREQUIS:[[:space:]]*\)${chemin}#\1${fichier}#" "$f"
            _info "overtchat-salons.service adaptée à cette installation (liste des salons du serveur posé)."
        fi
        return 0
    done
    return 0
}

# Les règles du pare-feu par plage : la SOURCE dans le dépôt, la COPIE lue par
# l'unité root dans /etc/overtchat/ (SerVuS, 05/10/2026).
#
# 🔴 Une unité root ne lit JAMAIS un fichier que le compte de service peut
# écrire : qui écrirait dans `Config/nftables/` ferait exécuter ses règles en
# root (`flush ruleset`, une redirection de port…). On pose donc une copie
# root:root 644 dans un dossier root 755, et on la REPOSE à chaque passage —
# c'est la seule voie par laquelle une règle corrigée en amont arrive au
# pare-feu, comme pour les unités elles-mêmes (la mise à jour n'atteint pas
# /etc). Deux fonctions sans décision pour les chemins : un banc peut les
# détourner sans toucher à ce qui décide.
_sysd_parefeu_src()  { printf '%s' "$BASE_DIR/Config/nftables/overtchat-limites.nft"; }
_sysd_parefeu_dest() { printf '%s' "/etc/overtchat/overtchat-limites.nft"; }

_sysd_poser_parefeu() {
    local src dest
    src="$(_sysd_parefeu_src)"; dest="$(_sysd_parefeu_dest)"
    # Pas de pare-feu livré (dépôt ancien, bac d'essai) : rien à poser, sans bruit.
    [[ -f "$src" ]] || return 0
    _as_root mkdir -p "$(dirname "$dest")" 2>/dev/null || true
    if _as_root install -o root -g root -m 644 "$src" "$dest" 2>/dev/null; then
        _ok_f "Règles du pare-feu posées : %s (root:root 644, reposées à chaque passage)." "$dest"
        return 0
    fi
    _err_f "Impossible de poser %s — l'unité du pare-feu ne pourra pas démarrer." "$dest"
    return 1
}

# Pose les unités livrées. N'en démarre AUCUNE.
_sysd_poser() {
    local dir compte f nom tmp n=0 parefeu=0
    dir="$(_sysd_dir)"

    [[ -d "$dir" ]] || { _err "Aucun dossier d'unités : $dir"; return 1; }
    _priv_can_elevate || {
        _err "Poser une unité exige root ou sudo — rien n'a été fait."
        return 1
    }

    compte="$(_sysd_compte)" || { _sysd_compte_refus; return 1; }
    _info_f "Compte de service retenu : %s%s%s (propriétaire de la racine)." "${BOLD}" "${compte}" "${RESET}"

    while IFS= read -r f; do
        nom="$(basename "$f")"
        tmp="$(mktemp)" || continue
        _sysd_substituer "$f" "$compte" > "$tmp"
        [[ "$nom" == irix.service ]] && _sysd_adapter_irix "$tmp"
        [[ "$nom" == overcore.service ]] && _sysd_adapter_overcore "$tmp"
        [[ "$nom" == overtchat-salons.service ]] && _sysd_adapter_salons "$tmp"
        if _as_root install -m 644 "$tmp" "/etc/systemd/system/$nom" 2>/dev/null; then
            n=$(( n + 1 ))
            [[ "$nom" == overtchat-pare-feu.service ]] && parefeu=1
        else
            _err_f "Pose refusée : %s" "$nom"
        fi
        rm -f "$tmp"
    done < <(find "$dir" -maxdepth 1 \( -name '*.service' -o -name '*.timer' \) | sort)

    # ⚠ Sans `daemon-reload`, l'unité posée reste INVISIBLE de systemd, et
    # `systemctl enable` juste après échouerait sans que la raison soit claire.
    _as_root systemctl daemon-reload 2>/dev/null || true

    _ok_f "%s unité(s) posée(s) dans /etc/systemd/system/." "$n"

    # Le pare-feu par plage (05/10/2026, `Config/nftables/overtchat-limites.nft`)
    # est posé comme les autres et, comme les autres `.service`, JAMAIS activé
    # ici : `_sysd_activer` n'active que des timers, et charger des règles
    # réseau est un geste que l'exploitant fait en le sachant. On nomme le
    # geste ; et si `nft` manque, l'activation échouerait sans dire pourquoi.
    # ⚠ La COPIE ROOT des règles est posée ici même, avec l'unité qui la lit :
    # sans elle, l'unité démarre sur un fichier absent.
    if [[ "$parefeu" == 1 ]]; then
        _sysd_poser_parefeu || parefeu=0
        if [[ -x /usr/sbin/nft ]]; then
            _info "Pare-feu par plage posé, non activé : systemctl enable --now overtchat-pare-feu.service"
        else
            _warn "Pare-feu par plage posé, mais /usr/sbin/nft manque : installez le paquet nftables avant de l'activer."
        fi
    fi
    return 0
}

# Active les timers dont TOUT est vérifiable sur place, et dit pourquoi il
# laisse les autres au repos.
#
# ⚠ On n'active QUE des `.timer`. Les sept services `oneshot` portent pourtant
# un `[Install] WantedBy=multi-user.target` : les activer les ferait tourner au
# démarrage EN PLUS du timer. C'est le timer qui déclenche, jamais le service.
# ⚠ `anope.service` n'a pas de timer, `overop@.service` est un template : ni
# l'un ni l'autre ne s'active seul, et c'est écrit dans leur en-tête.
_sysd_activer() {
    local dir tim nom svc pre actives=0 laisses=0
    dir="$(_sysd_dir)"
    _priv_can_elevate || { _err "Activer une unité exige root ou sudo."; return 1; }

    while IFS= read -r tim; do
        nom="$(basename "$tim")"

        svc="$(sed -n 's/^Unit=//p' "$tim" | head -1)"
        [[ -n "$svc" ]] || svc="${nom%.timer}.service"

        # ⚠ On juge sur l'unité POSÉE, pas sur celle du dépôt : c'est la posée
        # qui porte les chemins substitués, donc les seuls qui existent ici.
        local pose="/etc/systemd/system/$svc"
        if [[ ! -f "$pose" ]]; then
            _warn_f "%s — %s n'est pas posé, laissé au repos." "$nom" "$svc"
            laisses=$(( laisses + 1 )); continue
        fi
        if ! _sysd_execstart_ok "$pose"; then
            _warn_f "%s — le programme de %s est introuvable, laissé au repos." "$nom" "$svc"
            laisses=$(( laisses + 1 )); continue
        fi

        pre="$(_sysd_prerequis "$pose")"
        if ! _sysd_prerequis_ok "$pre"; then
            _warn_f "%s — prérequis non satisfait : %s%s%s" "$nom" "${DIM}" "${pre}" "${RESET}"
            laisses=$(( laisses + 1 )); continue
        fi

        if _as_root systemctl enable --now "$nom" 2>/dev/null; then
            _ok_f "%s activé." "$nom"
            actives=$(( actives + 1 ))
        else
            _err_f "%s — activation refusée." "$nom"
            laisses=$(( laisses + 1 ))
        fi
    done < <(find "$dir" -maxdepth 1 -name '*.timer' | sort)

    _info_f "%s timer(s) en service, %s laissé(s) au repos." "$actives" "$laisses"
    [[ $laisses -gt 0 ]] && _info "Les unités au repos sont POSÉES : il suffira de les activer quand leur prérequis sera là."
    return 0
}

# =============================================================================
# FICHIERS SYSTÈME hors systemd — sudoers, polkit, logrotate  (point 43)
# =============================================================================
#
# ── ⚠⚠ POURQUOI, ET POURQUOI C'EST LE PLUS URGENT DES TROIS ─
#
# `Tools/desinstaller.sh:175-181` sait RETIRER ces trois fichiers. L'installateur
# ne les posait pas. Même forme que les unités systemd du point 41, mais une
# conséquence plus grave, et l'unité de la veille le dit elle-même :
#
#   « Sans ce droit, la veille alerte quand même : elle constate et écrit, elle
#     ne peut simplement pas guérir. »
#
# Autrement dit, sur une installation neuve, la surveillance livrée au point 40
# voyait IriX mourir **sans avoir le droit de le relancer** — et rien ne le
# disait. Le polkit a le même effet pour le panel web ; le logrotate laisse les
# journaux du serveur enfler sans limite, ce qui est la troisième piste du
# point 38.
#
# ── La table porte des DOSSIERS, pas des fichiers ───────────
#
# ⚠ C'est ce qui la rend tenable : ajouter un fichier dans `Config/sudoers.d/`
# est couvert **automatiquement**. Seul l'ajout d'un DOSSIER destiné à `/etc`
# demande d'y toucher — et `Bootstrap/tests/test-systeme.sh` rougit alors.
#
# ⚠ Apache n'y est PAS, délibérément : ses cinq fichiers ont **trois**
# destinations (`sites-available/`, la racine d'Apache pour `ports.conf`, et
# un chemin servi pour `maintenance.html`), et leur activation demande un
# `a2ensite` puis un rechargement qui peut couper le site. Cela reste au
# point 43.
_sys_familles() {
    printf '%s\n' \
        "sudoers.d:/etc/sudoers.d:440" \
        "polkit:/etc/polkit-1/rules.d:644" \
        "logrotate.d:/etc/logrotate.d:644"
}

# ⚠⚠ UN SUDOERS INVALIDE PEUT RENDRE `sudo` INUTILISABLE SUR TOUTE LA MACHINE.
# C'est la seule pose de ce chantier qui puisse casser autre chose que le
# projet. On le contrôle donc AVANT de l'installer, jamais après.
_sys_valide() {
    local src="$1" dest="$2"
    case "$dest" in
        */sudoers.d)
            command -v visudo >/dev/null 2>&1 || { _warn "visudo absent — contrôle impossible"; return 1; }
            _as_root visudo -c -f "$src" >/dev/null 2>&1 ;;
        *) return 0 ;;
    esac
}

# La destination peut-elle recevoir quelque chose ?
#
# ⚠⚠ « LE LOGICIEL N'EST PAS LÀ » N'EST PAS « LA POSE A ÉCHOUÉ ». Constaté le
# 20/08/2026 : `/etc/polkit-1` n'existe pas sur la machine de production —
# polkit n'y est simplement pas installé. Le code annonçait « Pose refusée »,
# ce qui envoie chercher un problème de droits qui n'existe pas.
#
# ⚠ On teste la RACINE (`/etc/polkit-1`) et non le sous-dossier : celle-ci peut
# exister sans `rules.d`, et c'est alors à nous de créer ce dernier. En
# revanche, créer la racine d'un logiciel absent laisserait un dossier orphelin
# qui ferait croire à une installation.
_sys_dest_prete() {
    local dest="$1"
    [[ -d "${dest%/*}" || -d "$dest" ]]
}

# Adapte un fichier système livré à CETTE installation.
#
# ⚠⚠ POURQUOI CETTE FONCTION EXISTE — point 43, 24/08/2026. Depuis que l'archive
# emporte `Config/{sudoers.d,polkit,logrotate.d}`, ces fichiers ARRIVENT chez un
# tiers. Posés bruts, ils y seraient pires qu'absents : le sudoers et le polkit
# accorderaient des droits à l'utilisateur « servus », qui n'existe pas là-bas,
# et le logrotate surveillerait un `/home/servus` inexistant. L'installation
# aurait l'air configurée sans l'être.
#
# ⚠ ON VISE DES MOTIFS NOMMÉS, JAMAIS un `s/servus/…/g` global. C'est la règle
# que `_sysd_substituer` porte déjà et pour la même raison : un remplacement
# global réécrirait les commentaires — nombreux dans ces fichiers — et les
# chemins déjà substitués par la ligne précédente.
#
# ⚠ Les deux substitutions de chemin sont ORDONNÉES : la racine complète
# d'abord, le foyer ensuite. L'inverse laisserait « /home/servus/Service-Overtchat »
# à moitié réécrit.
_sys_substituer() {
    local src="$1" compte="$2"
    sed -e "s#/home/servus/Service-Overtchat#${BASE_DIR}#g" \
        -e "s#/home/servus#${TARGET_HOME}#g" \
        -e "s#^Defaults:servus#Defaults:${compte}#" \
        -e "s#^servus  *ALL=#${compte}   ALL=#" \
        -e "s#subject.user === \"servus\"#subject.user === \"${compte}\"#g" \
        -e "s#create 0640 servus servus#create 0640 ${compte} ${compte}#" \
        -e "s#^\([[:space:]]*\)su servus servus\$#\1su ${compte} ${compte}#" \
        -e 's#--uid\\=servus --gid\\=servus#--uid\\='"${compte}"' --gid\\='"${compte}"'#g' \
        "$src"
}

_sys_poser() {
    local ligne dossier dest mode src nom n=0 refuses=0 compte_sys

    _priv_can_elevate || { _err "Poser un fichier système exige root ou sudo."; return 1; }
    # 🔴 Audit #8 (29/09/2026) : ces fichiers accordent des droits AU COMPTE DE
    #   SERVICE ; sans compte ordinaire, rien n'est posé (plus de repli sur root).
    compte_sys="$(_sysd_compte)" || { _sysd_compte_refus; return 1; }

    while IFS= read -r ligne; do
        dossier="${ligne%%:*}"; ligne="${ligne#*:}"
        dest="${ligne%%:*}";    mode="${ligne##*:}"

        local depot="$BASE_DIR/Config/$dossier"
        [[ -d "$depot" ]] || { _warn "Config/$dossier absent du dépôt — ignoré."; continue; }

        # ⚠⚠ « LE LOGICIEL N'EST PAS LÀ » N'EST PAS « LA POSE A ÉCHOUÉ ».
        #
        # Constaté le 20/08/2026 : `/etc/polkit-1` n'existe pas sur la machine de
        # production — polkit n'y est tout simplement pas installé. Le code
        # annonçait alors « Pose refusée », ce qui envoie chercher un problème de
        # droits qui n'existe pas.
        #
        # ⚠ On ne CRÉE pas le répertoire d'un logiciel absent : une règle polkit
        # posée sans polkit ne sert à rien, et le dossier orphelin ferait croire
        # à une installation. On le dit, et on passe.
        #
        if ! _sys_dest_prete "$dest"; then
            _info "$dossier — ${dest%/*} absent : ce logiciel n'est pas installé ici, rien à poser."
            continue
        fi

        _as_root mkdir -p "$dest" 2>/dev/null || true

        while IFS= read -r src; do
            nom="$(basename "$src")"
            # 🔴 LE CONTRÔLE (visudo) PORTE SUR LE FICHIER ADAPTÉ, PAS SUR LE MODÈLE
            #   — audit du 29/09/2026, #25. Il lisait `$src`, le modèle livré ; le
            #   fichier POSÉ est celui qu'a réécrit `_sys_substituer` (compte,
            #   chemins). Une substitution qui casse la syntaxe passait donc le
            #   contrôle, et un sudoers invalide dans /etc/sudoers.d/ rend `sudo`
            #   inutilisable sur toute la machine. Le contrôle est donc plus bas,
            #   sur `$adapte`, juste avant `install`.
            # ⚠ On pose la version ADAPTÉE, jamais le modèle brut. Le
            # temporaire est indispensable : `install` ne lit pas un tube.
            #
            # 🔴 IL EST NOMMÉ, ET IL EST EFFACÉ — 08/09/2026. Écrit d'abord
            # `mktemp` nu et jamais retiré, il laissait UN fichier PAR FICHIER
            # POSÉ, à chaque exécution : la campagne complète en abandonnait
            # treize d'un coup, avec dedans du sudoers et du polkit. Sur une
            # vraie machine, l'installateur tourne en root.
            # 📌 Le modèle sert au DIAGNOSTIC, pas au constat : c'est un
            #    `tmp.XXXXXXXX` anonyme qui a coûté trois mesures avant qu'on
            #    puisse dire d'où il venait. ⚠ La mutation l'a montré — retirer
            #    le modèle ne fait PAS rougir `test-systeme.sh`, parce que
            #    `mktemp` nu respecte `TMPDIR` lui aussi. *C'est le `rm -f`
            #    ci-dessous qui est gardé, et lui seul.*
            local adapte
            adapte="$(mktemp "${TMPDIR:-/tmp}/overtchat-pose-XXXXXX")" \
                || { _err "Impossible de préparer $nom"; refuses=$((refuses+1)); continue; }
            _sys_substituer "$src" "$compte_sys" > "$adapte"
            if ! _sys_valide "$adapte" "$dest"; then
                _err_f "%s REFUSÉ — il ne passe pas son propre contrôle, rien n'a été posé." "$nom"
                refuses=$(( refuses + 1 ))
                rm -f "$adapte"
                continue
            fi
            if _as_root install -m "$mode" "$adapte" "$dest/$nom" 2>/dev/null; then
                _ok_f "%s → %s  (mode %s)" "$nom" "$dest" "$mode"
                n=$(( n + 1 ))
            else
                _err_f "Pose refusée : %s" "$nom"
                refuses=$(( refuses + 1 ))
            fi
            # ⚠ Après `install`, la copie est faite : le temporaire ne sert plus
            #   dans AUCUNE des deux branches. C'est ici qu'il s'efface, et pas
            #   dans l'une d'elles.
            rm -f "$adapte"
        done < <(find "$depot" -maxdepth 1 -type f | sort)
    done < <(_sys_familles)

    # ⚠ L'ancien fichier de sudoers, que celui-ci remplace. Son en-tête le dit :
    # le laisser ferait deux règles pour la même chose, et c'est toujours la
    # plus permissive qui gagne.
    if [[ -e /etc/sudoers.d/overtchat-irix ]]; then
        _as_root rm -f /etc/sudoers.d/overtchat-irix && \
            _info "Ancien /etc/sudoers.d/overtchat-irix retiré."
    fi

    _ok_f "%s fichier(s) système posé(s), %s refusé(s)." "$n" "$refuses"

    # ⚠⚠ POINT 77 — UNE RÈGLE FINE NE PROTÈGE DE RIEN SI LE COMPTE EST DÉJÀ
    #    SUDOER COMPLET, et rien ne le disait.
    #
    # `Config/sudoers.d/overtchat-services` découpe finement ce que le compte de
    # service a le droit de faire — `restart irix`, `disable overop@[a-z]`, et
    # rien d'autre. Chaque ligne a été pesée. ⚠ Mais si ce compte figure par
    # ailleurs en `(ALL) ALL`, **il peut déjà tout faire** : la règle devient
    # décorative, et la promesse « ce compte ne touche qu'aux robots » est
    # fausse sans que personne le voie.
    #
    # 📌 ON AVERTIT, ON NE REFUSE PAS — tranché le 07/09/2026. Refuser
    # casserait l'installation sur un poste de développement, où l'exploitant
    # est légitimement administrateur : c'est un cas normal, pas une faute.
    # Se taire, en revanche, laisserait croire à une protection inexistante.
    # *Le rôle de l'installateur est de dire ce qui est, pas de décider à la
    # place de qui exploite.*
    _sys_avertir_sudoer_complet

    [[ $refuses -eq 0 ]]
}

# Le compte de service peut-il déjà TOUT faire ? Si oui, le dire.
#
# ⚠ `sudo -l -U <compte>` demande à sudo ce qu'il AUTORISERAIT, sans rien
#   lancer. C'est la seule façon de le savoir sans effet de bord.
_sys_avertir_sudoer_complet() {
    local compte; compte="$(_sysd_compte)"
    [[ -n "$compte" ]] || return 0
    command -v sudo >/dev/null 2>&1 || return 0

    local lignes
    lignes="$(_as_root sudo -l -U "$compte" 2>/dev/null | grep -cE '\(ALL(\s*:\s*ALL)?\)\s*(NOPASSWD:\s*)?ALL' || true)"
    [[ "${lignes:-0}" -gt 0 ]] || return 0

    _warn_f "Le compte « %s » est déjà sudoer COMPLET sur cette machine." "$compte"
    _info "Les règles fines qui viennent d'être posées ne le restreignent donc pas :"
    _info "il peut déjà tout faire, robots compris. Sur un poste de développement"
    _info "c'est normal ; sur une machine de service, la promesse « ce compte ne"
    _info "touche qu'aux robots » n'est PAS tenue tant qu'il reste dans sudo."
    return 0
}

# Combien de fichiers système sont déjà posés ?
_sys_compte_poses() {
    local ligne dossier dest src n=0
    while IFS= read -r ligne; do
        dossier="${ligne%%:*}"; ligne="${ligne#*:}"; dest="${ligne%%:*}"
        while IFS= read -r src; do
            [[ -e "$dest/$(basename "$src")" ]] && n=$(( n + 1 ))
        done < <(find "$BASE_DIR/Config/$dossier" -maxdepth 1 -type f 2>/dev/null)
    done < <(_sys_familles)
    printf '%s' "$n"
}

_sys_compte_livres() {
    local ligne dossier n=0
    while IFS= read -r ligne; do
        dossier="${ligne%%:*}"
        n=$(( n + $(find "$BASE_DIR/Config/$dossier" -maxdepth 1 -type f 2>/dev/null | wc -l) ))
    done < <(_sys_familles)
    printf '%s' "$n"
}

# =============================================================================
# FICHIERS APACHE — la QUATRIÈME famille du point 43
# =============================================================================
#
# ── ⚠⚠ POURQUOI ELLE EST À PART, ET POURQUOI ON N'ACTIVE RIEN ───────────────
#
# Les trois familles ci-dessus se posent en dérivant un DOSSIER. Apache ne s'y
# prête pas : ses cinq fichiers ont TROIS destinations, et surtout, les poser ne
# suffit pas à les mettre en service — il faut un `a2ensite`, puis un
# rechargement qui peut couper le site. On pose donc, et on n'active RIEN.
#
# ⚠⚠ `irix-ssl.conf` est la raison du refus, et elle est écrite dans son propre
# en-tête : Apache refuse de DÉMARRER si `SSLCertificateFile` désigne un fichier
# absent — et c'est tout le serveur web qui tombe, pas seulement ce vhost. Un
# installateur qui l'activerait tout seul casserait le site chez un tiers qui
# n'a pas encore de certificat.
#
# ⚠⚠ ET IL FAUT SAVOIR CE QUE LE CONTRÔLE NE VOIT PAS, sinon on se croit gardé
# mieux qu'on ne l'est : un vhost posé dans `sites-available/` sans `a2ensite`
# n'est lu par PERSONNE — pas même par `apache2ctl configtest`, qui n'inclut que
# `sites-enabled/`. Le SEUL fichier de cette famille que le contrôle voie
# réellement est `ports.conf`. Les vhosts restent inertes jusqu'au geste de
# l'exploitant, et c'est précisément ce qui rend leur pose sans danger.

# ⚠ Ici la table porte des MOTIFS, pas un dossier — les trois destinations
# l'imposent. La garde qui remplace la dérivation : un fichier de
# `Config/apache/` qu'AUCUNE règle ne réclame n'est PAS posé au hasard, il est
# refusé et DIT. Ajouter un vhost `irix-*.conf` reste couvert tout seul.
#
# ── ⚠⚠ `*.html` ET NON `maintenance.html` — 03/09/2026 ──────────────────
#
# La règle nommait UN fichier. Le jour où `indisponible.html` est arrivé —
# celui que servent les `ErrorDocument 500`, `502` et `504` d'`irix-ssl.conf` —
# l'installateur ne l'a pas posé, et **rien ne l'a dit** : le vhost était juste,
# la conf passait `configtest`, et une installation tierce n'aurait montré la
# page d'usine d'Apache qu'au jour de sa première panne.
#
# 📌 C'est le défaut de famille de ce projet, par une porte de plus : ça marche
# ici — les deux pages y sont, posées à la main — et rien n'arrive chez le
# tiers. Le motif couvre désormais toute page future sans qu'on revienne ici.
#
# ── `mpm-*.conf` → conf-available (05/10/2026) ──────────────────────────
#
# Les réglages du MPM event (`mpm-event-overtchat.conf`) sont des directives
# de SERVEUR, pas de vhost : ils ne peuvent vivre ni dans un `irix-*.conf`,
# ni dans `ports.conf` sans en détourner le sens. `conf-available/` est lu
# après `mods-enabled/`, donc après `mpm_event.conf` du paquet : nos valeurs
# le remplacent sans l'écraser. Posé inactif ; `_apache_event` l'active.
_apache_regles() {
    printf '%s\n' \
        "irix-*.conf:/etc/apache2/sites-available:644" \
        "ports.conf:/etc/apache2:644" \
        "*.html:/var/www/overtchat-maintenance:644" \
        "mpm-*.conf:/etc/apache2/conf-available:644"
}

# La destination et le mode d'un fichier livré, ou rien s'il n'est réclamé par
# aucune règle. Rend « /chemin:mode ».
_apache_dest() {
    local nom="$1" ligne motif
    while IFS= read -r ligne; do
        motif="${ligne%%:*}"
        # shellcheck disable=SC2254
        case "$nom" in
            $motif) printf '%s' "${ligne#*:}"; return 0 ;;
        esac
    done < <(_apache_regles)
    return 1
}

# ⚠ Même règle qu'au polkit : « le logiciel n'est pas là » n'est pas « la pose a
# échoué ». On teste `/etc/apache2`, la racine du paquet — on ne la CRÉE jamais.
_apache_present() { [[ -d /etc/apache2 ]]; }

# 0 = configuration valide · 1 = invalide · 2 = outil absent, on ne sait pas.
# ⚠ Les trois se distinguent : « je ne sais pas » n'est pas « c'est bon ».
_apache_configtest() {
    command -v apache2ctl >/dev/null 2>&1 || return 2
    _as_root apache2ctl configtest >/dev/null 2>&1
}

# ⚠⚠ `ports.conf` N'EST PAS À NOUS. C'est un fichier du paquet Debian, et le
# poser l'ÉCRASE — c'est le seul de tout ce chantier dans ce cas. On garde donc
# l'original, UNE SEULE FOIS : cette pose est ré-exécutable, et une sauvegarde
# reprise au second passage ne sauvegarderait plus que notre propre fichier.
# ⚠ Une FONCTION, et non un chemin en dur dans les deux suivantes. Elle ne porte
# aucune décision — c'est précisément pour cela : un banc peut la remplacer par
# un faux `/etc` sans toucher à la logique de sauvegarde, et sans la vider de ce
# qu'elle garde. On ne redéfinit jamais ce qui décide.
_apache_ports() { printf '%s' "/etc/apache2/ports.conf"; }

_apache_sauver_ports() {
    local p; p="$(_apache_ports)"
    [[ -e "$p" ]] || return 0
    [[ -e "$p.avant-overtchat" ]] && return 0

    # ⚠⚠ NE PAS SAUVEGARDER NOTRE PROPRE FICHIER SOUS LE NOM DE L'ORIGINAL.
    #
    # Mesuré en production le 21/08/2026 : `/etc/apache2/ports.conf` y avait
    # déjà été posé à la main, en juillet. La clause « une seule fois » ne
    # protégeait pas de ce cas-là — il n'y avait aucune sauvegarde à préserver.
    # Le premier passage aurait donc écrit un fichier nommé « avant-overtchat »
    # qui contenait l'APRÈS, et la désinstallation aurait rendu à Apache une
    # configuration qui n'est pas la sienne, en croyant bien faire.
    #
    # ⚠ On compare le CONTENU, pas la date ni la présence : c'est la seule chose
    # qui distingue le fichier du paquet du nôtre.
    if cmp -s "$p" "$BASE_DIR/Config/apache/ports.conf"; then
        _info "ports.conf en place est déjà le nôtre — il n'y a pas d'original à conserver."
        return 0
    fi
    _as_root cp -p "$p" "$p.avant-overtchat"
}

_apache_restaurer_ports() {
    local p; p="$(_apache_ports)"
    [[ -e "$p.avant-overtchat" ]] || return 1
    _as_root cp -p "$p.avant-overtchat" "$p"
}

_apache_poser() {
    local depot="$BASE_DIR/Config/apache"
    local n=0 refuses=0 src nom couple dest mode avant=0

    _priv_can_elevate || { _err "Poser un fichier système exige root ou sudo."; return 1; }
    [[ -d "$depot" ]] || { _warn "Config/apache absent du dépôt — rien à poser."; return 1; }

    if ! _apache_present; then
        _info "apache — /etc/apache2 absent : ce logiciel n'est pas installé ici, rien à poser."
        return 0
    fi

    # ⚠⚠ L'ÉTAT DE DÉPART, MESURÉ AVANT DE TOUCHER À QUOI QUE CE SOIT. Sans lui,
    # un `configtest` rouge après la pose ferait accuser la pose alors que la
    # configuration pouvait déjà être cassée. On ne peut imputer à un geste que
    # ce qu'il a fait passer du vert au rouge.
    _apache_configtest; avant=$?
    case $avant in
        1) _warn "apache2ctl configtest est DÉJÀ rouge AVANT la pose — rien ne lui sera imputé." ;;
        2) _warn "apache2ctl absent — la pose ne pourra pas être contrôlée." ;;
    esac

    if ! _apache_sauver_ports; then
        _err "Impossible de conserver /etc/apache2/ports.conf — rien n'a été posé."
        return 1
    fi
    [[ -e /etc/apache2/ports.conf.avant-overtchat ]] && \
        _info "Original du paquet conservé : /etc/apache2/ports.conf.avant-overtchat"

    while IFS= read -r src; do
        nom="$(basename "$src")"
        if ! couple="$(_apache_dest "$nom")"; then
            _warn_f "%s — aucune règle ne dit où le poser : IGNORÉ (voir _apache_regles)." "$nom"
            refuses=$(( refuses + 1 ))
            continue
        fi
        dest="${couple%%:*}"; mode="${couple##*:}"

        # ⚠⚠ ON REFUSE DE POSER UN VHOST QUI N'EST PAS DE CETTE MACHINE, et
        # c'est une garde neuve du 24/08/2026. Depuis que l'archive emporte
        # `Config/apache/`, ces fichiers ARRIVENT chez un tiers — et
        # contrairement aux trois autres familles, ils ne sont PAS adaptés :
        # `irix-ssl.conf` porte un `ServerName` et des chemins Let's Encrypt en
        # dur. Posé tel quel, il désigne des certificats absents, et **Apache
        # refuse alors de démarrer**. Mieux vaut ne rien poser que casser le
        # serveur web de quelqu'un.
        #
        # ⚠ La garde ne mord QUE si le compte diffère : sur la machine d'où ces
        # modèles viennent, ces valeurs sont justes et la re-pose reste possible.
        if [[ "$(_sysd_compte)" != "servus" ]]            && grep -qE '/home/servus|service-overtchat[.]hd[.]free[.]fr' "$src" 2>/dev/null; then
            _warn_f "%s — modèle NON adapté à cette installation : IGNORÉ." "$nom"
            _info "Il porte le domaine et les chemins de la machine d'origine."
            _info "Adaptez-le à la main, ou attendez que l'installateur sache le faire."
            refuses=$(( refuses + 1 ))
            continue
        fi

        _as_root mkdir -p "$dest" 2>/dev/null || true
        if _as_root install -m "$mode" "$src" "$dest/$nom" 2>/dev/null; then
            _ok_f "%s → %s  (mode %s)" "$nom" "$dest" "$mode"
            n=$(( n + 1 ))
        else
            _err_f "Pose refusée : %s" "$nom"
            refuses=$(( refuses + 1 ))
        fi
    done < <(find "$depot" -maxdepth 1 -type f | sort)

    # ⚠ On ne contrôle qu'à partir d'un vert : sinon le verdict ne dit rien de
    # nous. Et le seul fichier posé que `configtest` lise est `ports.conf`.
    if [[ $avant -eq 0 ]] && ! _apache_configtest; then
        _err "apache2ctl configtest est passé au ROUGE — ports.conf est REMIS d'origine."
        if _apache_restaurer_ports; then
            _ok "/etc/apache2/ports.conf restauré depuis la sauvegarde du paquet."
        else
            _err "⚠⚠ RESTAURATION IMPOSSIBLE — reprendre à la main /etc/apache2/ports.conf."
        fi
        printf "  ${DIM}%s${RESET}\n" \
            "Les vhosts posés restent dans sites-available/ : inactifs, personne ne les lit."
        _log "APACHE : configtest rouge après pose, ports.conf restauré"
        return 1
    fi
    [[ $avant -eq 0 ]] && _ok "apache2ctl configtest : toujours vert."

    _ok_f "%s fichier(s) Apache posé(s), %s refusé(s) — AUCUN n'est activé." "$n" "$refuses"
    printf "  ${DIM}%s${RESET}\n" \
        "Les vhosts dorment dans sites-available/ : inertes tant qu'un a2ensite ne les réveille pas." \
        "⚠ irix-ssl.conf ne s'active QU'APRÈS le certificat — sinon Apache refuse de démarrer." \
        "Activer :  sudo a2ensite irix-default && sudo apache2ctl configtest && sudo systemctl reload apache2" \
        "⚠ ports.conf demande un restart, pas un reload : un Listen neuf n'ouvre pas de socket en rechargement."
    _log "APACHE : $n fichier(s) posé(s), $refuses refusé(s), aucun activé"
    [[ $refuses -eq 0 ]]
}

# =============================================================================
# APACHE EN MPM EVENT — sortir de prefork (05/10/2026, décision de SerVuS)
# =============================================================================
#
# ── Pourquoi ─────────────────────────────────────────────────────────────
#
# Mesuré le 05/10/2026 sur la production : `Server MPM: prefork`, imposé par
# `libapache2-mod-php8.4` (mod_php n'est pas thread-safe). Le SEUL code PHP
# qu'Apache exécutait par mod_php est phpMyAdmin (`irix-admin.conf`, boucle
# locale 8090) — le site passe par `php -S` derrière un `ProxyPass /`. Or
# chaque WebSocket du chat tient un processus prefork jusqu'à 3600 s, sur 150 :
# cent cinquante onglets de chat, et le site entier ne répond plus.
#
# ── Ce que la fonction fait, dans l'ordre, et ce qu'elle ne fait PAS ─────
#
# 1. PHP-FPM présent (sinon le paquet `php-fpm` est installé).
# 2. Le pool FPM de phpMyAdmin posé (`Config/php-fpm/`), SI phpMyAdmin existe ;
#    `php-fpm -t` le valide, sinon il est retiré ; FPM est rechargé (graceful).
# 3. `a2dismod php*` et `mpm_prefork`, `a2enmod mpm_event proxy_fcgi`,
#    `a2enconf mpm-event-overtchat` — des LIENS SYMBOLIQUES : Apache tourne
#    toujours en prefork tant qu'on ne le redémarre pas.
# 4. `apache2ctl configtest` — rouge : tout est REMIS comme avant.
# ⚠ Elle ne redémarre JAMAIS Apache : un changement de MPM exige un
#   `restart` (pas un `reload`), qui coupe le site quelques secondes et toute
#   WebSocket du chat. C'est le geste de l'exploitant, sous avis de travaux.
# Idempotente : déjà en event → rien à changer, dit tel quel.
_fpm_version() {
    local d
    for d in /etc/php/*/fpm; do
        [[ -d "$d" ]] || continue
        d="${d#/etc/php/}"; printf '%s' "${d%/fpm}"; return 0
    done
    return 1
}
_fpm_pool_src()  { printf '%s' "$BASE_DIR/Config/php-fpm/overtchat-phpmyadmin.conf"; }
_fpm_pool_dest() { printf '%s' "/etc/php/$1/fpm/pool.d/overtchat-phpmyadmin.conf"; }

_fpm_poser_pool() {
    local ver="$1" src dest
    src="$(_fpm_pool_src)"; dest="$(_fpm_pool_dest "$ver")"
    # Sans phpMyAdmin, rien n'a besoin de ce pool — et irix-admin.conf, posé
    # inactif, ne casse rien : son `IncludeOptional` tolère l'absence.
    [[ -d /usr/share/phpmyadmin ]] || { _info "phpMyAdmin absent (/usr/share/phpmyadmin) : pas de pool FPM à poser."; return 0; }
    [[ -f "$src" ]] || { _warn "Config/php-fpm/overtchat-phpmyadmin.conf absent du dépôt — pool non posé."; return 1; }
    if [[ -e "$dest" ]] && cmp -s "$src" "$dest"; then
        _info_f "Pool PHP-FPM de phpMyAdmin déjà en place : %s" "$dest"
        return 0
    fi
    _as_root install -o root -g root -m 644 "$src" "$dest" 2>/dev/null || { _err_f "Pose refusée : %s" "$dest"; return 1; }
    # ⚠ Un pool invalide empêche TOUT php-fpm de redémarrer : on vérifie, et
    # on retire plutôt que de laisser une bombe pour la prochaine mise à jour.
    if ! _as_root "php-fpm${ver}" -t >/dev/null 2>&1; then
        _as_root rm -f "$dest" 2>/dev/null || true
        _err "php-fpm -t refuse le pool posé — il est RETIRÉ."
        return 1
    fi
    if ! _as_root systemctl reload "php${ver}-fpm" >/dev/null 2>&1; then
        _warn_f "php%s-fpm n'a pas pu être rechargé — la socket du pool n'existera qu'après : systemctl reload php%s-fpm" "$ver" "$ver"
    fi
    _ok_f "Pool PHP-FPM de phpMyAdmin posé : %s" "$dest"
    return 0
}

_apache_mod_actif() { [[ -e "/etc/apache2/mods-enabled/$1.load" ]]; }

_apache_event() {
    local ver avant m rc=0 mods_php=()
    _priv_can_elevate || { _err "Passer Apache en MPM event exige root ou sudo."; return 1; }
    if ! _apache_present; then
        _info "apache — /etc/apache2 absent : ce logiciel n'est pas installé ici, rien à faire."
        return 0
    fi
    command -v a2enmod >/dev/null 2>&1 || {
        _warn "a2enmod absent : cette distribution ne gère pas les modules Apache à la Debian — passage en MPM event à faire à la main."
        return 1
    }

    # 1. PHP-FPM
    if ! ver="$(_fpm_version)"; then
        _info "PHP-FPM absent : installation du paquet php-fpm."
        _pkg_install php-fpm || return 1
        ver="$(_fpm_version)" || { _err "Aucun /etc/php/<version>/fpm trouvé après l'installation — arrêt."; return 1; }
    fi

    # 2. Le pool de phpMyAdmin (son échec n'empêche pas le MPM : il est DIT).
    _fpm_poser_pool "$ver" || rc=1

    # 3. Les modules. Déjà en event : rien à toucher (idempotence).
    for m in /etc/apache2/mods-enabled/php*.load; do
        [[ -e "$m" ]] || continue
        mods_php+=("$(basename "$m" .load)")
    done
    if _apache_mod_actif mpm_event && _apache_mod_actif proxy_fcgi \
       && ! _apache_mod_actif mpm_prefork && [[ ${#mods_php[@]} -eq 0 ]]; then
        if [[ -e /etc/apache2/conf-available/mpm-event-overtchat.conf && ! -e /etc/apache2/conf-enabled/mpm-event-overtchat.conf ]]; then
            _as_root a2enconf -q mpm-event-overtchat >/dev/null 2>&1 || true
        fi
        _info "Apache tourne déjà en MPM event : rien à changer."
        return $rc
    fi

    # ⚠ L'état de départ, MESURÉ avant de toucher : on n'impute à ce geste que
    # ce qu'il fait passer du vert au rouge (même règle qu'à la pose).
    _apache_configtest; avant=$?
    [[ $avant -eq 1 ]] && _warn "apache2ctl configtest est DÉJÀ rouge AVANT le passage en event — rien ne lui sera imputé."

    for m in ${mods_php[@]+"${mods_php[@]}"}; do _as_root a2dismod -q "$m" >/dev/null 2>&1 || true; done
    _apache_mod_actif mpm_prefork && { _as_root a2dismod -q mpm_prefork >/dev/null 2>&1 || true; }
    _apache_mod_actif mpm_worker  && { _as_root a2dismod -q mpm_worker  >/dev/null 2>&1 || true; }
    _as_root a2enmod -q mpm_event proxy_fcgi >/dev/null 2>&1 || true
    [[ -e /etc/apache2/conf-available/mpm-event-overtchat.conf ]] && { _as_root a2enconf -q mpm-event-overtchat >/dev/null 2>&1 || true; }

    # 4. Le contrôle, et le RETOUR si c'est nous qui avons cassé.
    if [[ $avant -eq 0 ]] && ! _apache_configtest; then
        _err "apache2ctl configtest est passé au ROUGE — modules REMIS comme avant (prefork, mod_php)."
        _as_root a2disconf -q mpm-event-overtchat >/dev/null 2>&1 || true
        _as_root a2dismod -q mpm_event proxy_fcgi >/dev/null 2>&1 || true
        _as_root a2enmod -q mpm_prefork >/dev/null 2>&1 || true
        for m in ${mods_php[@]+"${mods_php[@]}"}; do _as_root a2enmod -q "$m" >/dev/null 2>&1 || true; done
        _log "APACHE : configtest rouge après passage en event, modules restaurés"
        return 1
    fi
    _ok "Apache : mod_php et mpm_prefork désactivés, mpm_event et proxy_fcgi activés, réglages event posés."
    _info "Prend effet au prochain redémarrage : systemctl restart apache2 (un restart, pas un reload : on change de MPM)."
    _log "APACHE : passage en MPM event préparé (mod_php retiré : ${mods_php[*]:-aucun} ; php-fpm $ver)"
    return $rc
}

# Combien de fichiers Apache livrés sont déjà posés à leur destination ?
_apache_compte_poses() {
    local src nom couple dest n=0
    while IFS= read -r src; do
        nom="$(basename "$src")"
        couple="$(_apache_dest "$nom")" || continue
        dest="${couple%%:*}"
        [[ -e "$dest/$nom" ]] && n=$(( n + 1 ))
    done < <(find "$BASE_DIR/Config/apache" -maxdepth 1 -type f 2>/dev/null)
    printf '%s' "$n"
}

# ⚠ Compte TOUS les fichiers livrés, y compris ceux qu'aucune règle ne réclame :
# un « 4 / 5 » qui ne bouge plus après une pose réussie est le signe qu'un
# fichier est arrivé dans Config/apache/ sans destination.
_apache_compte_livres() {
    find "$BASE_DIR/Config/apache" -maxdepth 1 -type f 2>/dev/null | wc -l
}

# Combien d'unités livrées sont déjà posées dans /etc/systemd/system/ ?
_sysd_compte_posees() {
    local dir f n=0
    dir="$(_sysd_dir)"
    while IFS= read -r f; do
        [[ -e "/etc/systemd/system/$(basename "$f")" ]] && n=$(( n + 1 ))
    done < <(find "$dir" -maxdepth 1 \( -name '*.service' -o -name '*.timer' \) 2>/dev/null)
    printf '%s' "$n"
}

_sysd_compte_livrees() {
    find "$(_sysd_dir)" -maxdepth 1 \( -name '*.service' -o -name '*.timer' \) 2>/dev/null | wc -l
}

# L'écran des services système.
#
# ⚠ Il est RÉ-EXÉCUTABLE, et c'est tout son objet : la mise à jour n'atteint
# jamais `/etc/systemd/system/`, donc une unité corrigée en amont n'arrive par
# aucun autre chemin. On doit pouvoir repasser ici après coup — après avoir
# installé un composant, après avoir posé la phrase secrète des sauvegardes.
_panel_systemd() {
    local choice dir
    dir="$(_sysd_dir)"

    while true; do
        local livrees posees tim nom svc etat
        livrees="$(_sysd_compte_livrees)"
        posees="$(_sysd_compte_posees)"

        _ui_width
        _ui_title "Services système" "poser les unités, activer ce qui peut marcher"

        _ui_top
        _ui_kv "Unités livrées"    "$livrees"
        _ui_kv "Déjà posées"       "$posees / $livrees"
        _ui_kv "Fichiers système"  "$(_sys_compte_poses) / $(_sys_compte_livres)  ${DIM}sudoers · polkit · logrotate${RESET}"
        _ui_kv "Fichiers Apache"   "$(_apache_compte_poses) / $(_apache_compte_livres)  ${DIM}posés inactifs — vhosts · ports · maintenance${RESET}"
        _ui_kv "Compte de service" "$(_sysd_compte || echo "aucun — root refusé")"
        _ui_kv "Privilèges"        "$(_priv_label)"

        # ⚠ L'état est DÉRIVÉ à chaque affichage, jamais mémorisé : entre deux
        # passages, l'exploitant a pu installer un composant ou poser un secret.
        _ui_head "Timers"
        while IFS= read -r tim; do
            nom="$(basename "$tim")"
            svc="$(sed -n 's/^Unit=//p' "$tim" | head -1)"
            [[ -n "$svc" ]] || svc="${nom%.timer}.service"

            if [[ ! -e "/etc/systemd/system/$nom" ]]; then
                etat="${DIM}non posé${RESET}"
            elif systemctl is-active "$nom" >/dev/null 2>&1; then
                etat="${GREEN}en service${RESET}"
            elif ! _sysd_prerequis_ok "$(_sysd_prerequis "/etc/systemd/system/$svc")"; then
                etat="${ORANGE}posé — prérequis manquant${RESET}"
            else
                etat="${ORANGE}posé, au repos${RESET}"
            fi
            _ui_row "  ${nom}$(_ui_pad "  ${nom}" 40)${etat}"
        done < <(find "$dir" -maxdepth 1 -name '*.timer' 2>/dev/null | sort)

        _menu_reset
        _ui_head "Actions"
        _menu_add 1 "Poser (ou reposer) toutes les unités"
        _menu_gate 2 "Activer les timers dont le prérequis est satisfait" \
            "$([[ "$posees" -gt 0 ]] && echo "" || echo "pose d'abord les unités")"
        # ⚠ Point 43. Sans le sudoers, la veille du point 40 constate une panne
        # sans pouvoir relancer — c'est le plus conséquent des trois.
        _menu_add 3 "Poser les fichiers système ${DIM}(sudoers, polkit, logrotate)${RESET}"
        # ⚠⚠ Point 43, quatrième famille. Elle POSE et n'ACTIVE RIEN : activer
        # `irix-ssl.conf` sans certificat empêche Apache de démarrer, et c'est
        # tout le site qui tombe. L'`a2ensite` reste un geste de l'exploitant.
        _menu_add 4 "Poser les fichiers Apache ${DIM}(sans les activer)${RESET}"
        # ⚠ 05/10/2026 : prépare le MPM event (php-fpm, pool phpMyAdmin,
        # modules) et NE redémarre PAS Apache — le restart coupe le chat.
        _menu_add 5 "Passer Apache en MPM event ${DIM}(php-fpm, proxy_fcgi ; sans redémarrer)${RESET}"
        _menu_add 0 "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        _menu_has "$choice" || { _warn "Choix indisponible ou verrouillé."; continue; }

        case "$choice" in
            1) _sysd_poser   || true ; _pause ;;
            2) _sysd_activer || true ; _pause ;;
            3) _sys_poser    || true ; _pause ;;
            4) _apache_poser || true ; _pause ;;
            5) _apache_event || true ; _pause ;;
            0) return 0 ;;
        esac
    done
}

_install_step_icon() {
    case "$1" in
        done)   printf "${GREEN}✔${RESET}" ;;
        todo)   printf "${ORANGE}○${RESET}" ;;
        locked) printf "${DIM}✖${RESET}" ;;
    esac
}

_panel_install() {
    local choice
    _services_init; _servers_init; _st_reconcile

    while true; do
        # ── État des étapes ──────────────────────────────────────────
        local deps_ok=0 srv_ok=0 egg_ok=0 irix_ok=0 faites=0
        _req_satisfied eggdrop && _req_satisfied unrealircd && deps_ok=1
        _st_any_server_installed && srv_ok=1
        _st_svc_installed eggdrop && egg_ok=1
        _st_irix_installed && irix_ok=1
        faites=$(( deps_ok + srv_ok + egg_ok + irix_ok ))

        _ui_width
        _ui_title "Assistant d'installation" "quatre étapes obligatoires, dans cet ordre"

        _ui_top
        _ui_kv "Progression"          "$(_ui_bar "$faites" 4)"
        _ui_kv "Racine d'installation" "$BASE_DIR"
        _ui_kv "Privilèges"            "$(_priv_label)"
        _ui_kv "Distribution"          "${PKG_FAMILY} ${DIM}(${PKG_MGR:-aucun})${RESET}"

        # Chaque étape porte son état ET, si elle est verrouillée, ce qui la
        # débloque. Un « ✖ » sans explication oblige à deviner l'ordre des
        # opérations — c'est précisément ce qu'un assistant doit éviter.
        _ui_head "Étapes"
        _ui_row "$(_install_step_icon "$([[ $deps_ok -eq 1 ]] && echo done || echo todo)")  1. Dépendances système"
        _ui_row "$(_install_step_icon "$([[ $srv_ok -eq 1 ]] && echo done || { [[ $deps_ok -eq 1 ]] && echo todo || echo locked; })")  2. Serveur IRC$([[ $srv_ok -eq 1 ]] && printf "   ${DIM}%s installé(s)${RESET}" "$(_st_count_servers)")"
        [[ $deps_ok -eq 1 ]] || _ui_row "     ${ORANGE}↳${RESET} ${DIM}dépendances système incomplètes${RESET}"
        _ui_row "$(_install_step_icon "$([[ $egg_ok -eq 1 ]] && echo done || { [[ $srv_ok -eq 1 ]] && echo todo || echo locked; })")  3. Eggdrop"
        [[ $srv_ok -eq 1 ]] || _ui_row "     ${ORANGE}↳${RESET} ${DIM}requiert un serveur IRC${RESET}"
        _ui_row "$(_install_step_icon "$([[ $irix_ok -eq 1 ]] && echo done || { [[ $egg_ok -eq 1 ]] && echo todo || echo locked; })")  4. IriX"
        [[ $egg_ok -eq 1 ]] || _ui_row "     ${ORANGE}↳${RESET} ${DIM}requiert Eggdrop${RESET}"
        _ui_row "$(_install_step_icon "$([[ $srv_ok -eq 1 ]] && echo todo || echo locked)")  5. Services optionnels ${DIM}(Anope / Lea)${RESET}"
        [[ $srv_ok -eq 1 ]] || _ui_row "     ${ORANGE}↳${RESET} ${DIM}requiert un serveur IRC${RESET}"

        # ⚠ Sixième étape, ajoutée le 20/08/2026 — point 41. Sans elle, une
        # installation neuve avait les quatorze unités au dépôt et AUCUNE dans
        # /etc/systemd/system/ : ni sauvegardes, ni opérateurs appliqués, ni
        # veille sur IriX. Elle vient en dernier parce qu'une unité n'a de sens
        # qu'une fois son composant installé — et elle se repasse autant de fois
        # qu'on veut, la mise à jour n'atteignant jamais /etc.
        local sysd_posees sysd_livrees
        sysd_livrees="$(_sysd_compte_livrees)"
        sysd_posees="$(_sysd_compte_posees)"
        _ui_row "$(_install_step_icon "$([[ "$sysd_posees" -ge "$sysd_livrees" && "$sysd_livrees" -gt 0 ]] && echo done || echo todo)")  6. Services système ${DIM}(systemd — ${sysd_posees}/${sysd_livrees} posées)${RESET}"
        [[ "$sysd_posees" -gt 0 ]] || _ui_row "     ${ORANGE}↳${RESET} ${DIM}aucune unité posée : rien ne tournera tout seul${RESET}"

        _menu_reset
        _ui_head "Actions"
        _menu_add 1 "Vérifier / installer les dépendances système"
        _menu_gate 2 "Installer un serveur IRC" \
            "$([[ $deps_ok -eq 1 ]] && echo "" || echo "dépendances système incomplètes")"
        _menu_gate 3 "Installer Eggdrop" \
            "$([[ $srv_ok -eq 1 ]] && echo "" || echo "installe d'abord un serveur IRC")"
        _menu_gate 4 "Installer / déployer IriX" \
            "$([[ $egg_ok -eq 1 ]] && echo "" || echo "installe d'abord Eggdrop")"
        _menu_gate 5 "Services optionnels (Anope / Lea)" \
            "$([[ $srv_ok -eq 1 ]] && echo "" || echo "installe d'abord un serveur IRC")"
        # ⚠ Volontairement NON verrouillée. Poser une unité ne démarre rien, et
        # l'écran n'active que ce dont le prérequis est vérifiable : on peut
        # donc y venir à tout moment, y compris pour reposer une unité corrigée.
        _menu_add 6 "Services système (systemd)"
        _menu_add 0 "Retour"
        _ui_bot

        _ui_prompt
        read -r choice || choice=0
        _menu_has "$choice" || { _warn "Choix indisponible ou verrouillé."; continue; }

        case "$choice" in
            1) _panel_deps || true ;;
            2) _panel_servers || true ;;
            3) _services_init; _panel_service_detail "eggdrop" || true ;;
            4) _panel_irix || true ;;
            # ── Services optionnels ──────────────────────────────────
            #
            # ⚠⚠ CE SOUS-MENU CUMULAIT QUATRE ÉCARTS, corrigés le 28/08/2026.
            # Relevé en balayant les quinze menus du fichier : c'était le SEUL
            # qui en portait — les quatorze autres étaient exacts.
            #
            #   · `[0] Retour` était OFFERT sans aucune branche. Il retombait
            #     dans la boucle du menu parent, ce qui ressemble à un retour —
            #     mais par accident, pas par intention. Aucune autre entrée du
            #     fichier n'est traitée ainsi ;
            #   · `_menu_add` était appelé sans son `_menu_has` : les clés
            #     étaient construites puis **jamais consultées**. La validation
            #     était écrite, et jetée ;
            #   · aucune branche `*)` : taper `z` ne produisait **rien du tout**,
            #     ni message, ni redessin. Les autres menus disent au moins
            #     « Choix invalide » ;
            #   · les entrées s'affichaient **hors cadre** — `_menu_add` appelle
            #     `_ui_item`, donc `_ui_row`, dont le contrat écrit ligne 1552
            #     exige un `_ui_top` ouvert. Les barres verticales pendaient
            #     dans le vide.
            #
            # ⚠ `_menu_reset` écrase `MENU_KEYS`, qui est GLOBAL : le menu parent
            # perd ses clés. C'était sans effet ici, la boucle les reposant au
            # tour suivant — mais on remet les siennes en sortant, pour ne pas
            # laisser une bombe à retardement à qui déplacera ce bloc.
            5) _services_init
               local s
               _menu_reset
               _ui_title "Services optionnels"
               _ui_top
               _menu_add a "Anope Services"
               _menu_add b "Lea Service"
               _menu_add 0 "Retour"
               _ui_bot
               _ui_prompt
               read -r s || s=0
               if _menu_has "$s"; then
                   case "$s" in
                       a) _panel_service_detail "anope" || true ;;
                       b) _panel_service_detail "lea" || true ;;
                       0) : ;;   # retour au menu d'installation, dit et voulu
                   esac
               else
                   _warn "Choix invalide."
               fi
               _menu_reset ;;
            6) _panel_systemd || true ;;
            0) return ;;
        esac
    done
}

# =============================================================================
# SITE ET PANEL WEB
#
#   L'installateur posait le serveur IRC, les services et le bot — mais pas le
#   site, ni le panel, ni le fichier qui porte l'identité du réseau. Une
#   installation neuve n'avait donc aucun moyen de se nommer : tout ce que le
#   site affiche venait de valeurs de repli.
#
#   C'est identity.json qui répare cela. Il est écrit ICI, à partir de ce que
#   l'exploitant répond — son nom, son domaine, ses adresses — et non copié
#   depuis l'installation d'origine. Le nom du LOGICIEL, lui, ne bouge pas :
#   le dépôt, le paquet et le serveur Overtchat-Hybrid gardent le leur.
#
#   Ce que l'exploitant nomme ici, c'est son RÉSEAU — ce que voient ses
#   visiteurs. Le répertoire où tout cela est installé porte un autre nom,
#   demandé bien plus tôt (voir `_projet_demander`), et les deux n'ont aucune
#   raison de coïncider : on installe dans ~/MonReseau un réseau qui s'annonce
#   « Les Copains du Dimanche ».
# =============================================================================

# Demande une valeur, avec un défaut proposé entre crochets.
_site_ask() {
    local libelle="$1" defaut="${2:-}" auto="${3:-${2:-}}" rep=""

    # ── Le mode AUTO ────────────────────────────────────────────────────
    #
    # Le troisième paramètre est la réponse à donner quand personne ne peut
    # répondre. Il est FACULTATIF, et vaut le défaut interactif quand il est
    # absent — c'est le cas des trente questions de valeur.
    #
    # Il n'existe que pour les questions de CONSENTEMENT, dont le défaut
    # interactif est un refus prudent (« (o/N) ») alors que le mode AUTO,
    # lui, est précisément un accord donné d'avance. Sans ce troisième
    # paramètre, `--auto` répondrait « non » à « installer les extensions
    # PHP manquantes ? » et poserait un site qui ne démarre pas.
    #
    # ⚠ La trace part sur stderr, jamais sur stdout : cette fonction est
    # appelée dans un `$( )`, et tout ce qu'elle écrit sur stdout DEVIENT la
    # réponse.
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        if [[ -z "$auto" ]]; then
            # `_auto_prevol` est censé avoir arrêté le mode AUTO avant qu'une
            # question sans réponse ne soit posée. Si l'on arrive ici, c'est
            # qu'une question lui a échappé : le dire vaut mieux que rendre
            # une chaîne vide dont personne ne saura d'où elle vient.
            printf '  %s\n' "⚠ auto : « ${libelle} » n'a aucune valeur prévue — laissée vide." >&2
        else
            printf '  %s\n' "${libelle} : ${auto} ${DIM}(auto)${RESET}" >&2
        fi
        printf '%s' "$auto"
        return 0
    fi

    if [[ -n "$defaut" ]]; then
        read -rp "  ${libelle} [${defaut}] : " rep || true
        printf '%s' "${rep:-$defaut}"
    else
        read -rp "  ${libelle} : " rep || true
        printf '%s' "$rep"
    fi
}

# =============================================================================
# NOMS RÉSERVÉS ET DISPONIBILITÉ
#
#   Trois questions différentes, qu'il vaut mieux ne pas confondre :
#
#     1. Comment s'appelle VOTRE installation, sur votre disque   → _projet_*
#     2. Comment s'appelle VOTRE réseau, pour ses visiteurs       → identité
#     3. Le domaine que vous visez est-il libre                   → _domaine_*
#
#   Les deux premiers peuvent différer, et c'est le cas courant : on installe
#   dans `~/MonReseau` un réseau qui s'annonce « Les Copains du Dimanche ». Le
#   troisième ne dépend d'aucun des deux.
#
#   Ce qu'aucun de ces contrôles ne fait : décider à la place de l'exploitant.
#   Ils refusent un nom qui appartient à quelqu'un d'autre, ils signalent un
#   domaine déjà pris — et pour le reste, ils demandent confirmation et
#   s'effacent. Un installateur qui bloquerait sur une résolution DNS serait
#   inutilisable hors ligne, et un réseau qu'on monte pour ses amis n'a pas à
#   prouver quoi que ce soit à son installateur.
# =============================================================================

# Réduction d'un nom à sa forme comparable : minuscules, sans séparateur ni
# accent. « Service-Overtchat », « service overtchat » et « ServiceOvertchat »
# désignent la même chose et doivent être refusés pareillement.
_nom_normalise() {
    printf '%s' "$1" \
        | iconv -f UTF-8 -t ASCII//TRANSLIT 2>/dev/null || printf '%s' "$1"
}

_nom_reduit() {
    local n
    n=$(_nom_normalise "$1" | tr '[:upper:]' '[:lower:]')
    printf '%s' "${n//[^a-z0-9]/}"
}

# -----------------------------------------------------------------------------
# _nom_est_reserve — ce nom appartient-il au réseau d'origine ?
#
# « Service-Overtchat » et « Overtchat » nomment le réseau et le serveur écrits
# par l'auteur du projet : une installation tierce qui les reprendrait se
# ferait passer pour lui, dans les moteurs de recherche comme dans l'esprit de
# ses propres visiteurs. Le refus vaut pour le répertoire d'installation comme
# pour le nom public du réseau.
#
# Ce n'est pas une liste de mots interdits : c'est une liste de noms qui sont
# déjà pris. Le logiciel, lui, garde son nom partout — un exploitant fait
# tourner « Overtchat-Hybrid » sur « Les Copains du Dimanche », et c'est
# exactement ce qu'on veut lire.
# -----------------------------------------------------------------------------
_nom_est_reserve() {
    local reduit; reduit=$(_nom_reduit "$1")
    [[ -z "$reduit" ]] && return 1

    local r
    for r in serviceovertchat overtchat overtchatircd overtchathybrid overtchatd; do
        [[ "$reduit" == "$r" ]] && return 0
    done
    return 1
}

# -----------------------------------------------------------------------------
# _domaine_pris — ce domaine répond-il déjà à quelqu'un ?
#
# Deux constats, et rien de plus :
#
#   DNS   le nom résout-il vers une adresse ? Un domaine qui résout est
#         enregistré par quelqu'un — pas forcément vous.
#   HTTP  quelque chose répond-il en 80/443 ? Un site déjà servi à cette
#         adresse est le signe le plus net qu'on s'apprête à marcher dessus.
#
# Rend 0 si le domaine paraît PRIS, 1 s'il paraît libre ou si l'on n'a pas pu
# savoir. « Pas pu savoir » et « libre » se ressemblent trop pour être
# distingués ici : c'est l'appelant qui demande confirmation, et il vaut mieux
# une question de trop qu'un refus sur une machine sans réseau.
#
# `example.org`, `localhost` et les adresses littérales sont ignorés : ce sont
# les valeurs proposées par défaut, et interroger le DNS pour elles ne
# renseignerait sur rien.
# -----------------------------------------------------------------------------
_domaine_pris() {
    local d="${1,,}"

    [[ -z "$d" ]] && return 1
    [[ "$d" == localhost || "$d" == *.localhost ]] && return 1
    [[ "$d" == *example.org || "$d" == *example.com || "$d" == *.invalid ]] && return 1
    [[ "$d" =~ ^[0-9.]+$ ]] && return 1

    DOMAINE_CONSTAT=""

    local ip=""
    if command -v getent >/dev/null 2>&1; then
        ip=$(getent ahosts "$d" 2>/dev/null | awk 'NR==1{print $1}')
    fi
    if [[ -z "$ip" ]] && command -v host >/dev/null 2>&1; then
        ip=$(host -W 3 "$d" 2>/dev/null | awk '/has address/{print $NF; exit}')
    fi

    [[ -z "$ip" ]] && return 1

    DOMAINE_CONSTAT="résout vers ${ip}"

    # Une réponse HTTP, quel qu'en soit le code : 200, 301, 403 ou 500 disent
    # tous qu'un serveur écoute et se croit chez lui. Seul l'absence de réponse
    # laisse penser que le nom est libre d'usage web.
    if command -v curl >/dev/null 2>&1; then
        local code
        code=$(curl -s -o /dev/null -m 6 -w '%{http_code}' "https://${d}/" 2>/dev/null)
        [[ "$code" == "000" ]] && \
            code=$(curl -s -o /dev/null -m 6 -w '%{http_code}' "http://${d}/" 2>/dev/null)
        [[ "$code" != "000" && -n "$code" ]] && \
            DOMAINE_CONSTAT="${DOMAINE_CONSTAT}, et un site y répond (HTTP ${code})"
    fi

    return 0
}

# -----------------------------------------------------------------------------
# _projet_demander — le nom de l'installation, sur une installation neuve.
#
# Le nom retenu est déposé dans $PROJET_CHOISI, et NON écrit sur la sortie
# standard : cette fonction affiche un écran entier — titre, explications,
# refus successifs — et un appel en `$( )` capturerait tout cela avec le nom.
# La variable a été essayée dans l'autre sens d'abord : l'assistant se relançait
# alors avec un « --projet= » contenant son propre affichage, la garde de forme
# le refusait, et la question revenait indéfiniment.
#
# (Les invites de `read -p` échappent à ce piège, bash les écrivant sur la
# sortie d'erreur — c'est pourquoi `_site_ask`, lui, s'appelle bien en `$( )`.)
#
# La question n'est posée qu'une fois, au tout premier lancement : le nom se
# retrouve ensuite dans l'état, et changer de nom après coup demanderait de
# déplacer une arborescence entière, ce que cet assistant ne sait pas faire —
# et ce qu'il ne doit pas prétendre faire.
# -----------------------------------------------------------------------------
PROJET_CHOISI=""

_projet_demander() {
    local propose="${1:-MonReseau}" nom=""
    PROJET_CHOISI=""

    _ui_width
    _ui_title "Nom de votre installation" "il donne le répertoire racine"

    printf "  Tout ce que l'assistant installe vivra dans ${BOLD}%s/<nom>${RESET}.\n" "$TARGET_HOME"
    printf "  Ce nom est celui de VOTRE installation, pas celui du logiciel : les\n"
    printf "  services gardent le leur, et vous nommerez votre réseau plus tard,\n"
    printf "  séparément — les deux n'ont aucune raison de coïncider.\n\n"
    printf "  ${DIM}Lettres, chiffres, point, tiret et souligné. 2 à 64 caractères.${RESET}\n\n"

    # Une boucle de question qui ne compte pas ses tours est une boucle
    # infinie dès que l'entrée est épuisée : `read` rend alors la valeur par
    # défaut sans attendre, et un refus qui reboucle repose la même question
    # aussi vite que la machine le permet. Le cas se produit sans aucune
    # malveillance — installation pilotée par un script, entrée redirigée.
    local tours=0
    while :; do
        if (( ++tours > 10 )); then
            _err "Trop de réponses refusées — installation abandonnée."
            _info_f "Relancez avec le nom en argument : bash %s.sh --projet=MonReseau" "$SCRIPT_NAME"
            return 1
        fi

        nom=$(_site_ask "Nom de l'installation" "$propose")
        nom="${nom// /}"

        if [[ -z "$nom" ]]; then
            _warn "Un nom est nécessaire."
            continue
        fi

        if [[ ! "$nom" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$ ]] || [[ "$nom" == *..* ]]; then
            _err "Forme refusée : lettres, chiffres, point, tiret, souligné — 2 à 64 caractères."
            _info "Ce nom devient un chemin sur le disque ; tout le reste en dérive."
            continue
        fi

        if _nom_est_reserve "$nom"; then
            _err_f "« %s » est le nom du réseau d'origine — il reste à son auteur." "${nom}"
            _info "Le logiciel garde son nom quel que soit le vôtre : vous ferez tourner"
            _info "Overtchat-Hybrid et IriX sur VOTRE réseau, sous VOTRE nom."
            _info "Exemples : MonReseau, ChatDuDimanche, irc-lesamis"
            continue
        fi

        if [[ -e "$TARGET_HOME/$nom" ]]; then
            _warn_f "%s/%s existe déjà." "$TARGET_HOME" "$nom"
            local rep
            rep=$(_site_ask "L'utiliser quand même ? (o/N)" "N")
            [[ "$rep" =~ ^[oOyY] ]] || continue
        fi

        PROJET_CHOISI="$nom"
        return 0
    done
}

# Écrit Config/identity.json à partir des réponses.
#
# Passe par jq plutôt que par un heredoc : un nom de réseau contenant une
# apostrophe ou un accent casserait un JSON assemblé à la main, et le site
# repartirait alors sur ses valeurs par défaut sans dire pourquoi.
_site_write_identity() {
    local dest="$1" nom="$2" court="$3" domaine="$4" mail_from="$5" mail_to="$6"
    local modele="${BASE_DIR}/Config/identity.json.example"

    [[ -f "$modele" ]] || { _err "Modèle d'identité absent : $modele"; return 1; }

    if [[ -f "$dest" ]]; then
        cp -a "$dest" "${dest}.avant-$(date +%Y%m%d-%H%M%S)"
        _info "Identité précédente sauvegardée."
    fi

    jq --arg n "$nom" --arg c "$court" --arg d "$domaine" \
       --arg mf "$mail_from" --arg mt "$mail_to" \
       '.name = $n
        | .short = $c
        | .domain = $d
        | .mail.from = $mf
        | .mail.from_name = $n
        | .mail.to = $mt
        | .extra_hosts = ["localhost","127.0.0.1"]' \
       "$modele" > "${dest}.tmp" || { _err "Écriture de l'identité impossible."; return 1; }

    mv "${dest}.tmp" "$dest"
    _ok_f "Identité écrite : %s" "$dest"
}

# -----------------------------------------------------------------------------
# _postfix_configure — le relais de courriel local
#
#   Le site ne parle pas au monde : il remet ses messages à un Postfix sur
#   127.0.0.1, qui relaie ensuite. Ce détour n'est pas un caprice — il apporte
#   la file d'attente et les réessais, qu'un dialogue SMTP direct depuis PHP
#   n'a pas : un message refusé pour « trop de connexions » est différé puis
#   remis, au lieu d'être perdu.
#
#   Ce que cette fonction règle, ce sont les quatre valeurs qui décident que
#   ça marche ou pas. Toutes ont été trouvées à la dure sur l'installation
#   d'origine :
#
#     relayhost   le relais de l'opérateur, en port 25. Le 587 demande une
#                 authentification que l'on n'a pas.
#     myhostname  un nom qualifié. Un HELO en « .localdomain » est refusé
#                 d'emblée par les MX sérieux.
#     myorigin    le domaine dont on qualifie les adresses LOCALES, et il doit
#                 résoudre : sinon « 550 Sender address rejected: Domain not
#                 found », et c'est tout le courrier qui tombe.
#     alias root  pour que le courrier système arrive à une vraie boîte.
#
#   ⚠ `myorigin` valait le domaine de l'adresse d'EXPÉDITION, et c'était un
#   piège. Avec une adresse chez un fournisseur — le cas le plus courant — la
#   machine se met à prétendre qu'elle EST `free.fr` : le courrier local vers
#   `root` devient `root@free.fr`, une adresse que `mydestination` ne reconnaît
#   pas, donc EXTERNE, donc envoyée sur Internet vers une boîte qui n'existe
#   pas. Et comme les alias ne s'appliquent qu'aux adresses locales, l'alias
#   `root:` juste en dessous n'était JAMAIS consulté — le garde-fou prévu pour
#   ce cas précis était court-circuité par le réglage de la ligne du dessus.
#
#   Constaté le 31/07/2026 sur l'installation d'origine : 240 messages en file,
#   un par échec de `sudo` (Debian active `mail_badpass`), chacun suivi de son
#   avis de non-remise. La file se remplissait toute seule.
#
#   `myorigin` vaut donc `myhostname`. Il résout — c'est le domaine du site —,
#   il rend `root` local, et l'alias reprend son office. L'expéditeur des
#   messages du SITE n'est pas concerné : le client SMTP de `Web/inc/mailer.php`
#   émet un `MAIL FROM:` complet, qui ne dépend pas de ce réglage.
#
#   `postconf -e` plutôt qu'un sed dans main.cf : c'est l'outil officiel, il
#   est idempotent, et il ne touche à rien d'autre.
# -----------------------------------------------------------------------------
_postfix_configure() {
    # `mail_from` n'est plus lu depuis que `myorigin` vaut le domaine du site.
    # Le paramètre reste dans la signature : les trois valeurs vont ensemble,
    # et un appelant qui n'en passerait que deux serait plus difficile à
    # relire que ce commentaire. Le préfixe `_` dit à shellcheck que
    # l'inutilisation est voulue.
    local domaine="$1" _mail_from="$2" mail_to="$3"

    _section "Relais de courriel"

    if ! command -v postconf >/dev/null 2>&1; then
        _warn "Postfix n'est pas installé — le site enregistrera les messages sans les envoyer."
        _info "Pour l'installer : $(_pkg_install_cmd "$(_pkg_name postfix)")"
        return 0
    fi

    if ! _priv_can_elevate; then
        _warn "Configuration de Postfix impossible sans élévation."
        _info "À exécuter en root :"
        printf "    ${BOLD}postconf -e 'relayhost=[votre.relais]:25' 'myhostname=%s'${RESET}\n" "$domaine"
        return 0
    fi

    local rep relais origine
    printf "\n  ${DIM}Le relais de votre opérateur. Chez Free : smtp.free.fr — chez OVH :\n"
    printf "  ssl0.ovh.net. Laisser vide pour une remise directe (souvent refusée\n"
    printf "  depuis une adresse résidentielle).${RESET}\n\n"
    relais=$(_site_ask "Relais SMTP sortant" "")

    # Le domaine du SITE, et non celui de l'adresse d'expédition — voir
    # l'avertissement en tête de cette fonction. C'est ce qui rend `root`
    # local, donc aliasable ; prendre le domaine de l'expéditeur envoyait le
    # courrier interne de la machine sur Internet.
    #
    # Le repli sur `myhostname` couvre le cas où le domaine n'aurait pas été
    # saisi : une valeur vide ferait poser « myorigin= », que Postfix refuse.
    origine="$domaine"
    [[ -n "$origine" ]] || origine='$myhostname'

    # PRIV_MODE vaut « root », « sudo » ou « user ». On n'ajoute sudo que dans
    # le second cas : l'appeler en étant déjà root échouerait sur une image où
    # sudo n'est pas installé — c'est le cas d'un conteneur.
    local sudo_cmd=""
    [[ "$PRIV_MODE" == "sudo" ]] && sudo_cmd="sudo"

    local reglages=(
        "myhostname=${domaine}"
        "myorigin=${origine}"
        "inet_interfaces=loopback-only"
        "inet_protocols=ipv4"
        "smtp_tls_security_level=may"
    )
    [[ -n "$relais" ]] && reglages+=( "relayhost=[${relais}]:25" )

    # Sauvegarde avant tout : main.cf peut porter des réglages qui ne sont pas
    # de notre fait, et une installation qui écrase sans filet ne se rattrape
    # pas.
    if ! $sudo_cmd cp -a /etc/postfix/main.cf "/etc/postfix/main.cf.avant-overtchat-$(date +%Y%m%d-%H%M%S)" 2>/dev/null; then
        _warn "Sauvegarde de main.cf impossible — configuration abandonnée."
        return 1
    fi

    if $sudo_cmd postconf -e "${reglages[@]}" 2>/dev/null; then
        _ok "Postfix configuré."
        _kv "Relais"     "${relais:-remise directe}"
        _kv "myhostname" "$domaine"
        _kv "myorigin"   "$origine"
        _kv "Écoute"     "boucle locale seulement"
    else
        _err "postconf a échoué — la sauvegarde est dans /etc/postfix/."
        return 1
    fi

    # L'alias root. Ajouté seulement s'il n'y est pas : un « >> » aveugle crée
    # un doublon, et postalias avertit alors à chaque exécution.
    if [[ -f /etc/aliases ]] && ! grep -qE '^root:' /etc/aliases; then
        if printf 'root: %s\n' "$mail_to" | $sudo_cmd tee -a /etc/aliases >/dev/null; then
            $sudo_cmd newaliases 2>/dev/null && _ok "Courrier système redirigé vers $mail_to"
        fi
    elif [[ -f /etc/aliases ]]; then
        _info "Un alias root existe déjà — laissé tel quel."
    fi

    # `postfix reload` en dernier ressort : il ne dépend pas de systemd, absent
    # des conteneurs et de quelques distributions. Sans lui, une configuration
    # correcte restait sans effet jusqu'au prochain démarrage, sur un simple
    # avertissement.
    if $sudo_cmd systemctl reload postfix 2>/dev/null \
       || $sudo_cmd systemctl restart postfix 2>/dev/null \
       || $sudo_cmd postfix reload 2>/dev/null; then
        _ok "Postfix rechargé."
    else
        _warn "Postfix n'a pas pu être rechargé — la configuration prendra effet au démarrage."
    fi

    _info "Vérification depuis le panel : onglet Courriel, « Envoyer un message de test »."
    return 0
}

# Installe — ou met à jour — le site et le panel.
# ════════════════════════════════════════════════════════════════
# _edition_poser — l'édition de CETTE installation
#
# ⚠⚠ CE QU'ELLE DÉBLOQUE. Le champ `project.edition` avait trois lecteurs et
# zéro écrivain, et le fichier qui le porte n'atteignait même pas les
# installations tierces : mesuré le 01/09/2026 sur l'archive réellement servie,
# `Config/config.json` n'y figurait pas. Toute installation naissait donc
# « beta » par défaut prudent, sans aucun moyen d'en sortir.
#
# ⚠ LA LISTE DES ÉDITIONS N'EST PAS RECOPIÉE ICI. On source `Tools/edition.sh`,
# qui porte `edition_connues`, `edition_lire` et l'écrivain. C'est aussi le
# premier appelant de ce lecteur, qui n'en avait aucun depuis le 14/08.
#
# ⚠⚠ ET ON N'ÉCRASE JAMAIS UNE ÉDITION DÉJÀ POSÉE sans qu'on l'ait demandé.
# Relancer l'installateur sur une installation qui tourne ne doit pas la
# redescendre en « beta » — ce serait éteindre en silence ce qui marchait.
# La règle tient en trois cas, et le troisième est le plus important :
#
#   --edition=X donné            → on pose X (l'intention est explicite)
#   rien, et le champ ABSENT     → on pose le défaut prudent
#   rien, et le champ PRÉSENT    → ON NE TOUCHE À RIEN
# ════════════════════════════════════════════════════════════════

_edition_poser() {
    local cible="$BASE_DIR/Config/config.json"
    local modele="$BASE_DIR/Config/config.json.example"
    local demandee="${EDITION_CHOISIE:-}"

    # Le lecteur ET l'écrivain, en un seul endroit. ⚠ `Tools/` arrive avec
    # l'archive du site : si le fichier manque, l'installation est incomplète
    # et le dire vaut mieux que poser une édition au hasard.
    if [[ ! -r "$BASE_DIR/Tools/edition.sh" ]]; then
        _warn "Tools/edition.sh est absent — l'édition n'a pas été posée."
        return 1
    fi
    # shellcheck source=/dev/null
    source "$BASE_DIR/Tools/edition.sh"

    # ⚠ Le lecteur déduit la racine de SA propre place. Sourcé depuis
    # l'installateur, il la déduirait de `Bootstrap/`. On la redirige donc sur
    # l'installation — le motif employé par les trois bancs de l'édition.
    _edition_racine() { printf '%s\n' "$BASE_DIR"; }

    # Le fichier naît du modèle, comme `Config/identity.json` naît du sien.
    if [[ ! -f "$cible" ]]; then
        if [[ ! -r "$modele" ]]; then
            _warn "Config/config.json.example est absent — l'édition n'a pas été posée."
            return 1
        fi
        cp "$modele" "$cible" || { _warn "Copie du modèle impossible."; return 1; }
        _ok "Config/config.json posé depuis le modèle"
    elif [[ -z "$demandee" ]]; then
        # ⚠⚠ Le cas qui protège une installation qui tourne : le fichier est
        # là, personne n'a demandé d'édition, on ne touche à rien.
        _info "Édition inchangée : $(edition_lire)"
        return 0
    fi

    # ⚠⚠ DEUXIÈME GARDE, ET ELLE FAIT DOUBLE EMPLOI EXPRÈS — ne pas la retirer.
    #
    # Faute d'édition demandée, on repose CELLE QUI EST DÉJÀ LÀ. Le `elif`
    # ci-dessus sort déjà dans ce cas ; cette ligne le couvre une seconde fois,
    # par un autre chemin.
    #
    # ⚠ La passe de mutation du 01/09/2026 l'a montré de la façon la plus
    # nette : blesser l'une des deux ne fait rougir AUCUN constat, parce que
    # l'autre rattrape. Il a fallu les retirer ENSEMBLE pour voir la protection
    # tomber. Chacune paraît donc morte quand on l'examine seule — et c'est
    # exactement le raisonnement qui ferait supprimer la survivante un jour.
    #
    # Ce qu'elles protègent : qu'un installateur relancé sans option ne
    # redescende pas une installation qui tourne en « beta », c'est-à-dire
    # n'éteigne pas en silence ce qui marchait.
    [[ -z "$demandee" ]] && demandee="$(edition_lire)"

    # ⚠ Le code de retour se capture AVANT toute autre commande : après un
    # `if`, `$?` ne porte plus celui qu'on croit. C'est le piège bash le plus
    # courant, et il rendrait le message d'erreur faux — donc pire qu'absent.
    local rc=0
    edition_ecrire "$demandee" || rc=$?

    if [[ "$rc" -eq 0 ]]; then
        _ok_f "Édition de cette installation : %s" "$demandee"
        return 0
    fi

    # ⚠ Le refus se DIT, avec sa raison. `edition_ecrire` rend 2 sur un nom
    # inconnu — le cas de loin le plus fréquent, une faute de frappe.
    if [[ "$rc" -eq 2 ]]; then
        _err_f "Édition inconnue : « %s »." "$demandee"
        _info "Les éditions sont : $(edition_connues)"
    else
        _err_f "L'édition n'a pas pu être écrite (code %s)." "$rc"
    fi
    return 1
}

# ════════════════════════════════════════════════════════════════
# _edition_garde_descente — jamais DEUX éditions sur une installation
#
# 🔴 LE TROU QU'ELLE FERME (SerVuS, 02/10/2026 : « si je veux changer sur
# l'offre Standard… faudrait pas que ça rentre en conflit avec l'offre
# actuelle »). `--monter-edition=` refusait déjà toute descente, mais une
# RÉINSTALLATION avec `--edition=standard` sur une installation « complete »
# — ou le Lanceur, dont le palier par défaut est « standard » — recopiait le
# site par-dessus puis réécrivait l'édition SANS comparer les rangs
# (`_edition_poser` : « --edition=X donné → on pose X »). Les réglages allumés
# par la Complète (OverSystem, Rencontres dans config.php) restaient allumés :
# un mélange des deux éditions, annoncé « Édition : standard ».
#
# Décision de SerVuS (02/10) : une descente se REFUSE ; pour changer vers le
# bas, on désinstalle d'abord (ce qui efface l'installation), puis on installe.
# La montée, elle, passe par `--monter-edition=` (rien n'est effacé).
#
# ⚠ Elle tourne AVANT toute écriture : au prévol (mode auto) et en tête de
#   `_site_install` (le chemin guidé) — c'est `_site_install` qui recopie les
#   fichiers. Rend 0 quand il n'y a rien à refuser : pas d'édition demandée,
#   pas d'installation existante, même palier ou palier plus haut.
# ════════════════════════════════════════════════════════════════
_edition_garde_descente() {
    local demandee="${EDITION_CHOISIE:-}"
    [[ -z "$demandee" ]] && return 0
    [[ -r "$BASE_DIR/Config/config.json" && -r "$BASE_DIR/Tools/edition.sh" ]] || return 0

    # Lue dans un sous-shell : sourcer `Tools/edition.sh` ici ne doit rien
    # laisser dans l'installateur (même redirection de racine que `_edition_poser`).
    local posee rang_posee rang_demandee
    read -r posee rang_posee rang_demandee < <(
        # shellcheck source=/dev/null
        source "$BASE_DIR/Tools/edition.sh" 2>/dev/null || exit 0
        _edition_racine() { printf '%s\n' "$BASE_DIR"; }
        p="$(edition_lire)"; i=0; rp=-1; rd=-1
        for e in $(edition_connues); do
            [[ "$e" == "$p" ]] && rp=$i
            [[ "$e" == "$demandee" ]] && rd=$i
            i=$((i + 1))
        done
        printf '%s %s %s\n' "$p" "$rp" "$rd"
    )
    # Édition inconnue : ce n'est pas une descente, `_edition_poser` le dira.
    [[ -n "$posee" && "${rang_posee:--1}" -ge 0 && "${rang_demandee:--1}" -ge 0 ]] || return 0
    (( rang_demandee < rang_posee )) || return 0

    _err_f "Descente refusée : cette installation est en « %s », « %s » est un palier plus bas." "$posee" "$demandee"
    _info "Deux éditions ne cohabitent pas sur une installation : rien n'a été écrit."
    _info "Pour passer en « $demandee » : désinstaller d'abord — cela EFFACE l'installation actuelle"
    _info "(bash $BASE_DIR/Tools/desinstaller.sh, à blanc par défaut ; les sauvegardes restent),"
    _info "puis installer avec --edition=$demandee. Pour MONTER, --monter-edition=NOM n'efface rien."
    return 1
}

# ── MONTER l'édition d'une install existante (point 90, étape 5) ─────────
#
# `_edition_poser` POSE une édition à l'installation (et ne redescend jamais) ;
# celle-ci MONTE une install déjà en service d'un palier au suivant, à la demande
# — la « phase 4 » d'EDITIONS.md, jusqu'ici non câblée (`edition_monter` existait
# sans appelant). Elle : refuse toute descente (edition_monter), pose le DELTA de
# fonctionnalités du palier atteint (drapeaux de config), puis DIT ce qui reste à
# provisionner. ⚠ Elle ne lance PAS la provision LOURDE de la modération IA
# (venv + modèles) sans qu'on le demande : elle l'annonce.
#
# ⚠ Pas de redémarrage d'IriX : le site relit `config.json`/`config.php` à chaque
# requête, et rien côté Tcl ne se gouverne encore par l'édition. Toucher à IriX
# ne ferait qu'ajouter le risque d'un HUP pour un effet nul.
_conf_bool() {   # fichier  NOM  true|false — même écriture que _site_install
    local f="$1" n="$2" v="$3"
    if grep -qE "const ${n}[[:space:]]*=" "$f" 2>/dev/null; then
        sed -i "s/const ${n}[[:space:]]*=[^;]*;/const ${n} = ${v};/" "$f"
    else
        printf 'const %s = %s;\n' "$n" "$v" >> "$f"
    fi
}

# ════════════════════════════════════════════════════════════════
# _oversystem_demander — le consentement de l'édition Complète
#
# 🔴🔴 IL Y A DEUX CHEMINS VERS L'ÉDITION COMPLÈTE, ET UN SEUL SUFFIRAIT À
# CONTOURNER LA QUESTION : l'installation neuve (`--edition=complete`) et la
# montée d'après coup (`--monter-edition=complete`). Poser la question dans le
# premier seulement aurait fait de la seconde une porte dérobée — et personne
# ne l'aurait su, parce qu'elle marche.
#
# 📌 C'est pourquoi cette fonction vit ici, au niveau global, et pas dans
# `_site_install` : ses DEUX appelants doivent pouvoir l'atteindre.
#
# ── 🔴🔴🔴 L'EXCEPTION AU MODE AUTO, et elle est délibérée ────────────────
#
# La doctrine du mode AUTO (en tête de ce fichier) dit : « une question de
# CONSENTEMENT → oui, quand la question porte sur ce que l'installation vient
# faire ». **Celle-ci n'y entre pas.** Ce qu'on accorde ici ne concerne pas
# cette machine : c'est le droit, pour un TIERS, de reconnaître cette
# installation d'une fois sur l'autre. Une installation sans personne devant
# l'écran n'a accordé rien du tout.
#
# Donc, et c'est le seul endroit du script où cela s'écrit ainsi :
#   · en interactif, le défaut est « O » — parce que refuser veut dire
#     CHANGER D'ÉDITION, ce qui est un choix et non un défaut ;
#   · en --auto, l'absence de OVERTCHAT_OVERSYSTEM vaut REFUS.
#
# Rend 0 si accordé, 1 si refusé (ou impossible à écrire).
# ════════════════════════════════════════════════════════════════

_oversystem_demander() {
    local conf="${1:-$BASE_DIR/Web/inc/config.php}" rep=""

    # ⚠⚠ ON N'ANNONCE JAMAIS UN ACCORD QU'ON NE PEUT PAS ÉCRIRE. Sans
    # `config.php`, le drapeau n'a nulle part où aller : poser la question
    # quand même ferait croire à une activation qui n'aurait pas lieu.
    if [[ ! -f "$conf" ]]; then
        _warn "Web/inc/config.php introuvable — OverSystem reste éteint."
        return 1
    fi

    # Déjà accordé : on ne redemande pas. Un consentement qu'on repose à
    # chaque relance de l'installateur finit par être une case qu'on coche
    # sans lire.
    if grep -qE "const OVERSYSTEM_ENABLED[[:space:]]*=[[:space:]]*true" "$conf" 2>/dev/null; then
        _info "OverSystem est déjà activé sur cette installation."
        return 0
    fi

    cat <<'PAVE'

  ┌─ Édition Complète — ce qu'elle demande en retour ──────────────┐

  Cette édition envoie des rapports techniques au site du projet.
  C'est ce qui permet de corriger ce qui casse chez vous.

  Ce qui part :
    · un identifiant tiré au hasard, propre à cette installation ;
    · la version installée et celle de vos composants (PHP, Node, OS) ;
    · les rapports d'incident que vous envoyez, nettoyés.

  Ce qui ne part JAMAIS :
    · aucun pseudonyme, aucune adresse IP, aucun message ;
    · aucun secret, aucun mot de passe ;
    · ni le nom de votre réseau, ni son domaine.

  ⚠ Avec cet identifiant, le site sait que c'est la MÊME installation
    qui le contacte d'une fois sur l'autre. Aujourd'hui, la vérification
    de mise à jour ne le sait pas.

  ⚠ Refuser n'est pas une faute : l'installation se poursuit en édition
    « standard ». C'est l'édition Complète qui demande cet accord.

  Vous pouvez l'arrêter à tout moment depuis le panel, et demander
  l'effacement de ce qui a été envoyé.

  └────────────────────────────────────────────────────────────────┘

PAVE

    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        # ⚠ On ne lit PAS l'entrée standard : un `read` sans personne devant
        #   rend une chaîne vide par EOF, c'est-à-dire une réponse fabriquée.
        case "${OVERTCHAT_OVERSYSTEM:-}" in
            1|o|O|oui|OUI|y|Y|yes|true) rep="o" ;;
            *)                          rep="n" ;;
        esac
        if [[ "$rep" == "n" ]]; then
            _info "auto : OVERTCHAT_OVERSYSTEM n'est pas posé — OverSystem reste éteint."
        else
            _info "auto : OverSystem activé par OVERTCHAT_OVERSYSTEM."
        fi
    elif [[ ! -t 0 ]]; then
        # ── 🔴🔴🔴 PERSONNE DEVANT L'ÉCRAN, SANS --auto POUR LE DIRE ──────
        #
        # Le cas : l'installateur lancé depuis un script, un `curl | bash`,
        # une unité systemd — sans `--auto`. Le `read` ci-dessous ne trouve
        # personne : il rend une chaîne vide par EOF. Et comme le défaut est
        # « O », cette chaîne vide vaudrait ACCORD.
        #
        # ⚠⚠ C'est exactement ce que ce fichier appelle une confirmation
        # mensongère : une réponse fabriquée que rien ne distingue d'une
        # vraie. Ici elle ferait accorder, au nom de quelqu'un qui n'a rien lu,
        # le droit d'être reconnu d'une fois sur l'autre.
        #
        # 📌 Le défaut « O » ne vaut QUE devant un humain. Sans terminal, la
        # réponse est non — et on dit pourquoi.
        #
        # ✅ (12/10/2026) SAUF une variable posée EXPRÈS : `OVERTCHAT_OVERSYSTEM=oui`
        # n'est pas une réponse fabriquée, c'est un accord écrit d'avance par
        # quelqu'un — la même liste fermée qu'en --auto. Mesuré ce jour en
        # conteneur (point 9) : la montée `--monter-edition=complete` sans
        # terminal conseillait « OVERTCHAT_OVERSYSTEM=oui … --auto », et
        # `--auto` est REFUSÉ pour une sous-commande — le conseil menait à un
        # refus, et aucune montée vers Complète n'était automatisable.
        case "${OVERTCHAT_OVERSYSTEM:-}" in
            1|o|O|oui|OUI|y|Y|yes|true) rep="o"
                _info "Pas de terminal : OverSystem activé par OVERTCHAT_OVERSYSTEM." ;;
            *)  rep="n"
                _info "Pas de terminal : personne n'a pu lire ce qui précède — OverSystem reste éteint."
                _info "Pour l'activer sans interaction : OVERTCHAT_OVERSYSTEM=oui (devant la commande)" ;;
        esac
    else
        read -rp "  Activer OverSystem pour l'édition Complète ? (O/n) : " rep || rep=""
    fi

    # Le défaut « O » : une réponse vide vaut oui. Il n'est vrai QU'ICI, et
    # seulement parce que le pavé ci-dessus a été lu.
    if [[ -z "$rep" || "$rep" =~ ^[oOyY] ]]; then
        _conf_bool "$conf" OVERSYSTEM_ENABLED true
        _ok "OverSystem activé. Vous pouvez l'arrêter depuis le panel, page « Signaler un problème »."
        return 0
    fi

    _conf_bool "$conf" OVERSYSTEM_ENABLED false
    _info "OverSystem refusé — rien ne partira de cette installation."
    return 1
}

# ════════════════════════════════════════════════════════════════
# _oversystem_exiger — l'installation NEUVE en Complète, et ce qu'elle vérifie
#
# 🔴 08/10/2026, décision de SerVuS : « le pack Complète ACTIVE OverSystem ».
#   Jusque-là `_site_install` appelait `_oversystem_demander … || true` : en
#   --auto sans OVERTCHAT_OVERSYSTEM (le Lanceur ne la posait JAMAIS), une
#   Complète s'installait OverSystem ÉTEINT, et la fin disait « terminée »,
#   code 0 — le contraire de ce que promet la page des offres.
#
# ⚠⚠ On juge sur ce qui est ÉCRIT dans config.php, pas sur le code rendu :
#   un accord qui ne s'est pas écrit (fichier absent, `sed` qui échoue) n'est
#   pas une activation. Toute autre issue pose OVERSYSTEM_RESERVE — une
#   phrase que `_auto_installer` reprend au bilan (code 3), au lieu d'une
#   ligne perdue dans le défilement.
# ⚠ La question elle-même ne change pas : l'absence de OVERTCHAT_OVERSYSTEM
#   vaut toujours REFUS en --auto (voir plus haut, c'est délibéré). Ce qui
#   change, c'est que ce refus SE DIT au bilan.
# Banc : Bootstrap/tests/test-reperes-progres.sh (§ OverSystem).
#
# 🔴 08/10/2026, seconde décision de SerVuS : un REFUS (dit à la question, ou
#   `--auto` sans OVERTCHAT_OVERSYSTEM) fait RETOMBER l'installation en édition
#   STANDARD — ce que le pavé de `_oversystem_demander` promet depuis toujours
#   (« Refuser n'est pas une faute : l'installation se poursuit en édition
#   « standard » »), et que le code ne faisait pas (il gardait « complete »).
#   La réserve (code 3) ne reste que pour ce qui a ÉCHOUÉ : un accord qui ne
#   s'est pas écrit, un site absent, une retombée en Standard impossible.
# Rend 0 si OverSystem est actif OU si l'édition est retombée en Standard ;
#   1 sinon (et OVERSYSTEM_RESERVE est posée).
# ════════════════════════════════════════════════════════════════
OVERSYSTEM_RESERVE=""
_oversystem_exiger() {
    local conf="${1:-$BASE_DIR/Web/inc/config.php}" accord=0 rc=0
    OVERSYSTEM_RESERVE=""
    _oversystem_demander "$conf" && accord=1
    if grep -qE "const OVERSYSTEM_ENABLED[[:space:]]*=[[:space:]]*true[[:space:]]*;" "$conf" 2>/dev/null; then
        return 0
    fi
    if [[ ! -f "$conf" ]]; then
        OVERSYSTEM_RESERVE="édition Complète, mais OverSystem n'a pas pu être activé : ${conf} introuvable — l'édition Complète l'exige ; relancez l'installation une fois le site posé"
    elif [[ "$accord" == "1" ]]; then
        # Accordé, mais rien d'écrit : ce n'est PAS un refus — on ne retombe pas.
        OVERSYSTEM_RESERVE="édition Complète, OverSystem accordé mais NON écrit dans ${conf} — relancez l'installation"
    else
        # Le refus : la promesse du pavé. `edition_ecrire` vient de Tools/edition.sh,
        # sourcé par `_edition_poser` (l'appelant l'a vérifié par `edition_lire`).
        if declare -f edition_ecrire >/dev/null 2>&1; then
            edition_ecrire standard || rc=$?
        else
            rc=127
        fi
        if [[ "$rc" -eq 0 ]]; then
            _info "OverSystem refusé : l'installation se poursuit en édition « standard », comme annoncé."
            _info "Pour passer en Complète plus tard : --monter-edition=complete (l'accord sera redemandé)."
            return 0
        fi
        OVERSYSTEM_RESERVE="OverSystem refusé, et la retombée en édition « standard » a ÉCHOUÉ (code ${rc}) — l'installation reste en Complète sans OverSystem ; vérifiez Config/config.json"
    fi
    _warn "$OVERSYSTEM_RESERVE"
    return 1
}

_edition_monter_cli() {
    local cible="${1:-}"

    if [[ -z "$cible" ]]; then
        _err "Précisez l'édition visée : --monter-edition=standard|complete|payante"; return 2
    fi
    if [[ ! -r "$BASE_DIR/Tools/edition.sh" ]]; then
        _err "Tools/edition.sh est absent — montée impossible."; return 1
    fi
    # shellcheck source=/dev/null
    source "$BASE_DIR/Tools/edition.sh"
    _edition_racine() { printf '%s\n' "$BASE_DIR"; }   # la racine = CETTE install

    # ── 🔴🔴 LE PALIER DÉJÀ DÉTENU SE REFUSE AVANT TOUTE QUESTION (29/09/2026) ──
    #
    # Défaut signalé par SerVuS le 29/09 au soir : « le forfait qu'on a déjà,
    # on peut le rechoisir ». `edition_monter` refusait bien le sur-place
    # (code 6) — mais APRÈS le consentement OverSystem ci-dessous. Lu dans le
    # code : `--monter-edition=complete` sur une install DÉJÀ complète posait
    # la question, et pouvait écrire OVERSYSTEM_ENABLED dans config.php pour un
    # geste qui ne montait rien ; en --auto, il annonçait même « montée
    # abandonnée » là où il n'y avait rien à monter.
    #
    # Le refus vient donc ICI, avant tout effet. ⚠ La règle elle-même reste
    # dans `edition_monter` (rang, pas égalité) : on l'interroge par le même
    # `edition_au_moins`, on ne la recopie pas. Une DESCENTE est refusée ici
    # aussi — elle l'était déjà, simplement après la question.
    # Banc : Bootstrap/tests/test-montee-edition.sh (le CLI EXÉCUTÉ, extrait).
    case " $(edition_connues) " in
        *" $cible "*)
            if edition_au_moins "$cible"; then
                _info_f "Déjà en « %s » (ou plus haut) — aucune montée à faire." "$(edition_lire)"
                return 0
            fi
            ;;
    esac

    # ── 🔴🔴 LE CONSENTEMENT SE DEMANDE AVANT DE MONTER, PAS APRÈS ──────
    #
    # Monter puis redescendre sur un refus laisserait l'installation passer
    # par un état « Complète sans accord », si bref soit-il — et un `set -e`
    # ou une coupure au mauvais moment l'y figerait. On demande d'abord.
    #
    # ⚠ Un refus n'est pas une erreur : l'exploitant a dit non, l'édition
    #   reste ce qu'elle était. On rend 0, parce que rien n'a échoué.
    case "$cible" in
        complete|payante)
            if ! _oversystem_demander "$BASE_DIR/Web/inc/config.php"; then
                _info_f "Montée vers « %s » abandonnée : elle exige OverSystem." "$cible"
                return 0
            fi
            ;;
    esac

    local avant rc
    avant="$(edition_lire)"
    edition_monter "$cible"; rc=$?
    case "$rc" in
        0) _ok_f "Édition montée : %s → %s." "$avant" "$cible" ;;
        6) _info_f "Déjà en « %s » (ou plus haut) — aucune montée à faire." "$avant"; return 0 ;;
        2) _err_f "Édition inconnue : « %s »." "$cible"
           _info "Les éditions sont : $(edition_connues)"; return 2 ;;
        *) _err_f "La montée a échoué (code %s)." "$rc"; return 1 ;;
    esac

    # ── Le DELTA de fonctionnalités du palier atteint ──────────────
    # Les tables et migrations sont posées pour tous (idempotentes) ; ce qui
    # gouverne l'affichage, ce sont l'édition (déjà écrite) ET les drapeaux de
    # config. On aligne donc les drapeaux sur le palier atteint.
    local conf="$BASE_DIR/Web/inc/config.php"
    if edition_au_moins complete; then
        if [[ -f "$conf" ]]; then
            _conf_bool "$conf" RENCONTRES_ENABLED true
            _conf_bool "$conf" WEBCAM_MICRO_ENABLED true
            _ok "Rencontres activées pour ce palier (RENCONTRES_ENABLED, WEBCAM_MICRO_ENABLED)."
            # Le salon du module, proposé par le chat — 02/10/2026.
            _webchat_ajouter_salon "$conf" '#rencontres' || true
        else
            _warn "Web/inc/config.php introuvable — drapeaux de rencontres non posés."
        fi
        # La modération IA demande une provision LOURDE : on ne la lance pas
        # d'office dans une montée, on dit quoi faire si elle manque.
        if [[ -f /etc/systemd/system/overtchat-nudite.service ]] \
           && systemctl is-enabled overtchat-nudite.service >/dev/null 2>&1; then
            _ok "Modération IA déjà installée et active."
        else
            _warn "Modération IA (webcam/voix) pas encore installée pour ce palier."
            _info "Pour la poser : bash $BASE_DIR/Tools/moderation-setup.sh $BASE_DIR"
            _info "  puis : systemctl enable --now overtchat-nudite overtchat-voix"
        fi
    fi

    _ok "Montée terminée. Le site prend la nouvelle édition à la prochaine requête."
    return 0
}

# -----------------------------------------------------------------------------
# _site_inscrire_salons <salon>
#   Inscrit au registre du réseau (<bot>_channels) le salon principal du chat
#   et #Help. 0 = inscrits · 1 = pas encore possible (dit, sortie recopiée).
#   Idempotent (`channel` porte un index UNIQUE — mesuré).
#
# ⚠ Sortie de `_site_install` le 29/09/2026 pour être RAPPELÉE par l'étape 6 de
#   `--auto` : à l'étape 2 d'une installation neuve, la table n'existe pas encore
#   (voir l'appelant).
# -----------------------------------------------------------------------------
SALONS_A_INSCRIRE=""

# -----------------------------------------------------------------------------
# _site_base_differee <chemin>
#   0 = l'étape 2 de `--auto` ne touche PAS la base : chemin OverCore, et aucun
#   IriX.json là où `Web/sql/apply.sh` le cherche (ses trois endroits). 1 sinon.
#
# 🔴 19/10/2026 — vu dans deux essais tiers (conteneur Ubuntu 24.04, chemin
#   OverCore) : l'étape 2 tentait migrations et salons AVANT que IriX.json
#   n'existe (il s'écrit à l'étape 4) — « ⚠ Migrations non appliquées … IriX.json
#   introuvable », « ⚠ Le salon #accueil n'a pas pu être inscrit » et un `PHP
#   Fatal error … [1045]`, alors que les étapes 5 et 6 les réussissaient. Trois
#   alertes pour un échec ATTENDU, qui apprennent au tiers à ne plus lire les ⚠.
# ⚠ OverCore et Anope (Anope ajouté le même jour : IriX.json y est aussi écrit
#   à l'étape 4, et les rejeux des étapes 5 et 6 ne dépendent d'aucun chemin) ;
#   l'ancien chemin Eggdrop garde son comportement (décision du lot), et une
#   reconfiguration — IriX.json déjà là — migre dès l'étape 2.
#   Gardé par Bootstrap/tests/test-auto.sh § 9 sexies.
# -----------------------------------------------------------------------------
_site_base_differee() {
    [[ "${1:-}" == overcore || "${1:-}" == anope ]] || return 1
    local _c
    for _c in "$(_irix_install_dir)/${IRIX_CMDS_SUBDIR}/IriX.json" \
              "${TARGET_HOME}/IriX/cmds/IriX.json" \
              "${BASE_DIR}/IriX/cmds/IriX.json"; do
        [[ -e "$_c" ]] && return 1
    done
    return 0
}

_site_inscrire_salons() {
    local salon="$1" _sal_sortie
    if ! command -v php >/dev/null 2>&1; then
        _warn "php introuvable : le salon n'a pas été inscrit au registre."
        return 1
    fi
    if _sal_sortie=$(php -r '
        require_once $argv[1] . "/Web/inc/config.php";
        require_once $argv[1] . "/Web/inc/db.php";
        $st = db()->prepare("INSERT INTO " . tbl("channels")
            . " (channel, topic, added_by) VALUES (?, ?, ?)"
            . " ON DUPLICATE KEY UPDATE channel = channel");
        $st->execute([$argv[2], "Salon principal du chat — vous arrivez ici en vous connectant.", "installateur"]);
        $st->execute([$argv[3], "Salon d aide — posez votre question ici, sans inscription.", "installateur"]);
        echo "ok";
    ' "$BASE_DIR" "$salon" '#Help' 2>&1); then
        _ok_f "Les salons %s et #Help sont inscrits au registre du réseau." "${salon}"
        # Le responsable de l'installation en devient propriétaire —
        # demandé par SerVuS le 25/09/2026. S'il n'a pas encore de
        # compte owner sur le site, l'outil se tait : les salons lui
        # seront donnés à la création de ce compte (panel-account.php).
        php "$BASE_DIR/Tools/salons-proprietaire.php" --auto 2>&1 | sed 's/^/   /' \
            || _info "salons-proprietaire.php : rien d'attribué (aucun compte owner encore)."
        # ⚠ 02/10/2026 : inscrire n'est pas OUVRIR — voir _site_ouvrir_salons.
        #   Un échec ici se dit, il ne défait pas l'inscription.
        _site_ouvrir_salons || true
        return 0
    fi
    # ⚠ On n'arrête PAS l'installation : le reste du site est utilisable. Mais
    #   on DIT ce qui manque — un chat qui refuse tout le monde sans
    #   explication est le pire des deux.
    _warn_f "Le salon %s n'a pas pu être inscrit au registre." "${salon}"
    printf '%s\n' "$_sal_sortie" | sed 's/^/      /' >&2
    return 1
}

# -----------------------------------------------------------------------------
# _site_ouvrir_salons
#   Ouvre sur le serveur les salons du registre : `Tools/salons-exceptions.sh
#   --appliquer` régénère la zone d'exceptions de `salons-interdits.conf`.
#   0 = ouverts · 1 = échec (dit, avec la commande à rejouer) · 2 = sans objet
#   (pas de conf Overtchat-Hybrid ici : un autre serveur IRC a sa propre règle).
#
# ── 🔴 LE TROU — relu le 02/10/2026 ──────────────────────────────────────
#
# Le commentaire de `_site_install` disait que les exceptions sont « dérivées
# du registre par Tools/salons-exceptions.sh » — et l'installateur ne
# l'appelait NULLE PART. Le modèle `salons-interdits.example` interdisant tout
# salon hors de ses exceptions écrites à la main, le salon principal choisi par
# l'exploitant était inscrit au registre, proposé par le chat… et refusé par le
# serveur (448) à chaque visiteur.
#
# ⚠ L'outil lit la base par IriX.json (IRIX_JSON) : chez un tiers, il n'y a ni
#   `~/.overtchat/secrets.conf` ni `set dbpass` dans irix.conf.
# ⚠ `--appliquer` recharge le serveur s'il tourne (SIGHUP : une politique
#   refusée laisse la précédente en service) ; arrêté, il la lira au démarrage.
# -----------------------------------------------------------------------------
SALONS_OUVERTS=0
_site_ouvrir_salons() {
    local outil="${BASE_DIR}/Tools/salons-exceptions.sh" cf="" c fichier ij sortie
    if [[ ! -f "$outil" ]]; then
        _warn "Tools/salons-exceptions.sh absent : les salons inscrits ne sont PAS ouverts sur le serveur."
        return 1
    fi
    for c in "$(_anope_srv_conf)" \
             "${TARGET_HOME:-}/overtchatd/conf/overtchat.conf" \
             "${BASE_DIR}/Servers/overtchat-hybrid/conf/overtchat.conf"; do
        if [[ -n "$c" && -r "$c" ]]; then cf="$c"; break; fi
    done
    if [[ -z "$cf" ]]; then
        _info "Pas de conf Overtchat-Hybrid lisible ici : les salons s'ouvrent dans la conf de votre serveur IRC."
        return 2
    fi
    fichier="$(dirname "$cf")/salons-interdits.conf"
    if [[ ! -f "$fichier" ]]; then
        _info_f "%s absent : aucune liste de salons interdits, rien à ouvrir." "$fichier"
        return 2
    fi
    ij="$(_irix_install_dir)/${IRIX_CMDS_SUBDIR}/IriX.json"
    if [[ ! -r "$ij" ]]; then
        _warn_f "IriX.json illisible (%s) : les salons inscrits ne sont PAS ouverts sur le serveur." "$ij"
        return 1
    fi
    if sortie=$(FICHIER="$fichier" IRIX_JSON="$ij" bash "$outil" --appliquer 2>&1); then
        printf '%s\n' "$sortie" | sed 's/^/   /'
        _ok "Les salons du registre sont ouverts sur le serveur (salons-interdits.conf)."
        SALONS_OUVERTS=1
        return 0
    fi
    _warn "Les salons inscrits ne sont PAS ouverts sur le serveur — ce que l'outil a dit :"
    printf '%s\n' "$sortie" | sed 's/^/      /' >&2
    _kv "À rejouer" "FICHIER=${fichier} IRIX_JSON=${ij} bash ${outil} --appliquer"
    return 1
}

# -----------------------------------------------------------------------------
# _webchat_ajouter_salon <config.php> <salon>
#   Ajoute <salon> à la fin de WEBCHAT_CHANS s'il n'y est pas (casse ignorée).
#   0 = présent ou ajouté · 1 = liste introuvable ou sur plusieurs lignes (dit).
#
# ⚠ 02/10/2026 : `_site_install` écrivait `WEBCHAT_CHANS = [salon, '#Help']`
#   même quand l'exploitant activait les Rencontres. `#rencontres` est semé au
#   registre par la migration 038 et ouvert par salons-interdits, mais le chat
#   ne le PROPOSAIT pas : le module était posé et son salon introuvable.
# ⚠ <salon> est un nom fixe écrit par l'appelant (pas une saisie) : il passe
#   tel quel dans l'expression de `sed`.
# -----------------------------------------------------------------------------
_webchat_ajouter_salon() {
    local f="$1" s="$2" ligne
    ligne=$(grep -m1 -E "^const WEBCHAT_CHANS[[:space:]]*=" "$f" 2>/dev/null) || ligne=""
    if [[ -z "$ligne" || "$ligne" != *"];"* ]]; then
        _warn_f "WEBCHAT_CHANS introuvable sur une ligne dans %s : %s n'y est pas ajouté." "$f" "$s"
        return 1
    fi
    if [[ "${ligne,,}" == *"'${s,,}'"* ]]; then
        return 0
    fi
    if [[ "$ligne" =~ \[[[:space:]]*\] ]]; then
        sed -i "s|^\(const WEBCHAT_CHANS[[:space:]]*=[[:space:]]*\)\[[[:space:]]*\];|\1['${s}'];|" "$f"
    else
        sed -i "s|^\(const WEBCHAT_CHANS[[:space:]]*=[[:space:]]*\[[^]]*[^][:space:]]\)[[:space:]]*\];|\1, '${s}'];|" "$f"
    fi
    if grep -qE "^const WEBCHAT_CHANS[[:space:]]*=.*'${s}'" "$f"; then
        _ok_f "Le chat propose aussi %s." "$s"
        return 0
    fi
    _warn_f "%s n'a pas pu être ajouté à WEBCHAT_CHANS (%s)." "$s" "$f"
    return 1
}

_site_install() {
    _section "Site et panel web"

    # ⚠ AVANT la copie de l'archive par-dessus `$BASE_DIR` : une descente
    #   d'édition se refuse quand rien n'est encore écrit (le menu guidé arrive
    #   ici sans passer par le prévol du mode auto).
    _edition_garde_descente || return 1

    local outil ext
    for outil in jq tar curl php; do
        command -v "$outil" >/dev/null 2>&1 || {
            _err "Outil requis absent : $outil"; return 1; }
    done

    # Les EXTENSIONS de PHP, et pas seulement son binaire. Vérifier « php »
    # ne prouve rien : php-cli s'installe seul, sans mbstring ni pdo_mysql, et
    # le site part alors en erreur fatale au premier mb_substr — c'est-à-dire
    # à la première page. Constaté en installant sur une Debian où seul
    # php-cli était présent : le site s'installe, puis meurt sur
    # « Call to undefined function mb_substr() ».
    local ext_manquantes=()
    for ext in mbstring pdo_mysql json; do
        php -m 2>/dev/null | grep -qix "$ext" || ext_manquantes+=("$ext")
    done
    if [[ ${#ext_manquantes[@]} -gt 0 ]]; then
        _err "Extensions PHP manquantes : ${ext_manquantes[*]}"
        _info "Le site ne fonctionnerait pas sans elles. Installe-les :"
        printf "    ${BOLD}%s${RESET}\n\n" \
            "$(_pkg_install_cmd "$(_pkg_name php-mbstring)" "$(_pkg_name php-mysql)")"
        if _priv_can_elevate; then
            local rep
            # ⚠ Troisième paramètre : « o » en AUTO. Le défaut interactif est
            # un refus prudent, mais poser le site sans ses extensions PHP le
            # rendrait inutilisable — et `--auto` est justement l'accord donné
            # d'avance pour ce que l'installation vient faire.
            rep=$(_site_ask "Les installer maintenant ? (o/N)" "N" "o")
            if [[ "$rep" =~ ^[oOyY] ]]; then
                _pkg_install php-mbstring php-mysql || {
                    _err "Installation échouée."; return 1; }
                _ok "Extensions installées."
            else
                return 1
            fi
        else
            return 1
        fi
    fi

    # ── GD : le site tourne sans lui, mais refuse toute photo — 03/10/2026 ──
    #
    # Mesuré sur une installation tierce (lot 9-A, Ubuntu 24.04, `--auto`) :
    # GD absent, `image_reencoder` (inc/image.php) refusait TOUTE photo de
    # profil. NON bloquant, à la différence de mbstring/pdo_mysql : aucune page
    # ne meurt sans lui. Mais il se DIT, et se propose — `--auto` l'a déjà posé
    # à l'étape 1 (`_req_for site`) ; ce contrôle couvre le menu guidé et une
    # pose d'étape 1 qui aurait échoué en silence. L'absence finale devient une
    # RÉSERVE du mode auto (`_auto_installer`, étape 2).
    if ! php -m 2>/dev/null | grep -qix gd; then
        _warn "Extension PHP « gd » absente : le site tournera, mais refusera toute photo de profil."
        if _priv_can_elevate; then
            local rep_gd
            rep_gd=$(_site_ask "L'installer maintenant ? (o/N)" "N" "o")
            if [[ "$rep_gd" =~ ^[oOyY] ]]; then
                if _pkg_install php-gd; then
                    _ok "Extension gd installée."
                else
                    _warn "Installation de gd échouée — le site continue, sans photo de profil."
                fi
            fi
        else
            printf "    ${BOLD}%s${RESET}\n\n" "$(_pkg_install_cmd "$(_pkg_name php-gd)")"
        fi
    fi

    local brutegit archive tmp
    brutegit=$(jq -r '.git.brutegit' "$INIT_BASE")
    [[ -n "$brutegit" && "$brutegit" != "null" ]] || {
        _err "URL de distribution (git.brutegit) absente de la configuration."; return 1; }

    # ── 1. Identité ──────────────────────────────────────────
    #
    # Le nom du réseau n'a aucune raison de reprendre celui du répertoire
    # d'installation : on installe dans « ~/MonReseau » un réseau qui s'annonce
    # « Les Copains du Dimanche ». Les deux sont demandés séparément parce
    # qu'ils s'adressent à deux publics — l'un à l'exploitant qui parcourt son
    # disque, l'autre aux visiteurs.
    printf "\n  ${DIM}Ces valeurs nomment VOTRE réseau, pour ses visiteurs. Elles ne touchent\n"
    printf "  ni au nom du logiciel, ni aux chemins d'installation — votre\n"
    printf "  installation s'appelle « %s » et peut garder ce nom.${RESET}\n\n" "$PROJET_NOM"

    local nom court domaine mail_from mail_to salon

    # Le nom public passe la même garde que le répertoire : se faire appeler
    # « Overtchat » sur la place publique tromperait davantage que d'installer
    # dans un dossier de ce nom.
    # ⚠⚠ LE PIÈGE DE CES TROIS BOUCLES EN MODE AUTO. Elles reposent la question
    # tant que la réponse ne convient pas — ce qui est juste devant un humain,
    # et ce qui BOUCLE SANS FIN quand personne ne répond : la réponse suivante
    # serait la même que la précédente. Un second tour en AUTO n'est donc pas
    # une nouvelle chance, c'est la preuve que la valeur fournie est refusée.
    # On s'arrête, et on dit laquelle.
    local tour=0
    while :; do
        tour=$(( tour + 1 ))
        _auto_boucle "$tour" "le nom du réseau" || return 1
        nom=$(_site_ask "Nom du réseau" "Mon réseau IRC" "${OVERTCHAT_RESEAU:-Mon réseau IRC}")
        [[ -z "$nom" ]] && { _warn "Un nom est nécessaire."; continue; }
        if _nom_est_reserve "$nom"; then
            _err_f "« %s » est le nom du réseau d'origine — il reste à son auteur." "${nom}"
            _info "Donnez au vôtre un nom qui vous appartient ; le logiciel garde le sien."
            continue
        fi
        break
    done

    tour=0
    while :; do
        tour=$(( tour + 1 ))
        _auto_boucle "$tour" "le nom court" || return 1
        court=$(_site_ask "Nom court (menus, groupes)" "MonReseau" "${OVERTCHAT_COURT:-MonReseau}")
        if _nom_est_reserve "$court"; then
            _err_f "« %s » est réservé, comme le nom complet." "${court}"
            continue
        fi
        break
    done

    # ── Le domaine, et ce qui s'y trouve déjà ────────────────
    #
    # Un domaine qui répond appartient à quelqu'un. Le signaler AVANT
    # l'installation évite le cas le plus pénible : un réseau monté, configuré,
    # dont les courriels partent et dont le certificat se demande — pour un nom
    # que l'on ne possède pas. On ne refuse pas pour autant : l'exploitant peut
    # très bien être en train d'installer derrière son propre site.
    tour=0
    while :; do
        tour=$(( tour + 1 ))
        _auto_boucle "$tour" "le domaine du site" || return 1
        domaine=$(_site_ask "Domaine du site" "irc.example.org" "${OVERTCHAT_DOMAINE:-irc.example.org}")
        [[ -z "$domaine" ]] && { _warn "Un domaine est nécessaire."; continue; }

        domaine="${domaine,,}"
        domaine="${domaine#http://}"; domaine="${domaine#https://}"
        domaine="${domaine%%/*}"

        if [[ ! "$domaine" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]]; then
            _err_f "Forme de domaine invalide : « %s »." "${domaine}"
            continue
        fi

        if [[ "$domaine" == *service-overtchat* || "$domaine" == *overtchat* ]]; then
            _err "Ce domaine est celui du réseau d'origine — il ne peut pas être repris."
            continue
        fi

        _info_f "Vérification de %s…" "${domaine}"
        if _domaine_pris "$domaine"; then
            _warn_f "Ce domaine est déjà utilisé : %s." "${DOMAINE_CONSTAT}"
            printf "  ${DIM}Si ce site est le vôtre, poursuivez. Sinon, choisissez un autre nom :\n"
            printf "  votre réseau enverrait des courriels et demanderait un certificat pour\n"
            printf "  un domaine que vous ne possédez pas.${RESET}\n"
            local rep
            # ⚠ « o » en AUTO : sans cela, ce refus renverrait au début de la
            # boucle, qui reposerait la même question — et `_auto_boucle`
            # arrêterait l'installation sur un domaine pourtant valide. Le
            # constat reste écrit à l'écran, il n'est pas tu.
            rep=$(_site_ask "Ce domaine est-il bien le vôtre ? (o/N)" "N" "o")
            [[ "$rep" =~ ^[oOyY] ]] || continue
        else
            _ok "Aucun site ne répond à ce nom."
            printf "  ${DIM}Ce constat ne dit pas que le domaine est ENREGISTRABLE : vérifiez-le\n"
            printf "  auprès d'un bureau d'enregistrement avant d'y bâtir quoi que ce soit.${RESET}\n"
        fi
        break
    done

    mail_from=$(_site_ask "Adresse d'expédition" "contact@${domaine}" "${OVERTCHAT_MAIL_FROM:-contact@${domaine}}")
    mail_to=$(_site_ask   "Adresse qui reçoit les notifications" "$mail_from" "${OVERTCHAT_MAIL_TO:-$mail_from}")

    # ── Le salon où l'on ARRIVE ──────────────────────────────
    #
    # ⚠⚠ DEMANDÉ PAR SerVuS LE 21/09/2026. Jusqu'ici, toute installation
    # héritait de `#accueil` et `#membres` — les salons du réseau d'ORIGINE,
    # écrits en dur dans `config.example.php`. *C'est le défaut de famille de
    # ce projet : une valeur qui a du sens ici et aucun ailleurs.*
    #
    # ⚠ Le défaut proposé est `#accueil` et NON `#!Overtchat!` : ce dernier est
    # le salon du réseau d'origine, et le nom lui appartient — même principe
    # que `_nom_est_reserve` pour le nom du réseau, quinze lignes plus haut.
    tour=0
    while :; do
        tour=$(( tour + 1 ))
        _auto_boucle "$tour" "le salon principal" || return 1
        salon=$(_site_ask "Salon principal du chat" "#accueil" "${OVERTCHAT_SALON:-#accueil}")
        [[ -z "$salon" ]] && { _warn "Un salon est nécessaire."; continue; }
        # ⚠ On garde la FORME, pas le goût : un nom de salon commence par « # »
        #   et ne porte ni espace, ni virgule, ni « : » — le serveur les refuse
        #   (`nick_autorise` / `banned-channels`) et le chat du site aussi
        #   (`ircd_salon_valide`). La limite de 32 caractères est celle du
        #   serveur, `IRCD_CHANNELLEN`.
        if [[ ! "$salon" =~ ^#[^[:space:],:]{1,31}$ ]]; then
            _err_f "« %s » n'est pas un nom de salon : il commence par « # », sans espace ni virgule, 32 caractères au plus." "${salon}"
            continue
        fi
        if [[ "${salon,,}" == *overtchat* ]]; then
            _err_f "« %s » porte le nom du réseau d'origine — donnez au vôtre un nom qui vous appartient." "${salon}"
            continue
        fi
        break
    done

    if [[ -z "$nom" || -z "$domaine" ]]; then
        _err "Nom et domaine sont indispensables — rien n'a été installé."
        return 1
    fi

    # ── 2. Archive ───────────────────────────────────────────
    tmp=$(mktemp -d)
    archive="${tmp}/site.tar.gz"
    _info "Téléchargement du site…"
    if ! curl -fsSL --retry 3 --retry-delay 2 "${brutegit}/site?nocache=$(date +%s)" -o "$archive"; then
        rm -rf "$tmp"; _err "Téléchargement du site impossible depuis $brutegit"; return 1
    fi

    # L'empreinte annoncée par le serveur est comparée à celle du fichier reçu.
    # Sans ce contrôle, une coupure en cours de transfert donnerait une archive
    # tronquée qui s'extrait à moitié — et un site à moitié installé ne se
    # distingue pas d'un site mal configuré.
    local attendu obtenu
    attendu=$(curl -fsSL "${brutegit}/manifeste" 2>/dev/null \
              | jq -r '.fichiers.site.sha256 // empty' 2>/dev/null || true)
    obtenu=$(sha256sum "$archive" | cut -d' ' -f1)
    if [[ -n "$attendu" && "$attendu" != "$obtenu" ]]; then
        rm -rf "$tmp"
        _err "Empreinte incorrecte — archive refusée."
        _kv "Attendue" "$attendu"; _kv "Obtenue" "$obtenu"
        return 1
    fi

    # Manifeste injoignable : on ARRÊTE, sauf accord explicite.
    #
    # L'avertissement seul ne suffisait pas. Un portail captif, un proxy
    # d'entreprise ou une simple indisponibilité du site vidaient `$attendu`,
    # le test d'empreinte était donc sauté, et l'archive — quelle qu'elle soit —
    # s'extrayait par-dessus l'installation. Ce n'est pas un détail de forme :
    # l'archive est dépaquetée sur $BASE_DIR, elle peut donc réécrire
    # Bootstrap/overtchat-setup.sh et Config/*.json.
    if [[ -z "$attendu" ]]; then
        _warn "Manifeste indisponible — l'empreinte de l'archive N'A PAS été vérifiée."
        _kv "Empreinte reçue" "$obtenu"
        local rep_sha=""
        # ⚠⚠ LA SEULE QUESTION DE CONSENTEMENT À LAQUELLE `--auto` RÉPOND NON.
        # Les autres portent sur ce que l'installation vient faire ; celle-ci
        # demande de passer outre une vérification d'intégrité qui n'a pas pu
        # avoir lieu. Un accord donné d'avance, par un exploitant absent, sur
        # une archive dont l'empreinte est inconnue et qui s'extrait par-dessus
        # $BASE_DIR — c'est exactement ce que cette garde existe pour empêcher.
        if [[ "${MODE_AUTO:-0}" == "1" ]]; then
            _err "auto : archive non vérifiée — refusée. Relancez sans --auto pour décider."
            rep_sha="N"
        else
            read -rp "  Installer quand même cette archive non vérifiée ? [o/N] : " rep_sha || true
        fi
        if [[ ! "$rep_sha" =~ ^[oOyY]$ ]]; then
            rm -rf "$tmp"
            _info "Installation abandonnée — aucune modification."
            return 1
        fi
        _warn "Poursuite sur accord explicite, sans garantie d'intégrité."
    else
        _ok "Empreinte vérifiée."
    fi

    # ── 3. Extraction ────────────────────────────────────────
    #
    # Les chemins sont inspectés AVANT d'extraire. `tar` suit ce que l'archive
    # lui dit d'écrire : une entrée « ../ » ou un chemin absolu sortirait de
    # $BASE_DIR, et un lien symbolique pointant hors de l'arborescence servirait
    # de passerelle pour y écrire ensuite.
    local suspect
    suspect=$(tar tzf "$archive" 2>/dev/null | grep -m1 -E '^/|(^|/)\.\./' || true)
    if [[ -n "$suspect" ]]; then
        rm -rf "$tmp"
        _err "Archive refusée — elle contient un chemin hors de l'arborescence :"
        printf "    ${RED}%s${RESET}\n" "$suspect"
        return 1
    fi

    mkdir -p "$BASE_DIR"
    if ! tar xzf "$archive" -C "$BASE_DIR"; then
        rm -rf "$tmp"; _err "Extraction impossible."; return 1
    fi
    rm -rf "$tmp"

    # L'extraction a-t-elle produit ce qu'on attendait ? Une archive dont la
    # racine n'est pas « Web/ » s'extrayait sans erreur, et toute la suite —
    # config.php, migrations, rapport final — se déroulait en silence sur un
    # dossier inexistant, pour finir sur un « Site installé » complet.
    if [[ ! -d "${BASE_DIR}/Web" ]]; then
        _err_f "Archive extraite, mais %s/Web est absent." "${BASE_DIR}"
        _info "L'archive ne contient pas l'arborescence attendue — rien n'a été configuré."
        return 1
    fi
    _ok_f "Site extrait dans %s/Web" "$BASE_DIR"

    # ── 4. Identité et configuration ─────────────────────────
    mkdir -p "${BASE_DIR}/Config"
    _site_write_identity "${BASE_DIR}/Config/identity.json" \
        "$nom" "$court" "$domaine" "$mail_from" "$mail_to" || return 1

    # ⚠ L'édition se pose ICI : l'archive est extraite, donc `Tools/edition.sh`
    # et le modèle de configuration existent. Plus tôt, il n'y aurait rien à
    # lire ; plus tard, les composants seraient déjà posés sans elle.
    #
    # ⚠⚠ Un refus n'interrompt PAS l'installation. Une édition non posée laisse
    # les lecteurs sur « beta » — l'état le plus fermé, donc sûr —, alors
    # qu'abandonner ici laisserait un site à moitié installé.
    _edition_poser || true

    local conf="${BASE_DIR}/Web/inc/config.php"
    if [[ -f "$conf" ]]; then
        _info "config.php existe déjà — conservé tel quel."
    elif [[ -f "${conf%.php}.example.php" ]]; then
        cp "${conf%.php}.example.php" "$conf"
        _ok "config.php créé depuis l'exemple."
        _config_chemins_ircd "$conf" || true
        _warn "À relire : identifiants MySQL et réglages du chat."
    fi

    # ── 5. Base du site ──────────────────────────────────────
    # 19/10/2026 : `--auto`, chemin OverCore, installation neuve — la base se
    #   joint à l'étape 5, après IriX.json (`_site_base_differee`) : on le DIT
    #   comme une étape, au lieu d'un échec attendu affiché en ⚠.
    if [[ "${_SITE_BASE_DIFFEREE:-0}" == 1 ]]; then
        _info "Migrations du site : appliquées à l'étape 5, une fois l'accès à la base (IriX.json) écrit."
    elif [[ -x "${BASE_DIR}/Web/sql/apply.sh" ]]; then
        _info "Application des migrations du site…"
        # ⚠⚠ LA SORTIE EST GARDÉE, PAS JETÉE.
        #
        # Elle partait en `>/dev/null 2>&1`. L'échec était bien détecté — le
        # `if` teste le code de sortie —, mais le MOTIF disparaissait : or
        # `apply.sh` dit précisément QUELLE migration a échoué et recopie
        # l'erreur MySQL. On perdait la seule information utile, et il fallait
        # tout relancer à la main pour la retrouver.
        #
        # 📌 C'est le même travers que celui corrigé au point 51 pour
        # `bancs.sh`. Il subsistait ici, sur le chemin d'une INSTALLATION
        # NEUVE — là où personne n'est encore là pour relancer quoi que ce soit.
        #
        # ⚠ En cas de succès on ne montre rien : `apply.sh` détaille les 21
        # migrations et les tables créées, ce qui noierait la fin de
        # l'installation. La sortie n'apparaît QUE si elle sert à quelque chose.
        local _sql_sortie
        if _sql_sortie="$("${BASE_DIR}/Web/sql/apply.sh" 2>&1)"; then
            _ok "Migrations appliquées."
        else
            _warn "Migrations non appliquées — voici ce que l'applicateur a dit :"
            printf '%s\n' "$_sql_sortie" | sed 's/^/      /' >&2
            _warn "À relancer après configuration de MySQL :"
            _kv "Commande" "${BASE_DIR}/Web/sql/apply.sh"
            # ⚠ Relancer est SÛR : les 21 migrations sont rejouables
            # (`CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`,
            # `INSERT … ON DUPLICATE`). C'est mesuré et gardé par
            # `Bootstrap/tests/test-migrations.sh` — sans quoi cette phrase
            # deviendrait fausse à la première migration non idempotente.
        fi
    fi

    # ── 5bis. Chat de rencontre + webcam/micro — module OPTIONNEL ────
    #
    # Demandé par SerVuS : l'exploitant choisit s'il veut, EN PLUS, une partie
    # « chat de rencontre » (profils), et si oui, AVEC ou SANS gestion
    # webcam/micro (visio, photo caméra, clip vocal + modération locale). Les
    # deux drapeaux vivent dans config.php ; l'exemple les met à `true`, on ne
    # fait que les ABAISSER selon le refus. En --auto la réponse vient de
    # $OVERTCHAT_RENCONTRES / $OVERTCHAT_WEBCAM (défaut : non — un déploiement
    # automatisé n'installe pas les modèles lourds sans qu'on le demande).
    #
    # ⚠ L'activation des unités est GARDÉE par la présence du fichier d'unité :
    # en --auto elles sont posées à l'étape 6 (après ici), donc si elles ne sont
    # pas encore là on l'indique au lieu d'échouer — motif « ordre » de la
    # règle 5 (point 76).
    _regler_bool() {   # fichier  NOM  true|false
        local f="$1" n="$2" v="$3"
        if grep -qE "const ${n}[[:space:]]*=" "$f" 2>/dev/null; then
            sed -i "s/const ${n}[[:space:]]*=[^;]*;/const ${n} = ${v};/" "$f"
        else
            printf 'const %s = %s;\n' "$n" "$v" >> "$f"
        fi
    }

    # ── 5bis-a. LE SALON CHOISI ENTRE DANS LE SITE ──────────────────
    #
    # Deux gestes, et il faut les DEUX : le chat doit le PROPOSER, et le réseau
    # doit l'AUTORISER. L'un sans l'autre donne un chat qui envoie tout le
    # monde contre un refus.
    #
    # 🔴 `WEBCHAT_CHANS` est la liste des salons que le chat PROPOSE : son
    # formulaire d'entrée refuse tout salon qui n'y figure pas — « Salon
    # inconnu ». Le PREMIER de la liste est celui où l'on arrive.
    # ⚠ 02/10/2026 : ce commentaire ajoutait « y compris pour un `/join` tapé à
    #   la main » — FAUX, relu dans Web/public/assets/chat.js : le `/join` n'y
    #   est contrôlé que dans sa forme, puis part au serveur, seul juge
    #   (salons-interdits.conf). La liste n'est donc pas fermée ; la fermer est
    #   une décision en attente, le chat n'est pas changé.
    #
    # ⚠ `#Help` y est joint, et ce n'est pas décoratif : les sujets des salons
    # conseillent « /join #Help », et sans lui ce conseil ne marche pas. Mesuré
    # sur l'installation d'origine le 21/09/2026.
    _regler_liste() {   # fichier  NOM  contenu-entre-crochets
        local f="$1" n="$2" v="$3"
        if grep -qE "const ${n}[[:space:]]*=" "$f" 2>/dev/null; then
            # ⚠ Le séparateur est « | » : un nom de salon peut porter « / ».
            sed -i "s|const ${n}[[:space:]]*=[^;]*;|const ${n} = [${v}];|" "$f"
        else
            printf 'const %s = [%s];\n' "$n" "$v" >> "$f"
        fi
    }
    if [[ -f "$conf" && -n "${salon:-}" ]]; then
        _regler_liste "$conf" WEBCHAT_CHANS   "'${salon}', '#Help'"
        # ⚠ Aucun salon réservé aux identifiés au départ : le modèle en posait
        #   un (`#membres`), qui n'existe pas forcément chez l'exploitant. Un
        #   sous-ensemble qui désigne un salon absent de la liste fait rougir
        #   `Web/tests/test-webchat-salons.php`.
        _regler_liste "$conf" WEBCHAT_CHANS_R ""
        _ok_f "Le chat ouvrira sur %s." "${salon}"

        # ── Le salon doit EXISTER, pas seulement être proposé ────────
        #
        # 🔴🔴 Tout salon absent du registre est INTERDIT sur ce réseau : le
        # serveur n'ouvre que les exceptions de `salons-interdits.conf`, et
        # celles-ci sont dérivées du registre par `Tools/salons-exceptions.sh`.
        # ⚠ 02/10/2026 : que l'installateur n'appelait pas — c'est désormais
        #   `_site_inscrire_salons` qui le fait (`_site_ouvrir_salons`), et
        #   l'étape 6 de `--auto` le rejoue une fois le serveur posé.
        # Proposer au chat un salon qui n'y est pas revient à envoyer chaque
        # visiteur contre un refus, sans qu'aucun message ne l'explique.
        #
        # ⚠ On passe par PHP et non par `mysql` : les identifiants vivent dans
        # config.php, et les redemander ici serait une SECONDE source de vérité.
        # ⚠⚠ L'insertion est idempotente — `channel` porte un index UNIQUE
        # (mesuré, pas supposé) — donc relancer l'installateur ne crée pas de
        # doublon. Même principe que les migrations, rejouables par contrat.
        # 🔴🔴 LES DEUX SALONS DE LA LISTE, PAS SEULEMENT LE PREMIER — corrigé
        # le 21/09/2026, une heure après avoir écrit le contraire.
        #
        # Le premier jet n'inscrivait que le salon choisi et ajoutait `#Help` à
        # `WEBCHAT_CHANS` sans l'inscrire. Or **AUCUNE migration ne crée
        # `#Help`** : mesuré, seul `#rencontres` est semé (migration 038), et
        # les autres salons se créent par IriX (`!regchan`), par le panel ou à
        # la main. Chez une installation neuve, `#Help` aurait donc été proposé
        # au chat, refusé par le serveur, et `test-webchat-salons.php` aurait
        # rougi — *un salon proposé qui n'existe pas est un visiteur qui se
        # cogne à une porte fermée, sans un mot d'explication.*
        #
        # ⚠ La règle qui évite la rechute : **ce qu'on met dans `WEBCHAT_CHANS`,
        # on l'inscrit au registre — la liste et les inscriptions se lisent au
        # MÊME endroit.**
        # ⚠⚠ 29/09/2026 — sur une installation NEUVE, ce geste ne peut PAS
        #   réussir ici : la table des salons est une table du BOT (créée à son
        #   premier démarrage), et `IriX.json`, qui porte les identifiants de la
        #   base, n'est écrit qu'à l'étape 5. Mesuré sur une installation
        #   tierce : « IriX.json introuvable — identifiants de repli », puis
        #   « Access denied ». Le remède affiché (« apply.sh puis réinstaller »)
        #   ne pouvait rien. En `--auto`, l'étape 6 le REFAIT une fois IriX
        #   lancé et ses tables créées (`SALONS_A_INSCRIRE`).
        # 🔴 17/10/2026 (lot J2, essai réel en conteneur, chemin OverCore) : le
        #   même échec, pour la raison qui reste — IriX.json n'existe pas encore
        #   (il s'écrit à l'étape 4, sur les trois chemins ; la table, elle, naît
        #   du socle aux migrations). `--auto` rejoue les migrations à l'étape 5
        #   et l'inscription à l'étape 6, sur `migr_ok` (Bootstrap/tests/test-auto.sh
        #   § 9 quinquies épingle cet ordre).
        # 19/10/2026 : sur ce chemin, l'étape 2 ne TENTE plus (voir
        #   `_site_base_differee`) — le salon est mis de côté, l'étape 6 l'inscrit.
        if [[ "${_SITE_BASE_DIFFEREE:-0}" == 1 ]]; then
            SALONS_A_INSCRIRE="$salon"
            _info_f "Salons %s et #Help : inscrits à l'étape 6, après les migrations." "${salon}"
        elif ! _site_inscrire_salons "$salon"; then
            SALONS_A_INSCRIRE="$salon"
            _info "Normal sur une installation neuve : l'accès à la base (IriX.json) n'est pas encore écrit."
            _kv "À relancer" "une fois la base jointe (--auto le refait seul à l'étape 6, après les migrations)"
        fi
    fi
    # ── 5bis-0. OverSystem — SEULEMENT si l'édition posée l'exige ────
    #
    # ⚠ La question n'a de sens qu'en édition Complète : la poser aux autres
    #   serait demander un accord dont rien ne dépend. Le modèle pose « beta »,
    #   donc une installation neuve sans `--edition=` ne la voit jamais.
    #
    # ⚠⚠ `edition_lire` vient de `Tools/edition.sh`, sourcé par
    #    `_edition_poser` juste avant. Si celle-ci a échoué tôt, la fonction
    #    n'existe pas — et sous `set -u` l'appeler tuerait l'installation au
    #    milieu. On vérifie donc qu'elle est là avant de s'en servir.
    # ⚠⚠ 08/10/2026 : `_oversystem_exiger` et non plus `_oversystem_demander
    #    … || true` — un refus ou un échec d'écriture en Complète devient une
    #    RÉSERVE du bilan (code 3), au lieu d'une Complète éteinte « réussie ».
    if declare -f edition_lire >/dev/null 2>&1; then
        case "$(edition_lire)" in
            complete|payante) _oversystem_exiger "$conf" || true ;;
        esac
    fi

    if [[ -f "$conf" ]]; then
        local rep_renc rep_cam ia_ok
        rep_renc=$(_site_ask "Proposer un chat de rencontre (profils) sur le site ? (o/N)" "N" "${OVERTCHAT_RENCONTRES:-N}")
        if [[ "$rep_renc" =~ ^[oOyY] ]]; then
            _regler_bool "$conf" RENCONTRES_ENABLED true
            # ⚠ 02/10/2026 : et le chat PROPOSE son salon — voir
            #   _webchat_ajouter_salon (WEBCHAT_CHANS l'oubliait).
            _webchat_ajouter_salon "$conf" '#rencontres' || true

            # ── Le FOND DE CARTE ────────────────────────────────────────
            #
            # 🔴 Personne n'appelait `Tools/leaflet-vendor-fetch.sh` : le dossier
            # `Web/public/assets/vendor/` est gitignoré, donc sur une install
            # NEUVE la carte des membres (?p=carte) s'ouvrait sans fond, sans
            # que rien ne le dise. Corrigé le 13/09/2026. Le script est
            # idempotent (il ne retélécharge que ce qui manque) et sans effet
            # de bord : on l'appelle dès que les Rencontres sont posées.
            # ⚠ Un échec ne doit pas arrêter l'installation — la carte est une
            #   surface parmi d'autres, et `set -e` ferait tout tomber.
            if [[ -x "${BASE_DIR}/Tools/leaflet-vendor-fetch.sh" || -f "${BASE_DIR}/Tools/leaflet-vendor-fetch.sh" ]]; then
                _info "Fond de carte (Leaflet, auto-hébergé)…"
                if bash "${BASE_DIR}/Tools/leaflet-vendor-fetch.sh" >/dev/null 2>&1; then
                    _ok "Leaflet en place — la carte des membres a son fond."
                else
                    _warn "Leaflet non téléchargé — la carte (?p=carte) restera sans fond. À rejouer : bash Tools/leaflet-vendor-fetch.sh"
                fi
            fi
            # Le FOND lui-même, auto-hébergé depuis le 23/09/2026 (point 90) :
            # ~355 Mo de tuiles PMTiles (OpenStreetMap), extraites du build
            # Protomaps du jour. Remplace les tuiles CARTO (clé exigée, IP du
            # visiteur envoyée chez un tiers). Même règle : un échec n'arrête rien.
            if [[ -f "${BASE_DIR}/Tools/carte-tuiles-fetch.sh" ]]; then
                _info "Tuiles de la carte (~355 Mo, OpenStreetMap)…"
                if bash "${BASE_DIR}/Tools/carte-tuiles-fetch.sh" >/dev/null 2>&1; then
                    _ok "Tuiles en place — la carte ne dépend d'aucun service tiers."
                else
                    _warn "Tuiles non posées — la carte s'affichera sans fond. À rejouer : bash Tools/carte-tuiles-fetch.sh"
                fi
            fi

            # ── Ce qui PÈSE : la modération IA locale ───────────────────
            #
            # Demandé par SerVuS le 13/09/2026 : ne proposer le lourd que si la
            # machine peut le porter. La question webcam/micro entraîne le venv
            # Python et le modèle Whisper « small » — c'est ELLE qu'on garde,
            # pas la carte (voir `_capacite_ia`, qui dit pourquoi).
            #
            # ⚠ On ne refuse pas en silence : la doctrine des éditions veut
            #   qu'« une fonction éteinte se DISE, elle ne se cache pas »
            #   (Markdown/EDITIONS.md §8). On affiche donc le motif mesuré.
            # ⚠ Et on laisse une porte : OVERTCHAT_IA_FORCER=1 pose la question
            #   quand même, pour qui sait ce qu'il fait (machine qui grossira,
            #   disque monté après coup…).
            ia_ok=0
            if _capacite_ia; then
                ia_ok=1
            elif [[ "${OVERTCHAT_IA_FORCER:-0}" == "1" ]]; then
                ia_ok=1
                _warn "Machine sous les seuils ($IA_MOTIF) — proposée quand même : OVERTCHAT_IA_FORCER=1."
            fi

            if [[ "$ia_ok" -eq 0 ]]; then
                rep_cam="N"
                _warn "Webcam/micro NON proposés — la machine est sous les seuils : $IA_MOTIF."
                _info "Mode minimal : profils, photo par fichier et carte des membres restent ; pas de visio, pas de voix, pas de caméra ni de radar dans le chat, pas de modération IA locale."
                _info "Pour l'activer plus tard sur une machine plus grande : relancer ce module, ou OVERTCHAT_IA_FORCER=1 pour passer outre."
            else
                rep_cam=$(_site_ask "  … avec webcam et micro (visio, photo, voix) ? (o/N)" "N" "${OVERTCHAT_WEBCAM:-N}")
            fi

            if [[ "$rep_cam" =~ ^[oOyY] ]]; then
                _regler_bool "$conf" WEBCAM_MICRO_ENABLED true
                # 🔴 Ce drapeau n'était écrit par AUCUN code — il n'existait
                # qu'à la main dans le config.php de prod, si bien qu'une
                # install neuve n'avait jamais de caméra ni de radar dans le
                # chat, sans que rien ne l'explique. Posé ici depuis le
                # 13/09/2026.
                # ⚠ Il suit la webcam, et JAMAIS l'inverse : pas de caméra dans
                #   le chat sans modération locale (même doctrine fail-closed
                #   que le reste). Il emporte aussi le radar géo, qui n'aurait
                #   pourtant pas besoin d'IA — les deux partagent ce seul
                #   drapeau ; les séparer demanderait un second drapeau, et
                #   `Web/tests/test-radar-client.php` comme `test-cam-client.php`
                #   en exigent la formule littérale dans chat.php.
                _regler_bool "$conf" CHAT_CAM_RADAR_ENABLED true
                _pkg_install python-venv python-pip || \
                    _warn "venv/pip Python manquants — la modération échouera sans eux."
                _info "Mise en place de la modération locale (venv + modèles, quelques minutes)…"
                if bash "${BASE_DIR}/Tools/moderation-setup.sh" "$BASE_DIR"; then
                    if [[ -f /etc/systemd/system/overtchat-nudite.service ]]; then
                        _as_root systemctl enable --now overtchat-nudite.service overtchat-voix.service \
                            && _ok "Modération webcam/voix active." \
                            || _warn "Unités non activées — à faire : systemctl enable --now overtchat-nudite overtchat-voix"
                    else
                        _warn "Unités systemd pas encore posées ; après leur pose : systemctl enable --now overtchat-nudite overtchat-voix"
                    fi
                else
                    _warn "Modération locale non installée — la webcam/voix REFUSERA les envois (fail-closed) tant qu'elle manque."
                fi
            else
                _regler_bool "$conf" WEBCAM_MICRO_ENABLED false
                _regler_bool "$conf" CHAT_CAM_RADAR_ENABLED false
                _info "Rencontres SANS webcam/micro : profils (photo par fichier, bio, âge) et carte des membres ; sans visio, voix, caméra ni radar dans le chat."
            fi
        else
            _regler_bool "$conf" RENCONTRES_ENABLED false
            _regler_bool "$conf" WEBCAM_MICRO_ENABLED false
            _regler_bool "$conf" CHAT_CAM_RADAR_ENABLED false
            _info "Chat de rencontre non installé."
        fi
    fi

    # ── 6. Relais de courriel ────────────────────────────────
    #
    # À la fin, et non au début : le site est installé quoi qu'il advienne du
    # courrier. Un relais mal réglé ne doit pas faire échouer une installation
    # par ailleurs correcte — il rend le formulaire de contact muet, ce que
    # l'onglet Courriel montre et permet de corriger après coup.
    _postfix_configure "$domaine" "$mail_from" "$mail_to" || \
        _warn "Courriel non configuré — le site fonctionne, mais n'enverra rien."

    # ── 7. Ce qu'il reste à faire, dit explicitement ─────────
    _section "Site installé"
    _kv "Réseau"  "$nom"
    _kv "Domaine" "$domaine"
    _kv "Racine"  "${BASE_DIR}/Web"
    printf "\n  Démarrage :  ${BOLD}%s/Web/start.sh start${RESET}\n" "$BASE_DIR"
    printf "  Puis :       ${BOLD}php %s/Tools/panel-account.php create${RESET}\n" "$BASE_DIR"
    printf "  ${DIM}Le panel n'a aucun compte tant que celui-ci n'est pas créé.${RESET}\n\n"

    _log_entry "site" "install" "Site installé pour $nom ($domaine)"
    return 0
}

_main_menu() {
    while true; do
        # Les segments étaient concaténés sans séparateur ("InstalléServices
        # actifs : 0Serveurs installés : 0"). Et « Installé » ne reflétait que
        # la création des dossiers, pas l'état des composants — trompeur sur
        # une arborescence vide.
        _st_reconcile
        local n_srv n_svc
        n_srv=$(_st_count_servers)
        n_svc=$(_st_count_services)

        _ui_width
        clear 2>/dev/null || true
        _ui_title "Service-Overtchat — Panel de gestion" \
                  "v${VERSION} · ${PRIV_USER:-?}@$(hostname 2>/dev/null || echo '?') · ${BASE_DIR}"

        _ui_top
        _ui_kv "Arborescence" "$([[ "$(_jq_read '.install' "$INIT_BASE" "false")" == "true" ]] \
                                  && _ui_badge ok 'prête' || _ui_badge off 'absente')"
        _ui_kv "Serveurs IRC"  "$(_ui_bar "$n_srv" 4)"
        _ui_kv "Services"      "$(_ui_bar "$n_svc" 4)"
        _ui_kv "Privilèges"    "$(_priv_label)"
        [[ -n "${CURRENT_USER:-}" ]] && \
            _ui_kv "Session" "${CURRENT_USER} ${DIM}[${CURRENT_GRADE:-?}]${RESET}"

        # Les panels Services et Serveurs restent TOUJOURS accessibles : ce
        # sont eux qui permettent d'installer. Les verrouiller sur « aucun
        # service installé » créait un blocage circulaire — impossible d'ouvrir
        # le panel où l'on installe Eggdrop tant qu'Eggdrop n'était pas
        # installé. Le verrouillage fin (IriX exige Eggdrop, Anope exige un
        # serveur IRC) est appliqué à l'intérieur de chaque panel.
        _menu_reset
        # Les entrées sont GROUPÉES par thème, mais leur NUMÉROTATION est
        # inchangée. Les deux décisions sont indépendantes : regrouper aide
        # celui qui découvre le panel, renuméroter trahirait celui qui s'en
        # sert déjà — la numérotation est ce qu'on retient par la main.
        _ui_head "Installation"
        _menu_add  1 "Assistant d'installation  ${DIM}(guidé, étape par étape)${RESET}"
        _menu_add  2 "Dépendances système       ${DIM}(scan et installation)${RESET}"
        _menu_add 12 "Site et panel web         ${DIM}(identité du réseau)${RESET}"

        _ui_head "Exploitation"
        _menu_add  3 "Panel Services   ${DIM}Eggdrop / IriX / Anope / Lea${RESET}"
        _menu_add  4 "Panel Serveurs   ${DIM}UnrealIRCd / Hybrid / ircu / Overtchat-ircd${RESET}"
        _menu_add  6 "Access Manager   ${DIM}utilisateurs, grades, Eggdrop${RESET}"

        _ui_head "Diagnostic"
        _menu_add  5 "Panel Logs       ${DIM}vue centralisée, bugs, rapports${RESET}"
        _menu_add  7 "Rapport d'état système"
        _menu_add  9 "Vérifier l'intégrité SHA256"
        # ⚠ Le pendant assumé du refus. Depuis que `_sha256_verify` n'enregistre
        # plus toute seule (point 39), il faut un geste EXPLICITE pour poser les
        # références — sinon, après une migration, plus rien n'est vérifiable et
        # l'exploitant n'a aucun moyen de repartir.
        # ⚠ 13 et non 11 : les numéros 1 à 12 sont tous pris, et 11 porte déjà
        # « Redémarrer le panel ». Un doublon ici ne casse rien à la lecture,
        # mais le `case` plus bas n'aurait retenu que la première branche.
        _menu_add 13 "Poser les empreintes de référence ${DIM}(acte délibéré)${RESET}"

        _ui_head "Maintenance"
        _menu_add  8 "Mise à jour des fichiers"
        _menu_add 10 "Réinitialiser la configuration"
        _menu_add 11 "Redémarrer le panel"
        _menu_add  0 "Quitter"
        _ui_bot

        _ui_prompt
        read -r choice || { printf "\n${CYAN}Au revoir. Session : %s${RESET}\n\n" "$(_end_time $(( $(date +%s) - _start_time )))"; exit 0; }
        _menu_has "$choice" || { _warn "Choix indisponible ou verrouillé."; continue; }
        case "$choice" in
            1) _panel_install || true ;;
            2) _panel_deps || true ;;
            3) _panel_services || true ;;
            4) _panel_servers || true ;;
            5) _panel_logs || true ;;
            6) _require_grade 60 "Access Manager" && _access_manager_menu || true ;;
            7) _status_report; _pause || true ;;
            8) _panel_update; _pause || true ;;
            9) _integrity_check_all; _pause || true ;;
            # ⚠ Même grade que la réinitialisation : ce geste adopte l'état en
            # place, donc il décide de ce qui sera considéré comme sain.
            13) _require_grade 80 "La pose des empreintes de référence" \
                    && { _integrity_reposer || true; _pause; } || true ;;
            10) _require_grade 80 "La réinitialisation de la configuration" && _reset_config || true ;;
            12) _require_grade 60 "L'installation du site" && { _site_install || true; _pause; } || true ;;
            11) _log_entry "main" "restart" "Panel redémarré"
                _pause
                [[ -x "$0" ]] || chmod +x "$0"
                _ovt_tmp_menage
                exec "$0"
            ;;
            0) _log_entry "main" "quit" "Panel fermé"
               printf "\n${CYAN}Au revoir. Session : %s${RESET}\n\n" "$(_end_time $(( $(date +%s) - _start_time )))"
               exit 0 ;;
            *) _warn "Choix invalide" || true ;;
        esac
    done
}

# =============================================================================
# MAIN
# =============================================================================
_usage() {
    cat <<-USAGE

  ${BOLD}$SCRIPT_NAME${RESET} v$VERSION

  Usage : $0 [commande] [option]

  ${BOLD}Commandes${RESET} — sans interface, utilisables depuis une tâche planifiée :

    check       Compare les fichiers installés à ceux publiés. N'écrit RIEN.
                Rend 0 si tout est à jour, 1 s'il y a des mises à jour en
                attente, 2 en cas d'erreur.
    update      Vérifie puis applique. Sauvegarde chaque fichier avant de le
                remplacer, contrôle son empreinte, et refuse d'écrire si elle
                ne correspond pas.
    upgrade     Synonyme de « update ».
    status      L'état de chaque fichier surveillé, et sa dernière vérification.
    history     Les dernières mises à jour appliquées.
    rollback    Restaure une session de sauvegarde (demande laquelle).
    version     La version installée, et celle publiée par le site.
    check-compat
                Vérifie que CETTE machine peut faire tourner Overtchat (Linux,
                systemd, architecture, gestionnaire de paquets, ressources) et
                refuse si un prérequis dur manque. À lancer sur une machine
                VIERGE avant d'installer — il ne demande ni installation
                existante ni dépendances. Rend 0 si compatible, 1 sinon.
    --monter-edition=NOM
                MONTE une installation existante d'un palier au suivant (standard,
                complete, payante). Refuse toute DESCENTE et le sur-place. Pose le
                delta de fonctionnalités du palier atteint (drapeaux de config) et
                dit ce qui reste à provisionner (la modération IA n'est PAS
                téléchargée d'office). Le site prend l'édition à la requête suivante.
    anope-construire [--archive=CHEMIN]
                Récupère (archive officielle contrôlée à l'empreinte), compile
                et configure Anope SANS QUESTION, en compte ordinaire, et ne le
                DÉMARRE PAS. Valeurs lues dans le serveur IRC (bloc « link »,
                écoute des serveurs, SID) : il refuse si l'une manque. Une
                configuration existante est gardée en « .avant-<date> ». Refuse
                si Anope tourne. Lancée par le bouton du panel (unité
                overtchat-anope-construire.service).

  ${BOLD}Commande interactive${RESET} — celle-ci POSE DES QUESTIONS, donc jamais
  depuis une tâche planifiée :

    anope-config
                Écrit la configuration d'Anope si elle manque, et ne touche à
                rien si elle est déjà là. Le nom du serveur et le mot de passe
                du lien sont proposés d'après le bloc « link » du serveur IRC :
                il suffit alors de valider. Sans elle, un Anope compilé mais
                jamais configuré restait inatteignable — relancer son
                installation s'arrêtait sur « installation existante valide ».

  ${BOLD}Options${RESET}

    --root      Relance le panel en root (gestion automatisée des paquets).
                Nécessite sudo. L'arborescence reste installée dans le home
                de l'utilisateur courant, jamais dans /root.
    --user      Force le mode utilisateur simple, même si root est disponible.
    --projet=NOM
                Nom de VOTRE installation : tout est posé dans ~/NOM. Sans
                cette option, l'assistant le demande au premier lancement.
                « Service-Overtchat » et « Overtchat » sont refusés — ce sont
                les noms du réseau et du serveur d'origine. Le nom du logiciel,
                lui, ne change pas ; celui de votre réseau se règle plus tard,
                séparément. Même effet par OVERTCHAT_PROJET dans l'environnement.
    --edition=NOM
                L'édition de CETTE installation : beta, standard, complete ou
                payante. Sans cette option, une installation neuve naît en
                « beta » — le défaut prudent, qui ferme et n'ouvre jamais. Une
                édition déjà posée n'est PAS touchée : relancer l'installateur
                ne redescend rien, et une édition PLUS BASSE que celle posée est
                REFUSÉE avant toute écriture — deux éditions ne cohabitent pas :
                désinstaller d'abord (Tools/desinstaller.sh). Pour monter :
                --monter-edition=NOM. Tout le code est posé dans tous les cas ;
                l'édition décide de ce qui est allumé.
    --auto      Installe sans poser aucune question : site, serveur IRC,
                Eggdrop, IriX, puis les unités systemd. Chaque question prend
                la valeur qui lui est proposée par défaut ; celles qui n'en ont
                pas se donnent par l'environnement, et le script REFUSE de
                partir tant qu'il en manque une — avant d'avoir rien écrit.
                Même effet par OVERTCHAT_AUTO=1.

                  OVERTCHAT_SQL_PASS   exigé — mot de passe SQL d'IriX
                  OVERTCHAT_COMPTE     exigé en root SANS sudo : le compte
                                       ordinaire qui portera l'installation
                                       (les services ne tournent jamais en root)
                  OVERTCHAT_DOMAINE    domaine du site
                  OVERTCHAT_RESEAU     nom public du réseau
                  OVERTCHAT_PROPRIETAIRE  pseudonyme IRC du propriétaire du robot
                                       (ancien chemin Eggdrop seulement)
                  OVERTCHAT_COURT     nom court (menus, groupes)
                  OVERTCHAT_MAIL_FROM  adresse d'expédition
                  OVERTCHAT_MAIL_TO    adresse qui reçoit les notifications
                  OVERTCHAT_SALON      salon principal du chat (défaut #accueil)
                  OVERTCHAT_OVERSYSTEM oui : accord OverSystem, EXIGÉ par
                                       l'édition Complète ; absent, elle
                                       s'installe éteinte, en RÉSERVE (code 3)
                  OVERTCHAT_PROGRES    1 : repères machine « @@OVERTCHAT 1 … »
                                       sur la sortie (Lanceur Windows)
                  OVERTCHAT_SERVEUR    serveur IRC à poser (défaut overtchat_ircd :
                                       Overtchat-Hybrid, le serveur du projet —
                                       hors clone, le binaire compilé que sert
                                       le site, sous l'empreinte du manifeste ;
                                       il exige x86_64, glibc ≥ 2.34 et
                                       OpenSSL 3. Sur une autre machine, le
                                       défaut se rabat sur unrealircd et le
                                       DIT ; demandé explicitement, il est
                                       REFUSÉ avant toute écriture. Autres
                                       valeurs : unrealircd, hybrid, ircu,
                                       inspircd, bahamut)

                Exige --projet=NOM et une élévation qui ne demande pas de mot
                de passe. Anope n'en fait pas partie : son mot de passe de lien
                n'existe pas encore sur une installation neuve — il se pose
                ensuite par « anope-config ».
    --debug     Trace d'exécution (set -x).
    --help      Cet écran.

  Sans commande ni option : le panel, en mode utilisateur simple. Les paquets
  système nécessitent une élévation ponctuelle ; le panel la demandera si sudo
  est disponible.

  Une mise à jour qui échoue envoie un courriel à l'adresse d'exploitation
  (Config/identity.json, .mail.to) avec les incidents rencontrés — au plus une
  alerte toutes les six heures.

USAGE
}

# =============================================================================
# COMMANDES EN LIGNE — le module de mise à jour, sans le menu
# =============================================================================
#
# Le module existait depuis longtemps et n'était joignable que par l'entrée 8
# du menu, c'est-à-dire par quelqu'un devant un clavier. Une mise à jour qui
# demande une présence humaine n'est pas une mise à jour : c'est un rappel
# qu'on finit par ignorer.
#
# Ce chemin saute _panel_install, _access_login_gate et _offer_root — les
# trois choses qui attendent une saisie. Il conserve tout le reste :
# détection des privilèges et du gestionnaire de paquets, dépendances, et
# surtout la configuration, sans laquelle `_upd_brutegit_url` ne sait pas où
# télécharger.
_cli_commande() {
    local cmd="$1"

    if [[ ! -d "$BASE_DIR" ]]; then
        _err_f "Aucune installation dans %s." "$BASE_DIR"
        _info "Lancez « $0 » sans commande pour installer d'abord."
        return 2
    fi

    # ⚠ AVANT `_upd_state_init` : la configuration d'Anope ne relève pas du
    # module de mise à jour et n'a aucune raison d'en toucher l'état. Rangée
    # après, elle réécrivait le fichier de suivi des mises à jour pour un
    # travail qui ne le concerne pas.
    if [[ "$cmd" == "anope-config" ]]; then
        _service_configure_si_besoin "anope"
        return $?
    fi

    # 17/10/2026 : même raison — la construction d'Anope (bouton du panel) ne
    # relève pas de la mise à jour.
    if [[ "$cmd" == "anope-construire" ]]; then
        _anope_construire_cli "${ANOPE_ARCHIVE:-}"
        return $?
    fi

    # ⚠ Comme anope-config : la montée d'édition ne relève pas du module de mise
    # à jour et n'a pas à toucher son état. Traitée AVANT _upd_state_init.
    if [[ "$cmd" == "monter-edition" ]]; then
        _edition_monter_cli "$MONTER_CIBLE"
        return $?
    fi

    _upd_compteurs_reset
    _upd_state_init

    case "$cmd" in
        check)
            _upd_check_all
            return $?
            ;;
        update|upgrade)
            _upd_apply_all
            return $?
            ;;
        status)
            _upd_show_status
            return 0
            ;;
        history)
            _upd_show_history
            return 0
            ;;
        rollback)
            _upd_rollback_interactive
            return $?
            ;;
        version)
            local dist
            _ui_top
            _ui_row "${BOLD}Versions${RESET}"
            _ui_mid
            _ui_kv "Installée" "$VERSION"
            dist="$(_upd_version_publiee 2>/dev/null || true)"
            if [[ -z "$dist" ]]; then
                _ui_kv "Publiée" "${DIM}injoignable${RESET}"
                _ui_bot
                _warn "Le site n'a pas répondu — impossible de dire s'il y a du retard."
                return 2
            fi
            _ui_kv "Publiée" "$dist"
            _ui_bot

            # `_version_cmp` : 0 égales, 1 la locale devant, 2 la distante
            # devant. Le code est capturé AVANT tout autre test : après un
            # `if`, `$?` vaut le résultat du `if` lui-même et non celui de la
            # fonction — un `elif [[ $? -eq 1 ]]` aurait toujours été faux.
            # Le `|| cmp=$?` est aussi ce qui empêche `set -e` de tuer le
            # script sur un retour non nul parfaitement normal.
            local cmp=0
            _version_cmp "$VERSION" "$dist" || cmp=$?

            case "$cmp" in
                0) _ok "À jour."
                   return 0 ;;
                1) # Une installation EN AVANCE n'est pas une anomalie à
                   # corriger : c'est une machine de développement, ou un site
                   # pas encore déployé. On le dit, et on ne propose rien.
                   _info "Cette installation est en avance sur le site."
                   return 0 ;;
                *) _warn "Une nouvelle version est disponible : $VERSION → $dist"
                   _info "Pour l'appliquer : $0 update"
                   return 1 ;;
            esac
            ;;
    esac
    return 2
}

# -----------------------------------------------------------------------------
# Proposition d'élévation. L'utilisateur garde le choix : le mode root ne sert
# qu'à automatiser l'installation des paquets, tout le reste doit rester en
# compte utilisateur.
# -----------------------------------------------------------------------------
_offer_root() {
    [[ "$PRIV_MODE" == "root" ]] && return 0
    # --user est un choix explicite : le redemander à chaque lancement serait
    # du harcèlement, et la question consomme l'entrée standard.
    [[ "${FORCE_USER_MODE:-0}" == "1" ]] && return 0
    # ⚠ En AUTO, cette question n'a personne pour y répondre — et elle n'a plus
    # d'objet : `_auto_prevol` a exigé root ou un `sudo -n` utilisable, donc
    # l'élévation ponctuelle d'`_as_root` suffit déjà. Se relancer en root
    # ferait en outre repartir toute l'installation depuis le début.
    [[ "${MODE_AUTO:-0}" == "1" ]] && return 0
    command -v sudo &>/dev/null || return 0

    local missing="" comp
    for comp in eggdrop unrealircd; do
        _req_satisfied "$comp" || { missing="oui"; break; }
    done
    [[ -n "$missing" ]] || return 0

    _warn "Des paquets système manquent — une élévation sera nécessaire."
    printf "  Relancer le panel en root pour automatiser l'installation ? [y/N] : "
    local rep=""; read -r rep || true
    [[ "$rep" =~ ^[YyOo]$ ]] || { _info "Poursuite en mode utilisateur."; return 0; }

    _info "Élévation via sudo…"
    # -E conserve SUDO_USER : _resolve_base_home retrouve le home réel et
    # l'arborescence ne part pas dans /root.
    _ovt_tmp_menage
    exec sudo -E "$0" --root
}

# =============================================================================
# LE MODE AUTO — installer sans personne devant
#
#   Demandé par SerVuS le 24/08/2026, point 48 : « Auto : site + serveur +
#   services, sans question. »
#
#   Trois fonctions, et elles se lisent dans cet ordre :
#
#     _auto_boucle    la garde qui empêche une question refusée de tourner
#                     sans fin ;
#     _auto_prevol    ce qu'il faut AVOIR avant de commencer — contrôlé
#                     pendant que rien n'est encore écrit ;
#     _auto_installer l'enchaînement, dans l'ordre que l'assistant impose.
#
#   ⚠⚠ CE QUE CE MODE NE FAIT PAS, ET POURQUOI C'EST L'ESSENTIEL. Il ne lit
#   jamais l'entrée standard. Un `read` sans personne devant ne rend pas une
#   réponse : il rend une chaîne vide obtenue par EOF, que rien ne distingue
#   d'une réponse donnée. On installerait alors sous des valeurs que personne
#   n'a choisies, en croyant les avoir demandées.
#
#   ⚠ Et il ne devine aucun secret. Le mot de passe SQL n'a de défaut nulle
#   part : sans lui, IriX s'installe, l'écran dit « configuré », et le bot ne
#   joint jamais la base. C'est le genre de succès mensonger que ce projet
#   passe son temps à traquer — d'où le contrôle avant vol.
# =============================================================================

# Garde de boucle pour les questions qu'on repose tant que la réponse ne
# convient pas. Rend 0 quand la boucle peut continuer, 1 quand il n'y a plus
# lieu de reposer la question.
#
# ⚠⚠ Sans elle, `--auto` GÈLE au lieu d'échouer. Les boucles de `_site_install`
# rappellent `_site_ask` après un refus ; en AUTO la réponse suivante est
# identique à la précédente, donc le refus se répète — indéfiniment, sans rien
# écrire à l'écran. Un second tour n'est pas une seconde chance : c'est la
# preuve que la valeur fournie est refusée.
_auto_boucle() {
    local tour="$1" quoi="$2"
    [[ "${MODE_AUTO:-0}" == "1" ]] || return 0
    [[ "$tour" -le 1 ]] && return 0
    _err_f "auto : %s — la valeur fournie est refusée, et la reposer donnerait la même." "${quoi}"
    _info "Corrigez-la puis relancez ; rien de plus n'a été écrit."
    return 1
}

# Contrôle avant vol. Rend 1 — et n'écrit RIEN — s'il manque une réponse que
# le mode auto ne peut pas fabriquer.
#
# ⚠ Il tourne APRÈS `_priv_detect` (il teste l'élévation) et AVANT le premier
# `mkdir`. C'est la seule fenêtre où l'on peut encore refuser sans laisser
# derrière soi une installation à moitié faite.
_auto_prevol() {
    local manques=()

    # 1. Le nom de l'installation. Il ne se devine pas, et il ne se change pas
    #    après coup : toute l'arborescence, les unités systemd et les chemins
    #    en base en dérivent. C'est le pire endroit où prendre un défaut —
    #    d'où l'exigence, plutôt qu'un « MonReseau » que personne n'a voulu.
    if [[ "$PROJET_SOURCE" == "defaut" ]]; then
        manques+=( "le nom de l'installation — --projet=NOM, ou OVERTCHAT_PROJET" )
    fi

    # 2. Le mot de passe SQL d'IriX : le seul secret de l'installation qui n'a
    #    aucun défaut, nulle part.
    if [[ -z "${OVERTCHAT_SQL_PASS:-}" ]]; then
        manques+=( "le mot de passe SQL d'IriX — OVERTCHAT_SQL_PASS" )
    fi

    # 3. Une élévation qui ne demande rien. `_as_root` passe en `sudo -n` sous
    #    --auto : si le droit exige un mot de passe, chaque pose de paquet
    #    échouerait une par une, au lieu d'échouer ici, une seule fois et
    #    clairement.
    if [[ "$PRIV_MODE" != "root" ]]; then
        if ! { command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; }; then
            manques+=( "une élévation sans mot de passe — lancez « sudo -E … --auto »" )
        fi
    fi
    # 🔴 Audit #8 (29/09/2026) : en root SANS compte d'origine, tout tournait en
    #   root. Refusé ICI, avant la moindre pose de paquet.
    if [[ "$PRIV_MODE" == "root" && "${PRIV_USER:-}" == "root" ]]; then
        manques+=( "un compte ordinaire pour les services (jamais root) — « sudo -E … » depuis ce compte, ou OVERTCHAT_COMPTE=<compte>" )
    fi

    if [[ ${#manques[@]} -gt 0 ]]; then
        _err "Le mode auto ne peut pas partir — il manque ${#manques[@]} réponse(s) :"
        local m
        for m in ${manques[@]+"${manques[@]}"}; do
            printf "    %s %s\n" "${ORANGE}·${RESET}" "$m"
        done
        printf "\n"
        _info "Rien n'a été écrit. La forme complète :"
        printf "    %s\n\n" "${BOLD}OVERTCHAT_SQL_PASS=secret sudo -E ./overtchat-setup.sh --auto --projet=MonReseau${RESET}"
        return 1
    fi

    # Ce qui n'arrête pas, mais qui ne se tait pas non plus. Un site posé sur
    # « irc.example.org » s'installe très bien et ne sert à rien : le dire au
    # départ vaut mieux que de le découvrir au premier courriel.
    [[ -n "${OVERTCHAT_DOMAINE:-}" ]] \
        || _warn "OVERTCHAT_DOMAINE absent — le site sera posé sur « irc.example.org »."
    [[ -n "${OVERTCHAT_RESEAU:-}" ]] \
        || _warn "OVERTCHAT_RESEAU absent — le réseau s'annoncera « Mon réseau IRC »."
    # 19/10/2026 (reste du lot J2) : plus d'avertissement « le robot IriX n'aura
    #   aucun propriétaire » ICI. Ajouté le 26/09 quand --auto posait toujours
    #   Eggdrop, il mentait depuis J2 : avec notre serveur et Node ≥ 14.14, il
    #   n'y a ni IriX ni « set owner », et le chemin n'est pas encore connu au
    #   prévol (Node peut arriver avec les dépendances). L'ancien chemin Eggdrop
    #   le dit lui-même, au bon moment : `_irix_configure` (« Aucun propriétaire
    #   du robot (OVERTCHAT_PROPRIETAIRE) … »), gardé par test-proprietaire-irix.
    # ⚠ L'installateur ne pose PAS le serveur MariaDB et ne crée PAS la base
    #   (INSTALL.md, § 2 bis « La base de données ») : c'est voulu, la base peut vivre
    #   ailleurs. Mais sur une machine nue, on le découvrait à la FIN, après
    #   tout le reste. On le dit donc au départ — sans refuser, puisqu'une
    #   base distante est un cas légitime (23/09/2026, point 90).
    if ! _mariadb_local_present; then
        _warn "Aucun serveur MariaDB local — l'installateur ne le pose pas et ne crée pas la base. Si elle n'est pas ailleurs : INSTALL.md, § 2 bis « La base de données »."
    fi
    return 0
}

# Un serveur MariaDB/MySQL est-il installé sur CETTE machine ? Séparée pour que
# `Bootstrap/tests/test-auto.sh` simule son absence sur une machine qui en a un.
_mariadb_local_present() {
    command -v mariadbd >/dev/null 2>&1 || command -v mysqld >/dev/null 2>&1 \
        || [[ -x /usr/sbin/mariadbd || -x /usr/sbin/mysqld ]]
}

# ── Le SERVEUR IRC de --auto, décidé AVANT toute écriture — 29/09/2026 ──────
#
# Décision de SerVuS (29/09, soir) : « retire les sources et mets notre serveur
# par défaut ». Trois règles, fixées par lui :
#   · OVERTCHAT_SERVEUR absent → `overtchat_ircd`, NOTRE serveur ;
#   · sans clone, il ne vient QUE du binaire servi : une machine qui ne peut
#     pas le faire tourner (≠ x86_64, glibc < 2.34, OpenSSL < 3 —
#     `_overtchat_bin_compatible`) → UnrealIRCd, en le DISANT ici ET au bilan
#     final (réserve, code 3) ;
#   · OVERTCHAT_SERVEUR=overtchat_ircd EXPLICITE sur une telle machine → REFUS,
#     ici, avant la première écriture (l'appelant fait `exit 2`).
#   Un CLONE du dépôt compile toujours son arbre : aucun contrôle de machine.
#
# ⚠ Rend ses choix par trois globales, lues par `_auto_installer` :
#   SERVEUR_AUTO (le serveur retenu), SERVEUR_AUTO_EXPLICITE (0/1),
#   SERVEUR_AUTO_RESERVE (la phrase du bilan, vide sans repli).
# ⚠ Une valeur inconnue est refusée ICI aussi : elle échouait jusqu'alors à
#   l'étape 3 (« Serveur inconnu »), après le site et les paquets.
_auto_serveur_choisir() {
    local demande="${OVERTCHAT_SERVEUR:-}"
    SERVEUR_AUTO_EXPLICITE=0
    SERVEUR_AUTO_RESERVE=""
    [[ -n "$demande" ]] && SERVEUR_AUTO_EXPLICITE=1
    SERVEUR_AUTO="${demande:-overtchat_ircd}"

    case "$SERVEUR_AUTO" in
        unrealircd|hybrid|ircu|inspircd|bahamut|overtchat_ircd) ;;
        *)
            _err_f "OVERTCHAT_SERVEUR=%s : serveur IRC inconnu." "$SERVEUR_AUTO"
            _info "Valeurs admises : overtchat_ircd (défaut), unrealircd, hybrid, ircu, inspircd, bahamut. Rien n'a été écrit."
            return 1 ;;
    esac
    [[ "$SERVEUR_AUTO" == overtchat_ircd ]] || return 0

    if _overtchat_arbre_local; then
        _info "Serveur IRC : Overtchat-Hybrid, compilé depuis l'arbre du dépôt (Servers/overtchat-hybrid)."
        return 0
    fi
    _overtchat_bin_compatible avant && return 0

    if [[ "$SERVEUR_AUTO_EXPLICITE" == 1 ]]; then
        _err "OVERTCHAT_SERVEUR=overtchat_ircd est refusé : cette machine ne peut pas faire tourner le serveur compilé (voir ci-dessus), et sans clone du dépôt il n'y a pas d'autre voie — ses sources ne sont plus servies."
        _info "Rien n'a été écrit. Retirez OVERTCHAT_SERVEUR (UnrealIRCd sera pris à sa place, en le disant), choisissez OVERTCHAT_SERVEUR=unrealircd, ou installez depuis un clone du dépôt."
        return 1
    fi
    SERVEUR_AUTO=unrealircd
    SERVEUR_AUTO_RESERVE="le serveur du projet (Overtchat-Hybrid) ne peut pas tourner sur cette machine (il exige x86_64, glibc ≥ 2.34 et OpenSSL 3) : UnrealIRCd a été installé à sa place"
    _warn "Serveur IRC : repli sur UnrealIRCd — cette machine ne peut pas faire tourner le serveur du projet (voir ci-dessus). Ce repli sera rappelé à la fin."
    return 0
}

# ── CAPACITÉ pour la MODÉRATION IA LOCALE — mesurée, pas supposée ───────
#
# Demandé par SerVuS le 13/09/2026 : ne proposer ce qui est LOURD que si la
# machine peut le porter, et se replier proprement sinon.
#
# ⚠⚠ CE N'EST PAS LA CARTE QUI PÈSE, et c'est le point à ne pas se tromper.
# La carte des membres, c'est `Web/site/carte.php` (5,6 Ko), `carte.js` (4 Ko),
# Leaflet auto-hébergé (~150 Ko) et UNE colonne en base (migration 031) ; ses
# tuiles viennent d'un serveur EXTERNE (CARTO/OSM), donc elle ne coûte au
# serveur ni calcul, ni mémoire, ni disque. Elle reste toujours proposée —
# la conditionner à un seuil ne priverait que des machines qui la portent
# très bien.
#
# Ce qui pèse, c'est la modération IA locale posée par `Tools/moderation-setup.sh` :
# un venv Python (opencv, onnxruntime, NudeNet, faster-whisper) et surtout le
# modèle Whisper « small » (~500 Mo), que `overtchat-voix.service` garde CHAUD
# en mémoire — son en-tête annonce ~7 s de chargement au démarrage.
#
# Trois mesures, trois raisons :
#   · RAM    — les deux services gardent leur modèle en mémoire, en plus de
#              MariaDB, du serveur IRC, d'Eggdrop et de PHP ;
#   · disque — le venv et les modèles s'écrivent sous le home de l'exploitant ;
#   · cœurs  — la transcription est du calcul CPU pur (1 à 2 s par clip) ; sur
#              un seul cœur elle fait attendre tout le reste du service.
#
# ⚠ AUCUN GPU n'est requis ni utilisé : onnxruntime et faster-whisper tournent
#   ici en CPU. On ne mesure donc pas la carte graphique — mesurer ce dont rien
#   ne dépend donnerait un refus qui n'explique rien.
#
# Rend 0 si la machine peut, 1 sinon, le détail dans $IA_MOTIF.
#
# ⚠⚠ VOLONTAIREMENT AUTONOME, et PAS appelée depuis `_compat_prevol` : ce
#   contrôle-là refuse l'installation ENTIÈRE, alors qu'une machine modeste
#   doit pouvoir installer Overtchat — simplement sans l'IA. (Accessoirement,
#   `Bootstrap/tests/test-compat.sh` extrait `_compat_prevol` et l'évalue
#   seule, dans un décor simulé : un appel à une fonction voisine l'y ferait
#   échouer pour une raison sans rapport avec ce qu'il mesure.)
IA_MOTIF=""
IA_RAM_MIN_KO=4194304      # 4 Go — les deux modèles restent chargés
IA_DISQUE_MIN_KO=3145728   # 3 Go — venv Python + Whisper small + NudeNet
IA_COEURS_MIN=2            # la transcription est CPU, et elle n'est pas seule
_capacite_ia() {
    IA_MOTIF=""
    local manques=() ram_ko libre_ko coeurs cible

    # ⚠ Que des `if` et des `||`, JAMAIS `[[ … ]] && tableau+=(…)` : le script
    #   tourne sous `set -Eeuo pipefail`, et une telle liste rend 1 quand la
    #   condition est fausse — donc le cas NORMAL (machine capable) tuerait
    #   l'installation. C'est pour la même raison que `_compat_prevol` n'écrit
    #   qu'ainsi juste en dessous.
    ram_ko="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || true)"
    if [[ "$ram_ko" =~ ^[0-9]+$ && "$ram_ko" -lt "$IA_RAM_MIN_KO" ]]; then
        manques+=( "$(( ram_ko / 1024 )) Mo de mémoire vive ($(( IA_RAM_MIN_KO / 1048576 )) Go demandés)" )
    fi

    cible="${BASE_DIR:-$HOME}"; [[ -d "$cible" ]] || cible="$HOME"; [[ -d "$cible" ]] || cible="/"
    libre_ko="$(df -Pk "$cible" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
    if [[ "$libre_ko" =~ ^[0-9]+$ && "$libre_ko" -lt "$IA_DISQUE_MIN_KO" ]]; then
        manques+=( "$(( libre_ko / 1024 )) Mo libres sur $cible ($(( IA_DISQUE_MIN_KO / 1048576 )) Go demandés)" )
    fi

    coeurs="$(nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null || true)"
    if [[ "$coeurs" =~ ^[0-9]+$ && "$coeurs" -lt "$IA_COEURS_MIN" ]]; then
        manques+=( "$coeurs cœur CPU ($IA_COEURS_MIN demandés)" )
    fi

    if [[ "${#manques[@]}" -eq 0 ]]; then
        return 0
    fi

    IA_MOTIF="$(printf '%s, ' "${manques[@]}")"
    IA_MOTIF="${IA_MOTIF%, }"
    return 1
}

# ── Contrôle de compatibilité MACHINE — le frère de _auto_prevol ────────
#
# `_auto_prevol` garde ce qu'il faut SAVOIR (les variables du mode auto) ;
# celui-ci garde ce que la machine doit POUVOIR. Demandé par SerVuS le
# 11/09/2026 : « refus total de l'installation » sur une machine incompatible
# (point 90, étape 3, Markdown/PALIERS.md). Il tourne AVANT toute écriture, pour
# les DEUX chemins (interactif ET auto), et sert aussi de commande
# « check-compat » que le Lanceur lance à distance par SSH avant de rien pousser.
#
# Deux niveaux : un prérequis DUR refuse (return 1 → l'appelant fait exit 2) ;
# un point SOUPLE avertit sans bloquer. On ne ferme que ce qui empêche VRAIMENT
# le stack de tourner — pas une machine modeste, qu'on se contente de prévenir.
#
# ⚠ Architecture, RAM, version de bash et présence de systemd n'étaient vérifiées
# NULLE PART avant ce jour — c'est ici qu'elles le sont.
_compat_prevol() {
    [[ -z "${PKG_FAMILY:-}" ]] && _pkg_detect
    [[ -z "${PRIV_MODE:-}" ]]  && _priv_detect

    local durs=() souples=()
    local os arch

    os="$(uname -s 2>/dev/null || echo inconnu)"
    [[ "$os" == "Linux" ]] \
        || durs+=( "système « $os » — Overtchat exige Linux (services systemd)" )

    command -v systemctl >/dev/null 2>&1 \
        || durs+=( "systemd absent (systemctl introuvable) — requis pour les services" )

    if [[ "$PKG_FAMILY" == "inconnu" || -z "${PKG_MGR:-}" ]]; then
        durs+=( "aucun gestionnaire de paquets reconnu (apt, dnf, yum, pacman, zypper, apk)" )
    fi

    arch="$(uname -m 2>/dev/null || echo inconnu)"
    case "$arch" in
        x86_64|amd64) ;;
        aarch64|arm64)
            souples+=( "architecture $arch non éprouvée — le stack compile depuis les sources, il peut fonctionner" ) ;;
        *)
            durs+=( "architecture « $arch » non supportée — x86_64 attendu" ) ;;
    esac

    if [[ "${BASH_VERSINFO:-0}" -lt 4 ]]; then
        durs+=( "bash ${BASH_VERSION:-inconnu} trop ancien — bash 4 ou plus requis" )
    fi

    # Disque : la cible d'installation si connue, sinon le home, sinon la racine.
    local cible libre_ko
    cible="${BASE_DIR:-$HOME}"; [[ -d "$cible" ]] || cible="$HOME"; [[ -d "$cible" ]] || cible="/"
    libre_ko="$(df -Pk "$cible" 2>/dev/null | awk 'NR==2 {print $4}')"
    if [[ "$libre_ko" =~ ^[0-9]+$ ]]; then
        if   [[ "$libre_ko" -lt 524288 ]]; then
            durs+=( "espace disque critique — $(( libre_ko / 1024 )) Mo libres, 512 Mo au strict minimum" )
        elif [[ "$libre_ko" -lt 2097152 ]]; then
            souples+=( "espace disque bas — $(( libre_ko / 1024 )) Mo libres, 2 Go conseillés (compilation du serveur)" )
        fi
    fi

    # Mémoire vive — SOUPLE : une Standard sans modération IA vit avec peu.
    local ram_ko
    ram_ko="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null)"
    if [[ "$ram_ko" =~ ^[0-9]+$ && "$ram_ko" -lt 2097152 ]]; then
        souples+=( "mémoire vive $(( ram_ko / 1024 )) Mo — 2 Go conseillés (la modération IA en demande)" )
    fi

    _priv_can_elevate \
        || souples+=( "ni root ni sudo — l'installation réclamera des droits administrateur" )

    # Les avertissements souples ne bloquent pas.
    if [[ "${#souples[@]}" -gt 0 ]]; then
        local s
        for s in "${souples[@]}"; do _warn "$s"; done
    fi

    # Un seul prérequis dur suffit à refuser — et on les liste TOUS, pour ne pas
    # faire découvrir le second après avoir réglé le premier.
    if [[ "${#durs[@]}" -gt 0 ]]; then
        _err "Machine incompatible — installation refusée :"
        local d
        for d in "${durs[@]}"; do
            printf '     %s✖%s %s\n' "${RED}" "${RESET}" "$d" >&2
        done
        return 1
    fi

    _ok "Compatibilité machine confirmée — $os $arch, famille $PKG_FAMILY."
    return 0
}

# L'enchaînement. Même ordre que l'assistant, pour la même raison : chaque
# étape dépend de la précédente.
#
# ⚠ LE SITE PASSE AVANT IriX, et ce n'est pas un détail de présentation.
# `_irix_configure` tire ses défauts de `Config/identity.json`, écrit par
# `_site_install` : dans l'autre ordre, le bot prendrait des valeurs de repli
# et annoncerait un autre réseau que le site.
#
# ⚠ Anope reste DEHORS. Son mot de passe de lien se lit dans le bloc « link »
# du serveur IRC, qui n'existe pas encore sur une installation neuve :
# `_anope_configure` rendrait 1 sur « mot de passe du lien vide ». Il se pose
# ensuite, par « anope-config » ou par le menu.
_auto_installer() {
    # ⚠⚠ Défaut « overtchat_ircd », NOTRE serveur, depuis le 29/09/2026 au soir
    # (décision de SerVuS : « retire les sources et mets notre serveur par
    # défaut »). Jusque-là le défaut était `unrealircd` : notre serveur n'était
    # distribué par aucun canal, puis ne l'était que depuis la veille.
    # Mesuré le 29/09/2026 par des installations complètes PAR LE SCRIPT SEUL
    # (conteneur Ubuntu 24.04) : le binaire servi est posé, configuré, démarré.
    # Le choix est fait AVANT toute écriture, par `_auto_serveur_choisir` (prévol) :
    # SERVEUR_AUTO porte le serveur retenu — le nôtre, ou UnrealIRCd quand la
    # machine ne peut pas faire tourner le binaire et que personne n'a demandé
    # le nôtre explicitement (repli DIT, repris au bilan). Le repli sur
    # `OVERTCHAT_SERVEUR` ne sert qu'à un appel qui aurait sauté le prévol.
    # ⚠ L'étape 1 déclare encore pour lui la chaîne de compilation
    # (`_req_for overtchat_ircd` : gcc, make…). Jusqu'au 17/10/2026 cela
    # n'ajoutait aucun paquet (tous figuraient dans `_req_for eggdrop`) ; sur le
    # chemin OverCore (lot J2), sans Eggdrop, c'est elle seule qui les pose —
    # non mesuré : à constater par l'essai en conteneur.
    # 🔴 17/10/2026 (lot J2, décision ① de SerVuS) : les services ne sont plus
    # TOUJOURS Eggdrop + IriX — voir « LE CHEMIN DES SERVICES » à l'étape 1.
    local srv="${SERVEUR_AUTO:-${OVERTCHAT_SERVEUR:-overtchat_ircd}}"
    local echec=""
    # ⚠⚠ LES RÉSERVES — ce qui n'arrête pas l'installation mais la laisse
    #   INCOMPLÈTE. Jusqu'au 23/09/2026 elles n'étaient que des `_warn` noyés
    #   dans le défilement, et la fin disait « terminée » avec le code 0 — même
    #   quand la base n'avait jamais répondu (point 90, « P1 »). Chaque réserve
    #   est reprise à la fin, et le code de sortie devient 3.
    local reserves=()
    local base_muette=0
    local demarres=()   # les unités que l'étape 6 a DÉMARRÉES (mesuré, pas supposé)
    # Le repli sur UnrealIRCd décidé au prévol : une RÉSERVE, pas une ligne
    # perdue dans le défilement — l'exploitant a reçu un autre serveur que le
    # défaut, il doit le lire dans le bilan.
    if [[ -n "${SERVEUR_AUTO_RESERVE:-}" ]]; then
        reserves+=( "$SERVEUR_AUTO_RESERVE" )
    fi
    # Posée par `_oversystem_exiger` pendant l'étape 2 (Complète sans OverSystem).
    OVERSYSTEM_RESERVE=""
    # Repère machine (Lanceur) : le nombre d'étapes de la frise.
    _progres DEBUT 6

    _services_init; _servers_init; _st_reconcile

    _ui_width
    _ui_title "Installation automatique" "aucune question ne sera posée"
    _ui_top
    _ui_kv "Racine"       "$BASE_DIR"
    _ui_kv "Serveur IRC"  "$srv"
    _ui_kv "Privilèges"   "$(_priv_label)"
    _ui_kv "Distribution" "${PKG_FAMILY} ${DIM}(${PKG_MGR:-aucun})${RESET}"
    _ui_bot

    # ── 1. Dépendances système ──────────────────────────────────────────
    # Même liste que le choix 1 de `_panel_deps`, restreinte à ce que le socle
    # auto pose réellement : demander les paquets des cinq serveurs qu'on
    # n'installera pas ferait échouer l'étape sur une distribution qui n'en
    # empaquette qu'une partie.
    _section "1/6 — Dépendances système"
    _progres ETAPE "1/6 Dépendances système"
    # ── 🔴 LE CHEMIN DES SERVICES — 17/10/2026 (lot J2, décision ① de SerVuS) ──
    #
    # Jusqu'ici `--auto` posait TOUJOURS Eggdrop + IriX. Désormais :
    #   · overcore — NOTRE serveur (overtchat_ircd) et un Node.js qui passe le
    #     plancher (`_overcore_node_pret`) : OverCore, distribué par l'archive du
    #     site, sans aucun Eggdrop ;
    #   · irix     — notre serveur mais Node absent ou trop vieux : l'ANCIEN
    #     chemin Eggdrop + IriX, tant qu'IriX existe, et c'est une RÉSERVE (le
    #     bilan le dit ; jamais un dépôt NodeSource — décision du 17/10) ;
    #   · anope    — un serveur TIERS (UnrealIRCd…) : OverCore ne parle que le
    #     protocole d'Overtchat-Hybrid. Anope à la place d'IriX.
    # Le choix se refait après l'étape 3 si le serveur s'y rabat sur UnrealIRCd.
    local chemin="" migr_ok=0 _base_differee=0
    local paquets=() comp
    for comp in site "$srv"; do
        # shellcheck disable=SC2206
        paquets+=( $(_req_for "$comp") )
    done
    if [[ ${#paquets[@]} -gt 0 ]]; then
        _pkg_install ${paquets[@]+"${paquets[@]}"} || echec="les dépendances système"
    fi
    if [[ -z "$echec" && "$srv" == overtchat_ircd ]]; then
        # Le paquet de la distribution ; son échec n'arrête rien : il décide.
        if ! _overcore_node_pret; then
            # shellcheck disable=SC2046
            _pkg_install $(_req_for overcore) \
                || _warn "Paquet Node.js non posé — OverCore ne pourra pas tourner ici."
        fi
        if _overcore_node_pret; then
            chemin=overcore
            _ok "Node.js passe le plancher d'OverCore : les services seront OverCore (sans Eggdrop)."
        else
            chemin=irix
            _warn "Node.js absent ou trop ancien (plancher : 14.14) — l'ancien chemin Eggdrop + IriX est pris."
            reserves+=( "Node.js absent ou trop ancien pour OverCore (plancher 14.14, celui du paquet de la distribution) : l'ANCIEN chemin Eggdrop + IriX a été installé à sa place — IriX est en fin de vie ; installez un Node.js ≥ 14.14 puis relancez l'installation" )
        fi
    elif [[ -z "$echec" ]]; then
        chemin=anope
    fi
    if [[ -z "$echec" ]]; then
        local extras=()
        case "$chemin" in
            irix)  for comp in eggdrop irix; do
                       # shellcheck disable=SC2206
                       extras+=( $(_req_for "$comp") )
                   done ;;
            anope) # shellcheck disable=SC2206
                   extras+=( $(_req_for anope) ) ;;
        esac
        if [[ ${#extras[@]} -gt 0 ]]; then
            _pkg_install ${extras[@]+"${extras[@]}"} || echec="les dépendances système"
        fi
    fi

    # ── 2. Le site, et l'identité qu'il écrit ───────────────────────────
    if [[ -z "$echec" ]]; then
        _section "2/6 — Site et identité du réseau"
        _progres ETAPE "2/6 Site et identité du réseau"
        # 19/10/2026 : chemin OverCore ou Anope sans IriX.json — l'étape 2 laisse la base
        #   aux étapes 5 et 6 (`_site_base_differee`, test-auto.sh § 9 sexies).
        _site_base_differee "$chemin" && _base_differee=1
        _SITE_BASE_DIFFEREE=$_base_differee _site_install || echec="le site"
        # 🔴 08/10/2026 — une Complète sans OverSystem n'est pas une Complète :
        #   réserve du bilan (voir `_oversystem_exiger`), jamais une ligne muette.
        if [[ -n "${OVERSYSTEM_RESERVE:-}" ]]; then
            reserves+=( "$OVERSYSTEM_RESERVE" )
        fi
        # GD manquant n'arrête rien, mais laisse le site INCOMPLET : une
        # réserve, reprise au bilan (03/10/2026, lot 9-A — voir `_site_install`).
        if [[ -z "$echec" ]] && ! php -m 2>/dev/null | grep -qix gd; then
            reserves+=( "l'extension PHP gd est absente : le site refusera toute photo de profil — $(_pkg_install_cmd "$(_pkg_name php-gd)")" )
        fi
    fi

    # ── 3. Le serveur IRC ───────────────────────────────────────────────
    if [[ -z "$echec" ]]; then
        _section "3/6 — Serveur IRC"
        _progres ETAPE "3/6 Serveur IRC"
        local rc_srv=0
        _server_install "$srv" || rc_srv=$?
        # ⚠ Le REPLI TARDIF — 29/09/2026. Le prévol juge la machine avant
        #   `_check_deps` ; l'étape 3 refait le contrôle strict une fois OpenSSL
        #   posé, et essaie le binaire (`-v`). Le code 2 de `_install_overtchat`
        #   dit « pas sur cette machine » : même règle qu'au prévol — le défaut
        #   se rabat sur UnrealIRCd en le disant, un choix explicite échoue.
        if (( rc_srv == 2 )) && [[ "$srv" == overtchat_ircd && "${SERVEUR_AUTO_EXPLICITE:-0}" != 1 ]]; then
            _warn "Serveur IRC : repli sur UnrealIRCd — le serveur du projet ne peut pas tourner sur cette machine (voir ci-dessus)."
            reserves+=( "le serveur du projet (Overtchat-Hybrid) ne peut pas tourner sur cette machine (il exige x86_64, glibc ≥ 2.34 et OpenSSL 3) : UnrealIRCd a été installé à sa place" )
            srv=unrealircd
            rc_srv=0
            # shellcheck disable=SC2046
            _pkg_install $(_req_for unrealircd) || rc_srv=1
            (( rc_srv != 0 )) || _server_install "$srv" || rc_srv=$?
        fi
        (( rc_srv == 0 )) || echec="le serveur IRC ($srv)"
        # 17/10/2026 (lot J2) : un serveur TIERS (repli ci-dessus compris) ne
        #   parle pas le protocole d'OverCore — les services seront Anope.
        if [[ -z "$echec" && "$srv" != overtchat_ircd && "$chemin" != anope ]]; then
            chemin=anope
            # La réserve « Node trop vieux → IriX » ne dit plus vrai : retirée.
            local _r _gardees=()
            for _r in ${reserves[@]+"${reserves[@]}"}; do
                [[ "$_r" == "Node.js absent ou trop ancien"* ]] || _gardees+=( "$_r" )
            done
            reserves=( ${_gardees[@]+"${_gardees[@]}"} )
            # shellcheck disable=SC2046
            _pkg_install $(_req_for anope) || echec="les dépendances d'Anope"
        fi
    fi

    # ── 4. Les services du réseau — selon le chemin (17/10/2026, lot J2) ──────
    #
    # Une seule section quel que soit le chemin : la frise du Lanceur compte SIX
    # repères (Lanceur/tests/test-reperes.js), et elle dit ce qui se passe, pas
    # le programme qui le fait. Avant le 17/10 : « 4/6 Eggdrop », « 5/6 IriX ».
    # ⚠ `IriX.json` (l'accès à la base de TOUT le projet) s'écrit sur les trois
    #   chemins — sans lui, ni migration, ni lien, ni OverCore.
    if [[ -z "$echec" ]]; then
        _section "4/6 — Services du réseau"
        _progres ETAPE "4/6 Services du réseau"
        local _ij _cf
        _ij="$(_irix_install_dir)/${IRIX_CMDS_SUBDIR}/IriX.json"
        case "$chemin" in
            overcore)
                _info "Services : OverCore (Node.js), sans Eggdrop."
                _OVT_SANS_EGGDROP=1 _irix_configure || echec="l'accès à la base (IriX.json)"
                [[ -n "$echec" ]] || _overcore_configurer "$_ij" || echec="la configuration d'OverCore"
                # Le lien 004 se déclare AVANT le premier démarrage du serveur
                # (étape 6) : il lit sa conf au lancement. Même recherche de la
                # conf que l'amorçage (le registre d'abord, `_anope_srv_conf`).
                if [[ -z "$echec" ]]; then
                    for _cf in "$(_anope_srv_conf)" \
                               "${TARGET_HOME}/overtchatd/conf/overtchat.conf" \
                               "${BASE_DIR}/Servers/overtchat-hybrid/conf/overtchat.conf"; do
                        [[ -r "$_cf" ]] && break
                    done
                    if ! _overtchat_lien_overcore "$_cf"; then
                        reserves+=( "le lien d'OverCore (SID 004) n'est PAS déclaré dans la conf du serveur (${_cf}) : OverCore ne pourra pas se relier — voir ci-dessus" )
                    fi
                fi
                ;;
            irix)
                _info "Services : Eggdrop + IriX (Node.js manque — voir la réserve)."
                _service_install eggdrop || echec="Eggdrop"
                [[ -n "$echec" ]] || _irix_sync_source || echec="la synchronisation des sources IriX"
                [[ -n "$echec" ]] || _irix_configure || echec="la configuration d'IriX"
                [[ -n "$echec" ]] || _irix_deploy    || echec="le déploiement d'IriX"
                ;;
            anope)
                # ⚠ LA RÈGLE D'ANOPE, LUE (`_anope_construire_cli`, décision de
                #   SerVuS du 17/10) : on CONSTRUIT, on ne DÉMARRE PAS — la
                #   bascule se fait au panel, à part. `--auto` la suit : Anope
                #   est construit ici et laissé arrêté, ce qui est une RÉSERVE
                #   (étape 6) : une installation sans service n'est pas « terminée ».
                _info_f "Services : Anope (serveur tiers %s — OverCore ne parle que le protocole d'Overtchat-Hybrid)." "$srv"
                _OVT_SANS_EGGDROP=1 _irix_configure || echec="l'accès à la base (IriX.json)"
                if [[ -z "$echec" ]] && ! _ANOPE_SANS_CLAVIER=1 _service_install anope; then
                    reserves+=( "Anope n'a pas pu être construit (voir ci-dessus) : ce réseau n'a AUCUN service — panel, « Installer / reconstruire Anope »" )
                fi
                ;;
            *)
                echec="le choix des services (chemin inconnu : ${chemin:-vide})"
                ;;
        esac

        # ── Les modèles des ROBOTS DE PRÊT — 29/09/2026 ──────────────────────
        #
        # ⚠⚠ Mesuré sur une installation tierce (journalctl -u
        #   overtchat-botpool, 1er passage du minuteur, 3 min après le
        #   démarrage) : « !! modèle introuvable :
        #   <racine>/Robots/overop/overop.conf.exemple », code 1, unité en
        #   ÉCHEC — puis toutes les cinq minutes. L'étape 6 active
        #   `overtchat-botpool.timer`, dont le script (Tools/overop-pool.php)
        #   exige les deux fichiers de Robots/overop AVANT même de regarder la
        #   file ; or `site.tar.gz` n'emporte pas Robots/, et seul le menu
        #   OverOp les synchronisait. `--auto` activait donc un minuteur
        #   voué à l'échec. On les tirait ici, du site.
        # 🔴 04/10/2026 — RETIRÉ, avec ce qui l'exigeait : décision de SerVuS,
        #   la file d'installation des robots est supprimée (Tools/overop-pool.php
        #   et `overtchat-botpool.{service,timer}` ont quitté le dépôt ; le parc
        #   vit dans OverPur et OverBot). `--auto` n'a plus de minuteur qui lise
        #   Robots/overop : le menu OverOp, lui, synchronise encore à la demande
        #   (`_overop_deploy` appelle `_overop_sync_source` s'il manque un fichier).
        #   Gardé par Bootstrap/tests/test-auto.sh §8.
    fi

    # ── 5. La base : les migrations, maintenant qu'on sait s'y connecter ─────
    if [[ -z "$echec" ]]; then
        _section "5/6 — Base de données"
        _progres ETAPE "5/6 Base de données"
        # ── 🔴🔴 LES MIGRATIONS, MAINTENANT QU'ON SAIT S'Y CONNECTER ────────
        #
        # L'étape 2 les a déjà tentées — et sur une installation NEUVE elle ne
        # pouvait pas réussir : `IriX.json` porte l'hôte, la base et les
        # identifiants, et il n'est écrit qu'à l'étape 4 (`_base_acces_ecrire`).
        # On migrait donc la base AVANT de savoir comment s'y connecter.
        #
        # 📌 Mesuré sur une installation tierce le 05/09/2026 : les vingt-et-une
        # migrations n'étaient jamais appliquées, et le site s'ouvrait avec des
        # tables manquantes. Corriger le CHEMIN que cherchait `apply.sh` ne
        # suffisait pas — il ne peut pas trouver un fichier qui n'existe pas
        # encore. *Un défaut d'ORDRE ne se répare pas en corrigeant une adresse.*
        #
        # ⚠⚠ CE SECOND PASSAGE EST SÛR PARCE QUE LES MIGRATIONS SONT
        # REJOUABLES, et ce n'est pas une supposition :
        # `Bootstrap/tests/test-migrations.sh` le garde, constat par constat —
        # `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, le test sur
        # `information_schema`, `INSERT … ON DUPLICATE`. Sans cette garantie
        # écrite, rejouer serait un pari.
        #
        # 🔴 17/10/2026 (lot J2) — PLUS D'ATTENTE DES TABLES DU BOT. Depuis le
        #   socle (Web/sql/000-socle-irix.sql, 5ca97ee0), la base naît SANS
        #   IriX : les tables que le Tcl créait à son premier démarrage le sont
        #   par la migration 000. Le « troisième passage » d'après le démarrage
        #   d'IriX (et sa cause « que le BOT crée ») est retiré : une migration
        #   qui bute encore sur une table absente est une migration NON
        #   appliquée, dite comme telle — il n'y a plus de bot pour la sauver.
        #
        # ⚠ On ne fait rien échouer ici : une base absente reste une réserve,
        # comme à l'étape 2. L'installation aboutit, et le message dit quoi faire.
        if [[ -x "${BASE_DIR}/Web/sql/apply.sh" ]]; then
            if [[ "$_base_differee" == 1 ]]; then
                _info "Migrations du site, IriX.json étant désormais écrit…"
            else
                _info "Migrations du site — second passage, IriX.json étant désormais écrit…"
            fi
            local _sql2
            if _sql2="$("${BASE_DIR}/Web/sql/apply.sh" 2>&1)"; then
                _ok "Migrations appliquées."
                migr_ok=1
            else
                _warn "Migrations toujours non appliquées — ce que l'applicateur a dit :"
                printf '%s\n' "$_sql2" | sed 's/^/      /' >&2
                _kv "À relancer" "${BASE_DIR}/Web/sql/apply.sh"
                # ⚠⚠ DEUX CAUSES, DEUX RÉSERVES — 29/09/2026, revu le 17/10.
                #   « ABANDON : connexion refusée. » est la seule phrase
                #   d'apply.sh quand la base ne répond pas (Web/sql/apply.sh) :
                #   elle seule pose `base_muette`. Le 29/09, toutes les causes
                #   disaient « la base ne répond pas » — la réserve envoyait le
                #   tiers créer une base qui existait.
                if [[ "$_sql2" == *"ABANDON : connexion refusée."* ]]; then
                    # ⚠ L'installateur ne crée PAS la base, par choix (INSTALL.md,
                    #   § 2 bis « La base de données ») : sur une machine neuve,
                    #   c'est ICI que ça se voit, et c'est la réserve la plus fréquente.
                    reserves+=( "la base du site ne répond pas : migrations NON appliquées — créez la base et le compte (INSTALL.md, § 2 bis « La base de données »), puis ${BASE_DIR}/Web/sql/apply.sh" )
                    base_muette=1
                else
                    reserves+=( "migrations NON appliquées — voir ce qu'a dit l'applicateur ci-dessus, puis ${BASE_DIR}/Web/sql/apply.sh" )
                fi
            fi
        else
            reserves+=( "l'applicateur des migrations est absent (${BASE_DIR}/Web/sql/apply.sh) : la base n'a pas été créée" )
        fi
    fi

    # ── 6. Les unités systemd ───────────────────────────────────────────
    # ⚠ Poser n'est pas activer, et `_sysd_activer` n'active que ce dont le
    # prérequis est vérifiable — point 41. Les deux gestes restent séparés ici
    # comme ils le sont dans le menu.
    if [[ -z "$echec" ]]; then
        _section "6/6 — Services système (systemd)"
        _progres ETAPE "6/6 Services système (systemd)"
        # ── 🔴 LES FICHIERS À LEUR COMPTE, AVANT TOUTE UNITÉ — 29/09/2026 ────
        #
        # Mesuré sur une installation tierce par « sudo -E » (la voie
        # documentée) : seule l'ARBORESCENCE était rendue au compte d'origine
        # (`_priv_fix_owner "$BASE_DIR"` à sa création). Tout ce que les étapes
        # 2 à 5 posent ensuite restait à root — dont `~/IriX/runtime/irix.conf`
        # (640 root). IriX, qui tourne sous ce compte, ne pouvait pas lire sa
        # conf : « MSG534 » (CONFIG FILE NOT LOADED). INSTALL.md promettait
        # pourtant « réattribue les fichiers créés à cet utilisateur en fin
        # d'opération ». Sans effet quand on tourne en root nu (PRIV_USER=root).
        _priv_fix_owner "$BASE_DIR"
        _priv_fix_owner "$(_irix_install_dir)"
        _sysd_poser   || echec="la pose des unités systemd"
        [[ -n "$echec" ]] || _sysd_activer || echec="l'activation des unités systemd"

        # ── 🔴 LES FICHIERS SYSTÈME — AJOUTÉ LE 06/09/2026 ────────────────
        #
        # ⚠⚠ ILS N'ÉTAIENT POSÉS PAR AUCUNE INSTALLATION AUTOMATIQUE, et c'est
        # une régression PAR OMISSION : le point 43 a fermé exactement ce
        # défaut le 24/08/2026 — « quatre familles de fichiers système
        # n'atteignaient AUCUNE installation tierce » — mais il l'a fermé pour
        # le MENU. Le mode `--auto`, écrit ensuite (point 48), n'a pas repris le
        # correctif : `_sys_poser` n'avait qu'un seul appelant, l'entrée 3 du
        # panneau systemd.
        #
        # 📌 *Une fonctionnalité ajoutée après un correctif ne l'hérite pas.*
        # C'est le motif de famille de ce dépôt — une correction appliquée à un
        # endroit sur deux — vu ici dans le temps plutôt que dans l'espace.
        #
        # Mesuré le 06/09/2026 sur une Ubuntu 24.04 : installation automatique
        # menée à son terme, code 0, quatorze unités posées — et **zéro** fichier
        # dans `/etc/sudoers.d`, `/etc/polkit-1/rules.d`, `/etc/logrotate.d` et
        # `sites-available`, alors que `visudo`, `pkaction`, `logrotate` et
        # `a2ensite` étaient tous présents.
        #
        # ⚠ On ne fait PAS échouer l'installation : même doctrine que les
        # migrations quinze lignes plus haut. Un fichier système qui ne se pose
        # pas est un avertissement, pas une raison de tout arrêter — et
        # `_sys_poser` dit déjà précisément ce qu'elle n'a pas pu faire.
        if [[ -z "$echec" ]]; then
            _sys_poser || _warn "Des fichiers système n'ont pas pu être posés — voir ci-dessus."
        fi

        # ── Le LIEN DE SERVICES s'amorce seul ────────────────────────────
        #
        # Le lien ULine (serveur↔services) n'existe pas sur une base neuve : il
        # faut une ligne dans `<bot>_ulines_sockets`, dérivée du bloc `link` du
        # serveur, et un mot de passe. Les deux se font ICI, seuls — plus aucune
        # saisie ni SID à deviner. C'est le trou qu'une réinstallation à neuf a
        # révélé le 09/09/2026. On ne fait PAS échouer l'installation : même
        # doctrine que les migrations et les fichiers système ci-dessus.
        # 🔴 17/10/2026 (lot J2) : la table existe dès les migrations (le socle,
        #   000-socle-irix.sql) — l'« attente que le bot crée ses tables » (18 ×
        #   5 s) et le troisième passage des migrations sont RETIRÉS. Ce qui
        #   décide désormais de l'amorçage, c'est `migr_ok` (étape 5).
        if [[ -z "$echec" ]]; then
            local _jl _irix_vit=0 _oc_vit=0
            _ij="$(_irix_install_dir)/${IRIX_CMDS_SUBDIR}/IriX.json"
            # Le overtchat.conf de l'instance (serveur maison) ; sinon on saute.
            # ⚠ `_anope_srv_conf` EN TÊTE — 29/09/2026 : elle lit le registre
            #   (`config`, que `_overtchat_poser_conf` écrit) puis
            #   <install_dir>/conf. Les deux chemins écrits en dur ci-dessous
            #   sont ceux de la production (install-service.sh) et d'un clone ;
            #   une installation tierce pose son serveur ailleurs
            #   (<racine>/Servers/overtchat-ircd), et l'amorçage était SAUTÉ
            #   sans un mot (mesuré sur la première installation tierce).
            for _cf in "$(_anope_srv_conf)" \
                       "${TARGET_HOME}/overtchatd/conf/overtchat.conf" \
                       "${BASE_DIR}/Servers/overtchat-hybrid/conf/overtchat.conf"; do
                [[ -r "$_cf" ]] && break
            done
            if [[ "$base_muette" == 1 ]]; then
                # ⚠ Démarrer des services sur une base qui ne répond pas ne
                #   mène nulle part : on le dit, et la réserve de la base suffit
                #   à dire quoi faire.
                _info "Lien de services — non amorcé : la base ne répond pas (voir la réserve ci-dessous)."
                if [[ "$chemin" == overcore ]]; then
                    reserves+=( "OverCore n'est PAS démarré : la base ne répond pas — ce réseau n'a AUCUN service tant que la base n'est pas créée (puis relancez l'installation)" )
                fi
            else
                # ── 🔴 LA MISE EN ROUTE, UNE FOIS — 29/09/2026 ─────────────────
                #
                # Mesuré sur deux installations tierces complètes : `--auto`
                # posait tout et ne DÉMARRAIT rien. L'installation s'annonçait
                # donc incomplète par CONSTRUCTION.
                #
                # 📌 Le geste retenu est le plus sûr des deux : `systemctl start`,
                #   JAMAIS `enable`. Le serveur (si c'est le nôtre), le site puis
                #   les services tournent pour finir l'installation ; rien ne
                #   redémarrera avec la machine tant que l'exploitant ne l'a pas
                #   décidé — la doctrine de `_sysd_activer` (« installé » n'est
                #   pas « en service ») reste entière. Un démarrage raté se DIT
                #   (journal) et devient une réserve ; il n'interrompt rien.
                # ⚠ Le serveur d'abord : les services s'y relient, et le lien
                #   ULine exige un serveur qui écoute. Sur le chemin IriX, son
                #   lien des services est nommé d'après le robot AVANT qu'il lise
                #   sa conf ; sur le chemin OverCore, le bloc 004 l'est depuis
                #   l'étape 4.
                if [[ "$srv" == overtchat_ircd && -r "$_cf" && -f /etc/systemd/system/overtchatd.service ]]; then
                    if [[ "$chemin" == irix && -r "$_ij" ]] && ! _overtchat_lien_du_bot "$_cf" "$_ij"; then
                        reserves+=( "le lien des services n'est pas nommé d'après le robot dans ${_cf} (bloc « link <robot>.… ») : l'amorçage ne peut rien en extraire" )
                    fi
                    if _as_root systemctl start overtchatd.service 2>/dev/null; then
                        _ok "Serveur IRC démarré (overtchatd.service) — démarré, PAS activé au démarrage de la machine."
                        demarres+=( overtchatd.service )
                    else
                        _warn "Le serveur IRC n'a pas démarré — journalctl -u overtchatd.service"
                        reserves+=( "le serveur IRC n'a pas démarré — journalctl -u overtchatd.service, puis systemctl start overtchatd.service" )
                    fi
                fi
                # Le site (panel + pages publiques, php -S 127.0.0.1:8080) : même
                # geste, même doctrine. Sans lui, « installé » ne répondait nulle part.
                if [[ -f /etc/systemd/system/overtchat-web.service ]] \
                   && _sysd_execstart_ok /etc/systemd/system/overtchat-web.service; then
                    if _as_root systemctl start overtchat-web.service 2>/dev/null; then
                        _ok "Site démarré (overtchat-web.service, http://127.0.0.1:8080) — démarré, PAS activé au démarrage de la machine."
                        demarres+=( overtchat-web.service )
                    else
                        _warn "Le site n'a pas démarré — journalctl -u overtchat-web.service"
                        reserves+=( "le site n'a pas démarré — journalctl -u overtchat-web.service, puis systemctl start overtchat-web.service" )
                    fi
                fi
                # Les salons que l'étape 2 n'a pas pu inscrire (base pas encore
                # jointe : IriX.json n'existait pas) : les migrations sont passées,
                # les tables sont là (le socle) — on le refait. 29/09/2026, revu
                # le 17/10 (lot J2) : décidé par `migr_ok`, plus par les tables du bot.
                if [[ "$migr_ok" == 1 && -n "${SALONS_A_INSCRIRE:-}" ]]; then
                    if _site_inscrire_salons "$SALONS_A_INSCRIRE"; then
                        SALONS_A_INSCRIRE=""
                    fi
                fi
                # ⚠ 02/10/2026 : les salons inscrits dès l'étape 2 (base déjà
                #   là) n'ont pu être OUVERTS qu'avec un serveur posé — il ne
                #   l'est qu'à l'étape 3. On le refait ici, une fois.
                if [[ "$migr_ok" == 1 && -z "${SALONS_A_INSCRIRE:-}" && "${SALONS_OUVERTS:-0}" != 1 ]]; then
                    local _rco=0
                    _site_ouvrir_salons || _rco=$?
                    if [[ "$_rco" == 1 ]]; then
                        reserves+=( "les salons inscrits ne sont PAS ouverts sur le serveur (salons-interdits.conf) — voir ci-dessus, puis bash ${BASE_DIR}/Tools/salons-exceptions.sh --appliquer" )
                    fi
                fi

                case "$chemin" in
                irix)
                if ! _sysd_compte >/dev/null; then
                    # ⚠ Mesuré le 29/09/2026 (installation lancée en root SANS
                    #   SUDO_USER, tout sous /root) : « ERROR: Eggdrop will not run
                    #   as root! ». Le démarrer ne peut qu'échouer : on le dit, avec
                    #   le remède, au lieu d'un journal à déchiffrer.
                    _warn "IriX n'est pas démarré : ses unités tournent sous root (installation lancée en root sans compte d'origine), et Eggdrop refuse root."
                    reserves+=( "IriX ne peut pas tourner sous root (Eggdrop le refuse) : installez depuis un compte ordinaire, par « sudo -E » (INSTALL.md, mode --auto)" )
                elif [[ -f /etc/systemd/system/irix.service ]] && _sysd_execstart_ok /etc/systemd/system/irix.service; then
                    if _as_root systemctl start irix.service 2>/dev/null; then
                        # Cinq secondes : Eggdrop lit sa conf et charge le Tcl ;
                        # une erreur de chargement le fait tomber avant.
                        sleep 5
                        if systemctl is-active --quiet irix.service 2>/dev/null; then _irix_vit=1; fi
                    fi
                    if [[ "$_irix_vit" == 1 ]]; then
                        _ok "IriX démarré (irix.service) — démarré, PAS activé au démarrage de la machine."
                        demarres+=( irix.service )
                    else
                        _warn "IriX n'a pas démarré par son unité — ses dernières lignes :"
                        _jl="$(journalctl -u irix.service -n 8 --no-pager 2>&1)" || _jl=""
                        printf '%s\n' "$_jl" | sed 's/^/      /' >&2
                        # ⚠ `Restart=on-failure` le relancerait sans fin (mesuré le
                        #   29/09 : NRestarts=10 en deux minutes) : on l'arrête.
                        _as_root systemctl stop irix.service 2>/dev/null \
                            || _info "irix.service : l'arrêt a échoué — systemctl stop irix.service"
                        reserves+=( "IriX n'a pas démarré (journalctl -u irix.service) : le lien de services reste en attente" )
                    fi
                else
                    _warn "irix.service absente, ou son programme introuvable : IriX n'est pas démarré."
                    reserves+=( "IriX n'est pas démarré : irix.service absente ou son programme introuvable — voir le menu « Services système »" )
                fi
                if [[ "$_irix_vit" != 1 || ! -r "$_ij" ]]; then
                    : # dit plus haut (réserve IriX) — rien à amorcer sans le bot.
                elif [[ ! -r "$_cf" || ! -x "${BASE_DIR}/Tools/uline-bootstrap.sh" ]]; then
                    # ⚠ Avant le 29/09/2026 cette branche n'existait pas : sans conf
                    #   de serveur maison, l'amorçage était sauté SANS UN MOT.
                    _info "Lien de services — non amorcé ici : pas de conf Overtchat-Hybrid lisible (autre serveur IRC : lien à déclarer dans sa propre conf)."
                elif [[ "$migr_ok" == 1 ]]; then
                    # ── 🔴🔴 LE CODE DE RETOUR NE SE JETTE PAS — 23/09/2026 ──
                    #
                    # Ces deux gestes portaient `>/dev/null 2>&1 || true` et un
                    # pipe vers `sed` : leurs codes de retour disparaissaient
                    # tous les deux, et le `_ok` d'en dessous ne dépendait que
                    # de « la table existe », mesuré AVANT eux. L'installation
                    # annonçait donc « le réseau s'assemble seul » alors que le
                    # lien pouvait n'être pas monté du tout.
                    #
                    # ⚠⚠ Le cas 4 est le plus traître : la rotation est annulée
                    #    et le mot de passe par DÉFAUT reste en place — un
                    #    secret public sur une installation neuve, annoncé
                    #    comme un succès.
                    #
                    # 📌 C'était la SEULE occurrence de `>/dev/null 2>&1 || true`
                    #    du fichier, et la seule `|| true` de toute
                    #    `_auto_installer`. Le motif juste est celui de Leaflet
                    #    plus haut : capturer, tester, dire quoi rejouer.
                    local _rc=0 _rcb=0 _sortie="" _sortieb=""
                    # ⚠⚠ L'AMORÇAGE D'ABORD, LA ROTATION ENSUITE — 29/09/2026.
                    #   Dans l'autre ordre, `uline-secrets.sh` mettait à jour une
                    #   ligne `<bot>_ulines_sockets` qui n'existait pas encore
                    #   (UPDATE sans effet), relançait le bot et attendait 80 s un
                    #   lien qui ne pouvait pas monter. La ligne posée, la rotation
                    #   a quelque chose à tourner.
                    _sortieb="$(bash "${BASE_DIR}/Tools/uline-bootstrap.sh" --conf "$_cf" --json "$_ij" 2>&1)" || _rcb=$?
                    printf '%s\n' "$_sortieb" | sed 's/^/    /'

                    # ⚠ Ses chemins par défaut sont ceux de la PRODUCTION
                    #   (/home/servus/…) et il posait la conf en servus:servus :
                    #   chez un tiers, rien de cela n'existe. On lui passe ceux de
                    #   CETTE installation (variables prévues par le script).
                    _sortie="$(OVERTCHAT_CONF="$_cf" IRIX_JSON="$_ij" \
                               ULINE_BOOTSTRAP="${BASE_DIR}/Tools/uline-bootstrap.sh" \
                               ULINE_PROPRIO="$(_sysd_compte)" \
                               bash "${BASE_DIR}/Tools/uline-secrets.sh" 2>&1)" || _rc=$?
                    case "$_rc" in
                        0) : ;;
                        4) _warn "Secret du lien NON tourné — le mot de passe par défaut reste en place. À rejouer : bash Tools/uline-secrets.sh --force" ;;
                        6|7) _warn "Secret du lien : rien n'a été écrit (conf ou rechargement impossible). À rejouer : bash Tools/uline-secrets.sh" ;;
                        5|8) _warn "Secret du lien tourné, mais le lien n'est PAS remonté. La conf d'avant est dans overtchat.conf.avant-rotation-*" ;;
                        *) _warn_f "Secret du lien : code de retour inattendu (%s)." "$_rc" ;;
                    esac
                    printf '%s\n' "$_sortie" | sed 's/^/      /'

                    if [[ "$_rc" == 0 && "$_rcb" == 0 ]]; then
                        _ok "Lien de services amorcé — le réseau s'assemble seul."
                    else
                        _warn "Lien de services : l'amorçage n'est PAS complet — voir ci-dessus. À rejouer : bash Tools/uline-bootstrap.sh"
                        reserves+=( "le lien de services n'est pas amorcé — bash ${BASE_DIR}/Tools/uline-bootstrap.sh" )
                    fi
                else
                    _warn "Les migrations ne sont pas passées : lien de services NON amorcé — lancez « Tools/uline-bootstrap.sh » une fois la base prête."
                    reserves+=( "migrations non appliquées : lien de services non amorcé — bash ${BASE_DIR}/Tools/uline-bootstrap.sh une fois la base prête" )
                fi
                ;;

                overcore)
                # ── 🔴🔴 OVERCORE : UNE INSTALLATION NEUVE SANS AUCUN SERVICE —
                #    LE RISQUE N° 1 DU LOT J2 (17/10/2026) ──────────────────────
                #
                # Sans Eggdrop, OverCore est le SEUL service du réseau. Il se dit
                # « démarré » dès que Node tourne — mais un OverCore sans ligne,
                # sans service ou sans lien n'introduit personne. Le seul fait qui
                # compte est la LIGNE de sa socket passée à `socket_state='active'`
                # (lib/base.js, ETATS_LIGNE : OverCore l'écrit à l'établissement du
                # lien). On l'ATTEND, bornée ; sans elle, une RÉSERVE — jamais
                # « terminée ». Dans l'ordre : la ligne (OverCore ne l'écrit jamais
                # lui-même et refuse sans elle), PUIS l'unité, PUIS l'attente.
                local _rcbo=0 _sortiebo="" _etat="" _vu=0 _k
                if [[ ! " ${demarres[*]} " == *" overtchatd.service "* ]]; then
                    _warn "OverCore n'est pas démarré : le serveur IRC ne tourne pas (voir ci-dessus)."
                    reserves+=( "OverCore n'est PAS démarré : le serveur IRC ne tourne pas — ce réseau n'a AUCUN service ; systemctl start overtchatd.service puis overcore.service" )
                elif [[ "$migr_ok" != 1 ]]; then
                    _warn "OverCore n'est pas démarré : les migrations ne sont pas passées (voir ci-dessus)."
                    reserves+=( "OverCore n'est PAS démarré : migrations non appliquées — ce réseau n'a AUCUN service ; ${BASE_DIR}/Web/sql/apply.sh, puis relancez l'installation" )
                elif [[ ! -r "$_cf" || ! -x "${BASE_DIR}/Tools/uline-bootstrap.sh" ]]; then
                    _warn "OverCore n'est pas démarré : conf du serveur ou amorce du lien introuvable."
                    reserves+=( "OverCore n'est PAS démarré : conf du serveur (${_cf}) ou Tools/uline-bootstrap.sh introuvable — ce réseau n'a AUCUN service" )
                else
                    _sortiebo="$(bash "${BASE_DIR}/Tools/uline-bootstrap.sh" --overcore --conf "$_cf" --json "$_ij" 2>&1)" || _rcbo=$?
                    printf '%s\n' "$_sortiebo" | sed 's/^/    /'
                    if [[ "$_rcbo" != 0 ]]; then
                        _warn_f "La ligne d'OverCore n'a pas été posée en base (code %s) — voir ci-dessus." "$_rcbo"
                        reserves+=( "la ligne d'OverCore (lien 004) n'est pas posée en base : OverCore n'est PAS démarré — ce réseau n'a AUCUN service ; bash ${BASE_DIR}/Tools/uline-bootstrap.sh --overcore" )
                    elif [[ -f /etc/systemd/system/overcore.service ]] && _sysd_execstart_ok /etc/systemd/system/overcore.service; then
                        if _as_root systemctl start overcore.service 2>/dev/null; then
                            _info "OverCore démarré (overcore.service) — attente de son lien (150 s au plus)…"
                            # ⚠ BORNÉ : 30 × 5 s. OverCore réessaie seul (2, 5, 10…
                            #   120 s) et Restart=on-failure le relance 15 s après
                            #   une sortie : 150 s couvrent ses premières reprises.
                            # ⚠ LE PLANCHER : « la base n'a jamais répondu » n'est
                            #   pas « le lien tarde » (`_vu`) — dits autrement.
                            for _k in $(seq 1 30); do
                                sleep 5
                                _etat="$(_overcore_etat_lien "$_ij")" && _vu=1
                                [[ "$_etat" == active ]] && { _oc_vit=1; break; }
                            done
                        fi
                        if [[ "$_oc_vit" == 1 ]]; then
                            _ok "OverCore relié (socket « OverCore » active) — démarré, PAS activé au démarrage de la machine."
                            demarres+=( overcore.service )
                        elif ! systemctl is-active --quiet overcore.service 2>/dev/null; then
                            _warn "OverCore n'a pas démarré par son unité — ses dernières lignes :"
                            _jl="$(journalctl -u overcore.service -n 8 --no-pager 2>&1)" || _jl=""
                            printf '%s\n' "$_jl" | sed 's/^/      /' >&2
                            # Comme IriX : `Restart=on-failure` le relancerait sans fin.
                            _as_root systemctl stop overcore.service 2>/dev/null \
                                || _info "overcore.service : l'arrêt a échoué — systemctl stop overcore.service"
                            reserves+=( "OverCore n'a pas démarré (journalctl -u overcore.service) : ce réseau n'a AUCUN service" )
                        elif [[ "$_vu" != 1 ]]; then
                            _warn "OverCore tourne, mais la base n'a JAMAIS répondu à la lecture de son lien."
                            reserves+=( "OverCore tourne mais l'état de son lien est illisible en base (la base n'a jamais répondu) : services NON constatés — journalctl -u overcore.service" )
                        else
                            _warn_f "OverCore tourne, mais son lien n'est pas actif après 150 s (état : %s)." "${_etat:-?}"
                            reserves+=( "OverCore tourne mais son lien n'est PAS actif (état « ${_etat:-?} » après 150 s) : services NON constatés — journalctl -u overcore.service" )
                        fi
                    else
                        _warn "overcore.service absente, ou son programme introuvable (node) : OverCore n'est pas démarré."
                        reserves+=( "OverCore n'est PAS démarré : overcore.service absente ou node introuvable — ce réseau n'a AUCUN service" )
                    fi
                fi
                ;;

                anope)
                # La règle d'Anope (`_anope_construire_cli`) : construit, JAMAIS
                # démarré par l'installation — la bascule se fait au panel.
                _info "Anope n'est PAS démarré par l'installation (règle : la bascule se fait au panel)."
                reserves+=( "les services (Anope) ne sont PAS démarrés — c'est la règle d'Anope : la bascule se fait au panel (« Services »), ou systemctl start anope.service une fois sa configuration relue" )
                ;;
                esac
            fi
        fi
    fi

    # Les salons que ni l'étape 2 ni l'étape 6 n'ont pu inscrire : la réserve.
    if [[ -z "$echec" && -n "${SALONS_A_INSCRIRE:-}" ]]; then
        reserves+=( "le salon ${SALONS_A_INSCRIRE} et #Help ne sont PAS inscrits au registre du réseau (table des salons) — à refaire une fois la base prête (menu du site, ou relancer l'installation)" )
    fi

    printf "\n"
    if [[ -n "$echec" ]]; then
        _err_f "Installation automatique interrompue : %s." "${echec}"
        _info "Ce qui précède est en place. Relancez sans --auto pour reprendre au menu."
        _progres ECHEC "$echec"
        return 1
    fi

    # ── CE QUI TOURNE, ET CE QUI NE REPARTIRA PAS SEUL — 29/09/2026 ─────────
    # La fin disait « démarrez depuis le menu » : faux depuis que l'étape 6
    # démarre le serveur, le site et IriX. On dit ce qui est VRAI — ce qui a
    # démarré, que rien n'est activé au démarrage de la machine, et le geste
    # pour l'activer —, dans les deux fins (avec ou sans réserves).
    if [[ ${#demarres[@]} -gt 0 ]]; then
        _info_f "En service maintenant : %s." "${demarres[*]}"
        _info "Démarrés, PAS activés : ils ne repartiront pas seuls au prochain redémarrage de la machine."
        _info_f "Pour qu'ils repartent : sudo systemctl enable %s" "${demarres[*]}"
    else
        _info "Rien n'a été démarré par l'installation (voir ci-dessus)."
    fi
    if [[ -n "${OPER_ADMIN_FICHIER:-}" ]]; then
        _info_f "Mot de passe de l'opérateur IRC « admin » : %s (600, lisible par vous seul)." "$OPER_ADMIN_FICHIER"
    fi

    if [[ ${#reserves[@]} -gt 0 ]]; then
        _warn_f "Installation automatique terminée AVEC %s RÉSERVE(S) — elle n'est pas complète :" "${#reserves[@]}"
        local r
        for r in "${reserves[@]}"; do
            printf "    %s %s\n" "${ORANGE}·${RESET}" "$r"
            _progres RESERVE "$r"
        done
        printf "\n"
        _info "Vérifiez l'état avec « $0 status » une fois ces points réglés."
        # 3 et non 0 : un automate qui enchaîne sur « code 0 = prêt » ne doit
        # pas croire prête une installation sans base. 3 et non 1 : ce qui est
        # posé l'est bien, et 1 veut dire « interrompue ».
        return 3
    fi

    _ok "Installation automatique terminée."
    # ⚠ On ne dit pas « le réseau est en service » au-delà de ce qui a été
    # MESURÉ (la liste ci-dessus) : « status » le dit, lui, par mesure.
    _info "Vérifiez l'état avec « $0 status »."
    return 0
}

main() {
    local force_mode="" commande=""

    # Boucle `while … shift` et non `for a in "$@"` : une sous-commande peut
    # un jour prendre son propre argument, et une boucle `for` ne sait pas
    # consommer le suivant. Le coût est nul aujourd'hui, la réécriture ne le
    # serait plus une fois trois commandes en place.
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --help|-h) _usage; exit 0 ;;
            --root)    force_mode="root" ;;
            --user)    force_mode="user"; FORCE_USER_MODE=1 ;;

            # Installer sans personne devant — point 48. Ce qu'il faut avoir
            # est contrôlé par `_auto_prevol`, plus bas, avant toute écriture.
            --auto)    MODE_AUTO=1 ;;
            --debug)   : ;;   # déjà traité avant `set -Eeuo`, pour couvrir le démarrage

            # Le nom de l'installation. Déjà lu tout en haut du fichier, avant
            # $BASE_DIR — il n'y a donc rien à en faire ici, sinon ne pas le
            # prendre pour une faute de frappe. C'est aussi la forme sous
            # laquelle l'assistant se relance après avoir posé la question.
            # ── L'ÉDITION — 01/09/2026, phase 3 du chantier des éditions ──
            #
            # ⚠⚠ POURQUOI ELLE N'EXISTAIT PAS, ET CE QUE ÇA COÛTAIT. Le champ
            # `project.edition` avait TROIS lecteurs — un par langage — et
            # aucun écrivain. Pire : mesuré le 01/09 sur l'archive réellement
            # servie, `Config/config.json` n'y figurait pas et rien ne le
            # créait. Une installation tierce n'avait donc aucun fichier où
            # poser son édition, les trois lecteurs rendaient « beta » par
            # défaut prudent, et **aucune autre édition n'était atteignable**.
            #
            # ⚠ La valeur n'est PAS validée ici : `Tools/edition.sh` porte la
            # liste des éditions connues, et la valider une seconde fois ici
            # recréerait la liste centrale que ce fichier interdit partout
            # ailleurs. `_edition_poser` s'en charge, avec la vraie liste.
            --edition=*) EDITION_CHOISIE="${1#--edition=}" ;;

            # Même refus que `--projet` : la forme séparée n'est pas acceptée.
            # Le dire franchement vaut mieux qu'installer une édition autre que
            # celle qu'on croit avoir demandée.
            --edition)
                _err "Écrivez --edition=NOM, avec le signe égal."
                exit 2
                ;;

            # MONTER l'édition d'une install existante (point 90, étape 5). C'est
            # une commande à valeur : elle vise une install déjà posée, comme
            # update, mais porte l'édition cible.
            --monter-edition=*)
                if [[ -n "$commande" ]]; then
                    _err "Une seule commande à la fois (« $commande » puis « monter-edition »)."
                    exit 2
                fi
                commande="monter-edition"
                MONTER_CIBLE="${1#--monter-edition=}"
                ;;
            --monter-edition)
                _err "Écrivez --monter-edition=NOM, avec le signe égal."
                exit 2
                ;;
            --projet=*) : ;;

            # La forme séparée n'est pas acceptée : `_resolve_project_name`
            # tourne avant toute boucle d'arguments et ne lit qu'un jeton. Le
            # dire franchement vaut mieux que d'installer sous un autre nom
            # que celui qu'on croit avoir donné.
            --projet)
                _err "Écrivez --projet=NOM, avec le signe égal."
                exit 2
                ;;

            # Les sous-commandes. Une seule à la fois : « update status »
            # n'aurait pas de sens, et le silence sur la seconde en aurait
            # encore moins.
            # 17/10/2026 : l'archive d'Anope à construire, contrôlée à
            # l'empreinte par `_anope_construire_cli` (n'a de sens que pour elle).
            --archive=*)
                ANOPE_ARCHIVE="${1#--archive=}"
                ;;

            check|update|upgrade|status|history|rollback|version|anope-config|anope-construire|check-compat)
                if [[ -n "$commande" ]]; then
                    _err "Une seule commande à la fois (« $commande » puis « $1 »)."
                    exit 2
                fi
                commande="$1"
                ;;

            # Une option inconnue ARRÊTE le script. Elle se contentait d'un
            # avertissement, puis l'installation se poursuivait : une faute de
            # frappe sur « --root » lançait donc une installation en mode
            # utilisateur, sans élévation et sans que personne l'ait voulu.
            *)         _err "Option inconnue : $1"; _usage; exit 2 ;;
        esac
        shift
    done

    # `--auto` porte sur l'INSTALLATION. Les sous-commandes ne posent déjà
    # aucune question — sauf `rollback` et `anope-config`, que ce drapeau ne
    # saurait pas remplir. Les mélanger ferait croire à un effet qui n'existe
    # pas, ou à une automatisation qui n'aura pas lieu.
    if [[ "${MODE_AUTO:-0}" == "1" && -n "$commande" ]]; then
        _err_f "--auto s'applique à l'installation, pas à la commande « %s »." "$commande"
        exit 2
    fi
    # 17/10/2026 : `--archive=` ne vaut que pour `anope-construire` — accepté
    # ailleurs, il ferait croire à une archive prise en compte qui ne l'est pas.
    if [[ -n "${ANOPE_ARCHIVE:-}" && "$commande" != "anope-construire" ]]; then
        _err "--archive=CHEMIN ne s'emploie qu'avec la commande « anope-construire »."
        exit 2
    fi

    # ── check-compat : sur une machine VIERGE, avant toute install ──────
    #
    # Le Lanceur l'appelle à distance par SSH avant de rien pousser. Il ne
    # franchit donc NI le garde « une installation existe » (_cli_commande) NI
    # `_check_deps` : les dépendances peuvent justement manquer — c'est ce qu'on
    # installera ensuite. Juste de quoi établir famille et privilèges, puis le
    # contrôle. Intercepté ICI, avant la branche CLI générale.
    if [[ "$commande" == "check-compat" ]]; then
        _priv_detect
        _pkg_detect
        [[ "$force_mode" == "user" ]] && PRIV_MODE="user"
        _compat_prevol
        # ── Puis le verdict par FONCTION, pas seulement par machine ──────
        #
        # `_compat_prevol` répond « cette machine peut-elle installer
        # Overtchat ? » — une question binaire, qui refuse ou laisse passer.
        # Elle ne dit pas ce qui sera DISPONIBLE une fois installé. Depuis le
        # 13/09/2026 on ajoute donc le verdict de ce qui pèse, pour que
        # l'exploitant (et le Lanceur, qui appelle cette commande par SSH avant
        # de rien pousser) le sache AVANT de commencer, et non au milieu de
        # l'assistant.
        #
        # ⚠ On n'échoue jamais là-dessus : une machine modeste installe très
        #   bien Overtchat, simplement sans modération IA locale.
        if _capacite_ia; then
            _ok "Modération IA locale (visio, voix, photo caméra) : la machine peut la porter."
        else
            _warn "Modération IA locale : ne sera PAS proposée — $IA_MOTIF."
            _info "L'installation reste possible : profils, photo par fichier et carte des membres n'en dépendent pas."
        fi
        exit 0
    fi

    # ── Le chemin sans interface ────────────────────────────────────────
    #
    # Pas de logo, pas d'assistant, pas de demande de mot de passe : une
    # commande lancée par cron ne peut répondre à rien, et un `read` y
    # attendrait jusqu'au délai d'exécution. On garde en revanche tout ce
    # dont le module a besoin — privilèges, gestionnaire de paquets,
    # dépendances, et la configuration, sans laquelle `_upd_brutegit_url`
    # ignore où télécharger.
    if [[ -n "$commande" ]]; then
        _log "=== $SCRIPT_NAME v$VERSION — commande « $commande » ==="
        _priv_detect
        _pkg_detect
        [[ "$force_mode" == "user" ]] && PRIV_MODE="user"
        # 17/10/2026 : la construction d'Anope tourne sans élévation, dès ici
        # (`_check_deps` installerait sinon par sudo ce qui manque).
        [[ "$commande" == "anope-construire" ]] && PRIV_MODE="user"

        _check_deps

        # ⚠⚠ LE PIÈGE LE PLUS DISCRET DU DÉPLACEMENT — point 39. Ce chemin-ci
        # (CLI, cron) n'appelle JAMAIS `_create_folders` : tant que l'état
        # vivait dans `/tmp`, le répertoire existait toujours et la copie
        # passait. Sous `$BASE_DIR/Runtime/`, elle échouerait sur une machine
        # neuve — et la ligne suivante lirait un fichier absent.
        # ⚠ La dépendance est CIRCULAIRE : `_create_folders` dérive les chemins
        # à créer de ce fichier même. C'est donc ici, et pas ailleurs.
        mkdir -p "$(dirname "$INIT_BASE")" 2>/dev/null || true

        _write_default_config
        if [[ ! -f "$INIT_BASE" ]]; then
            cp "$INIT_DEFAULT" "$INIT_BASE"
            # ⚠ 600 : ce fichier porte les empreintes de référence. Une
            # référence que n'importe qui peut réécrire ne garde rien — celui
            # qui altère un fichier réécrirait l'empreinte dans la foulée.
            chmod 600 "$INIT_BASE" 2>/dev/null || true
        else
            _merge_configs
        fi

        local rc=0
        _cli_commande "$commande" || rc=$?
        # ── 🔴 LES FICHIERS RENDUS AU COMPTE, APRÈS TOUTE COMMANDE — 03/10/2026 ──
        #
        # Lu lors de l'essai 9-A (montée Beta → Standard en conteneur) : lancée
        # par « sudo -E » (la voie documentée), une commande en ligne réécrit
        # `Runtime/state.json` (fichier temporaire puis `mv`, donc en root 600),
        # `Config/update_state.json`, et pour `update` des fichiers du projet —
        # et rien ne les rendait au compte. Relancé ensuite SANS sudo,
        # l'installateur ne lisait plus son état et se croyait à sa « première
        # exécution ». Seule l'étape 6 de `--auto` faisait ce geste : on le
        # refait ici, que la commande ait réussi ou non.
        # ⚠ Sans effet quand on tourne en root nu ou sans élévation (PRIV_MODE
        #   ≠ root, ou PRIV_USER = root) : `_priv_fix_owner` le garde.
        _priv_fix_owner "$BASE_DIR"
        _priv_fix_owner "$(_irix_install_dir 2>/dev/null)"
        _log "=== $SCRIPT_NAME — commande « $commande » terminée (code $rc) ==="
        exit "$rc"
    fi

    _logo
    _log "=== $SCRIPT_NAME v$VERSION démarré ==="

    # Contexte AVANT toute vérification : si _check_deps échoue, l'utilisateur
    # doit voir sous quels privilèges et quelle distribution le diagnostic a été
    # posé — sinon le message d'erreur est ininterprétable.
    _priv_detect
    _pkg_detect
    [[ "$force_mode" == "user" ]] && PRIV_MODE="user"

    _ui_top
    _ui_row "${BOLD}Contexte de démarrage${RESET}"
    _ui_mid
    _ui_kv "Privilèges"   "$(_priv_label)"
    _ui_kv "Distribution" "${PKG_FAMILY} ${DIM}(${PKG_MGR:-aucun gestionnaire})${RESET}"
    _ui_kv "Racine"       "$BASE_DIR"
    _ui_kv "Journal"      "$LOG_FILE"
    # L'ÉDITION (le palier) de CETTE installation — demandé par SerVuS : que le
    # choix validé soit rappelé dans le cadre. Lue de l'install si elle existe ;
    # sur une machine vierge il n'y a pas encore d'édition, on le dit plutôt que
    # d'afficher un défaut trompeur. ⚠ Sourcé dans un SOUS-SHELL `$(…)` : les
    # fonctions d'edition.sh (dont la redéfinition de _edition_racine) ne fuient
    # pas dans main.
    local _ed="nouvelle installation"
    if [[ -r "$BASE_DIR/Tools/edition.sh" ]]; then
        _ed="$( source "$BASE_DIR/Tools/edition.sh"; _edition_racine() { printf '%s\n' "$BASE_DIR"; }; edition_lire )"
    fi
    _ui_kv "Édition"      "${_ed}"
    _ui_bot

    # ⚠⚠ LE CONTRÔLE AVANT VOL SE PLACE ICI, ET NULLE PART AILLEURS. Après
    # l'encart, pour que le refus se lise avec le contexte sous les yeux ; et
    # AVANT `_check_deps`, qui est la première chose à écrire sur le disque.
    # C'est la dernière ligne où refuser ne laisse rien derrière soi.
    #
    # Le contrôle de COMPATIBILITÉ MACHINE d'abord (les deux chemins), puis le
    # contrôle des VARIABLES du mode auto (auto seulement). Une machine
    # incompatible est refusée que l'installation soit guidée ou automatique.
    _compat_prevol || exit 2
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        _auto_prevol || exit 2
        # Le serveur IRC (défaut : le nôtre) et son refus éventuel — ICI aussi,
        # avant la première écriture : un refus ne doit rien laisser derrière.
        _auto_serveur_choisir || exit 2
    fi
    # Une DESCENTE d'édition par-dessus une installation existante (02/10/2026,
    # décision de SerVuS) : refusée ici, avant `_check_deps`, pour les deux
    # chemins — `_site_install` la refuse une seconde fois avant de recopier.
    _edition_garde_descente || exit 2

    _check_deps   # vérifie jq/curl/git/openssl, installe si l'élévation le permet

    # ── Le nom de l'installation, une seule fois ────────────────────────
    #
    # Posée ici : après `_check_deps`, donc avec jq disponible, et AVANT
    # `_write_default_config`, qui grave les chemins dans l'état. Plus tard,
    # il serait trop tard — l'arborescence serait décrite.
    #
    # Uniquement sur une installation NEUVE : ni état précédent, ni racine sur
    # le disque. Redemander à chaque lancement serait une invitation à répondre
    # autre chose, et donc à installer une seconde fois à côté.
    #
    # Et uniquement si le nom n'a pas été donné : `--projet=` et
    # `OVERTCHAT_PROJET` sont des réponses. C'est ce qui empêche l'assistant de
    # reposer sa question juste après s'être relancé avec elle.
    #
    # Le script se RELANCE avec la réponse plutôt que de corriger ses
    # variables : $BASE_DIR et la vingtaine de chemins qui en dérivent sont
    # `readonly`, figés depuis la première ligne. Les réécrire un par un serait
    # le genre de correction qui en oublie un — et l'oubli ne se verrait qu'à
    # l'installation suivante, dans un répertoire orphelin.
    if [[ ! -f "$INIT_BASE" && ! -d "$BASE_DIR" && "$PROJET_SOURCE" == "defaut" ]]; then
        local nom_choisi=""

        _projet_demander "MonReseau"
        nom_choisi="$PROJET_CHOISI"

        if [[ -n "$nom_choisi" && "$nom_choisi" != "$PROJET_NOM" ]]; then

            if [[ -r "$SCRIPT_PATH" ]]; then
                _info_f "Installation nommée « %s » — relance de l'assistant." "${nom_choisi}"
                _log "=== relance sous --projet=$nom_choisi ==="
                # Les arguments d'origine sont perdus ici : la boucle de
                # lecture les a consommés par `shift`. Seuls comptent ceux qui
                # survivent à une relance, et `--root`/`--user` en font partie.
                local -a relance=( bash "$SCRIPT_PATH" "--projet=${nom_choisi}" )
                [[ "$force_mode" == "root" ]] && relance+=( --root )
                [[ "$force_mode" == "user" ]] && relance+=( --user )
                # ⚠ `--auto` survit à la relance, au même titre que les deux
                # précédents. En pratique on n'arrive pas ici sous --auto —
                # `_auto_prevol` exige justement --projet=, donc la question
                # n'est pas posée. Le propager coûte une ligne et évite qu'un
                # jour un autre chemin reparte en interactif sans le dire.
                [[ "${MODE_AUTO:-0}" == "1" ]] && relance+=( --auto )
                _ovt_tmp_menage
                exec "${relance[@]}"
            fi

            # Lancé par un tube (`curl … | bash`), il n'y a pas de fichier à
            # relancer. On ne fait pas semblant : le nom est perdu, on le dit,
            # et on donne la commande exacte qui l'appliquera.
            _err "Impossible de relancer l'assistant : ce script n'a pas de fichier sur le disque."
            _info "Téléchargez-le, puis relancez-le ainsi :"
            printf "    ${BOLD}bash overtchat-setup.sh --projet=%s${RESET}\n\n" "$nom_choisi"
            exit 2
        fi
    fi

    # 🔴🔴 REFUS, ET NON PLUS AVERTISSEMENT — audit du 29/09/2026, #8. Lancé en
    #   root sans compte d'origine, l'installateur continuait sous /root et
    #   posait TOUS les services en root (Eggdrop refusait même de démarrer).
    #   On s'arrête avant d'écrire la moindre arborescence.
    if [[ "$PRIV_MODE" == "root" && "$PRIV_USER" == "root" ]]; then
        _err "Lancé en root sans compte d'origine : les services tourneraient en root. Refusé — rien n'a été écrit."
        _info "Depuis le compte qui portera l'installation : sudo -E ./overtchat-setup.sh …"
        _info "Ou, en root, désignez ce compte : OVERTCHAT_COMPTE=<compte> ./overtchat-setup.sh …"
        exit 2
    fi

    # ⚠ Point 39 — le répertoire d'abord. Ici la copie précède `_create_folders`
    # de dix-huit lignes, et c'est `_create_folders` qui crée l'arborescence
    # d'après ce fichier même. Sans ce `mkdir`, une installation neuve échoue au
    # tout premier lancement — une panne de démarrage, pas d'intégrité.
    mkdir -p "$(dirname "$INIT_BASE")" 2>/dev/null || true

    _write_default_config

    if [[ ! -f "$INIT_BASE" ]]; then
        cp "$INIT_DEFAULT" "$INIT_BASE"
        chmod 600 "$INIT_BASE" 2>/dev/null || true
        _ok_f "Config créée : %s" "$INIT_BASE"
    else
        _merge_configs
        _ok "Config chargée"
    fi

    # Première exécution : on crée l'arborescence puis on ENTRE dans le panel.
    #
    # L'ancien flux appelait _run_bootstrap, qui téléchargeait le script depuis
    # GitHub et faisait `exec` dessus (safe_encrypt run). Deux problèmes : le
    # panel n'était jamais atteint au premier lancement, et le « contrôle
    # d'intégrité » enregistrait le hash du fichier qu'il venait lui-même de
    # télécharger — il ne validait donc rien. La mise à jour reste disponible,
    # explicitement, depuis le menu « Mise à jour fichiers ».
    if [[ "$(_jq_read '.install' "$INIT_BASE" "false")" != "true" ]]; then
        _info "Première exécution — préparation de l'arborescence."
        _export_vars
        _create_folders || _die "Création de l'arborescence échouée — voir $LOG_FILE"
        _priv_fix_owner "$BASE_DIR"
        _ok_f "Arborescence prête : %s" "$BASE_DIR"
        echo ""
        # ⚠ En AUTO on ne descend pas ici : `_panel_install` est un MENU, et un
        # menu privé d'entrée standard lit `0` sur EOF — c'est-à-dire
        # « Retour ». Il rendrait donc la main sans rien installer, et sans
        # rien dire non plus.
        if [[ "${MODE_AUTO:-0}" != "1" ]]; then
            _info "Lancement de l'assistant d'installation."
            _pause
            _panel_install
        fi
    fi

    # ── Le chemin AUTO s'arrête ici ─────────────────────────────────────
    #
    # Il ne traverse ni `_access_login_gate` (qui demande un mot de passe dès
    # la deuxième exécution), ni `_offer_root`, ni `_main_menu` : les trois
    # attendent une saisie. C'est la même coupe que celle du chemin CLI, pour
    # la même raison.
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        local rc_auto=0
        _auto_installer || rc_auto=$?
        _log "=== $SCRIPT_NAME — installation automatique terminée (code $rc_auto) ==="
        exit "$rc_auto"
    fi

    _access_login_gate || _die "Authentification échouée."
    _offer_root
    _main_menu

    _log "=== $SCRIPT_NAME terminé ==="
}

main "$@"
