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

# streamlitctl - Streamlit Platform Manager for SecuBox Debian
# Control script for LXC-based Streamlit application hosting
# Three-fold architecture: Components, Status, Access
set -e

VERSION="1.1.0"

LXC_NAME="streamlit"
LXC_PATH="/var/lib/lxc/$LXC_NAME"
DATA_PATH="/srv/streamlit"
APPS_PATH="${SECUBOX_STREAMLIT_APPS_PATH:-$DATA_PATH/apps}"
CONF_PATH="${SECUBOX_STREAMLIT_CONF:-/etc/secubox/streamlit.toml}"
LOG_FILE="/var/log/secubox/streamlit.log"

# Logging
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" 2>/dev/null || echo "$*"; }
error() { log "ERROR: $*" >&2; }

# Load config
load_config() {
    if [ -f "$CONF_PATH" ]; then
        DATA_PATH=$(grep -E '^data_path\s*=' "$CONF_PATH" | cut -d'"' -f2 || echo "$DATA_PATH")
        APPS_PATH="$DATA_PATH/apps"
    fi
}

# LXC helpers
lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; }
lxc_exists() { [ -d "$LXC_PATH/rootfs" ]; }

# ─────────────────────────────────────────────────────────────────────
# IDLE-TRACKING HELPERS (#331)
# ─────────────────────────────────────────────────────────────────────

IDLE_STATE_DIR="${SECUBOX_STREAMLIT_IDLE_DIR:-/var/lib/secubox/streamlit/idle}"

# Répertoire des vignettes capturées (#958) — écrit par `streamlit-shotter`
# (api/shots.py, secubox_core.screenshots), jamais par ce script : ce
# dernier ne fait que LIRE la métadonnée pour l'exposer dans `app audit`
# (champs screenshot_*, voir plus bas). Ne dépend pas de `load_config` /
# data_path, donc pas de ré-application après `load_config()` comme
# APPS_PATH/CONF_PATH/IDLE_STATE_DIR plus bas dans MAIN.
SHOTS_DIR="${SECUBOX_STREAMLIT_SHOTS_DIR:-/var/cache/secubox/streamlit/shots}"

# Read [idle] settings from /etc/secubox/streamlit.toml with safe defaults.
# Note: `grep | head | cut | tr` always exits 0 (tr's status) even when grep
# matched nothing, so a trailing `|| echo $default` never fires and missing
# keys silently returned empty. Capture explicitly and fall back via
# `${v:-$default}` for the missing-or-blank case.
_idle_config() {
    local key="$1" default="$2" v
    v=$(grep -E "^\s*${key}\s*=" "$CONF_PATH" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d ' "')
    echo "${v:-$default}"
}

# Returns 0 if the app is listening on its port INSIDE the LXC, else 1.
_app_running() {
    local name="$1"
    local port; port=$(grep -E "^port\s*=" "$(_app_port_file "$name")" 2>/dev/null | cut -d'=' -f2 | tr -d ' "')
    [ -z "$port" ] && return 1
    lxc_running || return 1
    lxc-attach -n "$LXC_NAME" -- ss -tln "sport = :$port" 2>/dev/null \
        | awk -v p="$port" 'NR>1 && $4 ~ ":"p"$" {found=1} END {exit found?0:1}'
}

# Le décompte des connexions ESTABLISHED par port ne passe plus par une
# sonde `ss` PAR APPLI (ex-_port_active_conns, #958) : il est tiré de
# l'instantané unique de `app idle-check` (_idle_snapshot_ensure, #961),
# un seul lxc-attach pour tout le parc. Le port reste celui du PROCESS
# (_scan_running_apps), jamais celui du .streamlit.toml par-appli.

# Last-activity epoch for the app (file mtime). Touched by cmd_app_idle_check
# when ESTABLISHED>0; created at start time by cmd_app_start.
_app_last_active() {
    local name="$1"
    local f="$IDLE_STATE_DIR/$name.state"
    [ -f "$f" ] || { echo 0; return; }
    stat -c %Y "$f"
}

_app_touch_active() {
    local name="$1"
    mkdir -p "$IDLE_STATE_DIR"
    touch "$IDLE_STATE_DIR/$name.state"
}

# Chemin du .streamlit.toml par-appli (port persisté), lu par _app_running
# (encore utilisé par `app wake` pour son polling post-lancement, cf. ce
# fichier plus bas) et écrit par cmd_app_start/cmd_app_restart. Appli-
# répertoire : fichier DANS le répertoire de l'appli (comportement
# historique, inchangé). Appli-script à plat : fichier caché VOISIN sous
# APPS_PATH — jamais un répertoire fantôme homonyme du script, qui
# apparaîtrait comme une entrée disque en double dans `app audit`/`app
# list` (#959 : la moitié des applis de la board sont des scripts .py à
# plat, pas des répertoires).
_app_port_file() {
    local name="$1"
    if [ -d "$APPS_PATH/$name" ]; then
        printf '%s' "$APPS_PATH/$name/.streamlit.toml"
    else
        printf '%s' "$APPS_PATH/.streamlit-$name.toml"
    fi
}

# ─────────────────────────────────────────────────────────────────────
# SYSTEMD PER-APP UNIT HELPERS (structural fix — replaces streamlit-all.
# service) — one `streamlit-app@<name>.service` instance per app, running
# INSIDE the LXC (lxc/streamlit-app@.service + lxc/streamlit-launch).
# `cmd_app_start`/`cmd_app_stop`/`cmd_app_wake`/`cmd_app_idle_check` drive
# these units via `systemctl` instead of a detached `nohup`; the unit's
# own ExecStart (streamlit-launch) reads the exact same per-app
# .streamlit.toml this script already writes.
# ─────────────────────────────────────────────────────────────────────

# Systemd instance name for app $1. No escaping: app names are directory/
# file basenames already used unescaped as bash associative-array keys
# and TOML section names ([apps.$name]) throughout this file — the same
# assumption is extended here rather than introducing a THIRD naming
# convention (raw vs systemd-escaped) for the same app name.
_unit_instance() {
    printf 'streamlit-app@%s.service' "$1"
}

# Port already recorded for app $1, from the highest-priority source
# available: an explicit CLI argument ($2, may be empty), then the
# persisted per-app .streamlit.toml (_app_port_file — written by a prior
# `app start`/`app wake`/`app repair --apply`), then a declared
# `[apps.$1].port` in CONF_PATH. Never a hardcoded default: an app with
# none of these returns empty, which callers must treat as "refuse to
# start" — never silently pick 8501 (exactly how 5 apps ended up sharing
# that port on the board).
_resolve_app_port() {
    local name="$1" explicit="${2:-}"
    if [ -n "$explicit" ] && [ "$explicit" != "0" ]; then
        printf '%s' "$explicit"
        return 0
    fi
    local port_file; port_file=$(_app_port_file "$name")
    local persisted=""
    [ -f "$port_file" ] && persisted=$(grep -E '^port[ \t]*=' "$port_file" 2>/dev/null \
        | head -1 | cut -d'=' -f2 | tr -d ' "')
    if [ -n "$persisted" ] && [ "$persisted" != "0" ]; then
        printf '%s' "$persisted"
        return 0
    fi
    if _app_declared "$name"; then
        local declared; declared=$(_toml_section_value "$CONF_PATH" "$name" port)
        if [ -n "$declared" ] && [ "$declared" != "0" ]; then
            printf '%s' "$declared"
            return 0
        fi
    fi
    return 1
}

# True if port $2 is currently held by a RUNNING app other than $1, per
# _scan_running_apps (the same single lxc-attach/_ps_lines source of
# truth `app list`/`app audit`/`app shot-target` already use — #958). An
# app is never "in conflict with itself": restarting an app already
# running on its own persisted port must proceed.
_port_conflicts_with_another_app() {
    local name="$1" port="$2"
    _scan_running_apps
    local other
    for other in "${!_RUN_PORT[@]}"; do
        [ "$other" = "$name" ] && continue
        [ "${_RUN_PORT[$other]}" = "$port" ] && return 0
    done
    return 1
}

# Escape a string for safe JSON insertion (backlash and double quote)
_json_escape() {
    local s="$1"
    # Escape backslashes first, then double quotes
    s="${s//\\/\\\\}"
    s="${s//\"/\\\"}"
    echo "$s"
}

# Escape a string's ERE metacharacters for safe embedding in a `pkill -f`
# pattern (#961). A resolved entrypoint always contains at least one
# unescaped "." (the .py extension), which in a regex matches ANY
# character — left unescaped, "control/app.py" would also match a
# hypothetical "control/appXpy". Used by cmd_app_stop to build an exact
# pattern instead of the previous bare-substring "streamlit.*$name".
_ere_escape() {
    printf '%s' "$1" | sed -e 's/[][\.^$*+?(){}|]/\\&/g'
}

# ─────────────────────────────────────────────────────────────────────
# SCREENSHOT HELPERS (#958) — target resolution for the capturer, and
# read-only exposure of its results in `app audit`.
# ─────────────────────────────────────────────────────────────────────

# IP interne du conteneur LXC streamlit sur son bridge (br-lan, #958). Le
# chromium qui capture les vignettes tourne sur l'HÔTE, jamais dans le
# conteneur (~2 Go de RAM disponibles sur la board ne tiennent pas deux
# chromium, #957 §3.4) : c'est cette IP directe, jamais le vhost public,
# qui doit servir de cible. Deux raisons l'imposent : (1) la chaîne
# WAF/mitmproxy casse la mise à niveau WebSocket dont Streamlit dépend pour
# peindre quoi que ce soit — le rendu réel n'apparaît qu'après connexion
# websocket (#957 §2) ; (2) 4 des domaines déclarés ne sont même pas
# routés côté WAF (421). Le conteneur est sur le LAN (DHCP), pas sur un
# réseau isolé à IP fixe comme d'autres modules — d'où une résolution
# dynamique via lxc-info plutôt qu'un calcul.
_lxc_ip() {
    lxc_running || return 1
    local ip
    ip=$(lxc-info -n "$LXC_NAME" -i -H 2>/dev/null | grep -v '^127\.' | head -1)
    [ -n "$ip" ] || return 1
    printf '%s' "$ip"
}

# Extrait la valeur (guillemets retirés) de la clé $2 dans le fichier JSON
# $1 — écrit par secubox_core.screenshots (json.dumps(meta, indent=2), une
# paire clé/valeur par ligne). Pas de jq : jamais introduit comme
# dépendance dans ce fichier, et le format produit ici est fixe, propre à
# ce projet — un grep/sed ciblé suffit et reste cohérent avec le reste du
# fichier (TOML lu de la même façon).
_shot_meta_value() {
    local file="$1" key="$2"
    [ -f "$file" ] || return
    grep -E "\"${key}\"[ \t]*:" "$file" 2>/dev/null | head -1 \
        | sed -E "s/.*\"${key}\"[ \t]*:[ \t]*//; s/,[[:space:]]*\$//" | tr -d '"'
}

# ─────────────────────────────────────────────────────────────────────
# AUDIT HELPERS (#956) — cross-reference disk / declared / running
# ─────────────────────────────────────────────────────────────────────

# Liste des processus streamlit, une ligne par appli en cours.
# SECUBOX_STREAMLIT_PS_SOURCE permet de tester sans conteneur.
_ps_lines() {
    if [ -n "${SECUBOX_STREAMLIT_PS_SOURCE:-}" ]; then
        cat "$SECUBOX_STREAMLIT_PS_SOURCE" 2>/dev/null
        return
    fi
    # Instantané déjà relevé dans cette exécution (`app idle-check`, cf.
    # _idle_snapshot_ensure) : on le relit plutôt que de refaire un
    # lxc-attach — c'est ce qui tient `idle-check` à UN SEUL appel au
    # conteneur (#961) tout en le laissant passer par _scan_running_apps,
    # le même apparieur que `app list`/`app audit`/`app shot-target` (#958).
    if [ "${_IDLE_SNAPSHOT_FETCHED:-false}" = "true" ]; then
        printf '%s\n' "$_IDLE_SNAPSHOT_PS" | grep "streamlit run" | grep -v grep || true
        return 0
    fi
    # No -P here: it would force a specific lxcpath. lxc-attach must resolve
    # the container's own lxc.lxcpath (see /etc/lxc/lxc.conf on the board) —
    # hardcoding one here duplicated (and drifted from) that configuration.
    lxc-attach -n "$LXC_NAME" -- \
        ps -eo args 2>/dev/null | grep "streamlit run" | grep -v grep
}

# Nom d'appli déduit d'un chemin de script : "a/app.py" -> "a", "b.py" -> "b".
_app_name_of_script() {
    case "$1" in
        */*) printf '%s' "${1%%/*}" ;;
        *)   printf '%s' "${1%.py}" ;;
    esac
}

# Table "path déclaré (champ 'path = ...' d'une section [apps.NAME]) ->
# nom d'appli", à partir de CONF_PATH. Sert de priorité déclarée avant de
# retomber sur l'heuristique du nom de fichier lors du rattachement
# processus <-> appli (cf. _scan_running_apps ci-dessous). Résultat via le
# tableau associatif global _DECLARED_PATH_TO_NAME (remis à zéro à chaque
# appel).
_declared_path_to_name() {
    declare -gA _DECLARED_PATH_TO_NAME=()
    # `return 0`, jamais un `return` nu : il rendrait le statut du test
    # (1), et sous `set -e` un CONF_PATH absent ferait avorter en silence
    # tout appelant de _scan_running_apps — `app idle-check` compris.
    [ -f "$CONF_PATH" ] || return 0
    local line name p
    while IFS= read -r line; do
        [ -z "$line" ] && continue
        name=$(printf '%s' "$line" | sed 's/^\[apps\.//;s/\]$//')
        [ -n "$name" ] || continue
        p=$(sed -n "/^\[apps\.${name}\]/,/^\[/p" "$CONF_PATH" 2>/dev/null \
            | grep -E '^path\s*=' | head -1 | cut -d'"' -f2)
        # `if` explicite, jamais un `[ ... ] && affectation` en fin de
        # boucle : sous `set -e`, ce serait le dernier statut de la
        # dernière itération qui deviendrait celui de la boucle
        # while ELLE-MÊME (donc de cette fonction) — un plantage sec dès
        # la première section [apps.*] déclarée SANS "path = ..." (le cas
        # courant), avant même d'atteindre le code appelant.
        if [ -n "$p" ]; then
            _DECLARED_PATH_TO_NAME["$p"]="$name"
        fi
    done < <(grep -E '^\[apps\.[^]]+\]' "$CONF_PATH" 2>/dev/null)
}

# Appariement UNIQUE processus streamlit <-> nom d'appli + port réel
# (#958). Avant ce correctif, `app list` (matching maison, port lu dans le
# .streamlit.toml par-appli) et `app shot-target` (via _app_running : port
# lu dans le même .streamlit.toml, présence vérifiée par un `ss` PAR APPLI
# dans la LXC) se contredisaient sur la board : `app list` annonçait une
# appli "running":true avec "port":0, `app shot-target` répondait "not
# running" pour cette même appli au même instant — deux logiques
# parallèles jamais recroisées, chacune correcte sur son propre terrain et
# fausse sur l'autre. Cette fonction est désormais la SEULE source de
# vérité pour "quelles applis tournent, sur quel port", partagée par
# cmd_app_list, cmd_app_audit, cmd_app_repair et cmd_app_shot_target.
#
# Gère les deux formes de passage du port observées sur la board
# (`--server.port 8509`, séparé par une espace, et `--server.port=8509` —
# seule forme produite par le chemin de lancement de ce fichier ; les
# process déjà vivants sur la board n'ont pas tous été lancés par ce
# chemin) et les deux formes de point d'entrée (script à plat "app.py" et
# sous-répertoire "dir/app.py", via _app_name_of_script).
#
# Résultat via les tableaux associatifs globaux _RUN_SEEN (nom -> 1),
# _RUN_PORT (nom -> port observé, "0" si non extrait de la ligne process)
# et _RUN_TOKEN (nom -> chemin de script tel que lancé) — tous remis à
# zéro à chaque appel. Un seul appel à `_ps_lines` (donc un seul
# lxc-attach) par appel de cette fonction : ne JAMAIS la boucler par
# appli, cf. note perf de `_ps_lines`.
_scan_running_apps() {
    declare -gA _RUN_SEEN=() _RUN_PORT=() _RUN_TOKEN=()
    _declared_path_to_name

    local ps_line script port app
    while IFS= read -r ps_line; do
        [ -z "$ps_line" ] && continue
        script=$(printf '%s\n' "$ps_line" | sed -n 's/.*streamlit run \([^ ]*\).*/\1/p')
        [ -z "$script" ] && continue
        port=$(printf '%s\n' "$ps_line" | sed -n 's/.*--server\.port[= ]*\([0-9]*\).*/\1/p')
        if [ -n "${_DECLARED_PATH_TO_NAME[$script]:-}" ]; then
            app="${_DECLARED_PATH_TO_NAME[$script]}"
        else
            app=$(_app_name_of_script "$script")
        fi
        # _app_name_of_script attend un chemin RELATIF à APPS_PATH (le seul
        # format que produit le chemin de lancement de ce fichier — cf.
        # cmd_app_start — et le seul observé sur la board). Un chemin
        # absolu ferait remonter une chaîne vide (`${1%%/*}` sur un chemin
        # commençant par "/"), qui plante tout appariement en clé de
        # tableau associatif ("[]" n'est pas un indice valide) : ignorer la
        # ligne plutôt que planter tout `app list`/`app audit` sur un
        # unique process mal formé.
        [ -z "$app" ] && continue
        _RUN_SEEN["$app"]=1
        _RUN_TOKEN["$app"]="$script"
        _RUN_PORT["$app"]="${port:-0}"
    done < <(_ps_lines)
}

