/*********************************************************************************************************************
 *  CmdLine.cpp — Implémentation complète
 *  Version v24 — addCommands() : second tableau de commandes utilisateur optionnel
 *
 *  Sources et références :
 *    • Terminal.cpp — rweather/arduino-projects (MIT license)
 *      Origine des algorithmes : readKey(), machine d'état IAC,
 *      timeout ESC 40ms, backspace "\b \b"
 *    • RFC 854  — Telnet Protocol Specification
 *    • RFC 857  — Telnet Echo Option  (IAC WILL ECHO)
 *    • RFC 858  — Telnet Suppress Go Ahead Option (IAC WILL SGA)
 *
 *  Organisation du fichier :
 *    1.  Helpers internes : initSt(), cmdStrcmpLower(), cmdStrncmpLower()
 *    2.  Constructeur + begin()
 *    3.  telnetCmd()          — envoi négociation IAC (désactivé si CMDLINE_NO_IAC)
 *    4.  _clientConnected()   — abstraction connexion multi-plateforme
 *    5.  readNetworkKey()     — machine d'état réseau (9 états persistants)
 *    6.  update()             — boucle Serial (machine ESC 3 états)
 *    7.  updateNetwork()      — boucle réseau (Telnet / WiFi / BT)
 *    8.  handleKey()          — dispatch commun Serial + réseau
 *    9.  onEnter()            — validation de ligne (ENTER)
 *   10.  prompt()             — affichage du prompt "> "
 *   11.  redrawLine()         — réaffichage ligne complète
 *   12.  historyPush()        — enregistrement historique circulaire
 *   13.  historyNav()         — navigation historique ↑ ↓
 *   14.  tabComplete()        — complétion TAB + cycle Re-TAB
 *   15.  moveCursor()         — déplacement curseur ← →
 *   16.  insertChar()         — insertion caractère (avec écho)
 *   17.  delLeft()            — suppression caractère (Backspace)
 *   18.  processCommand()     — parse + exécution commande
 *   19.  print / println / write — sortie simultanée 3 canaux (v22/v23)
 *********************************************************************************************************************/

#include "CmdLine.h"
#include <string.h>   // memset, memcpy, memmove, strcmp, strncpy, strlen


// ════════════════════════════════════════════════════════════════════════════
//  1. HELPERS INTERNES
// ════════════════════════════════════════════════════════════════════════════


// ── initSt() ─────────────────────────────────────────────────────────────────
//  Initialise un CanalState à ses valeurs par défaut.
//  Remet tous les champs à 0 / false, puis fixe les sentinelles.
//  Appelé dans le constructeur pour _stS (Serial) et _stN (Network).
//
//  IN   st : référence sur le CanalState à initialiser (modifié en place)
//  OUT  —
//  VAR modifiées (dans st) :
//    buf[]         ← '\0' (memset)
//    len           ← 0
//    cur           ← 0
//    history[][]   ← '\0' (memset)
//    histCount     ← 0
//    histHead      ← 0
//    histPos       ← -1  (sentinelle : ligne courante, pas en navigation)
//    savedLine[]   ← '\0' (memset)
//    lineSaved     ← false
//    tabMatchCount ← 0
//    tabMatchIdx   ← -1  (sentinelle : pas de cycle TAB actif)
//    tabPrefixLen  ← 0
//    tstate        ← TS_INIT  (machine d'état réseau au repos)
//    timer         ← 0
//    willEchoSent  ← false  (négociation WILL ECHO pas encore envoyée)
// ─────────────────────────────────────────────────────────────────────────────
static void initSt(CanalState &st) {
    memset(&st, 0, sizeof(st));    // Met tout à 0 / false / '\0'
    st.histPos     = -1;           // -1 = on est sur la ligne courante
    st.tabMatchIdx = -1;           // -1 = pas de cycle TAB en cours
    st.tstate      = TS_INIT;      // Machine d'état Telnet au repos
    st.willEchoSent = false;       // Négociation WILL ECHO pas encore envoyée
}


// ── cmdStrcmpLower() ──────────────────────────────────────────────────────────
//  Compare deux chaînes en ignorant la casse (insensible majuscules/minuscules).
//  Aucune des deux chaînes n'est modifiée.
//
//  Remplace strcasecmp() — non disponible sur toutes les toolchains Arduino.
//
//  Utilisée par processCommand() :
//    Compare l'entrée utilisateur (convertie en minuscules) avec les noms
//    de cmd_t qui peuvent avoir des majuscules (SaveConf, LEDon, serveurWEB...).
//    La table _commands[] n'est JAMAIS modifiée.
//
//  IN   a   : première chaîne  (ex : "saveconf" — entrée utilisateur)
//  IN   b   : deuxième chaîne  (ex : "SaveConf" — nom dans _commands[])
//  OUT  int : 0 si égales (insensible casse), ≠0 si différentes
// ─────────────────────────────────────────────────────────────────────────────
static int cmdStrcmpLower(const char *a, const char *b) {
    while (*a && *b) {
        char ca = (*a >= 'A' && *a <= 'Z') ? (*a + 32) : *a;   // a → minuscule
        char cb = (*b >= 'A' && *b <= 'Z') ? (*b + 32) : *b;   // b → minuscule
        if (ca != cb) return (int)ca - (int)cb;
        ++a; ++b;
    }
    return (int)*a - (int)*b;   // 0 si les deux chaînes sont terminées en même temps
}


// ── cmdStrncmpLower() ─────────────────────────────────────────────────────────
//  Même principe que cmdStrcmpLower() mais limitée aux n premiers caractères.
//
//  Utilisée par tabComplete() :
//    Compare le préfixe saisi (st.buf[0..n-1]) avec le début des noms de
//    commandes, sans modifier la table ni le buffer.
//    Ex : "save" (n=4) trouve "SaveConf" car "save"=="save" (insensible).
//
//  IN   a   : première chaîne  (ex : "save"     — préfixe utilisateur)
//  IN   b   : deuxième chaîne  (ex : "SaveConf" — nom dans _commands[])
//  IN   n   : nombre de caractères à comparer (= st.tabPrefixLen = st.cur)
//  OUT  int : 0 si les n premiers caractères sont égaux (insensible casse)
// ─────────────────────────────────────────────────────────────────────────────
static int cmdStrncmpLower(const char *a, const char *b, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        char ca = (a[i] >= 'A' && a[i] <= 'Z') ? (a[i] + 32) : a[i];
        char cb = (b[i] >= 'A' && b[i] <= 'Z') ? (b[i] + 32) : b[i];
        if (ca != cb) return (int)ca - (int)cb;
        if (ca == '\0') return 0;   // Les deux chaînes finissent avant n
    }
    return 0;   // Les n premiers caractères sont identiques
}


// ════════════════════════════════════════════════════════════════════════════
//  2. CONSTRUCTEUR + begin()
// ════════════════════════════════════════════════════════════════════════════


// ── Constructeur ──────────────────────────────────────────────────────────────
//  Initialise les deux états de canaux (Serial et réseau).
//  Ne démarre pas la communication — appeler Serial.begin() avant begin().
//
//  IN   stream : référence au stream Serial de l'Arduino
//               (Serial pour usage standard, SerialBT pour ESP32 BT)
//  VAR modifiées : _stream = stream (référence conservée)
//                  _stS et _stN → initSt() (tous champs à 0/défaut)
// ─────────────────────────────────────────────────────────────────────────────
CmdLine::CmdLine(Stream &s) : _stream(s) {
    initSt(_stS);   // Canal Serial  : état initial
    initSt(_stN);   // Canal réseau  : état initial
}


// ── begin() ───────────────────────────────────────────────────────────────────
//  Enregistre la table des commandes et affiche le premier prompt "> " sur Serial.
//  À appeler dans setup() après Serial.begin() et l'initialisation réseau.
//
//  IN   cmds : tableau cmd_t global du sketch (ex : commands[])
//  IN   n    : nombre d'entrées — utiliser countof(commands)
//  OUT  —    : affiche "> " sur _stream (premier prompt)
//  VAR modifiées : _commands = cmds
//                  _num      = n
//                  _stS.len=0, _stS.cur=0, _stS.histPos=-1 (via prompt)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::begin(const cmd_t *cmds, size_t n) {
    _commands = cmds;
    _num      = n;
    prompt(_stS, _stream);   // Premier prompt Serial
}


