#!/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.

readonly LOG_FILE="/tmp/overtchat-setup.log"
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.
# -----------------------------------------------------------------------------
_resolve_base_home() {
    local h=""
    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"
    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=""
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. Lu par sed et non par jq : `_check_deps`
    # n'a pas encore tourné, et jq peut manquer — repartir sur le nom historique
    # parce que jq est absent installerait à côté de ce qui existe déjà.
    if [[ -z "$nom" && -r "/tmp/.service-overtchat.json" ]]; then
        nom=$(sed -n 's/.*"projet"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
              "/tmp/.service-overtchat.json" 2>/dev/null | head -n 1)
        [[ -n "$nom" ]] && PROJET_SOURCE="etat"
    fi

    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.
readonly -a CORE_WATCHED=(
    "Bootstrap/overtchat-setup.sh"
    "VERSION"
    "VERSION.md"
)

# 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"
readonly URL_EGGDROP="http://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"


# =============================================================================
# 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

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

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

_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")

# =============================================================================
# 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"
        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
}

# _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"
    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" ;;
            mysql-server) echo "mariadb-server" ;; postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            *) 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" ;;   zlib-dev) echo "zlib-devel" ;;
            bison)     echo "bison" ;;           flex)  echo "flex" ;;
            pkgconfig) echo "pkgconf-pkg-config" ;; mysql-client) echo "mariadb" ;;
            tcl-mysql) echo "tcl-mysqltcl" ;;                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 "jansson-devel" ;;
            tar) echo "tar" ;;                  php) echo "php-cli" ;;
            php-mysql) echo "php-mysqlnd" ;;    php-mbstring) echo "php-mbstring" ;;
            mysql-server) echo "mariadb-server" ;; postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            *) 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" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            *) 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" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            *) 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" ;;
            mysql-server) echo "mariadb" ;;     postfix) echo "postfix" ;;
            autoconf) echo "autoconf" ;;         automake) echo "automake" ;;
            libtool) echo "libtool" ;;           patch) echo "patch" ;;
            *) 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
    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
}

# 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 ;;
                *) _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 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
        #   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.
        site)        echo "php php-mysql php-mbstring mysql-client tar curl postfix" ;;
        *)           echo "" ;;
    esac
}