# Entrées de $APPS_PATH à exclure de l'inventaire (#956) : ce ne sont
# jamais des applis, elles ne doivent apparaître ni comme appli ni comme
# anomalie (a fortiori ne jamais être proposées à la suppression).
#   __pycache__  cache bytecode Python, généré à l'exécution — jamais un
#                point d'entrée.
#   pages        convention Streamlit multi-page : sous-dossier des
#                sous-pages d'une appli EXISTANTE (vérifié sur la board :
#                1_editor.py, 2_posts.py, 3_media.py, 4_settings.py, tous
#                référencés par metabolizer.py). Ce n'est pas une appli à
#                part entière et la supprimer casserait metabolizer.
_EXCLUDED_ENTRY_NAMES=(
    "__pycache__"
    "pages"
)

# Vrai si $1 (nom de base, sans chemin) ne doit jamais être traité comme
# une appli ni comme un candidat de point d'entrée : la liste ci-dessus,
# plus tout nom commençant par un point (dotfiles/dossiers cachés, ex.
# .git, .streamlit, .deploy.json).
_is_excluded_entry() {
    local base="$1"
    case "$base" in
        .*) return 0 ;;
    esac
    local ex
    for ex in "${_EXCLUDED_ENTRY_NAMES[@]}"; do
        [ "$base" = "$ex" ] && return 0
    done
    return 1
}

# Détection du point d'entrée d'une appli-répertoire (#956). L'ancienne
# détection ne cherchait que app.py/main.py/streamlit_app.py à la racine,
# ce qui faisait passer pour "sans point d'entrée" des applis bien
# vivantes, simplement nommées autrement (vérifié sur la board :
# Photo_cloud_streamlit_main/photo_cloud_streamlit.py, control/src/app.py,
# yijing/yijing-oracle/app.py...). Règles, dans l'ordre, sans jamais
# deviner en cas d'ambiguïté :
#   1. noms conventionnels Streamlit à la racine (priorité)
#   2. à défaut, un unique fichier .py à la racine
#   3. à défaut, un niveau plus bas : mêmes règles 1+2 appliquées dans
#      chacun des sous-répertoires (hors exclusions ci-dessus) ; retenu
#      seulement si UN SEUL sous-répertoire produit un candidat
#   4. sinon : aucune supposition — pas de point d'entrée retenu, mais le
#      nombre de candidats .py concurrents est renvoyé pour trancher
#      humain (_EP_CANDIDATES)
# Résultat renvoyé via les globales _EP_RESULT (chemin relatif au
# répertoire de l'appli, ou "" si non résolu) et _EP_CANDIDATES (0 si non
# ambigu, sinon le nombre de candidats concurrents trouvés).
_detect_entrypoint() {
    local dir="$1"
    _EP_RESULT=""
    _EP_CANDIDATES=0

    local c
    for c in app.py main.py streamlit_app.py; do
        if [ -f "$dir/$c" ]; then
            _EP_RESULT="$c"
            return
        fi
    done

    local -a root_py=()
    local f
    for f in "$dir"/*.py; do
        [ -f "$f" ] || continue
        root_py+=("$(basename "$f")")
    done

    if [ "${#root_py[@]}" -eq 1 ]; then
        _EP_RESULT="${root_py[0]}"
        return
    fi

    # Un niveau plus bas, mêmes règles 1+2 appliquées à chaque sous-dossier.
    local -a sub_hits=()
    local sd sbase
    for sd in "$dir"/*/; do
        [ -d "$sd" ] || continue
        sbase=$(basename "$sd")
        _is_excluded_entry "$sbase" && continue

        local found=""
        local sc
        for sc in app.py main.py streamlit_app.py; do
            [ -f "$sd$sc" ] && found="$sc" && break
        done
        if [ -z "$found" ]; then
            local -a sub_py=()
            local sf
            for sf in "$sd"*.py; do
                [ -f "$sf" ] || continue
                sub_py+=("$(basename "$sf")")
            done
            [ "${#sub_py[@]}" -eq 1 ] && found="${sub_py[0]}"
        fi
        [ -n "$found" ] && sub_hits+=("${sbase}/${found}")
    done

    if [ "${#sub_hits[@]}" -eq 1 ]; then
        _EP_RESULT="${sub_hits[0]}"
        return
    fi

    if [ "${#sub_hits[@]}" -gt 1 ]; then
        _EP_CANDIDATES="${#sub_hits[@]}"
        return
    fi

    # Rien de résolu : s'il y avait plusieurs .py concurrents à la racine
    # sans nom conventionnel, c'est l'ambiguïté à signaler (règle 4) —
    # ne jamais choisir arbitrairement lequel est le "bon".
    if [ "${#root_py[@]}" -gt 1 ]; then
        _EP_CANDIDATES="${#root_py[@]}"
    fi
}

# Résout le point d'entrée d'une appli connue par SON NOM, qu'elle soit un
# script .py à plat sous APPS_PATH ou un répertoire (délègue alors à
# _detect_entrypoint) (#959). Seule source de vérité pour "l'appli X
# existe et son point d'entrée est Y" dans tout le fichier —
# cmd_app_audit, cmd_app_start et cmd_app_wake s'y réfèrent tous, pour ne
# jamais faire diverger deux logiques de résolution différentes comme
# c'était le cas avant #959 (audit savait résoudre un script à plat ou un
# entrypoint non-conventionnel, start/wake ne connaissaient qu'un
# répertoire contenant app.py).
#
# Résultat renvoyé via les globales _EP_RESULT (chemin de l'entrypoint
# RELATIF À APPS_PATH — ex. "foo.py" pour un script à plat, "bar/app.py"
# pour un répertoire, y compris quand l'entrypoint du répertoire est
# lui-même imbriqué, ex. "bar/sub/app.py") et _EP_CANDIDATES (nombre de
# candidats concurrents ambigus, seulement pertinent pour les
# répertoires — cf. _detect_entrypoint).
#
# Code de retour :
#   0  résolu, _EP_RESULT non vide
#   3  appli inconnue : ni script "$name.py", ni répertoire "$name" sous
#      APPS_PATH
#   4  appli connue (répertoire présent) mais point d'entrée non résolu —
#      _EP_CANDIDATES porte l'ambiguïté éventuelle
_app_entrypoint() {
    local name="$1"
    _EP_RESULT=""
    _EP_CANDIDATES=0

    local script="$APPS_PATH/$name.py"
    local dir="$APPS_PATH/$name"

    if [ -f "$script" ]; then
        _EP_RESULT="$name.py"
        return 0
    fi

    if [ -d "$dir" ]; then
        _detect_entrypoint "$dir"
        if [ -n "$_EP_RESULT" ]; then
            _EP_RESULT="$name/$_EP_RESULT"
            return 0
        fi
        return 4
    fi

    return 3
}

# ─────────────────────────────────────────────────────────────────────
# ARCHIVE HELPERS (#956) — an archived app is repaired-and-known but never
# auto-started: `archived = true` must actually be honoured (unlike the
# pre-existing `enabled` field, which was declarative-only decoration read
# nowhere for apps — see cmd_instance_list for the one place `enabled` was
# ever consulted, and it wasn't this one).
# ─────────────────────────────────────────────────────────────────────

# Vrai si $1 possède une section [apps.NAME] dans CONF_PATH.
_app_declared() {
    local name="$1"
    [ -f "$CONF_PATH" ] || return 1
    grep -qE "^\[apps\.${name}\]" "$CONF_PATH" 2>/dev/null
}

# Valeur brute (guillemets retirés) de la clé $3 dans la section
# [apps.$2] de $1, ou vide si absente.
_toml_section_value() {
    local file="$1" name="$2" key="$3"
    [ -f "$file" ] || return
    sed -n "/^\[apps\.${name}\]/,/^\[/p" "$file" 2>/dev/null \
        | grep -E "^${key}[ \t]*=" | head -1 \
        | sed -E "s/^${key}[ \t]*=[ \t]*//" | tr -d '"'
}

# Vrai si $1 est déclarée avec archived = true.
_app_archived() {
    local name="$1"
    [ "$(_toml_section_value "$CONF_PATH" "$name" archived)" = "true" ]
}

# Vrai si un process streamlit tourne actuellement pour l'appli $1. Même
# logique de rattachement que `app audit`/`app repair` (priorité au
# "path = ..." déclaré, repli sur l'heuristique du nom de fichier) —
# dupliquée ici à dessein plutôt que factorisée dans cmd_app_audit/
# cmd_app_repair, pour ne rien risquer sur les deux verbes déjà couverts
# par tests.
_app_currently_running() {
    local target="$1"
    local -A path_to_name=()
    if [ -f "$CONF_PATH" ]; then
        local line name p
        while IFS= read -r line; do
            name=$(printf '%s' "$line" | sed 's/^\[apps\.//;s/\]$//')
            [ -n "$name" ] || continue
            p=$(sed -n "/^\[apps\.${name}\]/,/^\[/p" "$CONF_PATH" 2>/dev/null \
                | grep -E '^path\s*=' | head -1 | cut -d'"' -f2)
            [ -n "$p" ] && path_to_name["$p"]="$name"
        done < <(grep -E '^\[apps\.[^]]+\]' "$CONF_PATH" 2>/dev/null)
    fi
    local ps_line script app
    while IFS= read -r ps_line; do
        [ -z "$ps_line" ] && continue
        script=$(printf '%s\n' "$ps_line" | sed -n 's/.*streamlit run \([^ ]*\).*/\1/p')
        [ -z "$script" ] && continue
        if [ -n "${path_to_name[$script]:-}" ]; then
            app="${path_to_name[$script]}"
        else
            app=$(_app_name_of_script "$script")
        fi
        [ "$app" = "$target" ] && return 0
    done < <(_ps_lines)
    return 1
}

# Crée un fichier temporaire dans le MÊME répertoire que $1, jamais dans
# /tmp. /tmp et /etc (entre autres) sont des systèmes de fichiers
# distincts : un `mv` entre les deux n'est pas un rename(2) mais un
# copie+suppression, donc ni atomique ni protégé contre une interruption
# en cours de copie. Un temporaire voisin garantit que le `mv` final reste
# sur le même filesystem.
_toml_mktemp() {
    local file="$1"
    mktemp "${file}.XXXXXX"
}

# Termine une réécriture "temporaire + mv" : restaure le mode et le
# propriétaire du fichier remplacé (mktemp crée toujours en 0600, ce qui
# a déjà cassé la lecture de streamlit.toml par l'agrégateur — voir #956
# post-mortem), puis renomme. $tmp étant dans le même répertoire que
# $file (cf. _toml_mktemp), ce renommage est un vrai rename(2) atomique.
_toml_atomic_replace() {
    local file="$1" tmp="$2"
    if [ -e "$file" ]; then
        chmod --reference="$file" "$tmp" 2>/dev/null || true
        chown --reference="$file" "$tmp" 2>/dev/null || true
    fi
    mv -f "$tmp" "$file"
}