// ── addCommands() ─────────────────────────────────────────────────────────────
//  Enregistre un second tableau de commandes utilisateur, parcouru apres
//  commands[] dans processCommand() et tabComplete().
//  A appeler dans setup() apres begin(). Appel optionnel : si absent,
//  _user_commands reste nullptr et le comportement est identique a v23.
//
//  IN   cmds : tableau cmd_t utilisateur (ex : user_commands[])
//  IN   n    : nombre d entrees — utiliser countof(user_commands)
//  VAR modifiees : _user_commands = cmds
//                  _user_num      = n
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::addCommands(const cmd_t *cmds, size_t n) {
    _user_commands = cmds;
    _user_num      = n;
}


// ════════════════════════════════════════════════════════════════════════════
//  3. telnetCmd() — négociation IAC Telnet
//     (désactivé si CMDLINE_NO_IAC — mode Bluetooth)
// ════════════════════════════════════════════════════════════════════════════

#ifndef CMDLINE_NO_IAC
// ── telnetCmd() ───────────────────────────────────────────────────────────────
//  Envoie une séquence de négociation Telnet de 3 octets : IAC cmd opt.
//  Utilisé lors de la réception du 1er WILL client (négociation écho).
//
//  Séquences importantes :
//    { 0xFF, 0xFB, 0x01 } = IAC WILL ECHO  → "c'est moi qui gère l'écho"
//    { 0xFF, 0xFB, 0x03 } = IAC WILL SGA   → "mode full-duplex"
//    { 0xFF, 0xFE, opt  } = IAC DONT opt   → refus d'une option cliente
//    { 0xFF, 0xFB, 0x03 } = IAC WILL SGA   → confirmation SGA (réponse DO SGA)
//
//  Non disponible en mode Bluetooth (CMDLINE_NO_IAC défini dans CmdLineKey.h).
//
//  IN   client : client réseau destinataire
//  IN   cmd    : CMDLINE_WILL / CMDLINE_WONT / CMDLINE_DO / CMDLINE_DONT
//  IN   opt    : CMDLINE_OPT_ECHO (0x01) / CMDLINE_OPT_SGA (0x03) / autre
//  OUT  —      : écrit 3 octets bruts sur le socket TCP
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::telnetCmd(CmdLineClient &client, uint8_t cmd, uint8_t opt) {
    uint8_t buf[3] = { CMDLINE_IAC, cmd, opt };
    client.write(buf, 3);
}
#endif


// ════════════════════════════════════════════════════════════════════════════
//  4. _clientConnected() — abstraction connexion multi-plateforme
// ════════════════════════════════════════════════════════════════════════════

// ── _clientConnected() ────────────────────────────────────────────────────────
//  Teste si le client réseau est connecté et prêt à recevoir des données.
//  Abstraction nécessaire car l'API diffère selon la plateforme :
//    EthernetClient / WiFiClient → méthode .connected()
//    BluetoothSerial             → méthode .hasClient()
//
//  Utilisée par les fonctions de sortie print/println/write pour
//  conditionner l'écriture sur le canal réseau.
//
//  IN   — (utilise _c membre privé)
//  OUT  bool : true si _c != nullptr ET client connecté
// ─────────────────────────────────────────────────────────────────────────────
bool CmdLine::_clientConnected() const {
    if (!_c) return false;                  // Pas de client mémorisé → non connecté
#if defined(CMDLINE_NETWORK_BLUETOOTH)
    return _c->hasClient();                 // BluetoothSerial → hasClient()
#else
    return _c->connected();                 // EthernetClient ou WiFiClient → connected()
#endif
}


// ════════════════════════════════════════════════════════════════════════════
//  5. readNetworkKey() — machine d'état réseau (9 états persistants)
// ════════════════════════════════════════════════════════════════════════════

