#!/usr/bin/env python3
"""medulla_agents_V25 - Audit et correction des agents Medulla, par SSH et par XMPP.

ORGANISATION DU FICHIER
  EN-TETE   configuration : toutes les variables reglables (delais, seuils, chemins...)
  PARTIE 1  fonctions :
              1.1 tests ajoutables (TESTS_POSTE, TESTS_SERVEUR) : ajouter ici ses tests
              1.2 langues    1.3 bibliotheque interne (transport, audit, corrections)
  PARTIE 2  workflow : options, menus et deroule du releve (main)
  FIN       catalogue des traductions, lancement

V21-V25 : controles issus de la FAQ Medulla et diagnostic final (problemes, postes
concernes, action recommandee ; plan_de_remediation.csv). Seule correction ajoutee :
demarrage des services medullanetnotify et sshdaemon arretes. Tests serveur en lecture
seule. V24 : reconfiguration de l'agent (need_reconf=1), une machine ON a la fois.
V25 : machines qui fonctionnent protegees (--full), diagnostic reseau des injoignables,
droits administrateur, reconfiguration des machines a MAC blacklistee.

Fusion de diagnostic_machines_v16 (SSH, machines OFF) et diagnostic_machines_XMPPV6
(XMPP, machines ON) : un seul outil, un seul rapport. Le moyen d'acces est choisi
poste par poste :
  - machine OFF dans Medulla (agent deconnecte)  -> SSH (pulseuser, par cle) sur l'IP ;
  - machine ON (agent connecte a ejabberd)       -> XMPP (plugin shellcommand de l'agent) ;
  - machine ON dont l'agent ne repond pas en XMPP -> repli SSH sur l'IP de sa session
    (ou de sa fiche Medulla).
Une fiche OFF dont le poste repond en SSH sous le nom d'une machine ON (fiche obsolete)
n'est pas auditee en SSH : la machine ON est auditee par XMPP dans la foulee.

Audit, corrections et securites sont les memes quel que soit le transport (meme code
d'audit, meme code de reparer-agent-medulla.ps1, meme mise a niveau par delta des
fichiers de l'agent, meme creation de Medulla\\var\\log) :
  - l'audit ne modifie rien ;
  - toute action demande une confirmation explicite (OUI / YES / SI / JA) ;
  - le service est arrete, et l'arret controle, avant toute modification ; sauvegardes
    et retour arriere ; jamais de retrogradation de l'agent ; aucun fichier supprime ;
  - juste avant chaque action, le poste est reverifie (meme nom ; en SSH, toujours OFF
    dans Medulla, sauf repli : l'agent doit toujours etre muet en XMPP) ;
  - les corrections ne sont jamais rejouees automatiquement ;
  - reconfiguration de l'agent (need_reconf=1 dans xmppmaster.machines) : une seule
    machine, ON et connectee en XMPP (le serveur l'ignore sur une machine OFF), motif
    restant apres l'audit (RECONF_MOTIFS) ou --reconfigurer, confirmation separee ;
  - reset de l'agent (autre ecriture en base, serveur >= 5.6.4) : une seule machine,
    confirmation separee, et seulement en dernier recours : le poste etait joignable en
    SSH et son serveur XMPP joignable depuis le poste (les 2 protocoles etaient up),
    une action SSH a ete faite, et l'agent ne fonctionne toujours pas (OFF dans
    Medulla et aucune session XMPP).

Usage :
  sudo ./medulla_agents_V25.py [options] [dossier] [parallelisme]

Exemples :
  sudo ./medulla_agents_V25.py                              menus (langue, perimetre, mode)
  sudo ./medulla_agents_V25.py --dry-run                    audit des machines OFF (+ ON liees)
  sudo ./medulla_agents_V25.py --perimetre tout --dry-run   audit de toutes les machines
  sudo ./medulla_agents_V25.py --cible PC1068,10.0.0.12     machines designees (nom ou IP)
  sudo ./medulla_agents_V25.py --cible PC1068 --oui         audit + actions sur une machine
  sudo ./medulla_agents_V25.py --sans-inventaire --dry-run  machines ON sans UUID d'inventaire

Sorties dans releve_DATE : resultats.csv, statistiques.csv, fiches_off_obsoletes.csv,
journaux/<poste>.log et, avec --verbose, trace.log. CSV en ";" et UTF-8 avec BOM.
"""

import argparse
import configparser
import textwrap
import urllib.error
import urllib.request
import asyncio
import concurrent.futures
import inspect
import ssl
import zlib
import base64
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
import csv
from datetime import datetime
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import subprocess
import sys
import threading
import tempfile
import time
import uuid


# ===========================================================================================
# EN-TETE : CONFIGURATION (variables modifiables)
# ===========================================================================================
#
# Toutes les valeurs reglables du script sont ici. Les options de la ligne de commande
# (--timeout-ssh, --attente...) remplacent certaines d'entre elles au lancement. Les noms
# entre @@...@@ dans le code execute sur les postes sont remplaces par ces valeurs.

# --- Serveur Medulla : fichiers, journaux et outils ------------------------------------------
SERVER_VERSION_FILE = os.environ.get("MEDULLA_VERSION_FILE", "/var/lib/mmc/version")
AGENT_REF_DIR = os.environ.get(
    "MEDULLA_AGENT_REF",
    os.path.dirname(os.environ["MEDULLA_LIB_REF"].rstrip("/")) if os.environ.get("MEDULLA_LIB_REF")
    else "/var/lib/pulse2/xmpp_baseremoteagent")
EJABBERDCTL = os.environ.get("EJABBERDCTL", "ejabberdctl")
XMPP_SERVER = ("127.0.0.1", 5222)
REGISTRY_INI = "/etc/pulse-xmpp-agent-substitute/registeryagent.ini"
INVENTORY_INI = "/etc/pulse-xmpp-agent-substitute/agent_master_substitute_inv.ini"
GENERATION_OPTIONS = "/var/lib/pulse2/clients/.generation_options"
REGISTRATION_LOG = "/var/log/mmc/master-reg.log"
# Substituts Medulla attendus dans ejabberd : compte XMPP -> service
# pulse-xmpp-master-substitute-<service>
SUBSTITUTES = {"master_subs": "subscription", "master_reg": "registration",
               "master_asse": "assessor", "master_inv": "inventory",
               "master_reconf": "reconfigurator"}

# --- Postes Windows : emplacements -----------------------------------------------------------
AGENT_PYTHON = os.environ.get("MEDULLA_AGENT_PYTHON", r"C:\Program Files\Python3\python.exe")
WORK_DIR = os.environ.get("MEDULLA_WORK_DIR", r"C:\Windows\Temp\mdx")
REMOTE_TEMP = os.environ.get("MEDULLA_REMOTE_TEMP", "C:/Windows/Temp")

# --- Delais (secondes) -----------------------------------------------------------------------
PING_WAIT = 2
DEFAULT_SSH_CONNECT = 15     # secondes pour etablir la connexion SSH (--timeout-ssh)
DEFAULT_DIAG_TIMEOUT = 45   # un audit normal prend moins de 10 s (--timeout-audit)
REPAIR_TIMEOUT = 300
RESTART_TIMEOUT = 150
SERVICE_CHECK_TIMEOUT = 180
LOGDIR_TIMEOUT = 180  # arret (45 s max) + creation + demarrage (45 s max)
CLEANUP_TIMEOUT = 60
LIB_FIX_IDLE = 120       # ... mais abandon si le poste ne donne plus signe de vie (--inactivite-copie)
DEFAULT_WAIT = 120
RETRY_PAUSE = 5        # secondes d'attente avant un nouvel essai
DNS_WAIT = 5
INVENTORY_WAIT = 300
INVENTORY_CHECK_EVERY = 15
JOB_MARGIN = 300        # secondes accordees en plus du delai d'une action (redemarrage, retour)
POLL_EVERY = 5
SERVICES_TIMEOUT = 120      # demarrage de medullanetnotify / sshdaemon

# --- Nouveaux essais et parallelisme ---------------------------------------------------------
RETRIES = 1            # nouveaux essais apres un timeout du test SSH ou de l'audit (--essais)
REPAIR_WORKERS_MAX = 10

# --- Transport XMPP (depot des fichiers par blocs) -------------------------------------------
CHUNK = 7700            # caracteres base64 par bloc (ligne de commande cmd.exe : 8191 max)
INLINE_MAX = 6500       # code compresse transmis dans la commande elle-meme (sans depot)
WINDOW = 24             # blocs envoyes en parallele vers un meme poste

# --- Seuils des tests ------------------------------------------------------------------------
LOW_DISK_GB = 1.0
GLPI_FULL_MIN = (1, 8)  # option --full de GLPI-Agent, utilisee par l'agent Medulla
RESET_MIN_SERVER = (5, 6, 4)
RECONF_LOOP = 10              # reconfigurations dans le journal : boucle
INVENTORY_MAX_AGE_H = 168     # inventaire genere il y a plus de 7 jours
PAEXEC_LEFTOVERS = 3          # copies PAExec restees dans C:\Windows

# --- MAC partagees connues (cartes virtuelles : a blacklister) -------------------------------
SHARED_MACS = {"00:09:0f:aa:00:01": "Fortinet", "00:09:0f:fe:00:01": "Fortinet",
               "20:41:53:59:4e:ff": "RAS / VPN Windows", "00:00:00:00:00:00": "MAC nulle"}

# --- Reset de l'agent ------------------------------------------------------------------------
RESET_REASON = ("Agent OFF et sans session XMPP malgre les actions SSH (medulla_agents_V25)")

# --- Flux testes depuis les postes (ports TCP ouverts / UDP envoyes) ---------------------------
PORTS_TCP_SERVEUR = [22, 2002, 5222, 22067, 8443, 111, 2049, 55415, 80, 443, 9990, 9999]
PORTS_TCP_RELAIS = [22, 2002, 5222, 9990, 111, 2049, 80, 443]
PORTS_UDP = [67, 69, 111, 2049]

# --- Enquete cote serveur sur les postes injoignables (ni SSH ni XMPP) -----------------------
# Journaux du serveur Medulla lus (la fin de chaque fichier, une seule fois pour tous les postes)
SERVER_LOGS = {
    "ejabberd": "/var/log/ejabberd/ejabberd.log",
    "enregistrement": "/var/log/mmc/master-reg.log",
    "configuration": "/var/log/mmc/master-asse.log",
    "souscription": "/var/log/mmc/master-subs.log",
    "reconfiguration": "/var/log/mmc/master-reconf.log",
    "inventaire": "/var/log/mmc/master-inv.log",
    "relais": "/var/log/pulse/xmpp-agent-relay.log",
}
LOG_TAIL_MB = 20              # Mo lus a la fin de chaque journal
ENQUETE_MAX = 300             # postes injoignables examines au plus
ABSENCE_LONGUE_J = 30         # absent de Medulla depuis plus de N jours : poste retire ?
TRACES_PAR_JOURNAL = 3        # lignes gardees par poste et par journal

# --- Machines qui fonctionnent ---------------------------------------------------------------
# Machine ON dans Medulla dont l'agent repond en XMPP : audit seul, jamais de correction, de
# relance ni de reconfiguration automatique (les problemes sont seulement signales).
# True (ou --full) : ces machines sont traitees comme les autres. Sans --full, le script
# demande en mode interactif s'il faut traiter celles qui ont un defaut.
MODIFIER_MACHINES_FONCTIONNELLES = False

# --- Reconfiguration de l'agent (need_reconf=1 : une seule machine, ON et connectee) -------
# Problemes restant apres l'audit (et les corrections) qui font proposer la reconfiguration :
# libelle -> expression cherchee dans les problemes et les constats serveur du poste.
# --reconfigurer la propose meme sans motif.
RECONF_MOTIFS = {
    "Machine ON sans inventaire GLPI": r"sans inventaire GLPI",
    "Relais injoignable": r"(?i)relais injoignable|ABORT RELAY",
    "agentconf.ini a reprendre": r"agentconf\.ini",
    "Enregistrement incomplet": r"Enregistrement incomplet",
}
RECONF_ATTENTE = 300          # attente maximale du retour de l'agent apres la demande (s)
RECONF_SONDAGE = 10           # intervalle de lecture de need_reconf pendant l'attente (s)

# --- Produits de securite reconnus sur les postes (dossier dans Program Files) ----------------
EDR_FOLDERS = ["SentinelOne", "CrowdStrike", "Cybereason", "TEHTRIS", "WithSecure", "F-Secure",
               "Trend Micro", "Bitdefender", "Sophos", "ESET", "HarfangLab", "Kaspersky Lab"]


# ===========================================================================================
# PARTIE 1 - FONCTIONS
# ===========================================================================================
#
# 1.1  TESTS AJOUTABLES  (c'est ici que l'on ajoute ses propres tests)
# 1.2  Langues (affichage fr / en / es / de)
# 1.3  Bibliotheque : transport SSH et XMPP, audit, corrections, rapports
#
# -------------------------------------------------------------------------------------------
# 1.1  TESTS AJOUTABLES
# -------------------------------------------------------------------------------------------
#
# Deux listes : TESTS_POSTE (sur chaque poste audite) et TESTS_SERVEUR (une fois, sur le
# serveur Medulla). Ajouter un test = ajouter un element a la liste ; rien d'autre a modifier :
# le test apparait dans resultats.csv, le recapitulatif par famille, la liste des problemes du
# poste et le diagnostic final avec son action recommandee.
#
# TESTS_POSTE : un dictionnaire par test.
#   "nom"         identifiant unique en minuscules ; colonne test_<nom> de resultats.csv
#   "famille"     famille du recapitulatif (Agent, Configuration, Journal, Inventaire...)
#   "libelle"     ce qui est attendu, formule quand tout va bien ("Service X demarre")
#   "poste"       (optionnel) code Python execute SUR LE POSTE pendant l'audit, en lecture
#                 seule : une fonction collecte() sans argument qui renvoie une valeur simple
#                 (texte, nombre, liste, dictionnaire). Disponibles : os, re, time, json,
#                 subprocess, configparser, platform ; CONF (dossier etc de Medulla), ROOT
#                 (dossier de l'agent Python), BASE (C:\Program Files\Medulla) ;
#                 read(chemin, taille_max) -> octets ou None ; service_info(nom) ->
#                 {"etat": "RUNNING"|"STOPPED"|"ABSENT"..., "demarrage": "AUTO"|"DESACTIVE"...} ;
#                 journal_agent() -> fin de xmpp-agent-machine.log (texte) ;
#                 agentconf(section, cle) -> valeur de agentconf.ini (jamais de secret).
#                 Les valeurs de l'en-tete s'ecrivent @@NOM@@ (ex. @@EDR_FOLDERS@@).
#                 Une erreur dans collecte() n'arrete pas l'audit : la valeur vaut None.
#   "verifie"     fonction executee SUR LE SERVEUR : verifie(valeur, ligne) renvoie None si
#                 tout va bien, sinon le texte du probleme (ou (texte, False) : probleme non
#                 corrigeable cette fois). valeur = retour de collecte() (None sans code
#                 poste) ; ligne = les colonnes de resultats.csv du poste (ligne["os_poste"]...).
#   "diagnostic"  nom du probleme dans le diagnostic final
#   "action"      action recommandee affichee dans le diagnostic final
#   "correction"  (optionnel) correction automatique existante a declencher : "SERVICES"
#                 (demarrer medullanetnotify et sshdaemon). Sans cette cle : constat seulement.
#   "bloquant"    (optionnel) True : le poste n'est plus "reparable entierement" (OUI -> PARTIEL)
#   "recapitulatif" (optionnel) False : test absent du recapitulatif par famille (collecte seule)
#
# TESTS_SERVEUR : des fonctions test_xxx(ctx), lecture seule, avec :
#   ctx.lignes                       lignes de resultats.csv (une par poste ; ligne["_tests"] =
#                                    valeurs brutes des tests poste)
#   ctx.injoignables                 lignes des postes joints ni en SSH ni en XMPP
#   ctx.signale_ligne(ligne, texte, diagnostic, action)  constat sur une ligne precise
#   ctx.sql(requete)                 lignes JSON (dict) d'une requete sur xmppmaster
#   ctx.sql_brut(requete)            sortie texte de mysql
#   ctx.ini(fichier, section, cle)   valeur d'un .ini Medulla (surchargee par le .ini.local)
#   ctx.constat(gravite, texte, action)               constat global : CRITIQUE, ATTENTION, INFO
#   ctx.signale(nom_poste, texte, diagnostic, action) constat sur un poste du releve


def verifie_service(name):
    """Fabrique le controle d'un service Windows (valeur = service_info(nom))."""
    def verifie(valeur, ligne):
        info = valeur if isinstance(valeur, dict) else {}
        state = info.get("etat", "")
        if state in ("", "RUNNING", "INCONNU", "ACCES_REFUSE"):
            return None
        if state == "ABSENT":
            return f"Service {name} absent", False
        if info.get("demarrage") == "DESACTIVE":
            return f"Service {name} desactive", False
        return f"Service {name} {state}"
    return verifie


def verifie_reconfigurations(valeur, ligne):
    if isinstance(valeur, int) and valeur >= RECONF_LOOP:
        return f"Agent en boucle de reconfiguration ({valeur} dans le journal)"
    return None


def verifie_relais(valeur, ligne):
    if isinstance(valeur, int) and valeur > 0:
        return f"Relais injoignable signale par l'agent ({valeur} ABORT RELAY)"
    return None


def verifie_inventaire(valeur, ligne):
    if ligne.get("inventaire_a_jour") != "OUI" or not isinstance(valeur, dict):
        return None              # GLPI-Agent absent ou trop ancien : deja signale
    if valeur.get("fichier") == "ABSENT":
        return "Aucun inventaire genere sur le poste (Medulla\\tmp\\inventory.txt)"
    age = valeur.get("age_h")
    if isinstance(age, (int, float)) and age > INVENTORY_MAX_AGE_H:
        return f"Dernier inventaire genere il y a {int(age // 24)} jour(s)"
    return None


def verifie_droits_admin(valeur, ligne):
    """Compte de la session SSH : administrateur du poste ? (IsUserAnAdmin + whoami /groups)"""
    if not isinstance(valeur, dict) or valeur.get("admin") is not False:
        return None
    account = valeur.get("compte") or "pulseuser"
    if valeur.get("membre") and valeur.get("refus"):
        return (f"Compte {account} membre du groupe Administrateurs mais session SSH sans droits "
                f"eleves (UAC : groupe en refus uniquement) : aucune correction ni relance par SSH",
                False)
    if valeur.get("membre") is False:
        return (f"Compte {account} absent du groupe Administrateurs local du poste (whoami "
                f"/groups) : aucune correction ni relance par SSH", False)
    return (f"Compte {account} sans droits administrateur sur le poste (IsUserAnAdmin) : aucune "
            f"correction ni relance par SSH", False)


def verifie_systeme(valeur, ligne):
    system = str(ligne.get("os_poste") or "")
    if re.match(r"(?i)windows-(xp|vista|7|8|8\.1|2003|2008)\b", system):
        return f"Systeme non supporte par l'agent ({system})"
    return None


def verifie_paexec(valeur, ligne):
    info = valeur if isinstance(valeur, dict) else {}
    count = info.get("paexec_restes")
    if isinstance(count, int) and count >= PAEXEC_LEFTOVERS:
        edr = f" : {', '.join(info.get('produits') or [])}" if info.get("produits") else ""
        return f"{count} copies PAExec restees dans C:\\Windows (EDR a verifier{edr})"
    return None


TESTS_POSTE = [
    {
        "nom": "netnotify",
        "famille": "Agent",
        "libelle": "Service medullanetnotify demarre",
        "poste": '''
def collecte():
    return service_info("medullanetnotify")
''',
        "verifie": verifie_service("medullanetnotify"),
        "diagnostic": "Service medullanetnotify arrete, absent ou desactive",
        "action": "Arrete : demarre par le script. Absent ou desactive : reinstaller l'agent "
                  "ou sc config medullanetnotify start= auto",
        "correction": "SERVICES",
    },
    {
        "nom": "sshdaemon",
        "famille": "Agent",
        "libelle": "Service sshdaemon (OpenSSH Medulla) demarre",
        "poste": '''
def collecte():
    return service_info("sshdaemon")
''',
        "verifie": verifie_service("sshdaemon"),
        "diagnostic": "Service sshdaemon arrete, absent ou desactive",
        "action": "Arrete : demarre par le script. Absent ou desactive : reinstaller l'agent "
                  "ou sc config sshdaemon start= auto",
        "correction": "SERVICES",
    },
    {
        "nom": "reconfigurations",
        "famille": "Journal",
        "libelle": "Pas de boucle de reconfiguration de l'agent",
        "poste": '''
def collecte():
    return journal_agent().count("We start a reconfiguration of the medulla agent")
''',
        "verifie": verifie_reconfigurations,
        "diagnostic": "Agent en boucle de reconfiguration",
        "action": "ejabberdctl unregister <compte> pulse, puis redemarrer l'agent "
                  "(FAQ : hors ligne, point 5)",
        "bloquant": True,
    },
    {
        "nom": "relais",
        "famille": "Journal",
        "libelle": "Relais joignable (aucun ABORT RELAY dans le journal)",
        "poste": '''
def collecte():
    text = journal_agent()
    return text.count("ABORT RELAY DOWN") + text.count("ABORT INFO RELAY MISSING")
''',
        "verifie": verifie_relais,
        "diagnostic": "Relais injoignable signale par l'agent",
        "action": "Verifier le relais du poste (groupdeploy) et son service "
                  "(FAQ : hors ligne, point 10)",
    },
    {
        "nom": "niveau_journal",
        "famille": "Configuration",
        "libelle": "Journal de l'agent hors DEBUG (log_level)",
        "poste": '''
def collecte():
    return agentconf("global", "log_level").upper()
''',
        "verifie": lambda valeur, ligne: ("Journal de l'agent en DEBUG (log_level)"
                                          if valeur == "DEBUG" else None),
        "diagnostic": "Journal de l'agent en DEBUG",
        "action": "Remettre log_level = INFO et log_level_slixmpp = FATAL dans agentconf.ini",
    },
    {
        "nom": "inventaire",
        "famille": "Inventaire",
        "libelle": "Inventaire genere recemment sur le poste",
        "poste": '''
def collecte():
    path = os.path.join(BASE, "tmp", "inventory.txt")
    for name in (path, path + ".back"):
        if os.path.isfile(name):
            return {"fichier": os.path.basename(name),
                    "age_h": round((time.time() - os.path.getmtime(name)) / 3600, 1)}
    return {"fichier": "ABSENT"}
''',
        "verifie": verifie_inventaire,
        "diagnostic": "Inventaire non genere sur le poste",
        "action": "Demande d'inventaire par le script (actions, agent joignable en XMPP) ; "
                  "sinon glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt "
                  "(FAQ : Verifier que l'inventaire remonte)",
    },
    {
        "nom": "droits_admin",
        "famille": "Acces",
        "libelle": "Compte de connexion administrateur du poste",
        "poste": '''
def collecte():
    try:
        import ctypes
        admin = bool(ctypes.windll.shell32.IsUserAnAdmin())
    except (ImportError, AttributeError, OSError):
        return None
    member, deny_only = None, False
    try:
        out = subprocess.run(["whoami", "/groups", "/fo", "csv", "/nh"], stdout=subprocess.PIPE,
                             stderr=subprocess.DEVNULL, timeout=20).stdout.decode("cp850", "replace")
        line = next((item for item in out.splitlines() if "S-1-5-32-544" in item), "")
        member = bool(line)
        deny_only = bool(re.search(r"(?i)deny only|refuser", line))
    except Exception:
        pass
    return {"admin": admin, "compte": os.environ.get("USERNAME", ""), "membre": member,
            "refus": deny_only}
''',
        "verifie": verifie_droits_admin,
        "diagnostic": "Compte de connexion sans droits administrateur",
        "action": "Ajouter le compte au groupe Administrateurs local du poste (PowerShell : "
                  "Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) et verifier la GPO "
                  "ou LAPS qui le retire ; s'il est deja membre (UAC) : "
                  "LocalAccountTokenFilterPolicy = 1",
        "bloquant": True,
    },
    {
        "nom": "systeme",
        "famille": "Systeme",
        "libelle": "Systeme supporte par l'agent",
        "verifie": verifie_systeme,
        "diagnostic": "Systeme non supporte par l'agent",
        "action": "Postes supportes : Windows 10/11, Windows Server 2012 a 2025",
    },
    {
        "nom": "paexec",
        "famille": "Securite",
        "libelle": "Pas de copies PAExec bloquees (EDR)",
        "poste": '''
def collecte():
    found = []
    for root in (os.environ.get("ProgramFiles", r"C:\\Program Files"),
                 os.environ.get("ProgramFiles(x86)", r"C:\\Program Files (x86)")):
        try:
            names = {name.casefold() for name in os.listdir(root)}
        except OSError:
            continue
        found += [folder for folder in @@EDR_FOLDERS@@
                  if folder.casefold() in names and folder not in found]
    windir = os.environ.get("WINDIR", r"C:\\Windows")
    try:
        left = sum(1 for name in os.listdir(windir)
                   if name.lower().startswith("paexec-") and name.lower().endswith(".exe"))
    except OSError:
        left = None
    return {"produits": found, "paexec_restes": left}
''',
        "verifie": verifie_paexec,
        "diagnostic": "PAExec bloque (EDR probable)",
        "action": "Declarer les exclusions EDR : paexec.exe, C:\\Program Files\\Medulla\\, "
                  "C:\\Windows\\PAExec-*.exe (FAQ - EDR)",
    },
    {
        "nom": "macs",
        "famille": "Reseau",
        "libelle": "Adresses MAC du poste (relevees pour le controle serveur)",
        "poste": '''
def collecte():
    if not sys.platform.startswith("win"):
        return []
    out = subprocess.run(["getmac", "/fo", "csv", "/nh"], stdout=subprocess.PIPE,
                         stderr=subprocess.DEVNULL, timeout=20).stdout.decode("cp850", "replace")
    return sorted({mac.replace("-", ":").lower()
                   for mac in re.findall(r"[0-9A-Fa-f]{2}(?:-[0-9A-Fa-f]{2}){5}", out)})
''',
        "verifie": lambda valeur, ligne: None,
        "diagnostic": "Adresses MAC du poste",
        "action": "",
        "recapitulatif": False,
    },
]


def test_blacklist_mac(ctx):
    """La blacklist des MAC des substituts doit contenir au moins la MAC nulle."""
    if "00:00:00:00:00:00" not in mac_blacklist(ctx):
        ctx.constat("ATTENTION", "blacklisted_mac_addresses sans 00:00:00:00:00:00 "
                    "(registeryagent.ini.local)",
                    "Ajouter la blacklist des MAC puis restart-pulse-services allsubs "
                    "(FAQ : Doublons Adresse Mac)")


def test_mac_en_double(ctx):
    """MAC partagees par plusieurs machines dans xmppmaster.network, hors blacklist."""
    blacklist = mac_blacklist(ctx)
    macs, where, listed = {}, {}, {}
    for item in ctx.sql("SELECT JSON_OBJECT('mac', n.mac, 'hostname', m.hostname, 'ip', "
                        "n.ipaddress, 'enabled', m.enabled) FROM network n "
                        "JOIN machines m ON m.id = n.machines_id WHERE n.mac IN (SELECT mac FROM "
                        "network GROUP BY mac HAVING COUNT(DISTINCT machines_id) > 1);"):
        mac = normalize_mac(item.get("mac"))
        if mac and mac in blacklist:
            # MAC deja blacklistee mais encore enregistree sur plusieurs machines : leur
            # enregistrement est anterieur a la blacklist -> reconfiguration a faire.
            host = str(item.get("hostname") or "")
            if host:
                listed.setdefault(host, {"macs": set(), "on": False})
                listed[host]["macs"].add(mac)
                listed[host]["on"] |= item.get("enabled") == 1
        elif mac:
            host = str(item.get("hostname") or "")
            macs.setdefault(mac, set()).add(host)
            entry = where.setdefault((mac, host), {"ips": set(), "on": False})
            if item.get("ip"):
                entry["ips"].add(str(item["ip"]))
            entry["on"] |= item.get("enabled") == 1
    if macs:
        lines = []
        for mac in sorted(macs, key=lambda mac: (-len(macs[mac]), mac)):
            for host in sorted(macs[mac], key=str.casefold):
                entry = where[(mac, host)]
                lines.append({"mac": mac, "carte": SHARED_MACS.get(mac, ""),
                              "nb_machines": len(macs[mac]), "machine": host,
                              "etat": "ON" if entry["on"] else "OFF",
                              "ips": ", ".join(sorted(entry["ips"], key=ip_sort_key))})
        ctx.constat("ATTENTION", f"{len(macs)} adresse(s) MAC partagee(s) par plusieurs "
                    f"machines dans Medulla, non blacklistee(s)",
                    "Blacklister ces MAC (registeryagent.ini.local), restart-pulse-services "
                    "allsubs, puis Reconfigure machine agent",
                    (["mac", "carte", "nb_machines", "machine", "etat", "ips"],
                     ["Adresse MAC", "Carte connue", "Machines", "Machine", "Etat", "IP"],
                     lines, "mac_partagees.csv"))
    if listed:
        BLACKLISTED_SHARED.clear()
        BLACKLISTED_SHARED.update(listed)
        lines = [{"machine": host, "etat": "ON" if info["on"] else "OFF",
                  "macs": ", ".join(sorted(info["macs"])),
                  "carte": ", ".join(sorted({SHARED_MACS.get(mac, "") for mac in info["macs"]} - {""}))}
                 for host, info in sorted(listed.items(), key=lambda item: item[0].casefold())]
        ctx.constat("INFO", f"{len(listed)} machine(s) enregistree(s) avec une MAC partagee deja "
                    f"blacklistee : enregistrement anterieur a la blacklist",
                    "Reconfiguration de ces machines par le script (mode actions), apres "
                    "restart-pulse-services allsubs",
                    (["machine", "etat", "macs", "carte"],
                     ["Machine", "Etat", "Adresse MAC", "Carte connue"], lines,
                     "mac_blacklistees_a_reconfigurer.csv"))
    for mac, hosts in macs.items():
        for host in hosts:
            ctx.signale(host, f"Adresse MAC {mac} partagee avec {len(hosts) - 1} autre(s) "
                        "machine(s)", "Adresse MAC en double ou virtuelle",
                        "Blacklister la MAC (registeryagent.ini.local), restart-pulse-services "
                        "allsubs, Reconfigure machine agent")


def test_cartes_virtuelles(ctx):
    """Cartes Fortinet / VPN connues (MAC partagees) sur les postes, MAC non blacklistee."""
    blacklist = mac_blacklist(ctx)
    for row in ctx.lignes:
        for mac in (row.get("_tests") or {}).get("macs") or []:
            if mac in SHARED_MACS and mac not in blacklist:
                ctx.signale(row["hostname_ssh"], f"Carte {SHARED_MACS[mac]} ({mac}) presente, "
                            "MAC non blacklistee", "Adresse MAC en double ou virtuelle",
                            "Blacklister la MAC (registeryagent.ini.local), "
                            "restart-pulse-services allsubs, Reconfigure machine agent")


def test_entrees_orphelines(ctx):
    count = int((ctx.sql_brut("SELECT COUNT(*) FROM network WHERE machines_id NOT IN "
                              "(SELECT id FROM machines);").strip() or "0").splitlines()[-1])
    if count:
        ctx.constat("ATTENTION", f"{count} entree(s) orpheline(s) dans xmppmaster.network",
                    "Verifier puis supprimer apres sauvegarde (FAQ : Pourquoi une machine ne "
                    "s'enregistre-t-elle pas ? point 5)")


def test_etat_machines(ctx):
    """Machines ON sans inventaire GLPI, reconfiguration ou reset en attente."""
    names = sorted({row["hostname_ssh"] for row in ctx.lignes if row.get("hostname_ssh")})
    for start in range(0, len(names), 400):
        chunk = ", ".join(sql_text(name) for name in names[start:start + 400])
        try:
            items = ctx.sql("SELECT JSON_OBJECT('hostname', hostname, 'enabled', enabled, "
                            "'uuid', uuid_inventorymachine, 'reconf', need_reconf) "
                            f"FROM machines WHERE hostname IN ({chunk});")
        except subprocess.CalledProcessError:
            items = ctx.sql("SELECT JSON_OBJECT('hostname', hostname, 'enabled', enabled, "
                            "'uuid', uuid_inventorymachine) "
                            f"FROM machines WHERE hostname IN ({chunk});")
        for item in items:
            host = str(item.get("hostname") or "")
            if "uuid" in item and item.get("enabled") == 1 and not item.get("uuid"):
                audited = [row for form in name_forms(host) for row in ctx._by_name.get(form, [])]
                glpi_ko = any(row.get("programme_inventaire") == "ABSENT"
                              or row.get("inventaire_a_jour") == "NON" for row in audited)
                if not glpi_ko:
                    AUTO_RESET.setdefault(host, "machine ON sans inventaire GLPI rattache")
                ctx.signale(host, "Machine ON sans inventaire GLPI rattache "
                            "(uuid_inventorymachine vide)", "Machine ON sans inventaire GLPI rattache",
                            "Mettre GLPI-Agent a jour (>= 1.8) d'abord" if glpi_ko else
                            "Reset de l'agent par le script (mode actions)")
            if str(item.get("reconf")) == "1":
                ctx.signale(host, "Reconfiguration demandee en attente (need_reconf=1)",
                            "Reconfiguration en attente (need_reconf)",
                            "Attendre la reconnexion de l'agent (traitement par lots de 240 / 60 s)")
        pending = ctx.sql_brut("SELECT r.jid FROM reset_machine r JOIN machines m ON m.jid = "
                               f"r.jid WHERE m.hostname IN ({chunk});")
        for jid in filter(None, (line.strip() for line in pending.splitlines())):
            ctx.signale(jid_parts(jid)[0].split(".")[0], "Reset deja demande (reset_machine)",
                        "Reset deja demande", "Attendre la reconnexion de l'agent")


def test_ssh_postes_xmpp(ctx):
    """Agent joignable en XMPP : SSH fonctionne-t-il depuis le serveur ? (SSH sert a certaines
    actions de Medulla et au repli du script.) Sinon : sshdaemon arrete -> demarrage propose."""
    if not SSH_ENABLED:
        return
    rows = [row for row in ctx.lignes if by_xmpp(row.get("ip") or "") and row.get("ssh") == "OK"
            and is_ip(row.get("ip") or "") and row.get("hostname_ssh")]
    if not rows:
        return
    with ThreadPoolExecutor(max_workers=16) as pool:
        results = list(pool.map(ssh_from_server, rows))
    for row, (state, detail) in zip(rows, results):
        row["ssh_serveur"] = state if state == "OK" else f"KO : {detail}"
        vlog(_T("SSH depuis le serveur : {}", row['ssh_serveur']), row["ip"])
        if state == "OK":
            continue
        daemon = ((row.get("_tests") or {}).get("sshdaemon") or {}).get("etat", "")
        action = ("Demarrage de sshdaemon par le script (mode actions)" if daemon not in
                  ("RUNNING", "ABSENT", "") else "Verifier le pare-feu Windows du poste (port 22 "
                  "entrant) et la cle de pulseuser")
        ctx.signale_ligne(row, f"SSH injoignable depuis le serveur alors que l'agent repond en "
                          f"XMPP : {detail}", "SSH injoignable (agent joignable en XMPP)", action)


def ssh_from_server(row):
    """Connexion SSH du serveur vers le poste : ('OK', '') ou ('KO', cause)."""
    hostname, error = s_test_ssh(row["ip"])
    if hostname:
        return "OK", ""
    net = network_diagnosis(row["ip"], [row["hostname_ssh"]])
    if net["code"] == "OUVERT":
        return "KO", f"port 22 ouvert mais connexion refusee ({clean(error, 80)})"
    return "KO", net["verdict"]


def test_reconnexions(ctx):
    """Machines qui se reconnectent en boucle (FAQ : machine hors ligne, point 7), avec une
    cause probable : identite en double (clone, deux agents), fiches en double, agent qui se
    met a jour en boucle. Reset d'office seulement quand il peut aider."""
    looping = [item for item in ctx.sql(
        "SELECT JSON_OBJECT('hostname', hostname, 'n', COUNT(*)) FROM uptime_machine "
        "WHERE status = 1 AND updowntime < 100 AND date > (NOW() - INTERVAL 1 HOUR) "
        "GROUP BY hostname HAVING COUNT(*) > 2;")
        if str(item.get("n") or "").isdigit() and item.get("hostname")]
    if not looping:
        return
    causes = loop_causes(ctx, [str(item["hostname"]) for item in looping])
    lines = []
    for item in looping:
        host, count = str(item["hostname"]), item.get("n")
        cause, action, reset_useful = causes.get(host.casefold(), (
            "cause non identifiee cote serveur", "Reset de l'agent par le script (mode actions)",
            True))
        LOOP_CAUSES[host.casefold()] = cause
        if reset_useful:
            AUTO_RESET.setdefault(host, f"reconnexions en boucle ({count} en 1 h) : {cause}")
        lines.append({"machine": host, "reconnexions": count, "cause": cause, "action": action})
        ctx.signale(host, f"Se reconnecte en boucle ({count} fois en 1 h) : {cause}",
                    "Reconnexions en boucle", action)
    ctx.constat("INFO", f"{len(looping)} machine(s) qui se reconnectent en boucle "
                "(derniere heure)", "Reset d'office quand il peut aider (mode actions) ; sinon "
                "l'action indiquee par machine",
                (["machine", "reconnexions", "cause", "action"],
                 ["Machine", "Reconnexions (1 h)", "Cause probable", "Action"],
                 sorted(lines, key=lambda line: -int(line["reconnexions"])),
                 "reconnexions_en_boucle.csv"))


def loop_causes(ctx, hosts):
    """Cause probable des reconnexions en boucle, par machine (minuscules) :
    (cause, action, reset utile)."""
    found = {}
    names = ", ".join(sql_text(host) for host in hosts)
    stats = {str(item.get("hostname") or "").casefold(): item for item in ctx.sql(
        "SELECT JSON_OBJECT('hostname', hostname, 'jids', COUNT(DISTINCT jid), 'md5', "
        "COUNT(DISTINCT md5agentversion)) FROM uptime_machine WHERE date > (NOW() - INTERVAL 1 "
        f"HOUR) AND hostname IN ({names}) GROUP BY hostname;")}
    doubles = {str(item.get("hostname") or "").casefold(): item.get("n") for item in ctx.sql(
        "SELECT JSON_OBJECT('hostname', hostname, 'n', COUNT(*)) FROM machines WHERE hostname "
        f"IN ({names}) AND agenttype = 'machine' GROUP BY hostname HAVING COUNT(*) > 1;")}
    clones = {str(item.get("hostname") or "").casefold(): item.get("autres") for item in ctx.sql(
        "SELECT JSON_OBJECT('hostname', m.hostname, 'autres', GROUP_CONCAT(DISTINCT o.hostname)) "
        "FROM machines m JOIN machines o ON o.uuid_serial_machine = m.uuid_serial_machine "
        "AND o.hostname <> m.hostname AND o.uuid_serial_machine <> '' WHERE m.hostname IN "
        f"({names}) GROUP BY m.hostname;")}
    conflicts = ejabberd_conflicts(hosts)
    for host in hosts:
        key = host.casefold()
        info = stats.get(key, {})
        if conflicts.get(key):
            found[key] = ("deux connexions avec le meme compte XMPP (ejabberd : Replaced by new "
                          "connection) : poste clone ou deux agents", "Verifier les postes "
                          "clones et les agents en double (deux services ou deux postes avec ce "
                          "nom) ; le reset n'y changera rien", False)
        elif clones.get(key):
            found[key] = (f"meme numero de serie materiel que {clones[key]} : poste clone",
                          "Verifier ces postes (clonage) ; supprimer les fiches en trop", False)
        elif isinstance(info.get("jids"), int) and info["jids"] > 1:
            found[key] = (f"{info['jids']} JID differents en 1 h : plusieurs postes ou agents "
                          "sous ce nom", "Verifier les postes portant ce nom ; le reset n'y "
                          "changera rien", False)
        elif doubles.get(key):
            found[key] = (f"{doubles[key]} fiches au meme nom dans Medulla",
                          "Supprimer la fiche obsolete, puis reset si la boucle continue", False)
        elif isinstance(info.get("md5"), int) and info["md5"] > 1:
            found[key] = ("empreinte de l'agent qui change : mise a jour de l'agent en boucle",
                          "Reset de l'agent par le script (fichiers retelecharges)", True)
    return found


def ejabberd_conflicts(hosts):
    """Lignes 'Replaced by new connection' / conflit d'ejabberd par machine (minuscules)."""
    path = SERVER_LOGS.get("ejabberd", "")
    users = {}
    for machine in MEDULLA_ROWS:
        host = str(machine.get("hostname") or "")
        if host.casefold() in {item.casefold() for item in hosts} and machine.get("jid"):
            users[jid_parts(machine["jid"])[0].casefold()] = host.casefold()
    if not users or not os.path.isfile(path):
        return {}
    try:
        size = os.path.getsize(path)
        with open(path, "rb") as stream:
            stream.seek(max(0, size - LOG_TAIL_MB * 1048576))
            text = stream.read().decode("utf-8", "replace").casefold()
    except OSError:
        return {}
    found = {}
    for line in text.splitlines():
        if "replaced by new connection" not in line and "conflict" not in line:
            continue
        for user, host in users.items():
            if user in line:
                found[host] = found.get(host, 0) + 1
    return found


def test_enregistrements(ctx):
    """Enregistrements incomplets (Interface missing) dans master-reg.log."""
    if not os.path.isfile(REGISTRATION_LOG):
        return
    size = os.path.getsize(REGISTRATION_LOG)
    with open(REGISTRATION_LOG, "rb") as stream:
        stream.seek(max(0, size - 20971520))
        text = stream.read().decode("utf-8", "replace")
    failed = Counter()
    for match in re.finditer(r"(?:Registration incomplete for machine|Interface missing on "
                             r"machine)\s+([^\s@/]+)", text):
        failed[match.group(1).split(".")[0].casefold()] += 1
    for host, count in failed.items():
        ctx.signale(host, f"Enregistrement incomplet ({count} dans master-reg.log : interface "
                    "ou MAC)", "Enregistrement incomplet",
                    "Comparer MAC et interfaces, entrees orphelines de network (FAQ : enregistrement)")
    if failed:
        ctx.constat("ATTENTION", f"{len(failed)} machine(s) en enregistrement incomplet "
                    "(master-reg.log)", "Comparer MAC et interfaces, chercher les entrees "
                    "orphelines de network (FAQ : enregistrement)")


def test_substituts(ctx):
    """Substituts Medulla connectes a ejabberd (enregistrement, configuration, inventaire)."""
    if not XMPP_ENABLED:
        return
    users = {jid_parts(jid)[0].casefold() for jid in connected_sessions()}
    missing = [f"{user} ({service})" for user, service in SUBSTITUTES.items() if user not in users]
    if missing:
        ctx.constat("CRITIQUE", f"{len(missing)} substitut(s) non connecte(s) a ejabberd : "
                    f"{', '.join(missing)}",
                    "systemctl restart pulse-xmpp-master-substitute-<service> (ou "
                    "restart-pulse-services allsubs), puis ejabberdctl connected_users")


def test_url_glpi(ctx):
    """URL d'envoi des inventaires vers GLPI joignable (une reponse HTTP suffit)."""
    url = ctx.ini(INVENTORY_INI, "glpi", "url_to_forward")
    if not url:
        return
    try:
        urllib.request.urlopen(urllib.request.Request(url, data=b"", method="POST"), timeout=10)
    except urllib.error.HTTPError:
        return                       # GLPI repond (400 sur une requete vide : normal)
    except (urllib.error.URLError, OSError, ValueError) as error:
        ctx.constat("CRITIQUE", f"URL d'envoi des inventaires vers GLPI injoignable "
                    f"({url} : {clean(str(getattr(error, 'reason', error)), 80)})",
                    "Verifier GLPI et url_to_forward (FAQ : Verifier que l'inventaire remonte "
                    "bien dans Medulla)")


def test_options_generation(ctx):
    """--base-url dans .generation_options (sinon erreur 404 a l'installation des agents)."""
    if os.path.isfile(GENERATION_OPTIONS):
        with open(GENERATION_OPTIONS, encoding="utf-8", errors="replace") as stream:
            if "--base-url" not in stream.read():
                ctx.constat("INFO", ".generation_options sans --base-url : les agents peuvent "
                            "echouer au telechargement (erreur 404)",
                            "Ajouter --base-url=http://<serveur>/downloads puis regenerer les "
                            "agents (FAQ : Erreur 404)")


def test_enquete_injoignables(ctx):
    """Postes injoignables en SSH ET en XMPP : derniere presence dans Medulla, derniere
    activite ejabberd, messages de la table logs et lignes des journaux du serveur qui les
    citent ; une cause probable par poste (colonnes enquete_serveur, traces_serveur)."""
    rows = ctx.injoignables[:ENQUETE_MAX]
    vlog(_T("enquete : {} poste(s) injoignable(s) en SSH et en XMPP, {} examine(s) (ENQUETE_MAX = {})", len(ctx.injoignables), len(rows), ENQUETE_MAX))
    if not rows:
        return
    infos = {id(row): investigation_targets(row) for row in rows}
    users = sorted({user for info in infos.values() for user in info["users"]})
    names = sorted({name for info in infos.values() for name in info["names"]})
    presence = last_presence(ctx, names)
    vlog(_T("enquete : uptime_machine, {} / {} nom(s) connu(s)", sum(name.casefold() in presence for name in names), len(names)))
    activity = last_activity_all(users)
    vlog(_T("enquete : ejabberdctl get_last, {} / {} compte(s) avec une reponse", len(activity), len(users)))
    messages = medulla_messages(ctx, users)
    vlog(_T("enquete : table logs, {} compte(s) avec des messages sur 30 jours", len(messages)))
    traces = scan_server_logs(rows, infos)
    network = network_diagnosis_all(rows, infos)
    for row in rows:
        info = infos[id(row)]
        seen = next((presence[name.casefold()] for name in info["names"]
                     if name.casefold() in presence), None)
        lines = []
        if seen:
            row["derniere_presence"] = f"{'ON' if seen['status'] == 1 else 'OFF'} le {seen['date']}"
        for user in info["users"]:
            if activity.get(user):
                lines.append(f"[ejabberd get_last] {activity[user]}")
            lines += [f"[table logs] {text}" for text in messages.get(user, [])]
        for source, found in traces.get(id(row), {}).items():
            lines += [f"[{source}] {line}" for line in found]
        row["traces_serveur"] = " || ".join(lines)
        net = network.get(id(row))
        if net:
            row["reseau"], row["diagnostic_reseau"] = net["detail"], net["verdict"]
        cause, family, action = probable_cause(row, seen, traces.get(id(row), {}), net)
        row["enquete_serveur"] = cause
        vlog(_T("enquete : {} -> {} ({} trace(s))", ', '.join(info['names']) or '?', cause, len(lines)),
             row.get("ip") or "")
        ctx.signale_ligne(row, cause, family, action)


TESTS_SERVEUR = [test_enquete_injoignables, test_ssh_postes_xmpp, test_blacklist_mac, test_mac_en_double, test_cartes_virtuelles,
                 test_entrees_orphelines, test_etat_machines, test_reconnexions,
                 test_enregistrements, test_substituts, test_url_glpi, test_options_generation]


# -------------------------------------------------------------------------------------------
# Moteur des tests ajoutables (a ne pas modifier pour ajouter un test)
# -------------------------------------------------------------------------------------------

POSTE_HELPERS = r'''
def service_info(name):
    """Etat et type de demarrage d'un service Windows (ABSENT s'il n'existe pas)."""
    state = sc_value(["query", name], r"STATE\s*:\s*(\d+)", STATES)
    start = "" if state == "ABSENT" else sc_value(["qc", name], r"START_TYPE\s*:\s*(\d+)",
                                                   STARTS)
    return {"etat": state, "demarrage": start}


_JOURNAL = []


def journal_agent():
    """Fin (4 Mo) du journal xmpp-agent-machine.log, lue une seule fois."""
    if not _JOURNAL:
        path = os.path.join(BASE, "var", "log", "xmpp-agent-machine.log")
        text = ""
        if os.path.isfile(path):
            size = os.path.getsize(path)
            with open(path, "rb") as stream:
                if size > 4194304:
                    stream.seek(size - 4194304)
                text = stream.read().decode("utf-8", "replace")
        _JOURNAL.append(text)
    return _JOURNAL[0]


def agentconf(section, key):
    """Valeur de agentconf.ini (les mots de passe ne sont jamais renvoyes)."""
    if re.search(r"(?i)pass|pwd|key|secret", key):
        return ""
    raw = read(os.path.join(CONF, "agentconf.ini"), 1048576)
    if not raw:
        return ""
    parser = configparser.ConfigParser(interpolation=None)
    parser.read_string(raw.decode("utf-8-sig", "replace"))
    return parser.get(section, key, fallback="").strip()


BASE = os.path.dirname(CONF)
'''


def _config_value(match):
    return repr(globals()[match.group(1)])


def inject_poste_code(code):
    """Code d'audit du poste : valeurs de l'en-tete (@@NOM@@) et tests de TESTS_POSTE."""
    functions, calls = [POSTE_HELPERS.strip("\n")], []
    for test in TESTS_POSTE:
        if not test.get("poste"):
            continue
        name = "collecte_" + test["nom"]
        body = textwrap.dedent(test["poste"]).strip("\n")
        body, found = re.subn(r"^def collecte\(", f"def {name}(", body, count=1, flags=re.M)
        if not found:
            raise ValueError(f"test {test['nom']} : le code poste doit definir collecte()")
        functions.append(body)
        calls.append(f"    result['tests'][{test['nom']!r}] = timed({('test ' + test['nom'])!r}, "
                     f"None, {name})")
    code = code.replace("#@@TESTS_POSTE@@", "\n\n\n".join(functions))
    code = code.replace("#@@APPELS_TESTS_POSTE@@", "\n".join(calls) or "    pass")
    return re.sub(r"@@([A-Z_][A-Z0-9_]*)@@", _config_value, code)


def check_tests():
    """Controle la liste des tests au lancement (erreur claire plutot qu'un plantage)."""
    names = set()
    for test in TESTS_POSTE:
        for key in ("nom", "famille", "libelle", "verifie"):
            if key not in test:
                raise ValueError(f"test poste {test.get('nom', '?')} : cle {key!r} manquante")
        if not re.fullmatch(r"[a-z][a-z0-9_]*", test["nom"]) or test["nom"] in names:
            raise ValueError(f"test poste {test['nom']!r} : nom invalide ou en double")
        names.add(test["nom"])
        if test.get("correction") not in (None, "SERVICES"):
            raise ValueError(f"test poste {test['nom']} : correction inconnue "
                             f"{test['correction']!r} (seule possible : SERVICES)")


def fill_tests(row, diag):
    """Valeurs des tests poste dans la ligne (brutes dans _tests, texte dans test_<nom>)."""
    values = diag.get("tests") if isinstance(diag.get("tests"), dict) else {}
    row["_tests"] = values
    for test in TESTS_POSTE:
        value = values.get(test["nom"])
        row[f"test_{test['nom']}"] = ("" if value is None else value
                                      if isinstance(value, (str, int, float))
                                      else json.dumps(value, ensure_ascii=False))


def run_check(test, row):
    """(texte, corrigeable) si le test signale un probleme, sinon None."""
    try:
        answer = test["verifie"]((row.get("_tests") or {}).get(test["nom"]), row)
    except Exception as error:          # un test mal ecrit ne doit pas arreter l'audit
        return f"Test {test['nom']} en erreur : {clean(str(error), 120)}", False
    if not answer:
        return None
    text, fixable = answer if isinstance(answer, tuple) else (answer, True)
    return str(text), bool(fixable) and bool(test.get("correction"))


def analyse_tests(row, problems, fixable, blocked):
    """Ajoute les problemes des TESTS_POSTE a ceux de l'audit (appele par analyse())."""
    row["_tests_msgs"] = {}
    for test in TESTS_POSTE:
        found = run_check(test, row)
        if not found:
            continue
        text, can_fix = found
        problems.append(text)
        row["_tests_msgs"][text] = (test.get("diagnostic") or test["libelle"], can_fix,
                                    test.get("action", ""))
        if can_fix:
            if test["correction"] not in fixable:
                fixable.append(test["correction"])
        elif test.get("bloquant") or test.get("correction"):
            blocked.append(test["nom"])


def family_tests(complete):
    """Lignes du recapitulatif par famille pour les TESTS_POSTE."""
    keep = {id(row) for row in complete}
    result = []
    for test in TESTS_POSTE:
        if test.get("recapitulatif") is False:
            continue

        def verdict(row, test=test):
            if id(row) not in keep:
                return None
            if test.get("poste") and (row.get("_tests") or {}).get(test["nom"]) is None:
                return None
            return run_check(test, row) is None
        info = not test.get("correction") and not test.get("bloquant")
        result.append((test["famille"], test["libelle"], None, verdict, info))
    return result


SERVER_DETAILS = {}      # texte du constat -> (colonnes, libelles, lignes, fichier.csv)
BLACKLISTED_SHARED = {}  # machine -> {macs, on} : MAC partagee deja blacklistee (a reconfigurer)
AUTO_RESET = {}          # machine -> motif : reset fait d'office (reconnexions en boucle, ON sans inventaire)
AUTO_RESET_STATUS = {}   # machine (minuscules) -> resultat du reset d'office
LOOP_CAUSES = {}         # machine (minuscules) -> cause probable des reconnexions en boucle


class ServerContext:
    """Contexte donne aux TESTS_SERVEUR (lecture seule)."""

    def __init__(self, rows):
        self.lignes = rows
        self.constats = []
        # Postes que ni SSH ni XMPP n'ont pu joindre (aucun audit possible).
        self.injoignables = [row for row in rows if not row.get("hostname_ssh")
                             and row.get("diagnostic") in ("NON_REALISE", "")
                             and row.get("deja_on") != "OUI"
                             and (row.get("ip") or row.get("nom_demande")
                                  or row.get("hostnames_medulla"))]
        self._by_name = {}
        for row in rows:
            for form in name_forms(row.get("hostname_ssh") or ""):
                self._by_name.setdefault(form, []).append(row)

    def sql(self, query):
        return json_rows(query)

    def sql_brut(self, query):
        return mysql_exec(query)

    def ini(self, path, section, key):
        return ini_value(path, section, key)

    def constat(self, level, text, action="", detail=None):
        """detail (facultatif) : (colonnes, libelles, lignes, fichier.csv) affiche sous le
        constat dans le diagnostic final et ecrit dans le dossier du releve."""
        self.constats.append((level, text, action))
        if detail:
            SERVER_DETAILS[text] = detail

    def signale_ligne(self, row, text, diagnostic=None, action=""):
        """Constat sur une ligne precise (poste injoignable : pas de nom reel connu)."""
        known = [item for item in (row.get("constats_serveur") or "").split(" | ") if item]
        if text not in known:
            row["constats_serveur"] = " | ".join(known + [text])
        row.setdefault("_tests_msgs", {})[text] = (diagnostic or text, False, action)

    def signale(self, hostname, text, diagnostic=None, action=""):
        rows = {id(row): row for form in name_forms(hostname or "")
                for row in self._by_name.get(form, [])}
        for row in rows.values():
            known = [item for item in (row.get("constats_serveur") or "").split(" | ") if item]
            if text not in known:
                row["constats_serveur"] = " | ".join(known + [text])
            row.setdefault("_tests_msgs", {})[text] = (diagnostic or text, False, action)


def investigation_targets(row):
    """Noms, comptes XMPP et IP d'un poste injoignable, d'apres Medulla."""
    names = {re.sub(r"\s*\(.*$", "", item).strip()
             for item in (row.get("hostnames_medulla") or "").split(",")}
    names |= {item.strip() for item in (row.get("nom_demande") or "").split(",")}
    names = {name for name in names if name and not name.startswith("(")}
    users = set()
    for machine in MEDULLA_ROWS:
        if names and name_forms(machine.get("hostname")) & set().union(
                *(name_forms(name) for name in names)):
            user = jid_parts(machine.get("jid"))[0]
            if user:
                users.add(user.casefold())
    tokens = {form for name in names for form in name_forms(name) if len(form) >= 3} | users
    if is_ip(row.get("ip") or ""):
        tokens.add(row["ip"])
    return {"names": sorted(names), "users": sorted(users), "tokens": tokens}


def last_presence(ctx, names):
    """Dernier etat connu (uptime_machine) par nom : {nom: {status, date, age_j}}."""
    found = {}
    for start in range(0, len(names), 300):
        chunk = ", ".join(sql_text(name) for name in names[start:start + 300])
        for item in ctx.sql("SELECT JSON_OBJECT('hostname', hostname, 'status', status, 'date', "
                            "DATE_FORMAT(date, '%Y-%m-%d %H:%i'), 'age', DATEDIFF(NOW(), date)) "
                            "FROM uptime_machine WHERE id IN (SELECT MAX(id) FROM uptime_machine "
                            f"WHERE hostname IN ({chunk}) GROUP BY hostname);"):
            if item.get("hostname") and "status" in item:
                found[str(item["hostname"]).casefold()] = {
                    "status": item.get("status"), "date": item.get("date") or "?",
                    "age_j": item.get("age") if isinstance(item.get("age"), int) else None}
    return found


def last_activity_all(users):
    """Derniere activite ejabberd (ejabberdctl get_last) de chaque compte, en parallele."""
    if not users or not XMPP_ENABLED:
        return {}
    domain = next((jid_parts(m.get("jid"))[1] for m in MEDULLA_ROWS if m.get("jid")), "pulse")
    with ThreadPoolExecutor(max_workers=16) as pool:
        answers = pool.map(lambda user: last_activity(f"{user}@{domain}"), users)
        return {user: answer for user, answer in zip(users, answers) if answer}


def medulla_messages(ctx, users):
    """3 derniers messages de la table logs (30 jours) par compte, erreurs d'abord."""
    found = {}
    for start in range(0, len(users), 200):
        where = " OR ".join("who LIKE " + sql_text(user.replace("%", "") + "@%")
                            for user in users[start:start + 200])
        for item in ctx.sql("SELECT JSON_OBJECT('who', who, 'date', DATE_FORMAT(date, "
                            "'%Y-%m-%d %H:%i'), 'type', type, 'text', LEFT(text, 300)) FROM logs "
                            f"WHERE ({where}) AND date > NOW() - INTERVAL 30 DAY "
                            "ORDER BY date DESC LIMIT 2000;"):
            user = jid_parts(str(item.get("who") or ""))[0].casefold()
            text = clean(re.sub(r"<[^>]+>", "", str(item.get("text") or "")), 160)
            if user and text:
                found.setdefault(user, []).append(f"{item.get('date')} {item.get('type') or ''} {text}")
    for user, items in found.items():
        errors = [item for item in items if re.search(r"(?i)error|fail|echec|abort", item)]
        found[user] = (errors + [item for item in items if item not in errors])[:3]
    return found


def scan_server_logs(rows, infos):
    """Lit la fin de chaque journal de SERVER_LOGS une seule fois et garde, pour chaque
    poste, les dernieres lignes qui le citent (erreurs d'abord)."""
    owners = {}
    for row in rows:
        for token in infos[id(row)]["tokens"]:
            owners.setdefault(token.casefold(), []).append(id(row))
    if not owners:
        return {}
    pattern = re.compile(r"(?<![\w.-])(" + "|".join(
        re.escape(token) for token in sorted(owners, key=len, reverse=True)) + r")(?![\w-])")
    found = {}
    for source, path in SERVER_LOGS.items():
        if not os.path.isfile(path):
            vlog(_T("journal {} : {} absent", source, path))
            continue
        try:
            size = os.path.getsize(path)
            with open(path, "rb") as stream:
                stream.seek(max(0, size - LOG_TAIL_MB * 1048576))
                text = stream.read().decode("utf-8", "replace")
        except OSError as error:
            vlog(_T("journal {} : {} illisible ({})", source, path, error))
            continue
        per_row = {}
        for line in text.splitlines():
            lower = line.casefold()
            hits = {owner for match in pattern.finditer(lower) for owner in owners[match.group(1)]}
            if not hits:
                continue
            line = re.sub(r"(?i)(password\S*\s*[=:]\s*)\S+", r"\1****", line.strip())[:220]
            for owner in hits:
                per_row.setdefault(owner, []).append(line)
        for owner, lines in per_row.items():
            errors = [line for line in lines
                      if re.search(r"(?i)\b(error|erreur|warning|fail|failed|abort|denied|refus)", line)]
            chosen = (errors[-TRACES_PAR_JOURNAL:] if errors else lines[-TRACES_PAR_JOURNAL:])
            found.setdefault(owner, {})[source] = chosen
        read_mb = f"{min(size, LOG_TAIL_MB * 1048576) / 1048576:.1f}".replace(".", ",")
        vlog(_T("journal {} : {}, {} Mo lus, {} poste(s) cite(s)", source, path, read_mb, len(per_row)))
    return found


def probable_cause(row, seen, traces, net=None):
    """Cause probable d'un poste injoignable : (texte, diagnostic, action)."""
    if net and net["code"] in NETWORK_FIRST:
        return net["verdict"], net["famille"], net["action"]
    joined = " ".join(line for lines in traces.values() for line in lines)
    if re.search(r"(?i)(authentication fail|failed\b.{0,30}\bauth|not-authorized|"
                 r"invalid (username or )?password|bad password)", " ".join(traces.get("ejabberd", []))):
        return ("Authentification XMPP refusee pour l'agent (journal ejabberd)",
                "Authentification XMPP refusee",
                "Agent OFF : la reconfiguration (need_reconf) est sans effet ; verifier le "
                "compte (ejabberdctl check_account <compte> pulse), sinon reset de l'agent (--cible)")
    if re.search(r"Registration incomplete|Interface missing|incoherence between machines",
                 joined):
        return ("Enregistrement incomplet (journal master-reg)", "Enregistrement incomplet",
                "Comparer MAC et interfaces, entrees orphelines de network (FAQ : enregistrement)")
    if re.search(r"ABORT RELAY DOWN|ABORT INFO RELAY MISSING", joined):
        return ("Relais injoignable (journaux serveur)", "Relais injoignable signale par l'agent",
                "Verifier le relais du poste (groupdeploy) et son service "
                "(FAQ : hors ligne, point 10)")
    age = seen.get("age_j") if seen else None
    if isinstance(age, int) and age > ABSENCE_LONGUE_J:
        return (f"Absent de Medulla depuis {age} jours : poste eteint, retire ou renomme",
                "Poste absent depuis longtemps",
                "Verifier que le poste existe encore (GLPI, AD) ; supprimer la fiche s'il est retire")
    if seen and seen.get("status") == 1:
        return (f"Vu ON dans Medulla le {seen['date']} : coupure reseau ou pare-feu entre "
                "serveur et poste", "Poste vu recemment, injoignable maintenant",
                "Verifier le reseau du poste et le pare-feu (ports 22 et 5222) ; "
                "nmap -Pn -p 22 <IP>")
    if seen:
        return (f"Eteint ou deconnecte depuis le {seen['date']}", "Poste eteint ou deconnecte",
                "Allumer le poste ou verifier son reseau, puis relancer l'audit")
    if net:
        return net["verdict"], net["famille"], net["action"]
    if traces:
        return ("Traces dans les journaux serveur, cause non reconnue (voir traces_serveur)",
                "Traces serveur a analyser", "Lire la colonne traces_serveur de resultats.csv")
    return ("Aucune trace cote serveur : jamais connecte, ou journaux trop anciens",
            "Aucune trace cote serveur", "Verifier que l'agent est installe et configure sur le poste")


def mac_blacklist(ctx):
    raw = ctx.ini(REGISTRY_INI, "parameters", "blacklisted_mac_addresses")
    return {normalize_mac(item.replace("\\:", ":")) for item in raw.split(",") if item.strip()}


def normalize_mac(mac):
    """MAC comparable : minuscules, separateur ':' (la base peut stocker 00-09-0f-...)."""
    return str(mac or "").strip().lower().replace("-", ":")


def ini_value(path, section, key):
    """Valeur d'un .ini Medulla, surchargee par le .ini.local."""
    parser = configparser.ConfigParser(interpolation=None)
    try:
        parser.read([path, path + ".local"], encoding="utf-8")
    except (configparser.Error, OSError):
        return ""
    return parser.get(section, key, fallback="").strip()


def json_rows(sql):
    rows = []
    for line in mysql_exec(sql).splitlines():
        try:
            item = json.loads(line)
        except ValueError:
            continue
        if isinstance(item, dict):
            rows.append(item)
    return rows


def server_checks(rows):
    """Execute les TESTS_SERVEUR ; renvoie les constats [(gravite, texte, action)]."""
    ctx = ServerContext(rows)
    for test in TESTS_SERVEUR:
        before, start = len(ctx.constats), time.monotonic()
        doc = clean((test.__doc__ or "").strip(), 110)
        if doc:
            vlog(_T("controle serveur {} : {}", test.__name__, doc))
        else:
            vlog(_T("controle serveur {}", test.__name__))
        try:
            test(ctx)
            duration = f"{time.monotonic() - start:.1f}".replace(".", ",")
            vlog(_T("   -> {} constat(s) ({} s)", len(ctx.constats) - before, duration))
        except (subprocess.SubprocessError, OSError, ValueError) as error:
            vlog(_T("controle serveur '{}' impossible : {}", test.__name__, clean(str(error), 150)))
        except Exception as error:      # un test mal ecrit ne doit pas arreter le releve
            ctx.constat("INFO", f"Test serveur {test.__name__} en erreur : "
                        f"{clean(str(error), 120)}", "Corriger le test dans TESTS_SERVEUR")
    return ctx.constats


# -------------------------------------------------------------------------------------------
# 1.2  LANGUES (fr, en, es, de) : le catalogue des traductions est a la fin du fichier
# -------------------------------------------------------------------------------------------

LANGUAGES = {"fr": "Francais", "en": "English", "es": "Espanol", "de": "Deutsch"}
_LANG_INDEX = {"en": 0, "es": 1, "de": 2}
LANG = "fr"
YES_WORD = {"fr": "OUI", "en": "YES", "es": "SI", "de": "JA"}


def detect_language():
    """Langue du systeme (LC_ALL, LC_MESSAGES, LANG), francais par defaut."""
    for name in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
        code = os.environ.get(name, "")[:2].lower()
        if code in LANGUAGES:
            return code
    return "fr"


def set_language(code):
    global LANG
    LANG = code if code in LANGUAGES else "fr"


def language_menu(default):
    """Menu interactif de choix de la langue (avant tout autre affichage)."""
    codes = list(LANGUAGES)
    print("\n  Langue / Language / Idioma / Sprache")
    for number, code in enumerate(codes, 1):
        mark = " *" if code == default else ""
        print(f"    {number}) {LANGUAGES[code]}{mark}")
    while True:
        try:
            answer = input(f"  [{codes.index(default) + 1}] : ").strip().lower()
        except EOFError:
            return default
        if not answer:
            return default
        if answer in codes:
            return answer
        if answer.isdigit() and 1 <= int(answer) <= len(codes):
            return codes[int(answer) - 1]


def _split_ws(text):
    core = text.strip()
    if not core:
        return "", text, ""
    start = text.index(core)
    return text[:start], core, text[start + len(core):]


def _lookup(core):
    entry = CATALOG.get(core)
    if entry is None or LANG == "fr":
        return None
    return entry[_LANG_INDEX[LANG]]


def _T(template, *args):
    """Texte affiche : modele francais -> langue choisie, puis format(args)."""
    lead, core, trail = _split_ws(template)
    translated = _lookup(core)
    text = lead + (translated if translated is not None else core) + trail
    try:
        return text.format(*args)
    except (IndexError, KeyError, ValueError):
        return (lead + core + trail).format(*args)


_PATTERNS = None


def _patterns():
    global _PATTERNS
    if _PATTERNS is None:
        _PATTERNS = []
        for core in CATALOG:
            if "{" in core.replace("{{", ""):
                regex = re.escape(core.replace("{{", "{").replace("}}", "}"))
                regex = re.sub(r"\\\{[^{}]*?\\\}", "(.*?)", regex)
                _PATTERNS.append((re.compile(regex + r"\Z", re.S), core))
    return _PATTERNS


def tr(text):
    """Traduit une valeur deja construite (libelle, probleme, message d'erreur)."""
    if LANG == "fr" or not isinstance(text, str) or not text.strip():
        return text
    lead, core, trail = _split_ws(text)
    translated = _lookup(core)
    if translated is not None:
        return lead + translated.replace("{{", "{").replace("}}", "}") + trail
    for regex, key in _patterns():
        match = regex.match(core)
        if match:
            target = re.sub(r"\{[^{}]*\}", "{}", _lookup(key).replace("{{", "\0").replace("}}", "\1"))
            target = target.replace("\0", "{{").replace("\1", "}}")
            return lead + target.format(*(tr(group) for group in match.groups())) + trail
    if core.endswith(" (info)"):
        return lead + tr(core[:-7]) + " (info)" + trail
    if " - " in core:
        left, right = core.split(" - ", 1)
        if _lookup(left) is not None:
            return lead + tr(left) + " - " + tr(right) + trail
    # Prefixe connu suivi d'une liste (ex. message d'erreur + paquets).
    for key in sorted(CATALOG, key=len, reverse=True):
        if len(key) > 15 and "{" not in key and core.startswith(key):
            return lead + _lookup(key) + core[len(key):] + trail
    return text


def tr_problems(text):
    """Liste de problemes 'a | b' -> 'a, b' dans la langue choisie."""
    return ", ".join(tr(part) for part in (text or "").split(" | "))



# -------------------------------------------------------------------------------------------
# 1.3  BIBLIOTHEQUE (fonctionnement interne : transport, audit, corrections, rapports)
# -------------------------------------------------------------------------------------------



MIN_PYTHON = (3, 9)
# Commande requise -> paquet Debian qui la fournit.
REQUIRED_COMMANDS = {
    "mysql": "default-mysql-client",
    "ssh": "openssh-client",
    "scp": "openssh-client",
    "ping": "iputils-ping",
}

QUERY = """
SELECT JSON_OBJECT('hostname', hostname, 'ip_xmpp', ip_xmpp, 'enabled', enabled,
                   'jid', jid, 'agenttype', agenttype)
FROM machines WHERE enabled IN (0, 1);
"""

SSH_CONNECT = DEFAULT_SSH_CONNECT
SSH_TIMEOUT = SSH_CONNECT + 10   # duree maximale du test "hostname" (connexion + commande)
DIAG_TIMEOUT = DEFAULT_DIAG_TIMEOUT
ENV = {**os.environ, "LC_ALL": "C"}
SSH_BASE = [
    "ssh", "-T",
    "-o", "BatchMode=yes",
    "-o", "StrictHostKeyChecking=no",
    "-o", "UserKnownHostsFile=/dev/null",
    "-o", "GlobalKnownHostsFile=/dev/null",
    "-o", "CheckHostIP=no",
    "-o", "UpdateHostKeys=no",
    "-o", "VerifyHostKeyDNS=no",
    "-o", f"ConnectTimeout={SSH_CONNECT}",
    "-o", "ConnectionAttempts=1",
    # Poste muet pendant 2 minutes (4 x 30 s) : connexion consideree comme perdue.
    # Une operation lente mais active n'est jamais interrompue par ce controle.
    "-o", "ServerAliveInterval=30",
    "-o", "ServerAliveCountMax=4",
    "-o", "PreferredAuthentications=publickey",
    "-o", "PasswordAuthentication=no",
    "-o", "KbdInteractiveAuthentication=no",
    "-o", "GSSAPIAuthentication=no",
    "-o", "ForwardAgent=no",
    "-o", "ForwardX11=no",
    "-o", "ClearAllForwardings=yes",
    "-o", "ControlMaster=no",
    "-o", "ControlPath=none",
    "-o", "LogLevel=ERROR",
]

# Diagnostic execute sur le poste par le Python de l'agent, envoye sur stdin.
# Lecture seule ; ASCII uniquement (stdin Windows en page de code OEM).
DIAG_PY = r'''
import ast
import configparser
import hashlib
import io
import json
import os
import re
import platform
import shutil
import socket
import subprocess
import sys
import threading
import time
import tokenize
import warnings

warnings.simplefilter("ignore")
print("#ETAPE Python demarre", flush=True)
ARGS = sys.argv[1:]
ROOT = ARGS[0] if len(ARGS) > 0 else r"C:\Program Files\Python3\Lib\site-packages\pulse_xmpp_agent"
CONF = ARGS[1] if len(ARGS) > 1 else r"C:\Program Files\Medulla\etc"
PID = ARGS[2] if len(ARGS) > 2 else r"C:\Program Files\Medulla\bin\kiosk.pid"
STATES = {"1": "STOPPED", "2": "START_PENDING", "3": "STOP_PENDING", "4": "RUNNING",
          "5": "CONTINUE_PENDING", "6": "PAUSE_PENDING", "7": "PAUSED"}
STARTS = {"0": "BOOT", "1": "SYSTEM", "2": "AUTO", "3": "MANUEL", "4": "DESACTIVE"}


def read(path, limit):
    if os.path.islink(path):
        raise ValueError("lien")
    if not os.path.exists(path):
        return None
    if not os.path.isfile(path) or os.path.getsize(path) > limit:
        raise ValueError("taille")
    with open(path, "rb") as stream:
        return stream.read()


def guarded(default, function, *args):
    try:
        return function(*args)
    except PermissionError:
        return "ACCES_REFUSE"
    except Exception:
        return default


STEPS = {}


def timed(name, default, function, *args):
    """Execute une etape de l'audit : annonce (pour savoir ou en est un audit trop long)
    et duree."""
    print("#ETAPE " + name, flush=True)
    start = time.time()
    value = guarded(default, function, *args)
    STEPS[name] = round(time.time() - start, 1)
    return value


def resolve(host, wait=5):
    """Resolution DNS limitee a `wait` secondes (getaddrinfo n'a pas de delai)."""
    box = {}

    def run():
        try:
            box["value"] = sorted({info[4][0] for info in socket.getaddrinfo(
                host, None, type=socket.SOCK_STREAM)})
        except Exception as error:
            box["error"] = error

    worker = threading.Thread(target=run)
    worker.daemon = True
    worker.start()
    worker.join(wait)
    if worker.is_alive():
        raise socket.gaierror("pas de reponse DNS en %d s" % wait)
    if "error" in box:
        raise box["error"]
    return box["value"]


def in_background(default, function, name=""):
    """Lance un test reseau dans un fil separe pendant que le reste de l'audit avance."""
    box = {"value": default}

    def run():
        start = time.time()
        box["value"] = guarded(default, function)
        if name:
            STEPS[name] = round(time.time() - start, 1)

    worker = threading.Thread(target=run)
    worker.daemon = True
    worker.start()
    return worker, box, default


def result_of(job, wait=60):
    worker, box, default = job
    worker.join(wait)
    return default if worker.is_alive() else box["value"]


def connection_conf():
    """[connection] de agentconf.ini, surcharge par agentconf.ini.local."""
    parser = configparser.ConfigParser(interpolation=None)
    for name in ("agentconf.ini", "agentconf.ini.local"):
        try:
            raw = read(os.path.join(CONF, name), 1048576)
            if raw:
                parser.read_string(raw.decode("utf-8-sig"))
        except Exception:
            pass
    return parser


def catches_ad(handler):
    types = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
    catches = handler.type is None or any(
        isinstance(item, ast.Name) and item.id in ("Exception", "BaseException", "AttributeError")
        for item in types
    )
    return catches and all(
        isinstance(node, ast.Pass) or (
            isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
            and isinstance(node.value.func, ast.Attribute)
            and isinstance(node.value.func.value, ast.Name)
            and node.value.func.value.id in ("logging", "logger")
            and node.value.func.attr in ("warning", "error", "exception", "info", "debug")
        ) for node in handler.body
    )


def ad_state(path):
    raw = read(path, 4194304)
    if raw is None:
        return "ABSENT"
    compile(raw, path, "exec")
    encoding = tokenize.detect_encoding(io.BytesIO(raw).readline)[0]
    tree = ast.parse(raw.decode(encoding))
    methods = [node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "searchInfoMachine"]
    if len(methods) != 1:
        return "NON_RECONNU"
    method = methods[0]
    parents = {child: node for node in ast.walk(method) for child in ast.iter_child_nodes(node)}
    calls = [node for node in ast.walk(method) if isinstance(node, ast.Call)
             and isinstance(node.func, ast.Name) and node.func.id == "adusergroups"]
    if not calls:
        return "NON_CONCERNE"
    expected = ast.parse(
        'base64.b64encode(adusergroups(lastusersession).encode("utf-8")).decode("utf-8")',
        mode="eval",
    ).body
    unprotected = 0
    for call in calls:
        cursor = call
        protected = False
        while cursor in parents:
            parent = parents[cursor]
            if isinstance(parent, ast.Try) and cursor in parent.body:
                if parent.handlers and catches_ad(parent.handlers[0]):
                    protected = True
                    break
            cursor = parent
        if protected:
            continue
        cursor = call
        while cursor in parents and not isinstance(cursor, ast.Assign):
            cursor = parents[cursor]
        if not isinstance(cursor, ast.Assign) or len(cursor.targets) != 1:
            return "NON_RECONNU"
        target = cursor.targets[0]
        key = target.slice if isinstance(target, ast.Subscript) else None
        if hasattr(ast, "Index") and isinstance(key, ast.Index):
            key = key.value
        key_value = key.value if isinstance(key, ast.Constant) else getattr(key, "s", None)
        if not (
            isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name)
            and target.value.id == "dataobj" and key_value == "adusergroups"
            and ast.dump(cursor.value) == ast.dump(expected)
        ):
            return "NON_RECONNU"
        unprotected += 1
    return "A_CORRIGER" if unprotected else "PROTEGE"


def lib_files():
    """Inventaire de tout le dossier de l'agent (ROOT) : [chemin relatif, taille, sha256].
    Ignore caches et sauvegardes."""
    base = ROOT
    if not os.path.isdir(os.path.join(base, "lib")):
        return "DOSSIER_ABSENT"
    found = []
    for folder, dirs, files in os.walk(base):
        dirs[:] = [name for name in dirs if name.lower() not in ("__pycache__", "backup")
                   and not (folder == base and name.lower() in ("img_agent",))]
        for name in files:
            if name.lower().endswith((".pyc", ".pyo")):
                continue
            path = os.path.join(folder, name)
            if os.path.islink(path) or os.path.getsize(path) > 16777216:
                found.append([os.path.relpath(path, base).replace("\\", "/"), -1, ""])
                continue
            digest, size = hashlib.sha256(), 0
            with open(path, "rb") as stream:
                for block in iter(lambda: stream.read(1048576), b""):
                    digest.update(block)
                    size += len(block)
            found.append([os.path.relpath(path, base).replace("\\", "/"), size,
                          digest.hexdigest()])
    return found


# Flux poste -> serveurs (repris de windows_connection_check.ps1) : memes ports,
# 2 s par port, tous les ports d'une cible testes en parallele.
PULSE_TCP = @@PORTS_TCP_SERVEUR@@
RELAY_TCP = @@PORTS_TCP_RELAIS@@
UDP_PORTS = @@PORTS_UDP@@


def tcp_port(host, port):
    try:
        connection = socket.create_connection((host, port), timeout=2)
        connection.close()
        return "OK"
    except socket.timeout:
        return "TIMEOUT"
    except ConnectionRefusedError:
        return "REFUSE"
    except OSError:
        return "ERREUR"


def tcp_ports(host, ports):
    results = {}

    def run(port):
        results[port] = tcp_port(host, port)

    threads = [threading.Thread(target=run, args=(port,)) for port in ports]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join(10)
    return {str(port): results.get(port, "ERREUR") for port in ports}


def udp_ports(host, ports):
    """Envoi d'un octet par port UDP : confirme seulement l'envoi (pas de reponse attendue)."""
    results = {}
    for port in ports:
        try:
            sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sender.settimeout(1)
            sender.connect((host, port))
            sender.send(b"\x01")
            sender.close()
            results[str(port)] = "ENVOYE"
        except OSError:
            results[str(port)] = "ECHEC"
    return results


def flows():
    """Tests de flux depuis le poste vers le serveur Medulla (pulse) et vers son relais."""
    pulse = globals().get("PULSE_HOST") or ""
    relay = connection_conf().get("connection", "server", fallback="").strip()
    result = {"pulse": pulse, "relais": relay}
    for name, host, ports in (("pulse", pulse, PULSE_TCP), ("relais", relay, RELAY_TCP)):
        try:
            address = resolve(host)[0] if host else ""
        except Exception:
            result[name + "_tcp"] = dict((str(port), "ECHEC_DNS") for port in ports)
            address = ""
        if name == "pulse":
            pulse = address
        else:
            relay = address
    jobs = {}
    if pulse:
        jobs["pulse_tcp"] = in_background({}, lambda: tcp_ports(pulse, PULSE_TCP))
    if relay:
        jobs["relais_tcp"] = in_background({}, lambda: tcp_ports(relay, RELAY_TCP))
    if pulse:
        result["pulse_udp"] = udp_ports(pulse, UDP_PORTS)
    if relay:
        result["relais_udp"] = udp_ports(relay, UDP_PORTS)
    for key, job in jobs.items():
        result[key] = result_of(job, 30)
    return result


def log_errors():
    """Lignes ERROR du journal de l'agent (fin du fichier), secrets masques."""
    path = os.path.join(os.path.dirname(CONF), "var", "log", "xmpp-agent-machine.log")
    if not os.path.isfile(path):
        return {"etat": "ABSENT", "nombre": 0, "dernieres": []}
    size = os.path.getsize(path)
    with open(path, "rb") as stream:
        if size > 4194304:
            stream.seek(size - 4194304)
        text = stream.read().decode("utf-8", "replace")
    errors = [line.strip() for line in text.splitlines() if " - ERROR - " in line]
    masked = [re.sub(r"(?i)(password=|passwd=|pwd=)[^&\s'\"]+", r"\1***", line)[:220]
              for line in errors[-3:]]
    return {"etat": "PRESENT", "nombre": len(errors), "dernieres": masked,
            "partiel": size > 4194304}


def os_info():
    try:
        return platform.platform()
    except Exception:
        return sys.platform


def xmpp_target(parser, name):
    """Cible XMPP d'une configuration : [connection] server, sinon le serveur de
    configuration ([configuration_server] confserver) que l'agent contacte d'abord."""
    server = parser.get("connection", "server", fallback="").strip()
    port_text = parser.get("connection", "port", fallback="5222").strip() or "5222"
    if server and "@@" not in server:
        return server, port_text, ("" if name == "agentconf.ini" else "lu dans " + name)
    server = parser.get("configuration_server", "confserver", fallback="").split(",")[0].strip()
    port_text = parser.get("configuration_server", "confport", fallback="5222").strip() or "5222"
    if server and "@@" not in server:
        return server, port_text, "serveur de configuration lu dans " + name
    return "", "", ""


def xmpp_check():
    """Connexion du poste vers son serveur XMPP (ejabberd), lu dans la configuration de
    l'agent : agentconf.ini, surcharge par agentconf.ini.local. Resolution DNS puis
    ouverture d'une connexion TCP (5 s maximum), aussitot refermee. Aucun secret renvoye."""
    server, port_text, note = xmpp_target(connection_conf(), "agentconf.ini")
    if not server:
        # agentconf.ini illisible ou sans cible : on teste celle que l'agent
        # utilisera apres restauration (agentconf.ini_2, puis le modele .tpl).
        for name in ("agentconf.ini_2", "agentconf.ini.tpl"):
            parser = configparser.ConfigParser(interpolation=None)
            try:
                raw = read(os.path.join(CONF, name), 1048576)
                if raw:
                    parser.read_string(raw.decode("utf-8-sig"))
            except Exception:
                continue
            server, port_text, note = xmpp_target(parser, name)
            if server:
                note += " ; agentconf.ini inutilisable"
                break
    if not server:
        return {"cible": "", "etat": "NON_CONFIGURE",
                "detail": "aucun serveur dans agentconf.ini, agentconf.ini_2 ni agentconf.ini.tpl"}
    note = " (" + note + ")" if note else ""
    target = server + ":" + port_text
    try:
        port = int(port_text)
    except ValueError:
        return {"cible": target, "etat": "PORT_INVALIDE", "detail": ""}
    try:
        addresses = resolve(server)
    except Exception as error:
        return {"cible": target, "etat": "ECHEC_DNS", "detail": str(error)[:120] + note}
    start = time.time()
    try:
        connection = socket.create_connection((addresses[0], port), timeout=5)
        connection.close()
    except socket.timeout:
        return {"cible": target, "etat": "TIMEOUT", "detail": "pas de reponse en 5 s" + note}
    except ConnectionRefusedError:
        return {"cible": target, "etat": "REFUSE", "detail": "port ferme sur le serveur" + note}
    except OSError as error:
        return {"cible": target, "etat": "INJOIGNABLE",
                "detail": (error.strerror or type(error).__name__)[:120] + note}
    detail = "%d ms" % int((time.time() - start) * 1000)
    if addresses and addresses != [server]:
        detail += " (" + ", ".join(addresses[:3]) + ")"
    return {"cible": target, "etat": "OK", "detail": detail + note}


def inventory_tool():
    """Agent d'inventaire choisi dans inventory.ini (glpiagent par defaut chez Medulla),
    presence du programme et version : Version.pm (lecture instantanee), sinon --version."""
    agent = "glpiagent"
    try:
        parser = configparser.ConfigParser(interpolation=None)
        parser.read(os.path.join(CONF, "inventory.ini"), encoding="utf-8")
        if parser.get("parameters", "agent", fallback="glpiagent").strip().lower() \
                not in ("", "glpiagent"):
            agent = "fusioninventory"
    except Exception:
        pass
    pf = os.environ.get("ProgramFiles", r"C:\Program Files")
    folder, package, bat = {
        "glpiagent": ("GLPI-Agent", "GLPI", "glpi-agent.bat"),
        "fusioninventory": ("FusionInventory-Agent", "FusionInventory",
                            "fusioninventory-agent.bat")}[agent]
    program = os.path.join(pf, folder, bat)
    info = {"agent": agent, "programme": "PRESENT" if os.path.isfile(program) else "ABSENT",
            "version": ""}
    if info["programme"] == "ABSENT":
        other = (os.path.join(pf, "FusionInventory-Agent", "fusioninventory-agent.bat")
                 if agent == "glpiagent" else os.path.join(pf, "GLPI-Agent", "glpi-agent.bat"))
        info["autre"] = os.path.basename(os.path.dirname(other)) if os.path.isfile(other) else ""
    module = os.path.join(pf, folder, "perl", "agent", package, "Agent", "Version.pm")
    try:
        with open(module, "rb") as stream:
            found = re.search(r"VERSION\s*=\s*['\"]([0-9][0-9.]*)",
                              stream.read(20000).decode("utf-8", "replace"))
        if found:
            info["version"] = found.group(1)
    except OSError:
        pass
    if not info["version"] and info["programme"] == "PRESENT" \
            and sys.platform.startswith("win"):
        try:
            out = subprocess.run([program, "--version"], stdout=subprocess.PIPE,
                                 stderr=subprocess.STDOUT, timeout=20).stdout
            found = re.search(r"Agent \(([0-9][0-9.]*)", out.decode("cp850", "replace"))
            info["version"] = found.group(1) if found else ""
        except Exception:
            pass
    return info


def log_dir_state():
    """Etat de C:\\Program Files\\Medulla\\var\\log (dossier des journaux de l'agent)."""
    target = os.path.join(os.path.dirname(CONF), "var", "log")
    for path in (os.path.join(os.path.dirname(CONF), "var"), target):
        if os.path.islink(path) or (os.path.exists(path) and
                                    getattr(os.lstat(path), "st_file_attributes", 0) & 0x400):
            return "LIEN_REFUSE"
    if os.path.isdir(target):
        return "PRESENT"
    if os.path.exists(target) or os.path.isfile(os.path.join(os.path.dirname(CONF), "var")):
        return "PAS_UN_DOSSIER"
    return "ABSENT"


def agentxmpp_sha():
    raw = read(os.path.join(ROOT, "agentxmpp.py"), 16777216)
    return "ABSENT" if raw is None else hashlib.sha256(raw).hexdigest()


def agent_version():
    raw = read(os.path.join(ROOT, "agentversion"), 4096)
    if raw is None:
        return "ABSENT"
    value = raw.decode("utf-8", "replace").strip()
    return value if value and len(value) <= 40 and value.isprintable() else "ILLISIBLE"


def kiosk_state():
    raw = read(PID, 4096)
    if raw is None:
        return "ABSENT"
    if b"\0" in raw:
        return "CORROMPU_NUL"
    value = raw.strip(b" \t\r\n")
    return "VALIDE" if value.isdigit() and 0 < int(value) <= 2147483647 else "INATTENDU"


def ini_state(raw, optional=False):
    if raw is None:
        return ("SANS_SURCHARGE" if optional else "ABSENT"), None
    if not raw.strip() or b"\0" in raw:
        return "INVALIDE", None
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        return "NON_VERIFIE_ENCODAGE", None
    parser = configparser.ConfigParser(interpolation=None)
    try:
        parser.read_string(text)
    except configparser.Error:
        return "INVALIDE", None
    return ("VALIDE", parser) if parser.sections() else ("INVALIDE", None)


def valid_source(raw):
    state, parser = ini_state(raw)
    if state != "VALIDE":
        return False
    try:
        return (
            all(parser.get("connection", key).strip() for key in ("server", "port", "password"))
            and 1 <= parser.getint("connection", "port") <= 65535
        )
    except (configparser.Error, ValueError):
        return False


def valid_template(raw):
    """Modele agentconf.ini.tpl : meme critere que launcher.py de l'agent
    (section [configuration_server] complete). [connection] server y est vide par
    construction : l'agent le remplit lui-meme au demarrage."""
    state, parser = ini_state(raw)
    if state != "VALIDE":
        return False
    try:
        values = [parser.get("configuration_server", key).strip()
                  for key in ("confserver", "confport", "confdomain", "keyAES32")]
        parser.get("configuration_server", "confpassword")
        return (all(values) and not any("@@" in value for value in values)
                and 1 <= int(values[1]) <= 65535)
    except (configparser.Error, ValueError):
        return False


def config_states():
    current = read(os.path.join(CONF, "agentconf.ini"), 1048576)
    local = read(os.path.join(CONF, "agentconf.ini.local"), 1048576)
    second = read(os.path.join(CONF, "agentconf.ini_2"), 1048576)
    template = read(os.path.join(CONF, "agentconf.ini.tpl"), 1048576)
    second_state = "ABSENT" if second is None else ("VALIDE" if valid_source(second) else "INVALIDE")
    template_state = "ABSENT" if template is None else ("VALIDE" if valid_template(template) else "INVALIDE")
    if second_state == "VALIDE":
        backup = "INI_2"
    elif template_state == "VALIDE":
        backup = "TPL"
    elif second is None and template is None:
        backup = "AUCUNE"
    else:
        backup = "INI_2_%s+TPL_%s" % (second_state, template_state)
    return ini_state(current)[0], ini_state(local, True)[0], backup


def sc_value(args, pattern, labels):
    result = subprocess.run(
        ["sc.exe"] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        universal_newlines=True, errors="replace", timeout=30,
    )
    if result.returncode == 1060:
        return "ABSENT"
    match = re.search(pattern, result.stdout)
    if not match:
        return "INCONNU"
    return labels.get(match.group(1), "CODE_" + match.group(1))


#@@TESTS_POSTE@@


def main():
    result = {"ok": True, "python": sys.version.split()[0]}
    # Tests reseau (XMPP, flux) en fond : ils attendent le reseau pendant que
    # l'inventaire des fichiers occupe le disque.
    network = {"xmpp": in_background({"cible": "", "etat": "ILLISIBLE", "detail": ""},
                                     xmpp_check, "xmpp"),
               "flux": in_background({}, flows, "flux")}
    result["service"] = timed("service", "INCONNU", sc_value, ["query", "medullaagent"],
                              r"STATE\s*:\s*(\d+)", STATES)
    result["demarrage"] = "" if result["service"] == "ABSENT" else timed(
        "demarrage", "INCONNU", sc_value, ["qc", "medullaagent"],
        r"START_TYPE\s*:\s*(\d+)", STARTS)
    for key, relative in (("ad_principal", "agentxmpp.py"),
                          ("ad_image", os.path.join("img_agent", "agentxmpp.py")),
                          ("ad_secours", os.path.join("agentrescue", "agentxmpp.py"))):
        result[key] = timed(key, "NON_RECONNU", ad_state, os.path.join(ROOT, relative))
    result["kiosk_pid"] = timed("kiosk_pid", "NON_RECONNU", kiosk_state)
    result["version_agent"] = timed("version", "ILLISIBLE", agent_version)
    result["lib"] = timed("fichiers", "ILLISIBLE", lib_files)
    result["agentxmpp_sha"] = timed("agentxmpp", "ILLISIBLE", agentxmpp_sha)
    result["dossier_log"] = timed("dossier_log", "ILLISIBLE", log_dir_state)
    result["os"] = timed("os", "INCONNU", os_info)
    result["inventaire_agent"] = timed("agent_inventaire", {}, inventory_tool)
    result["journal"] = timed("journal", {"etat": "ILLISIBLE", "nombre": "", "dernieres": []},
                              log_errors)
    states = timed("configuration", None, config_states)
    if states == "ACCES_REFUSE":
        states = ("ACCES_REFUSE",) * 3
    elif not isinstance(states, tuple):
        states = ("NON_RECONNU",) * 3
    result["agentconf"], result["agentconf_local"], result["config_secours"] = states
    try:
        result["disque_c_libre_go"] = round(shutil.disk_usage("C:\\").free / 1e9, 1)
    except Exception:
        result["disque_c_libre_go"] = ""
    result['tests'] = {}
#@@APPELS_TESTS_POSTE@@
    print("#ETAPE tests reseau (XMPP, flux)", flush=True)
    result["xmpp"] = result_of(network["xmpp"])
    result["flux"] = result_of(network["flux"])
    result["durees"] = STEPS
    print(json.dumps(result, ensure_ascii=True))


try:
    main()
except Exception as error:
    print(json.dumps({"ok": False, "erreur": type(error).__name__}))
'''
DIAG_PY = inject_poste_code(DIAG_PY)

# Lanceur PowerShell : fonctionne que le shell SSH par defaut soit cmd ou
# PowerShell. Il lit DIAG_PY sur stdin et le passe au Python de l'agent.
BOOTSTRAP = (
    "$p='C:\\Program Files\\Python3\\python.exe';"
    "if(-not [IO.File]::Exists($p)){"
    "[Console]::Out.WriteLine('{\"ok\": false, \"python\": \"ABSENT\"}');exit 0};"
    "$c=[Console]::In.ReadToEnd();"
    "[Console]::Out.WriteLine('#ETAPE lancement de Python');[Console]::Out.Flush();"
    "$c|&$p -I -B -;"
    "exit $LASTEXITCODE"
)
REMOTE_DIAG = (
    "powershell -NoProfile -NonInteractive -EncodedCommand "
    + base64.b64encode(BOOTSTRAP.encode("utf-16-le")).decode("ascii")
)


# Script reparer-agent-medulla.ps1 integre tel quel (seule la ligne #Requires,
# refusee hors d'un fichier .ps1, est retiree). Il est envoye au poste et
# execute en memoire, sans -DryRun : memes controles et memes securites.
REPAIR_PS1 = r'''
<#
.SYNOPSIS
Réparation locale de l'agent Medulla, sans téléchargement ni changement de version.
.DESCRIPTION
Par défaut, le script applique les corrections (droits administrateur requis).
Avec -DryRun, il réalise uniquement le diagnostic, sans rien modifier.
Le service medullaagent est arrêté, et son arrêt confirmé, avant toute écriture.
Collecte AD : désactivation ciblée des appels non protégés (copies principale,
image et secours).
kiosk.pid : mis de côté uniquement s'il contient des octets NUL.
Configuration : restaurée seulement si agentconf.ini est absent, vide ou
endommagé, depuis agentconf.ini_2 s'il est valide (serveur, port et mot de
passe renseignés), sinon depuis le modèle agentconf.ini.tpl s'il est valide
(section [configuration_server] complète, comme le vérifie l'agent). Un fichier agentconf.ini.local invalide bloque cette restauration ;
les autres corrections restent indépendantes.
Chaque fichier modifié est d'abord sauvegardé dans un sous-dossier backup de
son propre dossier (créé si besoin), sous le nom <fichier>.<AAAA-MM-JJ_HH-mm-ss>.bak
(exemple : etc\backup\agentconf.ini.2026-10-27_13-15-02.bak). En cas d'échec,
les fichiers sont remis dans leur état initial.
Un redémarrage réussi ne garantit pas la remontée du poste dans Medulla.
Encodage : enregistrer ce fichier en UTF-8 avec BOM (requis par PowerShell 5.1).
.EXAMPLE
powershell.exe -NoProfile -File .\reparer-agent-medulla.ps1 -DryRun
.EXAMPLE
powershell.exe -NoProfile -File .\reparer-agent-medulla.ps1
#>
[CmdletBinding()]
param(
    [switch]$DryRun
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$MedullaPython = @'
# -*- coding: utf-8 -*-
import ast
import base64
import configparser
import hashlib
import io
import json
from pathlib import Path
import sys
import tokenize
import warnings

warnings.simplefilter("ignore", SyntaxWarning)

STATE_LABELS = {
    "VALIDE": "valide",
    "ABSENT": "absente",
    "INVALIDE": "vide ou endommagée",
    "NON_VERIFIE_ENCODAGE": "encodage non reconnu",
    "SANS_SURCHARGE": "aucune",
}


def digest(raw):
    return hashlib.sha256(raw).hexdigest()


def read_file(path, limit, watches):
    if path.is_symlink():
        raise ValueError("Lien symbolique refusé pour des raisons de sécurité : " + str(path))
    if not path.exists():
        watches[str(path)] = "ABSENT"
        return None
    if not path.is_file() or path.stat().st_size > limit:
        raise ValueError("Type ou taille de fichier non autorisé : " + str(path))
    raw = path.read_bytes()
    if len(raw) > limit:
        raise ValueError("Fichier trop volumineux : " + str(path))
    watches[str(path)] = digest(raw)
    return raw


def catches_ad(handler):
    types = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
    catches = handler.type is None or any(
        isinstance(item, ast.Name) and item.id in ("Exception", "BaseException", "AttributeError")
        for item in types
    )
    # Un handler qui peut relancer l'erreur n'est pas considéré comme une protection.
    return catches and all(
        isinstance(node, ast.Pass) or (
            isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
            and isinstance(node.value.func, ast.Attribute)
            and isinstance(node.value.func.value, ast.Name)
            and node.value.func.value.id in ("logging", "logger")
            and node.value.func.attr in ("warning", "error", "exception", "info", "debug")
        ) for node in handler.body
    )


def neutralize_ad(raw, path):
    compile(raw, str(path), "exec")
    encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
    text = raw.decode(encoding)
    tree = ast.parse(text)
    methods = [node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "searchInfoMachine"]
    if len(methods) != 1:
        raise ValueError("Fonction searchInfoMachine introuvable ou en plusieurs exemplaires : " + str(path))
    method = methods[0]
    parents = {child: node for node in ast.walk(method) for child in ast.iter_child_nodes(node)}
    calls = [node for node in ast.walk(method) if isinstance(node, ast.Call)
             and isinstance(node.func, ast.Name) and node.func.id == "adusergroups"]
    if not calls:
        return None, "aucun appel à la collecte AD (déjà corrigé ou non concerné)."
    replacements = []
    expected = ast.parse(
        'base64.b64encode(adusergroups(lastusersession).encode("utf-8")).decode("utf-8")',
        mode="eval",
    ).body
    # Découpage identique à celui du tokenizer (\n, \r\n, \r uniquement).
    lines = io.StringIO(text, newline="").readlines()

    def offset(line, column):
        # Les colonnes AST sont des positions en octets UTF-8, même dans un fichier cp1252.
        prefix = lines[line - 1].encode("utf-8")[:column].decode("utf-8")
        return sum(len(item) for item in lines[:line - 1]) + len(prefix)

    for call in calls:
        cursor = call
        protected = False
        while cursor in parents:
            parent = parents[cursor]
            if isinstance(parent, ast.Try) and cursor in parent.body:
                if parent.handlers and catches_ad(parent.handlers[0]):
                    protected = True
                    break
            cursor = parent
        if protected:
            continue
        cursor = call
        while cursor in parents and not isinstance(cursor, ast.Assign):
            cursor = parents[cursor]
        if not isinstance(cursor, ast.Assign) or len(cursor.targets) != 1:
            raise ValueError("Appel AD non protégé de forme inattendue : " + str(path))
        target = cursor.targets[0]
        key = target.slice if isinstance(target, ast.Subscript) else None
        if hasattr(ast, "Index") and isinstance(key, ast.Index):
            key = key.value
        key_value = key.value if isinstance(key, ast.Constant) else getattr(key, "s", None)
        if not (
            isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name)
            and target.value.id == "dataobj" and key_value == "adusergroups"
            and ast.dump(cursor.value) == ast.dump(expected)
        ):
            raise ValueError("Bloc AD non protégé non reconnu, aucune modification : " + str(path))
        if not hasattr(cursor, "end_lineno"):
            raise ValueError("Python 3.8 minimum requis pour corriger la collecte AD.")
        replacements.append((
            offset(cursor.lineno, cursor.col_offset),
            offset(cursor.end_lineno, cursor.end_col_offset),
            'dataobj["adusergroups"] = ""',
        ))
    if not replacements:
        return None, "collecte AD déjà protégée, aucune modification."
    for start, end, replacement in sorted(set(replacements), reverse=True):
        text = text[:start] + replacement + text[end:]
    candidate = text.encode(encoding)
    compile(candidate, str(path), "exec")
    return candidate, "collecte AD désactivée (les groupes AD seront envoyés vides)."


def ini_state(raw, optional=False):
    if raw is None:
        return ("SANS_SURCHARGE" if optional else "ABSENT"), None
    if not raw.strip() or b"\0" in raw:
        return "INVALIDE", None
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        return "NON_VERIFIE_ENCODAGE", None
    parser = configparser.ConfigParser(interpolation=None)
    try:
        parser.read_string(text)
    except configparser.Error:
        return "INVALIDE", None
    return ("VALIDE", parser) if parser.sections() else ("INVALIDE", None)


def valid_source(raw):
    state, parser = ini_state(raw)
    if state != "VALIDE":
        return False
    try:
        return (
            all(parser.get("connection", key).strip() for key in ("server", "port", "password"))
            and 1 <= parser.getint("connection", "port") <= 65535
        )
    except (configparser.Error, ValueError):
        return False


def valid_template(raw):
    """Modele agentconf.ini.tpl : meme critere que launcher.py de l'agent
    (section [configuration_server] complete). [connection] server y est vide par
    construction : l'agent le remplit lui-meme au demarrage."""
    state, parser = ini_state(raw)
    if state != "VALIDE":
        return False
    try:
        values = [parser.get("configuration_server", key).strip()
                  for key in ("confserver", "confport", "confdomain", "keyAES32")]
        parser.get("configuration_server", "confpassword")
        return (all(values) and not any("@@" in value for value in values)
                and 1 <= int(values[1]) <= 65535)
    except (configparser.Error, ValueError):
        return False


def build_plan(root, conf_dir):
    watches, changes, messages, blocked = {}, [], [], []

    def write_change(path, raw, candidate, category):
        changes.append(dict(
            path=str(path), before="ABSENT" if raw is None else digest(raw),
            after=digest(candidate), content=base64.b64encode(candidate).decode("ascii"),
            kind="write", category=category,
        ))

    for relative in ("agentxmpp.py", "img_agent/agentxmpp.py", "agentrescue/agentxmpp.py"):
        path = root / relative
        raw = read_file(path, 4194304, watches)
        if raw is None:
            messages.append("Collecte AD : fichier absent, ignoré : " + str(path))
            if relative == "agentxmpp.py":
                blocked.append("Fichier principal de l'agent absent : installation incomplète.")
            continue
        try:
            candidate, message = neutralize_ad(raw, path)
        except (ValueError, SyntaxError, UnicodeError, LookupError):
            blocked.append("Collecte AD : code non reconnu ou invalide, fichier laissé intact : " + str(path))
            continue
        messages.append("Collecte AD (" + str(path) + ") : " + message)
        if candidate is not None:
            write_change(path, raw, candidate, "AD")

    pid = conf_dir.parent / "bin" / "kiosk.pid"
    raw = read_file(pid, 4096, watches)
    if raw is not None and b"\0" in raw:
        changes.append(dict(path=str(pid), before=digest(raw), after="ABSENT",
                            kind="rename", category="PID"))
        messages.append("kiosk.pid : corrompu (octets NUL), il sera mis de côté.")
    else:
        if raw is None:
            state = "absent, rien à faire."
        else:
            value = raw.strip(b" \t\r\n")
            state = ("valide, conservé."
                     if value.isdigit() and 0 < int(value) <= 2147483647
                     else "contenu inattendu, à vérifier manuellement (non modifié).")
        messages.append("kiosk.pid : " + state)

    destination = conf_dir / "agentconf.ini"
    current = read_file(destination, 1048576, watches)
    local = read_file(conf_dir / "agentconf.ini.local", 1048576, watches)
    state, _ = ini_state(current)
    local_state, _ = ini_state(local, optional=True)
    messages.append("Configuration agentconf.ini : " + STATE_LABELS[state]
                    + " ; surcharge agentconf.ini.local : " + STATE_LABELS[local_state] + ".")
    if local_state not in ("VALIDE", "SANS_SURCHARGE"):
        blocked.append("Fichier agentconf.ini.local invalide ou illisible : restauration de la configuration bloquée.")
    if state == "NON_VERIFIE_ENCODAGE":
        blocked.append("Encodage de agentconf.ini non reconnu : pas de restauration automatique.")
    elif state in ("ABSENT", "INVALIDE"):
        source = candidate = None
        for name, check in (("agentconf.ini_2", valid_source), ("agentconf.ini.tpl", valid_template)):
            raw = read_file(conf_dir / name, 1048576, watches)
            if check(raw):
                source, candidate = conf_dir / name, raw
                break
        if source is None:
            blocked.append("Aucune copie de secours valide (agentconf.ini_2 ou agentconf.ini.tpl) : configuration laissée en l'état.")
        elif local_state in ("VALIDE", "SANS_SURCHARGE"):
            write_change(destination, current, candidate, "CONFIGURATION")
            messages.append("Configuration : agentconf.ini sera restauré depuis " + str(source) + "."
                            + (" L'agent renseignera lui-même son serveur au démarrage."
                               if source.name.endswith(".tpl") else ""))
    else:
        messages.append("Configuration : agentconf.ini conservé (la connexion au serveur n'est pas vérifiée).")
    return dict(ok=True, watches=watches, changes=changes, messages=messages, blocked=blocked)


if __name__ == "__main__":
    try:
        if sys.version_info < (3, 8):
            raise ValueError("Python 3.8 minimum requis.")
        result = build_plan(Path(sys.argv[1]), Path(sys.argv[2]))
    except PermissionError:
        result = dict(ok=False, error="Diagnostic impossible : accès refusé à un fichier de l'agent. "
                                      "Ouvrir PowerShell en tant qu'administrateur.")
    except ValueError as error:
        # Messages produits par ce script : chemins uniquement, aucun contenu de fichier.
        result = dict(ok=False, error="Diagnostic impossible : " + str(error))
    except (OSError, SyntaxError, UnicodeError) as error:
        # Pas de contenu INI ni de ligne de code dans les erreurs affichées.
        result = dict(ok=False, error="Diagnostic impossible : erreur " + type(error).__name__ + ".")
    print(json.dumps(result, ensure_ascii=True))
    sys.exit(0 if result["ok"] else 1)
'@

function Assert-RegularPath {
    param([string]$Path)
    $item = $null
    try { $item = Get-Item -LiteralPath $Path -Force }
    catch [System.Management.Automation.ItemNotFoundException] { }
    if ($null -ne $item -and $item.PSIsContainer) { throw "Un fichier était attendu, pas un dossier : $Path" }
    $cursor = $Path
    while ($cursor) {
        $ancestor = $null
        try { $ancestor = Get-Item -LiteralPath $cursor -Force }
        catch [System.Management.Automation.ItemNotFoundException] { }
        if ($null -ne $ancestor -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
            throw "Lien ou jonction refusé pour des raisons de sécurité : $cursor"
        }
        $cursor = Split-Path -Parent $cursor
    }
}

function New-MedullaBackupPath {
    # Renvoie <dossier du fichier>\backup\<nom>.<date_heure>.bak et crée le dossier backup au besoin.
    param([string]$Path, [string]$Stamp)
    $directory = Join-Path (Split-Path -Parent $Path) 'backup'
    if (Test-Path -LiteralPath $directory) {
        $item = Get-Item -LiteralPath $directory -Force
        if (!$item.PSIsContainer) {
            throw "Impossible de créer le dossier de sauvegarde, un fichier porte déjà ce nom : $directory"
        }
        if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
            throw "Dossier de sauvegarde refusé (lien ou jonction) : $directory"
        }
    }
    else {
        New-Item -ItemType Directory -Path $directory | Out-Null
        Write-Host "Dossier de sauvegarde créé : $directory"
    }
    $name = Split-Path -Leaf $Path
    $backup = Join-Path $directory "$name.$Stamp.bak"
    $index = 1
    while (Test-Path -LiteralPath $backup) {
        $backup = Join-Path $directory "$name.$Stamp-$index.bak"
        $index++
    }
    Assert-RegularPath $backup
    return $backup
}

function Get-CurrentHash {
    param([string]$Path)
    Assert-RegularPath $Path
    if (!(Test-Path -LiteralPath $Path)) { return 'ABSENT' }
    return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}

function Get-MedullaPlan {
    param([string]$Python, [string]$Root, [string]$ConfigDirectory)
    # Envoi du programme Python en UTF-8 (PowerShell 5.1 utilise ASCII par défaut).
    $OutputEncoding = New-Object Text.UTF8Encoding($false)
    # -I : mode isolé, ignore le dossier courant et les variables PYTHON*.
    $output = $MedullaPython | & $Python -I - $Root $ConfigDirectory
    $exitCode = $LASTEXITCODE
    $plan = $null
    try { $plan = ($output -join "`n") | ConvertFrom-Json } catch { }
    if ($null -eq $plan) {
        throw "Le diagnostic n'a pas pu s'exécuter (code $exitCode). Aucune modification effectuée."
    }
    if ($exitCode -ne 0 -or !$plan.ok) {
        throw "$($plan.error)`nAucune modification effectuée."
    }
    return $plan
}

function Assert-PlanUnchanged {
    param($Plan)
    foreach ($watch in $Plan.watches.PSObject.Properties) {
        if ((Get-CurrentHash $watch.Name) -ne $watch.Value) {
            throw "Un fichier a changé depuis le diagnostic, intervention annulée : $($watch.Name)"
        }
    }
}

function Wait-MedullaStatus {
    param([string]$Status)
    $service = Get-Service -Name 'medullaagent'
    $service.WaitForStatus(
        [System.ServiceProcess.ServiceControllerStatus]::$Status,
        [TimeSpan]::FromSeconds(45)
    )
    $service.Refresh()
    if ($service.Status.ToString() -ne $Status) {
        throw "Le service medullaagent n'a pas atteint l'état $Status dans le délai de 45 secondes."
    }
}

function Restore-MedullaChanges {
    param($Applied)
    $errors = @()
    for ($index = $Applied.Count - 1; $index -ge 0; $index--) {
        $record = $Applied[$index]
        try {
            if ($record.change.before -ne 'ABSENT' -and
                (Get-CurrentHash $record.backup) -ne $record.change.before) {
                throw "sauvegarde introuvable ou altérée"
            }
            if ($record.change.kind -eq 'rename') {
                if ((Get-CurrentHash $record.change.path) -ne 'ABSENT') {
                    throw "kiosk.pid a été recréé entre-temps, il n'est pas écrasé"
                }
                Copy-Item -LiteralPath $record.backup -Destination $record.change.path
            }
            elseif ($record.change.before -eq 'ABSENT') {
                if ((Get-CurrentHash $record.change.path) -ne 'ABSENT') {
                    Remove-Item -LiteralPath $record.change.path
                }
            }
            else {
                Copy-Item -LiteralPath $record.backup -Destination $record.change.path -Force
            }
            if ((Get-CurrentHash $record.change.path) -ne $record.change.before) {
                throw "le fichier restauré ne correspond pas à l'original"
            }
            Write-Warning "Fichier remis dans son état initial : $($record.change.path)"
        }
        catch {
            $errors += "$($record.change.path) : $($_.Exception.Message)"
        }
    }
    if ($errors.Count -gt 0) {
        throw ("Retour à l'état initial incomplet, vérification manuelle nécessaire : " + ($errors -join ' ; '))
    }
}

function Assert-MedullaAdministrator {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw "Droits administrateur requis : ouvrir PowerShell en tant qu'administrateur (ou utiliser -DryRun pour le diagnostic seul)."
    }
}

function Invoke-MedullaRepair {
    $root = 'C:\Program Files\Python3\Lib\site-packages\pulse_xmpp_agent'
    $conf = 'C:\Program Files\Medulla\etc'
    $python = 'C:\Program Files\Python3\python.exe'
    $labels = @{
        AD            = 'Collecte AD désactivée'
        PID           = 'kiosk.pid mis de côté'
        CONFIGURATION = 'Configuration restaurée'
    }
    $service = Get-Service -Name 'medullaagent' -ErrorAction SilentlyContinue
    if ($null -eq $service -and !(Test-Path -LiteralPath $root)) {
        throw "Aucun agent Medulla détecté sur ce poste."
    }
    if ($null -eq $service -or !(Test-Path -LiteralPath "$root\agentxmpp.py" -PathType Leaf)) {
        throw "Installation Medulla incomplète (service ou agentxmpp.py absent). Aucune modification effectuée."
    }
    if (!(Test-Path -LiteralPath $python -PathType Leaf)) {
        throw "Python de l'agent introuvable ($python). Aucune modification effectuée."
    }
    if ($service.Status.ToString() -notin @('Running', 'Stopped')) {
        throw "Le service medullaagent est en cours de démarrage ou d'arrêt. Relancer le script dans quelques instants."
    }
    if (!$DryRun) { Assert-MedullaAdministrator }
    $mutex = New-Object Threading.Mutex($false, 'Global\MedullaLocalRepair')
    $locked = $false
    try {
        try { $locked = $mutex.WaitOne(0) }
        catch [Threading.AbandonedMutexException] {
            $locked = $true
            throw "Une exécution précédente s'est interrompue brutalement. Vérifier les sauvegardes (dossiers backup) et l'état du service avant de relancer."
        }
        if (!$locked) { throw "Une autre exécution du script est déjà en cours sur ce poste." }
        foreach ($path in @(
            "$root\agentxmpp.py", "$root\img_agent\agentxmpp.py",
            "$root\agentrescue\agentxmpp.py", "$conf\agentconf.ini",
            "$conf\agentconf.ini.local", "$conf\agentconf.ini_2",
            "$conf\agentconf.ini.tpl", 'C:\Program Files\Medulla\bin\kiosk.pid'
        )) { Assert-RegularPath $path }
        Write-Host "=== Diagnostic ==="
        $plan = Get-MedullaPlan $python $root $conf
        foreach ($message in @($plan.messages)) { Write-Host $message }
        foreach ($message in @($plan.blocked)) { Write-Warning "Non corrigé : $message" }
        $changes = @($plan.changes)
        Write-Host "$($changes.Count) correction(s) à appliquer."
        if (@($changes | Where-Object { $_.category -eq 'AD' }).Count -gt 0) {
            Write-Warning "Effet de bord : sur les fichiers corrigés, les groupes Active Directory de l'utilisateur ne remonteront plus dans Medulla."
        }
        if ($DryRun) {
            Write-Host "Mode diagnostic (-DryRun) : aucun fichier modifié, service non touché. Relancer sans -DryRun pour appliquer les corrections."
            return
        }
        if ($changes.Count -eq 0) {
            Write-Host "Rien à corriger : le service n'a pas été touché."
            if (@($plan.blocked).Count -gt 0) {
                throw "Certains points n'ont pas pu être corrigés automatiquement (voir les avertissements ci-dessus)."
            }
            return
        }
        Assert-PlanUnchanged $plan
        Write-Host "=== Corrections ==="
        $applied = New-Object 'System.Collections.Generic.List[object]'
        # Même horodatage pour toutes les sauvegardes d'une exécution.
        $stamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'
        $stopRequested = $false
        $failure = $null
        try {
            $stopRequested = $true
            Write-Host "Arrêt du service medullaagent..."
            Stop-Service -Name 'medullaagent'
            Wait-MedullaStatus 'Stopped'
            Write-Host "Service arrêté. Vérification des fichiers avant modification..."
            Assert-PlanUnchanged $plan
            foreach ($change in $changes) {
                if ((Get-Service -Name 'medullaagent').Status.ToString() -ne 'Stopped') {
                    throw "Le service a redémarré pendant l'intervention : modifications interrompues."
                }
                if ((Get-CurrentHash $change.path) -ne $change.before) {
                    throw "Un fichier a été modifié pendant l'intervention : $($change.path)"
                }
                $backup = $null
                if ($change.kind -eq 'rename' -or $change.before -ne 'ABSENT') {
                    $backup = New-MedullaBackupPath $change.path $stamp
                }
                if ($change.kind -eq 'rename') {
                    Move-Item -LiteralPath $change.path -Destination $backup
                    $applied.Add([pscustomobject]@{change=$change; backup=$backup})
                    if ((Get-CurrentHash $backup) -ne $change.before) {
                        throw "La copie de kiosk.pid ne correspond pas à l'original : $backup"
                    }
                }
                else {
                    if ($change.before -ne 'ABSENT') {
                        Copy-Item -LiteralPath $change.path -Destination $backup
                        if ((Get-CurrentHash $backup) -ne $change.before) {
                            throw "La sauvegarde ne correspond pas à l'original : $backup"
                        }
                        Write-Host "Sauvegarde créée : $backup"
                    }
                    $applied.Add([pscustomobject]@{change=$change; backup=$backup})
                    [IO.File]::WriteAllBytes($change.path, [Convert]::FromBase64String($change.content))
                }
                if ((Get-CurrentHash $change.path) -ne $change.after) {
                    throw "Le fichier écrit ne correspond pas au résultat attendu : $($change.path)"
                }
                Write-Host "$($labels[$change.category]) : $($change.path)"
                if ($change.kind -eq 'rename') { Write-Host "Ancien kiosk.pid conservé dans : $backup" }
            }
        }
        catch {
            $failure = $_.Exception.Message
            Write-Warning "Intervention interrompue : $failure"
            if ($applied.Count -gt 0) {
                Write-Warning "Remise des fichiers dans leur état initial..."
                try {
                    Stop-Service -Name 'medullaagent'
                    Wait-MedullaStatus 'Stopped'
                    Restore-MedullaChanges $applied
                }
                catch { $failure += " ; " + $_.Exception.Message }
            }
        }
        finally {
            if ($stopRequested) {
                try {
                    Write-Host "Redémarrage du service medullaagent..."
                    Start-Service -Name 'medullaagent'
                    Wait-MedullaStatus 'Running'
                    Write-Host "Service medullaagent redémarré."
                }
                catch {
                    $restartError = "ÉCHEC DU REDÉMARRAGE du service medullaagent : " + $_.Exception.Message
                    if ($null -eq $failure) { $failure = $restartError }
                    else { $failure += " ; " + $restartError }
                }
            }
        }
        if ($null -ne $failure) { throw $failure }
        Write-Host "=== Terminé ==="
        Write-Host "Corrections appliquées. Les sauvegardes sont conservées dans le dossier backup de chaque fichier modifié."
        Write-Host "Vérifier les journaux de l'agent et la remontée du poste dans Medulla."
        if (@($plan.blocked).Count -gt 0) {
            throw "Corrections appliquées, mais certains points n'ont pas pu être corrigés automatiquement (voir les avertissements ci-dessus)."
        }
    }
    finally {
        if ($locked) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}

if ($MyInvocation.InvocationName -ne '.') {
    try { Invoke-MedullaRepair }
    catch {
        Write-Error -Message $_.Exception.Message -ErrorAction Continue
        exit 1
    }
}
'''

# Lanceur de la reparation : lit le script ci-dessus (base64 UTF-8) sur stdin
# et l'execute en memoire sur le poste. Aucun fichier n'est depose sur le
# poste hors des sauvegardes creees par le script lui-meme.
REPAIR_BOOTSTRAP = (
    "[Console]::OutputEncoding=New-Object Text.UTF8Encoding($false);"
    "$b=[Console]::In.ReadToEnd();"
    "$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b.Trim()));"
    "& ([scriptblock]::Create($s));"
    "exit 0"
)
REMOTE_REPAIR = (
    "powershell -NoProfile -NonInteractive -EncodedCommand "
    + base64.b64encode(REPAIR_BOOTSTRAP.encode("utf-16-le")).decode("ascii")
)
REPAIR_PAYLOAD = base64.b64encode(REPAIR_PS1.strip().encode("utf-8")).decode("ascii")
REPAIR_SHA256 = hashlib.sha256(REPAIR_PS1.strip().encode("utf-8")).hexdigest()
TO_REPAIR = ("OUI", "PARTIEL")
CRITICAL = ("ECHEC_REDEMARRAGE", "ECHEC_RESTAURATION_INCOMPLETE", "TIMEOUT_A_VERIFIER",
            "ECHEC_DEMARRAGE", "TIMEOUT_RELANCE_A_VERIFIER", "TIMEOUT_SERVICE_REDEMARRE",
            "TIMEOUT_SERVICE_OK")

# Relance simple du service (postes joignables, sans correction, OFF dans Medulla).
# Equivalent de "sc stop medullaagent && sc start medullaagent", mais sc stop
# rendant la main avant l'arret effectif, on attend l'etat STOPPED avant sc start,
# puis l'etat RUNNING (45 s maximum a chaque fois).
RESTART_PS = r"""
$ErrorActionPreference = 'Continue'
function Get-AgentState {
    $out = (& sc.exe query medullaagent) -join ' '
    if ($out -match 'STATE\s*:\s*(\d+)') { return [int]$Matches[1] }
    if ($out -match '1060') { return -1 }
    return 0
}
$state = Get-AgentState
"Etat initial du service : $state (1 = arrete, 4 = demarre)"
if ($state -eq -1) { 'RESULT=SERVICE_ABSENT'; exit 0 }
if ($state -eq 0) { 'RESULT=ETAT_ILLISIBLE'; exit 0 }
if ($state -ne 1) {
    'sc stop : ' + ((& sc.exe stop medullaagent) -join ' ')
    for ($i = 0; $i -lt 45 -and (Get-AgentState) -ne 1; $i++) { Start-Sleep -Seconds 1 }
}
if ((Get-AgentState) -ne 1) { 'RESULT=ECHEC_ARRET'; exit 0 }
'sc start : ' + ((& sc.exe start medullaagent) -join ' ')
for ($i = 0; $i -lt 45 -and (Get-AgentState) -ne 4; $i++) { Start-Sleep -Seconds 1 }
if ((Get-AgentState) -eq 4) { 'RESULT=RELANCE_OK' } else { 'RESULT=ECHEC_DEMARRAGE' }
"""
REMOTE_RESTART = (
    "powershell -NoProfile -NonInteractive -EncodedCommand "
    + base64.b64encode(RESTART_PS.strip().encode("utf-16-le")).decode("ascii")
)

# Controle apres un delai depasse : etat du service, redemarrage s'il est reste arrete.
SERVICE_CHECK_PS = r"""
$ErrorActionPreference = 'Continue'
function Get-AgentState {
    $out = (& sc.exe query medullaagent) -join ' '
    if ($out -match 'STATE\s*:\s*(\d+)') { return [int]$Matches[1] }
    if ($out -match '1060') { return -1 }
    return 0
}
$state = Get-AgentState
"Etat du service apres le delai depasse : $state (1 = arrete, 4 = demarre)"
if ($state -eq -1) { 'RESULT=SERVICE_ABSENT'; exit 0 }
for ($i = 0; $i -lt 60 -and (Get-AgentState) -notin 1, 4; $i++) { Start-Sleep -Seconds 1 }
$state = Get-AgentState
if ($state -eq 4) { 'RESULT=DEJA_DEMARRE'; exit 0 }
if ($state -ne 1) { "RESULT=BLOQUE_ETAT_$state"; exit 0 }
'sc start : ' + ((& sc.exe start medullaagent) -join ' ')
for ($i = 0; $i -lt 45 -and (Get-AgentState) -ne 4; $i++) { Start-Sleep -Seconds 1 }
if ((Get-AgentState) -eq 4) { 'RESULT=REDEMARRE' } else { 'RESULT=ECHEC_DEMARRAGE' }
""".replace("\\\\", "\\")
REMOTE_SERVICE_CHECK = (
    "powershell -NoProfile -NonInteractive -EncodedCommand "
    + base64.b64encode(SERVICE_CHECK_PS.strip().encode("utf-16-le")).decode("ascii")
)

# Reset d'agent (Medulla serveur >= 5.6.4) : demande inscrite dans
# xmppmaster.reset_machine, traitee ensuite par le serveur Medulla.

# Reference des fichiers lib de l'agent : agent de base distribue par le serveur.
# Fichiers modifies sur le poste par la reparation (collecte AD) : jamais compares
# ni copies comme les autres ; agentxmpp.py principal a son propre traitement.
AGENT_SPECIAL_FILES = ("agentxmpp.py", "img_agent/agentxmpp.py", "agentrescue/agentxmpp.py")
LIB_REF = None          # rempli par load_lib_reference() au lancement
LIB_FIX_TIMEOUT = None   # copie des fichiers : pas de limite de temps (--timeout-copie)

FIELDS = [
    "nom_demande", "ip", "acces", "source_ip", "hostnames_medulla", "ping", "ping_ms",
    "ssh", "ssh_erreur", "erreur_xmpp",
    "hostname_ssh", "etat_medulla", "deja_on", "fiche_on", "cause_fiche_off", "diagnostic",
    "python", "version_agent", "agent_a_jour",
    "windows", "plateforme_medulla", "os_poste",
    "xmpp_cible", "xmpp_connexion", "xmpp_detail",
    "pulse_cible", "pulse_tcp", "pulse_ports_ko", "pulse_udp",
    "relais_cible", "relais_tcp", "relais_ports_ko", "relais_udp",
    "journal_erreurs", "journal_dernieres_erreurs",
    "agent_inventaire", "programme_inventaire", "version_inventaire", "inventaire_a_jour",
    "lib_etat", "lib_manquants", "lib_liste", "fichiers_differents",
    "rescue_lib_etat", "rescue_lib_manquants", "rescue_lib_differents", "rescue_lib_liste",
    "dossier_log",
    "service", "demarrage",
    "agent_principal", "ad_principal", "ad_image", "ad_secours", "kiosk_pid",
    "agentconf", "agentconf_local", "config_secours", "disque_c_libre_go",
    "nouvel_essai", "nb_problemes", "problemes", "constats_serveur", "derniere_presence",
    "reseau", "diagnostic_reseau", "enquete_serveur", "traces_serveur", "ssh_serveur",
    "ssh_demarrage", "reparable_script",
    "detail_diagnostic",
    "correction", "detail_correction", "problemes_apres", "reparable_apres",
    "relance", "detail_relance", "etat_medulla_apres", "reset", "detail_reset",
    "reconfiguration", "detail_reconfiguration",
    "inventaire", "uuid_inventaire",
]
# Une colonne test_<nom> par test ajoutable (avant "nouvel_essai").
FIELDS[FIELDS.index("nouvel_essai"):FIELDS.index("nouvel_essai")] = [
    f"test_{test['nom']}" for test in TESTS_POSTE]
# Champs du diagnostic qui entrent dans l'analyse des problemes (identiques a la V1).
CHECK_KEYS = [
    "python", "service", "demarrage", "ad_principal", "ad_image", "ad_secours",
    "kiosk_pid", "agentconf", "agentconf_local", "config_secours", "disque_c_libre_go",
]
DIAG_KEYS = CHECK_KEYS + ["version_agent", "dossier_log"]
SSH_ERRORS = (
    ("Timeout SSH", "TIMEOUT"),
    ("timed out", "TIMEOUT"),
    ("Connection refused", "REFUSE"),
    ("Permission denied", "AUTH_REFUSEE"),
    ("No route to host", "PAS_DE_ROUTE"),
    ("Network is unreachable", "PAS_DE_ROUTE"),
    ("Connection reset", "CONNEXION_FERMEE"),
    ("Connection closed", "CONNEXION_FERMEE"),
    ("kex_exchange_identification", "CONNEXION_FERMEE"),
    ("Hostname SSH", "HOSTNAME_INEXPLOITABLE"),
    ("Host key verification failed", "CLE_HOTE_REFUSEE"),
    ("IDENTIFICATION HAS CHANGED", "CLE_HOTE_REFUSEE"),
    ("Timeout XMPP", "TIMEOUT"),
    ("Agent non connecte", "NON_CONNECTE"),
    ("Erreur de l'agent", "ERREUR_AGENT"),
    ("Nom du poste", "HOSTNAME_INEXPLOITABLE"),
)


# ---------------------------------------------------------------- affichage

COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ
CODES = {"vert": "32", "rouge": "31", "jaune": "33", "gras": "1", "gris": "90"}
GOOD = {"OK", "CORRIGE", "RIEN_A_CORRIGER", "Aucun", "RUNNING", "NON", "RELANCE_OK",
        "DEMANDE", "ON"}
BAD = {"KO", "TIMEOUT", "REFUSE", "AUTH_REFUSEE", "PAS_DE_ROUTE", "CONNEXION_FERMEE",
       "AUTRE", "HOSTNAME_INEXPLOITABLE", "TIMEOUT_A_VERIFIER", "NON_CORRIGEABLE"}


def paint(text, color):
    return f"\033[{CODES[color]}m{text}\033[0m" if COLOR else text


def color_of(value):
    if value in GOOD:
        return "vert"
    if value in BAD or value.startswith(("ECHEC", "ANNULE")):
        return "rouge"
    return "jaune"


def tag(value, width=0):
    value = value or "-"
    return paint(value.ljust(width), color_of(value))


def banner(title):
    line = "=" * 78
    print("\n" + paint(line, "gras"))
    print(paint(f"  {title}", "gras"))
    print(paint(line, "gras"))


def subtitle(title):
    print("\n" + paint(f"--- {title} " + "-" * max(0, 73 - len(title)), "gras"))


def kv(label, value, extra=""):
    print(f"  {label + ' ':.<52} {value}{extra}")


def percent(count, base):
    return f"{100 * count / base:.1f}".replace(".", ",") if base else ""


def print_table(headers, rows, colored=()):
    rows = [[str(cell) for cell in row] for row in rows]
    widths = [max([len(header)] + [len(row[index]) for row in rows])
              for index, header in enumerate(headers)]
    print("  " + "  ".join(paint(header.ljust(width), "gras")
                           for header, width in zip(headers, widths)))
    print("  " + "  ".join("-" * width for width in widths))
    for row in rows:
        cells = [tag(cell, width) if index in colored else cell.ljust(width)
                 for index, (cell, width) in enumerate(zip(row, widths))]
        print("  " + "  ".join(cells).rstrip())


def short(text, limit=70):
    return text if len(text) <= limit else text[:limit - 3] + "..."


# ---------------------------------------------------------------- tests

def ip_sort_key(value):
    try:
        address = ipaddress.ip_address(value)
        return (0, address.version, int(address), "")
    except ValueError:
        return (1, 0, 0, value)


def clean(text, limit=200):
    return " ".join(text.split())[:limit]


def s_test_ping(ip):
    try:
        result = subprocess.run(
            ["ping", "-n", "-c", "1", "-W", str(PING_WAIT), ip],
            capture_output=True, text=True, errors="replace",
            timeout=PING_WAIT + 3, env=ENV,
        )
    except subprocess.TimeoutExpired:
        return "KO", ""
    if result.returncode != 0:
        return "KO", ""
    match = re.search(r"time[=<]([\d.]+)", result.stdout)
    return "OK", match.group(1).replace(".", ",") if match else ""


def s_test_ssh(ip):
    try:
        result = subprocess.run(
            [*SSH_BASE, "-n", f"pulseuser@{ip}", "hostname"],
            stdin=subprocess.DEVNULL, capture_output=True, text=True,
            encoding="utf-8", errors="replace", timeout=SSH_TIMEOUT, env=ENV,
        )
    except subprocess.TimeoutExpired:
        return "", f"Timeout SSH ({SSH_TIMEOUT} s)"
    if result.returncode != 0:
        return "", clean(result.stderr) or f"code {result.returncode}"
    names = result.stdout.strip().splitlines()
    if len(names) != 1 or not names[0].strip():
        return "", "Hostname SSH vide ou multiligne"
    hostname = names[0].strip()
    if "�" in hostname or any(char.isspace() or ord(char) < 32 for char in hostname):
        return "", "Hostname SSH inexploitable"
    return hostname, ""


def classify_ssh(error):
    for marker, label in SSH_ERRORS:
        if marker in error:
            return label
    return "AUTRE"


def configure_ssh(connect_timeout):
    """Applique le delai de connexion SSH choisi a toutes les commandes SSH."""
    global SSH_CONNECT, SSH_TIMEOUT
    SSH_CONNECT = connect_timeout
    SSH_TIMEOUT = connect_timeout + 10
    index = next(i for i, value in enumerate(SSH_BASE) if value.startswith("ConnectTimeout="))
    SSH_BASE[index] = f"ConnectTimeout={connect_timeout}"


PULSE_OVERRIDE = ""     # --serveur-pulse
PLATFORMS = {}          # IP ou nom -> plateforme dans Medulla (si la base la fournit)


def pulse_address_for(ip):
    """Adresse du serveur Medulla vue depuis le poste : celle que le serveur utilise
    pour le joindre (ou --serveur-pulse si une traduction d'adresses s'interpose)."""
    if PULSE_OVERRIDE:
        return PULSE_OVERRIDE
    try:
        family = socket.AF_INET6 if ":" in ip else socket.AF_INET
        probe_socket = socket.socket(family, socket.SOCK_DGRAM)
        probe_socket.connect((ip, 9))
        address = probe_socket.getsockname()[0]
        probe_socket.close()
        return address
    except OSError:
        return ""


def read_platforms():
    """Plateforme de chaque machine selon Medulla (colonne platform), si disponible."""
    try:
        stdout = subprocess.run(
            ["mysql", "--default-character-set=utf8mb4", "--batch", "--raw",
             "--skip-column-names", "xmppmaster", "-e",
             "SELECT JSON_OBJECT('hostname', hostname, 'ip_xmpp', ip_xmpp, 'platform', platform) "
             "FROM machines WHERE enabled IN (0, 1);"],
            capture_output=True, text=True, encoding="utf-8", check=True, timeout=60,
        ).stdout
    except (subprocess.SubprocessError, OSError):
        return {}
    platforms = {}
    for line in stdout.splitlines():
        try:
            machine = json.loads(line)
        except ValueError:
            continue
        value = (machine.get("platform") or "").strip()
        if not value:
            continue
        if machine.get("ip_xmpp"):
            platforms[str(machine["ip_xmpp"]).strip()] = value
        if machine.get("hostname"):
            platforms["nom:" + str(machine["hostname"]).casefold()] = value
    return platforms


def s_run_diag(ip):
    try:
        result = subprocess.run(
            [*SSH_BASE, f"pulseuser@{ip}", REMOTE_DIAG],
            input=f"PULSE_HOST = {pulse_address_for(ip)!r}\n" + DIAG_PY, capture_output=True, text=True,
            encoding="utf-8", errors="replace", timeout=DIAG_TIMEOUT, env=ENV,
        )
    except subprocess.TimeoutExpired as error:
        partial = error.stdout or b""
        if isinstance(partial, bytes):
            partial = partial.decode("utf-8", "replace")
        steps = [line[7:].strip() for line in partial.splitlines() if line.startswith("#ETAPE ")]
        where = (f", bloque a l'etape : {steps[-1]}" if steps
                 else ", PowerShell n'a pas demarre ou n'a rien renvoye")
        return None, f"Timeout diagnostic ({DIAG_TIMEOUT} s){where}"
    for line in reversed(result.stdout.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                return json.loads(line), ""
            except ValueError:
                break
    return None, clean(result.stderr or result.stdout) or f"code {result.returncode}"


def python_version(value):
    try:
        return tuple(int(part) for part in value.split(".")[:2])
    except (AttributeError, ValueError):
        return None


def analyse(row):
    """Liste les problemes et dit si la reparation peut les corriger."""
    problems, fixable, blocked = [], [], []
    ad_states = [row["ad_principal"], row["ad_image"], row["ad_secours"]]
    if row["python"] == "ABSENT":
        problems.append("Python de l'agent absent")
        blocked.append("python")
    if row["service"] == "ABSENT":
        problems.append("Service medullaagent absent")
        blocked.append("service")
    elif row["service"] == "INCONNU":
        problems.append("Etat du service non determine")
    elif row["service"] not in ("RUNNING", ""):
        problems.append(f"Service medullaagent {row['service']}")
    if row["demarrage"] == "DESACTIVE":
        problems.append("Service medullaagent desactive")
    if row["agent_principal"] == "ABSENT":
        problems.append("agentxmpp.py principal absent")
        blocked.append("agent")
    if "A_CORRIGER" in ad_states:
        problems.append("Collecte AD non protegee")
        version = python_version(row["python"])
        if version is not None and version < (3, 8):
            blocked.append("python<3.8")
        else:
            fixable.append("AD")
    if row["ad_principal"] == "NON_RECONNU" and "NON_RECONNU" not in ad_states[1:] \
            and row.get("_root_plan") and lib_copy_allowed(row):
        # agentxmpp.py different de celui du serveur : il est aligne (sauvegarde avant),
        # puis l'audit apres copie reverifie la collecte AD.
        problems.append("Code collecte AD non reconnu : agentxmpp.py sera aligne sur le serveur")
    elif "NON_RECONNU" in ad_states:
        problems.append("Code collecte AD non reconnu")
        blocked.append("AD")
    if row["kiosk_pid"] == "CORROMPU_NUL":
        problems.append("kiosk.pid corrompu")
        fixable.append("PID")
    elif row["kiosk_pid"] == "INATTENDU":
        problems.append("kiosk.pid contenu inattendu")
    local_ok = row["agentconf_local"] in ("VALIDE", "SANS_SURCHARGE")
    if not local_ok and row["agentconf_local"]:
        problems.append("agentconf.ini.local invalide")
        blocked.append("local")
    if row["agentconf"] in ("ABSENT", "INVALIDE"):
        problems.append(f"agentconf.ini {row['agentconf']}")
        if row["config_secours"] in ("INI_2", "TPL") and local_ok:
            fixable.append("CONFIGURATION")
        else:
            blocked.append("configuration")
    elif row["agentconf"] == "NON_VERIFIE_ENCODAGE":
        problems.append("agentconf.ini encodage non reconnu")
        blocked.append("configuration")
    if "ACCES_REFUSE" in [row[key] for key in CHECK_KEYS]:
        problems.append("Acces refuse a un fichier de l'agent")
        blocked.append("acces")
    if row.get("lib_etat") in ("A_METTRE_A_NIVEAU", "DOSSIER_ABSENT"):
        summary = (f"{row['lib_manquants']} manquant(s) ou vide(s), "
                   f"{row['fichiers_differents'] or 0} different(s)")
        if lib_copy_allowed(row):
            problems.append(f"Agent a mettre a niveau depuis le serveur ({summary})")
            fixable.append("LIB")
        else:
            problems.append(f"Agent different du serveur ({summary}) mais plus recent que la "
                            f"reference ou reference sans version : verification manuelle")
            blocked.append("lib")
    elif row.get("lib_etat") == "ACCES_REFUSE":
        problems.append("Acces refuse au dossier de l'agent")
        blocked.append("acces")
    for copy, field, fix in (("agentrescue", "rescue", "RESCUE_LIB"),):
        if row.get(f"{field}_lib_etat") != "A_METTRE_A_NIVEAU":
            continue
        summary = (f"{row[f'{field}_lib_manquants']} manquant(s) ou vide(s), "
                   f"{row[f'{field}_lib_differents']} different(s)")
        if lib_copy_allowed(row):
            problems.append(f"{copy}\\lib a mettre a niveau ({summary})")
            fixable.append(fix)
        else:
            problems.append(f"{copy}\\lib different du serveur ({summary}), agent plus recent "
                            f"que la reference : verification manuelle")
            blocked.append(fix.lower())
    if xmpp_unreachable(row):
        problems.append(f"Serveur XMPP injoignable depuis le poste ({row['xmpp_cible']} : "
                        f"{row['xmpp_connexion']}) : reseau, pare-feu ou serveur ejabberd")
        blocked.append("xmpp")
    if row.get("dossier_log") == "ABSENT":
        problems.append("Dossier Medulla\\var\\log absent")
        fixable.append("LOG")
    elif row.get("dossier_log") in ("PAS_UN_DOSSIER", "LIEN_REFUSE"):
        problems.append("Medulla\\var\\log n'est pas un dossier normal : verification manuelle")
        blocked.append("log")
    elif row.get("dossier_log") == "ACCES_REFUSE":
        problems.append("Acces refuse au dossier Medulla\\var")
        blocked.append("acces")
    disk = row["disque_c_libre_go"]
    if isinstance(disk, (int, float)) and disk < LOW_DISK_GB:
        problems.append(f"Disque C: < {LOW_DISK_GB:g} Go libre")
    # GLPI-Agent : constat seulement (sa mise a jour n'est pas faite par ce script).
    if row.get("programme_inventaire") == "ABSENT":
        name = {"glpiagent": "GLPI-Agent", "fusioninventory": "FusionInventory-Agent"}.get(
            row["agent_inventaire"], "Agent d'inventaire")
        other = f" ({row['_autre_inventaire']} present)" if row.get("_autre_inventaire") else ""
        problems.append(f"{name} absent du poste{other} : aucun inventaire possible")
    elif row.get("inventaire_a_jour") == "NON":
        problems.append(f"GLPI-Agent {row['version_inventaire']} trop ancien (< "
                        f"{version_text(GLPI_FULL_MIN)} : option --full refusee, aucun "
                        f"inventaire produit)")
    analyse_tests(row, problems, fixable, blocked)
    row["_fixes"] = ",".join(fixable)
    if fixable and blocked:
        repair = "PARTIEL"
    elif fixable:
        repair = "OUI"
    elif blocked:
        repair = "NON"
    else:
        repair = "RIEN_A_CORRIGER"
    if repair in TO_REPAIR and no_admin(row):
        repair = "NON"              # sans droits administrateur, toute correction echouerait
    return problems, repair


def load_lib_reference():
    """Inventaire de la reference serveur (tout l'agent de base) :
    {cle: (chemin relatif, taille, sha256, chemin)}."""
    base = Path(AGENT_REF_DIR)
    if not (base / "lib").is_dir():
        return None
    files = {}
    for path in sorted(base.rglob("*")):
        relative = path.relative_to(base)
        if (not path.is_file() or path.is_symlink()
                or any(part.lower() in ("__pycache__", "backup") for part in relative.parts)
                or path.suffix.lower() in (".pyc", ".pyo")
                or relative.as_posix().casefold() in AGENT_SPECIAL_FILES
                or relative.parts[0].casefold() in AGENT_IGNORED_DIRS):
            continue
        raw = path.read_bytes()
        key = relative.as_posix().casefold()
        files[key] = (relative.as_posix(), len(raw), hashlib.sha256(raw).hexdigest(), path)
    version_file = base / "agentversion"
    try:
        version = version_file.read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        version = ""
    agent = base / "agentxmpp.py"
    agentxmpp = None
    if agent.is_file() and not agent.is_symlink():
        raw = agent.read_bytes()
        agentxmpp = (len(raw), hashlib.sha256(raw).hexdigest(), agent)
    return {"files": files, "version": version, "agentxmpp": agentxmpp}


# Copie de secours de l'agent avec son propre dossier lib (agentrescue) :
# deux controles independants, compares a la meme reference lib du serveur,
# seulement si la copie existe deja sur le poste.
COPY_FIELDS = {"agentrescue": "rescue"}
# Dossiers de l'agent ignores par l'inventaire et la comparaison (ni compares, ni copies).
AGENT_IGNORED_DIRS = ("img_agent",)


def compare_copy_lib(row, present, copy):
    """Controle independant de <copy>\\lib (agentrescue) : compare a la
    reference lib du serveur, seulement si la copie existe sur le poste."""
    field = COPY_FIELDS[copy]
    row[f"_{field}_plan"] = []
    if not any(key.startswith(copy + "/") for key in present):
        row[f"{field}_lib_etat"] = "COPIE ABSENTE (non controlee)"
        return
    plan = []
    for key, (relative, size, sha, _path) in LIB_REF["files"].items():
        if not key.startswith("lib/"):
            continue
        target = f"{copy}/{key}"
        if target not in present:
            plan.append((f"{copy}/{relative}", "create", "", key))
        elif present[target][0] == 0 and size > 0:
            plan.append((f"{copy}/{relative}", "replace", "", key))
        elif present[target][0] < 0:
            continue
        elif present[target][1] != sha:
            plan.append((f"{copy}/{relative}", "update", present[target][1], key))
    missing = [item for item in plan if item[1] in ("create", "replace")]
    updated = [item for item in plan if item[1] == "update"]
    row[f"_{field}_plan"] = plan
    row[f"{field}_lib_manquants"] = len(missing)
    row[f"{field}_lib_differents"] = len(updated)
    parts = [relative.split("/", 2)[2] + (" (vide)" if mode == "replace" else "")
             for relative, mode, _b, _k in missing]
    if updated:
        parts.append(f"{len(updated)} fichier(s) a mettre a jour")
    row[f"{field}_lib_liste"] = short(", ".join(parts), 500)
    row[f"{field}_lib_etat"] = "A_METTRE_A_NIVEAU" if plan else "COMPLET (identique au serveur)"


def compare_lib(row, inventory):
    """Compare tout le dossier de l'agent du poste a la reference serveur et prepare
    le delta : fichiers manquants (create), vides (replace) et differents (update).
    Les fichiers presents seulement sur le poste ne sont jamais touches."""
    row["_lib_plan"] = []
    row["_root_plan"] = []
    if LIB_REF is None:
        row["lib_etat"] = "NON_VERIFIE (reference serveur absente)"
        return
    if inventory in ("ACCES_REFUSE", "ILLISIBLE") or inventory is None:
        row["lib_etat"] = inventory or "ILLISIBLE"
        return
    present = {}
    if inventory != "DOSSIER_ABSENT":
        present = {str(item[0]).casefold(): (item[1], item[2] if len(item) > 2 else "")
                   for item in inventory}
    plan = []
    for copy in COPY_FIELDS:
        compare_copy_lib(row, present, copy)
    for key, (relative, size, sha, _path) in LIB_REF["files"].items():
        if key not in present:
            plan.append((relative, "create", ""))
        elif present[key][0] == 0 and size > 0:
            plan.append((relative, "replace", ""))
        elif present[key][0] < 0:
            continue                      # lien ou fichier trop gros : jamais touche
        elif present[key][1] != sha:
            plan.append((relative, "update", present[key][1]))
    missing = [item for item in plan if item[1] in ("create", "replace")]
    updated = [item for item in plan if item[1] == "update"]
    row["_lib_plan"] = plan
    row["lib_manquants"] = len(missing)
    row["fichiers_differents"] = len(updated) or ""
    parts = [relative + (" (vide)" if mode == "replace" else "") for relative, mode, _ in missing]
    if updated:
        parts.append(f"{len(updated)} fichier(s) a mettre a jour")
    # Delta a appliquer : agentxmpp.py est aussi aligne sur la reference, pour que
    # le programme principal et ses bibliotheques soient de la meme version.
    current = row.get("_agentxmpp_sha") or ""
    ad_unknown = row.get("ad_principal") == "NON_RECONNU"
    if (plan or ad_unknown) and LIB_REF.get("agentxmpp") and \
            current not in ("ACCES_REFUSE", "ILLISIBLE", ""):
        if current == "ABSENT":
            row["_root_plan"] = [("agentxmpp.py", "create", "")]
        elif current != LIB_REF["agentxmpp"][1]:
            row["_root_plan"] = [("agentxmpp.py", "update", current)]
        if row["_root_plan"]:
            parts.append("agentxmpp.py " + ("absent" if current == "ABSENT" else "a aligner"))
    row["lib_liste"] = short(", ".join(parts), 500)
    if inventory == "DOSSIER_ABSENT":
        row["lib_etat"] = "DOSSIER_ABSENT"
    elif plan or row["_root_plan"]:
        row["lib_etat"] = "A_METTRE_A_NIVEAU"
    else:
        row["lib_etat"] = "COMPLET (identique au serveur)"


def lib_copy_allowed(row):
    """Mise a niveau par delta autorisee, sauf si l'agent du poste est PLUS RECENT que
    la reference serveur (on ne retrograde jamais un agent) ou si la version de la
    reference est inconnue."""
    reference = parse_version(LIB_REF["version"]) if LIB_REF else None
    agent = parse_version(row["version_agent"])
    return bool(reference) and (agent is None or agent <= reference)


XMPP_FAILURES = ("ECHEC_DNS", "TIMEOUT", "REFUSE", "INJOIGNABLE", "PORT_INVALIDE")


def xmpp_unreachable(row):
    return row.get("xmpp_connexion") in XMPP_FAILURES


def fill_flows(row, flux):
    """Resume des tests de flux : ports TCP joignables et en echec, envois UDP."""
    for target, key in (("pulse", "pulse"), ("relais", "relais")):
        row[f"{key}_cible"] = flux.get(target, "")
        tcp = flux.get(f"{target}_tcp") or {}
        udp = flux.get(f"{target}_udp") or {}
        if not tcp:
            row[f"{key}_tcp"] = "NON_TESTE" if not row[f"{key}_cible"] else ""
            continue
        failed = [f"{port} ({state})" for port, state in tcp.items() if state != "OK"]
        row[f"{key}_tcp"] = f"{len(tcp) - len(failed)}/{len(tcp)} OK"
        row[f"{key}_ports_ko"] = ", ".join(failed)
        row[f"_{key}_tcp"] = tcp
        row[f"{key}_udp"] = ", ".join(f"{port} {state.lower()}" for port, state in udp.items())


def fill_diag(row, diag, detail):
    """Reporte un diagnostic distant dans la ligne et calcule les problemes."""
    if diag is None:
        if re.search(r"powershell.*(not found|introuvable|non reconnu|No such file)", detail or "",
                     re.IGNORECASE):
            # PowerShell absent : ce n'est pas un poste Windows (Linux, macOS...).
            row["windows"] = "NON"
            row["os_poste"] = "non Windows (PowerShell absent)"
            row["diagnostic"] = "NON_REALISE (pas un poste Windows)"
            return
        row["diagnostic"], row["detail_diagnostic"] = "ECHEC", detail
        return
    row["windows"] = "OUI"
    if not diag.get("ok"):
        if diag.get("python") != "ABSENT":
            row["diagnostic"] = "ECHEC"
            row["detail_diagnostic"] = f"Erreur Python distante : {diag.get('erreur', '?')}"
            return
        row["diagnostic"], row["python"] = "PARTIEL", "ABSENT"
    else:
        system = str(diag.get("os") or "")
        if system and system != "INCONNU" and not system.casefold().startswith("windows"):
            # L'audit a pu s'executer mais le systeme n'est pas Windows : poste exclu.
            row["windows"], row["os_poste"] = "NON", system
            row["diagnostic"] = "NON_REALISE (pas un poste Windows)"
            return
        row["diagnostic"] = "OK"
        for key in DIAG_KEYS:
            row[key] = diag.get(key, "")
        row["agent_principal"] = "ABSENT" if row["ad_principal"] == "ABSENT" else "PRESENT"
        row["_agentxmpp_sha"] = diag.get("agentxmpp_sha", "")
        xmpp = diag.get("xmpp") if isinstance(diag.get("xmpp"), dict) else {}
        row["xmpp_cible"] = xmpp.get("cible", "")
        row["xmpp_connexion"] = xmpp.get("etat", "")
        row["xmpp_detail"] = xmpp.get("detail", "")
        row["os_poste"] = diag.get("os", "")
        fill_flows(row, diag.get("flux") if isinstance(diag.get("flux"), dict) else {})
        journal = diag.get("journal") if isinstance(diag.get("journal"), dict) else {}
        row["journal_erreurs"] = journal.get("nombre", "") if journal.get("etat") == "PRESENT" \
            else journal.get("etat", "")
        row["journal_dernieres_erreurs"] = " || ".join(journal.get("dernieres") or [])
        fill_inventory_tool(row, diag.get("inventaire_agent"))
        fill_tests(row, diag)
        compare_lib(row, diag.get("lib"))
    problems, repair = analyse(row)
    row["nb_problemes"] = len(problems)
    row["problemes"] = " | ".join(problems) or "Aucun"
    row["reparable_script"] = repair
    if isinstance(row["disque_c_libre_go"], (int, float)):
        row["disque_c_libre_go"] = str(row["disque_c_libre_go"]).replace(".", ",")


def name_forms(name):
    """Formes comparables d'un nom : complet et court (sans domaine), sans casse."""
    name = (name or "").strip().casefold()
    return {name, name.split(".")[0]} - {""}


def on_state(stdout):
    """Noms et IP des machines ON (enabled=1) a partir de la sortie de QUERY."""
    names, ips = set(), set()
    for line in stdout.splitlines():
        if not line:
            continue
        machine = json.loads(line)
        if machine["enabled"] != 1:
            continue
        names |= name_forms(machine["hostname"])
        try:
            ips.add(str(ipaddress.ip_address((machine["ip_xmpp"] or "").strip())))
        except ValueError:
            pass
    return names, ips


def is_on(hostname, ip, state):
    """Vrai si le poste correspond a une machine ON : par nom ou par IP."""
    names, ips = state
    return bool(name_forms(hostname) & names) or ip in ips


def stale_cause(off_names, on_names):
    """Fiche OFF dont l'IP (ou le nom) est celle d'une machine ON : meme poste
    (ancienne fiche) ou IP reattribuee a un autre poste."""
    off_forms = set().union(*(name_forms(name) for name in off_names)) if off_names else set()
    on_forms = set().union(*(name_forms(name) for name in on_names)) if on_names else set()
    if off_forms & on_forms:
        return "MEME_POSTE (ancienne fiche OFF du poste ON)"
    if not off_forms:
        return "FICHE_OFF_SANS_NOM"
    return "IP_REATTRIBUEE (la fiche OFF est celle d'un autre poste)"


def read_medulla():
    return subprocess.run(
        ["mysql", "--default-character-set=utf8mb4", "--batch", "--raw",
         "--skip-column-names", "xmppmaster", "-e", QUERY],
        capture_output=True, text=True, encoding="utf-8", check=True, timeout=60,
    ).stdout


SSH_RETRY_ON = ("TIMEOUT", "CONNEXION_FERMEE")
IN_PROGRESS = {}       # ip -> (debut, etape) des audits en cours
_PROGRESS_LOCK = threading.Lock()


def progress(ip, stage=None):
    with _PROGRESS_LOCK:
        if stage is None:
            IN_PROGRESS.pop(ip, None)
        else:
            IN_PROGRESS[ip] = (IN_PROGRESS.get(ip, (time.monotonic(),))[0], stage)


def watch_progress(stop, every=20):
    """Toutes les `every` s, liste les postes encore en cours depuis plus de `every` s."""
    while not stop.wait(every):
        now = time.monotonic()
        with _PROGRESS_LOCK:
            slow = sorted(((now - start, ip, stage) for ip, (start, stage) in IN_PROGRESS.items()
                           if now - start >= every), reverse=True)
        if slow:
            listed = ", ".join(f"{ip} ({stage}, {elapsed:.0f} s)" for elapsed, ip, stage in slow[:8])
            more = f" et {len(slow) - 8} autre(s)" if len(slow) > 8 else ""
            print(paint(_T("  ... toujours en cours : {}{} (audit limite a {} s par poste)", listed, more, DIAG_TIMEOUT), "gris"), flush=True)


def probe(ip, on_names):
    progress(ip, "XMPP" if by_xmpp(ip) else "ping/SSH")
    try:
        return _probe(ip, on_names)
    finally:
        progress(ip)


def with_retry(ip, row, label, attempt, failed):
    """Execute attempt() ; s'il echoue par timeout (failed(resultat) vrai), nouvel essai
    jusqu'a RETRIES fois. Seulement pour des operations en lecture seule."""
    result = attempt()
    for number in range(2, RETRIES + 2):
        if not failed(result):
            break
        progress(ip, f"{label} ({number}e essai)")
        vlog(_T("{} : timeout, nouvel essai dans {} s ({}e essai)", label, RETRY_PAUSE, number), ip)
        time.sleep(RETRY_PAUSE)
        result = attempt()
        row["nouvel_essai"] = (row["nouvel_essai"] + ", " if row["nouvel_essai"] else "") + \
            f"{label} : {number}e essai " + ("en echec" if failed(result) else "reussi")
    return result


def _probe(ip, on_names):
    row = dict.fromkeys(FIELDS, "")
    row["ip"] = ip
    xmpp = by_xmpp(ip)
    row["acces"] = transport_label(ip)
    row["erreur_xmpp"] = FALLBACK.get(ip, "")
    with ThreadPoolExecutor(max_workers=1) as pinger:
        ping = pinger.submit(test_ping, ip)
        hostname, error = with_retry(
            ip, row, "XMPP" if xmpp else "SSH", lambda: test_ssh(ip),
            lambda result: not result[0] and classify_ssh(result[1]) in SSH_RETRY_ON)
        row["ping"], row["ping_ms"] = ping.result()
    if not hostname:
        row["ssh"], row["ssh_erreur"] = classify_ssh(error), error
        row["diagnostic"] = "NON_REALISE"
        return row
    row["ssh"], row["hostname_ssh"] = "OK", hostname
    row["etat_medulla"] = medulla_state(hostname)
    wanted = TARGETS.get(ip, set())
    if wanted and not any(name_forms(hostname) & name_forms(name) for name in wanted):
        # Le poste qui repond n'est pas celui demande (IP reattribuee, DNS obsolete) : pas touche.
        row["diagnostic"] = "NON_REALISE (autre poste)"
        row["detail_diagnostic"] = (f"{ip} repond {hostname}, pas "
                                    f"{', '.join(sorted(wanted))} : poste non traite")
        return row
    if not xmpp:
        row["deja_on"] = "OUI" if is_on(hostname, ip, on_names) else "NON"
        if row["deja_on"] == "OUI" and ip not in FALLBACK:
            # Le poste qui repond est ON dans Medulla : la fiche OFF est obsolete. Le poste
            # est audite par XMPP (son agent est connecte), pas par SSH.
            row["diagnostic"] = "NON_REALISE (deja ON)"
            return row
    medulla = PLATFORMS.get(ip) or PLATFORMS.get("nom:" + hostname.casefold(), "")
    row["plateforme_medulla"] = medulla
    if medulla and "windows" not in medulla.casefold():
        row["windows"] = "NON"
        row["diagnostic"] = "NON_REALISE (pas un poste Windows)"
        return row
    progress(ip, "audit de l'agent")
    fill_diag(row, *with_retry(
        ip, row, "audit", lambda: diag_with_cleanup(ip),
        lambda result: result[0] is None and str(result[1]).startswith("Timeout diagnostic")))
    return row


# ---------------------------------------------------------------- correction

def classify_repair(code, log):
    if "CHEC DU RED" in log:
        return "ECHEC_REDEMARRAGE"
    if "initial incomplet" in log:
        return "ECHEC_RESTAURATION_INCOMPLETE"
    if "Corrections appliqu" in log and "certains points" in log:
        return "PARTIEL"
    if "=== Termin" in log:
        return "CORRIGE" if code == 0 else "PARTIEL"
    if "Rien " in log and "Certains points" in log:
        return "NON_CORRIGEABLE"
    if "Rien " in log and code == 0:
        return "RIEN_A_CORRIGER"
    if "remis dans son" in log:
        return "ECHEC_RESTAURE"
    if "Droits administrateur requis" in log:
        return "ECHEC_DROITS_ADMIN"
    if "Une autre ex" in log:
        return "ECHEC_DEJA_EN_COURS"
    if "depuis le diagnostic" in log:
        return "ANNULE_FICHIER_MODIFIE"
    return "ECHEC"


def summarize_log(log):
    lines = [clean(line, 300) for line in log.splitlines() if line.strip()]
    applied = [line.split(" : ")[0] for line in lines
               if line.startswith(("Collecte AD d", "kiosk.pid mis", "Configuration restaur"))
               or "agent copie(s)" in line or line.startswith("Dossier cree : ")]
    issues = [line for line in lines if any(marker in line for marker in (
        "Non corrig", "Intervention interrompue", "CHEC", "incomplet",
        "Droits administrateur", "Une autre ex", "depuis le diagnostic",
        "introuvable", "Diagnostic impossible", "Aucun agent", "incompl"))]
    parts = []
    if applied:
        parts.append("Applique : " + ", ".join(dict.fromkeys(applied)))
    if issues:
        parts.append(" | ".join(dict.fromkeys(issues)))
    if not parts and lines:
        parts.append(lines[-1])
    return short(" ; ".join(parts), 500)


def s_run_repair(ip):
    """Execute la reparation sur le poste. Session detachee : un Ctrl+C sur
    le serveur n'interrompt pas une correction deja commencee."""
    try:
        result = subprocess.run(
            [*SSH_BASE, f"pulseuser@{ip}", REMOTE_REPAIR],
            input=REPAIR_PAYLOAD, capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=REPAIR_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired as error:
        output = error.stdout or ""
        if isinstance(output, bytes):
            output = output.decode("utf-8", "replace")
        log = output + f"\n[serveur] Pas de reponse apres {REPAIR_TIMEOUT} s : etat du poste a verifier."
        return "TIMEOUT_A_VERIFIER", log
    log = result.stdout
    if result.stderr.strip():
        log += "\n--- erreurs ---\n" + result.stderr
    log += f"\n[serveur] code de sortie : {result.returncode}"
    return classify_repair(result.returncode, log), log


def s_pre_action_check(row, action):
    """Derniers controles SSH, poste par poste, juste avant toute action.
    Renvoie (statut, journal) si l'action doit etre annulee, sinon None."""
    ip = row["ip"]
    hostname, error = s_test_ssh(ip)
    if not hostname:
        # Rien n'a encore ete fait sur le poste : un second essai du controle est sans risque.
        time.sleep(RETRY_PAUSE)
        hostname, error = s_test_ssh(ip)
    if not hostname:
        net = network_diagnosis(ip, [row["hostname_ssh"]] if row["hostname_ssh"] else [])
        return "ANNULE_SSH_PERDU", (f"[serveur] Controle avant {action} : SSH impossible ({error}). "
                                    f"Reseau : {net['verdict']} ({net['detail']}).")
    if hostname.casefold() != row["hostname_ssh"].casefold():
        return ("ANNULE_POSTE_DIFFERENT",
                f"[serveur] L'IP {ip} repond maintenant {hostname} au lieu de "
                f"{row['hostname_ssh']} : poste non touche.")
    try:
        state = on_state(read_medulla())
    except (subprocess.SubprocessError, OSError, ValueError) as error:
        return ("ANNULE_BASE_ILLISIBLE",
                f"[serveur] Lecture de Medulla impossible avant {action} ({error}) : "
                "poste non touche.")
    if is_on(hostname, ip, state):
        if ip not in FALLBACK:
            return ("EXCLU_PASSE_ON",
                    f"[serveur] {hostname} est ON dans Medulla au moment de la {action} : "
                    "poste non touche.")
        # Repli SSH : la machine est ON dans Medulla mais son agent ne repondait pas en
        # XMPP. On n'agit en SSH que s'il est toujours sans session XMPP.
        if ip in LAUNCH_REFUSED:
            return None             # XMPP joignable mais lancement refuse : repli SSH voulu
        jid = xmpp_session_for(hostname)
        if jid == "?":
            return ("EXCLU_ETAT_XMPP_INCONNU",
                    f"[serveur] {hostname} est ON dans Medulla et ses sessions XMPP sont "
                    f"illisibles au moment de la {action} : poste non touche en SSH.")
        if jid:
            return ("EXCLU_AGENT_XMPP_ACTIF",
                    f"[serveur] L'agent de {hostname} a une session XMPP ({jid}) au moment de "
                    f"la {action} : a traiter par XMPP, poste non touche en SSH.")
    return None


# Mise a niveau par delta des fichiers de l'agent : code execute sur le poste par le Python de
# l'agent (envoye sur stdin, rien n'est depose hors des fichiers copies et des
# sauvegardes). Memes securites que reparer-agent-medulla.ps1 : droits
# administrateur, verrou commun Global\MedullaLocalRepair, arret confirme du
# service avant ecriture, etat reverifie apres l'arret, sauvegarde horodatee des
# fichiers remplaces dans <dossier>\backup, controle sha256 de chaque ecriture,
# retour arriere complet en cas d'echec, redemarrage du service dans tous les cas.
LIB_FIX_PY = r'''
import base64
import ctypes
import datetime
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time

ARGS = sys.argv[1:]
ROOT = ARGS[0] if ARGS else r"C:\Program Files\Python3\Lib\site-packages\pulse_xmpp_agent"
LIB = os.path.join(ROOT, "lib")
LOG = []


def say(message):
    LOG.append(message)
    print("#ETAPE " + message, flush=True)   # signe de vie pour le serveur


def finish(status):
    print(json.dumps({"status": status, "log": LOG}, ensure_ascii=True))
    sys.exit(0)


def sha(path):
    with open(path, "rb") as stream:
        return hashlib.sha256(stream.read()).hexdigest()


def service_state():
    out = subprocess.run(["sc.exe", "query", "medullaagent"], stdout=subprocess.PIPE,
                         stderr=subprocess.STDOUT, universal_newlines=True,
                         errors="replace").stdout
    for line in out.splitlines():
        if "STATE" in line and ":" in line:
            try:
                return int(line.split(":", 1)[1].split()[0])
            except ValueError:
                return 0
    return -1 if "1060" in out else 0


def wait_state(target):
    for _ in range(45):
        if service_state() == target:
            return True
        time.sleep(1)
    return service_state() == target


def reparse(path):
    try:
        return os.path.islink(path) or bool(getattr(os.lstat(path), "st_file_attributes", 0) & 0x400)
    except (FileNotFoundError, NotADirectoryError):
        return False


def safe_target(item):
    """Chemin relatif au dossier de l'agent : pas de chemin absolu, pas de '..',
    pas de dossier backup ou __pycache__, pas de lien ou jonction."""
    relative = item["path"]
    if not relative or os.path.isabs(relative) or ":" in relative or "\\" in relative:
        raise ValueError("chemin refuse : " + relative)
    parts = relative.split("/")
    if any(part in ("", ".", "..") or part.lower() in ("backup", "__pycache__")
           for part in parts):
        raise ValueError("chemin refuse : " + relative)
    target = os.path.normpath(os.path.join(ROOT, *parts))
    if not target.startswith(os.path.normpath(ROOT) + os.sep):
        raise ValueError("chemin hors du dossier de l'agent : " + relative)
    cursor = target
    while True:
        if reparse(cursor):
            raise ValueError("lien ou jonction refuse : " + cursor)
        parent = os.path.dirname(cursor)
        if parent == cursor or len(parent) < len(os.path.normpath(ROOT)):
            break
        cursor = parent
    return target


def expected_state(item, target):
    if item["mode"] == "create":
        return not os.path.exists(target)
    if item["mode"] == "update":
        return os.path.isfile(target) and sha(target) == item["before"]
    return os.path.isfile(target) and os.path.getsize(target) == 0


def load_plan():
    """Plan de copie : depose sur le poste par scp (PAYLOAD_FILE), lu puis efface."""
    path = globals().get("PAYLOAD_FILE")
    if not path:
        return json.loads(base64.b64decode(PAYLOAD).decode("utf-8"))
    try:
        with open(path, "rb") as stream:
            raw = stream.read()
        return json.loads(base64.b64decode(raw).decode("utf-8"))
    except Exception as error:
        say("Fichier transfere illisible ou incomplet : %s" % type(error).__name__)
        finish("ECHEC_TRANSFERT")
    finally:
        try:
            os.remove(path)
        except OSError:
            pass


def main():
    plan = load_plan()
    say("Plan de copie recu : %d fichier(s)." % len(plan))
    if hasattr(ctypes, "windll"):
        if not ctypes.windll.shell32.IsUserAnAdmin():
            say("Droits administrateur requis.")
            finish("ECHEC_DROITS_ADMIN")
        mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "Global\\MedullaLocalRepair")
        wait = ctypes.windll.kernel32.WaitForSingleObject(mutex, 0)
        if wait == 0x102:
            say("Une autre reparation est deja en cours sur ce poste.")
            finish("ECHEC_DEJA_EN_COURS")
        if wait == 0x80:
            say("Une execution precedente s'est interrompue brutalement : verifier les "
                "sauvegardes et l'etat du service avant de relancer.")
            ctypes.windll.kernel32.ReleaseMutex(mutex)
            finish("ECHEC_EXECUTION_PRECEDENTE_INTERROMPUE")
    if reparse(ROOT) or (os.path.exists(LIB) and reparse(LIB)):
        say("Dossier de l'agent sur un lien ou une jonction : refuse.")
        finish("ECHEC_CHEMIN_REFUSE")
    try:
        targets = [(item, safe_target(item)) for item in plan]
    except ValueError as error:
        say(str(error))
        finish("ECHEC_CHEMIN_REFUSE")
    for item, target in targets:
        if not expected_state(item, target):
            say("Fichier modifie depuis l'audit, aucune copie : " + target)
            finish("ANNULE_FICHIER_MODIFIE")
    state = service_state()
    if state == -1:
        say("Service medullaagent absent.")
        finish("ECHEC_SERVICE_ABSENT")
    if state not in (1, 4):
        say("Service en cours de demarrage ou d'arret (etat %s) : relancer plus tard." % state)
        finish("ANNULE_SERVICE_EN_TRANSITION")

    stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    applied, created_dirs = [], []
    failure = None
    say("Arret du service medullaagent...")
    if state != 1:
        subprocess.run(["sc.exe", "stop", "medullaagent"], stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL)
    try:
        if not wait_state(1):
            raise RuntimeError("le service ne s'est pas arrete dans le delai de 45 s")
        say("Service arrete. Verification des fichiers avant copie...")
        for item, target in targets:
            if not expected_state(item, target):
                raise RuntimeError("fichier modifie pendant l'intervention : " + target)
        for item, target in targets:
            if service_state() != 1:
                raise RuntimeError("le service a redemarre pendant l'intervention")
            folder = os.path.dirname(target)
            missing = []
            cursor = folder
            while not os.path.isdir(cursor):
                missing.append(cursor)
                cursor = os.path.dirname(cursor)
            for directory in reversed(missing):
                os.mkdir(directory)
                created_dirs.append(directory)
            backup = None
            if item["mode"] in ("replace", "update"):
                backup_dir = os.path.join(folder, "backup")
                if not os.path.isdir(backup_dir):
                    os.mkdir(backup_dir)
                    say("Dossier de sauvegarde cree : " + backup_dir)
                backup = os.path.join(backup_dir, "%s.%s.bak" % (os.path.basename(target), stamp))
                index = 1
                while os.path.exists(backup):
                    backup = os.path.join(backup_dir, "%s.%s-%d.bak" % (
                        os.path.basename(target), stamp, index))
                    index += 1
                shutil.copy2(target, backup)
                if sha(backup) != sha(target):
                    raise RuntimeError("sauvegarde non conforme : " + backup)
                say("Sauvegarde creee : " + backup)
            data = base64.b64decode(item["data"])
            mode = "xb" if item["mode"] == "create" else "r+b"
            with open(target, mode) as stream:
                stream.truncate(0)
                stream.write(data)
            applied.append((item, target, backup))
            if sha(target) != item["sha256"]:
                raise RuntimeError("fichier copie non conforme : " + target)
            say({"create": "Fichier copie : ", "replace": "Fichier vide remplace : ",
                 "update": "Fichier aligne sur le serveur : "}[item["mode"]] + target)
    except Exception as error:
        failure = str(error) or type(error).__name__
        say("Intervention interrompue : " + failure)
        errors = []
        for item, target, backup in reversed(applied):
            try:
                if item["mode"] == "create":
                    os.remove(target)
                else:
                    shutil.copy2(backup, target)
                    if sha(target) != sha(backup):
                        raise RuntimeError("restauration non conforme")
                say("Remis dans son etat initial : " + target)
            except Exception as restore_error:
                errors.append(target + " : " + str(restore_error))
        for directory in reversed(created_dirs):
            try:
                os.rmdir(directory)
            except OSError:
                pass
        if errors:
            say("Retour a l'etat initial incomplet : " + " ; ".join(errors))
    finally:
        say("Redemarrage du service medullaagent...")
        subprocess.run(["sc.exe", "start", "medullaagent"], stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL)
        restarted = wait_state(4)
        say("Service medullaagent redemarre." if restarted
            else "ECHEC DU REDEMARRAGE du service medullaagent.")
    if failure is not None:
        if any(line.startswith("Retour a l'etat initial incomplet") for line in LOG):
            finish("ECHEC_RESTAURATION_INCOMPLETE")
        finish("ECHEC_RESTAURE" if restarted else "ECHEC_REDEMARRAGE")
    say("%d fichier(s) de l'agent copie(s) ou mis a jour%s." % (
        sum(1 for item, _t, _b in applied if item.get("base") != "root"),
        " + agentxmpp.py aligne sur le serveur"
        if any(item.get("base") == "root" for item, _t, _b in applied) else ""))
    finish("LIB_COPIES" if restarted else "ECHEC_REDEMARRAGE")


try:
    main()
except SystemExit:
    raise
except Exception as error:
    LOG.append("Erreur inattendue avant toute copie : " + type(error).__name__)
    print(json.dumps({"status": "ECHEC", "log": LOG}, ensure_ascii=True))
'''


def s_run_lib_fix(row, parts):
    """Mise a niveau par delta depuis la reference serveur, pour les controles retenus :
    LIB (agent principal + agentxmpp.py), RESCUE_LIB
    (agentrescue\\lib). Un seul arret / redemarrage du service pour l'ensemble."""
    plan = []
    sources = []
    if "LIB" in parts:
        sources += [(relative, mode, before, "root", LIB_REF["agentxmpp"])
                    for relative, mode, before in row.get("_root_plan", [])]
        sources += [(relative, mode, before, "agent", LIB_REF["files"][relative.casefold()][1:])
                    for relative, mode, before in row["_lib_plan"]]
    for fix, field in (("RESCUE_LIB", "rescue"),):
        if fix in parts:
            sources += [(relative, mode, before, "agent", LIB_REF["files"][key][1:])
                        for relative, mode, before, key in row.get(f"_{field}_plan", [])]
    for relative, mode, before, base, (size, sha, path) in sources:
        raw = Path(path).read_bytes()
        if hashlib.sha256(raw).hexdigest() != sha:
            return "ANNULE_REFERENCE_MODIFIEE", f"[serveur] Fichier de reference modifie : {path}"
        plan.append({"path": relative, "mode": mode, "sha256": sha, "base": base,
                     "before": before, "data": base64.b64encode(raw).decode("ascii")})
    payload = base64.b64encode(json.dumps(plan).encode("utf-8")).decode("ascii")
    # Les fichiers ne passent plus par l'entree standard (bloquee au-dela d'une certaine
    # taille sur certains postes) : depot du plan par scp, puis lecture sur le poste.
    remote = f"{REMOTE_TEMP}/medulla_delta_{uuid.uuid4().hex}.b64"
    sent, detail = send_file(row["ip"], payload, remote)
    if not sent:
        return "ECHEC_TRANSFERT", f"[serveur] Envoi du plan de copie impossible : {detail}"
    code = f"PAYLOAD_FILE = {remote!r}\n" + LIB_FIX_PY
    result = run_watched([*SSH_BASE, f"pulseuser@{row['ip']}", REMOTE_DIAG], code,
                         LIB_FIX_IDLE, LIB_FIX_TIMEOUT)
    if result.reason:
        steps = [line[7:].strip() for line in result.stdout.splitlines()
                 if line.startswith("#ETAPE ")]
        last = steps[-1] if steps else "aucune (PowerShell n'a rien renvoye)"
        return ("TIMEOUT_A_VERIFIER",
                "\n".join(steps) + f"\n[serveur] {result.reason}. Derniere etape : {last}. "
                "Etat du poste a verifier.")
    for line in reversed(result.stdout.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                answer = json.loads(line)
            except ValueError:
                break
            if answer.get("python") == "ABSENT":
                return "ECHEC", "[serveur] Python de l'agent absent sur le poste."
            return answer.get("status", "ECHEC"), "\n".join(answer.get("log", []))
    return "ECHEC", (result.stdout + "\n" + result.stderr).strip() or f"code {result.returncode}"




def send_file(ip, text, remote):
    """Depose `text` dans le fichier `remote` du poste par scp (protocole classique -O).
    Delai : 60 s + 1 s par 20 Ko. Rien n'est modifie sur le poste en dehors de ce fichier."""
    limit = 60 + len(text) // 20000
    with tempfile.NamedTemporaryFile("w", encoding="ascii", suffix=".b64", delete=False) as local:
        local.write(text)
    vlog(f"envoi du plan de copie par scp : {len(text) / 1e6:.1f} Mo vers {remote} "
         f"({limit} s max)".replace(".", ",", 1), ip)
    try:
        result = subprocess.run(
            ["scp", "-O", "-q", *SSH_BASE[2:], local.name, f"pulseuser@{ip}:{remote}"],
            stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace",
            timeout=limit, env=ENV, start_new_session=True)
    except subprocess.TimeoutExpired:
        return False, f"scp sans fin apres {limit} s"
    finally:
        os.unlink(local.name)
    if result.returncode != 0:
        return False, clean(result.stderr) or f"scp code {result.returncode}"
    return True, ""


class Watched:
    def __init__(self, stdout, stderr, returncode, reason):
        self.stdout, self.stderr, self.returncode, self.reason = stdout, stderr, returncode, reason


def run_watched(command, stdin_text, idle, total=None):
    """Lance une commande distante et surveille ses signes de vie : arret si rien n'est
    recu pendant `idle` s (poste bloque), ou au bout de `total` s si une limite est fixee.
    Renvoie un objet Watched ; `reason` est vide si la commande s'est terminee seule."""
    process = subprocess.Popen(
        command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        text=True, encoding="utf-8", errors="replace", env=ENV, start_new_session=True)
    out, err = [], []
    last = [time.monotonic()]

    def feed():
        try:
            process.stdin.write(stdin_text)
            process.stdin.close()
        except (BrokenPipeError, OSError):
            pass

    def pump(stream, sink):
        for line in stream:
            sink.append(line)
            last[0] = time.monotonic()

    threads = [threading.Thread(target=feed, daemon=True),
               threading.Thread(target=pump, args=(process.stdout, out), daemon=True),
               threading.Thread(target=pump, args=(process.stderr, err), daemon=True)]
    for thread in threads:
        thread.start()
    start, reason = time.monotonic(), ""
    while process.poll() is None:
        now = time.monotonic()
        if total and now - start > total:
            reason = f"Pas de fin apres {total} s"
        elif idle and now - last[0] > idle:
            reason = f"Aucun signe de vie du poste depuis {idle} s"
        if reason:
            try:
                os.killpg(process.pid, signal.SIGKILL)   # ssh et ses eventuels enfants
            except OSError:
                process.kill()
            break
        time.sleep(0.5)
    process.wait()
    for thread in threads[1:]:
        thread.join(5)
    return Watched("".join(out), "".join(err), process.returncode, reason)


# Creation de C:\Program Files\Medulla\var\log : code execute sur le poste par le
# Python de l'agent (envoye sur stdin). Droits administrateur exiges, chemins sur
# lien ou jonction refuses, aucun fichier existant touche, retour arriere si la
# creation echoue a mi-chemin.
LOGDIR_FIX_PY = r'''
import ctypes
import json
import os
import subprocess
import sys
import time

ARGS = sys.argv[1:]
CONF = ARGS[1] if len(ARGS) > 1 else r"C:\Program Files\Medulla\etc"
BASE = os.path.dirname(CONF)
VAR = os.path.join(BASE, "var")
TARGET = os.path.join(VAR, "log")
LOG = []


def finish(status):
    print(json.dumps({"status": status, "log": LOG}, ensure_ascii=True))
    sys.exit(0)


def reparse(path):
    try:
        return os.path.islink(path) or bool(getattr(os.lstat(path), "st_file_attributes", 0) & 0x400)
    except (FileNotFoundError, NotADirectoryError):
        return False


def service_state():
    out = subprocess.run(["sc.exe", "query", "medullaagent"], stdout=subprocess.PIPE,
                         stderr=subprocess.STDOUT, universal_newlines=True,
                         errors="replace").stdout
    for line in out.splitlines():
        if "STATE" in line and ":" in line:
            try:
                return int(line.split(":", 1)[1].split()[0])
            except ValueError:
                return 0
    return -1 if "1060" in out else 0


def wait_state(target):
    for _ in range(45):
        if service_state() == target:
            return True
        time.sleep(1)
    return service_state() == target


if hasattr(ctypes, "windll"):
    if not ctypes.windll.shell32.IsUserAnAdmin():
        LOG.append("Droits administrateur requis.")
        finish("ECHEC_DROITS_ADMIN")
    MUTEX = ctypes.windll.kernel32.CreateMutexW(None, False, "Global\\MedullaLocalRepair")
    WAIT = ctypes.windll.kernel32.WaitForSingleObject(MUTEX, 0)
    if WAIT == 0x102:
        LOG.append("Une autre reparation est deja en cours sur ce poste.")
        finish("ECHEC_DEJA_EN_COURS")
    if WAIT == 0x80:
        LOG.append("Une execution precedente s'est interrompue brutalement : verifier avant "
                   "de relancer.")
        ctypes.windll.kernel32.ReleaseMutex(MUTEX)
        finish("ECHEC_EXECUTION_PRECEDENTE_INTERROMPUE")
if not os.path.isdir(BASE):
    LOG.append("Dossier Medulla introuvable : " + BASE)
    finish("ECHEC_DOSSIER_MEDULLA_ABSENT")
if any(reparse(path) for path in (BASE, VAR, TARGET)):
    LOG.append("Lien ou jonction refuse sur le chemin : " + TARGET)
    finish("ECHEC_CHEMIN_REFUSE")
if os.path.isdir(TARGET):
    LOG.append("Dossier deja present : " + TARGET)
    finish("DEJA_PRESENT")
if os.path.exists(TARGET) or (os.path.exists(VAR) and not os.path.isdir(VAR)):
    LOG.append("Un fichier porte le nom du dossier attendu, aucune modification : " + TARGET)
    finish("ECHEC_PAS_UN_DOSSIER")

# Arret du service, et arret confirme, AVANT toute modification.
STATE = service_state()
if STATE == -1:
    LOG.append("Service medullaagent absent.")
    finish("ECHEC_SERVICE_ABSENT")
if STATE not in (1, 4):
    LOG.append("Service en cours de demarrage ou d'arret (etat %s) : relancer plus tard." % STATE)
    finish("ANNULE_SERVICE_EN_TRANSITION")
LOG.append("Arret du service medullaagent...")
if STATE != 1:
    subprocess.run(["sc.exe", "stop", "medullaagent"], stdout=subprocess.DEVNULL,
                   stderr=subprocess.DEVNULL)
if not wait_state(1):
    LOG.append("Le service ne s'est pas arrete dans le delai de 45 s : aucune modification.")
    subprocess.run(["sc.exe", "start", "medullaagent"], stdout=subprocess.DEVNULL,
                   stderr=subprocess.DEVNULL)
    finish("ECHEC_ARRET")
LOG.append("Service arrete.")
if os.path.exists(TARGET):
    LOG.append("Le dossier est apparu pendant l'arret du service, aucune modification.")
    created = None
else:
    created = []


def restart(status):
    LOG.append("Redemarrage du service medullaagent...")
    subprocess.run(["sc.exe", "start", "medullaagent"], stdout=subprocess.DEVNULL,
                   stderr=subprocess.DEVNULL)
    if wait_state(4):
        LOG.append("Service medullaagent redemarre.")
        finish(status)
    LOG.append("ECHEC DU REDEMARRAGE du service medullaagent.")
    finish("ECHEC_REDEMARRAGE")


if created is None:
    restart("DEJA_PRESENT")
try:
    if not os.path.isdir(VAR):
        os.mkdir(VAR)
        created.append(VAR)
        LOG.append("Dossier cree : " + VAR)
    os.mkdir(TARGET)
    created.append(TARGET)
    if not os.path.isdir(TARGET):
        raise RuntimeError("dossier introuvable apres creation")
except Exception as error:
    LOG.append("Creation impossible : " + (str(error) or type(error).__name__))
    for path in reversed(created):
        try:
            os.rmdir(path)
            LOG.append("Retire : " + path)
        except OSError:
            LOG.append("Non retire, a verifier : " + path)
    restart("ECHEC_RESTAURE")
LOG.append("Dossier cree : " + TARGET)
restart("DOSSIER_CREE")
'''


def s_run_logdir_fix(row):
    """Cree le dossier Medulla\\var\\log sur le poste s'il manque."""
    try:
        result = subprocess.run(
            [*SSH_BASE, f"pulseuser@{row['ip']}", REMOTE_DIAG],
            input=LOGDIR_FIX_PY, capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=LOGDIR_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired:
        return ("TIMEOUT_A_VERIFIER",
                f"[serveur] Pas de reponse apres {LOGDIR_TIMEOUT} s : etat du poste a verifier.")
    for line in reversed(result.stdout.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                answer = json.loads(line)
            except ValueError:
                break
            if answer.get("python") == "ABSENT":
                return "ECHEC", "[serveur] Python de l'agent absent sur le poste."
            return answer.get("status", "ECHEC"), "\n".join(answer.get("log", []))
    return "ECHEC", (result.stdout + "\n" + result.stderr).strip() or f"code {result.returncode}"


def s_run_service_check(ip):
    """Apres un delai depasse : verifie le service et le redemarre s'il est reste arrete."""
    try:
        result = subprocess.run(
            [*SSH_BASE, "-n", f"pulseuser@{ip}", REMOTE_SERVICE_CHECK],
            stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=SERVICE_CHECK_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired:
        return "SANS_REPONSE", f"[serveur] Controle du service sans reponse apres {SERVICE_CHECK_TIMEOUT} s."
    match = re.search(r"RESULT=(\w+)", result.stdout)
    log = result.stdout.strip() + (("\n" + result.stderr.strip()) if result.stderr.strip() else "")
    return (match.group(1) if match else "ILLISIBLE"), log


# Nettoyage apres un delai depasse : arrete sur le poste les processus lances par ce
# script et restes bloques (Python lance avec "-I -" sur stdin, PowerShell lance avec
# l'une de nos commandes encodees). Le Python de l'agent n'est jamais vise.
CLEANUP_PS = r"""
# NETTOYAGE_MEDULLA
$signatures = @(__SIGNATURES__)
$count = 0
Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='powershell.exe'" |
  Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
    $proc = $_
    $line = [string]$proc.CommandLine
    $ours = $false
    if ($proc.Name -eq 'python.exe') {
        $ours = $line -match '\s-I\s+(-B\s+)?-(\s|$)'
    } else {
        foreach ($s in $signatures) { if ($line.Contains($s)) { $ours = $true } }
    }
    if ($ours) {
        try {
            Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
            $count++
            "Processus arrete : $($proc.Name) (PID $($proc.ProcessId), lance le $($proc.CreationDate))"
        } catch {
            "Impossible d'arreter $($proc.Name) (PID $($proc.ProcessId)) : $($_.Exception.Message)"
        }
    }
}
Get-ChildItem -Path "$env:SystemRoot\Temp" -Filter 'medulla_delta_*.b64' -ErrorAction SilentlyContinue |
  ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
                   "Fichier de transfert supprime : $($_.Name)" }
"RESULT=$count"
"""


def _signature(remote):
    return remote.split("-EncodedCommand ", 1)[1][:120]


def cleanup_payload():
    signatures = ", ".join(f"'{_signature(remote)}'" for remote in (
        REMOTE_DIAG, REMOTE_REPAIR, REMOTE_RESTART, REMOTE_SERVICE_CHECK))
    script = CLEANUP_PS.replace("__SIGNATURES__", signatures).strip()
    return base64.b64encode(script.encode("utf-8")).decode("ascii")


def s_run_cleanup(ip):
    """Arrete sur le poste nos processus restes bloques apres un delai depasse."""
    try:
        result = subprocess.run(
            [*SSH_BASE, f"pulseuser@{ip}", REMOTE_REPAIR], input=cleanup_payload(),
            capture_output=True, text=True, encoding="utf-8", errors="replace",
            timeout=CLEANUP_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired:
        return "SANS_REPONSE", f"[serveur] Nettoyage sans reponse apres {CLEANUP_TIMEOUT} s."
    match = re.search(r"RESULT=(\d+)", result.stdout)
    log = "\n".join(line for line in result.stdout.splitlines() if line.strip()
                    and not line.startswith("RESULT="))
    return (match.group(1) if match else "ILLISIBLE"), log or clean(result.stderr)


def diag_with_cleanup(ip):
    """Audit ; s'il depasse son delai, nettoyage des processus restes sur le poste."""
    diag, detail = run_diag(ip)
    if diag is None and str(detail).startswith("Timeout diagnostic"):
        count, _log = run_cleanup(ip)
        detail += (f" ; {count} processus bloque(s) arrete(s) sur le poste" if count.isdigit()
                   else f" ; nettoyage du poste : {count}")
    return diag, detail


def s_with_service_check(ip, status, logs):
    """Si une action a depasse son delai, controle le service et ajuste le statut."""
    if not status.startswith("TIMEOUT"):
        return status, "\n\n".join(logs)
    count, log = run_cleanup(ip)
    logs.append("===== ARRET DES PROCESSUS BLOQUES SUR LE POSTE =====\n" + (log or "aucun")
                + f"\nResultat : {count}" + (" processus arrete(s)" if count.isdigit() else ""))
    check, log = run_service_check(ip)
    logs.append("===== CONTROLE DU SERVICE APRES DELAI DEPASSE =====\n" + log
                + f"\nResultat du controle : {check}")
    if check == "REDEMARRE":
        status = "TIMEOUT_SERVICE_REDEMARRE"
    elif check == "DEJA_DEMARRE":
        status = "TIMEOUT_SERVICE_OK"
    return status, "\n\n".join(logs)


PS1_FIXES = {"AD", "PID", "CONFIGURATION"}


def s_guarded_repair(row):
    """Correction d'un poste : creation de Medulla\\var\\log (si besoin), copie des lib
    manquantes (si besoin), puis reparer-agent-medulla.ps1 (si besoin). Controles
    prealables communs. Le service est redemarre a la fin si rien d'autre ne l'a fait."""
    blocked = pre_action_check(row, "correction")
    if blocked:
        return blocked
    fixes = set(filter(None, (row.get("_fixes") or "").split(",")))
    logs, status = [], "RIEN_A_CORRIGER"
    copied = log_created = restarted = False
    if "LOG" in fixes:
        status, log = run_logdir_fix(row)
        logs.append("===== CREATION DU DOSSIER Medulla\\var\\log =====\n" + log)
        if status not in ("DOSSIER_CREE", "DEJA_PRESENT"):
            return with_service_check(row["ip"], status, logs)
        log_created = status == "DOSSIER_CREE"
        restarted = log_created   # service arrete avant la creation, redemarre apres
        status = "CORRIGE" if log_created else "RIEN_A_CORRIGER"
    lib_parts = fixes & {"LIB", "RESCUE_LIB"}
    if lib_parts:
        names = {"LIB": "agent", "RESCUE_LIB": "agentrescue\\lib"}
        status, log = run_lib_fix(row, lib_parts)
        logs.append("===== MISE A NIVEAU PAR DELTA : "
                    + ", ".join(names[part] for part in sorted(lib_parts)) + " =====\n" + log)
        if status != "LIB_COPIES":
            return with_service_check(row["ip"], status, logs)
        status, restarted, copied = "CORRIGE", True, True
    # Reparation (reparer-agent-medulla.ps1) : apres une copie, un audit rapide en lecture
    # seule decide s'il faut la lancer (collecte AD du nouvel agentxmpp.py, kiosk.pid,
    # configuration). Elle arrete le service : on ne la lance que si elle sert.
    need_repair = bool(fixes & PS1_FIXES)
    if copied:
        diag, detail = diag_with_cleanup(row["ip"])
        fresh = dict.fromkeys(FIELDS, "")
        fresh["ip"] = row["ip"]
        fill_diag(fresh, diag, detail)
        if fresh["diagnostic"] in ("OK", "PARTIEL"):
            needed = set(filter(None, (fresh.get("_fixes") or "").split(","))) & PS1_FIXES
            need_repair = bool(needed)
            logs.append("===== AUDIT APRES COPIE =====\n" + (
                "Reparation necessaire : " + ", ".join(sorted(needed)) if needed else
                "Aucune correction de reparer-agent-medulla.ps1 necessaire : reparation non "
                "lancee, service non arrete une nouvelle fois."))
        else:
            # Audit impossible : la reparation n'est lancee que si l'audit initial l'exigeait
            # ou si agentxmpp.py vient d'etre remplace (sa collecte AD n'a pas ete controlee).
            replaced = bool(row.get("_root_plan")) and "LIB" in lib_parts
            need_repair = need_repair or replaced
            logs.append("===== AUDIT APRES COPIE =====\nAudit impossible (" + (detail or "?")
                        + ") : " + ("reparation lancee par precaution (agentxmpp.py remplace "
                                    "ou correction deja prevue)." if need_repair else
                                    "reparation non lancee (aucune correction prevue)."))
    if need_repair:
        repair_status, log = run_repair(row["ip"])
        logs.append("===== REPARATION (reparer-agent-medulla.ps1) =====\n" + log)
        if repair_status.startswith("TIMEOUT"):
            return with_service_check(row["ip"], repair_status, logs)
        if copied and repair_status == "RIEN_A_CORRIGER":
            repair_status = "CORRIGE"
        elif copied and repair_status == "NON_CORRIGEABLE":
            repair_status = "PARTIEL"
        if repair_status not in ("RIEN_A_CORRIGER", "NON_CORRIGEABLE"):
            restarted = True          # la reparation a arrete puis redemarre le service
        if log_created and repair_status in ("RIEN_A_CORRIGER", "NON_CORRIGEABLE"):
            repair_status = "CORRIGE" if repair_status == "RIEN_A_CORRIGER" else "PARTIEL"
        status = repair_status
    # Dossier cree et service pas encore redemarre : relance pour que l'agent en profite.
    if log_created and not restarted:
        restart_status, log = run_restart(row["ip"])
        logs.append("===== RELANCE DU SERVICE =====\n" + log)
        if restart_status != "RELANCE_OK":
            status = restart_status
    # Une partie des problemes n'est pas reparable automatiquement : correction partielle.
    if status == "CORRIGE" and row.get("reparable_script") == "PARTIEL":
        status = "PARTIEL"
    return with_service_check(row["ip"], status, logs)


# ---------------------------------------------------------------- relance et reset

def parse_version(value):
    numbers = re.findall(r"\d+", value or "")
    return tuple(int(number) for number in numbers[:3]) if numbers else None


def version_text(version):
    return ".".join(map(str, version)) if version else "INCONNUE"


def read_server_version():
    try:
        return Path(SERVER_VERSION_FILE).read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        return ""


def agent_up_to_date(row, server_version):
    agent = parse_version(row["version_agent"])
    if not agent or not server_version:
        return "INCONNU"
    return "OUI" if agent >= server_version else "NON"


def agent_usable(row):
    """Poste audite, Windows, agent present et identique au serveur, XMPP joignable."""
    return (row["ssh"] == "OK" and row["diagnostic"] in ("OK", "PARTIEL")
            and row.get("windows") != "NON" and not xmpp_unreachable(row)
            and row.get("lib_etat") not in ("A_METTRE_A_NIVEAU", "DOSSIER_ABSENT")
            and row["service"] not in ("", "ABSENT") and row["python"] != "ABSENT")


def restart_eligible(row):
    """XMPP : agent utilisable et service arrete (ou --relancer-tout).
    SSH : poste OFF (ou repli), aucune correction applicable, agent present."""
    if by_xmpp(row["ip"]):
        return (agent_usable(row) and row["reparable_script"] not in TO_REPAIR
                and (RELAUNCH_ALL or row["service"] != "RUNNING"))
    return (row["ssh"] == "OK" and (row["deja_on"] != "OUI" or row["ip"] in FALLBACK)
            and (not xmpp_unreachable(row) or row["service"] not in ("RUNNING", ""))
            and row.get("windows") != "NON"
            and row["diagnostic"] in ("OK", "PARTIEL")
            and (row.get("lib_etat") not in ("A_METTRE_A_NIVEAU", "DOSSIER_ABSENT")
                 or row["service"] not in ("RUNNING", ""))
            and row["reparable_script"] not in TO_REPAIR
            and row["service"] != "ABSENT" and row["python"] != "ABSENT"
            and not no_admin(row))


def reset_eligible(row, server_version):
    """Reset en dernier recours (seule ecriture en base) : poste joint en SSH pendant ce
    releve ET serveur XMPP joignable depuis le poste (les 2 protocoles etaient up), agent
    a jour, serveur >= 5.6.4. Au moment du reset, il faut en plus qu'une action SSH ait
    ete faite et que l'agent ne fonctionne toujours pas (OFF dans Medulla et aucune
    session XMPP) ; une seule machine, confirmation separee."""
    return (not by_xmpp(row["ip"]) and row["ssh"] == "OK"
            and (row["deja_on"] != "OUI" or row["ip"] in FALLBACK)
            and row.get("xmpp_connexion") == "OK"
            and row["diagnostic"] in ("OK", "PARTIEL") and row.get("windows") != "NON"
            and server_version is not None and server_version >= RESET_MIN_SERVER
            and row["agent_a_jour"] == "OUI")


def is_repairable(row, targeted):
    # Une machine ON n'est jamais corrigee en SSH (elle l'est par XMPP), sauf repli.
    return (row["reparable_script"] in TO_REPAIR
            and (row["deja_on"] != "OUI" or row["ip"] in FALLBACK))


def guarded_reset(row):
    """Reset apres un dernier controle : l'agent ne doit avoir aucune session XMPP."""
    jid = xmpp_session_for(row["hostname_ssh"])
    if jid == "?":
        return ("ANNULE_ETAT_XMPP_INCONNU",
                "Sessions XMPP illisibles (ejabberdctl) : impossible de verifier que l'agent est "
                "muet, reset non demande.")
    if jid:
        return ("EXCLU_AGENT_XMPP_ACTIF",
                f"{row['hostname_ssh']} a une session XMPP ({jid}) : XMPP fonctionne, reset inutile.")
    return run_reset(row)


def s_run_restart(ip):
    try:
        result = subprocess.run(
            [*SSH_BASE, "-n", f"pulseuser@{ip}", REMOTE_RESTART],
            stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=RESTART_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired:
        return ("TIMEOUT_RELANCE_A_VERIFIER",
                f"[serveur] Pas de reponse apres {RESTART_TIMEOUT} s : etat du service a verifier.")
    log = result.stdout + (("\n--- erreurs ---\n" + result.stderr) if result.stderr.strip() else "")
    match = re.search(r"RESULT=(\w+)", result.stdout)
    if match:
        return match.group(1), log
    return "ECHEC", log + f"\n[serveur] code de sortie : {result.returncode}"


def s_guarded_restart(row):
    blocked = pre_action_check(row, "relance")
    if blocked:
        return blocked
    status, log = run_restart(row["ip"])
    return with_service_check(row["ip"], status, [log])


def sql_text(value):
    return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"


def mysql_exec(sql):
    return subprocess.run(
        ["mysql", "--default-character-set=utf8mb4", "--batch", "--raw",
         "--skip-column-names", "xmppmaster", "-e", sql],
        capture_output=True, text=True, encoding="utf-8", check=True, timeout=60,
    ).stdout


def find_jid(row, stdout):
    """JID de la fiche OFF de ce poste : meme nom reel, type machine, une seule fiche."""
    forms = name_forms(row["hostname_ssh"])
    jids = set()
    for line in stdout.splitlines():
        if not line:
            continue
        machine = json.loads(line)
        if machine["enabled"] != 0 or not machine.get("jid"):
            continue
        if (machine.get("agenttype") or "machine") != "machine":
            continue
        if name_forms(machine["hostname"]) & forms:
            jids.add(machine["jid"])
    return sorted(jids)


def run_reset(row):
    """Inscrit la demande de reset du poste dans xmppmaster.reset_machine,
    apres avoir reverifie qu'il est toujours OFF et identifie sans ambiguite."""
    try:
        stdout = read_medulla()
        state = on_state(stdout)
    except (subprocess.SubprocessError, OSError, ValueError) as error:
        return "ANNULE_BASE_ILLISIBLE", f"Lecture de Medulla impossible ({error})."
    if is_on(row["hostname_ssh"], row["ip"], state):
        return "EXCLU_PASSE_ON", f"{row['hostname_ssh']} est repasse ON : reset inutile."
    jids = find_jid(row, stdout)
    if not jids:
        return "ANNULE_JID_INTROUVABLE", (f"Aucune fiche OFF de type machine au nom de "
                                          f"{row['hostname_ssh']} : reset a faire manuellement.")
    if len(jids) > 1:
        return "ANNULE_PLUSIEURS_JID", ("Plusieurs fiches OFF pour ce poste, reset a faire "
                                        "manuellement apres choix du JID : " + ", ".join(jids))
    jid = jids[0]
    try:
        count = "SELECT COUNT(*) FROM reset_machine WHERE jid = " + sql_text(jid) + ";"
        if int((mysql_exec(count).strip() or "0").splitlines()[-1]) > 0:
            return "DEJA_DEMANDE", f"Une demande de reset existe deja pour {jid}."
        mysql_exec("INSERT INTO reset_machine (jid, reason) VALUES ("
                   + sql_text(jid) + ", " + sql_text(RESET_REASON) + ");")
        if int((mysql_exec(count).strip() or "0").splitlines()[-1]) == 0:
            return "ECHEC_ENREGISTREMENT", f"Demande non retrouvee apres insertion pour {jid}."
    except subprocess.CalledProcessError as error:
        return "ECHEC", "Erreur MySQL : " + clean(error.stderr or "")
    except (subprocess.SubprocessError, OSError, ValueError, IndexError) as error:
        return "ECHEC", f"Erreur : {error}"
    return "DEMANDE", f"Reset demande pour {jid} (raison : {RESET_REASON})."


def reconf_reasons(row, forced=False):
    """Motifs (RECONF_MOTIFS) trouves dans ce qui reste a regler sur le poste."""
    remaining = row.get("problemes_apres") or row.get("problemes") or ""
    text = " | ".join(part for part in (remaining, row.get("constats_serveur") or "") if part)
    found = [label for label, pattern in RECONF_MOTIFS.items() if re.search(pattern, text)]
    if re.search(r"GLPI-Agent", remaining):
        # Inventaire impossible tant que GLPI-Agent manque ou est trop ancien : une
        # reconfiguration n'y changerait rien.
        found = [label for label in found if label != "Machine ON sans inventaire GLPI"]
    if forced and not found:
        found = ["demandee (--reconfigurer)"]
    return found


def reconf_eligible(row, forced=False):
    """Reconfiguration (need_reconf=1) : agent audite par XMPP (connecte, machine ON : le
    serveur ignore need_reconf sur une machine OFF), audit realise, aucune action en echec,
    et un motif (ou --reconfigurer). Une seule machine, confirmation separee."""
    return (by_xmpp(row["ip"]) and row["diagnostic"] in ("OK", "PARTIEL")
            and (forced or not machine_protected(row))
            and row["correction"] not in CRITICAL and row["relance"] not in CRITICAL
            and bool(reconf_reasons(row, forced)))


def machine_records(hostname):
    """Fiches de type machine de ce nom : [{id, hostname, jid, enabled, reconf}]."""
    raw = (hostname or "").strip()
    forms = sorted(name_forms(raw) | ({raw, raw.split(".")[0]} - {""}))
    if not forms:
        return []
    stdout = mysql_exec(
        "SELECT JSON_OBJECT('id', id, 'hostname', hostname, 'jid', jid, 'enabled', enabled, "
        "'reconf', need_reconf, 'agenttype', agenttype) FROM machines WHERE hostname IN ("
        + ", ".join(sql_text(form) for form in forms) + ");")
    found = []
    for line in stdout.splitlines():
        if not line.strip().startswith("{"):
            continue
        item = json.loads(line)
        if (item.get("agenttype") or "machine") == "machine" and \
                name_forms(item.get("hostname")) & name_forms(raw) and "reconf" in item:
            found.append(item)
    return found


def guarded_reconf(row):
    """Ecrit need_reconf=1 sur la fiche ON du poste, apres un dernier controle : session XMPP
    active, une seule fiche ON, pas de demande en cours, substitut master_reconf connecte.
    Renvoie (statut, detail, id de la fiche ou None)."""
    name = row["hostname_ssh"]
    if not name:
        return "ANNULE_NOM_INCONNU", "Nom reel du poste inconnu : reconfiguration non demandee.", None
    jid = xmpp_session_for(name)
    vlog(_T("reconfiguration {} : session XMPP {}", name, jid or 'aucune'), row.get("ip") or "")
    if jid == "?":
        return ("ANNULE_ETAT_XMPP_INCONNU", "Sessions XMPP illisibles (ejabberdctl) : "
                "reconfiguration non demandee.", None)
    if not jid:
        return ("EXCLU_AGENT_SANS_SESSION", f"{name} n'a plus de session XMPP : le serveur ne "
                "reconfigure que les agents connectes.", None)
    users = {jid_parts(item)[0].casefold() for item in connected_sessions()}
    reconfigurator = next((user for user, service in SUBSTITUTES.items()
                           if service == "reconfigurator"), "master_reconf")
    vlog(_T("reconfiguration {} : substitut {} {}", name, reconfigurator, 'connecte' if reconfigurator in users else 'non connecte'), row.get("ip") or "")
    if reconfigurator not in users:
        return ("ANNULE_RECONFIGURATEUR_ABSENT", f"Substitut {reconfigurator} non connecte a "
                "ejabberd : la demande ne serait pas traitee (restart-pulse-services allsubs).",
                None)
    try:
        records = [item for item in machine_records(name) if item.get("enabled") == 1]
        if not records:
            return ("ANNULE_FICHE_ON_INTROUVABLE", f"Aucune fiche ON de type machine au nom de "
                    f"{name} : reconfiguration non demandee.", None)
        if len(records) > 1:
            return ("ANNULE_PLUSIEURS_FICHES", "Plusieurs fiches ON pour ce poste : "
                    + ", ".join(str(item.get("jid")) for item in records), None)
        record = records[0]
        ident = int(record["id"])
        vlog(_T("reconfiguration {} : fiche ON id {} ({}), need_reconf={}", name, ident, record.get('jid'), record.get('reconf')), row.get("ip") or "")
        if str(record.get("reconf")) == "1":
            return ("DEJA_DEMANDEE", f"need_reconf vaut deja 1 pour {record.get('jid')} : "
                    "demande en cours.", ident)
        mysql_exec(f"UPDATE machines SET need_reconf = 1 WHERE id = {ident} AND enabled = 1 "
                   "AND need_reconf = 0 LIMIT 1;")
        after = [item for item in machine_records(name) if int(item["id"]) == ident]
        if not after or (str(after[0].get("reconf")) != "1" and after[0].get("enabled") == 1):
            return ("ECHEC_ENREGISTREMENT", f"need_reconf non retrouve a 1 apres l'ecriture "
                    f"(fiche {ident}).", None)
    except subprocess.CalledProcessError as error:
        return "ECHEC", "Erreur MySQL : " + clean(error.stderr or ""), None
    except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError) as error:
        return "ECHEC", f"Erreur : {error}", None
    return ("DEMANDEE", f"need_reconf=1 sur la fiche {ident} ({record.get('jid')}) ; "
            f"traitement par {reconfigurator}.", ident)


def follow_reconf(name, wait):
    """Attend que l'agent revienne ON avec need_reconf=0 (reconfiguration faite par
    l'assessor). Renvoie (statut, detail). Ctrl+C : etat lu tout de suite."""
    end_time = time.monotonic() + wait
    state = "EN_ATTENTE"
    try:
        while True:
            try:
                records = machine_records(name)
            except (subprocess.SubprocessError, OSError, ValueError) as error:
                return "NON_VERIFIEE", f"Lecture de Medulla impossible ({error})."
            pending = any(str(item.get("reconf")) == "1" for item in records)
            online = any(item.get("enabled") == 1 for item in records)
            vlog(_T("reconfiguration {} : need_reconf={}, {}", name, 1 if pending else 0, 'ON' if online else 'OFF'))
            if not pending and online:
                return "RECONFIGUREE", "Agent revenu ON, need_reconf remis a 0 par le serveur."
            state = "EN_COURS" if not pending else "EN_ATTENTE"
            remaining = int(end_time - time.monotonic())
            if remaining <= 0:
                break
            if remaining % 60 < RECONF_SONDAGE:
                print(_T("  ... encore {} s", remaining), flush=True)
            time.sleep(min(RECONF_SONDAGE, max(1, remaining)))
    except KeyboardInterrupt:
        print(paint(_T("\n  Attente interrompue : controle immediat."), "jaune"))
    if state == "EN_ATTENTE":
        return ("EN_ATTENTE", "need_reconf toujours a 1 : demande pas encore prise en charge "
                "(traitement par lots) ; relancer l'audit plus tard.")
    return ("EN_COURS", "Demande prise en charge, agent pas encore revenu ON : relancer "
            "l'audit plus tard.")


def real_machines(rows):
    """Machines reelles joignables pendant l'audit (SSH ou XMPP fonctionnel), dedoublonnees
    par nom reel : {cle: {poste, ips, ssh, xmpp, rows}}."""
    machines = {}
    for row in rows:
        if row.get("ssh") != "OK" or not row.get("hostname_ssh") \
                or str(row.get("diagnostic")).startswith("NON_REALISE (autre poste"):
            continue
        item = machines.setdefault(machine_key(row), {
            "poste": row["hostname_ssh"], "ips": [], "ssh": False, "xmpp": False, "rows": []})
        if row["ip"] and row["ip"] not in item["ips"]:
            item["ips"].append(row["ip"])
        item["rows"].append(row)
        if row.get("acces") == "XMPP":
            item["xmpp"] = True
        else:
            item["ssh"] = True
    return machines


def medulla_snapshot(records):
    """Etat Medulla et sessions ejabberd a un instant : (noms ON, comptes connectes ou None)."""
    on = set()
    for machine in records:
        if machine.get("enabled") == 1:
            on |= name_forms(machine.get("hostname"))
    try:
        users = {jid_parts(jid)[0].casefold() for jid in connected_sessions()} if XMPP_ENABLED else None
    except (OSError, subprocess.SubprocessError):
        users = None
    return on, users


def mark_machines(machines, snapshot, when):
    """Renseigne medulla_<when> (ON/OFF) et session_<when> (OUI/NON/?) de chaque machine."""
    on, users = snapshot
    for item in machines.values():
        forms = name_forms(item["poste"])
        item[f"medulla_{when}"] = "ON" if forms & on else "OFF"
        if users is None:
            item[f"session_{when}"] = "?"
            continue
        accounts = {jid_parts(m.get("jid"))[0].casefold() for m in MEDULLA_ROWS
                    if m.get("jid") and name_forms(m.get("hostname")) & forms}
        shorts = {form.split(".")[0] for form in forms}
        item[f"session_{when}"] = "OUI" if any(
            user in accounts or user.split(".")[0] in shorts for user in users) else "NON"


def machine_actions(item):
    """Actions faites sur une machine reelle (toutes ses lignes)."""
    done = []
    for row in item["rows"]:
        for label, field in (("correction", "correction"), ("relance", "relance"),
                             ("reset", "reset"), ("reconfiguration", "reconfiguration")):
            value = (row.get(field) or "").split(" (")[0]
            if value and not value.startswith(("NON_", "EXCLU", "A_FAIRE", "ANNULE")):
                done.append(f"{label} {value}")
    return ", ".join(dict.fromkeys(done))


def before_after_counts(machines):
    """Compteurs avant / apres : [(libelle, avant, apres)] ; apres = None si non mesure."""
    items = list(machines.values())
    measured = any("medulla_apres" in item for item in items)

    def count(test, when):
        if when == "apres" and not measured:
            return None
        return sum(1 for item in items if test(item, when))

    total = len(items)
    rows = [
        ("Machines reelles joignables (SSH ou XMPP fonctionnel)", total, total if measured else None),
        ("  dont SSH fonctionnel", sum(item["ssh"] for item in items), None),
        ("  dont XMPP fonctionnel", sum(item["xmpp"] for item in items), None),
        ("  dont SSH et XMPP fonctionnels", sum(item["ssh"] and item["xmpp"] for item in items), None),
        ("OFF dans Medulla", count(lambda i, w: i.get(f"medulla_{w}") == "OFF", "avant"),
         count(lambda i, w: i.get(f"medulla_{w}") == "OFF", "apres")),
        ("  dont SSH fonctionnel",
         count(lambda i, w: i["ssh"] and i.get(f"medulla_{w}") == "OFF", "avant"),
         count(lambda i, w: i["ssh"] and i.get(f"medulla_{w}") == "OFF", "apres")),
        ("  dont XMPP fonctionnel",
         count(lambda i, w: i["xmpp"] and i.get(f"medulla_{w}") == "OFF", "avant"),
         count(lambda i, w: i["xmpp"] and i.get(f"medulla_{w}") == "OFF", "apres")),
        ("ON dans Medulla", count(lambda i, w: i.get(f"medulla_{w}") == "ON", "avant"),
         count(lambda i, w: i.get(f"medulla_{w}") == "ON", "apres")),
        ("Agent connecte a ejabberd (session ouverte)",
         count(lambda i, w: i.get(f"session_{w}") == "OUI", "avant"),
         count(lambda i, w: i.get(f"session_{w}") == "OUI", "apres")),
    ]
    if all(item.get("session_avant", "?") == "?" for item in items):
        rows = [row for row in rows if not row[0].startswith("Agent connecte")]
    if measured:
        rows += [
            ("Repassees ON apres reparation", None, sum(
                item.get("medulla_avant") == "OFF" and item.get("medulla_apres") == "ON"
                for item in items)),
            ("Restees OFF", None, sum(
                item.get("medulla_avant") == "OFF" and item.get("medulla_apres") == "OFF"
                for item in items)),
            ("Passees OFF (ON avant la reparation)", None, sum(
                item.get("medulla_avant") == "ON" and item.get("medulla_apres") == "OFF"
                for item in items)),
        ]
    return rows


def print_before_after(machines, acting):
    """Tableau avant / apres reparation des machines reelles joignables."""
    counts = before_after_counts(machines)
    total = len(machines)

    def cell(value):
        if value is None:
            return "-"
        return f"{value}" + (f" ({percent(value, total)} %)" if total and value != total else "")

    print_table([_T("Machines reelles"), _T("Avant reparation"), _T("Apres reparation")],
                [[tr(label.strip()) if not label.startswith("  ") else "  " + tr(label.strip()),
                  cell(before), cell(after)] for label, before, after in counts])
    if not acting:
        print(paint(_T("  Audit seul : aucune reparation, pas de mesure apres."), "gris"))
    elif not any("medulla_apres" in item for item in machines.values()):
        print(paint(_T("  Etat apres reparation non mesure (lecture de Medulla impossible ou interruption)."),
                    "jaune"))
    return counts


def before_after_stats(machines):
    """Lignes de statistiques.csv (section Avant / apres reparation)."""
    total = len(machines)
    stats = []
    for label, before, after in before_after_counts(machines):
        for when, value in (("avant", before), ("apres", after)):
            if value is not None:
                stats.append(dict(section="Avant / apres reparation (machines reelles joignables)",
                                  indicateur=f"{label.strip()} ({when})", nombre=value, base=total,
                                  pourcentage=percent(value, total) if total else ""))
    return stats


INVENTORY_MISSING = r"Aucun inventaire genere sur le poste|Dernier inventaire genere il y a"


def inventory_missing(row):
    """Inventaire absent ou trop ancien sur le poste (test inventaire), GLPI-Agent correct."""
    remaining = row.get("problemes_apres") or row.get("problemes") or ""
    return row.get("inventaire_a_jour") == "OUI" and bool(re.search(INVENTORY_MISSING, remaining))


def print_server_detail(text):
    """Tableau de detail d'un constat serveur (30 lignes au plus)."""
    if text not in SERVER_DETAILS:
        return
    fields, labels, lines, filename = SERVER_DETAILS[text]
    table = [[str(line.get(field, "")) for field in fields] for line in lines[:30]]
    headers = [tr(label) for label in labels]
    print_table(["   " + headers[0]] + headers[1:], [["   " + row[0]] + row[1:] for row in table])
    if len(lines) > 30:
        print(_T("      ... et {} autre(s) ligne(s) : {}", len(lines) - 30, filename))
    else:
        print(paint(_T("      Detail : {}", filename), "gris"))


def write_server_details(output):
    """Ecrit le detail des constats serveur (un fichier par constat) ; renvoie les chemins."""
    written = []
    for fields, labels, lines, filename in SERVER_DETAILS.values():
        path = output / filename
        COLUMN_LABELS.update({field: label for field, label in zip(fields, labels)
                              if field not in COLUMN_LABELS})
        write_csv(path, fields, lines)
        written.append(path)
    return written


PROTECTED = "EXCLU_MACHINE_FONCTIONNELLE (ON dans Medulla, agent joignable : audit seul ; --full)"


def machine_works(row):
    """Machine qui fonctionne : ON dans Medulla (au lancement) et agent qui repond en XMPP."""
    if not (by_xmpp(row["ip"]) and row.get("ssh") == "OK"):
        return False
    forms = name_forms(row.get("hostname_ssh"))
    for item in (row.get("hostnames_medulla") or "").split(","):
        forms |= name_forms(re.sub(r"\s*\(.*$", "", item))
    return any(machine.get("enabled") == 1 and name_forms(machine.get("hostname")) & forms
               for machine in MEDULLA_ROWS)


def machine_protected(row):
    """Vrai si le script ne doit pas modifier cette machine (voir MODIFIER_MACHINES_FONCTIONNELLES)."""
    return not MODIFIER_MACHINES_FONCTIONNELLES and machine_works(row)


def ask_yes_no(question):
    """Question oui / non en mode interactif (non par defaut)."""
    if not sys.stdin.isatty():
        return False
    try:
        answer = input(paint(f"\n  {tr(question)} [o/N] ", "gras")).strip().lower()
    except EOFError:
        return False
    return answer in ("o", "oui", "y", "yes", "s", "si", "sí", "j", "ja")


# Diagnostic reseau des postes injoignables (lecture seule : connexion TCP, ip route, ip neigh, DNS)
NETWORK_FIRST = ("OUVERT", "REFUSE", "DNS_AUTRE_IP", "PAS_DE_ROUTE")


def tcp_probe(ip, port=22, timeout=5):
    """Etat du port vu du serveur : OUVERT, REFUSE, TIMEOUT, HOTE_INJOIGNABLE, PAS_DE_ROUTE."""
    import errno
    probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    probe.settimeout(timeout)
    try:
        probe.connect((ip, port))
        return "OUVERT"
    except socket.timeout:
        return "TIMEOUT"
    except ConnectionRefusedError:
        return "REFUSE"
    except OSError as error:
        if error.errno == errno.EHOSTUNREACH:
            return "HOTE_INJOIGNABLE"
        if error.errno == errno.ENETUNREACH:
            return "PAS_DE_ROUTE"
        return f"ERREUR_{errno.errorcode.get(error.errno, error.errno)}"
    finally:
        probe.close()


def route_info(ip):
    """Route du serveur vers l'IP (ip route get) : {via, dev} ; via vide = meme sous-reseau."""
    if not shutil.which("ip"):
        return {}
    try:
        out = subprocess.run(["ip", "route", "get", ip], capture_output=True, text=True,
                             timeout=5).stdout
    except (subprocess.SubprocessError, OSError):
        return {}
    via = re.search(r"\bvia (\S+)", out)
    dev = re.search(r"\bdev (\S+)", out)
    return {"via": via.group(1) if via else "", "dev": dev.group(1) if dev else ""} if dev else {}


def neigh_state(ip):
    """Etat ARP de l'IP (ip neigh) : REACHABLE, STALE, FAILED, INCOMPLETE... ou vide."""
    if not shutil.which("ip"):
        return ""
    try:
        out = subprocess.run(["ip", "neigh", "show", ip], capture_output=True, text=True,
                             timeout=5).stdout.split()
    except (subprocess.SubprocessError, OSError):
        return ""
    return out[-1] if out else ""


def network_diagnosis(ip, names):
    """Diagnostic reseau d'un poste injoignable : {code, verdict, famille, action, detail}."""
    port = tcp_probe(ip)
    route = route_info(ip)
    arp = neigh_state(ip) if route and not route.get("via") else ""
    dns = {}
    for name in names[:2]:
        found = resolve_name(name)
        if found:
            dns[name] = found
    other = {name: ips for name, ips in dns.items() if ip not in ips}
    detail = [f"port 22 {port}"]
    if route:
        detail.append(f"route via {route['via']} ({route['dev']})" if route["via"]
                      else f"meme sous-reseau ({route['dev']})")
    if arp:
        detail.append(f"ARP {arp}")
    for name, ips in dns.items():
        detail.append(f"DNS {name} -> {', '.join(ips)}")
    if not dns and names:
        detail.append(f"DNS {names[0]} inconnu")
    result = {"code": port, "detail": " ; ".join(detail)}

    def verdict(code, text, family, action):
        result.update(code=code, verdict=text, famille=family, action=action)
        return result

    if port == "OUVERT":
        return verdict("OUVERT", "Port 22 ouvert maintenant : coupure passagere pendant l'audit",
                       "Coupure reseau passagere", "Relancer l'audit sur ce poste (--cible)")
    if port == "REFUSE":
        return verdict("REFUSE", "Poste allume mais port 22 ferme : sshdaemon (OpenSSH Medulla) "
                       "arrete ou absent", "sshdaemon arrete (port 22 ferme)",
                       "Demarrer sshdaemon sur le poste (ou par XMPP si l'agent revient) ; "
                       "sinon reinstaller l'agent")
    if other:
        name, ips = next(iter(other.items()))
        return verdict("DNS_AUTRE_IP", f"Le DNS donne {name} a l'IP {', '.join(ips)} : IP de "
                       "Medulla perimee (DHCP)", "IP perimee dans Medulla (le DNS donne une autre IP)",
                       f"Relancer l'audit par le nom : --cible {name}")
    if port == "PAS_DE_ROUTE":
        return verdict("PAS_DE_ROUTE", "Aucune route du serveur vers ce reseau",
                       "Pas de route vers le reseau du poste",
                       "Corriger le routage du serveur Medulla (ip route) ou passer par un relais")
    if route and not route.get("via") and arp in ("FAILED", "INCOMPLETE", ""):
        return verdict("ARP_SANS_REPONSE", "Aucun poste ne repond a cette IP sur le reseau local "
                       "(ARP sans reponse) : poste eteint ou IP changee",
                       "Poste eteint ou IP changee (ARP sans reponse)",
                       "Allumer le poste ou retrouver sa nouvelle IP (DHCP), puis relancer l'audit")
    if port == "HOTE_INJOIGNABLE":
        return verdict("ROUTEUR_REFUSE", f"Le routeur {route.get('via') or '?'} signale l'IP "
                       "injoignable : poste eteint, IP changee ou filtrage (REJECT)",
                       "IP injoignable signalee par le routeur",
                       "Verifier que le poste est allume et son IP ; sinon le filtrage entre "
                       "le serveur et ce sous-reseau")
    if port == "TIMEOUT" and arp in ("REACHABLE", "STALE", "DELAY", "PERMANENT"):
        return verdict("PORT_FILTRE", "Poste present sur le reseau local mais port 22 sans reponse : "
                       "pare-feu du poste", "Port 22 filtre par le pare-feu du poste",
                       "Autoriser le port 22 entrant (sshdaemon) dans le pare-feu du poste")
    if port == "TIMEOUT":
        return verdict("SANS_REPONSE", "Aucune reponse (port 22) : poste eteint, ou pare-feu qui "
                       "bloque sans repondre", "Aucune reponse reseau",
                       "Verifier que le poste est allume ; sinon le pare-feu (ports 22 et 5222)")
    return verdict(port, f"Erreur reseau {port}", "Erreur reseau", "Verifier le reseau du poste")


def network_diagnosis_all(rows, infos):
    """Diagnostic reseau en parallele des postes injoignables qui ont une IP."""
    targets = [row for row in rows if is_ip(row.get("ip") or "")]
    if not targets:
        return {}
    with ThreadPoolExecutor(max_workers=32) as pool:
        results = pool.map(lambda row: network_diagnosis(row["ip"], infos[id(row)]["names"]), targets)
        found = {id(row): result for row, result in zip(targets, results)}
    for row in targets:
        vlog(_T("reseau : {} -> {}", found[id(row)]['detail'], found[id(row)]['verdict']), row["ip"])
    return found


def no_admin(row):
    """Compte de connexion SSH sans droits administrateur sur le poste (test droits_admin)."""
    value = (row.get("_tests") or {}).get("droits_admin")
    return value is False or (isinstance(value, dict) and value.get("admin") is False)


def blacklist_applied():
    """Vrai si le substitut d'enregistrement a redemarre apres la derniere modification de
    registeryagent.ini.local (la blacklist est alors prise en compte) ; None si inconnu."""
    paths = [path for path in (REGISTRY_INI, REGISTRY_INI + ".local") if os.path.isfile(path)]
    if not paths or not shutil.which("systemctl"):
        return None
    changed = max(os.path.getmtime(path) for path in paths)
    try:
        started = subprocess.run(["systemctl", "show", "--value", "-p", "ActiveEnterTimestamp",
                                  "pulse-xmpp-master-substitute-registration"],
                                 capture_output=True, text=True, timeout=10).stdout.strip()
        if not started or started == "n/a":
            return None
        epoch = subprocess.run(["date", "-d", started, "+%s"], capture_output=True, text=True,
                               timeout=10).stdout.strip()
        return int(epoch) >= changed
    except (subprocess.SubprocessError, OSError, ValueError):
        return None


def mac_reconf(name):
    """need_reconf=1 sur la fiche d'une machine enregistree avec une MAC blacklistee.
    Machine ON : reconfiguree dans la minute ; OFF : a son retour ON (le serveur ne traite
    need_reconf que pour les machines ON). Renvoie (statut, detail)."""
    try:
        records = machine_records(name)
        if not records:
            return "ANNULE_FICHE_INTROUVABLE", f"Aucune fiche de type machine au nom de {name}."
        if len(records) > 1:
            return "ANNULE_PLUSIEURS_FICHES", "Plusieurs fiches : " + ", ".join(
                str(item.get("jid")) for item in records)
        record = records[0]
        ident = int(record["id"])
        if str(record.get("reconf")) == "1":
            return "DEJA_DEMANDEE", f"need_reconf vaut deja 1 pour {record.get('jid')}."
        mysql_exec(f"UPDATE machines SET need_reconf = 1 WHERE id = {ident} AND need_reconf = 0 "
                   "LIMIT 1;")
        after = [item for item in machine_records(name) if int(item["id"]) == ident]
        if not after or str(after[0].get("reconf")) != "1":
            if after and after[0].get("enabled") == 0 and record.get("enabled") == 1:
                return "DEMANDEE", f"Reconfiguration deja prise en charge (fiche {ident})."
            return "ECHEC_ENREGISTREMENT", f"need_reconf non retrouve a 1 (fiche {ident})."
    except subprocess.CalledProcessError as error:
        return "ECHEC", "Erreur MySQL : " + clean(error.stderr or "")
    except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError) as error:
        return "ECHEC", f"Erreur : {error}"
    if record.get("enabled") == 1:
        return "DEMANDEE", f"need_reconf=1 sur la fiche {ident} ({record.get('jid')}) : machine ON."
    return ("DEMANDEE_AU_RETOUR_ON", f"need_reconf=1 sur la fiche {ident} ({record.get('jid')}) : "
            "appliquee quand la machine repassera ON.")


def reset_request(name, reason):
    """Demande de reset dans xmppmaster.reset_machine pour une machine designee par son nom.
    Le serveur la traite quand l'agent est connecte (sinon a son retour). (statut, detail)."""
    try:
        records = machine_records(name)
        on = [item for item in records if item.get("enabled") == 1]
        chosen = on if on else records
        if not chosen:
            return "ANNULE_FICHE_INTROUVABLE", f"Aucune fiche de type machine au nom de {name}."
        if len(chosen) > 1:
            return "ANNULE_PLUSIEURS_FICHES", "Plusieurs fiches : " + ", ".join(
                str(item.get("jid")) for item in chosen)
        record = chosen[0]
        jid = str(record.get("jid") or "")
        if not jid:
            return "ANNULE_JID_INTROUVABLE", f"Fiche de {name} sans JID."
        count = "SELECT COUNT(*) FROM reset_machine WHERE jid = " + sql_text(jid) + ";"
        if int((mysql_exec(count).strip() or "0").splitlines()[-1]) > 0:
            return "DEJA_DEMANDE", f"Une demande de reset existe deja pour {jid}."
        mysql_exec("INSERT INTO reset_machine (jid, reason) VALUES (" + sql_text(jid) + ", "
                   + sql_text(f"{reason} (medulla_agents)") + ");")
        if int((mysql_exec(count).strip() or "0").splitlines()[-1]) == 0:
            return "ECHEC_ENREGISTREMENT", f"Demande non retrouvee apres insertion pour {jid}."
    except subprocess.CalledProcessError as error:
        return "ECHEC", "Erreur MySQL : " + clean(error.stderr or "")
    except (subprocess.SubprocessError, OSError, ValueError, IndexError, KeyError) as error:
        return "ECHEC", f"Erreur : {error}"
    if record.get("enabled") == 1:
        return "DEMANDE", f"Reset demande pour {jid} (machine ON : traite au prochain passage)."
    return "DEMANDE_AU_RETOUR_ON", f"Reset demande pour {jid} (machine OFF : traite a son retour)."


LAUNCH_REFUSED = {}      # cle -> motif : lancement refuse par XMPP, action refaite par SSH


def ssh_after_refusal(row, status, log, action):
    """Apres un refus de lancement par XMPP (Planificateur de taches bloque sur le poste) :
    refait l'action par SSH si le poste y repond sous le meme nom. Rien n'avait ete
    modifie par XMPP. Renvoie (statut, journal) ou None si pas de repli."""
    if status != "ECHEC_LANCEMENT" or not SSH_ENABLED or not is_ip(row.get("ip") or ""):
        return None
    ip = row["ip"]
    hostname, error = s_test_ssh(ip)
    if not hostname:
        reason = (log.strip().splitlines() or [""])[-1]
        return status, f"{reason} ; repli SSH impossible : {clean(error, 100)}"
    if hostname.casefold() != (row.get("hostname_ssh") or "").casefold():
        return status, (log + f"\n[serveur] Repli SSH abandonne : l'IP {ip} repond {hostname} "
                        f"au lieu de {row.get('hostname_ssh')}.")
    vlog(_T("repli SSH apres refus du lancement par XMPP ({})", action), ip)
    VIA[ip] = "ssh"
    FALLBACK[ip] = "lancement refuse par XMPP"
    LAUNCH_REFUSED[ip] = log.strip().splitlines()[-1] if log.strip() else "lancement refuse"
    row["acces"] = transport_label(ip)
    VERIFIED.pop(ip, None)
    if action == "correction":
        new_status, new_log = s_guarded_repair(row)
    else:
        new_status, new_log = s_guarded_restart(row)
    return new_status, ("===== XMPP : LANCEMENT REFUSE =====\n" + log.strip() + "\n\n"
                        "===== REPLI SSH =====\n" + (new_log or ""))


def run_pool(items, function, workers, on_result, graceful):
    """Execute function(item) en parallele. graceful=True : sur Ctrl+C, les
    taches non commencees sont annulees et celles en cours vont au bout."""
    pool = ThreadPoolExecutor(max_workers=max(1, min(workers, len(items))))
    futures = {pool.submit(function, item): item for item in items}
    handled = set()
    try:
        for future in as_completed(futures):
            handled.add(future)
            on_result(futures[future], future.result())
    except KeyboardInterrupt:
        if not graceful:
            pool.shutdown(wait=False, cancel_futures=True)
            raise
        print(paint(_T("\n  Arret demande : aucune nouvelle correction ne sera lancee."), "jaune"))
        print(paint(_T("  Attente de la fin des corrections deja commencees (ne pas interrompre a nouveau)..."), "jaune"))
        pool.shutdown(wait=True, cancel_futures=True)
        for future, item in futures.items():
            if future not in handled and future.done() and not future.cancelled():
                on_result(item, future.result())
        return [item for future, item in futures.items() if future.cancelled()]
    pool.shutdown()
    return []


# ---------------------------------------------------------------- fichiers

# Intitules des colonnes dans les CSV (les cles internes restent inchangees).
COLUMN_LABELS = {
    'ip': 'IP (ou session XMPP)',
    'hostnames_medulla': 'Nom(s) dans Medulla',
    'ping': 'Ping (SSH) ou session ejabberd (XMPP)',
    'ping_ms': 'Temps de ping (ms)',
    'ssh': 'Connexion au poste (SSH ou XMPP)',
    'ssh_erreur': 'Erreur de connexion',
    'nouvel_essai': 'Nouvel essai apres timeout',
    'hostname_ssh': 'Nom réel du poste',
    'deja_on': 'Poste déjà ON dans Medulla',
    'fiche_on': 'Fiche ON correspondante (nom @ IP)',
    'cause_fiche_off': 'Fiche OFF obsolète : cause probable',
    'diagnostic': "Diagnostic de l'agent",
    'python': "Version Python de l'agent",
    'service': 'État du service medullaagent',
    'demarrage': 'Type de démarrage du service',
    'agent_principal': 'Fichier agentxmpp.py',
    'ad_principal': 'Collecte AD - agent principal',
    'ad_image': 'Collecte AD - img_agent',
    'ad_secours': 'Collecte AD - agentrescue',
    'kiosk_pid': 'Fichier kiosk.pid',
    'agentconf': 'Fichier agentconf.ini',
    'agentconf_local': 'Fichier agentconf.ini.local',
    'config_secours': 'Copie de secours de la configuration',
    'disque_c_libre_go': 'Espace libre sur C: (Go)',
    'nb_problemes': 'Nombre de problèmes',
    'problemes': 'Problèmes détectés',
    'reparable_script': 'Réparable automatiquement',
    'detail_diagnostic': "Détail de l'erreur de diagnostic",
    'correction': 'Résultat de la correction',
    'detail_correction': 'Détail de la correction',
    'problemes_apres': 'Problèmes restants après correction',
    'reparable_apres': 'Réparable après correction',
    'version_agent': "Version de l'agent",
    'lib_etat': "Fichiers de l'agent",
    'lib_manquants': "Nombre de fichiers de l'agent manquants ou vides",
    'lib_liste': "Fichiers de l'agent manquants ou vides",
    'fichiers_differents': "Nombre de fichiers de l'agent differents du serveur (a mettre a jour)",
    'rescue_lib_etat': 'agentrescue\\lib',
    'rescue_lib_manquants': 'agentrescue\\lib : fichiers manquants ou vides',
    'rescue_lib_differents': 'agentrescue\\lib : fichiers differents du serveur',
    'rescue_lib_liste': 'agentrescue\\lib : detail',
    'xmpp_cible': "Serveur XMPP de l'agent (agentconf.ini)",
    'xmpp_connexion': 'Connexion poste vers serveur XMPP',
    'xmpp_detail': 'Connexion XMPP : detail',
    'windows': 'Poste Windows',
    'plateforme_medulla': 'Plateforme dans Medulla',
    'os_poste': 'Systeme du poste',
    'pulse_cible': 'Serveur Medulla teste depuis le poste',
    'pulse_tcp': 'Flux TCP vers serveur Medulla',
    'pulse_ports_ko': 'Ports TCP en echec vers serveur Medulla',
    'pulse_udp': 'UDP vers serveur Medulla (envoi seulement, non verifiable)',
    'relais_cible': 'Relais teste depuis le poste',
    'relais_tcp': 'Flux TCP vers relais',
    'relais_ports_ko': 'Ports TCP en echec vers relais',
    'relais_udp': 'UDP vers relais (envoi seulement, non verifiable)',
    'journal_erreurs': 'Lignes ERROR dans xmpp-agent-machine.log',
    'journal_dernieres_erreurs': 'Dernieres erreurs du journal (secrets masques)',
    'dossier_log': 'Dossier Medulla\\var\\log',
    'agent_a_jour': 'Agent à jour (version >= serveur)',
    'relance': 'Relance du service',
    'detail_relance': 'Détail de la relance',
    'etat_medulla_apres': 'État dans Medulla après actions',
    'reset': "Reset de l'agent",
    'detail_reset': 'Détail du reset',
    'reconfiguration': "Reconfiguration de l'agent (need_reconf)",
    'detail_reconfiguration': 'Détail de la reconfiguration',
    'section': 'Catégorie',
    'indicateur': 'Indicateur',
    'nombre': 'Nombre',
    'base': 'Sur un total de',
    'pourcentage': 'Pourcentage (%)',
    'acces': 'Accès au poste (SSH, XMPP, SSH en repli)',
    'source_ip': "Origine de l'IP",
    'nom_demande': 'Nom demandé',
    'erreur_xmpp': 'Erreur XMPP (repli SSH)',
    'etat_medulla': 'État dans Medulla (information)',
    'agent_inventaire': "Agent d'inventaire (inventory.ini)",
    'programme_inventaire': "Programme d'inventaire présent",
    'version_inventaire': "Version de l'agent d'inventaire",
    'inventaire_a_jour': 'GLPI-Agent >= 1.8 (option --full)',
    'inventaire': "Demande d'inventaire",
    'uuid_inventaire': "UUID d'inventaire",
    'constats_serveur': 'Constats côté serveur',
    'derniere_presence': 'Dernière présence dans Medulla (uptime_machine)',
    'enquete_serveur': 'Poste injoignable : cause probable (journaux serveur)',
    'traces_serveur': 'Poste injoignable : traces côté serveur',
    'reseau': 'Poste injoignable : tests réseau (port 22, route, ARP, DNS)',
    'ssh_serveur': 'SSH depuis le serveur (agent joignable en XMPP)',
    'ssh_demarrage': 'Démarrage de SSH par le script',
    'diagnostic_reseau': 'Poste injoignable : diagnostic réseau',
    'famille': 'Famille de problème',
    'probleme': 'Problème',
    'traitement': 'Traitement',
    'action': 'Action recommandée',
    'poste': 'Poste',
}
COLUMN_LABELS.update({f"test_{test['nom']}": test["libelle"] for test in TESTS_POSTE})


def write_csv(path, fields, rows):
    with path.open("w", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=fields, delimiter=";",
                                extrasaction="ignore")
        writer.writerow({field: tr(COLUMN_LABELS.get(field, field)) for field in fields})
        if LANG == "fr":
            writer.writerows(rows)
            return
        for row in rows:
            row = dict(row)
            for field in ("section", "indicateur", "nombre", "famille", "action", "derniere_presence"):
                if field in row:
                    row[field] = tr(row[field])
            for field in ("problemes", "problemes_apres", "probleme", "constats_serveur", "enquete_serveur"):
                if row.get(field):
                    row[field] = tr_problems(row[field])
            writer.writerow(row)


def machine_key(row):
    """Identite d'un poste pour le dedoublonnage : nom reel (SSH), sinon l'IP."""
    if row["hostname_ssh"]:
        return "nom:" + row["hostname_ssh"].casefold()
    if not row["ip"] and row.get("nom_demande"):
        return "demande:" + row["nom_demande"].casefold()
    return "ip:" + row["ip"]


def synthesis(rows, targeted, server_version=None):
    """Synthese dedoublonnee : (libelle, nb IP, nb postes, niveau)."""
    tested = [row for row in rows if row["ping"]]
    repairable = [row for row in tested if is_repairable(row, targeted)]
    taken = {id(row) for row in repairable}
    already_on = [row for row in tested if row["deja_on"] == "OUI" and id(row) not in taken
                  and row["ip"] not in FALLBACK]
    taken |= {id(row) for row in already_on}
    other_host = [row for row in tested if row["diagnostic"] == "NON_REALISE (autre poste)"
                  and id(row) not in taken]
    taken |= {id(row) for row in other_host}
    others = [row for row in tested if id(row) not in taken]
    pinging = [row for row in others if row["ping"] == "OK" or row["ssh"] == "OK"]
    silent = [row for row in others if row["ping"] != "OK" and row["ssh"] != "OK"]

    def entry(label, subset, level=0):
        return (label, len(subset), len({machine_key(row) for row in subset}), level)

    return [
        entry("Machines testees", tested),
        entry("  dont par SSH", [row for row in tested if row["acces"] == "SSH"], 1),
        entry("  dont par XMPP", [row for row in tested if row["acces"] == "XMPP"], 1),
        entry("  dont par SSH en repli (XMPP indisponible ou agent muet)",
              [row for row in tested if row["acces"] == "SSH (repli)"], 1),
        entry("Reparables automatiquement", repairable),
        entry("  dont reparation complete possible",
              [row for row in repairable if row["reparable_script"] == "OUI"], 1),
        entry("  dont reparation partielle seulement",
              [row for row in repairable if row["reparable_script"] == "PARTIEL"], 1),
        entry("Non reparables, joignables (SSH, ping ou session XMPP)", pinging),
        entry("  dont joignables en SSH malgre le ping KO (ICMP filtre)",
              [row for row in pinging if row["ping"] != "OK" and row["ssh"] == "OK"], 1),
        entry("  dont connexion impossible (SSH ou commande XMPP)",
              [row for row in pinging if row["ssh"] != "OK"], 1),
        entry("  dont audit de l'agent impossible",
              [row for row in pinging if row["ssh"] == "OK"
               and row["diagnostic"] not in ("OK", "PARTIEL")], 1),
        entry("  dont probleme non reparable par le script",
              [row for row in pinging if row["reparable_script"] == "NON"], 1),
        entry("    dont compte de connexion sans droits administrateur",
              [row for row in pinging if no_admin(row)], 2),
        entry("  dont serveur XMPP injoignable depuis le poste",
              [row for row in pinging if xmpp_unreachable(row)], 1),
        entry("  dont aucun probleme detecte par l'audit",
              [row for row in pinging if row["reparable_script"] == "RIEN_A_CORRIGER"], 1),
        entry("  -> relance du service possible",
              [row for row in pinging if restart_eligible(row)], 1),
        entry("  -> reset possible si l'agent reste OFF et sans session XMPP",
              [row for row in pinging if reset_eligible(row, server_version)], 1),
        entry("Non reparables, injoignables (ni SSH, ni ping, ni session XMPP)", silent),
        entry("Fiches OFF obsoletes : poste ON (audite par XMPP)", already_on),
        entry("Fiches OFF dont l'IP est celle d'une machine ON (non testees en SSH)",
              [row for row in rows if row["diagnostic"] == "NON_REALISE (IP deja ON)"]),
        entry("Ecartees : le poste repond un autre nom que celui demande", other_host),
    ]


def tcp_family(row, key):
    """Flux TCP : OK si tous les ports repondent, KO si au moins un echoue."""
    ports = row.get(f"_{key}_tcp")
    if not ports:
        return None
    return all(state == "OK" for state in ports.values())


def families(rows):
    """Recapitulatif par grande famille de test, par IP unique testee.
    Chaque test : (famille, test, OK, probleme, non teste, information seulement)."""
    tested = [row for row in rows if row["ping"]]
    audited = [row for row in tested if row["diagnostic"] in ("OK", "PARTIEL")]
    complete = [row for row in audited if row["diagnostic"] == "OK"]

    def check(subset, verdict):
        ok = ko = 0
        for row in subset:
            value = verdict(row)
            if value is True:
                ok += 1
            elif value is False:
                ko += 1
        return ok, ko, len(tested) - ok - ko

    def among(rows_in, test):
        keep = {id(row) for row in rows_in}
        return lambda row: test(row) if id(row) in keep else None

    def disk(row):
        try:
            return float(str(row["disque_c_libre_go"]).replace(",", ".")) >= LOW_DISK_GB
        except ValueError:
            return None

    ad = lambda row: (False if {"A_CORRIGER", "NON_RECONNU"} & {  # noqa: E731
        row["ad_principal"], row["ad_image"], row["ad_secours"]} else True)
    tests = [
        ("Acces", "Ping (SSH) ou session ejabberd (XMPP)", None,
         lambda row: row["ping"] == "OK", False),
        ("Acces", "Connexion SSH ou commande XMPP", None,
         lambda row: row["ssh"] == "OK", False),
        ("Systeme", "Poste Windows", None,
         lambda row: {"OUI": True, "NON": False}.get(row["windows"]), False),
        ("Medulla", "Fiche OFF a jour (le poste n'est pas deja ON)", None,
         lambda row: {"NON": True, "OUI": False}.get(row["deja_on"]), True),
        ("Audit", "Audit de l'agent realise", None,
         lambda row: (True if row["diagnostic"] == "OK" else False if row["diagnostic"]
                      in ("ECHEC", "PARTIEL") else None), False),
        ("Agent", "Python de l'agent present", audited,
         lambda row: row["python"] != "ABSENT", False),
        ("Agent", "Service medullaagent demarre", complete,
         lambda row: row["service"] == "RUNNING" if row["service"] else None, False),
        ("Agent", "Demarrage du service non desactive", complete,
         lambda row: row["demarrage"] != "DESACTIVE" if row["demarrage"] else None, False),
        ("Agent", "Version de l'agent a jour (serveur)", complete,
         lambda row: {"OUI": True, "NON": False}.get(row["agent_a_jour"]), False),
        ("Fichiers", "Fichiers de l'agent identiques au serveur", complete,
         lambda row: (None if not row["lib_etat"] or row["lib_etat"].startswith("NON_VERIFIE")
                      else row["lib_etat"].startswith("COMPLET")), False),
        ("Fichiers", "agentrescue\\lib identique au serveur", complete,
         lambda row: (row["rescue_lib_etat"].startswith("COMPLET")
                      if row["rescue_lib_etat"] and "ABSENTE" not in row["rescue_lib_etat"]
                      else None), False),
        ("Fichiers", "Dossier Medulla\\var\\log present", complete,
         lambda row: row["dossier_log"] == "PRESENT" if row["dossier_log"] else None, False),
        ("Configuration", "agentconf.ini valide", complete,
         lambda row: row["agentconf"] == "VALIDE" if row["agentconf"] else None, False),
        ("Configuration", "agentconf.ini.local valide ou absent", complete,
         lambda row: (row["agentconf_local"] in ("VALIDE", "SANS_SURCHARGE")
                      if row["agentconf_local"] else None), False),
        ("Configuration", "kiosk.pid correct", complete,
         lambda row: row["kiosk_pid"] not in ("CORROMPU_NUL", "INATTENDU")
         if row["kiosk_pid"] else None, False),
        ("Configuration", "Collecte AD protegee", complete, ad, False),
        ("Configuration", f"Disque C: >= {LOW_DISK_GB:g} Go libre", complete, disk, False),
        ("Flux", "Connexion au serveur XMPP (5222)", complete,
         lambda row: (True if row["xmpp_connexion"] == "OK"
                      else False if xmpp_unreachable(row) else None), False),
        ("Flux", "Tous les ports TCP vers le serveur Medulla", complete,
         lambda row: tcp_family(row, "pulse"), True),
        ("Flux", "Tous les ports TCP vers le relais", complete,
         lambda row: tcp_family(row, "relais"), True),
        ("Inventaire", f"GLPI-Agent present et >= {version_text(GLPI_FULL_MIN)}", complete,
         lambda row: (False if row["programme_inventaire"] == "ABSENT"
                      else {"OUI": True, "NON": False}.get(row["inventaire_a_jour"])), False),
        ("Journal", "Aucune ligne ERROR dans le journal", complete,
         lambda row: (row["journal_erreurs"] == 0
                      if isinstance(row["journal_erreurs"], int) else None), True),
    ]
    tests += family_tests(complete)
    order = {}
    for item in tests:
        order.setdefault(item[0], len(order))
    tests.sort(key=lambda item: order[item[0]])     # tests ajoutables dans leur famille
    result = []
    for family, label, subset, verdict, info in tests:
        rule = verdict if subset is None else among(subset, verdict)
        result.append((family, label, *check(tested, rule), info))
    return result


def print_families(rows):
    """Tableau du recapitulatif par famille de test."""
    summary = families(rows)
    headers = [tr(h) for h in ("Famille", "Test", "OK", "Probleme", "Non teste", "% probleme")]
    table = [[tr(family), tr(label) + (" (info)" if info else ""), str(ok), str(ko), str(na),
              percent(ko, ok + ko)] for family, label, ok, ko, na, info in summary]
    widths = [max([len(header)] + [len(row[index]) for row in table])
              for index, header in enumerate(headers)]
    print("  " + "  ".join(paint(header.ljust(width) if index < 2 else header.rjust(width), "gras")
                           for index, (header, width) in enumerate(zip(headers, widths))))
    print("  " + "  ".join("-" * width for width in widths))
    previous = None
    for row, (_family, _label, ok, ko, _na, info) in zip(table, summary):
        family = row[0] if row[0] != previous else ""
        previous = row[0]
        cells = [paint(family.ljust(widths[0]), "gras"), row[1].ljust(widths[1]),
                 paint(row[2].rjust(widths[2]), "vert") if ok else row[2].rjust(widths[2]),
                 (paint(row[3].rjust(widths[3]), "jaune" if info else "rouge") if ko
                  else row[3].rjust(widths[3])),
                 paint(row[4].rjust(widths[4]), "gris"), row[5].rjust(widths[5])]
        print("  " + "  ".join(cells).rstrip())
    print(paint(_T("  Comptes par IP unique testee. Les tests d'une famille ne portent que sur les postes\n  arrives a cette etape (ex. : fichiers = audits complets). (info) = sans effet sur la reparation."), "gris"))


def build_stats(rows, context, targeted, server_version=None):
    stats = []

    def add(section, label, count, base):
        stats.append(dict(section=section, indicateur=label, nombre=count,
                          base=base, pourcentage=percent(count, base)))

    def distribution(section, subset, key):
        for value, count in sorted(Counter(row[key] or "VIDE" for row in subset).items(),
                                   key=lambda item: (-item[1], item[0])):
            add(section, value, count, len(subset))

    tested = [row for row in rows if row["ping"]]
    total = len(tested)
    for label, value in context:
        stats.append(dict(section="Releve", indicateur=label, nombre=value,
                          base="", pourcentage=""))
    add("Releve", "IP uniques testees", total, total)

    # Synthese dedoublonnee : par IP, puis par poste (nom reel, sinon IP).
    summary = synthesis(rows, targeted, server_version)
    machines = summary[0][2]
    for label, ips, postes, _level in summary:
        add("Synthese (IP uniques)", label.strip(), ips, total)
    for label, ips, postes, _level in summary:
        add("Synthese (postes uniques)", label.strip(), postes, machines)

    by_family = families(rows)
    for title, column in (("OK", 2), ("probleme", 3), ("non teste", 4)):
        for item in by_family:
            name = f"{item[0]} - {item[1]}" + (" (info)" if item[5] else "")
            base = total if column == 4 else item[2] + item[3]
            add(f"Recapitulatif par famille de test ({title})", name, item[column], base)

    # Sections identiques a la V1.
    distribution("Ping", tested, "ping")
    distribution("SSH", [row for row in tested if not by_xmpp(row["ip"])], "ssh")
    distribution("Commande par XMPP", [row for row in tested if by_xmpp(row["ip"])], "ssh")
    distribution("Acces au poste", tested, "acces")
    for ping in ("OK", "KO"):
        for ssh_ok in (True, False):
            count = sum(1 for row in tested
                        if row["ping"] == ping and (row["ssh"] == "OK") == ssh_ok)
            add("Croisement ping / SSH",
                f"Ping {ping} / SSH {'OK' if ssh_ok else 'KO'}", count, total)
    reachable = [row for row in tested if row["ssh"] == "OK"]
    distribution("Hostname deja ON (parmi SSH OK)",
                 [row for row in reachable if not by_xmpp(row["ip"])], "deja_on")
    distribution("Etat dans Medulla (postes joints)", reachable, "etat_medulla")
    distribution("Agent d'inventaire (audits complets)",
                 [row for row in reachable if row["diagnostic"] == "OK"], "version_inventaire")
    distribution("Diagnostic (parmi SSH OK)", reachable, "diagnostic")
    diagnosed = [row for row in reachable if row["diagnostic"] in ("OK", "PARTIEL")]
    complete = [row for row in diagnosed if row["diagnostic"] == "OK"]
    for key, section in (
        ("service", "Service medullaagent"),
        ("demarrage", "Type de demarrage"),
        ("ad_principal", "Collecte AD - agent principal"),
        ("ad_image", "Collecte AD - img_agent"),
        ("ad_secours", "Collecte AD - agentrescue"),
        ("kiosk_pid", "kiosk.pid"),
        ("agentconf", "agentconf.ini"),
        ("agentconf_local", "agentconf.ini.local"),
        ("config_secours", "Copie de secours configuration"),
    ):
        distribution(section, complete, key)
    problem_counts = Counter(
        problem for row in diagnosed
        for problem in row["problemes"].split(" | ") if problem != "Aucun"
    )
    for problem, count in problem_counts.most_common():
        add("Problemes (postes concernes)", problem, count, len(diagnosed))
    add("Problemes (postes concernes)", "Aucun probleme detecte",
        sum(1 for row in diagnosed if row["problemes"] == "Aucun"), len(diagnosed))
    distribution("Reparable automatiquement", diagnosed, "reparable_script")

    # Versions de l'agent.
    distribution("Version de l'agent (audits complets)", complete, "version_agent")
    distribution("Fichiers de l'agent (audits complets)", complete, "lib_etat")
    distribution("agentrescue\\lib (audits complets)", complete, "rescue_lib_etat")
    distribution("Poste Windows (parmi SSH OK)", reachable, "windows")
    distribution("Connexion poste vers serveur XMPP (audits complets)", complete, "xmpp_connexion")
    for key, section in (("pulse", "Flux TCP poste vers serveur Medulla : ports joignables"),
                         ("relais", "Flux TCP poste vers relais : ports joignables")):
        tested_rows = [row for row in complete if row.get(f"_{key}_tcp")]
        ports = sorted({port for row in tested_rows for port in row[f"_{key}_tcp"]}, key=int)
        for port in ports:
            subset = [row for row in tested_rows if port in row[f"_{key}_tcp"]]
            add(section, f"port {port}",
                sum(row[f"_{key}_tcp"][port] == "OK" for row in subset), len(subset))
    with_log = [row for row in complete if isinstance(row.get("journal_erreurs"), int)]
    add("Journal de l'agent", "Postes avec au moins une ligne ERROR",
        sum(row["journal_erreurs"] > 0 for row in with_log), len(with_log))
    per_server = Counter((row["xmpp_cible"], row["xmpp_connexion"] == "OK")
                         for row in complete if row["xmpp_cible"])
    for target in sorted({target for target, _ok in per_server}):
        reached, total = per_server[(target, True)], per_server[(target, True)] + per_server[(target, False)]
        add("Serveurs XMPP testes depuis les postes (postes qui le joignent)", target, reached, total)
    distribution("Dossier Medulla\\var\\log (audits complets)", complete, "dossier_log")
    missing_files = Counter(
        relative for row in complete for relative, mode, _before in row.get("_lib_plan", [])
        if mode != "update")
    lib_affected = [row for row in complete if row.get("_lib_plan")]
    for relative, count in missing_files.most_common(30):
        add("Fichiers de l'agent manquants les plus frequents (postes concernes)", relative, count,
            len(lib_affected))
    distribution("Agent a jour par rapport au serveur (audits complets)", complete, "agent_a_jour")

    # Sections propres a la correction.
    concerned = [row for row in rows if row["correction"]]
    distribution("Correction", concerned, "correction")
    distribution("Relance du service", [row for row in rows if row["relance"]], "relance")
    distribution("Etat dans Medulla apres actions",
                 [row for row in rows if row["etat_medulla_apres"]], "etat_medulla_apres")
    distribution("Reset de l'agent", [row for row in rows if row["reset"]], "reset")
    verified = [row for row in rows if row["problemes_apres"]]
    after = Counter(
        problem for row in verified
        for problem in row["problemes_apres"].split(" | ") if problem != "Aucun"
    )
    for problem, count in after.most_common():
        add("Problemes apres correction (postes verifies)", problem, count, len(verified))
    if verified:
        add("Problemes apres correction (postes verifies)", "Aucun probleme restant",
            sum(1 for row in verified if row["problemes_apres"] == "Aucun"), len(verified))
    return stats


def print_synthesis(rows, targeted, server_version=None):
    summary = synthesis(rows, targeted, server_version)
    total_ip, total_postes = summary[0][1], summary[0][2]
    table = []
    for label, ips, postes, level in summary:
        table.append([tr(label), ips, percent(ips, total_ip), postes, percent(postes, total_postes)])
    headers = [tr(h) for h in ("Synthese (dedoublonnee)", "IP", "% IP", "Postes", "% postes")]
    rows_text = [[str(cell) for cell in row] for row in table]
    widths = [max([len(header)] + [len(row[index]) for row in rows_text])
              for index, header in enumerate(headers)]
    print("  " + "  ".join(paint(header.ljust(width) if index == 0 else header.rjust(width), "gras")
                           for index, (header, width) in enumerate(zip(headers, widths))))
    print("  " + "  ".join("-" * width for width in widths))
    colors = {"Reparables automatiquement": "vert",
              "Non reparables, joignables (SSH, ping ou session XMPP)": "jaune",
              "Non reparables, injoignables (ni SSH, ni ping, ni session XMPP)": "rouge",
              "Fiches OFF obsoletes : poste ON (audite par XMPP)": "gris",
              "Fiches OFF dont l'IP est celle d'une machine ON (non testees en SSH)": "gris",
              "Ecartees : le poste repond un autre nom que celui demande": "gris"}
    for row, (label, _ips, _postes, level) in zip(rows_text, summary):
        cells = [row[0].ljust(widths[0])] + [cell.rjust(width)
                                             for cell, width in zip(row[1:], widths[1:])]
        line = "  " + "  ".join(cells)
        if label in colors:
            line = paint(line, colors[label])
        elif level == 0:
            line = paint(line, "gras")
        print(line)
    print(paint(_T("  Postes : dedoublonnes par nom reel du poste (SSH) ; sans acces SSH, l'IP fait foi."),
                "gris"))


def print_detailed_stats(stats):
    """Affichage identique a la V1 : toutes les sections du fichier de statistiques."""
    section = None
    for item in stats:
        if item["section"] != section:
            section = item["section"]
            print("\n" + paint(f"  == {tr(section)}", "gras"))
        extra = f" ({item['pourcentage']} %)" if item["pourcentage"] else ""
        print(f"    {tr(item['indicateur'])} : {tr(item['nombre'])}{extra}")


# ---------------------------------------------------------------- prerequis

def apt_install(packages):
    command = ["apt-get", "install", "-y", "--no-install-recommends", *packages]
    env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
    if subprocess.run(command, env=env).returncode == 0:
        return True
    print(_T("Echec de l'installation, mise a jour de l'index apt puis nouvel essai..."))
    if subprocess.run(["apt-get", "update"], env=env).returncode != 0:
        return False
    return subprocess.run(command, env=env).returncode == 0


def ensure_requirements(auto_install):
    """Verifie Python et les commandes ; installe les paquets manquants si accepte."""
    if sys.version_info < MIN_PYTHON:
        raise OSError(
            f"Python {'.'.join(map(str, MIN_PYTHON))} minimum requis "
            f"(version actuelle : {sys.version.split()[0]})."
        )
    missing = {command: package for command, package in REQUIRED_COMMANDS.items()
               if shutil.which(command) is None}
    if not missing:
        return
    packages = sorted(set(missing.values()))
    print(_T("Commandes manquantes : ") + ", ".join(sorted(missing)))
    print(_T("Paquets Debian a installer : ") + " ".join(packages))
    if shutil.which("apt-get") is None:
        raise OSError("apt-get introuvable : installer ces paquets manuellement.")
    if os.geteuid() != 0:
        raise OSError("Droits root requis pour installer : relancer avec sudo, "
                      "ou executer : apt-get install " + " ".join(packages))
    if not auto_install:
        if not sys.stdin.isatty():
            raise OSError("Execution non interactive : ajouter --installer pour autoriser "
                          "l'installation, ou executer : apt-get install " + " ".join(packages))
        answer = input(_T("Installer maintenant ? [o/N] ")).strip().lower()
        if answer not in ("o", "oui", "y", "yes", "s", "si", "sí", "j", "ja"):
            raise OSError("Installation refusee ; aucun test lance.")
    if not apt_install(packages):
        raise OSError("Installation impossible : executer manuellement apt-get install "
                      + " ".join(packages))
    still_missing = [command for command in missing if shutil.which(command) is None]
    if still_missing:
        raise OSError("Toujours introuvable apres installation : " + ", ".join(still_missing))
    print(_T("Installation terminee.\n"))

# ---------------------------------------------------------------- programme

# ---------------------------------------------------------------- mode verbeux

VERBOSE = False
TRACE = []
_TRACE_LOCK = threading.Lock()


def vlog(message, ip=""):
    """Ligne de trace (mode --verbose) : affichee en gris et gardee pour trace.log."""
    if not VERBOSE:
        return
    stamp = datetime.now().strftime("%H:%M:%S")
    lines = str(message).rstrip().splitlines() or [""]
    with _TRACE_LOCK:
        for index, line in enumerate(lines):
            text = (f"[{stamp}] {ip + ' : ' if ip else ''}{line}" if index == 0
                    else f"           {line}")
            TRACE.append(text)
            print(paint("  v " + text, "gris"), flush=True)


def _ip_of(args):
    first = args[0] if args else ""
    return first.get("ip", "") if isinstance(first, dict) else str(first)


def _traced(function, before, after, with_ip=True):
    """Enveloppe une fonction : trace son lancement, sa duree et son resultat."""
    def wrapper(*args, **kwargs):
        ip = _ip_of(args) if with_ip else ""
        vlog(before(*args, **kwargs), ip)
        start = time.monotonic()
        try:
            result = function(*args, **kwargs)
        except Exception as error:
            vlog(f"   -> erreur {type(error).__name__} : {error} "
                 f"({time.monotonic() - start:.1f} s)".replace(".", ","), ip)
            raise
        duration = f"{time.monotonic() - start:.1f}".replace(".", ",")
        vlog(f"   -> {after(result)} ({duration} s)", ip)
        return result
    return wrapper


def _status_log(result):
    status, log = result
    log = (log or "").strip()
    return f"{status}" + (f"\n{log}" if log else "")


def _diag_summary(result):
    diag, detail = result
    if diag is None:
        return f"audit impossible : {detail}"
    if not diag.get("ok"):
        return "Python de l'agent absent" if diag.get("python") == "ABSENT" else f"erreur {diag}"
    files = diag.get("lib")
    count = len(files) if isinstance(files, list) else files
    return ("audit recu : " + ", ".join(f"{key}={diag.get(key)}" for key in (
        "python", "version_agent", "service", "demarrage", "kiosk_pid", "agentconf",
        "agentconf_local", "config_secours", "dossier_log", "disque_c_libre_go",
        "inventaire_agent"))
        + f", xmpp={diag.get('xmpp')}"
        + f", fichiers de l'agent inventories={count}"
        + "".join(f"\nduree {name} : {value:g} s".replace(".", ",")
                  for name, value in sorted((diag.get("durees") or {}).items(),
                                            key=lambda item: -item[1]) if value >= 1))


def _plan_text(row, parts):
    lines = [f"mise a niveau par delta ({', '.join(sorted(parts))}) : arret du service, copie, "
             "controle sha256, redemarrage"]
    plans = []
    if "LIB" in parts:
        plans += [(mode, relative) for relative, mode, _b in row.get("_root_plan", [])]
        plans += [(mode, relative) for relative, mode, _b in row.get("_lib_plan", [])]
    for fix, field in (("RESCUE_LIB", "rescue"),):
        if fix in parts:
            plans += [(mode, relative) for relative, mode, _b, _k in row.get(f"_{field}_plan", [])]
    labels = {"create": "copie (manquant)", "replace": "remplace (vide)",
              "update": "met a jour (different)"}
    lines += [f"  {labels.get(mode, mode)} : {relative}" for mode, relative in plans]
    return "\n".join(lines)


def enable_verbose():
    """Active les traces en enveloppant les fonctions qui agissent sur les postes ou la base."""
    global VERBOSE, s_test_ping, s_test_ssh, x_test_ssh, s_run_diag, x_run_diag
    global s_pre_action_check, x_pre_action_check, s_run_repair, x_run_repair
    global s_run_restart, x_run_restart, s_run_lib_fix, x_run_lib_fix
    global s_run_logdir_fix, x_run_logdir_fix, s_run_cleanup, x_run_cleanup
    global s_run_service_check, x_run_service_check, run_reset, read_medulla, mysql_exec
    VERBOSE = True
    s_test_ping = _traced(s_test_ping, lambda ip: f"ping (1 paquet, {PING_WAIT} s max)",
                          lambda r: f"ping {r[0]}" + (f", {r[1]} ms" if r[1] else ""))
    s_test_ssh = _traced(s_test_ssh, lambda ip: f"ssh pulseuser@{ip} hostname "
                         f"(connexion {SSH_CONNECT} s max)",
                         lambda r: f"SSH OK, le poste repond '{r[0]}'" if r[0]
                         else f"SSH impossible : {r[1]}")
    x_test_ssh = _traced(x_test_ssh, lambda ip: f"XMPP {JID_OF.get(ip)} : hostname "
                         f"({SSH_TIMEOUT} s max)",
                         lambda r: f"l'agent repond '{r[0]}'" if r[0]
                         else f"agent sans reponse : {r[1]}")
    s_run_diag = _traced(s_run_diag, lambda ip: "audit distant de l'agent par SSH (lecture seule)",
                         _diag_summary)
    x_run_diag = _traced(x_run_diag, lambda ip: "audit distant de l'agent par XMPP (lecture seule)",
                         _diag_summary)
    s_pre_action_check = _traced(
        s_pre_action_check,
        lambda row, action: f"controles avant {action} : meme nom en SSH, toujours OFF dans Medulla",
        lambda r: "controles OK" if r is None else f"action annulee : {_status_log(r)}")
    x_pre_action_check = _traced(
        x_pre_action_check,
        lambda row, action: f"controles avant {action} : l'agent repond le meme nom (XMPP)",
        lambda r: "controles OK" if r is None else f"action annulee : {_status_log(r)}")
    s_run_repair = _traced(s_run_repair, lambda ip: "reparation par SSH (reparer-agent-medulla.ps1)",
                           _status_log)
    x_run_repair = _traced(x_run_repair, lambda ip: "reparation par XMPP (reparer-agent-medulla.ps1)",
                           _status_log)
    s_run_restart = _traced(s_run_restart, lambda ip: "relance du service par SSH : sc stop, "
                            "attente, sc start", _status_log)
    x_run_restart = _traced(x_run_restart, lambda ip: "relance du service par XMPP (Planificateur "
                            "de taches) : sc stop, attente, sc start", _status_log)
    s_run_lib_fix = _traced(s_run_lib_fix, _plan_text, _status_log)
    x_run_lib_fix = _traced(x_run_lib_fix, _plan_text, _status_log)
    s_run_logdir_fix = _traced(s_run_logdir_fix, lambda row: "creation du dossier "
                               "Medulla\\var\\log (SSH)", _status_log)
    x_run_logdir_fix = _traced(x_run_logdir_fix, lambda row: "creation du dossier "
                               "Medulla\\var\\log (XMPP)", _status_log)
    s_run_cleanup = _traced(s_run_cleanup, lambda ip: "arret des processus de ce script restes "
                            "bloques sur le poste", _status_log)
    x_run_cleanup = _traced(x_run_cleanup, lambda ip: "nettoyage du dossier de travail du poste",
                            _status_log)
    run_reset = _traced(run_reset, lambda row: "demande de reset dans xmppmaster.reset_machine",
                        _status_log)
    s_run_service_check = _traced(s_run_service_check,
                                  lambda ip: "delai depasse : controle du service par SSH",
                                  _status_log)
    x_run_service_check = _traced(x_run_service_check,
                                  lambda ip: "delai depasse : controle du service par XMPP",
                                  _status_log)
    read_medulla = _traced(read_medulla, lambda: "lecture de Medulla (SELECT sur machines)",
                           lambda r: f"{len(r.splitlines())} ligne(s) lue(s)", with_ip=False)
    mysql_exec = _traced(mysql_exec, lambda sql: f"MySQL : {sql}",
                         lambda r: f"reponse : {r.strip() or '(vide)'}", with_ip=False)


def confirm(question, assume_yes):
    """Securite n.2 : confirmation explicite apres affichage de la liste."""
    if assume_yes:
        print(paint(_T("  Confirmation donnee par --oui."), "jaune"))
        return True
    if not sys.stdin.isatty():
        print(paint(_T("  Execution non interactive sans --oui : actions NON lancees."), "rouge"))
        return False
    try:
        answer = input(paint(_T("\n  Taper OUI (en majuscules) pour {}, ou Entree pour annuler : ", question), "gras"))
    except EOFError:
        return False
    return answer.strip() in ("OUI", YES_WORD[LANG])


def select_unique(rows, eligible, limit, field):
    """Choisit les postes a traiter : un seul passage par poste, limite eventuelle.
    Renseigne field pour les postes ecartes."""
    selected, seen = [], {}
    for row in sorted(rows, key=lambda row: ip_sort_key(row["ip"])):
        if not eligible(row):
            continue
        key = machine_key(row)
        if key in seen:
            row[field] = f"DOUBLON_MEME_POSTE (traite via {seen[key]})"
        elif limit and len(selected) >= limit:
            row[field] = "HORS_LIMITE"
        else:
            seen[key] = row["ip"]
            selected.append(row)
    return selected


def launched(status):
    """Vrai si l'action a reellement ete lancee sur le poste."""
    return bool(status) and not status.startswith(
        ("EXCLU", "ANNULE", "NON_DEMANDE", "NON_CONFIRM", "NON_POSSIBLE", "NON_UTILE", "NON_TRAITE",
         "NON_DISPONIBLE",
         "NON_ELIGIBLE", "DOUBLON", "HORS_LIMITE"))


# ---------------------------------------------------------------- transport XMPP
#
# Toutes les operations sur les postes passent par XMPP : le script ouvre une session
# avec un compte temporaire (cree par ejabberdctl, supprime a la fin) et envoie a l'agent
# des messages "shellcommand" (plugin de l'agent : la commande est executee par cmd.exe,
# avec les droits de l'agent, et sa sortie revient dans la reponse).
# Les fichiers (code de l'audit, scripts, fichiers de l'agent a copier) sont compresses,
# decoupes en blocs de quelques Ko et deposes dans C:\Windows\Temp\mdx, puis
# reassembles et verifies (sha256) par un petit programme Python, le "runner",
# depose une fois sur le poste. Les actions qui arretent le service (correction,
# copie, relance) sont lancees par le Planificateur de taches, en compte SYSTEM :
# elles vont au bout meme pendant que l'agent est arrete, puis le service est
# controle et redemarre si besoin (garde) ; le serveur recupere le resultat quand
# l'agent est revenu.


RUNNER_PY = r'''
import base64
import hashlib
import json
import os
import subprocess
import sys
import time
import zlib

WORK = os.path.dirname(os.path.abspath(__file__))
TASK = "MedullaDiag_"
PIPE, STDOUT = subprocess.PIPE, subprocess.STDOUT


def emit(obj):
    import socket
    obj.setdefault("hostname", os.environ.get("COMPUTERNAME") or socket.gethostname())
    data = base64.b64encode(zlib.compress(json.dumps(obj).encode("utf-8"))).decode("ascii")
    sys.stdout.write("MDX:" + data + ":MDX\n")
    sys.stdout.flush()


def path(name):
    return os.path.join(WORK, name)


def remove(name):
    try:
        os.remove(path(name))
    except OSError:
        pass


def run(command, timeout=60):
    try:
        result = subprocess.run(command, stdout=PIPE, stderr=STDOUT, timeout=timeout)
        return result.returncode, result.stdout.decode("cp850", "replace")
    except Exception as error:
        return -1, "%s : %s (%s)" % (type(error).__name__, error, command[0])


def com_task(name, command_file):
    """Tache SYSTEM creee et lancee par l'API COM du Planificateur de taches, sans lancer
    schtasks.exe (bloque par certains EDR ou AppLocker)."""
    try:
        import pythoncom
        import win32com.client
        pythoncom.CoInitialize()
        service = win32com.client.Dispatch("Schedule.Service")
        service.Connect()
        folder = service.GetFolder("\\")
        task = service.NewTask(0)
        task.RegistrationInfo.Description = "Medulla : action du script de diagnostic"
        task.Principal.RunLevel = 1
        task.Settings.Enabled = True
        task.Settings.DisallowStartIfOnBatteries = False
        task.Settings.StopIfGoingOnBatteries = False
        task.Settings.ExecutionTimeLimit = "PT3H"
        action = task.Actions.Create(0)
        action.Path = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32",
                                   "cmd.exe")
        action.Arguments = '/c "%s"' % command_file
        registered = folder.RegisterTaskDefinition(name, task, 6, "SYSTEM", None, 5)
        registered.Run(None)
        return 0, "tache creee par l'API du Planificateur de taches"
    except Exception as error:
        return -1, "API du Planificateur : %s : %s" % (type(error).__name__, error)


def delete_task(name):
    code, _out = run(["schtasks", "/delete", "/f", "/tn", name])
    if code != 0:
        try:
            import pythoncom
            import win32com.client
            pythoncom.CoInitialize()
            service = win32com.client.Dispatch("Schedule.Service")
            service.Connect()
            service.GetFolder("\\").DeleteTask(name, 0)
        except Exception:
            pass


def service_state():
    code, out = run(["sc.exe", "query", "medullaagent"], 30)
    for line in out.splitlines():
        if "STATE" in line and ":" in line:
            value = line.split(":", 1)[1].split()
            if value and value[0].isdigit():
                return int(value[0])
    return -1 if "1060" in out else 0


def guard():
    """Apres une action : le service doit etre demarre ; s'il est arrete, il est relance."""
    for _ in range(60):
        state = service_state()
        if state in (1, 4, -1):
            break
        time.sleep(1)
    if state == 4:
        return "DEJA_DEMARRE"
    if state == -1:
        return "SERVICE_ABSENT"
    if state != 1:
        return "BLOQUE_ETAT_%d" % state
    run(["sc.exe", "start", "medullaagent"], 60)
    for _ in range(45):
        if service_state() == 4:
            return "REDEMARRE"
        time.sleep(1)
    return "ECHEC_DEMARRAGE"


def kill_tree(pid):
    run(["taskkill", "/T", "/F", "/PID", str(pid)], 60)


def execute(ident, kind, code_id, timeout, protect, prelude):
    """Execute un code depose (Python ou PowerShell) ; sortie dans <ident>.log/.err."""
    with open(path(code_id + ".bin"), "rb") as stream:
        code = stream.read().decode("utf-8")
    prelude = base64.b64decode(prelude).decode("utf-8") if prelude != "-" else ""
    extra = os.environ.get("MEDULLA_RUNNER_PRELUDE", "")
    if kind == "py":
        script = path(ident + ".py")
        with open(script, "w", encoding="utf-8") as stream:
            stream.write(extra + prelude + code)
        args = [a for a in os.environ.get("MEDULLA_RUNNER_ARGS", "").split("|") if a]
        command = [sys.executable, "-I", "-B", script] + args
    else:
        script = path(ident + ".ps1")
        with open(script, "w", encoding="utf-8-sig") as stream:
            stream.write(prelude + code)
        command = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
                   "-Command", "$ProgressPreference='SilentlyContinue'; & '%s'; exit $LASTEXITCODE"
                   % script]
    start = time.time()
    expired = False
    with open(path(ident + ".log"), "wb") as out, open(path(ident + ".err"), "wb") as err:
        process = subprocess.Popen(command, stdout=out, stderr=err, cwd=WORK)
        try:
            code = process.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            expired = True
            kill_tree(process.pid)
            try:
                code = process.wait(timeout=30)
            except subprocess.TimeoutExpired:
                code = -9
    result = {"rc": code, "timeout": expired, "duree": round(time.time() - start, 1)}
    for key, suffix in (("stdout", ".log"), ("stderr", ".err")):
        with open(path(ident + suffix), "rb") as stream:
            result[key] = stream.read().decode("utf-8", "replace")
        remove(ident + suffix)
    remove(os.path.basename(script))
    result["garde"] = guard() if protect else ""
    return result


def clean():
    """Fichiers de travail de plus d'un jour ; codes du script (c_*) gardes 30 jours."""
    removed = 0
    now = time.time()
    for name in os.listdir(WORK):
        if name == os.path.basename(__file__) or name.endswith(".state"):
            continue
        try:
            if now - os.path.getmtime(path(name)) > (30 if name.startswith("c_") else 1) * 86400:
                os.remove(path(name))
                removed += 1
        except OSError:
            pass
    return removed


def same_host(expected):
    import socket
    mine = (os.environ.get("COMPUTERNAME") or socket.gethostname()).casefold()
    expected = expected.casefold()
    return not expected or mine == expected or mine.split(".")[0] == expected.split(".")[0]


def status_of(output):
    for line in reversed(output.splitlines()):
        if line.strip().startswith("{"):
            try:
                return json.loads(line).get("status", "")
            except ValueError:
                return ""
    return ""


def sequence(ident, spec):
    """Etapes d'une correction, executees a la suite sur le poste (sans aller-retour
    avec le serveur). Une etape dont le statut n'est pas attendu arrete la suite."""
    result = {"etapes": [], "arret": ""}
    if not same_host(spec.get("attendu", "")):
        result["arret"] = "poste_different"
        return result
    for step in spec["etapes"]:
        try:
            done = execute(ident, step["kind"], step["code"], int(step["timeout"]), False,
                           step.get("prelude", "-"))
        except Exception as error:
            done = {"rc": -1, "timeout": False, "stdout": "",
                    "stderr": "Erreur du lanceur : %s" % error}
        done["nom"] = step["nom"]
        result["etapes"].append(done)
        if done.get("timeout") or (step.get("ok") and status_of(done.get("stdout") or "")
                                   not in step["ok"]):
            result["arret"] = step["nom"]
            break
    result["garde"] = guard()
    return result


def main(action, args):
    if action == "hello":
        emit({"ok": True, "python": sys.version.split()[0], "supprimes": clean()})
    elif action == "put":
        # Petit code transmis dans la commande elle-meme, puis action suivante.
        with open(path(args[0] + ".bin"), "wb") as stream:
            stream.write(zlib.decompress(base64.b64decode(args[1])))
        main(args[2], args[3:])
    elif action == "has":
        emit({"ok": os.path.isfile(path(args[0] + ".bin"))})
    elif action == "assemble":
        ident, count, digest = args[0], int(args[1]), args[2]
        name = args[3] if len(args) > 3 and args[3].startswith("@") else ""
        if name:
            args = args[:3] + args[4:]
        stem = name[1:] or ident
        if os.path.isfile(path(ident + ".bin")):
            # Deja reassemble (message repete apres une reponse perdue) : on enchaine.
            with open(path(ident + ".bin"), "rb") as stream:
                if hashlib.sha256(stream.read()).hexdigest() == digest:
                    for k in range(count):
                        remove("%s.%d" % (stem, k))
                    if len(args) > 3:
                        main(args[3], args[4:])
                    else:
                        emit({"ok": True})
                    return
        missing = [k for k in range(count) if not os.path.isfile(path("%s.%d" % (stem, k)))]
        if missing:
            emit({"ok": False, "missing": missing})
            return
        text = "".join(open(path("%s.%d" % (stem, k))).read().strip() for k in range(count))
        try:
            raw = zlib.decompress(base64.b64decode(text))
        except Exception as error:
            raw, problem = b"", type(error).__name__
        else:
            problem = ""
        for k in range(count):
            remove("%s.%d" % (stem, k))
        if problem or hashlib.sha256(raw).hexdigest() != digest:
            emit({"ok": False, "missing": [], "erreur": problem or "empreinte differente"})
            return
        with open(path(ident + ".tmp"), "wb") as stream:
            stream.write(raw)
        os.replace(path(ident + ".tmp"), path(ident + ".bin"))
        if len(args) > 3:
            main(args[3], args[4:])          # action enchainee (run, start...)
        else:
            emit({"ok": True, "taille": len(raw)})
    elif action == "run" and not os.path.isfile(path(args[2] + ".bin")):
        emit({"manque": [args[2]]})
    elif action == "run":
        ident, kind, code_id, timeout, protect, prelude = args
        emit(execute(ident, kind, code_id, int(timeout), protect == "1", prelude))
    elif action == "start" and any(os.path.isfile(path(args[0] + suffix))
                                   for suffix in (".job", ".state", ".out")):
        # Deja lancee (message repete) : jamais deux fois la meme action.
        emit({"ok": True, "job": args[0], "detail": "action deja lancee"})
    elif action == "start":
        ident = args[0]
        spec = json.loads(zlib.decompress(base64.b64decode(args[1])).decode("utf-8"))
        missing = [step["code"] for step in spec["etapes"]
                   if not os.path.isfile(path(step["code"] + ".bin"))]
        if missing:
            emit({"manque": missing})
            return
        if not same_host(spec.get("attendu", "")):
            emit({"ok": False, "detail": "poste different de celui du diagnostic"})
            return
        with open(path(ident + ".job"), "w") as stream:
            json.dump(spec, stream)
        with open(path(ident + ".cmd"), "w") as stream:
            stream.write('@"%s" -I -B "%s" job %s\r\n' % (sys.executable, os.path.abspath(__file__),
                                                          ident))
        past = time.localtime(max(time.time() - 120, time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))))
        code, out = run(["schtasks", "/create", "/f", "/tn", TASK + ident, "/ru", "SYSTEM",
                         "/rl", "HIGHEST", "/sc", "once", "/st", "%02d:%02d" % past[3:5],
                         "/tr", path(ident + ".cmd")])
        if code == 0:
            code, more = run(["schtasks", "/run", "/tn", TASK + ident])
            out += more
        if code != 0:
            # schtasks refuse ou bloque : meme tache par l'API COM du Planificateur.
            delete_task(TASK + ident)
            code, more = com_task(TASK + ident, path(ident + ".cmd"))
            out = ("schtasks : " + out.strip() + " ; " + more) if code != 0 else more
        if code != 0:
            remove(ident + ".job")
            remove(ident + ".cmd")
            delete_task(TASK + ident)
        emit({"ok": code == 0, "job": ident, "detail": " ".join(out.split())[:400]})
    elif action == "job":
        ident = args[0]
        with open(path(ident + ".job")) as stream:
            spec = json.load(stream)
        with open(path(ident + ".state"), "w") as stream:
            stream.write(str(time.time()))
        try:
            result = sequence(ident, spec)
        except Exception as error:
            result = {"etapes": [], "arret": "erreur", "garde": guard(),
                      "erreur": "Erreur du lanceur : %s" % error}
        with open(path(ident + ".tmp"), "w") as stream:
            json.dump(result, stream)
        os.replace(path(ident + ".tmp"), path(ident + ".out"))
        remove(ident + ".job")
        remove(ident + ".state")
    elif action == "poll":
        ident = args[0]
        if os.path.isfile(path(ident + ".out")):
            with open(path(ident + ".out")) as stream:
                result = json.load(stream)
            # Resultat conserve (efface au bout d'un jour) : une reponse perdue se redemande.
            remove(ident + ".cmd")
            delete_task(TASK + ident)
            emit({"etat": "termine", "resultat": result})
            return
        last = ""
        try:
            with open(path(ident + ".log"), "rb") as stream:
                lines = [l for l in stream.read().decode("utf-8", "replace").splitlines() if l.strip()]
                last = lines[-1][:200] if lines else ""
        except OSError:
            pass
        state = ("en_cours" if os.path.isfile(path(ident + ".state")) else
                 "en_attente" if os.path.isfile(path(ident + ".job")) else "inconnu")
        emit({"etat": state, "derniere_ligne": last})
    elif action == "clean":
        emit({"ok": True, "supprimes": clean()})


try:
    main(sys.argv[1], sys.argv[2:])
except Exception as error:
    emit({"ok": False, "erreur": "%s : %s" % (type(error).__name__, error)})
'''
_BUNDLE = {}


def code_id(text):
    return "c_" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:24]


def bundle():
    """Runner + tous les codes du script : deposes en une fois sur le poste au premier
    contact, puis reutilises (aucun envoi de code ensuite tant que la version ne change pas)."""
    if not _BUNDLE:
        texts = [DIAG_PY, REPAIR_PS1.strip(), LIB_FIX_PY, LOGDIR_FIX_PY, RESTART_PS.strip(),
                 SERVICE_CHECK_PS.strip(), INVENTORY_DIAG_PY]
        files = {code_id(text) + ".bin": text for text in texts}
        version = hashlib.sha256((RUNNER_PY + "".join(texts)).encode("utf-8")).hexdigest()[:12]
        files["runner_" + version + ".py"] = RUNNER_PY
        _BUNDLE.update(version=version, files=files,
                       path=WORK_DIR + "\\runner_" + version + ".py")
    return _BUNDLE


class XmppLink:
    """Session XMPP du script : compte temporaire, envoi de shellcommand, reponses."""

    def __init__(self):
        self.client = None
        self.loop = None
        self.thread = None
        self.pending = {}
        self.targets = {}
        self.lock = threading.Lock()
        self.account = None
        self.sent_bytes = 0

    def start(self, domain):
        try:
            import slixmpp
        except ImportError:
            raise OSError("module slixmpp absent (apt install python3-slixmpp)")
        user = "diagnostic-" + uuid.uuid4().hex[:8]
        password = base64.urlsafe_b64encode(os.urandom(24)).decode("ascii")
        created = subprocess.run([EJABBERDCTL, "register", user, domain, password],
                                 capture_output=True, text=True, timeout=60)
        if created.returncode != 0:
            raise OSError("creation du compte XMPP temporaire impossible : "
                          + clean(created.stderr or created.stdout))
        self.account = (user, domain)
        # Mecanismes SASL essayes dans l'ordre : choix automatique de slixmpp, puis
        # SCRAM-SHA-1 et PLAIN (sous TLS). Certains couples slixmpp / ejabberd refusent
        # le mecanisme choisi d'office (SCRAM-...-PLUS, liaison de canal TLS 1.3).
        errors = []
        for mechanism in (None, "SCRAM-SHA-1", "PLAIN"):
            try:
                self._connect(slixmpp, f"{user}@{domain}/diagnostic", password, mechanism)
                vlog(_T("XMPP : session ouverte (mecanisme {})", mechanism or 'automatique'))
                return
            except OSError as error:
                errors.append(f"{mechanism or 'automatique'} : {error}")
                vlog(_T("XMPP : echec avec le mecanisme {} ({})", mechanism or 'automatique', error))
                self._close_session()
                if "authentification" not in str(error):
                    self.stop()
                    raise
        self.stop()
        raise OSError("authentification XMPP refusee avec le compte temporaire ("
                      + " ; ".join(errors) + "). Voir le journal ejabberd : "
                      "grep -i diagnostic- /var/log/ejabberd/ejabberd.log | tail")

    def _connect(self, slixmpp, jid, password, mechanism):
        ready = concurrent.futures.Future()

        def runner():
            self.loop = asyncio.new_event_loop()
            asyncio.set_event_loop(self.loop)
            options = {"sasl_mech": mechanism} if mechanism else {}
            client = slixmpp.ClientXMPP(jid, password, **options)
            context = getattr(client, "ssl_context", None)
            if context is not None:          # ejabberd local : certificat souvent auto-signe
                context.check_hostname = False
                context.verify_mode = ssl.CERT_NONE
            client.add_event_handler("ssl_invalid_cert", lambda *args: None)

            def started(_event):
                client.send_presence(ppriority=-1)
                if not ready.done():
                    ready.set_result(True)

            def failed(*_args):
                if not ready.done():
                    ready.set_exception(OSError("authentification refusee"))

            client.add_event_handler("session_start", started)
            client.add_event_handler("failed_auth", failed)
            client.add_event_handler("failed_all_auth", failed)
            client.add_event_handler("message", self._on_message)
            self.client = client
            params = inspect.signature(client.connect).parameters
            if "host" in params:
                client.connect(host=XMPP_SERVER[0], port=XMPP_SERVER[1])
            else:
                client.connect(address=XMPP_SERVER)
            self.loop.run_forever()
            pending = [task for task in asyncio.all_tasks(self.loop) if not task.done()]
            for task in pending:
                task.cancel()
            if pending:
                self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))

        self.thread = threading.Thread(target=runner, daemon=True)
        self.thread.start()
        try:
            ready.result(30)
        except concurrent.futures.TimeoutError:
            raise OSError(f"connexion XMPP impossible a {XMPP_SERVER[0]}:{XMPP_SERVER[1]} en 30 s")

    def _on_message(self, message):
        try:
            data = json.loads(message["body"])
        except (ValueError, TypeError):
            return
        sender = str(message["from"].bare).casefold()
        with self.lock:
            future = self.pending.get(data.get("sessionid"))
            if future is None and str(data.get("action", "")).endswith("error"):
                # Erreur de l'agent sans numero de session : attribuee a la seule demande
                # en attente vers cet agent (sinon, le delai normal s'applique).
                waiting = [item for sid, item in self.pending.items()
                           if self.targets.get(sid) == sender and not item.done()]
                future = waiting[0] if len(waiting) == 1 else None
        if future is not None and not future.done():
            future.set_result(data)

    def command(self, jid, command, timeout):
        """Execute `command` (cmd.exe) par l'agent `jid`. Renvoie (ok, sortie ou erreur)."""
        session = "mdx" + uuid.uuid4().hex
        future = concurrent.futures.Future()
        with self.lock:
            self.pending[session] = future
            self.targets[session] = jid.split("/")[0].casefold()
        body = json.dumps({"action": "shellcommand", "sessionid": session,
                           "data": {"cmd": command}, "ret": 0, "base64": False})
        self.sent_bytes += len(body)
        self.loop.call_soon_threadsafe(
            lambda: self.client.send_message(mto=jid, mbody=body, mtype="chat"))
        try:
            data = future.result(timeout)
        except concurrent.futures.TimeoutError:
            return False, f"Timeout XMPP ({timeout} s sans reponse de l'agent)"
        finally:
            with self.lock:
                self.pending.pop(session, None)
                self.targets.pop(session, None)
        if data.get("ret") == 0 and data.get("action") == "resultshellcommand":
            return True, str((data.get("data") or {}).get("result", ""))
        message = (data.get("data") or {}).get("msg", "")
        return False, f"Erreur de l'agent (ret {data.get('ret')}) {clean(str(message), 150)}"

    def send(self, jid, action, data):
        """Envoie une action a l'agent sans attendre de reponse (ex. : inventory)."""
        body = json.dumps({"action": action, "sessionid": "mdx" + uuid.uuid4().hex,
                           "data": data, "ret": 0, "base64": False})
        try:
            self.loop.call_soon_threadsafe(
                lambda: self.client.send_message(mto=jid, mbody=body, mtype="chat"))
        except (RuntimeError, AttributeError) as error:
            return False, str(error)
        return True, ""

    def _close_session(self):
        if self.client is not None and self.loop is not None and self.loop.is_running():
            closed = concurrent.futures.Future()

            def finish(*_args):
                if not closed.done():
                    closed.set_result(True)

            def disconnect():
                self.client.add_event_handler("disconnected", finish)
                self.client.disconnect()

            try:
                self.loop.call_soon_threadsafe(disconnect)
                closed.result(5)
            except (RuntimeError, concurrent.futures.TimeoutError):
                pass
            self.loop.call_soon_threadsafe(self.loop.stop)
            self.thread.join(10)
        self.client = None

    def stop(self):
        self._close_session()
        if self.account:
            user, domain = self.account
            removed = subprocess.run([EJABBERDCTL, "unregister", user, domain],
                                     capture_output=True, text=True, timeout=60)
            if removed.returncode != 0:
                print(_T("ATTENTION : supprimer a la main le compte XMPP {}@{} (ejabberdctl unregister {} {})", user, domain, user, domain), file=sys.stderr)
            self.account = None


LINK = XmppLink()
ADDRESSES = {}          # JID -> IP de la session (lue une fois, en parallele)
JID_OF = {}             # cle du poste (IP de sa session) -> JID complet de l'agent
_RUNNER_OK = set()
_FRESH = set()          # postes ou le runner vient d'etre depose : aucun code en cache
HELLO = {}              # derniere reponse "hello" du runner (nom du poste...)


def jid_parts(jid):
    bare, _, resource = (jid or "").partition("/")
    user, _, domain = bare.partition("@")
    return user, domain, resource


def connected_sessions():
    """Sessions ouvertes sur ejabberd (JID complets)."""
    result = subprocess.run([EJABBERDCTL, "connected_users"], capture_output=True, text=True,
                            timeout=60)
    if result.returncode != 0:
        raise OSError("ejabberdctl connected_users a echoue : "
                      + clean(result.stderr or result.stdout))
    return [line.strip() for line in result.stdout.splitlines() if "@" in line]


def session_address(jid):
    """IP du poste d'apres sa session ejabberd (ejabberdctl user_sessions_info)."""
    user, domain, resource = jid_parts(jid)
    try:
        result = subprocess.run([EJABBERDCTL, "user_sessions_info", user, domain],
                                capture_output=True, text=True, timeout=60)
    except (subprocess.SubprocessError, OSError):
        return ""
    for line in result.stdout.splitlines():
        if resource and resource not in line and len(result.stdout.splitlines()) > 1:
            continue
        for token in re.split(r"[\s,;]+", line):
            token = token.strip("[]()").removeprefix("::ffff:")
            try:
                return str(ipaddress.ip_address(token))
            except ValueError:
                continue
    return ""


def last_activity(jid):
    user, domain, _ = jid_parts(jid)
    try:
        result = subprocess.run([EJABBERDCTL, "get_last", user, domain],
                                capture_output=True, text=True, timeout=60)
    except (subprocess.SubprocessError, OSError):
        return ""
    return clean(result.stdout or result.stderr, 80)


def refresh_jid(key):
    """L'agent a pu se reconnecter (redemarrage du service) avec une autre ressource."""
    current = JID_OF.get(key)
    if not current:
        return False
    user, domain, _ = jid_parts(current)
    try:
        sessions = connected_sessions()
    except (OSError, subprocess.SubprocessError):
        return False
    for jid in sessions:
        if jid_parts(jid)[:2] == (user, domain):
            JID_OF[key] = jid
            return True
    return False


def agent_cmd(key, command, timeout, attempts=2):
    """Commande cmd.exe executee par l'agent du poste. Toujours terminee par "exit 0" :
    l'agent ne repond qu'aux succes. Certains agents perdent des messages (souvent le
    premier d'une session) : sans reponse, la commande est renvoyee (toutes nos
    commandes sont rejouables sans effet double), apres verification de la session."""
    if not JID_OF.get(key):
        return False, "Agent non connecte a ejabberd"
    start = time.monotonic()
    for attempt in range(1, attempts + 1):
        ok, text = LINK.command(JID_OF[key], command + " & exit 0", timeout)
        if ok or not text.startswith("Timeout XMPP"):
            break
        refresh_jid(key)
        if VERBOSE and attempt < attempts:
            vlog(_T("   [xmpp] pas de reponse en {} s : commande renvoyee ({}e envoi)", timeout, attempt + 1), key)
    if VERBOSE:
        shown = re.sub(r"[A-Za-z0-9+/=]{60,}", lambda m: f"<{len(m.group())} car.>", command)
        answer = re.sub(r"MDX:[A-Za-z0-9+/=]+:MDX", "<reponse du runner>", text)
        vlog(_T("   [xmpp {:.1f} s] {} -> {} {}", time.monotonic() - start, short(shown, 110), 'OK' if ok else 'ECHEC', short(clean(answer), 100)), key)
    return ok, text


def decode_answer(text):
    match = re.search(r"MDX:([A-Za-z0-9+/=]+):MDX", text or "")
    if not match:
        return None
    try:
        return json.loads(zlib.decompress(base64.b64decode(match.group(1))).decode("utf-8"))
    except (ValueError, zlib.error):
        return None


def runner(key, arguments, timeout, attempts=2):
    """Lance le runner du poste ; renvoie (reponse decodee, erreur)."""
    ok, text = agent_cmd(key, f'"{AGENT_PYTHON}" -I -B "{bundle()["path"]}" {arguments}', timeout,
                         attempts)
    if not ok:
        return None, text
    answer = decode_answer(text)
    if answer is None:
        return None, "reponse illisible du poste : " + clean(text, 150)
    return answer, ""


def send_chunks(key, name, blob):
    """Depose `blob` (texte base64) en blocs <name>.<k> dans WORK_DIR, en parallele.
    Chaque bloc est un fichier ecrase (un nouvel essai ne double rien) ; le dossier est
    cree au passage, sans aller-retour supplementaire."""
    chunks = [blob[i:i + CHUNK] for i in range(0, len(blob), CHUNK)] or [""]

    def send(index):
        command = (f'(if not exist "{WORK_DIR}" mkdir "{WORK_DIR}")& '
                   f'>"{WORK_DIR}\\{name}.{index}" echo {chunks[index]}')
        ok, error = agent_cmd(key, command, 45, attempts=3)
        return "" if ok else error

    errors = []
    with ThreadPoolExecutor(max_workers=WINDOW) as pool:
        for error in pool.map(send, range(len(chunks))):
            if error:
                errors.append(error)
    return len(chunks), (errors[0] if errors else "")


def ensure_runner(key):
    """Runner present et a jour sur le poste (une commande si deja depose)."""
    if key in _RUNNER_OK:
        return True, ""
    hello = f'"{AGENT_PYTHON}" -I -B "{bundle()["path"]}" hello'
    ok, text = agent_cmd(key, f'if exist "{bundle()["path"]}" ({hello}) else (echo MDXABSENT)',
                         min(SSH_TIMEOUT, 15))
    if not ok:
        return False, text
    answer = decode_answer(text)
    if answer is None:
        info = bundle()
        name = "lot_" + info["version"] + "_" + uuid.uuid4().hex[:6] + ".b64"
        blob = base64.b64encode(zlib.compress(json.dumps(info["files"]).encode("utf-8"), 9)
                                ).decode("ascii")
        count, error = send_chunks(key, name, blob)
        if error:
            return False, "depot du runner impossible : " + error
        work = WORK_DIR.replace("\\", "/")
        code = (f"import base64,zlib,os,json;d='{work}/';n='{name}.';"
                f"p=[open(d+n+str(k)).read().strip() for k in range({count})];"
                f"b=json.loads(zlib.decompress(base64.b64decode(''.join(p))));"
                f"[open(d+f,'wb').write(c.encode('utf-8')) for f,c in sorted(b.items())];"
                f"[os.remove(d+n+str(k)) for k in range({count})]")
        ok, text = agent_cmd(key, f'"{AGENT_PYTHON}" -I -B -c "{code}" & {hello}', 45)
        answer = decode_answer(text) if ok else None
        if not answer:
            return False, "runner inutilisable sur le poste : " + (text if not ok else clean(text, 150))
        _FRESH.add(key)
    if not answer.get("ok"):
        return False, "runner inutilisable sur le poste : " + str(answer)
    if answer.get("supprimes"):
        vlog(_T("{} fichier(s) de travail de plus d'un jour supprime(s)", answer['supprimes']), key)
    _RUNNER_OK.add(key)
    HELLO[key] = answer
    return True, ""


def upload_blob(key, data, then="", ident=None):
    """Depose `data` (octets) sur le poste : blocs compresses, reassembles et verifies.
    `then` : action du runner enchainee apres l'assemblage (meme commande).
    Renvoie (identifiant, reponse de l'action enchainee ou de l'assemblage, erreur)."""
    digest = hashlib.sha256(data).hexdigest()
    ident = ident or "d_" + digest[:24]
    blob = base64.b64encode(zlib.compress(data, 9)).decode("ascii")
    start = time.monotonic()
    for _attempt in range(2):
        stem = ident + "_" + uuid.uuid4().hex[:6]
        count, error = send_chunks(key, stem, blob)
        if error:
            return None, None, "envoi par XMPP interrompu : " + error
        answer, error = runner(key, f"assemble {ident} {count} {digest} @{stem} {then}".strip(),
                               WAIT_OF.get(then.split(" ")[0], 120) if then else 120)
        if answer and "missing" not in answer and answer.get("erreur") is None:
            break
    else:
        return None, None, "fichier incomplet sur le poste apres 2 envois : " + (error or str(answer))
    elapsed = max(time.monotonic() - start, 0.1)
    vlog(_T("depot par XMPP : {:.0f} Ko ({:.0f} Ko envoyes en {} bloc(s), {:.0f} s, {:.0f} Ko/s)", len(data) / 1e3, len(blob) / 1e3, count, elapsed, len(blob) / 1e3 / elapsed), key)
    return ident, answer, ""


WAIT_OF = {}


def run_remote(key, kind, code, timeout, prelude="", protect=False):
    """Execute un code du script sur le poste et attend le resultat (une commande).
    Pour les actions qui n'arretent pas le service (audits)."""
    ok, error = ensure_runner(key)
    if not ok:
        return None, error
    ident = code_id(code)
    pre = base64.b64encode(prelude.encode("utf-8")).decode("ascii") if prelude else "-"
    action = f"run {uuid.uuid4().hex[:12]} {kind} {ident} {timeout} {int(protect)} {pre}"
    answer, error = runner(key, action, timeout + 60)
    if answer is not None and answer.get("manque"):
        WAIT_OF["run"] = timeout + 60
        _ident, answer, error = upload_blob(key, code.encode("utf-8"), then=action, ident=ident)
    return answer, error


def job_step(name, kind, code, timeout, prelude="", ok=None):
    return {"nom": name, "kind": kind, "code_text": code, "timeout": int(timeout),
            "prelude": prelude, "ok": ok or []}


POLL_STEPS = (2, 2, 3, 3, 5, 5, 5, 10)


def run_sequence(key, steps, expected="", data=None):
    """Une seule action sur le poste pour toute une correction : les etapes s'executent
    a la suite par le Planificateur de taches (compte SYSTEM), le nom du poste est
    verifie sur place avant toute modification, puis le service est controle (garde).
    `data` : fichier depose avant (DATA_FILE dans le prelude des etapes Python)."""
    ok, error = ensure_runner(key)
    if not ok:
        return None, error
    job = uuid.uuid4().hex[:12]
    texts = {}
    spec_steps = []
    data_line = ""
    data_bytes = data
    if data_bytes is not None:
        data_id = "d_" + hashlib.sha256(data_bytes).hexdigest()[:24]
        # Chemin resolu sur le poste (le code s'execute dans le dossier de travail).
        data_line = f"DATA_FILE = __import__('os').path.abspath({data_id + '.bin'!r})\n"
    for item in steps:
        ident = code_id(item["code_text"])
        texts[ident] = item["code_text"]
        prelude = (data_line if item["kind"] == "py" else "") + item["prelude"]
        spec_steps.append({"nom": item["nom"], "kind": item["kind"], "code": ident,
                           "timeout": item["timeout"], "ok": item["ok"],
                           "prelude": base64.b64encode(prelude.encode("utf-8")).decode("ascii")
                           if prelude else "-"})
    spec = base64.b64encode(zlib.compress(json.dumps(
        {"etapes": spec_steps, "attendu": expected}).encode("utf-8"), 9)).decode("ascii")
    action = f"start {job} {spec}"
    WAIT_OF["start"] = 45
    for _round in range(3):
        if data_bytes is not None:
            # Donnees deposees et action lancee dans la meme commande.
            _ident, answer, error = upload_blob(key, data_bytes, then=action)
            data_bytes = None
        else:
            answer, error = runner(key, action, 45, attempts=3)
        if answer is None or not answer.get("manque"):
            break
        for ident in answer["manque"]:
            if ident in texts:
                _i, _a, error = upload_blob(key, texts[ident].encode("utf-8"), ident=ident)
    if not answer or not answer.get("ok"):
        return None, "lancement par le Planificateur de taches impossible : " + (
            (answer or {}).get("detail", "") or error)
    total = sum(item["timeout"] for item in steps)
    vlog(_T("action lancee sur le poste (tache MedullaDiag_{} : ", job)
         + ", ".join(item["nom"] for item in steps) + ")", key)
    deadline = time.monotonic() + total + JOB_MARGIN
    seen, polls = "", 0
    while time.monotonic() < deadline:
        time.sleep(POLL_STEPS[min(polls, len(POLL_STEPS) - 1)])
        polls += 1
        answer, error = runner(key, f"poll {job}", 15, attempts=1)
        if answer is None:
            progress(key, "attente du retour de l'agent")
            continue
        if answer.get("etat") == "termine":
            return answer.get("resultat") or {}, ""
        line = answer.get("derniere_ligne", "")
        if line and line != seen:
            seen = line
            vlog("   " + line.removeprefix("#ETAPE ").strip(), key)
    return None, (f"pas de resultat apres {total + JOB_MARGIN} s (agent injoignable ou "
                  "action toujours en cours)")


def run_job(key, kind, code, timeout, prelude="", data=None):
    """Une action seule (relance, controle) : sequence d'une etape."""
    result, error = run_sequence(key, [job_step("action", kind, code, timeout, prelude)], data=data)
    if result is None:
        return None, error
    done = (result.get("etapes") or [{}])[0]
    done["garde"] = result.get("garde", "")
    return done, ""


def job_log(result):
    log = (result.get("stdout") or "").rstrip()
    if (result.get("stderr") or "").strip():
        log += "\n--- erreurs ---\n" + result["stderr"].rstrip()
    if result.get("timeout"):
        log += "\n[poste] Delai depasse : action arretee sur le poste."
    if result.get("garde"):
        log += f"\nGARDE_SERVICE={result['garde']}"
    return log


SILENT = {}             # JID (sans ressource) -> erreur : agent sans reponse deja constate


TARGETS = {}            # IP -> noms demandes qui ont donne cette IP


MEDULLA_ROWS = []       # fiches de xmppmaster.machines (lues au lancement)


RELAUNCH_ALL = False    # --relancer-tout




def medulla_state(hostname):
    """Etat du poste dans Medulla, pour information : ON, OFF ou ABSENT."""
    forms = name_forms(hostname)
    found = [row for row in MEDULLA_ROWS if name_forms(row.get("hostname")) & forms]
    if not found:
        return "ABSENT"
    return "ON" if any(row.get("enabled") == 1 for row in found) else "OFF"


def resolve_name(name):
    """IPv4 du nom par le DNS (5 s maximum), liste vide si inconnu."""
    box = {}

    def work():
        try:
            box["value"] = sorted({item[4][0] for item in socket.getaddrinfo(
                name, None, socket.AF_INET, socket.SOCK_STREAM)})
        except (OSError, UnicodeError):
            box["value"] = []

    worker = threading.Thread(target=work, daemon=True)
    worker.start()
    worker.join(DNS_WAIT)
    return box.get("value", [])


VERIFIED = {}           # IP -> audit fait sur le poste a la fin de sa correction


def parse_diag(output, stderr="", rc=None, expired=False):
    if expired:
        steps = [line[7:].strip() for line in output.splitlines() if line.startswith("#ETAPE ")]
        where = (f", bloque a l'etape : {steps[-1]}" if steps else ", Python n'a rien renvoye")
        return None, f"Timeout diagnostic ({DIAG_TIMEOUT} s){where}"
    for line in reversed(output.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                return json.loads(line), ""
            except ValueError:
                break
    return None, clean(stderr or output) or f"code {rc}"


def fill_inventory_tool(row, info):
    """Agent d'inventaire du poste (lu pendant l'audit) : GLPI-Agent < 1.8 ne connait
    pas l'option --full que l'agent Medulla lui passe."""
    info = info if isinstance(info, dict) else {}
    row["agent_inventaire"] = info.get("agent", "")
    row["programme_inventaire"] = info.get("programme", "")
    row["version_inventaire"] = info.get("version", "")
    row["_autre_inventaire"] = info.get("autre", "")
    version = parse_version(row["version_inventaire"])
    if row["agent_inventaire"] != "glpiagent" or row["programme_inventaire"] != "PRESENT":
        row["inventaire_a_jour"] = ""
    elif version is None:
        row["inventaire_a_jour"] = "INCONNU"
    else:
        row["inventaire_a_jour"] = "OUI" if version[:2] >= GLPI_FULL_MIN else "NON"


LIB_PRELUDE = (
    "import base64 as _b64, json as _json, os as _os\n"
    "with open(DATA_FILE, 'rb') as _s:\n"
    "    _raw = _s.read()\n"
    "_n = int(_raw[:12])\n"
    "_index = _json.loads(_raw[12:12 + _n].decode('utf-8'))\n"
    "_pos = 12 + _n\n"
    "for _item in _index:\n"
    "    _size = _item.pop('size')\n"
    "    _item['data'] = _b64.b64encode(_raw[_pos:_pos + _size]).decode('ascii')\n"
    "    _pos += _size\n"
    "PAYLOAD_FILE = DATA_FILE + '.plan'\n"
    "with open(PAYLOAD_FILE, 'wb') as _s:\n"
    "    _s.write(_b64.b64encode(_json.dumps(_index).encode('utf-8')))\n"
    "del _raw\n"
    "try:\n"
    "    _os.remove(DATA_FILE)\n"
    "except OSError:\n"
    "    pass\n"
)


def lib_container(row, parts):
    """Fichiers de reference a copier, en un seul conteneur (sans base64 superflu)."""
    index, contents, sources = [], [], []
    if "LIB" in parts:
        sources += [(relative, mode, before, "root", LIB_REF["agentxmpp"])
                    for relative, mode, before in row.get("_root_plan", [])]
        sources += [(relative, mode, before, "agent", LIB_REF["files"][relative.casefold()][1:])
                    for relative, mode, before in row["_lib_plan"]]
    if "RESCUE_LIB" in parts:
        sources += [(relative, mode, before, "agent", LIB_REF["files"][key][1:])
                    for relative, mode, before, key in row.get("_rescue_plan", [])]
    for relative, mode, before, base, (size, sha, path) in sources:
        raw = Path(path).read_bytes()
        if hashlib.sha256(raw).hexdigest() != sha:
            return None, f"[serveur] Fichier de reference modifie : {path}"
        index.append({"path": relative, "mode": mode, "sha256": sha, "base": base,
                      "before": before, "size": len(raw)})
        contents.append(raw)
    header = json.dumps(index).encode("utf-8")
    return b"%012d" % len(header) + header + b"".join(contents), ""


def step_status(done):
    """Statut JSON d'une etape Python (creation var\\log, copie) et son journal."""
    output = done.get("stdout") or ""
    for line in reversed(output.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                answer = json.loads(line)
            except ValueError:
                break
            if answer.get("python") == "ABSENT":
                return "ECHEC", "[serveur] Python de l'agent absent sur le poste."
            return answer.get("status", "ECHEC"), "\n".join(answer.get("log", []))
    if done.get("timeout"):
        steps = [line[7:].strip() for line in output.splitlines() if line.startswith("#ETAPE ")]
        return "TIMEOUT_A_VERIFIER", "\n".join(steps) + "\n[poste] Delai depasse : etape arretee."
    return "ECHEC", job_log(done) or f"code {done.get('rc')}"


INVENTORY_SQL = (
    "SELECT JSON_OBJECT('id', id, 'hostname', hostname, 'jid', jid, 'ip_xmpp', ip_xmpp, "
    "'uuid_serial_machine', uuid_serial_machine, 'macaddress', macaddress, "
    "'platform', platform) FROM machines "
    "WHERE (uuid_inventorymachine = '' OR uuid_inventorymachine IS NULL) "
    "AND enabled = 1 AND agenttype = 'machine';"
)
INVENTORY_FIELDS = [
    "id", "hostname", "jid", "ip", "plateforme", "uuid_serial_medulla", "uuid_smbios_poste",
    "agent_connecte", "agent_repond", "nom_poste", "agent_inventaire", "programme_inventaire",
    "autre_programme", "version_inventaire", "via_xmpp", "inventaire_poste", "inventaire_age_h", "inventaire_ko",
    "erreurs_journal", "journal_medulla", "problemes", "demande_possible", "action",
    "resultat", "uuid_inventaire_apres", "detail",
]
INVENTORY_LABELS = {
    "id": "Id Medulla", "hostname": "Nom dans Medulla", "jid": "JID", "ip": "IP (session XMPP)",
    "plateforme": "Plateforme", "uuid_serial_medulla": "UUID materiel connu de Medulla",
    "uuid_smbios_poste": "UUID materiel du poste (SMBIOS)",
    "agent_connecte": "Agent connecte a ejabberd", "agent_repond": "Agent repond (XMPP)",
    "nom_poste": "Nom repondu par l'agent", "agent_inventaire": "Agent d'inventaire configure",
    "programme_inventaire": "Programme d'inventaire", "autre_programme": "Autre agent d'inventaire",
    "version_inventaire": "Version de l'agent d'inventaire",
    "via_xmpp": "Envoi par XMPP (inventory.ini)", "inventaire_poste": "Dernier inventaire sur le poste",
    "inventaire_age_h": "Age du dernier inventaire (h)", "inventaire_ko": "Taille (Ko)",
    "erreurs_journal": "Erreurs d'inventaire dans le journal de l'agent",
    "journal_medulla": "Derniers messages d'inventaire dans Medulla",
    "problemes": "Problemes", "demande_possible": "Demande d'inventaire possible",
    "action": "Action", "resultat": "Resultat", "uuid_inventaire_apres": "UUID d'inventaire apres",
    "detail": "Detail",
}

# Audit de la chaine d'inventaire sur le poste (lecture seule).
INVENTORY_DIAG_PY = r'''
import configparser
import json
import os
import re
import subprocess
import sys
import time

ARGS = sys.argv[1:]
CONF = ARGS[1] if len(ARGS) > 1 else r"C:\Program Files\Medulla\etc"
BASE = os.path.dirname(CONF)
PF = os.environ.get("ProgramFiles", r"C:\Program Files")
result = {"ok": True}
parser = configparser.ConfigParser(interpolation=None)
try:
    parser.read(os.path.join(CONF, "inventory.ini"), encoding="utf-8")
except Exception:
    result["inventory_ini"] = "ILLISIBLE"


def get(key, default=""):
    try:
        return parser.get("parameters", key, fallback=default).strip()
    except Exception:
        return default


agent = "glpiagent" if get("agent").lower() == "glpiagent" else "fusioninventory"
programs = {"glpiagent": os.path.join(PF, "GLPI-Agent", "glpi-agent.bat"),
            "fusioninventory": os.path.join(PF, "FusionInventory-Agent", "fusioninventory-agent.bat")}
other = "fusioninventory" if agent == "glpiagent" else "glpiagent"
result["agent_inventaire"] = agent
result["programme"] = "PRESENT" if os.path.isfile(programs[agent]) else "ABSENT"
result["autre_programme"] = (other + " present") if os.path.isfile(programs[other]) else "absent"
result["via_xmpp"] = get("via_xmpp", "True") or "True"


def inventory_version(name):
    """Version de GLPI-Agent / FusionInventory : lue dans Version.pm (instantane), sinon
    par --version (quelques secondes)."""
    folder, package = {"glpiagent": ("GLPI-Agent", "GLPI"),
                       "fusioninventory": ("FusionInventory-Agent", "FusionInventory")}[name]
    module = os.path.join(PF, folder, "perl", "agent", package, "Agent", "Version.pm")
    try:
        with open(module, "rb") as stream:
            found = re.search(r"VERSION\s*=\s*['\"]([0-9][0-9.]*)", stream.read().decode("utf-8", "replace"))
        if found:
            return found.group(1)
    except OSError:
        pass
    if result.get("programme") != "PRESENT" or not sys.platform.startswith("win"):
        return ""
    try:
        out = subprocess.run([programs[name], "--version"], stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT, timeout=30).stdout.decode("cp850", "replace")
    except Exception:
        return ""
    found = re.search(r"Agent \(([0-9][0-9.]*)", out)
    return found.group(1) if found else ""


result["version_inventaire"] = inventory_version(agent)
result["urlinventory"] = "OUI" if get("urlinventory") else "NON"
inventory = os.path.join(BASE, "tmp", "inventory.txt")
result["inventaire_fichier"] = "ABSENT"
for name in (inventory, inventory + ".back"):
    if os.path.isfile(name):
        info = os.stat(name)
        result["inventaire_fichier"] = os.path.basename(name)
        result["inventaire_age_h"] = round((time.time() - info.st_mtime) / 3600, 1)
        result["inventaire_ko"] = round(info.st_size / 1024.0, 1)
        break
errors = []
log = os.path.join(BASE, "var", "log", "xmpp-agent-machine.log")
try:
    with open(log, "rb") as stream:
        stream.seek(max(0, os.path.getsize(log) - 3000000))
        lines = stream.read().decode("utf-8", "replace").splitlines()
    errors = [re.sub(r"(?i)(password\S*\s*[=:]\s*)\S+", r"\1****", line.strip())[:220]
              for line in lines if "nventor" in line and "ERROR" in line.upper()][-3:]
except OSError:
    pass
result["erreurs"] = errors
uuid = os.environ.get("MEDULLA_TEST_SMBIOS", "")
if not uuid and sys.platform.startswith("win"):
    for command, limit in ((["wmic", "csproduct", "get", "uuid", "/value"], 10),
                           (["powershell", "-NoProfile", "-NonInteractive", "-Command",
                             "(Get-CimInstance Win32_ComputerSystemProduct).UUID"], 20)):
        try:
            out = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                                 timeout=limit).stdout.decode("cp850", "replace")
        except Exception:
            continue
        uuid = out.replace("UUID=", "").strip()
        if uuid:
            break
result["uuid_smbios"] = uuid
print(json.dumps(result))
'''


def inventory_logs(jids):
    """Derniers messages d'inventaire de Medulla (table logs, 30 jours) par JID."""
    users = sorted({jid_parts(jid)[0] for jid in jids if jid})
    found = {}
    for start in range(0, len(users), 200):
        part = users[start:start + 200]
        where = " OR ".join("who LIKE " + sql_text(user.replace("%", "") + "@%") for user in part)
        try:
            output = mysql_exec(
                "SELECT JSON_OBJECT('who', who, 'date', DATE_FORMAT(date, '%Y-%m-%d %H:%i'), "
                "'text', LEFT(text, 300)) FROM logs WHERE type LIKE 'Invent%' AND ("
                + where + ") AND date > NOW() - INTERVAL 30 DAY ORDER BY date DESC;")
        except (subprocess.SubprocessError, OSError):
            return {}
        for line in output.splitlines():
            try:
                item = json.loads(line)
            except ValueError:
                continue
            user = jid_parts(item.get("who"))[0]
            text = re.sub(r"<[^>]+>", "", item.get("text") or "")
            found.setdefault(user, [])
            if len(found[user]) < 3:
                found[user].append(f"{item.get('date')} {clean(text, 140)}")
    return found


def inventory_audit(key):
    """Agent joignable, bon poste, puis audit de l'inventaire sur le poste."""
    progress(key, "XMPP")
    try:
        silent = SILENT.get(jid_parts(JID_OF.get(key, ""))[0].casefold())
        if silent:
            return {"agent_repond": "TIMEOUT",
                    "detail": f"deja sans reponse pendant le diagnostic : {silent}"}
        if key not in _RUNNER_OK:
            hostname, error = test_ssh(key)
            if not hostname:
                return {"agent_repond": classify_ssh(error), "detail": error}
        progress(key, "audit de l'inventaire")
        remote, error = run_remote(key, "py", INVENTORY_DIAG_PY, 90)
        if remote is None and "Timeout XMPP" in (error or ""):
            return {"agent_repond": "TIMEOUT", "detail": error}
        answer = {"agent_repond": "OK", "nom_poste": (remote or {}).get("hostname")
                  or HELLO.get(key, {}).get("hostname", "")}
        if remote is None or remote.get("timeout"):
            answer["detail"] = "audit de l'inventaire impossible : " + (error or "delai depasse")
            return answer
        for line in reversed((remote.get("stdout") or "").splitlines()):
            if line.strip().startswith("{"):
                try:
                    answer["poste"] = json.loads(line)
                except ValueError:
                    pass
                break
        if "poste" not in answer:
            answer["detail"] = "audit de l'inventaire illisible : " + clean(
                remote.get("stderr") or remote.get("stdout") or "", 150)
        return answer
    finally:
        progress(key)


def analyse_inventory(row, answer):
    row["agent_repond"] = answer.get("agent_repond", "")
    row["nom_poste"] = answer.get("nom_poste", "")
    row["detail"] = answer.get("detail", "")
    problems, blocked = [], False
    if row["agent_repond"] != "OK":
        problems.append("agent sans reponse par XMPP")
        blocked = True
    elif not name_forms(row["nom_poste"]) & name_forms(row["hostname"]):
        problems.append(f"l'agent repond {row['nom_poste']} (autre poste)")
        row["detail"] = "poste non traite : nom different de la fiche Medulla"
        blocked = True
    poste = answer.get("poste")
    if poste:
        row["agent_inventaire"] = poste.get("agent_inventaire", "")
        row["programme_inventaire"] = poste.get("programme", "")
        row["autre_programme"] = poste.get("autre_programme", "")
        row["version_inventaire"] = poste.get("version_inventaire", "")
        row["via_xmpp"] = poste.get("via_xmpp", "")
        row["inventaire_poste"] = poste.get("inventaire_fichier", "")
        row["inventaire_age_h"] = str(poste.get("inventaire_age_h", "")).replace(".", ",")
        row["inventaire_ko"] = str(poste.get("inventaire_ko", "")).replace(".", ",")
        row["erreurs_journal"] = " | ".join(poste.get("erreurs") or [])
        row["uuid_smbios_poste"] = poste.get("uuid_smbios", "")
        if row["programme_inventaire"] == "ABSENT":
            names = {"glpiagent": "GLPI-Agent", "fusioninventory": "FusionInventory-Agent"}
            problems.append(f"{names.get(row['agent_inventaire'], row['agent_inventaire'])} "
                            "absent du poste (agent d'inventaire a installer"
                            + (f", {row['autre_programme']}" if "present" in row["autre_programme"]
                               else "") + ")")
            blocked = True
        version = parse_version(row["version_inventaire"])
        if (row["agent_inventaire"] == "glpiagent" and row["programme_inventaire"] == "PRESENT"
                and version and version < GLPI_FULL_MIN):
            # L'agent Medulla lance GLPI-Agent avec --full, option apparue en 1.8 : les
            # versions anterieures la refusent et ne produisent aucun inventaire.
            problems.append(f"GLPI-Agent {row['version_inventaire']} trop ancien (< 1.8 : option "
                            "--full refusee, aucun inventaire produit) : mettre GLPI-Agent a jour")
            blocked = True
        if row["via_xmpp"].lower() == "false" and poste.get("urlinventory") == "NON":
            problems.append("inventory.ini : via_xmpp = False sans urlinventory")
            blocked = True
        if row["inventaire_poste"] == "ABSENT":
            problems.append("aucun inventaire produit sur le poste")
        if row["erreurs_journal"]:
            problems.append("erreurs d'inventaire dans le journal de l'agent")
        serial = (row["uuid_serial_medulla"] or "").strip().casefold()
        smbios = row["uuid_smbios_poste"].strip().casefold()
        if not serial:
            problems.append("UUID materiel absent de Medulla (rattachement GLPI par MAC seulement)")
        elif smbios and serial != smbios:
            problems.append("UUID materiel different entre Medulla et le poste")
    elif row["agent_repond"] == "OK" and not blocked:
        problems.append("audit de l'inventaire impossible")
    row["problemes"] = " | ".join(problems) or "Aucun"
    row["demande_possible"] = "NON" if blocked or not poste else "OUI"


def request_inventories(rows, args, step):
    """Apres les corrections du choix 2 : demande d'inventaire aux postes corriges ou
    relances avec succes (comme l'action rapide Inventaire de Medulla), puis controle du
    rattachement dans Medulla pour ceux qui n'avaient pas d'UUID d'inventaire."""
    failed = ("ECHEC", "TIMEOUT", "ANNULE")
    targets = [row for row in rows if row["ip"] in JID_OF and (
        (launched(row["correction"]) and not row["correction"].startswith(failed)) or
        (launched(row["relance"]) and not row["relance"].startswith(failed)) or
        (inventory_missing(row) and not row["correction"].startswith(failed)
         and not row["relance"].startswith(failed)))]
    for row in [row for row in targets if row.get("inventaire_a_jour") == "NON"
                or row.get("programme_inventaire") == "ABSENT"]:
        row["inventaire"] = ("NON_DEMANDE (GLPI-Agent absent)"
                             if row.get("programme_inventaire") == "ABSENT" else
                             f"NON_DEMANDE (GLPI-Agent {row['version_inventaire']} < "
                             f"{version_text(GLPI_FULL_MIN)})")
        targets.remove(row)
        print(paint(_T("  Pas de demande d'inventaire pour {} : {} (mettre GLPI-Agent a jour d'abord).", row['hostname_ssh'], row['inventaire'][13:-1]), "jaune"))
    if not targets:
        return
    step(_T("DEMANDE D'INVENTAIRE : {} poste(s) corrige(s), relance(s) ou sans inventaire recent", len(targets)))
    names = sorted({row["hostname_ssh"] for row in targets})
    try:
        output = mysql_exec("SELECT JSON_OBJECT('hostname', hostname, 'uuid', "
                            "uuid_inventorymachine) FROM machines WHERE hostname IN ("
                            + ", ".join(sql_text(name) for name in names) + ");")
    except (subprocess.SubprocessError, OSError):
        output = ""
    known = {}
    for line in output.splitlines():
        try:
            item = json.loads(line)
        except ValueError:
            continue
        if item.get("uuid"):
            known[(item.get("hostname") or "").casefold()] = item["uuid"]
    print_table(["IP", _T("Poste"), _T("Action"), _T("UUID d'inventaire")],
                [[row["ip"], short(row["hostname_ssh"], 24),
                  row["correction"] if launched(row["correction"]) else
                  row["relance"] if launched(row["relance"]) else "INVENTAIRE_ABSENT_OU_ANCIEN",
                  known.get(row["hostname_ssh"].casefold(), "aucun")] for row in targets],
                colored=(2,))
    print(paint(_T("  L'agent lance son agent d'inventaire et l'envoie au serveur (aucun fichier modifie, service non"), "jaune"))
    print(paint(_T("  arrete). Pour les postes sans UUID, le rattachement dans Medulla est ensuite controle."), "jaune"))
    if not confirm(_T("demander un inventaire a {} poste(s)", len(targets)), args.oui):
        for row in targets:
            row["inventaire"] = "NON_CONFIRME"
        return
    for row in targets:
        refresh_jid(row["ip"])                 # l'agent a pu se reconnecter apres la relance
        ok, error = LINK.send(JID_OF[row["ip"]], "inventory", {"forced": "forced"})
        row["inventaire"] = "INVENTAIRE_DEMANDE" if ok else "ECHEC_ENVOI"
        row["uuid_inventaire"] = known.get(row["hostname_ssh"].casefold(), "")
    waiting = {row["hostname_ssh"].casefold(): row for row in targets
               if row["inventaire"] == "INVENTAIRE_DEMANDE" and not row["uuid_inventaire"]}
    print(_T("  Demandes envoyees : {}", sum(row['inventaire'] == 'INVENTAIRE_DEMANDE' for row in targets))
          + (_T(" ; rattachement controle pour {} poste(s) sans UUID ({} s au plus, Ctrl+C pour arreter l'attente).", len(waiting), args.attente_inventaire) if waiting
             else "."))
    end_time = time.monotonic() + args.attente_inventaire
    try:
        while waiting and time.monotonic() < end_time:
            time.sleep(INVENTORY_CHECK_EVERY)
            output = mysql_exec(
                "SELECT JSON_OBJECT('hostname', hostname, 'uuid', uuid_inventorymachine) "
                "FROM machines WHERE hostname IN ("
                + ", ".join(sql_text(row["hostname_ssh"]) for row in waiting.values())
                + ") AND uuid_inventorymachine <> '';")
            for line in output.splitlines():
                try:
                    item = json.loads(line)
                except ValueError:
                    continue
                row = waiting.pop((item.get("hostname") or "").casefold(), None)
                if row is not None:
                    row["inventaire"], row["uuid_inventaire"] = "RATTACHE", item.get("uuid", "")
                    print(f"  {short(row['hostname_ssh'], 24):<24}  "
                          + paint(_T("rattache ({})", row['uuid_inventaire']), "vert"), flush=True)
            if waiting:
                print(paint(_T("  ... {} en attente, encore {} s", len(waiting), max(int(end_time - time.monotonic()), 0)), "gris"), flush=True)
    except KeyboardInterrupt:
        print(paint(_T("\n  Attente interrompue."), "jaune"))
    for row in waiting.values():
        row["inventaire"] = "TOUJOURS_SANS_UUID"


def inventory_mode(args, acting, reason):
    """Machines ON sans UUID d'inventaire : audit de l'inventaire par XMPP, puis (apres OUI)
    demande d'inventaire a l'agent et controle du rattachement dans Medulla."""
    banner(_T("MEDULLA - MACHINES ON SANS UUID D'INVENTAIRE (XMPP)"))
    print(_T("  Mode         : ") + (paint(_T("AUDIT + DEMANDE D'INVENTAIRE"), "jaune") if acting
                                 else paint(_T("AUDIT SEUL, aucune modification"), "vert"))
          + f"  ({reason})")
    print(_T("  Selection    : machines enabled = 1, agenttype = 'machine', uuid_inventorymachine vide"))
    print(_T("  Attente      : rattachement controle toutes les {} s pendant {} s au plus apres la demande", INVENTORY_CHECK_EVERY, args.attente_inventaire))
    global DIAG_TIMEOUT
    DIAG_TIMEOUT = 90          # libelle du suivi : delai de l'audit de l'inventaire
    started = datetime.now().isoformat(timespec="seconds")
    machines = [json.loads(line) for line in mysql_exec(INVENTORY_SQL).splitlines() if line.strip()]
    if args.limite:
        machines = machines[:args.limite]
    subtitle(_T("Base Medulla"))
    kv(_T("Machines ON sans UUID d'inventaire"), paint(str(len(machines)), "gras"))
    if not machines:
        print(paint(_T("\n  Aucune machine concernee."), "vert"))
        return
    sessions = connected_sessions()
    by_user = {}
    for jid in sessions:
        by_user.setdefault(jid_parts(jid)[0].casefold(), jid)
    rows, keys = [], {}
    for machine in machines:
        row = dict.fromkeys(INVENTORY_FIELDS, "")
        row.update(id=str(machine.get("id", "")), hostname=machine.get("hostname") or "",
                   jid=machine.get("jid") or "", plateforme=machine.get("platform") or "",
                   uuid_serial_medulla=machine.get("uuid_serial_machine") or "")
        jid = by_user.get(jid_parts(row["jid"])[0].casefold())
        row["agent_connecte"] = "OUI" if jid else "NON"
        if jid:
            # Meme agent que pendant le diagnostic : meme cle (runner deja verifie).
            key = next((k for k, value in JID_OF.items() if value == jid), f"inv{row['id']}")
            JID_OF[key] = jid
            keys[key] = row
        else:
            row.update(agent_repond="NON_CONNECTE", problemes="agent non connecte a ejabberd "
                       "(fiche ON obsolete ?)", demande_possible="NON")
        rows.append(row)
    with ThreadPoolExecutor(max_workers=16) as pool:
        for key, address in zip(keys, pool.map(session_address, [JID_OF[k] for k in keys])):
            keys[key]["ip"] = address or keys[key]["ip"]
    kv(_T("Agents connectes a ejabberd"), len(keys))
    kv(_T("Agents non connectes (non testes)"), len(rows) - len(keys))
    logs = inventory_logs([row["jid"] for row in rows])
    for row in rows:
        row["journal_medulla"] = " | ".join(logs.get(jid_parts(row["jid"])[0], []))

    if keys and not LINK.account:
        LINK.start(jid_parts(JID_OF[next(iter(keys))])[1])
        user, domain = LINK.account
        kv(_T("Compte XMPP temporaire"), f"{user}@{domain} (supprime a la fin)")
    banner(_T("ETAPE 1 - AUDIT DE L'INVENTAIRE (lecture seule) : {} agent(s)", len(keys)))
    done = [0]

    def on_audit(key, answer):
        row = keys[key]
        analyse_inventory(row, answer)
        done[0] += 1
        color = "vert" if row["problemes"] == "Aucun" else "jaune"
        print(f"  [{done[0]:>{len(str(len(keys)))}}/{len(keys)}] {short(row['hostname'], 20):<20}  "
              f"agent {tag(row['agent_repond'], 12)}  inventaire {tag(row['programme_inventaire'] or '?', 7)} "
              f"{short(row['version_inventaire'] or '', 8):<8}  "
              + paint(short(tr_problems(row["problemes"]), 90), color), flush=True)

    stop_watch = threading.Event()
    threading.Thread(target=watch_progress, args=(stop_watch,), daemon=True).start()
    try:
        run_pool(list(keys), inventory_audit, args.parallelisme, on_audit, graceful=False)
    finally:
        stop_watch.set()

    subtitle(_T("Bilan de l'audit"))
    eligible = [row for row in rows if row["demande_possible"] == "OUI"]
    kv(_T("Machines ON sans UUID d'inventaire"), len(rows))
    kv(_T("  agent non connecte a ejabberd"), sum(row["agent_connecte"] == "NON" for row in rows))
    kv(_T("  agent sans reponse ou autre poste"),
       sum(row["agent_connecte"] == "OUI" and row["demande_possible"] != "OUI" and
           not row["programme_inventaire"] for row in rows))
    kv(_T("  agent d'inventaire absent du poste"),
       sum(row["programme_inventaire"] == "ABSENT" for row in rows))
    old_glpi = [row for row in rows if "trop ancien" in row["problemes"]]
    kv(_T("  GLPI-Agent anterieur a 1.8 (a mettre a jour)"), len(old_glpi))
    kv(_T("  demande d'inventaire possible"), paint(str(len(eligible)), "gras"))
    for label, count in Counter(problem for row in rows for problem in row["problemes"].split(" | ")
                                if problem and problem != "Aucun").most_common(12):
        kv("  " + short(label, 60), count)

    if acting and eligible:
        banner(_T("ETAPE 2 - DEMANDE D'INVENTAIRE : {} machine(s)", len(eligible)))
        print_table(["Id", _T("Poste"), _T("Agent d'inventaire"), _T("Constat")],
                    [[row["id"], short(row["hostname"], 24), row["agent_inventaire"],
                      short(tr_problems(row["problemes"]), 60)] for row in eligible[:50]])
        if len(eligible) > 50:
            print(_T("  ... et {} autre(s).", len(eligible) - 50))
        print(paint(_T("  Sur chaque poste, l'agent lance son agent d'inventaire (comme l'action rapide Inventaire de"), "jaune"))
        print(paint(_T("  Medulla) et l'envoie au serveur. Aucun fichier de l'agent n'est modifie, le service n'est pas"), "jaune"))
        print(paint(_T("  arrete. Medulla rattache ensuite la machine a son inventaire GLPI."), "jaune"))
        if confirm(_T("demander un inventaire a {} machine(s)", len(eligible)), args.oui):
            for row in eligible:
                key = next(k for k, value in keys.items() if value is row)
                ok, error = LINK.send(JID_OF[key], "inventory", {"forced": "forced"})
                row["action"] = "INVENTAIRE_DEMANDE" if ok else "ECHEC_ENVOI"
                row["detail"] = error
            waiting = {row["id"]: row for row in eligible if row["action"] == "INVENTAIRE_DEMANDE"}
            print(_T("  Demandes envoyees : {}. Controle du rattachement dans Medulla (Ctrl+C pour arreter l'attente)...", len(waiting)))
            end_time = time.monotonic() + args.attente_inventaire
            try:
                while waiting and time.monotonic() < end_time:
                    time.sleep(INVENTORY_CHECK_EVERY)
                    ids = ", ".join(str(int(value)) for value in waiting if value.isdigit())
                    if not ids:
                        break
                    output = mysql_exec(
                        "SELECT JSON_OBJECT('id', id, 'uuid', uuid_inventorymachine) FROM machines "
                        f"WHERE id IN ({ids}) AND uuid_inventorymachine <> '';")
                    for line in output.splitlines():
                        try:
                            item = json.loads(line)
                        except ValueError:
                            continue
                        row = waiting.pop(str(item.get("id")), None)
                        if row is not None:
                            row["resultat"], row["uuid_inventaire_apres"] = "RATTACHE", item.get("uuid")
                            print(f"  {short(row['hostname'], 24):<24}  "
                                  + paint(_T("rattache ({})", item.get('uuid')), "vert"), flush=True)
                    remaining = int(end_time - time.monotonic())
                    if waiting:
                        print(paint(_T("  ... {} en attente, encore {} s", len(waiting), max(remaining, 0)),
                                    "gris"), flush=True)
            except KeyboardInterrupt:
                print(paint(_T("\n  Attente interrompue."), "jaune"))
            if waiting:
                late = inventory_logs([row["jid"] for row in waiting.values()])
                for row in waiting.values():
                    row["resultat"] = "TOUJOURS_SANS_UUID"
                    row["journal_medulla"] = " | ".join(late.get(jid_parts(row["jid"])[0], [])) \
                        or row["journal_medulla"]
        else:
            print(paint(_T("  Demandes annulees : aucun inventaire demande."), "rouge"))
            for row in eligible:
                row["action"] = "NON_CONFIRMEE"
    elif acting:
        print(paint(_T("\n  Aucune machine ne permet une demande d'inventaire."), "jaune"))

    output_dir = Path(args.dossier) / datetime.now().strftime("releve_inventaire_%Y%m%d_%H%M%S")
    output_dir.mkdir(parents=True)
    with (output_dir / "inventaire.csv").open("w", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=INVENTORY_FIELDS, delimiter=";",
                                extrasaction="ignore")
        writer.writerow(INVENTORY_LABELS)
        writer.writerows(rows)
    if VERBOSE:
        (output_dir / "trace.log").write_text("\n".join(TRACE) + "\n", encoding="utf-8")
    banner(_T("RESUME"))
    kv(_T("Debut"), started)
    kv(_T("Machines ON sans UUID d'inventaire"), len(rows))
    kv(_T("Demande d'inventaire possible"), len(eligible))
    if acting:
        for value, count in sorted(Counter(row["resultat"] or row["action"] or "-"
                                           for row in eligible).items()):
            kv(_T("Resultat : {}", tag(value)), count)
        still = [row for row in eligible if row["resultat"] == "TOUJOURS_SANS_UUID"]
        if still:
            subtitle(paint(_T("TOUJOURS SANS UUID D'INVENTAIRE"), "rouge"))
            print_table(["Id", _T("Poste"), _T("Derniers messages d'inventaire dans Medulla")],
                        [[row["id"], short(row["hostname"], 24),
                          short(row["journal_medulla"] or "aucun message", 90)] for row in still])
            print(paint(_T("  Causes possibles : injection GLPI en echec, adresse MAC ou UUID materiel en double, agent"), "gris"))
            print(paint(_T("  d'inventaire en erreur sur le poste (voir colonne erreurs du CSV)."), "gris"))
    if old_glpi:
        subtitle(paint(_T("GLPI-AGENT A METTRE A JOUR (version < 1.8)"), "jaune"))
        print_table(["Id", _T("Poste"), _T("Version GLPI-Agent")],
                    [[row["id"], short(row["hostname"], 24), row["version_inventaire"]]
                     for row in old_glpi])
        print(paint(_T("  L'agent Medulla appelle GLPI-Agent avec --full (option apparue en 1.8) : ces postes ne"), "gris"))
        print(paint(_T("  produisent aucun inventaire tant que GLPI-Agent n'est pas mis a jour."), "gris"))
    subtitle(_T("Fichiers"))
    print(f"  {output_dir / 'inventaire.csv'}")
    print()


# ---------------------------------------------------------------- transport XMPP : actions sur le poste

def x_test_ping(ip):
    """Session de l'agent ouverte sur ejabberd (remplace le ping)."""
    jid = JID_OF.get(ip)
    return ("OK", "") if jid else ("KO", "")


def x_test_ssh(ip):
    """Nom du poste, demande a l'agent par XMPP. Au premier contact, la meme commande
    verifie (ou depose) le runner ; ensuite, une commande "hello" a chaque appel."""
    if ip in _RUNNER_OK:
        answer, error = runner(ip, "hello", SSH_TIMEOUT)
        if answer is None:
            return "", error
        HELLO[ip] = answer
    else:
        ok, error = ensure_runner(ip)
        if not ok:
            if error.startswith("Timeout XMPP") or "Timeout XMPP" in error:
                SILENT[jid_parts(JID_OF.get(ip, ""))[0].casefold()] = error
            return "", error
    SILENT.pop(jid_parts(JID_OF.get(ip, ""))[0].casefold(), None)
    hostname = str(HELLO.get(ip, {}).get("hostname") or "").strip()
    if not hostname:
        return "", "Nom du poste vide"
    if "\ufffd" in hostname or any(char.isspace() or ord(char) < 32 for char in hostname):
        return "", "Nom du poste inexploitable"
    return hostname, ""


def x_run_diag(ip):
    if ip in VERIFIED:
        return VERIFIED.pop(ip)       # audit deja fait sur le poste, a la fin de la correction
    answer, error = run_remote(ip, "py", DIAG_PY, DIAG_TIMEOUT,
                               prelude=f"PULSE_HOST = {pulse_address_for(ip)!r}\n")
    if answer is None:
        if error.startswith("Timeout XMPP"):
            return None, f"Timeout diagnostic ({DIAG_TIMEOUT} s) : {error}"
        return None, error
    output = answer.get("stdout") or ""
    if answer.get("timeout"):
        steps = [line[7:].strip() for line in output.splitlines() if line.startswith("#ETAPE ")]
        where = (f", bloque a l'etape : {steps[-1]}" if steps
                 else ", Python n'a rien renvoye")
        return None, f"Timeout diagnostic ({DIAG_TIMEOUT} s){where}"
    for line in reversed(output.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                return json.loads(line), ""
            except ValueError:
                break
    return None, clean(answer.get("stderr") or output) or f"code {answer.get('rc')}"


def x_run_repair(ip):
    """Execute reparer-agent-medulla.ps1 sur le poste, par le Planificateur de taches :
    la correction va au bout meme si le serveur perd le contact avec l'agent."""
    result, error = run_job(ip, "ps1", REPAIR_PS1.strip(), REPAIR_TIMEOUT)
    if result is None:
        return "TIMEOUT_A_VERIFIER", f"[serveur] {error} : etat du poste a verifier."
    log = job_log(result) + f"\n[serveur] code de sortie : {result.get('rc')}"
    if result.get("timeout"):
        return "TIMEOUT_A_VERIFIER", log
    return classify_repair(result.get("rc"), log), log


def x_run_lib_fix(row, parts):
    """Mise a niveau par delta depuis la reference serveur, pour les controles retenus :
    LIB (agent principal + agentxmpp.py), RESCUE_LIB (agentrescue\\lib). Les fichiers
    sont deposes par XMPP (sans base64 superflu : compression efficace), puis la copie
    est lancee par le Planificateur de taches (le service est arrete pendant la copie)."""
    index, contents = [], []
    sources = []
    if "LIB" in parts:
        sources += [(relative, mode, before, "root", LIB_REF["agentxmpp"])
                    for relative, mode, before in row.get("_root_plan", [])]
        sources += [(relative, mode, before, "agent", LIB_REF["files"][relative.casefold()][1:])
                    for relative, mode, before in row["_lib_plan"]]
    for fix, field in (("RESCUE_LIB", "rescue"),):
        if fix in parts:
            sources += [(relative, mode, before, "agent", LIB_REF["files"][key][1:])
                        for relative, mode, before, key in row.get(f"_{field}_plan", [])]
    for relative, mode, before, base, (size, sha, path) in sources:
        raw = Path(path).read_bytes()
        if hashlib.sha256(raw).hexdigest() != sha:
            return "ANNULE_REFERENCE_MODIFIEE", f"[serveur] Fichier de reference modifie : {path}"
        index.append({"path": relative, "mode": mode, "sha256": sha, "base": base,
                      "before": before, "size": len(raw)})
        contents.append(raw)
    header = json.dumps(index).encode("utf-8")
    container = b"%012d" % len(header) + header + b"".join(contents)
    timeout = LIB_FIX_TIMEOUT or 1800
    result, error = run_job(row["ip"], "py", LIB_FIX_PY, timeout, prelude=LIB_PRELUDE,
                            data=container)
    if result is None:
        if error.startswith("lancement"):
            return "ECHEC_LANCEMENT", (f"[poste] Tache planifiee non lancee : {error.split(' : ', 1)[-1]}"
                                       " (rien n'a ete modifie sur le poste)")
        if error.startswith(("envoi", "fichier incomplet", "depot", "runner")):
            return "ECHEC_TRANSFERT", f"[serveur] {error} : rien n'a ete modifie sur le poste."
        return "TIMEOUT_A_VERIFIER", f"[serveur] {error} : etat du poste a verifier."
    output = result.get("stdout") or ""
    if result.get("timeout"):
        steps = [line[7:].strip() for line in output.splitlines() if line.startswith("#ETAPE ")]
        return ("TIMEOUT_A_VERIFIER", "\n".join(steps) + f"\n[poste] Delai de {timeout} s depasse"
                f" : copie arretee. GARDE_SERVICE={result.get('garde')}")
    for line in reversed(output.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                answer = json.loads(line)
            except ValueError:
                break
            return answer.get("status", "ECHEC"), "\n".join(answer.get("log", []))
    return "ECHEC", job_log(result) or f"code {result.get('rc')}"


def x_run_logdir_fix(row):
    """Cree le dossier Medulla\\var\\log sur le poste s'il manque (service arrete pendant
    la creation : action lancee par le Planificateur de taches)."""
    result, error = run_job(row["ip"], "py", LOGDIR_FIX_PY, LOGDIR_TIMEOUT)
    if result is None:
        return "TIMEOUT_A_VERIFIER", f"[serveur] {error} : etat du poste a verifier."
    output = result.get("stdout") or ""
    for line in reversed(output.splitlines()):
        line = line.strip()
        if line.startswith("{"):
            try:
                answer = json.loads(line)
            except ValueError:
                break
            return answer.get("status", "ECHEC"), "\n".join(answer.get("log", []))
    if result.get("timeout"):
        return "TIMEOUT_A_VERIFIER", job_log(result)
    return "ECHEC", job_log(result) or f"code {result.get('rc')}"


def x_run_service_check(ip):
    """Apres un delai depasse : verifie le service et le redemarre s'il est reste arrete."""
    answer, error = run_remote(ip, "ps1", SERVICE_CHECK_PS.strip(), 120)
    if answer is None:
        return "SANS_REPONSE", f"[serveur] Controle du service impossible : {error}"
    output = answer.get("stdout") or ""
    match = re.search(r"RESULT=(\w+)", output)
    return (match.group(1) if match else "ILLISIBLE"), output.strip()


def x_run_cleanup(ip):
    """Nettoyage du dossier de travail du poste (fichiers de plus d'un jour). Les actions
    en cours sur le poste s'arretent seules a leur delai (le runner les surveille)."""
    answer, error = runner(ip, "clean", 60)
    if answer is None:
        return "SANS_REPONSE", error
    return str(answer.get("supprimes", 0)), ""


def x_with_service_check(ip, status, logs):
    """Si une action a depasse son delai : etat du service d'apres la garde du poste
    (controle et redemarrage faits sur place), sinon controle par XMPP."""
    if not status.startswith("TIMEOUT"):
        return status, "\n\n".join(logs)
    found = re.findall(r"GARDE_SERVICE=(\w+)", "\n".join(logs))
    check = found[-1] if found else ""
    if not check:
        check, log = run_service_check(ip)
        logs.append("===== CONTROLE DU SERVICE APRES DELAI DEPASSE =====\n" + log
                    + f"\nResultat du controle : {check}")
    if check in ("REDEMARRE",):
        status = "TIMEOUT_SERVICE_REDEMARRE"
    elif check in ("DEJA_DEMARRE",):
        status = "TIMEOUT_SERVICE_OK"
    return status, "\n\n".join(logs)


def x_guarded_repair(row):
    """Correction d'un poste en UNE action sur le poste : creation de Medulla\\var\\log,
    mise a niveau par delta, reparer-agent-medulla.ps1, puis audit de verification,
    a la suite, sans aller-retour avec le serveur entre les etapes. Memes regles que V13 :
    une etape en echec arrete la suite ; le nom du poste est verifie sur place."""
    ip = row["ip"]
    fixes = set(filter(None, (row.get("_fixes") or "").split(",")))
    lib_parts = fixes & {"LIB", "RESCUE_LIB"}
    steps, data = [], None
    if "LOG" in fixes:
        steps.append(job_step("logdir", "py", LOGDIR_FIX_PY, LOGDIR_TIMEOUT,
                          ok=["DOSSIER_CREE", "DEJA_PRESENT"]))
    if lib_parts:
        data, error = lib_container(row, lib_parts)
        if data is None:
            return "ANNULE_REFERENCE_MODIFIEE", error
        steps.append(job_step("copie", "py", LIB_FIX_PY, LIB_FIX_TIMEOUT or 1800, LIB_PRELUDE,
                          ok=["LIB_COPIES"]))
    # reparer-agent-medulla.ps1 : prevue par l'audit, ou agentxmpp.py remplace par la copie
    # (sa collecte AD n'a pas encore ete controlee). Sans rien a corriger, elle ne touche
    # pas au service.
    replaced = bool(row.get("_root_plan")) and "LIB" in lib_parts
    if fixes & PS1_FIXES or replaced:
        steps.append(job_step("reparation", "ps1", REPAIR_PS1.strip(), REPAIR_TIMEOUT))
    steps.append(job_step("verification", "py", DIAG_PY, DIAG_TIMEOUT,
                      f"PULSE_HOST = {pulse_address_for(ip)!r}\n"))
    result, error = run_sequence(ip, steps, expected=row["hostname_ssh"], data=data)
    if result is None:
        if error.startswith("lancement"):
            return "ECHEC_LANCEMENT", (f"[poste] Tache planifiee non lancee : {error.split(' : ', 1)[-1]}"
                                       " (rien n'a ete modifie sur le poste)")
        if error.startswith(("envoi", "fichier incomplet", "depot", "runner")):
            return "ECHEC_TRANSFERT", f"[serveur] {error} : rien n'a ete modifie sur le poste."
        return with_service_check(ip, "TIMEOUT_A_VERIFIER",
                                  [f"[serveur] {error} : etat du poste a verifier."])
    if result.get("arret") == "poste_different":
        return ("ANNULE_POSTE_DIFFERENT", f"[poste] Le poste ne s'appelle plus "
                f"{row['hostname_ssh']} : rien n'a ete modifie.")
    done = {item.get("nom"): item for item in result.get("etapes", [])}
    garde = f"GARDE_SERVICE={result.get('garde', '')}"
    logs, status = [], "RIEN_A_CORRIGER"
    copied = log_created = restarted = False
    if "logdir" in done:
        status, log = step_status(done["logdir"])
        logs.append("===== CREATION DU DOSSIER Medulla\\var\\log =====\n" + log)
        if status not in ("DOSSIER_CREE", "DEJA_PRESENT"):
            return with_service_check(ip, status, logs + [garde])
        log_created = restarted = status == "DOSSIER_CREE"
        status = "CORRIGE" if log_created else "RIEN_A_CORRIGER"
    if "copie" in done:
        names = {"LIB": "agent", "RESCUE_LIB": "agentrescue\\lib"}
        status, log = step_status(done["copie"])
        logs.append("===== MISE A NIVEAU PAR DELTA : "
                    + ", ".join(names[part] for part in sorted(lib_parts)) + " =====\n" + log)
        if status != "LIB_COPIES":
            return with_service_check(ip, status, logs + [garde])
        status, restarted, copied = "CORRIGE", True, True
    if "reparation" in done:
        item = done["reparation"]
        log = job_log(item) + f"\n[poste] code de sortie : {item.get('rc')}"
        logs.append("===== REPARATION (reparer-agent-medulla.ps1) =====\n" + log)
        repair_status = ("TIMEOUT_A_VERIFIER" if item.get("timeout")
                         else classify_repair(item.get("rc"), log))
        if repair_status.startswith("TIMEOUT"):
            return with_service_check(ip, repair_status, logs + [garde])
        if copied and repair_status == "RIEN_A_CORRIGER":
            repair_status = "CORRIGE"
        elif copied and repair_status == "NON_CORRIGEABLE":
            repair_status = "PARTIEL"
        if repair_status not in ("RIEN_A_CORRIGER", "NON_CORRIGEABLE"):
            restarted = True
        if log_created and repair_status in ("RIEN_A_CORRIGER", "NON_CORRIGEABLE"):
            repair_status = "CORRIGE" if repair_status == "RIEN_A_CORRIGER" else "PARTIEL"
        status = repair_status
    if "verification" in done:
        item = done["verification"]
        VERIFIED[ip] = parse_diag(item.get("stdout") or "", item.get("stderr") or "",
                                  item.get("rc"), item.get("timeout"))
    if log_created and not restarted:
        restart_status, log = run_restart(ip)
        logs.append("===== RELANCE DU SERVICE =====\n" + log)
        if restart_status != "RELANCE_OK":
            status = restart_status
    if status == "CORRIGE" and row.get("reparable_script") == "PARTIEL":
        status = "PARTIEL"
    return with_service_check(ip, status, logs + [garde])


def x_run_restart(ip):
    """sc stop / sc start, lance par le Planificateur de taches : l'agent qui recoit
    l'ordre est lui-meme arrete pendant la relance."""
    result, error = run_job(ip, "ps1", RESTART_PS.strip(), RESTART_TIMEOUT)
    if result is None:
        return "TIMEOUT_RELANCE_A_VERIFIER", f"[serveur] {error} : etat du service a verifier."
    log = job_log(result)
    match = re.search(r"RESULT=(\w+)", result.get("stdout") or "")
    if match:
        return match.group(1), log
    return "ECHEC", log + f"\n[serveur] code de sortie : {result.get('rc')}"


def x_guarded_restart(row):
    """Relance : le nom du poste est verifie sur place, dans la meme action."""
    result, error = run_sequence(row["ip"], [job_step("relance", "ps1", RESTART_PS.strip(),
                                                  RESTART_TIMEOUT)],
                                 expected=row["hostname_ssh"])
    if result is None:
        if error.startswith("lancement"):
            return ("ECHEC_LANCEMENT", f"[poste] Tache planifiee non lancee : "
                    f"{error.split(' : ', 1)[-1]} (rien n'a ete modifie sur le poste)")
        return with_service_check(row["ip"], "TIMEOUT_RELANCE_A_VERIFIER",
                                  [f"[serveur] {error} : etat du service a verifier."])
    if result.get("arret") == "poste_different":
        return ("ANNULE_POSTE_DIFFERENT", f"[poste] Le poste ne s'appelle plus "
                f"{row['hostname_ssh']} : rien n'a ete modifie.")
    done = (result.get("etapes") or [{}])[0]
    log = job_log(done) + f"\nGARDE_SERVICE={result.get('garde', '')}"
    match = re.search(r"RESULT=(\w+)", done.get("stdout") or "")
    status = match.group(1) if match else "ECHEC"
    return with_service_check(row["ip"], status, [log])


def x_pre_action_check(row, action):
    """Derniers controles, poste par poste, juste avant toute action.
    Renvoie (statut, journal) si l'action doit etre annulee, sinon None."""
    ip = row["ip"]
    hostname, error = test_ssh(ip)
    if not hostname:
        return ("ANNULE_AGENT_PERDU",
                f"[serveur] Controle avant {action} : agent injoignable par XMPP ({error}).")
    if hostname.casefold() != row["hostname_ssh"].casefold():
        return ("ANNULE_POSTE_DIFFERENT",
                f"[serveur] L'agent {JID_OF.get(ip)} repond maintenant {hostname} au lieu de "
                f"{row['hostname_ssh']} : poste non touche.")
    return None


# ---------------------------------------------------------------- aiguillage SSH / XMPP (V20)
#
# Chaque poste a une cle (son IP, ou l'IP de sa session XMPP) et un transport. Les
# fonctions qui agissent sur un poste existent en deux versions : s_... (SSH) et x_...
# (XMPP) ; les fonctions sans prefixe choisissent selon le transport du poste.

VIA = {}                # cle du poste -> "ssh" ou "xmpp"
FALLBACK = {}           # cle -> raison du repli SSH (agent muet en XMPP)
XMPP_ENABLED = True     # --ssh-seul : False
SSH_ENABLED = True      # --xmpp-seul : False
XMPP_ERROR = ""         # pourquoi XMPP est indisponible (ejabberd, slixmpp...)


def by_xmpp(key):
    return VIA.get(key) == "xmpp"


def transport_label(key):
    if by_xmpp(key):
        return "XMPP"
    return "SSH (repli)" if key in FALLBACK else "SSH"


def test_ping(ip):
    return (x_test_ping if by_xmpp(ip) else s_test_ping)(ip)


def test_ssh(ip):
    return (x_test_ssh if by_xmpp(ip) else s_test_ssh)(ip)


def run_diag(ip):
    return (x_run_diag if by_xmpp(ip) else s_run_diag)(ip)


def run_repair(ip):
    return (x_run_repair if by_xmpp(ip) else s_run_repair)(ip)


def run_service_check(ip):
    return (x_run_service_check if by_xmpp(ip) else s_run_service_check)(ip)


def run_cleanup(ip):
    return (x_run_cleanup if by_xmpp(ip) else s_run_cleanup)(ip)


def run_restart(ip):
    return (x_run_restart if by_xmpp(ip) else s_run_restart)(ip)


def with_service_check(ip, status, logs):
    return (x_with_service_check if by_xmpp(ip) else s_with_service_check)(ip, status, logs)


def run_lib_fix(row, parts):
    return (x_run_lib_fix if by_xmpp(row["ip"]) else s_run_lib_fix)(row, parts)


def run_logdir_fix(row):
    return (x_run_logdir_fix if by_xmpp(row["ip"]) else s_run_logdir_fix)(row)


def guarded_restart(row):
    status, log = (x_guarded_restart if by_xmpp(row["ip"]) else s_guarded_restart)(row)
    if status == "ECHEC_LANCEMENT":
        status, log = ssh_after_refusal(row, status, log, "relance") or (status, log)
    return status, log


def pre_action_check(row, action):
    return (x_pre_action_check if by_xmpp(row["ip"]) else s_pre_action_check)(row, action)


def ensure_link(jid):
    """Ouvre la session XMPP du script (compte temporaire) au premier besoin."""
    global XMPP_ENABLED, XMPP_ERROR
    if LINK.account:
        return True
    if not XMPP_ENABLED:
        return False
    try:
        LINK.start(jid_parts(jid)[1])
    except (OSError, subprocess.SubprocessError) as error:
        XMPP_ENABLED, XMPP_ERROR = False, str(error)
        print(paint(_T("  XMPP indisponible ({}) : repli SSH pour les machines ON.", error), "jaune"))
        return False
    user, domain = LINK.account
    kv(_T("Compte XMPP temporaire"), f"{user}@{domain} (supprime a la fin)")
    return True


def sessions_now():
    """Sessions ejabberd ; liste vide (et XMPP desactive) si ejabberdctl est indisponible."""
    global XMPP_ENABLED, XMPP_ERROR
    if not XMPP_ENABLED:
        return []
    try:
        return connected_sessions()
    except (OSError, subprocess.SubprocessError) as error:
        XMPP_ENABLED, XMPP_ERROR = False, str(error)
        print(paint(_T("  XMPP indisponible ({}) : repli SSH pour les machines ON.", clean(str(error), 120)), "jaune"))
        return []


def medulla_ips(name):
    """IP connues dans Medulla pour ce nom (toutes ses fiches), les plus recentes d'abord."""
    forms = name_forms(name)
    found = []
    for machine in MEDULLA_ROWS:
        if name_forms(machine.get("hostname")) & forms:
            try:
                address = str(ipaddress.ip_address(str(machine.get("ip_xmpp") or "").strip()))
            except ValueError:
                continue
            if address not in found:
                found.append(address)
    return found


def resolve_xmpp(names, hostnames, sources, reuse=()):
    """Agents connectes a ejabberd pour ces noms : enregistre cle, JID et transport.
    Renvoie la liste des cles trouvees et celle des noms sans agent connecte."""
    sessions = sessions_now()
    if not sessions:
        return [], [(name, XMPP_ERROR or "agent non connecte a ejabberd") for name in names]
    wanted = [name_forms(name) for name in names]
    shorts_all = {form.split(".")[0] for forms in wanted for form in forms}
    users_all = {jid_parts(m.get("jid"))[0].casefold() for m in MEDULLA_ROWS
                 if m.get("jid") and any(name_forms(m.get("hostname")) & forms for forms in wanted)}
    candidates = [jid for jid in sessions if jid not in ADDRESSES and (
        jid_parts(jid)[0].casefold() in users_all
        or jid_parts(jid)[0].casefold().split(".")[0] in shorts_all)]
    with ThreadPoolExecutor(max_workers=16) as pool:
        ADDRESSES.update(zip(candidates, pool.map(session_address, candidates)))
    keys, missing = [], []
    for name in names:
        forms = name_forms(name)
        shorts = {form.split(".")[0] for form in forms}
        fiches = [machine for machine in MEDULLA_ROWS
                  if name_forms(machine.get("hostname")) & forms]
        users = {jid_parts(machine.get("jid"))[0].casefold() for machine in fiches
                 if machine.get("jid")} - {""}
        found = [jid for jid in sessions if jid_parts(jid)[0].casefold() in users
                 or jid_parts(jid)[0].casefold().split(".")[0] in shorts]
        labels = {(machine.get("hostname") or "") + (" (ON)" if machine["enabled"] == 1 else "")
                  for machine in fiches}
        if not found:
            last = next((last_activity(machine["jid"]) for machine in fiches
                         if machine.get("jid")), "") if len(names) <= 50 else ""
            missing.append((name, "agent non connecte a ejabberd"
                            + (f" (derniere activite : {last})" if last else "")))
            continue
        for jid in found:
            if any(value == jid for value in JID_OF.values()):
                continue                      # agent deja enregistre (autre nom demande)
            user = jid_parts(jid)[0].casefold()
            key = ADDRESSES.get(jid) or session_address(jid) or next(
                (str(machine.get("ip_xmpp") or "").strip() for machine in fiches
                 if jid_parts(machine.get("jid"))[0].casefold() == user
                 and machine.get("ip_xmpp")), "") or "xmpp:" + user
            if (key in VIA or key in JID_OF) and key not in reuse:
                key = f"{key} ({jid_parts(jid)[2] or user})"
            JID_OF[key] = jid
            VIA[key] = "xmpp"
            TARGETS.setdefault(key, set()).add(name)
            hostnames.setdefault(key, set()).update(labels or {"(session XMPP)"})
            sources[key] = "session XMPP " + jid
            keys.append(key)
    if keys and not ensure_link(JID_OF[keys[0]]):
        for key in keys:
            VIA.pop(key, None)
            JID_OF.pop(key, None)
        return [], [(name, XMPP_ERROR) for name in names]
    return keys, missing


def xmpp_session_for(hostname):
    """JID d'une session ejabberd ouverte pour ce poste, sinon "". En cas de doute
    (ejabberd illisible), renvoie "?" : l'agent n'est pas declare muet."""
    if not hostname:
        return "?"
    try:
        sessions = connected_sessions()
    except (OSError, subprocess.SubprocessError):
        return "?"
    forms = name_forms(hostname)
    users = {jid_parts(m.get("jid"))[0].casefold() for m in MEDULLA_ROWS
             if m.get("jid") and name_forms(m.get("hostname")) & forms}
    shorts = {form.split(".")[0] for form in forms}
    for jid in sessions:
        user = jid_parts(jid)[0].casefold()
        if user in users or user.split(".")[0] in shorts:
            return jid
    return ""


SERVICES_PS = r"""
$ErrorActionPreference = 'Continue'
$result = @()
foreach ($name in @('medullanetnotify', 'sshdaemon')) {
    $service = Get-Service -Name $name -ErrorAction SilentlyContinue
    if (-not $service) { $result += "$name=ABSENT"; continue }
    if ($service.Status -eq 'Running') { $result += "$name=DEJA_DEMARRE"; continue }
    $mode = (Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue).StartMode
    if ($mode -eq 'Disabled') { $result += "$name=DESACTIVE"; continue }
    try {
        Start-Service -Name $name -ErrorAction Stop
        $service.WaitForStatus('Running', '00:00:30')
        "$name demarre"
        $result += "$name=DEMARRE"
    } catch {
        "$name : echec du demarrage : $($_.Exception.Message)"
        $result += "$name=ECHEC"
    }
}
'RESULT=' + ($result -join ',')
"""
REMOTE_SERVICES = (
    "powershell -NoProfile -NonInteractive -EncodedCommand "
    + base64.b64encode(SERVICES_PS.strip().encode("utf-16-le")).decode("ascii")
)


def services_status(output):
    """RESULT=nom=ETAT,... -> statut global."""
    match = re.search(r"RESULT=(\S*)", output or "")
    if not match:
        return "ILLISIBLE"
    states = [item.split("=", 1)[-1] for item in match.group(1).split(",") if item]
    if "ECHEC" in states:
        return "ECHEC_SERVICES"
    if "DEMARRE" in states:
        return "SERVICES_DEMARRES"
    return "RIEN_A_CORRIGER"


def s_run_services(ip):
    try:
        result = subprocess.run(
            [*SSH_BASE, "-n", f"pulseuser@{ip}", REMOTE_SERVICES],
            stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=SERVICES_TIMEOUT, env=ENV, start_new_session=True,
        )
    except subprocess.TimeoutExpired:
        return "TIMEOUT_A_VERIFIER", f"[serveur] Pas de reponse apres {SERVICES_TIMEOUT} s."
    log = result.stdout.strip() + (("\n" + result.stderr.strip()) if result.stderr.strip() else "")
    return services_status(result.stdout), log


def x_run_services(ip):
    answer, error = run_remote(ip, "ps1", SERVICES_PS.strip(), SERVICES_TIMEOUT)
    if answer is None:
        return "SANS_REPONSE", f"[serveur] {error}"
    output = answer.get("stdout") or ""
    return services_status(output), output.strip()


def run_services(ip):
    return (x_run_services if by_xmpp(ip) else s_run_services)(ip)


def guarded_repair(row):
    """Correction d'un poste (SSH ou XMPP), puis demarrage des services annexes arretes
    (medullanetnotify, sshdaemon) : sans arret de l'agent, aucun fichier modifie."""
    fixes = set(filter(None, (row.get("_fixes") or "").split(",")))
    if fixes - {"SERVICES"}:
        status, log = (x_guarded_repair if by_xmpp(row["ip"]) else s_guarded_repair)(row)
        if status == "ECHEC_LANCEMENT":
            status, log = ssh_after_refusal(row, status, log, "correction") or (status, log)
    else:
        blocked = pre_action_check(row, "correction")
        if blocked:
            return blocked
        status, log = "RIEN_A_CORRIGER", ""
    if "SERVICES" not in fixes or status not in (
            "CORRIGE", "PARTIEL", "RIEN_A_CORRIGER", "TIMEOUT_SERVICE_OK",
            "TIMEOUT_SERVICE_REDEMARRE"):
        return status, log
    result, detail = run_services(row["ip"])
    VERIFIED.pop(row["ip"], None)       # la verification refait l'audit (services demarres)
    log = ((log + "\n\n") if log else "") + \
        "===== DEMARRAGE DES SERVICES ANNEXES (medullanetnotify, sshdaemon) =====\n" + detail
    if result == "SERVICES_DEMARRES" and status == "RIEN_A_CORRIGER":
        status = "PARTIEL" if row.get("reparable_script") == "PARTIEL" else "CORRIGE"
    elif result not in ("SERVICES_DEMARRES", "RIEN_A_CORRIGER") and status == "CORRIGE":
        status = "PARTIEL"
    return status, log


# ---------------------------------------------------------------- diagnostic final (V21)
#
# Chaque probleme est rattache a une famille, avec l'action recommandee par la FAQ Medulla.
# (motif, famille, traite par le script, action recommandee)
REMEDIATION = [
    (r"^Service medullaagent absent|^Python de l'agent absent|^agentxmpp\.py principal absent",
     "Agent incomplet ou desinstalle", False,
     "Reinstaller l'agent (Medulla-Agent-windows-FULL-latest.exe /S), puis redemarrer le poste"),
    (r"^Service medullaagent desactive", "Service medullaagent desactive", False,
     "sc config medullaagent start= auto, puis sc start medullaagent"),
    (r"^Service medullaagent|^Etat du service", "Service medullaagent arrete", True,
     "Relance du service par le script (ou sc start medullaagent)"),
    (r"^Collecte AD", "Collecte AD non protegee", True, "Correction par le script"),
    (r"^Code collecte AD non reconnu : agentxmpp", "Code collecte AD non reconnu", True,
     "Alignement de agentxmpp.py sur le serveur par le script"),
    (r"^Code collecte AD", "Code collecte AD non reconnu", False,
     "Verification manuelle de agentxmpp.py"),
    (r"^kiosk\.pid", "kiosk.pid corrompu ou inattendu", True, "Correction par le script"),
    (r"^agentconf\.ini\.local", "agentconf.ini.local invalide", False,
     "Corriger ou supprimer agentconf.ini.local, puis Reconfigure machine agent"),
    (r"^agentconf\.ini", "agentconf.ini absent ou invalide", True,
     "Restauration par le script ; sinon Quick Action Reconfigure machine agent"),
    (r"^Agent a mettre a niveau|^agentrescue\\lib a mettre", "Agent a mettre a niveau", True,
     "Mise a niveau par le script (fichiers du serveur)"),
    (r"^Agent different du serveur|^agentrescue\\lib different", "Agent different du serveur",
     False, "Publier la reference (medulla_publier_reference_agent.py) ou verifier a la main"),
    (r"^Acces refuse", "Acces refuse aux fichiers de l'agent", False,
     "Verifier les droits (SYSTEM, Administrateurs) et l'EDR"),
    (r"^Serveur XMPP injoignable", "Serveur XMPP injoignable depuis le poste", False,
     "Flux 5222 poste vers serveur/relais : DNS, pare-feu (FAQ : Test de flux, Firewall)"),
    (r"^Dossier Medulla\\var\\log absent", "Dossier des journaux absent", True,
     "Creation par le script"),
    (r"^Medulla\\var\\log n'est pas", "Dossier des journaux anormal", False,
     "Verification manuelle (lien ou fichier a la place du dossier)"),
    (r"^Disque C", "Disque C: plein", False, "Liberer de l'espace sur C:"),
    (r"absent du poste.*inventaire possible", "Agent d'inventaire absent", False,
     "Installer GLPI-Agent (downloads/win/downloads/GLPI-Agent-*.msi)"),
    (r"^GLPI-Agent .* trop ancien", "GLPI-Agent sans l'option --full (< 1.8)", False,
     "Mettre GLPI-Agent a jour (>= 1.8) : non fait par le script"),
    (r"^CONNEXION:(TIMEOUT|PAS_DE_ROUTE)", "Poste injoignable (eteint ou filtre)", False,
     "Verifier que le poste est allume ; nmap -Pn -p 22 <IP> ; pare-feu"),
    (r"^CONNEXION:REFUSE", "Port SSH ferme sur le poste", False,
     "Service sshdaemon du poste, pare-feu Windows, port SSH (FAQ : Changer le port SSH)"),
    (r"^CONNEXION:AUTH_REFUSEE", "Cle SSH refusee (pulseuser)", False,
     "Reinstaller l'agent ou redeployer la cle de pulseuser"),
    (r"^CONNEXION:(NON_CONNECTE|ERREUR_AGENT)", "Agent non connecte a ejabberd", False,
     "Verifier medullaagent et le flux 5222 ; machine hors ligne : FAQ point 1 a 4"),
    (r"^CONNEXION:", "Connexion au poste impossible", False,
     "Verifier le poste et les flux (FAQ : Test de flux)"),
]


def remediation_family(problem, row=None):
    known = (row or {}).get("_tests_msgs") or {}
    if problem in known:
        return known[problem]          # probleme d'un test ajoutable : son diagnostic et action
    for pattern, family, by_script, action in REMEDIATION:
        if re.search(pattern, problem):
            return family, by_script, action
    return re.sub(r"\s*\(.*$", "", problem), False, "Analyse manuelle"


def remediation_plan(rows):
    """Lignes du plan : un probleme par poste, avec famille, action et etat de traitement."""
    plan = []
    for row in rows:
        problems = [item for item in (row.get("problemes") or "").split(" | ")
                    if item and item != "Aucun"]
        if not row.get("hostname_ssh") and row.get("ssh") not in ("", "OK", "NON_TESTE"):
            problems.append(f"CONNEXION:{row['ssh']}")
        problems += [item for item in (row.get("constats_serveur") or "").split(" | ") if item]
        after = [item for item in (row.get("problemes_apres") or "").split(" | ") if item]
        verified = bool(row.get("problemes_apres")) and not row["problemes_apres"].startswith(
            "Verification impossible")
        for problem in problems:
            family, by_script, action = remediation_family(problem, row)
            if verified and problem in (row.get("problemes") or "") and \
                    not any(remediation_family(item, row)[0] == family for item in after):
                state = "CORRIGE"
            elif family == "Service medullaagent arrete" and row.get("relance") in (
                    "RELANCE_OK", "REDEMARRE"):
                state = "RELANCE"
            elif by_script and not row.get("correction") and not row.get("relance"):
                state = "A_CORRIGER (script)"
            elif by_script:
                state = (row.get("correction") or row.get("relance") or "").split(" (")[0]
            elif family == "SSH injoignable (agent joignable en XMPP)" and row.get("ssh_demarrage"):
                state = ("CORRIGE" if row["ssh_demarrage"] == "SSH_OK"
                         else row["ssh_demarrage"].split(" ")[0])
            elif family in ("Reconnexions en boucle", "Machine ON sans inventaire GLPI rattache") \
                    and AUTO_RESET_STATUS.get((row.get("hostname_ssh") or "").casefold()):
                status = AUTO_RESET_STATUS[row["hostname_ssh"].casefold()]
                state = "RESET_DEMANDE" if status.startswith(("DEMANDE", "DEJA_DEMANDE")) else status
            else:
                state = "ACTION MANUELLE"
            plan.append(dict(ip=row["ip"], acces=row.get("acces", ""),
                             poste=row.get("hostname_ssh") or row.get("nom_demande")
                             or row.get("hostnames_medulla", ""),
                             famille=family, probleme=problem.replace("CONNEXION:", ""),
                             traitement=state, action=action))
    return plan


def print_final_diagnosis(plan, findings):
    """Bilan final : problemes par famille (postes concernes), traitement, action."""
    banner(_T("DIAGNOSTIC FINAL : PROBLEMES ET ACTIONS RECOMMANDEES"))
    if findings:
        subtitle(_T("Serveur Medulla"))
        for level, text, action in findings:
            color = {"CRITIQUE": "rouge", "ATTENTION": "jaune"}.get(level, "gris")
            print("  " + paint(f"[{tr(level)}]", color) + f" {tr(text)}")
            print(paint(f"      -> {tr(action)}", "gris"))
            print_server_detail(text)
    if not plan:
        print(paint(_T("\n  Aucun probleme sur les postes audites."), "vert"))
        return
    families = {}
    for item in plan:
        entry = families.setdefault(item["famille"], {"postes": set(), "corriges": set(),
                                                      "action": item["action"],
                                                      "raisons": Counter()})
        entry["postes"].add(item["poste"] or item["ip"])
        if item["traitement"] in ("CORRIGE", "RELANCE", "RESET_DEMANDE"):
            entry["corriges"].add(item["poste"] or item["ip"])
        else:
            entry["raisons"][item["traitement"] or "?"] += 1
    subtitle(_T("Postes : problemes par famille"))
    print_table([_T("Probleme"), _T("Postes"), _T("Traites"), _T("Non traites : raison"), _T("Action recommandee")],
                [[short(tr(family), 44), len(entry["postes"]), len(entry["corriges"]),
                  short(", ".join(f"{reason} {count}" for reason, count
                                  in entry["raisons"].most_common(2)), 40),
                  short(tr(entry["action"]), 70)]
                 for family, entry in sorted(families.items(),
                                             key=lambda item: (-len(item[1]["postes"]), item[0]))])
    print(paint(_T("  Detail poste par poste : plan_de_remediation.csv (une ligne par probleme)."),
                "gris"))


# ===========================================================================================
# PARTIE 2 - WORKFLOW
# ===========================================================================================
#
# main() deroule les etapes dans cet ordre :
#   1. options, langue, prerequis, menu (perimetre et mode : audit seul ou audit + actions)
#   2. lecture de Medulla (xmppmaster.machines) et choix des postes et du transport :
#      OFF -> SSH, ON -> XMPP, cibles par nom ou IP
#   3. audit des postes, en plusieurs passes : fiches OFF obsoletes confiees a XMPP, agents
#      muets en XMPP repris en SSH, postes injoignables en SSH cherches dans ejabberd
#   4. tests serveur (TESTS_SERVEUR), bilan et recapitulatif par famille
#   5. plan d'actions puis confirmation (OUI) : corrections, relances du service
#   6. verification des corrections (nouvel audit) et etat dans Medulla apres actions
#   7. reset de l'agent en dernier recours (une seule machine, confirmation separee)
#   8. demande d'inventaire aux postes corriges (XMPP)
#   9. rapports : resultats.csv, statistiques.csv, fiches_off_obsoletes.csv,
#      glpi_agent_sans_full.csv, plan_de_remediation.csv, journaux/ et diagnostic final


def parse_targets(values):
    """Noms et IP melanges (separes par des virgules), sans doublon."""
    targets = []
    for value in values or []:
        for item in value.split(","):
            item = item.strip()
            if not item:
                continue
            try:
                item = str(ipaddress.ip_address(item))
            except ValueError:
                if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,252}", item):
                    raise ValueError(f"nom ou IP invalide : {item}")
            if item.casefold() not in {target.casefold() for target in targets}:
                targets.append(item)
    return targets


def is_ip(value):
    try:
        ipaddress.ip_address(value)
        return True
    except ValueError:
        return False


def parse_args():
    parser = argparse.ArgumentParser(
        description="Audit et correction des agents Medulla, par SSH (machines OFF) et par "
                    "XMPP (machines ON).",
    )
    parser.add_argument("dossier", nargs="?", default="/root/analyse",
                        help="dossier des releves (defaut /root/analyse)")
    parser.add_argument("parallelisme", nargs="?", type=int, default=50,
                        help="tests simultanes, de 1 a 256 (defaut 50)")
    parser.add_argument("--dry-run", "--diagnostic", dest="dry_run", action="store_true",
                        help="audit seul, aucune action, pas de menu")
    parser.add_argument("--cible", "--ip", "--nom", dest="cible", action="append",
                        metavar="NOM|IP[,...]",
                        help="machines a traiter, par nom ou par IP (option repetable)")
    parser.add_argument("--perimetre", choices=("off", "on", "tout"), default=None,
                        help="sans --cible : off = machines OFF par SSH (et machines ON liees "
                             "par XMPP), on = machines ON par XMPP, tout = les deux "
                             "(defaut : off)")
    parser.add_argument("--ssh-seul", action="store_true",
                        help="n'utilise jamais XMPP (comportement de la V16)")
    parser.add_argument("--xmpp-seul", action="store_true",
                        help="n'utilise jamais SSH ni ping (comportement de XMPPV6)")
    parser.add_argument("--reconfigurer", action="store_true",
                        help="propose la reconfiguration de l'agent (need_reconf=1) meme sans "
                             "motif ; une seule machine ON (--cible), confirmation separee")
    parser.add_argument("--full", "--corriger-on", dest="full", action="store_true",
                        help="by-pass : traite aussi les machines qui fonctionnent (ON dans "
                             "Medulla, agent joignable en XMPP) mais qui ont un defaut")
    parser.add_argument("--relancer-tout", action="store_true",
                        help="XMPP : relance aussi le service quand il est deja demarre")
    parser.add_argument("--sans-inventaire", action="store_true",
                        help="machines ON sans UUID d'inventaire : audit, puis demande "
                             "d'inventaire (avec --oui)")
    parser.add_argument("--attente-inventaire", type=int, default=INVENTORY_WAIT, metavar="S",
                        help=f"attente maximale du rattachement apres une demande d'inventaire "
                             f"(defaut {INVENTORY_WAIT} s, de 60 a 3600)")
    parser.add_argument("--oui", action="store_true",
                        help="choisit audit + actions et confirme sans question")
    parser.add_argument("--limite", type=int,
                        help="corrige au plus N postes et relance au plus N services")
    parser.add_argument("--serveur-pulse", default="", metavar="ADRESSE",
                        help="adresse du serveur Medulla a tester depuis les postes (defaut : "
                             "adresse que ce serveur utilise pour joindre chaque poste)")
    parser.add_argument("--timeout-copie", type=int, default=0, metavar="S",
                        help="duree maximale de la copie des fichiers, en secondes "
                             "(defaut 0 = pas de limite)")
    parser.add_argument("--inactivite-copie", type=int, default=LIB_FIX_IDLE, metavar="S",
                        help=f"abandon de la copie si le poste ne repond plus pendant S secondes "
                             f"(defaut {LIB_FIX_IDLE}, de 30 a 3600)")
    parser.add_argument("--timeout-ssh", type=int, default=DEFAULT_SSH_CONNECT, metavar="S",
                        help=f"delai de connexion SSH (et de reponse XMPP : S + 10), de 1 a 300 "
                             f"(defaut {DEFAULT_SSH_CONNECT})")
    parser.add_argument("--timeout-audit", type=int, default=DEFAULT_DIAG_TIMEOUT, metavar="S",
                        help=f"duree maximale de l'audit d'un poste, de 10 a 600 s "
                             f"(defaut {DEFAULT_DIAG_TIMEOUT})")
    parser.add_argument("--essais", type=int, default=RETRIES, metavar="N",
                        help=f"nouveaux essais de la connexion et de l'audit apres un timeout, "
                             f"de 0 a 3 (defaut {RETRIES}) ; les corrections ne sont jamais "
                             f"rejouees")
    parser.add_argument("--attente", type=int, default=DEFAULT_WAIT,
                        help=f"secondes d'attente avant de relire Medulla apres les actions "
                             f"(defaut {DEFAULT_WAIT}, 0 = pas d'attente)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="affiche le detail de chaque operation (aussi dans trace.log)")
    parser.add_argument("-l", "--lang", choices=sorted(LANGUAGES),
                        help="langue de l'affichage : fr, en, es, de (defaut : menu si "
                             "interactif, sinon langue du systeme)")
    parser.add_argument("--installer", action="store_true",
                        help="installe sans question les paquets manquants")
    args = parser.parse_args()
    if not 1 <= args.parallelisme <= 256:
        parser.error("le parallelisme doit etre compris entre 1 et 256")
    if args.limite is not None and args.limite < 1:
        parser.error("--limite doit etre superieur ou egal a 1")
    if args.timeout_copie < 0:
        parser.error("--timeout-copie doit etre positif ou nul (0 = pas de limite)")
    if not 1 <= args.timeout_ssh <= 300:
        parser.error("--timeout-ssh doit etre compris entre 1 et 300")
    if args.attente < 0:
        parser.error("--attente doit etre positif ou nul")
    if not 30 <= args.inactivite_copie <= 3600:
        parser.error("--inactivite-copie doit etre compris entre 30 et 3600")
    if not 10 <= args.timeout_audit <= 600:
        parser.error("--timeout-audit doit etre compris entre 10 et 600")
    if not 0 <= args.essais <= 3:
        parser.error("--essais doit etre compris entre 0 et 3")
    if not 60 <= args.attente_inventaire <= 3600:
        parser.error("--attente-inventaire doit etre compris entre 60 et 3600")
    if args.dry_run and args.oui:
        parser.error("--dry-run et --oui sont incompatibles")
    if args.ssh_seul and args.xmpp_seul:
        parser.error("--ssh-seul et --xmpp-seul sont incompatibles")
    try:
        args.cible = parse_targets(args.cible)
    except ValueError as error:
        parser.error(f"--cible : {error}")
    if args.sans_inventaire and (args.cible or args.perimetre or args.ssh_seul):
        parser.error("--sans-inventaire est incompatible avec --cible, --perimetre et --ssh-seul")
    if args.cible and args.perimetre:
        parser.error("--cible et --perimetre sont incompatibles")
    if args.xmpp_seul and args.perimetre in ("off", "tout"):
        parser.error("--xmpp-seul ne traite que les machines ON (--perimetre on)")
    if args.ssh_seul and args.perimetre == "on":
        parser.error("--ssh-seul ne traite pas les machines ON par XMPP (--perimetre on)")
    return args


def ask_targets():
    """Demande un ou plusieurs noms ou IP ; Entree vide revient au menu."""
    while True:
        try:
            answer = input(_T("\n  Machines (noms ou IP, plusieurs : separer par des virgules, Entree = retour au menu) : "))
        except EOFError:
            return []
        if not answer.strip():
            return []
        try:
            targets = parse_targets([answer])
        except ValueError as error:
            print(paint(_T("  {}. Exemple : PC1068, 10.0.0.10 ou PC1068,10.0.0.11", error), "rouge"))
            continue
        if targets:
            return targets


def choose_mode(args):
    """Securite n.1 : choix explicite du perimetre et du mode (audit ou actions).
    Renvoie (mode, raison) ; peut renseigner args.cible ou args.perimetre."""
    prefix = "inventaire_" if args.sans_inventaire else ""
    if args.dry_run:
        return prefix + "audit", "--dry-run"
    if args.oui:
        return prefix + "correction", "--oui"
    if not sys.stdin.isatty():
        return prefix + "audit", "execution non interactive sans --oui"

    safe = paint(_T("AUCUNE modification"), "vert")
    risky = paint(_T("corrige, relance le service, reset (une seule machine)"), "jaune")
    while True:
        banner(_T("QUE VOULEZ-VOUS FAIRE ?"))
        if args.cible:
            print(_T("  Machines : {}\n", ', '.join(args.cible)))
            choices = {"1": ("audit", None), "2": ("correction", None)}
            print(_T("  {}) Audit seul           : {}", paint('1', 'gras'), safe))
            print(_T("  {}) Audit + actions      : {}", paint('2', 'gras'), risky))
        elif args.sans_inventaire:
            choices = {"1": ("inventaire_audit", None), "2": ("inventaire_correction", None)}
            print("  " + paint(_T("Machines ON sans UUID d'inventaire"), "gras") + " :")
            print(_T("  {}) Audit seul           : {}", paint('1', 'gras'), safe))
            print(_T("  {}) Audit + actions      : ", paint('2', 'gras'))
                  + paint(_T("corrige l'agent, puis demande d'inventaire"), "jaune"))
        else:
            choices = {"1": ("audit", "off"), "2": ("audit", "on"), "3": ("audit", "tout"),
                       "4": ("audit", "cible"),
                       "5": ("correction", "off"), "6": ("correction", "on"),
                       "7": ("correction", "tout"), "8": ("correction", "cible"),
                       "9": ("inventaire_audit", None), "10": ("inventaire_correction", None)}
            for title, first, note in (("Audit seul", 1, safe), ("Audit + actions", 5, risky)):
                print("  " + paint(tr(title), "gras") + f" ({note}) :")
                print(_T("    {}) machines OFF dans Medulla, par SSH (et machines ON liees, par XMPP)", paint(str(first), 'gras')))
                print(_T("    {}) machines ON dans Medulla, par XMPP", paint(str(first + 1), 'gras')))
                print(_T("    {}) toutes les machines (OFF par SSH, ON par XMPP)", paint(str(first + 2), 'gras')))
                print(_T("    {}) une ou plusieurs machines (nom ou IP)\n", paint(str(first + 3), 'gras')))
            print("  " + paint(_T("Machines ON sans UUID d'inventaire"), "gras") + " :")
            print(_T("    {}) audit seul ; {}) audit + correction, puis demande d'inventaire", paint('9', 'gras'), paint('10', 'gras')))
        print("\n  " + paint("q", "gras") + _T(") Quitter sans rien faire"))
        print(paint(_T("  Les actions demandent toujours une confirmation (OUI) apres l'audit."), "gris"))
        while True:
            try:
                answer = input(_T("\n  Votre choix [1] : ")).strip().lower()
            except EOFError:
                return "audit", "pas de reponse"
            if answer == "":
                answer = "1"
            if answer in ("q", "quitter"):
                print(_T("\n  Aucune action effectuee."))
                sys.exit(0)
            if answer in choices:
                break
            print(paint(_T("  Choix invalide : taper {} ou q.", ', '.join(choices)), "rouge"))
        mode, scope = choices[answer]
        if mode.startswith("inventaire"):
            args.sans_inventaire = True
        elif scope == "cible":
            targets = ask_targets()
            if not targets:
                continue          # retour au menu
            args.cible = targets
        elif scope:
            args.perimetre = scope
        return mode, "menu"


def main():
    """Workflow du releve : voir le plan en tete de la PARTIE 2."""
    # 1. Options, langue, prerequis, menu.
    args = parse_args()
    check_tests()
    if args.lang:
        set_language(args.lang)
    elif sys.stdin.isatty() and not args.oui:
        set_language(language_menu(detect_language()))
    else:
        set_language(detect_language())
    global XMPP_ENABLED, SSH_ENABLED
    XMPP_ENABLED, SSH_ENABLED = not args.ssh_seul, not args.xmpp_seul
    if SSH_ENABLED:
        ensure_requirements(args.installer)
    configure_ssh(args.timeout_ssh)
    global RETRIES, DIAG_TIMEOUT
    RETRIES = args.essais
    DIAG_TIMEOUT = args.timeout_audit
    global LIB_FIX_TIMEOUT, LIB_FIX_IDLE
    LIB_FIX_TIMEOUT = args.timeout_copie or None
    LIB_FIX_IDLE = args.inactivite_copie
    global PULSE_OVERRIDE, PLATFORMS, RELAUNCH_ALL, MODIFIER_MACHINES_FONCTIONNELLES
    PULSE_OVERRIDE = args.serveur_pulse.strip()
    RELAUNCH_ALL = args.relancer_tout
    MODIFIER_MACHINES_FONCTIONNELLES = MODIFIER_MACHINES_FONCTIONNELLES or args.full
    PLATFORMS = read_platforms()
    vlog(_T("plateformes lues dans Medulla : {} entree(s)", len(PLATFORMS))
         + ("" if PLATFORMS else _T(" (colonne platform indisponible : detection sur le poste)")))
    if args.verbose:
        enable_verbose()
    mode, reason = choose_mode(args)
    inventory_after = None
    if mode.startswith("inventaire"):
        # Machines ON sans UUID d'inventaire : audit complet de l'agent (et correction apres
        # OUI) sur toute la liste, puis volet inventaire.
        machines = [json.loads(line) for line in mysql_exec(INVENTORY_SQL).splitlines()
                    if line.strip()]
        names = []
        for machine in machines:
            name = (machine.get("hostname") or "").strip()
            if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,252}", name) and \
                    name.casefold() not in {item.casefold() for item in names}:
                names.append(name)
        if not names:
            banner(_T("MEDULLA - MACHINES ON SANS UUID D'INVENTAIRE"))
            print(paint(_T("\n  Aucune machine concernee."), "vert"))
            return
        inventory_after = (mode == "inventaire_correction", reason)
        args.cible = names
        mode = "correction" if inventory_after[0] else "audit"
    targeted = bool(args.cible)
    perimeter = None if targeted else (args.perimetre or ("on" if args.xmpp_seul else "off"))
    acting = mode == "correction"
    step_number = [0]

    def step(title):
        step_number[0] += 1
        banner(_T("ETAPE {} - {}", step_number[0], title))

    global LIB_REF
    try:
        LIB_REF = load_lib_reference()
    except OSError as error:
        print(paint(_T("Reference de l'agent illisible ({}) : controle des fichiers desactive.", error), "jaune"))
        LIB_REF = None
    server_text = read_server_version()
    server_version = parse_version(server_text)
    reset_available = server_version is not None and server_version >= RESET_MIN_SERVER
    # Reset uniquement quand une seule machine est demandee (jamais en traitement de masse).
    single = targeted and len(args.cible) == 1

    banner(_T("MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V25)"))
    if acting:
        print(_T("  Mode         : ") + paint(_T("AUDIT + ACTIONS (correction, relance, reset)"), "jaune")
              + _T("  (choisi par : {})", reason))
        print(_T("  Reparation   : code de reparer-agent-medulla.ps1 integre (sha256 {}...)", REPAIR_SHA256[:16]))
    else:
        print(_T("  Mode         : ") + paint(_T("AUDIT SEUL, aucune modification"), "vert")
              + f"  ({reason})")
    scope_text = {"off": "machines OFF dans Medulla (SSH), puis machines ON liees (XMPP)",
                  "on": "machines ON dans Medulla (XMPP)",
                  "tout": "toutes les machines (OFF par SSH, ON par XMPP)"}
    print(_T("  Perimetre    : ") + (", ".join(args.cible[:20]) + (" ..." if len(args.cible) > 20 else "")
                                 if targeted else tr(scope_text[perimeter])))
    print(_T("  Transports   : ") + ", ".join(filter(None, (
        tr("SSH (pulseuser, par cle)") if SSH_ENABLED else "",
        tr(f"XMPP (ejabberd {XMPP_SERVER[0]}:{XMPP_SERVER[1]}, plugin shellcommand)")
        if XMPP_ENABLED else ""))))
    print(_T("  Serveur      : Medulla {}", version_text(server_version))
          + (_T(" (lu dans {})", SERVER_VERSION_FILE) if server_version
             else _T(" ({} illisible)", SERVER_VERSION_FILE))
          + ("" if reset_available
             else paint(_T("  -> reset d'agent indisponible (5.6.4 minimum)"), "jaune")))
    if LIB_REF:
        print(_T("  Ref. agent   : {} ({} fichiers, agent {})", AGENT_REF_DIR, len(LIB_REF['files']), LIB_REF['version'] or 'version inconnue'))
    else:
        print(_T("  Ref. agent   : ") + paint(_T("{} absente -> controle des fichiers desactive", AGENT_REF_DIR), "jaune"))
    print(_T("  Dossier      : {}", args.dossier))
    print(_T("  Delais       : SSH {} s pour se connecter, XMPP {} s par commande, audit {} s par poste", SSH_CONNECT, SSH_TIMEOUT, DIAG_TIMEOUT))
    print(_T("  Copie        : ") + (_T("{} s maximum", LIB_FIX_TIMEOUT) if LIB_FIX_TIMEOUT
                                  else _T("sans limite de duree"))
          + _T(", abandon si le poste ne repond plus pendant {} s", LIB_FIX_IDLE))
    print(_T("  Nouvel essai : {} apres un timeout de la connexion ou de l'audit (lecture seule ; les corrections ne sont jamais rejouees)", RETRIES))
    print(_T("  Parallelisme : {} audit(s)", args.parallelisme)
          + (_T(", {} action(s)", min(args.parallelisme, REPAIR_WORKERS_MAX)) if acting else "")
          + _T(" simultane(s)"))
    if args.limite and acting:
        print(_T("  Limite       : {} correction(s) et {} relance(s) au maximum", args.limite, args.limite))
    if os.environ.get("SSH_CONNECTION") and not (os.environ.get("STY") or os.environ.get("TMUX")):
        print(paint(_T("  Conseil      : session SSH hors screen/tmux ; une deconnexion arreterait le script. Relancer dans"), "jaune"))
        print(paint(_T("                 screen -S medulla (detacher : Ctrl+A puis D ; reprendre : screen -r medulla)."), "jaune"))

    # 2. Lecture de Medulla, choix des postes et du transport (OFF -> SSH, ON -> XMPP).
    started = datetime.now().isoformat(timespec="seconds")
    vlog(_T("lecture de Medulla : SELECT hostname, ip_xmpp, enabled, jid, agenttype FROM xmppmaster.machines"))
    query = subprocess.run(
        ["mysql", "--default-character-set=utf8mb4", "--batch", "--raw",
         "--skip-column-names", "xmppmaster", "-e", QUERY],
        capture_output=True, text=True, encoding="utf-8", check=True,
    )
    vlog(_T("   -> {} fiche(s) lue(s) (enabled = 0 ou 1)", len(query.stdout.splitlines())))
    MEDULLA_ROWS.clear()
    on_count = off_count = without_ip = 0
    off_ips, invalid, on_by_ip, on_by_name, on_names_all = {}, {}, {}, {}, []
    for line in query.stdout.splitlines():
        if not line:
            continue
        machine = json.loads(line)
        MEDULLA_ROWS.append(machine)
        name = machine["hostname"] or ""
        value = (machine["ip_xmpp"] or "").strip()
        if machine["enabled"] == 1:
            on_count += 1
            if machine.get("agenttype") in (None, "", "machine") and name:
                on_names_all.append(name)
            for form in name_forms(name):
                on_by_name.setdefault(form, set()).add(f"{name} @ {value or '?'}")
            try:
                on_by_ip.setdefault(str(ipaddress.ip_address(value)), set()).add(name)
            except ValueError:
                pass
            continue
        off_count += 1
        if not value:
            without_ip += 1
            continue
        try:
            off_ips.setdefault(str(ipaddress.ip_address(value)), set()).add(name)
        except ValueError:
            invalid.setdefault(value, set()).add(name)

    subtitle(_T("Base Medulla"))
    kv(_T("Machines ON (lignes)"), on_count)
    kv(_T("Machines OFF (lignes)"), off_count)
    hostnames, sources, rows = {}, {}, []
    xmpp_names, ssh_keys = [], []

    def blank(**fields):
        row = dict.fromkeys(FIELDS, "")
        row.update(fields)
        rows.append(row)
        return row

    def add_ssh(ip, labels, source, name=None):
        if ip in VIA:
            if name:
                TARGETS.setdefault(ip, set()).add(name)
            return
        VIA[ip] = "ssh"
        ssh_keys.append(ip)
        hostnames.setdefault(ip, set()).update(labels or {"(IP inconnue dans Medulla)"})
        sources[ip] = source
        if name:
            TARGETS.setdefault(ip, set()).add(name)

    if targeted:
        for target in args.cible:
            if is_ip(target):
                labels = {name for name in off_ips.get(target, set()) if name}
                labels |= {f"{name} (ON)" for name in on_by_ip.get(target, set()) if name}
                on_here = sorted(filter(None, on_by_ip.get(target, set())))
                if on_here and XMPP_ENABLED:
                    xmpp_names.extend(name for name in on_here if name not in xmpp_names)
                    kv(_T("Cible {}", target), ", ".join(sorted(labels)) + " -> XMPP")
                elif SSH_ENABLED:
                    add_ssh(target, labels, "IP demandee")
                    kv(_T("Cible {}", target), (", ".join(sorted(labels)) or "IP inconnue dans Medulla")
                       + " -> SSH")
                else:
                    blank(ip=target, nom_demande=target, ping="KO", ssh="NON_TESTE",
                          diagnostic="NON_REALISE", detail_diagnostic="aucune machine ON a cette "
                          "IP (--xmpp-seul : SSH non utilise)")
                continue
            fiches = [m for m in MEDULLA_ROWS if name_forms(m.get("hostname")) & name_forms(target)]
            state = ("absente de Medulla" if not fiches else
                     "ON" if any(m["enabled"] == 1 for m in fiches) else "OFF")
            if XMPP_ENABLED:
                # XMPP si l'agent a une session ejabberd (ON ou OFF dans Medulla), sinon SSH.
                xmpp_names.append(target)
                kv(_T("Cible {}", target), f"{len(fiches)} fiche(s) Medulla ({state}) -> XMPP si "
                   "l'agent est connecte, sinon SSH")
                continue
            ips = medulla_ips(target) or resolve_name(target)
            if ips and SSH_ENABLED:
                labels = {(m.get("hostname") or "") + (" (ON)" if m["enabled"] == 1 else "")
                          for m in fiches}
                origin = "fiche Medulla" if medulla_ips(target) else "DNS"
                add_ssh(ips[0], labels, f"{origin} ({target})", target)
                kv(_T("Cible {}", target), f"{len(fiches)} fiche(s) Medulla ({state}) -> SSH {ips[0]} "
                   f"({origin})")
            else:
                detail = ("SSH non utilise (--xmpp-seul)" if ips else
                          "aucune IP (ni fiche Medulla avec IP, ni DNS)")
                kv(_T("Cible {}", target), f"{len(fiches)} fiche(s) Medulla ({state}) ; "
                   + paint(detail, "rouge"))
                blank(nom_demande=target, ping="KO", ssh="NON_TESTE", diagnostic="NON_REALISE",
                      hostnames_medulla=", ".join(sorted(m.get("hostname") or "" for m in fiches)),
                      detail_diagnostic=detail)
    else:
        if perimeter in ("off", "tout") and SSH_ENABLED:
            kv(_T("OFF sans IP (non testees)"), without_ip)
            kv(_T("OFF avec IP invalide (non testees)"), len(invalid))
            for value, names in invalid.items():
                blank(ip=value, hostnames_medulla=", ".join(sorted(filter(None, names))),
                      diagnostic="NON_REALISE", detail_diagnostic="IP invalide ; non testee")
            stale_ip = 0
            for ip in sorted(off_ips, key=ip_sort_key):
                if ip in on_by_ip:
                    # IP deja celle d'une machine ON : c'est ce poste ON qui repondrait. Pas de
                    # SSH : la fiche OFF est a nettoyer, la machine ON est auditee par XMPP.
                    off_names = sorted(filter(None, off_ips[ip]))
                    on_here = sorted(filter(None, on_by_ip[ip]))
                    blank(ip=ip, hostnames_medulla=", ".join(off_names), ssh="NON_TESTE",
                          deja_on="OUI", diagnostic="NON_REALISE (IP deja ON)",
                          fiche_on=", ".join(f"{name} @ {ip}" for name in on_here),
                          cause_fiche_off=stale_cause(off_names, on_here))
                    if perimeter == "off" and XMPP_ENABLED:
                        xmpp_names.extend(name for name in on_here if name not in xmpp_names)
                    stale_ip += 1
                    continue
                add_ssh(ip, off_ips[ip], "fiche OFF")
            kv(_T("IP deja utilisees par une machine ON (non testees en SSH)"), stale_ip)
        if perimeter in ("on", "tout") and XMPP_ENABLED:
            xmpp_names.extend(name for name in on_names_all if name not in xmpp_names)
    kv(_T("Postes a joindre en SSH"), paint(str(len(ssh_keys)), "gras"))

    # 3. Audit des postes (lecture seule), en plusieurs passes.
    state = on_state(query.stdout)
    width_ip = 15
    counter = [0]
    diag_duration = 0.0
    handed = set()          # noms deja cherches dans ejabberd
    audited = set()
    ssh_failed = {}         # nom -> IP : SSH impossible, agent cherche dans ejabberd

    def on_diag(ip, row):
        row["hostnames_medulla"] = ", ".join(sorted(filter(None, hostnames.get(ip, set()))))
        row["nom_demande"] = ", ".join(sorted(TARGETS.get(ip, set())))
        row["source_ip"] = sources.get(ip, "")
        row["agent_a_jour"] = (agent_up_to_date(row, server_version)
                               if row["diagnostic"] == "OK" else "")
        if row["deja_on"] == "OUI":
            matches = set(on_by_ip.get(ip, set()) and
                          {f"{name} @ {ip}" for name in on_by_ip[ip] if name})
            for form in name_forms(row["hostname_ssh"]):
                matches |= on_by_name.get(form, set())
            row["fiche_on"] = ", ".join(sorted(matches))
            row["cause_fiche_off"] = stale_cause(
                [name for name in hostnames.get(ip, ()) if name and not name.endswith(" (ON)")],
                [row["hostname_ssh"]])
        rows[:] = [item for item in rows if item.get("ip") != ip or item is row]
        rows.append(row)
        counter[0] += 1
        line = (f"  [{counter[0]:>4}] {ip:<{width_ip}}  {row['acces']:<11} "
                f"{'ping ' + tag(row['ping'], 2) if not by_xmpp(ip) else 'agent':<7}  "
                f"{tag(row['ssh'], 16)}")
        if row["ssh"] == "OK" and row["diagnostic"] == "NON_REALISE (deja ON)":
            line += (f"  {'':<13}  {short(row['hostname_ssh'], 20):<20}  "
                     + paint(_T("ON dans Medulla : fiche OFF obsolete, audit par XMPP"), "gris"))
        elif row["ssh"] == "OK":
            line += _T("  audit {}  {:<20}", tag(row['diagnostic'], 7), short(row['hostname_ssh'], 20))
            if row["version_agent"]:
                version = f"v{short(row['version_agent'], 10)}"
                line += "  " + paint(f"{version:<11}",
                                     "jaune" if row["agent_a_jour"] == "NON" else "gris")
            if row["version_inventaire"] or row["programme_inventaire"] == "ABSENT":
                glpi = f"GLPI {short(row['version_inventaire'] or 'absent', 8)}"
                line += "  " + paint(f"{glpi:<13}", "jaune" if row["inventaire_a_jour"] == "NON"
                                     or row["programme_inventaire"] == "ABSENT" else "gris")
            if row["problemes"] == "Aucun":
                line += "  " + paint(_T("aucun probleme"), "vert")
            elif row["problemes"]:
                line += "  " + paint(_T("{} pb : {}", row["nb_problemes"], tr_problems(row["problemes"])),
                                     "jaune")
            elif row["detail_diagnostic"]:
                line += "  " + paint(short(tr(row["detail_diagnostic"]), 80), "rouge")
        elif row["ssh_erreur"]:
            line += "  " + paint(short(row["ssh_erreur"], 80), "gris")
        print(line, flush=True)

    def fall_back(name, reason_text, prefer=()):
        """Agent sans session XMPP (ou muet) : SSH sur une IP connue du poste. IP essayees :
        celle de la session, celle qui a deja repondu ce nom en SSH, celles des fiches
        Medulla, puis le DNS. Machine ON dans Medulla : repli (actions soumises a l'absence
        de session XMPP au moment d'agir)."""
        if not SSH_ENABLED:
            return None
        forms = name_forms(name)
        proven = [row["ip"] for row in rows if row.get("hostname_ssh") and not by_xmpp(row["ip"])
                  and name_forms(row["hostname_ssh"]) & forms]
        known = medulla_ips(name)
        candidates = [ip for ip in prefer if is_ip(ip)] + proven + known
        if not candidates:
            candidates = resolve_name(name)
        for ip in dict.fromkeys(candidates):
            existing = next((row for row in rows if row.get("ip") == ip), None)
            if existing is not None and existing["diagnostic"] not in (
                    "NON_REALISE (deja ON)", "NON_REALISE (IP deja ON)"):
                continue                     # IP deja auditee completement
            if by_xmpp(ip) and ip not in audited:
                continue                     # agent XMPP pas encore interroge
            VIA[ip] = "ssh"
            if medulla_state(name) == "ON":
                FALLBACK[ip] = reason_text
            rows[:] = [row for row in rows if row.get("ip") != ip]
            audited.discard(ip)
            TARGETS.setdefault(ip, set()).add(name)
            hostnames.setdefault(ip, set()).update(
                {(m.get("hostname") or "") + (" (ON)" if m["enabled"] == 1 else "")
                 for m in MEDULLA_ROWS if name_forms(m.get("hostname")) & forms} or {name})
            sources[ip] = ("session XMPP" if ip in prefer else "SSH" if ip in proven
                           else "fiche Medulla" if ip in known else "DNS") + f" ({name})"
            return ip
        return None

    pending = list(ssh_keys)
    pass_number = 0
    while True:
        new_names = [name for name in xmpp_names if name.casefold() not in handed]
        handed |= {name.casefold() for name in new_names}
        if new_names:
            found, missing = resolve_xmpp(new_names, hostnames, sources,
                                          reuse=set(ssh_failed.values()))
            for key in found:
                audited.discard(key)         # IP reprise par XMPP (SSH en echec)
            if found:
                kv(_T("Agents connectes a ejabberd (XMPP)"), paint(str(len(found)), "gras"))
            pending += found
            for name, detail in missing:
                if name.casefold() in ssh_failed:
                    continue                 # SSH deja en echec, pas d'agent connecte
                ip = fall_back(name, detail)
                if ip:
                    pending.append(ip)
                    kv(f"{name}", f"{short(detail, 60)} -> SSH {ip}")
                else:
                    blank(nom_demande=name, ping="KO", ssh="NON_CONNECTE", ssh_erreur=detail,
                          acces="XMPP", diagnostic="NON_REALISE", detail_diagnostic=detail,
                          hostnames_medulla=name)
        pending = [key for key in dict.fromkeys(pending) if key not in audited]
        if not pending:
            break
        pass_number += 1
        width_ip = max([width_ip] + [len(key) for key in pending])
        step((_T("AUDIT (lecture seule) : ") if pass_number == 1 else
              _T("AUDIT, SUITE (lecture seule) : "))
             + _T("{} poste(s) par SSH, {} par XMPP", sum(not by_xmpp(key) for key in pending), sum(by_xmpp(key) for key in pending)))
        t0 = time.monotonic()
        stop_watch = threading.Event()
        threading.Thread(target=watch_progress, args=(stop_watch,), daemon=True).start()
        try:
            run_pool(pending, lambda ip: probe(ip, state), args.parallelisme, on_diag,
                     graceful=False)
        finally:
            stop_watch.set()
        diag_duration += time.monotonic() - t0
        audited |= set(pending)
        pending = []
        # Agent joint par XMPP pour une machine injoignable en SSH : une seule ligne.
        for row in list(rows):
            key = row.get("ip") or ""
            if by_xmpp(key) and row["ssh"] == "OK":
                for name in TARGETS.get(key, ()):
                    failed_ip = ssh_failed.pop(name.casefold(), None)
                    if failed_ip:
                        rows[:] = [item for item in rows
                                   if item.get("ip") != failed_ip or item is row]
                        row["source_ip"] = (row["source_ip"] or sources.get(key, "")) + \
                            f" ; SSH impossible sur {failed_ip}"
        # Machines injoignables en SSH : leur agent est peut-etre connecte a ejabberd.
        if XMPP_ENABLED:
            for row in rows:
                ip = row.get("ip") or ""
                if by_xmpp(ip) or ip not in audited or row["ssh"] in ("OK", "NON_TESTE", "") \
                        or row.get("_xmpp_tente"):
                    continue
                row["_xmpp_tente"] = True
                for name in sorted(hostnames.get(ip, ())):
                    if name and not name.endswith(")") and name.casefold() not in handed \
                            and name not in xmpp_names:
                        ssh_failed[name.casefold()] = ip
                        xmpp_names.append(name)
        # Fiches OFF obsoletes : la machine ON qui a repondu est auditee par XMPP.
        for row in rows:
            if row["diagnostic"] == "NON_REALISE (deja ON)" and row["hostname_ssh"] \
                    and XMPP_ENABLED and row["hostname_ssh"] not in xmpp_names:
                xmpp_names.append(row["hostname_ssh"])
        # Agents connectes mais muets en XMPP : repli SSH (IP de la session d'abord).
        for row in list(rows):
            key = row.get("ip") or ""
            if not by_xmpp(key) or key not in audited or row["ssh"] == "OK" \
                    or row.get("_repli_tente"):
                continue
            row["_repli_tente"] = True
            reason_text = f"agent muet en XMPP : {row['ssh']} {row['ssh_erreur']}".strip()
            rows.remove(row)
            for name in sorted(TARGETS.get(key, set())):
                ip = fall_back(name, reason_text, prefer=(key.split(" (")[0],))
                if ip:
                    pending.append(ip)
                    break
            else:
                rows.append(row)
        if not pending and not [name for name in xmpp_names if name.casefold() not in handed]:
            break

    # 4. Tests serveur (TESTS_SERVEUR), bilan, recapitulatif par famille.
    subtitle(_T("Controles cote serveur (lecture seule)"))
    findings = server_checks(rows)
    kv(_T("Constats sur le serveur Medulla"), len(findings))
    kv(_T("Postes avec un constat cote serveur"), sum(bool(row.get("constats_serveur")) for row in rows))
    investigated = [row for row in rows if row.get("enquete_serveur")]
    if investigated:
        subtitle(_T("Enquete cote serveur : {} poste(s) injoignable(s) en SSH et en XMPP", len(investigated)))
        print_table(["IP", _T("Poste"), _T("Derniere presence"), _T("Reseau"), _T("Cause probable")],
                    [[row["ip"] or "-", short(row["hostnames_medulla"] or row["nom_demande"] or "-", 24),
                      tr(row["derniere_presence"] or "-"), short(row.get("reseau") or "-", 34),
                      short(tr(row["enquete_serveur"]), 64)]
                     for row in investigated[:60]])
        if len(investigated) > 60:
            print(_T("  ... et {} autre(s) (voir enquete_postes_injoignables.csv).", len(investigated) - 60))
        print(paint(_T("  Traces (ejabberd, master-*.log, table logs) : enquete_postes_injoignables.csv"),
                    "gris"))

    subtitle(_T("Bilan de l'audit ({:.0f} s)", diag_duration))
    print_synthesis(rows, targeted, server_version)
    subtitle(_T("Recapitulatif par famille de test"))
    print_families(rows)

    # 4 bis. Machines reelles joignables (SSH ou XMPP fonctionnel) et OFF dans Medulla,
    # mesurees avant la reparation (puis apres, etape 7 ter).
    real = real_machines(rows)
    mark_machines(real, medulla_snapshot(MEDULLA_ROWS), "avant")
    subtitle(_T("Machines reelles joignables, etat dans Medulla avant reparation"))
    kv(_T("Machines reelles joignables (SSH ou XMPP fonctionnel)"), len(real))
    kv(_T("  dont SSH fonctionnel"), sum(item["ssh"] for item in real.values()))
    kv(_T("  dont XMPP fonctionnel"), sum(item["xmpp"] for item in real.values()))
    off_before = sum(item["medulla_avant"] == "OFF" for item in real.values())
    kv(_T("OFF dans Medulla malgre SSH ou XMPP fonctionnel"), off_before,
       f"  ({percent(off_before, len(real))} %)" if real else "")

    # 5. Plan d'actions, confirmation, corrections et relances.
    tested = [row for row in rows if row["ping"]]
    repairable = [row for row in tested if row["reparable_script"] in TO_REPAIR]
    restartable = [row for row in tested if row["ssh"] == "OK"
                   and row["reparable_script"] not in TO_REPAIR]
    for row in repairable:
        if row["deja_on"] == "OUI" and row["ip"] not in FALLBACK:
            row["correction"] = "EXCLU_DEJA_ON (traite par XMPP)"
    for row in restartable:
        if row["deja_on"] == "OUI" and row["ip"] not in FALLBACK:
            row["relance"] = "EXCLU_DEJA_ON (traite par XMPP)"
        elif row["diagnostic"] == "NON_REALISE (autre poste)":
            row["relance"] = "NON_TRAITE (le poste repond un autre nom)"
        elif row["diagnostic"] not in ("OK", "PARTIEL"):
            row["relance"] = "NON_POSSIBLE (diagnostic non realise)"
        elif row["service"] == "ABSENT" or row["python"] == "ABSENT":
            row["relance"] = "NON_POSSIBLE (agent incomplet)"
        elif row["windows"] == "NON":
            row["relance"] = "NON_TRAITE (poste non Windows)"
        elif no_admin(row):
            row["relance"] = "NON_POSSIBLE (compte de connexion sans droits administrateur)"
        elif xmpp_unreachable(row) and row["service"] in ("RUNNING", ""):
            row["relance"] = "NON_UTILE (serveur XMPP injoignable depuis le poste)"
        elif row["lib_etat"] in ("A_METTRE_A_NIVEAU", "DOSSIER_ABSENT") and row["service"] in ("RUNNING", ""):
            row["relance"] = "NON_UTILE (agent different du serveur : verification manuelle)"
        elif by_xmpp(row["ip"]) and row["service"] == "RUNNING" and not RELAUNCH_ALL:
            row["relance"] = "NON_UTILE (agent connecte, service demarre ; --relancer-tout pour forcer)"
    # Une machine qui fonctionne (ON dans Medulla, agent joignable) n'est pas modifiee.
    for row in repairable:
        if not row["correction"] and machine_protected(row):
            row["correction"] = PROTECTED
    for row in restartable:
        if not row["relance"] and machine_protected(row):
            row["relance"] = PROTECTED
    defective = list({id(row): row for row in repairable + restartable
                      if row["correction"] == PROTECTED
                      or (row["relance"] == PROTECTED and restart_eligible(row))}.values())
    if defective:
        subtitle(_T("Machines qui fonctionnent mais ont un defaut : {}", len(defective)))
        print_table(["IP", _T("Poste"), _T("Action possible"), _T("Defaut")],
                    [[row["ip"], short(row["hostname_ssh"], 24),
                      tr("correction" if row["correction"] == PROTECTED else "relance"),
                      short(tr_problems(row["problemes"]), 64)] for row in defective[:50]])
        if len(defective) > 50:
            print(_T("  ... et {} autre(s).", len(defective) - 50))
        print(paint(_T("  Ces machines sont ON dans Medulla et leur agent repond : par defaut, elles ne sont pas touchees."), "jaune"))
        include = False
        if not acting:
            print(paint(_T("  Audit seul : relancer avec --full pour les traiter aussi."), "gris"))
        elif args.oui:
            print(paint(_T("  --oui sans --full : elles ne sont pas traitees (ajouter --full pour les inclure)."), "jaune"))
        else:
            include = ask_yes_no(f"Traiter aussi ces {len(defective)} machine(s) qui fonctionnent ?")
            if not include:
                print(paint(_T("  Elles ne seront pas touchees."), "vert"))
        if include:
            MODIFIER_MACHINES_FONCTIONNELLES = True
            for row in defective:
                for field in ("correction", "relance"):
                    if row[field] == PROTECTED:
                        row[field] = ""
            print(paint(_T("  {} machine(s) ajoutee(s) au plan d'actions.", len(defective)), "jaune"))

    repair_logs, restart_logs, reset_logs, reconf_logs = {}, {}, {}, {}
    repair_duration = restart_duration = 0.0
    waited = 0
    interrupted = False

    if not acting:
        for row in repairable:
            if not row["correction"]:
                row["correction"] = "NON_DEMANDEE (audit seul)"
        for row in restartable:
            if restart_eligible(row) and not row["relance"]:
                row["relance"] = "NON_DEMANDEE (audit seul)"
                if reset_eligible(row, server_version):
                    row["reset"] = "NON_DEMANDE (audit seul)"
        for row in rows:
            if reconf_eligible(row, args.reconfigurer):
                row["reconfiguration"] = "NON_DEMANDEE (audit seul)"
    else:
        to_repair = select_unique(repairable, lambda row: not row["correction"],
                                  args.limite, "correction")
        to_restart = select_unique(restartable, lambda row: restart_eligible(row) and not row["relance"],
                                   args.limite, "relance")

        step(_T("PLAN D'ACTIONS : {} correction(s), {} relance(s)", len(to_repair), len(to_restart)))
        excluded = Counter(
            f"{tr(label)} : {row[field].split(' (')[0]}"
            for label, field, subset in (("Correction", "correction", repairable),
                                         ("Relance", "relance", restartable))
            for row in subset if row[field])
        for label, count in sorted(excluded.items()):
            kv(_T("Ecartes - {}", label), count)

        if to_repair:
            subtitle(_T("Corrections prevues : {} poste(s)", len(to_repair)))
            print_table(
                ["IP", _T("Acces"), _T("Poste"), _T("Reparable"), _T("Problemes")],
                [[row["ip"], row["acces"], short(row["hostname_ssh"], 24), row["reparable_script"],
                  short(tr_problems(row["problemes"]), 64)] for row in to_repair[:50]],
                colored=(3,))
            if len(to_repair) > 50:
                print(_T("  ... et {} autre(s).", len(to_repair) - 50))
            print(paint(_T("  Sur chaque poste : arret du service, sauvegarde des fichiers modifies, correction,"), "jaune"))
            print(paint(_T("  puis redemarrage du service. Retour arriere automatique en cas d'echec."),
                        "jaune"))
            if any(by_xmpp(row["ip"]) for row in to_repair):
                print(paint(_T("  Par XMPP : action lancee par le Planificateur de taches (compte SYSTEM), elle va au bout"), "jaune"))
                print(paint(_T("  pendant que l'agent est arrete ; le service est ensuite controle."),
                            "jaune"))
            if any("LIB" in (row.get("_fixes") or "") for row in to_repair):
                print(paint(_T("  Mise a niveau de l'agent : fichiers manquants copies et fichiers differents du serveur"), "jaune"))
                print(paint(_T("  remplaces (chacun sauvegarde avant), empreinte verifiee. Aucun fichier supprime."), "jaune"))
            if any("Collecte AD" in row["problemes"] for row in to_repair):
                print(paint(_T("  Effet de bord : sur les postes ou la collecte AD est corrigee, les groupes AD de"), "jaune"))
                print(paint(_T("  l'utilisateur ne remonteront plus dans Medulla."), "jaune"))
        if to_restart:
            subtitle(_T("Relances du service prevues : {} poste(s)", len(to_restart)))
            print_table(
                ["IP", _T("Acces"), _T("Poste"), _T("Version"), _T("Service"), _T("Constat de l'audit")],
                [[row["ip"], row["acces"], short(row["hostname_ssh"], 24),
                  row["version_agent"] or "?", row["service"] or "?",
                  short(tr_problems(row["problemes"] if row["problemes"] not in ("", "Aucun")
                                    else row["detail_diagnostic"] or "aucun probleme detecte"), 56)]
                 for row in to_restart[:50]],
                colored=(4,))
            if len(to_restart) > 50:
                print(_T("  ... et {} autre(s).", len(to_restart) - 50))
            print(paint(_T("  Sur chaque poste : sc stop medullaagent, attente de l'arret, sc start medullaagent,"), "jaune"))
            print(paint(_T("  attente du demarrage. Aucun fichier n'est modifie."), "jaune"))
        if reset_available and (to_repair or to_restart):
            if single:
                print(paint(_T("  Reset : seulement si, apres les actions SSH, l'agent ne fonctionne toujours pas (OFF et sans"), "jaune"))
                print(paint(_T("  session XMPP) alors que SSH et XMPP etaient joignables (confirmation separee)."), "jaune"))
            else:
                print(paint(_T("  Reset non propose sur plusieurs machines : les postes concernes seront listes,"), "jaune"))
                print(paint(_T("  le reset se fait ensuite machine par machine (--cible)."), "jaune"))
        if not to_repair and not to_restart:
            print(paint(_T("\n  Aucune action a mener."), "vert"))
        else:
            print(paint(_T("\n  Juste avant chaque action, le poste est reverifie : meme nom (SSH ou XMPP) ; en SSH, toujours"), "jaune"))
            print(paint(_T("  OFF dans Medulla (repli : agent toujours sans session XMPP). Sinon, il n'est pas touche."), "jaune"))
            question = _T(" et ").join(part for part in (
                _T("corriger {} poste(s)", len(to_repair)) if to_repair else "",
                _T("relancer le service sur {} poste(s)", len(to_restart)) if to_restart else "",
            ) if part)
            if not confirm(question, args.oui):
                print(paint(_T("  Actions annulees : aucun poste modifie."), "rouge"))
                for row in to_repair:
                    row["correction"] = "NON_CONFIRMEE"
                for row in to_restart:
                    row["relance"] = "NON_CONFIRMEE"
                to_repair, to_restart = [], []

        # -------------------------------------------------------- corrections
        if to_repair:
            step(_T("CORRECTION : {} poste(s)", len(to_repair)))
            by_ip = {row["ip"]: row for row in to_repair}
            done = [0]

            def on_repair(ip, result):
                status, log = result
                row = by_ip[ip]
                row["correction"] = status
                row["detail_correction"] = summarize_log(log)
                repair_logs[ip] = log
                done[0] += 1
                print(f"  [{done[0]:>{len(str(len(by_ip)))}}/{len(by_ip)}] {ip:<{width_ip}}  "
                      f"{row['acces']:<11} {short(row['hostname_ssh'], 20):<20}  {tag(status, 29)}  "
                      + paint(short(row["detail_correction"], 60), "gris"), flush=True)

            t1 = time.monotonic()
            cancelled = run_pool(list(by_ip), lambda ip: guarded_repair(by_ip[ip]),
                                 min(args.parallelisme, REPAIR_WORKERS_MAX), on_repair,
                                 graceful=True)
            repair_duration = time.monotonic() - t1
            for ip in cancelled:
                by_ip[ip]["correction"] = "ANNULEE_INTERRUPTION"
            interrupted = bool(cancelled)

        # -------------------------------------------------------- relances
        if to_restart and not interrupted:
            step(_T("RELANCE DU SERVICE : {} poste(s)", len(to_restart)))
            by_ip = {row["ip"]: row for row in to_restart}
            done = [0]

            def on_restart(ip, result):
                status, log = result
                row = by_ip[ip]
                row["relance"] = status
                row["detail_relance"] = short(" ".join(
                    clean(line, 150) for line in log.splitlines()
                    if line.strip() and not line.startswith("RESULT=")), 400)
                restart_logs[ip] = log
                done[0] += 1
                print(f"  [{done[0]:>{len(str(len(by_ip)))}}/{len(by_ip)}] {ip:<{width_ip}}  "
                      f"{row['acces']:<11} {short(row['hostname_ssh'], 20):<20}  {tag(status, 29)}",
                      flush=True)

            t2 = time.monotonic()
            cancelled = run_pool(list(by_ip), lambda ip: guarded_restart(by_ip[ip]),
                                 min(args.parallelisme, REPAIR_WORKERS_MAX), on_restart,
                                 graceful=True)
            restart_duration = time.monotonic() - t2
            for ip in cancelled:
                by_ip[ip]["relance"] = "ANNULEE_INTERRUPTION"
            interrupted = bool(cancelled)
        elif to_restart:
            for row in to_restart:
                row["relance"] = "ANNULEE_INTERRUPTION"

        # 6. Verification des corrections et etat dans Medulla apres actions.
        repaired = [row for row in to_repair if launched(row["correction"])]
        if repaired and not interrupted:
            step(_T("VERIFICATION DES CORRECTIONS : {} poste(s)", len(repaired)))
            by_ip = {row["ip"]: row for row in repaired}
            checked = [0]

            def on_verify(ip, result):
                row = by_ip[ip]
                after = dict.fromkeys(FIELDS, "")
                fill_diag(after, *result)
                if after["diagnostic"] in ("OK", "PARTIEL"):
                    row["problemes_apres"] = after["problemes"]
                    row["reparable_apres"] = after["reparable_script"]
                    verdict = (paint(_T("plus aucun probleme"), "vert") if after["problemes"] == "Aucun"
                               else paint(_T("reste : ") + tr_problems(after["problemes"]), "jaune"))
                    if row["correction"].startswith("TIMEOUT") and after["problemes"] == row["problemes"]:
                        # Le serveur n'a pas eu de resultat et rien n'a change sur le poste.
                        row["correction"] = "NON_APPLIQUE (delai depasse, aucun changement constate)"
                        verdict = paint(_T("NON_APPLIQUE : rien n'a change sur le poste"), "rouge")
                else:
                    row["problemes_apres"] = "Verification impossible : " + after["detail_diagnostic"]
                    verdict = paint(short(tr(row["problemes_apres"]), 80), "rouge")
                checked[0] += 1
                print(f"  [{checked[0]:>{len(str(len(by_ip)))}}/{len(by_ip)}] {ip:<{width_ip}}  "
                      f"{short(row['hostname_ssh'], 20):<20}  {verdict}", flush=True)

            try:
                run_pool(list(by_ip), run_diag, args.parallelisme, on_verify, graceful=False)
            except KeyboardInterrupt:
                print(paint(_T("\n  Verification interrompue."), "jaune"))
                interrupted = True

        # -------------------------------------------------------- etat Medulla
        acted = [row for row in rows
                 if launched(row["correction"]) or launched(row["relance"])]
        if acted and not interrupted:
            step(_T("RETOUR DANS MEDULLA : {} poste(s) traite(s)", len(acted)))
            try:
                if args.attente:
                    print(_T("  Attente de {} s pour laisser les agents se reconnecter (Ctrl+C pour passer)...", args.attente))
                    end_time = time.monotonic() + args.attente
                    while time.monotonic() < end_time:
                        remaining = int(end_time - time.monotonic())
                        if remaining and remaining % 30 == 0:
                            print(_T("  ... encore {} s", remaining), flush=True)
                        time.sleep(1)
                waited = args.attente
            except KeyboardInterrupt:
                print(paint(_T("\n  Attente interrompue : controle immediat."), "jaune"))
            try:
                state_after = on_state(read_medulla())
            except (subprocess.SubprocessError, OSError, ValueError) as error:
                print(paint(_T("  Lecture de Medulla impossible ({}) : etat non verifie, aucun reset ne sera propose.", error), "rouge"))
                state_after = None
            for row in acted:
                if state_after is None:
                    row["etat_medulla_apres"] = "NON_VERIFIE"
                else:
                    row["etat_medulla_apres"] = ("ON" if is_on(row["hostname_ssh"], row["ip"],
                                                               state_after) else "OFF")
            print_table(
                ["IP", _T("Acces"), _T("Poste"), _T("Action"), _T("Resultat"), _T("Medulla")],
                [[row["ip"], row["acces"], short(row["hostname_ssh"], 24),
                  "correction" if launched(row["correction"]) else "relance",
                  row["correction"] if launched(row["correction"]) else row["relance"],
                  row["etat_medulla_apres"]] for row in acted],
                colored=(4, 5))
            back = sum(row["etat_medulla_apres"] == "ON" for row in acted)
            print(_T("\n  Repasses ON : {} / {}", back, len(acted)))

        # 7. Reset de l'agent en dernier recours.
        # Poste joint en SSH et serveur XMPP joignable depuis le poste (2 protocoles up),
        # action SSH faite, et l'agent ne fonctionne toujours pas : OFF dans Medulla et
        # aucune session XMPP (verifie juste avant le reset).
        reset_candidates = [row for row in acted
                            if not by_xmpp(row["ip"]) and row["etat_medulla_apres"] == "OFF"
                            and row["correction"] not in CRITICAL
                            and row["relance"] not in CRITICAL
                            and reset_eligible(row, server_version)]
        for row in acted:
            if row in reset_candidates or row["etat_medulla_apres"] != "OFF":
                continue
            if by_xmpp(row["ip"]):
                row["reset"] = "NON_UTILE (agent joignable en XMPP)"
            elif not reset_available:
                row["reset"] = "NON_DISPONIBLE (serveur < 5.6.4)"
            elif row.get("xmpp_connexion") != "OK":
                row["reset"] = "NON_ELIGIBLE (serveur XMPP non joignable depuis le poste)"
            elif row["agent_a_jour"] != "OUI":
                row["reset"] = f"NON_ELIGIBLE (agent {row['agent_a_jour'] or 'INCONNU'} a jour)"
        if interrupted:
            reset_candidates = []
        if reset_candidates and not single:
            for row in reset_candidates:
                row["reset"] = "A_FAIRE_MACHINE_PAR_MACHINE (reset possible sur une seule machine)"
            subtitle(_T("Reset de l'agent possible pour {} poste(s)", len(reset_candidates)))
            print(paint(_T("  Le reset n'est propose que pour une seule machine a la fois. Pour chaque poste ci-dessous,"), "jaune"))
            print(paint(_T("  relancer le script sur cette seule machine :"), "jaune"))
            for row in reset_candidates:
                print(_T("    python3 {} --cible {}   # {}", Path(sys.argv[0]).name, row['ip'], row['hostname_ssh']))
            reset_candidates = []
        if reset_candidates:
            step(_T("RESET DE L'AGENT : {} poste(s) toujours OFF", len(reset_candidates)))
            print_table(
                ["IP", _T("Poste"), _T("Version agent"), _T("Correction"), _T("Relance")],
                [[row["ip"], short(row["hostname_ssh"], 24), row["version_agent"],
                  row["correction"] or "-", row["relance"] or "-"]
                 for row in reset_candidates], colored=(3, 4))
            print(paint(_T("\n  SSH et XMPP etaient joignables, les actions SSH n'ont pas suffi : l'agent reste OFF."), "jaune"))
            print(paint(_T("  Une demande de reset est inscrite dans xmppmaster.reset_machine"), "jaune"))
            print(paint(_T("  (JID de sa fiche OFF, raison : ") + RESET_REASON + ").", "jaune"))
            print(paint(_T("  Le serveur Medulla reinitialise ensuite l'agent. Seules ecritures en base : reset et reconfiguration."), "jaune"))
            print(paint(_T("  Juste avant : le poste doit etre encore OFF, sans session XMPP, et identifie par une seule fiche."), "jaune"))
            if confirm(_T("demander le reset de {} agent(s)", len(reset_candidates)), args.oui):
                width_c = len(str(len(reset_candidates)))
                try:
                    for index, row in enumerate(reset_candidates, 1):
                        status, detail = guarded_reset(row)
                        row["reset"], row["detail_reset"] = status, detail
                        reset_logs[row["ip"]] = detail
                        print(f"  [{index:>{width_c}}/{len(reset_candidates)}] "
                              f"{row['ip']:<{width_ip}}  {short(row['hostname_ssh'], 20):<20}  "
                              f"{tag(status, 24)}  " + paint(short(detail, 70), "gris"),
                              flush=True)
                except KeyboardInterrupt:
                    print(paint(_T("\n  Interrompu : les resets restants ne sont pas demandes."),
                                "jaune"))
                for row in reset_candidates:
                    if not row["reset"]:
                        row["reset"] = "ANNULE_INTERRUPTION"
            else:
                print(paint(_T("  Reset annule : aucune demande inscrite."), "rouge"))
                for row in reset_candidates:
                    row["reset"] = "NON_CONFIRME"

        # 7 bis. Reconfiguration de l'agent (need_reconf=1) : seconde ecriture en base.
        # Machine ON auditee par XMPP (le serveur ignore need_reconf sur une machine OFF),
        # motif restant (RECONF_MOTIFS) ou --reconfigurer ; une seule machine, confirmation.
        reconf_candidates = [] if interrupted else [
            row for row in rows if reconf_eligible(row, args.reconfigurer)]
        if reconf_candidates and not single:
            for row in reconf_candidates:
                row["reconfiguration"] = "A_FAIRE_MACHINE_PAR_MACHINE (une seule machine a la fois)"
            subtitle(_T("Reconfiguration de l'agent possible pour {} poste(s)", len(reconf_candidates)))
            print(paint(_T("  La reconfiguration n'est proposee que pour une seule machine a la fois. Pour chaque poste,"), "jaune"))
            print(paint(_T("  relancer le script sur cette seule machine :"), "jaune"))
            for row in reconf_candidates:
                print(_T("    python3 {} --cible {}{}   # {}", Path(sys.argv[0]).name, row['hostname_ssh'] or row['ip'], ' --reconfigurer' if args.reconfigurer else '', ', '.join(tr(reason) for reason in reconf_reasons(row, args.reconfigurer))))
            reconf_candidates = []
        if reconf_candidates:
            step(_T("RECONFIGURATION DE L'AGENT : {} poste(s)", len(reconf_candidates)))
            print_table(
                ["IP", _T("Poste"), _T("Version agent"), _T("Motif")],
                [[row["ip"], short(row["hostname_ssh"], 24), row["version_agent"] or "?",
                  short(", ".join(tr(reason) for reason in reconf_reasons(row, args.reconfigurer)), 60)]
                 for row in reconf_candidates])
            print(paint(_T("\n  need_reconf=1 est ecrit sur la fiche ON du poste (xmppmaster.machines). Le substitut"), "jaune"))
            print(paint(_T("  master_reconf envoie alors force_setup_agent a l'agent, qui redemande sa configuration"), "jaune"))
            print(paint(_T("  et se reenregistre : le poste passe OFF quelques instants puis revient ON."), "jaune"))
            print(paint(_T("  Juste avant : session XMPP active, une seule fiche ON, master_reconf connecte."), "jaune"))
            if confirm(_T("reconfigurer l'agent de {} poste(s)", len(reconf_candidates)), args.oui):
                try:
                    for row in reconf_candidates:
                        status, detail, ident = guarded_reconf(row)
                        print(f"  {row['ip']:<{width_ip}}  {short(row['hostname_ssh'], 20):<20}  {tag(status, 24)}  " + paint(short(detail, 70), "gris"), flush=True)
                        lines = [f"{status} : {detail}"]
                        if status in ("DEMANDEE", "DEJA_DEMANDEE") and RECONF_ATTENTE > 0:
                            print(_T("  Attente du retour de l'agent (au plus {} s, Ctrl+C pour passer)...", RECONF_ATTENTE))
                            status, detail = follow_reconf(row["hostname_ssh"], RECONF_ATTENTE)
                            print(f"  {row['ip']:<{width_ip}}  {short(row['hostname_ssh'], 20):<20}  {tag(status, 24)}  " + paint(short(detail, 70), "gris"), flush=True)
                            lines.append(f"{status} : {detail}")
                        row["reconfiguration"], row["detail_reconfiguration"] = status, detail
                        reconf_logs[row["ip"]] = "\n".join(lines)
                except KeyboardInterrupt:
                    print(paint(_T("\n  Interrompu : reconfiguration non suivie."), "jaune"))
                for row in reconf_candidates:
                    if not row["reconfiguration"]:
                        row["reconfiguration"] = "ANNULEE_INTERRUPTION"
            else:
                print(paint(_T("  Reconfiguration annulee : aucune ecriture en base."), "rouge"))
                for row in reconf_candidates:
                    row["reconfiguration"] = "NON_CONFIRMEE"

    # 7 bis b. Agent joignable en XMPP mais SSH injoignable, sshdaemon arrete : demarrage
    # (sans arret de l'agent, aucun fichier modifie ; aussi sur les machines qui fonctionnent).
    ssh_starts = [] if not acting or interrupted else [
        row for row in rows if (row.get("ssh_serveur") or "").startswith("KO")
        and by_xmpp(row["ip"]) and not launched(row.get("correction") or "")
        and ((row.get("_tests") or {}).get("sshdaemon") or {}).get("etat", "")
        not in ("RUNNING", "ABSENT", "")]
    if ssh_starts:
        step(_T("DEMARRAGE DE SSH : {} poste(s)", len(ssh_starts)))
        print_table(["IP", _T("Poste"), _T("sshdaemon"), _T("SSH depuis le serveur")],
                    [[row["ip"], short(row["hostname_ssh"], 24),
                      ((row.get("_tests") or {}).get("sshdaemon") or {}).get("etat", "?"),
                      short(tr(row["ssh_serveur"]), 60)] for row in ssh_starts])
        print(paint(_T("  L'agent repond en XMPP mais SSH ne fonctionne pas : le service sshdaemon est demarre par"), "jaune"))
        print(paint(_T("  XMPP (sans arret de l'agent, aucun fichier modifie), puis SSH est reteste."), "jaune"))
        if confirm(_T("demarrer SSH sur {} poste(s)", len(ssh_starts)), args.oui):
            for row in ssh_starts:
                status, log = x_run_services(row["ip"])
                hostname, error = s_test_ssh(row["ip"])
                row["ssh_demarrage"] = ("SSH_OK" if hostname else
                                        f"SSH_TOUJOURS_KO ({status}) : {clean(error, 80)}")
                print(f"  {row['ip']:<{width_ip}}  {short(row['hostname_ssh'], 20):<20}  "
                      f"{tag(row['ssh_demarrage'].split(' ')[0], 18)}  "
                      + paint(short(row["ssh_demarrage"], 60), "gris"), flush=True)
        else:
            for row in ssh_starts:
                row["ssh_demarrage"] = "NON_CONFIRME"

    # 7 quater. Machines enregistrees avec une MAC partagee deja blacklistee : reconfiguration
    # (ON : immediate ; OFF : appliquee a leur retour ON). Confirmation separee.
    mac_results = []
    mac_targets = sorted(BLACKLISTED_SHARED, key=str.casefold)
    if acting and mac_targets and not interrupted:
        step(_T("RECONFIGURATION APRES BLACKLIST DES MAC : {} machine(s)", len(mac_targets)))
        plan_mac = []
        for name in mac_targets:
            info = BLACKLISTED_SHARED[name]
            session = xmpp_session_for(name) if info["on"] else ""
            works = info["on"] and session != ""
            if works and not MODIFIER_MACHINES_FONCTIONNELLES:
                effect = "EXCLU_MACHINE_FONCTIONNELLE (--full pour l'inclure)"
            else:
                effect = "immediate" if info["on"] else "au retour ON"
            plan_mac.append({"machine": name, "etat": "ON" if info["on"] else "OFF",
                             "macs": ", ".join(sorted(info["macs"])), "effet": effect,
                             "statut": "", "detail": ""})
        print_table([_T("Machine"), _T("Etat"), _T("Adresse MAC"), _T("Effet")],
                    [[item["machine"], item["etat"], item["macs"], tr(item["effet"])]
                     for item in plan_mac[:60]])
        if len(plan_mac) > 60:
            print(_T("  ... et {} autre(s).", len(plan_mac) - 60))
        todo = [item for item in plan_mac if not item["effet"].startswith("EXCLU")]
        for item in plan_mac:
            if item["effet"].startswith("EXCLU"):
                item["statut"] = item["effet"]
        applied = blacklist_applied()
        try:
            users = {jid_parts(jid)[0].casefold() for jid in connected_sessions()}
        except (OSError, subprocess.SubprocessError):
            users = set()
        blocked_reason = ""
        if applied is False:
            blocked_reason = ("ANNULE_SUBSTITUTS_NON_REDEMARRES (lancer restart-pulse-services "
                              "allsubs, puis relancer le script)")
            print(paint(_T("  registeryagent.ini.local a ete modifie apres le dernier demarrage du substitut"), "rouge"))
            print(paint(_T("  d'enregistrement : la blacklist n'est pas encore prise en compte. Lancer"), "rouge"))
            print(paint(_T("  restart-pulse-services allsubs, puis relancer le script."), "rouge"))
        elif "master_reconf" not in users:
            blocked_reason = "ANNULE_RECONFIGURATEUR_ABSENT (substitut master_reconf non connecte)"
            print(paint(_T("  Substitut master_reconf non connecte a ejabberd : reconfiguration impossible."), "rouge"))
        if blocked_reason:
            for item in todo:
                item["statut"] = blocked_reason
        elif todo:
            if applied is None:
                print(paint(_T("  Redemarrage des substituts non verifiable : s'assurer que restart-pulse-services allsubs"), "jaune"))
                print(paint(_T("  a ete lance apres la modification de la blacklist."), "jaune"))
            print(paint(_T("\n  need_reconf=1 est ecrit sur la fiche de chaque machine : l'agent se reenregistre sans la"), "jaune"))
            print(paint(_T("  MAC blacklistee. Machine ON : dans la minute ; machine OFF : quand elle repassera ON."), "jaune"))
            if confirm(_T("reconfigurer {} machine(s)", len(todo)), args.oui):
                try:
                    for item in todo:
                        item["statut"], item["detail"] = mac_reconf(item["machine"])
                        print(f"  {short(item['machine'], 24):<24}  {tag(item['statut'], 24)}  "
                              + paint(short(item["detail"], 70), "gris"), flush=True)
                except KeyboardInterrupt:
                    print(paint(_T("\n  Interrompu : les machines restantes ne sont pas reconfigurees."), "jaune"))
                for item in todo:
                    if not item["statut"]:
                        item["statut"] = "ANNULEE_INTERRUPTION"
            else:
                print(paint(_T("  Reconfiguration annulee : aucune ecriture en base."), "rouge"))
                for item in todo:
                    item["statut"] = "NON_CONFIRMEE"
        mac_results = plan_mac

    # 7 quinquies. Reset d'office : reconnexions en boucle, machine ON sans inventaire GLPI.
    auto_results = []
    auto_targets = sorted(AUTO_RESET, key=str.casefold)
    done_reset = {row["hostname_ssh"].casefold() for row in rows
                  if row.get("hostname_ssh") and (row.get("reset") or "").startswith("DEMANDE")}
    auto_targets = [name for name in auto_targets if name.casefold() not in done_reset]
    if acting and auto_targets and not interrupted:
        step(_T("RESET D'OFFICE : {} machine(s)", len(auto_targets)))
        auto_results = [{"machine": name, "motif": AUTO_RESET[name], "statut": "", "detail": ""}
                        for name in auto_targets]
        print_table([_T("Machine"), _T("Motif")], [[item["machine"], tr(item["motif"])]
                                           for item in auto_results[:60]])
        if len(auto_results) > 60:
            print(_T("  ... et {} autre(s).", len(auto_results) - 60))
        if not reset_available:
            print(paint(_T("  Serveur Medulla < 5.6.4 : reset indisponible."), "rouge"))
            for item in auto_results:
                item["statut"] = "NON_DISPONIBLE (serveur < 5.6.4)"
        else:
            print(paint(_T("\n  Une demande de reset est inscrite dans xmppmaster.reset_machine pour chaque machine."), "jaune"))
            print(paint(_T("  Le serveur reinitialise l'agent (fichiers retelecharges) des qu'il est connecte ;"), "jaune"))
            print(paint(_T("  une machine OFF est traitee a son retour."), "jaune"))
            if confirm(_T("demander le reset de {} agent(s)", len(auto_results)), args.oui):
                try:
                    for item in auto_results:
                        item["statut"], item["detail"] = reset_request(item["machine"], item["motif"])
                        AUTO_RESET_STATUS[item["machine"].casefold()] = item["statut"]
                        print(f"  {short(item['machine'], 24):<24}  {tag(item['statut'], 24)}  "
                              + paint(short(item["detail"], 70), "gris"), flush=True)
                except KeyboardInterrupt:
                    print(paint(_T("\n  Interrompu : les resets restants ne sont pas demandes."), "jaune"))
                for item in auto_results:
                    if not item["statut"]:
                        item["statut"] = "ANNULE_INTERRUPTION"
            else:
                print(paint(_T("  Reset annule : aucune demande inscrite."), "rouge"))
                for item in auto_results:
                    item["statut"] = "NON_CONFIRME"

    # 7 ter. Etat dans Medulla apres la reparation (memes machines reelles).
    if acting:
        try:
            records = [json.loads(line) for line in read_medulla().splitlines() if line.strip()]
            mark_machines(real, medulla_snapshot(records), "apres")
        except (subprocess.SubprocessError, OSError, ValueError) as error:
            vlog(_T("etat apres reparation non mesure : {}", error))

    # 8. Demande d'inventaire aux postes corriges.
    if acting and not inventory_after and not interrupted and XMPP_ENABLED:
        # Postes corriges en SSH et revenus ON : leur agent est joignable par XMPP.
        for row in rows:
            if not by_xmpp(row["ip"]) and row["etat_medulla_apres"] == "ON" \
                    and row["ip"] not in JID_OF:
                jid = xmpp_session_for(row["hostname_ssh"])
                if jid and jid != "?" and ensure_link(jid):
                    JID_OF[row["ip"]] = jid
        request_inventories(rows, args, step)

    # 9. Rapports et diagnostic final.
    output = Path(args.dossier) / datetime.now().strftime("releve_%Y%m%d_%H%M%S_%f")
    output.mkdir(parents=True)
    if VERBOSE:
        (output / "trace.log").write_text("\n".join(TRACE) + "\n", encoding="utf-8")
    rows.sort(key=lambda row: ip_sort_key(row["ip"]))
    write_csv(output / "resultats.csv", FIELDS, rows)
    log_ips = sorted(set(repair_logs) | set(restart_logs) | set(reset_logs) | set(reconf_logs),
                     key=ip_sort_key)
    names_of = {row["ip"]: f"{row['hostname_ssh'] or row['nom_demande']}_{row['ip']}"
                for row in rows if row["ip"]}
    if log_ips:
        log_dir = output / "journaux"
        log_dir.mkdir()
        for ip in log_ips:
            parts = []
            for title, logs in (("CORRECTION", repair_logs), ("RELANCE DU SERVICE", restart_logs),
                                ("RESET DE L'AGENT", reset_logs),
                                ("RECONFIGURATION DE L'AGENT", reconf_logs)):
                if ip in logs:
                    parts.append(f"===== {title} ({transport_label(ip)}) =====\n"
                                 f"{logs[ip].rstrip()}\n")
            filename = re.sub(r"[^A-Za-z0-9._-]+", "_", names_of.get(ip) or ip)
            (log_dir / f"{filename}.log").write_text("\n".join(parts), encoding="utf-8")
    context = [
        ("Debut du releve (heure serveur)", started),
        ("Mode", "AUDIT + ACTIONS" if acting else "AUDIT SEUL"),
        ("Perimetre", ", ".join(args.cible) if targeted else scope_text[perimeter]),
        ("Transports", ", ".join(filter(None, ("SSH" if SSH_ENABLED else "",
                                                "XMPP" if XMPP_ENABLED else ""))) +
         (f" (XMPP indisponible : {XMPP_ERROR})" if XMPP_ERROR else "")),
        ("Version du serveur Medulla", version_text(server_version)),
        ("Reset d'agent disponible (serveur >= 5.6.4)", "OUI" if reset_available else "NON"),
        ("Reference des fichiers de l'agent", AGENT_REF_DIR if LIB_REF else
         "absente (controle desactive)"),
        ("Version de la reference", (LIB_REF or {}).get("version", "")),
        ("Nombre de fichiers de reference", len(LIB_REF["files"]) if LIB_REF else ""),
        ("Duree des tests (s)", f"{diag_duration:.1f}".replace(".", ",")),
        ("Duree des corrections (s)", f"{repair_duration:.1f}".replace(".", ",")),
        ("Duree des relances (s)", f"{restart_duration:.1f}".replace(".", ",")),
        ("Attente avant controle Medulla (s)", waited),
        ("Parallelisme", args.parallelisme),
        ("Delai de connexion SSH (s)", SSH_CONNECT),
        ("Delai de reponse d'une commande XMPP (s)", SSH_TIMEOUT),
        ("Nouveaux essais apres timeout (connexion, audit)", RETRIES),
        ("Delai maximal de l'audit d'un poste (s)", DIAG_TIMEOUT),
        ("Limite de corrections", args.limite or ""),
        ("Code de reparation integre (sha256)", REPAIR_SHA256),
        ("Medulla enabled=1 (lignes)", on_count),
        ("Medulla enabled=0 (lignes, avec doublons)", off_count),
        ("Lignes OFF sans IP (non testees)", without_ip),
        ("Valeurs IP invalides distinctes (non testees)", len(invalid)),
    ]
    stats = build_stats(rows, context, targeted, server_version) + before_after_stats(real)
    if real:
        write_csv(output / "avant_apres_reparation.csv",
                  ["poste", "ips", "ssh_fonctionnel", "xmpp_fonctionnel", "medulla_avant",
                   "medulla_apres", "session_avant", "session_apres", "actions"],
                  [{"poste": item["poste"], "ips": ", ".join(item["ips"]),
                    "ssh_fonctionnel": "OUI" if item["ssh"] else "NON",
                    "xmpp_fonctionnel": "OUI" if item["xmpp"] else "NON",
                    "medulla_avant": item.get("medulla_avant", ""),
                    "medulla_apres": item.get("medulla_apres", "NON_MESURE"),
                    "session_avant": item.get("session_avant", ""),
                    "session_apres": item.get("session_apres", "NON_MESURE"),
                    "actions": machine_actions(item)}
                   for item in sorted(real.values(), key=lambda item: item["poste"].casefold())])
    write_csv(output / "statistiques.csv",
              ("section", "indicateur", "nombre", "base", "pourcentage"), stats)

    banner(_T("STATISTIQUES DETAILLEES (contenu de statistiques.csv)"))
    print_detailed_stats(stats)

    banner(_T("RESUME"))
    print_synthesis(rows, targeted, server_version)
    print()
    print_families(rows)
    subtitle(_T("Machines reelles joignables : OFF dans Medulla avant / apres reparation"))
    print_before_after(real, acting)
    if acting:
        print()
        for label, field in (("Correction", "correction"), ("Relance", "relance"),
                             ("Medulla apres", "etat_medulla_apres"), ("Reset", "reset"),
                             ("Reconfiguration", "reconfiguration"),
                             ("Inventaire", "inventaire")):
            results = Counter(row[field].split(" (")[0] for row in rows if row[field])
            for value, count in sorted(results.items(), key=lambda item: (-item[1], item[0])):
                kv(f"{tr(label)} : {tag(value)}", count)
        verified = [row for row in rows if row["problemes_apres"]]
        if verified:
            kv(_T("Corrections verifiees sans probleme restant"),
               sum(row["problemes_apres"] == "Aucun" for row in verified),
               f"  / {len(verified)} verifie(s)")
        critical = [row for row in rows
                    if row["correction"] in CRITICAL or row["relance"] in CRITICAL
                    or row["reset"].startswith("ECHEC")
                    or row["reconfiguration"].startswith("ECHEC")]
        if critical:
            subtitle(paint(_T("A VERIFIER MANUELLEMENT EN PRIORITE"), "rouge"))
            print_table(["IP", _T("Acces"), _T("Poste"), _T("Correction"), _T("Relance"), _T("Reset")],
                        [[row["ip"], row["acces"], row["hostname_ssh"], row["correction"] or "-",
                          row["relance"] or "-", row["reset"] or "-"] for row in critical],
                        colored=(3, 4, 5))
    stale = [row for row in rows if row["deja_on"] == "OUI"]
    if stale:
        write_csv(output / "fiches_off_obsoletes.csv",
                  ["ip", "hostnames_medulla", "hostname_ssh", "fiche_on", "cause_fiche_off"],
                  stale)
        subtitle(_T("FICHES OFF OBSOLETES : {} (poste ON dans Medulla, audite par XMPP)", len(stale)))
        print_table(["IP", _T("Fiche(s) OFF"), _T("Poste qui repond / fiche ON"), _T("Cause probable")],
                    [[row["ip"], short(row["hostnames_medulla"] or "-", 24),
                      short(row["fiche_on"] or row["hostname_ssh"] or "-", 34),
                      row["cause_fiche_off"].split(" (")[0]] for row in stale[:60]])
        if len(stale) > 60:
            print(_T("  ... et {} autre(s) (voir fiches_off_obsoletes.csv).", len(stale) - 60))
        print(paint(_T("  MEME_POSTE : ancienne fiche du poste ON (reinstallation, nouveau JID). IP_REATTRIBUEE : l'IP de la"), "gris"))
        print(paint(_T("  fiche OFF est maintenant celle d'un autre poste (DHCP) : le poste OFF reste a trouver."), "gris"))
    # GLPI-Agent trop ancien pour l'option --full : constat seulement, aucune mise a jour.
    seen_glpi, old_glpi = set(), []
    for row in rows:
        if row.get("inventaire_a_jour") == "NON" and machine_key(row) not in seen_glpi:
            seen_glpi.add(machine_key(row))
            old_glpi.append(row)
    if old_glpi:
        write_csv(output / "glpi_agent_sans_full.csv",
                  ["ip", "acces", "hostname_ssh", "agent_inventaire", "version_inventaire",
                   "etat_medulla"], old_glpi)
        subtitle(_T("GLPI-AGENT SANS L'OPTION --full (version < {}) : {} poste(s)", version_text(GLPI_FULL_MIN), len(old_glpi)))
        print_table(["IP", _T("Acces"), _T("Poste"), _T("Version GLPI-Agent"), _T("Medulla")],
                    [[row["ip"], row["acces"], short(row["hostname_ssh"], 24),
                      row["version_inventaire"], row["etat_medulla"] or "-"]
                     for row in old_glpi[:100]], colored=(4,))
        if len(old_glpi) > 100:
            print(_T("  ... et {} autre(s) (voir glpi_agent_sans_full.csv).", len(old_glpi) - 100))
        print(paint(_T("  Constat seulement : ce script ne met jamais GLPI-Agent a jour. L'agent Medulla lance GLPI-Agent"), "gris"))
        print(paint(_T("  avec --full : ces postes ne produisent aucun inventaire tant que GLPI-Agent n'est pas mis a jour."), "gris"))
    investigated = [row for row in rows if row.get("enquete_serveur")]
    if investigated:
        write_csv(output / "enquete_postes_injoignables.csv",
                  ["ip", "hostnames_medulla", "nom_demande", "ssh", "ssh_erreur",
                   "derniere_presence", "reseau", "diagnostic_reseau", "enquete_serveur",
                   "traces_serveur"], investigated)
    plan = remediation_plan(rows)
    details_written = write_server_details(output)
    if auto_results:
        COLUMN_LABELS.update({"motif": "Motif", "statut": "Resultat", "detail": "Detail"})
        write_csv(output / "reset_d_office.csv", ["machine", "motif", "statut", "detail"],
                  auto_results)
        details_written.append(output / "reset_d_office.csv")
    if mac_results:
        COLUMN_LABELS.update({"effet": "Effet", "statut": "Resultat", "detail": "Detail"})
        write_csv(output / "reconfiguration_mac.csv",
                  ["machine", "etat", "macs", "effet", "statut", "detail"], mac_results)
        details_written.append(output / "reconfiguration_mac.csv")
    write_csv(output / "plan_de_remediation.csv",
              ["ip", "acces", "poste", "famille", "probleme", "traitement", "action"], plan)
    print_final_diagnosis(plan, findings)
    subtitle(_T("Fichiers"))
    print(f"  {output / 'resultats.csv'}")
    print(f"  {output / 'statistiques.csv'}")
    if stale:
        print(f"  {output / 'fiches_off_obsoletes.csv'}")
    if old_glpi:
        print(f"  {output / 'glpi_agent_sans_full.csv'}")
    print(f"  {output / 'plan_de_remediation.csv'}")
    for path in details_written:
        print(f"  {path}")
    if real:
        print(f"  {output / 'avant_apres_reparation.csv'}")
    if investigated:
        print(f"  {output / 'enquete_postes_injoignables.csv'}")
    if log_ips:
        print(_T("  {}/ ({} journal(aux) d'actions)", output / 'journaux', len(log_ips)))
    print()
    if inventory_after:
        inventory_mode(args, *inventory_after)



# ===========================================================================================
# FIN - CATALOGUE DES TRADUCTIONS (texte francais -> anglais, espagnol, allemand)
# ===========================================================================================
CATALOG = {
    '% postes': ('% computers', '% equipos', '% Rechner'),
    '% probleme': ('% problem', '% problema', '% Problem'),
    '(JID de sa fiche OFF, raison :': ('(JID of its OFF record, reason:', '(JID de su ficha OFF, motivo:', '(JID seines OFF-Eintrags, Grund:'),
    '(choisi par : {})': ('(chosen by: {})', '(elegido por: {})', '(gewählt durch: {})'),
    '(choix 1 = diagnostic seul ; choix 2 = correction des seuls postes qui en ont besoin, apres OUI)': ('(choice 1 = diagnostic only; choice 2 = fix only the computers that need it, after YES)', '(opción 1 = solo diagnóstico; opción 2 = corrección solo de los equipos que lo necesitan, tras SI)', '(Wahl 1 = nur Diagnose; Wahl 2 = Korrektur nur der Rechner, die es brauchen, nach JA)'),
    '(colonne platform indisponible : detection sur le poste)': ('(platform column unavailable: detection on the computer)', '(columna platform no disponible: detección en el equipo)', '(Spalte platform nicht verfügbar: Erkennung auf dem Rechner)'),
    '(confirmation separee).': ('(separate confirmation).', '(confirmación aparte).', '(separate Bestätigung).'),
    '(lu dans {})': ('(read from {})', '(leído en {})', '(gelesen aus {})'),
    '({} illisible)': ('({} unreadable)', '({} ilegible)', '({} unlesbar)'),
    ') Quitter sans rien faire': (') Quit without doing anything', ') Salir sin hacer nada', ') Beenden, ohne etwas zu tun'),
    '). Voir le journal ejabberd : grep -i diagnostic- /var/log/ejabberd/ejabberd.log | tail': ('). See the ejabberd log: grep -i diagnostic- /var/log/ejabberd/ejabberd.log | tail', '). Vea el registro de ejabberd: grep -i diagnostic- /var/log/ejabberd/ejabberd.log | tail', '). Siehe ejabberd-Protokoll: grep -i diagnostic- /var/log/ejabberd/ejabberd.log | tail'),
    ', abandon si le poste ne repond plus pendant {} s': (', abort if the computer stops answering for {} s', ', abandono si el equipo deja de responder durante {} s', ', Abbruch, wenn der Rechner {} s nicht mehr antwortet'),
    ', {} action(s)': (', {} action(s)', ', {} acción(es)', ', {} Aktion(en)'),
    '--oui sans --full : elles ne sont pas traitees (ajouter --full pour les inclure).': ('--oui without --full: they are not processed (add --full to include them).', '--oui sin --full: no se tratan (añadir --full para incluirlas).', '--oui ohne --full: sie werden nicht bearbeitet (--full hinzufügen, um sie einzubeziehen).'),
    '-> relance du service possible': ('-> service restart possible', '-> reinicio del servicio posible', '-> Neustart des Dienstes möglich'),
    '-> relance du service possible (SSH OK)': ('-> service restart possible (SSH OK)', '-> reinicio del servicio posible (SSH OK)', '-> Neustart des Dienstes möglich (SSH OK)'),
    "-> reset d'agent indisponible (5.6.4 minimum)": ('-> agent reset unavailable (5.6.4 minimum)', '-> reinicialización del agente no disponible (5.6.4 mínimo)', '-> Agent-Reset nicht verfügbar (mindestens 5.6.4)'),
    "-> reset possible si l'agent reste OFF et sans session XMPP": ('-> reset possible if the agent stays OFF with no XMPP session', '-> reinicialización posible si el agente sigue OFF y sin sesión XMPP', '-> Reset möglich, falls der Agent OFF und ohne XMPP-Sitzung bleibt'),
    '-> reset possible si toujours OFF apres relance': ('-> reset possible if still OFF after restart', '-> reinicialización posible si sigue OFF tras el reinicio', '-> Reset möglich, falls nach Neustart weiter OFF'),
    '-> {} constat(s) ({} s)': ('-> {} finding(s) ({} s)', '-> {} constatación(es) ({} s)', '-> {} Befund(e) ({} s)'),
    '-> {} fiche(s) lue(s) (enabled = 0 ou 1)': ('-> {} record(s) read (enabled = 0 or 1)', '-> {} ficha(s) leída(s) (enabled = 0 o 1)', '-> {} Eintrag/Einträge gelesen (enabled = 0 oder 1)'),
    '... encore {} s': ('... {} s left', '... quedan {} s', '... noch {} s'),
    '... et {} autre(s) (voir enquete_postes_injoignables.csv).': ('... and {} more (see enquete_postes_injoignables.csv).', '... y {} más (vea enquete_postes_injoignables.csv).', '... und {} weitere (siehe enquete_postes_injoignables.csv).'),
    '... et {} autre(s) (voir fiches_off_obsoletes.csv).': ('... and {} more (see fiches_off_obsoletes.csv).', '... y {} más (vea fiches_off_obsoletes.csv).', '... und {} weitere (siehe fiches_off_obsoletes.csv).'),
    '... et {} autre(s) (voir glpi_agent_sans_full.csv).': ('... and {} more (see glpi_agent_sans_full.csv).', '... y {} más (vea glpi_agent_sans_full.csv).', '... und {} weitere (siehe glpi_agent_sans_full.csv).'),
    '... et {} autre(s) ligne(s) : {}': ('... and {} more line(s): {}', '... y {} línea(s) más: {}', '... und {} weitere Zeile(n): {}'),
    '... et {} autre(s).': ('... and {} more.', '... y {} más.', '... und {} weitere.'),
    '... toujours en cours : {}{} (audit limite a {} s par poste)': ('... still running: {}{} (audit limited to {} s per computer)', '... todavía en curso: {}{} (auditoría limitada a {} s por equipo)', '... läuft noch: {}{} (Audit auf {} s je Rechner begrenzt)'),
    '... {} en attente, encore {} s': ('... {} waiting, {} s left', '... {} en espera, quedan {} s', '... {} wartend, noch {} s'),
    '.generation_options sans --base-url : les agents peuvent echouer au telechargement (erreur 404)': ('.generation_options without --base-url: agents may fail to download (error 404)', '.generation_options sin --base-url: los agentes pueden fallar en la descarga (error 404)', '.generation_options ohne --base-url: Agenten können beim Herunterladen scheitern (Fehler 404)'),
    '/ {} verifie(s)': ('/ {} checked', '/ {} verificado(s)', '/ {} geprüft'),
    "; rattachement controle pour {} poste(s) sans UUID ({} s au plus, Ctrl+C pour arreter l'attente).": ('; matching checked for {} computer(s) with no UUID ({} s at most, Ctrl+C to stop waiting).', '; vinculación controlada para {} equipo(s) sin UUID ({} s como máximo, Ctrl+C para dejar de esperar).', '; Zuordnung geprüft für {} Rechner ohne UUID (höchstens {} s, Strg+C beendet das Warten).'),
    'A VERIFIER MANUELLEMENT EN PRIORITE': ('TO CHECK MANUALLY FIRST', 'A REVISAR MANUALMENTE PRIMERO', 'ZUERST MANUELL PRÜFEN'),
    'ATTENTION': ('WARNING', 'ATENCIÓN', 'ACHTUNG'),
    'ATTENTION : supprimer a la main le compte XMPP {}@{} (ejabberdctl unregister {} {})': ('WARNING: delete the XMPP account {}@{} manually (ejabberdctl unregister {} {})', 'ATENCIÓN: eliminar manualmente la cuenta XMPP {}@{} (ejabberdctl unregister {} {})', 'ACHTUNG: XMPP-Konto {}@{} manuell löschen (ejabberdctl unregister {} {})'),
    'AUCUNE modification': ('NO change', 'NINGUNA modificación', 'KEINE Änderung'),
    'AUDIT (lecture seule) :': ('AUDIT (read-only):', 'AUDITORÍA (solo lectura):', 'AUDIT (nur lesend):'),
    'AUDIT (lecture seule) : {} IP': ('AUDIT (read-only): {} IP', 'AUDITORÍA (solo lectura): {} IP', 'AUDIT (nur lesend): {} IP'),
    'AUDIT + ACTIONS': ('AUDIT + ACTIONS', 'AUDITORÍA + ACCIONES', 'AUDIT + AKTIONEN'),
    'AUDIT + ACTIONS (correction, relance, reset)': ('AUDIT + ACTIONS (fix, restart, reset)', 'AUDITORÍA + ACCIONES (corrección, reinicio, reinicialización)', 'AUDIT + AKTIONEN (Korrektur, Neustart, Reset)'),
    "AUDIT + DEMANDE D'INVENTAIRE": ('AUDIT + INVENTORY REQUEST', 'AUDITORÍA + SOLICITUD DE INVENTARIO', 'AUDIT + INVENTURANFORDERUNG'),
    'AUDIT SEUL': ('AUDIT ONLY', 'SOLO AUDITORÍA', 'NUR AUDIT'),
    'AUDIT SEUL, aucune modification': ('AUDIT ONLY, no change', 'SOLO AUDITORÍA, ninguna modificación', 'NUR AUDIT, keine Änderung'),
    'AUDIT, SUITE (lecture seule) :': ('AUDIT, CONTINUED (read-only):', 'AUDITORÍA, CONTINUACIÓN (solo lectura):', 'AUDIT, FORTSETZUNG (nur lesend):'),
    'Absent de Medulla depuis {} jours : poste eteint, retire ou renomme': ('Absent from Medulla for {} days: computer off, removed or renamed', 'Ausente de Medulla desde hace {} días: equipo apagado, retirado o renombrado', 'Seit {} Tagen nicht in Medulla: Rechner aus, entfernt oder umbenannt'),
    'Acces': ('Access', 'Acceso', 'Zugriff'),
    'Acces au poste': ('Access to the computer', 'Acceso al equipo', 'Zugriff auf den Rechner'),
    "Acces refuse a un fichier de l'agent": ('Access denied to an agent file', 'Acceso denegado a un archivo del agente', 'Zugriff auf eine Agent-Datei verweigert'),
    'Acces refuse au dossier Medulla\\var': ('Access denied to the Medulla\\var folder', 'Acceso denegado a la carpeta Medulla\\var', 'Zugriff auf den Ordner Medulla\\var verweigert'),
    "Acces refuse au dossier de l'agent": ('Access denied to the agent folder', 'Acceso denegado a la carpeta del agente', 'Zugriff auf den Agent-Ordner verweigert'),
    "Acces refuse aux fichiers de l'agent": ('Access denied to the agent files', 'Acceso denegado a los archivos del agente', 'Zugriff auf Agent-Dateien verweigert'),
    'Accès SSH': ('SSH access', 'Acceso SSH', 'SSH-Zugriff'),
    'Accès au poste (SSH, XMPP, SSH en repli)': ('Access to the computer (SSH, XMPP, SSH fallback)', 'Acceso al equipo (SSH, XMPP, SSH de respaldo)', 'Zugriff auf den Rechner (SSH, XMPP, SSH als Ausweichweg)'),
    'Action': ('Action', 'Acción', 'Aktion'),
    'Action possible': ('Possible action', 'Acción posible', 'Mögliche Aktion'),
    'Action recommandee': ('Recommended action', 'Acción recomendada', 'Empfohlene Aktion'),
    'Action recommandée': ('Recommended action', 'Acción recomendada', 'Empfohlene Aktion'),
    'Actions annulees : aucun poste modifie.': ('Actions cancelled: no computer changed.', 'Acciones canceladas: ningún equipo modificado.', 'Aktionen abgebrochen: kein Rechner geändert.'),
    'Adresse MAC': ('MAC address', 'Dirección MAC', 'MAC-Adresse'),
    'Adresse MAC en double ou virtuelle': ('Duplicate or virtual MAC address', 'Dirección MAC duplicada o virtual', 'Doppelte oder virtuelle MAC-Adresse'),
    'Adresse MAC {} partagee avec {} autre(s) machine(s)': ('MAC address {} shared with {} other machine(s)', 'Dirección MAC {} compartida con {} otro(s) equipo(s)', 'MAC-Adresse {} mit {} anderen Rechner(n) geteilt'),
    'Adresses MAC': ('MAC addresses', 'Direcciones MAC', 'MAC-Adressen'),
    'Adresses MAC du poste': ('Computer MAC addresses', 'Direcciones MAC del equipo', 'MAC-Adressen des Rechners'),
    'Adresses MAC du poste (relevees pour le controle serveur)': ('Computer MAC addresses (collected for the server check)', 'Direcciones MAC del equipo (recogidas para el control del servidor)', 'MAC-Adressen des Rechners (für die Serverprüfung erfasst)'),
    'Age du dernier inventaire (h)': ('Age of the last inventory (h)', 'Antigüedad del último inventario (h)', 'Alter der letzten Inventur (h)'),
    'Agent': ('Agent', 'Agente', 'Agent'),
    "Agent OFF : la reconfiguration (need_reconf) est sans effet ; verifier le compte (ejabberdctl check_account <compte> pulse), sinon reset de l'agent (--cible)": ('Agent OFF: reconfiguration (need_reconf) has no effect; check the account (ejabberdctl check_account <account> pulse), otherwise reset the agent (--cible)', 'Agente OFF: la reconfiguración (need_reconf) no tiene efecto; verificar la cuenta (ejabberdctl check_account <cuenta> pulse), si no, reinicializar el agente (--cible)', 'Agent OFF: die Neukonfiguration (need_reconf) wirkt nicht; Konto prüfen (ejabberdctl check_account <Konto> pulse), sonst Agent-Reset (--cible)'),
    'Agent a jour par rapport au serveur (audits complets)': ('Agent up to date with the server (complete audits)', 'Agente actualizado respecto al servidor (auditorías completas)', 'Agent aktuell gegenüber dem Server (vollständige Audits)'),
    'Agent a mettre a niveau': ('Agent to upgrade', 'Agente que actualizar', 'Agent zu aktualisieren'),
    'Agent a mettre a niveau depuis le serveur ({})': ('Agent to upgrade from the server ({})', 'Agente que actualizar desde el servidor ({})', 'Agent vom Server zu aktualisieren ({})'),
    'Agent connecte a ejabberd': ('Agent connected to ejabberd', 'Agente conectado a ejabberd', 'Agent mit ejabberd verbunden'),
    'Agent connecte a ejabberd (session ouverte)': ('Agent connected to ejabberd (open session)', 'Agente conectado a ejabberd (sesión abierta)', 'Agent mit ejabberd verbunden (offene Sitzung)'),
    "Agent d'inventaire": ('Inventory agent', 'Agente de inventario', 'Inventuragent'),
    "Agent d'inventaire (audits complets)": ('Inventory agent (complete audits)', 'Agente de inventario (auditorías completas)', 'Inventuragent (vollständige Audits)'),
    "Agent d'inventaire (inventory.ini)": ('Inventory agent (inventory.ini)', 'Agente de inventario (inventory.ini)', 'Inventuragent (inventory.ini)'),
    "Agent d'inventaire absent": ('Inventory agent missing', 'Agente de inventario ausente', 'Inventuragent fehlt'),
    "Agent d'inventaire configure": ('Configured inventory agent', 'Agente de inventario configurado', 'Konfigurierter Inventuragent'),
    'Agent different du serveur': ('Agent differs from the server', 'Agente distinto del servidor', 'Agent weicht vom Server ab'),
    'Agent different du serveur ({}) mais plus recent que la reference ou reference sans version : verification manuelle': ('Agent differs from the server ({}) but is newer than the reference or the reference has no version: manual check', 'Agente distinto del servidor ({}) pero más reciente que la referencia o referencia sin versión: revisión manual', 'Agent weicht vom Server ab ({}), ist aber neuer als die Referenz oder Referenz ohne Version: manuelle Prüfung'),
    'Agent en boucle de reconfiguration': ('Agent in a reconfiguration loop', 'Agente en bucle de reconfiguración', 'Agent in Neukonfigurationsschleife'),
    'Agent en boucle de reconfiguration ({} dans le journal)': ('Agent in a reconfiguration loop ({} in the log)', 'Agente en bucle de reconfiguración ({} en el registro)', 'Agent in einer Neukonfigurationsschleife ({} im Protokoll)'),
    'Agent incomplet ou desinstalle': ('Agent incomplete or uninstalled', 'Agente incompleto o desinstalado', 'Agent unvollständig oder deinstalliert'),
    'Agent non connecte a ejabberd': ('Agent not connected to ejabberd', 'Agente no conectado a ejabberd', 'Agent nicht mit ejabberd verbunden'),
    'Agent repond (XMPP)': ('Agent answers (XMPP)', 'El agente responde (XMPP)', 'Agent antwortet (XMPP)'),
    'Agent à jour (version >= serveur)': ('Agent up to date (version >= server)', 'Agente actualizado (versión >= servidor)', 'Agent aktuell (Version >= Server)'),
    'Agents connectes a ejabberd': ('Agents connected to ejabberd', 'Agentes conectados a ejabberd', 'Mit ejabberd verbundene Agenten'),
    'Agents connectes a ejabberd (XMPP)': ('Agents connected to ejabberd (XMPP)', 'Agentes conectados a ejabberd (XMPP)', 'Mit ejabberd verbundene Agenten (XMPP)'),
    'Agents non connectes (non testes)': ('Agents not connected (not tested)', 'Agentes no conectados (no probados)', 'Nicht verbundene Agenten (nicht getestet)'),
    'Ajouter --base-url=http://<serveur>/downloads puis regenerer les agents (FAQ : Erreur 404)': ('Add --base-url=http://<server>/downloads then regenerate the agents (FAQ: Error 404)', 'Añadir --base-url=http://<servidor>/downloads y regenerar los agentes (FAQ: Error 404)', '--base-url=http://<server>/downloads ergänzen, dann Agenten neu erzeugen (FAQ: Fehler 404)'),
    'Ajouter la blacklist des MAC puis restart-pulse-services allsubs (FAQ : Doublons Adresse Mac)': ('Add the MAC blacklist then restart-pulse-services allsubs (FAQ: Duplicate MAC addresses)', 'Añadir la lista negra de MAC y luego restart-pulse-services allsubs (FAQ: MAC duplicadas)', 'MAC-Sperrliste ergänzen, dann restart-pulse-services allsubs (FAQ: doppelte MAC-Adressen)'),
    "Ajouter le compte au groupe Administrateurs local du poste (PowerShell : Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) et verifier la GPO ou LAPS qui le retire ; s'il est deja membre (UAC) : LocalAccountTokenFilterPolicy = 1": ("Add the account to the computer's local Administrators group (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) and check the GPO or LAPS that removes it; if already a member (UAC): LocalAccountTokenFilterPolicy = 1", 'Añadir la cuenta al grupo Administradores local del equipo (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) y verificar la GPO o LAPS que la quita; si ya es miembro (UAC): LocalAccountTokenFilterPolicy = 1', 'Konto zur lokalen Gruppe Administratoren hinzufügen (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) und GPO oder LAPS prüfen, die es entfernt; falls bereits Mitglied (UAC): LocalAccountTokenFilterPolicy = 1'),
    'Alignement de agentxmpp.py sur le serveur par le script': ('agentxmpp.py aligned with the server by the script', 'Alineación de agentxmpp.py con el servidor por el script', 'Angleichung von agentxmpp.py an den Server durch das Skript'),
    "Allumer le poste ou retrouver sa nouvelle IP (DHCP), puis relancer l'audit": ('Turn the computer on or find its new IP (DHCP), then run the audit again', 'Encender el equipo o encontrar su nueva IP (DHCP), y volver a lanzar la auditoría', 'Rechner einschalten oder seine neue IP finden (DHCP), dann Audit erneut starten'),
    "Allumer le poste ou verifier son reseau, puis relancer l'audit": ('Turn the computer on or check its network, then run the audit again', 'Encender el equipo o verificar su red, y volver a lanzar la auditoría', 'Rechner einschalten oder Netzwerk prüfen, dann Audit erneut starten'),
    'Analyse manuelle': ('Manual analysis', 'Análisis manual', 'Manuelle Analyse'),
    'Analyser xmpp-agent-machine.log ; reset en dernier recours': ('Analyse xmpp-agent-machine.log; reset as a last resort', 'Analizar xmpp-agent-machine.log; reinicialización como último recurso', 'xmpp-agent-machine.log analysieren; Reset als letztes Mittel'),
    'Analyser xmpp-agent-machine.log de ces postes (FAQ : Pourquoi une machine apparait-elle hors ligne ? point 7)': ('Analyse xmpp-agent-machine.log on these computers (FAQ: Why does a machine appear offline? item 7)', 'Analizar xmpp-agent-machine.log de estos equipos (FAQ: ¿Por qué un equipo aparece desconectado? punto 7)', 'xmpp-agent-machine.log dieser Rechner analysieren (FAQ: Warum erscheint ein Rechner offline? Punkt 7)'),
    'Apres reparation': ('After repair', 'Después de la reparación', 'Nach der Reparatur'),
    'Arret demande : aucune nouvelle correction ne sera lancee.': ('Stop requested: no new fix will be started.', 'Parada solicitada: no se iniciará ninguna corrección nueva.', 'Stopp angefordert: es wird keine neue Korrektur gestartet.'),
    "Arrete : demarre par le script. Absent ou desactive : reinstaller l'agent ou sc config medullanetnotify start= auto": ('Stopped: started by the script. Missing or disabled: reinstall the agent or sc config medullanetnotify start= auto', 'Detenido: iniciado por el script. Ausente o deshabilitado: reinstalar el agente o sc config medullanetnotify start= auto', 'Gestoppt: vom Skript gestartet. Fehlt oder deaktiviert: Agent neu installieren oder sc config medullanetnotify start= auto'),
    "Arrete : demarre par le script. Absent ou desactive : reinstaller l'agent ou sc config sshdaemon start= auto": ('Stopped: started by the script. Missing or disabled: reinstall the agent or sc config sshdaemon start= auto', 'Detenido: iniciado por el script. Ausente o deshabilitado: reinstalar el agente o sc config sshdaemon start= auto', 'Gestoppt: vom Skript gestartet. Fehlt oder deaktiviert: Agent neu installieren oder sc config sshdaemon start= auto'),
    "Attendre la reconnexion de l'agent": ('Wait for the agent to reconnect', 'Esperar la reconexión del agente', 'Auf die Wiederverbindung des Agenten warten'),
    "Attendre la reconnexion de l'agent (traitement par lots de 240 / 60 s)": ('Wait for the agent to reconnect (processed in batches of 240 / 60 s)', 'Esperar la reconexión del agente (procesado por lotes de 240 / 60 s)', 'Auf die Wiederverbindung des Agenten warten (Verarbeitung in Blöcken von 240 / 60 s)'),
    'Attente      : rattachement controle toutes les {} s pendant {} s au plus apres la demande': ('Wait         : matching checked every {} s for {} s at most after the request', 'Espera       : vinculación controlada cada {} s durante {} s como máximo tras la solicitud', 'Wartezeit    : Zuordnung alle {} s geprüft, höchstens {} s nach der Anforderung'),
    'Attente avant controle Medulla (s)': ('Wait before Medulla check (s)', 'Espera antes del control en Medulla (s)', 'Wartezeit vor Medulla-Prüfung (s)'),
    'Attente de la fin des corrections deja commencees (ne pas interrompre a nouveau)...': ('Waiting for fixes already started to finish (do not interrupt again)...', 'Esperando a que terminen las correcciones ya iniciadas (no interrumpa de nuevo)...', 'Warten auf das Ende der bereits gestarteten Korrekturen (nicht erneut unterbrechen)...'),
    'Attente de {} s pour laisser les agents se reconnecter (Ctrl+C pour passer)...': ('Waiting {} s for agents to reconnect (Ctrl+C to skip)...', 'Esperando {} s a que los agentes se reconecten (Ctrl+C para saltar)...', '{} s warten, bis sich die Agenten neu verbinden (Strg+C zum Überspringen)...'),
    "Attente du retour de l'agent (au plus {} s, Ctrl+C pour passer)...": ('Waiting for the agent to come back (at most {} s, Ctrl+C to skip)...', 'Esperando el regreso del agente (como máximo {} s, Ctrl+C para omitir)...', 'Warten auf die Rückkehr des Agenten (höchstens {} s, Strg+C zum Überspringen)...'),
    'Attente interrompue : controle immediat.': ('Wait interrupted: checking now.', 'Espera interrumpida: control inmediato.', 'Warten unterbrochen: sofortige Prüfung.'),
    'Attente interrompue.': ('Wait interrupted.', 'Espera interrumpida.', 'Warten unterbrochen.'),
    'Aucun': ('None', 'Ninguno', 'Keine'),
    'Aucun agent de souscription master_subs* connecte a ejabberd : enregistrements lents ou bloques': ('No master_subs* subscription agent connected to ejabberd: registrations slow or blocked', 'Ningún agente de suscripción master_subs* conectado a ejabberd: registros lentos o bloqueados', 'Kein master_subs*-Abonnementagent mit ejabberd verbunden: Registrierungen langsam oder blockiert'),
    'Aucun inventaire genere sur le poste (Medulla\\tmp\\inventory.txt)': ('No inventory generated on the computer (Medulla\\tmp\\inventory.txt)', 'Ningún inventario generado en el equipo (Medulla\\tmp\\inventory.txt)', 'Keine Inventur auf dem Rechner erstellt (Medulla\\tmp\\inventory.txt)'),
    'Aucun poste ne repond a cette IP sur le reseau local (ARP sans reponse) : poste eteint ou IP changee': ('No computer answers this IP on the local network (no ARP reply): computer off or IP changed', 'Ningún equipo responde a esta IP en la red local (ARP sin respuesta): equipo apagado o IP cambiada', 'Kein Rechner antwortet im lokalen Netz auf diese IP (keine ARP-Antwort): Rechner aus oder IP geändert'),
    'Aucun probleme detecte': ('No problem detected', 'Ningún problema detectado', 'Kein Problem erkannt'),
    'Aucun probleme restant': ('No problem remaining', 'Ningún problema restante', 'Kein Problem mehr'),
    'Aucun probleme sur les postes audites.': ('No problem on the audited computers.', 'Ningún problema en los equipos auditados.', 'Kein Problem auf den geprüften Rechnern.'),
    'Aucune action a mener.': ('No action needed.', 'Ninguna acción necesaria.', 'Keine Aktion nötig.'),
    'Aucune action effectuee.': ('No action taken.', 'No se ha realizado ninguna acción.', 'Keine Aktion ausgeführt.'),
    'Aucune ligne ERROR dans le journal': ('No ERROR line in the log', 'Ninguna línea ERROR en el registro', 'Keine ERROR-Zeile im Protokoll'),
    'Aucune machine concernee.': ('No machine concerned.', 'Ningún equipo afectado.', 'Kein Rechner betroffen.'),
    "Aucune machine ne permet une demande d'inventaire.": ('No machine allows an inventory request.', 'Ningún equipo permite una solicitud de inventario.', 'Kein Rechner erlaubt eine Inventuranforderung.'),
    'Aucune reponse (port 22) : poste eteint, ou pare-feu qui bloque sans repondre': ('No answer (port 22): computer off, or a firewall dropping without replying', 'Ninguna respuesta (puerto 22): equipo apagado, o cortafuegos que bloquea sin responder', 'Keine Antwort (Port 22): Rechner aus oder Firewall verwirft ohne Antwort'),
    'Aucune reponse reseau': ('No network answer', 'Ninguna respuesta de red', 'Keine Netzwerkantwort'),
    'Aucune route du serveur vers ce reseau': ('No route from the server to this network', 'Ninguna ruta del servidor hacia esta red', 'Keine Route vom Server zu diesem Netzwerk'),
    'Aucune trace cote serveur': ('No server-side trace', 'Ninguna traza del lado del servidor', 'Keine serverseitige Spur'),
    'Aucune trace cote serveur : jamais connecte, ou journaux trop anciens': ('No server-side trace: never connected, or logs too old', 'Ninguna traza del lado del servidor: nunca conectado, o registros demasiado antiguos', 'Keine serverseitige Spur: nie verbunden oder Protokolle zu alt'),
    'Audit': ('Audit', 'Audit', 'Audit'),
    'Audit + actions': ('Audit + actions', 'Auditoría + acciones', 'Audit + Aktionen'),
    "Audit de l'agent realise": ('Agent audit done', 'Auditoría del agente realizada', 'Agent-Audit durchgeführt'),
    'Audit seul': ('Audit only', 'Solo auditoría', 'Nur Audit'),
    'Audit seul : aucune reparation, pas de mesure apres.': ('Audit only: no repair, no measurement after.', 'Solo auditoría: ninguna reparación, sin medición posterior.', 'Nur Audit: keine Reparatur, keine Messung danach.'),
    'Audit seul : relancer avec --full pour les traiter aussi.': ('Audit only: run again with --full to process them too.', 'Solo auditoría: volver a lanzar con --full para tratarlas también.', 'Nur Audit: mit --full erneut starten, um sie ebenfalls zu bearbeiten.'),
    'Authentification XMPP refusee': ('XMPP authentication refused', 'Autenticación XMPP rechazada', 'XMPP-Authentifizierung abgelehnt'),
    "Authentification XMPP refusee pour l'agent (journal ejabberd)": ('XMPP authentication refused for the agent (ejabberd log)', 'Autenticación XMPP rechazada para el agente (registro ejabberd)', 'XMPP-Authentifizierung für den Agenten abgelehnt (ejabberd-Protokoll)'),
    'Autoriser le port 22 entrant (sshdaemon) dans le pare-feu du poste': ("Allow inbound port 22 (sshdaemon) in the computer's firewall", 'Permitir el puerto 22 entrante (sshdaemon) en el cortafuegos del equipo', 'Eingehenden Port 22 (sshdaemon) in der Firewall des Rechners erlauben'),
    "Autre agent d'inventaire": ('Other inventory agent', 'Otro agente de inventario', 'Anderer Inventuragent'),
    'Avant / apres reparation (machines reelles joignables)': ('Before / after repair (reachable real machines)', 'Antes / después de la reparación (máquinas reales accesibles)', 'Vor / nach der Reparatur (erreichbare reale Rechner)'),
    'Avant reparation': ('Before repair', 'Antes de la reparación', 'Vor der Reparatur'),
    'Base Medulla': ('Medulla database', 'Base de Medulla', 'Medulla-Datenbank'),
    "Bilan de l'audit": ('Audit summary', 'Balance de la auditoría', 'Audit-Bilanz'),
    "Bilan de l'audit ({:.0f} s)": ('Audit summary ({:.0f} s)', 'Balance de la auditoría ({:.0f} s)', 'Audit-Bilanz ({:.0f} s)'),
    'Blacklister ces MAC (registeryagent.ini.local), restart-pulse-services allsubs, puis Reconfigure machine agent': ('Blacklist these MACs (registeryagent.ini.local), restart-pulse-services allsubs, then Reconfigure machine agent', 'Poner estas MAC en lista negra (registeryagent.ini.local), restart-pulse-services allsubs, luego Reconfigure machine agent', 'Diese MACs sperren (registeryagent.ini.local), restart-pulse-services allsubs, dann Reconfigure machine agent'),
    'Blacklister la MAC (registeryagent.ini.local), restart-pulse-services allsubs, Reconfigure machine agent': ('Blacklist the MAC (registeryagent.ini.local), restart-pulse-services allsubs, Reconfigure machine agent', 'Poner la MAC en lista negra (registeryagent.ini.local), restart-pulse-services allsubs, Reconfigure machine agent', 'MAC sperren (registeryagent.ini.local), restart-pulse-services allsubs, Reconfigure machine agent'),
    'CONNEXION:{}': ('CONNEXION:{}', 'CONNEXION:{}', 'CONNEXION:{}'),
    'CORRECTION : {} poste(s)': ('FIX: {} computer(s)', 'CORRECCIÓN: {} equipo(s)', 'KORREKTUR: {} Rechner'),
    'CORRIGE': ('CORRIGE', 'CORRIGE', 'CORRIGE'),
    'CRITIQUE': ('CRITICAL', 'CRÍTICO', 'KRITISCH'),
    'Carte connue': ('Known adapter', 'Tarjeta conocida', 'Bekannte Karte'),
    'Carte {} ({}) presente, MAC non blacklistee': ('{} adapter ({}) present, MAC not blacklisted', 'Tarjeta {} ({}) presente, MAC sin lista negra', 'Adapter {} ({}) vorhanden, MAC nicht gesperrt'),
    'Cause probable': ('Likely cause', 'Causa probable', 'Wahrscheinliche Ursache'),
    'Causes possibles : injection GLPI en echec, adresse MAC ou UUID materiel en double, agent': ('Possible causes: GLPI injection failed, duplicate MAC address or hardware UUID, inventory', 'Causas posibles: inyección GLPI fallida, dirección MAC o UUID de hardware duplicado, agente', 'Mögliche Ursachen: GLPI-Injektion fehlgeschlagen, doppelte MAC-Adresse oder Hardware-UUID, Inventur-'),
    'Ces machines sont ON dans Medulla et leur agent repond : par defaut, elles ne sont pas touchees.': ('These machines are ON in Medulla and their agent answers: by default, they are left untouched.', 'Estas máquinas están ON en Medulla y su agente responde: por defecto, no se tocan.', 'Diese Rechner sind in Medulla ON und ihr Agent antwortet: standardmäßig werden sie nicht verändert.'),
    "Ces postes sont ON : ce script n'y touche pas. Pour les auditer et les corriger par XMPP :": ('These computers are ON: this script does not touch them. To audit and fix them by XMPP:', 'Estos equipos están ON: este script no los toca. Para auditarlos y corregirlos por XMPP:', 'Diese Rechner sind ON: dieses Skript verändert sie nicht. Um sie per XMPP zu prüfen und zu korrigieren:'),
    'Choix invalide : taper {} ou q.': ('Invalid choice: type {} or q.', 'Elección no válida: escriba {} o q.', 'Ungültige Wahl: {} oder q eingeben.'),
    'Cible {}': ('Target {}', 'Objetivo {}', 'Ziel {}'),
    'Cle SSH refusee (pulseuser)': ('SSH key refused (pulseuser)', 'Clave SSH rechazada (pulseuser)', 'SSH-Schlüssel abgelehnt (pulseuser)'),
    'Code collecte AD non reconnu': ('AD collection code not recognised', 'Código de recopilación AD no reconocido', 'AD-Erfassungscode nicht erkannt'),
    'Code collecte AD non reconnu : agentxmpp.py sera aligne sur le serveur': ('AD collection code not recognised: agentxmpp.py will be aligned with the server', 'Código de recogida AD no reconocido: agentxmpp.py se alineará con el servidor', 'AD-Erfassungscode nicht erkannt: agentxmpp.py wird an den Server angeglichen'),
    'Code de reparation integre (sha256)': ('Built-in repair code (sha256)', 'Código de reparación integrado (sha256)', 'Integrierter Reparaturcode (sha256)'),
    'Collecte AD - agent principal': ('AD collection - main agent', 'Recopilación AD - agente principal', 'AD-Erfassung - Hauptagent'),
    'Collecte AD - agentrescue': ('AD collection - agentrescue', 'Recopilación AD - agentrescue', 'AD-Erfassung - agentrescue'),
    'Collecte AD - img_agent': ('AD collection - img_agent', 'Recopilación AD - img_agent', 'AD-Erfassung - img_agent'),
    'Collecte AD non protegee': ('AD collection not protected', 'Recopilación AD no protegida', 'AD-Erfassung nicht abgesichert'),
    'Collecte AD protegee': ('AD collection protected', 'Recopilación AD protegida', 'AD-Erfassung abgesichert'),
    'Commande par XMPP': ('Command by XMPP', 'Comando por XMPP', 'Befehl per XMPP'),
    'Commandes manquantes :': ('Missing commands:', 'Comandos que faltan:', 'Fehlende Befehle:'),
    'Comparer MAC et interfaces, chercher les entrees orphelines de network (FAQ : enregistrement)': ('Compare MACs and interfaces, look for orphan network entries (FAQ: registration)', 'Comparar MAC e interfaces, buscar entradas huérfanas de network (FAQ: registro)', 'MACs und Schnittstellen vergleichen, verwaiste network-Einträge suchen (FAQ: Registrierung)'),
    'Comparer MAC et interfaces, entrees orphelines de network (FAQ : enregistrement)': ('Compare MACs and interfaces, orphan network entries (FAQ: registration)', 'Comparar MAC e interfaces, entradas huérfanas de network (FAQ: registro)', 'MACs und Schnittstellen vergleichen, verwaiste network-Einträge (FAQ: Registrierung)'),
    'Compte XMPP temporaire': ('Temporary XMPP account', 'Cuenta XMPP temporal', 'Temporäres XMPP-Konto'),
    'Compte de connexion administrateur du poste': ('Login account is an administrator on the computer', 'Cuenta de conexión administradora del equipo', 'Anmeldekonto ist Administrator auf dem Rechner'),
    'Compte de connexion sans droits administrateur': ('Login account without administrator rights', 'Cuenta de conexión sin derechos de administrador', 'Anmeldekonto ohne Administratorrechte'),
    'Compte de connexion sans droits administrateur sur le poste : aucune correction ni relance possible par SSH': ('Login account without administrator rights on the computer: no fix or restart possible by SSH', 'Cuenta de conexión sin derechos de administrador en el equipo: ninguna corrección ni reinicio posible por SSH', 'Anmeldekonto ohne Administratorrechte auf dem Rechner: keine Korrektur und kein Neustart per SSH möglich'),
    'Compte pulseuser sans droits administrateur': ('pulseuser account without administrator rights', 'Cuenta pulseuser sin derechos de administrador', 'Konto pulseuser ohne Administratorrechte'),
    'Compte {} absent du groupe Administrateurs local du poste (whoami /groups) : aucune correction ni relance par SSH': ("Account {} is not in the computer's local Administrators group (whoami /groups): no fix or restart by SSH", 'Cuenta {} ausente del grupo Administradores local del equipo (whoami /groups): ninguna corrección ni reinicio por SSH', 'Konto {} ist nicht in der lokalen Gruppe Administratoren des Rechners (whoami /groups): keine Korrektur und kein Neustart per SSH'),
    'Compte {} membre du groupe Administrateurs mais session SSH sans droits eleves (UAC : groupe en refus uniquement) : aucune correction ni relance par SSH': ('Account {} is in the Administrators group but the SSH session has no elevated rights (UAC: group set to deny only): no fix or restart by SSH', 'Cuenta {} miembro del grupo Administradores pero la sesión SSH no tiene derechos elevados (UAC: grupo solo para denegar): ninguna corrección ni reinicio por SSH', 'Konto {} ist in der Gruppe Administratoren, aber die SSH-Sitzung hat keine erhöhten Rechte (UAC: Gruppe nur zum Verweigern): keine Korrektur und kein Neustart per SSH'),
    'Compte {} sans droits administrateur sur le poste (IsUserAnAdmin) : aucune correction ni relance par SSH': ('Account {} has no administrator rights on the computer (IsUserAnAdmin): no fix or restart by SSH', 'Cuenta {} sin derechos de administrador en el equipo (IsUserAnAdmin): ninguna corrección ni reinicio por SSH', 'Konto {} hat keine Administratorrechte auf dem Rechner (IsUserAnAdmin): keine Korrektur und kein Neustart per SSH'),
    "Comptes par IP unique testee. Les tests d'une famille ne portent que sur les postes\n  arrives a cette etape (ex. : fichiers = audits complets). (info) = sans effet sur la reparation.": ('Counts per unique tested IP. Tests in a family only cover computers\n  that reached that step (e.g. files = complete audits). (info) = no effect on repair.', 'Recuentos por IP única probada. Las pruebas de una familia solo cubren los equipos\n  que llegaron a esa etapa (p. ej. archivos = auditorías completas). (info) = sin efecto en la reparación.', 'Zählung je getesteter eindeutiger IP. Die Tests einer Familie betreffen nur Rechner,\n  die diesen Schritt erreicht haben (z. B. Dateien = vollständige Audits). (info) = ohne Einfluss auf die Reparatur.'),
    'Configuration': ('Configuration', 'Configuración', 'Konfiguration'),
    'Confirmation donnee par --oui.': ('Confirmation given by --oui.', 'Confirmación dada por --oui.', 'Bestätigung durch --oui erteilt.'),
    'Connexion SSH (pulseuser)': ('SSH connection (pulseuser)', 'Conexión SSH (pulseuser)', 'SSH-Verbindung (pulseuser)'),
    "Connexion SSH : pulseuser, par cle. Cles d'hote SSH non verifiees.": ('SSH connection: pulseuser, by key. SSH host keys not verified.', 'Conexión SSH: pulseuser, por clave. Claves de host SSH no verificadas.', 'SSH-Verbindung: pulseuser, per Schlüssel. SSH-Hostschlüssel nicht geprüft.'),
    'Connexion SSH ou commande XMPP': ('SSH connection or XMPP command', 'Conexión SSH o comando XMPP', 'SSH-Verbindung oder XMPP-Befehl'),
    'Connexion XMPP : detail': ('XMPP connection: detail', 'Conexión XMPP: detalle', 'XMPP-Verbindung: Detail'),
    'Connexion au poste (SSH ou XMPP)': ('Connection to the computer (SSH or XMPP)', 'Conexión al equipo (SSH o XMPP)', 'Verbindung zum Rechner (SSH oder XMPP)'),
    'Connexion au poste impossible': ('Connection to the computer impossible', 'Conexión al equipo imposible', 'Verbindung zum Rechner unmöglich'),
    'Connexion au serveur XMPP (5222)': ('Connection to the XMPP server (5222)', 'Conexión al servidor XMPP (5222)', 'Verbindung zum XMPP-Server (5222)'),
    'Connexion poste vers serveur XMPP': ('Computer to XMPP server connection', 'Conexión del equipo al servidor XMPP', 'Verbindung Rechner zu XMPP-Server'),
    'Connexion poste vers serveur XMPP (audits complets)': ('Computer to XMPP server connection (complete audits)', 'Conexión del equipo al servidor XMPP (auditorías completas)', 'Verbindung Rechner zu XMPP-Server (vollständige Audits)'),
    'Conseil      : session SSH hors screen/tmux ; une deconnexion arreterait le script. Relancer dans': ('Advice       : SSH session outside screen/tmux; a disconnection would stop the script. Run it again in', 'Consejo      : sesión SSH fuera de screen/tmux; una desconexión detendría el script. Vuelva a ejecutarlo en', 'Hinweis      : SSH-Sitzung außerhalb von screen/tmux; ein Verbindungsabbruch würde das Skript beenden. Erneut starten in'),
    'Constat': ('Finding', 'Hallazgo', 'Befund'),
    "Constat de l'audit": ('Audit finding', 'Hallazgo de la auditoría', 'Audit-Befund'),
    "Constat seulement : ce script ne met jamais GLPI-Agent a jour. L'agent Medulla lance GLPI-Agent": ('Finding only: this script never updates GLPI-Agent. The Medulla agent runs GLPI-Agent', 'Solo constatación: este script nunca actualiza GLPI-Agent. El agente Medulla ejecuta GLPI-Agent', 'Nur Befund: dieses Skript aktualisiert GLPI-Agent nie. Der Medulla-Agent startet GLPI-Agent'),
    'Constats côté serveur': ('Server-side findings', 'Constataciones del lado del servidor', 'Serverseitige Befunde'),
    'Constats sur le serveur Medulla': ('Findings on the Medulla server', 'Constataciones en el servidor Medulla', 'Befunde auf dem Medulla-Server'),
    'Controles cote serveur (lecture seule)': ('Server-side checks (read-only)', 'Controles del lado del servidor (solo lectura)', 'Serverseitige Prüfungen (nur lesend)'),
    'Copie        :': ('Copy         :', 'Copia        :', 'Kopie        :'),
    'Copie de secours configuration': ('Configuration backup copy', 'Copia de seguridad de la configuración', 'Sicherungskopie der Konfiguration'),
    'Copie de secours de la configuration': ('Configuration backup copy', 'Copia de seguridad de la configuración', 'Sicherungskopie der Konfiguration'),
    'Copies PAExec restées dans C:\\Windows': ('PAExec copies left in C:\\Windows', 'Copias de PAExec que quedan en C:\\Windows', 'In C:\\Windows verbliebene PAExec-Kopien'),
    'Correction': ('Fix', 'Corrección', 'Korrektur'),
    'Correction par le script': ('Fixed by the script', 'Corrección por el script', 'Korrektur durch das Skript'),
    'Corrections prevues : {} poste(s)': ('Planned fixes: {} computer(s)', 'Correcciones previstas: {} equipo(s)', 'Geplante Korrekturen: {} Rechner'),
    'Corrections verifiees sans probleme restant': ('Fixes verified with no remaining problem', 'Correcciones verificadas sin problema restante', 'Geprüfte Korrekturen ohne verbleibendes Problem'),
    'Corriger le routage du serveur Medulla (ip route) ou passer par un relais': ("Fix the Medulla server's routing (ip route) or go through a relay", 'Corregir el enrutamiento del servidor Medulla (ip route) o pasar por un relé', 'Routing des Medulla-Servers korrigieren (ip route) oder über ein Relay gehen'),
    'Corriger le test dans TESTS_SERVEUR': ('Fix the test in TESTS_SERVEUR', 'Corregir la prueba en TESTS_SERVEUR', 'Test in TESTS_SERVEUR korrigieren'),
    'Corriger ou supprimer agentconf.ini.local, puis Reconfigure machine agent': ('Fix or delete agentconf.ini.local, then Reconfigure machine agent', 'Corregir o eliminar agentconf.ini.local, luego Reconfigure machine agent', 'agentconf.ini.local korrigieren oder löschen, dann Reconfigure machine agent'),
    'Corriges': ('Fixed', 'Corregidos', 'Korrigiert'),
    'Coupure reseau passagere': ('Temporary network outage', 'Corte de red pasajero', 'Vorübergehender Netzwerkausfall'),
    'Creation par le script': ('Created by the script', 'Creación por el script', 'Erstellung durch das Skript'),
    'Croisement ping / SSH': ('Ping / SSH cross-check', 'Cruce ping / SSH', 'Abgleich Ping / SSH'),
    "DEMANDE D'INVENTAIRE : {} poste(s) corrige(s) ou relance(s)": ('INVENTORY REQUEST: {} computer(s) fixed or restarted', 'SOLICITUD DE INVENTARIO: {} equipo(s) corregido(s) o reiniciado(s)', 'INVENTURANFORDERUNG: {} korrigierte oder neu gestartete Rechner'),
    "DEMANDE D'INVENTAIRE : {} poste(s) corrige(s), relance(s) ou sans inventaire recent": ('INVENTORY REQUEST: {} computer(s) fixed, restarted or without a recent inventory', 'SOLICITUD DE INVENTARIO: {} equipo(s) corregido(s), reiniciado(s) o sin inventario reciente', 'INVENTARANFORDERUNG: {} Rechner korrigiert, neu gestartet oder ohne aktuelles Inventar'),
    'DEMARRAGE DE SSH : {} poste(s)': ('SSH START: {} computer(s)', 'ARRANQUE DE SSH: {} equipo(s)', 'SSH-START: {} Rechner'),
    'DIAGNOSTIC FINAL : PROBLEMES ET ACTIONS RECOMMANDEES': ('FINAL DIAGNOSIS: PROBLEMS AND RECOMMENDED ACTIONS', 'DIAGNÓSTICO FINAL: PROBLEMAS Y ACCIONES RECOMENDADAS', 'ABSCHLIESSENDE DIAGNOSE: PROBLEME UND EMPFOHLENE AKTIONEN'),
    'Debut': ('Start', 'Inicio', 'Beginn'),
    'Debut du releve (heure serveur)': ('Start of the report (server time)', 'Inicio del informe (hora del servidor)', 'Beginn des Berichts (Serverzeit)'),
    'Declarer les exclusions EDR : paexec.exe, C:\\Program Files\\Medulla\\, C:\\Windows\\PAExec-*.exe (FAQ - EDR)': ('Declare the EDR exclusions: paexec.exe, C:\\Program Files\\Medulla\\, C:\\Windows\\PAExec-*.exe (FAQ - EDR)', 'Declarar las exclusiones EDR: paexec.exe, C:\\Program Files\\Medulla\\, C:\\Windows\\PAExec-*.exe (FAQ - EDR)', 'EDR-Ausnahmen hinterlegen: paexec.exe, C:\\Program Files\\Medulla\\, C:\\Windows\\PAExec-*.exe (FAQ - EDR)'),
    'Defaut': ('Defect', 'Defecto', 'Fehler'),
    'Delai SSH    : {} s pour se connecter ({} s au total pour le test SSH)': ('SSH timeout  : {} s to connect ({} s in total for the SSH test)', 'Espera SSH   : {} s para conectar ({} s en total para la prueba SSH)', 'SSH-Zeitlimit: {} s zum Verbinden ({} s insgesamt für den SSH-Test)'),
    'Delai audit  : {} s par poste': ('Audit timeout: {} s per computer', 'Espera audit.: {} s por equipo', 'Audit-Limit  : {} s je Rechner'),
    'Delai de connexion SSH (s)': ('SSH connection timeout (s)', 'Plazo de conexión SSH (s)', 'SSH-Verbindungszeitlimit (s)'),
    "Delai de reponse d'une commande XMPP (s)": ('XMPP command reply timeout (s)', 'Plazo de respuesta de un comando XMPP (s)', 'Antwortzeitlimit eines XMPP-Befehls (s)'),
    "Delai maximal de l'audit d'un poste (s)": ('Maximum audit time per computer (s)', 'Duración máxima de la auditoría de un equipo (s)', 'Maximale Auditdauer je Rechner (s)'),
    'Delais       : SSH {} s pour se connecter, XMPP {} s par commande, audit {} s par poste': ('Timeouts     : SSH {} s to connect, XMPP {} s per command, audit {} s per computer', 'Esperas      : SSH {} s para conectar, XMPP {} s por comando, auditoría {} s por equipo', 'Zeitlimits   : SSH {} s zum Verbinden, XMPP {} s je Befehl, Audit {} s je Rechner'),
    "Demande d'inventaire": ('Inventory request', 'Solicitud de inventario', 'Inventuranforderung'),
    "Demande d'inventaire par le script (actions, agent joignable en XMPP) ; sinon glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ : Verifier que l'inventaire remonte)": ('Inventory request by the script (actions, agent reachable by XMPP); otherwise glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Check that the inventory is uploaded)', 'Solicitud de inventario por el script (acciones, agente accesible por XMPP); si no, glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Verificar que el inventario sube)', 'Inventaranforderung durch das Skript (Aktionen, Agent per XMPP erreichbar); sonst glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Prüfen, ob das Inventar ankommt)'),
    "Demande d'inventaire possible": ('Inventory request possible', 'Solicitud de inventario posible', 'Inventuranforderung möglich'),
    'Demandes annulees : aucun inventaire demande.': ('Requests cancelled: no inventory requested.', 'Solicitudes canceladas: ningún inventario solicitado.', 'Anforderungen abgebrochen: keine Inventur angefordert.'),
    'Demandes envoyees : {}': ('Requests sent: {}', 'Solicitudes enviadas: {}', 'Gesendete Anforderungen: {}'),
    "Demandes envoyees : {}. Controle du rattachement dans Medulla (Ctrl+C pour arreter l'attente)...": ('Requests sent: {}. Checking the matching in Medulla (Ctrl+C to stop waiting)...', 'Solicitudes enviadas: {}. Control de la vinculación en Medulla (Ctrl+C para dejar de esperar)...', 'Gesendete Anforderungen: {}. Zuordnung in Medulla wird geprüft (Strg+C beendet das Warten)...'),
    'Demarrage de sshdaemon par le script (mode actions)': ('sshdaemon started by the script (actions mode)', 'Arranque de sshdaemon por el script (modo acciones)', 'sshdaemon-Start durch das Skript (Aktionsmodus)'),
    'Demarrage du service non desactive': ('Service start not disabled', 'Inicio del servicio no deshabilitado', 'Dienststart nicht deaktiviert'),
    'Demarrage du service par le script': ('Service started by the script', 'Inicio del servicio por el script', 'Dienststart durch das Skript'),
    "Demarrer sshdaemon sur le poste (ou par XMPP si l'agent revient) ; sinon reinstaller l'agent": ('Start sshdaemon on the computer (or by XMPP if the agent comes back); otherwise reinstall the agent', 'Iniciar sshdaemon en el equipo (o por XMPP si el agente vuelve); si no, reinstalar el agente', 'sshdaemon auf dem Rechner starten (oder per XMPP, wenn der Agent zurückkommt); sonst Agent neu installieren'),
    'Dernier inventaire genere il y a {} jour(s)': ('Last inventory generated {} day(s) ago', 'Último inventario generado hace {} día(s)', 'Letzte Inventur vor {} Tag(en) erstellt'),
    'Dernier inventaire généré sur le poste': ('Last inventory generated on the computer', 'Último inventario generado en el equipo', 'Letzte auf dem Rechner erstellte Inventur'),
    'Dernier inventaire sur le poste': ('Last inventory on the computer', 'Último inventario en el equipo', 'Letzte Inventur auf dem Rechner'),
    'Derniere presence': ('Last seen', 'Última presencia', 'Zuletzt gesehen'),
    'Dernieres erreurs du journal (secrets masques)': ('Last log errors (secrets masked)', 'Últimos errores del registro (secretos ocultos)', 'Letzte Protokollfehler (Geheimnisse maskiert)'),
    "Derniers messages d'inventaire dans Medulla": ('Last inventory messages in Medulla', 'Últimos mensajes de inventario en Medulla', 'Letzte Inventurmeldungen in Medulla'),
    'Dernière présence dans Medulla (uptime_machine)': ('Last seen in Medulla (uptime_machine)', 'Última presencia en Medulla (uptime_machine)', 'Zuletzt in Medulla gesehen (uptime_machine)'),
    'Detail': ('Detail', 'Detalle', 'Detail'),
    'Detail : {}': ('Details: {}', 'Detalle: {}', 'Details: {}'),
    'Detail poste par poste : plan_de_remediation.csv (une ligne par probleme).': ('Computer-by-computer detail: plan_de_remediation.csv (one line per problem).', 'Detalle equipo por equipo: plan_de_remediation.csv (una línea por problema).', 'Detail je Rechner: plan_de_remediation.csv (eine Zeile pro Problem).'),
    'Diagnostic (parmi SSH OK)': ('Diagnostic (among SSH OK)', 'Diagnóstico (entre SSH OK)', 'Diagnose (unter SSH OK)'),
    "Diagnostic de l'agent": ('Agent diagnostic', 'Diagnóstico del agente', 'Agent-Diagnose'),
    'Disque C: < {:g} Go libre': ('Disk C: < {:g} GB free', 'Disco C: < {:g} GB libres', 'Laufwerk C: < {:g} GB frei'),
    'Disque C: >= {:g} Go libre': ('Disk C: >= {:g} GB free', 'Disco C: >= {:g} GB libres', 'Laufwerk C: >= {:g} GB frei'),
    'Disque C: plein': ('Disk C: full', 'Disco C: lleno', 'Laufwerk C: voll'),
    'Dossier      : {}': ('Folder       : {}', 'Carpeta      : {}', 'Ordner       : {}'),
    'Dossier Medulla\\var\\log': ('Medulla\\var\\log folder', 'Carpeta Medulla\\var\\log', 'Ordner Medulla\\var\\log'),
    'Dossier Medulla\\var\\log (audits complets)': ('Medulla\\var\\log folder (complete audits)', 'Carpeta Medulla\\var\\log (auditorías completas)', 'Ordner Medulla\\var\\log (vollständige Audits)'),
    'Dossier Medulla\\var\\log absent': ('Medulla\\var\\log folder missing', 'Falta la carpeta Medulla\\var\\log', 'Ordner Medulla\\var\\log fehlt'),
    'Dossier Medulla\\var\\log present': ('Medulla\\var\\log folder present', 'Carpeta Medulla\\var\\log presente', 'Ordner Medulla\\var\\log vorhanden'),
    'Dossier des journaux absent': ('Log folder missing', 'Carpeta de registros ausente', 'Protokollordner fehlt'),
    'Dossier des journaux anormal': ('Abnormal log folder', 'Carpeta de registros anómala', 'Ungewöhnlicher Protokollordner'),
    'Droits root requis pour installer : relancer avec sudo, ou executer : apt-get install': ('Root rights required to install: run again with sudo, or run: apt-get install', 'Se requieren derechos de root para instalar: vuelva a ejecutar con sudo, o ejecute: apt-get install', 'Root-Rechte zur Installation erforderlich: mit sudo erneut starten oder ausführen: apt-get install'),
    'Duree des corrections (s)': ('Fix duration (s)', 'Duración de las correcciones (s)', 'Dauer der Korrekturen (s)'),
    'Duree des relances (s)': ('Restart duration (s)', 'Duración de los reinicios (s)', 'Dauer der Neustarts (s)'),
    'Duree des tests (s)': ('Test duration (s)', 'Duración de las pruebas (s)', 'Dauer der Tests (s)'),
    'Démarrage de SSH par le script': ('SSH started by the script', 'Arranque de SSH por el script', 'SSH-Start durch das Skript'),
    'Démarrage de medullanetnotify': ('medullanetnotify start type', 'Inicio de medullanetnotify', 'Starttyp von medullanetnotify'),
    'Démarrage de sshdaemon': ('sshdaemon start type', 'Inicio de sshdaemon', 'Starttyp von sshdaemon'),
    "Détail de l'erreur de diagnostic": ('Diagnostic error detail', 'Detalle del error de diagnóstico', 'Detail des Diagnosefehlers'),
    'Détail de la correction': ('Fix detail', 'Detalle de la corrección', 'Detail der Korrektur'),
    'Détail de la reconfiguration': ('Reconfiguration details', 'Detalle de la reconfiguración', 'Details der Neukonfiguration'),
    'Détail de la relance': ('Restart detail', 'Detalle del reinicio', 'Detail des Neustarts'),
    'Détail du reset': ('Reset detail', 'Detalle de la reinicialización', 'Detail des Resets'),
    'ECHEC_DNS': ('ECHEC_DNS', 'ECHEC_DNS', 'ECHEC_DNS'),
    "ETAPE 1 - AUDIT DE L'INVENTAIRE (lecture seule) : {} agent(s)": ('STEP 1 - INVENTORY AUDIT (read-only): {} agent(s)', 'ETAPA 1 - AUDITORÍA DEL INVENTARIO (solo lectura): {} agente(s)', 'SCHRITT 1 - INVENTUR-AUDIT (nur lesend): {} Agent(en)'),
    "ETAPE 2 - DEMANDE D'INVENTAIRE : {} machine(s)": ('STEP 2 - INVENTORY REQUEST: {} machine(s)', 'ETAPA 2 - SOLICITUD DE INVENTARIO: {} equipo(s)', 'SCHRITT 2 - INVENTURANFORDERUNG: {} Rechner'),
    'ETAPE {} - {}': ('STEP {} - {}', 'ETAPA {} - {}', 'SCHRITT {} - {}'),
    "EXCLU_MACHINE_FONCTIONNELLE (--full pour l'inclure)": ('EXCLU_MACHINE_FONCTIONNELLE (--full to include it)', 'EXCLU_MACHINE_FONCTIONNELLE (--full para incluirla)', 'EXCLU_MACHINE_FONCTIONNELLE (--full, um ihn einzubeziehen)'),
    'Ecartees : deja ON dans Medulla sous ce nom': ('Excluded: already ON in Medulla under this name', 'Descartados: ya ON en Medulla con este nombre', 'Ausgeschlossen: in Medulla unter diesem Namen bereits ON'),
    'Ecartees : le poste repond un autre nom que celui demande': ('Excluded: the computer answers a name other than the requested one', 'Descartados: el equipo responde otro nombre que el solicitado', 'Ausgeschlossen: der Rechner antwortet mit einem anderen als dem angefragten Namen'),
    "Ecartees avant test : IP deja celle d'une machine ON": ('Excluded before test: IP already used by an ON machine', 'Descartados antes de la prueba: IP ya usada por un equipo ON', 'Vor dem Test ausgeschlossen: IP gehört bereits einem ON-Rechner'),
    'Ecartes - {}': ('Excluded - {}', 'Descartados - {}', 'Ausgeschlossen - {}'),
    "Echec de l'installation, mise a jour de l'index apt puis nouvel essai...": ('Installation failed, updating the apt index and retrying...', 'Fallo de la instalación, actualizando el índice apt y reintentando...', 'Installation fehlgeschlagen, apt-Index wird aktualisiert und erneut versucht...'),
    'Effet': ('Effect', 'Efecto', 'Wirkung'),
    'Effet de bord : sur les postes ou la collecte AD est corrigee, les groupes AD de': ('Side effect: on computers where the AD collection is fixed, the AD groups of', 'Efecto secundario: en los equipos donde se corrige la recopilación AD, los grupos AD del', 'Nebenwirkung: auf Rechnern mit korrigierter AD-Erfassung werden die AD-Gruppen des'),
    'Elles ne seront pas touchees.': ('They will be left untouched.', 'No se tocarán.', 'Sie werden nicht verändert.'),
    'Enquete cote serveur : {} poste(s) injoignable(s) en SSH et en XMPP': ('Server-side investigation: {} computer(s) unreachable by SSH and XMPP', 'Investigación del lado del servidor: {} equipo(s) inaccesible(s) por SSH y XMPP', 'Serverseitige Untersuchung: {} Rechner weder per SSH noch per XMPP erreichbar'),
    'Enregistrement incomplet': ('Incomplete registration', 'Registro incompleto', 'Unvollständige Registrierung'),
    'Enregistrement incomplet (journal master-reg)': ('Incomplete registration (master-reg log)', 'Registro incompleto (registro master-reg)', 'Unvollständige Registrierung (Protokoll master-reg)'),
    'Enregistrement incomplet ({} dans master-reg.log : interface ou MAC)': ('Incomplete registration ({} in master-reg.log: interface or MAC)', 'Registro incompleto ({} en master-reg.log: interfaz o MAC)', 'Unvollständige Registrierung ({} in master-reg.log: Schnittstelle oder MAC)'),
    'Envoi par XMPP (inventory.ini)': ('Sent by XMPP (inventory.ini)', 'Envío por XMPP (inventory.ini)', 'Versand per XMPP (inventory.ini)'),
    'Erreur : {}': ('Error: {}', 'Error: {}', 'Fehler: {}'),
    'Erreur MySQL : {}': ('MySQL error: {}', 'Error MySQL: {}', 'MySQL-Fehler: {}'),
    'Erreur SSH': ('SSH error', 'Error SSH', 'SSH-Fehler'),
    'Erreur XMPP (repli SSH)': ('XMPP error (SSH fallback)', 'Error XMPP (respaldo SSH)', 'XMPP-Fehler (Ausweichweg SSH)'),
    'Erreur de connexion': ('Connection error', 'Error de conexión', 'Verbindungsfehler'),
    'Erreur reseau': ('Network error', 'Error de red', 'Netzwerkfehler'),
    'Erreur reseau {}': ('Network error {}', 'Error de red {}', 'Netzwerkfehler {}'),
    "Erreurs d'inventaire dans le journal de l'agent": ('Inventory errors in the agent log', 'Errores de inventario en el registro del agente', 'Inventurfehler im Agent-Protokoll'),
    'Espace libre sur C: (Go)': ('Free space on C: (GB)', 'Espacio libre en C: (GB)', 'Freier Speicher auf C: (GB)'),
    'Etat': ('State', 'Estado', 'Zustand'),
    'Etat apres reparation non mesure (lecture de Medulla impossible ou interruption).': ('State after repair not measured (Medulla unreadable or interruption).', 'Estado después de la reparación no medido (Medulla ilegible o interrupción).', 'Zustand nach der Reparatur nicht gemessen (Medulla nicht lesbar oder Unterbrechung).'),
    'Etat dans Medulla (postes joints)': ('State in Medulla (computers reached)', 'Estado en Medulla (equipos alcanzados)', 'Status in Medulla (erreichte Rechner)'),
    'Etat dans Medulla apres actions': ('State in Medulla after actions', 'Estado en Medulla tras las acciones', 'Status in Medulla nach den Aktionen'),
    'Etat du service non determine': ('Service state could not be determined', 'Estado del servicio no determinado', 'Dienststatus nicht ermittelt'),
    'Eteint ou deconnecte depuis le {}': ('Off or disconnected since {}', 'Apagado o desconectado desde el {}', 'Aus oder getrennt seit {}'),
    "Execution non interactive : ajouter --installer pour autoriser l'installation, ou executer : apt-get install": ('Non-interactive run: add --installer to allow installation, or run: apt-get install', 'Ejecución no interactiva: añada --installer para permitir la instalación, o ejecute: apt-get install', 'Nicht interaktiver Lauf: --installer hinzufügen, um die Installation zu erlauben, oder ausführen: apt-get install'),
    'Execution non interactive sans --oui : actions NON lancees.': ('Non-interactive run without --oui: actions NOT started.', 'Ejecución no interactiva sin --oui: acciones NO lanzadas.', 'Nicht interaktiver Lauf ohne --oui: Aktionen NICHT gestartet.'),
    'FICHES OFF OBSOLETES : {} (poste ON dans Medulla, audite par XMPP)': ('OBSOLETE OFF RECORDS: {} (computer ON in Medulla, audited by XMPP)', 'FICHAS OFF OBSOLETAS: {} (equipo ON en Medulla, auditado por XMPP)', 'VERALTETE OFF-EINTRÄGE: {} (Rechner in Medulla ON, per XMPP geprüft)'),
    'FICHES OFF OBSOLETES : {} (poste ON dans Medulla, rien a reparer)': ('OBSOLETE OFF RECORDS: {} (computer ON in Medulla, nothing to repair)', 'FICHAS OFF OBSOLETAS: {} (equipo ON en Medulla, nada que reparar)', 'VERALTETE OFF-EINTRÄGE: {} (Rechner in Medulla ON, nichts zu reparieren)'),
    'Famille': ('Family', 'Familia', 'Familie'),
    'Famille de problème': ('Problem family', 'Familia de problema', 'Problemfamilie'),
    "Fiche OFF a jour (le poste n'est pas deja ON)": ('OFF record up to date (computer not already ON)', 'Ficha OFF al día (el equipo no está ya ON)', 'OFF-Eintrag aktuell (Rechner nicht bereits ON)'),
    'Fiche OFF obsolète : cause probable': ('Obsolete OFF record: likely cause', 'Ficha OFF obsoleta: causa probable', 'Veralteter OFF-Eintrag: wahrscheinliche Ursache'),
    'Fiche ON correspondante (nom @ IP)': ('Matching ON record (name @ IP)', 'Ficha ON correspondiente (nombre @ IP)', 'Zugehöriger ON-Eintrag (Name @ IP)'),
    'Fiche(s) OFF': ('OFF record(s)', 'Ficha(s) OFF', 'OFF-Eintrag/Einträge'),
    "Fiches OFF dont l'IP est celle d'une machine ON (non testees en SSH)": ('OFF records whose IP belongs to an ON machine (not tested by SSH)', 'Fichas OFF cuya IP es la de un equipo ON (no probadas por SSH)', 'OFF-Einträge mit der IP eines ON-Rechners (nicht per SSH getestet)'),
    'Fiches OFF obsoletes : poste ON (audite par XMPP)': ('Obsolete OFF records: computer ON (audited by XMPP)', 'Fichas OFF obsoletas: equipo ON (auditado por XMPP)', 'Veraltete OFF-Einträge: Rechner ON (per XMPP geprüft)'),
    'Fichier agentconf.ini': ('agentconf.ini file', 'Archivo agentconf.ini', 'Datei agentconf.ini'),
    'Fichier agentconf.ini.local': ('agentconf.ini.local file', 'Archivo agentconf.ini.local', 'Datei agentconf.ini.local'),
    'Fichier agentxmpp.py': ('agentxmpp.py file', 'Archivo agentxmpp.py', 'Datei agentxmpp.py'),
    'Fichier kiosk.pid': ('kiosk.pid file', 'Archivo kiosk.pid', 'Datei kiosk.pid'),
    'Fichiers': ('Files', 'Archivos', 'Dateien'),
    "Fichiers de l'agent": ('Agent files', 'Archivos del agente', 'Agent-Dateien'),
    "Fichiers de l'agent (audits complets)": ('Agent files (complete audits)', 'Archivos del agente (auditorías completas)', 'Agent-Dateien (vollständige Audits)'),
    "Fichiers de l'agent identiques au serveur": ('Agent files identical to the server', 'Archivos del agente idénticos al servidor', 'Agent-Dateien identisch mit dem Server'),
    "Fichiers de l'agent manquants les plus frequents (postes concernes)": ('Most frequent missing agent files (computers affected)', 'Archivos del agente ausentes más frecuentes (equipos afectados)', 'Häufigste fehlende Agent-Dateien (betroffene Rechner)'),
    "Fichiers de l'agent manquants ou vides": ('Missing or empty agent files', 'Archivos del agente ausentes o vacíos', 'Fehlende oder leere Agent-Dateien'),
    'Flux': ('Traffic', 'Flujos', 'Verbindungen'),
    'Flux 5222 poste vers serveur/relais : DNS, pare-feu (FAQ : Test de flux, Firewall)': ('Port 5222 traffic computer to server/relay: DNS, firewall (FAQ: Traffic test, Firewall)', 'Flujo 5222 equipo hacia servidor/relé: DNS, cortafuegos (FAQ: Prueba de flujos, Firewall)', 'Verkehr 5222 Rechner zu Server/Relay: DNS, Firewall (FAQ: Verbindungstest, Firewall)'),
    'Flux TCP poste vers relais : ports joignables': ('TCP traffic computer to relay: reachable ports', 'Flujos TCP equipo hacia relé: puertos accesibles', 'TCP-Verkehr Rechner zum Relay: erreichbare Ports'),
    'Flux TCP poste vers serveur Medulla : ports joignables': ('TCP traffic computer to Medulla server: reachable ports', 'Flujos TCP equipo hacia servidor Medulla: puertos accesibles', 'TCP-Verkehr Rechner zum Medulla-Server: erreichbare Ports'),
    'Flux TCP vers relais': ('TCP traffic to the relay', 'Flujos TCP hacia el relé', 'TCP-Verkehr zum Relay'),
    'Flux TCP vers serveur Medulla': ('TCP traffic to the Medulla server', 'Flujos TCP hacia el servidor Medulla', 'TCP-Verkehr zum Medulla-Server'),
    'GLPI-AGENT A METTRE A JOUR (version < 1.8)': ('GLPI-AGENT TO UPDATE (version < 1.8)', 'GLPI-AGENT POR ACTUALIZAR (versión < 1.8)', 'GLPI-AGENT ZU AKTUALISIEREN (Version < 1.8)'),
    "GLPI-AGENT SANS L'OPTION --full (version < {}) : {} poste(s)": ('GLPI-AGENT WITHOUT THE --full OPTION (version < {}): {} computer(s)', 'GLPI-AGENT SIN LA OPCIÓN --full (versión < {}): {} equipo(s)', 'GLPI-AGENT OHNE DIE OPTION --full (Version < {}): {} Rechner'),
    'GLPI-Agent >= 1.8 (option --full)': ('GLPI-Agent >= 1.8 (option --full)', 'GLPI-Agent >= 1.8 (opción --full)', 'GLPI-Agent >= 1.8 (Option --full)'),
    'GLPI-Agent anterieur a 1.8 (a mettre a jour)': ('GLPI-Agent older than 1.8 (to update)', 'GLPI-Agent anterior a 1.8 (por actualizar)', 'GLPI-Agent älter als 1.8 (zu aktualisieren)'),
    'GLPI-Agent present et >= {}': ('GLPI-Agent present and >= {}', 'GLPI-Agent presente y >= {}', 'GLPI-Agent vorhanden und >= {}'),
    "GLPI-Agent sans l'option --full (< 1.8)": ('GLPI-Agent without the --full option (< 1.8)', 'GLPI-Agent sin la opción --full (< 1.8)', 'GLPI-Agent ohne Option --full (< 1.8)'),
    'GLPI-Agent {} trop ancien (< 1.8 : option --full refusee, aucun inventaire produit) : mettre GLPI-Agent a jour': ('GLPI-Agent {} too old (< 1.8: option --full refused, no inventory produced): update GLPI-Agent', 'GLPI-Agent {} demasiado antiguo (< 1.8: opción --full rechazada, ningún inventario generado): actualizar GLPI-Agent', 'GLPI-Agent {} zu alt (< 1.8: Option --full abgelehnt, keine Inventur erstellt): GLPI-Agent aktualisieren'),
    'GLPI-Agent {} trop ancien (< {} : option --full refusee, aucun inventaire produit)': ('GLPI-Agent {} too old (< {}: option --full refused, no inventory produced)', 'GLPI-Agent {} demasiado antiguo (< {}: opción --full rechazada, ningún inventario generado)', 'GLPI-Agent {} zu alt (< {}: Option --full abgelehnt, keine Inventur erstellt)'),
    'Hostname deja ON (parmi SSH OK)': ('Hostname already ON (among SSH OK)', 'Nombre ya ON (entre SSH OK)', 'Hostname bereits ON (unter SSH OK)'),
    'INFO': ('INFO', 'INFO', 'INFO'),
    'INVENTAIRE_ABSENT_OU_ANCIEN': ('INVENTAIRE_ABSENT_OU_ANCIEN', 'INVENTAIRE_ABSENT_OU_ANCIEN', 'INVENTAIRE_ABSENT_OU_ANCIEN'),
    'IP (ou session XMPP)': ('IP (or XMPP session)', 'IP (o sesión XMPP)', 'IP (oder XMPP-Sitzung)'),
    'IP (session XMPP)': ('IP (XMPP session)', 'IP (sesión XMPP)', 'IP (XMPP-Sitzung)'),
    'IP a traiter (plusieurs : separer par des virgules, Entree = retour au menu) :': ('IPs to process (several: comma-separated, Enter = back to menu):', 'IP a tratar (varias: separadas por comas, Intro = volver al menú):', 'Zu verarbeitende IPs (mehrere: durch Kommas getrennt, Eingabe = zurück zum Menü):'),
    'IP ciblee {}': ('Targeted IP {}', 'IP solicitada {}', 'Ziel-IP {}'),
    'IP deja utilisees par une machine ON (non testees en SSH)': ('IPs already used by an ON machine (not tested by SSH)', 'IP ya usadas por un equipo ON (no probadas por SSH)', 'IPs bereits von einem ON-Rechner genutzt (nicht per SSH getestet)'),
    'IP deja utilisees par une machine ON (non testees)': ('IPs already used by an ON machine (not tested)', 'IP ya usadas por un equipo ON (no probadas)', 'IPs bereits von einem ON-Rechner genutzt (nicht getestet)'),
    'IP injoignable signalee par le routeur': ('IP reported unreachable by the router', 'IP inaccesible señalada por el router', 'IP vom Router als nicht erreichbar gemeldet'),
    'IP invalide : {}': ('Invalid IP: {}', 'IP no válida: {}', 'Ungültige IP: {}'),
    'IP perimee dans Medulla (le DNS donne une autre IP)': ('Out-of-date IP in Medulla (DNS gives another IP)', 'IP obsoleta en Medulla (el DNS da otra IP)', 'Veraltete IP in Medulla (DNS liefert eine andere IP)'),
    'IP uniques a tester': ('Unique IPs to test', 'IP únicas que probar', 'Zu testende eindeutige IPs'),
    'IP uniques testees': ('Unique IPs tested', 'IP únicas probadas', 'Getestete eindeutige IPs'),
    'Id Medulla': ('Medulla id', 'Id Medulla', 'Medulla-ID'),
    'Installation impossible : executer manuellement apt-get install': ('Installation impossible: run manually apt-get install', 'Instalación imposible: ejecute manualmente apt-get install', 'Installation unmöglich: manuell ausführen apt-get install'),
    'Installation refusee ; aucun test lance.': ('Installation refused; no test run.', 'Instalación rechazada; no se ejecuta ninguna prueba.', 'Installation abgelehnt; kein Test gestartet.'),
    'Installation terminee.': ('Installation complete.', 'Instalación terminada.', 'Installation abgeschlossen.'),
    'Installer GLPI-Agent (downloads/win/downloads/GLPI-Agent-*.msi)': ('Install GLPI-Agent (downloads/win/downloads/GLPI-Agent-*.msi)', 'Instalar GLPI-Agent (downloads/win/downloads/GLPI-Agent-*.msi)', 'GLPI-Agent installieren (downloads/win/downloads/GLPI-Agent-*.msi)'),
    'Installer maintenant ? [o/N]': ('Install now? [y/N]', '¿Instalar ahora? [s/N]', 'Jetzt installieren? [j/N]'),
    'Interrompu : les machines restantes ne sont pas reconfigurees.': ('Interrupted: the remaining machines are not reconfigured.', 'Interrumpido: las máquinas restantes no se reconfiguran.', 'Unterbrochen: die übrigen Rechner werden nicht neu konfiguriert.'),
    'Interrompu : les resets restants ne sont pas demandes.': ('Interrupted: the remaining resets are not requested.', 'Interrumpido: las reinicializaciones restantes no se solicitan.', 'Unterbrochen: die übrigen Resets werden nicht angefordert.'),
    'Interrompu : reconfiguration non suivie.': ('Interrupted: reconfiguration not followed up.', 'Interrumpido: reconfiguración sin seguimiento.', 'Unterbrochen: Neukonfiguration nicht weiterverfolgt.'),
    'Interrompu avant toute action : aucune modification, aucun releve enregistre.': ('Interrupted before any action: no change, no report saved.', 'Interrumpido antes de cualquier acción: ninguna modificación, ningún informe guardado.', 'Vor jeder Aktion unterbrochen: keine Änderung, kein Bericht gespeichert.'),
    'Inventaire': ('Inventory', 'Inventario', 'Inventur'),
    'Inventaire genere recemment sur le poste': ('Inventory recently generated on the computer', 'Inventario generado recientemente en el equipo', 'Inventur kürzlich auf dem Rechner erstellt'),
    'Inventaire non genere sur le poste': ('Inventory not generated on the computer', 'Inventario no generado en el equipo', 'Inventur auf dem Rechner nicht erstellt'),
    'Journal': ('Log', 'Registro', 'Protokoll'),
    "Journal de l'agent": ('Agent log', 'Registro del agente', 'Agent-Protokoll'),
    "Journal de l'agent en DEBUG": ('Agent log in DEBUG', 'Registro del agente en DEBUG', 'Agent-Protokoll auf DEBUG'),
    "Journal de l'agent en DEBUG (log_level)": ('Agent log in DEBUG (log_level)', 'Registro del agente en DEBUG (log_level)', 'Agent-Protokoll auf DEBUG (log_level)'),
    "Journal de l'agent hors DEBUG (log_level)": ('Agent log not in DEBUG (log_level)', 'Registro del agente fuera de DEBUG (log_level)', 'Agent-Protokoll nicht auf DEBUG (log_level)'),
    'Juste avant : le poste doit etre encore OFF, sans session XMPP, et identifie par une seule fiche.': ('Just before: the computer must still be OFF, with no XMPP session, and identified by a single record.', 'Justo antes: el equipo debe seguir OFF, sin sesión XMPP e identificado por una sola ficha.', 'Direkt davor: der Rechner muss weiterhin OFF, ohne XMPP-Sitzung und durch einen einzigen Eintrag identifiziert sein.'),
    'Juste avant : session XMPP active, une seule fiche ON, master_reconf connecte.': ('Just before: active XMPP session, a single ON record, master_reconf connected.', 'Justo antes: sesión XMPP activa, una sola ficha ON, master_reconf conectado.', 'Unmittelbar davor: aktive XMPP-Sitzung, ein einziger ON-Eintrag, master_reconf verbunden.'),
    'Juste avant chaque action, le poste est reverifie : meme nom (SSH ou XMPP) ; en SSH, toujours': ('Just before each action, the computer is checked again: same name (SSH or XMPP); by SSH, still', 'Justo antes de cada acción, el equipo se vuelve a comprobar: mismo nombre (SSH o XMPP); por SSH, todavía', 'Direkt vor jeder Aktion wird der Rechner erneut geprüft: gleicher Name (SSH oder XMPP); per SSH weiterhin'),
    'Juste avant chaque action, le poste est reverifie : meme nom en SSH et toujours OFF dans': ('Just before each action, the computer is checked again: same SSH name and still OFF in', 'Justo antes de cada acción, el equipo se vuelve a comprobar: mismo nombre en SSH y todavía OFF en', 'Direkt vor jeder Aktion wird der Rechner erneut geprüft: gleicher SSH-Name und weiterhin OFF in'),
    "L'agent Medulla appelle GLPI-Agent avec --full (option apparue en 1.8) : ces postes ne": ('The Medulla agent calls GLPI-Agent with --full (option introduced in 1.8): these computers', 'El agente Medulla llama a GLPI-Agent con --full (opción aparecida en 1.8): estos equipos no', 'Der Medulla-Agent ruft GLPI-Agent mit --full auf (Option seit 1.8): diese Rechner'),
    "L'agent lance son agent d'inventaire et l'envoie au serveur (aucun fichier modifie, service non": ('The agent runs its inventory agent and sends the result to the server (no file changed, service not', 'El agente ejecuta su agente de inventario y lo envía al servidor (ningún archivo modificado, servicio no', 'Der Agent startet seinen Inventuragenten und sendet das Ergebnis an den Server (keine Datei geändert, Dienst nicht'),
    "L'agent repond en XMPP mais SSH ne fonctionne pas : le service sshdaemon est demarre par": ('The agent answers by XMPP but SSH does not work: the sshdaemon service is started by', 'El agente responde por XMPP pero SSH no funciona: el servicio sshdaemon se inicia por', 'Der Agent antwortet per XMPP, aber SSH funktioniert nicht: der Dienst sshdaemon wird per'),
    "La reconfiguration n'est proposee que pour une seule machine a la fois. Pour chaque poste,": ('Reconfiguration is only offered for one machine at a time. For each computer,', 'La reconfiguración solo se propone para una máquina a la vez. Para cada equipo,', 'Die Neukonfiguration wird nur für einen Rechner gleichzeitig angeboten. Für jeden Rechner'),
    "Le DNS donne {} a l'IP {} : IP de Medulla perimee (DHCP)": ("DNS gives {} the IP {}: Medulla's IP is out of date (DHCP)", 'El DNS da a {} la IP {}: IP de Medulla obsoleta (DHCP)', 'Das DNS gibt {} die IP {}: IP in Medulla veraltet (DHCP)'),
    'Le poste doit encore etre OFF et identifie par une seule fiche, sinon il est ecarte.': ('The computer must still be OFF and identified by a single record, otherwise it is excluded.', 'El equipo debe seguir OFF e identificado por una sola ficha; si no, se descarta.', 'Der Rechner muss weiterhin OFF und durch einen einzigen Eintrag identifiziert sein, sonst wird er ausgeschlossen.'),
    "Le reset n'est propose que pour une seule machine a la fois. Pour chaque poste ci-dessous,": ('The reset is only offered for one machine at a time. For each computer below,', 'La reinicialización solo se propone para un equipo a la vez. Para cada equipo de abajo,', 'Der Reset wird nur für jeweils einen Rechner angeboten. Für jeden Rechner unten'),
    "Le routeur {} signale l'IP injoignable : poste eteint, IP changee ou filtrage (REJECT)": ('Router {} reports the IP unreachable: computer off, IP changed or filtering (REJECT)', 'El router {} indica la IP inaccesible: equipo apagado, IP cambiada o filtrado (REJECT)', 'Router {} meldet die IP als nicht erreichbar: Rechner aus, IP geändert oder Filterung (REJECT)'),
    "Le serveur Medulla reinitialise ensuite l'agent. C'est la seule ecriture en base du script.": ("The Medulla server then resets the agent. This is the script's only database write.", 'El servidor Medulla reinicializa luego el agente. Es la única escritura en base del script.', 'Der Medulla-Server setzt den Agenten dann zurück. Das ist der einzige Datenbank-Schreibvorgang des Skripts.'),
    "Le serveur Medulla reinitialise ensuite l'agent. Seules ecritures en base : reset et reconfiguration.": ('The Medulla server then resets the agent. Only database writes: reset and reconfiguration.', 'El servidor Medulla reinicializa luego el agente. Únicas escrituras en base: reinicialización y reconfiguración.', 'Der Medulla-Server setzt den Agenten dann zurück. Einzige Datenbank-Schreibvorgänge: Reset und Neukonfiguration.'),
    "Le serveur reinitialise l'agent (fichiers retelecharges) des qu'il est connecte ;": ('The server resets the agent (files downloaded again) as soon as it is connected;', 'El servidor reinicializa el agente (archivos descargados de nuevo) en cuanto está conectado;', 'Der Server setzt den Agenten zurück (Dateien neu geladen), sobald er verbunden ist;'),
    'Lecture de Medulla impossible ({}) : etat non verifie, aucun reset ne sera propose.': ('Cannot read Medulla ({}): state not checked, no reset will be offered.', 'No se puede leer Medulla ({}): estado no verificado, no se propondrá ninguna reinicialización.', 'Medulla nicht lesbar ({}): Status nicht geprüft, kein Reset wird angeboten.'),
    "Les actions demandent toujours une confirmation (OUI) apres l'audit, et une machine ON n'est jamais touchee.": ('Actions always require a confirmation (YES) after the audit, and an ON machine is never touched.', 'Las acciones siempre piden una confirmación (SI) tras la auditoría, y nunca se toca un equipo ON.', 'Aktionen erfordern nach dem Audit immer eine Bestätigung (JA), und ein ON-Rechner wird nie verändert.'),
    "Les actions demandent toujours une confirmation (OUI) apres l'audit.": ('Actions always require a confirmation (YES) after the audit.', 'Las acciones siempre piden una confirmación (SI) tras la auditoría.', 'Aktionen erfordern nach dem Audit immer eine Bestätigung (JA).'),
    "Liberer de l'espace sur C:": ('Free up space on C:', 'Liberar espacio en C:', 'Speicher auf C: freigeben'),
    'Lignes ERROR dans xmpp-agent-machine.log': ('ERROR lines in xmpp-agent-machine.log', 'Líneas ERROR en xmpp-agent-machine.log', 'ERROR-Zeilen in xmpp-agent-machine.log'),
    'Lignes OFF sans IP (non testees)': ('OFF rows without IP (not tested)', 'Filas OFF sin IP (no probadas)', 'OFF-Zeilen ohne IP (nicht getestet)'),
    'Limite       : {} correction(s) et {} relance(s) au maximum': ('Limit        : {} fix(es) and {} restart(s) at most', 'Límite       : {} corrección(es) y {} reinicio(s) como máximo', 'Limit        : höchstens {} Korrektur(en) und {} Neustart(s)'),
    'Limite de corrections': ('Fix limit', 'Límite de correcciones', 'Korrekturlimit'),
    'Lire la colonne traces_serveur de resultats.csv': ('Read the traces_serveur column of resultats.csv', 'Leer la columna traces_serveur de resultats.csv', 'Spalte traces_serveur in resultats.csv lesen'),
    'Liste :': ('List:', 'Lista:', 'Liste:'),
    'MAC blacklistee. Machine ON : dans la minute ; machine OFF : quand elle repassera ON.': ('blacklisted MAC. ON machine: within a minute; OFF machine: when it comes back ON.', 'MAC en lista negra. Máquina ON: en el minuto; máquina OFF: cuando vuelva a ON.', 'gesperrte MAC. ON-Rechner: innerhalb einer Minute; OFF-Rechner: sobald er wieder ON ist.'),
    'MAC nulle': ('null MAC', 'MAC nula', 'Null-MAC'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS (V16)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS (V16)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES (V16)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN (V16)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V20)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V20)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V20)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V20)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V21)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V21)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V21)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V21)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V22)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V22)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V22)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V22)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V23)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V23)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V23)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V23)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V25)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V25)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V25)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V25)'),
    'MEDULLA - AUDIT ET ACTIONS SUR LES AGENTS, SSH + XMPP (V25)': ('MEDULLA - AUDIT AND ACTIONS ON AGENTS, SSH + XMPP (V25)', 'MEDULLA - AUDITORÍA Y ACCIONES SOBRE LOS AGENTES, SSH + XMPP (V25)', 'MEDULLA - AUDIT UND AKTIONEN AN DEN AGENTEN, SSH + XMPP (V25)'),
    "MEDULLA - MACHINES ON SANS UUID D'INVENTAIRE": ('MEDULLA - ON MACHINES WITHOUT INVENTORY UUID', 'MEDULLA - EQUIPOS ON SIN UUID DE INVENTARIO', 'MEDULLA - ON-RECHNER OHNE INVENTUR-UUID'),
    "MEDULLA - MACHINES ON SANS UUID D'INVENTAIRE (XMPP)": ('MEDULLA - ON MACHINES WITHOUT INVENTORY UUID (XMPP)', 'MEDULLA - EQUIPOS ON SIN UUID DE INVENTARIO (XMPP)', 'MEDULLA - ON-RECHNER OHNE INVENTUR-UUID (XMPP)'),
    "MEME_POSTE : ancienne fiche du poste ON (reinstallation, nouveau JID). IP_REATTRIBUEE : l'IP de la": ('MEME_POSTE: old record of the ON computer (reinstall, new JID). IP_REATTRIBUEE: the IP of the', 'MEME_POSTE: ficha antigua del equipo ON (reinstalación, nuevo JID). IP_REATTRIBUEE: la IP de la', 'MEME_POSTE: alter Eintrag des ON-Rechners (Neuinstallation, neue JID). IP_REATTRIBUEE: die IP des'),
    'Machine': ('Machine', 'Máquina', 'Rechner'),
    'Machine ON sans inventaire GLPI': ('ON machine without GLPI inventory', 'Máquina ON sin inventario GLPI', 'ON-Rechner ohne GLPI-Inventar'),
    'Machine ON sans inventaire GLPI rattache': ('ON machine with no linked GLPI inventory', 'Equipo ON sin inventario GLPI vinculado', 'ON-Rechner ohne zugeordnete GLPI-Inventur'),
    'Machine ON sans inventaire GLPI rattache (uuid_inventorymachine vide)': ('ON machine with no linked GLPI inventory (uuid_inventorymachine empty)', 'Equipo ON sin inventario GLPI vinculado (uuid_inventorymachine vacío)', 'ON-Rechner ohne zugeordnete GLPI-Inventur (uuid_inventorymachine leer)'),
    'Machines': ('Machines', 'Máquinas', 'Rechner'),
    'Machines (noms ou IP, plusieurs : separer par des virgules, Entree = retour au menu) :': ('Machines (names or IPs, several: comma-separated, Enter = back to menu):', 'Equipos (nombres o IP, varios: separados por comas, Intro = volver al menú):', 'Rechner (Namen oder IPs, mehrere: durch Kommas getrennt, Eingabe = zurück zum Menü):'),
    'Machines : {}': ('Machines: {}', 'Equipos: {}', 'Rechner: {}'),
    'Machines OFF (lignes)': ('OFF machines (rows)', 'Equipos OFF (filas)', 'OFF-Rechner (Zeilen)'),
    'Machines ON (lignes)': ('ON machines (rows)', 'Equipos ON (filas)', 'ON-Rechner (Zeilen)'),
    "Machines ON sans UUID d'inventaire": ('ON machines without inventory UUID', 'Equipos ON sin UUID de inventario', 'ON-Rechner ohne Inventur-UUID'),
    'Machines demandees': ('Requested machines', 'Equipos solicitados', 'Angefragte Rechner'),
    'Machines qui fonctionnent mais ont un defaut : {}': ('Working machines that have a defect: {}', 'Máquinas que funcionan pero tienen un defecto: {}', 'Funktionierende Rechner mit einem Fehler: {}'),
    'Machines reelles': ('Real machines', 'Máquinas reales', 'Reale Rechner'),
    'Machines reelles joignables (SSH ou XMPP fonctionnel)': ('Reachable real machines (SSH or XMPP working)', 'Máquinas reales accesibles (SSH o XMPP operativo)', 'Erreichbare reale Rechner (SSH oder XMPP funktioniert)'),
    'Machines reelles joignables : OFF dans Medulla avant / apres reparation': ('Reachable real machines: OFF in Medulla before / after repair', 'Máquinas reales accesibles: OFF en Medulla antes / después de la reparación', 'Erreichbare reale Rechner: OFF in Medulla vor / nach der Reparatur'),
    'Machines reelles joignables, etat dans Medulla avant reparation': ('Reachable real machines, state in Medulla before repair', 'Máquinas reales accesibles, estado en Medulla antes de la reparación', 'Erreichbare reale Rechner, Zustand in Medulla vor der Reparatur'),
    'Machines testees': ('Machines tested', 'Equipos probados', 'Getestete Rechner'),
    'Medulla': ('Medulla', 'Medulla', 'Medulla'),
    'Medulla apres': ('Medulla after', 'Medulla después', 'Medulla danach'),
    'Medulla enabled=0 (lignes, avec doublons)': ('Medulla enabled=0 (rows, with duplicates)', 'Medulla enabled=0 (filas, con duplicados)', 'Medulla enabled=0 (Zeilen, mit Duplikaten)'),
    'Medulla enabled=1 (lignes)': ('Medulla enabled=1 (rows)', 'Medulla enabled=1 (filas)', 'Medulla enabled=1 (Zeilen)'),
    "Medulla) et l'envoie au serveur. Aucun fichier de l'agent n'est modifie, le service n'est pas": ('Medulla) and sends the result to the server. No agent file is changed, the service is not', 'Medulla) y lo envía al servidor. No se modifica ningún archivo del agente, el servicio no se', 'Medulla) und sendet das Ergebnis an den Server. Keine Agent-Datei wird geändert, der Dienst wird nicht'),
    "Medulla. Sinon, il n'est pas touche.": ('Medulla. Otherwise it is not touched.', 'Medulla. Si no, no se toca.', 'Medulla. Sonst wird er nicht verändert.'),
    "Medulla\\var\\log n'est pas un dossier normal : verification manuelle": ('Medulla\\var\\log is not a normal folder: manual check', 'Medulla\\var\\log no es una carpeta normal: revisión manual', 'Medulla\\var\\log ist kein normaler Ordner: manuelle Prüfung'),
    'Messages ABORT RELAY dans le journal': ('ABORT RELAY messages in the log', 'Mensajes ABORT RELAY en el registro', 'ABORT-RELAY-Meldungen im Protokoll'),
    'Mettre GLPI-Agent a jour (>= 1.8) : non fait par le script': ('Update GLPI-Agent (>= 1.8): not done by the script', 'Actualizar GLPI-Agent (>= 1.8): no lo hace el script', 'GLPI-Agent aktualisieren (>= 1.8): nicht durch das Skript'),
    "Mettre GLPI-Agent a jour (>= 1.8) d'abord": ('Update GLPI-Agent (>= 1.8) first', 'Actualizar GLPI-Agent (>= 1.8) primero', 'Zuerst GLPI-Agent aktualisieren (>= 1.8)'),
    "Mise a niveau de l'agent : fichiers manquants copies et fichiers differents du serveur": ('Agent upgrade: missing files copied and files different from the server', 'Actualización del agente: archivos ausentes copiados y archivos distintos del servidor', 'Agent-Aktualisierung: fehlende Dateien kopiert und vom Server abweichende Dateien'),
    'Mise a niveau par le script (fichiers du serveur)': ('Upgraded by the script (server files)', 'Actualización por el script (archivos del servidor)', 'Aktualisierung durch das Skript (Serverdateien)'),
    'Mode': ('Mode', 'Modo', 'Modus'),
    'Mode         :': ('Mode         :', 'Modo         :', 'Modus        :'),
    'Motif': ('Reason', 'Motivo', 'Grund'),
    'NON': ('NO', 'NO', 'NEIN'),
    "NON_APPLIQUE : rien n'a change sur le poste": ('NON_APPLIQUE: nothing changed on the computer', 'NON_APPLIQUE: nada ha cambiado en el equipo', 'NON_APPLIQUE: nichts hat sich auf dem Rechner geändert'),
    'Niveau de journal (log_level)': ('Log level (log_level)', 'Nivel de registro (log_level)', 'Protokollstufe (log_level)'),
    'Nom dans Medulla': ('Name in Medulla', 'Nombre en Medulla', 'Name in Medulla'),
    'Nom demandé': ('Requested name', 'Nombre solicitado', 'Angefragter Name'),
    'Nom du poste OFF dans Medulla (pas deja ON)': ('Computer name OFF in Medulla (not already ON)', 'Nombre del equipo OFF en Medulla (no ya ON)', 'Rechnername in Medulla OFF (nicht bereits ON)'),
    "Nom repondu par l'agent": ('Name answered by the agent', 'Nombre respondido por el agente', 'Vom Agenten gemeldeter Name'),
    'Nom réel du poste': ('Actual computer name', 'Nombre real del equipo', 'Tatsächlicher Rechnername'),
    'Nom réel du poste (SSH)': ('Actual computer name (SSH)', 'Nombre real del equipo (SSH)', 'Tatsächlicher Rechnername (SSH)'),
    'Nom(s) dans Medulla': ('Name(s) in Medulla', 'Nombre(s) en Medulla', 'Name(n) in Medulla'),
    "Nombre de fichiers de l'agent differents du serveur (a mettre a jour)": ('Number of agent files different from the server (to update)', 'Número de archivos del agente distintos del servidor (que actualizar)', 'Anzahl der vom Server abweichenden Agent-Dateien (zu aktualisieren)'),
    "Nombre de fichiers de l'agent manquants ou vides": ('Number of missing or empty agent files', 'Número de archivos del agente ausentes o vacíos', 'Anzahl fehlender oder leerer Agent-Dateien'),
    'Nombre de fichiers de reference': ('Number of reference files', 'Número de archivos de referencia', 'Anzahl der Referenzdateien'),
    'Nombre de problèmes': ('Number of problems', 'Número de problemas', 'Anzahl der Probleme'),
    'Non reparables, injoignables (ni SSH, ni ping, ni session XMPP)': ('Not repairable, unreachable (no SSH, ping or XMPP session)', 'No reparables, inaccesibles (ni SSH, ni ping, ni sesión XMPP)', 'Nicht reparierbar, nicht erreichbar (weder SSH, Ping noch XMPP-Sitzung)'),
    'Non reparables, joignables (SSH, ping ou session XMPP)': ('Not repairable, reachable (SSH, ping or XMPP session)', 'No reparables, accesibles (SSH, ping o sesión XMPP)', 'Nicht reparierbar, erreichbar (SSH, Ping oder XMPP-Sitzung)'),
    'Non reparables, joignables (ping SSH ou session XMPP)': ('Not repairable, reachable (SSH ping or XMPP session)', 'No reparables, accesibles (ping SSH o sesión XMPP)', 'Nicht reparierbar, erreichbar (SSH-Ping oder XMPP-Sitzung)'),
    'Non reparables, ne repondent pas au ping': ('Not repairable, do not answer ping', 'No reparables, no responden al ping', 'Nicht reparierbar, antworten nicht auf Ping'),
    'Non reparables, ni ping ni session XMPP': ('Not repairable, no ping and no XMPP session', 'No reparables, ni ping ni sesión XMPP', 'Nicht reparierbar, weder Ping noch XMPP-Sitzung'),
    'Non reparables, repondent au ping, OFF dans Medulla': ('Not repairable, answer ping, OFF in Medulla', 'No reparables, responden al ping, OFF en Medulla', 'Nicht reparierbar, antworten auf Ping, OFF in Medulla'),
    'Non teste': ('Not tested', 'No probado', 'Nicht getestet'),
    'Non traites : raison': ('Not processed: reason', 'No tratados: motivo', 'Nicht bearbeitet: Grund'),
    'Nouveaux essais apres timeout (SSH, audit)': ('Retries after timeout (SSH, audit)', 'Reintentos tras tiempo agotado (SSH, auditoría)', 'Neue Versuche nach Zeitüberschreitung (SSH, Audit)'),
    'Nouveaux essais apres timeout (connexion, audit)': ('Retries after timeout (connection, audit)', 'Reintentos tras tiempo agotado (conexión, auditoría)', 'Neue Versuche nach Zeitüberschreitung (Verbindung, Audit)'),
    "Nouvel essai : {} apres un timeout de la connexion ou de l'audit (lecture seule ; les corrections ne sont jamais rejouees)": ('Retry        : {} after a timeout of the connection or audit (read-only; fixes are never replayed)', 'Reintento    : {} tras un tiempo agotado de la conexión o de la auditoría (solo lectura; las correcciones nunca se repiten)', 'Neuer Versuch: {} nach Zeitüberschreitung der Verbindung oder des Audits (nur lesend; Korrekturen werden nie wiederholt)'),
    "Nouvel essai : {} apres un timeout du test SSH ou de l'audit (lecture seule ; les corrections ne sont jamais rejouees)": ('Retry        : {} after a timeout of the SSH test or audit (read-only; fixes are never replayed)', 'Reintento    : {} tras un tiempo agotado de la prueba SSH o de la auditoría (solo lectura; las correcciones nunca se repiten)', 'Neuer Versuch: {} nach Zeitüberschreitung des SSH-Tests oder Audits (nur lesend; Korrekturen werden nie wiederholt)'),
    'Nouvel essai apres timeout': ('Retry after timeout', 'Nuevo intento tras tiempo agotado', 'Neuer Versuch nach Zeitüberschreitung'),
    'OFF avec IP invalide (non testees)': ('OFF with invalid IP (not tested)', 'OFF con IP no válida (no probados)', 'OFF mit ungültiger IP (nicht getestet)'),
    'OFF dans Medulla': ('OFF in Medulla', 'OFF en Medulla', 'OFF in Medulla'),
    "OFF dans Medulla (repli : agent toujours sans session XMPP). Sinon, il n'est pas touche.": ('OFF in Medulla (fallback: agent still with no XMPP session). Otherwise it is not touched.', 'OFF en Medulla (respaldo: agente todavía sin sesión XMPP). Si no, no se toca.', 'OFF in Medulla (Ausweichweg: Agent weiterhin ohne XMPP-Sitzung). Sonst wird er nicht verändert.'),
    'OFF dans Medulla malgre SSH ou XMPP fonctionnel': ('OFF in Medulla despite SSH or XMPP working', 'OFF en Medulla pese a SSH o XMPP operativo', 'OFF in Medulla trotz funktionierendem SSH oder XMPP'),
    'OFF sans IP (non testees)': ('OFF without IP (not tested)', 'OFF sin IP (no probados)', 'OFF ohne IP (nicht getestet)'),
    'ON dans Medulla': ('ON in Medulla', 'ON en Medulla', 'ON in Medulla'),
    'ON dans Medulla : fiche OFF obsolete, audit par XMPP': ('ON in Medulla: obsolete OFF record, audit by XMPP', 'ON en Medulla: ficha OFF obsoleta, auditoría por XMPP', 'ON in Medulla: veralteter OFF-Eintrag, Audit per XMPP'),
    'OUI': ('YES', 'SÍ', 'JA'),
    "Origine de l'IP": ('Origin of the IP', 'Origen de la IP', 'Herkunft der IP'),
    'PAExec bloque (EDR probable)': ('PAExec blocked (likely EDR)', 'PAExec bloqueado (EDR probable)', 'PAExec blockiert (wahrscheinlich EDR)'),
    'PARTIEL': ('PARTIEL', 'PARTIEL', 'PARTIEL'),
    "PLAN D'ACTIONS : {} correction(s), {} relance(s)": ('ACTION PLAN: {} fix(es), {} restart(s)', 'PLAN DE ACCIONES: {} corrección(es), {} reinicio(s)', 'AKTIONSPLAN: {} Korrektur(en), {} Neustart(s)'),
    'POSTES ON A TRAITER PAR XMPP : {} (diagnostic_machines_XMPPV6)': ('ON COMPUTERS TO HANDLE BY XMPP: {} (diagnostic_machines_XMPPV6)', 'EQUIPOS ON QUE TRATAR POR XMPP: {} (diagnostic_machines_XMPPV6)', 'PER XMPP ZU BEHANDELNDE ON-RECHNER: {} (diagnostic_machines_XMPPV6)'),
    'Paquets Debian a installer :': ('Debian packages to install:', 'Paquetes Debian que instalar:', 'Zu installierende Debian-Pakete:'),
    'Par XMPP : action lancee par le Planificateur de taches (compte SYSTEM), elle va au bout': ('By XMPP: action started by the Task Scheduler (SYSTEM account), it runs to completion', 'Por XMPP: acción lanzada por el Programador de tareas (cuenta SYSTEM), llega hasta el final', 'Per XMPP: Aktion über die Aufgabenplanung (Konto SYSTEM) gestartet, sie läuft bis zum Ende'),
    'Parallelisme': ('Parallelism', 'Paralelismo', 'Parallelität'),
    'Parallelisme : {} audit(s)': ('Parallelism  : {} audit(s)', 'Paralelismo  : {} auditoría(s)', 'Parallelität : {} Audit(s)'),
    "Pas de boucle de reconfiguration de l'agent": ('No agent reconfiguration loop', 'Sin bucle de reconfiguración del agente', 'Keine Neukonfigurationsschleife des Agenten'),
    'Pas de copies PAExec bloquees (EDR)': ('No blocked PAExec copies (EDR)', 'Sin copias de PAExec bloqueadas (EDR)', 'Keine blockierten PAExec-Kopien (EDR)'),
    "Pas de demande d'inventaire pour {} : {} (mettre GLPI-Agent a jour d'abord).": ('No inventory request for {}: {} (update GLPI-Agent first).', 'Ninguna solicitud de inventario para {}: {} (actualizar GLPI-Agent primero).', 'Keine Inventuranforderung für {}: {} (zuerst GLPI-Agent aktualisieren).'),
    'Pas de route vers le reseau du poste': ("No route to the computer's network", 'Sin ruta hacia la red del equipo', 'Keine Route zum Netzwerk des Rechners'),
    'Passees OFF (ON avant la reparation)': ('Went OFF (ON before the repair)', 'Pasaron a OFF (ON antes de la reparación)', 'Auf OFF gegangen (vor der Reparatur ON)'),
    'Perimetre': ('Scope', 'Alcance', 'Umfang'),
    'Perimetre    :': ('Scope        :', 'Alcance      :', 'Umfang       :'),
    'Perimetre : {} IP ciblee(s) : {}': ('Scope: {} IP(s) targeted: {}', 'Alcance: {} IP solicitada(s): {}', 'Umfang: {} Ziel-IP(s): {}'),
    'Ping': ('Ping', 'Ping', 'Ping'),
    'Ping (SSH) ou session ejabberd (XMPP)': ('Ping (SSH) or ejabberd session (XMPP)', 'Ping (SSH) o sesión ejabberd (XMPP)', 'Ping (SSH) oder ejabberd-Sitzung (XMPP)'),
    'Ping {} / SSH {}': ('Ping {} / SSH {}', 'Ping {} / SSH {}', 'Ping {} / SSH {}'),
    'Plateforme dans Medulla': ('Platform in Medulla', 'Plataforma en Medulla', 'Plattform in Medulla'),
    'Port 22 filtre par le pare-feu du poste': ("Port 22 filtered by the computer's firewall", 'Puerto 22 filtrado por el cortafuegos del equipo', 'Port 22 von der Firewall des Rechners gefiltert'),
    "Port 22 ouvert maintenant : coupure passagere pendant l'audit": ('Port 22 open now: temporary outage during the audit', 'Puerto 22 abierto ahora: corte pasajero durante la auditoría', 'Port 22 jetzt offen: vorübergehender Ausfall während des Audits'),
    'Port SSH ferme sur le poste': ('SSH port closed on the computer', 'Puerto SSH cerrado en el equipo', 'SSH-Port auf dem Rechner geschlossen'),
    'Ports TCP en echec vers relais': ('Failed TCP ports to the relay', 'Puertos TCP fallidos hacia el relé', 'Fehlgeschlagene TCP-Ports zum Relay'),
    'Ports TCP en echec vers serveur Medulla': ('Failed TCP ports to the Medulla server', 'Puertos TCP fallidos hacia el servidor Medulla', 'Fehlgeschlagene TCP-Ports zum Medulla-Server'),
    'Poste': ('Computer', 'Equipo', 'Rechner'),
    'Poste Windows': ('Windows computer', 'Equipo Windows', 'Windows-Rechner'),
    'Poste Windows (parmi SSH OK)': ('Windows computer (among SSH OK)', 'Equipo Windows (entre SSH OK)', 'Windows-Rechner (unter SSH OK)'),
    'Poste absent depuis longtemps': ('Computer absent for a long time', 'Equipo ausente desde hace tiempo', 'Rechner seit Langem abwesend'),
    'Poste allume mais port 22 ferme : sshdaemon (OpenSSH Medulla) arrete ou absent': ('Computer on but port 22 closed: sshdaemon (Medulla OpenSSH) stopped or missing', 'Equipo encendido pero puerto 22 cerrado: sshdaemon (OpenSSH de Medulla) detenido o ausente', 'Rechner an, aber Port 22 geschlossen: sshdaemon (Medulla-OpenSSH) gestoppt oder fehlt'),
    'Poste déjà ON dans Medulla': ('Computer already ON in Medulla', 'Equipo ya ON en Medulla', 'Rechner in Medulla bereits ON'),
    'Poste eteint ou IP changee (ARP sans reponse)': ('Computer off or IP changed (no ARP reply)', 'Equipo apagado o IP cambiada (ARP sin respuesta)', 'Rechner aus oder IP geändert (keine ARP-Antwort)'),
    'Poste eteint ou deconnecte': ('Computer off or disconnected', 'Equipo apagado o desconectado', 'Rechner aus oder getrennt'),
    'Poste injoignable (eteint ou filtre)': ('Computer unreachable (off or filtered)', 'Equipo inaccesible (apagado o filtrado)', 'Rechner nicht erreichbar (aus oder gefiltert)'),
    'Poste injoignable : cause probable (journaux serveur)': ('Unreachable computer: likely cause (server logs)', 'Equipo inaccesible: causa probable (registros del servidor)', 'Nicht erreichbarer Rechner: wahrscheinliche Ursache (Serverprotokolle)'),
    'Poste injoignable : diagnostic réseau': ('Unreachable computer: network diagnosis', 'Equipo inaccesible: diagnóstico de red', 'Nicht erreichbarer Rechner: Netzwerkdiagnose'),
    'Poste injoignable : tests réseau (port 22, route, ARP, DNS)': ('Unreachable computer: network tests (port 22, route, ARP, DNS)', 'Equipo inaccesible: pruebas de red (puerto 22, ruta, ARP, DNS)', 'Nicht erreichbarer Rechner: Netzwerktests (Port 22, Route, ARP, DNS)'),
    'Poste injoignable : traces côté serveur': ('Unreachable computer: server-side traces', 'Equipo inaccesible: trazas del lado del servidor', 'Nicht erreichbarer Rechner: serverseitige Spuren'),
    'Poste present sur le reseau local mais port 22 sans reponse : pare-feu du poste': ("Computer present on the local network but no answer on port 22: computer's firewall", 'Equipo presente en la red local pero puerto 22 sin respuesta: cortafuegos del equipo', 'Rechner im lokalen Netz vorhanden, aber keine Antwort auf Port 22: Firewall des Rechners'),
    'Poste qui repond / fiche ON': ('Computer answering / ON record', 'Equipo que responde / ficha ON', 'Antwortender Rechner / ON-Eintrag'),
    'Poste vu recemment, injoignable maintenant': ('Computer seen recently, unreachable now', 'Equipo visto recientemente, ahora inaccesible', 'Rechner kürzlich gesehen, jetzt nicht erreichbar'),
    'Postes': ('Computers', 'Equipos', 'Rechner'),
    "Postes : dedoublonnes par nom reel du poste (SSH) ; sans acces SSH, l'IP fait foi.": ('Computers: deduplicated by actual computer name (SSH); without SSH access, the IP is used.', 'Equipos: deduplicados por nombre real del equipo (SSH); sin acceso SSH, cuenta la IP.', 'Rechner: nach tatsächlichem Rechnernamen (SSH) dedupliziert; ohne SSH-Zugriff zählt die IP.'),
    'Postes : problemes par famille': ('Computers: problems by family', 'Equipos: problemas por familia', 'Rechner: Probleme nach Familie'),
    'Postes a joindre en SSH': ('Computers to reach by SSH', 'Equipos que alcanzar por SSH', 'Per SSH zu erreichende Rechner'),
    'Postes avec au moins une ligne ERROR': ('Computers with at least one ERROR line', 'Equipos con al menos una línea ERROR', 'Rechner mit mindestens einer ERROR-Zeile'),
    'Postes avec un constat cote serveur': ('Computers with a server-side finding', 'Equipos con una constatación del lado del servidor', 'Rechner mit serverseitigem Befund'),
    'Postes supportes : Windows 10/11, Windows Server 2012 a 2025': ('Supported computers: Windows 10/11, Windows Server 2012 to 2025', 'Equipos soportados: Windows 10/11, Windows Server 2012 a 2025', 'Unterstützte Rechner: Windows 10/11, Windows Server 2012 bis 2025'),
    'Pour chaque poste, une demande de reset est inscrite dans xmppmaster.reset_machine': ('For each computer, a reset request is written to xmppmaster.reset_machine', 'Para cada equipo, se inscribe una solicitud de reinicialización en xmppmaster.reset_machine', 'Für jeden Rechner wird eine Reset-Anfrage in xmppmaster.reset_machine eingetragen'),
    'Pourcentage (%)': ('Percentage (%)', 'Porcentaje (%)', 'Prozent (%)'),
    'Probleme': ('Problem', 'Problema', 'Problem'),
    'Problemes': ('Problems', 'Problemas', 'Probleme'),
    'Problemes (postes concernes)': ('Problems (computers affected)', 'Problemas (equipos afectados)', 'Probleme (betroffene Rechner)'),
    'Problemes apres correction (postes verifies)': ('Problems after fix (computers checked)', 'Problemas tras la corrección (equipos verificados)', 'Probleme nach Korrektur (geprüfte Rechner)'),
    'Problèmes détectés': ('Problems detected', 'Problemas detectados', 'Erkannte Probleme'),
    'Problèmes restants après correction': ('Problems remaining after fix', 'Problemas restantes tras la corrección', 'Verbleibende Probleme nach Korrektur'),
    'Produits de sécurité détectés': ('Security products detected', 'Productos de seguridad detectados', 'Erkannte Sicherheitsprodukte'),
    "Programme d'inventaire": ('Inventory program', 'Programa de inventario', 'Inventurprogramm'),
    "Programme d'inventaire présent": ('Inventory program present', 'Programa de inventario presente', 'Inventurprogramm vorhanden'),
    'Publier la reference (medulla_publier_reference_agent.py) ou verifier a la main': ('Publish the reference (medulla_publier_reference_agent.py) or check manually', 'Publicar la referencia (medulla_publier_reference_agent.py) o verificar manualmente', 'Referenz veröffentlichen (medulla_publier_reference_agent.py) oder manuell prüfen'),
    "Python de l'agent absent": ("Agent's Python missing", 'Falta el Python del agente', 'Python des Agenten fehlt'),
    "Python de l'agent present": ("Agent's Python present", 'Python del agente presente', 'Python des Agenten vorhanden'),
    'Python {} minimum requis (version actuelle : {}).': ('Python {} minimum required (current version: {}).', 'Se requiere Python {} como mínimo (versión actual: {}).', 'Python {} mindestens erforderlich (aktuelle Version: {}).'),
    'QUE VOULEZ-VOUS FAIRE ?': ('WHAT DO YOU WANT TO DO?', '¿QUÉ QUIERE HACER?', 'WAS MÖCHTEN SIE TUN?'),
    'Quick Action Run Inventory, puis Reconfigure machine agent (ou --sans-inventaire)': ('Quick Action Run Inventory, then Reconfigure machine agent (or --sans-inventaire)', 'Quick Action Run Inventory, luego Reconfigure machine agent (o --sans-inventaire)', 'Quick Action Run Inventory, dann Reconfigure machine agent (oder --sans-inventaire)'),
    'RAS / VPN Windows': ('RAS / Windows VPN', 'RAS / VPN de Windows', 'RAS / Windows-VPN'),
    'RECONFIGURATION APRES BLACKLIST DES MAC : {} machine(s)': ('RECONFIGURATION AFTER MAC BLACKLIST: {} machine(s)', 'RECONFIGURACIÓN TRAS LISTA NEGRA DE MAC: {} máquina(s)', 'NEUKONFIGURATION NACH MAC-SPERRLISTE: {} Rechner'),
    "RECONFIGURATION DE L'AGENT": ('AGENT RECONFIGURATION', 'RECONFIGURACIÓN DEL AGENTE', 'NEUKONFIGURATION DES AGENTEN'),
    "RECONFIGURATION DE L'AGENT : {} poste(s)": ('AGENT RECONFIGURATION: {} computer(s)', 'RECONFIGURACIÓN DEL AGENTE: {} equipo(s)', 'NEUKONFIGURATION DES AGENTEN: {} Rechner'),
    'RELANCE DU SERVICE : {} poste(s)': ('SERVICE RESTART: {} computer(s)', 'REINICIO DEL SERVICIO: {} equipo(s)', 'DIENST-NEUSTART: {} Rechner'),
    "RESET D'OFFICE : {} machine(s)": ('AUTOMATIC RESET: {} machine(s)', 'REINICIALIZACIÓN DE OFICIO: {} máquina(s)', 'AUTOMATISCHER RESET: {} Rechner'),
    "RESET DE L'AGENT": ('AGENT RESET', 'REINICIALIZACIÓN DEL AGENTE', 'AGENT-RESET'),
    "RESET DE L'AGENT : {} poste(s) toujours OFF": ('AGENT RESET: {} computer(s) still OFF', 'REINICIALIZACIÓN DEL AGENTE: {} equipo(s) todavía OFF', 'AGENT-RESET: {} Rechner weiterhin OFF'),
    'RESUME': ('SUMMARY', 'RESUMEN', 'ZUSAMMENFASSUNG'),
    'RETOUR DANS MEDULLA : {} poste(s) traite(s)': ('BACK IN MEDULLA: {} computer(s) processed', 'VUELTA EN MEDULLA: {} equipo(s) tratado(s)', 'ZURÜCK IN MEDULLA: {} Rechner verarbeitet'),
    'Recapitulatif par famille de test': ('Summary by test family', 'Resumen por familia de pruebas', 'Übersicht nach Testfamilie'),
    'Recapitulatif par famille de test ({})': ('Summary by test family ({})', 'Resumen por familia de pruebas ({})', 'Übersicht nach Testfamilie ({})'),
    'Reconfiguration': ('Reconfiguration', 'Reconfiguración', 'Neukonfiguration'),
    'Reconfiguration annulee : aucune ecriture en base.': ('Reconfiguration cancelled: nothing written to the database.', 'Reconfiguración anulada: ninguna escritura en base.', 'Neukonfiguration abgebrochen: nichts in die Datenbank geschrieben.'),
    'Reconfiguration de ces machines par le script (mode actions), apres restart-pulse-services allsubs': ('Reconfiguration of these machines by the script (actions mode), after restart-pulse-services allsubs', 'Reconfiguración de estas máquinas por el script (modo acciones), tras restart-pulse-services allsubs', 'Neukonfiguration dieser Rechner durch das Skript (Aktionsmodus), nach restart-pulse-services allsubs'),
    "Reconfiguration de l'agent (need_reconf)": ('Agent reconfiguration (need_reconf)', 'Reconfiguración del agente (need_reconf)', 'Neukonfiguration des Agenten (need_reconf)'),
    "Reconfiguration de l'agent possible pour {} poste(s)": ('Agent reconfiguration possible for {} computer(s)', 'Reconfiguración del agente posible para {} equipo(s)', 'Neukonfiguration des Agenten für {} Rechner möglich'),
    'Reconfiguration demandee en attente (need_reconf=1)': ('Reconfiguration request pending (need_reconf=1)', 'Reconfiguración solicitada pendiente (need_reconf=1)', 'Angeforderte Neukonfiguration ausstehend (need_reconf=1)'),
    'Reconfiguration en attente (need_reconf)': ('Reconfiguration pending (need_reconf)', 'Reconfiguración pendiente (need_reconf)', 'Neukonfiguration ausstehend (need_reconf)'),
    'Reconfigurations dans le journal': ('Reconfigurations in the log', 'Reconfiguraciones en el registro', 'Neukonfigurationen im Protokoll'),
    "Reconfigurer l'agent ; sinon ejabberdctl unregister <compte> pulse et redemarrer l'agent": ('Reconfigure the agent; otherwise ejabberdctl unregister <account> pulse and restart the agent', 'Reconfigurar el agente; si no, ejabberdctl unregister <cuenta> pulse y reiniciar el agente', 'Agent neu konfigurieren; sonst ejabberdctl unregister <Konto> pulse und Agent neu starten'),
    'Reconnexions (1 h)': ('Reconnections (1 h)', 'Reconexiones (1 h)', 'Wiederverbindungen (1 h)'),
    'Reconnexions en boucle': ('Reconnection loop', 'Reconexiones en bucle', 'Wiederverbindungsschleife'),
    "Redemarrage des substituts non verifiable : s'assurer que restart-pulse-services allsubs": ('Substitute restart cannot be checked: make sure restart-pulse-services allsubs', 'Reinicio de los sustitutos no verificable: asegurarse de que restart-pulse-services allsubs', 'Neustart der Substitute nicht prüfbar: sicherstellen, dass restart-pulse-services allsubs'),
    'Ref. agent   :': ('Agent ref.   :', 'Ref. agente  :', 'Agent-Ref.   :'),
    'Ref. agent   : {} ({} fichiers, agent {})': ('Agent ref.   : {} ({} files, agent {})', 'Ref. agente  : {} ({} archivos, agente {})', 'Agent-Ref.   : {} ({} Dateien, Agent {})'),
    "Reference de l'agent illisible ({}) : controle des fichiers desactive.": ('Agent reference unreadable ({}): file check disabled.', 'Referencia del agente ilegible ({}): control de archivos desactivado.', 'Agent-Referenz unlesbar ({}): Dateiprüfung deaktiviert.'),
    "Reference des fichiers de l'agent": ('Agent file reference', 'Referencia de los archivos del agente', 'Referenz der Agent-Dateien'),
    "Reinstaller l'agent (Medulla-Agent-windows-FULL-latest.exe /S), puis redemarrer le poste": ('Reinstall the agent (Medulla-Agent-windows-FULL-latest.exe /S), then restart the computer', 'Reinstalar el agente (Medulla-Agent-windows-FULL-latest.exe /S) y reiniciar el equipo', 'Agent neu installieren (Medulla-Agent-windows-FULL-latest.exe /S), dann Rechner neu starten'),
    "Reinstaller l'agent ou redeployer la cle de pulseuser": ('Reinstall the agent or redeploy the pulseuser key', 'Reinstalar el agente o volver a desplegar la clave de pulseuser', 'Agent neu installieren oder pulseuser-Schlüssel neu verteilen'),
    "Reinstaller l'agent, ou sc config <service> start= auto": ('Reinstall the agent, or sc config <service> start= auto', 'Reinstalar el agente, o sc config <service> start= auto', 'Agent neu installieren oder sc config <service> start= auto'),
    'Relais injoignable': ('Relay unreachable', 'Relé inaccesible', 'Relay nicht erreichbar'),
    'Relais injoignable (journaux serveur)': ('Relay unreachable (server logs)', 'Relé inaccesible (registros del servidor)', 'Relay nicht erreichbar (Serverprotokolle)'),
    "Relais injoignable signale par l'agent": ('Relay unreachable, reported by the agent', 'Relé inaccesible, señalado por el agente', 'Relay nicht erreichbar, vom Agenten gemeldet'),
    "Relais injoignable signale par l'agent ({} ABORT RELAY)": ('Relay unreachable reported by the agent ({} ABORT RELAY)', 'Relé inaccesible señalado por el agente ({} ABORT RELAY)', 'Vom Agenten gemeldetes unerreichbares Relay ({} ABORT RELAY)'),
    'Relais joignable (aucun ABORT RELAY dans le journal)': ('Relay reachable (no ABORT RELAY in the log)', 'Relé accesible (ningún ABORT RELAY en el registro)', 'Relay erreichbar (kein ABORT RELAY im Protokoll)'),
    'Relais teste depuis le poste': ('Relay tested from the computer', 'Relé probado desde el equipo', 'Vom Rechner getestetes Relay'),
    'Relance': ('Restart', 'Reinicio', 'Neustart'),
    'Relance du service': ('Service restart', 'Reinicio del servicio', 'Neustart des Dienstes'),
    'Relance du service par le script (ou sc start medullaagent)': ('Service restarted by the script (or sc start medullaagent)', 'Reinicio del servicio por el script (o sc start medullaagent)', 'Dienstneustart durch das Skript (oder sc start medullaagent)'),
    "Relancer l'audit par le nom : --cible {}": ('Run the audit again by name: --cible {}', 'Volver a lanzar la auditoría por nombre: --cible {}', 'Audit erneut per Name starten: --cible {}'),
    "Relancer l'audit sur ce poste (--cible)": ('Run the audit again on this computer (--cible)', 'Volver a lanzar la auditoría en este equipo (--cible)', 'Audit auf diesem Rechner erneut starten (--cible)'),
    'Relances du service prevues : {} poste(s)': ('Planned service restarts: {} computer(s)', 'Reinicios del servicio previstos: {} equipo(s)', 'Geplante Dienst-Neustarts: {} Rechner'),
    'Releve': ('Report', 'Informe', 'Bericht'),
    'Remettre log_level = INFO et log_level_slixmpp = FATAL dans agentconf.ini': ('Set log_level = INFO and log_level_slixmpp = FATAL back in agentconf.ini', 'Volver a poner log_level = INFO y log_level_slixmpp = FATAL en agentconf.ini', 'log_level = INFO und log_level_slixmpp = FATAL in agentconf.ini zurücksetzen'),
    'Remettre pulseuser dans le groupe Administrateurs local (PowerShell : Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser) ; verifier la GPO ou LAPS qui le retire': ('Put pulseuser back in the local Administrators group (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser); check the GPO or LAPS that removes it', 'Volver a poner pulseuser en el grupo Administradores local (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser); verificar la GPO o LAPS que lo quita', 'pulseuser wieder in die lokale Gruppe Administratoren aufnehmen (PowerShell: Add-LocalGroupMember -SID S-1-5-32-544 -Member pulseuser); GPO oder LAPS prüfen, die es entfernt'),
    'Reparable': ('Repairable', 'Reparable', 'Reparierbar'),
    'Reparable automatiquement': ('Automatically repairable', 'Reparable automáticamente', 'Automatisch reparierbar'),
    'Reparables automatiquement': ('Automatically repairable', 'Reparables automáticamente', 'Automatisch reparierbar'),
    'Reparation   : code de reparer-agent-medulla.ps1 integre (sha256 {}...)': ('Repair       : built-in reparer-agent-medulla.ps1 code (sha256 {}...)', 'Reparación   : código de reparer-agent-medulla.ps1 integrado (sha256 {}...)', 'Reparatur    : integrierter Code von reparer-agent-medulla.ps1 (sha256 {}...)'),
    'Repassees ON apres reparation': ('Back ON after repair', 'De nuevo ON tras la reparación', 'Nach der Reparatur wieder ON'),
    'Repasses ON : {} / {}': ('Back ON: {} / {}', 'De nuevo ON: {} / {}', 'Wieder ON: {} / {}'),
    'Reponse au ping': ('Answers ping', 'Responde al ping', 'Antwortet auf Ping'),
    'Reseau': ('Network', 'Red', 'Netzwerk'),
    'Reset': ('Reset', 'Reset', 'Reset'),
    "Reset : seulement si, apres les actions SSH, l'agent ne fonctionne toujours pas (OFF et sans": ('Reset: only if, after the SSH actions, the agent still does not work (OFF and no', 'Reinicialización: solo si, tras las acciones SSH, el agente sigue sin funcionar (OFF y sin', 'Reset: nur wenn der Agent nach den SSH-Aktionen weiterhin nicht funktioniert (OFF und ohne'),
    'Reset annule : aucune demande inscrite.': ('Reset cancelled: no request written.', 'Reinicialización cancelada: ninguna solicitud inscrita.', 'Reset abgebrochen: keine Anfrage eingetragen.'),
    "Reset d'agent disponible (serveur >= 5.6.4)": ('Agent reset available (server >= 5.6.4)', 'Reinicialización del agente disponible (servidor >= 5.6.4)', 'Agent-Reset verfügbar (Server >= 5.6.4)'),
    "Reset d'office quand il peut aider (mode actions) ; sinon l'action indiquee par machine": ('Automatic reset when it can help (actions mode); otherwise the action given per machine', 'Reinicialización de oficio cuando puede ayudar (modo acciones); si no, la acción indicada por máquina', 'Automatischer Reset, wenn er helfen kann (Aktionsmodus); sonst die je Rechner angegebene Aktion'),
    'Reset de ces agents par le script (mode actions) ; sinon analyser xmpp-agent-machine.log (FAQ : machine hors ligne, point 7)': ('Reset of these agents by the script (actions mode); otherwise analyse xmpp-agent-machine.log (FAQ: machine offline, item 7)', 'Reinicialización de estos agentes por el script (modo acciones); si no, analizar xmpp-agent-machine.log (FAQ: máquina fuera de línea, punto 7)', 'Reset dieser Agenten durch das Skript (Aktionsmodus); sonst xmpp-agent-machine.log analysieren (FAQ: Rechner offline, Punkt 7)'),
    "Reset de l'agent": ('Agent reset', 'Reinicialización del agente', 'Agent-Reset'),
    "Reset de l'agent par le script (fichiers retelecharges)": ('Agent reset by the script (files downloaded again)', 'Reinicialización del agente por el script (archivos descargados de nuevo)', 'Agent-Reset durch das Skript (Dateien neu geladen)'),
    "Reset de l'agent par le script (mode actions)": ('Agent reset by the script (actions mode)', 'Reinicialización del agente por el script (modo acciones)', 'Agent-Reset durch das Skript (Aktionsmodus)'),
    "Reset de l'agent possible pour {} poste(s)": ('Agent reset possible for {} computer(s)', 'Reinicialización del agente posible para {} equipo(s)', 'Agent-Reset möglich für {} Rechner'),
    'Reset deja demande': ('Reset already requested', 'Reinicialización ya solicitada', 'Reset bereits angefordert'),
    'Reset deja demande (reset_machine)': ('Reset already requested (reset_machine)', 'Reinicialización ya solicitada (reset_machine)', 'Reset bereits angefordert (reset_machine)'),
    'Reset non propose sur plusieurs machines : les postes concernes seront listes,': ('Reset not offered on several machines: the computers concerned will be listed,', 'Reinicialización no propuesta en varios equipos: se listarán los equipos afectados,', 'Kein Reset für mehrere Rechner: die betroffenen Rechner werden aufgelistet,'),
    'Reset non propose sur plusieurs machines : les postes toujours OFF seront listes,': ('Reset not offered on several machines: computers still OFF will be listed,', 'Reinicialización no propuesta en varios equipos: se listarán los equipos que sigan OFF,', 'Kein Reset für mehrere Rechner: weiterhin OFF stehende Rechner werden aufgelistet,'),
    'Restauration par le script ; sinon Quick Action Reconfigure machine agent': ('Restored by the script; otherwise Quick Action Reconfigure machine agent', 'Restauración por el script; si no, Quick Action Reconfigure machine agent', 'Wiederherstellung durch das Skript; sonst Quick Action Reconfigure machine agent'),
    'Restees OFF': ('Still OFF', 'Siguen OFF', 'Weiterhin OFF'),
    'Resultat': ('Result', 'Resultado', 'Ergebnis'),
    'Resultat : {}': ('Result: {}', 'Resultado: {}', 'Ergebnis: {}'),
    'Réparable après correction': ('Repairable after fix', 'Reparable tras la corrección', 'Reparierbar nach Korrektur'),
    'Réparable automatiquement': ('Automatically repairable', 'Reparable automáticamente', 'Automatisch reparierbar'),
    'Résultat de la correction': ('Fix result', 'Resultado de la corrección', 'Ergebnis der Korrektur'),
    'SSH': ('SSH', 'SSH', 'SSH'),
    'SSH (pulseuser, par cle)': ('SSH (pulseuser, by key)', 'SSH (pulseuser, por clave)', 'SSH (pulseuser, per Schlüssel)'),
    'SSH depuis le serveur': ('SSH from the server', 'SSH desde el servidor', 'SSH vom Server'),
    'SSH depuis le serveur (agent joignable en XMPP)': ('SSH from the server (agent reachable by XMPP)', 'SSH desde el servidor (agente accesible por XMPP)', 'SSH vom Server (Agent per XMPP erreichbar)'),
    'SSH depuis le serveur : {}': ('SSH from the server: {}', 'SSH desde el servidor: {}', 'SSH vom Server: {}'),
    "SSH et XMPP etaient joignables, les actions SSH n'ont pas suffi : l'agent reste OFF.": ('SSH and XMPP were reachable, the SSH actions were not enough: the agent stays OFF.', 'SSH y XMPP eran accesibles, las acciones SSH no bastaron: el agente sigue OFF.', 'SSH und XMPP waren erreichbar, die SSH-Aktionen reichten nicht: der Agent bleibt OFF.'),
    'SSH injoignable (agent joignable en XMPP)': ('SSH unreachable (agent reachable by XMPP)', 'SSH inaccesible (agente accesible por XMPP)', 'SSH nicht erreichbar (Agent per XMPP erreichbar)'),
    "SSH injoignable depuis le serveur alors que l'agent repond en XMPP : {}": ('SSH unreachable from the server although the agent answers by XMPP: {}', 'SSH inaccesible desde el servidor aunque el agente responde por XMPP: {}', 'SSH vom Server nicht erreichbar, obwohl der Agent per XMPP antwortet: {}'),
    'STATISTIQUES DETAILLEES (contenu de statistiques.csv)': ('DETAILED STATISTICS (content of statistiques.csv)', 'ESTADÍSTICAS DETALLADAS (contenido de statistiques.csv)', 'DETAILLIERTE STATISTIK (Inhalt von statistiques.csv)'),
    'Se reconnecte en boucle ({} fois en 1 h) : {}': ('Reconnecting in a loop ({} times in 1 h): {}', 'Se reconecta en bucle ({} veces en 1 h): {}', 'Verbindet sich in einer Schleife neu ({}-mal in 1 h): {}'),
    'Se reconnecte en boucle ({} fois en 1 h, uptime_machine)': ('Reconnects in a loop ({} times in 1 h, uptime_machine)', 'Se reconecta en bucle ({} veces en 1 h, uptime_machine)', 'Verbindet sich in Schleife neu ({}-mal in 1 h, uptime_machine)'),
    'Securite': ('Security', 'Seguridad', 'Sicherheit'),
    "Selection    : machines enabled = 1, agenttype = 'machine', uuid_inventorymachine vide": ("Selection    : machines enabled = 1, agenttype = 'machine', uuid_inventorymachine empty", "Selección    : equipos enabled = 1, agenttype = 'machine', uuid_inventorymachine vacío", "Auswahl      : Rechner enabled = 1, agenttype = 'machine', uuid_inventorymachine leer"),
    'Serveur      : Medulla {}': ('Server       : Medulla {}', 'Servidor     : Medulla {}', 'Server       : Medulla {}'),
    'Serveur Medulla': ('Medulla server', 'Servidor Medulla', 'Medulla-Server'),
    'Serveur Medulla < 5.6.4 : reset indisponible.': ('Medulla server < 5.6.4: reset unavailable.', 'Servidor Medulla < 5.6.4: reinicialización no disponible.', 'Medulla-Server < 5.6.4: Reset nicht verfügbar.'),
    'Serveur Medulla teste depuis le poste': ('Medulla server tested from the computer', 'Servidor Medulla probado desde el equipo', 'Vom Rechner getesteter Medulla-Server'),
    "Serveur XMPP de l'agent (agentconf.ini)": ("Agent's XMPP server (agentconf.ini)", 'Servidor XMPP del agente (agentconf.ini)', 'XMPP-Server des Agenten (agentconf.ini)'),
    'Serveur XMPP injoignable depuis le poste': ('XMPP server unreachable from the computer', 'Servidor XMPP inaccesible desde el equipo', 'XMPP-Server vom Rechner nicht erreichbar'),
    'Serveur XMPP injoignable depuis le poste ({} : {}) : reseau, pare-feu ou serveur ejabberd': ('XMPP server unreachable from the computer ({}: {}): network, firewall or ejabberd server', 'Servidor XMPP inaccesible desde el equipo ({}: {}): red, cortafuegos o servidor ejabberd', 'XMPP-Server vom Rechner nicht erreichbar ({}: {}): Netzwerk, Firewall oder ejabberd-Server'),
    'Serveurs XMPP testes depuis les postes (postes qui le joignent)': ('XMPP servers tested from computers (computers reaching it)', 'Servidores XMPP probados desde los equipos (equipos que lo alcanzan)', 'Von den Rechnern getestete XMPP-Server (Rechner, die ihn erreichen)'),
    'Service': ('Service', 'Servicio', 'Dienst'),
    'Service annexe absent ou desactive': ('Auxiliary service missing or disabled', 'Servicio auxiliar ausente o deshabilitado', 'Zusatzdienst fehlt oder deaktiviert'),
    'Service annexe arrete (medullanetnotify, sshdaemon)': ('Auxiliary service stopped (medullanetnotify, sshdaemon)', 'Servicio auxiliar detenido (medullanetnotify, sshdaemon)', 'Zusatzdienst gestoppt (medullanetnotify, sshdaemon)'),
    'Service medullaagent': ('medullaagent service', 'Servicio medullaagent', 'Dienst medullaagent'),
    'Service medullaagent absent': ('medullaagent service missing', 'Falta el servicio medullaagent', 'Dienst medullaagent fehlt'),
    'Service medullaagent arrete': ('medullaagent service stopped', 'Servicio medullaagent detenido', 'Dienst medullaagent gestoppt'),
    'Service medullaagent demarre': ('medullaagent service running', 'Servicio medullaagent en ejecución', 'Dienst medullaagent läuft'),
    'Service medullaagent desactive': ('medullaagent service disabled', 'Servicio medullaagent deshabilitado', 'Dienst medullaagent deaktiviert'),
    'Service medullaagent {}': ('medullaagent service {}', 'Servicio medullaagent {}', 'Dienst medullaagent {}'),
    'Service medullanetnotify': ('medullanetnotify service', 'Servicio medullanetnotify', 'Dienst medullanetnotify'),
    'Service medullanetnotify arrete, absent ou desactive': ('medullanetnotify service stopped, missing or disabled', 'Servicio medullanetnotify detenido, ausente o deshabilitado', 'Dienst medullanetnotify gestoppt, fehlt oder deaktiviert'),
    'Service medullanetnotify demarre': ('medullanetnotify service running', 'Servicio medullanetnotify en ejecución', 'Dienst medullanetnotify läuft'),
    'Service sshdaemon (OpenSSH Medulla)': ('sshdaemon service (Medulla OpenSSH)', 'Servicio sshdaemon (OpenSSH de Medulla)', 'Dienst sshdaemon (Medulla-OpenSSH)'),
    'Service sshdaemon (OpenSSH Medulla) demarre': ('sshdaemon service (Medulla OpenSSH) running', 'Servicio sshdaemon (OpenSSH de Medulla) en ejecución', 'Dienst sshdaemon (Medulla-OpenSSH) läuft'),
    'Service sshdaemon arrete, absent ou desactive': ('sshdaemon service stopped, missing or disabled', 'Servicio sshdaemon detenido, ausente o deshabilitado', 'Dienst sshdaemon gestoppt, fehlt oder deaktiviert'),
    'Service sshdaemon du poste, pare-feu Windows, port SSH (FAQ : Changer le port SSH)': ('sshdaemon service on the computer, Windows firewall, SSH port (FAQ: Change the SSH port)', 'Servicio sshdaemon del equipo, cortafuegos de Windows, puerto SSH (FAQ: Cambiar el puerto SSH)', 'Dienst sshdaemon des Rechners, Windows-Firewall, SSH-Port (FAQ: SSH-Port ändern)'),
    'Service {} absent': ('{} service missing', 'Falta el servicio {}', 'Dienst {} fehlt'),
    'Service {} desactive': ('{} service disabled', 'Servicio {} deshabilitado', 'Dienst {} deaktiviert'),
    'Service {} {}': ('Service {} {}', 'Servicio {} {}', 'Dienst {} {}'),
    'Si le poste est toujours OFF apres la relance, un reset pourra ensuite etre demande': ('If the computer is still OFF after the restart, a reset may then be requested', 'Si el equipo sigue OFF tras el reinicio, podrá solicitarse una reinicialización', 'Ist der Rechner nach dem Neustart weiter OFF, kann danach ein Reset angefordert werden'),
    "Substitut d'inventaire master_inv non connecte a ejabberd : aucun inventaire n'est transmis a GLPI": ('Inventory substitute master_inv not connected to ejabberd: no inventory is sent to GLPI', 'Sustituto de inventario master_inv no conectado a ejabberd: ningún inventario se transmite a GLPI', 'Inventur-Substitut master_inv nicht mit ejabberd verbunden: keine Inventur wird an GLPI übermittelt'),
    'Substitut master_reconf non connecte a ejabberd : reconfiguration impossible.': ('Substitute master_reconf not connected to ejabberd: reconfiguration impossible.', 'Sustituto master_reconf no conectado a ejabberd: reconfiguración imposible.', 'Substitut master_reconf nicht mit ejabberd verbunden: Neukonfiguration unmöglich.'),
    'Supprimer la fiche obsolete, puis reset si la boucle continue': ('Delete the obsolete record, then reset if the loop continues', 'Eliminar la ficha obsoleta y reinicializar si el bucle continúa', 'Veralteten Eintrag löschen, dann Reset, falls die Schleife weitergeht'),
    'Sur chaque poste : arret du service, sauvegarde des fichiers modifies, correction,': ('On each computer: service stop, backup of changed files, fix,', 'En cada equipo: parada del servicio, copia de los archivos modificados, corrección,', 'Auf jedem Rechner: Dienst stoppen, geänderte Dateien sichern, korrigieren,'),
    "Sur chaque poste : sc stop medullaagent, attente de l'arret, sc start medullaagent,": ('On each computer: sc stop medullaagent, wait for the stop, sc start medullaagent,', 'En cada equipo: sc stop medullaagent, espera de la parada, sc start medullaagent,', 'Auf jedem Rechner: sc stop medullaagent, Warten auf den Stopp, sc start medullaagent,'),
    "Sur chaque poste, l'agent lance son agent d'inventaire (comme l'action rapide Inventaire de": ('On each computer, the agent runs its inventory agent (like the Inventory quick action of', 'En cada equipo, el agente ejecuta su agente de inventario (como la acción rápida Inventario de', 'Auf jedem Rechner startet der Agent seinen Inventuragenten (wie die Schnellaktion Inventur von'),
    'Sur un total de': ('Out of a total of', 'Sobre un total de', 'Von insgesamt'),
    'Synthese (IP uniques)': ('Summary (unique IPs)', 'Resumen (IP únicas)', 'Zusammenfassung (eindeutige IPs)'),
    'Synthese (dedoublonnee)': ('Summary (deduplicated)', 'Resumen (sin duplicados)', 'Zusammenfassung (dedupliziert)'),
    'Synthese (postes uniques)': ('Summary (unique computers)', 'Resumen (equipos únicos)', 'Zusammenfassung (eindeutige Rechner)'),
    'Systeme': ('System', 'Sistema', 'System'),
    'Systeme du poste': ('Computer system', 'Sistema del equipo', 'System des Rechners'),
    "Systeme non supporte par l'agent": ('System not supported by the agent', 'Sistema no soportado por el agente', 'System vom Agenten nicht unterstützt'),
    "Systeme non supporte par l'agent ({})": ('System not supported by the agent ({})', 'Sistema no soportado por el agente ({})', 'System vom Agenten nicht unterstützt ({})'),
    "Systeme supporte par l'agent": ('System supported by the agent', 'Sistema soportado por el agente', 'System vom Agenten unterstützt'),
    'TIMEOUT': ('TIMEOUT', 'TIMEOUT', 'TIMEOUT'),
    "TOUJOURS SANS UUID D'INVENTAIRE": ('STILL WITHOUT INVENTORY UUID', 'TODAVÍA SIN UUID DE INVENTARIO', 'WEITERHIN OHNE INVENTUR-UUID'),
    'Taille (Ko)': ('Size (KB)', 'Tamaño (KB)', 'Größe (KB)'),
    'Taper OUI (en majuscules) pour {}, ou Entree pour annuler :': ('Type YES (in capitals) to {}, or Enter to cancel:', 'Escriba SI (en mayúsculas) para {}, o Intro para cancelar:', 'JA (in Großbuchstaben) eingeben, um {}, oder Eingabe zum Abbrechen:'),
    'Temps de ping (ms)': ('Ping time (ms)', 'Tiempo de ping (ms)', 'Ping-Zeit (ms)'),
    'Test': ('Test', 'Prueba', 'Test'),
    'Test serveur {} en erreur : {}': ('Server test {} failed: {}', 'Prueba de servidor {} con error: {}', 'Servertest {} fehlerhaft: {}'),
    'Test {} en erreur : {}': ('Test {} failed: {}', 'Prueba {} con error: {}', 'Test {} fehlerhaft: {}'),
    'Toujours introuvable apres installation :': ('Still not found after installation:', 'Todavía no encontrado tras la instalación:', 'Nach der Installation immer noch nicht gefunden:'),
    'Tous les ports TCP vers le relais': ('All TCP ports to the relay', 'Todos los puertos TCP hacia el relé', 'Alle TCP-Ports zum Relay'),
    'Tous les ports TCP vers le serveur Medulla': ('All TCP ports to the Medulla server', 'Todos los puertos TCP hacia el servidor Medulla', 'Alle TCP-Ports zum Medulla-Server'),
    'Traces (ejabberd, master-*.log, table logs) : enquete_postes_injoignables.csv': ('Traces (ejabberd, master-*.log, logs table): enquete_postes_injoignables.csv', 'Trazas (ejabberd, master-*.log, tabla logs): enquete_postes_injoignables.csv', 'Spuren (ejabberd, master-*.log, Tabelle logs): enquete_postes_injoignables.csv'),
    'Traces dans les journaux serveur, cause non reconnue (voir traces_serveur)': ('Traces in the server logs, cause not recognised (see traces_serveur)', 'Trazas en los registros del servidor, causa no reconocida (vea traces_serveur)', 'Spuren in den Serverprotokollen, Ursache nicht erkannt (siehe traces_serveur)'),
    'Traces serveur a analyser': ('Server traces to analyse', 'Trazas del servidor por analizar', 'Serverspuren zu analysieren'),
    'Traiter aussi ces {} machine(s) qui fonctionnent ?': ('Also process these {} working machine(s)?', '¿Tratar también estas {} máquina(s) que funcionan?', 'Diese {} funktionierenden Rechner ebenfalls bearbeiten?'),
    'Traites': ('Processed', 'Tratados', 'Bearbeitet'),
    'Transports': ('Transports', 'Transportes', 'Transportwege'),
    'Transports   :': ('Transports   :', 'Transportes  :', 'Transportwege:'),
    'Type de demarrage': ('Start type', 'Tipo de inicio', 'Starttyp'),
    'Type de démarrage du service': ('Service start type', 'Tipo de inicio del servicio', 'Starttyp des Dienstes'),
    'UDP vers relais (envoi seulement, non verifiable)': ('UDP to the relay (send only, not verifiable)', 'UDP hacia el relé (solo envío, no verificable)', 'UDP zum Relay (nur Senden, nicht prüfbar)'),
    'UDP vers serveur Medulla (envoi seulement, non verifiable)': ('UDP to the Medulla server (send only, not verifiable)', 'UDP hacia el servidor Medulla (solo envío, no verificable)', 'UDP zum Medulla-Server (nur Senden, nicht prüfbar)'),
    "URL d'envoi des inventaires vers GLPI injoignable ({} : {})": ('URL sending inventories to GLPI unreachable ({}: {})', 'URL de envío de inventarios a GLPI inaccesible ({}: {})', 'URL für den Inventurversand an GLPI nicht erreichbar ({}: {})'),
    "UUID d'inventaire": ('Inventory UUID', 'UUID de inventario', 'Inventur-UUID'),
    "UUID d'inventaire apres": ('Inventory UUID after', 'UUID de inventario después', 'Inventur-UUID danach'),
    'UUID materiel absent de Medulla (rattachement GLPI par MAC seulement)': ('Hardware UUID missing from Medulla (GLPI matching by MAC only)', 'UUID de hardware ausente de Medulla (vinculación GLPI solo por MAC)', 'Hardware-UUID fehlt in Medulla (GLPI-Zuordnung nur per MAC)'),
    'UUID materiel connu de Medulla': ('Hardware UUID known to Medulla', 'UUID de hardware conocido por Medulla', 'Hardware-UUID in Medulla'),
    'UUID materiel different entre Medulla et le poste': ('Hardware UUID differs between Medulla and the computer', 'UUID de hardware distinto entre Medulla y el equipo', 'Hardware-UUID unterscheidet sich zwischen Medulla und Rechner'),
    'UUID materiel du poste (SMBIOS)': ('Computer hardware UUID (SMBIOS)', 'UUID de hardware del equipo (SMBIOS)', 'Hardware-UUID des Rechners (SMBIOS)'),
    'Une demande de reset est inscrite dans xmppmaster.reset_machine': ('A reset request is written to xmppmaster.reset_machine', 'Se inscribe una solicitud de reinicialización en xmppmaster.reset_machine', 'Eine Reset-Anfrage wird in xmppmaster.reset_machine eingetragen'),
    'Une demande de reset est inscrite dans xmppmaster.reset_machine pour chaque machine.': ('A reset request is written to xmppmaster.reset_machine for each machine.', 'Se inscribe una solicitud de reinicialización en xmppmaster.reset_machine para cada máquina.', 'Für jeden Rechner wird eine Reset-Anfrage in xmppmaster.reset_machine eingetragen.'),
    'VERIFICATION DES CORRECTIONS : {} poste(s)': ('CHECKING THE FIXES: {} computer(s)', 'VERIFICACIÓN DE LAS CORRECCIONES: {} equipo(s)', 'PRÜFUNG DER KORREKTUREN: {} Rechner'),
    'VIDE': ('EMPTY', 'VACÍO', 'LEER'),
    'Valeurs IP invalides distinctes (non testees)': ('Distinct invalid IP values (not tested)', 'Valores de IP no válidos distintos (no probados)', 'Verschiedene ungültige IP-Werte (nicht getestet)'),
    'Verification impossible : {}': ('Check impossible: {}', 'Verificación imposible: {}', 'Prüfung unmöglich: {}'),
    'Verification interrompue.': ('Check interrupted.', 'Verificación interrumpida.', 'Prüfung unterbrochen.'),
    'Verification manuelle (lien ou fichier a la place du dossier)': ('Manual check (link or file instead of the folder)', 'Revisión manual (enlace o archivo en lugar de la carpeta)', 'Manuelle Prüfung (Link oder Datei statt Ordner)'),
    'Verification manuelle de agentxmpp.py': ('Manual check of agentxmpp.py', 'Revisión manual de agentxmpp.py', 'Manuelle Prüfung von agentxmpp.py'),
    "Verifier GLPI et url_to_forward (FAQ : Verifier que l'inventaire remonte bien dans Medulla)": ('Check GLPI and url_to_forward (FAQ: Check that the inventory reaches Medulla)', 'Verificar GLPI y url_to_forward (FAQ: Verificar que el inventario llega a Medulla)', 'GLPI und url_to_forward prüfen (FAQ: Prüfen, ob die Inventur in Medulla ankommt)'),
    'Verifier ces postes (clonage) ; supprimer les fiches en trop': ('Check these computers (cloning); delete the extra records', 'Verificar estos equipos (clonación); eliminar las fichas sobrantes', 'Diese Rechner prüfen (Klonen); überzählige Einträge löschen'),
    'Verifier le pare-feu Windows du poste (port 22 entrant) et la cle de pulseuser': ("Check the computer's Windows firewall (inbound port 22) and pulseuser's key", 'Verificar el cortafuegos de Windows del equipo (puerto 22 entrante) y la clave de pulseuser', 'Windows-Firewall des Rechners (eingehender Port 22) und den Schlüssel von pulseuser prüfen'),
    'Verifier le poste et les flux (FAQ : Test de flux)': ('Check the computer and the traffic (FAQ: Traffic test)', 'Verificar el equipo y los flujos (FAQ: Prueba de flujos)', 'Rechner und Verbindungen prüfen (FAQ: Verbindungstest)'),
    'Verifier le relais du poste (groupdeploy) et son service (FAQ : hors ligne, point 10)': ("Check the computer's relay (groupdeploy) and its service (FAQ: offline, item 10)", 'Verificar el relé del equipo (groupdeploy) y su servicio (FAQ: fuera de línea, punto 10)', 'Relay des Rechners (groupdeploy) und dessen Dienst prüfen (FAQ: offline, Punkt 10)'),
    'Verifier le reseau du poste': ("Check the computer's network", 'Verificar la red del equipo', 'Netzwerk des Rechners prüfen'),
    'Verifier le reseau du poste et le pare-feu (ports 22 et 5222) ; nmap -Pn -p 22 <IP>': ("Check the computer's network and firewall (ports 22 and 5222); nmap -Pn -p 22 <IP>", 'Verificar la red del equipo y el cortafuegos (puertos 22 y 5222); nmap -Pn -p 22 <IP>', 'Netzwerk und Firewall des Rechners prüfen (Ports 22 und 5222); nmap -Pn -p 22 <IP>'),
    "Verifier les droits (SYSTEM, Administrateurs) et l'EDR": ('Check permissions (SYSTEM, Administrators) and the EDR', 'Verificar los permisos (SYSTEM, Administradores) y el EDR', 'Rechte (SYSTEM, Administratoren) und EDR prüfen'),
    "Verifier les postes clones et les agents en double (deux services ou deux postes avec ce nom) ; le reset n'y changera rien": ('Check cloned computers and duplicate agents (two services or two computers with this name); a reset will not help', 'Verificar los equipos clonados y los agentes duplicados (dos servicios o dos equipos con este nombre); la reinicialización no cambiará nada', 'Geklonte Rechner und doppelte Agenten prüfen (zwei Dienste oder zwei Rechner mit diesem Namen); ein Reset hilft nicht'),
    "Verifier les postes portant ce nom ; le reset n'y changera rien": ('Check the computers with this name; a reset will not help', 'Verificar los equipos con este nombre; la reinicialización no cambiará nada', 'Rechner mit diesem Namen prüfen; ein Reset hilft nicht'),
    'Verifier medullaagent et le flux 5222 ; machine hors ligne : FAQ point 1 a 4': ('Check medullaagent and port 5222 traffic; offline machine: FAQ items 1 to 4', 'Verificar medullaagent y el flujo 5222; equipo desconectado: FAQ puntos 1 a 4', 'medullaagent und Verkehr 5222 prüfen; Rechner offline: FAQ Punkte 1 bis 4'),
    "Verifier puis supprimer apres sauvegarde (FAQ : Pourquoi une machine ne s'enregistre-t-elle pas ? point 5)": ('Check, then delete after a backup (FAQ: Why does a machine not register? item 5)', 'Verificar y luego eliminar tras una copia de seguridad (FAQ: ¿Por qué no se registra un equipo? punto 5)', 'Prüfen, dann nach Sicherung löschen (FAQ: Warum registriert sich ein Rechner nicht? Punkt 5)'),
    "Verifier que l'agent est installe et configure sur le poste": ('Check that the agent is installed and configured on the computer', 'Verificar que el agente está instalado y configurado en el equipo', 'Prüfen, ob der Agent auf dem Rechner installiert und konfiguriert ist'),
    'Verifier que le poste est allume ; nmap -Pn -p 22 <IP> ; pare-feu': ('Check that the computer is on; nmap -Pn -p 22 <IP>; firewall', 'Verificar que el equipo está encendido; nmap -Pn -p 22 <IP>; cortafuegos', 'Prüfen, ob der Rechner an ist; nmap -Pn -p 22 <IP>; Firewall'),
    'Verifier que le poste est allume ; sinon le pare-feu (ports 22 et 5222)': ('Check that the computer is on; otherwise the firewall (ports 22 and 5222)', 'Verificar que el equipo está encendido; si no, el cortafuegos (puertos 22 y 5222)', 'Prüfen, ob der Rechner an ist; sonst die Firewall (Ports 22 und 5222)'),
    'Verifier que le poste est allume et son IP ; sinon le filtrage entre le serveur et ce sous-reseau': ('Check that the computer is on and its IP; otherwise the filtering between the server and this subnet', 'Verificar que el equipo está encendido y su IP; si no, el filtrado entre el servidor y esta subred', 'Prüfen, ob der Rechner an ist und seine IP; sonst die Filterung zwischen Server und diesem Subnetz'),
    "Verifier que le poste existe encore (GLPI, AD) ; supprimer la fiche s'il est retire": ('Check that the computer still exists (GLPI, AD); delete the record if it was removed', 'Verificar que el equipo sigue existiendo (GLPI, AD); eliminar la ficha si se retiró', 'Prüfen, ob der Rechner noch existiert (GLPI, AD); Eintrag löschen, falls entfernt'),
    'Version': ('Version', 'Versión', 'Version'),
    'Version GLPI-Agent': ('GLPI-Agent version', 'Versión GLPI-Agent', 'GLPI-Agent-Version'),
    "Version Python de l'agent": ("Agent's Python version", 'Versión de Python del agente', 'Python-Version des Agenten'),
    'Version agent': ('Agent version', 'Versión del agente', 'Agent-Version'),
    "Version de l'agent": ('Agent version', 'Versión del agente', 'Agent-Version'),
    "Version de l'agent (audits complets)": ('Agent version (complete audits)', 'Versión del agente (auditorías completas)', 'Agent-Version (vollständige Audits)'),
    "Version de l'agent a jour (serveur)": ('Agent version up to date (server)', 'Versión del agente actualizada (servidor)', 'Agent-Version aktuell (Server)'),
    "Version de l'agent d'inventaire": ('Inventory agent version', 'Versión del agente de inventario', 'Version des Inventuragenten'),
    'Version de la reference': ('Reference version', 'Versión de la referencia', 'Version der Referenz'),
    'Version du serveur Medulla': ('Medulla server version', 'Versión del servidor Medulla', 'Version des Medulla-Servers'),
    'Votre choix [1] :': ('Your choice [1]:', 'Su elección [1]:', 'Ihre Wahl [1]:'),
    'Vu ON dans Medulla le {} : coupure reseau ou pare-feu entre serveur et poste': ('Seen ON in Medulla on {}: network outage or firewall between server and computer', 'Visto ON en Medulla el {}: corte de red o cortafuegos entre servidor y equipo', 'Am {} in Medulla ON gesehen: Netzwerkausfall oder Firewall zwischen Server und Rechner'),
    'XMPP (ejabberd {}:{}, plugin shellcommand)': ('XMPP (ejabberd {}:{}, shellcommand plugin)', 'XMPP (ejabberd {}:{}, plugin shellcommand)', 'XMPP (ejabberd {}:{}, Plugin shellcommand)'),
    "XMPP (sans arret de l'agent, aucun fichier modifie), puis SSH est reteste.": ('XMPP (agent not stopped, no file changed), then SSH is tested again.', 'XMPP (sin detener el agente, ningún archivo modificado), y SSH se vuelve a probar.', 'XMPP gestartet (Agent nicht gestoppt, keine Datei geändert), dann wird SSH erneut getestet.'),
    'XMPP : echec avec le mecanisme {} ({})': ('XMPP: failure with mechanism {} ({})', 'XMPP: fallo con el mecanismo {} ({})', 'XMPP: Fehler mit Mechanismus {} ({})'),
    'XMPP : session ouverte (mecanisme {})': ('XMPP: session open (mechanism {})', 'XMPP: sesión abierta (mecanismo {})', 'XMPP: Sitzung geöffnet (Mechanismus {})'),
    'XMPP indisponible ({}) : repli SSH pour les machines ON.': ('XMPP unavailable ({}): SSH fallback for ON machines.', 'XMPP no disponible ({}): respaldo SSH para los equipos ON.', 'XMPP nicht verfügbar ({}): SSH als Ausweichweg für ON-Rechner.'),
    '[deja ON]': ('[already ON]', '[ya ON]', '[bereits ON]'),
    '[xmpp {:.1f} s] {} -> {} {}': ('[xmpp {:.1f} s] {} -> {} {}', '[xmpp {:.1f} s] {} -> {} {}', '[xmpp {:.1f} s] {} -> {} {}'),
    '[xmpp] pas de reponse en {} s : commande renvoyee ({}e envoi)': ('[xmpp] no answer in {} s: command sent again (send no. {})', '[xmpp] sin respuesta en {} s: comando reenviado (envío n.º {})', '[xmpp] keine Antwort in {} s: Befehl erneut gesendet ({}. Sendung)'),
    'a ete lance apres la modification de la blacklist.': ('was run after the blacklist was changed.', 'se lanzó después de modificar la lista negra.', 'nach der Änderung der Sperrliste ausgeführt wurde.'),
    'absente (controle desactive)': ('missing (check disabled)', 'ausente (control desactivado)', 'fehlt (Prüfung deaktiviert)'),
    'action lancee sur le poste (tache MedullaDiag_{} :': ('action started on the computer (task MedullaDiag_{}:', 'acción iniciada en el equipo (tarea MedullaDiag_{}:', 'Aktion auf dem Rechner gestartet (Aufgabe MedullaDiag_{}:'),
    "agent d'inventaire absent du poste": ('inventory agent missing from the computer', 'agente de inventario ausente del equipo', 'Inventuragent fehlt auf dem Rechner'),
    'agent non connecte a ejabberd': ('agent not connected to ejabberd', 'agente no conectado a ejabberd', 'Agent nicht mit ejabberd verbunden'),
    'agent sans reponse ou autre poste': ('agent not answering or other computer', 'agente sin respuesta u otro equipo', 'Agent antwortet nicht oder anderer Rechner'),
    'agent sans reponse par XMPP': ('agent not answering by XMPP', 'agente sin respuesta por XMPP', 'Agent antwortet nicht per XMPP'),
    'agentconf.ini a reprendre': ('agentconf.ini to fix', 'agentconf.ini por corregir', 'agentconf.ini zu korrigieren'),
    'agentconf.ini absent ou invalide': ('agentconf.ini missing or invalid', 'agentconf.ini ausente o no válido', 'agentconf.ini fehlt oder ungültig'),
    'agentconf.ini encodage non reconnu': ('agentconf.ini encoding not recognised', 'codificación de agentconf.ini no reconocida', 'Kodierung von agentconf.ini nicht erkannt'),
    'agentconf.ini valide': ('agentconf.ini valid', 'agentconf.ini válido', 'agentconf.ini gültig'),
    'agentconf.ini {}': ('agentconf.ini {}', 'agentconf.ini {}', 'agentconf.ini {}'),
    'agentconf.ini.local invalide': ('agentconf.ini.local invalid', 'agentconf.ini.local no válido', 'agentconf.ini.local ungültig'),
    'agentconf.ini.local valide ou absent': ('agentconf.ini.local valid or absent', 'agentconf.ini.local válido o ausente', 'agentconf.ini.local gültig oder nicht vorhanden'),
    'agentrescue\\lib (audits complets)': ('agentrescue\\lib (complete audits)', 'agentrescue\\lib (auditorías completas)', 'agentrescue\\lib (vollständige Audits)'),
    'agentrescue\\lib : detail': ('agentrescue\\lib: detail', 'agentrescue\\lib: detalle', 'agentrescue\\lib: Detail'),
    'agentrescue\\lib : fichiers differents du serveur': ('agentrescue\\lib: files different from the server', 'agentrescue\\lib: archivos distintos del servidor', 'agentrescue\\lib: vom Server abweichende Dateien'),
    'agentrescue\\lib : fichiers manquants ou vides': ('agentrescue\\lib: missing or empty files', 'agentrescue\\lib: archivos ausentes o vacíos', 'agentrescue\\lib: fehlende oder leere Dateien'),
    'agentrescue\\lib identique au serveur': ('agentrescue\\lib identical to the server', 'agentrescue\\lib idéntico al servidor', 'agentrescue\\lib identisch mit dem Server'),
    'agentxmpp.py principal absent': ('Main agentxmpp.py missing', 'Falta agentxmpp.py principal', 'Haupt-agentxmpp.py fehlt'),
    'apt-get introuvable : installer ces paquets manuellement.': ('apt-get not found: install these packages manually.', 'apt-get no encontrado: instale estos paquetes manualmente.', 'apt-get nicht gefunden: diese Pakete manuell installieren.'),
    'arrete). Pour les postes sans UUID, le rattachement dans Medulla est ensuite controle.': ('stopped). For computers with no UUID, the matching in Medulla is then checked.', 'detenido). Para los equipos sin UUID, se controla luego la vinculación en Medulla.', 'gestoppt). Bei Rechnern ohne UUID wird danach die Zuordnung in Medulla geprüft.'),
    'arrete. Medulla rattache ensuite la machine a son inventaire GLPI.': ('stopped. Medulla then matches the machine with its GLPI inventory.', 'detiene. Medulla vincula luego el equipo a su inventario GLPI.', 'gestoppt. Medulla ordnet den Rechner danach seiner GLPI-Inventur zu.'),
    "attente du demarrage. Aucun fichier n'est modifie.": ('wait for the start. No file is changed.', 'espera del arranque. No se modifica ningún archivo.', 'Warten auf den Start. Keine Datei wird geändert.'),
    'au retour ON': ('when back ON', 'al volver a ON', 'bei Rückkehr auf ON'),
    'aucun inventaire produit sur le poste': ('no inventory produced on the computer', 'ningún inventario generado en el equipo', 'keine Inventur auf dem Rechner erstellt'),
    'aucun probleme': ('no problem', 'ningún problema', 'kein Problem'),
    'aucun probleme detecte': ('no problem detected', 'ningún problema detectado', 'kein Problem erkannt'),
    "audit de l'inventaire impossible": ('inventory audit impossible', 'auditoría del inventario imposible', 'Inventur-Audit unmöglich'),
    'audit {}  {:<20}': ('audit {}  {:<20}', 'audit. {} {:<20}', 'Audit {}  {:<20}'),
    'authentification XMPP refusee avec le compte temporaire (': ('XMPP authentication refused with the temporary account (', 'autenticación XMPP rechazada con la cuenta temporal (', 'XMPP-Authentifizierung mit dem temporären Konto abgelehnt ('),
    'authentification refusee': ('authentication refused', 'autenticación rechazada', 'Authentifizierung abgelehnt'),
    "avec --full : ces postes ne produisent aucun inventaire tant que GLPI-Agent n'est pas mis a jour.": ('with --full: these computers produce no inventory until GLPI-Agent is updated.', 'con --full: estos equipos no generan ningún inventario mientras GLPI-Agent no se actualice.', 'mit --full: diese Rechner erstellen keine Inventur, solange GLPI-Agent nicht aktualisiert ist.'),
    'blacklisted_mac_addresses sans 00:00:00:00:00:00 (registeryagent.ini.local)': ('blacklisted_mac_addresses without 00:00:00:00:00:00 (registeryagent.ini.local)', 'blacklisted_mac_addresses sin 00:00:00:00:00:00 (registeryagent.ini.local)', 'blacklisted_mac_addresses ohne 00:00:00:00:00:00 (registeryagent.ini.local)'),
    'cause non identifiee cote serveur': ('cause not identified on the server side', 'causa no identificada del lado del servidor', 'Ursache serverseitig nicht ermittelt'),
    'connexion XMPP impossible a {}:{} en 30 s': ('XMPP connection to {}:{} impossible within 30 s', 'conexión XMPP imposible a {}:{} en 30 s', 'XMPP-Verbindung zu {}:{} innerhalb von 30 s unmöglich'),
    "controle serveur '{}' impossible : {}": ("server check '{}' impossible: {}", "control de servidor '{}' imposible: {}", "Serverprüfung '{}' unmöglich: {}"),
    'controle serveur {}': ('server check {}', 'control del servidor {}', 'Serverprüfung {}'),
    'controle serveur {} : {}': ('server check {}: {}', 'control del servidor {}: {}', 'Serverprüfung {}: {}'),
    'copie (manquant)': ('copy (missing)', 'copia (ausente)', 'kopieren (fehlt)'),
    'correction': ('fix', 'corrección', 'Korrektur'),
    "corrige l'agent, puis demande d'inventaire": ('fixes the agent, then requests an inventory', 'corrige el agente y luego solicita un inventario', 'korrigiert den Agenten, fordert dann eine Inventur an'),
    'corrige, relance le service, reset (une seule IP)': ('fixes, restarts the service, reset (single IP)', 'corrige, reinicia el servicio, reinicialización (una sola IP)', 'korrigiert, startet den Dienst neu, Reset (nur eine IP)'),
    'corrige, relance le service, reset (une seule machine)': ('fixes, restarts the service, reset (single machine)', 'corrige, reinicia el servicio, reinicialización (un solo equipo)', 'korrigiert, startet den Dienst neu, Reset (nur ein Rechner)'),
    'corriger {} poste(s)': ('fix {} computer(s)', 'corregir {} equipo(s)', '{} Rechner zu korrigieren'),
    'creation du compte XMPP temporaire impossible :': ('cannot create the temporary XMPP account:', 'imposible crear la cuenta XMPP temporal:', 'temporäres XMPP-Konto kann nicht angelegt werden:'),
    "d'enregistrement : la blacklist n'est pas encore prise en compte. Lancer": ('substitute: the blacklist is not applied yet. Run', 'de registro: la lista negra aún no se aplica. Lanzar', 'Substituts geändert: die Sperrliste gilt noch nicht. Ausführen:'),
    "d'inventaire en erreur sur le poste (voir colonne erreurs du CSV).": ('agent in error on the computer (see the errors column of the CSV).', 'de inventario con error en el equipo (vea la columna de errores del CSV).', 'agent mit Fehler auf dem Rechner (siehe Fehlerspalte der CSV).'),
    'deja ON dans Medulla : fiche OFF obsolete, poste non audite': ('already ON in Medulla: obsolete OFF record, computer not audited', 'ya ON en Medulla: ficha OFF obsoleta, equipo no auditado', 'in Medulla bereits ON: veralteter OFF-Eintrag, Rechner nicht geprüft'),
    "demande d'inventaire possible": ('inventory request possible', 'solicitud de inventario posible', 'Inventuranforderung möglich'),
    'demandee (--reconfigurer)': ('requested (--reconfigurer)', 'solicitada (--reconfigurer)', 'angefordert (--reconfigurer)'),
    'demander le reset de {} agent(s)': ('request the reset of {} agent(s)', 'solicitar la reinicialización de {} agente(s)', 'den Reset von {} Agent(en) anzufordern'),
    'demander un inventaire a {} machine(s)': ('request an inventory from {} machine(s)', 'solicitar un inventario a {} equipo(s)', 'eine Inventur von {} Rechner(n) anzufordern'),
    'demander un inventaire a {} poste(s)': ('request an inventory from {} computer(s)', 'solicitar un inventario a {} equipo(s)', 'eine Inventur von {} Rechner(n) anzufordern'),
    'demarrer SSH sur {} poste(s)': ('start SSH on {} computer(s)', 'iniciar SSH en {} equipo(s)', 'SSH auf {} Rechner(n) zu starten'),
    'depot par XMPP : {:.0f} Ko ({:.0f} Ko envoyes en {} bloc(s), {:.0f} s, {:.0f} Ko/s)': ('XMPP upload: {:.0f} KB ({:.0f} KB sent in {} block(s), {:.0f} s, {:.0f} KB/s)', 'depósito por XMPP: {:.0f} KB ({:.0f} KB enviados en {} bloque(s), {:.0f} s, {:.0f} KB/s)', 'Ablage per XMPP: {:.0f} KB ({:.0f} KB in {} Block/Blöcken gesendet, {:.0f} s, {:.0f} KB/s)'),
    'deux connexions avec le meme compte XMPP (ejabberd : Replaced by new connection) : poste clone ou deux agents': ('two connections with the same XMPP account (ejabberd: Replaced by new connection): cloned computer or two agents', 'dos conexiones con la misma cuenta XMPP (ejabberd: Replaced by new connection): equipo clonado o dos agentes', 'zwei Verbindungen mit demselben XMPP-Konto (ejabberd: Replaced by new connection): geklonter Rechner oder zwei Agenten'),
    'dont SSH OK malgre tout (ICMP filtre)': ('of which SSH OK anyway (ICMP filtered)', 'de los cuales SSH OK de todos modos (ICMP filtrado)', 'davon SSH trotzdem OK (ICMP gefiltert)'),
    'dont SSH et XMPP fonctionnels': ('of which SSH and XMPP working', 'de ellas SSH y XMPP operativos', 'davon SSH und XMPP funktionieren'),
    'dont SSH fonctionnel': ('of which SSH working', 'de ellas SSH operativo', 'davon SSH funktioniert'),
    'dont SSH impossible': ('of which SSH impossible', 'de los cuales SSH imposible', 'davon SSH unmöglich'),
    'dont XMPP fonctionnel': ('of which XMPP working', 'de ellas XMPP operativo', 'davon XMPP funktioniert'),
    "dont aucun probleme detecte par l'audit": ('of which no problem found by the audit', 'de los cuales ningún problema detectado por la auditoría', 'davon kein Problem im Audit gefunden'),
    "dont audit de l'agent impossible": ('of which agent audit impossible', 'de los cuales auditoría del agente imposible', 'davon Agent-Audit unmöglich'),
    'dont compte de connexion sans droits administrateur': ('of which login account without administrator rights', 'de ellos cuenta de conexión sin derechos de administrador', 'davon Anmeldekonto ohne Administratorrechte'),
    'dont connexion impossible (SSH ou commande XMPP)': ('of which connection impossible (SSH or XMPP command)', 'de los cuales conexión imposible (SSH o comando XMPP)', 'davon Verbindung unmöglich (SSH oder XMPP-Befehl)'),
    'dont joignables en SSH malgre le ping KO (ICMP filtre)': ('of which reachable by SSH despite failed ping (ICMP filtered)', 'de ellos accesibles por SSH pese al ping KO (ICMP filtrado)', 'davon per SSH erreichbar trotz Ping KO (ICMP gefiltert)'),
    'dont par SSH': ('of which by SSH', 'de los cuales por SSH', 'davon per SSH'),
    'dont par SSH en repli (XMPP indisponible ou agent muet)': ('of which by SSH as fallback (XMPP unavailable or agent silent)', 'de los cuales por SSH de respaldo (XMPP no disponible o agente mudo)', 'davon per SSH als Ausweichweg (XMPP nicht verfügbar oder Agent stumm)'),
    'dont par XMPP': ('of which by XMPP', 'de los cuales por XMPP', 'davon per XMPP'),
    'dont probleme non reparable par le script': ('of which problem not repairable by the script', 'de los cuales problema no reparable por el script', 'davon vom Skript nicht reparierbares Problem'),
    'dont reparation complete possible': ('of which full repair possible', 'de los cuales reparación completa posible', 'davon vollständige Reparatur möglich'),
    'dont reparation partielle seulement': ('of which partial repair only', 'de los cuales solo reparación parcial', 'davon nur teilweise Reparatur'),
    'dont serveur XMPP injoignable depuis le poste': ('of which XMPP server unreachable from the computer', 'de los cuales servidor XMPP inaccesible desde el equipo', 'davon XMPP-Server vom Rechner nicht erreichbar'),
    'ejabberdctl connected_users a echoue :': ('ejabberdctl connected_users failed:', 'ejabberdctl connected_users ha fallado:', 'ejabberdctl connected_users fehlgeschlagen:'),
    "ejabberdctl unregister <compte> pulse, puis redemarrer l'agent (FAQ : hors ligne, point 5)": ('ejabberdctl unregister <account> pulse, then restart the agent (FAQ: offline, item 5)', 'ejabberdctl unregister <cuenta> pulse, luego reiniciar el agente (FAQ: desconectado, punto 5)', 'ejabberdctl unregister <Konto> pulse, dann Agent neu starten (FAQ: offline, Punkt 5)'),
    "empreinte de l'agent qui change : mise a jour de l'agent en boucle": ('agent fingerprint keeps changing: agent update loop', 'huella del agente que cambia: actualización del agente en bucle', 'Agent-Fingerabdruck ändert sich: Update-Schleife des Agenten'),
    'enquete : ejabberdctl get_last, {} / {} compte(s) avec une reponse': ('investigation: ejabberdctl get_last, {} / {} account(s) answered', 'investigación: ejabberdctl get_last, {} / {} cuenta(s) con respuesta', 'Untersuchung: ejabberdctl get_last, {} / {} Konto/Konten mit Antwort'),
    'enquete : table logs, {} compte(s) avec des messages sur 30 jours': ('investigation: logs table, {} account(s) with messages over 30 days', 'investigación: tabla logs, {} cuenta(s) con mensajes en 30 días', 'Untersuchung: Tabelle logs, {} Konto/Konten mit Meldungen in 30 Tagen'),
    'enquete : uptime_machine, {} / {} nom(s) connu(s)': ('investigation: uptime_machine, {} / {} name(s) known', 'investigación: uptime_machine, {} / {} nombre(s) conocido(s)', 'Untersuchung: uptime_machine, {} / {} Name(n) bekannt'),
    'enquete : {} -> {} ({} trace(s))': ('investigation: {} -> {} ({} trace(s))', 'investigación: {} -> {} ({} traza(s))', 'Untersuchung: {} -> {} ({} Spur(en))'),
    'enquete : {} poste(s) injoignable(s) en SSH et en XMPP, {} examine(s) (ENQUETE_MAX = {})': ('investigation: {} computer(s) unreachable by SSH and XMPP, {} examined (ENQUETE_MAX = {})', 'investigación: {} equipo(s) inaccesible(s) por SSH y XMPP, {} examinado(s) (ENQUETE_MAX = {})', 'Untersuchung: {} Rechner weder per SSH noch per XMPP erreichbar, {} untersucht (ENQUETE_MAX = {})'),
    "erreurs d'inventaire dans le journal de l'agent": ('inventory errors in the agent log', 'errores de inventario en el registro del agente', 'Inventurfehler im Agent-Protokoll'),
    'et': ('and', 'y', 'und'),
    'et se reenregistre : le poste passe OFF quelques instants puis revient ON.': ('and registers again: the computer goes OFF for a moment, then back ON.', 'y se vuelve a registrar: el equipo pasa a OFF unos instantes y vuelve a ON.', 'und registriert sich neu: der Rechner geht kurz auf OFF und dann wieder auf ON.'),
    'etat apres reparation non mesure : {}': ('state after repair not measured: {}', 'estado después de la reparación no medido: {}', 'Zustand nach der Reparatur nicht gemessen: {}'),
    "fiche OFF est maintenant celle d'un autre poste (DHCP) : le poste OFF reste a trouver.": ('OFF record now belongs to another computer (DHCP): the OFF computer is still to be found.', 'ficha OFF pertenece ahora a otro equipo (DHCP): queda por encontrar el equipo OFF.', 'OFF-Eintrags gehört jetzt einem anderen Rechner (DHCP): der OFF-Rechner ist noch zu finden.'),
    "glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ : Verifier que l'inventaire remonte)": ('glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Check that the inventory arrives)', 'glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Verificar que el inventario llega)', 'glpi-agent.bat --config=none --scan-profiles --local=...\\inventory.txt (FAQ: Prüfen, ob die Inventur ankommt)'),
    'immediate': ('immediate', 'inmediata', 'sofort'),
    'indicateur': ('indicator', 'indicador', 'Kennzahl'),
    'inventory.ini : via_xmpp = False sans urlinventory': ('inventory.ini: via_xmpp = False without urlinventory', 'inventory.ini: via_xmpp = False sin urlinventory', 'inventory.ini: via_xmpp = False ohne urlinventory'),
    'journal {} : {} absent': ('log {}: {} missing', 'registro {}: {} ausente', 'Protokoll {}: {} fehlt'),
    'journal {} : {} illisible ({})': ('log {}: {} unreadable ({})', 'registro {}: {} ilegible ({})', 'Protokoll {}: {} unlesbar ({})'),
    'journal {} : {}, {} Mo lus, {} poste(s) cite(s)': ('log {}: {}, {} MB read, {} computer(s) mentioned', 'registro {}: {}, {} MB leídos, {} equipo(s) citado(s)', 'Protokoll {}: {}, {} MB gelesen, {} Rechner erwähnt'),
    'kiosk.pid contenu inattendu': ('kiosk.pid unexpected content', 'kiosk.pid con contenido inesperado', 'kiosk.pid mit unerwartetem Inhalt'),
    'kiosk.pid correct': ('kiosk.pid correct', 'kiosk.pid correcto', 'kiosk.pid korrekt'),
    'kiosk.pid corrompu': ('kiosk.pid corrupted', 'kiosk.pid dañado', 'kiosk.pid beschädigt'),
    'kiosk.pid corrompu ou inattendu': ('kiosk.pid corrupted or unexpected', 'kiosk.pid dañado o inesperado', 'kiosk.pid beschädigt oder unerwartet'),
    "l'agent repond {} (autre poste)": ('the agent answers {} (other computer)', 'el agente responde {} (otro equipo)', 'der Agent antwortet {} (anderer Rechner)'),
    "l'utilisateur ne remonteront plus dans Medulla.": ('the user will no longer be reported to Medulla.', 'usuario ya no se enviarán a Medulla.', 'Benutzers nicht mehr an Medulla gemeldet.'),
    'le reset se fait ensuite machine par machine (--cible).': ('the reset is then done machine by machine (--cible).', 'la reinicialización se hace luego equipo por equipo (--cible).', 'der Reset erfolgt dann Rechner für Rechner (--cible).'),
    'le reset se fait ensuite poste par poste (une seule IP).': ('the reset is then done computer by computer (single IP).', 'la reinicialización se hace luego equipo por equipo (una sola IP).', 'der Reset erfolgt dann Rechner für Rechner (nur eine IP).'),
    'lecture de Medulla : SELECT hostname, ip_xmpp, enabled, jid, agenttype FROM xmppmaster.machines': ('reading Medulla: SELECT hostname, ip_xmpp, enabled, jid, agenttype FROM xmppmaster.machines', 'lectura de Medulla: SELECT hostname, ip_xmpp, enabled, jid, agenttype FROM xmppmaster.machines', 'Medulla wird gelesen: SELECT hostname, ip_xmpp, enabled, jid, agenttype FROM xmppmaster.machines'),
    'machine ON sans inventaire GLPI rattache': ('ON machine without a linked GLPI inventory', 'máquina ON sin inventario GLPI vinculado', 'ON-Rechner ohne verknüpftes GLPI-Inventar'),
    'machines OFF dans Medulla': ('OFF machines in Medulla', 'equipos OFF en Medulla', 'OFF-Rechner in Medulla'),
    'machines OFF dans Medulla (SSH), puis machines ON liees (XMPP)': ('OFF machines in Medulla (SSH), then related ON machines (XMPP)', 'equipos OFF en Medulla (SSH), luego equipos ON relacionados (XMPP)', 'OFF-Rechner in Medulla (SSH), dann zugehörige ON-Rechner (XMPP)'),
    'machines ON dans Medulla (XMPP)': ('ON machines in Medulla (XMPP)', 'equipos ON en Medulla (XMPP)', 'ON-Rechner in Medulla (XMPP)'),
    "master_reconf envoie alors force_setup_agent a l'agent, qui redemande sa configuration": ('master_reconf then sends force_setup_agent to the agent, which requests its configuration again', 'master_reconf envía entonces force_setup_agent al agente, que vuelve a pedir su configuración', 'master_reconf sendet dann force_setup_agent an den Agenten, der seine Konfiguration erneut anfordert'),
    'meme numero de serie materiel que {} : poste clone': ('same hardware serial number as {}: cloned computer', 'mismo número de serie de hardware que {}: equipo clonado', 'gleiche Hardware-Seriennummer wie {}: geklonter Rechner'),
    'met a jour (different)': ('update (different)', 'actualizar (distinto)', 'aktualisieren (abweichend)'),
    'module slixmpp absent (apt install python3-slixmpp)': ('slixmpp module missing (apt install python3-slixmpp)', 'falta el módulo slixmpp (apt install python3-slixmpp)', 'Modul slixmpp fehlt (apt install python3-slixmpp)'),
    'need_reconf=1 est ecrit sur la fiche ON du poste (xmppmaster.machines). Le substitut': ("need_reconf=1 is written to the computer's ON record (xmppmaster.machines). The substitute", 'need_reconf=1 se escribe en la ficha ON del equipo (xmppmaster.machines). El sustituto', 'need_reconf=1 wird in den ON-Eintrag des Rechners geschrieben (xmppmaster.machines). Der Substitut'),
    "need_reconf=1 est ecrit sur la fiche de chaque machine : l'agent se reenregistre sans la": ("need_reconf=1 is written to each machine's record: the agent registers again without the", 'need_reconf=1 se escribe en la ficha de cada máquina: el agente se vuelve a registrar sin la', 'need_reconf=1 wird in den Eintrag jedes Rechners geschrieben: der Agent registriert sich neu ohne die'),
    'nom ou IP invalide : {}': ('invalid name or IP: {}', 'nombre o IP no válido: {}', 'ungültiger Name oder IP: {}'),
    'non teste': ('not tested', 'no probado', 'nicht getestet'),
    "pendant que l'agent est arrete ; le service est ensuite controle.": ('while the agent is stopped; the service is checked afterwards.', 'mientras el agente está detenido; el servicio se controla después.', 'während der Agent gestoppt ist; der Dienst wird danach geprüft.'),
    'plateformes lues dans Medulla : {} entree(s)': ('platforms read from Medulla: {} entry(ies)', 'plataformas leídas en Medulla: {} entrada(s)', 'aus Medulla gelesene Plattformen: {} Eintrag/Einträge'),
    'plus aucun probleme': ('no problem left', 'ningún problema restante', 'kein Problem mehr'),
    'port 22 ouvert mais connexion refusee ({})': ('port 22 open but connection refused ({})', 'puerto 22 abierto pero conexión rechazada ({})', 'Port 22 offen, aber Verbindung abgelehnt ({})'),
    'port {}': ('port {}', 'port {}', 'port {}'),
    'probleme': ('problem', 'problema', 'Problem'),
    "produisent aucun inventaire tant que GLPI-Agent n'est pas mis a jour.": ('produce no inventory until GLPI-Agent is updated.', 'generan ningún inventario mientras GLPI-Agent no se actualice.', 'erstellen keine Inventur, solange GLPI-Agent nicht aktualisiert ist.'),
    "puis redemarrage du service. Retour arriere automatique en cas d'echec.": ('then service start. Automatic rollback on failure.', 'luego arranque del servicio. Vuelta atrás automática si falla.', 'dann Dienst starten. Automatisches Zurücksetzen bei Fehler.'),
    'python3 {} --cible {}   # {}': ('python3 {} --cible {}   # {}', 'python3 {} --cible {}   # {}', 'python3 {} --cible {}   # {}'),
    'python3 {} --ip {}   # {}': ('python3 {} --ip {}   # {}', 'python3 {} --ip {}   # {}', 'python3 {} --ip {}   # {}'),
    'rattache ({})': ('matched ({})', 'vinculado ({})', 'zugeordnet ({})'),
    'reconfiguration {} : fiche ON id {} ({}), need_reconf={}': ('reconfiguration {}: ON record id {} ({}), need_reconf={}', 'reconfiguración {}: ficha ON id {} ({}), need_reconf={}', 'Neukonfiguration {}: ON-Eintrag id {} ({}), need_reconf={}'),
    'reconfiguration {} : need_reconf={}, {}': ('reconfiguration {}: need_reconf={}, {}', 'reconfiguración {}: need_reconf={}, {}', 'Neukonfiguration {}: need_reconf={}, {}'),
    'reconfiguration {} : session XMPP {}': ('reconfiguration {}: XMPP session {}', 'reconfiguración {}: sesión XMPP {}', 'Neukonfiguration {}: XMPP-Sitzung {}'),
    'reconfiguration {} : substitut {} {}': ('reconfiguration {}: substitute {} {}', 'reconfiguración {}: sustituto {} {}', 'Neukonfiguration {}: Substitut {} {}'),
    "reconfigurer l'agent de {} poste(s)": ('reconfigure the agent of {} computer(s)', 'reconfigurar el agente de {} equipo(s)', 'den Agenten von {} Rechner(n) neu zu konfigurieren'),
    'reconfigurer {} machine(s)': ('reconfigure {} machine(s)', 'reconfigurar {} máquina(s)', '{} Rechner neu zu konfigurieren'),
    'reconnexions en boucle ({} en 1 h)': ('reconnection loop ({} in 1 h)', 'reconexiones en bucle ({} en 1 h)', 'Wiederverbindungsschleife ({} in 1 h)'),
    'reconnexions en boucle ({} en 1 h) : {}': ('reconnection loop ({} in 1 h): {}', 'reconexiones en bucle ({} en 1 h): {}', 'Wiederverbindungsschleife ({} in 1 h): {}'),
    'registeryagent.ini.local a ete modifie apres le dernier demarrage du substitut': ('registeryagent.ini.local was changed after the last start of the registration', 'registeryagent.ini.local se modificó después del último arranque del sustituto', 'registeryagent.ini.local wurde nach dem letzten Start des Registrierungs-'),
    'relance': ('restart', 'reinicio', 'Neustart'),
    'relancer le script avec le choix 4 du menu (une IP) ou --ip :': ('run the script again with menu choice 4 (one IP) or --ip:', 'vuelva a ejecutar el script con la opción 4 del menú (una IP) o --ip:', 'das Skript mit Menüpunkt 4 (eine IP) oder --ip erneut starten:'),
    'relancer le script sur cette seule machine :': ('run the script again on that single machine:', 'vuelva a ejecutar el script sobre ese único equipo:', 'das Skript erneut nur für diesen Rechner starten:'),
    'relancer le service sur {} poste(s)': ('restart the service on {} computer(s)', 'reiniciar el servicio en {} equipo(s)', 'den Dienst auf {} Rechner(n) neu zu starten'),
    'remplace (vide)': ('replace (empty)', 'reemplazar (vacío)', 'ersetzen (leer)'),
    'remplaces (chacun sauvegarde avant), empreinte verifiee. Aucun fichier supprime.': ('replaced (each backed up first), checksum verified. No file deleted.', 'reemplazados (cada uno guardado antes), huella verificada. Ningún archivo eliminado.', 'ersetzt (jede vorher gesichert), Prüfsumme kontrolliert. Keine Datei gelöscht.'),
    'repli SSH apres refus du lancement par XMPP ({})': ('SSH fallback after the XMPP launch was refused ({})', 'repliegue SSH tras el rechazo del lanzamiento por XMPP ({})', 'SSH-Rückfall nach abgelehntem Start per XMPP ({})'),
    'reseau : {} -> {}': ('network: {} -> {}', 'red: {} -> {}', 'Netzwerk: {} -> {}'),
    'restart-pulse-services allsubs, puis ejabberdctl connected_users | grep master_subs': ('restart-pulse-services allsubs, then ejabberdctl connected_users | grep master_subs', 'restart-pulse-services allsubs, luego ejabberdctl connected_users | grep master_subs', 'restart-pulse-services allsubs, dann ejabberdctl connected_users | grep master_subs'),
    'restart-pulse-services allsubs, puis relancer le script.': ('restart-pulse-services allsubs, then run the script again.', 'restart-pulse-services allsubs, y volver a lanzar el script.', 'restart-pulse-services allsubs, dann das Skript erneut starten.'),
    'reste :': ('remaining:', 'pendiente:', 'verbleibend:'),
    'sans limite de duree': ('no time limit', 'sin límite de duración', 'ohne Zeitlimit'),
    'sc config medullaagent start= auto, puis sc start medullaagent': ('sc config medullaagent start= auto, then sc start medullaagent', 'sc config medullaagent start= auto, luego sc start medullaagent', 'sc config medullaagent start= auto, dann sc start medullaagent'),
    'screen -S medulla (detacher : Ctrl+A puis D ; reprendre : screen -r medulla).': ('screen -S medulla (detach: Ctrl+A then D; resume: screen -r medulla).', 'screen -S medulla (separar: Ctrl+A y luego D; retomar: screen -r medulla).', 'screen -S medulla (lösen: Strg+A, dann D; fortsetzen: screen -r medulla).'),
    'section': ('section', 'sección', 'Abschnitt'),
    'session XMPP) alors que SSH et XMPP etaient joignables (confirmation separee).': ('XMPP session) although SSH and XMPP were reachable (separate confirmation).', 'sesión XMPP) aunque SSH y XMPP eran accesibles (confirmación aparte).', 'XMPP-Sitzung), obwohl SSH und XMPP erreichbar waren (separate Bestätigung).'),
    'simultane(s)': ('simultaneous', 'simultánea(s)', 'gleichzeitig'),
    'sshdaemon arrete (port 22 ferme)': ('sshdaemon stopped (port 22 closed)', 'sshdaemon detenido (puerto 22 cerrado)', 'sshdaemon gestoppt (Port 22 geschlossen)'),
    'sudo ./diagnostic_machines_XMPPV6.py --nom "$(paste -sd, {})"': ('sudo ./diagnostic_machines_XMPPV6.py --nom "$(paste -sd, {})"', 'sudo ./diagnostic_machines_XMPPV6.py --nom "$(paste -sd, {})"', 'sudo ./diagnostic_machines_XMPPV6.py --nom "$(paste -sd, {})"'),
    'systemctl restart pulse-xmpp-master-substitute-<service> (ou restart-pulse-services allsubs), puis ejabberdctl connected_users': ('systemctl restart pulse-xmpp-master-substitute-<service> (or restart-pulse-services allsubs), then ejabberdctl connected_users', 'systemctl restart pulse-xmpp-master-substitute-<service> (o restart-pulse-services allsubs), luego ejabberdctl connected_users', 'systemctl restart pulse-xmpp-master-substitute-<service> (oder restart-pulse-services allsubs), dann ejabberdctl connected_users'),
    'systemctl restart pulse-xmpp-master-substitute-inventory': ('systemctl restart pulse-xmpp-master-substitute-inventory', 'systemctl restart pulse-xmpp-master-substitute-inventory', 'systemctl restart pulse-xmpp-master-substitute-inventory'),
    'test poste {!r} : nom invalide ou en double': ('computer test {!r}: invalid or duplicate name', 'prueba de equipo {!r}: nombre no válido o duplicado', 'Rechnertest {!r}: ungültiger oder doppelter Name'),
    'test poste {} : cle {!r} manquante': ('computer test {}: key {!r} missing', 'prueba de equipo {}: falta la clave {!r}', 'Rechnertest {}: Schlüssel {!r} fehlt'),
    'test poste {} : correction inconnue {!r} (seule possible : SERVICES)': ('computer test {}: unknown fix {!r} (only possible: SERVICES)', 'prueba de equipo {}: corrección desconocida {!r} (única posible: SERVICES)', 'Rechnertest {}: unbekannte Korrektur {!r} (einzig möglich: SERVICES)'),
    'test {} : le code poste doit definir collecte()': ('test {}: the computer code must define collecte()', 'prueba {}: el código del equipo debe definir collecte()', 'Test {}: der Rechnercode muss collecte() definieren'),
    'toutes les machines (OFF par SSH, ON par XMPP)': ('all machines (OFF by SSH, ON by XMPP)', 'todos los equipos (OFF por SSH, ON por XMPP)', 'alle Rechner (OFF per SSH, ON per XMPP)'),
    'toutes les machines OFF dans Medulla': ('all OFF machines in Medulla', 'todos los equipos OFF en Medulla', 'alle OFF-Rechner in Medulla'),
    'une machine OFF est traitee a son retour.': ('an OFF machine is processed when it comes back.', 'una máquina OFF se trata a su regreso.', 'ein OFF-Rechner wird bei seiner Rückkehr bearbeitet.'),
    '{} (apres)': ('{} (after)', '{} (después)', '{} (nachher)'),
    '{} (avant)': ('{} (before)', '{} (antes)', '{} (vorher)'),
    '{} : timeout, nouvel essai dans {} s ({}e essai)': ('{}: timeout, retrying in {} s (attempt {})', '{}: tiempo agotado, nuevo intento en {} s (intento {})', '{}: Zeitüberschreitung, neuer Versuch in {} s (Versuch {})'),
    '{} JID differents en 1 h : plusieurs postes ou agents sous ce nom': ('{} different JIDs in 1 h: several computers or agents under this name', '{} JID distintos en 1 h: varios equipos o agentes con este nombre', '{} verschiedene JIDs in 1 h: mehrere Rechner oder Agenten unter diesem Namen'),
    "{} absent du poste (agent d'inventaire a installer": ('{} missing from the computer (inventory agent to install', '{} ausente del equipo (agente de inventario por instalar', '{} fehlt auf dem Rechner (Inventuragent zu installieren'),
    '{} absent du poste{} : aucun inventaire possible': ('{} missing from the computer{}: no inventory possible', '{} ausente del equipo{}: ningún inventario posible', '{} fehlt auf dem Rechner{}: keine Inventur möglich'),
    '{} absente -> controle des fichiers desactive': ('{} missing -> file check disabled', '{} ausente -> control de archivos desactivado', '{} fehlt -> Dateiprüfung deaktiviert'),
    '{} adresse(s) MAC partagee(s) par plusieurs machines dans Medulla, non blacklistee(s)': ('{} MAC address(es) shared by several machines in Medulla, not blacklisted', '{} dirección(es) MAC compartida(s) por varios equipos en Medulla, sin lista negra', '{} MAC-Adresse(n) von mehreren Rechnern in Medulla geteilt, nicht gesperrt'),
    '{} copies PAExec restees dans C:\\Windows (EDR a verifier{})': ('{} PAExec copies left in C:\\Windows (EDR to check{})', '{} copias de PAExec en C:\\Windows (EDR a verificar{})', '{} PAExec-Kopien in C:\\Windows (EDR prüfen{})'),
    '{} entree(s) orpheline(s) dans xmppmaster.network': ('{} orphan entry(ies) in xmppmaster.network', '{} entrada(s) huérfana(s) en xmppmaster.network', '{} verwaiste(r) Eintrag/Einträge in xmppmaster.network'),
    '{} fiches au meme nom dans Medulla': ('{} records with the same name in Medulla', '{} fichas con el mismo nombre en Medulla', '{} Einträge mit demselben Namen in Medulla'),
    "{} fichier(s) de travail de plus d'un jour supprime(s)": ('{} work file(s) older than one day deleted', '{} archivo(s) de trabajo de más de un día eliminado(s)', '{} Arbeitsdatei(en) älter als ein Tag gelöscht'),
    '{} le {}': ('{} on {}', '{} el {}', '{} am {}'),
    "{} machine(s) ajoutee(s) au plan d'actions.": ('{} machine(s) added to the action plan.', '{} máquina(s) añadida(s) al plan de acciones.', '{} Rechner zum Aktionsplan hinzugefügt.'),
    '{} machine(s) en enregistrement incomplet (master-reg.log)': ('{} machine(s) with incomplete registration (master-reg.log)', '{} equipo(s) con registro incompleto (master-reg.log)', '{} Rechner mit unvollständiger Registrierung (master-reg.log)'),
    '{} machine(s) enregistree(s) avec une MAC partagee deja blacklistee : enregistrement anterieur a la blacklist': ('{} machine(s) registered with a shared MAC that is already blacklisted: registration older than the blacklist', '{} máquina(s) registrada(s) con una MAC compartida ya en lista negra: registro anterior a la lista negra', '{} Rechner mit einer bereits gesperrten gemeinsamen MAC registriert: Registrierung älter als die Sperrliste'),
    '{} machine(s) qui se reconnectent en boucle (derniere heure)': ('{} machine(s) reconnecting in a loop (last hour)', '{} equipo(s) que se reconectan en bucle (última hora)', '{} Rechner mit Wiederverbindungsschleife (letzte Stunde)'),
    '{} pb : {}': ('{} pb: {}', '{} pb: {}', '{} Pb.: {}'),
    '{} poste(s) par SSH, {} par XMPP': ('{} computer(s) by SSH, {} by XMPP', '{} equipo(s) por SSH, {} por XMPP', '{} Rechner per SSH, {} per XMPP'),
    '{} s maximum': ('{} s maximum', '{} s como máximo', 'höchstens {} s'),
    '{} substitut(s) non connecte(s) a ejabberd : {}': ('{} substitute(s) not connected to ejabberd: {}', '{} sustituto(s) no conectado(s) a ejabberd: {}', '{} Substitut(e) nicht mit ejabberd verbunden: {}'),
    '{}) Audit + actions      :': ('{}) Audit + actions    :', '{}) Auditoría + acciones:', '{}) Audit + Aktionen   :'),
    '{}) Audit + actions      : {}': ('{}) Audit + actions    : {}', '{}) Auditoría + acciones: {}', '{}) Audit + Aktionen   : {}'),
    '{}) Audit seul           : {}': ('{}) Audit only         : {}', '{}) Solo auditoría     : {}', '{}) Nur Audit          : {}'),
    "{}) audit seul ; {}) audit + correction, puis demande d'inventaire": ('{}) audit only; {}) audit + fix, then inventory request', '{}) solo auditoría; {}) auditoría + corrección, luego solicitud de inventario', '{}) nur Audit; {}) Audit + Korrektur, dann Inventuranforderung'),
    '{}) machines OFF dans Medulla, par SSH (et machines ON liees, par XMPP)': ('{}) OFF machines in Medulla, by SSH (and related ON machines, by XMPP)', '{}) equipos OFF en Medulla, por SSH (y equipos ON relacionados, por XMPP)', '{}) OFF-Rechner in Medulla, per SSH (und zugehörige ON-Rechner, per XMPP)'),
    '{}) machines ON dans Medulla, par XMPP': ('{}) ON machines in Medulla, by XMPP', '{}) equipos ON en Medulla, por XMPP', '{}) ON-Rechner in Medulla, per XMPP'),
    '{}) toutes les IP des machines OFF dans Medulla': ('{}) all IPs of OFF machines in Medulla', '{}) todas las IP de los equipos OFF en Medulla', '{}) alle IPs der OFF-Rechner in Medulla'),
    '{}) toutes les machines (OFF par SSH, ON par XMPP)': ('{}) all machines (OFF by SSH, ON by XMPP)', '{}) todos los equipos (OFF por SSH, ON por XMPP)', '{}) alle Rechner (OFF per SSH, ON per XMPP)'),
    '{}) une ou plusieurs IP': ('{}) one or more IPs', '{}) una o varias IP', '{}) eine oder mehrere IPs'),
    '{}) une ou plusieurs machines (nom ou IP)': ('{}) one or more machines (name or IP)', '{}) uno o varios equipos (nombre o IP)', '{}) ein oder mehrere Rechner (Name oder IP)'),
    '{}. Exemple : 10.0.0.10 ou 10.0.0.10,10.0.0.11': ('{}. Example: 10.0.0.10 or 10.0.0.10,10.0.0.11', '{}. Ejemplo: 10.0.0.10 o 10.0.0.10,10.0.0.11', '{}. Beispiel: 10.0.0.10 oder 10.0.0.10,10.0.0.11'),
    '{}. Exemple : PC1068, 10.0.0.10 ou PC1068,10.0.0.11': ('{}. Example: PC1068, 10.0.0.10 or PC1068,10.0.0.11', '{}. Ejemplo: PC1068, 10.0.0.10 o PC1068,10.0.0.11', '{}. Beispiel: PC1068, 10.0.0.10 oder PC1068,10.0.0.11'),
    "{}/ ({} journal(aux) d'actions)": ('{}/ ({} action log(s))', '{}/ ({} registro(s) de acciones)', '{}/ ({} Aktionsprotokoll(e))'),
    '{}\\lib a mettre a niveau ({})': ('{}\\lib to upgrade ({})', '{}\\lib que actualizar ({})', '{}\\lib zu aktualisieren ({})'),
    '{}\\lib different du serveur ({}), agent plus recent que la reference : verification manuelle': ('{}\\lib differs from the server ({}), agent newer than the reference: manual check', '{}\\lib distinto del servidor ({}), agente más reciente que la referencia: revisión manual', '{}\\lib weicht vom Server ab ({}), Agent neuer als die Referenz: manuelle Prüfung'),
    'Âge du dernier inventaire (h)': ('Age of the last inventory (h)', 'Antigüedad del último inventario (h)', 'Alter der letzten Inventur (h)'),
    'État dans Medulla (information)': ('State in Medulla (information)', 'Estado en Medulla (información)', 'Status in Medulla (Information)'),
    'État dans Medulla après actions': ('State in Medulla after actions', 'Estado en Medulla tras las acciones', 'Status in Medulla nach den Aktionen'),
    'État du service medullaagent': ('medullaagent service state', 'Estado del servicio medullaagent', 'Status des Dienstes medullaagent'),
}


if __name__ == "__main__":
    try:
        try:
            main()
        finally:
            LINK.stop()
    except KeyboardInterrupt:
        print(_T("\nInterrompu avant toute action : aucune modification, aucun releve enregistre."),
              file=sys.stderr)
        sys.exit(130)
    except subprocess.CalledProcessError as error:
        print(_T("Erreur MySQL : {}", error.stderr), file=sys.stderr)
        sys.exit(1)
    except (OSError, ValueError) as error:
        print(_T("Erreur : {}", tr(str(error))), file=sys.stderr)
        sys.exit(1)