_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" ;;
        *) 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; 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 "AUCUNE RÉFÉRENCE [$key] — impossible de vérifier quoi que ce soit."
        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 "SHA256 OK [$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 "HASH INVALIDE [$key] — fichier corrompu !"
        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" },
                    { 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-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-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"  },
                    { repo: "Runtime/cmds_irix.json",                                 local: "cmds_irix.json",        role: "cmds"       }
                ],
                modes: {
                    sql: {
                        entry: "IriX-sql.tcl",
                        modules: ["IriX-src.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-nickguard.tcl","irix-nickexpire.tcl","irix-chatalerte.tcl","irix-online.tcl","irix-modq.tcl","irix-operjoin.tcl","irix-modtrace.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)
    ' "$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 "Aucun chemin trouvé dans $INIT_BASE — arborescence non créée."
        _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 "Arborescence validée ($count dossiers)"
        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() {
    local f="${TMPDIR:-/tmp}/overtchat-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 "Empreinte incorrecte — fichier refusé : $rel"
            _err "  attendu : $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 "Utilisateur ajouté : $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 "Mot de passe mis à jour : $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 "$login → actif=$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 "Bienvenue, ${login} [${CURRENT_GRADE}]"
            return 0
        fi

        tries=$(( tries + 1 ))
        _log_entry "access" "login_failed" "${login:-?}"
        _err "Identifiants invalides. (${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 "Bot : $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 "Salon ajouté : $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 "$nick → $grade sur $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 "$nick → $grade (réseau)"
}

_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 "install_dir manquant pour ${svc} — reposé sur ${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 "$svc est déjà en cours d'exécution."
        return 0
    fi

    mkdir -p "$run_dir"
    _info "Démarrage de $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 "$svc démarré (PID : $pid)."
    else
        rm -f "$pid_file"
        _service_report_error "$svc" "Échec du démarrage"
        _err "Le processus $svc s'est terminé immédiatement (voir $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 "$svc : aucun PID trouvé, probablement déjà arrêté."
        _svc_set "$svc" '.services[$s].running = false'
        return 0
    fi

    pid=$(cat "$pid_file")
    if kill -0 "$pid" 2>/dev/null; then
        _info "Envoi de SIGTERM au PID $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 "$svc : PID $pid introuvable dans les processus actifs."
    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 "$svc arrêté."
}

_service_restart() {
    local svc="$1"
    _info "Redémarrage de $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 "Logs de $svc (Ctrl+C pour quitter) :"
    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

    _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; }

    # shellcheck disable=SC2046 -- _req_for renvoie une liste séparée par des espaces
    _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 "Récupération d'Eggdrop ${VER_EGGDROP}…"
    if [[ "$EGGDROP_EST_RC" == "oui" ]]; then
        _warn "Eggdrop ${VER_EGGDROP} est une VERSION CANDIDATE, retenue délibérément."
        _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 "Eggdrop compilé, mais aucun module .so dans ${dir}/modules/."
        _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 "Récupération d'Anope ${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 "Anope compilé (CMake) et installé (${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."
    }
}

# -----------------------------------------------------------------------------
# _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_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}"

    if [[ -f "$conf" ]]; then
        _info "${cible} existe déjà — conservé tel quel."
        return 0
    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 "Modèle de configuration Anope introuvable sous ${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 "Aucun module de protocole sous ${dir}/modules — Anope ne pourra pas se lier."
    fi
    proto=$(_site_ask "Protocole du serveur IRC (module Anope)" "$proto_defaut")
    if [[ -z "$proto" ]]; then
        _err "Aucun module de protocole choisi — Anope refuserait de démarrer."
        return 1
    fi
    if ((${#dispo[@]})) && [[ " ${dispo[*]} " != *" ${proto} "* ]]; then
        _err "« ${proto} » n'est pas compilé ici — Anope ne saurait pas le charger."
        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 "« ${proto} » n'est pas un nom de module recevable."
        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)
    [[ -z "$nom_defaut" ]] && nom_defaut="services.${reseau,,}"

    nom_serv=$(_site_ask "Nom du serveur de services" "$nom_defaut")

    # ⚠ 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 "Aucun bloc « link ${nom_serv} » dans la configuration du serveur IRC."
        _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
    up_host=$(_site_ask "Serveur IRC à rejoindre (hôte)" "127.0.0.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 "Le serveur IRC attend ses serveurs sur le port ${port_defaut}."
    else
        port_defaut="7000"
        _warn "Port du lien introuvable dans la configuration du serveur — 7000 proposé."
    fi
    up_port=$(_site_ask "Port du lien" "$port_defaut")
    # 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 [[ -n "$pass_defaut" ]]; then
        _info "Bloc « link ${nom_serv} » trouvé dans ${_ANOPE_LINK_SRC}."
        printf "  Mot de passe du lien ${DIM}[Entrée = celui de ce bloc]${RESET} : "
    else
        _warn "Aucun bloc « link ${nom_serv} » trouvé : le serveur IRC en voudra un."
        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"

    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 "Port « ${up_port} » : un nombre est attendu."
        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.
    sed -Ei \
        -e "/^[[: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}\"|
            }" \
        -e "/^[[:space:]]*serverinfo[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
              s|^([[:space:]]*description[[:space:]]*=).*|\1 \"${e_desc}\"|
            }" \
        "$tmp" || { rm -f "$tmp"; _err "Substitution impossible."; return 1; }

    # 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 "SID « ${sid_lien} » illisible dans le bloc link — un chiffre puis deux caractères sont attendus."
            return 1
        fi
        sed -Ei "/^[[:space:]]*serverinfo[[:space:]]*\{?[[:space:]]*$/,/^[[:space:]]*\}/ {
                   s|^([[:space:]]*)#?[[:space:]]*id[[:space:]]*=.*|\1id = \"${sid_lien}\"|
                 }" "$tmp"
        _info "SID ${sid_lien}, repris du bloc « link ${nom_serv} »."
    else
        _warn "Aucun SID dans le bloc « link ${nom_serv} » — la ligne « id » du modèle est laissée telle quelle."
        _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")
    grep -qF "\"${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."

    mv "$tmp" "$conf" || { rm -f "$tmp"; return 1; }
    chmod 600 "$conf"
    _ok "Configuration écrite : ${conf} ${DIM}(droits 600)${RESET}"
    _ok "Protocole : ${proto} — lien vers ${up_host}:${up_port}, serveur ${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 "Le modèle a été repris tel quel pour le reste : relis ${conf}."
    _info "À vérifier en priorité : le bloc oper, et — côté SERVEUR IRC — un bloc"
    _info "link pour ${nom_serv} et son inscription dans les ulines."
    _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
    }
}

_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 "$name désinstallé."
}