// ── readNetworkKey() ──────────────────────────────────────────────────────────
//  Lit UN octet du client réseau et le décode via la machine d'état _stN.tstate.
//  La machine d'état persiste entre les appels de loop() car les séquences
//  multi-octets (ESC[A, IAC WILL xx) peuvent arriver en plusieurs paquets TCP.
//
//  En mode Bluetooth (CMDLINE_NO_IAC) :
//    La gestion IAC est désactivée par compilation conditionnelle.
//    Les séquences ESC (flèches) et CR/LF restent pleinement actives.
//
//  Timeouts :
//    TS_ESC   : 40ms  — ESC isolé abandonné si rien ne suit
//    TS_MATCH : 200ms — séquence ESC[ abandonnée si lettre finale trop tardive
//
//  ┌──────────┐  0xFF (IAC)    ┌──────────┐  WILL    ┌──────────┐
//  │ TS_INIT  │──────────────► │ TS_IAC   │────────► │ TS_WILL  │──► répond IAC WILL ECHO (1 fois)
//  │          │                │          │           └──────────┘    + IAC WILL SGA → TS_INIT
//  │          │                │          │  WONT    ┌──────────┐
//  │          │                │          │────────► │ TS_WONT  │──► ignoré → TS_INIT
//  │          │                │          │  DO      ┌──────────┐
//  │          │                │          │────────► │  TS_DO   │──► répond WILL SGA ou WONT → TS_INIT
//  │          │                │          │  DONT    ┌──────────┐
//  │          │                │          │────────► │ TS_DONT  │──► ignoré → TS_INIT
//  │          │                └──────────┘           └──────────┘
//  │          │  0x1B (ESC) ──► TS_ESC ──['──► TS_MATCH ──A──► KEY_UP    → TS_INIT
//  │          │                timeout 40ms─►TS_INIT     ──B──► KEY_DOWN  → TS_INIT
//  │          │                              timeout 200ms►TS_INIT──C──► KEY_RIGHT → TS_INIT
//  │          │                                            ──D──► KEY_LEFT  → TS_INIT
//  │          │  0x0D (CR) ──► TS_CR ──0x0A/0x00──► TS_INIT (absorbé, RFC 854) KEY_RETURN
//  │          │  0x09 TAB  ──────────────────────────────────────────────────► KEY_TAB
//  │          │  0x08/0x7F ──────────────────────────────────────────────────► KEY_BACKSPACE
//  │          │  0x20..0x7E──────────────────────────────────────────────────► char ASCII
//  │          │  autres    ──────────────────────────────────────────────────► KEY_NONE
//  └──────────┘
//
//  IN   client : client réseau courant (EthernetClient, WiFiClient, BluetoothSerial)
//  OUT  int    : KEY_UP / KEY_DOWN / KEY_RIGHT / KEY_LEFT / KEY_RETURN /
//                KEY_BACKSPACE / KEY_TAB / char 0x20..0x7E / KEY_NONE
//  VAR modifiées (dans _stN) :
//    tstate       ← état suivant de la machine d'état
//    timer        ← armé lors de TS_ESC (timeout ESC) et TS_MATCH (timeout SEQ)
//    willEchoSent ← mis à true lors du 1er TS_WILL reçu
// ─────────────────────────────────────────────────────────────────────────────
int CmdLine::readNetworkKey(CmdLineClient &client) {

    // ── Timeout état TS_ESC (40ms) ────────────────────────────────────────────
    //  Après réception de ESC (0x1B), si aucun octet ne suit dans les 40ms
    //  → ESC isolé → on abandonne silencieusement (retour TS_INIT).
    //  Si des octets arrivent avant le timeout → on continue normalement.
    if (_stN.tstate == TS_ESC) {
        if (!client.available()) {
            if ((millis() - _stN.timer) >= ESC_TIMEOUT_MS) {
                _stN.tstate = TS_INIT;              // ESC isolé → abandon
            }
            return KEY_NONE;                        // Attendre le prochain octet
        }
    }

    // ── Timeout état TS_MATCH (200ms) ─────────────────────────────────────────
    //  ESC[ reçu, on attend A/B/C/D. Si la lettre finale tarde trop → abandon.
    if (_stN.tstate == TS_MATCH) {
        if (!client.available()) {
            if ((millis() - _stN.timer) >= SEQ_TIMEOUT_MS) {
                _stN.tstate = TS_INIT;              // Séquence incomplète → abandon
            }
            return KEY_NONE;
        }
    }

    if (!client.available()) return KEY_NONE;   // Rien à lire → sort immédiatement

    int ch = client.read();
    if (ch < 0) return KEY_NONE;               // Erreur lecture
    unsigned char c = (unsigned char)ch;

    switch (_stN.tstate) {

    // ── TS_CR : CR reçu lors du cycle précédent ───────────────────────────────
    //  RFC 854 : ENTER Telnet = CR+LF (0x0D 0x0A) ou CR+NUL (0x0D 0x00)
    //  selon la configuration du client :
    //    PuTTY ^M coché   → 0x0D 0x0A (CR LF)
    //    PuTTY ^M décoché → 0x0D 0x00 (CR NUL)
    //    MobaXterm        → 0x0D 0x0A
    //  On absorbe l'octet suivant (LF ou NUL) pour éviter le double ENTER.
    //  Si l'octet n'est ni LF ni NUL → traitement normal (goto process_init).
    //
    //  IN   c : octet reçu après le CR
    //  VAR  _stN.tstate ← TS_INIT
    case TS_CR:
        _stN.tstate = TS_INIT;
        if (c == 0x0A || c == 0x00) return KEY_NONE;   // LF ou NUL absorbé
        goto process_init;                              // Autre octet → traitement normal

    // ── TS_ESC : ESC (0x1B) reçu, on attend '[' ──────────────────────────────
    //  Flèches ANSI : ESC (0x1B) → '[' (0x5B) → A/B/C/D
    //  Si on reçoit '[' → on passe à TS_MATCH et on arme le timer 200ms.
    //  Tout autre octet → ESC non reconnu → retour TS_INIT.
    //
    //  IN   c : octet reçu après ESC
    //  VAR  _stN.tstate ← TS_MATCH (si '[') ou TS_INIT (si autre)
    //       _stN.timer  ← millis() (armé pour timeout TS_MATCH)
    case TS_ESC:
        if (c == '[') {
            _stN.tstate = TS_MATCH;
            _stN.timer  = millis();             // Arme le timer 200ms pour la lettre finale
            return KEY_NONE;
        }
        _stN.tstate = TS_INIT;
        goto process_init;                      // Octet inconnu → traitement normal

    // ── TS_MATCH : ESC[ reçu, on attend la lettre finale ─────────────────────
    //  Décode la touche flèche selon la lettre :
    //    0x41 (A) → KEY_UP     ↑
    //    0x42 (B) → KEY_DOWN   ↓
    //    0x43 (C) → KEY_RIGHT  →
    //    0x44 (D) → KEY_LEFT   ←
    //  Toute autre lettre → séquence ignorée (KEY_NONE).
    //
    //  IN   c : lettre finale de la séquence ESC[x
    //  OUT  KEY_UP / KEY_DOWN / KEY_RIGHT / KEY_LEFT / KEY_NONE
    //  VAR  _stN.tstate ← TS_INIT
    case TS_MATCH:
        _stN.tstate = TS_INIT;
        if (c == 0x41) return KEY_UP;
        if (c == 0x42) return KEY_DOWN;
        if (c == 0x43) return KEY_RIGHT;
        if (c == 0x44) return KEY_LEFT;
        return KEY_NONE;

#ifndef CMDLINE_NO_IAC
    // ── TS_IAC : IAC (0xFF) reçu, on attend la commande Telnet ──────────────
    //  Dispatch selon l'octet commande reçu :
    //    CMDLINE_WILL (0xFB) → TS_WILL  (client annonce une option)
    //    CMDLINE_WONT (0xFC) → TS_WONT  (client refuse une option)
    //    CMDLINE_DO   (0xFD) → TS_DO    (client demande activation)
    //    CMDLINE_DONT (0xFE) → TS_DONT  (client demande désactivation)
    //    autre               → TS_INIT  (commande inconnue → ignorée)
    //
    //  IN   c : octet commande Telnet
    //  VAR  _stN.tstate ← TS_WILL / TS_WONT / TS_DO / TS_DONT / TS_INIT
    case TS_IAC:
        switch (c) {
            case CMDLINE_WILL: _stN.tstate = TS_WILL; break;
            case CMDLINE_WONT: _stN.tstate = TS_WONT; break;
            case CMDLINE_DO:   _stN.tstate = TS_DO;   break;
            case CMDLINE_DONT: _stN.tstate = TS_DONT; break;
            default:           _stN.tstate = TS_INIT; break;
        }
        return KEY_NONE;

    // ── TS_WILL : client dit WILL <opt> ──────────────────────────────────────
    //  À la 1ère réception → on répond IAC WILL ECHO + IAC WILL SGA (une seule fois).
    //  IAC WILL ECHO : "c'est l'Arduino qui gère l'écho" → PuTTY coupe son écho local.
    //  IAC WILL SGA  : "mode full-duplex, pas de Go-Ahead" → connexion fluide.
    //
    //  On accepte silencieusement :
    //    0x1F = WindowSize (NAWS) → taille fenêtre → pas besoin d'agir
    //    0x21 = Remote Flow Control → pas besoin d'agir
    //  On refuse tout autre option cliente via IAC DONT opt.
    //
    //  IN   c : octet option Telnet (ex : OPT_ECHO=0x01, OPT_SGA=0x03, NAWS=0x1F...)
    //  VAR  _stN.tstate ← TS_INIT
    //       _stN.willEchoSent ← true (à la 1ère négociation)
    case TS_WILL:
        _stN.tstate = TS_INIT;
        if (!_stN.willEchoSent) {
            _stN.willEchoSent = true;
            telnetCmd(client, CMDLINE_WILL, CMDLINE_OPT_ECHO);   // IAC WILL ECHO — on gère l'écho
            telnetCmd(client, CMDLINE_WILL, CMDLINE_OPT_SGA);    // IAC WILL SGA — mode full-duplex
        }
        // Accept WindowSize (0x1F) et Remote Flow Control (0x21) silencieusement
        if (c != 0x1F && c != 0x21) {
            telnetCmd(client, CMDLINE_DONT, c);                   // IAC DONT opt — refus des autres
        }
        return KEY_NONE;

    // ── TS_WONT / TS_DONT : client refuse une option ─────────────────────────
    //  On ignore ces réponses — le client nous dit qu'il ne peut pas ou ne veut pas.
    //  Pas d'action nécessaire → retour TS_INIT.
    //
    //  IN   c : octet option (ignoré)
    //  VAR  _stN.tstate ← TS_INIT
    case TS_WONT:
    case TS_DONT:
        _stN.tstate = TS_INIT;
        return KEY_NONE;

    // ── TS_DO : client demande au serveur d'activer <opt> ────────────────────
    //  OPT_ECHO (0x01) : confirmation de notre IAC WILL ECHO → rien à faire
    //  OPT_SGA  (0x03) : on confirme avec IAC WILL SGA
    //  autre           : on refuse avec IAC WONT opt
    //
    //  IN   c : octet option Telnet demandée
    //  VAR  _stN.tstate ← TS_INIT
    case TS_DO:
        _stN.tstate = TS_INIT;
        if (c == CMDLINE_OPT_ECHO) {
            // IAC DO ECHO = confirmation de notre WILL ECHO → déjà actif, rien à faire
        } else if (c == CMDLINE_OPT_SGA) {
            telnetCmd(client, CMDLINE_WILL, CMDLINE_OPT_SGA);    // Confirmation SGA
        } else {
            telnetCmd(client, CMDLINE_WONT, c);                   // Refus option inconnue
        }
        return KEY_NONE;
#endif // CMDLINE_NO_IAC

    case TS_INIT:
    default:
        break;
    }

    // ── Traitement TS_INIT — décodage de l'octet courant ─────────────────────
process_init:

#ifndef CMDLINE_NO_IAC
    // IAC (0xFF) → début d'une séquence de négociation Telnet
    if (c == CMDLINE_IAC) {
        _stN.tstate = TS_IAC;               // VAR : tstate ← TS_IAC
        return KEY_NONE;
    }
#endif

    // ESC (0x1B) → début d'une séquence de flèche ANSI
    if (c == 0x1B) {
        _stN.tstate = TS_ESC;               // VAR : tstate ← TS_ESC
        _stN.timer  = millis();             // VAR : timer armé (timeout 40ms)
        return KEY_NONE;
    }

    // CR (0x0D) → touche ENTER ; l'octet suivant (LF ou NUL) sera absorbé en TS_CR
    if (c == 0x0D) { _stN.tstate = TS_CR; return KEY_RETURN; }

    // LF seul (0x0A) → ENTER sans CR préalable (certains clients Linux)
    if (c == 0x0A) { return KEY_RETURN; }

    // TAB (0x09) → complétion automatique
    if (c == 0x09) { return KEY_TAB; }

    // Backspace (0x08) ou DEL (0x7F) → suppression caractère gauche
    if (c == 0x08 || c == 0x7F) { return KEY_BACKSPACE; }

    // Caractère ASCII imprimable → retourné tel quel comme int
    if (c >= 0x20 && c <= 0x7E) { return (int)c; }

    return KEY_NONE;    // Caractère de contrôle non reconnu → ignoré
}