# Remplace (ou insère si absente) la ligne "$3 = $4" dans la section
# [apps.$2] de $1. Réécriture complète via temporaire voisin + mv, mode
# et propriétaire d'origine préservés — même discipline que
# _set_toml_path_in_section.
_set_toml_key_in_section() {
    local file="$1" name="$2" key="$3" value="$4" tmp
    tmp=$(_toml_mktemp "$file")
    awk -v want="[apps.${name}]" -v key="$key" -v val="$value" '
        BEGIN { in_sec=0; done=0 }
        {
            if ($0 == want) { print; in_sec=1; done=0; next }
            if (in_sec && /^\[/) {
                if (!done) print key " = " val
                in_sec=0
            }
            if (in_sec && $0 ~ "^"key"[ \t]*=") {
                print key " = " val
                done=1
                next
            }
            print
        }
        END { if (in_sec && !done) print key " = " val }
    ' "$file" > "$tmp"
    _toml_atomic_replace "$file" "$tmp"
}

# ─────────────────────────────────────────────────────────────────────
# CONTAINER MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

# Where lxc/streamlit-app@.service + lxc/streamlit-launch ship once
# packaged (see debian/rules) — overridable so this can be pointed at the
# source tree's own lxc/ during development.
LXC_ASSETS_PATH="${SECUBOX_STREAMLIT_LXC_ASSETS_PATH:-/usr/lib/secubox/streamlit/lxc}"

cmd_install() {
    if lxc_exists; then
        log "Container already exists. Use 'update' to upgrade."
        return 1
    fi

    log "Installing Streamlit LXC container..."
    mkdir -p "$DATA_PATH" "$APPS_PATH" "$(dirname $LOG_FILE)"

    # Debian bookworm, not Alpine: the per-app systemd units this
    # container hosts (streamlit-app@<name>.service, structural fix
    # replacing streamlit-all.service) need systemd, which Alpine does not
    # ship. Matches every other LXC-hosted module in this repo (see
    # packages/secubox-torrent/lxc/install-lxc.sh) and the board's actual
    # running container, which already exhibits systemd-specific behavior
    # (MainPID tracking, `enabled`/`WantedBy`) an Alpine/OpenRC container
    # could never produce.
    lxc-create -n "$LXC_NAME" -t download -- \
        --dist debian --release bookworm --arch "$(dpkg --print-architecture)" || {
        error "Failed to create LXC container"
        return 1
    }

    # Configure container. Identity bind mount (DATA_PATH at the SAME
    # absolute path inside the container, not remapped to /srv/apps):
    # matches the board's actual running container, where
    # /srv/streamlit/apps is confirmed to be the cwd of every streamlit
    # process (see cmd_app_start's own note) — lxc/streamlit-launch relies
    # on that same path by default.
    cat >> "$LXC_PATH/config" << EOF

# Streamlit bind mounts (identity: same absolute path inside and outside)
lxc.mount.entry = $DATA_PATH ${DATA_PATH#/} none bind,create=dir 0 0

# Network
lxc.net.0.type = veth
lxc.net.0.link = br-lan
lxc.net.0.flags = up
lxc.net.0.name = eth0

# Resource limits
lxc.cgroup2.memory.max = 1G
lxc.cgroup2.cpu.max = 100000 100000
EOF

    # Start container and install packages
    lxc-start -n "$LXC_NAME"
    sleep 5

    log "Installing packages inside container..."
    lxc-attach -n "$LXC_NAME" -- sh -c '
        rm -f /etc/resolv.conf
        printf "nameserver 1.1.1.1\nnameserver 9.9.9.9\n" > /etc/resolv.conf
        apt-get update
        apt-get install -y --no-install-recommends python3 python3-pip python3-venv git curl ca-certificates iproute2
        pip3 install --break-system-packages streamlit pandas numpy plotly altair pillow
        mkdir -p /var/log/streamlit
    '

    # Deploy the per-app systemd unit template + its launcher (structural
    # fix — replaces streamlit-all.service). Nothing is enabled or
    # started here: an app comes up on demand (`app wake`) or, for the
    # ones declared `[apps.<name>].autostart = true`, via `systemctl
    # enable` (see cmd_autostart) — never all of them, never by default.
    if [ -f "$LXC_ASSETS_PATH/streamlit-app@.service" ] && [ -f "$LXC_ASSETS_PATH/streamlit-launch" ]; then
        tar -C "$LXC_ASSETS_PATH" -cf - "streamlit-app@.service" streamlit-launch \
            | lxc-attach -n "$LXC_NAME" -- tar -C /tmp -xf -
        lxc-attach -n "$LXC_NAME" -- sh -c '
            install -m 644 "/tmp/streamlit-app@.service" /etc/systemd/system/
            install -m 755 /tmp/streamlit-launch /usr/local/bin/streamlit-launch
            rm -f "/tmp/streamlit-app@.service" /tmp/streamlit-launch
            systemctl daemon-reload
        '
    else
        error "install: lxc unit assets not found under $LXC_ASSETS_PATH — per-app units not deployed"
    fi

    # Create hello world app (dormant: no port declared, never started
    # automatically — an operator must `app start hello <port>` or
    # declare [apps.hello].port before it can run).
    mkdir -p "$APPS_PATH/hello"
    cat > "$APPS_PATH/hello/app.py" << 'PYEOF'
import streamlit as st

st.set_page_config(page_title="SecuBox Streamlit", page_icon=":rocket:")
st.title("Welcome to SecuBox Streamlit Platform")
st.markdown("""
This is your Streamlit application hosting environment.

### Getting Started
1. Upload a ZIP file containing your app
2. The app should have an `app.py` or `main.py` entrypoint
3. Include `requirements.txt` for dependencies

### Features
- Isolated LXC container execution
- Automatic dependency installation
- Domain/vhost publishing
- Gitea integration for version control
""")
PYEOF

    log "Container installation complete"
    return 0
}

cmd_uninstall() {
    log "Uninstalling Streamlit container..."

    if lxc_running; then
        lxc-stop -n "$LXC_NAME" || true
        sleep 2
    fi

    if lxc_exists; then
        lxc-destroy -n "$LXC_NAME" || true
    fi

    log "Container removed (data preserved in $DATA_PATH)"
}

cmd_update() {
    if ! lxc_exists; then
        error "Container not installed. Run install first."
        return 1
    fi

    local was_running=false
    if lxc_running; then
        was_running=true
        log "Stopping container for update..."
        lxc-stop -n "$LXC_NAME"
        sleep 2
    fi

    log "Updating packages inside container..."
    lxc-start -n "$LXC_NAME"
    sleep 3

    lxc-attach -n "$LXC_NAME" -- sh -c '
        apk update && apk upgrade
        pip3 install --break-system-packages --upgrade streamlit pandas numpy
    '

    if ! $was_running; then
        lxc-stop -n "$LXC_NAME"
    fi

    log "Update complete"
}

cmd_start() {
    if ! lxc_exists; then
        error "Container not installed"
        return 1
    fi

    if lxc_running; then
        log "Container already running"
        return 0
    fi

    log "Starting Streamlit container..."
    lxc-start -n "$LXC_NAME"
    sleep 2

    # Start any autostart instances
    cmd_autostart

    log "Container started"
}

cmd_stop() {
    if ! lxc_running; then
        log "Container not running"
        return 0
    fi

    log "Stopping Streamlit container..."
    lxc-stop -n "$LXC_NAME"
    log "Container stopped"
}

cmd_status() {
    local running="false"
    local installed="false"

    lxc_exists && installed="true"
    lxc_running && running="true"

    local app_count=0
    [ -d "$APPS_PATH" ] && app_count=$(find "$APPS_PATH" -maxdepth 1 -type d | wc -l)
    app_count=$((app_count - 1))
    [ $app_count -lt 0 ] && app_count=0

    cat << EOF
{
  "installed": $installed,
  "running": $running,
  "container": "$LXC_NAME",
  "data_path": "$DATA_PATH",
  "app_count": $app_count
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# THREE-FOLD ARCHITECTURE: Components (What)
# ─────────────────────────────────────────────────────────────────────

cmd_components() {
    local installed=false
    local running=false
    lxc_exists && installed=true
    lxc_running && running=true

    local app_count=0
    [ -d "$APPS_PATH" ] && app_count=$(find "$APPS_PATH" -maxdepth 1 -type d 2>/dev/null | wc -l)
    app_count=$((app_count - 1))
    [ $app_count -lt 0 ] && app_count=0

    cat <<EOF
{
  "components": [
    {
      "name": "Streamlit Container",
      "type": "lxc",
      "container": "$LXC_NAME",
      "description": "Python Streamlit application runtime",
      "installed": $installed,
      "running": $running
    },
    {
      "name": "Applications",
      "type": "apps",
      "path": "$APPS_PATH",
      "description": "Deployed Streamlit applications",
      "count": $app_count
    },
    {
      "name": "Python Runtime",
      "type": "runtime",
      "description": "Python 3 with Streamlit packages",
      "installed": $installed
    }
  ]
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# THREE-FOLD ARCHITECTURE: Access (How to connect)
# ─────────────────────────────────────────────────────────────────────

cmd_access() {
    local apps_json="["
    local first=true

    for app_dir in "$APPS_PATH"/*/; do
        [ -d "$app_dir" ] || continue
        local name=$(basename "$app_dir")
        [ "$name" = "*" ] && continue

        local port=$(grep -E "^port\s*=" "$app_dir/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "")
        local running=false
        [ -n "$port" ] && ss -tln "sport = :$port" 2>/dev/null | grep -q "$port" && running=true

        $first || apps_json+=","
        first=false
        apps_json+="{\"name\":\"$name\",\"port\":${port:-0},\"running\":$running,\"url\":\"http://localhost:${port:-8501}\"}"
    done
    apps_json+="]"

    cat <<EOF
{
  "data_path": "$DATA_PATH",
  "apps_path": "$APPS_PATH",
  "apps": $apps_json,
  "admin_panel": "/streamlit/",
  "default_port_range": "8501-8600"
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# APP MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

cmd_app_list() {
    echo '{"apps":['
    local first=true

    # Appariement processus <-> appli unique (#958) — un seul appel à
    # _ps_lines (donc un seul lxc-attach) pour tout le parc, partagé avec
    # cmd_app_audit/cmd_app_repair/cmd_app_shot_target. Avant ce correctif,
    # cette fonction avait sa propre logique (port lu dans le
    # .streamlit.toml par-appli, jamais dans le process réel) qui
    # contredisait celle de `app shot-target` pour la même appli.
    _scan_running_apps

    # Entrées disque (répertoires ET scripts .py à plat, #956/#959) — les
    # scripts à plat étaient ignorés ici avant #958, ce qui faisait
    # disparaître la moitié des applis de la board du mur mosaïque.
    # __pycache__/pages/dotfiles exclus via _is_excluded_entry, jamais
    # traités comme une appli.
    local -A disk_names=()
    if [ -d "$APPS_PATH" ]; then
        local entry base
        for entry in "$APPS_PATH"/*; do
            [ -e "$entry" ] || continue
            base=$(basename "$entry")
            _is_excluded_entry "$base" && continue
            if [ -d "$entry" ]; then
                disk_names["$base"]=1
            elif [ "${base%.py}" != "$base" ]; then
                disk_names["${base%.py}"]=1
            fi
        done
    fi

    local now; now=$(date +%s)
    local tmo; tmo=$(_idle_config "timeout_minutes" "30")

    local name
    for name in $(printf '%s\n' "${!disk_names[@]}" | sort); do
        # Point d'entrée résolu par la seule source de vérité du fichier
        # (#959) — script à plat ou répertoire, conventionnel ou non.
        local ep_rc=0
        _app_entrypoint "$name" || ep_rc=$?
        local main_py=""
        [ "$ep_rc" -eq 0 ] && main_py="${_EP_RESULT#"$name"/}"

        local running="false"
        [ -n "${_RUN_SEEN[$name]:-}" ] && running="true"
        local port="${_RUN_PORT[$name]:-0}"

        $first || echo ","
        first=false

        local state="sleeping"
        [ "$running" = "true" ] && state="running"
        local last; last=$(_app_last_active "$name")
        local idle=0
        [ "$last" -gt 0 ] && idle=$(( now - last ))

        local escaped_name; escaped_name=$(_json_escape "$name")
        local escaped_main; escaped_main=$(_json_escape "$main_py")

        cat << EOF
  {"name": "$escaped_name", "main": "$escaped_main", "running": $running, "port": ${port:-0},
   "state": "$state", "last_active": $last, "idle_seconds": $idle,
   "sleep_after_seconds": $(( tmo * 60 ))}
EOF
    done

    echo ']}'
}

# app audit — croise disque (répertoires ET scripts .py à plat), déclarations
# ([apps.*] du CONF_PATH) et processus en cours (_ps_lines). Lecture seule :
# ne crée, ne modifie, ne supprime rien, ne démarre/arrête rien (#956).
#
# Valeurs possibles de "issues" par appli :
#   no-entrypoint    répertoire présent sur disque sans point d'entrée résolu
#                    (voir _detect_entrypoint) ; si l'absence vient d'une
#                    ambiguïté entre plusieurs .py concurrents, le champ
#                    "entrypoint_candidates" porte leur nombre — jamais de
#                    choix arbitraire
#   not-declared     appli observée (disque ou process) mais sans section [apps.*]
#   declared-missing appli déclarée mais introuvable ailleurs (ni disque, ni process)
#   stale-port       .streamlit.toml par-appli présent dont le port ne correspond
#                    pas au port réellement observé (ou l'appli n'est plus en ligne)
#   running-unlisted appli en cours d'exécution mais de forme "script" — donc
#                    invisible pour l'ancien app list qui n'itère que les répertoires
#   duplicate-path   le "path = ..." déclaré est partagé par plusieurs sections
#                    [apps.*] (config malformée) ; portée par CHACUNE des sections
#                    concernées — aucune n'est éliminée de la sortie pour autant
#
# __pycache__ et pages/ (convention multi-page Streamlit) ainsi que tout nom
# commençant par un point sont exclus de l'inventaire (_is_excluded_entry) :
# ce ne sont jamais des applis, cf. #956.
#
# Champs de tempo (#956 lot 1, mêmes noms/sémantique que cmd_app_list) :
#   last_active         horodatage epoch ABSOLU de la dernière activité connue
#                        (mtime du fichier d'état sous IDLE_STATE_DIR), 0 si
#                        jamais observée — jamais un décompte, le navigateur
#                        calcule le compte à rebours localement
#   idle_seconds         now - last_active, 0 si last_active vaut 0
#   sleep_after_seconds   timeout_minutes configuré (× 60), identique pour
#                        toutes les applis
cmd_app_audit() {
    local -A disk_shape=()      # name -> dir|script
    local -A disk_path=()       # name -> chemin disque absolu
    local -A declared=()        # name -> 1 si section [apps.name] présente
    # run_port/run_token/run_seen : remplis plus bas (étape 3) depuis
    # _scan_running_apps (#958) — seule source de vérité processus <->
    # appli, partagée avec cmd_app_list/cmd_app_repair/cmd_app_shot_target.

    # 1) Entrées disque : répertoires et scripts .py à plat sous APPS_PATH.
    #    _is_excluded_entry filtre __pycache__/pages/dotfiles (#956) : ce ne
    #    sont jamais des applis, ni comme entrée ni comme anomalie.
    if [ -d "$APPS_PATH" ]; then
        local entry base
        for entry in "$APPS_PATH"/*; do
            [ -e "$entry" ] || continue
            base=$(basename "$entry")
            _is_excluded_entry "$base" && continue
            if [ -d "$entry" ]; then
                disk_shape["$base"]="dir"
                disk_path["$base"]="$entry"
            elif [ "${base%.py}" != "$base" ]; then
                local sname="${base%.py}"
                disk_shape["$sname"]="script"
                disk_path["$sname"]="$entry"
            fi
            # Toute autre entrée (non-dir, non-.py) n'est pas une appli.
        done
    fi

    # 2) Applis déclarées : sections [apps.NAME] du config central, avec leur
    #    éventuel "path = ..." — le lien fiable nom-déclaré <-> script lancé
    #    (vérifié sur la board : `path = "metabolizer.py"` sous [apps.*]).
    #    Le lien path -> name lui-même (pour le matching process) est
    #    reconstruit par _scan_running_apps (#958, cf. étape 3 plus bas) ;
    #    ce bloc ne construit plus que ce dont il a besoin en propre :
    #    path_owners/dup_path pour l'issue "duplicate-path".
    local -A path_owners=()     # path déclaré -> liste (espace-séparée) des noms qui le déclarent
    local -A dup_path=()        # name -> 1 si son path déclaré est partagé par une autre section
    local -A declared_archived=()  # name -> 1 si archived = true (#956)
    if [ -f "$CONF_PATH" ]; then
        local line name
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            name=$(printf '%s' "$line" | sed 's/^\[apps\.//;s/\]$//')
            [ -n "$name" ] && declared["$name"]=1
        done < <(grep -E '^\[apps\.[^]]+\]' "$CONF_PATH" 2>/dev/null)

        for name in "${!declared[@]}"; do
            local p arch
            p=$(sed -n "/^\[apps\.${name}\]/,/^\[/p" "$CONF_PATH" 2>/dev/null \
                | grep -E '^path\s*=' | head -1 | cut -d'"' -f2)
            [ -n "$p" ] && path_owners["$p"]="${path_owners[$p]:-}$name "
            arch=$(_toml_section_value "$CONF_PATH" "$name" archived)
            [ "$arch" = "true" ] && declared_archived["$name"]=1
        done

        # Un même path déclaré par plusieurs sections [apps.*] est une
        # config malformée : il ne doit ni faire disparaître une appli, ni
        # être avalé en silence — chacune des sections concernées est
        # marquée (issue "duplicate-path" plus bas), aucune n'est éliminée.
        local path owner
        for path in "${!path_owners[@]}"; do
            local -a owners=()
            read -ra owners <<< "${path_owners[$path]}"
            if [ "${#owners[@]}" -gt 1 ]; then
                for owner in "${owners[@]}"; do
                    dup_path["$owner"]=1
                done
            fi
        done
    fi

    # 2bis) Domaine public déclaré via [instances.*], cross-référencé par le
    # champ "app" de chaque instance (#960). Le mur mosaïque construisait
    # jusqu'ici un lien "ouvrir" en assemblant http://<hôte-du-panneau>:<port>
    # — une URL qui ne peut jamais fonctionner : les applis écoutent DANS la
    # LXC (pas sur l'hôte du panneau), et le pare-feu y est en DROP par
    # défaut. Seul le domaine réellement émancipé (déclaré sous
    # [instances.*], champ "domain") constitue une destination valide ; une
    # appli qui n'en a pas ne doit renvoyer qu'une chaîne vide — jamais une
    # autre URL fabriquée. Si plusieurs instances déclarent le même "app",
    # le premier domaine rencontré est retenu, sans en faire une erreur.
    local -A app_domain=()   # nom d'appli (champ "app" d'une instance) -> domaine
    if [ -f "$CONF_PATH" ]; then
        local iline iid isection iapp idomain
        while IFS= read -r iline; do
            [ -z "$iline" ] && continue
            iid=$(printf '%s' "$iline" | sed 's/^\[instances\.//;s/\]$//')
            [ -n "$iid" ] || continue
            # Pas de `head -n -1` ici (contrairement à cmd_instance_list) :
            # quand l'instance est la DERNIÈRE section du fichier, sed
            # imprime jusqu'à la fin, et `head -n -1` couperait alors la
            # dernière ligne réelle de la section (souvent "domain = ...")
            # au lieu de l'éventuel en-tête de section suivant qui n'existe
            # pas. On laisse la plage inclure l'en-tête de la section
            # suivante quand il existe : il ne matche jamais "^app\s*="
            # ni "^domain\s*=" plus bas, donc sans effet.
            isection=$(sed -n "/^\[instances\.${iid}\]/,/^\[/p" "$CONF_PATH" 2>/dev/null)
            iapp=$(printf '%s\n' "$isection" | grep -E '^app\s*=' | head -1 | cut -d'"' -f2)
            idomain=$(printf '%s\n' "$isection" | grep -E '^domain\s*=' | head -1 | cut -d'"' -f2)
            [ -n "$iapp" ] && [ -n "$idomain" ] && [ -z "${app_domain[$iapp]:-}" ] && app_domain["$iapp"]="$idomain"
        done < <(grep -E '^\[instances\.[^]]+\]' "$CONF_PATH" 2>/dev/null)
    fi

    # 3) Processus en ligne, croisés par nom d'appli — appariement unique
    #    (#958), partagé avec cmd_app_list/cmd_app_repair/cmd_app_shot_target
    #    (single source of truth : ne jamais laisser deux logiques de
    #    matching diverger comme avant #958).
    _scan_running_apps
    local -A run_port=() run_token=() run_seen=()
    local rn
    for rn in "${!_RUN_SEEN[@]}"; do run_seen["$rn"]=1; done
    for rn in "${!_RUN_PORT[@]}"; do run_port["$rn"]="${_RUN_PORT[$rn]}"; done
    for rn in "${!_RUN_TOKEN[@]}"; do run_token["$rn"]="${_RUN_TOKEN[$rn]}"; done

    # Union de tous les noms connus (disque ∪ déclarés ∪ en ligne).
    local -A seen_names=()
    local n
    for n in "${!disk_shape[@]}"; do seen_names["$n"]=1; done
    for n in "${!declared[@]}"; do seen_names["$n"]=1; done
    for n in "${!run_seen[@]}"; do seen_names["$n"]=1; done

    local names_sorted
    names_sorted=$(printf '%s\n' "${!seen_names[@]}" | sort)

    local total=0 running_count=0 archived_count=0
    local out="["
    local first=true

    # Tempo de veille (#956 lot 1) — même calcul que cmd_app_list, hissé hors
    # de la boucle : `now` et le timeout configuré ne changent pas par appli,
    # seul `_app_last_active` (un `stat` par appli, négligeable) en dépend.
    # Surtout : AUCUN appel lxc-attach par appli ici — `_ps_lines` reste
    # appelé une seule fois plus haut, ce qui avait fait chuter cmd_app_list
    # de 17s à 8s ; le réintroduire par appli ferait regonfler app audit.
    local now; now=$(date +%s)
    local tmo; tmo=$(_idle_config "timeout_minutes" "30")
    local sleep_after=$(( tmo * 60 ))

    while IFS= read -r n; do
        [ -z "$n" ] && continue
        total=$(( total + 1 ))

        local shape="${disk_shape[$n]:-}"
        local dpath="${disk_path[$n]:-}"
        local is_declared=false
        [ -n "${declared[$n]:-}" ] && is_declared=true
        local is_running=false
        [ -n "${run_seen[$n]:-}" ] && is_running=true
        [ "$is_running" = "true" ] && running_count=$(( running_count + 1 ))
        local is_archived=false
        [ -n "${declared_archived[$n]:-}" ] && is_archived=true
        [ "$is_archived" = "true" ] && archived_count=$(( archived_count + 1 ))
        # Réparée et connue, mais jamais démarrée : un état à part, ni
        # running ni sleeping (#956) — running reste prioritaire si
        # l'appli tourne malgré tout (ex. archivée --force pendant
        # qu'elle était en cours).
        local state="sleeping"
        [ "$is_archived" = "true" ] && state="archived"
        [ "$is_running" = "true" ] && state="running"

        # last_active reste un horodatage epoch ABSOLU, jamais un décompte —
        # c'est ce qui permet au panneau d'égrener les secondes côté
        # navigateur sans rappeler le serveur. Une appli jamais vue (aucun
        # fichier d'état sous IDLE_STATE_DIR) renvoie 0 via _app_last_active,
        # jamais une erreur : le panneau doit l'afficher comme "jamais vue".
        local last; last=$(_app_last_active "$n")
        local idle=0
        [ "$last" -gt 0 ] && idle=$(( now - last ))

        local entrypoint="" persisted_port="" entrypoint_candidates=0
        if [ "$shape" = "dir" ]; then
            # Même résolution qu'un `app start`/`app wake` sur cette appli
            # (#959) — _app_entrypoint retourne un chemin relatif à
            # APPS_PATH ("$n/..."), on retire ce préfixe pour retrouver le
            # champ "entrypoint" historique, relatif au répertoire de
            # l'appli elle-même.
            local ep_rc=0
            _app_entrypoint "$n" || ep_rc=$?
            entrypoint_candidates="$_EP_CANDIDATES"
            [ "$ep_rc" -eq 0 ] && entrypoint="${_EP_RESULT#"$n"/}"
            persisted_port=$(grep -E "^port\s*=" "$dpath/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' "')
        elif [ "$shape" = "script" ]; then
            local ep_rc=0
            _app_entrypoint "$n" || ep_rc=$?
            [ "$ep_rc" -eq 0 ] && entrypoint="$_EP_RESULT"
        elif [ -n "${run_token[$n]:-}" ]; then
            # Pas d'entrée disque : on ne connaît l'appli que par un process
            # en cours (ou une déclaration) — la forme du chemin lancé donne
            # la meilleure estimation de forme disponible.
            case "${run_token[$n]}" in
                */*) shape="dir" ;;
                *)   shape="script" ;;
            esac
            entrypoint="${run_token[$n]}"
        else
            # Ni disque, ni process : déclarée seule (fantôme). On retombe
            # sur le modèle historique (répertoire) faute de mieux.
            shape="dir"
        fi

        local port=0
        if [ "$is_running" = "true" ]; then
            port="${run_port[$n]:-0}"
        elif [ -n "$persisted_port" ]; then
            port="$persisted_port"
        fi

        local exists_observed=false
        { [ -n "$dpath" ] || [ "$is_running" = "true" ]; } && exists_observed=true

        local -a issue_list=()
        [ "$shape" = "dir" ] && [ -n "$dpath" ] && [ -z "$entrypoint" ] && issue_list+=("no-entrypoint")
        [ "$exists_observed" = "true" ] && [ "$is_declared" = "false" ] && issue_list+=("not-declared")
        [ "$exists_observed" = "false" ] && [ "$is_declared" = "true" ] && issue_list+=("declared-missing")
        if [ -n "$persisted_port" ]; then
            if [ "$is_running" = "false" ] || [ "$persisted_port" != "${run_port[$n]:-}" ]; then
                issue_list+=("stale-port")
            fi
        fi
        [ "$is_running" = "true" ] && [ "$shape" = "script" ] && issue_list+=("running-unlisted")
        [ -n "${dup_path[$n]:-}" ] && issue_list+=("duplicate-path")

        local issues="[" ifirst=true
        local i
        for i in "${issue_list[@]:-}"; do
            [ -z "$i" ] && continue
            $ifirst || issues+=","
            ifirst=false
            issues+="\"$i\""
        done
        issues+="]"

        # Domaine public émancipé (#960), jamais une URL fabriquée : chaîne
        # vide si l'appli n'a aucune instance déclarée avec un domaine.
        local domain="${app_domain[$n]:-}"

        # Vignette conservée (#958) — lue depuis SHOTS_DIR, jamais calculée
        # ici : ce bloc ne fait QUE rapporter ce que streamlit-shotter a
        # déjà écrit, à AUCUN moment il ne déclenche une capture. Réutilise
        # $shape/$entrypoint déjà résolus ci-dessus (jamais une seconde
        # résolution d'entrypoint, cf. _app_entrypoint comme source unique).
        # "stale" compare l'empreinte enregistrée (mtime:taille au moment
        # de la capture) à celle du fichier source ACTUEL : les deux côtés
        # (ici et api/shots.py) doivent produire le même format
        # "<mtime>:<taille>" — stat -c '%Y:%s' côté bash, f"{mtime}:{size}"
        # côté Python — pour que la comparaison ait un sens.
        local shot_available=false shot_ok=false shot_stale=true shot_captured=""
        if [ -n "$entrypoint" ]; then
            local shot_src=""
            if [ "$shape" = "dir" ]; then
                shot_src="$APPS_PATH/$n/$entrypoint"
            else
                shot_src="$APPS_PATH/$entrypoint"
            fi
            [ -f "$SHOTS_DIR/$n/screenshot.png" ] && shot_available=true
            local shot_meta="$SHOTS_DIR/$n/screenshot.json"
            if [ -f "$shot_meta" ]; then
                local shot_ok_raw; shot_ok_raw=$(_shot_meta_value "$shot_meta" "ok")
                [ "$shot_ok_raw" = "true" ] && shot_ok=true
                shot_captured=$(_shot_meta_value "$shot_meta" "captured_at")
                local shot_stored_fp shot_current_fp
                shot_stored_fp=$(_shot_meta_value "$shot_meta" "fingerprint")
                shot_current_fp=""
                [ -f "$shot_src" ] && shot_current_fp=$(stat -c '%Y:%s' "$shot_src" 2>/dev/null)
                if [ "$shot_ok" = "true" ] && [ -n "$shot_current_fp" ] && [ "$shot_current_fp" = "$shot_stored_fp" ]; then
                    shot_stale=false
                fi
            fi
        fi

        local ename eentry edomain eshotcap
        ename=$(_json_escape "$n")
        eentry=$(_json_escape "$entrypoint")
        edomain=$(_json_escape "$domain")
        eshotcap=$(_json_escape "$shot_captured")

        $first || out+=","
        first=false
        out+="{\"name\":\"$ename\",\"shape\":\"$shape\",\"entrypoint\":\"$eentry\",\"entrypoint_candidates\":$entrypoint_candidates,\"declared\":$is_declared,\"archived\":$is_archived,\"state\":\"$state\",\"running\":$is_running,\"port\":$port,\"domain\":\"$edomain\",\"issues\":$issues,\"last_active\":$last,\"idle_seconds\":$idle,\"sleep_after_seconds\":$sleep_after,\"screenshot_available\":$shot_available,\"screenshot_ok\":$shot_ok,\"screenshot_stale\":$shot_stale,\"screenshot_captured_at\":\"$eshotcap\"}"
    done <<< "$names_sorted"

    out+="]"

    cat <<EOF
{"apps":$out,"summary":{"total":$total,"running":$running_count,"archived":$archived_count}}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# app repair — helpers d'écriture TOML (#956). Réécrivent tout le fichier
# via un temporaire voisin + mv, mode/propriétaire d'origine préservés,
# jamais d'édition in-place (cf. _toml_mktemp / _toml_atomic_replace
# ci-dessus).
# ─────────────────────────────────────────────────────────────────────

# Retire entièrement la section [apps.NAME] (jusqu'à la prochaine section
# ou la fin de fichier) d'un fichier TOML.
_remove_toml_section() {
    local file="$1" name="$2" tmp
    tmp=$(_toml_mktemp "$file")
    awk -v want="[apps.${name}]" '
        $0 == want { skip=1; next }
        skip && /^\[/ { skip=0 }
        skip { next }
        { print }
    ' "$file" > "$tmp"
    _toml_atomic_replace "$file" "$tmp"
}

# Remplace la ligne "path = ..." de la section [apps.NAME] par le chemin
# corrigé donné.
_set_toml_path_in_section() {
    local file="$1" name="$2" new_path="$3" tmp
    tmp=$(_toml_mktemp "$file")
    awk -v want="[apps.${name}]" -v newp="$new_path" '
        $0 == want { print; in_sec=1; next }
        in_sec && /^\[/ { in_sec=0 }
        in_sec && /^path[ \t]*=/ { print "path = \"" newp "\""; next }
        { print }
    ' "$file" > "$tmp"
    _toml_atomic_replace "$file" "$tmp"
}

# Remplace (ou ajoute) la ligne "port = ..." d'un .streamlit.toml par-appli.
_set_app_port() {
    local file="$1" new_port="$2" tmp
    tmp=$(_toml_mktemp "$file")
    if [ -f "$file" ] && grep -qE '^port[ \t]*=' "$file"; then
        awk -v p="$new_port" '/^port[ \t]*=/ { print "port = " p; next } { print }' "$file" > "$tmp"
    else
        { [ -f "$file" ] && cat "$file"; printf 'port = %s\n' "$new_port"; } > "$tmp"
    fi
    _toml_atomic_replace "$file" "$tmp"
}

# Remplace (ou ajoute) la ligne "entrypoint = "..."" d'un .streamlit.toml
# par-appli (fichier PLAT, sans section — même format que _set_app_port,
# jamais _set_toml_key_in_section qui vise les sections [apps.NAME] du
# CONF_PATH central). $new_value est écrit TEL QUEL (déjà entre guillemets
# par l'appelant) — cf. cmd_app_repair, seul appelant à ce jour.
_set_app_entrypoint() {
    local file="$1" new_value="$2" tmp
    tmp=$(_toml_mktemp "$file")
    if [ -f "$file" ] && grep -qE '^entrypoint[ \t]*=' "$file"; then
        awk -v v="$new_value" '/^entrypoint[ \t]*=/ { print "entrypoint = " v; next } { print }' "$file" > "$tmp"
    else
        { [ -f "$file" ] && cat "$file"; printf 'entrypoint = %s\n' "$new_value"; } > "$tmp"
    fi
    _toml_atomic_replace "$file" "$tmp"
}

# app repair [--apply] — corrige des références de configuration erronées
# sans jamais supprimer un fichier ni un répertoire (#956). Mode
# simulation par défaut : sans --apply, rien n'est écrit, seule la liste
# des actions PROPOSÉES est renvoyée. Avec --apply, chaque fichier de
# configuration modifié est d'abord sauvegardé (suffixe horodaté) puis
# réécrit.
#
# Cas traités (tous vérifiés sur la board) :
#   orphan-declaration  section [apps.NAME] du CONF_PATH sans rien nulle
#                        part ailleurs (ni disque, ni process en cours) ->
#                        proposer de retirer la section. Exemple observé :
#                        [apps.test_app] alors que test_app est absent du
#                        disque.
#   wrong-path           un "path = ..." déclaré est partagé par une autre
#                        section [apps.*] (config malformée, copier-coller)
#                        ET un FICHIER portant le nom de CETTE appli existe
#                        sur disque -> proposer de corriger la déclaration
#                        vers ce fichier. Seule la section dont le path
#                        déclaré ne correspond PAS déjà à son propre
#                        fichier est corrigée. Exemple observé :
#                        [apps.wuyun_liuqi] path="tong_shu_app.py" (en
#                        double avec [apps.tong_shu_app]) alors que
#                        wuyun_liuqi.py existe sur disque.
#   stale-port           le .streamlit.toml par-appli déclare un port qui
#                        ne correspond pas à celui du processus RÉELLEMENT
#                        en cours -> proposer de le mettre à jour depuis le
#                        processus observé. Sans processus en cours il n'y
#                        a pas de vérité de référence : rien n'est proposé
#                        (l'anomalie stale-port de `app audit` reste seule
#                        juge dans ce cas).
#
# Ce verbe ne touche JAMAIS aux fichiers/répertoires d'appli eux-mêmes,
# uniquement à CONF_PATH et aux .streamlit.toml par-appli.
cmd_app_repair() {
    local apply=false
    [ "${1:-}" = "--apply" ] && apply=true

    # ── 1) Sections déclarées + leur "path = ..." ───────────────────────
    local -A declared=() declared_path=() path_owners=()
    if [ -f "$CONF_PATH" ]; then
        local line name
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            name=$(printf '%s' "$line" | sed 's/^\[apps\.//;s/\]$//')
            [ -n "$name" ] && declared["$name"]=1
        done < <(grep -E '^\[apps\.[^]]+\]' "$CONF_PATH" 2>/dev/null)

        for name in "${!declared[@]}"; do
            local p
            p=$(sed -n "/^\[apps\.${name}\]/,/^\[/p" "$CONF_PATH" 2>/dev/null \
                | grep -E '^path\s*=' | head -1 | cut -d'"' -f2)
            if [ -n "$p" ]; then
                declared_path["$name"]="$p"
                path_owners["$p"]="${path_owners[$p]:-}$name "
            fi
        done
    fi

    # ── 2) Entrées disque (exclusions #956 appliquées) ─────────────────
    local -A disk_shape=() disk_path=()
    if [ -d "$APPS_PATH" ]; then
        local entry base
        for entry in "$APPS_PATH"/*; do
            [ -e "$entry" ] || continue
            base=$(basename "$entry")
            _is_excluded_entry "$base" && continue
            if [ -d "$entry" ]; then
                disk_shape["$base"]="dir"
                disk_path["$base"]="$entry"
            elif [ "${base%.py}" != "$base" ]; then
                disk_shape["${base%.py}"]="script"
                disk_path["${base%.py}"]="$entry"
            fi
        done
    fi

    # ── 3) Processus en ligne (nom -> port) — appariement unique (#958),
    #    partagé avec cmd_app_list/cmd_app_audit/cmd_app_shot_target ──
    _scan_running_apps
    local -A run_port=() run_seen=()
    local rn
    for rn in "${!_RUN_SEEN[@]}"; do run_seen["$rn"]=1; done
    for rn in "${!_RUN_PORT[@]}"; do run_port["$rn"]="${_RUN_PORT[$rn]}"; done

    local -A backed_up=()
    local backup_suffix; backup_suffix=$(date +%Y%m%dT%H%M%S)
    local out="[" first=true total=0 applied=0

    # ── Cas 1 : déclaration orpheline ───────────────────────────────────
    local name
    for name in $(printf '%s\n' "${!declared[@]}" | sort); do
        local on_disk=false
        [ -n "${disk_shape[$name]:-}" ] && on_disk=true
        local running=false
        [ -n "${run_seen[$name]:-}" ] && running=true
        [ "$on_disk" = "true" ] && continue
        [ "$running" = "true" ] && continue

        local did=false
        if [ "$apply" = "true" ]; then
            if [ -z "${backed_up[$CONF_PATH]:-}" ]; then
                cp -p "$CONF_PATH" "${CONF_PATH}.bak.${backup_suffix}"
                backed_up["$CONF_PATH"]=1
            fi
            _remove_toml_section "$CONF_PATH" "$name"
            did=true
            applied=$(( applied + 1 ))
        fi
        total=$(( total + 1 ))
        $first || out+=","
        first=false
        out+="{\"type\":\"orphan-declaration\",\"app\":\"$(_json_escape "$name")\",\"file\":\"$(_json_escape "$CONF_PATH")\",\"action\":\"remove section [apps.$(_json_escape "$name")]\",\"applied\":$did}"
    done

    # ── Cas 2 : chemin déclaré en double, corrigible vers le fichier
    #    propre de l'appli ─────────────────────────────────────────────
    local dup_path
    for dup_path in $(printf '%s\n' "${!path_owners[@]}" | sort); do
        local -a owners=()
        read -ra owners <<< "${path_owners[$dup_path]}"
        [ "${#owners[@]}" -gt 1 ] || continue

        local owner
        for owner in "${owners[@]}"; do
            [ "${disk_shape[$owner]:-}" = "script" ] || continue
            local correct="${owner}.py"
            [ "${declared_path[$owner]}" = "$correct" ] && continue

            local did=false
            if [ "$apply" = "true" ]; then
                if [ -z "${backed_up[$CONF_PATH]:-}" ]; then
                    cp -p "$CONF_PATH" "${CONF_PATH}.bak.${backup_suffix}"
                    backed_up["$CONF_PATH"]=1
                fi
                _set_toml_path_in_section "$CONF_PATH" "$owner" "$correct"
                did=true
                applied=$(( applied + 1 ))
            fi
            total=$(( total + 1 ))
            $first || out+=","
            first=false
            out+="{\"type\":\"wrong-path\",\"app\":\"$(_json_escape "$owner")\",\"file\":\"$(_json_escape "$CONF_PATH")\",\"from\":\"$(_json_escape "${declared_path[$owner]}")\",\"to\":\"$(_json_escape "$correct")\",\"applied\":$did}"
        done
    done

    # ── Cas 3 : port périmé dans le .streamlit.toml par-appli, corrigible
    #    seulement s'il existe un processus réel à citer comme vérité ──
    for name in $(printf '%s\n' "${!disk_shape[@]}" | sort); do
        [ "${disk_shape[$name]}" = "dir" ] || continue
        [ "${run_seen[$name]:-}" = "1" ] || continue
        local toml="${disk_path[$name]}/.streamlit.toml"
        [ -f "$toml" ] || continue
        local persisted; persisted=$(grep -E '^port\s*=' "$toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' "')
        local real="${run_port[$name]:-}"
        [ -z "$persisted" ] && continue
        [ -z "$real" ] && continue
        [ "$persisted" = "$real" ] && continue

        local did=false
        if [ "$apply" = "true" ]; then
            if [ -z "${backed_up[$toml]:-}" ]; then
                cp -p "$toml" "${toml}.bak.${backup_suffix}"
                backed_up["$toml"]=1
            fi
            _set_app_port "$toml" "$real"
            did=true
            applied=$(( applied + 1 ))
        fi
        total=$(( total + 1 ))
        $first || out+=","
        first=false
        out+="{\"type\":\"stale-port\",\"app\":\"$(_json_escape "$name")\",\"file\":\"$(_json_escape "$toml")\",\"from\":$persisted,\"to\":$real,\"applied\":$did}"
    done

    # ── Cas 4 : appli EN LIGNE dont le .streamlit.toml par-appli est
    #    absent, OU existe mais n'a pas encore de ligne "entrypoint = ..."
    #    (fichiers écrits par une version de ce script antérieure au
    #    lanceur systemd par-appli — #958 structural fix). Sans cette
    #    ligne, lxc/streamlit-launch refuse de démarrer l'appli : il ne
    #    redérive plus jamais l'entrypoint tout seul (cf. son propre
    #    commentaire — la classe de bug "deux résolutions divergentes"
    #    déjà vue en #958/#959). C'est le pas de migration : sur la board,
    #    la grande majorité des applis en ligne n'ont jamais été démarrées
    #    via `app start`, donc n'ont aucun fichier du tout.
    #
    #    Le port utilisé est TOUJOURS celui RÉELLEMENT observé
    #    (_scan_running_apps), jamais choisi/deviné — cette appli ne perd
    #    ni ne gagne de port, elle obtient juste l'enregistrement de celui
    #    qu'elle utilise déjà. Sans processus en cours pour citer un port
    #    de référence, rien n'est proposé ici (l'appli reste une anomalie
    #    "missing-port-file" ordinaire d'`app audit` tant qu'elle n'a
    #    jamais tourné sous ce contrôle).
    for name in $(printf '%s\n' "${!disk_shape[@]}" | sort); do
        local shape="${disk_shape[$name]}"
        local dpath="${disk_path[$name]}"
        local toml
        if [ "$shape" = "dir" ]; then
            toml="$dpath/.streamlit.toml"
        else
            toml="$APPS_PATH/.streamlit-${name}.toml"
        fi

        local has_entrypoint=false
        [ -f "$toml" ] && grep -qE '^entrypoint[ \t]*=' "$toml" 2>/dev/null && has_entrypoint=true
        [ "$has_entrypoint" = "true" ] && continue

        local real_port="${run_port[$name]:-}"
        [ -z "$real_port" ] && continue
        [ "$real_port" = "0" ] && continue

        local ep_rc=0
        _app_entrypoint "$name" || ep_rc=$?
        [ "$ep_rc" -eq 0 ] || continue
        # _EP_RESULT is already relative to APPS_PATH (e.g.
        # "control/test_dashboard.py" for a directory app, "billets.py"
        # for a flat script) — the exact format lxc/streamlit-launch
        # expects (it `cd`s to APPS_PATH before exec'ing streamlit run),
        # and the exact format cmd_app_start already persists. No
        # stripping here — a second, subtly different format for the
        # same field is exactly the kind of divergence #958/#959 already
        # burned this project on once.
        local ep_for_file="$_EP_RESULT"

        local did=false
        if [ "$apply" = "true" ]; then
            mkdir -p "$(dirname "$toml")"
            if [ -f "$toml" ]; then
                if [ -z "${backed_up[$toml]:-}" ]; then
                    cp -p "$toml" "${toml}.bak.${backup_suffix}"
                    backed_up["$toml"]=1
                fi
                _set_app_port "$toml" "$real_port"
            else
                printf 'port = %s\n' "$real_port" > "$toml"
            fi
            _set_app_entrypoint "$toml" "\"$ep_for_file\""
            did=true
            applied=$(( applied + 1 ))
        fi
        total=$(( total + 1 ))
        $first || out+=","
        first=false
        out+="{\"type\":\"missing-entrypoint-record\",\"app\":\"$(_json_escape "$name")\",\"file\":\"$(_json_escape "$toml")\",\"port\":$real_port,\"entrypoint\":\"$(_json_escape "$ep_for_file")\",\"applied\":$did}"
    done

    out+="]"

    cat <<EOF
{"apply":$apply,"actions":$out,"summary":{"total":$total,"applied":$applied}}
EOF
}

# app archive <name> [--apply] [--force] — bascule une appli DÉJÀ DÉCLARÉE
# vers archived = true, enabled = false (#956). Convention `app repair`
# exacte : simulation par défaut (rien n'est écrit sans --apply), sortie
# JSON {"apply":...,"actions":[...],"summary":{...}}, sauvegarde horodatée
# de CONF_PATH avant toute écriture. Ne touche jamais aux fichiers/
# répertoires d'appli — archiver n'est pas supprimer.
#
# Une appli en cours d'exécution n'est jamais arrêtée par ce verbe : sans
# --force, l'action est signalée bloquée ("running, use --force...")
# plutôt qu'appliquée. Une appli déjà archivée est un no-op signalé
# ("already archived"), pas une erreur.
cmd_app_archive() {
    local name="" apply=false force=false
    local a
    for a in "$@"; do
        case "$a" in
            --apply) apply=true ;;
            --force) force=true ;;
            *) [ -z "$name" ] && name="$a" ;;
        esac
    done

    [ -z "$name" ] && { error "Usage: streamlitctl app archive <name> [--apply] [--force]"; return 1; }

    if ! _app_declared "$name"; then
        error "App non déclarée : $name (déclarez-la d'abord, ou utilisez 'app archive --undeclared' pour l'archivage groupé)"
        return 1
    fi

    local already=false
    _app_archived "$name" && already=true
    local running=false
    _app_currently_running "$name" && running=true

    local blocked=false reason=""
    if [ "$already" = "true" ]; then
        blocked=true
        reason="already archived"
    elif [ "$running" = "true" ] && [ "$force" = "false" ]; then
        blocked=true
        reason="running, use --force to archive anyway (the app itself is left untouched)"
    fi

    local did=false
    if [ "$blocked" = "false" ] && [ "$apply" = "true" ]; then
        cp -p "$CONF_PATH" "${CONF_PATH}.bak.$(date +%Y%m%dT%H%M%S)"
        _set_toml_key_in_section "$CONF_PATH" "$name" "archived" "true"
        _set_toml_key_in_section "$CONF_PATH" "$name" "enabled" "false"
        did=true
    fi

    local applied=0
    [ "$did" = "true" ] && applied=1

    cat <<EOF
{"apply":$apply,"actions":[{"type":"archive","app":"$(_json_escape "$name")","file":"$(_json_escape "$CONF_PATH")","running":$running,"blocked":$blocked,"reason":"$(_json_escape "$reason")","applied":$did}],"summary":{"total":1,"applied":$applied}}
EOF
}

# app unarchive <name> [--apply] — l'inverse de `app archive` : archived =
# false, enabled = true. Ne démarre rien — l'appli redevient simplement
# démarrable via `app start`. Même convention simulation/--apply que
# `app repair`.
cmd_app_unarchive() {
    local name="" apply=false
    local a
    for a in "$@"; do
        case "$a" in
            --apply) apply=true ;;
            *) [ -z "$name" ] && name="$a" ;;
        esac
    done

    [ -z "$name" ] && { error "Usage: streamlitctl app unarchive <name> [--apply]"; return 1; }

    if ! _app_declared "$name"; then
        error "App non déclarée : $name"
        return 1
    fi

    local was_archived=false
    _app_archived "$name" && was_archived=true

    local blocked=false reason=""
    [ "$was_archived" = "false" ] && { blocked=true; reason="not archived"; }

    local did=false
    if [ "$blocked" = "false" ] && [ "$apply" = "true" ]; then
        cp -p "$CONF_PATH" "${CONF_PATH}.bak.$(date +%Y%m%dT%H%M%S)"
        _set_toml_key_in_section "$CONF_PATH" "$name" "archived" "false"
        _set_toml_key_in_section "$CONF_PATH" "$name" "enabled" "true"
        did=true
    fi

    local applied=0
    [ "$did" = "true" ] && applied=1

    cat <<EOF
{"apply":$apply,"actions":[{"type":"unarchive","app":"$(_json_escape "$name")","file":"$(_json_escape "$CONF_PATH")","blocked":$blocked,"reason":"$(_json_escape "$reason")","applied":$did}],"summary":{"total":1,"applied":$applied}}
EOF
}

# app archive --undeclared [--apply] [--force] — déclare, directement en
# état archivé, toutes les applis présentes sur APPS_PATH mais absentes de
# CONF_PATH (#956 : 29 relevées sur la board). Réutilise la détection de
# point d'entrée de `app audit` (_detect_entrypoint) : sans point d'entrée
# résolu, l'appli est tout de même déclarée archivée (sans "path = ..."),
# `app audit`/`app repair` restant seuls juges de cette anomalie séparée.
#
# Même garde-fou que `app archive <name>` : une appli déjà en cours
# d'exécution n'est jamais arrêtée, et n'est déclarée que si --force est
# passé. Mêmes conventions repair : simulation par défaut, JSON, backup
# horodaté unique pour tout le lot avant la première écriture.
cmd_app_archive_undeclared() {
    local apply=false force=false
    local a
    for a in "$@"; do
        case "$a" in
            --apply) apply=true ;;
            --force) force=true ;;
        esac
    done

    local -A declared=()
    if [ -f "$CONF_PATH" ]; then
        local line name
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            name=$(printf '%s' "$line" | sed 's/^\[apps\.//;s/\]$//')
            [ -n "$name" ] && declared["$name"]=1
        done < <(grep -E '^\[apps\.[^]]+\]' "$CONF_PATH" 2>/dev/null)
    fi

    local -A disk_shape=() disk_path=()
    if [ -d "$APPS_PATH" ]; then
        local entry base
        for entry in "$APPS_PATH"/*; do
            [ -e "$entry" ] || continue
            base=$(basename "$entry")
            _is_excluded_entry "$base" && continue
            if [ -d "$entry" ]; then
                disk_shape["$base"]="dir"
                disk_path["$base"]="$entry"
            elif [ "${base%.py}" != "$base" ]; then
                disk_shape["${base%.py}"]="script"
                disk_path["${base%.py}"]="$entry"
            fi
        done
    fi

    local backup_done=false
    local backup_suffix; backup_suffix=$(date +%Y%m%dT%H%M%S)
    local out="[" first=true total=0 applied=0

    local name
    for name in $(printf '%s\n' "${!disk_shape[@]}" | sort); do
        [ -n "${declared[$name]:-}" ] && continue   # déjà déclarée : hors périmètre

        local shape="${disk_shape[$name]}"
        local dpath="${disk_path[$name]}"
        local path_field=""
        if [ "$shape" = "dir" ]; then
            _detect_entrypoint "$dpath"
            [ -n "$_EP_RESULT" ] && path_field="${name}/${_EP_RESULT}"
        else
            path_field="${name}.py"
        fi

        local running=false
        _app_currently_running "$name" && running=true
        local blocked=false reason=""
        if [ "$running" = "true" ] && [ "$force" = "false" ]; then
            blocked=true
            reason="running, use --force to archive anyway (the app itself is left untouched)"
        fi

        local did=false
        if [ "$blocked" = "false" ] && [ "$apply" = "true" ]; then
            if [ "$backup_done" = "false" ]; then
                [ -f "$CONF_PATH" ] && cp -p "$CONF_PATH" "${CONF_PATH}.bak.${backup_suffix}"
                backup_done=true
            fi
            {
                printf '\n[apps.%s]\n' "$name"
                printf 'name = "%s"\n' "$name"
                [ -n "$path_field" ] && printf 'path = "%s"\n' "$path_field"
                printf 'archived = true\n'
                printf 'enabled = false\n'
            } >> "$CONF_PATH"
            did=true
            applied=$(( applied + 1 ))
        fi

        total=$(( total + 1 ))
        $first || out+=","
        first=false
        out+="{\"type\":\"archive-undeclared\",\"app\":\"$(_json_escape "$name")\",\"file\":\"$(_json_escape "$CONF_PATH")\",\"path\":\"$(_json_escape "$path_field")\",\"shape\":\"$(_json_escape "$shape")\",\"running\":$running,\"blocked\":$blocked,\"reason\":\"$(_json_escape "$reason")\",\"applied\":$did}"
    done

    out+="]"

    cat <<EOF
{"apply":$apply,"actions":$out,"summary":{"total":$total,"applied":$applied}}
EOF
}

# app autostart <name> <on|off> [--apply] — the declarative "always on"
# toggle (structural fix): sets [apps.<name>].autostart in CONF_PATH,
# following the exact dry-run/--apply/JSON/timestamped-backup convention
# as `app archive`. With --apply and the LXC running, also reconciles the
# unit's own systemd enablement right away (`systemctl enable`/`disable
# streamlit-app@<name>.service`) — the SAME flag `cmd_autostart` reads for
# its reconciliation sweep and `app idle-check` reads to exempt the app
# from being stopped (see both above). Switching ON is refused outright
# when the app has no resolvable port yet: never enable a unit that would
# just refuse to start (lxc/streamlit-launch's own guard).
cmd_app_autostart_set() {
    local name="${1:-}" mode="${2:-}" apply=false
    [ "${3:-}" = "--apply" ] && apply=true

    if [ -z "$name" ] || { [ "$mode" != "on" ] && [ "$mode" != "off" ]; }; then
        error "Usage: streamlitctl app autostart <name> <on|off> [--apply]"
        return 1
    fi

    if ! _app_declared "$name"; then
        error "App non déclarée : $name (ajoutez d'abord une section [apps.$name] dans $CONF_PATH)"
        return 1
    fi

    local want="false"
    [ "$mode" = "on" ] && want="true"

    local blocked=false reason=""
    if [ "$want" = "true" ]; then
        local resolved
        if ! resolved=$(_resolve_app_port "$name" ""); then
            blocked=true
            reason="no port configured — declare [apps.$name].port first (never enabling a unit that would just refuse to start)"
        fi
    fi

    local did=false
    if [ "$blocked" = "false" ] && [ "$apply" = "true" ]; then
        cp -p "$CONF_PATH" "${CONF_PATH}.bak.$(date +%Y%m%dT%H%M%S)"
        _set_toml_key_in_section "$CONF_PATH" "$name" "autostart" "$want"
        if lxc_running; then
            if [ "$want" = "true" ]; then
                lxc-attach -n "$LXC_NAME" -- systemctl enable "$(_unit_instance "$name")" >/dev/null 2>&1 || true
            else
                lxc-attach -n "$LXC_NAME" -- systemctl disable "$(_unit_instance "$name")" >/dev/null 2>&1 || true
            fi
        fi
        did=true
    fi

    local applied=0
    [ "$did" = "true" ] && applied=1

    cat <<EOF
{"apply":$apply,"actions":[{"type":"autostart","app":"$(_json_escape "$name")","file":"$(_json_escape "$CONF_PATH")","autostart":$want,"blocked":$blocked,"reason":"$(_json_escape "$reason")","applied":$did}],"summary":{"total":1,"applied":$applied}}
EOF
}

# app info <name> — print metadata + runtime state from manifest + pid file (#182)
cmd_app_info() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl app info <name>"; return 1; }
    local d="$APPS_PATH/$name"
    [ -d "$d" ] || { error "app not found: $name"; return 1; }
    # NOTE: directory-only, conventional-names-only lookup — pre-#959
    # narrowness kept here on purpose (not covered by this fix's tests);
    # `app start`/`app wake`/`app audit` all resolve via _app_entrypoint,
    # this one doesn't yet.
    local entry=""
    for c in app.py main.py streamlit_app.py; do
        [ -f "$d/$c" ] && entry="$c" && break
    done
    local port=""
    [ -f "$d/.streamlit.toml" ] && port=$(grep -E "^port" "$d/.streamlit.toml" 2>/dev/null | cut -d= -f2 | tr -d ' ')
    local pidf="/var/run/streamlit-${name}.pid"
    local pid="" alive="no"
    if lxc_running; then
        pid=$(lxc-attach -n "$LXC_NAME" -- cat "$pidf" 2>/dev/null || true)
        if [ -n "$pid" ]; then
            lxc-attach -n "$LXC_NAME" -- kill -0 "$pid" >/dev/null 2>&1 && alive="yes"
        fi
    fi
    cat <<EOF
name:        $name
path:        $d
entrypoint:  ${entry:-(none)}
port:        ${port:-(unset)}
pid_file:    $pidf
pid:         ${pid:-(none)}
running:     $alive
EOF
}

# app restart <name> — stop + start (#182)
cmd_app_restart() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl app restart <name>"; return 1; }
    cmd_app_stop "$name" || true
    sleep 1
    # Recover the previous port from .streamlit.toml so restart preserves
    # it (cf. _app_port_file : répertoire ou script à plat, #959). No
    # ${port:-8501} fallback here any more: if nothing was ever persisted,
    # cmd_app_start's own resolution (declared port, or explicit refusal)
    # decides — never a silently-guessed port.
    local port_file; port_file=$(_app_port_file "$name")
    local port=""
    [ -f "$port_file" ] && \
        port=$(grep -E "^port" "$port_file" 2>/dev/null | cut -d= -f2 | tr -d ' ')
    if [ -n "$port" ]; then
        cmd_app_start "$name" "$port"
    else
        cmd_app_start "$name"
    fi
}

# app start <name> [port] — starts the app's dedicated
# `streamlit-app@<name>.service` unit inside the LXC (structural fix:
# replaces the old detached `nohup streamlit run ...`, which is exactly
# how streamlit-all.service ended up owning every app's process instead
# of systemd — MainPID=0, a stop of one app read as "the whole service
# failed", relaunching the entire fleet).
#
# streamlitctl never launches `streamlit run` itself any more — that is
# lxc/streamlit-launch's job, executed BY the unit. This function only
# ever resolves/validates the port and asks systemd to start the unit.
cmd_app_start() {
    local name="$1"
    local explicit_port="${2:-}"

    [ -z "$name" ] && { error "Usage: streamlitctl app start <name> [port]"; return 1; }

    # Une appli existe si elle a un point d'entrée résoluble — répertoire
    # OU script .py à plat — jamais seulement "c'est un répertoire" (#959,
    # même résolution que `app audit`, via _app_entrypoint). Les deux échecs
    # distincts gardent des messages distincts : appli inconnue (aucune
    # trace sur disque) vs appli connue sans entrypoint déterminable.
    local ep_rc=0
    _app_entrypoint "$name" || ep_rc=$?
    if [ "$ep_rc" -eq 3 ]; then
        error "App not found: $name"
        return 1
    elif [ "$ep_rc" -eq 4 ]; then
        error "No entrypoint found in $name"
        return 1
    fi
    local rel_entrypoint="$_EP_RESULT"

    # Archivée = repérée et connue, mais jamais démarrée sans geste explicite
    # de désarchivage (#956). Refus net plutôt qu'un démarrage silencieux.
    if _app_archived "$name"; then
        error "App archivée : $name — désarchivez-la d'abord (streamlitctl app unarchive $name --apply)"
        return 1
    fi

    if ! lxc_running; then
        error "Container not running. Start it first."
        return 1
    fi

    # Port resolution — NEVER a default. The old `local port="${2:-8501}"`
    # silently picked 8501 whenever the caller (including `app wake`,
    # which never passes one) omitted it: exactly how 5 apps ended up
    # sharing that single port on the board. An app with no explicit
    # argument, no persisted .streamlit.toml, and no declared
    # [apps.$name].port simply cannot start — that is a real gap in the
    # fleet's config (13 apps on the board), not something to guess past.
    local port
    if ! port=$(_resolve_app_port "$name" "$explicit_port"); then
        error "No port configured for $name — declare [apps.$name].port in $CONF_PATH or pass one explicitly (streamlitctl app start $name <port>)"
        return 1
    fi

    # Conflict guard: refuse rather than let two apps fight over the same
    # socket. Checked against the REAL process table (_scan_running_apps,
    # same single lxc-attach call `app list`/`app audit` already use),
    # never against other apps' merely-declared ports — declaring the
    # same port twice is exactly the board's current (out-of-scope-to-fix)
    # reality for 5 apps; only an ACTUAL collision is refused.
    if _port_conflicts_with_another_app "$name" "$port"; then
        local other=""
        local o
        for o in "${!_RUN_PORT[@]}"; do
            [ "$o" != "$name" ] && [ "${_RUN_PORT[$o]}" = "$port" ] && other="$o" && break
        done
        error "Port $port is already in use by running app '$other' — refusing to start $name on a conflicting port"
        return 1
    fi

    # Persist port + entrypoint — cf. _app_port_file : dans le répertoire
    # de l'appli pour une appli-répertoire (inchangé), fichier caché
    # voisin sous APPS_PATH pour un script à plat (jamais un répertoire
    # fantôme homonyme du script, qui polluerait `app audit`/`app list`).
    # This file is the ONLY thing lxc/streamlit-launch reads inside the
    # container (via the APPS_PATH bind mount) — never a second,
    # independently-derived resolution of entrypoint/port (#958/#959).
    local port_file; port_file=$(_app_port_file "$name")
    mkdir -p "$(dirname "$port_file")"
    {
        printf 'port = %s\n' "$port"
        printf 'entrypoint = "%s"\n' "$rel_entrypoint"
    } > "$port_file"

    log "Starting app $name on port $port..."
    if ! lxc-attach -n "$LXC_NAME" -- systemctl start "$(_unit_instance "$name")"; then
        error "systemd unit failed to start for $name (see journalctl -u $(_unit_instance "$name") inside the LXC)"
        return 1
    fi

    log "App $name started"
    _app_touch_active "$name"
}

# app stop <name> — stops the app's dedicated unit. `systemctl stop` only
# ever touches THIS instance: unlike the old `pkill -f "streamlit.*$name"`
# (which could match more than intended) and unlike stopping the old
# streamlit-all.service (whose ExecStop, `pkill -f streamlit`, killed
# every app in the fleet), this can never take another app down with it.
#
# Repli EXACT pour un process lancé HORS de son unité (#961, restauré par
# #1776) : les applis démarrées avant le passage aux unités par appli —
# `nohup streamlit run` de l'ancien `app start`, ou lancées à la main —
# n'appartiennent à aucune unité, et `systemctl stop` les laisse vivre :
# idle-check annoncerait alors un arrêt qui n'a jamais lieu, à chaque tour.
# Le repli vise le point d'entrée RÉSOLU de l'appli (_app_entrypoint, même
# source de vérité que `app start`/`app audit`, #959), ancré juste après
# "streamlit run " et échappé (_ere_escape) — jamais la sous-chaîne
# "streamlit.*$name", qui tuait aussi secubox_control en arrêtant control,
# bazi_calculator_1 avec bazi_calculator, etc. (9 collisions relevées sur
# la board). Sans entrypoint résoluble, on n'a rien d'exact à cibler :
# aucun repli par motif, seule l'unité est arrêtée.
cmd_app_stop() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl app stop <name>"; return 1; }

    if ! lxc_running; then
        return 0
    fi

    log "Stopping app $name..."
    lxc-attach -n "$LXC_NAME" -- systemctl stop "$(_unit_instance "$name")" || true

    local ep_rc=0
    _app_entrypoint "$name" || ep_rc=$?
    if [ "$ep_rc" -eq 0 ]; then
        # Le motif passe par l'environnement plutôt que par interpolation
        # dans le script — jamais de recomposition de commande shell à
        # partir d'un nom d'appli non maîtrisé.
        lxc-attach -n "$LXC_NAME" -- env \
            SBX_STOP_PATTERN="streamlit run $(_ere_escape "$_EP_RESULT") " \
            sh -c 'pkill -f "$SBX_STOP_PATTERN" 2>/dev/null || true' 2>/dev/null || true
    fi

    log "App $name stopped"
}

# ─────────────────────────────────────────────────────────────────────
# IDLE-CHECK BULK SNAPSHOT (#961) — un seul appel au conteneur pour toute
# la boucle d'inactivité, jamais un par appli. Une version antérieure
# faisant un lxc-attach par appli (un `ss` ciblé par port) mettait 17s pour
# ~60 applis, exactement le motif que _ps_lines a été introduit pour
# éliminer sur `app list`/`app audit` — le réintroduire ici aurait le même
# effet. La liste des process ET le signal "quelqu'un est connecté" (un
# unique `ss -tn state established`) viennent donc du MÊME appel ; la liste
# des process est ensuite relue par _ps_lines, donc par _scan_running_apps
# (#958) : idle-check n'a pas sa propre logique de détection.
# ─────────────────────────────────────────────────────────────────────

_IDLE_SNAPSHOT_FETCHED=false
_IDLE_SNAPSHOT_PS=""
_IDLE_SNAPSHOT_SS=""

# Remplit _IDLE_SNAPSHOT_PS / _IDLE_SNAPSHOT_SS depuis un seul appel au
# conteneur, mémoïsé (au plus UN lxc-attach par exécution de `app
# idle-check`, quel que soit le nombre d'applis). SECUBOX_STREAMLIT_PS_SOURCE
# / SECUBOX_STREAMLIT_SS_SOURCE (même convention que _ps_lines) fournissent
# chacun une moitié en test ; l'appel au conteneur n'est évité que si les
# DEUX sont fournis — une moitié absente vient toujours du vrai conteneur,
# jamais d'une valeur vide par défaut qui se lirait "zéro connexion".
_idle_snapshot_ensure() {
    [ "$_IDLE_SNAPSHOT_FETCHED" = "true" ] && return 0
    _IDLE_SNAPSHOT_FETCHED=true

    if [ -n "${SECUBOX_STREAMLIT_PS_SOURCE:-}" ] && [ -n "${SECUBOX_STREAMLIT_SS_SOURCE:-}" ]; then
        return 0
    fi
    lxc_running || return 0

    local raw
    raw=$(lxc-attach -n "$LXC_NAME" -- sh -c '
        echo "@@SBX-PS@@"
        ps -eo args
        echo "@@SBX-SS@@"
        ss -tn state established
    ' 2>/dev/null) || true
    _IDLE_SNAPSHOT_PS=$(printf '%s\n' "$raw" \
        | sed -n '/^@@SBX-PS@@$/,/^@@SBX-SS@@$/{/^@@SBX-PS@@$/d;/^@@SBX-SS@@$/d;p;}')
    _IDLE_SNAPSHOT_SS=$(printf '%s\n' "$raw" | sed -n '/^@@SBX-SS@@$/,$p' | sed '1d')
}

_idle_established_lines() {
    if [ -n "${SECUBOX_STREAMLIT_SS_SOURCE:-}" ]; then
        cat "$SECUBOX_STREAMLIT_SS_SOURCE" 2>/dev/null
        return 0
    fi
    _idle_snapshot_ensure
    printf '%s\n' "$_IDLE_SNAPSHOT_SS"
}

# Port LOCAL d'une ligne de `ss -tn` : le premier champ "adresse:port" de
# la ligne. Ne JAMAIS le lire par position fixe : avec le filtre `state
# established`, ss omet la colonne State (en-tête "Recv-Q Send-Q Local…"),
# si bien que le 4e champ devient l'adresse du PAIR — lire $4 comptait les
# connexions par port éphémère du client, jamais par port de l'appli, et
# toute appli en cours d'usage se lisait "zéro connexion", donc
# endormable (#1776). Le premier champ contenant ":" est l'adresse locale
# dans les deux mises en page (avec ou sans colonne State, IPv4 ou IPv6).
_ss_local_port() {
    local addr
    addr=$(printf '%s\n' "$1" | awk '{ for (i = 1; i <= NF; i++) if (index($i, ":")) { print $i; exit } }')
    printf '%s' "${addr##*:}"
}

# app idle-check [--dry-run] — arrête les applis dont l'inactivité dépasse
# [idle].timeout_minutes (#331 ; détection unifiée, #958 follow-up ;
# instantané unique et applis archivées, #961).
#
# Avant #958, ce verbe itérait UNIQUEMENT sur les sous-répertoires
# de APPS_PATH (`for app_dir in "$APPS_PATH"/*/`) et jugeait "en cours"
# via _app_running/_app_port_file — port lu dans le .streamlit.toml
# par-appli, écrit SEULEMENT par le chemin `app start`/`app wake`. Mesuré
# sur la board : 24 applis Streamlit réellement en cours (dont des
# scripts à plat sous APPS_PATH, jamais vus par ce glob répertoire-seul)
# mais seulement 17 fichiers .streamlit.toml — idle-check rapportait donc
# systématiquement `active=0 idle=0 stopped=0`, aveugle au parc réel :
# exactement le défaut déjà identifié et corrigé pour `app list`/`app
# shot-target` par #958 (cf. _scan_running_apps ci-dessus). Router ce
# verbe sur la même fonction élimine la troisième logique de détection
# parallèle que ce correctif visait précisément à éviter — et évite de
# réintroduire un avatar de la classe de bug déjà vue deux fois sur ce
# même verbe (extraction de port boguée, changelog 1.2.4-1~bookworm1).
#
# Applis archivées (#956/#961) : jamais évaluées ni comptées — elles ne
# tournent pas par convention, et leur arrêt éventuel relève d'un geste
# explicite (`app archive`), jamais de l'automatique.
#
# --dry-run : calcule et RAPPORTE les candidates à l'arrêt (une ligne de
# log par décision, plus le résumé habituel) SANS jamais appeler
# cmd_app_stop ni toucher l'horodatage IDLE_STATE_DIR d'une candidate —
# une exécution en simulation reste rejouable à l'identique juste après.
# Pensé pour valider manuellement sur la board avant de laisser le timer
# systemd (secubox-streamlit-idle.timer) agir pour de vrai : rendre ce
# verbe soudain voyant lui donne d'un coup toutes les candidates qu'il ne
# voyait jamais avant (24 sur la board mesurée) — un mauvais calcul
# d'inactivité éteindrait donc en masse des applis en cours d'usage.
#
# Horodatage de dernière activité absent (aucun fichier IDLE_STATE_DIR
# pour cette appli), nul (epoch 0) ou dans le futur (horloge en dérive,
# fichier d'état corrompu) : JAMAIS traité comme "inactif depuis
# toujours". Dans les trois cas l'appli est réarmée (touch) et comptée
# "active" ce tour-ci, sans jamais être arrêtée — elle ne redevient
# candidate à l'arrêt qu'à un tour suivant, une fois qu'un horodatage de
# référence fiable s'est écoulé. Le cas "absent" reprend le choix déjà
# fait avant ce correctif (jamais changé : une appli tout juste vue pour
# la première fois par idle-check mérite une période de grâce, pas une
# extinction immédiate sur défaut de données) ; le cas "futur" est traité
# explicitement plutôt que de compter sur l'arithmétique signée de
# `$(( now - last ))` pour rester accidentellement sûre.
cmd_app_idle_check() {
    local dry_run=false
    local a
    for a in "$@"; do
        case "$a" in
            --dry-run) dry_run=true ;;
        esac
    done

    local enabled; enabled=$(_idle_config "enabled" "true")
    [ "$enabled" = "true" ] || { log "idle-check disabled in $CONF_PATH"; return 0; }

    local timeout_min; timeout_min=$(_idle_config "timeout_minutes" "30")
    local timeout_sec=$(( timeout_min * 60 ))
    local now; now=$(date +%s)

    # L'instantané DOIT être relevé ici, dans ce shell — jamais laissé au
    # premier appel de _ps_lines/_idle_established_lines ci-dessous. Ces
    # fonctions sont consommées via `< <(...)` (substitution de process),
    # qui exécute chaque appel dans un SOUS-shell séparé : la mémoïsation
    # (variables globales mutées) ne survivrait pas à la sortie du premier
    # sous-shell, et le second relancerait un lxc-attach — exactement le
    # double appel que #961 interdit. Appelée ici, dans le shell principal
    # de la fonction, elle ne s'exécute qu'UNE fois ; les sous-shells n'ont
    # plus qu'à lire des variables déjà peuplées (héritées en lecture).
    _idle_snapshot_ensure

    _scan_running_apps

    # ── Connexions ESTABLISHED par port local, du même instantané ────────
    local -A established=()
    local ss_line lport
    while IFS= read -r ss_line; do
        [ -z "$ss_line" ] && continue
        case "$ss_line" in
            State*|Netid*|Recv-Q*) continue ;;
        esac
        lport=$(_ss_local_port "$ss_line")
        case "$lport" in ''|*[!0-9]*) continue ;; esac
        established["$lport"]=$(( ${established[$lport]:-0} + 1 ))
    done < <(_idle_established_lines)

    local stopped=0 active=0 idle=0
    local name
    for name in "${!_RUN_SEEN[@]}"; do
        if _app_archived "$name"; then
            continue
        fi

        # Declared always-on (`[apps.$name].autostart = true`, the same
        # flag `cmd_autostart` uses to `systemctl enable` this app's
        # unit) is never a stop candidate — under Restart=no an idle-stop
        # here would never come back on its own, defeating the whole
        # point of declaring the app always-on in the first place.
        if _app_declared "$name" && [ "$(_toml_section_value "$CONF_PATH" "$name" autostart)" = "true" ]; then
            log "idle-check: $name is declared autostart — never a stop candidate"
            _app_touch_active "$name"
            active=$(( active + 1 ))
            continue
        fi

        local port="${_RUN_PORT[$name]:-0}"
        if [ -z "$port" ] || [ "$port" = "0" ]; then
            # Process vu (streamlit run ...) mais --server.port non
            # reconnu dans la ligne process (cf. _scan_running_apps) :
            # impossible de vérifier les connexions ESTABLISHED. En cas de
            # doute, ne pas endormir (#961) — se tromper en la laissant
            # tourner coûte du CPU, se tromper en l'arrêtant coûte le
            # travail de quelqu'un. Même traitement de grâce que
            # l'horodatage manquant ci-dessous.
            log "idle-check: $name has no resolvable port — treating as active (cannot verify)"
            _app_touch_active "$name"
            active=$(( active + 1 ))
            continue
        fi

        local conns="${established[$port]:-0}"
        if [ "$conns" -gt 0 ]; then
            _app_touch_active "$name"
            active=$(( active + 1 ))
            continue
        fi

        local last; last=$(_app_last_active "$name")
        if [ "$last" -le 0 ]; then
            _app_touch_active "$name"
            active=$(( active + 1 ))
            continue
        fi
        if [ "$last" -gt "$now" ]; then
            log "idle-check: $name last-active timestamp is in the future ($last > $now, clock skew or corrupt state) — granting grace period"
            _app_touch_active "$name"
            active=$(( active + 1 ))
            continue
        fi
        local idle_sec=$(( now - last ))

        if [ "$idle_sec" -ge "$timeout_sec" ]; then
            if [ "$dry_run" = "true" ]; then
                log "idle-check: [dry-run] would stop $name (idle ${idle_sec}s >= ${timeout_sec}s)"
            else
                log "idle-stop: $name (idle ${idle_sec}s >= ${timeout_sec}s)"
                cmd_app_stop "$name" >/dev/null 2>&1 || true
            fi
            stopped=$(( stopped + 1 ))
        else
            idle=$(( idle + 1 ))
        fi
    done

    if [ "$dry_run" = "true" ]; then
        log "idle-check: [dry-run] active=$active idle=$idle would-stop=$stopped"
    else
        log "idle-check: active=$active idle=$idle stopped=$stopped"
    fi
}

# Budget par défaut (secondes) de `app wake` quand l'appelant ne fournit
# PAS explicitement de budget (2e argument positionnel de la commande,
# `app wake <name> <seconds>`, qui reste toujours prioritaire — c'est déjà
# le canal CLI de configurabilité). Mesuré sur la board sous charge
# moyenne 45-90 (#958) : les réveils prennent couramment 26 à 78s, parfois
# plus. L'ancien plafond enfoui de 30s — utilisé par tout appelant qui
# omet le 2e argument, dont le script de la passe de capture — échouait
# donc systématiquement dès que la board travaillait. 300s laisse une
# marge large sans magic number : surchageable par [wake].budget_seconds
# dans $CONF_PATH (second canal de configurabilité, lu via _idle_config —
# générique malgré son nom, une seule paire clé/valeur quelque part dans
# le TOML, cf. sa docstring).
#
# Le canal CLI reste la SEULE limite qui gouverne la boucle d'attente
# ci-dessous (`while [ "$elapsed" -lt "$wait_sec" ]`) : aucun compteur
# d'essais indépendant n'y est ajouté. C'est précisément le piège déjà
# corrigé ailleurs sur ce projet (cf. secubox_core/shotter.py) — une
# boucle interne avec son propre plafond calibré finit par se disputer
# silencieusement la décision avec le budget demandé par l'appelant.
WAKE_DEFAULT_BUDGET_SECONDS=300

# Nom de l'appli dont le .streamlit.toml persisté revendique le port $1, ou
# retour non nul si aucune. Lit EXACTEMENT les mêmes fichiers que
# lxc/streamlit-launch et que ceux qu'écrit `app repair` — jamais la table
# [instances.*], qui porte sa propre notion de port, contredit celle-ci sur
# cinq entrées de la board, et déclare même deux fois la même appli sur deux
# ports. Introduire ici une résolution indépendante rejouerait exactement la
# classe de bug de #958/#959.
_app_owning_port() {
    local want="$1" entry base name toml p
    [ -d "$APPS_PATH" ] || return 1
    for entry in "$APPS_PATH"/*; do
        [ -e "$entry" ] || continue
        base=$(basename "$entry")
        _is_excluded_entry "$base" && continue
        if [ -d "$entry" ]; then
            name="$base"; toml="$entry/.streamlit.toml"
        elif [ "${base%.py}" != "$base" ]; then
            name="${base%.py}"; toml="$APPS_PATH/.streamlit-${name}.toml"
        else
            continue
        fi
        [ -f "$toml" ] || continue
        p=$(grep -E '^port[ \t]*=' "$toml" 2>/dev/null | head -1 | cut -d'=' -f2 | tr -d ' "')
        if [ -n "$p" ] && [ "$p" = "$want" ]; then
            printf '%s' "$name"
            return 0
        fi
    done
    return 1
}

# app wake-by-port <port> [wait] — réveille l'appli QUI POSSÈDE ce port (#746).
#
# C'est le verbe qu'emprunte le réveil-sur-accès. Le chemin public ne connaît
# qu'un port : sbxwaf a résolu le Host du visiteur vers 10.100.0.50:<port> par
# haproxy-routes.json, n'a trouvé personne à l'écoute, et a passé la main au
# waker. Demander « réveille ce qui possède le port P » plutôt que « réveille
# l'appli X » est délibéré : aucun nom d'appli fourni par un tiers n'atteint
# jamais systemctl — un appelant ne peut solliciter que l'appli propriétaire du
# vhost qu'il a demandé.
#
# Trois issues distinctes, parce que l'appelant doit pouvoir les distinguer :
#   0  réveillée (ou déjà en cours)
#   2  aucune appli ne possède ce port — rien à réveiller, attendre est vain
#      (cinq vhosts de la board sont dans cet état en permanence)
#   3  port invalide
# Le port est VALIDÉ ici, pas supposé : il arrive d'un chemin exposé au réseau,
# et un nom d'appli passé en guise de port ne doit jamais devenir un nom
# d'instance systemd.
cmd_app_wake_by_port() {
    local port="${1:-}"
    local wait_sec="${2:-}"
    local check_only=false

    # --check : résout et rapporte le propriétaire SANS rien démarrer. Sert au
    # waker, qui doit distinguer « endormie, patiente » de « rien ici, attendre
    # est vain » AVANT de servir une page d'attente. Volontairement sans
    # privilège : la résolution ne fait que lire des fichiers déjà lisibles par
    # secubox, donc l'appelant n'a pas à passer par sudo pour poser la question
    # — seul le démarrage effectif l'exige.
    if [ "$wait_sec" = "--check" ]; then
        check_only=true
        wait_sec=""
    fi

    case "$port" in
        ''|*[!0-9]*)
            error "Usage: streamlitctl app wake-by-port <port> [wait_seconds]"
            return 3
            ;;
    esac
    if [ "$port" -le 0 ] || [ "$port" -gt 65535 ]; then
        error "wake-by-port: port hors plage: $port"
        return 3
    fi

    local name
    if ! name=$(_app_owning_port "$port"); then
        error "wake-by-port: aucune appli ne possède le port $port — rien à réveiller"
        return 2
    fi

    if [ "$check_only" = "true" ]; then
        printf '%s\n' "$name"
        return 0
    fi

    if [ -n "$wait_sec" ]; then
        cmd_app_wake "$name" "$wait_sec"
    else
        cmd_app_wake "$name"
    fi
}

cmd_app_wake() {
    local name="$1"
    local wait_sec="${2:-}"
    [ -z "$wait_sec" ] && wait_sec=$(_idle_config "budget_seconds" "$WAKE_DEFAULT_BUDGET_SECONDS")
    [ -z "$name" ] && { error "Usage: streamlitctl app wake <name> [wait_seconds]"; return 2; }

    # Même garde d'existence que `app start` (#959) : un script à plat est
    # une appli valide, pas seulement un répertoire. On ne distingue ici
    # que "appli inconnue" (retour 2, mappé sur 404 côté API) — un
    # répertoire sans entrypoint résoluble est laissé à cmd_app_start
    # ci-dessous, qui produira son propre message distinct.
    local ep_rc=0
    _app_entrypoint "$name" || ep_rc=$?
    [ "$ep_rc" -eq 3 ] && { error "App not found: $name"; return 2; }

    if _app_running "$name"; then
        _app_touch_active "$name"
        log "wake: $name already running"
        return 0
    fi

    log "wake: starting $name (will wait up to ${wait_sec}s)"
    cmd_app_start "$name" >/dev/null 2>&1 || { error "wake: failed to start $name"; return 1; }

    local elapsed=0
    while [ "$elapsed" -lt "$wait_sec" ]; do
        if _app_running "$name"; then
            _app_touch_active "$name"
            log "wake: $name listening after ${elapsed}s"
            return 0
        fi
        sleep 1
        elapsed=$(( elapsed + 1 ))
    done

    error "wake: $name did not come up within ${wait_sec}s"
    return 1
}

# app shot-target <name> — résout la cible de capture d'une appli EN
# LIGNE (#958) : son URL directe (IP du conteneur : port — jamais le vhost
# public, voir _lxc_ip ci-dessus) et le chemin ABSOLU côté hôte de son
# fichier source, pour l'empreinte mtime+taille que compare
# secubox_core.screenshots. N'existe QUE pour une appli déjà éveillée —
# invariant central du design (#957 §3.5) : une appli endormie renvoie
# "not running", jamais un réveil déclenché depuis ici. Seule source de
# vérité pour cette résolution ; api/shots.py (côté secubox-streamlit)
# s'y appelle plutôt que de redériver elle-même entrypoint/port/IP, pour
# ne jamais diverger de app start/wake/audit comme c'était le cas avant
# #959.
#
# "running" et le port viennent de _scan_running_apps (#958) — EXACTEMENT
# la même lecture des process que `app list`/`app audit`. Avant #958, ce
# verbe passait par _app_running/_app_port_file (port lu dans le
# .streamlit.toml par-appli, présence vérifiée par un `ss` ciblé) : sur la
# board, ce fichier n'existe ou n'est à jour QUE pour les applis démarrées
# par `app start`/`app wake` — les autres process déjà vivants (démarrés
# autrement, ou avant le déploiement de ce contrôle) n'en ont pas, si bien
# que ce verbe répondait "not running" pour une appli que `app list`
# venait de signaler "running":true au même instant. Router les deux sur
# la même fonction élimine la contradiction par construction.
#
# Sortie JSON {"ok":true,"url":"...","source":"..."} ou
# {"ok":false,"error":"..."}. Codes de retour (non lus par l'appelant
# Python, qui ne parse que le JSON, mais tenus cohérents avec app wake) :
#   2  appli inconnue, ou connue sans point d'entrée résoluble
#   3  appli connue mais endormie
#   4  port inconnu (le process est vu mais sans --server.port reconnu)
#   5  IP de conteneur indisponible (conteneur arrêté entre deux checks)
#   6  port partagé avec une AUTRE appli actuellement en ligne (#958)
cmd_app_shot_target() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl app shot-target <name>"; return 2; }

    local ep_rc=0
    _app_entrypoint "$name" || ep_rc=$?
    if [ "$ep_rc" -eq 3 ]; then
        printf '{"ok":false,"error":"app not found"}\n'
        return 2
    elif [ "$ep_rc" -eq 4 ]; then
        printf '{"ok":false,"error":"no entrypoint"}\n'
        return 2
    fi
    local rel_entrypoint="$_EP_RESULT"

    _scan_running_apps
    if [ -z "${_RUN_SEEN[$name]:-}" ]; then
        printf '{"ok":false,"error":"not running"}\n'
        return 3
    fi

    local port="${_RUN_PORT[$name]:-}"
    if [ -z "$port" ] || [ "$port" = "0" ]; then
        printf '{"ok":false,"error":"port unknown"}\n'
        return 4
    fi

    # Deux applis déclarées sur le même port existent sur la board (#958,
    # réassignation hors chantier ici). `_scan_running_apps` ne connaît le
    # port de chaque appli QUE via son propre `--server.port` (aucun PID
    # remonté par `_ps_lines` — délibérément un seul `lxc-attach` pour tout
    # le parc, ne pas le complexifier ici) : rien ici ne permet de savoir
    # laquelle des deux détient RÉELLEMENT le port en écoute. S'y fier
    # renverrait la même URL pour les deux noms — une capture de l'une
    # sous le nom de l'autre, silencieuse. Tant que le conflit existe sur
    # la board, c'est un échec explicite, jamais une résolution au hasard.
    local rn
    for rn in "${!_RUN_PORT[@]}"; do
        [ "$rn" = "$name" ] && continue
        if [ "${_RUN_PORT[$rn]}" = "$port" ]; then
            printf '{"ok":false,"error":"port %s shared with another running app"}\n' "$port"
            return 6
        fi
    done

    local ip
    ip=$(_lxc_ip) || { printf '{"ok":false,"error":"lxc ip unavailable"}\n'; return 5; }

    printf '{"ok":true,"url":"http://%s:%s/","source":"%s"}\n' \
        "$ip" "$port" "$(_json_escape "$APPS_PATH/$rel_entrypoint")"
}

cmd_app_deploy() {
    local zip_file="$1"
    local name="$2"

    [ -z "$zip_file" ] || [ ! -f "$zip_file" ] && {
        error "Usage: streamlitctl app deploy <zipfile> [name]"
        return 1
    }

    # Auto-name from filename if not specified
    [ -z "$name" ] && name=$(basename "$zip_file" .zip | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g')

    local app_dir="$APPS_PATH/$name"
    log "Deploying $name from $zip_file..."

    # Remove existing
    [ -d "$app_dir" ] && rm -rf "$app_dir"
    mkdir -p "$app_dir"

    # Extract with flattening
    local tmpdir="/tmp/streamlit_extract_$$"
    mkdir -p "$tmpdir"
    unzip -q "$zip_file" -d "$tmpdir"

    # Check for single root directory
    local root_count=$(ls -1 "$tmpdir" | wc -l)
    if [ "$root_count" = "1" ]; then
        local single_root="$tmpdir/$(ls -1 $tmpdir | head -1)"
        if [ -d "$single_root" ]; then
            mv "$single_root"/* "$app_dir/" 2>/dev/null || true
            mv "$single_root"/.[!.]* "$app_dir/" 2>/dev/null || true
        else
            mv "$single_root" "$app_dir/"
        fi
    else
        mv "$tmpdir"/* "$app_dir/" 2>/dev/null || true
    fi
    rm -rf "$tmpdir"

    # Install requirements if present and container is running
    if [ -f "$app_dir/requirements.txt" ] && lxc_running; then
        log "Installing requirements..."
        lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/$name/requirements.txt" || true
    fi

    log "App $name deployed to $app_dir"
    echo '{"success": true, "name": "'"$name"'", "path": "'"$app_dir"'"}'
}

cmd_app_remove() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl app remove <name>"; return 1; }

    # Stop if running
    cmd_app_stop "$name" 2>/dev/null || true

    # Remove directory
    if [ -d "$APPS_PATH/$name" ]; then
        rm -rf "$APPS_PATH/$name"
        log "App $name removed"
    else
        error "App not found: $name"
        return 1
    fi
}

cmd_app_logs() {
    local name="$1"
    local lines="${2:-100}"

    [ -z "$name" ] && { error "Usage: streamlitctl app logs <name> [lines]"; return 1; }

    local log_file="$DATA_PATH/logs/$name.log"
    if [ -f "$log_file" ]; then
        tail -n "$lines" "$log_file"
    else
        echo "No logs found for $name"
    fi
}

# app autostart — the declarative "always on" flag: `[apps.<name>]
# autostart = true` (+ a resolvable port) both starts the app now (if the
# LXC is up) AND `systemctl enable`s its dedicated unit so it survives a
# container reboot on its own — systemd itself starts an enabled instance
# at boot, no bulk imperative "start everything" step is required for
# that part. autostart=false/absent never enables anything: nothing here
# starts the whole fleet by default (the defect being fixed).
#
# The SAME flag also exempts the app from `app idle-check` (see below):
# a declared always-on app that idle-check silently stopped would, under
# Restart=no, never come back on its own.
cmd_autostart() {
    if [ -f "$CONF_PATH" ]; then
        local apps; apps=$(grep -E '^\[apps\.' "$CONF_PATH" | sed 's/\[apps\.//;s/\]//')
        local name
        for name in $apps; do
            # Une appli archivée n'est jamais démarrée par le chemin groupé
            # (#956) — seul un `app unarchive` explicite la rend éligible.
            _app_archived "$name" && continue
            local autostart; autostart=$(_toml_section_value "$CONF_PATH" "$name" autostart)
            [ "$autostart" = "true" ] || continue
            local port; port=$(_toml_section_value "$CONF_PATH" "$name" port)
            if [ -z "$port" ] || [ "$port" = "0" ]; then
                error "autostart: $name has no port declared — skipping (never guessing a port)"
                continue
            fi
            cmd_app_start "$name" "$port" 2>/dev/null || true
            if lxc_running; then
                lxc-attach -n "$LXC_NAME" -- systemctl enable "$(_unit_instance "$name")" >/dev/null 2>&1 || true
            fi
        done
    fi
}

# ─────────────────────────────────────────────────────────────────────
# INSTANCE MANAGEMENT (multiple instances per app)
# ─────────────────────────────────────────────────────────────────────

cmd_instance_list() {
    echo '{"instances":['
    local first=true

    if [ -f "$CONF_PATH" ]; then
        grep -E '^\[instances\.' "$CONF_PATH" | while read -r line; do
            local id=$(echo "$line" | sed 's/\[instances\.//;s/\]//')

            # Parse instance config
            local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
            local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)
            local port=$(echo "$section" | grep -E '^port\s*=' | cut -d'=' -f2 | tr -d ' ')
            local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'true' && echo "true" || echo "false")

            local running="false"
            [ -n "$port" ] && ss -tln "sport = :$port" 2>/dev/null | grep -q "$port" && running="true"

            $first || echo ","
            first=false

            echo "  {\"id\": \"$id\", \"app\": \"$app\", \"port\": ${port:-0}, \"enabled\": $enabled, \"running\": $running}"
        done
    fi

    echo ']}'
}

cmd_instance_start() {
    local id="$1"
    [ -z "$id" ] && { error "Usage: streamlitctl instance start <id>"; return 1; }

    # Read instance config
    if [ -f "$CONF_PATH" ]; then
        local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
        local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)
        local port=$(echo "$section" | grep -E '^port\s*=' | cut -d'=' -f2 | tr -d ' ')

        [ -z "$app" ] || [ -z "$port" ] && { error "Instance $id not configured"; return 1; }

        cmd_app_start "$app" "$port"
    else
        error "No config file found"
        return 1
    fi
}

cmd_instance_stop() {
    local id="$1"
    [ -z "$id" ] && { error "Usage: streamlitctl instance stop <id>"; return 1; }

    if [ -f "$CONF_PATH" ]; then
        local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
        local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)

        [ -n "$app" ] && cmd_app_stop "$app"
    fi
}

# ─────────────────────────────────────────────────────────────────────
# MIGRATION
# ─────────────────────────────────────────────────────────────────────

cmd_migrate() {
    local source="${1:-192.168.255.1}"

    log "Migrating Streamlit data from $source..."
    mkdir -p "$DATA_PATH" "$APPS_PATH"

    # Sync apps directory
    log "Syncing applications..."
    rsync -avz --progress "root@$source:/srv/streamlit/apps/" "$APPS_PATH/" 2>/dev/null || {
        # Try alternative path
        rsync -avz --progress "root@$source:/overlay/upper/srv/streamlit/apps/" "$APPS_PATH/" 2>/dev/null || true
    }

    # Convert UCI config to TOML
    log "Converting UCI configuration to TOML..."
    ssh "root@$source" "uci show streamlit 2>/dev/null" | while read -r line; do
        case "$line" in
            streamlit.main.*)
                key=$(echo "$line" | cut -d'.' -f3 | cut -d'=' -f1)
                val=$(echo "$line" | cut -d'=' -f2- | tr -d "'")
                echo "$key = \"$val\""
                ;;
        esac
    done > "$CONF_PATH.tmp"

    # Add apps sections
    for app_dir in "$APPS_PATH"/*/; do
        [ -d "$app_dir" ] || continue
        local name=$(basename "$app_dir")
        [ "$name" = "*" ] && continue

        # Get port from UCI if available
        local port=$(ssh "root@$source" "uci -q get streamlit.$name.port 2>/dev/null" || echo "")
        local enabled=$(ssh "root@$source" "uci -q get streamlit.$name.enabled 2>/dev/null" || echo "1")

        cat >> "$CONF_PATH.tmp" << EOF

[apps.$name]
enabled = $([ "$enabled" = "1" ] && echo "true" || echo "false")
port = ${port:-0}
EOF
    done

    # Finalize config
    [ -s "$CONF_PATH.tmp" ] && mv "$CONF_PATH.tmp" "$CONF_PATH"

    # Count migrated apps
    local count=$(find "$APPS_PATH" -maxdepth 1 -type d | wc -l)
    count=$((count - 1))

    log "Migration complete: $count apps imported"
    echo "{\"success\": true, \"apps_migrated\": $count}"
}

# ─────────────────────────────────────────────────────────────────────
# GITEA INTEGRATION
# ─────────────────────────────────────────────────────────────────────

cmd_gitea_push() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: streamlitctl gitea push <app>"; return 1; }
    [ -d "$APPS_PATH/$name" ] || { error "App not found: $name"; return 1; }

    # Read Gitea config
    if [ ! -f "$CONF_PATH" ]; then
        error "No config file. Configure Gitea first."
        return 1
    fi

    local gitea_url=$(grep -E '^url\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)
    local gitea_user=$(grep -E '^user\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)
    local gitea_token=$(grep -E '^token\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)

    [ -z "$gitea_url" ] && { error "Gitea URL not configured"; return 1; }

    cd "$APPS_PATH/$name"

    # Initialize git if needed
    if [ ! -d .git ]; then
        git init
        git add -A
        git commit -m "Initial commit from SecuBox Streamlit"
    fi

    # Create repo on Gitea if it doesn't exist
    if [ -n "$gitea_token" ]; then
        curl -s -X POST "$gitea_url/api/v1/user/repos" \
            -H "Authorization: token $gitea_token" \
            -H "Content-Type: application/json" \
            -d "{\"name\": \"$name\", \"private\": true}" 2>/dev/null || true
    fi

    # Set remote and push
    local remote_url="$gitea_url/${gitea_user}/${name}.git"
    git remote remove origin 2>/dev/null || true
    git remote add origin "$remote_url"

    if [ -n "$gitea_token" ]; then
        # Use token in URL
        remote_url=$(echo "$remote_url" | sed "s|://|://$gitea_token@|")
        git push -u "$remote_url" main 2>/dev/null || git push -u "$remote_url" master 2>/dev/null || true
    fi

    log "Pushed $name to Gitea"
}

cmd_gitea_clone() {
    local name="$1"
    local repo="$2"

    [ -z "$name" ] || [ -z "$repo" ] && {
        error "Usage: streamlitctl gitea clone <name> <repo_url>"
        return 1
    }

    local app_dir="$APPS_PATH/$name"
    [ -d "$app_dir" ] && { error "App already exists: $name"; return 1; }

    log "Cloning $repo to $name..."
    git clone -- "$repo" "$app_dir"

    # Install requirements if present
    if [ -f "$app_dir/requirements.txt" ] && lxc_running; then
        lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/$name/requirements.txt" || true
    fi

    log "Cloned $name from Gitea"
}

# ─────────────────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────────────────

load_config

# Re-apply environment variable overrides after load_config()
# (load_config may have modified APPS_PATH based on data_path from config file)
APPS_PATH="${SECUBOX_STREAMLIT_APPS_PATH:-$APPS_PATH}"
CONF_PATH="${SECUBOX_STREAMLIT_CONF:-$CONF_PATH}"
IDLE_STATE_DIR="${SECUBOX_STREAMLIT_IDLE_DIR:-$IDLE_STATE_DIR}"

# ── ARGUMENTS ADMIS AVANT TOUT GESTE ───────────────────────────────────────
# L'API refuse deja les noms malformes avant sudo (_NOM_APPLI_RE), mais la
# regle sudoers admet TOUT argument : n'importe quel processus du compte
# `secubox` peut appeler ce script en root sans passer par l'API. Un nom est
# donc valide ICI aussi, avec la meme grammaire : sans « / » ni point en
# tete, il designe toujours une entree DANS $APPS_PATH, jamais un chemin
# (`app remove ../x` finissait en `rm -rf` hors du depot d'applis).
_nom_admis()    { [[ "${1:-}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; }
_entier_admis() { [[ "${1:-}" =~ ^[0-9]{1,6}$ ]]; }
# Depot git : un schema reseau explicite, sans espace. Jamais un argument
# commencant par « - » (`--upload-pack=...` ferait executer git en root),
# ni ext::/file:// (transports locaux ou a commande).
_depot_admis()  { [[ "${1:-}" =~ ^(https?|ssh)://[^[:space:]]+$ || "${1:-}" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^[:space:]-][^[:space:]]*$ ]]; }
# Hote de migration : nom ou adresse, jamais une option.
_hote_admis()   { [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.:-]{0,252}$ ]]; }
_refuser() { error "argument refuse : $(printf '%q' "${1:-}")"; exit 64; }
_exiger_nom()    { _nom_admis "${1:-}" || _refuser "${1:-}"; }
_exiger_entier() { _entier_admis "${1:-}" || _refuser "${1:-}"; }
_si_entier()     { [ -z "${1:-}" ] || _entier_admis "$1" || _refuser "$1"; }

case "${1:-}:${2:-}" in
    app:archive)
        [ "${3:-}" = "--undeclared" ] || _exiger_nom "${3:-}" ;;
    app:unarchive|app:autostart|app:shot-target|app:stop|app:remove|app:info|app:restart)
        _exiger_nom "${3:-}" ;;
    app:start|app:wake|app:logs)
        _exiger_nom "${3:-}"; _si_entier "${4:-}" ;;
    app:wake-by-port)
        _exiger_entier "${3:-}"; [ "${4:-}" = "--check" ] || _si_entier "${4:-}" ;;
    app:deploy)
        [ -z "${4:-}" ] || _exiger_nom "$4" ;;
    instance:start|instance:stop|gitea:push)
        _exiger_nom "${3:-}" ;;
    gitea:clone)
        _exiger_nom "${3:-}"; _depot_admis "${4:-}" || _refuser "${4:-}" ;;
    migrate:*)
        [ -z "${2:-}" ] || _hote_admis "$2" || _refuser "$2" ;;
esac

case "${1:-}" in
    # Three-fold architecture
    components) cmd_components ;;
    access)     cmd_access ;;
    status)     cmd_status ;;
    # Container control
    install)    cmd_install ;;
    uninstall)  cmd_uninstall ;;
    update)     cmd_update ;;
    start)      cmd_start ;;
    stop)       cmd_stop ;;
    restart)    cmd_stop; sleep 1; cmd_start ;;

    app)
        case "${2:-}" in
            list)       cmd_app_list ;;
            audit)      cmd_app_audit ;;
            repair)     cmd_app_repair "$3" ;;
            archive)
                if [ "${3:-}" = "--undeclared" ]; then
                    cmd_app_archive_undeclared "$4" "$5"
                else
                    cmd_app_archive "$3" "$4" "$5"
                fi
                ;;
            unarchive)  cmd_app_unarchive "$3" "$4" ;;
            autostart)  cmd_app_autostart_set "$3" "$4" "$5" ;;
            idle-check) cmd_app_idle_check "$3" ;;
            wake)       cmd_app_wake "$3" "$4" ;;
            wake-by-port) cmd_app_wake_by_port "$3" "$4" ;;
            shot-target) cmd_app_shot_target "$3" ;;
            start)  cmd_app_start "$3" "$4" ;;
            stop)   cmd_app_stop "$3" ;;
            deploy) cmd_app_deploy "$3" "$4" ;;
            remove) cmd_app_remove "$3" ;;
            logs)   cmd_app_logs "$3" "$4" ;;
            info)   cmd_app_info "$3" ;;
            restart) cmd_app_restart "$3" ;;
            *)      echo "Usage: streamlitctl app {list|audit|repair|archive|unarchive|autostart|start|stop|restart|deploy|remove|logs|info|idle-check [--dry-run]|wake|shot-target} [args]" ;;
        esac
        ;;

    instance)
        case "${2:-}" in
            list)   cmd_instance_list ;;
            start)  cmd_instance_start "$3" ;;
            stop)   cmd_instance_stop "$3" ;;
            *)      echo "Usage: streamlitctl instance {list|start|stop} [id]" ;;
        esac
        ;;

    migrate)    cmd_migrate "$2" ;;
    autostart)  cmd_autostart ;;

    gitea)
        case "${2:-}" in
            push)   cmd_gitea_push "$3" ;;
            clone)  cmd_gitea_clone "$3" "$4" ;;
            *)      echo "Usage: streamlitctl gitea {push|clone} [args]" ;;
        esac
        ;;

    *)
        cat << EOF