_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 "$name désactivé (processus arrêté)."
    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 "$name activé (processus démarré)."
    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 "$svc logs → $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 "$svc : erreurs effacées"
}

_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 "$svc binary : $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 "Source mise à jour : ${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 ne se télécharge pas : ses sources sont dans le
        # projet. Le source_url qui traînait pointait vers un compte GitHub
        # abandonné — inerte depuis que _install_overtchat lit l'arbre local,
        # mais 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 "Source mise à jour : ${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 "Serveur ajouté à la configuration : ${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.
# -----------------------------------------------------------------------------
_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
    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 "$srv est déjà en cours d'exécution."
        return 0
    fi

    mkdir -p "$run_dir"
    _info "Démarrage de $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 "$srv démarré (PID : $pid)."
    else
        rm -f "$pid_file"
        _server_report_error "$srv" "Échec du démarrage"
        _err "Le processus $srv s'est terminé immédiatement."
        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 "$srv : aucun PID trouvé, processus probablement déjà arrêté."
        return 0
    fi

    pid=$(cat "$pid_file")

    if kill -0 "$pid" 2>/dev/null; then
        _info "Envoi de SIGTERM au PID $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 "$srv : PID $pid introuvable dans les processus actifs."
    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 "$srv arrêté."
}

# -----------------------------------------------------------------------------
# _server_restart <serveur>
# -----------------------------------------------------------------------------
_server_restart() {
    local srv="$1"
    _info "Redémarrage de $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 "Aucun fichier de log trouvé pour $srv."
        return 1
    fi

    _info "Logs de $srv (Ctrl+C pour quitter) :"
    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 "$srv → enabled=${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 "$srv : logs → ${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 "Port IRC invalide : « $port » (attendu : 1-65535). Inchangé."
        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 "Port SSL invalide : « $ssl_port » (attendu : 1-65535). Inchangé."
        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 "$srv : historique des erreurs effacé."
}

# =============================================================================
# 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 "Aucune archive source trouvée dans ${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 ;;
    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() {
    local url="$1" dir="$2" pattern="$3" srv="${4:-composant}"
    local archive local_archive

    mkdir -p "$dir"

    local_archive=$(_find_local_archive "$pattern")
    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 "Aucune archive locale et aucun source_url défini pour $srv."
        _info "Dépose une archive dans Components/Serveur/lib/ (motif : $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 "Clonage depuis $url…"
        if ! git clone --depth=1 "$url" "$tmpc/src" 2>/dev/null; then
            rm -rf "$tmpc"
            _err "Clonage impossible : $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 "Téléchargement depuis $url…"
    archive="${dir}/src.tar.gz"
    curl -fsSL --retry 3 --connect-timeout 10 "$url" -o "$archive" || return 2
    _verify_gzip "$archive" || return 2
    tar -xzf "$archive" -C "$dir" --strip-components=1 || return 1
    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épose dans le même
    # dossier. `_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 "Sources d'ircu2 indisponibles (réseau, ou tag ${VER_IRCU} retiré)."
        _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 "Sources d'InspIRCd indisponibles (réseau, ou tag v${VER_INSPIRCD} retiré)."
        _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 "Sources de Bahamut indisponibles (réseau, ou tag v${VER_BAHAMUT} retiré)."
        _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 "  -subj '/CN=votre.serveur' -out ${dir}/ircd.crt -keyout ${dir}/ircd.key"
            fi
        else
            _warn "openssl absent : pas de certificat, donc aucun port ouvert au démarrage."
        fi
    fi

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

# -----------------------------------------------------------------------------
# _install_overtchat
# -----------------------------------------------------------------------------
_install_overtchat() {
    local srv="overtchat_ircd" dir rc
    dir=$(_srv_get "$srv" "install_dir")

    # 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; }

    # 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"
    if [[ -f "${locales}/Makefile" && -d "${locales}/src" ]]; then
        _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; }
    else
        # ⚠ « *overtchat* » ET NON « *overtchat*ircd* », élargi le 12/08/2026.
        #
        # L'ancien motif exigeait le mot « ircd » APRÈS « overtchat ». Or
        # l'archive que fabrique Tools/faire-archive-hybrid.sh s'appelle
        # `overtchat-hybrid-<version>.tar.gz`, d'après le dossier des sources —
        # elle n'était donc pas trouvée, et ce repli ne pouvait jamais servir.
        # Le seul chemin qui restait était l'arbre local, c'est-à-dire le cas
        # que ce repli existe précisément pour couvrir.
        _srv_fetch_sources "$srv" "$dir" "*overtchat*.tar.gz"; rc=$?
        if (( rc == 2 )); then
            _warn "Aucune source d'Overtchat-Hybrid : ni dans le projet, ni téléchargeable."
            _info "Attendues dans Servers/overtchat-hybrid (Makefile + src/),"
            _info "ou en archive dans Components/Serveur/lib/ (Tools/faire-archive-hybrid.sh)."
            _install_fail "$srv" sources
            return 1
        fi
        (( rc != 0 )) && { _install_fail "$srv"; return 1; }
    fi

    # 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"
}

# -----------------------------------------------------------------------------
# _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 "Sauvegarde vers ${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 "Sauvegarde en place : $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 "Remets-la en place à la main : mv '$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"
        _menu_gate 1 "UnrealIRCd      ${DIM}(recommandé — cible d'IriX)${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"  "$(_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" \
        '{
            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:     "/var/run/mysqld/mysqld.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 "Chemin absolu attendu (commençant par /) — reçu : $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 "Instance déplacée : $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 "Fichiers laissés dans ${current} — le nouveau dossier est vide."
            _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 "Dossier d'instance IriX → $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")
    cible="${TMPDIR:-/tmp}/overtchat-depouiller.$$.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 "Entrée git.irix.files[$i] invalide (repo/local manquant)."
            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 "Modules allégés : ${lignes_avant} → ${lignes_apres} lignes (${gain} % de moins). La version commentée reste au dépôt."
    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 "Sources IriX synchronisées dans ${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 "$local_name : pas de hash de référence."
            continue
        fi

        if [[ "$stored" == "$current" ]]; then
            _ok "$local_name : SHA-256 valide."
        else
            _err "$local_name : SHA-256 INVALIDE !"
            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 "Mode invalide : '$mode' (attendu : sql | std)"
        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 "Aucune entrée définie pour le mode '$mode' (git.irix.modes)."
        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 "Mode IriX -> $mode ($entry). Redéploiement requis avant de redémarrer."
}