// ════════════════════════════════════════════════════════════════════════════
//  6. update() — canal Serial
// ════════════════════════════════════════════════════════════════════════════

// ── update() ──────────────────────────────────────────────────────────────────
//  Lit les octets disponibles sur _stream (Serial) et les traite via handleKey().
//  Retourne immédiatement si aucun octet n'est disponible (non bloquant).
//  À appeler dans loop() à chaque cycle.
//
//  Machine d'état ESC Serial intégrée (3 états, sans timeout) :
//  Serial est synchrone → les 3 octets ESC [ A arrivent dans la même lecture
//  → pas besoin de timeout entre les octets.
//
//  Schéma machine d'état ESC Serial :
//    _serialEscState :
//      0 ──► 0x1B (ESC) reçu ─────────────────► état 1
//      1 ──► '[' (0x5B) reçu ─────────────────► état 2   / autre → état 0
//      2 ──► 0x41 (A) → KEY_UP   → état 0
//            0x42 (B) → KEY_DOWN  → état 0
//            0x43 (C) → KEY_RIGHT → état 0
//            0x44 (D) → KEY_LEFT  → état 0
//            autre    → ignoré    → état 0
//
//  Pour les caractères non-ESC :
//    0x0D ou 0x0A → KEY_RETURN   (CR ou LF)
//    0x09         → KEY_TAB
//    0x08 / 0x7F  → KEY_BACKSPACE
//    0x20..0x7E   → caractère ASCII imprimable (retourné tel quel)
//
//  IN  —          : lit _stream.read() en boucle tant que .available() > 0
//  OUT —          : appelle handleKey(_stS, _stream, key) pour chaque touche
//  VAR modifiées  : _serialEscState (machine d'état ESC Serial : 0/1/2)
//                   via handleKey() → _stS.buf, _stS.len, _stS.cur
//                   via onEnter()   → _stS.histHead, histCount, histPos
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::update() {
    while (_stream.available()) {
        unsigned char c = (unsigned char)_stream.read();
        int key = KEY_NONE;

        // ── Machine d'état ESC Serial ──────────────────────────────────────────

        if (_serialEscState == 1) {                             // État 1 : ESC reçu → attend '['
            _serialEscState = (c == '[') ? 2 : 0;              // '[' → état 2 / autre → état 0
            continue;
        }
        if (_serialEscState == 2) {                             // État 2 : ESC[ reçu → attend A/B/C/D
            _serialEscState = 0;                                // Retour état 0 dans tous les cas
            if      (c == 0x41) key = KEY_UP;
            else if (c == 0x42) key = KEY_DOWN;
            else if (c == 0x43) key = KEY_RIGHT;
            else if (c == 0x44) key = KEY_LEFT;
            if (key != KEY_NONE) handleKey(_stS, _stream, key);
            continue;
        }
        if (c == 0x1B) { _serialEscState = 1; continue; }      // ESC → état 1 (début séquence)

        // ── Décodage caractères normaux (état 0) ────────────────────────────────
        if      (c == 0x0D || c == 0x0A) key = KEY_RETURN;     // CR ou LF → ENTER
        else if (c == 0x09)              key = KEY_TAB;         // TAB → complétion
        else if (c == 0x08 || c == 0x7F) key = KEY_BACKSPACE;   // BS ou DEL → suppression
        else if (c >= 0x20 && c <= 0x7E) key = (int)c;         // ASCII imprimable → direct

        if (key != KEY_NONE) handleKey(_stS, _stream, key);    // Dispatch vers l'action
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  7. updateNetwork() — canal réseau (Telnet / WiFi / Bluetooth)
// ════════════════════════════════════════════════════════════════════════════

// ── updateNetwork() ───────────────────────────────────────────────────────────
//  Lit et traite les octets du client réseau via readNetworkKey().
//  Mémorise le pointeur client dans _c pour que print/println écrivent
//  simultanément sur ce canal.
//  À appeler dans loop() à chaque cycle.
//
//  Différence par rapport à update() :
//    update()        → machine ESC simple 3 états, pas de timeout, _stS
//    updateNetwork() → machine d'état 9 états persistants, timeouts, _stN
//
//  Obtention du client selon la plateforme :
//    UNO R4      : clientTelnet = serverTelnet.available()
//    ESP8266     : clientTelnet = serverTelnet.available()
//    ESP32 WiFi  : if (serverTelnet.hasClient()) clientTelnet = serverTelnet.available()
//    ESP32 BT    : cmdLine.updateNetwork(SerialBT) directement (pas de server)
//
//  IN   client : client réseau courant
//               UNO R4      → EthernetClient (serverTelnet.available())
//               ESP8266/32  → WiFiClient (serverTelnet.available())
//               ESP32 BT    → BluetoothSerial (objet global)
//  OUT  —      : appelle handleKey(_stN, client, key) pour chaque touche décodée
//  VAR modifiées : _c = &client (mémorisé pour les sorties print/println)
//                  via readNetworkKey() → _stN.tstate, _stN.timer, _stN.willEchoSent
//                  via handleKey()      → _stN.buf, _stN.len, _stN.cur
//                  via onEnter()        → _stN.histHead, histCount, histPos
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::updateNetwork(CmdLineClient &client) {
    _c = &client;                           // VAR : mémorise client pour print/println
    if (!client) return;                    // Client invalide (non connecté) → sort

    while (true) {
        int key = readNetworkKey(client);   // Décode un octet (machine 9 états)
        if (key == KEY_NONE) break;         // Pas de touche complète → fin de boucle
        handleKey(_stN, client, key);       // Dispatch vers l'action correspondante
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  8. handleKey() — dispatch commun Serial + réseau
// ════════════════════════════════════════════════════════════════════════════

// ── handleKey() ───────────────────────────────────────────────────────────────
//  Reçoit une touche décodée et exécute l'action correspondante.
//  Commun aux deux canaux (Serial et réseau) — seuls st et out diffèrent.
//  Réinitialise le cycle TAB (tabMatchIdx=-1) pour toute touche sauf KEY_TAB.
//
//  IN   st  : état du canal (_stS pour Serial, _stN pour réseau)
//  IN   out : stream de sortie (Serial ou client réseau)
//  IN   key : KEY_RETURN, KEY_BACKSPACE, KEY_TAB, KEY_UP, KEY_DOWN,
//             KEY_RIGHT, KEY_LEFT, ou char imprimable 0x20..0x7E
//  OUT  —   : appelle la méthode correspondante
//  VAR modifiées : st.tabMatchIdx ← -1 (pour toute touche sauf TAB)
//    KEY_RETURN    → onEnter()   : st.buf, len, cur, histHead, histCount, histPos
//    KEY_BACKSPACE → delLeft()   : st.buf, st.len, st.cur
//    KEY_TAB       → tabComplete(): st.buf, len, cur, tabMatchIdx, tabMatchCount
//    KEY_UP/DOWN   → historyNav(): st.buf, len, cur, histPos, savedLine, lineSaved
//    KEY_RIGHT/LEFT→ moveCursor(): st.cur
//    char 0x20..7E → insertChar(): st.buf, st.len, st.cur
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::handleKey(CanalState &st, Print &out, int key) {
    switch (key) {

    case KEY_RETURN:                        // ENTER : valider la commande
        out.print(F("\r\n"));               // Saut de ligne sur le terminal
        st.tabMatchIdx = -1;               // Réinitialise le cycle TAB
        onEnter(st, out);                  // Valide, enregistre, exécute, prompt
        break;

    case KEY_BACKSPACE:                     // Backspace / DEL : supprimer char gauche
        st.tabMatchIdx = -1;
        delLeft(st, out);                  // Supprime st.buf[cur-1], cur-1, len-1
        break;

    case KEY_TAB:                           // TAB : complétion automatique
        tabComplete(st, out);              // Complète sur le préfixe actuel
        redrawLine(st, out);               // Efface "^I" affiché par PuTTY (écho parasite)
        // Note : tabMatchIdx N'EST PAS réinitialisé ici — conservé pour le cycle Re-TAB
        break;

    case KEY_UP:                            // Flèche ↑ : historique précédent (vers le passé)
        st.tabMatchIdx = -1;
        historyNav(st, out, -1);           // dir=-1 → remonte dans l'historique
        break;

    case KEY_DOWN:                          // Flèche ↓ : historique suivant (vers le présent)
        st.tabMatchIdx = -1;
        historyNav(st, out, +1);           // dir=+1 → descend dans l'historique
        break;

    case KEY_RIGHT:                         // Flèche → : curseur droite
        st.tabMatchIdx = -1;
        moveCursor(st, out, +1);           // dir=+1 → cur+1 si cur < len
        break;

    case KEY_LEFT:                          // Flèche ← : curseur gauche
        st.tabMatchIdx = -1;
        moveCursor(st, out, -1);           // dir=-1 → cur-1 si cur > 0
        break;

    default:                                // Caractère ASCII imprimable 0x20..0x7E
        if (key >= 0x20 && key <= 0x7E) {
            st.tabMatchIdx = -1;
            insertChar(st, out, (unsigned char)key);  // Insère à la position cur
        }
        break;
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  9. onEnter() — validation de ligne (ENTER)
// ════════════════════════════════════════════════════════════════════════════

// ── onEnter() ─────────────────────────────────────────────────────────────────
//  Appelé quand KEY_RETURN est reçu (après que handleKey a déjà émis \r\n).
//
//  Étapes :
//    1. Termine st.buf par '\0' (nécessaire pour strcmp dans processCommand)
//    2. Si buffer non vide : enregistre dans l'historique + exécute la commande
//    3. Affiche le prompt suivant "> "
//
//  IN   st, out
//  OUT  — : appelle historyPush(), processCommand(), prompt()
//  VAR modifiées : st.buf[st.len] ← '\0'
//                  st.histHead, st.histCount (via historyPush, si buf non vide)
//                  st.len=0, st.cur=0, st.histPos=-1, st.lineSaved=false (via prompt)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::onEnter(CanalState &st, Print &out) {
    st.buf[st.len] = '\0';                  // Termine la chaîne pour strcmp
    if (st.len > 0) {
        historyPush(st, st.buf);            // Enregistre dans l'historique circulaire
        processCommand(st, out);            // Parse + exécute la commande
    }
    prompt(st, out);                        // Affiche "> " pour la prochaine saisie
}


// ════════════════════════════════════════════════════════════════════════════
//  10. prompt()
// ════════════════════════════════════════════════════════════════════════════

// ── prompt() ──────────────────────────────────────────────────────────────────
//  Remet le buffer et les curseurs à zéro, puis affiche "> " sur le canal.
//  Appelé par begin() (prompt initial) et onEnter() (après chaque commande).
//
//  IN   st, out
//  OUT  — : affiche "> " sur out
//  VAR modifiées : st.len     ← 0  (buffer vide)
//                  st.cur     ← 0  (curseur au début)
//                  st.histPos ← -1 (retour en mode "ligne courante")
//                  st.lineSaved ← false (sauvegarde invalidée)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::prompt(CanalState &st, Print &out) {
    st.len = 0;
    st.cur = 0;
    st.histPos   = -1;
    st.lineSaved = false;
    out.print(F("> "));                     // Affiche le prompt sur le canal
}


// ════════════════════════════════════════════════════════════════════════════
//  11. redrawLine()
// ════════════════════════════════════════════════════════════════════════════

// ── redrawLine() ──────────────────────────────────────────────────────────────
//  Réaffiche la ligne complète depuis le début de la ligne terminal.
//  Séquence émise : \r + "> " + buf[0..len] + ESC[K + ESC[D × (len-cur)
//
//  Rôle du \r (Carriage Return — retour colonne 0 sans descendre) :
//    → Écrase l'écho parasite que PuTTY affiche AVANT que la négociation
//      IAC WILL ECHO ne soit effective :
//        TAB     → PuTTY affiche "^I"   (visible quelques ms)
//        Flèche↑ → PuTTY affiche "^[[A" (visible quelques ms)
//      Le \r replace le curseur en colonne 0 → la réécriture "> " + buf
//      écrase ces artefacts → l'utilisateur ne les voit pas.
//
//  ESC[K : efface du curseur jusqu'à la fin de la ligne
//    → Supprime les résidus si la nouvelle ligne est plus courte que l'ancienne
//
//  ESC[D × N : replace le curseur à la position logique st.cur
//    → N = (st.len - st.cur) positions depuis la fin
//    → Permet l'insertion au milieu de la ligne
//
//  IN   st, out
//  OUT  — : écrit sur out (\r, "> ", buf, ESC[K, ESC[D × N)
//  VAR modifiées : aucune (st est lu uniquement)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::redrawLine(CanalState &st, Print &out) {
    out.print('\r');                                            // Retour colonne 0 (efface artefacts PuTTY)
    out.print(F("> "));                                         // Réaffiche le prompt
    if (st.len > 0) out.write((const uint8_t*)st.buf, st.len); // Réaffiche tout le buffer
    out.print(F("\033[K"));                                     // ESC[K : efface jusqu'en fin de ligne
    size_t back = st.len - st.cur;                              // Nombre de positions à reculer
    for (size_t i = 0; i < back; ++i) out.print(F("\033[D"));  // ESC[D × N : replace le curseur
}


// ════════════════════════════════════════════════════════════════════════════
//  12. historyPush()
// ════════════════════════════════════════════════════════════════════════════

// ── historyPush() ─────────────────────────────────────────────────────────────
//  Ajoute une commande dans l'historique circulaire FIFO.
//  Ignore les lignes vides (cmd[0]=='\0') et les doublons consécutifs.
//
//  Structure circulaire :
//    histHead pointe sur la prochaine case libre.
//    Après écriture : histHead = (histHead + 1) mod CMDLINE_HISTORY_SIZE
//    histCount plafonne à CMDLINE_HISTORY_SIZE (pas de dépassement)
//
//  Accès à la dernière entrée : index = (histHead - 1 + N) mod N
//
//  IN   st
//  IN   cmd : chaîne '\0'-terminée à enregistrer (= st.buf après onEnter)
//  OUT  — : rien si vide ou doublon
//  VAR modifiées : st.history[st.histHead] ← copie de cmd (strncpy)
//                  st.histHead ← (histHead + 1) mod HISTORY_SIZE
//                  st.histCount ← min(histCount + 1, HISTORY_SIZE)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::historyPush(CanalState &st, const char *cmd) {
    if (!cmd[0]) return;                    // Ligne vide → ignorée

    // Doublon consécutif : compare avec la dernière entrée enregistrée
    if (st.histCount > 0) {
        int last = (st.histHead - 1 + CMDLINE_HISTORY_SIZE) % CMDLINE_HISTORY_SIZE;
        if (strcmp(st.history[last], cmd) == 0) return;   // Doublon → ignoré
    }

    // Enregistrement dans la case circulaire courante
    strncpy(st.history[st.histHead], cmd, CMDLINE_BUFFER_SIZE - 1);
    st.history[st.histHead][CMDLINE_BUFFER_SIZE - 1] = '\0';        // Sécurité '\0'
    st.histHead = (st.histHead + 1) % CMDLINE_HISTORY_SIZE;         // Avance la tête
    if (st.histCount < CMDLINE_HISTORY_SIZE) ++st.histCount;        // Compte (plafonné)
}


// ════════════════════════════════════════════════════════════════════════════
//  13. historyNav()
// ════════════════════════════════════════════════════════════════════════════

// ── historyNav() ──────────────────────────────────────────────────────────────
//  Navigation ↑ / ↓ dans l'historique des commandes.
//
//  ↑ (dir = -1) : remonte vers le passé
//    1er ↑ (histPos==-1) :
//      → Sauvegarde la ligne courante dans st.savedLine (lineSaved=true)
//      → histPos passe de -1 à 0 (entrée la plus récente)
//    ↑ suivants :
//      → histPos++ jusqu'à histCount-1 (butée en haut)
//
//  ↓ (dir = +1) : redescend vers le présent
//    → histPos-- jusqu'à -1
//    → histPos==-1 : restaure st.savedLine dans st.buf (ligne originale)
//
//  Indexation dans le tableau circulaire :
//    histPos=0 (plus récent) → idx = (histHead - 1) mod N
//    histPos=N               → idx = (histHead - 1 - N) mod N
//    Formule : idx = (histHead - 1 - histPos + N) mod N
//
//  IN   st, out
//  IN   dir : -1 = ↑ (vers le passé)  /  +1 = ↓ (vers le présent)
//  OUT  — : appelle redrawLine() pour afficher la nouvelle ligne chargée
//  VAR modifiées : st.histPos (±1, borné à -1..histCount-1)
//                  st.savedLine, st.lineSaved (1er ↑ uniquement)
//                  st.buf, st.len, st.cur (chargement de l'entrée sélectionnée)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::historyNav(CanalState &st, Print &out, int dir) {
    if (!st.histCount) return;              // Historique vide → rien à faire

    if (dir == -1) {                        // ↑ : remonte vers le passé
        if (st.histPos == -1) {             // 1er ↑ → sauvegarder la ligne courante
            strncpy(st.savedLine, st.buf, CMDLINE_BUFFER_SIZE);
            st.savedLine[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.lineSaved = true;            // VAR : savedLine valide
        }
        if (st.histPos < st.histCount - 1) ++st.histPos;   // Avance (pas en butée)
        else return;                        // Déjà à l'entrée la plus ancienne → rien
    } else {                                // ↓ : redescend vers le présent
        if (st.histPos == -1) return;       // Déjà sur la ligne courante → rien
        --st.histPos;                       // Recule
    }

    // ── Chargement de l'entrée sélectionnée dans st.buf ──────────────────────
    if (st.histPos == -1) {
        // Retour à la ligne courante → restaure savedLine
        if (st.lineSaved) {
            strncpy(st.buf, st.savedLine, CMDLINE_BUFFER_SIZE);
            st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.len = strlen(st.buf);
        } else {
            st.buf[0] = '\0';
            st.len = 0;
        }
    } else {
        // Charge l'entrée historique à l'index calculé
        int idx = (st.histHead - 1 - st.histPos + CMDLINE_HISTORY_SIZE) % CMDLINE_HISTORY_SIZE;
        strncpy(st.buf, st.history[idx], CMDLINE_BUFFER_SIZE);
        st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
        st.len = strlen(st.buf);
    }
    st.cur = st.len;                        // VAR : curseur en fin de ligne chargée
    redrawLine(st, out);                    // Réaffiche la nouvelle ligne
}


// ════════════════════════════════════════════════════════════════════════════
//  14. tabComplete()
// ════════════════════════════════════════════════════════════════════════════

// ── tabComplete() ─────────────────────────────────────────────────────────────
//  Complétion TAB sur le préfixe st.buf[0..tabPrefixLen-1].
//
//  1er TAB (tabMatchIdx == -1) :
//    Mémorise tabPrefixLen = st.cur (longueur du préfixe actuel).
//    Compte les commandes dont le début correspond au préfixe.
//    0 match  → BEL (0x07) — bip sonore
//    1 match  → complétion immédiate :
//               copie le nom complet dans st.buf + ajoute espace + redrawLine
//    N matchs → affiche la liste des correspondances + charge la 1ère
//               + active le cycle Re-TAB (tabMatchIdx=1, tabMatchCount=N)
//
//  Re-TAB (tabMatchIdx >= 0) :
//    target = tabMatchIdx % tabMatchCount → index dans les correspondances
//    Parcourt _commands[] et charge la (target)ème correspondance
//    tabPrefixLen conservé → compare toujours sur le même préfixe d'origine
//    tabMatchIdx++ après chaque Re-TAB (défilement circulaire)
//
//  Si CMDLINE_CASE_INSENSITIVE=1 :
//    cmdStrncmpLower() → "save" trouve "SaveConf", "saveAll"...
//    Les noms affichés sont ceux de la table (casse originale préservée)
//
//  IN   st, out
//  OUT  — : peut afficher BEL, liste des commandes, ou complétion
//  VAR modifiées (1er TAB) : st.tabPrefixLen ← st.cur
//                             st.tabMatchCount ← nombre de correspondances
//                             st.tabMatchIdx   ← 1 (si N matchs) ou inchangé
//                             st.buf, st.len, st.cur ← complétion effective
//  VAR modifiées (Re-TAB)  : st.buf, st.len, st.cur ← entrée suivante du cycle
//                             st.tabMatchIdx ← +1
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::tabComplete(CanalState &st, Print &out) {
    if (!_commands || !_num) return;        // Pas de table systeme → rien a faire

    if (st.tabMatchIdx == -1) {
        // ── 1er TAB : compte et traite les correspondances ───────────────────
        st.tabPrefixLen = st.cur;           // VAR : memorise le prefixe
        int cnt = 0, first = -1;
        // Parcours systeme
        for (size_t i = 0; i < _num; ++i) {
            if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
                ++cnt;
                if (first < 0) first = (int)i;   // Memorise le 1er match
            }
        }
        int user_first = -1;
        if (_user_commands && _user_num) {
            for (size_t i = 0; i < _user_num; ++i) {
                if (cmdStrncmpLower(st.buf, _user_commands[i].command, st.tabPrefixLen) == 0) {
                    ++cnt;
                    if (first < 0 && user_first < 0) user_first = (int)i;
                }
            }
        }

        if (!cnt) {
            out.write((uint8_t)0x07);       // 0 match → BEL (bip sonore)
            return;
        }

        // Determine le nom du 1er match (systeme prioritaire sur utilisateur)
        const char *firstName = (first >= 0)
            ? _commands[first].command
            : _user_commands[user_first].command;

        if (cnt == 1) {
            // 1 match → completion immediate + espace
            strncpy(st.buf, firstName, CMDLINE_BUFFER_SIZE - 1);
            st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.len = strlen(st.buf);
            st.cur = st.len;
            if (st.len < CMDLINE_BUFFER_SIZE - 1) {
                st.buf[st.len++] = ' ';     // Ajoute espace apres le nom
                st.cur = st.len;
            }
            redrawLine(st, out);
            return;
        }

        // N matchs → liste toutes les correspondances + charge la 1ere
        out.print(F("\r\n"));
        // Affichage systeme
        for (size_t i = 0; i < _num; ++i) {
            if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
                out.print(F("  "));
                out.println(_commands[i].command);   // Affiche chaque correspondance
            }
        }
        if (_user_commands && _user_num) {
            for (size_t i = 0; i < _user_num; ++i) {
                if (cmdStrncmpLower(st.buf, _user_commands[i].command, st.tabPrefixLen) == 0) {
                    out.print(F("  "));
                    out.println(_user_commands[i].command);
                }
            }
        }
        strncpy(st.buf, firstName, CMDLINE_BUFFER_SIZE - 1);
        st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
        st.len = strlen(st.buf);
        st.cur = st.len;
        redrawLine(st, out);
        st.tabMatchCount = cnt;             // VAR : memorise le nombre de matchs
        st.tabMatchIdx   = 1;              // VAR : demarre le cycle (1 = 2eme entree)
        return;
    }

    // ── Re-TAB : cycle sur les correspondances ───────────────────────────────
    int target = st.tabMatchIdx % st.tabMatchCount;  // Index dans le cycle
    int found  = 0;
    bool done  = false;
    // Parcours systeme
    for (size_t i = 0; i < _num && !done; ++i) {
        if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
            if (found == target) {
                strncpy(st.buf, _commands[i].command, CMDLINE_BUFFER_SIZE - 1);
                st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
                st.len = strlen(st.buf);
                st.cur = st.len;
                redrawLine(st, out);
                done = true;
            }
            ++found;
        }
    }
    if (!done && _user_commands && _user_num) {
        for (size_t i = 0; i < _user_num && !done; ++i) {
            if (cmdStrncmpLower(st.buf, _user_commands[i].command, st.tabPrefixLen) == 0) {
                if (found == target) {
                    strncpy(st.buf, _user_commands[i].command, CMDLINE_BUFFER_SIZE - 1);
                    st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
                    st.len = strlen(st.buf);
                    st.cur = st.len;
                    redrawLine(st, out);
                    done = true;
                }
                ++found;
            }
        }
    }
    ++st.tabMatchIdx;                       // VAR : avance dans le cycle
}