streamlitctl v$VERSION - Streamlit Platform Manager
Three-fold architecture: Components, Status, Access

Information (Three-fold):
  components    List system components (JSON)
  status        Show container status (JSON)
  access        Show apps and access info (JSON)

Container:
  install       Install LXC container with Streamlit
  uninstall     Remove container (preserves data)
  update        Update packages in container
  start         Start container
  stop          Stop container
  restart       Restart container

Apps:
  app list              List all apps (directories only, legacy)
  app audit             Cross-reference disk/declared/running (JSON, read-only)
  app repair [--apply]  Fix orphan/wrong-path/stale-port config refs (dry-run by default)
  app archive <name> [--apply] [--force]   Mark an app archived=true, enabled=false (dry-run by default)
  app archive --undeclared [--apply] [--force]  Declare+archive all undeclared on-disk apps
  app unarchive <name> [--apply]           Reverse of archive (dry-run by default)
  app autostart <name> <on|off> [--apply]  Declarative always-on: systemd enable + idle-check exempt
  app start <name> [port]   Start an app (drives streamlit-app@<name>.service; refuses without a resolvable port)
  app stop <name>           Stop an app (drives streamlit-app@<name>.service)
  app wake-by-port <port> [wait]  Wake whatever app owns <port> (on-access path, #746; exit 2 = no owner)
  app deploy <zip> [name]   Deploy from ZIP
  app remove <name>         Remove an app
  app logs <name> [lines]   View app logs

Instances:
  instance list         List configured instances
  instance start <id>   Start instance
  instance stop <id>    Stop instance

Integration:
  migrate [source]      Migrate from OpenWrt (default: 192.168.255.1)
  gitea push <app>      Push app to Gitea
  gitea clone <name> <url>  Clone from Gitea

Examples:
  streamlitctl components        # JSON list of components
  streamlitctl access            # JSON list of apps with URLs

EOF
        ;;
esac