# -----------------------------------------------------------------------------
# _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_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
    _ui_title "Configuration d'IriX" "identité du bot, serveur IRC et base de données"

    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
    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")
    admin=$(_site_ask "Administrateur"           "${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
    if [[ -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)
        d_user=$(grep -oP "'user'\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}"
    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.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 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}"
    mkdir -p "$cmds"
    local tmp; tmp="$(mktemp)" || return 1
    jq -n --arg h "$sql_host" --arg l "$sql_user" --arg p "$sql_pass" \
          --arg s "$sql_sock" --arg d "$sql_base" --arg ip "$host" \
          --arg b "$nick" --arg e "$mail" --arg a "$admin" \
        '{host:$h, login:$l, pass:$p, 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; }
    mv "$tmp" "${cmds}/IriX.json" && chmod 600 "${cmds}/IriX.json"
    _ok "Accès à la base écrit : ${cmds}/IriX.json ${DIM}(droits 600)${RESET}"

    _log_entry "irix" "configure" "botnick=$nick base=$sql_base"
    _ok "Configuration enregistrée. Déploie l'instance pour l'appliquer."
}

# -----------------------------------------------------------------------------
# _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_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'
        printf 'listen %s %s all\n\n' "$_lip" "$(_irix_get 'instance.port')"

        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 "Configuration Eggdrop écrite : $dest"

    # 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 "puis renseigne mod-path dans : $dest"
    else
        _ok "Modules Eggdrop : $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 "Mode IriX inconnu ou non configuré : $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 "Eggdrop trouvé : $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 "${n} fichier(s) déployé(s) dans ${cmds}"

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

    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 "Instance IriX déployée dans : $dir (mode : $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 "Démarrage d'IriX via ${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 "IriX démarré (PID : $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 "Fichier PID illisible ($pid_file) — il est supprimé."
        rm -f "$pid_file"
        _irix_set '.instance.running = false'
        return 0
    fi

    if kill -0 "$pid" 2>/dev/null; then
        _info "Envoi de SIGTERM au PID $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 "Le PID $pid survit à SIGKILL — IriX n'a PAS été arrêté."
            _info "Le fichier PID est conservé : le processus reste identifiable."
            _irix_report_error "Arrêt impossible du PID $pid"
            return 1
        fi
    else
        _warn "IriX : PID $pid introuvable dans les processus actifs."
    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 "IriX : logs → ${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 "Robot inconnu : $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 "Connexions déclarées depuis cette machine : ${total}/${OVEROP_MAXPERIP} (IriX comprise)."
        return 0
    fi

    _warn "⚠ ${total} connexions déclarées depuis cette adresse IP, pour un plafond de ${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 "$racine/$sous absent — le robot pourrait journaliser en codes."
            manque=$(( manque + 1 ))
        fi
    done
    if (( manque == 0 )); then
        _ok "Messages d'Eggdrop liés : language, help, text ${DIM}(depuis ${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 "Entrée git.overop.files[$i] invalide (repo/local manquant)."
            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 "Sources OverOp synchronisées dans ${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 "Chemin absolu attendu pour la racine des instances — reçu : $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 "Configuration Eggdrop écrite : $dest"

    if [[ -z "$moddir" ]]; then
        _warn "Modules Eggdrop (*.so) introuvables — ${nom} ne pourra pas démarrer en l'état."
        _info "Ils sont produits par la compilation d'Eggdrop. Vérifie le Panel Services,"
        _info "puis renseigne mod-path dans : $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 "set mod-path relatif ($modp) : résolu depuis le répertoire courant, donc introuvable."
        ok=false
    elif [[ ! -d "$modp" ]]; then
        _err "set mod-path pointe un dossier absent : $modp"
        ok=false
    else
        _ok "mod-path absolu et présent : $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 "Premier module chargé : « $premier » au lieu de pbkdf2."
        _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 "Propriétaire déclaré : $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 "Répertoires de messages manquants : ${manque}— le journal sortira en codes"
        _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_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 "Eggdrop trouvé : $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 "Script déployé : ${dir}/overop.tcl"

    # 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

    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 "Robot ${nom} déployé dans : ${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 "${nom} tourne encore sur l'ANCIEN script : redémarre-le pour appliquer."
    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 "${nom} est déjà déclaré. « Déployer » le repose ; il n'y a rien à ajouter."
        return 1
    fi

    port=$(_site_ask "Port du telnet d'administration" "$(_overop_port_libre)")
    if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1024 || port > 65535 )); then
        _err "Port invalide : $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 "Robot ${nom} déclaré."

    # ⚠ 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 "${nom} tourne — il est arrêté avant tout retrait."
        _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 "Dossier conservé : $dir"
    fi

    _overop_set 'del(.robots[$n])' --arg n "$nom"
    _log_entry "overop" "remove" "${nom} retiré du registre"
    _ok "Robot ${nom} retiré du registre."
}