// ════════════════════════════════════════════════════════════════════════════
//  15. moveCursor()
// ════════════════════════════════════════════════════════════════════════════

// ── moveCursor() ──────────────────────────────────────────────────────────────
//  Déplace le curseur logique d'une position et envoie la séquence ANSI
//  correspondante au terminal (ESC[C pour droite, ESC[D pour gauche).
//  Ne fait rien aux bornes (pas de bip, pas d'action).
//
//  Séquences ANSI émises :
//    ESC[C (0x1B 0x5B 0x43) : curseur vers la droite (→)
//    ESC[D (0x1B 0x5B 0x44) : curseur vers la gauche (←)
//
//  IN   st, out
//  IN   dir : -1 = ← (gauche, décrémente cur)  /  +1 = → (droite, incrémente cur)
//  OUT  — : envoie ESC[D (←) ou ESC[C (→) sur out
//  VAR modifiées : st.cur ← cur-1 (si dir==-1 && cur>0)
//                  st.cur ← cur+1 (si dir==+1 && cur<len)
//                  (aucun changement si on est à une borne)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::moveCursor(CanalState &st, Print &out, int dir) {
    if (dir == -1 && st.cur > 0) {                   // ← : possible si pas au début
        --st.cur;
        out.print(F("\033[D"));                        // ESC[D : recule d'une position
    } else if (dir == +1 && st.cur < st.len) {        // → : possible si pas à la fin
        ++st.cur;
        out.print(F("\033[C"));                        // ESC[C : avance d'une position
    }
    // Aux bornes → rien (silencieux)
}


// ════════════════════════════════════════════════════════════════════════════
//  16. insertChar()
// ════════════════════════════════════════════════════════════════════════════

// ── insertChar() ──────────────────────────────────────────────────────────────
//  Insère le caractère c à la position st.cur dans st.buf.
//  Décale les caractères suivants vers la droite (memmove).
//  Ignore si le buffer est plein (len >= BUFFER_SIZE-1).
//
//  C'est le SEUL endroit où un octet brut ASCII est renvoyé en écho.
//
//  Comportement écho (conditionné par _echoEnabled) :
//    _echoEnabled == true :
//      → En fin de ligne (cur == len-1 après insertion) :
//          Écho simple : renvoie c directement (out.write(c))
//      → Au milieu de la ligne (cur < len-1 après insertion) :
//          Renvoie c + réaffiche les caractères décalés
//          ESC[K pour effacer les résidus
//          ESC[D × N pour repositionner le curseur
//
//    _echoEnabled == false (MobaXterm) :
//      → Pas d'écho brut
//      → Si insertion au milieu : redrawLine() pour la cohérence visuelle
//      → En fin de ligne : rien (MobaXterm a déjà affiché le caractère)
//
//  IN   st, out
//  IN   c : caractère imprimable 0x20..0x7E (garanti par handleKey())
//  OUT  — : écrit sur out si _echoEnabled
//  VAR modifiées : st.buf (décalage droite + insertion à cur)
//                  st.len ← len + 1
//                  st.cur ← cur + 1 (après insertion)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::insertChar(CanalState &st, Print &out, unsigned char c) {
    if (st.len >= CMDLINE_BUFFER_SIZE - 1) return;     // Buffer plein → ignoré

    // Décale les caractères après cur vers la droite (memmove sécurisé)
    memmove(&st.buf[st.cur + 1], &st.buf[st.cur], st.len - st.cur);
    st.buf[st.cur] = (char)c;                          // Insère c à la position cur
    ++st.len;                                           // VAR : len + 1

    if (_echoEnabled) {
        if (st.cur == st.len - 1) {
            // ── Fin de ligne : écho simple ────────────────────────────────────
            out.write(c);                               // Renvoie c directement
        } else {
            // ── Milieu de ligne : renvoie c + caractères décalés + repositionne
            out.write(c);
            out.write((const uint8_t*)&st.buf[st.cur + 1], st.len - st.cur - 1);
            out.print(F("\033[K"));                     // Efface résidus en fin de ligne
            size_t back = st.len - st.cur - 1;
            for (size_t i = 0; i < back; ++i) out.print(F("\033[D")); // Repositionne curseur
        }
    } else {
        // ── Pas d'écho brut (MobaXterm) ──────────────────────────────────────
        if (st.cur < st.len - 1) redrawLine(st, out);  // Milieu → redraw pour cohérence
        // En fin de ligne → MobaXterm a déjà affiché le caractère, rien à faire
    }
    ++st.cur;                                           // VAR : cur + 1 (après insertion)
}