# =============================================================================
# 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 "${nom} n'est pas déployé. Utilise d'abord « Déployer »."
        return 1
    fi

    if ! egg=$(_irix_eggdrop_bin); then
        _err "Binaire Eggdrop introuvable — ${nom} ne peut pas démarrer sans lui."
        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 "${nom} est déjà en cours d'exécution."
        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 "Premier démarrage de ${nom} : création du userfile (option -m)."
    fi

    _info "Démarrage de ${nom} via ${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 "${nom} démarré (PID : ${pid})."
        _overop_avertir_maxperip 0 || true
    else
        rm -f "$pid_file"
        _err "${nom} s'est arrêté immédiatement. Dernières lignes du journal :"
        [[ -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 "${nom} : aucun PID trouvé, probablement déjà arrêté."
        _overop_bot_set "$nom" '.robots[$n].running = false'
        return 0
    fi

    pid=$(cat "$pid_file")
    if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
        _warn "Fichier PID illisible (${pid_file}) — il est supprimé."
        rm -f "$pid_file"
        _overop_bot_set "$nom" '.robots[$n].running = false'
        return 0
    fi

    if kill -0 "$pid" 2>/dev/null; then
        _info "Envoi de SIGTERM au PID ${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 "Le PID ${pid} survit à SIGKILL — ${nom} n'a PAS été arrêté."
            _info "Le fichier PID est conservé : le processus reste identifiable."
            return 1
        fi
    else
        _warn "${nom} : PID ${pid} introuvable dans les processus actifs."
    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 "${nom} arrêté."
}

_overop_restart() {
    local nom="${1:-}"
    _info "Redémarrage de ${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 "Journal de ${nom} (Ctrl+C pour quitter) :"
    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 "${nom} illisible — l'état des erreurs est INCONNU, pas vide."
        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 "$n empreinte(s) posée(s), $absents fichier(s) absent(s)."
    _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 "$corrompus fichier(s) NE CORRESPONDENT PAS à leur empreinte."
        return 2
    fi
    if [[ $sans_reference -gt 0 ]]; then
        _warn "$sans_reference fichier(s) sans empreinte de référence — rien n'a pu être vérifié."
        return 1
    fi
    _ok "Tous les fichiers présents correspondent à leur empreinte ($absents absent(s))."
    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.

readonly UPD_ALERTE_STAMP="/tmp/.overtchat-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 ;;
        # ⚠ 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 ;;
        # 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/apache/*|Config/unrealircd/*)             return 0 ;;
        Config/systemd/*|Config/polkit/*)                return 0 ;;
        Config/logrotate.d/*|Config/sudoers.d/*)         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 ;;
        # 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 "Impossible de créer le répertoire pour : $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 "Restauration échouée : $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 "Session ${session} vide — aucun fichier à restaurer."
        return 1
    fi
    if (( echecs > 0 )); then
        _err "Rollback INCOMPLET : ${count}/${total} restauré(s), ${echecs} en échec."
        _warn "L'installation mélange deux versions — corrige avant de la relancer."
        _kv "Sauvegardes" "$session_dir"
        return 1
    fi

    _ok "${count} fichier(s) restauré(s) depuis la session ${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.
_sysd_compte() {
    local u
    u="$(stat -c '%U' "$BASE_DIR" 2>/dev/null || true)"
    if [[ -n "$u" && "$u" != "root" ]]; then printf '%s' "$u"; return 0; fi
    if [[ -n "${PRIV_USER:-}" && "$PRIV_USER" != "root" ]]; then printf '%s' "$PRIV_USER"; return 0; fi
    printf '%s' "$(id -un)"
}

# 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
}

# Pose les unités livrées. N'en démarre AUCUNE.
_sysd_poser() {
    local dir compte f nom tmp n=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)"
    _info "Compte de service retenu : ${BOLD}${compte}${RESET} (propriétaire de la racine)."

    while IFS= read -r f; do
        nom="$(basename "$f")"
        tmp="$(mktemp)" || continue
        _sysd_substituer "$f" "$compte" > "$tmp"
        if _as_root install -m 644 "$tmp" "/etc/systemd/system/$nom" 2>/dev/null; then
            n=$(( n + 1 ))
        else
            _err "Pose refusée : $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 "$n unité(s) posée(s) dans /etc/systemd/system/."
    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 "$nom — $svc n'est pas posé, laissé au repos."
            laisses=$(( laisses + 1 )); continue
        fi
        if ! _sysd_execstart_ok "$pose"; then
            _warn "$nom — le programme de $svc est introuvable, laissé au repos."
            laisses=$(( laisses + 1 )); continue
        fi

        pre="$(_sysd_prerequis "$pose")"
        if ! _sysd_prerequis_ok "$pre"; then
            _warn "$nom — prérequis non satisfait : ${DIM}${pre}${RESET}"
            laisses=$(( laisses + 1 )); continue
        fi

        if _as_root systemctl enable --now "$nom" 2>/dev/null; then
            _ok "$nom activé."
            actives=$(( actives + 1 ))
        else
            _err "$nom — activation refusée."
            laisses=$(( laisses + 1 ))
        fi
    done < <(find "$dir" -maxdepth 1 -name '*.timer' | sort)

    _info "$actives timer(s) en service, $laisses laissé(s) au repos."
    [[ $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}#" \
        "$src"
}

_sys_poser() {
    local ligne dossier dest mode src nom n=0 refuses=0

    _priv_can_elevate || { _err "Poser un fichier système exige root ou sudo."; 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")"
            if ! _sys_valide "$src" "$dest"; then
                _err "$nom REFUSÉ — il ne passe pas son propre contrôle, rien n'a été posé."
                refuses=$(( refuses + 1 ))
                continue
            fi
            # ⚠ On pose la version ADAPTÉE, jamais le modèle brut. Le
            # temporaire est indispensable : `install` ne lit pas un tube.
            local adapte
            adapte="$(mktemp)" || { _err "Impossible de préparer $nom"; refuses=$((refuses+1)); continue; }
            _sys_substituer "$src" "$(_sysd_compte)" > "$adapte"
            if _as_root install -m "$mode" "$adapte" "$dest/$nom" 2>/dev/null; then
                _ok "$nom → $dest  (mode $mode)"
                n=$(( n + 1 ))
            else
                _err "Pose refusée : $nom"
                refuses=$(( refuses + 1 ))
            fi
        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 "$n fichier(s) système posé(s), $refuses refusé(s)."
    [[ $refuses -eq 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.
_apache_regles() {
    printf '%s\n' \
        "irix-*.conf:/etc/apache2/sites-available:644" \
        "ports.conf:/etc/apache2:644" \
        "maintenance.html:/var/www/overtchat-maintenance: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 "$nom — aucune règle ne dit où le poser : IGNORÉ (voir _apache_regles)."
            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 "$nom — modèle NON adapté à cette installation : IGNORÉ."
            _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 "$nom → $dest  (mode $mode)"
            n=$(( n + 1 ))
        else
            _err "Pose refusée : $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 "$n fichier(s) Apache posé(s), $refuses refusé(s) — AUCUN n'est activé."
    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 ]]
}

# 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)"
        _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}"
        _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 ;;
            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 "Relancez avec le nom en argument : bash $SCRIPT_NAME.sh --projet=MonReseau"
            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 "« ${nom} » est le nom du réseau d'origine — il reste à son auteur."
            _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 "$TARGET_HOME/$nom existe déjà."
            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 "Identité écrite : $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 "Édition de cette installation : $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 "Édition inconnue : « $demandee »."
        _info "Les éditions sont : $(edition_connues)"
    else
        _err "L'édition n'a pas pu être écrite (code $rc)."
    fi
    return 1
}

_site_install() {
    _section "Site et panel web"

    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

    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

    # 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 "« ${nom} » est le nom du réseau d'origine — il reste à son auteur."
            _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 "« ${court} » est réservé, comme le nom complet."
            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 "Forme de domaine invalide : « ${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 "Vérification de ${domaine}…"
        if _domaine_pris "$domaine"; then
            _warn "Ce domaine est déjà utilisé : ${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}")

    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 "Archive extraite, mais ${BASE_DIR}/Web est absent."
        _info "L'archive ne contient pas l'arborescence attendue — rien n'a été configuré."
        return 1
    fi
    _ok "Site extrait dans $BASE_DIR/Web"

    # ── 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."
        _warn "À relire : identifiants MySQL et réglages du chat."
    fi

    # ── 5. Base du site ──────────────────────────────────────
    if [[ -x "${BASE_DIR}/Web/sql/apply.sh" ]]; then
        _info "Application des migrations du site…"
        if "${BASE_DIR}/Web/sql/apply.sh" >/dev/null 2>&1; then
            _ok "Migrations appliquées."
        else
            _warn "Migrations non appliquées — à lancer après configuration de MySQL :"
            _kv "Commande" "${BASE_DIR}/Web/sql/apply.sh"
        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"
                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.

  ${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. 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_DOMAINE    domaine du site
                  OVERTCHAT_RESEAU     nom public du réseau
                  OVERTCHAT_COURT      nom court (menus, groupes)
                  OVERTCHAT_MAIL_FROM  adresse d'expédition
                  OVERTCHAT_MAIL_TO    adresse qui reçoit les notifications
                  OVERTCHAT_SERVEUR    serveur IRC à poser (défaut 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 "Aucune installation dans $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

    _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.
    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 "auto : ${quoi} — la valeur fournie est refusée, et la reposer donnerait la même."
    _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

    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 »."
    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 « unrealircd », et non le serveur du projet. `overtchat_ircd`
    # n'est distribué par AUCUN canal — c'est une décision ouverte au registre
    # —, donc un mode auto qui le choisirait échouerait chez tout tiers, au
    # milieu de l'installation. UnrealIRCd, lui, se télécharge d'une source
    # officielle épinglée.
    local srv="${OVERTCHAT_SERVEUR:-unrealircd}"
    local echec=""

    _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"
    local paquets=() comp
    for comp in site "$srv" eggdrop irix; 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

    # ── 2. Le site, et l'identité qu'il écrit ───────────────────────────
    if [[ -z "$echec" ]]; then
        _section "2/6 — Site et identité du réseau"
        _site_install || echec="le site"
    fi

    # ── 3. Le serveur IRC ───────────────────────────────────────────────
    if [[ -z "$echec" ]]; then
        _section "3/6 — Serveur IRC"
        _server_install "$srv" || echec="le serveur IRC ($srv)"
    fi

    # ── 4. Eggdrop ──────────────────────────────────────────────────────
    if [[ -z "$echec" ]]; then
        _section "4/6 — Eggdrop"
        _service_install eggdrop || echec="Eggdrop"
    fi

    # ── 5. IriX ─────────────────────────────────────────────────────────
    if [[ -z "$echec" ]]; then
        _section "5/6 — IriX"
        _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"
    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)"
        _sysd_poser   || echec="la pose des unités systemd"
        [[ -n "$echec" ]] || _sysd_activer || echec="l'activation des unités systemd"
    fi

    printf "\n"
    if [[ -n "$echec" ]]; then
        _err "Installation automatique interrompue : ${echec}."
        _info "Ce qui précède est en place. Relancez sans --auto pour reprendre au menu."
        return 1
    fi

    _ok "Installation automatique terminée."
    # ⚠ On ne dit pas « le réseau est en service » : les unités sont posées et
    # activées, ce qui n'est pas la même chose que démarrées et jointes. Le
    # menu « Services système » et « status » le disent, eux, par mesure.
    _info "Vérifiez l'état avec « $0 status », puis démarrez depuis le menu."
    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
                ;;
            --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.
            check|update|upgrade|status|history|rollback|version|anope-config)
                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 "--auto s'applique à l'installation, pas à la commande « $commande »."
        exit 2
    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"

        _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=$?
        _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"
    _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.
    if [[ "${MODE_AUTO:-0}" == "1" ]]; then
        _auto_prevol || exit 2
    fi

    _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 "Installation nommée « ${nom_choisi} » — relance de l'assistant."
                _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 )
                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

    if [[ "$PRIV_MODE" == "root" && "$PRIV_USER" == "root" ]]; then
        _warn "Lancé en root sans SUDO_USER : l'arborescence ira dans ${BASE_DIR}."
        _warn "Préfère « sudo $0 --root » depuis ton compte pour garder les fichiers chez toi."
    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 "Config créée : $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 "Arborescence prête : $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 "$@"