// ════════════════════════════════════════════════════════════════════════════
//  17. delLeft()
// ════════════════════════════════════════════════════════════════════════════

// ── delLeft() ─────────────────────────────────────────────────────────────────
//  Supprime le caractère à gauche du curseur (touche Backspace / DEL).
//  Décale les caractères suivants vers la gauche (memmove).
//  Ne fait rien si le curseur est au début (cur == 0).
//
//  Méthode d'affichage "\b \b" (issue de Terminal.cpp — rweather) :
//    1. BS (0x08)    : recule le curseur terminal d'une position
//    2. Réécriture   : réécrit les caractères décalés (si milieu de ligne)
//    3. ESPACE (0x20): efface le dernier caractère résiduel en fin de ligne
//    4. BS × N       : replace le curseur à la bonne position
//
//  Pourquoi cette méthode plutôt que ESC[D ?
//    Universelle PuTTY + MobaXterm + Serial + Bluetooth.
//    ESC[D se comporte différemment selon la configuration du terminal.
//    "\b \b" fonctionne de façon prévisible sur tous les terminaux testés.
//
//  IN   st, out
//  OUT  — : écrit séquence \b + (chars décalés) + espace + \b × N sur out
//  VAR modifiées : st.buf (décalage gauche d'un caractère depuis cur-1)
//                  st.len ← len - 1
//                  st.cur ← cur - 1
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::delLeft(CanalState &st, Print &out) {
    if (!st.cur) return;                    // Curseur au début → rien à faire

    // Décale les caractères après cur-1 vers la gauche (efface cur-1)
    memmove(&st.buf[st.cur - 1], &st.buf[st.cur], st.len - st.cur);
    --st.cur;                               // VAR : cur - 1
    --st.len;                               // VAR : len - 1
    st.buf[st.len] = '\0';                  // Efface le caractère résiduel à la fin

    // ── Mise à jour affichage ─────────────────────────────────────────────────
    out.write((uint8_t)0x08);              // BS : recule le curseur terminal d'une position
    if (st.len > st.cur) {
        // Milieu de ligne : réaffiche les caractères décalés
        out.write((const uint8_t*)&st.buf[st.cur], st.len - st.cur);
    }
    out.write((uint8_t)0x20);             // ESPACE : efface le dernier caractère résiduel
    size_t back = st.len - st.cur + 1;   // Nombre de BS pour repositionner le curseur
    for (size_t i = 0; i < back; ++i) out.write((uint8_t)0x08);  // BS × N
}


// ════════════════════════════════════════════════════════════════════════════
//  18. processCommand()
// ════════════════════════════════════════════════════════════════════════════

// ── processCommand() ──────────────────────────────────────────────────────────
//  Parse st.buf et exécute la commande reconnue.
//
//  Étapes :
//    1. [si CMDLINE_CASE_INSENSITIVE=1]
//       Convertit la partie commande (avant le 1er espace) en minuscules.
//       L'argument n'est PAS touché.
//       Exemple : "SAVECONF 8.8.8.8" → commande="saveconf" arg="8.8.8.8" intact
//
//    2. Sépare commande et argument au premier espace.
//       st.buf → commande seule ('\0'-terminée à la position de l'espace)
//       p      → pointeur sur l'argument (peut être "" si aucun argument)
//
//    3. Recherche dans _commands[] avec cmdStrcmpLower() :
//       Compare la commande saisie (minuscules) avec chaque nom de la table
//       converti à la volée → fonctionne avec SaveConf, LEDon, serveurWEB...
//       La table _commands[] n'est JAMAIS modifiée.
//       Match    → appelle _commands[i].action(arg)
//       Pas de match → affiche "Invalid command" sur les 3 canaux
//
//  IN   st, out
//  OUT  — : appelle l'action trouvée, ou affiche "Invalid command"
//  VAR modifiées : st.buf (partie commande convertie en minuscules si CASE_INSENSITIVE)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::processCommand(CanalState &st, Print &out) {

#if CMDLINE_CASE_INSENSITIVE
    // Conversion en minuscules de la partie commande uniquement (avant le 1er espace)
    // L'argument (après espace) est préservé intact.
    for (char *q = st.buf; *q && *q != ' '; ++q) {
        if (*q >= 'A' && *q <= 'Z') *q += 32;   // 'A'→'a', 'B'→'b'... (+ 32 en ASCII)
    }
#endif

    // Sépare commande et argument au 1er espace
    char *p = st.buf;
    while (*p && *p != ' ') ++p;           // Avance jusqu'à l'espace ou la fin
    if (*p == ' ') *p++ = '\0';            // Remplace l'espace par '\0' (termine la commande)
    while (*p == ' ') ++p;                 // Saute les espaces multiples
    const char *arg = p;                   // arg pointe sur l'argument (peut être "")

    if (_commands && _num) {
        for (size_t i = 0; i < _num; ++i) {
            if (cmdStrcmpLower(st.buf, _commands[i].command) == 0) {
                _commands[i].action(arg);  // Appelle la fonction de commande avec l'argument
                return;
            }
        }
    }
    if (_user_commands && _user_num) {
        for (size_t i = 0; i < _user_num; ++i) {
            if (cmdStrcmpLower(st.buf, _user_commands[i].command) == 0) {
                _user_commands[i].action(arg);
                return;
            }
        }
    }
    if (_commands && _num) {
        println(F("Invalid command"));     // Commande non reconnue → affichage sur 3 canaux
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  19. print / println / write — sortie simultanée 3 canaux  (v22/v23)
//
//  Canal 1 : _stream     → Serial (USB / PuTTY Serial) ou SerialBT si _stream=SerialBT
//  Canal 2 : _c          → Telnet (WiFiClient ou EthernetClient) si client connecté
//  Canal 3 : _btStream   → Bluetooth SerialBT (via Stream*) si _btActive == true
//
//  Surcharges explicites dans ce fichier (.cpp) :
//    println()                    — saut de ligne seul \r\n
//    print/println(const char*)   — chaîne C RAM (char[], sprintf...) (v23)
//    print/println(F("..."))      — chaîne PROGMEM (__FlashStringHelper)
//    print/println(long, base)    — entier avec base DEC/HEX/OCT/BIN
//    print/println(ulong, base)   — idem unsigned long
//    write(buf, len)              — octets bruts (séquences ANSI, binaire)
//
//  Le template générique dans le .h couvre les autres types : int, float,
//  String, bool... Pour char* le template fonctionnait mais sans surcharge
//  explicite, le compilateur pouvait choisir une conversion imprévue.
//
//  Note v22 : avant v22, ces fonctions écrivaient hardcodé sur `Serial`
//  au lieu de `_stream`. Pour cmdLineBT(SerialBT) : _stream=SerialBT mais
//  la sortie allait quand même sur Serial → app Android ne recevait rien.
//  Correction v22 : Serial.xxx() → _stream.xxx() dans toutes les fonctions.
//
//  Note v23 : ajout surcharges print(const char*) / println(const char*)
//  Cas d'usage typique :
//    char hexByte[3];
//    sprintf(hexByte, "%02X", conf_TMP.mac[i]);   // formate en hex
//    cmdLine.print(hexByte);                       // surcharge char* → 3 canaux
// ════════════════════════════════════════════════════════════════════════════


// ── println() ─────────────────────────────────────────────────────────────────
//  Saut de ligne \r\n sur les 3 canaux simultanément.
//
//  IN  —
//  OUT — : \r\n sur _stream + _c (si connecté) + _btStream (si activé)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::println() {
    _stream.println();                                          // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->println();               // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->println();          // Canal 3 : BT si activé  (v22)
#endif
}


// ── print(const char *str) / println(const char *str) ─────────────────────────
//  Surcharges explicites pour chaîne C en RAM : char[], résultat de sprintf...
//  Sans ces surcharges, le compilateur pouvait résoudre char* via le template
//  générique T=char* ce qui fonctionnait, mais ces surcharges rendent le choix
//  explicite et documenté.
//
//  Cas d'usage typique (formatage hexadécimal adresse MAC) :
//    char hexByte[3];
//    sprintf(hexByte, "%02X", conf_TMP.mac[i]);   // ex : "A3"
//    cmdLine.print(hexByte);                       // → Serial + Telnet + BT
//
//  IN  str : pointeur sur chaîne C '\0'-terminée en RAM
//  OUT —   : affiche str sur _stream + _c (si connecté) + _btStream (si activé)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::print(const char *str) {
    _stream.print(str);                                         // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->print(str);              // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->print(str);         // Canal 3 : BT si activé  (v23)
#endif
}

void CmdLine::println(const char *str) {
    _stream.println(str);                                       // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->println(str);            // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->println(str);       // Canal 3 : BT si activé  (v23)
#endif
}


// ── print(F("...")) / println(F("...")) ───────────────────────────────────────
//  Chaînes PROGMEM F("...") sur les 3 canaux.
//
//  IN  t : pointeur __FlashStringHelper (résultat de F("texte"))
//  OUT — : affiche t sur _stream + _c (si connecté) + _btStream (si activé)
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::print(const __FlashStringHelper *t) {
    _stream.print(t);                                          // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->print(t);               // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->print(t);          // Canal 3 : BT si activé  (v22)
#endif
}

void CmdLine::println(const __FlashStringHelper *t) {
    _stream.println(t);                                        // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->println(t);             // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->println(t);        // Canal 3 : BT si activé  (v22)
#endif
}


// ── print(long n, int base) / println(long n, int base) ───────────────────────
//  Affichage d'un entier avec base numérique, identique à Serial.print(n, base).
//  Deux surcharges : long et unsigned long.
//  Les types int, byte, uint8_t... se convertissent implicitement.
//
//  IN  n    : valeur entière à afficher
//  IN  base : DEC (10), HEX (16), OCT (8), BIN (2)
//  OUT —    : affiche sur _stream + _c (si connecté) + _btStream (si activé)
//
//  Exemples : cmdLine.print(42, DEC)     → "42"
//             cmdLine.print(255, HEX)    → "FF"
//             cmdLine.println(0x1A, HEX) → "1A\r\n"
// ─────────────────────────────────────────────────────────────────────────────
void CmdLine::print(long n, int base) {
    _stream.print(n, base);                                    // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->print(n, base);         // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->print(n, base);    // Canal 3 : BT si activé  (v22)
#endif
}

void CmdLine::println(long n, int base) {
    _stream.println(n, base);                                  // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->println(n, base);       // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->println(n, base);  // Canal 3 : BT si activé  (v22)
#endif
}

void CmdLine::print(unsigned long n, int base) {
    _stream.print(n, base);                                    // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->print(n, base);         // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->print(n, base);    // Canal 3 : BT si activé  (v22)
#endif
}

void CmdLine::println(unsigned long n, int base) {
    _stream.println(n, base);                                  // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->println(n, base);       // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->println(n, base);  // Canal 3 : BT si activé  (v22)
#endif
}


// ── write(buf, len) ───────────────────────────────────────────────────────────
//  Écriture binaire brute simultanée sur les 3 canaux.
//  Utile pour envoyer des séquences ANSI, des buffers binaires, ou tout
//  contenu préparé dans un tableau d'octets.
//
//  IN  buf  : pointeur sur le tableau d'octets à envoyer
//  IN  len  : nombre d'octets à envoyer
//  OUT size_t : nombre d'octets écrits sur _stream (valeur de référence)
//
//  Exemple — texte en rouge ANSI :
//    uint8_t rouge[] = { 0x1B, '[', '3', '1', 'm' };   // ESC[31m
//    cmdLine.write(rouge, sizeof(rouge));
//    cmdLine.println(F("ERREUR"));
//    uint8_t reset[] = { 0x1B, '[', '0', 'm' };         // ESC[0m
//    cmdLine.write(reset, sizeof(reset));
// ─────────────────────────────────────────────────────────────────────────────
size_t CmdLine::write(const uint8_t *buf, size_t len) {
    size_t n = _stream.write(buf, len);                        // Canal 1 : Serial (ou SerialBT)
    if (_c && _clientConnected()) _c->write(buf, len);        // Canal 2 : Telnet si connecté
#ifdef CMDLINE_HAS_BLUETOOTH
    if (_btActive && _btStream) _btStream->write(buf, len);   // Canal 3 : BT si activé  (v22)
#endif
    return n;                                                   // Retourne nb octets écrits sur _stream
}

