// Construit et imaginé par Jean-François Avart
(() => {
  // ============================================================
  // DÉTECTION MODE DEMO
  // ============================================================
  // Le mode DEMO est activé via une meta tag dans index.html:
  // <meta name="csi-mode" content="demo">
  const DEMO_MODE = (() => {
    const meta = document.querySelector('meta[name="csi-mode"]');
    return meta && meta.content === 'demo';
  })();

  // URL du site pour télécharger l'application complète
  const DEMO_DOWNLOAD_URL = 'https://cyber-assistant.com/client_web.zip';
  const DEMO_WEBSITE_URL = 'https://cyber-assistant.com';
  const DEMO_CONTACT_EMAIL = 'info@cyber-assistant.com';

  // Configuration mode DEMO
  const DEMO_AUTO_RELOAD_INTERVAL = 60 * 60 * 1000; // 1 heure en millisecondes
  let demoAutoReloadTimer = null;

  // Log mode DEMO si activé
  if (DEMO_MODE) {
    void 0;
    void 0;
  }

  /**
   * Affiche un message de blocage pour les fonctions désactivées en mode démo
   * @param {string} featureName - Nom de la fonctionnalité bloquée
   */
  function showDemoBlockedMessage(featureName = t('demo.thisFeature', 'Cette fonction')) {
    if (typeof showToast2 === 'function') {
      showToast2(`${featureName} ${t('demo.disabledInDemo', 'est désactivée en mode démonstration.')}`, 'warning');
    } else {
      void 0;
    }
  }

  /**
   * Démarre le timer de rechargement automatique des données démo
   */
  function startDemoAutoReload() {
    if (!DEMO_MODE) return;

    // Arrêter tout timer existant
    if (demoAutoReloadTimer) {
      clearInterval(demoAutoReloadTimer);
    }

    // Démarrer le rechargement automatique toutes les heures
    demoAutoReloadTimer = setInterval(() => {
      void 0;
      if (typeof loadDemoDataSilent === 'function') {
        loadDemoDataSilent();
        if (typeof showToast2 === 'function') {
          showToast2(t('toasts.demoDataReloaded'), 'info');
        }
      }
    }, DEMO_AUTO_RELOAD_INTERVAL);

    void 0;
  }

  /**
   * Arrête le timer de rechargement automatique
   */
  function stopDemoAutoReload() {
    if (demoAutoReloadTimer) {
      clearInterval(demoAutoReloadTimer);
      demoAutoReloadTimer = null;
      void 0;
    }
  }

  // ============================================================
  // PROTECTIONS ANTI-ABUS (MODE DEMO)
  // ============================================================

  // Rate limiting pour les actions fréquentes
  const demoRateLimiter = {
    actions: new Map(),
    maxActions: 100,        // Max 100 actions par fenêtre
    windowMs: 60000,        // Fenêtre de 1 minute
    cooldownMs: 5000,       // Pause de 5 secondes si dépassé

    /**
     * Vérifie si une action est autorisée (rate limiting)
     * @param {string} actionType - Type d'action (save, create, update, etc.)
     * @returns {boolean} - true si l'action est autorisée
     */
    checkAction(actionType = 'default') {
      if (!DEMO_MODE) return true;

      const now = Date.now();
      const key = actionType;

      if (!this.actions.has(key)) {
        this.actions.set(key, { count: 0, windowStart: now, blocked: false });
      }

      const action = this.actions.get(key);

      // Réinitialiser la fenêtre si expirée
      if (now - action.windowStart > this.windowMs) {
        action.count = 0;
        action.windowStart = now;
        action.blocked = false;
      }

      // Vérifier le cooldown
      if (action.blocked && now - action.blockedAt < this.cooldownMs) {
        return false;
      } else if (action.blocked) {
        action.blocked = false;
      }

      // Vérifier le rate limit
      if (action.count >= this.maxActions) {
        action.blocked = true;
        action.blockedAt = now;
        void 0;
        return false;
      }

      action.count++;
      return true;
    },

    /**
     * Affiche un message de rate limit
     */
    showRateLimitMessage() {
      if (typeof showToast2 === 'function') {
        showToast2(t('toasts.tooManyActions'), 'warning');
      }
    }
  };

  // Limite de taille des données en mode démo (protection mémoire)
  const DEMO_MAX_ITEMS = {
    controls: 200,
    actions: 500,
    risks: 200,
    threats: 200,
    nonconformities: 100,
    audits: 50,
    documents: 100,
    stakeholders: 100,
    criticalAssets: 100,
    projectRisks: 100
  };

  /**
   * Vérifie les limites de données en mode démo
   * @param {string} dataType - Type de données
   * @param {number} currentCount - Nombre actuel d'éléments
   * @returns {boolean} - true si on peut ajouter
   */
  function checkDemoDataLimit(dataType, currentCount) {
    if (!DEMO_MODE) return true;

    const limit = DEMO_MAX_ITEMS[dataType];
    if (!limit) return true;

    if (currentCount >= limit) {
      showDemoBlockedMessage(t('demo.limitReached', 'Limite atteinte') + ` (${limit} ${dataType} ` + t('demo.maxInDemo', 'maximum en mode démo') + `)`);
      return false;
    }
    return true;
  }

  /**
   * Protection contre le localStorage flooding
   * @returns {boolean} - true si on peut sauvegarder
   */
  function checkDemoStorageLimit() {
    if (!DEMO_MODE) return true;

    try {
      // Estimer la taille actuelle du localStorage
      let totalSize = 0;
      for (let key in localStorage) {
        if (localStorage.hasOwnProperty(key)) {
          totalSize += localStorage[key].length * 2; // UTF-16 = 2 bytes par char
        }
      }

      // Limite à 4MB en mode démo (sur les 5MB typiques)
      const MAX_DEMO_STORAGE = 4 * 1024 * 1024;
      if (totalSize > MAX_DEMO_STORAGE) {
        void 0;
        showDemoBlockedMessage(t('demo.storageLimitReached', 'Limite de stockage atteinte en mode démo'));
        return false;
      }
      return true;
    } catch (e) {
      return true; // En cas d'erreur, autoriser
    }
  }

  // Canonical domain values (used for data storage - do NOT translate)
  const DOMAIN_BY_PREFIX = {
    '5': 'Organisationnel',
    '6': 'Humain',
    '7': 'Physique',
    '8': 'Technologique'
  };
  // Translated domain display labels
  const DOMAIN_DISPLAY_KEYS = {
    'Organisationnel': 'domains.organisationnel',
    'Humain': 'domains.humain',
    'Physique': 'domains.physique',
    'Technologique': 'domains.technologique',
    'Non classé': 'domains.nonClasse'
  };
  function getTranslatedDomain(canonicalValue) {
    const key = DOMAIN_DISPLAY_KEYS[canonicalValue];
    return key ? t(key) : canonicalValue;
  }
  // Resolve ISO 27002 control title/description based on current language
  function isoCtrlTitle(control) {
    var lang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : 'fr';
    return (lang !== 'fr' && control.titleEn) ? control.titleEn : (control.title || '');
  }
  function isoCtrlDesc(control) {
    var lang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : 'fr';
    return (lang !== 'fr' && control.descriptionEn) ? control.descriptionEn : (control.measure || control.description || '');
  }
  // Resolve ISO 27002 category label based on current language
  function isoCatLabel(cat) {
    var lang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : 'fr';
    return (lang !== 'fr' && cat.labelEn) ? cat.labelEn : cat.label;
  }
  const normalizeCapabilityKey = (value) =>
    value
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .replace(/[\s'']+/g, "_")
      .replace(/_+/g, "_");
  const getIso27002DomainFromValue = (value) => {
    const text = String(value || "").trim();
    if (!text) return "";
    const match = text.match(/(^|[^0-9])([5-8])\s*(?:\.|$)/);
    if (!match) return "";
    return DOMAIN_BY_PREFIX[match[2]] || "";
  };
  const inferIso27002Category = (control = {}) => {
    const fromNumber = getIso27002DomainFromValue(control.numero);
    if (fromNumber) return fromNumber;
    const fromReference = getIso27002DomainFromValue(control.reference);
    if (fromReference) return fromReference;
    const title = String(control.title || "").trim();
    if (title && typeof iso27002_data_default !== "undefined") {
      const match = iso27002_data_default.find((item) => item.numero === control.numero || item.titre === title);
      if (match) {
        const prefix = String(match.numero || "").split(".")[0];
        return DOMAIN_BY_PREFIX[prefix] || "";
      }
    }
    return "Non classé";
  };

  // Génération d'ID unique avec crypto.randomUUID() et fallback
  const generateId = (prefix = '') => {
    let uuid;
    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
      uuid = crypto.randomUUID();
    } else {
      // Fallback pour navigateurs anciens
      uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
        const r = Math.random() * 16 | 0;
        const v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
      });
    }
    return prefix ? `${prefix}-${uuid}` : uuid;
  };

  // Accès sécurisé aux éléments DOM
  const safeSetText = (id, text) => {
    const el = document.getElementById(id);
    if (el) el.textContent = text;
  };


  const safeSetHTML = (id, html) => {
    const el = document.getElementById(id);
    if (el) el.textContent = html;
  };

  const safeAddClass = (id, className) => {
    const el = document.getElementById(id);
    if (el) el.classList.add(className);
  };

  const safeRemoveClass = (id, className) => {
    const el = document.getElementById(id);
    if (el) el.classList.remove(className);
  };

  const CAPABILITY_I18N_KEYS = [
    { raw: "Gouvernance", i18n: "capabilities.gouvernance" },
    { raw: "Gestion_des_actifs", i18n: "capabilities.gestionActifs" },
    { raw: "Protection_des_informations", i18n: "capabilities.protectionInformations" },
    { raw: "Sécurité_des_ressources_humaines", i18n: "capabilities.securiteRH" },
    { raw: "Sécurité_physique", i18n: "capabilities.securitePhysique" },
    { raw: "Sécurité_système_et_réseau", i18n: "capabilities.securiteSystemeReseau" },
    { raw: "Sécurité_des_applications", i18n: "capabilities.securiteApplications" },
    { raw: "Configuration_sécurisée", i18n: "capabilities.configurationSecurisee" },
    { raw: "Gestion_des_identités_et_des_accès", i18n: "capabilities.gestionIdentitesAcces" },
    { raw: "Gestion_des_menaces_et_des_vulnérabilités", i18n: "capabilities.gestionMenacesVulnerabilites" },
    { raw: "Continuité", i18n: "capabilities.continuite" },
    { raw: "Sécurité_des_relations_fournisseurs", i18n: "capabilities.securiteRelationsFournisseurs" },
    { raw: "Réglementation_et_conformité", i18n: "capabilities.reglementationConformite" },
    { raw: "Gestion_des_événements_de_sécurité_de_l'information", i18n: "capabilities.gestionEvenementsSI" },
    { raw: "Assurance_de_sécurité_de_l'information", i18n: "capabilities.assuranceSI" }
  ];
  function getOperationalCapabilities() {
    return CAPABILITY_I18N_KEYS.map(item => ({
      key: normalizeCapabilityKey(item.raw),
      label: t(item.i18n)
    }));
  }
  let OPERATIONAL_CAPABILITIES = getOperationalCapabilities();
  const NIS2_CATEGORIES = [
    'ID.AM', 'ID.BE', 'ID.GV', 'ID.RA', 'ID.RM', 'ID.SC',
    'PR.AC', 'PR.AT', 'PR.DS', 'PR.IP', 'PR.MA', 'PR.PT',
    'DE.AE', 'DE.CM', 'DE.DP',
    'RS.RP', 'RS.CO', 'RS.AN', 'RS.MI', 'RS.IM',
    'RC.RP', 'RC.IM', 'RC.CO'
  ];
  let currentTheme = "poudre";
  const THEME_STORAGE_KEY = "smsi_theme";
  function getThemeOptions() {
    return [
      { value: "light", label: t('settings.theme_clair', 'Clair') },
      { value: "dark", label: t('settings.theme_sombre', 'Sombre') },
      { value: "poudre", label: t('settings.theme_poudre', 'Poudre') },
      { value: "bleu", label: t('settings.theme_bleu', 'Bleu') }
    ];
  }
  const THEME_OPTIONS = getThemeOptions();
  // Lire le thème par défaut depuis la balise meta (pour branding) ou utiliser poudre
  const getDefaultTheme = () => {
    const metaTheme = document.querySelector('meta[name="csi-default-theme"]');
    if (metaTheme && metaTheme.content) {
      return metaTheme.content;
    }
    return "poudre";
  };
  const DEFAULT_THEME = getDefaultTheme();
  const normalizeTheme = (value) => {
    if (!value) return DEFAULT_THEME;
    return THEME_OPTIONS.some((opt) => opt.value === value) ? value : DEFAULT_THEME;
  };
  // ============================================================
  // DOCUMENT_REVIEW_ITEMS: loaded from iso27001 or open-security-management framework
  // Builds the same structure as the previously hardcoded array for backward compatibility.
  // Reference format: "4.1 Title" (composite string used as data key in appData.documentReview)
  // ============================================================
  function _getActiveManagementFramework() {
    var fws = window._csiFrameworks || {};
    if (fws['iso27001'] && _isFrameworkAllowed('iso27001')) return fws['iso27001'];
    if (fws['open-security-management'] && _isFrameworkAllowed('open-security-management')) return fws['open-security-management'];
    return null;
  }
  function _getActiveManagementFrameworkId() {
    var fws = window._csiFrameworks || {};
    if (fws['iso27001'] && _isFrameworkAllowed('iso27001')) return 'iso27001';
    if (fws['open-security-management'] && _isFrameworkAllowed('open-security-management')) return 'open-security-management';
    return null;
  }
  function _isFrameworkAllowed(id) {
    var ed = window._csiEdition;
    if (ed && ed.forcedFrameworks && Array.isArray(ed.frameworks)) {
      return ed.frameworks.indexOf(id) !== -1;
    }
    return true;
  }
  function _getActiveControlsFramework() {
    var fws = window._csiFrameworks || {};
    if (fws['iso27002'] && _isFrameworkAllowed('iso27002')) return fws['iso27002'];
    if (fws['open-security-controls'] && _isFrameworkAllowed('open-security-controls')) return fws['open-security-controls'];
    return null;
  }
  function _getActiveControlsFrameworkId() {
    var fws = window._csiFrameworks || {};
    if (fws['iso27002'] && _isFrameworkAllowed('iso27002')) return 'iso27002';
    if (fws['open-security-controls'] && _isFrameworkAllowed('open-security-controls')) return 'open-security-controls';
    return null;
  }
  /** Returns the correct default enabledFrameworks list based on loaded edition */
  function _defaultEnabledFrameworks() {
    var ed = window._csiEdition;
    if (ed && ed.forcedFrameworks && Array.isArray(ed.frameworks)) {
      // Edition avec frameworks forcés → utiliser uniquement la liste de l'édition
      var list = [];
      var fwReg = window._csiFrameworks || {};
      ed.frameworks.forEach(function(fwId) {
        if (fwReg[fwId]) list.push(fwId);
      });
      return list;
    }
    return [_getActiveControlsFrameworkId() || 'iso27002', 'nis2'];
  }
  const DOCUMENT_REVIEW_ITEMS = (function() {
    var mgmtFw = _getActiveManagementFramework();
    if (!mgmtFw || !Array.isArray(mgmtFw.clauses)) return [];
    var chapters = mgmtFw.chapters || [];
    return mgmtFw.clauses.map(function(clause) {
      if (clause.type === 'chapter') {
        var ch = chapters.find(function(c) { return c.id === clause.chapterId; });
        return { type: 'chapter', title: ch ? ch.title : '' };
      }
      return {
        reference: clause.reference + ' ' + clause.title,
        description: clause.description
      };
    });
  })();
  const createDefaultDocumentReview = (source = []) => {
    const src = Array.isArray(source) ? source : [];
    // B5 garde-fou : si le référentiel de management n'est pas chargé (échec de
    // script, package incomplet), DOCUMENT_REVIEW_ITEMS est vide → ne JAMAIS
    // écraser des données existantes par une liste vide (perte totale sinon).
    if (!DOCUMENT_REVIEW_ITEMS.some(it => it.reference)) return src;
    const map = new Map(src.map(item => [item.reference, item]));
    // B5 repli : correspondance par numéro de clause (préfixe avant le 1er espace)
    // pour rescaper les données saisies si le libellé d'une clause change.
    const byNum = new Map();
    src.forEach(item => {
      const num = String(item.reference || "").split(" ")[0];
      if (num && !byNum.has(num)) byNum.set(num, item);
    });
    return DOCUMENT_REVIEW_ITEMS.filter(it => it.reference).map(it => {
      const num = String(it.reference).split(" ")[0];
      const existing = map.get(it.reference) || byNum.get(num) || {};
      // B1 : spread d'abord pour préserver `comments` et tout champ inconnu
      // (patron FIX-CONTROL-FIELD-LOSS), puis on fige les champs connus + la clé.
      return {
        ...existing,
        reference: it.reference,
        justification: existing.justification || "",
        tool: existing.tool || "",
        status: existing.status || "",
        responsible: existing.responsible || "",
        lastReview: existing.lastReview || "",
        nextReview: existing.nextReview || "",
        documentLink: existing.documentLink || ""
      };
    });
  };
  function parseCssColor(value) {
    const raw = (value || "").trim();
    if (!raw) return null;
    if (raw.startsWith("#")) {
      const hex = raw.slice(1);
      if (hex.length === 3) {
        const r = parseInt(hex[0] + hex[0], 16);
        const g = parseInt(hex[1] + hex[1], 16);
        const b = parseInt(hex[2] + hex[2], 16);
        return { r, g, b };
      }
      if (hex.length === 6) {
        const r = parseInt(hex.slice(0, 2), 16);
        const g = parseInt(hex.slice(2, 4), 16);
        const b = parseInt(hex.slice(4, 6), 16);
        return { r, g, b };
      }
      return null;
    }
    const match = raw.match(/rgba?\(([^)]+)\)/i);
    if (!match) return null;
    const parts = match[1].split(",").map((p) => parseFloat(p.trim()));
    if (parts.length < 3 || parts.some((p) => Number.isNaN(p))) return null;
    return { r: parts[0], g: parts[1], b: parts[2] };
  }
  function withAlpha(color, alpha) {
    const rgb = parseCssColor(color);
    if (!rgb) return color;
    return `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${alpha})`;
  }
  function getThemeVars() {
    const target = document.body || document.documentElement;
    const styles = getComputedStyle(target);
    const readVar = (name, fallback) => styles.getPropertyValue(name).trim() || fallback;
    const textColor = readVar("--color-text", "#2d3e4a");
    const textSecondary = readVar("--color-text-secondary", "#758592");
    const borderColor = readVar("--color-border", "#e9ecef");
    const surface = readVar("--color-surface", "#ffffff");
    const primary = readVar("--color-primary", "#1fb8cd");
    const secondary = readVar("--color-secondary", "#5d878f");
    const success = readVar("--color-success", "#32c086");
    const warning = readVar("--color-warning", "#ffc185");
    const danger = readVar("--color-danger", "#db4545");
    const info = readVar("--color-info", "#5d878f");
    return {
      textColor,
      textSecondary,
      borderColor,
      surface,
      primary,
      secondary,
      success,
      warning,
      danger,
      info
    };
  }
  function safeT(key, fallback) {
    if (typeof t === 'function') {
      var val = t(key);
      if (val && val !== key) return val;
    }
    return fallback;
  }
  // Refonte header 2026-07 : le bouton thème est un item du menu utilisateur.
  // Libellé dynamique « Thème : <nom> » + icône propre à chacun des 4 thèmes réels.
  const THEME_MENU_ICONS = { light: "fa-sun", dark: "fa-moon", poudre: "fa-feather", bleu: "fa-droplet" };
  const THEME_LABEL_KEYS = { light: "settings.theme_clair", dark: "settings.theme_sombre", poudre: "settings.theme_poudre", bleu: "settings.theme_bleu" };
  function updateThemeToggleUi() {
    const btn = document.getElementById("themeToggleBtn");
    if (!btn) return;
    const iconEl = btn.querySelector("i");
    if (iconEl) iconEl.className = "fas " + (THEME_MENU_ICONS[currentTheme] || "fa-circle-half-stroke");
    const fallbackNames = { light: "Clair", dark: "Sombre", poudre: "Poudre", bleu: "Bleu" };
    const themeName = safeT(THEME_LABEL_KEYS[currentTheme] || "", fallbackNames[currentTheme] || currentTheme);
    // La ponctuation (espace insécable FR / deux-points EN) vit dans la clé i18n
    const label = safeT("header.themeLabel", "Thème :") + " " + themeName;
    const labelEl = btn.querySelector(".csi-usermenu__theme-label");
    if (labelEl) labelEl.textContent = label;
    else btn.textContent = label;
    btn.title = safeT("header.themeToggleTitle", "Changer de thème");
  }
  function setTheme(mode) {
    currentTheme = normalizeTheme(mode);
    document.body.classList.toggle("theme-dark", currentTheme === "dark");
    document.body.classList.toggle("theme-light", currentTheme === "light");
    document.body.classList.toggle("theme-poudre", currentTheme === "poudre");
    document.body.classList.toggle("theme-bleu", currentTheme === "bleu");
    // Set data-theme for CSS selectors and prefers-color-scheme compatibility
    document.documentElement.setAttribute('data-theme', currentTheme);
    updateThemeToggleUi();
    try {
      if (typeof Chart !== "undefined") {
        const { textColor } = getThemeVars();
        Chart.defaults.color = textColor;
      }
    } catch (e) {
      void 0;
    }
    if (typeof updateCharts === "function") {
      updateCharts();
    }
    try {
      localStorage.setItem(THEME_STORAGE_KEY, currentTheme);
    } catch (e) {
      void 0;
    }
  }
  function toggleTheme() {
    const themes = THEME_OPTIONS.map(function(o) { return o.value; });
    const idx = themes.indexOf(currentTheme);
    setTheme(themes[(idx + 1) % themes.length]);
  }
  function downloadJsonBackup(content, name = "smsi_backup.json") {
    try {
      const blob = new Blob([content], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = name;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    } catch (e) {
      void 0;
    }
  }
  function initThemeToggle() {
    // Lire le thème par défaut depuis la balise meta (pour branding/packaging)
    const metaTheme = document.querySelector('meta[name="csi-default-theme"]');
    const brandDefaultTheme = metaTheme && metaTheme.content ? metaTheme.content : null;

    const saved = (() => {
      try {
        return localStorage.getItem(THEME_STORAGE_KEY);
      } catch (e) {
        return null;
      }
    })();

    // Si un thème de marque est défini et pas de thème sauvegardé, utiliser le thème de marque
    const defaultToUse = brandDefaultTheme || DEFAULT_THEME;
    const initial = normalizeTheme(saved || defaultToUse);
    setTheme(initial);
    // Note: click listener added in setupEventListeners(), not here, to avoid double-binding
  }
  const DEFAULT_NIS2_PHASES = [
    { id: "phase-1", label: "Phase 1 (2026)", order: 1 },
    { id: "phase-2", label: "Phase 2 (2027)", order: 2 },
    { id: "phase-3", label: "Phase 3 (2028)", order: 3 },
    { id: "phase-unplanned", label: t('templates.outOfPhase'), order: 99, fallback: true }
  ];
  // DEFAULT_NIS2_MATURITY is now dynamic - initialized in constants.js, rebuilt by setCyfunAssuranceLevel()
  // On first load, rebuild categories from framework data if available
  if (typeof window.rebuildCyfunCategories === 'function') {
    window.rebuildCyfunCategories('basic'); // default level until user data is loaded
  }
  const DEFAULT_SOCLE_PILLARS = [
    {
      id: "socle-pillar-1",
      title: t('templates.pillar1Title', 'Gouverner (GV)'),
      subtitle: t('templates.pillar1Subtitle', 'Gouvernance et politique cyber'),
      items: [
        "Formalisation de la gouvernance NIS2",
        "Politique de s\u00e9curit\u00e9 de l'information",
        "R\u00f4les et responsabilit\u00e9s SSI",
        "Strat\u00e9gie de gestion des risques",
        "Gestion des risques cha\u00eene d'approvisionnement"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-landmark"
    },
    {
      id: "socle-pillar-2",
      title: t('templates.pillar2Title', 'Identifier (ID)'),
      subtitle: t('templates.pillar2Subtitle', 'Actifs, risques et amélioration'),
      items: [
        "Cartographie des actifs critiques",
        "\u00c9valuation des risques cyber",
        "Programme d'am\u00e9lioration continue",
        "Inventaire des syst\u00e8mes et donn\u00e9es"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-magnifying-glass"
    },
    {
      id: "socle-pillar-3",
      title: t('templates.pillar3Title', 'Protéger (PR)'),
      subtitle: t('templates.pillar3Subtitle', 'Accès, données et infrastructure'),
      items: [
        "MFA et gestion des acc\u00e8s \u00e0 privil\u00e8ges",
        "Sensibilisation et formation cyber",
        "Chiffrement et s\u00e9curit\u00e9 des donn\u00e9es",
        "Durcissement des plateformes",
        "R\u00e9silience de l'infrastructure"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-shield-halved"
    },
    {
      id: "socle-pillar-4",
      title: t('templates.pillar4Title', 'Détecter (DE)'),
      subtitle: t('templates.pillar4Subtitle', 'Surveillance et analyse'),
      items: [
        "SIEM centralis\u00e9 et surveillance continue",
        "Analyse des \u00e9v\u00e9nements ind\u00e9sirables",
        "D\u00e9tection des anomalies et menaces",
        "Anti-malware et journalisation"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-chart-line"
    },
    {
      id: "socle-pillar-5",
      title: t('templates.pillar5Title', 'Répondre (RS)'),
      subtitle: t('templates.pillar5Subtitle', 'Gestion et communication des incidents'),
      items: [
        "Processus de gestion des incidents",
        "Analyse et att\u00e9nuation des incidents",
        "Circuit de notification NIS2",
        "Communication avec les autorit\u00e9s"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-bolt"
    },
    {
      id: "socle-pillar-6",
      title: t('templates.pillar6Title', 'Rétablir (RC)'),
      subtitle: t('templates.pillar6Subtitle', 'Reprise et communication de crise'),
      items: [
        "Plan de reprise d'activit\u00e9 (DRP)",
        "RTO/RPO d\u00e9finis et test\u00e9s",
        "Communication de reprise",
        "Retour d'exp\u00e9rience post-incident"
      ],
      owner: t('templates.defaultOwner'),
      icon: "fa-rotate-right"
    }
  ];
  const buildNis2Maturity = (source = {}) => {
    const result = {};
    CYFUN_CATEGORIES.forEach(cat => {
      result[cat] = 0;
    });
    // Apply values from source
    Object.keys(source).forEach(key => {
      if (typeof source[key] === 'number' && result.hasOwnProperty(key)) {
        result[key] = source[key];
      }
    });
    // Legacy compat: try to map pilier1-5 keys
    if (typeof source.pilier1 === 'number') {
      // Old pilier format, skip - not directly mappable to CyFun categories
    }
    return result;
  };
  const cloneDefaultSoclePillars = () => DEFAULT_SOCLE_PILLARS.map((pillar) => ({
    id: pillar.id,
    title: pillar.title,
    subtitle: pillar.subtitle,
    items: [...pillar.items],
    owner: pillar.owner,
    icon: pillar.icon
  }));
  const normalizeSoclePillars = (pillars = []) => {
    const source = Array.isArray(pillars) ? pillars : [];
    return DEFAULT_SOCLE_PILLARS.map((fallback, idx) => {
      const current = source[idx] || {};
      return {
        id: typeof current.id === "string" ? current.id : fallback.id,
        title: typeof current.title === "string" ? current.title : fallback.title,
        subtitle: typeof current.subtitle === "string" ? current.subtitle : fallback.subtitle,
        items: Array.isArray(current.items) ? current.items.filter(Boolean) : [...fallback.items],
        owner: typeof current.owner === "string" ? current.owner : fallback.owner,
        icon: typeof current.icon === "string" ? current.icon : fallback.icon
      };
    });
  };
  const DEFAULT_NIS2_PLAN = {
    phases: [...DEFAULT_NIS2_PHASES],
    maturity: {
      labels: CYFUN_CATEGORIES.length > 0 ? CYFUN_CATEGORIES.slice() : ['GV.OC','GV.RM','GV.RR','GV.PO','ID.AM','ID.RA','ID.IM','PR.AA','PR.AT','PR.DS','PR.PS','PR.IR','DE.CM','DE.AE','RS.MA','RS.CO','RC.RP'],
      current: Array(CYFUN_CATEGORIES.length || 17).fill(0),
      currentDoc: Array(CYFUN_CATEGORIES.length || 17).fill(0),
      currentImpl: Array(CYFUN_CATEGORIES.length || 17).fill(0),
      target2027: 0,
      target2028: 0
    },
    statuses: [],
    budgetBlocks: [
      { id: "nis2-budget-1", title: t('templates.budgetTotal', 'Budget total mise en conformité') },
      { id: "nis2-budget-2", title: t('templates.budget2026', 'Budget 2026') },
      { id: "nis2-budget-3", title: t('templates.budget2027', 'Budget 2027') },
      { id: "nis2-budget-4", title: t('templates.budget2028', 'Budget 2028') }
    ],
    customBlocks: [
      { id: "nis2-highlight-1", title: t('templates.budgetEngaged'), value: 0, format: "currency" },
      { id: "nis2-highlight-2", title: t('templates.globalProgress'), value: 0, format: "percent" },
      { id: "nis2-highlight-3", title: t('templates.completedActions'), value: 0, format: "percent" }
    ],
    domains: [
      {
        title: t('templates.domainGovernance', 'Gouvernance & responsabilité'),
        objective: "",
        pillar: 1,
        functionId: "GV",
        urgency: "standard",
        actions: [
          { text: "Formaliser la gouvernance NIS2 et le comit\u00e9 de pilotage.", phase: 1, status: "in_progress" },
          { text: "D\u00e9finir la cartographie des r\u00f4les et responsabilit\u00e9s SSI.", phase: 1, status: "todo" },
          { text: "Mettre en place un reporting trimestriel des risques majeurs.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainRiskMgmt', 'Gestion des risques (SMSI)'),
        objective: "",
        pillar: 1,
        functionId: "GV",
        urgency: "standard",
        actions: [
          { text: "Actualiser la m\u00e9thodologie d'analyse des risques.", phase: 1, status: "in_progress" },
          { text: "Lancer l'\u00e9valuation des risques m\u00e9tiers critiques.", phase: 1, status: "todo" },
          { text: "Prioriser le plan de traitement.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainSupplyChain', 'Sécurité de la chaîne d\'approvisionnement'),
        objective: "",
        pillar: 1,
        functionId: "GV",
        urgency: "high",
        actions: [
          { text: "Classifier les fournisseurs critiques.", phase: 1, status: "todo" },
          { text: "D\u00e9ployer un questionnaire de s\u00e9curit\u00e9 tiers.", phase: 1, status: "todo" },
          { text: "Planifier des audits p\u00e9riodiques.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainAssetRisk', 'Actifs et évaluation des risques'),
        objective: "",
        pillar: 2,
        functionId: "ID",
        urgency: "high",
        actions: [
          { text: "Cartographier les actifs critiques et d\u00e9pendances.", phase: 1, status: "in_progress" },
          { text: "R\u00e9aliser l'\u00e9valuation des risques selon NIS2.", phase: 1, status: "todo" },
          { text: "Mettre en place le programme d'am\u00e9lioration continue.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainAccessMgmt', 'Gestion des accès au SI'),
        objective: "",
        pillar: 3,
        functionId: "PR",
        urgency: "high",
        actions: [
          { text: "D\u00e9ployer le MFA sur les comptes sensibles.", phase: 1, status: "todo" },
          { text: "Lancer le programme de sensibilisation.", phase: 1, status: "in_progress" },
          { text: "Appliquer le moindre privil\u00e8ge.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainDataInfra', 'Sécurité données et infrastructure'),
        objective: "",
        pillar: 3,
        functionId: "PR",
        urgency: "high",
        actions: [
          { text: "Chiffrer les donn\u00e9es sensibles au repos et en transit.", phase: 1, status: "todo" },
          { text: "Durcir les configurations des plateformes.", phase: 1, status: "todo" },
          { text: "Renforcer la r\u00e9silience de l'infrastructure.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainDevSec', 'Sécurité dans l\'acquisition, le développement & la maintenance'),
        objective: "",
        pillar: 3,
        functionId: "PR",
        urgency: "high",
        actions: [
          { text: "\u00c9tablir le calendrier des scans et correctifs.", phase: 1, status: "in_progress" },
          { text: "Suivre les vuln\u00e9rabilit\u00e9s des d\u00e9pendances.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainTraceability', 'Traçabilité et auditabilité'),
        objective: "",
        pillar: 4,
        functionId: "DE",
        urgency: "medium",
        actions: [
          { text: "Mettre en place la surveillance continue.", phase: 1, status: "todo" },
          { text: "D\u00e9finir les r\u00e8gles d'analyse des \u00e9v\u00e9nements.", phase: 2, status: "todo" },
          { text: "D\u00e9ployer les alertes et corr\u00e9lations prioritaires.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainIncident', 'Gestion des incidents & notification'),
        objective: "",
        pillar: 5,
        functionId: "RS",
        urgency: "high",
        actions: [
          { text: "Formaliser le processus de gestion des incidents.", phase: 1, status: "todo" },
          { text: "Mettre en place le circuit de notification NIS2.", phase: 1, status: "todo" },
          { text: "Organiser un exercice de gestion d'incident.", phase: 2, status: "todo" }
        ]
      },
      {
        title: t('templates.domainBcpDrp', 'Continuité d\'activité & gestion de crise (BCP/DRP)'),
        objective: "",
        pillar: 6,
        functionId: "RC",
        urgency: "high",
        actions: [
          { text: "\u00c9laborer le plan de reprise d'incident.", phase: 1, status: "in_progress" },
          { text: "D\u00e9finir les RTO/RPO cibles et tester.", phase: 2, status: "todo" },
          { text: "D\u00e9finir le plan de communication de reprise.", phase: 2, status: "todo" }
        ]
      }
    ]
  };
  const clampPercent = (value, fallback = 0) => {
    const num = parseFloat(value);
    if (Number.isFinite(num)) return Math.max(0, Math.min(100, Math.round(num)));
    return Math.max(0, Math.min(100, fallback || 0));
  };
  const parseLooseNumber = (value) => {
    if (typeof value === "number") return value;
    if (value === null || value === void 0) return NaN;
    const str = String(value).replace(/\s/g, "").replace(",", ".").replace(/[^0-9.\-]/g, "");
    const num = parseFloat(str);
    return Number.isFinite(num) ? num : NaN;
  };
  const cloneDefaultNis2Plan = (options = {}) => {
    const includeActions = options.includeActions === true;
    const clamp = typeof clampPercent === "function"
      ? clampPercent
      : (value, fallback = 0) => {
          const num = parseFloat(value);
          if (Number.isFinite(num)) return Math.max(0, Math.min(100, Math.round(num)));
          return Math.max(0, Math.min(100, fallback || 0));
        };
    const parseLoose = typeof parseLooseNumber === "function"
      ? parseLooseNumber
      : (value) => {
          if (typeof value === "number") return value;
          if (value === null || value === void 0) return NaN;
          const str = String(value).replace(/\s/g, "").replace(",", ".").replace(/[^0-9.\-]/g, "");
          const num = parseFloat(str);
          return Number.isFinite(num) ? num : NaN;
        };
    return {
      phases: [...DEFAULT_NIS2_PLAN.phases],
      maturity: {
        labels: [...DEFAULT_NIS2_PLAN.maturity.labels],
        current: [...DEFAULT_NIS2_PLAN.maturity.current],
        target2027: DEFAULT_NIS2_PLAN.maturity.target2027,
        target2028: DEFAULT_NIS2_PLAN.maturity.target2028
      },
      statuses: DEFAULT_NIS2_PLAN.statuses.map((s, idx) => ({
        id: s.id || `nis2-status-${idx + 1}`,
        text: s.text,
        status: s.status
      })),
      budgetBlocks: (DEFAULT_NIS2_PLAN.budgetBlocks || []).map((b, idx) => ({
        id: b.id || `nis2-budget-${idx + 1}`,
        title: b.title || `Budget ${idx + 1}`
      })),
      customBlocks: (DEFAULT_NIS2_PLAN.customBlocks || []).map((b, idx) => {
        const format = (b.format || "currency").toLowerCase();
        const rawValue = typeof b.value !== "undefined" ? b.value : b.percent;
        const value = format === "currency"
          ? (() => {
              const num = parseLoose(rawValue);
              return Number.isFinite(num) ? num : 0;
            })()
          : clamp(rawValue, 0);
        return {
          id: b.id || `nis2-highlight-${idx + 1}`,
          title: b.title,
          value,
          format
        };
      }),
      domains: DEFAULT_NIS2_PLAN.domains.map((d, dIndex) => {
        const actions = includeActions ? (d.actions || []).map((a, aIndex) => ({
          id: a.id || `nis2-act-${dIndex + 1}-${aIndex + 1}`,
          text: a.text,
          phase: typeof a.phase === "number" ? a.phase : parseInt(a.phase, 10) || 1,
          status: a.status || "todo",
          owner: a.owner || "",
          workType: a.workType || "initiative",
          budget: typeof a.budget === "number" ? String(a.budget) : a.budget || "",
          budget2026: typeof a.budget2026 === "number" ? String(a.budget2026) : a.budget2026 || "",
          budget2027: typeof a.budget2027 === "number" ? String(a.budget2027) : a.budget2027 || "",
          budget2028: typeof a.budget2028 === "number" ? String(a.budget2028) : a.budget2028 || "",
          budgetCategory: a.budgetCategory || "divers",
          progress: typeof a.progress === "number" ? Math.max(0, Math.min(100, a.progress)) : a.status === "done" ? 100 : 0
        })) : [];
        return {
          id: d.id || `nis2-domain-${dIndex + 1}`,
          title: d.title,
          objective: d.objective,
          pillar: d.pillar,
          functionId: d.functionId || "",
          urgency: d.urgency || "standard",
          actions
        };
      })
    };
  };
  const getEmptyNis2Plan = () => {
    const plan = cloneDefaultNis2Plan();
    const labels = Array.isArray(plan.maturity?.labels) ? plan.maturity.labels : [];
    plan.maturity = {
      labels,
      current: labels.map(() => 0),
      target2027: 0,
      target2028: 0
    };
    plan.statuses = [];
    plan.budgetBlocks = [];
    plan.customBlocks = [];
    plan.domains = (plan.domains || []).map((domain) => ({
      ...domain,
      actions: []
    }));
    return plan;
  };
  // ===== Budget Cybersécurité — modèle de lignes budgétaires autonomes =====
  // Statuts budgétaires génériques (indépendants de NIS2).
  var BUDGET_STATUSES = ['planifie', 'engage', 'realise'];
  function normalizeBudgetStatus(s) {
    s = (s == null ? '' : String(s)).toLowerCase();
    if (s === 'engage' || s === 'engagé' || s === 'committed' || s === 'in_progress') return 'engage';
    if (s === 'realise' || s === 'réalisé' || s === 'spent' || s === 'done') return 'realise';
    return 'planifie';
  }
  function defaultBudgetYears() {
    var y;
    try { y = (new Date()).getFullYear(); } catch (e) { y = 2026; }
    if (!Number.isFinite(y)) y = 2026;
    return [String(y), String(y + 1), String(y + 2)];
  }
  // Migration one-shot, non destructive : convertit les budgets portés par les
  // actions des domaines NIS2 en lignes budgétaires autonomes (zéro perte de budget).
  // Seules les actions effectivement budgétées deviennent des lignes.
  // Les champs budget portés par les actions legacy ont des noms FIGÉS
  // budget2026 / budget2027 / budget2028 (jamais dérivés de l'année courante).
  var LEGACY_BUDGET_YEARS = ['2026', '2027', '2028'];
  function migrateActionsToBudgetLines(domains, years, parseLoose) {
    var lines = [];
    var counter = 0;
    var yrs = (Array.isArray(years) && years.length) ? years : defaultBudgetYears();
    (domains || []).forEach(function(d) {
      ((d && d.actions) || []).forEach(function(a) {
        if (!a) return;
        var amounts = {};
        var hasAny = false;
        yrs.forEach(function(yr) { amounts[yr] = 0; });
        // Mapping POSITIONNEL des champs legacy figés (budget2026/27/28) vers les
        // années cibles yrs[0..2]. Indispensable : sans cela, dès 2027 la lecture
        // a['budget'+yr] (yr = année système) viserait budget2027/28/29 et perdrait
        // silencieusement les montants saisis sur budget2026.
        LEGACY_BUDGET_YEARS.forEach(function(ly, i) {
          if (i >= yrs.length) return;
          var n = parseLoose(a['budget' + ly]);
          if (Number.isFinite(n) && n !== 0) { amounts[yrs[i]] += n; hasAny = true; }
        });
        // Champ legacy `budget` = total unique (utilisé seulement en l'absence de
        // ventilation annuelle, comme l'ancien getActionBudgetTotal).
        if (!hasAny) {
          var legacy = parseLoose(a.budget);
          if (Number.isFinite(legacy) && legacy !== 0) {
            amounts[yrs[0]] = legacy;
            hasAny = true;
          }
        }
        if (!hasAny) return;
        counter++;
        lines.push({
          id: a.id ? ('bl-' + a.id) : ('bl-mig-' + counter),
          label: a.text || ('Ligne ' + counter),
          category: a.budgetCategory || 'divers',
          owner: a.owner || '',
          status: normalizeBudgetStatus(a.status),
          amounts: amounts
        });
      });
    });
    return lines;
  }
  const normalizeNis2Plan = (plan) => {
    const defaults = cloneDefaultNis2Plan();
    const src = plan && typeof plan === "object" ? plan : {};
    const clamp = typeof clampPercent === "function"
      ? clampPercent
      : (value, fallback = 0) => {
          const num = parseFloat(value);
          if (Number.isFinite(num)) return Math.max(0, Math.min(100, Math.round(num)));
          return Math.max(0, Math.min(100, fallback || 0));
        };
    const parseLoose = typeof parseLooseNumber === "function"
      ? parseLooseNumber
      : (value) => {
          if (typeof value === "number") return value;
          if (value === null || value === void 0) return NaN;
          const str = String(value).replace(/\s/g, "").replace(",", ".").replace(/[^0-9.\-]/g, "");
          const num = parseFloat(str);
          return Number.isFinite(num) ? num : NaN;
        };
    const phases = Array.isArray(src.phases) && src.phases.length ? src.phases.map((p, idx) => ({
      id: p.id || `phase-${idx + 1}`,
      label: p.label || p.name || `Phase ${idx + 1}`,
      order: typeof p.order === "number" ? p.order : parseInt(p.order, 10) || idx + 1,
      fallback: !!p.fallback
    })) : defaults.phases;
    appData.nis2Plan = appData.nis2Plan || {};
    appData.nis2Plan.phases = phases;
    const normalizedPhases = ensureNis2Phases();
    const maturity = {
      labels: Array.isArray(src?.maturity?.labels) && src.maturity.labels.length ? src.maturity.labels : defaults.maturity.labels,
      current: Array.isArray(src?.maturity?.current) && src.maturity.current.length ? src.maturity.current : defaults.maturity.current,
      target2027: typeof src?.maturity?.target2027 === "number" ? src.maturity.target2027 : defaults.maturity.target2027,
      target2028: typeof src?.maturity?.target2028 === "number" ? src.maturity.target2028 : defaults.maturity.target2028
    };
    let statuses = Array.isArray(src.statuses) ? src.statuses.map((s, idx) => {
      const fallbackText = defaults.statuses.length
        ? defaults.statuses[idx % defaults.statuses.length].text
        : `Statut ${idx + 1}`;
      return {
        id: s.id || `nis2-status-${idx + 1}`,
        text: s.text || fallbackText,
        status: s.status || "todo"
      };
    }) : defaults.statuses;
    const budgetBlocks = (Array.isArray(src.budgetBlocks) && src.budgetBlocks.length ? src.budgetBlocks : defaults.budgetBlocks || []).map((b, idx) => ({
      id: b.id || `nis2-budget-${idx + 1}`,
      title: b.title || defaults.budgetBlocks?.[idx % (defaults.budgetBlocks.length || 1)]?.title || `Budget ${idx + 1}`
    }));
    const sourceBlocks = Array.isArray(src.customBlocks) && src.customBlocks.length ? src.customBlocks : defaults.customBlocks;
    const customBlocks = sourceBlocks.map((b, idx) => {
      const fallback = defaults.customBlocks[idx % defaults.customBlocks.length] || defaults.customBlocks[0] || {};
      let format = (b.format || fallback.format || "currency").toLowerCase();
      if (idx === 0 && format !== "currency") format = "currency";
      const rawValue = typeof b.value !== "undefined" ? b.value : (typeof b.percent !== "undefined" ? b.percent : (typeof fallback.value !== "undefined" ? fallback.value : fallback.percent));
      const fallbackValue = typeof fallback.value !== "undefined" ? fallback.value : fallback.percent;
      const value = format === "currency"
        ? (() => {
            const num = parseLoose(rawValue);
            const fb = parseLoose(fallbackValue);
            return Number.isFinite(num) ? num : (Number.isFinite(fb) ? fb : 0);
          })()
        : clamp(rawValue ?? fallbackValue ?? 0, clamp(fallbackValue ?? 0, 0));
      return {
        id: b.id || `nis2-highlight-${idx + 1}`,
        title: b.title || fallback.title || `Bloc ${idx + 1}`,
        value,
        format
      };
    });
    const domainsSource = Array.isArray(src.domains) ? src.domains : defaults.domains;
    const domains = domainsSource.map((d, dIndex) => {
      const templateDomain = defaults.domains[dIndex % defaults.domains.length];
      const templateActions = templateDomain.actions || [];
      return {
        id: d.id || `nis2-domain-${dIndex + 1}`,
        title: d.title || templateDomain.title,
        objective: d.objective || templateDomain.objective,
        pillar: d.pillar || templateDomain.pillar,
        functionId: d.functionId || templateDomain.functionId || "",
        urgency: d.urgency || "standard",
        actions: Array.isArray(d.actions) ? d.actions.map((a, aIndex) => {
          const fallback = templateActions[aIndex % templateActions.length] || templateActions[0] || {};
          const phase = normalizePhaseId(typeof a.phase === "undefined" ? fallback.phase : a.phase);
          const progress = typeof a.progress === "number" ? Math.max(0, Math.min(100, a.progress)) : a.status === "done" ? 100 : 0;
          const budget = typeof a.budget === "number" ? String(a.budget) : a.budget || fallback.budget || "";
          const budget2026 = typeof a.budget2026 === "number" ? String(a.budget2026) : a.budget2026 || fallback.budget2026 || "";
          const budget2027 = typeof a.budget2027 === "number" ? String(a.budget2027) : a.budget2027 || fallback.budget2027 || "";
          const budget2028 = typeof a.budget2028 === "number" ? String(a.budget2028) : a.budget2028 || fallback.budget2028 || "";
          const budgetCategory = a.budgetCategory || fallback.budgetCategory || "divers";
          return {
            id: a.id || `nis2-act-${dIndex + 1}-${aIndex + 1}`,
            text: a.text || fallback.text || "",
            phase,
            status: a.status || fallback.status || "todo",
            owner: a.owner || "",
            workType: a.workType || fallback.workType || "initiative",
            budget,
            budget2026,
            budget2027,
            budget2028,
            budgetCategory,
            progress
          };
        }) : []
      };
    });
    // Budget categories (user-editable)
    var defaultBudgetCategories = [
      { value: "infra", label: t('budgetCategories.infra', 'Infrastructure') },
      { value: "cyber", label: t('budgetCategories.cyber', 'Cybersécurité') },
      { value: "divers", label: t('budgetCategories.divers', 'Divers') }
    ];
    var budgetCategories = Array.isArray(src.budgetCategories) && src.budgetCategories.length
      ? src.budgetCategories.map(function(c) { return { value: c.value || c.label || '', label: c.label || c.value || '' }; })
      : defaultBudgetCategories;
    // Migrate legacy "ssi"/"spot" → "cyber" in all domain actions
    domains.forEach(function(d) {
      (d.actions || []).forEach(function(a) {
        if (a.budgetCategory === 'ssi' || a.budgetCategory === 'spot') a.budgetCategory = 'cyber';
      });
    });
    // NB : l'ancienne migration de titres de blocs (SSI/SPOT/Cybersécurité → « Budget 2028 »,
    // Infrastructure → « Budget 2027 ») a été retirée — elle réécrivait silencieusement des
    // libellés personnalisés et entrait en collision avec le nom « Budget Cybersécurité ».
    // Budget Cybersécurité — années configurables (libellés libres).
    var budgetYears;
    if (Array.isArray(src.budgetYears) && src.budgetYears.length) {
      budgetYears = src.budgetYears.map(function(yr) { return String(yr).trim(); }).filter(Boolean);
    } else if (Array.isArray(src.budgetLines) && src.budgetLines.length) {
      // budgetLines importées sans budgetYears : reconstituer les années depuis les
      // clés de montants présentes (sinon ces montants seraient orphelins/perdus).
      var seen = {};
      src.budgetLines.forEach(function(l) {
        if (l && l.amounts && typeof l.amounts === 'object') {
          Object.keys(l.amounts).forEach(function(yr) { var k = String(yr).trim(); if (k) seen[k] = true; });
        }
      });
      budgetYears = Object.keys(seen).sort();
      if (!budgetYears.length) budgetYears = defaultBudgetYears();
    } else {
      budgetYears = defaultBudgetYears();
    }
    budgetYears = budgetYears.filter(function(yr, i) { return budgetYears.indexOf(yr) === i; });
    if (!budgetYears.length) budgetYears = defaultBudgetYears();
    // Budget Cybersécurité — lignes budgétaires autonomes.
    // Si `budgetLines` est déjà présent (même vide) on le respecte ; sinon migration one-shot
    // depuis les budgets portés par les actions des domaines (rétro-compatibilité legacy).
    // NB : les champs budget des actions (domains[].budget2026/27/28 + budget) sont CONSERVÉS
    // volontairement (migration NON destructive : sécurité rollback / client non à jour) mais
    // ne sont PLUS la source de vérité une fois budgetLines créé — ne pas les rééditer.
    var budgetLines;
    if (Array.isArray(src.budgetLines)) {
      budgetLines = src.budgetLines.map(function(l, idx) {
        var srcAmounts = (l && l.amounts && typeof l.amounts === 'object') ? l.amounts : {};
        var amounts = {};
        budgetYears.forEach(function(yr) {
          var n = parseLoose(srcAmounts[yr]);
          amounts[yr] = Number.isFinite(n) ? n : 0;
        });
        return {
          id: (l && l.id) || ('bl-' + (idx + 1)),
          label: (l && l.label != null) ? String(l.label) : '',
          category: (l && l.category) || 'divers',
          owner: (l && l.owner) || '',
          status: normalizeBudgetStatus(l && l.status),
          amounts: amounts
        };
      });
    } else {
      budgetLines = migrateActionsToBudgetLines(domains, budgetYears, parseLoose);
    }
    return { phases: normalizedPhases, maturity, statuses, budgetBlocks, customBlocks, domains, budgetCategories, budgetYears, budgetLines };
  };
  if (typeof window !== 'undefined') {
    window.DOMAIN_BY_PREFIX = DOMAIN_BY_PREFIX;
    window.OPERATIONAL_CAPABILITIES = OPERATIONAL_CAPABILITIES;
    window.NIS2_CATEGORIES = NIS2_CATEGORIES;
  }
  // modules/dashboard.js
	  function initDashboard() {
	    updateDashboard();
	  }
	  function updateDashboard() {
	    _csiRenderDashboardViewSwitch();
	    updateKPIs();
	    updateCharts();
	    updateControlsLabel();
	    if (_csiDashboardConsolidatedActive()) updateConsolidatedDashboard();
	    else _csiApplyDashboardModeFrame(false);
	    if (typeof updateDashboardActivity === 'function') updateDashboardActivity();
	    // === CSI-ME === tuile « Vue groupe » (compte principal, mode multi-entités).
	    // Inutile en vue consolidée : le panorama porte les mêmes informations et la
	    // tuile y est masquée en CSS — la peindre coûtait un appel group-activity
	    // supplémentaire (le même que celui du panorama) pour un élément invisible.
	    if (!_csiDashboardConsolidatedActive() &&
	        window.CSI_EntityUI && window.CSI_EntityUI.renderGroupTile) window.CSI_EntityUI.renderGroupTile();
	  }
	  function _csiConsolidation() { return window.CSI_EntityConsolidation || null; }
	  function _csiDashboardConsolidatedActive() {
	    var C = _csiConsolidation();
	    return !!(C && C.isAllowed && C.isAllowed() && C.getDashboardMode && C.getDashboardMode() === 'consolidated');
	  }
	  function _csiRenderDashboardViewSwitch() {
	    var sw = document.getElementById('dashboardViewSwitch');
	    if (!sw) return;
	    var C = _csiConsolidation();
	    var allowed = !!(C && C.isAllowed && C.isAllowed());
	    sw.style.display = allowed ? '' : 'none';
	    if (!allowed && C && C.setDashboardMode) C.setDashboardMode('scope');
	    var mode = allowed && C.getDashboardMode ? C.getDashboardMode() : 'scope';
	    sw.querySelectorAll('[data-dashboard-view]').forEach(function(btn) {
	      var on = btn.getAttribute('data-dashboard-view') === mode;
	      btn.classList.toggle('dashboard-view-switch__btn--active', on);
	      btn.setAttribute('aria-pressed', on ? 'true' : 'false');
	      if (!btn._csiViewBound) {
	        btn._csiViewBound = true;
	        btn.addEventListener('click', function() {
	          var target = btn.getAttribute('data-dashboard-view') || 'scope';
	          if (C && C.setDashboardMode && C.setDashboardMode(target)) updateDashboard();
	        });
	      }
	    });
	  }
	  function _csiApplyDashboardModeFrame(consolidated) {
	    var dash = document.getElementById('dashboard');
	    if (dash) dash.classList.toggle('dashboard--consolidated', !!consolidated);
	    var note = document.getElementById('dashboardConsolidatedNote');
	    if (note) note.style.display = consolidated ? '' : 'none';
	    var panel = document.getElementById('dashboardMaturityPanel');
	    if (panel) panel.style.display = consolidated ? '' : 'none';
	  }
	  function _csiSectionMetric(summary, section, key) {
	    var s = summary && summary.sections ? summary.sections[section] : null;
	    if (!s) return 0;
	    if (!key) return s.total || 0;
	    return s[key] || 0;
	  }
	  function _csiSetMetric(id, value) {
	    var el = document.getElementById(id);
	    if (el) el.textContent = String(value == null ? 0 : value);
	  }
	  // Jeton de séquence : deux chargements concurrents (double-clic sur « Toutes les
	  // entités », ou changement de langue pendant un chargement lent à 50 entités)
	  // se marchaient dessus — la réponse la PLUS LENTE gagnait. Seule la réponse du
	  // dernier chargement lancé a le droit de peindre (ou de rapporter son échec).
	  var _csiConsolidatedRun = 0;
	  var _CSI_CONSOLIDATED_METRIC_IDS = ['totalActions','criticalRisks','openNonConformities','projectRisksCount','criticalAssetsCount','threatsCount','auditsCount','documentsCount','riskAnalysesCount','managementReviewsCount'];
	  // Peint TOUT l'écran consolidé depuis un résumé (tuiles + alertes + panorama).
	  // Sert aussi de repli en cas d'échec transitoire : re-peindre le DERNIER résumé
	  // consolidé connu est le seul affichage correctement étiqueté — l'ancienne
	  // « restauration » relisait le DOM, qui venait d'être peint par updateKPIs()
	  // avec les valeurs du PÉRIMÈTRE COURANT (updateDashboard l'appelle toujours
	  // avant nous) : des chiffres d'une seule entité s'affichaient comme totaux
	  // groupe sous l'en-tête « Toutes les entités ».
	  function _csiPaintConsolidatedSummary(summary) {
	    _csiSetMetric('totalActions', _csiSectionMetric(summary, 'actions', 'active'));
	    _csiSetMetric('criticalRisks', _csiSectionMetric(summary, 'risks', 'critical'));
	    _csiSetMetric('openNonConformities', _csiSectionMetric(summary, 'nonconformities', 'open'));
	    _csiSetMetric('projectRisksCount', _csiSectionMetric(summary, 'project-risks'));
	    _csiSetMetric('criticalAssetsCount', _csiSectionMetric(summary, 'critical-assets'));
	    _csiSetMetric('threatsCount', _csiSectionMetric(summary, 'threats-section'));
	    _csiSetMetric('auditsCount', _csiSectionMetric(summary, 'audits'));
	    _csiSetMetric('documentsCount', _csiSectionMetric(summary, 'documents'));
	    _csiSetMetric('riskAnalysesCount', _csiSectionMetric(summary, 'risk-analysis'));
	    _csiSetMetric('managementReviewsCount', _csiSectionMetric(summary, 'managementReviews'));
	    _csiRenderConsolidatedAlerts(summary);
	    _csiRenderPanorama(summary);
	  }
	  function updateConsolidatedDashboard() {
	    var C = _csiConsolidation();
	    if (!C || !C.fetchSummary || !C.isAllowed || !C.isAllowed()) { _csiApplyDashboardModeFrame(false); return; }
	    _csiApplyDashboardModeFrame(true);
	    // L'en-tête du panorama est rendu AVANT la résolution du fetch : le <thead>
	    // statique d'index.html est vide (source unique = _CSI_PANORAMA_COLS).
	    _csiRenderPanoramaHead();
	    var run = ++_csiConsolidatedRun;
	    _CSI_CONSOLIDATED_METRIC_IDS.forEach(function(id) { _csiSetMetric(id, '…'); });
	    // L'activité par partition (dernière modification, verrou) est un ENRICHISSEMENT
	    // du panorama : son échec ne doit jamais faire échouer la vue consolidée, d'où
	    // le catch local qui la neutralise en `null` (colonne « — »).
	    var activityPromise = (C.fetchActivity ? C.fetchActivity() : Promise.resolve(null))
	      ['catch'](function() { return null; });
	    Promise.all([C.fetchSummary({ force: true }), activityPromise]).then(function(results) {
	      if (run !== _csiConsolidatedRun) return;          // réponse périmée : on ne peint pas
	      var summary = results[0];
	      _csiPanoramaActivity = results[1] || null;
	      if (!_csiDashboardConsolidatedActive() || !summary) return;
	      _csiApplyDashboardModeFrame(true);
	      _csiPaintConsolidatedSummary(summary);
	    }).catch(function(error) {
	      // Un échec périmé ne doit ni alerter l'utilisateur ni défaire un rendu réussi
	      // plus récent : seul le dernier chargement lancé rapporte son échec.
	      if (run !== _csiConsolidatedRun) return;
	      if (!_csiDashboardConsolidatedActive()) {
	        // L'utilisateur a quitté la vue pendant le vol : la bascule a déjà repeint
	        // le dashboard de périmètre — ni toast tardif, ni retouche des tuiles
	        // partagées. Cas particulier : autorisation perdue en vol (multi-entités
	        // coupé) — on retire le cadre consolidé résiduel.
	        if (!C.isAllowed || !C.isAllowed()) { _csiApplyDashboardModeFrame(false); _csiRenderDashboardViewSwitch(); }
	        return;
	      }
	      if (window.console && console.warn) void 0;
	      if (typeof showToast === 'function') showToast(t('dashboard.consolidatedLoadError', 'Vue consolidée indisponible pour le moment.'), 'warning');
	      if (error && (error.status === 400 || error.status === 401 || error.status === 403)) {
	        if (C.setDashboardMode) C.setDashboardMode('scope');
	        _csiRenderDashboardViewSwitch();
	        _csiApplyDashboardModeFrame(false);
	        // Les tuiles portent encore les « … » posés au départ du chargement :
	        // repeindre les valeurs du périmètre, sinon elles restaient figées
	        // jusqu'au prochain passage sur l'onglet (défaut hérité de l'ancien code).
	        if (typeof updateKPIs === 'function') updateKPIs();
	        return;
	      }
	      // Échec transitoire en vue consolidée : dernier résumé connu si on en a un
	      // (données de groupe correctement étiquetées, juste plus si fraîches),
	      // sinon « — » — jamais des chiffres d'un autre périmètre.
	      if (_csiPanoramaSummary) {
	        _csiPaintConsolidatedSummary(_csiPanoramaSummary);
	      } else {
	        _CSI_CONSOLIDATED_METRIC_IDS.forEach(function(id) { _csiSetMetric(id, '—'); });
	        _csiRenderPanorama(null);
	      }
	    });
	  }
	  function _csiRenderConsolidatedAlerts(summary) {
	    var alertsList = document.getElementById("alertsList");
	    if (!alertsList) return;
	    var alerts = [];
	    var critical = _csiSectionMetric(summary, 'risks', 'critical');
	    var overdueActions = _csiSectionMetric(summary, 'actions', 'overdue');
	    var overdueAudits = _csiSectionMetric(summary, 'audits', 'overdue');
	    var upcomingAudits = _csiSectionMetric(summary, 'audits', 'upcoming30');
	    var redProjects = _csiSectionMetric(summary, 'project-risks', 'red');
	    if (critical > 0) alerts.push({ icon: 'critical', iconClass: 'fas fa-skull-crossbones', text: critical + ' ' + t('dashboard.alertCriticalRisks'), badge: t('dashboard.badgeUrgent'), target: 'risks' });
	    if (overdueActions > 0) alerts.push({ icon: 'warning', iconClass: 'fas fa-clock', text: overdueActions + ' ' + t('dashboard.alertOverdueActions'), badge: t('dashboard.badgeOverdue'), target: 'actions' });
	    if (overdueAudits > 0) alerts.push({ icon: 'warning', iconClass: 'fas fa-clipboard-check', text: overdueAudits + ' ' + t('dashboard.alertOverdueAudits', 'audit(s) en retard'), badge: t('dashboard.badgeOverdue'), target: 'audits' });
	    if (upcomingAudits > 0) alerts.push({ icon: 'info', iconClass: 'fas fa-calendar-check', text: upcomingAudits + ' ' + t('dashboard.alertUpcomingAudits'), badge: t('dashboard.badgePlanned'), target: 'audits' });
	    if (redProjects > 0) alerts.push({ icon: 'purple', iconClass: 'fas fa-diagram-project', text: redProjects + ' ' + t('dashboard.alertProjectsAtRisk'), badge: t('dashboard.badgeProjects'), target: 'project-risks' });
	    if (alerts.length === 0) {
	      alertsList.innerHTML = '<div class="dashboard-alerts__empty"><i class="fas fa-check-circle"></i>&nbsp;' + escapeHtml(t('dashboard.noAlerts')) + '</div>';
	      return;
	    }
	    alertsList.innerHTML = alerts.map(function(a) {
	      return '<div class="alert-item" data-nav-target="' + escapeHtml(a.target) + '" role="button" tabindex="0">' +
	        '<div class="alert-item__icon alert-item__icon--' + escapeHtml(a.icon) + '"><i class="' + escapeHtml(a.iconClass) + '"></i></div>' +
	        '<span class="alert-item__text">' + escapeHtml(a.text) + '</span>' +
	        '<span class="alert-item__badge">' + escapeHtml(a.badge) + '</span></div>';
	    }).join('');
	    alertsList.querySelectorAll('.alert-item[data-nav-target]').forEach(function(item) {
	      item.addEventListener('click', function() { _csiOpenConsolidatedSection(item.getAttribute('data-nav-target')); });
	      item.addEventListener('keypress', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } });
	    });
	  }
	  // ── Panorama des entités (dashboard consolidé « Toutes les entités ») ─────────
	  // Remplace l'ancien tableau « Maturité par entité » : mêmes scores (calculés
	  // SÉPARÉMENT par entité — aucun contrôle n'est jamais fusionné) mais lisibles
	  // d'un coup d'œil, replacés dans leur contexte (niveau du référentiel, couverture
	  // d'évaluation) et complétés des signaux opérationnels et de l'état de vie.
	  // Tri PUREMENT CLIENT : plafond produit de 50 entités, aucun besoin serveur.
	  // Défaut = score NIS2 croissant, soit « les entités à surveiller d'abord ».
	  //
	  // Construction par NŒUDS DOM (textContent), pas par concaténation HTML : les noms
	  // d'entités et d'utilisateurs sont des données saisies, et ce panorama est la vue
	  // où se croisent les données de TOUTES les partitions. Même contrat que la tuile
	  // « Vue groupe » (entity-ui.js) — aucune surface d'injection possible.
	  var _csiPanoramaSort = { key: 'maturityRate', dir: 1 };
	  var _csiPanoramaSummary = null;
	  var _csiPanoramaActivity = null;
	  var CSI_PANORAMA_IDLE_DAYS = 14;
	  var _CSI_PANORAMA_COLS = [
	    { key: 'entityName', label: 'entities.entityCol', fallback: 'Entité' },
	    { key: 'referential', label: 'dashboard.referential', fallback: 'Référentiel' },
	    { key: 'controlRate', label: 'dashboard.iso27001Conformity', fallback: 'Conformité ISO 27001' },
	    { key: 'isoMaturityRate', label: 'dashboard.iso27002Maturity', fallback: 'Maturité ISO 27002' },
	    { key: 'maturityRate', label: 'dashboard.nis2MaturityScore', fallback: 'Score NIS2' },
	    { key: 'signals', label: 'dashboard.entitySignals', fallback: 'Signaux' },
	    { key: 'activity', label: 'entities.colLastActivity', fallback: 'Dernière activité' }
	  ];
	  function _csiEl(tag, opts) {
	    var node = document.createElement(tag);
	    opts = opts || {};
	    if (opts.cls) node.className = opts.cls;
	    if (opts.text != null) node.textContent = opts.text;
	    if (opts.attrs) {
	      Object.keys(opts.attrs).forEach(function(name) { node.setAttribute(name, opts.attrs[name]); });
	    }
	    if (opts.children) {
	      opts.children.forEach(function(child) { if (child) node.appendChild(child); });
	    }
	    return node;
	  }
	  function _csiIconNode(name) {
	    return _csiEl('i', { cls: 'fas ' + name, attrs: { 'aria-hidden': 'true' } });
	  }
	  function _csiPanoramaPct(value) {
	    var n = parseInt(value, 10);
	    if (!Number.isFinite(n)) n = 0;
	    return Math.max(0, Math.min(100, n));
	  }
	  // Seuils alignés sur la jauge SMSI du dashboard (rouge < 40, vert >= 70) : un
	  // même pourcentage doit se lire de la même façon partout dans l'application.
	  function _csiScoreTone(value) {
	    var n = _csiPanoramaPct(value);
	    return n >= 70 ? 'high' : (n >= 40 ? 'mid' : 'low');
	  }
	  function _csiScoreCellNode(value, subLabel) {
	    var n = _csiPanoramaPct(value);
	    // <div> et non <span> : .progress-bar__fill ne déclare pas de `display`, or
	    // width/height sont sans effet sur un inline — la barre restait invisible.
	    // Tous les autres usages de .progress-bar dans l'application sont des <div>.
	    var fill = _csiEl('div', { cls: 'progress-bar__fill' });
	    fill.style.width = n + '%';
	    var td = _csiEl('td', { children: [
	      _csiEl('div', { cls: 'entity-score entity-score--' + _csiScoreTone(n), children: [
	        _csiEl('span', { cls: 'entity-score__value', text: n + '%' }),
	        _csiEl('div', { cls: 'progress-bar entity-score__bar', children: [fill] })
	      ]})
	    ]});
	    if (subLabel) td.appendChild(_csiEl('div', { cls: 'entity-score__sub', text: subLabel }));
	    return td;
	  }
	  function _csiPanoramaName(row) {
	    if (!row) return '';
	    if (row.entityId === 'group') return t('entities.mainEntity', 'Entité principale');
	    return row.entityName || row.entityId || '—';
	  }
	  function _csiReferentialLabel(referential) {
	    if (referential === 'recyf2025') return t('dashboard.refShortFR', 'ReCyF France');
	    if (referential === 'cyfun2025') return t('dashboard.refShortBE', 'CyFun 2025');
	    return referential || '—';
	  }
	  // Le NIVEAU est indissociable du score : 72 % au niveau Basic et 72 % au niveau
	  // Essential ne portent pas la même exigence — sans lui, comparer deux entités
	  // sur la même colonne induit en erreur.
	  function _csiReferentialLevelLabel(referential, level) {
	    if (!level) return '';
	    if (referential === 'recyf2025') {
	      if (level === 'essentielle') return t('dashboard.recyfLevelEE', 'Entité essentielle (EE)');
	      if (level === 'importante') return t('dashboard.recyfLevelEI', 'Entité importante (EI)');
	      return '';
	    }
	    if (level === 'essential') return t('dashboard.cyfunLevelEssential', 'Niveau Essential');
	    if (level === 'important') return t('dashboard.cyfunLevelImportant', 'Niveau Important');
	    if (level === 'basic') return t('dashboard.cyfunLevelBasic', 'Niveau Basic');
	    return '';
	  }
	  // Couverture d'évaluation : un score bas sur un référentiel à peine renseigné
	  // n'est pas un constat de faiblesse mais un constat d'absence de données.
	  // `assessedControls` absent = serveur antérieur au lot : on n'affiche rien.
	  function _csiCoverageLabel(row) {
	    if (!row || row.assessedControls == null) return '';
	    var total = parseInt(row.evaluatedControls, 10);
	    if (!Number.isFinite(total) || total <= 0) return '';
	    var assessed = parseInt(row.assessedControls, 10);
	    if (!Number.isFinite(assessed)) assessed = 0;
	    var pct = Math.max(0, Math.min(100, Math.round((assessed / total) * 100)));
	    return t('dashboard.coverageAssessed', '{{n}} % des mesures évaluées').replace('{{n}}', String(pct));
	  }
	  function _csiEntitySignal(summary, section, field, entityId) {
	    var bucket = summary && summary.sections ? summary.sections[section] : null;
	    // Clé absente = serveur antérieur au lot 2 : la colonne se dégrade en « — »
	    // au lieu d'afficher des zéros qui se liraient comme « aucun problème ».
	    if (!bucket || !bucket.byEntityFields) return null;
	    var perEntity = bucket.byEntityFields[entityId];
	    // Coercition stricte : une valeur non numérique s'afficherait telle quelle dans
	    // la pastille et ferait retourner NaN au comparateur de tri (ordre non spécifié).
	    var count = parseInt(perEntity ? perEntity[field] : 0, 10);
	    return (Number.isFinite(count) && count > 0) ? count : 0;
	  }
	  function _csiSignalPillNode(tone, iconClass, value, label) {
	    var n = (value == null) ? 0 : value;
	    var text = n + ' ' + label;
	    var pill = _csiEl('span', {
	      cls: 'entity-signal entity-signal--' + tone + (n ? '' : ' entity-signal--zero'),
	      attrs: { title: text, 'aria-label': text }
	    });
	    pill.appendChild(_csiIconNode(iconClass));
	    pill.appendChild(document.createTextNode(String(n)));
	    return pill;
	  }
	  function _csiSignalsCellNode(summary, entityId) {
	    var critical = _csiEntitySignal(summary, 'risks', 'critical', entityId);
	    var overdue = _csiEntitySignal(summary, 'actions', 'overdue', entityId);
	    var openNc = _csiEntitySignal(summary, 'nonconformities', 'open', entityId);
	    if (critical === null && overdue === null && openNc === null) {
	      return _csiEl('td', { children: [_csiEl('span', { cls: 'text-muted', text: '—' })] });
	    }
	    return _csiEl('td', { children: [
	      _csiEl('div', { cls: 'entity-signals', children: [
	        _csiSignalPillNode('danger', 'fa-triangle-exclamation', critical, t('dashboard.signalCritical', 'risque(s) critique(s)')),
	        _csiSignalPillNode('warning', 'fa-clock', overdue, t('dashboard.alertOverdueActions', 'action(s) en retard')),
	        _csiSignalPillNode('neutral', 'fa-file-circle-exclamation', openNc, t('dashboard.signalOpenNc', 'non-conformité(s) ouverte(s)'))
	      ]})
	    ]});
	  }
	  // Ordre lexicographique STRICT critique > retard > NC. Une pondération numérique
	  // (1000/10/1) se faisait doubler dès qu'un compteur dépassait la base suivante :
	  // 11 NC ouvertes pesaient plus qu'1 action en retard, et 100 retards autant
	  // qu'1 risque critique — aucun plafond ne borne ces compteurs par entité.
	  function _csiCompareSignals(summary, leftId, rightId) {
	    var fields = [['risks', 'critical'], ['actions', 'overdue'], ['nonconformities', 'open']];
	    for (var i = 0; i < fields.length; i++) {
	      var diff = SortUtils.numeric(
	        _csiEntitySignal(summary, fields[i][0], fields[i][1], leftId) || 0,
	        _csiEntitySignal(summary, fields[i][0], fields[i][1], rightId) || 0);
	      if (diff !== 0) return diff;
	    }
	    return 0;
	  }
	  function _csiActivityFor(entityId) {
	    if (!_csiPanoramaActivity) return null;
	    if (entityId === 'group') return _csiPanoramaActivity.main || null;
	    var list = _csiPanoramaActivity.entities || [];
	    for (var i = 0; i < list.length; i++) {
	      if (list[i] && list[i].id === entityId) return list[i];
	    }
	    return null;
	  }
	  function _csiActivityTs(entityId) {
	    var activity = _csiActivityFor(entityId);
	    // Horodatage Unix en secondes ; toute valeur non numérique vaut 0 (« aucune
	    // activité ») plutôt que de propager un NaN dans le tri et dans _csiIdleDays.
	    var ts = Number(activity && activity.lastActivity);
	    return (Number.isFinite(ts) && ts > 0) ? ts : 0;
	  }
	  function _csiIdleDays(entityId) {
	    var ts = _csiActivityTs(entityId);
	    if (!ts) return null;
	    return Math.floor(((Date.now() / 1000) - ts) / 86400);
	  }
	  function _csiRelTime(ts) {
	    var UI = window.CSI_EntityUI;
	    if (UI && typeof UI.relTime === 'function') return UI.relTime(ts);
	    if (!ts) return t('entities.noActivity', 'aucune activité');
	    var date = new Date(ts * 1000);
	    return isNaN(date.getTime()) ? '—' : date.toLocaleDateString();
	  }
	  function _csiActivityCellNode(entityId) {
	    var activity = _csiActivityFor(entityId);
	    if (!activity) {
	      return _csiEl('td', { children: [_csiEl('span', { cls: 'text-muted', text: '—' })] });
	    }
	    var wrap = _csiEl('div', { cls: 'entity-activity', children: [
	      _csiEl('span', { text: _csiRelTime(_csiActivityTs(entityId)) })
	    ]});
	    if (activity.lastActivityBy) wrap.appendChild(_csiEl('small', { text: activity.lastActivityBy }));
	    return _csiEl('td', { children: [wrap] });
	  }
	  function _csiEntityCellNode(row) {
	    var entityId = row.entityId || '';
	    var isMain = (entityId === 'group');
	    var activity = _csiActivityFor(entityId);
	    var idle = _csiIdleDays(entityId);
	    var td = _csiEl('td', { cls: 'entity-cell' });
	    var button = _csiEl('button', {
	      cls: 'entity-link', text: _csiPanoramaName(row), attrs: { type: 'button' }
	    });
	    button.addEventListener('click', function() { _csiPanoramaOpenEntity(entityId); });
	    td.appendChild(button);
	    if (activity && activity.lockHolder) {
	      // split/join et non .replace() : lockHolder est un nom d'utilisateur saisi,
	      // et les motifs $&, $`, $' d'un replacement string seraient interprétés
	      // (« J$&B » aurait affiché « En édition · J{{name}}B » dans l'infobulle).
	      var lockText = t('entities.inEdition', 'En édition · {{name}}').split('{{name}}').join(activity.lockHolder);
	      var lock = _csiEl('span', { cls: 'entity-tag entity-tag--lock', attrs: { title: lockText } });
	      lock.appendChild(_csiIconNode('fa-lock'));
	      lock.appendChild(document.createTextNode(activity.lockHolder));
	      td.appendChild(lock);
	    }
	    if (idle !== null && idle >= CSI_PANORAMA_IDLE_DAYS) {
	      td.appendChild(_csiEl('span', { cls: 'entity-tag entity-tag--idle',
	        text: t('dashboard.idleDays', 'inactive {{n}} j').replace('{{n}}', String(idle)) }));
	    }
	    td.appendChild(_csiEl('small', { cls: 'entity-link__kind',
	      text: isMain ? t('dashboard.scopeMain', 'Périmètre principal') : t('dashboard.scopeSub', 'Sous-entité') }));
	    return td;
	  }
	  function _csiReferentialCellNode(row) {
	    var td = _csiEl('td', { cls: 'entity-ref' });
	    td.appendChild(document.createTextNode(_csiReferentialLabel(row.referential)));
	    var levelLabel = _csiReferentialLevelLabel(row.referential, row.level);
	    if (levelLabel) td.appendChild(_csiEl('small', { text: levelLabel }));
	    return td;
	  }
	  function _csiSortPanoramaRows(rows, summary) {
	    var key = _csiPanoramaSort.key;
	    var dir = _csiPanoramaSort.dir;
	    return rows.slice().sort(function(a, b) {
	      var diff;
	      if (key === 'entityName') diff = SortUtils.text(_csiPanoramaName(a), _csiPanoramaName(b));
	      else if (key === 'referential') diff = SortUtils.text(_csiReferentialLabel(a.referential), _csiReferentialLabel(b.referential));
	      else if (key === 'signals') diff = _csiCompareSignals(summary, a.entityId, b.entityId);
	      else if (key === 'activity') diff = SortUtils.numeric(_csiActivityTs(a.entityId), _csiActivityTs(b.entityId));
	      else diff = SortUtils.numeric(_csiPanoramaPct(a[key]), _csiPanoramaPct(b[key]));
	      if (diff !== 0) return diff * dir;
	      // Départage stable et prévisible quand deux entités sont à égalité.
	      return SortUtils.text(_csiPanoramaName(a), _csiPanoramaName(b));
	    });
	  }
	  function _csiRenderPanoramaHead() {
	    var thead = document.getElementById('dashboardMaturityHead');
	    if (!thead) return;
	    _clearChildren(thead);
	    // (audit a11y 2026-07-03) tri actionnable au clavier via un vrai <button> et
	    // état exposé par aria-sort — même contrat que _consTh des vues consolidées.
	    var tr = _csiEl('tr');
	    _CSI_PANORAMA_COLS.forEach(function(col) {
	      var active = (_csiPanoramaSort.key === col.key);
	      var arrow = active ? (_csiPanoramaSort.dir > 0 ? ' ▲' : ' ▼') : '';
	      // Pas de `csi-num` ici : cette classe centre l'en-tête alors que les
	      // cellules du panorama (barres, pastilles) restent alignées à gauche —
	      // l'en-tête se serait décalé de son contenu.
	      var th = _csiEl('th', { cls: 'csi-sortable', attrs: { scope: 'col' } });
	      if (active) th.setAttribute('aria-sort', _csiPanoramaSort.dir > 0 ? 'ascending' : 'descending');
	      var button = _csiEl('button', {
	        cls: 'csi-th-btn', text: t(col.label, col.fallback) + arrow,
	        attrs: { type: 'button', 'data-panorama-sort': col.key }
	      });
	      button.addEventListener('click', function() {
	        if (_csiPanoramaSort.key === col.key) _csiPanoramaSort.dir = -_csiPanoramaSort.dir;
	        else { _csiPanoramaSort.key = col.key; _csiPanoramaSort.dir = 1; }
	        _csiRenderPanorama(_csiPanoramaSummary);
	        // Le re-rendu vient de détruire le bouton porteur du focus : sans
	        // restitution, un tri au clavier renvoyait le focus sur <body>
	        // (WCAG 2.4.3 — le thead persiste, seuls ses enfants sont recréés).
	        if (thead.querySelector) {
	          var again = thead.querySelector('[data-panorama-sort="' + col.key + '"]');
	          if (again && again.focus) again.focus();
	        }
	      });
	      th.appendChild(button);
	      tr.appendChild(th);
	    });
	    thead.appendChild(tr);
	  }
	  function _csiMedian(values) {
	    if (!values.length) return 0;
	    var sorted = values.slice().sort(function(a, b) { return a - b; });
	    var mid = Math.floor(sorted.length / 2);
	    return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
	  }
	  // Bandeau de synthèse : JAMAIS de moyenne de conformité « groupe ». Les scores
	  // portent des référentiels et des niveaux différents ; les additionner produirait
	  // un chiffre sans signification méthodologique — et contredirait la règle
	  // « les scores restent calculés séparément ». On expose donc la MÉDIANE (repère
	  // de position), l'entité la plus faible, et les totaux d'ÉLÉMENTS — qui, eux,
	  // s'additionnent légitimement.
	  function _csiRenderPanoramaStrip(rows, summary) {
	    var strip = document.getElementById('dashboardGroupStrip');
	    if (!strip) return;
	    _clearChildren(strip);
	    if (!rows.length) return;
	    var scores = rows.map(function(row) { return _csiPanoramaPct(row.maturityRate); });
	    // « À surveiller » ne se dit que d'une entité dont le référentiel est réellement
	    // renseigné : sans ce filtre, une entité fraîchement créée (0 mesure évaluée)
	    // était désignée « 0 % — à surveiller » dès qu'une autre avait un score — une
	    // absence de données lue comme une faiblesse, exactement ce que la couverture
	    // d'évaluation (colonne Score NIS2) a été introduite pour distinguer.
	    // Serveur antérieur au lot (assessedControls absent) : un score > 0 suffit.
	    function _watchEligible(row) {
	      if (row.assessedControls != null) {
	        var assessed = parseInt(row.assessedControls, 10);
	        return Number.isFinite(assessed) && assessed > 0;
	      }
	      return _csiPanoramaPct(row.maturityRate) > 0;
	    }
	    var weakest = null;
	    rows.forEach(function(row) {
	      if (!_watchEligible(row)) return;
	      if (!weakest || _csiPanoramaPct(row.maturityRate) < _csiPanoramaPct(weakest.maturityRate)) weakest = row;
	    });
	    var chips = [{ value: String(rows.length), label: t('dashboard.activeEntities', 'Entités actives') }];
	    if (weakest) {
	      chips.push({
	        value: _csiPanoramaName(weakest) + ' — ' + _csiPanoramaPct(weakest.maturityRate) + '%',
	        label: t('dashboard.entityToWatch', 'Entité à surveiller (score NIS2 le plus bas)'),
	        tone: 'warning', wide: true
	      });
	    }
	    chips.push({ value: _csiMedian(scores) + '%', label: t('dashboard.medianNis2', 'Médiane des scores NIS2') });
	    chips.push({ value: String(_csiSectionMetric(summary, 'risks', 'critical')),
	                 label: t('dashboard.criticalRisks', 'Risques Critiques'), tone: 'danger' });
	    chips.push({ value: String(_csiSectionMetric(summary, 'actions', 'overdue')),
	                 label: t('dashboard.overdueActions', 'Actions en retard'), tone: 'warning' });
	    chips.forEach(function(chip) {
	      strip.appendChild(_csiEl('div', {
	        cls: 'group-chip' + (chip.tone ? ' group-chip--' + chip.tone : '') + (chip.wide ? ' group-chip--wide' : ''),
	        children: [
	          _csiEl('div', { cls: 'group-chip__value', text: chip.value }),
	          _csiEl('div', { cls: 'group-chip__label', text: chip.label })
	        ]
	      }));
	    });
	  }
	  function _csiRenderPanoramaLegend(hasRows) {
	    var legend = document.getElementById('dashboardMaturityLegend');
	    if (!legend) return;
	    _clearChildren(legend);
	    if (!hasRows) return;
	    [['low', t('dashboard.legendLow', 'moins de 40 % — action requise')],
	     ['mid', t('dashboard.legendMid', '40 à 69 % — en progression')],
	     ['high', t('dashboard.legendHigh', '70 % et plus — bon niveau')]].forEach(function(pair) {
	      legend.appendChild(_csiEl('span', { cls: 'score-legend', children: [
	        _csiEl('span', { cls: 'score-legend__dot score-legend__dot--' + pair[0] }),
	        document.createTextNode(pair[1])
	      ]}));
	    });
	  }
	  function _csiRenderPanorama(summary) {
	    _csiPanoramaSummary = summary;
	    var tbody = document.getElementById('dashboardMaturityRows');
	    if (!tbody) return;
	    // Filtrage défensif des entrées nulles : une seule suffisait à faire échouer le
	    // rendu du panorama ET, par remontée dans le .catch de updateConsolidatedDashboard,
	    // à afficher un toast « vue indisponible » sur un chargement pourtant réussi.
	    var rows = (summary && Array.isArray(summary.maturity))
	      ? summary.maturity.filter(function(row) { return row && typeof row === 'object'; })
	      : [];
	    _csiRenderPanoramaHead();
	    _csiRenderPanoramaStrip(rows, summary);
	    _csiRenderPanoramaLegend(rows.length > 0);
	    _clearChildren(tbody);
	    if (!rows.length) {
	      tbody.appendChild(_csiEl('tr', { children: [
	        _csiEl('td', { cls: 'text-muted', text: t('dashboard.noMaturityData', 'Aucune maturité renseignée.'),
	                       attrs: { colspan: String(_CSI_PANORAMA_COLS.length) } })
	      ]}));
	      return;
	    }
	    _csiSortPanoramaRows(rows, summary).forEach(function(row) {
	      var applicable = t('dashboard.applicableFraction', '{{done}} / {{total}} applicables')
	        .replace('{{done}}', String(row.implementedControls || 0))
	        .replace('{{total}}', String(row.applicableControls || 0));
	      tbody.appendChild(_csiEl('tr', { children: [
	        _csiEntityCellNode(row),
	        _csiReferentialCellNode(row),
	        _csiScoreCellNode(row.controlRate, applicable),
	        _csiScoreCellNode(row.isoMaturityRate, ''),
	        _csiScoreCellNode(row.maturityRate, _csiCoverageLabel(row)),
	        _csiSignalsCellNode(summary, row.entityId),
	        _csiActivityCellNode(row.entityId)
	      ]}));
	    });
	  }
	  function _csiPanoramaOpenEntity(entityId) {
	    var C = _csiConsolidation();
	    if (!entityId || entityId === 'group') {
	      // Ligne « Entité principale » : le compte principal EST déjà sur ce périmètre —
	      // une bascule serveur ne ferait qu'un re-pull inutile. On revient simplement à
	      // la vue « Périmètre courant », qui EST le tableau de bord de cette entité.
	      if (C && C.setDashboardMode) C.setDashboardMode('scope');
	      updateDashboard();
	      return;
	    }
	    if (typeof window._csiSwitchScope !== 'function') return;
	    window._csiSwitchScope(entityId).then(function(result) {
	      if (!result || result.ok === false) return;
	      if (C && C.setDashboardMode) C.setDashboardMode('scope');
	      if (typeof switchModule === 'function') switchModule('dashboard');
	      else updateDashboard();
	    })['catch'](function(error) {
	      if (window.console && console.warn) void 0;
	    });
	  }
	  function _csiOpenConsolidatedSection(sectionId) {
	    var C = _csiConsolidation();
	    if (C && C.isSectionConsolidable && C.isSectionConsolidable(sectionId) && C.setSectionMode) {
	      C.setSectionMode(sectionId, 'consolidated');
	    }
	    if (typeof switchModule === "function") switchModule(sectionId);
	    else if (typeof loadModuleContent === "function") loadModuleContent(sectionId);
	  }
	  function _csiIsSectionConsolidable(sectionId) {
	    var C = _csiConsolidation();
	    return !!(C && C.isSectionConsolidable && C.isSectionConsolidable(sectionId));
	  }
	  function _csiRerenderSection(sectionId) {
	    if (typeof _rerenderCurrentTab === 'function') _rerenderCurrentTab(sectionId);
	    else if (sectionId === 'risk-analysis' && typeof window.renderRiskAnalysisModule === 'function') window.renderRiskAnalysisModule();
	    else if (typeof loadModuleContent === 'function') loadModuleContent(sectionId);
	  }
	  function _csiGetPathValue(row, path) {
	    if (!row || !path) return '';
	    var parts = String(path).split('.');
	    var value = row;
	    for (var index = 0; index < parts.length; index++) {
	      if (value == null || typeof value !== 'object' || !(parts[index] in value)) return '';
	      value = value[parts[index]];
	    }
	    return value == null ? '' : value;
	  }
	  function _csiPickValue(row, paths) {
	    for (var index = 0; index < paths.length; index++) {
	      var value = _csiGetPathValue(row, paths[index]);
	      if (value !== '' && value != null) return value;
	    }
	    return '';
	  }
	  function _csiHumanValue(value) {
	    if (Array.isArray(value)) return value.join(', ');
	    if (value == null || value === '') return '—';
	    if (typeof value === 'boolean') return value ? t('common.yes', 'Oui') : t('common.no', 'Non');
	    return String(value).replace(/_/g, ' ');
	  }
	  function _csiDateValue(value) {
	    if (!value) return '—';
	    var date = new Date(value);
	    return isNaN(date.getTime()) ? String(value) : date.toLocaleDateString();
	  }
	  // FIX-CONS-RA (audit v2 #43) : sans format, _csiHumanValue ne fait qu'un
	  // String().replace(/_/g,' ') — la vue consolidée affichait 'complete'/'draft'
	  // littéraux dans les 3 langues, alors que la liste locale traduit.
	  // NOTE DE PÉRIMÈTRE : le statut brut n'est PAS spécifique à l'analyse de risques
	  // (actions, audits, non-conformités, menaces, risques projets ont le même défaut).
	  // C'est un chantier d'unification déjà inscrit en différé (MEMOIRE-IA/04) : on ne
	  // l'étend PAS ici, sinon on fige une incohérence de plus.
	  function _csiRaStatusValue(value) {
	    var raw = String(value == null ? '' : value);
	    if (raw === 'complete') return t('riskAnalysis.statusComplete', 'Terminée');
	    if (raw === 'draft') return t('riskAnalysis.statusDraft', 'Brouillon');
	    return raw === '' ? '—' : raw;
	  }
	  function _csiRaScoreValue(value) {
	    var n = Number(value);
	    return Number.isFinite(n) ? (n + '/5') : '—';
	  }
	  function _csiPercentValue(value) {
	    var number = parseInt(value, 10);
	    if (!Number.isFinite(number)) number = 0;
	    return Math.max(0, Math.min(100, number)) + '%';
	  }
	  function _csiRiskScoreValue(value, row) {
	    var impact = parseFloat(_csiPickValue(row, ['impact'])) || 0;
	    var probability = parseFloat(_csiPickValue(row, ['probability', 'likelihood'])) || 0;
	    var score = impact * probability;
	    return score ? String(score) : '—';
	  }
	  function _csiReviewCommentsValue(value, row) {
	    if (Array.isArray(row && row.comments)) return String(row.comments.length);
	    return _csiHumanValue(value);
	  }
	  var _csiConsolidatedSectionSchemas = {
	    'risks': [
	      { label: 'entities.cols.title', fallback: 'Risque', paths: ['title', 'name'] },
	      { label: 'entities.cols.impactShort', fallback: 'I', paths: ['impact'] },
	      { label: 'entities.cols.likelihoodShort', fallback: 'V', paths: ['probability', 'likelihood'] },
	      { label: 'entities.cols.severity', fallback: 'Niveau', paths: ['level', 'severity'] },
	      { label: 'risks.columns.treatment', fallback: 'Traitement', paths: ['treatment'] },
	      { label: 'risks.columns.codirDate', fallback: 'Date CODIR', paths: ['codirDate', 'reviewDate'], format: _csiDateValue }
	    ],
	    'threats-section': [
	      { label: 'entities.cols.title', fallback: 'Titre', paths: ['title', 'name'] },
	      { label: 'entities.cols.impactShort', fallback: 'I', paths: ['impact'] },
	      { label: 'entities.cols.likelihoodShort', fallback: 'V', paths: ['likelihood', 'probability'] },
	      { label: 'threats.columns.score', fallback: 'Score', paths: ['score'], format: _csiRiskScoreValue },
	      { label: 'entities.cols.severity', fallback: 'Niveau', paths: ['level', 'severity'] },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] }
	    ],
	    'actions': [
	      { label: 'actions.id', fallback: 'ID', paths: ['displayId', 'ref', 'id'] },
	      { label: 'entities.cols.title', fallback: 'Action', paths: ['title', 'name'] },
	      { label: 'entities.cols.priority', fallback: 'Priorité', paths: ['priority'] },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] },
	      { label: 'actions.progress', fallback: 'Avancement', paths: ['progress'], format: _csiPercentValue },
	      { label: 'entities.cols.dueDate', fallback: 'Échéance', paths: ['dueDate'], format: _csiDateValue },
	      { label: 'entities.cols.assignee', fallback: 'Responsable', paths: ['assignedTo', 'owner'] }
	    ],
	    'nonconformities': [
	      { label: 'entities.cols.title', fallback: 'Titre', paths: ['title', 'name'] },
	      { label: 'entities.cols.type', fallback: 'Type', paths: ['type'] },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] },
	      { label: 'entities.cols.date', fallback: 'Date', paths: ['detectionDate', 'date'], format: _csiDateValue },
	      { label: 'entities.cols.assignee', fallback: 'Responsable', paths: ['assignedTo', 'owner'] },
	      { label: 'entities.cols.item', fallback: 'Description', paths: ['description'] }
	    ],
	    'critical-assets': [
	      // FIX-REVUE ACTIFS (2026-09) : résolution clé→libellé au rendu consolidé — sans format,
	      // un actif migré aux paliers affichait « lt4h » brut (fonction appelée au rendu, options
	      // initialisées à ce moment-là ; valeur legacy = inchangée).
	      { label: 'entities.cols.name', fallback: 'Produit', paths: ['productName', 'name', 'title'] },
	      { label: 'criticalAssets.columns.beneficiary', fallback: 'Bénéficiaire', paths: ['beneficiary'] },
	      { label: 'criticalAssets.columns.assetType', fallback: 'Type', paths: ['assetType'], format: _csiAssetTypeValue },
	      { label: 'entities.cols.importance', fallback: 'Importance', paths: ['importance'], format: _csiAssetImportanceValue },
	      { label: 'criticalAssets.columns.rto', fallback: 'RTO', paths: ['rto'], format: _csiAssetRtoValue },
	      { label: 'criticalAssets.columns.rpo', fallback: 'RPO', paths: ['rpo'], format: _csiAssetRpoValue },
	      { label: 'criticalAssets.columns.availability', fallback: 'Disponibilité', paths: ['availability'], format: _csiAssetAvailabilityValue },
	      { label: 'entities.cols.impactShort', fallback: 'Impact', paths: ['impact'], format: _csiAssetImpactValue }
	    ],
	    'audits': [
	      { label: 'entities.cols.title', fallback: 'Titre', paths: ['title', 'name'] },
	      { label: 'entities.cols.type', fallback: 'Type', paths: ['type'] },
	      { label: 'audits.columns.scope', fallback: 'Périmètre', paths: ['scope'] },
	      { label: 'entities.cols.date', fallback: 'Date', paths: ['plannedDate', 'date'], format: _csiDateValue },
	      { label: 'audits.columns.auditor', fallback: 'Auditeur', paths: ['auditor'] },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] }
	    ],
	    'documents': [
	      { label: 'entities.cols.category', fallback: 'Niveau', paths: ['category'] },
	      { label: 'entities.cols.type', fallback: 'Type', paths: ['type', 'title'] },
	      { label: 'documents.columns.tool', fallback: 'Outil', paths: ['tool'] },
	      { label: 'documents.columns.link', fallback: 'Lien', paths: ['link'] }
	    ],
	    'stakeholders': [
	      { label: 'entities.cols.name', fallback: 'Nom', paths: ['name', 'title'] },
	      { label: 'entities.cols.role', fallback: 'Rôle', paths: ['role'] },
	      { label: 'fields.contact', fallback: 'Contact', paths: ['contact'] },
	      { label: 'entities.cols.item', fallback: 'Notes', paths: ['notes'] }
	    ],
	    'kpis': [
	      { label: 'entities.cols.indicator', fallback: 'Indicateur', paths: ['title', 'name'] },
	      { label: 'kpi.description', fallback: 'Description', paths: ['description'] },
	      { label: 'kpi.progress', fallback: 'Progression', paths: ['progress'], format: _csiPercentValue },
	      { label: 'entities.cols.target', fallback: 'Cible', paths: ['target'] }
	    ],
	    'managementReviews': [
	      { label: 'entities.cols.date', fallback: 'Date', paths: ['date'], format: _csiDateValue },
	      { label: 'reviews.columns.participants', fallback: 'Participants', paths: ['participants'] },
	      { label: 'reviews.columns.inputs', fallback: 'Entrées', paths: ['inputs'] },
	      { label: 'reviews.columns.decisions', fallback: 'Décisions', paths: ['decisions'] },
	      { label: 'reviews.columns.comments', fallback: 'Commentaires', paths: ['comments'], format: _csiReviewCommentsValue }
	    ],
	    'project-risks': [
	      { label: 'projectRisks.projectName', fallback: 'Projet', paths: ['projectName', 'title', 'name'] },
	      { label: 'projectRisks.beneficiary', fallback: 'Bénéficiaire', paths: ['beneficiary'] },
	      { label: 'projectRisks.projectDescription', fallback: 'Description', paths: ['description'] },
	      { label: 'entities.cols.date', fallback: 'Date', paths: ['completionDate', 'date'], format: _csiDateValue },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] },
	      { label: 'entities.cols.priority', fallback: 'Priorité', paths: ['priority'] },
	      { label: 'projectRisks.note', fallback: 'Note', paths: ['note'] }
	    ],
	    'risk-analysis': [
	      { label: 'riskAnalysis.listColumns.title', fallback: 'Titre', paths: ['meta.title', 'identity.name', 'title', 'name'] },
	      { label: 'riskAnalysis.listColumns.created', fallback: 'Créée le', paths: ['meta.createdAt', 'createdAt'], format: _csiDateValue },
	      { label: 'riskAnalysis.listColumns.status', fallback: 'Statut', paths: ['meta.status', 'status'], format: _csiRaStatusValue },
	      { label: 'riskAnalysis.listColumns.score', fallback: 'Score', paths: ['results.globalScore'], format: _csiRaScoreValue },
	      { label: 'riskAnalysis.reviewDate.column', fallback: 'Revue', paths: ['meta.nextReviewDate'], format: _csiDateValue },
	      { label: 'projectRisks.beneficiary', fallback: 'Bénéficiaire', paths: ['identity.beneficiary', 'beneficiary'] }
	    ]
	  };
	  function _csiSectionName(sectionId) {
	    return t('entities.sections.' + sectionId, sectionId);
	  }
	  function _csiSectionSchema(sectionId) {
	    return _csiConsolidatedSectionSchemas[sectionId] || [
	      { label: 'entities.cols.title', fallback: 'Intitulé', paths: ['title', 'name', 'id'] },
	      { label: 'entities.cols.status', fallback: 'Statut', paths: ['status'] }
	    ];
	  }
	  function _csiRowTitle(sectionId, row) {
	    var schema = _csiSectionSchema(sectionId);
	    return _csiHumanValue(_csiPickValue(row, schema[0].paths || ['title', 'name', 'id']));
	  }
	  function _csiEntityMeta(row) {
	    var entity = row && row._entity && typeof row._entity === 'object' ? row._entity : {};
	    return {
	      id: entity.id || '',
	      name: entity.name || entity.id || t('entities.mainEntity', 'Entité principale')
	    };
	  }
	  function _csiEnsureSectionViewBar(sectionId, module, consolidated) {
	    var bar = module.querySelector('.csi-section-viewbar[data-section="' + sectionId + '"]');
	    var previousSearch = bar ? (bar.querySelector('[data-csi-section-search]') || {}).value || '' : '';
	    var previousEntity = bar ? (bar.querySelector('[data-csi-entity-filter]') || {}).value || '' : '';
	    if (!bar) {
	      bar = document.createElement('div');
	      bar.className = 'csi-section-viewbar';
	      bar.setAttribute('data-section', sectionId);
	      var header = module.querySelector('.module__header');
	      if (header && header.parentNode) header.parentNode.insertBefore(bar, header.nextSibling);
	      else module.insertBefore(bar, module.firstChild);
	    } else {
	      // Toolbar v2 : la rangée « Vue » doit TOUJOURS être en premier. Un re-init
	      // des SectionToolbar (ex. changement de langue) réinsère la toolbar juste
	      // après le header, passant devant la viewbar — on la repositionne.
	      var existingHeader = module.querySelector('.module__header');
	      if (existingHeader && existingHeader.parentNode && bar.previousElementSibling !== existingHeader) {
	        existingHeader.parentNode.insertBefore(bar, existingHeader.nextSibling);
	      }
	    }
	    bar.innerHTML =
	      '<div class="csi-section-viewbar__left">' +
	        '<span class="csi-section-viewbar__label">' + escapeHtml(t('dashboard.viewLabel', 'Vue')) + '</span>' +
	        '<div class="csi-section-toggle" role="group" aria-label="' + escapeHtml(t('entities.sectionViewAria', 'Vue de la section')) + '">' +
	          '<button type="button" class="csi-section-toggle__btn' + (!consolidated ? ' csi-section-toggle__btn--active' : '') + '" data-csi-section-mode="scope" aria-pressed="' + (!consolidated ? 'true' : 'false') + '">' + escapeHtml(t('dashboard.currentPerimeter', 'Périmètre courant')) + '</button>' +
	          '<button type="button" class="csi-section-toggle__btn' + (consolidated ? ' csi-section-toggle__btn--active' : '') + '" data-csi-section-mode="consolidated" aria-pressed="' + (consolidated ? 'true' : 'false') + '">' + escapeHtml(t('dashboard.allEntities', 'Toutes les entités')) + '</button>' +
	        '</div>' +
	        (consolidated ? '<span class="csi-readonly-pill"><i class="fas fa-eye" aria-hidden="true"></i> ' + escapeHtml(t('entities.readOnly', 'Lecture seule')) + '</span>' : '') +
	      '</div>' +
	      '<div class="csi-section-viewbar__filters" style="' + (consolidated ? '' : 'display:none') + '">' +
	        '<select class="form-control form-control--sm" data-csi-entity-filter aria-label="' + escapeHtml(t('entities.entityCol', 'Entité')) + '"><option value="">' + escapeHtml(t('entities.entityFilterAll', 'Toutes les entités')) + '</option></select>' +
	        '<input type="search" class="form-control form-control--sm" data-csi-section-search placeholder="' + escapeHtml(t('entities.sectionSearchPlaceholder', 'Rechercher dans la vue consolidée…')) + '" aria-label="' + escapeHtml(t('common.search', 'Rechercher...')) + '">' +
	        '<button type="button" class="btn btn--outline btn--sm" data-csi-section-refresh><i class="fas fa-rotate-right" aria-hidden="true"></i> ' + escapeHtml(t('entities.refresh', 'Actualiser')) + '</button>' +
	      '</div>';
	    var searchInput = bar.querySelector('[data-csi-section-search]');
	    var entitySelect = bar.querySelector('[data-csi-entity-filter]');
	    if (searchInput) searchInput.value = previousSearch;
	    if (entitySelect) entitySelect.value = previousEntity;
	    bar.querySelectorAll('[data-csi-section-mode]').forEach(function(button) {
	      button.addEventListener('click', function() {
	        var C = _csiConsolidation();
	        var mode = button.getAttribute('data-csi-section-mode') || 'scope';
	        if (C && C.setSectionMode) C.setSectionMode(sectionId, mode);
	        _csiRerenderSection(sectionId);
	      });
	    });
	    if (searchInput) {
	      searchInput.addEventListener('input', function() {
	        if (module._csiConsolidatedData) _csiRenderConsolidatedSectionRows(module, sectionId, module._csiConsolidatedData);
	      });
	    }
	    if (entitySelect) {
	      entitySelect.addEventListener('change', function() {
	        if (module._csiConsolidatedData) _csiRenderConsolidatedSectionRows(module, sectionId, module._csiConsolidatedData);
	      });
	    }
	    var refreshButton = bar.querySelector('[data-csi-section-refresh]');
	    if (refreshButton) {
	      refreshButton.addEventListener('click', function() {
	        module._csiForceConsolidatedFetch = true;
	        _csiRerenderSection(sectionId);
	      });
	    }
	    // Toolbar v2 : fusion visuelle viewbar + SectionToolbar en UNE carte.
	    // Classe modificatrice posée en JS (pas de :has — compat vieux navigateurs).
	    // En vue consolidée, la toolbar native est masquée (_csiSetNativeHidden) :
	    // la viewbar redevient une carte autonome (pas de coin plat orphelin).
	    var siblingToolbar = bar.nextElementSibling;
	    var isToolbar = !!(siblingToolbar && siblingToolbar.classList && siblingToolbar.classList.contains('section-toolbar'));
	    var attach = isToolbar && !consolidated;
	    bar.classList.toggle('csi-section-viewbar--attached', attach);
	    if (isToolbar) siblingToolbar.classList.toggle('section-toolbar--attached', attach);
	    return bar;
	  }
	  function _csiCleanupConsolidatedSection(module, options) {
	    if (!module) return;
	    module.classList.remove('module--consolidated-read');
	    var overlay = module.querySelector('.csi-consolidated-section');
	    if (overlay) overlay.remove();
	    Array.prototype.forEach.call(module.children, function(child) {
	      child.classList.remove('csi-section-native--hidden');
	    });
	    if (options && options.removeBar) {
	      var bar = module.querySelector('.csi-section-viewbar');
	      if (bar) bar.remove();
	      // Toolbar v2 : plus de viewbar au-dessus → la toolbar redevient une carte pleine.
	      var attachedToolbar = module.querySelector('.section-toolbar--attached');
	      if (attachedToolbar) attachedToolbar.classList.remove('section-toolbar--attached');
	    }
	  }
	  function _csiSetNativeHidden(module, hidden) {
	    Array.prototype.forEach.call(module.children, function(child) {
	      var keep = child.classList.contains('module__header') ||
	        child.classList.contains('csi-section-viewbar') ||
	        child.classList.contains('csi-consolidated-section');
	      if (!keep) child.classList.toggle('csi-section-native--hidden', !!hidden);
	    });
	  }
	  function _csiConsolidatedTarget(module, sectionId, options) {
	    if (options && options.contentId) return document.getElementById(options.contentId);
	    var overlay = module.querySelector('.csi-consolidated-section');
	    if (!overlay) {
	      overlay = document.createElement('div');
	      overlay.className = 'csi-consolidated-section';
	      var bar = module.querySelector('.csi-section-viewbar');
	      if (bar && bar.parentNode) bar.parentNode.insertBefore(overlay, bar.nextSibling);
	      else module.appendChild(overlay);
	    }
	    return overlay;
	  }
	  function _csiPopulateEntityFilter(module, rows) {
	    var select = module.querySelector('[data-csi-entity-filter]');
	    if (!select) return '';
	    var currentValue = select.value || '';
	    var entities = {};
	    rows.forEach(function(row) {
	      var entity = _csiEntityMeta(row);
	      if (entity.id) entities[entity.id] = entity.name;
	    });
	    var html = '<option value="">' + escapeHtml(t('entities.entityFilterAll', 'Toutes les entités')) + '</option>';
	    Object.keys(entities).sort(function(left, right) {
	      return String(entities[left]).localeCompare(String(entities[right]));
	    }).forEach(function(id) {
	      html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(entities[id]) + '</option>';
	    });
	    select.innerHTML = html;
	    if (currentValue && entities[currentValue]) select.value = currentValue;
	    return select.value || '';
	  }
	  function _csiVisibleCellText(row, column) {
	    var rawValue = _csiPickValue(row, column.paths || []);
	    var formatted = column.format ? column.format(rawValue, row) : _csiHumanValue(rawValue);
	    return formatted == null ? '' : String(formatted);
	  }
	  function _csiConsolidatedRelatedHtml(sectionId) {
	    if (sectionId !== 'project-risks') return '';
	    return '<div class="csi-consolidated-related">' +
	      '<div class="csi-consolidated-related__text">' +
	        '<strong>' + escapeHtml(t('projectRisks.consolidatedAnalysesTitle', 'Analyses de risques réalisées')) + '</strong>' +
	        '<span>' + escapeHtml(t('projectRisks.consolidatedAnalysesHint', 'Cette table liste le portefeuille des risques projets. Les analyses détaillées disposent d\'une vue consolidée dédiée.')) + '</span>' +
	      '</div>' +
	      '<button type="button" class="btn btn--outline btn--sm" data-csi-open-risk-analysis>' +
	        '<i class="fas fa-magnifying-glass-chart" aria-hidden="true"></i> ' +
	        escapeHtml(t('projectRisks.openConsolidatedAnalyses', 'Voir toutes les analyses')) +
	      '</button>' +
	    '</div>';
	  }
	  function _csiRenderConsolidatedSectionRows(module, sectionId, data) {
	    var target = module._csiConsolidatedTarget || _csiConsolidatedTarget(module, sectionId, {});
	    var rows = data && Array.isArray(data.items) ? data.items.slice() : [];
	    var schema = _csiSectionSchema(sectionId);
	    var entityFilter = _csiPopulateEntityFilter(module, rows);
	    var searchInput = module.querySelector('[data-csi-section-search]');
	    var search = NORMALIZE(searchInput ? searchInput.value || '' : '');
	    var filtered = rows.filter(function(row) {
	      var entity = _csiEntityMeta(row);
	      if (entityFilter && entity.id !== entityFilter) return false;
	      if (!search) return true;
	      var haystack = [entity.name, entity.id];
	      schema.forEach(function(column) { haystack.push(_csiVisibleCellText(row, column)); });
	      return NORMALIZE(haystack.join(' ')).indexOf(search) !== -1;
	    });
	    filtered.sort(function(left, right) {
	      var leftEntity = _csiEntityMeta(left).name;
	      var rightEntity = _csiEntityMeta(right).name;
	      var entityDiff = SortUtils.text(leftEntity, rightEntity);
	      if (entityDiff !== 0) return entityDiff;
	      return SortUtils.text(_csiVisibleCellText(left, schema[0]), _csiVisibleCellText(right, schema[0]));
	    });
	    var headerHtml = '<th scope="col">' + escapeHtml(t('entities.entityCol', 'Entité')) + '</th>';
	    schema.forEach(function(column) {
	      headerHtml += '<th scope="col">' + escapeHtml(t(column.label, column.fallback)) + '</th>';
	    });
	    headerHtml += '<th scope="col" class="csi-actions-col">' + escapeHtml(t('riskAnalysis.listColumns.actions', 'Actions')) + '</th>';
	    var bodyHtml = '';
	    if (!filtered.length) {
	      bodyHtml = '<tr><td colspan="' + (schema.length + 2) + '" class="table__empty-state">' + escapeHtml(t('entities.noItems', 'Aucun élément.')) + '</td></tr>';
	    } else {
	      filtered.forEach(function(row) {
	        var entity = _csiEntityMeta(row);
	        bodyHtml += '<tr>' +
	          '<td><span class="entity-badge" title="' + escapeHtml(entity.name) + '">' + escapeHtml(entity.name) + '</span></td>';
	        schema.forEach(function(column) {
	          bodyHtml += '<td><div class="cell-clamp cell-clamp--2">' + escapeHtml(_csiVisibleCellText(row, column)) + '</div></td>';
	        });
	        bodyHtml += '<td class="csi-consolidated-actions">';
	        if (entity.id) {
	          bodyHtml += '<button type="button" class="btn btn--outline btn--sm" data-csi-open-source data-entity="' + escapeHtml(entity.id) + '" data-item="' + escapeHtml(row.id || '') + '">' +
	            '<i class="fas fa-arrow-right" aria-hidden="true"></i> ' + escapeHtml(t('entities.openInEntity', 'Ouvrir dans l\'entité')) + '</button>';
	        } else {
	          bodyHtml += '—';
	        }
	        bodyHtml += '</td></tr>';
	      });
	    }
	    var totalCount = data && data.total != null ? data.total : rows.length;
	    var loadedCount = data && data.loaded != null ? data.loaded : rows.length;
	    var countLabel = String(filtered.length) + '/' + String(totalCount);
	    if (data && data.truncated) countLabel += ' · ' + String(loadedCount) + ' ' + t('adminUi.loadedSuffix', 'chargés');
	    var truncatedHtml = data && data.truncated ?
	      '<small class="csi-consolidated-truncated" title="' + escapeHtml(t('entities.truncatedTitle', 'Affichage limité pour préserver la fluidité.')) + '">' +
	        '<i class="fas fa-triangle-exclamation" aria-hidden="true"></i> ' +
	        escapeHtml(t('entities.truncatedHint', 'Liste partielle — affinez par recherche ou entité.')) +
	      '</small>' : '';
	    var relatedHtml = _csiConsolidatedRelatedHtml(sectionId);
	    target.innerHTML =
	      '<div class="csi-consolidated-banner" role="status">' +
	        '<i class="fas fa-layer-group" aria-hidden="true"></i>' +
	        '<span>' + escapeHtml(t('entities.consoleHint', 'Lecture seule. Chaque ligne porte l\'entité d\'origine. L\'édition se fait dans le périmètre concerné.')) + '</span>' +
	        truncatedHtml +
	        '<strong>' + escapeHtml(countLabel) + '</strong>' +
	      '</div>' +
	      relatedHtml +
	      '<div class="table-container csi-consolidated-tablewrap">' +
	        '<table class="table csi-consolidated-table">' +
	          '<thead><tr>' + headerHtml + '</tr></thead>' +
	          '<tbody>' + bodyHtml + '</tbody>' +
	        '</table>' +
	      '</div>';
	    target.querySelectorAll('[data-csi-open-risk-analysis]').forEach(function(button) {
	      button.addEventListener('click', function() {
	        var C = _csiConsolidation();
	        if (C && C.setSectionMode) C.setSectionMode('risk-analysis', 'consolidated');
	        if (typeof switchModule === 'function') switchModule('risk-analysis');
	        else document.querySelector('[data-tab="risk-analysis"]')?.click();
	        if (typeof window.renderRiskAnalysisModule === 'function') window.renderRiskAnalysisModule();
	      });
	    });
	    target.querySelectorAll('[data-csi-open-source]').forEach(function(button) {
	      button.addEventListener('click', function() {
	        _csiOpenConsolidatedSource(sectionId, button.getAttribute('data-entity'), button.getAttribute('data-item'));
	      });
	    });
	  }
	  function _csiOpenConsolidatedSource(sectionId, entityId, itemId) {
	    var C = _csiConsolidation();
	    if (C && C.setSectionMode) C.setSectionMode(sectionId, 'scope');
	    if (!window._csiSwitchScope) {
	      if (typeof switchModule === 'function') switchModule(sectionId);
	      return;
	    }
	    window._csiSwitchScope(entityId === 'group' ? null : entityId).then(function(result) {
	      if (!result || result.ok === false) return;
	      if (typeof switchModule === 'function') switchModule(sectionId);
	      else if (typeof loadModuleContent === 'function') loadModuleContent(sectionId);
	      if (typeof showToast2 === 'function') {
	        showToast2(t('entities.openedInEntity', 'Périmètre source ouvert. L\'élément reste en consultation.'), 'info', 5000);
	      }
	      if (itemId) {
	        window._csiLastOpenedConsolidatedItem = { section: sectionId, id: itemId, at: Date.now() };
	      }
	    });
	  }
	  function _csiRenderConsolidatedSectionIfNeeded(sectionId, options) {
	    options = options || {};
	    var module = document.getElementById(sectionId);
	    var C = _csiConsolidation();
	    var allowed = !!(module && C && C.isAllowed && C.isAllowed() && C.isSectionConsolidable && C.isSectionConsolidable(sectionId));
	    if (!module) return false;
	    if (!allowed) {
	      _csiCleanupConsolidatedSection(module, { removeBar: true });
	      return false;
	    }
	    var consolidated = C.getSectionMode && C.getSectionMode(sectionId) === 'consolidated';
	    _csiEnsureSectionViewBar(sectionId, module, consolidated);
	    if (!consolidated) {
	      _csiCleanupConsolidatedSection(module, { removeBar: false });
	      return false;
	    }
	    module.classList.add('module--consolidated-read');
	    if (!options.contentId) _csiSetNativeHidden(module, true);
	    var target = _csiConsolidatedTarget(module, sectionId, options);
	    module._csiConsolidatedTarget = target;
	    target.innerHTML = '<div class="csi-consolidated-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i> ' + escapeHtml(t('entities.loading', 'Chargement…')) + '</div>';
	    var requestToken = Date.now() + ':' + Math.random();
	    module._csiConsolidatedToken = requestToken;
	    var force = !!module._csiForceConsolidatedFetch;
	    module._csiForceConsolidatedFetch = false;
	    C.fetchSection(sectionId, { force: force }).then(function(data) {
	      if (module._csiConsolidatedToken !== requestToken) return;
	      if (!C.isAllowed || !C.isAllowed() || C.getSectionMode(sectionId) !== 'consolidated') return;
	      module._csiConsolidatedData = data || { items: [], total: 0 };
	      _csiRenderConsolidatedSectionRows(module, sectionId, module._csiConsolidatedData);
	    }).catch(function(error) {
	      target.innerHTML = '<div class="csi-consolidated-error"><i class="fas fa-triangle-exclamation" aria-hidden="true"></i> ' + escapeHtml(t('entities.loadFailed', 'Chargement impossible')) + '</div>';
	      void 0;
	    });
	    return true;
	  }
	  window._csiRenderConsolidatedSectionIfNeeded = _csiRenderConsolidatedSectionIfNeeded;
  function validateDataQuality() {
    const issues = [];
    const collectDuplicates = (items, label, key = "title") => {
      const map = new Map();
      items.forEach((it) => {
        const val = (it[key] || "").trim().toLowerCase();
        if (!val) return;
        map.set(val, (map.get(val) || 0) + 1);
      });
      const dups = [...map.entries()].filter(([, count]) => count > 1);
      dups.forEach(([val, count]) => issues.push(`Doublon ${label}: "${val}" (${count}x)`));
    };
    collectDuplicates(appData.actions, "action");
    collectDuplicates(appData.risks, "risque");
    collectDuplicates(appData.documents, "document", "type");

    const controlsMissingSubcat = (appData.controls || []).filter(c => !c.subcategory || (Array.isArray(c.subcategory) && c.subcategory.length === 0));
    if (controlsMissingSubcat.length) {
      issues.push(`${controlsMissingSubcat.length} contrôle(s) sans sous-catégorie opérationnelle`);
    }

    const badLinks = (appData.documents || []).filter((d) => {
      if (!d.link) return false;
      try {
        const url = new URL(d.link);
        return !["http:", "https:"].includes(url.protocol);
      } catch (e) {
        return true;
      }
    });
    badLinks.forEach((doc) => issues.push(`Lien document invalide: ${doc.link || "(vide)"}`));

    if (issues.length === 0) {
      showToast2(t('toasts.noInconsistency'), "success");
      return;
    }
    const body = issues.map((i) => `- ${i}`).join("\n");
    openModal(t('modals.dataQuality'), [
      { name: "issues", label: "Points détectés", type: "textarea", value: body, readOnly: true }
    ], () => true);
  }
  function getHistoryLimit() {
    try {
      const raw = localStorage.getItem(HISTORY_LIMIT_STORAGE_KEY);
      const value = parseInt(raw, 10);
      if (Number.isFinite(value) && value >= 1 && value <= 30) return value;
    } catch (e) {
      // no-op
    }
    return HISTORY_LIMIT_DEFAULT;
  }
  function normalizeHistorySnapshot(entry) {
    const cleanLabel = typeof entry?.label === "string" ? entry.label.trim() : "";
    const data = typeof entry?.data === "string" ? entry.data : "";
    const tag = entry?.tag === "auto" ? "auto" : "manuel";
    const size = typeof entry?.size === "number" ? entry.size : (data ? data.length : 0);
    return {
      ...entry,
      label: cleanLabel || "Snapshot",
      tag,
      size,
      data
    };
  }
  function getHistorySnapshots() {
    // Mode serveur : historique en RAM uniquement
    // FIX-SNAPSHOT-MODE (audit 2026-06-10, bundle.js:1192) : utiliser le même prédicat
    // que saveData (isServerModeUnlocked, basé licence/édition) et non le protocole —
    // une édition locale servie en http(s) stockait ses données en localStorage mais ses
    // snapshots en RAM volatile (perdus à chaque rechargement).
    if (typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) {
      return _ramHistorySnapshots.map(normalizeHistorySnapshot);
    }
    try {
      const raw = localStorage.getItem(HISTORY_KEY);
      const list = raw ? JSON.parse(raw) : [];
      if (!Array.isArray(list)) return [];
      return list.map(normalizeHistorySnapshot);
    } catch (e) {
      return [];
    }
  }
  function saveHistorySnapshots(list) {
    const limit = getHistoryLimit();
    const normalized = Array.isArray(list) ? list.map(normalizeHistorySnapshot) : [];
    normalized.sort((a, b) => SortUtils.date(a.date, b.date));
    // Mode serveur : stocker en RAM uniquement
    // FIX-SNAPSHOT-MODE : même prédicat que getHistorySnapshots (voir ci-dessus)
    if (typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) {
      _ramHistorySnapshots = normalized.slice(-limit);
      return true;
    }
    try {
      localStorage.setItem(HISTORY_KEY, JSON.stringify(normalized.slice(-limit)));
      refreshStorageHealth();
      return true;
    } catch (e) {
      // FIX-SNAPSHOT-QUOTA (audit 2026-06-10) : ne plus échouer en silence (quota
      // localStorage dépassé) — retourner false pour que l'appelant alerte l'utilisateur.
      void 0;
      return false;
    }
  }
  function addHistorySnapshot(label = "Snapshot", options = {}) {
    try {
      ensureNis2Plan();
      ensureCriticalAssets();
      ensureProjectRisks();
      const data = JSON.stringify(appData);
      const cleanLabel = typeof label === "string" ? label.trim() : "Snapshot";
      const tag = options?.tag === "auto" ? "auto" : "manuel";
      const entry = {
        id: generateId("snapshot"),
        label: cleanLabel || "Snapshot",
        date: new Date().toISOString(),
        data,
        size: data.length,
        tag
      };
      const list = getHistorySnapshots();
      list.push(entry);
      // FIX-SNAPSHOT-QUOTA (audit 2026-06-10) : un échec d'écriture (quota localStorage)
      // affichait quand même le toast de succès — y compris pour les snapshots de
      // sécurité automatiques avant restauration/réinitialisation/import.
      if (!saveHistorySnapshots(list)) {
        showToast2(t('toasts.snapshotError', 'Impossible de créer le snapshot : {{message}}').replace('{{message}}', t('settings.storageInsufficient', 'Espace de stockage insuffisant')), "error", 8000);
        renderHistoryList();
        return false;
      }
      showToast2(t('toasts.snapshotSaved'), "success");
      renderHistoryList();
      return true;
    } catch (e) {
      showToast2(t('toasts.snapshotError', 'Impossible de créer le snapshot : {{message}}').replace('{{message}}', e.message), "error");
      return false;
    }
  }
  function formatSnapshotSize(bytes) {
    const size = Number(bytes);
    if (!Number.isFinite(size) || size <= 0) return "0 KB";
    if (size < 1024) return `${size} o`;
    const kb = size / 1024;
    if (kb < 1024) return `${kb.toFixed(1)} Ko`;
    const mb = kb / 1024;
    return `${mb.toFixed(1)} Mo`;
  }
  function renderHistoryList() {
    const container = document.getElementById("historyList");
    if (!container) return;
    const searchInput = document.getElementById("historySearch");
    const tagFilter = document.getElementById("historyTagFilter");
    const searchQuery = NORMALIZE(searchInput ? searchInput.value.trim() : "");
    const selectedTag = tagFilter ? tagFilter.value : "all";
    const list = getHistorySnapshots().slice().sort((a, b) => SortUtils.date(b.date, a.date));
    const filtered = list.filter((snap) => {
      const tagMatch = selectedTag === "all" || snap.tag === selectedTag;
      if (!tagMatch) return false;
      if (!searchQuery) return true;
      return NORMALIZE(snap.label).includes(searchQuery);
    });
    container.innerHTML = "";
    if (!list.length) {
      container.textContent = t('snapshots.empty', 'Aucun snapshot enregistré');
      const meta = document.getElementById("historyMeta");
      if (meta) {
        meta.innerHTML = "";
        const empty = document.createElement("div");
        empty.textContent = t('snapshots.historyEmpty', 'Historique vide.');
        meta.appendChild(empty);
      }
      return;
    }
    const meta = document.getElementById("historyMeta");
    if (meta) {
      meta.innerHTML = "";
      const countLine = document.createElement("div");
      const limit = getHistoryLimit();
      countLine.textContent = t('snapshots.kept', 'Snapshots conservés : {{count}}/{{limit}}').replace('{{count}}', list.length).replace('{{limit}}', limit);
      const visibleLine = document.createElement("div");
      visibleLine.textContent = t('snapshots.displayed', 'Affichés : {{count}}').replace('{{count}}', filtered.length);
      const latest = list[0];
      const latestLine = document.createElement("div");
      const latestDate = latest?.date ? new Date(latest.date).toLocaleString() : "N/A";
      latestLine.textContent = t('snapshots.latest', 'Dernier snapshot : {{date}}').replace('{{date}}', latestDate);
      const totalSize = list.reduce((sum, snap) => {
        const size = typeof snap.size === "number" ? snap.size : (snap.data ? snap.data.length : 0);
        return sum + (Number.isFinite(size) ? size : 0);
      }, 0);
      const sizeLine = document.createElement("div");
      sizeLine.textContent = t('snapshots.totalSize', 'Taille cumulée : {{size}}').replace('{{size}}', formatSnapshotSize(totalSize));
      meta.append(countLine, visibleLine, latestLine, sizeLine);
    }
    if (!filtered.length) {
      container.textContent = t('snapshots.noMatch', 'Aucun snapshot ne correspond aux filtres.');
      return;
    }
    filtered.forEach((snap) => {
      const item = document.createElement("div");
      item.className = "history-item";
      const date = snap.date ? new Date(snap.date).toLocaleString() : "N/A";
      const size = typeof snap.size === "number" ? snap.size : (snap.data ? snap.data.length : 0);
      const sizeLabel = formatSnapshotSize(size);
      const tagLabel = snap.tag === "auto" ? t('snapshots.tagAuto', 'Auto') : t('snapshots.tagManual', 'Manuel');
      item.innerHTML = `
        <div class="history-item__info">
          <div class="history-item__headline">
            <span class="history-item__tag history-item__tag--${escapeHtml(snap.tag)}">${tagLabel}</span>
            <div class="history-item__title">${escapeHtml(snap.label || "Snapshot")}</div>
          </div>
          <div class="history-item__meta">${escapeHtml(date)} • ${escapeHtml(sizeLabel)}</div>
        </div>
        <div class="history-item__actions">
          <button class="btn btn--outline btn--sm" data-action="restore" data-id="${snap.id}">${t('snapshots.restore', 'Restaurer')}</button>
          <button class="btn btn--outline btn--sm" data-action="download" data-id="${snap.id}">${t('snapshots.export', 'Exporter')}</button>
          <button class="btn btn--outline btn--sm" data-action="delete" data-id="${snap.id}">${t('common.delete', 'Supprimer')}</button>
        </div>
      `;
      container.appendChild(item);
    });
    container.querySelectorAll("[data-action]").forEach((btn) => {
      btn.addEventListener("click", (e) => {
        const id = e.currentTarget.dataset.id;
        const action = e.currentTarget.dataset.action;
        const snap = getHistorySnapshots().find((s) => s.id === id);
        if (!snap) return;
        if (action === "restore") {
          showConfirmModal(t('common.confirm', 'Confirmer'), t('confirms.restoreSnapshot', 'Restaurer ce snapshot ? Les données actuelles seront remplacées.'), async function() {
            // === CSI-CO === (audit 2026-08-05) Sas de grappes AVANT de remplacer appData :
            // le POST import exige les 4 grappes → 423 déterministe, et l'état restauré
            // restait en RAM puis repartait section par section (autosave/navigation).
            const _snapGate = await _csiBeginGlobalOperation('snapshots.restore', 'Restauration');
            if (!_snapGate.ok) return;
            try {
              addHistorySnapshot(t('snapshots.backupBeforeRestore', 'Sauvegarde avant restauration'), { tag: "auto" });
            } catch (err) {
              void 0;
            }
            try {
              // Mode serveur : charger dans appData + push au serveur, PUIS recharger
              // FIX-C2: Attendre la fin du push serveur avant de recharger la page
              // FIX-SNAPSHOT-MODE (audit 2026-06-10) : prédicat aligné sur saveData — une
              // édition locale servie en http(s) prenait la branche serveur (push sans
              // auth = échec silencieux) et la restauration était perdue au rechargement.
              if (typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) {
                var restoredData = JSON.parse(snap.data);
                Object.assign(appData, restoredData);
                // GOV-NIS2 S4 (audit 2026-08-20, MAJEUR M11) : Object.assign FUSIONNE — une
                // clé absente du snapshot survivait à la restauration. Pour l'archive CyFun,
                // c'est une résurrection de données postérieures au snapshot lors de la
                // prochaine montée de niveau : on aligne sur la sémantique « remplacement »
                // de la branche locale pour cette clé.
                if (!('_cyfunEvalArchive' in restoredData)) delete appData._cyfunEvalArchive;
                window.appData = appData;
                if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) appData muté par restauration snapshot ; en cas d'échec sync (423) il n'y a pas de reload → index recherche à rafraîchir
                showToast2(t('toasts.snapshotRestored', 'Snapshot restauré, rechargement...'), "success");
                if (window.CSI_SyncManager && window.CSI_SyncManager.forceServerSync) {
                  window.CSI_SyncManager.forceServerSync().then(function(r) {
                    // FIX-RESTORE-423 (revue prod 2026-06-11) : forceServerSync RÉSOUT
                    // {success:false} sur 423/erreur (ne rejette plus). NE PAS recharger si
                    // le push a échoué — un reload écraserait la restauration locale par
                    // l'ancien état serveur. Message clair + on garde l'état restauré à l'écran.
                    if (r && r.success) {
                      setTimeout(function() { location.reload(); }, 300);
                      return;
                    }
                    if (r && r.code === 423) {
                      showToast2(t('toasts.restoreNeedsLock', 'Restauration non appliquée au serveur : passez en mode édition (ou reprenez la main) puis recommencez.'), 'error', 12000);
                    } else {
                      showToast2(t('toasts.restoreSyncFailed', 'Échec de synchronisation de la restauration — non appliquée au serveur. Réessayez.'), 'error', 12000);
                    }
                    // (audit 2026-08-05) L'état restauré ne doit pas rester en RAM : il
                    // repartait ensuite par morceaux (autosave) sur un serveur non restauré.
                    return _csiRecoverFromFailedGlobalPush();
                  }).catch(function(err) {
                    void 0;
                    showToast2(t('toasts.restoreSyncFailed', 'Échec de synchronisation de la restauration — non appliquée au serveur. Réessayez.'), 'error', 12000);
                    return _csiRecoverFromFailedGlobalPush();
                  }).then(function() { return _csiReleaseBorrowedClusters(_snapGate.borrowed); });
                } else {
                  setTimeout(function() { location.reload(); }, 300);
                }
              } else {
                // Mode local : comportement inchangé
                localStorage.setItem("smsi_data", snap.data);
                showToast2(t('toasts.snapshotRestored', 'Snapshot restauré, rechargement...'), "success");
                setTimeout(() => location.reload(), 300);
              }
            } catch(e) {
              void 0;
              showToast2(t('toasts.restoreFailed', 'Impossible de restaurer le snapshot'), 'error');
            }
          }, { type: 'warning' });
        } else if (action === "download") {
          downloadJsonBackup(snap.data, `smsi_snapshot_${snap.id}.json`);
        } else if (action === "delete") {
          showConfirmModal(t('common.delete', 'Supprimer'), t('confirms.deleteSnapshot', 'Supprimer ce snapshot ?'), function() {
            const nextList = getHistorySnapshots().filter((s) => s.id !== snap.id);
            saveHistorySnapshots(nextList);
            renderHistoryList();
          }, { type: 'danger' });
        }
      });
    });
  }
  function clearHistorySnapshots() {
    showConfirmModal(t('common.delete', 'Supprimer'), t('confirms.clearSnapshots', "Supprimer tout l'historique des snapshots ?"), function() {
      try {
        // FIX-SNAPSHOT-MODE : prédicat aligné + nettoyage des deux emplacements en mode
        // local (d'éventuels snapshots RAM hérités d'une session http-local pré-correctif).
        if (typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) {
          _ramHistorySnapshots = [];
        } else {
          _ramHistorySnapshots = [];
          localStorage.removeItem(HISTORY_KEY);
        }
        renderHistoryList();
        refreshStorageHealth();
        showToast2(t('toasts.snapshotHistoryCleared'), "info");
      } catch (e) {
        void 0;
        showToast2(t('toasts.snapshotHistoryError', "Impossible de supprimer l'historique"), "error");
      }
    }, { type: 'danger' });
  }
  window.addHistorySnapshot = addHistorySnapshot;
  window.refreshStorageHealth = refreshStorageHealth;

  // ============================================================
  // SORT UTILITIES — Generic, locale-aware sorting
  // ============================================================
  const SortUtils = (() => {
    const collator = new Intl.Collator("fr", { sensitivity: "base", numeric: true });

    return {
      /** Locale-aware text comparison (French, case/accent insensitive, numeric) */
      text(a, b) {
        return collator.compare(a || "", b || "");
      },

      /** Numeric comparison with fallback for null/undefined */
      numeric(a, b, fallback) {
        const fa = fallback !== undefined ? fallback : 0;
        return (a ?? fa) - (b ?? fa);
      },

      /** ISO date string comparison. nullsLast=true puts empty dates at the end */
      date(a, b, nullsLast) {
        const sentinel = (nullsLast !== false) ? 8640000000000000 : -8640000000000000;
        const da = a ? Date.parse(a) : NaN;
        const db = b ? Date.parse(b) : NaN;
        return (Number.isFinite(da) ? da : sentinel) - (Number.isFinite(db) ? db : sentinel);
      },

      /** Creates a comparator from an enum→order map. Unknown values sort last (fallback). */
      orderedMap(map, fallback) {
        const fb = fallback !== undefined ? fallback : 99;
        return function(a, b) { return (map[a] ?? fb) - (map[b] ?? fb); };
      },

      /** Reverses a comparator's direction */
      desc(compareFn) {
        return function(a, b) { return compareFn(b, a); };
      },

      /** Chains multiple comparators (first non-zero wins) */
      chain() {
        var fns = Array.prototype.slice.call(arguments);
        return function(a, b) {
          for (var i = 0; i < fns.length; i++) {
            var r = fns[i](a, b);
            if (r !== 0) return r;
          }
          return 0;
        };
      },

      /** Reference string sorter using collator (e.g., "A.5.1" < "A.5.10") */
      reference(a, b) {
        return collator.compare(a || "", b || "");
      }
    };
  })();
  window.SortUtils = SortUtils;

  // ============================================================
  // AUDIT LOG (Traçabilité)
  // ============================================================
  const AUDIT_ACTION_ICONS = { add: 'fa-plus', edit: 'fa-pen', delete: 'fa-trash', import: 'fa-file-import', export: 'fa-file-export', config: 'fa-gear' };
  const AUDIT_CAT_KEYS = { controls: 'catControls', actions: 'catActions', risks: 'catRisks', audits: 'catAudits', nc: 'catNc', reviews: 'catReviews', kpis: 'catKpis', documents: 'catDocuments', stakeholders: 'catStakeholders', threats: 'catThreats', riskAnalysis: 'catRiskAnalysis', projectRisks: 'catProjectRisks', criticalAssets: 'catCriticalAssets', impactMatrices: 'catImpactMatrices', nis2: 'catNis2', soa: 'catSoa', settings: 'catSettings', system: 'catSystem', docReview: 'catDocReview' };

  function getAuditUser() {
    if (window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn && window.CSI_AuthManager.isLoggedIn()) {
      var u = window.CSI_AuthManager.getCurrentUser && window.CSI_AuthManager.getCurrentUser();
      if (u) return typeof u === 'string' ? u : (u.email || u.name || u.username || t('audit.localUser'));
    }
    return t('audit.localUser');
  }

  function addAuditEntry(action, category, target, details) {
    if (!appData.auditLog) appData.auditLog = [];
    // FIX-AUDITLOG-CORPLOCAL (audit 2026-06-10) : isServerModeUnlocked() retourne toujours
    // false en file:// — la licence Corporate Local active (CSI_LocalTrialManager) doit
    // aussi débloquer le plafond de 100 entrées des éditions payantes.
    // FIX-AUDITLOG-EDITION (revue audit 2026-06-11) : baser le plafond sur l'ÉDITION
    // (window._csiEdition, synchrone et fiable) et non sur isLicensed() — ce dernier peut
    // être transitoirement faux (détection async non finie au boot, Firefox+file:// sans
    // crypto.subtle, licence expirée), ce qui aurait fait retomber le plafond à 10 et
    // TRONQUÉ IRRÉVERSIBLEMENT un journal de 100 entrées au prochain ajout.
    var _ed = window._csiEdition;
    var _paidEdition = !!(_ed && (_ed.id === 'corporate-local' || _ed.id === 'corporate-server'));
    var limit = ((typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) || _paidEdition) ? 100 : (DEMO_MODE ? 30 : 10);
    // GOV-NIS2 S5 : étiquette d'entité — une entrée exportée (JSON/PDF) perdait tout
    // contexte de périmètre ('DE.AE-03.1' ambigu en multi-entités). Additif, sans
    // migration ; le journal de sécurité serveur estampille déjà (entAuditScope).
    var _auditEntity = '';
    try {
      if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity && window.CSI_EntityManager.isMultiEntity() && window.CSI_EntityManager.echoId) {
        _auditEntity = window.CSI_EntityManager.echoId() || '';
      }
    } catch (e) { /* hors multi-entités */ }
    appData.auditLog.push({
      id: generateId("audit"),
      timestamp: new Date().toISOString(),
      entity: _auditEntity || undefined,
      user: getAuditUser(),
      action: action || 'edit',
      category: category || 'system',
      target: (target || '').substring(0, 120),
      details: (details || '').substring(0, 200)
    });
    // Le plafond est désormais STABLE (dépend de l'édition, pas d'un état transitoire) :
    // le slice normal ne risque plus de jeter brutalement 90 entrées sur une bascule.
    if (appData.auditLog.length > limit) {
      appData.auditLog = appData.auditLog.slice(-limit);
    }
    // FIX-GLOBALMETA-SYNC (audit 2026-06-10) : marquer le journal comme à pousser
    // (saveData enverra le pseudo-onglet 'global-meta' en mode serveur).
    window._csiGlobalMetaDirty = true;
    // Persist audit entries immediately
    if (typeof saveData === 'function') saveData();
  }
  window.addAuditEntry = addAuditEntry;

  function getAuditActionLabel(action) {
    var map = { add: 'audit.actionAdd', edit: 'audit.actionEdit', delete: 'audit.actionDelete', import: 'audit.actionImport', export: 'audit.actionExport', config: 'audit.actionConfig' };
    return t(map[action] || map.edit);
  }
  function getAuditCatLabel(cat) {
    var key = AUDIT_CAT_KEYS[cat];
    return key ? t('audit.' + key) : cat;
  }

  function updateDashboardActivity() {
    var list = document.getElementById('activityList');
    if (!list) return;
    var log = (appData.auditLog || []).slice(-3).reverse();
    if (!log.length) {
      list.innerHTML = '<div class="dashboard-activity__empty">' + escapeHtml(t('audit.noActivity')) + '</div>';
      return;
    }
    var html = '';
    log.forEach(function(entry) {
      var d;
      try { d = new Date(entry.timestamp); } catch(e) { d = new Date(); }
      var timeStr = String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
      var dayDiff = Math.round((Date.now() - d.getTime()) / 86400000);
      if (dayDiff >= 1) timeStr = d.toLocaleDateString(undefined, { day:'2-digit', month:'2-digit' });
      var iconClass = AUDIT_ACTION_ICONS[entry.action] || 'fa-pen';
      html += '<div class="activity-item">';
      html += '<span class="activity-item__time">' + timeStr + '</span>';
      html += '<span class="activity-item__icon activity-item__icon--' + escapeHtml(entry.action) + '"><i class="fas ' + iconClass + '"></i></span>';
      html += '<span class="activity-item__text" title="' + escapeHtml(entry.target || '') + '">' + escapeHtml(entry.target || '-') + '</span>';
      html += '</div>';
    });
    list.innerHTML = html;
  }
  window.updateDashboardActivity = updateDashboardActivity;

  function renderAuditLogModule() {
    var container = document.getElementById('auditLogContent');
    if (!container) return;
    var log = (appData.auditLog || []).slice().reverse();
    var html = '<div class="audit-page">';
    // Header
    html += '<div class="audit-page__header">';
    html += '<div class="audit-page__title"><i class="fas fa-clock-rotate-left"></i> ' + escapeHtml(t('audit.title')) + '</div>';
    html += '</div>';
    // Toolbar v2 : même langage visuel que SectionToolbar (markup généré à la main —
    // le composant s'insère via .module__header/.table-container, inapplicable ici).
    html += '<div class="section-toolbar">';
    html += '<div class="section-toolbar__row section-toolbar__filters">';
    html += '<input type="search" id="auditFilterSearch" class="form-control form-control--sm section-toolbar__search" placeholder="' + escapeHtml(t('common.search', 'Rechercher...')) + '..." aria-label="' + escapeHtml(t('common.search', 'Rechercher...')) + '">';
    html += '<select id="auditFilterAction" class="form-control form-control--sm" aria-label="' + escapeHtml(t('audit.columnAction')) + '"><option value="">' + escapeHtml(t('audit.columnAction')) + '</option>';
    ['add','edit','delete','import','export','config'].forEach(function(a) {
      html += '<option value="' + a + '">' + escapeHtml(getAuditActionLabel(a)) + '</option>';
    });
    html += '</select>';
    html += '<select id="auditFilterCategory" class="form-control form-control--sm" aria-label="' + escapeHtml(t('audit.columnCategory')) + '"><option value="">' + escapeHtml(t('audit.columnCategory')) + '</option>';
    Object.keys(AUDIT_CAT_KEYS).forEach(function(c) {
      html += '<option value="' + escapeHtml(c) + '">' + escapeHtml(getAuditCatLabel(c)) + '</option>';
    });
    html += '</select>';
    html += '<button class="btn btn--danger btn--sm section-toolbar__end" id="auditClearBtn" data-readonly-lock="true"><i class="fas fa-trash"></i> ' + escapeHtml(t('audit.clearHistory')) + '</button>';
    html += '</div></div>';
    // Table — Lot 1b : 4 colonnes (Catégorie fusionnée dans Cible, Détails en tooltip +
    // ligne extensible au clic), enveloppée dans .table-container comme les autres tableaux.
    html += '<div class="table-container"><table class="table--audit"><thead><tr>';
    html += '<th>' + escapeHtml(t('audit.columnDate')) + '</th>';
    html += '<th>' + escapeHtml(t('audit.columnUser')) + '</th>';
    html += '<th>' + escapeHtml(t('audit.columnAction')) + '</th>';
    html += '<th>' + escapeHtml(t('audit.columnTarget')) + '</th>';
    html += '</tr></thead><tbody id="auditTableBody">';
    if (!log.length) {
      html += '<tr><td colspan="4" class="audit-empty">' + escapeHtml(t('audit.emptyState')) + '</td></tr>';
    } else {
      log.forEach(function(entry) {
        var d;
        try { d = new Date(entry.timestamp); } catch(e) { d = new Date(); }
        var dateStr = d.toLocaleDateString() + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
        var hasDetails = !!(entry.details && String(entry.details).trim());
        html += '<tr data-action="' + escapeHtml(entry.action) + '" data-category="' + escapeHtml(entry.category || '') + '" data-target="' + escapeHtml((entry.target || '').toLowerCase()) + '" data-user="' + escapeHtml((entry.user || '').toLowerCase()) + '" data-details="' + escapeHtml((entry.details ? String(entry.details) : '').toLowerCase()) + '"'
          + (hasDetails ? ' class="audit-row--expandable" title="' + escapeHtml(entry.details) + '"' : '') + '>';
        html += '<td style="white-space:nowrap">' + escapeHtml(dateStr) + '</td>';
        html += '<td>' + escapeHtml(entry.user || '') + '</td>';
        html += '<td><span class="audit-badge audit-badge--' + escapeHtml(entry.action) + '"><i class="fas ' + (AUDIT_ACTION_ICONS[entry.action] || 'fa-pen') + '"></i> ' + escapeHtml(getAuditActionLabel(entry.action)) + '</span></td>';
        html += '<td class="audit-target" title="' + escapeHtml(entry.target || '') + '"><span class="audit-cat-badge">' + escapeHtml(getAuditCatLabel(entry.category)) + '</span> · ' + escapeHtml(entry.target || '—') + '</td>';
        html += '</tr>';
        if (hasDetails) {
          html += '<tr class="audit-details-row" hidden><td colspan="4" class="audit-details">' + escapeHtml(entry.details) + '</td></tr>';
        }
      });
    }
    html += '</tbody></table></div></div>';
    container.innerHTML = html;
    // Filters
    var filterAction = document.getElementById('auditFilterAction');
    var filterCategory = document.getElementById('auditFilterCategory');
    var filterSearch = document.getElementById('auditFilterSearch');
    var clearBtn = document.getElementById('auditClearBtn');
    function applyFilters() {
      var action = filterAction ? filterAction.value : '';
      var category = filterCategory ? filterCategory.value : '';
      var search = filterSearch ? filterSearch.value.toLowerCase().trim() : '';
      var rows = document.querySelectorAll('#auditTableBody tr[data-action]');
      rows.forEach(function(row) {
        var matchAction = !action || row.dataset.action === action;
        var matchCategory = !category || row.dataset.category === category;
        // Recherche étendue : cible + utilisateur + détails
        var haystack = (row.dataset.target || '') + ' ' + (row.dataset.user || '') + ' ' + (row.dataset.details || '');
        var matchSearch = !search || haystack.indexOf(search) >= 0;
        var visible = matchAction && matchCategory && matchSearch;
        row.style.display = visible ? '' : 'none';
        // Ligne de détails associée : masquée si sa ligne parente est filtrée,
        // et repliée à chaque changement de filtre.
        var next = row.nextElementSibling;
        if (next && next.classList.contains('audit-details-row')) {
          next.hidden = true;
          next.style.display = visible ? '' : 'none';
        }
      });
    }
    if (filterAction) filterAction.addEventListener('change', applyFilters);
    if (filterCategory) filterCategory.addEventListener('change', applyFilters);
    if (filterSearch) filterSearch.addEventListener('input', applyFilters);
    // Lot 1b : clic sur une ligne = déplier/replier les détails
    var auditBody = document.getElementById('auditTableBody');
    if (auditBody) auditBody.addEventListener('click', function(e) {
      var row = e.target && e.target.closest ? e.target.closest('tr[data-action]') : null;
      if (!row) return;
      var next = row.nextElementSibling;
      if (next && next.classList.contains('audit-details-row')) next.hidden = !next.hidden;
    });
    if (clearBtn) clearBtn.addEventListener('click', function() {
      showConfirmModal(
        t('audit.clearTitle', 'Effacer le journal'),
        t('audit.clearConfirm'),
        function() {
          appData.auditLog = [];
          saveData();
          renderAuditLogModule();
        },
        { type: 'danger' }
      );
    });
    if (typeof window.refreshReadOnlyLocks === 'function') window.refreshReadOnlyLocks();
  }
  window.renderAuditLogModule = renderAuditLogModule;

  function shouldShowWelcome(hasCachedData) {
    // FIX-WIZARD-SERVER (audit 2026-06-10) : évaluer la branche serveur AVANT le
    // court-circuit hasCachedData. En http(s), hasCachedData est forcé à true
    // (isServerMode() basé sur le protocole), ce qui rendait la branche wasSetup()
    // inatteignable — régression 2d9a728 sur le correctif 67e4826. Le comportement
    // local (file://) est inchangé : isServerModeUnlocked() y retourne false.
    // Mode serveur : le login wall s'exécute AVANT ce check, donc isLoggedIn()=true.
    // On ne montre le wizard que si c'est le tout premier setup admin (wasSetup=true).
    // Les utilisateurs normaux qui se connectent ensuite ne voient pas le wizard.
    if (typeof isServerModeUnlocked === "function" && isServerModeUnlocked()
        && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
      return !!(window.CSI_AuthManager.wasSetup && window.CSI_AuthManager.wasSetup());
    }
    if (hasCachedData) return false;
    if (typeof shouldAutoUnlockForTests === "function" && shouldAutoUnlockForTests()) return false;
    try {
      return !localStorage.getItem(WELCOME_STORAGE_KEY);
    } catch (e) {
      return false;
    }
  }
  // =====================================================
  // WIZARD DE CONFIGURATION AU PREMIER LANCEMENT
  // =====================================================

  const WIZARD_IMAGE_URL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%23e8d5e0'/%3E%3Cstop offset='50%25' style='stop-color:%23d4c4d9'/%3E%3Cstop offset='100%25' style='stop-color:%23c9d4e8'/%3E%3C/linearGradient%3E%3ClinearGradient id='cloud' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%23ffffff;stop-opacity:0.9'/%3E%3Cstop offset='100%25' style='stop-color:%23f0f4f8;stop-opacity:0.8'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300'/%3E%3Cellipse cx='200' cy='120' rx='140' ry='80' fill='url(%23cloud)' stroke='%23ffffff' stroke-width='3'/%3E%3Crect x='110' y='80' width='180' height='100' rx='8' fill='%23ffffff' fill-opacity='0.95' stroke='%23e0e0e0'/%3E%3Ccircle cx='120' cy='90' r='3' fill='%23ff6b6b'/%3E%3Ccircle cx='130' cy='90' r='3' fill='%23ffd93d'/%3E%3Ccircle cx='140' cy='90' r='3' fill='%2332d296'/%3E%3Crect x='125' y='110' width='50' height='55' rx='4' fill='%23f8f9fa' stroke='%23e9ecef'/%3E%3Crect x='185' y='110' width='50' height='55' rx='4' fill='%23f8f9fa' stroke='%23e9ecef'/%3E%3Crect x='245' y='110' width='40' height='55' rx='4' fill='%23f8f9fa' stroke='%23e9ecef'/%3E%3Cpath d='M150 125 L150 145 L140 145 L150 155 L160 145 L150 145' fill='none' stroke='%231fb8cd' stroke-width='2'/%3E%3Ccircle cx='210' cy='137' r='12' fill='none' stroke='%231fb8cd' stroke-width='2'/%3E%3Ccircle cx='210' cy='137' r='4' fill='%231fb8cd'/%3E%3Cpath d='M260 125 L270 125 L270 150 L260 150 M265 130 L265 145' fill='none' stroke='%231fb8cd' stroke-width='2'/%3E%3Cpath d='M180 230 C180 200 200 190 200 190 L200 190' fill='none' stroke='%23d4a5a5' stroke-width='8' stroke-linecap='round'/%3E%3Cellipse cx='200' cy='250' rx='60' ry='8' fill='%23c9c9c9' fill-opacity='0.3'/%3E%3C/svg%3E";

  function showSetupWizard(onComplete) {
    const _t = window.CSI_i18n ? window.CSI_i18n.t.bind(window.CSI_i18n) : (k) => k;
    // Skip if already shown or not first launch
    const isFirstLang = window.CSI_i18n && window.CSI_i18n.isFirstLaunch();
    const isFirstWelcome = shouldShowWelcome(false);

    if (!isFirstLang && !isFirstWelcome) {
      if (onComplete) onComplete();
      return;
    }

    if (document.getElementById("setupWizardOverlay")) {
      if (onComplete) onComplete();
      return;
    }

    // Kick license detection early if not yet started (async, non-blocking)
    // This ensures isServerModeUnlocked() returns true ASAP for shouldShow() checks
    var LM = window.CSI_LicenseManager;
    if (LM && !LM.isDetected() && window.location.protocol !== 'file:') {
      LM.detect().catch(function() {});
    }

    let currentStep = 0;
    const steps = [];

    // Step 1: Language selection (if needed)
    if (isFirstLang) {
      steps.push({
        id: "language",
        title: "Welcome",
        subtitle: "Choose your language",
        content: () => {
          const langs = window.CSI_i18n.getAvailableLanguages();
          return `
            <div class="wizard-language-grid">
              ${Object.entries(langs).map(([code, info]) => `
                <button class="wizard-lang-btn" type="button" data-lang="${code}">
                  <span class="wizard-lang-btn__flag">${info.flag}</span>
                  <span class="wizard-lang-btn__name">${info.name}</span>
                  <span class="wizard-lang-btn__check"><i class="fas fa-check"></i></span>
                </button>
              `).join("")}
            </div>
            <p class="wizard-hint" id="setupWizardLangHint">You can change this choice in settings.</p>
          `;
        },
        onEnter: (stepEl) => {
          const langBtns = stepEl.querySelectorAll(".wizard-lang-btn");
          langBtns.forEach(btn => {
            btn.addEventListener("click", () => {
              langBtns.forEach(b => b.classList.remove("wizard-lang-btn--selected"));
              btn.classList.add("wizard-lang-btn--selected");
            });
          });
        },
        onLeave: (stepEl) => {
          const selectedBtn = stepEl.querySelector(".wizard-lang-btn--selected");
          let lang = selectedBtn ? selectedBtn.dataset.lang : null;
          if (lang && window.CSI_i18n) {
            window.CSI_i18n.setLanguage(lang);
            window.CSI_i18n.markLanguageChosen();
            window.CSI_i18n.translatePage();
            updateWizardTexts();
          }
        },
        canProceed: (stepEl) => {
          return !!stepEl.querySelector(".wizard-lang-btn--selected");
        }
      });
    }

    // Step: License activation (HTTP(S) mode + trial detected, or Corporate Local in file://)
    var TM = window.CSI_TrialManager;
    var _isCorporateLocal = window._csiEdition && window._csiEdition.id === 'corporate-local';
    if (TM && TM.isDetected() && (window.location.protocol !== 'file:' || _isCorporateLocal)) {
      var trialActive = TM.isTrialActive();
      var trialExpired = TM.isTrialExpired() || TM.isLicenseExpired();
      var needsLicense = TM.needsLicense();

      if (trialActive || needsLicense) {
        steps.push({
          id: 'license',
          title: _t('license.title', 'Activation'),
          subtitle: needsLicense ? _t('license.trialExpired', 'Période d\'essai expirée') : _t('license.trialActive', 'Période d\'essai active'),
          content: function() {
            var daysLeft = TM.getDaysLeft();
            var badgeHtml = '';
            if (trialActive) {
              badgeHtml = '<div class="csi-trial-banner csi-trial-banner--active" style="padding:12px 16px;border-radius:8px;margin-bottom:16px;text-align:center;font-size:0.95rem;">' +
                '<i class="fas fa-clock"></i> ' + escapeHtml(_t('wizard.trialBadge', 'Essai gratuit — {days} jours restants sur 14').replace('{days}', String(daysLeft))) +
                '</div>' +
                '<p style="font-size:0.85rem;color:var(--color-text-secondary,#666);margin:0 0 12px;line-height:1.5;">' +
                escapeHtml(_t('wizard.trialExplain', 'Vous disposez d\'un essai gratuit de 14 jours pour tester le mode serveur avec synchronisation, co-édition et gestion des utilisateurs.')) +
                '</p>' +
                '<p style="font-size:0.85rem;color:var(--color-text-secondary,#666);margin:0 0 16px;line-height:1.5;">' +
                escapeHtml(_t('wizard.trialExpiry', 'Après l\'expiration, les données restent consultables mais les modifications seront bloquées jusqu\'à l\'activation d\'une clé de licence.')) +
                '</p>';
            } else {
              badgeHtml = '<div class="csi-trial-banner csi-trial-banner--expired" style="padding:12px 16px;border-radius:8px;margin-bottom:16px;text-align:center;font-size:0.95rem;">' +
                '<i class="fas fa-exclamation-triangle"></i> ' + _t('license.readOnlyNotice', 'Mode lecture seule — entrez une clé de licence pour réactiver') +
                '</div>';
            }
            return badgeHtml +
              '<div class="wizard-step-license">' +
              '<label style="display:block;margin-bottom:6px;font-weight:500;font-size:0.9rem;">' + _t('license.pasteKey', 'Collez votre clé d\'activation') + '</label>' +
              '<textarea id="wizardLicenseKey" class="csi-license-textarea" placeholder="CSI2-..." ' +
              'style="width:100%;min-height:80px;padding:10px;border:1px solid var(--color-border,#ddd);border-radius:6px;font-family:monospace;font-size:0.85rem;resize:vertical;box-sizing:border-box;"></textarea>' +
              '<button id="wizardLicenseActivateBtn" type="button" style="margin-top:8px;padding:8px 18px;border:none;border-radius:6px;background:var(--color-primary,#3b82f6);color:#fff;font-size:0.9rem;cursor:pointer;">' + _t('license.activateBtn', 'Activer') + '</button>' +
              '<div id="wizardLicenseMsg" style="margin-top:8px;font-size:0.9rem;display:none;padding:8px 12px;border-radius:6px;"></div>' +
              '</div>';
          },
          onEnter: function(stepEl) {
            var keyInput = stepEl.querySelector('#wizardLicenseKey');
            if (keyInput) setTimeout(function() { keyInput.focus(); }, 100);
            // Wire up Activate button
            var activateBtn = stepEl.querySelector('#wizardLicenseActivateBtn');
            if (activateBtn && TM) {
              activateBtn.addEventListener('click', function() {
                var ki = stepEl.querySelector('#wizardLicenseKey');
                var key = ki ? (ki.value || '').trim() : '';
                var msgDiv = stepEl.querySelector('#wizardLicenseMsg');
                if (!key || key.indexOf('CSI2-') !== 0) {
                  if (msgDiv) {
                    msgDiv.style.display = 'block';
                    msgDiv.textContent = _t('license.error', 'Clé de licence invalide');
                    msgDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
                    msgDiv.style.color = 'var(--color-danger,#dc3545)';
                  }
                  return;
                }
                activateBtn.disabled = true;
                activateBtn.textContent = '...';
                TM.activate(key).then(function(result) {
                  stepEl._licenseOk = true;
                  if (msgDiv) {
                    msgDiv.style.display = 'block';
                    msgDiv.textContent = _t('license.success', 'Licence activée avec succès');
                    msgDiv.style.background = 'var(--color-success-bg,#f0fdf4)';
                    msgDiv.style.color = 'var(--color-success,#22c55e)';
                  }
                  activateBtn.textContent = _t('license.activated', 'Activee');
                }).catch(function(err) {
                  stepEl._licenseOk = false;
                  if (msgDiv) {
                    msgDiv.style.display = 'block';
                    msgDiv.textContent = err.message || _t('license.error', 'Clé invalide');
                    msgDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
                    msgDiv.style.color = 'var(--color-danger,#dc3545)';
                  }
                  activateBtn.disabled = false;
                  activateBtn.textContent = _t('license.activateBtn', 'Activer');
                });
              });
            }
          },
          onLeave: function(stepEl) {
            // Activation is now handled by the Activate button
          },
          canProceed: function(stepEl) {
            // If trial expired, license must be activated successfully
            if (needsLicense) {
              if (!stepEl._licenseOk) {
                var msgDiv = stepEl.querySelector('#wizardLicenseMsg');
                if (msgDiv) {
                  msgDiv.style.display = 'block';
                  msgDiv.textContent = _t('license.mustActivate', 'Veuillez activer votre licence avant de continuer');
                  msgDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
                  msgDiv.style.color = 'var(--color-danger,#dc3545)';
                }
                return false;
              }
            }
            return true;
          }
        });
      }
    }

    // Step: Framework selection (only on first welcome, if multiple frameworks available, and not forced by edition)
    var _edWiz = window._csiEdition;
    if (_edWiz && _edWiz.forcedFrameworks) {
      // Edition avec frameworks forcés → activer automatiquement sans wizard
      if (typeof appData !== 'undefined') {
        if (!appData.frameworkMapper) appData.frameworkMapper = {};
        appData.frameworkMapper.enabledFrameworks = _defaultEnabledFrameworks();
      }
    } else if (isFirstWelcome && window._csiFrameworks && Object.keys(window._csiFrameworks).length > 0) {
      steps.push({
        id: "frameworks",
        title: _t('setup.frameworksTitle'),
        subtitle: _t('setup.frameworksSubtitle'),
        content: () => {
          var fws = window._csiFrameworks;
          var ids = Object.keys(fws);
          // Deduplicate aliases: skip frameworks whose aliasOf target is also loaded
          var seen = [];
          ids = ids.filter(function(id) {
            var meta = fws[id].meta || {};
            // If this framework is an alias of another loaded framework, skip it
            if (meta.aliasOf && fws[meta.aliasOf]) return false;
            // Also check object identity (e.g. 'nis2' -> 'cyfun2025' share same object)
            for (var i = 0; i < seen.length; i++) {
              if (fws[id] === fws[seen[i]]) return false;
            }
            seen.push(id);
            return true;
          });
          var items = ids.map(function(id) {
            var meta = fws[id].meta || {};
            var isMandatory = !!meta.mandatory;
            var controlCount = meta.controlCount || meta.clauseCount || (fws[id].controls ? fws[id].controls.length : (fws[id].clauses ? fws[id].clauses.length : (fws[id].requirements ? fws[id].requirements.length : 0)));
            var iconClass = meta.icon || 'fa-shield-halved';
            var color = meta.color || 'var(--color-primary)';
            return '<div class="wizard-framework-item' + (isMandatory ? ' wizard-framework-item--locked' : '') + '" data-fw-id="' + id + '">' +
              '<div class="wizard-framework-item__icon" style="color:' + color + '"><i class="fas ' + iconClass + '"></i></div>' +
              '<div class="wizard-framework-item__info">' +
                '<strong>' + (meta.shortName || meta.name || id) + '</strong>' +
                '<span>' + (meta.name || id) + ' — ' + controlCount + ' ' + _t('setup.frameworksControls') + '</span>' +
              '</div>' +
              '<div class="wizard-framework-item__toggle">' +
                (isMandatory
                  ? '<span class="wizard-framework-item__badge"><i class="fas fa-lock"></i> ' + _t('setup.frameworksLocked') + '</span>'
                  : '<label class="toggle" style="margin:0"><input type="checkbox" class="toggle__input" data-fw-toggle="' + id + '" checked><span class="toggle__slider"></span></label>'
                ) +
              '</div>' +
            '</div>';
          });
          return '<div class="wizard-frameworks-list">' + items.join('') + '</div>' +
            '<p class="wizard-hint"><i class="fas fa-info-circle"></i> ' + _t('setup.frameworksHint') + '</p>';
        },
        onEnter: function(stepEl) {
          // Toggle visual feedback on item click (not for locked items)
          var items = stepEl.querySelectorAll('.wizard-framework-item:not(.wizard-framework-item--locked)');
          items.forEach(function(item) {
            item.addEventListener('click', function(e) {
              if (e.target.closest('.toggle')) return;
              var toggle = item.querySelector('.toggle__input');
              if (toggle) toggle.checked = !toggle.checked;
            });
          });
        },
        onLeave: function(stepEl) {
          // Collect enabled frameworks and save
          var enabled = [];
          var fws = window._csiFrameworks;
          Object.keys(fws).forEach(function(id) {
            var meta = fws[id].meta || {};
            if (meta.mandatory) {
              enabled.push(id);
              return;
            }
            var toggle = stepEl.querySelector('[data-fw-toggle="' + id + '"]');
            if (toggle && toggle.checked) {
              enabled.push(id);
            } else if (!toggle) {
              // This ID was deduped (alias) — check if its target object is enabled
              for (var j = 0; j < enabled.length; j++) {
                if (fws[enabled[j]] === fws[id]) {
                  enabled.push(id); // include alias too
                  break;
                }
              }
            }
          });
          if (typeof appData !== 'undefined') {
            if (!appData.frameworkMapper) appData.frameworkMapper = {};
            appData.frameworkMapper.enabledFrameworks = enabled;
            if (typeof saveData === 'function') saveData();
          }
        },
        canProceed: function() { return true; }
      });

      // Step: NIS2 Assurance Level (conditional — shown only if NIS2/CyFun2025 selected in frameworks step)
      steps.push({
        id: 'nis2-level',
        title: _t('wizard.nis2LevelTitle', "Niveau d'assurance NIS2"),
        subtitle: _t('wizard.nis2LevelSub', 'Définissez le niveau CyFun correspondant à votre organisation'),
        // Remplacé par l'étape combinée 'nis2-setup' (référentiel NIS2 + degré). Conservé inerte.
        shouldShow: function() { return false; },
        content: function() {
          return '<div class="wizard-nis2-level">' +
            '<div class="cyfun-level-info">' +
              '<i class="fas fa-info-circle cyfun-level-info__icon"></i>' +
              '<strong>' + _t('wizard.nis2LevelAdviceLabel', 'Conseil :') + '</strong> ' +
              _t('wizard.nis2LevelAdvice', 'Avant de choisir, nous vous recommandons d\'utiliser l\'outil d\'auto-identification du') +
              ' <a href="https://atwork.safeonweb.be/fr/nis2" target="_blank" rel="noopener">CCB (Centre for Cybersecurity Belgium)</a> ' +
              _t('wizard.nis2LevelAdvice2', 'pour déterminer votre niveau d\'assurance NIS2.') +
            '</div>' +
            '<p class="cyfun-level-option__desc">' + _t('wizard.nis2LevelSelect', 'Sélectionnez le niveau d\'assurance NIS2 correspondant à votre organisation :') + '</p>' +
            '<div class="cyfun-level-options">' +
              '<label class="cyfun-level-option">' +
                '<input type="radio" name="wizardNis2Level" value="basic" checked>' +
                '<div>' +
                  '<strong class="cyfun-level-option__title--basic">BASIC</strong> ' +
                  '<span class="cyfun-level-option__meta">(34 ' + _t('wizard.nis2Requirements', 'exigences') + ', 13 ' + _t('wizard.nis2KeyMeasures', 'mesures clés') + ')</span>' +
                  '<p class="cyfun-level-option__desc">' + _t('wizard.nis2LevelBasicDesc', 'Pour les organisations qui débutent leur démarche NIS2. Seuil de conformité : 2.5/5.') + '</p>' +
                '</div>' +
              '</label>' +
              '<label class="cyfun-level-option">' +
                '<input type="radio" name="wizardNis2Level" value="important">' +
                '<div>' +
                  '<strong class="cyfun-level-option__title--important">IMPORTANT</strong> ' +
                  '<span class="cyfun-level-option__meta">(133 ' + _t('wizard.nis2Requirements', 'exigences') + ', 22 ' + _t('wizard.nis2KeyMeasures', 'mesures clés') + ')</span>' +
                  '<p class="cyfun-level-option__desc">' + _t('wizard.nis2LevelImportantDesc', 'Pour les entités importantes au sens NIS2. Seuil de conformité : 3/5.') + '</p>' +
                '</div>' +
              '</label>' +
              '<label class="cyfun-level-option">' +
                '<input type="radio" name="wizardNis2Level" value="essential">' +
                '<div>' +
                  '<strong class="cyfun-level-option__title--essential">ESSENTIAL</strong> ' +
                  '<span class="cyfun-level-option__meta">(218 ' + _t('wizard.nis2Requirements', 'exigences') + ', 29 ' + _t('wizard.nis2KeyMeasures', 'mesures clés') + ')</span>' +
                  '<p class="cyfun-level-option__desc">' + _t('wizard.nis2LevelEssentialDesc', 'Pour les entités essentielles au sens NIS2. Seuil de conformité : 3.5/5 + minimum par catégorie.') + '</p>' +
                '</div>' +
              '</label>' +
            '</div>' +
            '<div class="cyfun-level-lang">' +
              '<label class="cyfun-level-lang__label">' + _t('wizard.nis2Lang', 'Langue des contrôles :') + '</label>' +
              '<div class="cyfun-level-lang__options">' +
                '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="en" checked><span>English</span></label>' +
                '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="fr"><span>Français</span></label>' +
                '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="nl"><span>Nederlands</span></label>' +
              '</div>' +
              '<p class="cyfun-level-lang__hint">' + _t('wizard.nis2LangHint', 'La langue des titres et descriptions des exigences NIS2.') + '</p>' +
            '</div>' +
          '</div>';
        },
        onEnter: function(stepEl) {
          var curLevel = appData.cyfunLevel || 'basic';
          var radio = stepEl.querySelector('input[name="wizardNis2Level"][value="' + curLevel + '"]');
          if (radio) radio.checked = true;
          var curLang = appData.cyfunLang || 'en';
          var langRadio = stepEl.querySelector('input[name="wizardNis2Lang"][value="' + curLang + '"]');
          if (langRadio) langRadio.checked = true;
        },
        onLeave: function(stepEl) {
          var levelRadio = stepEl.querySelector('input[name="wizardNis2Level"]:checked');
          var level = levelRadio ? levelRadio.value : 'basic';
          var langRadio = stepEl.querySelector('input[name="wizardNis2Lang"]:checked');
          var lang = langRadio ? langRadio.value : 'en';
          appData.cyfunLang = lang;
          setCyfunAssuranceLevel(level);
          recalcNis2Maturity();
          appData._cyfunLevelChosen = true;
          var _s = document.getElementById('cyfunLevelSelect');
          if (_s) _s.value = level;
          if (typeof saveData === 'function') saveData();
          // FIX-GLOBAL-SYNC: Push cyfunLevel choice to server for other users
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          // FIX-CONFIG-PERSIST-LOCK (revue prod 2026-06-11) : acquérir le verrou si besoin
          _persistConfigWithLock(['cyfunLevel', 'cyfunLang', '_cyfunLevelChosen', 'nis2Controls', '_cyfunEvalArchive', 'targetMaturity']);
        },
        canProceed: function() { return true; }
      });
    }

    // Step: Référentiel NIS2 (Belgique/CyFun ↔ France/ReCyF) + degré — toutes éditions
    if (isFirstWelcome) {
      steps.push({
        id: 'nis2-setup',
        title: _t('wizard.nis2SetupTitle', 'Référentiel NIS2'),
        subtitle: _t('wizard.nis2SetupSub', "Choisissez le référentiel et le niveau adaptés à votre organisation"),
        shouldShow: function() {
          var _fwE = (appData.frameworkMapper && appData.frameworkMapper.enabledFrameworks) || [];
          return (_fwE.indexOf('nis2') >= 0 || _fwE.indexOf('cyfun2025') >= 0 || _fwE.indexOf('recyf2025') >= 0);
        },
        content: function() {
          var fwR = (window._csiFrameworks || {})['recyf2025'];
          var _cntEI = fwR ? fwR.requirements.filter(function(r) { return r.level === 'importante'; }).length : 76;
          var _cntEE = fwR ? fwR.requirements.length : 152;
          return '<div class="wizard-nis2-level">' +
            '<p class="cyfun-level-option__desc">' + _t('wizard.nis2RefSelect', 'Quel référentiel NIS2 souhaitez-vous utiliser ?') + '</p>' +
            '<div class="cyfun-level-options">' +
              '<label class="cyfun-level-option"><input type="radio" name="wizardNis2Ref" value="cyfun2025" checked>' +
                '<div><strong>NIS2 Belgique — CyFun 2025 (CCB)</strong>' +
                  '<p class="cyfun-level-option__desc">' + _t('wizard.nis2RefBEDesc', 'Cadre belge (CCB) basé sur NIST CSF 2.0. Niveaux Basic / Important / Essential, évaluation par maturité.') + '</p></div></label>' +
              '<label class="cyfun-level-option"><input type="radio" name="wizardNis2Ref" value="recyf2025">' +
                '<div><strong>NIS2 France — ReCyF (ANSSI)</strong>' +
                  '<p class="cyfun-level-option__desc">' + _t('wizard.nis2RefFRDesc', "Référentiel Cyber France (ANSSI). 20 objectifs de sécurité, degrés Entité Importante / Essentielle, moyens acceptables de conformité.") + '</p></div></label>' +
            '</div>' +
            '<div id="wizCyfunBlock" style="margin-top:8px;">' +
              '<div class="cyfun-level-info"><i class="fas fa-info-circle cyfun-level-info__icon"></i> ' +
                '<strong>' + _t('wizard.nis2LevelAdviceLabel', 'Conseil :') + '</strong> ' +
                _t('wizard.nis2LevelAdvice', "Avant de choisir, nous vous recommandons d'utiliser l'outil d'auto-identification du") +
                ' <a href="https://atwork.safeonweb.be/fr/nis2" target="_blank" rel="noopener">CCB (Centre for Cybersecurity Belgium)</a> ' +
                _t('wizard.nis2LevelAdvice2', "pour déterminer votre niveau d'assurance NIS2.") + '</div>' +
              '<div class="cyfun-level-options">' +
                '<label class="cyfun-level-option"><input type="radio" name="wizardNis2Level" value="basic" checked><div><strong class="cyfun-level-option__title--basic">BASIC</strong> <span class="cyfun-level-option__meta">(34 ' + _t('wizard.nis2Requirements', 'exigences') + ')</span></div></label>' +
                '<label class="cyfun-level-option"><input type="radio" name="wizardNis2Level" value="important"><div><strong class="cyfun-level-option__title--important">IMPORTANT</strong> <span class="cyfun-level-option__meta">(133 ' + _t('wizard.nis2Requirements', 'exigences') + ')</span></div></label>' +
                '<label class="cyfun-level-option"><input type="radio" name="wizardNis2Level" value="essential"><div><strong class="cyfun-level-option__title--essential">ESSENTIAL</strong> <span class="cyfun-level-option__meta">(218 ' + _t('wizard.nis2Requirements', 'exigences') + ')</span></div></label>' +
              '</div>' +
              '<div class="cyfun-level-lang"><label class="cyfun-level-lang__label">' + _t('wizard.nis2Lang', 'Langue des contrôles :') + '</label>' +
                '<div class="cyfun-level-lang__options">' +
                  '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="en" checked><span>English</span></label>' +
                  '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="fr"><span>Français</span></label>' +
                  '<label class="cyfun-level-lang__option"><input type="radio" name="wizardNis2Lang" value="nl"><span>Nederlands</span></label>' +
                '</div></div>' +
            '</div>' +
            '<div id="wizRecyfBlock" style="margin-top:8px;display:none;">' +
              '<div class="cyfun-level-info"><i class="fas fa-info-circle cyfun-level-info__icon"></i> ' +
                '<strong>' + _t('wizard.nis2LevelAdviceLabel', 'Conseil :') + '</strong> ' +
                _t('wizard.recyfAdvice', "Le degré dépend de la qualification de votre entité au titre de NIS2 : Entité Importante (EI) ou Entité Essentielle (EE).") + '</div>' +
              '<div class="cyfun-level-options">' +
                '<label class="cyfun-level-option"><input type="radio" name="wizardRecyfDegree" value="importante" checked><div><strong>Entité Importante (EI)</strong> <span class="cyfun-level-option__meta">(' + _cntEI + ' ' + _t('common.measures', 'mesures') + ', ' + _t('wizard.recyfObj115', 'objectifs 1 à 15') + ')</span><p class="cyfun-level-option__desc">' + _t('wizard.recyfEIDesc', "Pour les entités importantes au sens NIS2.") + '</p></div></label>' +
                '<label class="cyfun-level-option"><input type="radio" name="wizardRecyfDegree" value="essentielle"><div><strong>Entité Essentielle (EE)</strong> <span class="cyfun-level-option__meta">(' + _cntEE + ' ' + _t('common.measures', 'mesures') + ', ' + _t('wizard.recyfObj120', 'objectifs 1 à 20') + ')</span><p class="cyfun-level-option__desc">' + _t('wizard.recyfEEDesc', "Pour les entités essentielles (dont approche par les risques, audit, supervision).") + '</p></div></label>' +
              '</div>' +
            '</div>' +
          '</div>';
        },
        onEnter: function(stepEl) {
          var beBlock = stepEl.querySelector('#wizCyfunBlock');
          var frBlock = stepEl.querySelector('#wizRecyfBlock');
          function syncBlocks() {
            var sel = stepEl.querySelector('input[name="wizardNis2Ref"]:checked');
            var v = sel ? sel.value : 'cyfun2025';
            if (beBlock) beBlock.style.display = (v === 'cyfun2025') ? '' : 'none';
            if (frBlock) frBlock.style.display = (v === 'recyf2025') ? '' : 'none';
          }
          var refRadios = stepEl.querySelectorAll('input[name="wizardNis2Ref"]');
          for (var i = 0; i < refRadios.length; i++) refRadios[i].addEventListener('change', syncBlocks);
          var curRef = appData.nis2Referential || 'cyfun2025';
          var rr = stepEl.querySelector('input[name="wizardNis2Ref"][value="' + curRef + '"]');
          if (rr) rr.checked = true;
          var lr = stepEl.querySelector('input[name="wizardNis2Level"][value="' + (appData.cyfunLevel || 'basic') + '"]');
          if (lr) lr.checked = true;
          var lgr = stepEl.querySelector('input[name="wizardNis2Lang"][value="' + (appData.cyfunLang || 'en') + '"]');
          if (lgr) lgr.checked = true;
          var dr = stepEl.querySelector('input[name="wizardRecyfDegree"][value="' + (appData.recyfLevel || 'importante') + '"]');
          if (dr) dr.checked = true;
          syncBlocks();
        },
        onLeave: function(stepEl) {
          var refSel = stepEl.querySelector('input[name="wizardNis2Ref"]:checked');
          var ref = refSel ? refSel.value : 'cyfun2025';
          appData.nis2Referential = ref;
          appData._nis2ReferentialLocked = true; // 1er choix confirmé -> référentiel figé (Reset pour changer)
          // Aligner immédiatement la liste des frameworks activés sur le référentiel choisi
          // (le référentiel actif doit être « activé », l'autre retiré) — modèle exclusif.
          if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
          if (ref === 'recyf2025') {
            var degSel = stepEl.querySelector('input[name="wizardRecyfDegree"]:checked');
            if (typeof setRecyfDegree === 'function') setRecyfDegree(degSel ? degSel.value : 'importante');
            appData._cyfunLevelChosen = true; // évite la modale CyFun post-wizard
          } else {
            var levelRadio = stepEl.querySelector('input[name="wizardNis2Level"]:checked');
            var level = levelRadio ? levelRadio.value : 'basic';
            var langRadio = stepEl.querySelector('input[name="wizardNis2Lang"]:checked');
            appData.cyfunLang = langRadio ? langRadio.value : 'en';
            if (typeof setCyfunAssuranceLevel === 'function') setCyfunAssuranceLevel(level);
            if (typeof recalcNis2Maturity === 'function') recalcNis2Maturity();
            appData._cyfunLevelChosen = true;
            var _s = document.getElementById('cyfunLevelSelect');
            if (_s) _s.value = level;
          }
          if (typeof saveData === 'function') saveData();
          if (typeof applyNis2Referential === 'function') applyNis2Referential(false);
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          // FIX-CONFIG-PERSIST-LOCK (revue prod 2026-06-11)
          _persistConfigWithLock(['nis2Referential', '_nis2ReferentialLocked', 'cyfunLevel', 'cyfunLang', '_cyfunLevelChosen', 'nis2Controls', '_cyfunEvalArchive', 'recyfControls', 'recyfLevel', 'targetMaturity', 'frameworkMapper']);
        },
        canProceed: function() { return true; }
      });
    }

    // Step: Welcome & Demo data
    if (isFirstWelcome) {
      steps.push({
        id: "welcome",
        title: _t('setup.welcomeTitle'),
        subtitle: _t('setup.welcomeSubtitle'),
        content: () => `
          <div class="wizard-welcome-content">
            <div class="wizard-features">
              <div class="wizard-feature">
                <div class="wizard-feature__icon"><i class="fas fa-shield-halved"></i></div>
                <div class="wizard-feature__text">
                  <strong>ISO 27001 & NIS2</strong>
                  <span id="setupFeatureCompliance">${_t('setup.featureCompliance')}</span>
                </div>
              </div>
              <div class="wizard-feature">
                <div class="wizard-feature__icon"><i class="fas fa-chart-pie"></i></div>
                <div class="wizard-feature__text">
                  <strong id="setupFeatureDashboards">${_t('setup.featureDashboards')}</strong>
                  <span id="setupFeatureDashboardsDesc">${_t('setup.featureDashboardsDesc')}</span>
                </div>
              </div>
              <div class="wizard-feature">
                <div class="wizard-feature__icon"><i class="fas fa-file-export"></i></div>
                <div class="wizard-feature__text">
                  <strong id="setupFeatureExports">${_t('setup.featureExports')}</strong>
                  <span id="setupFeatureExportsDesc">${_t('setup.featureExportsDesc')}</span>
                </div>
              </div>
            </div>
          </div>
        `,
        onEnter: () => {},
        onLeave: () => {
          try { localStorage.setItem(WELCOME_STORAGE_KEY, "1"); } catch (e) { void 0; }
        },
        canProceed: () => true
      });

      // Import step removed — data import is now available via Settings
    }

    // Step: Local password setup (LOCAL mode only)
    if (isFirstWelcome && window.location.protocol === 'file:') {
      steps.push({
        id: 'local-password',
        title: _t('wizard.localPwdTitle', 'Protection par mot de passe'),
        subtitle: _t('wizard.localPwdSub', 'Obligatoire pour protéger vos données'),
        content: function() {
          return '<div class="wizard-password-setup">' +
            '<p style="margin-bottom:16px;line-height:1.6;">' + _t('wizard.localPwdExplain', 'Définissez un mot de passe pour protéger l\'accès au mode édition. Ce mot de passe empêche les modifications accidentelles de vos données.') + '</p>' +
            '<div class="wizard-pwd-field"><label for="wizardLocalPwd">' + _t('wizard.localPwdLabel', 'Mot de passe') + '</label>' +
            '<input type="password" id="wizardLocalPwd" class="form-control" autocomplete="new-password" placeholder="' + _t('wizard.localPwdPlaceholder', 'Minimum 18 caractères') + '"></div>' +
            '<div class="wizard-pwd-field" style="margin-top:10px;"><label for="wizardLocalPwdConfirm">' + _t('wizard.localPwdConfirmLabel', 'Confirmer le mot de passe') + '</label>' +
            '<input type="password" id="wizardLocalPwdConfirm" class="form-control" autocomplete="new-password" placeholder="' + _t('wizard.localPwdConfirmPlaceholder', 'Répétez le mot de passe') + '"></div>' +
            '<div id="wizardLocalPwdPolicy" style="margin-top:12px;"></div>' +
            '<div id="wizardLocalPwdError" style="display:none;margin-top:8px;padding:8px 12px;border-radius:8px;background:var(--color-danger-bg,#fee2e2);color:var(--color-danger,#dc2626);font-size:0.85rem;"></div>' +
            '<p class="wizard-hint" style="margin-top:16px;"><i class="fas fa-shield-halved"></i> ' +
              _t('wizard.localPwdHint', 'Ce mot de passe est requis pour protéger l\'accès au mode édition de vos données.') +
            '</p>' +
          '</div>';
        },
        onEnter: function(stepEl) {
          var pwdInput = stepEl.querySelector('#wizardLocalPwd');
          var confirmInput = stepEl.querySelector('#wizardLocalPwdConfirm');
          var policyDiv = stepEl.querySelector('#wizardLocalPwdPolicy');
          var errorDiv = stepEl.querySelector('#wizardLocalPwdError');
          if (pwdInput && policyDiv && typeof createPasswordPolicyList === 'function') {
            var result = createPasswordPolicyList(MIN_EDIT_PASSWORD_LENGTH);
            policyDiv.appendChild(result.wrap);
            var updatePolicy = function() {
              updatePasswordPolicyList(result.list, pwdInput.value, MIN_EDIT_PASSWORD_LENGTH);
              errorDiv.style.display = 'none';
            };
            pwdInput.addEventListener('input', updatePolicy);
            updatePolicy();
          }
          if (confirmInput) {
            confirmInput.addEventListener('input', function() {
              errorDiv.style.display = 'none';
            });
          }
        },
        onLeave: function(stepEl) {
          var pwdInput = stepEl.querySelector('#wizardLocalPwd');
          var pwd = pwdInput ? (pwdInput.value || '').trim() : '';
          if (pwd) {
            var hash = hashEditPasswordSync(pwd);
            setStoredEditPasswordHash(hash);
            try { localStorage.setItem('smsi_password_setup_done', '1'); } catch(e) { void 0; }
          }
        },
        canProceed: function(stepEl) {
          var pwdInput = stepEl.querySelector('#wizardLocalPwd');
          var confirmInput = stepEl.querySelector('#wizardLocalPwdConfirm');
          var errorDiv = stepEl.querySelector('#wizardLocalPwdError');
          var pwd = pwdInput ? (pwdInput.value || '').trim() : '';
          var confirm = confirmInput ? (confirmInput.value || '').trim() : '';
          // Password is mandatory — cannot skip
          if (!pwd) {
            if (errorDiv) {
              errorDiv.textContent = _t('wizard.localPwdRequired', 'Un mot de passe est requis pour protéger vos données.');
              errorDiv.style.display = 'block';
            }
            return false;
          }
          // Validate password policy
          var policy = getEditPasswordPolicyStatus(pwd, MIN_EDIT_PASSWORD_LENGTH);
          if (!policy.isValid) {
            if (errorDiv) {
              errorDiv.textContent = _t('wizard.localPwdWeak', 'Le mot de passe ne respecte pas les critères requis.');
              errorDiv.style.display = 'block';
            }
            return false;
          }
          if (pwd !== confirm) {
            if (errorDiv) {
              errorDiv.textContent = _t('wizard.localPwdMismatch', 'Les mots de passe ne correspondent pas.');
              errorDiv.style.display = 'block';
            }
            return false;
          }
          return true;
        }
      });
    }

    // Step: Transversality presentation (both modes, on first welcome)
    if (isFirstWelcome) {
      steps.push({
        id: 'transversality',
        title: _t('wizard.transTitle', 'Transversalité des référentiels'),
        subtitle: _t('wizard.transSub', 'Un effort, de multiples conformités'),
        content: function() {
          return '<div class="wizard-transversality">' +
            '<div class="wizard-trans-diagram">' +
              '<svg viewBox="0 0 400 240" class="wizard-trans-svg" aria-hidden="true">' +
                // Central hub
                '<circle cx="200" cy="120" r="45" fill="var(--color-primary,#b07c7c)" opacity="0.15" />' +
                '<circle cx="200" cy="120" r="35" fill="var(--color-primary,#b07c7c)" opacity="0.25" />' +
                '<text x="200" y="115" text-anchor="middle" fill="var(--color-primary,#b07c7c)" font-size="11" font-weight="700">' + (function() { var _fw = _getActiveControlsFramework(); return _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27002'; })() + '</text>' +
                '<text x="200" y="130" text-anchor="middle" fill="var(--text-secondary,#666)" font-size="8">93 ' + _t('wizard.transMeasures', 'mesures') + '</text>' +
                // NIS2 spoke (top-right)
                '<line x1="235" y1="105" x2="310" y2="55" stroke="var(--color-primary,#b07c7c)" stroke-width="2" opacity="0.5" stroke-dasharray="4,3" />' +
                '<circle cx="320" cy="48" r="28" fill="var(--color-info,#3b82f6)" opacity="0.12" />' +
                '<text x="320" y="45" text-anchor="middle" fill="var(--color-info,#3b82f6)" font-size="10" font-weight="600">NIS2</text>' +
                '<text x="320" y="57" text-anchor="middle" fill="var(--text-secondary,#666)" font-size="7">Directive EU</text>' +
                // CyFun spoke (bottom-right)
                '<line x1="235" y1="135" x2="310" y2="185" stroke="var(--color-primary,#b07c7c)" stroke-width="2" opacity="0.5" stroke-dasharray="4,3" />' +
                '<circle cx="320" cy="192" r="28" fill="var(--color-warning,#f59e0b)" opacity="0.12" />' +
                '<text x="320" y="189" text-anchor="middle" fill="var(--color-warning,#f59e0b)" font-size="10" font-weight="600">CyFun</text>' +
                '<text x="320" y="201" text-anchor="middle" fill="var(--text-secondary,#666)" font-size="7">CCB 2025</text>' +
                // NIST spoke (left)
                '<line x1="165" y1="120" x2="90" y2="120" stroke="var(--color-primary,#b07c7c)" stroke-width="2" opacity="0.5" stroke-dasharray="4,3" />' +
                '<circle cx="70" cy="120" r="28" fill="var(--color-success,#22c55e)" opacity="0.12" />' +
                '<text x="70" y="117" text-anchor="middle" fill="var(--color-success,#22c55e)" font-size="10" font-weight="600">NIST CSF</text>' +
                '<text x="70" y="129" text-anchor="middle" fill="var(--text-secondary,#666)" font-size="7">2.0</text>' +
              '</svg>' +
            '</div>' +
            '<div class="wizard-trans-benefits">' +
              '<div class="wizard-trans-benefit">' +
                '<div class="wizard-trans-benefit__icon"><i class="fas fa-clock"></i></div>' +
                '<div class="wizard-trans-benefit__text">' +
                  '<strong>' + _t('wizard.transTimeSave', 'Gain de temps') + '</strong>' +
                  '<p>' + _t('wizard.transTimeSaveDesc', 'Documentez une mesure ISO 27002 : elle couvre automatiquement les exigences NIS2, CyFun et NIST CSF correspondantes.') + '</p>' +
                '</div>' +
              '</div>' +
              '<div class="wizard-trans-benefit">' +
                '<div class="wizard-trans-benefit__icon"><i class="fas fa-link"></i></div>' +
                '<div class="wizard-trans-benefit__text">' +
                  '<strong>' + _t('wizard.transConsistency', 'Cohérence garantie') + '</strong>' +
                  '<p>' + _t('wizard.transConsistencyDesc', 'Les mappings automatiques entre référentiels assurent une cohérence de bout en bout, sans ressaisie ni oubli.') + '</p>' +
                '</div>' +
              '</div>' +
              '<div class="wizard-trans-benefit">' +
                '<div class="wizard-trans-benefit__icon"><i class="fas fa-chart-line"></i></div>' +
                '<div class="wizard-trans-benefit__text">' +
                  '<strong>' + _t('wizard.transVisibility', 'Visibilité unifiée') + '</strong>' +
                  '<p>' + _t('wizard.transVisibilityDesc', 'Un tableau de bord unique reflète votre conformité sur l\'ensemble des référentiels activés.') + '</p>' +
                '</div>' +
              '</div>' +
            '</div>' +
          '</div>';
        },
        onEnter: function() {},
        onLeave: function() {},
        canProceed: function() { return true; }
      });
    }

    // === SERVER MODE SLIDES ===
    // Positioned after general slides so that CSI_LicenseManager.detect() (async)
    // has completed by the time the wizard reaches these steps.
    // shouldShow() provides lazy evaluation as a safety net.
    var _wizardServerStatus = null;

    // Step: Server pre-flight checks (HTTP(S) mode + server mode unlocked)
    if (isFirstWelcome && window.location.protocol !== 'file:') {
      steps.push({
        id: 'server-check',
        shouldShow: function() { return isServerModeUnlocked(); },
        title: _t('wizard.serverCheck', 'Vérification du serveur'),
        subtitle: _t('wizard.serverCheckSub', 'Vérification de la disponibilité des services'),
        content: function() {
          // Build check items via safe string (static content only, no user data)
          var checkItems = [
            { key: 'sync', label: _t('wizard.checkSync', 'Point d\'accès synchronisation') + ' (sync.php)' },
            { key: 'users', label: _t('wizard.checkUsers', 'Point d\'accès utilisateurs') + ' (users.php)' },
            { key: 'sysinfo', label: _t('wizard.checkSysInfo', 'Informations système') },
            { key: 'runtime', label: _t('wizard.checkRuntime', 'Environnement d\'exécution') },
            { key: 'license', label: _t('wizard.checkLicense', 'État de la licence') },
            { key: 'config', label: _t('wizard.checkServer', 'Configuration serveur') }
          ];
          var html = '<div class="wizard-check-list" id="wizardCheckList">';
          for (var ci = 0; ci < checkItems.length; ci++) {
            html += '<div class="wizard-check-item wizard-check-item--pending" data-check="' + checkItems[ci].key + '">' +
              '<span class="wizard-check-icon"><i class="fas fa-spinner fa-spin"></i></span>' +
              '<span class="wizard-check-label">' + escapeHtml(checkItems[ci].label) + '</span>' +
              '<span class="wizard-check-status"></span></div>';
          }
          html += '</div>' +
            '<div id="wizardCheckSummary" style="margin-top:16px;text-align:center;font-size:0.95rem;"></div>' +
            '<div style="text-align:center;margin-top:12px;"><button type="button" id="wizardRecheckBtn" class="btn btn--outline" style="display:none;"><i class="fas fa-redo"></i> ' + escapeHtml(_t('wizard.recheck', 'Re-vérifier')) + '</button></div>';
          return html;
        },
        onEnter: function(stepEl) {
          function runChecks() {
            var items = stepEl.querySelectorAll('.wizard-check-item');
            items.forEach(function(item) {
              item.className = 'wizard-check-item wizard-check-item--pending';
              var icon = item.querySelector('.wizard-check-icon');
              while (icon.firstChild) icon.removeChild(icon.firstChild);
              var spinner = document.createElement('i');
              spinner.className = 'fas fa-spinner fa-spin';
              icon.appendChild(spinner);
              item.querySelector('.wizard-check-status').textContent = '';
            });
            var summary = stepEl.querySelector('#wizardCheckSummary');
            summary.textContent = '';
            var recheckBtn = stepEl.querySelector('#wizardRecheckBtn');
            recheckBtn.style.display = 'none';
            var results = { sync: null, users: null, sysinfo: null, runtime: null, license: null, config: null };

            function updateItem(check, ok, detail) {
              var item = stepEl.querySelector('[data-check="' + check + '"]');
              if (!item) return;
              item.className = 'wizard-check-item ' + (ok ? 'wizard-check-item--ok' : 'wizard-check-item--error');
              var icon = item.querySelector('.wizard-check-icon');
              while (icon.firstChild) icon.removeChild(icon.firstChild);
              var ic = document.createElement('i');
              ic.className = ok ? 'fas fa-check-circle' : 'fas fa-times-circle';
              icon.appendChild(ic);
              item.querySelector('.wizard-check-status').textContent = detail || '';
              results[check] = ok;
              checkAllDone();
            }

            function checkAllDone() {
              if (Object.values(results).some(function(v) { return v === null; })) return;
              var allOk = Object.values(results).every(function(v) { return v; });
              summary.textContent = '';
              var statusIcon = document.createElement('i');
              var statusText = document.createElement('span');
              statusText.style.fontWeight = '500';
              if (allOk) {
                statusIcon.className = 'fas fa-check-circle';
                statusText.style.color = 'var(--color-success,#22c55e)';
                statusText.textContent = ' ' + _t('wizard.serverReady', 'Serveur prêt');
              } else {
                statusIcon.className = 'fas fa-exclamation-triangle';
                statusText.style.color = 'var(--color-danger,#dc3545)';
                statusText.textContent = ' ' + _t('wizard.serverError', 'Problème détecté');
                recheckBtn.style.display = '';
              }
              statusText.style.color = allOk ? 'var(--color-success,#22c55e)' : 'var(--color-danger,#dc3545)';
              summary.appendChild(statusIcon);
              summary.appendChild(statusText);
            }

            // Check sync.php
            // FIX-WIZARD-CONFIG-RACE (audit 2026-06-10) : conserver les promesses des deux
            // requêtes de statut pour attendre leur fin réelle (au lieu d'un délai fixe de
            // 500 ms qui marquait à tort « Serveur indisponible » sur un serveur lent).
            var _syncCheckP = fetchWithTimeout('./data/sync.php?action=status', { cache: 'no-store' })
              .then(function(r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
              .then(function(d) {
                _wizardServerStatus = d;
                updateItem('sync', true, 'OK');
              })
              .catch(function() { updateItem('sync', false, _t('wizard.syncMissing', 'Non déployé')); });

            // Check users.php
            var _usersCheckP = fetchWithTimeout('./data/users.php?action=status', { cache: 'no-store' })
              .then(function(r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
              .then(function(d) {
                _wizardServerStatus = _wizardServerStatus || {};
                _wizardServerStatus.configured = d.configured;
                _wizardServerStatus.userCount = d.userCount;
                _wizardServerStatus.maxUsers = d.maxUsers;
                updateItem('users', true, d.userCount + '/' + d.maxUsers + ' ' + _t('users.usersLabel', 'utilisateurs'));
              })
              .catch(function() { updateItem('users', false, _t('wizard.usersMissing', 'Non déployé')); });

            // Check system info (runtime, extensions, disk, permissions)
            fetchWithTimeout('./data/sync.php?action=system-info', { cache: 'no-store' })
              .then(function(r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
              .then(function(d) {
                var issues = [];
                var runtime = 'PHP ' + (d.phpVersion || '');
                if (d.phpVersion && !d.phpVersionOk) issues.push('PHP ' + d.phpVersion + ' (min ' + (d.phpVersionMin || '7.4') + ')');
                if (d.requiredExtensions && !d.allExtensionsOk) {
                  var missing = Object.keys(d.requiredExtensions).filter(function(k) { return !d.requiredExtensions[k]; });
                  if (missing.length) issues.push('ext: ' + missing.join(', '));
                }
                if (d.writePermissions && !d.writePermissions.dataDir) issues.push(_t('wizard.noWriteAccess', 'Pas d\'accès écriture'));
                if (d.diskSpace && !d.diskSpace.sufficient) issues.push(_t('wizard.lowDiskSpace', 'Espace disque insuffisant'));
                if (issues.length) {
                  updateItem('sysinfo', false, issues.join(' | '));
                } else {
                  var detail = runtime;
                  if (d.diskSpace && d.diskSpace.free && d.diskSpace.free !== 'N/A') detail += ' · ' + d.diskSpace.free + ' ' + _t('wizard.freeSpace', 'libre');
                  updateItem('sysinfo', true, detail);
                }
                // Runtime detection — prominent separate item
                var runtimeLabel = _t('wizard.runtimePHP', 'Apache/PHP') + ' ' + (d.phpVersion || '');
                updateItem('runtime', true, _t('wizard.runtimeDetected', 'Détecté : {runtime}').replace('{runtime}', runtimeLabel));
              })
              .catch(function() {
                updateItem('sysinfo', false, _t('wizard.sysInfoUnavailable', 'Info système indisponible'));
                updateItem('runtime', false, _t('wizard.sysInfoUnavailable', 'Info système indisponible'));
              });

            // Check license/trial
            var _TM = window.CSI_TrialManager;
            if (_TM && _TM.isDetected()) {
              var state = _TM.getState();
              if (state && state.licensed) {
                updateItem('license', true, _t('license.active', 'Licence active'));
              } else if (state && state.trial && !state.trialExpired && state.trialDaysLeft > 0) {
                updateItem('license', true, _t('license.daysLeft', '{days} jours restants').replace('{days}', String(state.trialDaysLeft)));
              } else {
                updateItem('license', false, _t('license.trialExpired', 'Période d\'essai expirée'));
              }
            } else {
              updateItem('license', true, _t('license.active', 'Active'));
            }

            // Check server configured
            // FIX-WIZARD-CONFIG-RACE (audit 2026-06-10) : attendre la fin des deux requêtes
            // de statut (timeout 8 s assuré par fetchWithTimeout) avant d'évaluer la
            // configuration — les chaînes .catch ci-dessus résolvent toujours.
            Promise.all([_syncCheckP, _usersCheckP]).then(function() {
              if (_wizardServerStatus) {
                updateItem('config', true, _wizardServerStatus.configured ? _t('wizard.configured', 'Configuré') : _t('wizard.firstSetup', 'Premier démarrage'));
              } else {
                updateItem('config', false, _t('wizard.serverUnavailable', 'Serveur indisponible'));
              }
            });
          }

          runChecks();
          var recheckBtn = stepEl.querySelector('#wizardRecheckBtn');
          if (recheckBtn) {
            recheckBtn.addEventListener('click', function() { runChecks(); });
          }
        },
        canProceed: function() { return true; }
      });
    }

    // Step: Network security warning (server mode only)
    if (isFirstWelcome && window.location.protocol !== 'file:') {
      steps.push({
        id: 'network-security',
        shouldShow: function() { return isServerModeUnlocked(); },
        title: _t('wizard.networkSecTitle', 'Sécurité réseau'),
        subtitle: _t('wizard.networkSecSub', 'Information importante'),
        content: function() {
          return '<div style="padding:4px 0;">' +
            '<div style="background:linear-gradient(135deg,#f59e0b 0%,#d97706 100%);color:#fff;padding:16px 18px;border-radius:10px;margin-bottom:16px;display:flex;align-items:flex-start;gap:12px;">' +
              '<i class="fas fa-exclamation-triangle" style="font-size:1.4rem;margin-top:2px;flex-shrink:0;"></i>' +
              '<span style="font-size:0.95rem;line-height:1.5;">' + escapeHtml(_t('wizard.networkSecWarning', 'Cette application est conçue pour un usage en réseau local (LAN/intranet) uniquement.')) + '</span>' +
            '</div>' +
            '<p style="font-size:0.9rem;color:var(--color-text,#333);line-height:1.6;margin:0 0 16px;" id="wizardNetSecExplain">' + escapeHtml(_t('wizard.networkSecExplain', 'Cyber-Assistant intègre une authentification et des contrôles d\'accès, mais n\'est pas conçu pour être exposé sur Internet. Si l\'application est hébergée sur un réseau local, il est essentiel de restreindre les accès au niveau du réseau (pare-feu, VLAN, VPN) car elle ne dispose pas de protections suffisantes contre les attaques depuis Internet.')) + '</p>' +
            '<p style="font-weight:600;font-size:0.9rem;margin:0 0 8px;color:var(--color-text,#333);" id="wizardNetSecRecTitle">' + escapeHtml(_t('wizard.networkSecRecTitle', 'Précautions recommandées :')) + '</p>' +
            '<ul style="list-style:none;padding:0;margin:0 0 16px;">' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec1"><i class="fas fa-shield-alt" style="color:#3b82f6;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec1', 'Déployer derrière un pare-feu d\'entreprise ou un VPN')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec2"><i class="fas fa-network-wired" style="color:#3b82f6;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec2', 'Restreindre l\'accès au réseau interne uniquement')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec3"><i class="fas fa-lock" style="color:#3b82f6;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec3', 'Utiliser HTTPS avec un certificat TLS valide')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec4"><i class="fas fa-globe" style="color:#dc3545;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec4', 'Ne jamais exposer l\'application sur Internet')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec5"><i class="fas fa-sync-alt" style="color:#3b82f6;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec5', 'Mettre à jour régulièrement l\'environnement serveur')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec6"><i class="fas fa-user-shield" style="color:#dc3545;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec6', 'Limiter l\'accès à l\'application aux seuls utilisateurs autorisés')) + '</li>' +
              '<li style="padding:6px 0;font-size:0.88rem;color:var(--color-text,#333);display:flex;align-items:center;gap:8px;" id="wizardNetSecRec7"><i class="fas fa-ban" style="color:#dc3545;width:16px;text-align:center;"></i> ' + escapeHtml(_t('wizard.networkSecRec7', 'Ne pas laisser l\'application en libre accès sur le réseau sans authentification')) + '</li>' +
            '</ul>' +
            '<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:12px 14px;background:var(--color-surface-alt,#f8fafc);border:1px solid var(--color-border,#e2e8f0);border-radius:8px;">' +
              '<input type="checkbox" id="wizardNetSecAck" style="width:18px;height:18px;accent-color:#f59e0b;flex-shrink:0;">' +
              '<span style="font-size:0.9rem;color:var(--color-text,#333);" id="wizardNetSecAckLabel">' + escapeHtml(_t('wizard.networkSecAck', 'Je m\'engage à restreindre l\'accès à cette application aux seuls utilisateurs autorisés et à ne pas l\'exposer en libre accès sur le réseau')) + '</span>' +
            '</label>' +
          '</div>';
        },
        onEnter: function(stepEl) {
          var ackCheckbox = stepEl.querySelector('#wizardNetSecAck');
          if (ackCheckbox) {
            ackCheckbox.addEventListener('change', function() {
              var nextBtn = overlay ? overlay.querySelector('[data-wizard-action="next"]') : null;
              var finishBtn = overlay ? overlay.querySelector('[data-wizard-action="finish"]') : null;
              var btn = nextBtn || finishBtn;
              if (btn) btn.disabled = !ackCheckbox.checked;
            });
          }
        },
        canProceed: function() {
          var ack = document.querySelector('#wizardNetSecAck');
          return ack && ack.checked;
        }
      });
    }

    // Step: Admin account creation (server mode + not yet configured)
    if (isFirstWelcome && window.location.protocol !== 'file:') {
      steps.push({
        id: 'admin-setup',
        shouldShow: function() { return isServerModeUnlocked(); },
        title: _t('wizard.adminSetup', 'Compte administrateur'),
        subtitle: _t('auth.setupPrompt', 'Vous serez l\'administrateur.'),
        content: function() {
          if (_wizardServerStatus && _wizardServerStatus.configured) {
            return '<div style="text-align:center;padding:24px 0;">' +
              '<i class="fas fa-check-circle" style="font-size:2rem;color:var(--color-success,#22c55e);margin-bottom:12px;display:block;"></i>' +
              '<p style="font-size:1rem;color:var(--color-text,#333);">' + escapeHtml(_t('wizard.alreadyConfigured', 'Le serveur est déjà configuré.')) + '</p>' +
              '<p style="font-size:0.9rem;color:var(--color-text-secondary,#666);">' + escapeHtml(_t('wizard.loginHint', 'Connectez-vous après le wizard pour accéder au mode édition.')) + '</p>' +
              '</div>';
          }
          return '<div class="wizard-admin-form">' +
            '<label style="display:block;margin-bottom:4px;font-weight:500;font-size:0.9rem;">' + escapeHtml(_t('auth.yourName', 'Votre nom')) + '</label>' +
            '<input type="text" id="wizardAdminName" autocomplete="username" style="width:100%;padding:10px 12px;border:1px solid var(--color-border,#ddd);border-radius:6px;margin-bottom:12px;font-size:0.95rem;box-sizing:border-box;">' +
            '<label style="display:block;margin-bottom:4px;font-weight:500;font-size:0.9rem;">' + escapeHtml(_t('auth.password', 'Mot de passe')) + '</label>' +
            '<input type="password" id="wizardAdminPwd" autocomplete="new-password" style="width:100%;padding:10px 12px;border:1px solid var(--color-border,#ddd);border-radius:6px;margin-bottom:12px;font-size:0.95rem;box-sizing:border-box;">' +
            '<label style="display:block;margin-bottom:4px;font-weight:500;font-size:0.9rem;">' + escapeHtml(_t('auth.confirmPassword', 'Confirmer')) + '</label>' +
            '<input type="password" id="wizardAdminConfirm" autocomplete="new-password" style="width:100%;padding:10px 12px;border:1px solid var(--color-border,#ddd);border-radius:6px;margin-bottom:12px;font-size:0.95rem;box-sizing:border-box;">' +
            '<p style="margin:0 0 8px;font-size:0.8rem;color:var(--color-text-secondary,#999);">' + escapeHtml(_t('auth.passwordPolicy', '18+ caractères, majuscule, minuscule, caractère spécial')) + '</p>' +
            '<div id="wizardAdminError" style="display:none;padding:8px 12px;background:var(--color-danger-light, #fee2e2);color:var(--color-danger, #b91c1c);border-radius:var(--border-radius-sm, 6px);margin-bottom:8px;font-size:0.85rem;"></div>' +
            '<div id="wizardAdminSuccess" style="display:none;padding:8px 12px;background:var(--color-success-bg,#f0fdf4);color:var(--color-success,#22c55e);border-radius:6px;margin-bottom:8px;font-size:0.85rem;"></div>' +
          '</div>';
        },
        onEnter: function(stepEl) {
          var nameInput = stepEl.querySelector('#wizardAdminName');
          if (nameInput) setTimeout(function() { nameInput.focus(); }, 100);
        },
        canProceed: function(stepEl) {
          if (_wizardServerStatus && _wizardServerStatus.configured) return true;
          var nameInput = stepEl.querySelector('#wizardAdminName');
          var pwdInput = stepEl.querySelector('#wizardAdminPwd');
          var confirmInput = stepEl.querySelector('#wizardAdminConfirm');
          var errorDiv = stepEl.querySelector('#wizardAdminError');
          if (!nameInput || !pwdInput) return false;

          var name = (nameInput.value || '').trim();
          var pwd = pwdInput.value || '';
          var confirm = confirmInput ? confirmInput.value || '' : '';

          if (!name) { if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = _t('auth.nameRequired', 'Nom requis'); } return false; }

          var errors = [];
          if (pwd.length < 18) errors.push(_t('auth.chars18', '18+ characters'));
          if (!/[A-Z]/.test(pwd)) errors.push(_t('auth.uppercase', 'uppercase'));
          if (!/[a-z]/.test(pwd)) errors.push(_t('auth.lowercase', 'lowercase'));
          if (!/[^a-zA-Z0-9]/.test(pwd)) errors.push(_t('auth.specialChar', 'special character'));
          if (errors.length) { if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = _t('auth.passwordInvalid', 'Mot de passe') + ': ' + errors.join(', '); } return false; }
          if (pwd !== confirm) { if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = _t('auth.passwordMismatch', 'Les mots de passe ne correspondent pas'); } return false; }

          if (errorDiv) errorDiv.style.display = 'none';

          // Trigger async admin setup — block wizard until complete
          var successDiv = stepEl.querySelector('#wizardAdminSuccess');
          var AM = window.CSI_AuthManager;
          if (AM && typeof AM.setup === 'function' && !stepEl._setupStarted) {
            stepEl._setupStarted = true;
            if (errorDiv) errorDiv.style.display = 'none';
            nameInput.disabled = true;
            pwdInput.disabled = true;
            if (confirmInput) confirmInput.disabled = true;
            AM.setup(name, pwd).then(function(r) {
              if (r.ok) {
                _wizardServerStatus = _wizardServerStatus || {};
                _wizardServerStatus.configured = true;
                stepEl._setupDone = true;
                if (successDiv) { successDiv.style.display = 'block'; successDiv.textContent = _t('auth.accountCreated', 'Compte admin créé'); }
                // Auto-click next
                // FIX-WIZARD-AUTOADVANCE (audit 2026-06-10) : closest('[class*=wizard]')
                // retournait stepEl lui-même (classe wizard-step-content) → le bouton
                // « Suivant » du footer n'était jamais trouvé. Cibler l'overlay du wizard
                // et son bouton [data-wizard-action], en vérifiant que l'étape est toujours
                // affichée (l'utilisateur peut avoir cliqué Suivant manuellement entre-temps).
                var _wizOverlay = stepEl.closest('.wizard-overlay') || document.getElementById('setupWizardOverlay');
                if (_wizOverlay) {
                  setTimeout(function() {
                    if (!document.body.contains(stepEl)) return; // étape déjà quittée
                    var btn = _wizOverlay.querySelector('[data-wizard-action="next"], [data-wizard-action="finish"]');
                    if (btn) btn.click();
                  }, 500);
                }
              } else {
                stepEl._setupStarted = false;
                nameInput.disabled = false;
                pwdInput.disabled = false;
                if (confirmInput) confirmInput.disabled = false;
                if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = (r.data && r.data.error) || _t('common.error', 'Erreur'); }
              }
            }).catch(function(err) {
              stepEl._setupStarted = false;
              nameInput.disabled = false;
              pwdInput.disabled = false;
              if (confirmInput) confirmInput.disabled = false;
              if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.textContent = err.message || _t('common.error', 'Erreur'); }
            });
            return false; // Block until setup completes
          }
          return stepEl._setupDone === true;
        },
        onLeave: function() {}
      });
    }

    // Step: Server version promo (LOCAL mode only)
    if (isFirstWelcome && window.location.protocol === 'file:') {
      steps.push({
        id: 'server-promo',
        title: _t('wizard.serverPromoTitle', 'Version Serveur disponible'),
        subtitle: _t('wizard.serverPromoSub', 'Collaborez en équipe sur votre SMSI'),
        content: function() {
          return '<div class="wizard-promo">' +
            '<div class="wizard-promo-features">' +
              '<div class="wizard-promo-feature">' +
                '<div class="wizard-promo-feature__icon" style="background:var(--color-info-bg,#dbeafe);color:var(--color-info,#3b82f6);"><i class="fas fa-users"></i></div>' +
                '<div class="wizard-promo-feature__text">' +
                  '<strong>' + _t('wizard.serverPromoCoEdit', 'Co-édition en temps réel') + '</strong>' +
                  '<p>' + _t('wizard.serverPromoCoEditDesc', 'Travaillez à plusieurs simultanément sur le même SMSI avec un système de verrouillage intelligent.') + '</p>' +
                '</div>' +
              '</div>' +
              '<div class="wizard-promo-feature">' +
                '<div class="wizard-promo-feature__icon" style="background:var(--color-warning-bg,#fef3c7);color:var(--color-warning,#f59e0b);"><i class="fas fa-user-shield"></i></div>' +
                '<div class="wizard-promo-feature__text">' +
                  '<strong>' + _t('wizard.serverPromoUsers', 'Gestion des utilisateurs') + '</strong>' +
                  '<p>' + _t('wizard.serverPromoUsersDesc', 'Rôles administrateur et éditeur, jusqu\'à 5 utilisateurs avec authentification sécurisée.') + '</p>' +
                '</div>' +
              '</div>' +
              '<div class="wizard-promo-feature">' +
                '<div class="wizard-promo-feature__icon" style="background:var(--color-success-bg,#dcfce7);color:var(--color-success,#22c55e);"><i class="fas fa-cloud-arrow-up"></i></div>' +
                '<div class="wizard-promo-feature__text">' +
                  '<strong>' + _t('wizard.serverPromoSync', 'Synchronisation automatique') + '</strong>' +
                  '<p>' + _t('wizard.serverPromoSyncDesc', 'Données centralisées sur le serveur, accessibles depuis n\'importe quel poste connecté.') + '</p>' +
                '</div>' +
              '</div>' +
            '</div>' +
            '<div class="wizard-promo-cta">' +
              '<a href="https://cyber-assistant.com" target="_blank" rel="noopener" class="wizard-promo-link">' +
              '<i class="fas fa-globe"></i> ' + _t('wizard.serverPromoLink', 'En savoir plus sur cyber-assistant.com') +
              ' <i class="fas fa-external-link-alt" style="font-size:0.8em;opacity:0.7;"></i></a>' +
            '</div>' +
          '</div>';
        },
        onEnter: function() {},
        onLeave: function() {},
        canProceed: function() { return true; }
      });
    }

    // Step: Server capabilities (SERVER mode only)
    if (isFirstWelcome && window.location.protocol !== 'file:') {
      steps.push({
        id: 'server-capabilities',
        shouldShow: function() { return isServerModeUnlocked(); },
        title: _t('wizard.serverCapTitle', 'Fonctionnalités serveur'),
        subtitle: _t('wizard.serverCapSub', 'Cette version serveur vous permet\u2026'),
        content: function() {
          return '<div class="wizard-server-caps">' +
            '<div class="wizard-cap-grid">' +
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-users-gear"></i></div>' +
                '<strong>' + _t('wizard.serverCapCoEdit', 'Co-édition') + '</strong>' +
                '<p>' + _t('wizard.serverCapCoEditDesc', 'Travaillez à plusieurs simultanément avec un verrouillage automatique par section.') + '</p>' +
              '</div>' +
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-user-shield"></i></div>' +
                '<strong>' + _t('wizard.serverCapUsers', 'Gestion des utilisateurs') + '</strong>' +
                '<p>' + _t('wizard.serverCapUsersDesc', 'Rôles admin et éditeur, jusqu\'à 5 utilisateurs avec sessions sécurisées.') + '</p>' +
              '</div>' +
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-arrows-rotate"></i></div>' +
                '<strong>' + _t('wizard.serverCapSync', 'Synchronisation') + '</strong>' +
                '<p>' + _t('wizard.serverCapSyncDesc', 'Données centralisées et synchronisées automatiquement entre tous les postes.') + '</p>' +
              '</div>' +
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-lock"></i></div>' +
                '<strong>' + _t('wizard.serverCapLock', 'Verrouillage') + '</strong>' +
                '<p>' + _t('wizard.serverCapLockDesc', 'Système de heartbeat empêchant les conflits d\'édition entre collaborateurs.') + '</p>' +
              '</div>' +
              // === CSI-MCP === 2 capacites ajoutees (texte statique, aucune donnee utilisateur)
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-sitemap"></i></div>' +
                '<strong>' + _t('wizard.serverCapEntities', 'Multi-entités') + '</strong>' +
                '<p>' + _t('wizard.serverCapEntitiesDesc', 'Pilotez jusqu\'à 50 entités : tableau de bord consolidé pour le groupe, cloisonnement strict par site.') + '</p>' +
              '</div>' +
              '<div class="wizard-cap-card">' +
                '<div class="wizard-cap-card__icon"><i class="fas fa-wand-magic-sparkles"></i></div>' +
                '<strong>' + _t('wizard.serverCapMcp', 'Accès IA (MCP)') + '</strong>' +
                '<p>' + _t('wizard.serverCapMcpDesc', 'Connectez votre assistant IA à votre SMSI via le standard MCP — en lecture seule et sous votre contrôle. Idéalement une IA hébergée chez vous ou souveraine.') + '</p>' +
              '</div>' +
            '</div>' +
          '</div>';
        },
        onEnter: function() {},
        onLeave: function() {},
        canProceed: function() { return true; }
      });
    }

    // Disclaimer slide (shown only in public/open edition — when only open-* frameworks are loaded)
    steps.push({
      id: 'framework-disclaimer',
      title: _t('wizard.disclaimerTitle', 'Référentiels de sécurité'),
      subtitle: _t('wizard.disclaimerSubtitle', 'Information importante'),
      shouldShow: function() {
        var fws = window._csiFrameworks || {};
        // Show only when NO official ISO frameworks are present (public edition)
        return !fws['iso27002'] && !fws['iso27001'];
      },
      content: function() {
        return '<div class="wizard-disclaimer" style="text-align:left;max-width:600px;margin:0 auto;">' +
          '<div style="background:var(--color-info-bg,#eff6ff);border:1px solid var(--color-info,#3b82f6);border-radius:8px;padding:16px;margin-bottom:16px;">' +
            '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' +
              '<i class="fas fa-info-circle" style="color:var(--color-info,#3b82f6);font-size:18px;"></i>' +
              '<strong style="color:var(--color-info,#3b82f6);">' + _t('wizard.disclaimerInfoTitle', 'Référentiels ouverts') + '</strong>' +
            '</div>' +
            '<p style="margin:0;line-height:1.5;">' + _t('wizard.disclaimerInfoBody', 'Cette édition utilise les référentiels <strong>Open Security Controls</strong> (93 contrôles) et <strong>Open Security Management Framework</strong> (clauses 4 à 10), inspirés des bonnes pratiques internationales en matière de sécurité de l\'information.') + '</p>' +
          '</div>' +
          '<div style="background:var(--color-warning-bg,#fffbeb);border:1px solid var(--color-warning,#f59e0b);border-radius:8px;padding:16px;">' +
            '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' +
              '<i class="fas fa-exclamation-triangle" style="color:var(--color-warning,#f59e0b);font-size:18px;"></i>' +
              '<strong style="color:var(--color-warning,#f59e0b);">' + _t('wizard.disclaimerWarningTitle', 'Avertissement') + '</strong>' +
            '</div>' +
            '<p style="margin:0;line-height:1.5;">' + _t('wizard.disclaimerWarningBody', 'Ces référentiels ne sont <strong>pas des normes ISO officielles</strong>. Ils ne se substituent pas aux normes ISO/IEC 27001:2022 et ISO/IEC 27002:2022. Pour une certification officielle, vous devez vous procurer les normes originales auprès de l\'ISO ou de votre organisme national de normalisation.') + '</p>' +
          '</div>' +
        '</div>';
      },
      onEnter: function() {},
      onLeave: function() {},
      canProceed: function() { return true; }
    });

    // === CSI-ME === Multi-entity feature slide — always shown (all editions ; en local
    // il est informatif/upsell, comme server-promo). Poussé SANS shouldShow (même
    // convention que corporate-upsell) ; le contenu s'adapte DANS content(), réévalué à
    // chaque rendu — donc après la détection de licence asynchrone.
    steps.push({
      id: 'multi-entity',
      title: _t('wizard.multiEntTitle', 'Multi-entités'),
      subtitle: _t('wizard.multiEntSub', 'Un SMSI, plusieurs périmètres cloisonnés'),
      content: function() {
        var _meServer = (typeof isServerModeUnlocked === 'function') && isServerModeUnlocked();
        var html = '<div class="wizard-server-caps">' +
          '<div class="wizard-cap-grid">' +
            '<div class="wizard-cap-card">' +
              '<div class="wizard-cap-card__icon"><i class="fas fa-sitemap"></i></div>' +
              '<strong>' + _t('wizard.multiEntIsolation', 'Cloisonnement') + '</strong>' +
              '<p>' + _t('wizard.multiEntIsolationDesc', 'Plusieurs entités ou sites dans une seule installation — les données de chaque périmètre (risques, actions, contrôles…) sont strictement séparées.') + '</p>' +
            '</div>' +
            '<div class="wizard-cap-card">' +
              '<div class="wizard-cap-card__icon"><i class="fas fa-user-tag"></i></div>' +
              '<strong>' + _t('wizard.multiEntAccounts', 'Comptes rattachés') + '</strong>' +
              '<p>' + _t('wizard.multiEntAccountsDesc', 'Chaque compte rattaché ne voit que son périmètre, avec des droits par section (lecture / écriture). Multi-appartenance possible.') + '</p>' +
            '</div>' +
            '<div class="wizard-cap-card">' +
              '<div class="wizard-cap-card__icon"><i class="fas fa-layer-group"></i></div>' +
              '<strong>' + _t('wizard.multiEntGroup', 'Vue groupe consolidée') + '</strong>' +
              '<p>' + _t('wizard.multiEntGroupDesc', 'Tuile de pilotage par entité sur le tableau de bord et console « Toutes les entités » en lecture consolidée.') + '</p>' +
            '</div>' +
            '<div class="wizard-cap-card">' +
              '<div class="wizard-cap-card__icon"><i class="fas fa-right-left"></i></div>' +
              '<strong>' + _t('wizard.multiEntTransfer', 'Répartition maîtrisée') + '</strong>' +
              '<p>' + _t('wizard.multiEntTransferDesc', 'Assistant de déplacement entre périmètres, exports nommés par entité et garde-fou anti-import croisé.') + '</p>' +
            '</div>' +
          '</div>';
        if (_meServer) {
          html += '<p class="wizard-hint" style="margin-top:12px;text-align:center;">' +
            '<i class="fas fa-toggle-on" style="margin-right:6px;"></i>' +
            _t('wizard.multiEntAvailServer', 'Fonction activable dans Réglages > Multi-entités (option de licence multi-entités).') + '</p>';
        } else {
          html += '<p class="wizard-hint" style="margin-top:12px;text-align:center;">' +
            _t('wizard.multiEntAvailLocal', 'Disponible en mode serveur (option de licence).') + ' ' +
            '<a href="https://cyber-assistant.com" target="_blank" rel="noopener" class="wizard-promo-link">' +
            '<i class="fas fa-globe"></i> ' + _t('wizard.serverPromoLink', 'En savoir plus sur cyber-assistant.com') +
            ' <i class="fas fa-external-link-alt" style="font-size:0.8em;opacity:0.7;"></i></a></p>';
        }
        return html + '</div>';
      },
      onEnter: function() {},
      onLeave: function() {},
      canProceed: function() { return true; }
    });

    // === CSI-MCP === Slide vitrine (editions locales) — presente Multi-entites et
    // Acces IA (MCP) comme capacites de l'edition Serveur. Convention du projet :
    // slide poussee SANS shouldShow, contenu adaptatif dans content() (cf. multi-entity).
    steps.push({
      id: 'mcp-showcase',
      title: _t('wizard.mcpSlideTitle', 'Allez plus loin avec l\'édition Serveur'),
      subtitle: _t('wizard.mcpSlideSubtitle', 'Deux capacités qui changent la donne'),
      content: function() {
        var _mcpServer = (typeof isServerModeUnlocked === 'function') && isServerModeUnlocked();
        if (_mcpServer) {
          // En mode serveur, ces capacites sont deja presentees par server-capabilities.
          return '<div style="text-align:center;padding:24px 0;">' +
            '<i class="fas fa-circle-check" style="font-size:2rem;color:var(--color-success,#22c55e);margin-bottom:12px;display:block;"></i>' +
            '<p>' + _t('wizard.mcpSlideServerOk', 'Votre édition Serveur inclut le multi-entités et l\'accès IA (MCP).') + '</p>' +
          '</div>';
        }
        return '<div class="wizard-corporate-upsell-slide" style="text-align:left;max-width:600px;margin:0 auto;">' +
          '<div style="background:linear-gradient(135deg,var(--color-info-bg,#eff6ff),#f0f4ff);border:1px solid var(--color-info,#3b82f6);border-radius:10px;padding:24px;margin-bottom:16px;">' +
            '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">' +
              '<i class="fas fa-wand-magic-sparkles" style="color:var(--color-info,#3b82f6);font-size:22px;"></i>' +
              '<strong style="color:var(--color-info,#3b82f6);font-size:1.1em;">' + _t('wizard.mcpSlideHead', 'Votre SMSI, interrogeable par votre IA') + '</strong>' +
            '</div>' +
            '<p style="margin:0 0 14px 0;line-height:1.6;font-size:0.97em;">' +
              _t('wizard.mcpSlideBody', 'L\'édition Serveur expose une interface <strong>MCP</strong> standard : votre assistant IA répond directement sur vos données de conformité, en lecture seule, avec un accès nominatif et révocable. Compatible avec les assistants du marché comme avec une IA hébergée sur votre infrastructure.') +
            '</p>' +
            '<div style="display:flex;align-items:center;gap:10px;margin:18px 0 12px 0;">' +
              '<i class="fas fa-sitemap" style="color:var(--color-info,#3b82f6);font-size:20px;"></i>' +
              '<strong style="color:var(--color-info,#3b82f6);font-size:1.05em;">' + _t('wizard.mcpSlideEntHead', 'Pilotez plusieurs entités') + '</strong>' +
            '</div>' +
            '<p style="margin:0 0 14px 0;line-height:1.6;font-size:0.97em;">' +
              _t('wizard.mcpSlideEntBody', 'Jusqu\'à 50 entités dans une seule installation : vision consolidée pour le groupe, isolation stricte par site, import/export par entité.') +
            '</p>' +
            '<div style="background:rgba(255,255,255,0.7);border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:10px;">' +
              '<i class="fas fa-circle-info" style="color:var(--color-info,#3b82f6);font-size:16px;"></i>' +
              '<span style="font-size:0.93em;color:var(--color-text-secondary,#555);">' +
                _t('wizard.mcpSlideAvail', 'Disponible en édition Serveur.') +
              '</span>' +
            '</div>' +
          '</div>' +
        '</div>';
      },
      onEnter: function() {},
      onLeave: function() {},
      canProceed: function() { return true; }
    });

    // Corporate upsell slide — always shown (all editions, including demo)
    steps.push({
      id: 'corporate-upsell',
      title: _t('wizard.corporateUpsellSlideTitle', 'Édition Corporate'),
      subtitle: _t('wizard.corporateUpsellSlideSubtitle', 'Allez plus loin'),
      content: function() {
        return '<div class="wizard-corporate-upsell-slide" style="text-align:left;max-width:600px;margin:0 auto;">' +
          '<div style="background:linear-gradient(135deg,var(--color-info-bg,#eff6ff),#f0f4ff);border:1px solid var(--color-info,#3b82f6);border-radius:10px;padding:24px;margin-bottom:16px;">' +
            '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">' +
              '<i class="fas fa-building" style="color:var(--color-info,#3b82f6);font-size:22px;"></i>' +
              '<strong style="color:var(--color-info,#3b82f6);font-size:1.1em;">' + _t('wizard.corporateUpsellTitle', 'Envie d\'aller plus loin ?') + '</strong>' +
            '</div>' +
            '<p style="margin:0 0 14px 0;line-height:1.6;font-size:0.97em;">' +
              _t('wizard.corporateUpsellBody', 'La <strong>version Corporate</strong> intègre les normes <strong>ISO/IEC 27001:2022</strong> et <strong>ISO/IEC 27002:2022</strong> officielles — idéale si vous visez une certification ou un audit formel.') +
            '</p>' +
            '<ul style="margin:0 0 14px 0;padding-left:20px;line-height:1.8;font-size:0.95em;">' +
              '<li><i class="fas fa-users" style="color:var(--color-info,#3b82f6);margin-right:6px;"></i>' + _t('wizard.corporateFeatureCollab', 'Co-édition multi-collaborateurs en temps réel') + '</li>' +
              '<li><i class="fas fa-server" style="color:var(--color-info,#3b82f6);margin-right:6px;"></i>' + _t('wizard.corporateFeatureServer', 'Mode serveur centralisé avec synchronisation') + '</li>' +
              '<li><i class="fas fa-certificate" style="color:var(--color-info,#3b82f6);margin-right:6px;"></i>' + _t('wizard.corporateFeatureISO', 'Normes ISO/IEC 27001 & 27002 officielles intégrées') + '</li>' +
              '<li><i class="fas fa-language" style="color:var(--color-info,#3b82f6);margin-right:6px;"></i>' + _t('wizard.corporateFeatureLang', 'Anglais natif avancé (traductions professionnelles)') + '</li>' +
            '</ul>' +
            '<div style="background:rgba(255,255,255,0.7);border-radius:8px;padding:12px 16px;display:flex;align-items:center;gap:10px;">' +
              '<i class="fas fa-envelope" style="color:var(--color-info,#3b82f6);font-size:16px;"></i>' +
              '<span style="font-size:0.93em;color:var(--color-text-secondary,#555);">' +
                _t('wizard.corporateUpsellContact', 'Contactez-nous pour en savoir plus\u00a0: <a href="mailto:info@cyber-assistant.com" style="color:var(--color-info,#3b82f6);text-decoration:underline;">info@cyber-assistant.com</a>') +
              '</span>' +
            '</div>' +
          '</div>' +
        '</div>';
      },
      onEnter: function() {},
      onLeave: function() {},
      canProceed: function() { return true; }
    });

    // If no steps, just complete
    if (steps.length === 0) {
      if (onComplete) onComplete();
      return;
    }

    // Create wizard overlay
    const overlay = document.createElement("div");
    overlay.id = "setupWizardOverlay";
    overlay.className = "wizard-overlay";

    // Helper: get visible steps (filter out conditional steps that shouldShow() === false)
    const getVisibleSteps = () => steps.filter(s => !s.shouldShow || s.shouldShow());
    const getVisibleIndex = () => {
      var visible = getVisibleSteps();
      for (var i = 0; i < visible.length; i++) {
        if (visible[i] === steps[currentStep]) return i;
      }
      return 0;
    };

    const renderStep = () => {
      // Skip current step if it has shouldShow() returning false
      while (currentStep < steps.length && steps[currentStep].shouldShow && !steps[currentStep].shouldShow()) {
        currentStep++;
      }
      if (currentStep >= steps.length) { closeWizard(); return; }

      const step = steps[currentStep];
      // First slide (language) is always rendered in English
      // FIX-WIZARD-LANG-FLAG (audit 2026-06-10) : bascule TEMPORAIRE ({persist:false}) —
      // l'ancien setLanguage('en') persistant consommait le flag de premier lancement
      // (isFirstLaunch) dès le rendu : un F5 pendant le wizard figeait silencieusement la
      // langue sans choix utilisateur. Le flag n'est désormais consommé qu'à la validation
      // de l'étape (onLeave → setLanguage + markLanguageChosen).
      var _langBefore = null;
      if (step.id === 'language' && window.CSI_i18n) {
        _langBefore = window.CSI_i18n.getLanguage();
        window.CSI_i18n.setLanguage('en', { persist: false });
      }
      const visibleSteps = getVisibleSteps();
      const visibleIdx = getVisibleIndex();
      const isFirst = visibleIdx === 0;
      const isLast = visibleIdx === visibleSteps.length - 1;
      const progress = ((visibleIdx + 1) / visibleSteps.length) * 100;

      overlay.innerHTML = `
        <div class="wizard-modal" role="dialog" aria-modal="true" aria-labelledby="wizardTitle">
          <div class="wizard-modal__header">
            <div class="wizard-progress">
              <div class="wizard-progress__bar" style="width: ${progress}%"></div>
            </div>
            <div class="wizard-steps-indicator">
              ${visibleSteps.map((s, i) => `
                <span class="wizard-step-dot ${i < visibleIdx ? 'wizard-step-dot--done' : ''} ${i === visibleIdx ? 'wizard-step-dot--active' : ''}" data-step="${i}">
                  ${i < visibleIdx ? '<i class="fas fa-check"></i>' : i + 1}
                </span>
              `).join("")}
            </div>
          </div>

          <div class="wizard-modal__body">
            <div class="wizard-modal__image">
              <img src="${WIZARD_IMAGE_URL}" alt="Cyber-Assistant" class="wizard-hero-image" />
              <div class="wizard-modal__image-overlay">
                <i class="fas fa-shield-alt"></i>
                <span>Cyber-Assistant</span>
              </div>
            </div>

            <div class="wizard-modal__content">
              <div class="wizard-content-inner">
                <h2 class="wizard-title" id="wizardTitle">${step.title}</h2>
                <p class="wizard-subtitle">${step.subtitle}</p>
                <div class="wizard-step-content" data-step-id="${step.id}">
                  ${step.content()}
                </div>
              </div>

              <div class="wizard-modal__footer">
                ${!isFirst ? `
                  <button class="btn btn--outline" type="button" data-wizard-action="prev">
                    <i class="fas fa-arrow-left"></i> ${_t('setup.btnPrevious')}
                  </button>
                ` : '<div></div>'}
                <button class="btn btn--primary" type="button" data-wizard-action="${isLast ? 'finish' : 'next'}">
                  ${isLast ? '<i class="fas fa-rocket"></i> ' + _t('setup.btnStart') : _t('setup.btnNext') + ' <i class="fas fa-arrow-right"></i>'}
                </button>
              </div>
            </div>
          </div>
        </div>
      `;

      // Restore language after rendering language step in English
      // FIX-WIZARD-LANG-FLAG : restauration également non persistante
      if (_langBefore !== null && window.CSI_i18n) {
        window.CSI_i18n.setLanguage(_langBefore, { persist: false });
      }

      // Bind step enter
      const stepEl = overlay.querySelector(`[data-step-id="${step.id}"]`);
      if (step.onEnter) step.onEnter(stepEl);

      // Bind navigation
      const prevBtn = overlay.querySelector('[data-wizard-action="prev"]');
      const nextBtn = overlay.querySelector('[data-wizard-action="next"]');
      const finishBtn = overlay.querySelector('[data-wizard-action="finish"]');

      if (prevBtn) {
        prevBtn.addEventListener("click", () => {
          if (currentStep > 0) {
            currentStep--;
            // Skip conditional steps going backward
            while (currentStep > 0 && steps[currentStep].shouldShow && !steps[currentStep].shouldShow()) {
              currentStep--;
            }
            renderStep();
          }
        });
      }

      if (nextBtn) {
        nextBtn.addEventListener("click", () => {
          const step = steps[currentStep];
          const stepEl = overlay.querySelector(`[data-step-id="${step.id}"]`);
          if (step.canProceed(stepEl)) {
            if (step.onLeave) step.onLeave(stepEl);
            currentStep++;
            renderStep();
          }
        });
      }

      if (finishBtn) {
        finishBtn.addEventListener("click", () => {
          const step = steps[currentStep];
          const stepEl = overlay.querySelector(`[data-step-id="${step.id}"]`);
          if (step.canProceed && !step.canProceed(stepEl)) return;
          if (step.onLeave) step.onLeave(stepEl);
          closeWizard();
        });
      }
    };

    const updateWizardTexts = () => {
      // Re-translate all wizard step titles after language change
      // Note: step 'language' keeps hardcoded English (always shown in English)
      steps.forEach(function(s) {
        if (s.id === 'license') { s.title = _t('license.title', 'Activation'); s.subtitle = needsLicense ? _t('license.trialExpired', 'Trial expired') : _t('license.trialActive', 'Trial active'); }
        if (s.id === 'server-check') { s.title = _t('wizard.serverCheck', 'Server check'); s.subtitle = _t('wizard.serverCheckSub', 'Checking service availability'); }
        if (s.id === 'network-security') { s.title = _t('wizard.networkSecTitle', 'Network Security'); s.subtitle = _t('wizard.networkSecSub', 'Important information'); }
        if (s.id === 'admin-setup') { s.title = _t('wizard.adminSetup', 'Administrator account'); s.subtitle = _t('auth.setupPrompt', 'You will be the administrator.'); }
        if (s.id === 'frameworks') { s.title = _t('setup.frameworksTitle'); s.subtitle = _t('setup.frameworksSubtitle'); }
        if (s.id === 'nis2-level') { s.title = _t('wizard.nis2LevelTitle', 'NIS2 Assurance Level'); s.subtitle = _t('wizard.nis2LevelSub', 'Set the CyFun level for your organisation'); }
        if (s.id === 'welcome') { s.title = _t('setup.welcomeTitle'); s.subtitle = _t('setup.welcomeSubtitle'); }
        if (s.id === 'local-password') { s.title = _t('wizard.localPwdTitle', 'Password protection'); s.subtitle = _t('wizard.localPwdSub', 'Required to protect your data'); }
        if (s.id === 'transversality') { s.title = _t('wizard.transTitle', 'Cross-framework mapping'); s.subtitle = _t('wizard.transSub', 'One effort, multiple compliance'); }
        if (s.id === 'server-promo') { s.title = _t('wizard.serverPromoTitle', 'Server version available'); s.subtitle = _t('wizard.serverPromoSub', 'Collaborate as a team on your ISMS'); }
        if (s.id === 'server-capabilities') { s.title = _t('wizard.serverCapTitle', 'Server features'); s.subtitle = _t('wizard.serverCapSub', 'This server version enables\u2026'); }
        if (s.id === 'framework-disclaimer') { s.title = _t('wizard.disclaimerTitle', 'Référentiels de sécurité'); s.subtitle = _t('wizard.disclaimerSubtitle', 'Information importante'); }
        if (s.id === 'multi-entity') { s.title = _t('wizard.multiEntTitle', 'Multi-entités'); s.subtitle = _t('wizard.multiEntSub', 'Un SMSI, plusieurs périmètres cloisonnés'); }
        // === CSI-MCP === obligatoire : sans cette ligne le titre ne se retraduit pas.
        if (s.id === 'mcp-showcase') { s.title = _t('wizard.mcpSlideTitle', 'Allez plus loin avec l\'édition Serveur'); s.subtitle = _t('wizard.mcpSlideSubtitle', 'Deux capacités qui changent la donne'); }
        if (s.id === 'corporate-upsell') { s.title = _t('wizard.corporateUpsellSlideTitle', 'Édition Corporate'); s.subtitle = _t('wizard.corporateUpsellSlideSubtitle', 'Allez plus loin'); }
      });
      // Re-render current step to apply translations
      renderStep();
    };

    const closeWizard = () => {
      overlay.classList.remove("is-visible");
      setTimeout(() => {
        overlay.remove();
        if (window.CSI_FrameworkMapper) CSI_FrameworkMapper.applyFrameworkVisibility();
        queueEditPasswordSetup();
        // After wizard closes, check if CyFun level modal should be shown
        // (frameworks may have just been enabled in the wizard)
        var _fwE = (appData.frameworkMapper && appData.frameworkMapper.enabledFrameworks) || [];
        // m-RC4 (audit fond 2026-07-11) : inclure recyf2025 — sinon un utilisateur ReCyF n'ayant
        // pas fait le wizard ne voyait jamais la modale de choix de degré (EI/EE).
        var _nis2On = _fwE.indexOf('nis2') >= 0 || _fwE.indexOf('cyfun2025') >= 0 || _fwE.indexOf('recyf2025') >= 0;
        if (_nis2On && !appData._cyfunLevelChosen) {
          setTimeout(function() {
            var modal = document.getElementById("cyfunLevelModal");
            if (modal) {
              var curLang = appData.cyfunLang || 'en';
              var langRadio = modal.querySelector('input[name="cyfunLangChoice"][value="' + curLang + '"]');
              if (langRadio) langRadio.checked = true;
              if (window.prepareNis2LevelModal) window.prepareNis2LevelModal();
              modal.classList.add("modal-overlay--visible");
            }
          }, 500);
        }
        if (onComplete) onComplete();
      }, 300);
    };

    document.body.appendChild(overlay);
    renderStep();
    requestAnimationFrame(() => overlay.classList.add("is-visible"));
  }

  // Keep old functions for backwards compatibility but redirect to wizard
  function showLanguageModal(onComplete) {
    showSetupWizard(onComplete);
  }

  function updateKPIs() {
    var FM = window.CSI_FrameworkMapper;
    var isoStats = FM ? FM.getConformityStats(_getActiveControlsFrameworkId() || 'iso27002', { soaFilter: true }) : null;
    var applicableCount, implementedControls, conformityRate;
    if (isoStats) {
      applicableCount = isoStats.applicable;
      implementedControls = isoStats.implemented;
      conformityRate = isoStats.rate;
    } else {
      // Fallback: manual computation if FrameworkMapper not available
      const soaEntries = Array.isArray(appData.soa) ? appData.soa : [];
      const soaMap = new Map(soaEntries.map((entry) => [entry.controlId, entry]));
      const applicableControls = (appData.controls || []).filter((c) => {
        const entry = soaMap.get(c.id);
        if (!entry) return true;
        return entry.applicable !== false;
      });
      applicableCount = applicableControls.length || 0;
      implementedControls = applicableControls.filter((c) => ["implemente", "verifie"].includes(c.status)).length;
      conformityRate = applicableCount > 0 ? Math.round(implementedControls / applicableCount * 100) : 0;
    }
    // NOTE: control.status is the ISO 27002 implementation status
    // soa[].implStatus is the SOA-specific implementation status (may differ intentionally)
    const activeActions = (appData.actions || []).filter((a) => !["termine", "annule"].includes(a.status)).length;
    const criticalRisks = (appData.risks || []).filter((r) => r.level === "Critique").length;
    const openNCs = (Array.isArray(appData.nonconformities) ? appData.nonconformities : []).filter(function(nc) { return nc.status !== 'cloture' && nc.status !== 'closed' && nc.status !== 'fermee'; }).length;

    // KPI principaux
    safeSetText("conformityRate", conformityRate + "%");
    safeSetText("implementedControls", implementedControls + "/" + applicableCount);
    safeSetText("totalActions", activeActions);
    safeSetText("criticalRisks", criticalRisks);
    safeSetText("openNonConformities", openNCs);

    // A11y: update KPI card aria-labels with current values
    var _kpiLabels = [
      { sel: '[data-nav-target="controls"].kpi-card--primary', label: t('dashboard.conformityRate', 'Conformité globale'), value: conformityRate + '%' },
      { sel: '[data-nav-target="controls"].kpi-card--success', label: t('dashboard.implementedControls', 'Contrôles implémentés'), value: implementedControls + '/' + applicableCount },
      { sel: '[data-nav-target="actions"]', label: t('dashboard.totalActions', 'Actions en cours'), value: activeActions },
      { sel: '[data-nav-target="risks"]', label: t('dashboard.criticalRisks', 'Risques critiques'), value: criticalRisks },
      { sel: '[data-nav-target="nonconformities"]', label: t('dashboard.openNonConformities', 'Non-conformités'), value: openNCs }
    ];
    _kpiLabels.forEach(function(k) {
      var el = document.querySelector('.kpi-card' + k.sel);
      if (el) el.setAttribute('aria-label', k.label + ': ' + k.value);
    });

    // KPI secondaires
    const projectRisks = appData.projectRisks || [];
    const projectRisksTotal = projectRisks.length;
    // FIX-PR-KPI-SUSPENDED (audit v2 #42) : le statut `suspended` n'était compté dans
    // AUCUN des trois sous-totaux, alors que le compteur affiché au-dessus est le total
    // brut — le détail ne totalisait pas la carte. Deux contradictions internes sur le
    // même écran : le GRAPHE compte bien les 5 statuts, et le KPI du module définit
    // « terminées » comme green+yellow+red.
    // On lit le statut NORMALISÉ : les enregistrements legacy ('rouge', 'a realiser',
    // 'Suspendue') étaient perdus par la comparaison brute.
    // Invariant obtenu : terminées + à risque + à faire + suspendues === total.
    const _prStatut = (r) => normalizeProjectRiskStatus(r && r.status);
    const completedStatuses = ['green', 'yellow'];
    const projectRisksCompleted = projectRisks.filter(r => completedStatuses.includes(_prStatut(r))).length;
    const projectRisksAtRisk = projectRisks.filter(r => _prStatut(r) === 'red').length;
    const projectRisksInProgress = projectRisks.filter(r => _prStatut(r) === 'todo').length;
    const projectRisksSuspended = projectRisks.filter(r => _prStatut(r) === 'suspended').length;
    safeSetText("projectRisksCount", projectRisksTotal);
    const detailEl = document.getElementById("projectRisksDetail");
    if (detailEl && projectRisksTotal > 0) {
      detailEl.textContent = `${projectRisksCompleted} ${t('labels.completed', 'terminées')}`
        + (projectRisksAtRisk > 0 ? ` · ${projectRisksAtRisk} ${t('labels.atRisk', 'à risque')}` : '')
        + ` · ${projectRisksInProgress} ${t('labels.todo', 'à faire')}`
        + (projectRisksSuspended > 0 ? ` · ${projectRisksSuspended} ${t('labels.suspendedPlural', 'suspendues')}` : '');
    }

    const criticalAssets = appData.criticalAssets || [];
    safeSetText("criticalAssetsCount", criticalAssets.length);

    const threats = appData.threats || [];
    safeSetText("threatsCount", threats.length);

    const audits = appData.audits || [];
    safeSetText("auditsCount", audits.length);

    const documents = appData.documents || [];
    safeSetText("documentsCount", documents.length);

    const riskAnalysesList = appData.riskAnalyses || [];
    safeSetText("riskAnalysesCount", riskAnalysesList.length);

    const managementReviewsList = appData.reviews || [];
    safeSetText("managementReviewsCount", managementReviewsList.length);

    // SoA coverage is managed by updateSoaCoverageStats() in loadSoaTable()
    // Removed: safeSetText("soaCoverage") was destroying sub-elements of the widget

    // Overdue audit detection
    var overdueAudits = (appData.audits || []).filter(function(a) {
      return a.plannedDate && a.status !== 'realise' && a.status !== 'annule' && new Date(a.plannedDate) < new Date();
    });
    var overdueEl = document.getElementById('overdueAuditsBadge');
    // FIX-OVERDUE-BADGE (audit 2026-06-10) : #overdueAuditsBadge n'existe dans aucun HTML —
    // créer le badge dynamiquement sur l'onglet Audits, même motif que les badges de
    // présence (.lock-badge greffés sur les boutons de navigation par lock-manager.js).
    if (!overdueEl) {
      var _auditsNavBtn = document.querySelector('.nav__item[data-tab="audits"]');
      if (_auditsNavBtn) {
        _auditsNavBtn.style.position = 'relative';
        overdueEl = document.createElement('span');
        overdueEl.id = 'overdueAuditsBadge';
        overdueEl.style.cssText = 'position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;padding:0 4px;border-radius:9px;font-size:var(--font-size-xs,0.7rem);font-weight:700;line-height:18px;text-align:center;color:#fff;background:var(--color-danger,#dc3545);pointer-events:none;box-sizing:border-box;display:none;';
        _auditsNavBtn.appendChild(overdueEl);
      }
    }
    if (overdueEl) {
      overdueEl.textContent = overdueAudits.length;
      overdueEl.style.display = overdueAudits.length > 0 ? '' : 'none';
    }

    // Mettre à jour le Score Global et les alertes
    updateScoreGlobal(conformityRate);
    updateDashboardAlerts();
    updateSparklines();
  }

  // Score Global SMSI
  function updateScoreGlobal(isoConformity) {
    // Calcul des scores composants — controls accessed via FrameworkMapper when available
    var FM = window.CSI_FrameworkMapper;
    var isoControls = FM ? FM.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const soaEntries = Array.isArray(appData.soa) ? appData.soa : [];
    const soaMap = new Map(soaEntries.map((entry) => [entry.controlId, entry]));
    const applicableControls = isoControls.filter((c) => {
      const entry = soaMap.get(c.id);
      if (!entry) return true;
      return entry.applicable !== false;
    });

    // Score ISO 27001
    const isoScore = isoConformity;
    safeSetText("scoreIso27001", isoScore + "%");

    // Score NIS2 (moyenne des maturités NIS2) — only if enabled
    var nis2Enabled = window.CSI_FrameworkMapper && CSI_FrameworkMapper.isEnabled('nis2');
    var nis2Score = 0;
    if (nis2Enabled) {
      if ((appData.nis2Referential || 'cyfun2025') === 'recyf2025' && typeof computeRecyfConformity === 'function') {
        // Référentiel France actif → score = taux de conformité ReCyF.
        // M3 (audit 2026-07-10) : le KPI global du dashboard doit être « coverage-aware »
        // (les mesures applicables non évaluées comptent pour 0), sinon une seule mesure
        // conforme affichait 100 %. On utilise percentApplicable (dénominateur = applicables),
        // distinct du percent « mesures évaluées » affiché — et labellisé — dans le module ReCyF.
        nis2Score = computeRecyfConformity().percentApplicable;
        safeSetText("scoreNis2", nis2Score + "%");
      } else {
        // GOV-NIS2 S3 (audit 2026-08-20, BLOQUANT B5) : targetMaturity.nis2 est une valeur
        // DÉRIVÉE qui n'était recalculée sur aucun chemin de chargement/pull — après un F5
        // en mode serveur, le KPI affichait 0 % à côté d'un radar saturé. On la recalcule
        // ici, au point de lecture : ≤218 itérations, toujours fraîche, plus aucun état
        // persisté à synchroniser.
        if (typeof recalcNis2Maturity === 'function') recalcNis2Maturity();
        const nis2Mat = appData.targetMaturity?.nis2 || {};
        // M3 : inclure les catégories non évaluées (valeur 0) au dénominateur — convention
        // CCB (non évalué = 0). Le filtre `v > 0` faisait afficher 100 % dès une seule
        // catégorie évaluée à 5/5.
        const nis2Values = Object.values(nis2Mat).filter(v => typeof v === 'number');
        const nis2Avg = nis2Values.length > 0 ? nis2Values.reduce((a, b) => a + b, 0) / nis2Values.length : 0;
        nis2Score = Math.round((nis2Avg / 5) * 100);
        safeSetText("scoreNis2", nis2Score + "%");
      }
    }

    // Score Risques maîtrisés
    const totalRisks = (appData.risks || []).length;
    const criticalGraveRisks = (appData.risks || []).filter(r => r.level === "Critique" || r.level === "Grave").length;
    const risksControlled = totalRisks > 0 ? Math.round(((totalRisks - criticalGraveRisks) / totalRisks) * 100) : 0;
    safeSetText("scoreRisks", risksControlled + "%");

    // Score global pondéré — weights change if NIS2 disabled
    const globalScore = nis2Enabled
      ? Math.round(isoScore * 0.4 + nis2Score * 0.3 + risksControlled * 0.3)
      : Math.round(isoScore * 0.55 + risksControlled * 0.45);

    // Lancer l'animation au chargement
    animateScoreGauge(globalScore);
  }

  function animateScoreGauge(globalScore) {
    const arc = document.getElementById("scoreGlobalArc");
    const valueEl = document.getElementById("scoreGlobalValue");
    const gaugeContainer = document.querySelector(".score-global__gauge");

    if (!arc || !valueEl) return;

    const circumference = 2 * Math.PI * 54; // r=54

    // Couleur dynamique selon le score
    let strokeColor = 'var(--color-danger, #ef4444)'; // Rouge < 40
    if (globalScore >= 70) strokeColor = 'var(--color-success, #22c55e)'; // Vert
    else if (globalScore >= 40) strokeColor = 'var(--color-warning, #f59e0b)'; // Orange

    // Reset pour l'animation
    arc.style.transition = 'none';
    arc.style.strokeDashoffset = circumference;
    arc.style.stroke = strokeColor;

    // Forcer le reflow
    arc.getBoundingClientRect();

    // Animation fluide de la jauge (1.5s)
    arc.style.transition = 'stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1)';
    const targetOffset = circumference - (globalScore / 100) * circumference;
    arc.style.strokeDashoffset = targetOffset;

    // Animation du compteur
    animateValue(valueEl, 0, globalScore, 1500);

    // A11y: update SVG aria-label with score
    var svgEl = document.getElementById('scoreGlobalSvg');
    if (svgEl) svgEl.setAttribute('aria-label', t('dashboard.scoreSMSI', 'Score SMSI') + ': ' + globalScore + '%');

    // Effet pulse sur le conteneur
    if (gaugeContainer) {
      gaugeContainer.classList.remove('gauge-pulse');
      void gaugeContainer.offsetWidth; // Force reflow
      gaugeContainer.classList.add('gauge-pulse');
    }
  }

  function animateValue(element, start, end, duration) {
    const startTime = performance.now();
    const update = (currentTime) => {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const easeOut = 1 - Math.pow(1 - progress, 3);
      const current = Math.round(start + (end - start) * easeOut);
      element.textContent = current;
      if (progress < 1) requestAnimationFrame(update);
    };
    requestAnimationFrame(update);
  }

  // Alertes dynamiques
  function updateDashboardAlerts() {
    const alertsList = document.getElementById("alertsList");
    if (!alertsList) return;

    const alerts = [];

    // Risques critiques
    const criticalRisks = (appData.risks || []).filter(r => r.level === "Critique");
    if (criticalRisks.length > 0) {
      alerts.push({
        icon: 'critical',
        iconClass: 'fas fa-skull-crossbones',
        text: `${criticalRisks.length} ${t('dashboard.alertCriticalRisks')}`,
        badge: t('dashboard.badgeUrgent'),
        target: 'risks'
      });
    }

    // Actions en retard
    const today = new Date().toISOString().split('T')[0];
    const overdueActions = (appData.actions || []).filter(a => {
      if (['termine', 'annule'].includes(a.status)) return false;
      return a.dueDate && a.dueDate < today;
    });
    if (overdueActions.length > 0) {
      alerts.push({
        icon: 'warning',
        iconClass: 'fas fa-clock',
        text: `${overdueActions.length} ${t('dashboard.alertOverdueActions')}`,
        badge: t('dashboard.badgeOverdue'),
        target: 'actions'
      });
    }

    // Audits à venir (30 jours)
    const in30Days = new Date();
    in30Days.setDate(in30Days.getDate() + 30);
    const upcomingAudits = (appData.audits || []).filter(a => {
      if (!a.plannedDate) return false;
      const auditDate = new Date(a.plannedDate);
      return auditDate >= new Date() && auditDate <= in30Days;
    });
    if (upcomingAudits.length > 0) {
      alerts.push({
        icon: 'info',
        iconClass: 'fas fa-calendar-check',
        text: `${upcomingAudits.length} ${t('dashboard.alertUpcomingAudits')}`,
        badge: t('dashboard.badgePlanned'),
        target: 'audits'
      });
    }

    // Projets à risque (status red)
    const projectsAtRisk = (appData.projectRisks || []).filter(p => p.status === 'red');
    if (projectsAtRisk.length > 0) {
      alerts.push({
        icon: 'purple',
        iconClass: 'fas fa-diagram-project',
        text: `${projectsAtRisk.length} ${t('dashboard.alertProjectsAtRisk')}`,
        badge: t('dashboard.badgeProjects'),
        target: 'project-risks'
      });
    }

    // Rendu des alertes
    if (alerts.length === 0) {
      alertsList.innerHTML = `<div class="dashboard-alerts__empty"><i class="fas fa-check-circle"></i>&nbsp;${t('dashboard.noAlerts')}</div>`;
    } else {
      alertsList.innerHTML = alerts.map(a => `
        <div class="alert-item" data-nav-target="${a.target}" role="button" tabindex="0">
          <div class="alert-item__icon alert-item__icon--${a.icon}">
            <i class="${a.iconClass}"></i>
          </div>
          <span class="alert-item__text">${escapeHtml(a.text)}</span>
          <span class="alert-item__badge">${escapeHtml(a.badge)}</span>
        </div>
      `).join('');

      // Event listeners pour navigation
      alertsList.querySelectorAll('.alert-item[data-nav-target]').forEach(item => {
        item.addEventListener('click', () => {
          const target = item.getAttribute('data-nav-target');
          // Utiliser la même logique que setupKpiCardNavigation
          if (_csiDashboardConsolidatedActive() && _csiIsSectionConsolidable(target)) {
            _csiOpenConsolidatedSection(target);
            return;
          }
          if (typeof switchModule === "function") {
            switchModule(target);
          } else {
            const navBtn = document.querySelector(`.nav__item[data-tab="${target}"]`);
            if (navBtn) navBtn.click();
            else if (typeof loadModuleContent === "function") loadModuleContent(target);
          }
        });
        item.addEventListener('keypress', (e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            item.click();
          }
        });
      });
    }
  }

  // Sparklines mini-graphiques
  function updateSparklines() {
    // Historique des métriques (stocké dans appData)
    if (!appData.kpiHistory) {
      appData.kpiHistory = {
        conformity: [],
        controls: [],
        actions: [],
        risks: [],
        nc: []
      };
    }

    // Récupérer les valeurs actuelles via FrameworkMapper when available
    var _FM = window.CSI_FrameworkMapper;
    var _sparkIsoStats = _FM ? _FM.getConformityStats(_getActiveControlsFrameworkId() || 'iso27002', { soaFilter: true }) : null;
    var implementedCount, conformityRate;
    if (_sparkIsoStats) {
      implementedCount = _sparkIsoStats.implemented;
      conformityRate = _sparkIsoStats.rate;
    } else {
      const soaEntries = Array.isArray(appData.soa) ? appData.soa : [];
      const soaMap = new Map(soaEntries.map((entry) => [entry.controlId, entry]));
      const applicableControls = (appData.controls || []).filter((c) => {
        const entry = soaMap.get(c.id);
        if (!entry) return true;
        return entry.applicable !== false;
      });
      implementedCount = applicableControls.filter((c) => ["implemente", "verifie"].includes(c.status)).length;
      conformityRate = applicableControls.length > 0 ? Math.round(implementedCount / applicableControls.length * 100) : 0;
    }
    const activeActions = (appData.actions || []).filter((a) => !["termine", "annule"].includes(a.status)).length;
    const criticalRisks = (appData.risks || []).filter((r) => r.level === "Critique").length;
    const openNCs = (Array.isArray(appData.nonconformities) ? appData.nonconformities : []).filter(function(nc) { return nc.status !== 'cloture' && nc.status !== 'closed' && nc.status !== 'fermee'; }).length;

    // Ajouter au historique (max 6 points)
    const addToHistory = (arr, val) => {
      if (!Array.isArray(arr)) return;
      arr.push(val);
      if (arr.length > 6) arr.shift();
    };

    // Ne mettre à jour l'historique qu'une fois par session (éviter les doublons).
    // (audit global 2026-07-03) PAS en mode lecture seule : un simple consulteur (viewer, ou
    // co-éditeur en consultation) ne doit ni muter kpiHistory persisté ni armer un push serveur
    // (qui échouerait en 403 et créerait une incohérence RAM/serveur) sur un simple affichage.
    const lastUpdate = appData.kpiHistory._lastUpdate || 0;
    const now = Date.now();
    var _kpiReadOnly = typeof isReadOnlyMode === 'function' && isReadOnlyMode();
    if (!_kpiReadOnly && now - lastUpdate > 60000) { // 1 minute minimum entre updates
      addToHistory(appData.kpiHistory.conformity, conformityRate);
      addToHistory(appData.kpiHistory.controls, implementedCount);
      addToHistory(appData.kpiHistory.actions, activeActions);
      addToHistory(appData.kpiHistory.risks, criticalRisks);
      addToHistory(appData.kpiHistory.nc, openNCs);
      appData.kpiHistory._lastUpdate = now;
      // FIX-GLOBALMETA-SYNC (audit 2026-06-10) : pousser l'historique KPI au serveur
      window._csiGlobalMetaDirty = true;
    }

    // Dessiner les sparklines
    drawSparkline('sparkConformity', appData.kpiHistory.conformity, '#0f9b7a');
    drawSparkline('sparkControls', appData.kpiHistory.controls, '#22c55e');
    drawSparkline('sparkActions', appData.kpiHistory.actions, '#f59e0b');
    drawSparkline('sparkRisks', appData.kpiHistory.risks, '#ef4444', true);
    drawSparkline('sparkNC', appData.kpiHistory.nc, '#2563eb', true);
  }

  function drawSparkline(canvasId, data, color, invertTrend = false) {
    const canvas = document.getElementById(canvasId);
    // FIX-KPIHISTORY: guard defensif — data peut être undefined si kpiHistory.X manque
    // (ex: server-data.json généré à la main sans sous-clés conformity/controls/etc.)
    if (!canvas || !Array.isArray(data) || data.length < 2) return;

    const ctx = canvas.getContext('2d');
    const width = canvas.width;
    const height = canvas.height;
    const padding = 2;

    ctx.clearRect(0, 0, width, height);

    const min = Math.min(...data);
    const max = Math.max(...data);
    const range = max - min || 1;

    const points = data.map((val, i) => ({
      x: padding + (i / (data.length - 1)) * (width - padding * 2),
      y: height - padding - ((val - min) / range) * (height - padding * 2)
    }));

    // Ligne
    ctx.beginPath();
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.5;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    points.forEach((p, i) => {
      if (i === 0) ctx.moveTo(p.x, p.y);
      else ctx.lineTo(p.x, p.y);
    });
    ctx.stroke();

    // Point final
    const last = points[points.length - 1];
    ctx.beginPath();
    ctx.fillStyle = color;
    ctx.arc(last.x, last.y, 2.5, 0, Math.PI * 2);
    ctx.fill();
  }
  function updateControlsLabel() {
    var navLabel = document.getElementById("controlsNavLabel");
    if (!navLabel) return;
    var ctrlFw = _getActiveControlsFramework();
    if (ctrlFw && ctrlFw.meta) {
      var isEn = (typeof currentLang !== 'undefined' && currentLang === 'en') || (document.documentElement.lang === 'en');
      navLabel.textContent = (isEn ? ctrlFw.meta.shortName || ctrlFw.meta.name : (ctrlFw.meta.nameFr || ctrlFw.meta.shortName || ctrlFw.meta.name));
      navLabel.removeAttribute('data-i18n');
      // Update page title and subtitle to match active framework
      // Remove data-i18n to prevent translatePage() from overwriting dynamic values
      var titleEl = document.querySelector('[data-i18n="controlsISO.title"]');
      var subtitleEl = document.querySelector('[data-i18n="controlsISO.subtitle"]');
      if (titleEl) {
        titleEl.textContent = isEn ? (ctrlFw.meta.name || 'Controls') : (ctrlFw.meta.nameFr || ctrlFw.meta.name);
        titleEl.removeAttribute('data-i18n');
      }
      if (subtitleEl) {
        var desc = isEn ? (ctrlFw.meta.description || '') : (ctrlFw.meta.descriptionFr || ctrlFw.meta.description || '');
        subtitleEl.textContent = desc;
        subtitleEl.removeAttribute('data-i18n');
      }
    } else {
      navLabel.textContent = t('nav.controlsISO27002', 'Contrôles ISO27002');
    }
  }
  function updateCharts() {
    if (typeof Chart === "undefined") {
      void 0;
      showToast(t('toasts.chartLibUnavailable', 'Erreur : bibliothèque de graphiques non disponible'), "error");
      return;
    }
    updateCapabilityRadarChart();
    if (window.CSI_FrameworkMapper && (CSI_FrameworkMapper.isEnabled('nis2') || CSI_FrameworkMapper.isEnabled('recyf2025'))) {
      charts.nis2Radar = updateNis2RadarChart();
    }
    updateActionsChart();
    updateRisksChart();
    updateCmmChart();
    updateRiskMatrix();
    // Nouveaux graphiques compacts
    updateActionsStatusChart();
    updateControlsDomainChart();
    updateProjectRisksChart();
  }

  // A11y: make chart canvas accessible with sr-only description
  function makeChartAccessible(canvas, chartTitle, dataDescription) {
    if (!canvas) return;
    canvas.setAttribute('aria-hidden', 'true');
    var descId = canvas.id + '-desc';
    var desc = document.getElementById(descId);
    if (!desc) {
      desc = document.createElement('div');
      desc.id = descId;
      desc.className = 'sr-only';
      desc.setAttribute('role', 'img');
      canvas.parentNode.insertBefore(desc, canvas.nextSibling);
    }
    desc.setAttribute('aria-label', chartTitle + ': ' + dataDescription);
  }

  // Graphique Statut des Actions (Doughnut)
  function updateActionsStatusChart() {
    const ctx = document.getElementById("actionsStatusChart");
    if (!ctx) return;
    if (charts.actionsStatus) charts.actionsStatus.destroy();
    const themeVars = getThemeVars();

    const statusCounts = {
      'planifie': 0,
      'en_cours': 0,
      'termine': 0,
      'annule': 0
    };

    (appData.actions || []).forEach(a => {
      if (statusCounts.hasOwnProperty(a.status)) {
        statusCounts[a.status]++;
      }
    });

    const data = [statusCounts.planifie, statusCounts.en_cours, statusCounts.termine, statusCounts.annule];
    const total = data.reduce((a, b) => a + b, 0);

    if (total === 0) {
      ctx.style.display = 'none';
      var emptyMsg = ctx.parentElement.querySelector('.chart-empty-msg');
      if (!emptyMsg) {
        emptyMsg = document.createElement('div');
        emptyMsg.className = 'chart-empty-msg';
        emptyMsg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--color-text-light);font-size:0.875rem;';
        ctx.parentElement.appendChild(emptyMsg);
      }
      emptyMsg.textContent = t('dashboard.noActions', 'Aucune action');
      emptyMsg.style.display = 'flex';
      return;
    }
    ctx.style.display = '';
    var existingMsg = ctx.parentElement.querySelector('.chart-empty-msg');
    if (existingMsg) existingMsg.style.display = 'none';

    const colors = [
      withAlpha(themeVars.warning, 0.8),
      withAlpha(themeVars.info, 0.8),
      withAlpha(themeVars.success, 0.8),
      withAlpha('#9ca3af', 0.6)
    ];
    const labels = [t('actionStatuses.planifie', 'Planifié'), t('actionStatuses.enCours', 'En cours'), t('actionStatuses.termine', 'Terminé'), t('actionStatuses.annule', 'Annulé')];

    charts.actionsStatus = new Chart(ctx, {
      type: "doughnut",
      data: {
        labels: labels,
        datasets: [{
          data: data,
          backgroundColor: colors,
          borderWidth: 0
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        cutout: '60%',
        plugins: {
          legend: { display: false },
          tooltip: {
            callbacks: {
              label: (ctx) => `${ctx.label}: ${ctx.raw} (${Math.round(ctx.raw / total * 100)}%)`
            }
          }
        }
      }
    });

    // Mini-légende
    renderMiniLegend('actionsStatusLegend', labels, colors, data);
    makeChartAccessible(ctx, t('dashboard.actionsStatus', 'Statut actions'), labels.map(function(l, i) { return l + ': ' + data[i]; }).join(', '));
  }

  // Graphique Contrôles par Domaine (Bar horizontal)
  function updateControlsDomainChart() {
    const ctx = document.getElementById("controlsDomainChart");
    if (!ctx) return;
    if (charts.controlsDomain) charts.controlsDomain.destroy();
    const themeVars = getThemeVars();

    const domains = ['Organisationnel', 'Humain', 'Physique', 'Technologique'];
    // Apply SoA applicability filter to exclude non-applicable controls
    const soaMap = new Map((appData.soa || []).map(function(e) { return [e.controlId, e]; }));
    const domainData = domains.map(domain => {
      var _isoCtrlsDomain = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
      const controls = _isoCtrlsDomain.filter(c => {
        if (c.category !== domain) return false;
        var soaEntry = soaMap.get(c.id);
        if (soaEntry && soaEntry.applicable === false) return false;
        return true;
      });
      const implemented = controls.filter(c => ['implemente', 'verifie'].includes(c.status)).length;
      return {
        domain: domain.substring(0, 4) + '.',
        total: controls.length,
        implemented: implemented
      };
    });

    charts.controlsDomain = new Chart(ctx, {
      type: "bar",
      data: {
        labels: domainData.map(d => d.domain),
        datasets: [
          {
            label: t('dashboard.chartImplemented'),
            data: domainData.map(d => d.implemented),
            backgroundColor: withAlpha(themeVars.success, 0.8),
            borderRadius: 4
          },
          {
            label: 'Total',
            data: domainData.map(d => d.total - d.implemented),
            backgroundColor: withAlpha(themeVars.secondary, 0.3),
            borderRadius: 4
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        indexAxis: 'y',
        scales: {
          x: {
            stacked: true,
            display: false
          },
          y: {
            stacked: true,
            grid: { display: false },
            ticks: { color: themeVars.textColor, font: { size: 10 } }
          }
        },
        plugins: {
          legend: { display: false },
          tooltip: {
            callbacks: {
              title: (items) => domains[items[0].dataIndex],
              label: (ctx) => ctx.datasetIndex === 0
                ? `${t('dashboard.chartImplemented')}: ${ctx.raw}`
                : `${t('dashboard.chartRemaining')}: ${ctx.raw}`
            }
          }
        }
      }
    });

    // Mini-légende
    const totalImpl = domainData.reduce((a, d) => a + d.implemented, 0);
    const totalAll = domainData.reduce((a, d) => a + d.total, 0);
    renderMiniLegend('controlsDomainLegend',
      [t('dashboard.chartImplemented'), t('dashboard.chartRemaining')],
      [withAlpha(themeVars.success, 0.8), withAlpha(themeVars.secondary, 0.3)],
      [totalImpl, totalAll - totalImpl]
    );
    makeChartAccessible(ctx, t('dashboard.controlsByDomain', 'Contrôles par domaine'), domains.map(function(d, i) { return d + ': ' + domainData[i].implemented + '/' + domainData[i].total; }).join(', '));
  }

  // Graphique Projets Risques (Doughnut avec statuts)
  function updateProjectRisksChart() {
    const ctx = document.getElementById("projectRisksChart");
    if (!ctx) return;
    if (charts.projectRisks) charts.projectRisks.destroy();
    const themeVars = getThemeVars();

    const projectRisks = appData.projectRisks || [];
    const statusCounts = {
      'green': 0,
      'yellow': 0,
      'red': 0,
      'todo': 0,
      'suspended': 0
    };

    projectRisks.forEach(p => {
      if (statusCounts.hasOwnProperty(p.status)) {
        statusCounts[p.status]++;
      }
    });

    const data = [statusCounts.green, statusCounts.yellow, statusCounts.red, statusCounts.todo, statusCounts.suspended];
    const labels = [t('projectRiskStatuses.green', 'Vert'), t('statuses.inProgress', 'En cours'), t('projectRiskStatuses.red', 'Rouge'), t('statuses.todo', 'À faire'), t('statuses.suspended', 'Suspendu')];
    const colors = [
      withAlpha(themeVars.success, 0.85),
      withAlpha(themeVars.warning, 0.85),
      withAlpha(themeVars.danger, 0.85),
      withAlpha('#9e9e9e', 0.85),
      withAlpha('#ff9800', 0.85)
    ];
    const total = data.reduce((a, b) => a + b, 0);

    if (total === 0) {
      ctx.style.display = 'none';
      var emptyMsg2 = ctx.parentElement.querySelector('.chart-empty-msg');
      if (!emptyMsg2) {
        emptyMsg2 = document.createElement('div');
        emptyMsg2.className = 'chart-empty-msg';
        emptyMsg2.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--color-text-light);font-size:0.875rem;';
        ctx.parentElement.appendChild(emptyMsg2);
      }
      emptyMsg2.textContent = t('dashboard.noProjects', 'Aucun projet');
      emptyMsg2.style.display = 'flex';
      const legend = document.getElementById('projectRisksLegend');
      if (legend) legend.innerHTML = '';
      return;
    }
    ctx.style.display = '';
    var existingMsg2 = ctx.parentElement.querySelector('.chart-empty-msg');
    if (existingMsg2) existingMsg2.style.display = 'none';

    charts.projectRisks = new Chart(ctx, {
      type: "doughnut",
      data: {
        labels: labels,
        datasets: [{
          data: data,
          backgroundColor: colors,
          borderWidth: 0
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        cutout: '55%',
        plugins: {
          legend: { display: false },
          tooltip: {
            callbacks: {
              label: (ctx) => `${ctx.label}: ${ctx.raw} projet(s)`
            }
          }
        }
      }
    });

    // Mini-légende
    renderMiniLegend('projectRisksLegend', labels, colors, data);
    makeChartAccessible(ctx, t('dashboard.projectRisks', 'Risques projets'), labels.map(function(l, i) { return l + ': ' + data[i]; }).join(', '));
  }

  // Fonction helper pour générer les mini-légendes
  function renderMiniLegend(containerId, labels, colors, values) {
    const container = document.getElementById(containerId);
    if (!container) return;

    container.innerHTML = labels.map((label, i) => {
      if (values[i] === 0) return '';
      return `
        <span class="chart-mini-legend__item">
          <span class="chart-mini-legend__dot" style="background:${colors[i]}"></span>
          <span>${escapeHtml(label)}</span>
          <span class="chart-mini-legend__value">${values[i]}</span>
        </span>
      `;
    }).join('');
  }

  function getStatusLabel(status) {
    const labels = {
      'planifie': t('actionStatuses.planifie', 'Planifié'),
      'en_cours': t('actionStatuses.enCours', 'En cours'),
      'termine': t('actionStatuses.termine', 'Terminé'),
      'annule': t('actionStatuses.annule', 'Annulé')
    };
    return labels[status] || status || '';
  }

  function updateConformityChart() {
    const ctx = document.getElementById("conformityChart");
    if (!ctx) return;
    if (charts.conformity) charts.conformity.destroy();
    const themeVars = getThemeVars();
    const soaEntries = Array.isArray(appData.soa) ? appData.soa : [];
    const soaMapChart = new Map(soaEntries.map(e => [e.controlId, e]));
    const applicableControls = (appData.controls || []).filter(c => {
      const soaEntry = soaMapChart.get(c.id);
      return !soaEntry || soaEntry.applicable !== false;
    });
    const categories = [...new Set(applicableControls.map((c) => c.category))].filter(Boolean);
    if (categories.length === 0) {
      ctx.parentElement && (ctx.parentElement.dataset.empty = 'true');
      return;
    }
    const data = categories.map((category) => {
      const filtered = applicableControls.filter((c) => c.category === category);
      const implemented = filtered.filter((c) => ["implemente", "verifie"].includes(c.status)).length;
      return filtered.length > 0 ? Math.round(implemented / filtered.length * 100) : 0;
    });
    const counts = categories.map((cat) => applicableControls.filter((c) => c.category === cat).length);
    const labels = categories.map((cat, i) => `${cat} (${counts[i]})`);
    const palette = [themeVars.primary, themeVars.secondary, themeVars.warning, themeVars.success, themeVars.info];
    const colors = categories.map((_, i) => withAlpha(palette[i % palette.length], 0.75));
    charts.conformity = new Chart(ctx, {
      type: "doughnut",
      data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 1, borderColor: themeVars.surface }] },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: {
            position: "bottom",
            labels: { font: { size: 12 }, padding: 20 }
          },
          tooltip: {
            callbacks: {
              label: (context) => `${context.label}: ${context.raw}% de conformit\xE9`
            }
          }
        },
        cutout: "60%"
      }
    });
    makeChartAccessible(ctx, t('dashboard.conformityByCategory', 'Conformité par catégorie'), labels.map(function(l, i) { return l + ': ' + data[i] + '%'; }).join(', '));
  }
  function updateActionsChart() {
    const ctx = document.getElementById("actionsChart");
    if (!ctx) return;
    if (charts.actions) charts.actions.destroy();
    const themeVars = getThemeVars();
    const gridColor = withAlpha(themeVars.borderColor, 0.6);
    const priorities = ["critical", "high", "medium", "low"];
    const priorityLabels = [t('priorities.critical', 'Critique'), t('priorities.high', 'Haute'), t('priorities.medium', 'Moyenne'), t('priorities.low', 'Faible')];
    const currentData = priorities.map((p) => (appData.actions || []).filter((a) => a.priority === p).length);
    var months = [];
    var now = new Date();
    for (var i = 5; i >= 0; i--) {
      var d = new Date(now.getFullYear(), now.getMonth() - i, 1);
      months.push(d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' }));
    }
    if (!appData.actionsHistory) appData.actionsHistory = {};
    // FIX-ACTIONS-CHART-SAMPLING (audit 2026-06-10) : l'axe X est étiqueté par MOIS mais
    // l'historique était échantillonné par JOUR (valeurs d'hier sous le mois d'il y a 5
    // mois). Harmonisation : un seul instantané par mois calendaire (comparaison sur
    // AAAA-MM), aligné sur les libellés des 6 derniers mois.
    var _ahLastKey = '_actionsHistoryLastSnapshot';
    var _ahToday = new Date().toISOString().slice(0, 7);
    var _ahLast = (appData.actionsHistory[_ahLastKey] || '').slice(0, 7);
    if (_ahLast !== _ahToday) {
      priorities.forEach((priority, index) => {
        if (!Array.isArray(appData.actionsHistory[priority])) appData.actionsHistory[priority] = [];
        appData.actionsHistory[priority].push(currentData[index]);
        if (appData.actionsHistory[priority].length > months.length) {
          appData.actionsHistory[priority].shift();
        }
      });
      appData.actionsHistory[_ahLastKey] = _ahToday;
    } else {
      priorities.forEach((priority, index) => {
        if (!Array.isArray(appData.actionsHistory[priority])) appData.actionsHistory[priority] = [];
        if (appData.actionsHistory[priority].length === 0) {
          appData.actionsHistory[priority].push(currentData[index]);
        } else {
          appData.actionsHistory[priority][appData.actionsHistory[priority].length - 1] = currentData[index];
        }
      });
    }
    // FIX-ACTIONS-CHART-EMPTY : l'historique s'accumule un instantané par mois
    // calendaire au fil des ouvertures réelles du dashboard. Tant qu'il n'existe pas au
    // moins 2 mois de données, une courbe à point unique donne une impression de bug.
    // On affiche alors un état vide explicite plutôt qu'un graphique trompeur.
    const _content = (ctx.closest && ctx.closest('.chart-card__content')) || ctx.parentElement;
    if (_content) {
      const _prevNote = _content.querySelector('.chart-empty-note');
      if (_prevNote) _prevNote.remove();
    }
    const _storedMonths = Math.max.apply(null, [0].concat(priorities.map(p => (appData.actionsHistory[p] || []).length)));
    if (_storedMonths < 2) {
      ctx.style.display = 'none';
      if (_content) {
        const _note = document.createElement('div');
        _note.className = 'chart-empty-note';
        _note.textContent = t('charts.actionsHistoryBuilding', "L'historique se construit au fil des mois. La courbe d'évolution apparaîtra dès qu'au moins deux mois de données auront été enregistrés.");
        _content.appendChild(_note);
      }
      makeChartAccessible(ctx, t('dashboard.actionsByPriority', 'Actions par priorité'), priorityLabels.map(function(l, i) { return l + ': ' + currentData[i]; }).join(', '));
      return;
    }
    ctx.style.display = '';
    const datasets = priorities.map((priority, index) => {
      const history = appData.actionsHistory[priority] || [];
      const colors = {
        critical: withAlpha(themeVars.danger, 0.75),
        high: withAlpha(themeVars.warning, 0.75),
        medium: withAlpha(themeVars.secondary, 0.75),
        low: withAlpha(themeVars.success, 0.75)
      };
      // FIX-ACTIONS-CHART-SAMPLING (audit 2026-06-10) : caler les valeurs à droite
      // (la plus récente sous le mois courant) en complétant à gauche par null quand
      // l'historique compte moins de 6 mois.
      const _histSlice = history.slice(-months.length);
      const _histPadded = new Array(months.length - _histSlice.length).fill(null).concat(_histSlice);
      return {
        label: priorityLabels[index],
        data: _histPadded,
        backgroundColor: colors[priority],
        borderColor: withAlpha(colors[priority], 1),
        borderWidth: 2,
        tension: 0.3
      };
    });
    charts.actions = new Chart(ctx, {
      type: "line",
      data: { labels: months, datasets },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: { position: "bottom" },
          tooltip: { mode: "index", intersect: false }
        },
        scales: {
          y: {
            beginAtZero: true,
            ticks: { precision: 0, color: themeVars.textColor },
            grid: { color: gridColor },
            title: { display: true, text: t('charts.actionCount', "Nombre d'actions"), color: themeVars.textColor }
          },
          x: {
            ticks: { color: themeVars.textColor },
            grid: { color: gridColor },
            title: { display: true, text: t('charts.months', 'Mois'), color: themeVars.textColor }
          }
        }
      }
    });
    makeChartAccessible(ctx, t('dashboard.actionsByPriority', 'Actions par priorité'), priorityLabels.map(function(l, i) { return l + ': ' + currentData[i]; }).join(', '));
  }
  function updateRisksChart() {
    const ctx = document.getElementById("riskChart");
    if (!ctx) return;
    if (charts.risks) charts.risks.destroy();
    const themeVars = getThemeVars();
    const levels = ["Critique", "Grave", "Significatif", "Mineur", "Insignifiant"];
    const data = levels.map((level) => (appData.risks || []).filter((r) => r.level === level).length);
    const colors = [
      withAlpha(themeVars.danger, 0.75),
      withAlpha(themeVars.warning, 0.75),
      withAlpha(themeVars.primary, 0.7),
      withAlpha(themeVars.success, 0.7),
      withAlpha(themeVars.info, 0.7)
    ];
    charts.risks = new Chart(ctx, {
      type: "pie",
      data: { labels: levels, datasets: [{ data, backgroundColor: colors, borderWidth: 1, borderColor: themeVars.surface }] },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: { position: "bottom" },
          tooltip: {
            callbacks: {
              label: (context) => `${context.label}: ${context.raw} risque(s)`
            }
          }
        }
      }
    });
    makeChartAccessible(ctx, t('dashboard.risksByLevel', 'Risques par niveau'), levels.map(function(l, i) { return l + ': ' + data[i]; }).join(', '));
  }

  function updateCmmChart() {
    const ctx = document.getElementById("cmmChart");
    if (!ctx) return;
    if (charts.cmm) charts.cmm.destroy();
    const themeVars = getThemeVars();
    const levels = ["Initial", "R\xE9p\xE9table", "D\xE9fini", "G\xE9r\xE9", "Optimis\xE9"];
    var applicableSoa = (appData.soa || []).filter(function(s) { return s.applicable !== false; });
    var applicableIds = new Set(applicableSoa.map(function(s) { return s.controlId; }));
    var _isoCtrlsCmm = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    var applicableControls = _isoCtrlsCmm.filter(function(c) {
      return applicableIds.size === 0 || applicableIds.has(c.id);
    });
    const data = levels.map((lvl) => applicableControls.filter((c) => c.cmm === lvl).length);
    const colors = [
      withAlpha(themeVars.primary, 0.7),
      withAlpha(themeVars.success, 0.7),
      withAlpha(themeVars.warning, 0.7),
      withAlpha(themeVars.secondary, 0.7),
      withAlpha(themeVars.danger, 0.7)
    ];
    charts.cmm = new Chart(ctx, {
      type: "pie",
      data: { labels: levels, datasets: [{ data, backgroundColor: colors, borderWidth: 1, borderColor: themeVars.surface }] },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: { legend: { position: "bottom" } }
      }
    });
    makeChartAccessible(ctx, t('dashboard.cmmLevels', 'Niveaux de maturité'), levels.map(function(l, i) { return l + ': ' + data[i]; }).join(', '));
  }

  function updateCapabilityRadarChart() {
    const ctx = document.getElementById("capabilityRadarChart");
    if (!ctx) return;
    if (charts.capabilityRadar) charts.capabilityRadar.destroy();
    const themeVars = typeof getThemeVars === "function" ? getThemeVars() : (() => {
      const target = document.body || document.documentElement;
      const styles = getComputedStyle(target);
      return {
        textColor: styles.getPropertyValue("--color-text").trim() || "#2d3e4a",
        textSecondary: styles.getPropertyValue("--color-text-secondary").trim() || "#758592",
        borderColor: styles.getPropertyValue("--color-border").trim() || "#e9ecef",
        primary: styles.getPropertyValue("--color-primary").trim() || "#1fb8cd",
        warning: styles.getPropertyValue("--color-warning").trim() || "#ffc185"
      };
    })();
    const { textColor, textSecondary, borderColor, primary, warning } = themeVars;
    const gridColor = withAlpha(borderColor, 0.7);
    const capabilities = OPERATIONAL_CAPABILITIES;
    const cmmValues = {
      Initial: 1,
      "R\xE9p\xE9table": 2,
      "D\xE9fini": 3,
      "G\xE9r\xE9": 4,
      "Optimis\xE9": 5
    };
    var radarApplicableSoa = (appData.soa || []).filter(function(s) { return s.applicable !== false; });
    var radarApplicableIds = new Set(radarApplicableSoa.map(function(s) { return s.controlId; }));
    var _isoCtrlsRadar = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const soaFilteredControls = _isoCtrlsRadar.filter(function(c) {
      return radarApplicableIds.size === 0 || radarApplicableIds.has(c.id);
    });
    const validControls = soaFilteredControls.filter(c =>
      Array.isArray(c.subcategory) ? c.subcategory.length > 0 : c.subcategory
    );
    const missingCount = soaFilteredControls.length - validControls.length;
    if (missingCount > 0) {
      void 0;
    }
    // Légende détaillée (numérotation assurée par <ol>)
    const renderLegend = typeof renderRadarLegend === "function" ? renderRadarLegend : () => {};
    renderLegend("capabilityRadarLegend", capabilities.map((cap) => cap.label));
    const data = capabilities.map((cap) => {
      const controls = validControls.filter((c) =>
        Array.isArray(c.subcategory) ? c.subcategory.includes(cap.key) : c.subcategory === cap.key
      );
      if (controls.length === 0) return 0;
      const sum = controls.reduce(
        (acc, c) => acc + (cmmValues[c.cmm] || 0),
        0
      );
      return +(sum / controls.length).toFixed(2);
    });
    const targetValue = Number(appData.targetMaturity?.iso27002) || 0;
    const targetData = capabilities.map(() => targetValue);
    charts.capabilityRadar = new Chart(ctx, {
      type: "radar",
      data: {
        labels: capabilities.map((cap) => cap.label),
        datasets: [
          {
            label: t('chartLabels.target'),
            data: targetData,
            backgroundColor: withAlpha(warning, 0.08),
            borderColor: withAlpha(warning, 0.55),
            borderWidth: 3,
            pointRadius: 2,
            borderDash: [4, 4],
            fill: true
          },
          {
            label: t('chartLabels.averageCMM'),
            data,
            backgroundColor: withAlpha(primary, 0.2),
            borderColor: withAlpha(primary, 1),
            borderWidth: 3,
            pointBackgroundColor: withAlpha(primary, 1),
            pointRadius: 3,
            pointHoverRadius: 5
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        scales: {
          r: {
            beginAtZero: true,
            suggestedMin: 0,
            suggestedMax: 5,
            ticks: {
              stepSize: 1,
              color: textColor,
              backdropColor: "transparent",
              font: { size: 12 }
            },
            grid: { color: gridColor },
            angleLines: { color: gridColor },
            pointLabels: {
              display: true,
              color: textSecondary || borderColor,
              font: { size: 11, weight: "400" },
              callback: (_label, index) => `${index + 1}`
            }
          }
        },
        plugins: { legend: { display: false } }
      }
    });
  }

  function updateNis2RadarChart() {
    const ctx = document.getElementById("nis2RadarChart");
    if (!ctx) return null;
    if (charts.nis2Radar) charts.nis2Radar.destroy();
    const themeVars = typeof getThemeVars === "function" ? getThemeVars() : (() => {
      const target = document.body || document.documentElement;
      const styles = getComputedStyle(target);
      return {
        textColor: styles.getPropertyValue("--color-text").trim() || "#2d3e4a",
        textSecondary: styles.getPropertyValue("--color-text-secondary").trim() || "#758592",
        borderColor: styles.getPropertyValue("--color-border").trim() || "#e9ecef",
        secondary: styles.getPropertyValue("--color-secondary").trim() || "#5d878f",
        warning: styles.getPropertyValue("--color-warning").trim() || "#ffc185"
      };
    })();
    const { textColor, textSecondary, borderColor, secondary, warning } = themeVars;
    const gridColor = withAlpha(borderColor, 0.7);
    // Radar référentiel-conscient : ReCyF (France) = conformité par objectif (0-100 %) ;
    // CyFun (Belgique) = maturité par catégorie (0-5). CyFun reste inchangé ci-dessous.
    var _radarCard = ctx.closest ? ctx.closest('.chart-card') : null;
    var _radarTitle = _radarCard ? _radarCard.querySelector('.chart-card__title') : null;
    var _isRecyfRadar = (typeof appData !== 'undefined' && appData.nis2Referential === 'recyf2025');
    if (_radarTitle) {
      if (_isRecyfRadar) {
        // Retire data-i18n pour que translatePage() ne réécrase pas le titre ReCyF.
        _radarTitle.removeAttribute('data-i18n');
        _radarTitle.textContent = t('recyfControls.radarTitle', 'Conformité ReCyF par objectif');
      } else {
        _radarTitle.setAttribute('data-i18n', 'nis2Program.maturity');
        _radarTitle.textContent = t('nis2Program.maturity', 'Maturité NIS2');
      }
    }
    if (_isRecyfRadar) return _renderRecyfObjectiveRadar(ctx, themeVars);
    const categories = CYFUN_CATEGORIES.length > 0 ? CYFUN_CATEGORIES : Object.keys(appData.targetMaturity?.nis2 || {});
    if (categories.length === 0) return null;

    const labelsFull = categories.map((k) => {
      const full = CATEGORY_LABELS[k] || k;
      return full.split(" (")[0].trim();
    });
    const renderLegend = typeof renderRadarLegend === "function" ? renderRadarLegend : () => {};
    renderLegend("nis2RadarLegend", labelsFull);

    // Compute per-category doc and impl averages from controls via FrameworkMapper
    var FM = window.CSI_FrameworkMapper;
    var catMaturity = FM ? FM.getCategoryMaturity('cyfun2025') : null;
    const docData = [];
    const implData = [];
    if (catMaturity) {
      categories.forEach(function(catId) {
        var cm = catMaturity[catId];
        docData.push(cm ? cm.docAvg : 0);
        implData.push(cm ? cm.implAvg : 0);
      });
    } else {
      // Fallback: manual computation
      var nis2Controls = appData.nis2Controls || [];
      categories.forEach(function(catId) {
        var controls = nis2Controls.filter(function(c) { return c.categoryId === catId; });
        var docSum = 0, implSum = 0, count = 0;
        controls.forEach(function(c) {
          var d = c.docMaturity || 0;
          var i = c.implMaturity || 0;
          if (d > 0 || i > 0) { docSum += d; implSum += i; count++; }
        });
        docData.push(count > 0 ? +(docSum / count).toFixed(2) : 0);
        implData.push(count > 0 ? +(implSum / count).toFixed(2) : 0);
      });
    }

    const globalTarget = Number(appData.targetMaturity?.nis2Target) || 0;
    const globalData = categories.map(() => globalTarget);
    const fontSize = categories.length > 20 ? 11 : 14;

    charts.nis2Radar = new Chart(ctx, {
      type: "radar",
      data: {
        labels: labelsFull,
        datasets: [
          {
            label: t('chartLabels.globalTarget', 'Cible globale'),
            data: globalData,
            backgroundColor: withAlpha(warning, 0.08),
            borderColor: withAlpha(warning, 0.55),
            borderWidth: 3,
            pointRadius: 2,
            borderDash: [4, 4],
            fill: true
          },
          {
            label: t('chartLabels.docMaturity', 'Documentation'),
            data: docData,
            backgroundColor: withAlpha('#3b82f6', 0.12),
            borderColor: withAlpha('#3b82f6', 0.85),
            borderWidth: 2,
            pointRadius: 3,
            pointBackgroundColor: withAlpha('#3b82f6', 1),
            fill: true
          },
          {
            label: t('chartLabels.implMaturity', 'Implémentation'),
            data: implData,
            backgroundColor: withAlpha(secondary, 0.18),
            borderColor: withAlpha(secondary, 1),
            borderWidth: 3,
            pointBackgroundColor: withAlpha(secondary, 1),
            pointRadius: 3,
            pointHoverRadius: 5,
            fill: true
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        scales: {
          r: {
            beginAtZero: true,
            suggestedMin: 0,
            suggestedMax: 5,
            ticks: {
              stepSize: 1,
              color: textColor,
              backdropColor: "transparent",
              font: { size: 12 }
            },
            grid: { color: gridColor },
            angleLines: { color: gridColor },
            pointLabels: {
              display: true,
              color: textSecondary || borderColor,
              font: { size: fontSize, weight: "400" },
              callback: (_label, index) => `${index + 1}`
            }
          }
        },
        plugins: { legend: { display: true, position: 'bottom', labels: { usePointStyle: true, padding: 12 } } }
      }
    });
    return charts.nis2Radar;
  }

  // Radar/araignée ReCyF : taux de conformité par objectif de sécurité (périmètre du degré courant).
  function _renderRecyfObjectiveRadar(ctx, themeVars) {
    if (typeof ensureRecyfControls === 'function') ensureRecyfControls();
    var byObj = (typeof computeRecyfConformityByObjective === 'function') ? computeRecyfConformityByObjective() : [];
    // GOV-NIS2 S3 (audit 2026-08-20, MAJEUR M5) : un objectif dont TOUTES les mesures du
    // périmètre sont « Non applicable » était tracé à 0 % — visuellement identique à une
    // non-conformité réelle (cas concret : obj. 15 en EI = 1 seule mesure). Il sort du
    // radar ; le module ReCyF continue, lui, de l'afficher avec son compte N/A.
    byObj = byObj.filter(function(b) { return (typeof b.applicable !== 'number') || b.applicable > 0; });
    var renderLegend = typeof renderRadarLegend === 'function' ? renderRadarLegend : function() {};
    if (byObj.length === 0) {
      renderLegend('nis2RadarLegend', []);
      return null;
    }
    var blue = '#0055A4';
    var gridColor = withAlpha(themeVars.borderColor, 0.7);
    var labels = byObj.map(function(b) { return b.objectiveId; });
    // M-RC3 : radar coverage-aware (percentApplicable), cohérent avec le KPI global du dashboard.
    var data = byObj.map(function(b) { return (typeof b.percentApplicable === 'number') ? b.percentApplicable : b.percent; });
    renderLegend('nis2RadarLegend', byObj.map(function(b) { return 'Obj. ' + b.objectiveId + ' — ' + (b.objectiveLabel || ''); }));
    var fontSize = byObj.length > 18 ? 11 : 13;
    charts.nis2Radar = new Chart(ctx, {
      type: 'radar',
      data: {
        labels: labels,
        datasets: [{
          label: t('recyfControls.conformity', 'Conformité') + ' (%)',
          data: data,
          backgroundColor: withAlpha(blue, 0.15),
          borderColor: withAlpha(blue, 0.9),
          borderWidth: 2,
          pointRadius: 3,
          pointBackgroundColor: withAlpha(blue, 1),
          pointHoverRadius: 5,
          fill: true
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        scales: {
          r: {
            beginAtZero: true,
            suggestedMin: 0,
            suggestedMax: 100,
            ticks: {
              stepSize: 20,
              color: themeVars.textColor,
              backdropColor: 'transparent',
              font: { size: 12 },
              callback: function(v) { return v + '%'; }
            },
            grid: { color: gridColor },
            angleLines: { color: gridColor },
            pointLabels: {
              display: true,
              color: themeVars.textSecondary || themeVars.borderColor,
              font: { size: fontSize, weight: '400' },
              callback: function(_label, index) { return byObj[index] ? byObj[index].objectiveId : (index + 1); }
            }
          }
        },
        plugins: {
          legend: { display: true, position: 'bottom', labels: { usePointStyle: true, padding: 12 } },
          tooltip: { callbacks: { label: function(c) {
            var b = byObj[c.dataIndex];
            if (!b) return c.formattedValue + '%';
            // GOV-NIS2 S3 (M5) : même métrique que le point tracé (coverage-aware) — l'infobulle
            // affichait percent (base évaluées) et contredisait le radar (2 évaluées à 100 %
            // → point à 12 %, bulle à 100 %).
            var _pa = (typeof b.percentApplicable === 'number') ? b.percentApplicable : b.percent;
            return 'Obj. ' + b.objectiveId + ' : ' + _pa + '% (' + b.evaluated + '/' + (b.total - b.na) + ')';
          } } }
        }
      }
    });
    return charts.nis2Radar;
  }
  // Correspondance niveau (calculateRiskLevel) -> classe CSS thématisée existante.
  const MATRIX_LEVEL_CLASS = { 'Critique': 'critique', 'Grave': 'grave', 'Significatif': 'significatif', 'Mineur': 'mineur', 'Insignifiant': 'insignifiant' };
  function updateRiskMatrix() {
    const matrix = document.getElementById("matrixGrid");
    if (!matrix) return;
    matrix.innerHTML = "";
    // FIX-DASHBOARD-BAREME (audit v2 #22, 2026-08-25) : cette matrice compte des risques
    // du REGISTRE — elle doit donc parler le barème du registre. Elle empruntait le seuil
    // du module Analyse de risques pour le COLORIAGE, pendant que le niveau, le KPI
    // « Risques critiques » et le camembert restaient sur calculateRiskLevel : avec le seuil
    // PAR DÉFAUT, 14 cellules sur 25 divergeaient (3×5 et 5×3 affichés « Très élevé » en
    // rouge alors que le score 15 vaut « Grave » → KPI critiques à 0 et part rouge vide).
    // Aucun seuil personnalisé n'était nécessaire pour produire les deux verdicts.
    // Deux effets de bord réparés au passage : les AXES juxtaposaient les libellés du seuil
    // et ceux du registre, et style.backgroundColor en dur court-circuitait les classes
    // thématisées (.matrix-cell--*) — la matrice ne suivait plus le thème.
    // Un coloriage paramétrable du registre passerait par un seuil DÉDIÉ au registre + une
    // légende : c'est un chantier, pas un correctif.
    // Single-pass: bucket risk counts by impact-probability key (O(N) instead of O(25*N))
    var riskCounts = {};
    var risks = appData.risks || [];
    for (var ri = 0; ri < risks.length; ri++) {
      var rk = (risks[ri].impact || 0) + '-' + (risks[ri].probability || 0);
      riskCounts[rk] = (riskCounts[rk] || 0) + 1;
    }
    for (let impact = 5; impact >= 1; impact--) {
      for (let prob = 1; prob <= 5; prob++) {
        const cell = document.createElement("div");
        cell.className = "matrix-cell";
        const score = impact * prob;
        // Résolveur UNIQUE, partagé avec le KPI et le camembert (le second barème
        // 20/15/10/5 dupliqué ici a disparu : il n'y a plus qu'une source de vérité).
        const niveau = calculateRiskLevel(score);
        cell.classList.add(`matrix-cell--${MATRIX_LEVEL_CLASS[niveau]}`);
        cell.title = `${IMPACT_LEVEL_LABELS[impact]} / ${PROBABILITY_LEVEL_LABELS[prob]} — ${translateRiskLevel(niveau)}`;
        const label = document.createElement("div");
        label.className = "matrix-cell__label";
        label.textContent = score;
        cell.appendChild(label);
        const count = riskCounts[impact + '-' + prob] || 0;
        if (count > 0) {
          const countElem = document.createElement("div");
          countElem.className = "matrix-cell__count";
          countElem.textContent = count;
          cell.appendChild(countElem);
        }
        matrix.appendChild(cell);
      }
    }
  }
  window.updateDashboard = updateDashboard;
  window.updateRiskMatrix = updateRiskMatrix;

  // modules/search.js
  function switchModule(moduleId) {
    const navBtn = document.querySelector(`.nav__item[data-tab="${moduleId}"]`);
    if (navBtn) {
      navBtn.click();
    } else {
      loadModuleContent(moduleId);
    }
  }
  // --- Deep Search Engine ---
  // Flatten any value (string, array, object) into a single searchable string
  function _flattenForSearch(val, depth) {
    if (depth === undefined) depth = 0;
    if (depth > 3 || val == null) return "";
    if (typeof val === "string") return val;
    if (typeof val === "number" || typeof val === "boolean") return String(val);
    if (Array.isArray(val)) return val.map(function(v) { return _flattenForSearch(v, depth + 1); }).join(" ");
    if (typeof val === "object") {
      var parts = [];
      for (var k in val) {
        if (val.hasOwnProperty(k) && k !== "id" && k !== "history") {
          parts.push(_flattenForSearch(val[k], depth + 1));
        }
      }
      return parts.join(" ");
    }
    return "";
  }

  function _pushEntry(entries, type, label, searchFields, module, action) {
    var searchParts = [];
    for (var i = 0; i < searchFields.length; i++) {
      var text = _flattenForSearch(searchFields[i]);
      if (text) searchParts.push(text);
    }
    var _searchText = searchParts.join(" ");
    entries.push({
      type: type,
      label: label || "",
      searchText: _searchText,
      // (audit global 2026-07-03, perf) formes normalisées NFD calculées UNE fois à la
      // construction de l'index (au lieu de NORMALIZE par frappe sur chaque enregistrement).
      nSearch: NORMALIZE(_searchText),
      nLabel: NORMALIZE(label || ""),
      module: module,
      action: action
    });
  }

  // Registry of known indexed appData keys (prevents double-indexing in auto-discovery)
  var _INDEXED_KEYS = {};

  // (audit global 2026-07-03, perf) l'index de recherche était RECONSTRUIT intégralement à
  // CHAQUE frappe (parcours de tout appData + i18n + flatten récursif + NFD par enregistrement).
  // On le met en CACHE, invalidé au seul point de mutation (saveData/loadData/changement de
  // langue) via _csiInvalidateSearchIndex — les données ne changent pas entre deux saveData.
  var _searchIndexCache = null;
  window._csiInvalidateSearchIndex = function() { _searchIndexCache = null; };
  function getSearchIndex() {
    if (_searchIndexCache) return _searchIndexCache;
    var entries = [];
    _INDEXED_KEYS = {};

    // --- Actions ---
    _INDEXED_KEYS.actions = true;
    (appData.actions || []).forEach(function(a) {
      _pushEntry(entries, t('searchResults.action'), a.title,
        [a.title, a.description, a.assignedTo, a.status, a.priority, a.owner, a.phase,
         (Array.isArray(a.comments) ? a.comments.map(function(c) { return c && c.text; }).join(' ') : a.comments)],
        "actions", function() { editAction(a.id); });
    });

    // --- Risks ---
    _INDEXED_KEYS.risks = true;
    (appData.risks || []).forEach(function(r) {
      _pushEntry(entries, t('searchResults.risk'), r.title,
        [r.title, r.description, r.treatment, r.level, r.comments],
        "risks", function() { editRisk(r.id); });
    });

    // --- Threats ---
    _INDEXED_KEYS.threats = true;
    (appData.threats || []).forEach(function(th) {
      _pushEntry(entries, t('searchResults.threat'), th.title,
        [th.title, th.description, th.category, th.level, th.comments],
        "threats-section", function() { editThreat(th.id); });
    });

    // --- Non-conformities ---
    _INDEXED_KEYS.nonconformities = true;
    (appData.nonconformities || []).forEach(function(nc) {
      _pushEntry(entries, t('searchResults.nc'), nc.title,
        [nc.title, nc.description, nc.rootCause, nc.correctiveAction, nc.status, nc.assignedTo, nc.type],
        "nonconformities", function() { editNC(nc.id); });
    });

    // --- Audits ---
    _INDEXED_KEYS.audits = true;
    (appData.audits || []).forEach(function(a) {
      _pushEntry(entries, t('searchResults.audit'), a.title,
        [a.title, a.description, a.scope, a.auditor, a.findings, a.recommendations, a.status, a.type],
        "audits", function() { editAudit(a.id); });
    });

    // --- Management Reviews ---
    _INDEXED_KEYS.reviews = true;
    (appData.reviews || []).forEach(function(r) {
      _pushEntry(entries, t('searchResults.review'), r.inputs || r.decisions || r.participants,
        [r.participants, r.inputs, r.decisions, r.comments],
        "managementReviews", function() { editReview(r.id); });
    });

    // --- Stakeholders ---
    _INDEXED_KEYS.stakeholders = true;
    (appData.stakeholders || []).forEach(function(s) {
      _pushEntry(entries, t('searchResults.stakeholder'), s.name,
        [s.name, s.role, s.contact, s.notes, s.category, s.expectations],
        "stakeholders", function() { editStakeholder(s.id); });
    });

    // --- Documents ---
    _INDEXED_KEYS.documents = true;
    (appData.documents || []).forEach(function(d) {
      _pushEntry(entries, t('searchResults.document'), d.title || d.type,
        [d.title, d.type, d.reference, d.owner, d.notes, d.status, d.location, d.version],
        "documents", function() { editDocument(d.id); });
    });

    // --- ISO 27002 Controls ---
    _INDEXED_KEYS.controls = true;
    var _FM = window.CSI_FrameworkMapper;
    var _searchIsoControls = _FM ? _FM.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    _searchIsoControls.forEach(function(c) {
      _pushEntry(entries, t('searchResults.control'), isoCtrlTitle(c),
        [c.title, c.titleEn, c.measure, c.description, c.descriptionEn, c.reference, c.category, c.status, c.cmm, c.comments, c.evidence, c.justificationControl],
        "controls", function() { editControl(c.id); });
    });

    // --- NIS2 Controls ---
    _INDEXED_KEYS.nis2Controls = true;
    var _searchNis2Controls = _FM ? _FM.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
    _searchNis2Controls.forEach(function(c) {
      // GOV-NIS2 S5 (M25) : la réf CCB (GV.OC-01.1) et le titre sont indexés et affichés —
      // chercher une réf dans la recherche globale ne remontait rien.
      _pushEntry(entries, t('searchResults.nis2Control'), (c.ref ? c.ref + ' — ' : '') + (c.title || c.description || c.categorie),
        [c.ref, c.title, c.description, c.fonction, c.categorie, c.status, c.cmm, c.justification, c.owner, c.reviewer],
        "nis2-controls", function() { if (typeof editNis2Control === "function") editNis2Control(c.id); });
    });

    // --- ReCyF Controls (NIS2 France) ---
    // FIX-SEARCH-RECYF (audit 2026-06-10) : les mesures ReCyF étaient invisibles dans la
    // recherche globale — l'auto-découverte les écartait (aucun champ title/name/label/
    // description/reference dans les objets créés par loadRecyfControls). Indexation
    // explicite ref + objectif + contenu, avec navigation vers l'onglet recyf-controls.
    _INDEXED_KEYS.recyfControls = true;
    // GOV-NIS2 S5 (M25) : périmètre du degré EI/EE — une mesure EE hors périmètre EI
    // remontait dans la recherche, s'ouvrait en modale, s'évaluait... invisible de la
    // table, du score et des exports (mais comptée par le serveur).
    var _searchRecyfControls = (typeof recyfControlsInScope === 'function') ? recyfControlsInScope()
      : (_FM ? _FM.getAppDataControls('recyf2025') : (appData.recyfControls || []));
    _searchRecyfControls.forEach(function(c) {
      var _recyfLabel = (c.ref ? c.ref + ' — ' : '') + (c.objectiveLabel || c.theme || c.content || '');
      _pushEntry(entries, t('searchResults.recyfControl', 'Mesure ReCyF'), _recyfLabel,
        [c.ref, c.objectiveLabel, c.theme, c.content, c.conformity, c.justification],
        "recyf-controls", function() { if (typeof editRecyfControl === "function") editRecyfControl(c.id); });
    });

    // --- KPIs ---
    _INDEXED_KEYS.kpis = true;
    (appData.kpis || []).forEach(function(k) {
      _pushEntry(entries, "KPI", k.title,
        [k.title, k.description, k.unit, k.frequency, k.trend],
        "kpis", function() { editKpi(k.id); });
    });

    // --- Critical Assets ---
    _INDEXED_KEYS.criticalAssets = true;
    (appData.criticalAssets || []).forEach(function(a) {
      _pushEntry(entries, t('searchResults.criticalAsset'), getCriticalAssetLabel(a),
        [a.productName, a.productCode, a.beneficiary, a.owner, a.productTags, a.supportSystems, a.availabilityNote, a.mfaDetail],
        "critical-assets", function() { openCriticalAssetModal(a.id); });
    });

    // --- Project Risks ---
    _INDEXED_KEYS.projectRisks = true;
    (appData.projectRisks || []).forEach(function(r) {
      _pushEntry(entries, t('searchResults.projectRisk'), r.projectName || r.reportNumber,
        [r.projectName, r.reportNumber, r.beneficiary, r.description, r.status, r.priority, r.recommendations],
        "project-risks", function() { openProjectRiskModal(r.id); });
    });

    // --- SOA (Statement of Applicability) ---
    _INDEXED_KEYS.soa = true;
    (appData.soa || []).forEach(function(s) {
      var _soaIsoControls = _FM ? _FM.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
      var ctrl = _soaIsoControls.find(function(c) { return c.id === s.controlId; });
      var label = ctrl ? isoCtrlTitle(ctrl) : s.controlId;
      _pushEntry(entries, "SOA", label,
        [label, s.justification, s.exclusionReason, s.owner, s.evidenceRef, s.implStatus, s.source],
        "soa", null);
    });

    // --- Document Review (normative) ---
    _INDEXED_KEYS.documentReview = true;
    (appData.documentReview || []).forEach(function(d) {
      if (!d.justification && !d.tool) return;
      _pushEntry(entries, "Revue normative", d.reference,
        [d.reference, d.justification, d.tool],
        "docReview", null);
    });

    // --- Budget Cybersécurité : lignes budgétaires ---
    _INDEXED_KEYS.nis2Plan = true;
    if (appData.nis2Plan && Array.isArray(appData.nis2Plan.budgetLines)) {
      appData.nis2Plan.budgetLines.forEach(function(line) {
        if (!line || !line.label) return;
        _pushEntry(entries, t('nav.nis2Program', 'Budget Cybersécurité'), line.label,
          [line.label, line.owner, line.category],
          "nis2-program", null);
      });
    }

    // --- SOCLE Pillars ---
    _INDEXED_KEYS.soclePillars = true;
    (appData.soclePillars || []).forEach(function(p) {
      _pushEntry(entries, "Socle NIS2", p.title,
        [p.title, p.subtitle, p.items, p.owner],
        "nis2-socle", null);
    });

    // --- Risk Analyses ---
    _INDEXED_KEYS.riskAnalyses = true;
    (appData.riskAnalyses || []).forEach(function(a) {
      var meta = a.meta || {};
      var ctx = a.context || {};
      var ident = a.identity || {};
      // FIX-AUDIT-SEARCH (audit 2026-08-25) : les RISQUES de l'analyse n'étaient pas indexés
      // (menaces, scénarios, propriétaires, notes, justifications de non-applicabilité) —
      // seuls les risques promus au registre SMSI étaient retrouvables. Contenu textuel réel,
      // avec replis snapshot pour les analyses dont le pack n'est plus installé.
      var _raRisks = (a.risks || []).map(function(rk) {
        return [rk._threat, rk._scenario, rk.customTitle, rk.owner, rk.notes].filter(Boolean).join(" ");
      }).join(" ");
      _pushEntry(entries, "Analyse de risques", meta.title || "Analyse",
        [meta.title, meta.reportNumber, ctx.perimeter, ctx.description, ident.beneficiary, ident.encoder, ident.validator, a.finalRecs, _raRisks],
        "risk-analysis", null);
    });

    // --- Impact Matrices ---
    _INDEXED_KEYS.impactMatrices = true;
    if (appData.impactMatrices && Array.isArray(appData.impactMatrices.matrices)) {
      appData.impactMatrices.matrices.forEach(function(m) {
        _pushEntry(entries, "Matrice d'impacts", m.name,
          [m.name, m.description],
          "impact-matrices", null);
      });
    }

    // --- Skip non-searchable keys ---
    _INDEXED_KEYS.riskThresholds = true;
    _INDEXED_KEYS.title = true;
    _INDEXED_KEYS.settings = true;
    _INDEXED_KEYS.nextActionId = true;
    _INDEXED_KEYS.targetMaturity = true;
    _INDEXED_KEYS.criticalAssetsMeta = true;
    _INDEXED_KEYS.kpiHistory = true;
    _INDEXED_KEYS.actionsHistory = true;

    // --- Auto-discovery: index unknown arrays in appData ---
    // Ensures future sections are automatically searchable
    for (var key in appData) {
      if (!appData.hasOwnProperty(key) || _INDEXED_KEYS[key]) continue;
      var val = appData[key];
      if (Array.isArray(val) && val.length > 0 && typeof val[0] === "object") {
        val.forEach(function(item) {
          var label = item.title || item.name || item.label || item.description || item.reference || "";
          if (!label) return;
          _pushEntry(entries, key, label, [item], key, null);
        });
      }
    }

    // (audit 2026-07-03, revue effets de bord M/N/O) peupler RÉELLEMENT le cache : sans cette
    // ligne, _searchIndexCache restait null (jamais assigné) → l'index était reconstruit à chaque
    // frappe malgré le test en tête (le gain de perf annoncé était inopérant). Corollaire :
    // désormais que le cache VIT, il DOIT être invalidé à tout point de mutation d'appData — y
    // compris les pulls serveur (_pullAndApplyTab, bascule d'onglet, parachute 409) qui écrivent
    // appData hors saveData/loadData ; ces invalidations sont ajoutées à ces 3 sites.
    _searchIndexCache = entries;
    return entries;
  }

  function renderSearchResults(results, query) {
    var container = document.getElementById("globalSearchResults");
    if (!container) return;
    container.innerHTML = "";
    // === CSI-ME === (Lot 3c, §11.4) : la recherche ne couvre QUE le périmètre chargé —
    // l'afficher supprime la fausse négation silencieuse (« introuvable » devant un
    // auditeur alors que l'objet vit dans une autre partition).
    var _emSearch = window.CSI_EntityManager;
    if (_emSearch && _emSearch.isMultiEntity && _emSearch.isMultiEntity()) {
      var _scopeLbl = null;
      if (_emSearch.activeScope()) {
        _scopeLbl = _emSearch.activeScope();
        _emSearch.entities().forEach(function(e) { if (e && e.id === _scopeLbl) _scopeLbl = e.name || e.id; });
      } else if (_emSearch.isSubEntityUser()) {
        _scopeLbl = (_emSearch.info() && _emSearch.info().entityName) || null;
      } else if (_emSearch.isGroupUser()) {
        _scopeLbl = t('entities.mainEntity', 'Entité principale');
      }
      if (_scopeLbl) {
        var _scopeNote = document.createElement("div");
        _scopeNote.className = "csi-search-scope";
        _scopeNote.textContent = t('search.scopeNote', 'Recherche limitée au périmètre :') + ' ' + _scopeLbl;
        container.appendChild(_scopeNote);
      }
    }
    if (!results.length) {
      container.appendChild(document.createTextNode(t('search.noResults', 'Aucun résultat trouvé')));
      return;
    }
    var terms = NORMALIZE(query || "").split(/\s+/).filter(Boolean);
    results.slice(0, 30).forEach(function(res) {
      var btn = document.createElement("button");
      btn.type = "button";
      btn.className = "global-search__item";
      // Highlight matched terms in label
      var labelHtml = escapeHtml(res.label);
      if (terms.length) {
        terms.forEach(function(term) {
          var re = new RegExp("(" + term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "gi");
          labelHtml = labelHtml.replace(re, '<mark>$1</mark>');
        });
      }
      // Build match context: snippet from a field other than the label
      var contextHtml = "";
      if (res.matchField) {
        var ctx = res.matchField.length > 80 ? res.matchField.substring(0, 80) + "\u2026" : res.matchField;
        var ctxEsc = escapeHtml(ctx);
        if (terms.length) {
          terms.forEach(function(term) {
            var re = new RegExp("(" + term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "gi");
            ctxEsc = ctxEsc.replace(re, '<mark>$1</mark>');
          });
        }
        contextHtml = '<span class="global-search__context">' + ctxEsc + '</span>';
      }
      btn.innerHTML =
        '<span class="global-search__type">' + escapeHtml(res.type) + '</span>' +
        '<span class="global-search__label">' + labelHtml + contextHtml + '</span>' +
        '<span class="global-search__module">' + escapeHtml(res.module) + '</span>';
      btn.addEventListener("click", function() {
        switchModule(res.module);
        setTimeout(function() {
          if (typeof res.action === "function") res.action();
        }, 200);
      });
      container.appendChild(btn);
    });
  }

  function performGlobalSearch(query) {
    var q = NORMALIZE(query).trim();
    if (!q) {
      renderSearchResults([]);
      return;
    }
    var terms = q.split(/\s+/).filter(Boolean);
    var index = getSearchIndex();
    var scored = [];
    for (var i = 0; i < index.length; i++) {
      var entry = index[i];
      var nSearch = entry.nSearch !== undefined ? entry.nSearch : NORMALIZE(entry.searchText);
      var nLabel = entry.nLabel !== undefined ? entry.nLabel : NORMALIZE(entry.label);
      // All terms must appear somewhere in the full search text
      var allMatch = true;
      for (var j = 0; j < terms.length; j++) {
        if (nSearch.indexOf(terms[j]) === -1) { allMatch = false; break; }
      }
      if (!allMatch) continue;
      // Score: title/label match = highest, other fields = lower
      var score = 0;
      var labelMatched = false;
      for (var k = 0; k < terms.length; k++) {
        if (nLabel.indexOf(terms[k]) !== -1) { score += 10; labelMatched = true; }
        else score += 1;
      }
      // Exact phrase in label bonus
      if (nLabel.indexOf(q) !== -1) score += 20;
      // Type match bonus
      if (NORMALIZE(entry.type).indexOf(terms[0]) !== -1) score += 5;
      // Find match context: snippet from searchText beyond the label
      var matchField = "";
      if (!labelMatched || terms.length > 1) {
        var afterLabel = entry.searchText.substring(entry.label.length).trim();
        for (var m = 0; m < terms.length; m++) {
          var idx = NORMALIZE(afterLabel).indexOf(terms[m]);
          if (idx !== -1) {
            var start = Math.max(0, idx - 20);
            var end = Math.min(afterLabel.length, idx + terms[m].length + 60);
            matchField = (start > 0 ? "\u2026" : "") + afterLabel.substring(start, end).trim();
            break;
          }
        }
      }
      scored.push({
        type: entry.type,
        label: entry.label,
        module: entry.module,
        action: entry.action,
        score: score,
        matchField: matchField
      });
    }
    scored.sort((a, b) => SortUtils.numeric(b.score, a.score));
    renderSearchResults(scored, query);
  }

  function setupGlobalSearch() {
    var input = document.getElementById("globalSearchInput");
    var btn = document.getElementById("globalSearchBtn");
    if (!input || !btn) return;
    var debounceTimer;
    input.addEventListener("input", function() {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(function() { performGlobalSearch(input.value); }, 150);
    });
    input.addEventListener("keydown", function(e) {
      if (e.key === "Enter") {
        e.preventDefault();
        clearTimeout(debounceTimer);
        performGlobalSearch(input.value);
      }
      if (e.key === "Escape") {
        input.value = "";
        renderSearchResults([]);
      }
    });
    btn.addEventListener("click", function() { performGlobalSearch(input.value); });
  }

  // modules/readOnlyMode.js
  const EDIT_PASSWORD_PROMPT = null; // deprecated: now uses i18n via t('header.editPasswordPrompt')
  const EDIT_PASSWORD_HASH = "15a4c04ed176b88620ebbe26e1f730cfdfa4d9e2bef419ec5cc2e438e80c1b51";
  function toUtf8Bytes(value) {
    if (typeof TextEncoder !== "undefined") {
      return new TextEncoder().encode(value);
    }
    const encoded = unescape(encodeURIComponent(value));
    const result = new Uint8Array(encoded.length);
    for (let i = 0; i < encoded.length; i++) {
      result[i] = encoded.charCodeAt(i);
    }
    return result;
  }
  function rotr(value, bits) {
    return value >>> bits | value << 32 - bits;
  }
  function sha256Fallback(bytes) {
    const K = [
      0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
      0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
      0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
      0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
      0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
      0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
      0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
      0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
    ];
    const H = [
      0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
      0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
    ];
    const bitLen = bytes.length * 8;
    const withOne = bytes.length + 1;
    let paddedLength = withOne;
    while (paddedLength % 64 !== 56) paddedLength++;
    const totalLength = paddedLength + 8;
    const msg = new Uint8Array(totalLength);
    msg.set(bytes);
    msg[bytes.length] = 0x80;
    const view = new DataView(msg.buffer);
    const high = Math.floor(bitLen / 0x100000000);
    const low = bitLen >>> 0;
    view.setUint32(totalLength - 8, high, false);
    view.setUint32(totalLength - 4, low, false);
    const W = new Uint32Array(64);
    for (let i = 0; i < msg.length; i += 64) {
      for (let j = 0; j < 16; j++) {
        W[j] = view.getUint32(i + j * 4, false);
      }
      for (let j = 16; j < 64; j++) {
        const s0 = rotr(W[j - 15], 7) ^ rotr(W[j - 15], 18) ^ W[j - 15] >>> 3;
        const s1 = rotr(W[j - 2], 17) ^ rotr(W[j - 2], 19) ^ W[j - 2] >>> 10;
        W[j] = (W[j - 16] + s0 + W[j - 7] + s1) >>> 0;
      }
      let a = H[0];
      let b = H[1];
      let c = H[2];
      let d = H[3];
      let e = H[4];
      let f = H[5];
      let g = H[6];
      let h = H[7];
      for (let j = 0; j < 64; j++) {
        const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
        const ch = e & f ^ ~e & g;
        const temp1 = (h + S1 + ch + K[j] + W[j]) >>> 0;
        const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
        const maj = a & b ^ a & c ^ b & c;
        const temp2 = (S0 + maj) >>> 0;
        h = g;
        g = f;
        f = e;
        e = (d + temp1) >>> 0;
        d = c;
        c = b;
        b = a;
        a = (temp1 + temp2) >>> 0;
      }
      H[0] = (H[0] + a) >>> 0;
      H[1] = (H[1] + b) >>> 0;
      H[2] = (H[2] + c) >>> 0;
      H[3] = (H[3] + d) >>> 0;
      H[4] = (H[4] + e) >>> 0;
      H[5] = (H[5] + f) >>> 0;
      H[6] = (H[6] + g) >>> 0;
      H[7] = (H[7] + h) >>> 0;
    }
    return Array.from(H).map((val) => val.toString(16).padStart(8, "0")).join("");
  }
  async function hashEditPassword(raw) {
    const data = toUtf8Bytes(raw || "");
    const cryptoObj = (typeof crypto !== "undefined" && crypto) || (typeof window !== "undefined" ? window.crypto : undefined);
    if (cryptoObj && cryptoObj.subtle && typeof cryptoObj.subtle.digest === "function") {
      const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
      return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
    }
    return sha256Fallback(data);
  }
  function hashEditPasswordSync(raw) {
    return sha256Fallback(toUtf8Bytes(raw || ""));
  }
  const EDIT_PASSWORD_ALLOWED_HASHES = [EDIT_PASSWORD_HASH, "06898c84c85f504230dcf2664c054e62899be54a9d8daa319ef0cca8f6b2ae3d"].filter(Boolean);
  const EDIT_PASSWORD_STORAGE_KEY = "smsi_edit_password_hash";
  const MIN_EDIT_PASSWORD_LENGTH = 18;
  const EDIT_PASSWORD_REGEX = {
    upper: /[A-Z]/,
    lower: /[a-z]/,
    special: /[^A-Za-z0-9\s]/
  };
  let editPasswordUnlocked = false;
  function getEditPasswordPolicyStatus(password = "", minLength = MIN_EDIT_PASSWORD_LENGTH) {
    const value = String(password || "");
    const checks = [
      { key: "length", label: t('passwordPolicy.minLength', 'Au moins {{min}} caractères').replace('{{min}}', minLength), ok: value.length >= minLength },
      { key: "upper", label: t('passwordPolicy.uppercase', 'Au moins une majuscule'), ok: EDIT_PASSWORD_REGEX.upper.test(value) },
      { key: "lower", label: t('passwordPolicy.lowercase', 'Au moins une minuscule'), ok: EDIT_PASSWORD_REGEX.lower.test(value) },
      { key: "special", label: t('passwordPolicy.special', 'Au moins un caractère spécial'), ok: EDIT_PASSWORD_REGEX.special.test(value) }
    ];
    const missing = checks.filter((check) => !check.ok).map((check) => check.label);
    return { checks, missing, isValid: missing.length === 0 };
  }
  function createPasswordPolicyList(minLength = MIN_EDIT_PASSWORD_LENGTH) {
    const wrap = document.createElement("div");
    wrap.className = "password-policy";
    const title = document.createElement("p");
    title.className = "password-policy__title";
    title.textContent = t('passwordPolicy.title', 'Exigences du mot de passe :');
    const list = document.createElement("ul");
    list.className = "password-policy__list";
    getEditPasswordPolicyStatus("", minLength).checks.forEach((check) => {
      const item = document.createElement("li");
      item.className = "password-policy__item";
      item.dataset.policyKey = check.key;
      item.dataset.label = check.label;
      list.appendChild(item);
    });
    wrap.append(title, list);
    updatePasswordPolicyList(list, "", minLength);
    return { wrap, list };
  }
  function updatePasswordPolicyList(list, password, minLength = MIN_EDIT_PASSWORD_LENGTH) {
    if (!list) return false;
    const status = getEditPasswordPolicyStatus(password, minLength);
    status.checks.forEach((check) => {
      const item = list.querySelector(`[data-policy-key="${check.key}"]`);
      if (!item) return;
      const label = item.dataset.label || check.label;
      item.textContent = check.ok ? (t('passwordPolicy.ok', 'OK') + ': ' + label) : (t('passwordPolicy.missing', 'Manque') + ': ' + label);
      item.classList.toggle("password-policy__item--ok", check.ok);
      item.classList.toggle("password-policy__item--missing", !check.ok);
    });
    return status.isValid;
  }
  function getStoredEditPasswordHash() {
    try {
      const stored = localStorage.getItem(EDIT_PASSWORD_STORAGE_KEY);
      return typeof stored === "string" ? stored : "";
    } catch (e) {
      return "";
    }
  }
  function setStoredEditPasswordHash(hash) {
    try {
      if (hash) localStorage.setItem(EDIT_PASSWORD_STORAGE_KEY, hash);
      else localStorage.removeItem(EDIT_PASSWORD_STORAGE_KEY);
    } catch (e) {
      // Ignore storage errors
    }
  }
  function getAllowedEditPasswordHashes() {
    const stored = getStoredEditPasswordHash();
    if (stored && stored.length >= 64) return [stored];
    return EDIT_PASSWORD_ALLOWED_HASHES;
  }
  function isLocalhost() {
    try {
      return ["localhost", "127.0.0.1", "", "::1"].includes(window.location.hostname);
    } catch (e) {
      return false;
    }
  }
  async function verifyEditPassword(pwd) {
    try {
      const hash = await hashEditPassword(pwd);
      const allowed = getAllowedEditPasswordHashes();
      if (!allowed.includes(hash)) return false;
      // Check if matched a factory hash (not a user-set custom one)
      const storedHash = getStoredEditPasswordHash();
      const isFactory = (!storedHash || storedHash.length < 64) && EDIT_PASSWORD_ALLOWED_HASHES.includes(hash);
      if (isFactory) {
        verifyEditPassword._lastMatchedFactory = true;
      } else {
        verifyEditPassword._lastMatchedFactory = false;
      }
      return true;
    } catch (e) {
      void 0;
      return false;
    }
  }
  function shouldPromptEditPasswordSetup() {
    if (typeof shouldAutoUnlockForTests === "function" && shouldAutoUnlockForTests()) return false;
    // Mode serveur : l'auth JWT gère l'accès édition — pas besoin du mot de passe local Phase 1
    if (typeof isServerModeUnlocked === "function" && isServerModeUnlocked()) return false;
    return !getStoredEditPasswordHash();
  }
  function queueEditPasswordSetup() {
    if (!shouldPromptEditPasswordSetup()) return;
    setTimeout(() => {
      if (!shouldPromptEditPasswordSetup()) return;
      openEditPasswordSetupModal({ allowSkip: false });
    }, 250);
  }
  function openEditPasswordSetupModal(options = {}) {
    if (!shouldPromptEditPasswordSetup()) return;
    const overlay = document.getElementById("modalOverlay");
    if (overlay && overlay.classList.contains("modal-overlay--visible")) return;
    const minLength = typeof options.minLength === "number" ? options.minLength : MIN_EDIT_PASSWORD_LENGTH;
    const allowSkip = options.allowSkip !== false;
    const cancelLabel = allowSkip ? t('common.later', 'Plus tard') : t('common.cancel', 'Annuler');
    openModal(t('modals.setPassword'), [
      { name: "password", label: t('auth.newPassword', 'Nouveau mot de passe'), type: "password", value: "" },
      { name: "confirm", label: t('auth.confirmPassword', 'Confirmer le mot de passe'), type: "password", value: "" }
    ], (data) => {
      const password = data.password || "";
      const confirm = data.confirm || "";
      if (!password) {
        showToast2(t('toasts.enterPassword'), "warning");
        return false;
      }
      const policy = getEditPasswordPolicyStatus(password, minLength);
      if (!policy.isValid) {
        const detail = policy.missing.length ? t('passwordPolicy.missingRequirements', 'Exigences manquantes') + ': ' + policy.missing.join(", ") + '.' : "";
        showToast2((t('settings.passwordNonCompliant', 'Mot de passe non conforme.') + ' ' + detail).trim(), "warning");
        return false;
      }
      if (password !== confirm) {
        showToast2(t('toasts.passwordMismatch'), "error");
        return false;
      }
      const hash = hashEditPasswordSync(password);
      const isFirstPasswordSetup = !localStorage.getItem("smsi_password_setup_done");
      setStoredEditPasswordHash(hash);
      editPasswordUnlocked = true;
      showToast2(t('toasts.passwordSaved'), "success");
      if (isFirstPasswordSetup) {
        localStorage.setItem("smsi_password_setup_done", "1");
        setTimeout(() => {
          showToast2(t('toasts.readOnlyStart', "L'application démarre en mode lecture seule. Cliquez sur le cadenas pour passer en mode édition."), "info", 8000);
        }, 2000);
      }
      return true;
    }, null, {
      allowReadOnly: true,
      saveLabel: t('common.save', 'Enregistrer'),
      cancelLabel,
      hideCancel: !allowSkip,
      hideClose: !allowSkip
    });
    const modalBody = document.getElementById("modalBody");
    const passwordInput = document.getElementById("modal-input-password");
    if (modalBody && passwordInput) {
      const { wrap, list } = createPasswordPolicyList(minLength);
      modalBody.appendChild(wrap);
      const update = () => updatePasswordPolicyList(list, passwordInput.value, minLength);
      passwordInput.addEventListener("input", update);
      update();
    }
  }
  let editModeEnabled = false;
  const HISTORY_KEY = "smsi_history";
  // Mode serveur : historique en RAM uniquement (perdu au rechargement)
  var _ramHistorySnapshots = [];
  const HISTORY_LIMIT_DEFAULT = 10;
  const HISTORY_LIMIT_STORAGE_KEY = "smsi_history_limit";
  const WELCOME_STORAGE_KEY = "smsi_welcome_seen";
  const NORMALIZE = (str) => (str || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
  function shortenLegendLabel(label) {
    const clean = (label || "").replace(/\s+/g, " ").trim();
    const maxLen = 38;
    if (clean.length <= maxLen) return clean;
    const truncated = clean.slice(0, maxLen);
    const lastSpace = truncated.lastIndexOf(" ");
    const base = lastSpace > 18 ? truncated.slice(0, lastSpace) : truncated;
    return `${base.trim()}…`;
  }
  function renderRadarLegend(targetId, items) {
    const container = document.getElementById(targetId);
    if (!container) return;
    if (!Array.isArray(items) || items.length === 0) {
      container.innerHTML = "";
      return;
    }
    container.classList.add("chart-legend--two-cols");
    const list = document.createElement("ol");
    items.forEach((text) => {
      const li = document.createElement("li");
      li.textContent = shortenLegendLabel(text);
      list.appendChild(li);
    });
    container.innerHTML = "";
    container.appendChild(list);
  }
  function attachLegendTooltip(targetId) {
    const btn = document.querySelector(`[data-legend-toggle="${targetId}"]`);
    const legend = document.getElementById(targetId);
    if (!btn || !legend) return;
    const show = () => legend.removeAttribute("hidden");
    const hide = () => legend.setAttribute("hidden", "");
    ["mouseenter", "focus"].forEach((evt) => btn.addEventListener(evt, show));
    ["mouseleave", "blur"].forEach((evt) => btn.addEventListener(evt, hide));
    ["mouseenter", "mouseleave"].forEach((evt) => legend.addEventListener(evt, (e) => {
      if (evt === "mouseenter") show();
      else hide();
    }));
    hide();
  }
  const EDIT_LOCK_IDS = [
    "addControlBtn",
    "addActionBtn",
    "manageBudgetCategoriesBtn",
    "manageBudgetYearsBtn",
    "addBudgetLineBtn",
    "addRiskBtn",
    "importRisksBtn",
    "addThreatBtn",
    "proposeThreatsBtn",
    "addNcBtn",
    "addAuditBtn",
    "addReviewBtn",
    "addStakeholderBtn",
    "addDocumentBtn",
    "addKpiBtn",
    "addProjectRiskBtn",
    "serverSyncBtn",
    "settingsImport",
    "settingsReset",
    "settingsDemo",
    "serverSyncDownload",
    "serverSyncUpload"
  ];
  function isReadOnlyMode() {
    // === CSI-CO === _csiClusterReadOnly : en co-édition par grappes, l'onglet AFFICHÉ
    // appartient à une grappe détenue par un autre utilisateur → cet écran est en lecture
    // seule SANS quitter le mode édition (les grappes détenues restent éditables ailleurs).
    // Toujours false hors co-édition (posé/levé uniquement par _csiEnsureClusterForTab).
    return !editModeEnabled || !!window._csiClusterReadOnly;
  }

  // === CSI-BUILD === Bandeau « nouvelle version disponible » (appelé par lock-manager
  // quand le serverBuild du presence-ping diffère de CSI_CLIENT_BUILD : onglet resté ouvert
  // pendant une mise à jour serveur). Un SEUL affichage par build (pas de harcèlement) ;
  // jamais de reload forcé — le bouton sauvegarde, draine les pushes, libère les verrous
  // PUIS recharge. Si un rechargement a déjà été tenté pour ce build et que le client est
  // toujours périmé (cache coincé/proxy), le message escalade vers le rechargement forcé
  // (Ctrl+F5) — le « vider le cache » n'est jamais demandé en premier recours.
  var _csiBuildNotified = null;
  function _csiNotifyServerBuild(serverBuild) {
    if (_csiBuildNotified === serverBuild) return;           // déjà signalé pour ce build
    if (document.getElementById('csiUpdateBanner')) return;  // bandeau déjà affiché
    _csiBuildNotified = serverBuild;
    var attemptedKey = 'csi_reload_attempted_' + serverBuild;
    var alreadyTried = false;
    try { alreadyTried = !!localStorage.getItem(attemptedKey); } catch (e) { /* storage indispo */ }

    var bar = document.createElement('div');
    bar.id = 'csiUpdateBanner';
    bar.className = 'update-banner';
    bar.setAttribute('role', 'status');
    var msg = document.createElement('span');
    msg.textContent = alreadyTried
      ? t('updateBanner.hardReload', 'La mise à jour n\'a pas été prise en compte — faites un rechargement forcé (Ctrl+F5, ou Cmd+Maj+R sur Mac).')
      : t('updateBanner.newVersion', 'Une nouvelle version de l\'application est disponible.');
    bar.appendChild(msg);
    if (!alreadyTried) {
      var btn = document.createElement('button');
      btn.className = 'btn btn--primary btn--sm';
      btn.textContent = t('updateBanner.reload', 'Mettre à jour');
      btn.onclick = async function () {
        btn.disabled = true;
        try { if (!isReadOnlyMode() && typeof saveData === 'function') saveData(); } catch (e) { /* best effort */ }
        try { if (typeof _flushServerPushes === 'function') await _flushServerPushes(15000); } catch (e) { /* best effort */ }
        try {
          var ELMu = window.CSI_EditLockManager;
          if (ELMu && ELMu.release) await ELMu.release();
        } catch (e) { /* best effort */ }
        try { localStorage.setItem(attemptedKey, '1'); } catch (e) { /* storage indispo */ }
        window.location.reload();
      };
      bar.appendChild(btn);
    }
    var close = document.createElement('button');
    close.className = 'update-banner__close';
    close.setAttribute('aria-label', t('updateBanner.dismiss', 'Ignorer'));
    close.textContent = '×';
    close.onclick = function () { if (bar.parentNode) bar.parentNode.removeChild(bar); };
    bar.appendChild(close);
    document.body.appendChild(bar);
  }
  window._csiNotifyServerBuild = _csiNotifyServerBuild;

  // === CSI-CO === Libellé humain d'une grappe (le bandeau ne dit jamais « grappe »).
  function _csiClusterLabel(cluster) {
    var map = {
      coeur: t('clusters.coeur', 'Pilier SMSI'),
      pilotage: t('clusters.pilotage', 'Pilotage & documents'),
      analyse: t('clusters.analyse', 'Analyses de risques'),
      config: t('clusters.config', 'Configuration')
    };
    return map[cluster] || cluster || '';
  }

  // === CSI-CO === (courtoisie §12, INV-C9) Vrai si la grappe a des onglets avec des
  // modifications locales non confirmées par le serveur (dirty) ou des pushes à rejouer
  // (pending) — une telle grappe ne doit JAMAIS être relâchée par courtoisie.
  function _csiClusterHasPendingWork(cluster) {
    var ELM = window.CSI_EditLockManager;
    var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
    var map = (mgr && mgr.getTabDataKeys) ? mgr.getTabDataKeys() : null;
    if (!map || !ELM || !ELM.clusterForTab) return true; // dans le doute : ne pas relâcher
    for (var tb in map) {
      if (!map.hasOwnProperty(tb)) continue;
      if (ELM.clusterForTab(tb) !== cluster) continue;
      if (_csiDirtyTabs[tb] || _pendingPushTabs[tb]) return true;
    }
    return false;
  }

  // FIX-COURTESY-TOUCH (audit co-édition 2026-07-17, N3) : empreinte des données d'un
  // onglet prise à son AFFICHAGE. Le marquage « grappe travaillée » (INV-C9) n'a lieu que
  // si les données de l'onglet ont RÉELLEMENT changé depuis — avant, l'autosave 30 s
  // poussait l'onglet affiché sur un delta venu d'une AUTRE grappe et le marquait à tort,
  // et un simple filtre de tableau suffisait via notifyEdit : grappes collantes, courtoisie
  // inerte (prouvé au banc). Même hash roulant 32 bits que computeDataHash, par onglet.
  // Empreinte inconnue → prudence : on considère l'onglet modifié (idiome « dans le doute »).
  var _csiTabEntryHash = {};
  function _csiTabHash(tabId) {
    var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
    var map = (mgr && mgr.getTabDataKeys) ? mgr.getTabDataKeys() : null;
    var keys = map && map[tabId];
    if (!keys || !window.appData) return null;
    var payload = {};
    for (var i = 0; i < keys.length; i++) payload[keys[i]] = appData[keys[i]];
    var str;
    try { str = JSON.stringify(payload); } catch (e) { return null; }
    var hash = 0;
    for (var k = 0; k < str.length; k++) hash = ((hash << 5) - hash + str.charCodeAt(k)) | 0;
    return hash;
  }
  function _csiSnapshotTabEntry(tabId) {
    var ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.isCoedit || !ELM.isCoedit()) return;
    var h = _csiTabHash(tabId);
    if (h !== null) _csiTabEntryHash[tabId] = h;
  }
  function _csiTabDataChanged(tabId) {
    var ref = _csiTabEntryHash[tabId];
    if (ref === undefined) return true; // pas d'empreinte connue : prudence
    var h = _csiTabHash(tabId);
    return h === null || h !== ref;
  }

  // === CSI-CO === À chaque affichage d'onglet en mode édition : garantit la grappe de
  // l'onglet (acquisition silencieuse si libre). Refus → l'écran passe en lecture seule
  // (bandeau orange nominatif), les grappes déjà détenues restent éditables. No-op complet
  // hors co-édition (flag serveur off / mode local) — zéro changement de comportement.
  // (courtoisie §12) AVANT l'acquisition (INV-C10), relâche silencieusement les grappes
  // détenues NON touchées (aucune édition réelle) et sans push en attente — le module
  // redevient immédiatement disponible pour un collègue. Une grappe travaillée reste
  // détenue jusqu'à la sortie d'édition (INV-C9). Aucun message utilisateur.
  // === CSI-CO === (audit co-édition 2026-08-05, BLOQUANT) Les opérations GLOBALES — import
  // de fichier, restauration d'instantané, réinitialisation — poussent l'état complet via un
  // POST import, que le serveur ne sert QUE si les 4 grappes sont détenues
  // (isEditLockHeldByAllClusters, sync.php). Or l'entrée en mode édition n'acquiert que la
  // grappe de l'onglet affiché, et AUCUN parcours d'interface n'acquiert `config` : le 423
  // était donc DÉTERMINISTE, utilisateur seul compris. Pire, appData était déjà remplacé en
  // RAM : l'autosave et les changements d'onglet poussaient ensuite l'état importé/vidé
  // section par section, fabriquant un état hybride sur le serveur — l'inverse exact du
  // message affiché (« non enregistré »).
  // Ces deux helpers ferment la boucle : acquisition explicite des 4 grappes AVANT de muter,
  // et retour à un état serveur cohérent si le push échoue malgré tout.
  var CSI_ALL_CLUSTERS = ['coeur', 'pilotage', 'analyse', 'config'];
  function _csiAcquireAllClusters() {
    var ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.isCoedit || !ELM.isCoedit()) return Promise.resolve({ granted: true, acquired: [] });
    var before = (ELM.heldClusters && ELM.heldClusters()) || [];
    return ELM.acquire(CSI_ALL_CLUSTERS).then(function (res) {
      if (!res || !res.granted) return { granted: false, blockedBy: (res && res.blockedBy) || null, acquired: [] };
      // Ne relâcher ensuite QUE ce qu'on vient d'emprunter pour l'occasion.
      var acquired = CSI_ALL_CLUSTERS.filter(function (c) { return before.indexOf(c) === -1; });
      return { granted: true, acquired: acquired };
    }).catch(function () { return { granted: false, blockedBy: null, acquired: [] }; });
  }
  function _csiReleaseBorrowedClusters(acquired) {
    var ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.releaseClusters || !acquired || !acquired.length) return Promise.resolve();
    return ELM.releaseClusters(acquired).catch(function () { /* best-effort : le TTL couvre */ });
  }
  // Nom du détenteur bloquant, pour un message honnête (pas « repassez en mode édition »
  // alors que l'utilisateur Y EST DÉJÀ — ancien message trompeur des 3 chemins).
  function _csiBlockedByLabel(blockedBy) {
    if (!blockedBy) return null;
    for (var c in blockedBy) {
      if (Object.prototype.hasOwnProperty.call(blockedBy, c) && blockedBy[c] && blockedBy[c].userName) {
        return blockedBy[c].userName;
      }
    }
    return null;
  }
  // Après un échec de push global : l'état RAM ne doit JAMAIS rester divergent du serveur,
  // sinon l'autosave le propage par morceaux. On repart de l'état autoritaire et on vide les
  // files d'écriture en attente.
  async function _csiRecoverFromFailedGlobalPush() {
    try {
      if (typeof _csiDirtyTabs !== 'undefined') { for (var k in _csiDirtyTabs) delete _csiDirtyTabs[k]; }
      if (typeof _pendingPushTabs !== 'undefined') { for (var p in _pendingPushTabs) delete _pendingPushTabs[p]; }
      if (typeof _csiGlobalMetaDirty !== 'undefined') _csiGlobalMetaDirty = false;
      if (typeof _updateUnsavedFlag === 'function') _updateUnsavedFlag();
      if (typeof loadData === 'function') await loadData();
      if (typeof _csiResetTabRevisions === 'function') _csiResetTabRevisions();
      if (typeof updateDashboard === 'function') updateDashboard();
      var _cur = (typeof _currentActiveTab !== 'undefined' && _currentActiveTab) ? _currentActiveTab : null;
      if (_cur && typeof loadModuleContent === 'function') loadModuleContent(_cur);
    } catch (e) {
      void 0;
    }
  }

  function _csiEnsureClusterForTab(moduleId) {
    var ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.isCoedit || !ELM.isCoedit()) { window._csiClusterReadOnly = false; return; }
    if (!editModeEnabled) {
      window._csiClusterReadOnly = false;
      // FIX-FRICTION-STALE-LABEL (simulation friction 2026-07-19) : hors mode édition, le
      // bouton d'en-tête doit refléter le verrou du module AFFICHÉ dès la navigation. Sans
      // ceci, « Verrouillé » (hérité d'un module occupé qu'on vient de quitter) persistait
      // en arrivant sur un module LIBRE jusqu'au presence-ping suivant (~30 s), laissant
      // croire que le module libre l'est aussi. isHeldByOther() est cluster-aware sur
      // _currentActiveTab (déjà mis à jour à ce stade) → l'affichage se corrige instantanément.
      updateEditModeButtonUi();
      return;
    }
    // FIX-COURTESY-TOUCH : empreinte de l'onglet à son affichage (référence du « delta réel »).
    _csiSnapshotTabEntry(moduleId);
    var _courtesy = Promise.resolve();
    if (ELM.releaseClusters && ELM.heldClusters) {
      var _keep = (ELM.isNeutralTab && ELM.isNeutralTab(moduleId)) ? null : ELM.clusterForTab(moduleId);
      var _candidates = ELM.heldClusters().filter(function (c) {
        return c !== _keep && !_csiClusterHasPendingWork(c);
      });
      // releaseClusters re-filtre à l'envoi (grappe de l'onglet courant, grappes touchées) —
      // double garde anti-course R4 ; best-effort silencieux (échec = grappe conservée).
      if (_candidates.length) _courtesy = ELM.releaseClusters(_candidates);
    }
    _courtesy.then(function () { return ELM.ensureClusterFor(moduleId); }).then(function (res) {
      // L'utilisateur a pu re-naviguer pendant l'acquisition : n'appliquer qu'à l'onglet courant.
      if (moduleId !== _currentActiveTab) return;
      var wasRo = !!window._csiClusterReadOnly;
      window._csiClusterReadOnly = !res.editable;
      if (!res.editable && !wasRo) {
        var holderName = (res.holder && (res.holder.userName || res.holder.userId)) || '?';
        showToast2(t('modeBanner.clusterBusy', 'Module « {{module}} » en cours d\'édition par {{name}} — lecture seule ici.')
          .replace('{{module}}', _csiClusterLabel(ELM.clusterForTab(moduleId)))
          .replace('{{name}}', holderName), 'warning');
      }
      if (window._csiClusterReadOnly !== wasRo) refreshReadOnlyLocks();
      updateModeBannerUi();
      updateEditModeButtonUi();
    }).catch(function () { /* best-effort — l'état sera réévalué au prochain ping */ });
  }
  function showReadOnlyNotice(actionLabel = "") {
    try {
      showToast2(t('header.readOnlyNotice'), "warning");
    } catch (e) {
      void 0;
    }
  }
  function refreshReadOnlyLocks() {
    const disabled = isReadOnlyMode();
    document.body.classList.toggle("mode-readonly", disabled);
    document.body.classList.toggle("mode-edit", !disabled);
    document.querySelectorAll("[data-readonly-lock]").forEach((el) => {
      const isPermanent = el.dataset?.permanentReadonly === "true";
      if (isPermanent) {
        el.disabled = true;
        return;
      }
      if ("disabled" in el) {
        el.disabled = disabled;
      } else if (disabled) {
        el.setAttribute("aria-disabled", "true");
      } else if (el.getAttribute("aria-disabled") === "true") {
        el.removeAttribute("aria-disabled");
      }
    });
  }
  // Timer pour le compte à rebours du bandeau "édité par autre"
  let _modeBannerCountdownTimer = null;
  function _stopModeBannerCountdown() {
    if (_modeBannerCountdownTimer) {
      clearInterval(_modeBannerCountdownTimer);
      _modeBannerCountdownTimer = null;
    }
  }
  function _clearChildren(node) {
    while (node && node.firstChild) node.removeChild(node.firstChild);
  }
  function _setBannerStatusDom(target, iconClass, leadText, strongText) {
    _clearChildren(target);
    if (iconClass) {
      const ic = document.createElement("i");
      ic.className = iconClass;
      ic.setAttribute("aria-hidden", "true");
      target.appendChild(ic);
      target.appendChild(document.createTextNode(" "));
    }
    if (leadText) target.appendChild(document.createTextNode(leadText));
    if (strongText) {
      const s = document.createElement("strong");
      s.textContent = strongText;
      target.appendChild(s);
    }
  }
  function _setBannerCountdownDom(target, seconds, suffixText) {
    _clearChildren(target);
    if (seconds <= 0) return;
    const cd = document.createElement("span");
    cd.className = "mode-banner__countdown";
    cd.textContent = seconds + "s";
    target.appendChild(cd);
    target.appendChild(document.createTextNode(" " + suffixText));
  }
  // FIX-BANNER-HONESTY (audit co-édition 2026-07-17, §6) : tant que le détenteur est ACTIF
  // (son heartbeat de 20 s ré-arme le TTL — secondsRemaining serveur oscille entre ~70 et 90),
  // afficher « {N}s avant expiration » promettait une échéance qui n'arrivait jamais (dents
  // de scie mesurées 79→55→71 : décompte qui REMONTE, attente vaine, clic rageur). Au-dessus
  // du seuil : état honnête « édition en cours ». Le décompte n'apparaît que lorsque les
  // battements ont réellement cessé et que l'expiration approche pour de vrai.
  var BANNER_COUNTDOWN_THRESHOLD_S = 30;
  function _setBannerHolderSubtitle(target, secondsLeft) {
    if (!target) return;
    if (secondsLeft > BANNER_COUNTDOWN_THRESHOLD_S) {
      target.textContent = t('modeBanner.holderActive', 'édition en cours');
    } else if (secondsLeft > 0) {
      _setBannerCountdownDom(target, secondsLeft, t('modeBanner.untilExpiry', "avant expiration du verrou"));
    } else {
      target.textContent = t('modeBanner.lockExpiringSoon', "verrou en attente d'expiration");
    }
  }
  function updateModeBannerUi() {
    const banner = document.getElementById("modeBanner");
    const status = document.getElementById("modeBannerStatus");
    const subtitle = document.getElementById("modeBannerSubtitle");
    const takeoverBtn = document.getElementById("modeBannerTakeoverBtn");
    if (!banner) return;

    // En mode local : bandeau masqué — comportement historique préservé
    const inServerMode = isServerModeUnlocked();
    if (!inServerMode) {
      banner.style.display = "none";
      _stopModeBannerCountdown();
      return;
    }
    banner.style.display = "";

    const ELM = window.CSI_EditLockManager;
    const heldByOther = !!(ELM && ELM.isHeldByOther && ELM.isHeldByOther());
    const editing = !isReadOnlyMode();

    banner.classList.remove("mode-banner--readonly", "mode-banner--edit", "mode-banner--locked-by-other");

    if (heldByOther) {
      banner.classList.add("mode-banner--locked-by-other");
      const holder = ELM.getCurrentHolder() || {};
      const holderName = holder.userName || holder.userId || "?";
      const secondsLeft = Math.max(0, parseInt(holder.secondsRemaining, 10) || 0);

      // === CSI-CO === co-édition : le bandeau parle du MODULE affiché (« Module Pilotage —
      // édité par Alice »), jamais du système. Hors co-édition : libellé historique inchangé.
      var _coLockLead = t('modeBanner.editingByOther', 'Édité par') + " ";
      if (ELM.isCoedit && ELM.isCoedit() && ELM.clusterForTab) {
        _coLockLead = t('modeBanner.moduleEditedBy', 'Module {{module}} — édité par')
          .replace('{{module}}', _csiClusterLabel(ELM.clusterForTab(_currentActiveTab))) + " ";
      }
      if (status) _setBannerStatusDom(status, "fas fa-user-pen", _coLockLead, holderName);
      if (subtitle) _setBannerHolderSubtitle(subtitle, secondsLeft);
      if (takeoverBtn) {
        const isAdmin = !!(window.CSI_AuthManager && window.CSI_AuthManager.isAdmin && window.CSI_AuthManager.isAdmin());
        takeoverBtn.style.display = isAdmin ? "" : "none";
      }
      // === CSI-CO === (Lot 4, 2026-07-17) « Demander la main » : visible pour TOUT éditeur
      // bloqué sous co-édition (l'admin garde en plus la reprise forcée). Après envoi, le
      // bouton affiche « Demande envoyée » et se réactive à l'expiration serveur (120 s).
      var requestBtn = document.getElementById("modeBannerRequestBtn");
      if (requestBtn) {
        var _hrIsViewer = !!(window.CSI_AuthManager && window.CSI_AuthManager.isViewer && window.CSI_AuthManager.isViewer());
        if (!_hrIsViewer && ELM.isCoedit && ELM.isCoedit() && ELM.clusterForTab && !ELM.isNeutralTab(_currentActiveTab)) {
          var _hrCluster = ELM.clusterForTab(_currentActiveTab);
          var _hrSentAt = _hrCluster ? _csiHandReqSentAt[_hrCluster] : null;
          var _hrPending = _hrSentAt && (Date.now() - _hrSentAt < 120000);
          requestBtn.style.display = "";
          requestBtn.disabled = !!_hrPending;
          // Préserver l'icône <i> (même idiome que translatePage — un textContent nu la détruirait).
          var _hrIcon = requestBtn.querySelector('i');
          var _hrLabel = _hrPending
            ? t('modeBanner.requestHandSent', 'Demande envoyée')
            : t('modeBanner.requestHand', 'Demander la main');
          requestBtn.textContent = '';
          if (_hrIcon) requestBtn.appendChild(_hrIcon);
          requestBtn.appendChild(document.createTextNode((_hrIcon ? ' ' : '') + _hrLabel));
        } else {
          requestBtn.style.display = "none";
        }
      }
      // Compte à rebours rafraîchi toutes les secondes côté client (la valeur de référence
      // est rafraîchie toutes les ~30s par le presence-ping)
      if (!_modeBannerCountdownTimer) {
        // FIX-BANNER-REFRESH (audit co-édition 2026-07-17, N4) : ~24 s mesurées de
        // « Verrouillé » mensonger après libération réelle (l'UI n'apprenait le release
        // qu'au presence-ping de 30 s). Tant que le bandeau orange est affiché, re-vérifier
        // l'état des verrous toutes les 5 s — refreshStatus ne touche pas à la détention
        // (FIX-REFRESHSTATUS-COEDIT), il fusionne l'affichage et notifie : la libération
        // par le collègue est vue en ≤5 s, et la valeur affichée se recale (fin des
        // dents de scie profondes).
        var _bannerRefreshTick = 0;
        _modeBannerCountdownTimer = setInterval(function () {
          if (!ELM || !ELM.isHeldByOther || !ELM.isHeldByOther()) {
            _stopModeBannerCountdown();
            updateModeBannerUi();
            return;
          }
          _bannerRefreshTick = (_bannerRefreshTick + 1) % 5;
          if (_bannerRefreshTick === 0 && ELM.refreshStatus) ELM.refreshStatus();
          const h = ELM.getCurrentHolder() || {};
          if (typeof h.secondsRemaining === 'number' && h.secondsRemaining > 0) {
            h.secondsRemaining = Math.max(0, h.secondsRemaining - 1);
          }
          const sub = document.getElementById("modeBannerSubtitle");
          if (sub) _setBannerHolderSubtitle(sub, parseInt(h.secondsRemaining, 10) || 0);
        }, 1000);
      }
    } else if (editing) {
      banner.classList.add("mode-banner--edit");
      _stopModeBannerCountdown();
      if (status) _setBannerStatusDom(status, "fas fa-pen", t('modeBanner.editingByYou', 'Mode édition actif'), null);
      // === CSI-CO === co-édition : préciser le module éditable de l'écran courant.
      // Onglet NEUTRE (dashboard/settings, INV-C11) : sous-titre historique — aucun module
      // n'y est verrouillé, afficher un nom de grappe serait mensonger.
      if (subtitle) {
        if (ELM && ELM.isCoedit && ELM.isCoedit() && ELM.clusterForTab
            && !(ELM.isNeutralTab && ELM.isNeutralTab(_currentActiveTab))) {
          subtitle.textContent = t('modeBanner.editingModule', 'Module : {{module}}')
            .replace('{{module}}', _csiClusterLabel(ELM.clusterForTab(_currentActiveTab)));
        } else {
          subtitle.textContent = t('header.editModeSubtitle');
        }
      }
      if (takeoverBtn) takeoverBtn.style.display = "none";
      var _rqE = document.getElementById("modeBannerRequestBtn");
      if (_rqE) _rqE.style.display = "none"; // === CSI-CO === (Lot 4)
    } else {
      banner.classList.add("mode-banner--readonly");
      _stopModeBannerCountdown();
      if (status) _setBannerStatusDom(status, "fas fa-lock", t('header.readOnlyMode'), null);
      if (subtitle) subtitle.textContent = "";
      if (takeoverBtn) takeoverBtn.style.display = "none";
      var _rqR = document.getElementById("modeBannerRequestBtn");
      if (_rqR) _rqR.style.display = "none"; // === CSI-CO === (Lot 4)
    }
  }
  // Refonte header 2026-07 : le bouton porte une icône <i> + un <span> libellé (le span
  // est masqué en palier étroit ≤1120px → bouton icône seule). Cette aide pose le libellé
  // sans écraser l'icône, gère data-i18n (sur le span, pour translatePage) et pose
  // aria-label + title (title seulement quand le libellé visuel est masqué, ou déjà fourni).
  function _setEditModeBtnFace(btn, iconClass, label, i18nKey) {
    var iconEl = btn.querySelector("i");
    if (iconEl) iconEl.className = "fas " + iconClass;
    var span = btn.querySelector("span");
    if (span) {
      span.textContent = label;
      if (i18nKey) span.setAttribute("data-i18n", i18nKey);
      else span.removeAttribute("data-i18n");
    } else {
      btn.textContent = label;
      if (i18nKey) btn.setAttribute("data-i18n", i18nKey);
      else btn.removeAttribute("data-i18n");
    }
    btn.setAttribute("aria-label", label);
    var narrow = false;
    try { narrow = !!(window.matchMedia && window.matchMedia("(max-width: 1120px)").matches); } catch (e) { /* file:// vieux moteurs */ }
    if (!btn.title && narrow) btn.title = label;
  }
  function updateEditModeButtonUi() {
    const btn = document.getElementById("editModeBtn");
    if (!btn) return;
    // Viewer role: permanently disabled read-only button
    if (window.CSI_AuthManager && window.CSI_AuthManager.isViewer()) {
      btn.classList.remove("btn--danger", "btn--success", "btn--primary", "btn--warning");
      btn.classList.add("btn--outline");
      btn.disabled = true;
      btn.title = t('header.viewerReadOnly', 'Votre rôle est Lecteur — mode lecture seule permanent.');
      btn.setAttribute("aria-pressed", "false");
      _setEditModeBtnFace(btn, "fa-eye", t('header.readOnlyMode', 'Mode lecture seule'), 'header.readOnlyMode');
      return;
    }

    // Mode serveur : si le verrou d'édition est détenu par un autre utilisateur
    const ELM = window.CSI_EditLockManager;
    const heldByOther = isServerModeUnlocked() && ELM && ELM.isHeldByOther && ELM.isHeldByOther();
    if (heldByOther) {
      const holder = ELM.getCurrentHolder() || {};
      const holderName = holder.userName || holder.userId || "?";
      btn.classList.remove("btn--danger", "btn--success", "btn--primary");
      btn.classList.add("btn--outline");
      // === CSI-CO === co-édition : bouton CLIQUABLE — le clic retente l'acquisition réelle
      // (423 frais nominatif si toujours occupé) au lieu d'attendre le prochain presence-ping
      // (jusqu'à 30 s). Hors co-édition : désactivé, comportement historique inchangé.
      var _coClickable = !!(ELM.isCoedit && ELM.isCoedit());
      btn.disabled = !_coClickable;
      btn.title = t('modeBanner.editingByOther', 'Édité par') + " " + holderName
        + (_coClickable ? ' — ' + t('modeBanner.clickToRetry', 'cliquez pour réessayer') : '');
      btn.setAttribute("aria-pressed", "false");
      _setEditModeBtnFace(btn, "fa-lock", t('modeBanner.lockedShort', 'Verrouillé'), null);
      return;
    }

    btn.disabled = false;
    btn.title = "";
    if (isReadOnlyMode()) {
      btn.classList.remove("btn--danger", "btn--primary", "btn--outline", "btn--warning");
      btn.classList.add("btn--success");
      btn.setAttribute("aria-pressed", "false");
      _setEditModeBtnFace(btn, "fa-pen", t('header.editMode'), 'header.editMode');
    } else {
      btn.classList.remove("btn--success", "btn--primary", "btn--outline", "btn--warning");
      btn.classList.add("btn--danger");
      btn.setAttribute("aria-pressed", "true");
      _setEditModeBtnFace(btn, "fa-xmark", t('header.exitEditMode'), 'header.exitEditMode');
    }
  }
  function setEditMode(enabled, options = {}) {
    // Defense in depth: viewers can never enter edit mode
    if (enabled && window.CSI_AuthManager && window.CSI_AuthManager.isViewer()) {
      enabled = false;
    }
    // === CSI-ME === (Lot 3c, anti-confusion §11.3) : première entrée en édition
    // après une bascule de périmètre → confirmation explicite (le bandeau seul est
    // insuffisant — cécité d'accoutumance). Une fois confirmé, plus redemandé
    // jusqu'à la prochaine bascule.
    if (enabled && window._csiConfirmScopeEdit) {
      // (audit, mineur) Ne demander confirmation que si on est TOUJOURS sur une
      // sous-entité : un re-ingest (/me, refresh) a pu remettre le périmètre au
      // principal (_activeScope=null) sans nettoyer ce drapeau → sinon confirm parasite.
      var _emEdit = window.CSI_EntityManager;
      if (_emEdit && _emEdit.activeScope && _emEdit.activeScope()) {
        var _scopeName = window._csiConfirmScopeEdit;
        if (!window.confirm(t('entities.confirmScopeEdit', 'Vous êtes sur le périmètre « {{name}} ».\n\nActiver le mode édition sur CE périmètre ?').replace('{{name}}', _scopeName))) {
          return;
        }
      }
      window._csiConfirmScopeEdit = null;
    }
    editModeEnabled = !!enabled;
    // FIX-COURTESY-TOUCH : l'entrée en édition sur l'onglet déjà affiché ne repasse pas par
    // _csiEnsureClusterForTab — prendre l'empreinte ici pour que le « delta réel » ait sa
    // référence dès le début de la session d'édition.
    if (enabled && _currentActiveTab) _csiSnapshotTabEntry(_currentActiveTab);
    refreshReadOnlyLocks();
    updateModeBannerUi();
    updateEditModeButtonUi();
    // Resynchronise l'onglet Budget Cybersécurité (boutons edit/suppr. selon read-only)
    // UNIQUEMENT s'il est l'onglet actif — sinon on reconstruirait inutilement deux
    // graphiques Chart.js à chaque bascule de mode depuis n'importe quel module.
    var _budgetModule = document.getElementById("nis2-program");
    if (typeof renderBudgetTab === "function" && _budgetModule && _budgetModule.classList.contains("module--active")) {
      try {
        renderBudgetTab();
      } catch (e) {
        void 0;
      }
    }
    if (options.silent) return;
    showToast2(enabled ? t('toasts.editModeEnabled', 'Mode édition activé') : t('toasts.editModeDisabled', 'Mode lecture seule activé'), enabled ? "success" : "info");
  }
  function shouldAutoUnlockForTests() {
    return isLocalhost() && !!(typeof navigator !== "undefined" && navigator.webdriver);
  }

  // Modale sécurisée pour la saisie du mot de passe (masqué)
  function showPasswordModal(title, message) {
    return new Promise((resolve) => {
      const overlay = document.getElementById("modalOverlay");
      const modalTitle = document.getElementById("modalTitle");
      const modalBody = document.getElementById("modalBody");
      const saveBtn = document.getElementById("modalSave");
      const cancelBtn = document.getElementById("modalCancel");
      const closeBtn = document.getElementById("modalClose");
      const deleteBtn = document.getElementById("modalDelete");

      if (!overlay || !modalTitle || !modalBody || !saveBtn || !cancelBtn) {
        resolve(null);
        return;
      }

      modalTitle.textContent = title;
      modalBody.innerHTML = "";

      // Message d'instruction
      if (message) {
        const messageEl = document.createElement("p");
        messageEl.className = "modal__message";
        messageEl.textContent = message;
        modalBody.appendChild(messageEl);
      }

      // Champ mot de passe
      const label = document.createElement("label");
      label.textContent = t('header.passwordLabel');
      label.htmlFor = "modal-password-input";
      label.className = "modal__label";

      const inputWrapper = document.createElement("div");
      inputWrapper.className = "password-input-wrapper";
      inputWrapper.style.cssText = "position: relative; display: flex; align-items: center;";

      const input = document.createElement("input");
      input.type = "password";
      input.id = "modal-password-input";
      input.className = "form-control";
      input.autocomplete = "current-password";
      input.placeholder = t('header.passwordLabel');
      input.style.paddingRight = "2.5rem";

      // Bouton pour afficher/masquer le mot de passe
      const toggleBtn = document.createElement("button");
      toggleBtn.type = "button";
      toggleBtn.className = "password-toggle-btn";
      toggleBtn.setAttribute("aria-label", "Afficher le mot de passe");
      toggleBtn.style.cssText = "position: absolute; right: 0.5rem; background: none; border: none; cursor: pointer; padding: 0.25rem; color: var(--color-text-muted, #6c757d); display: flex; align-items: center; justify-content: center;";
      toggleBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';

      toggleBtn.addEventListener("click", () => {
        const isPassword = input.type === "password";
        input.type = isPassword ? "text" : "password";
        toggleBtn.setAttribute("aria-label", isPassword ? "Masquer le mot de passe" : "Afficher le mot de passe");
        toggleBtn.innerHTML = isPassword
          ? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>'
          : '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
        input.focus();
      });

      inputWrapper.appendChild(input);
      inputWrapper.appendChild(toggleBtn);

      modalBody.appendChild(label);
      modalBody.appendChild(inputWrapper);

      // Configuration des boutons
      saveBtn.textContent = t('header.validate');
      saveBtn.disabled = false;
      cancelBtn.textContent = t('common.cancel');
      cancelBtn.style.display = "";
      if (closeBtn) closeBtn.style.display = "";
      if (deleteBtn) deleteBtn.style.display = "none";

      // Fermer la modale
      function close(result) {
        overlay.classList.remove("modal-overlay--visible");
        document.body.style.overflow = '';
        overlay.onclick = null;
        modalTitle.textContent = "";
        modalBody.innerHTML = "";
        saveBtn.replaceWith(saveBtn.cloneNode(true));
        cancelBtn.replaceWith(cancelBtn.cloneNode(true));
        if (closeBtn) closeBtn.replaceWith(closeBtn.cloneNode(true));
        if (deleteBtn) deleteBtn.style.display = '';
        resolve(result);
      }

      // Événements
      document.getElementById("modalSave").addEventListener("click", () => {
        close(input.value || null);
      });

      document.getElementById("modalCancel").addEventListener("click", () => {
        close(null);
      });

      if (closeBtn) {
        document.getElementById("modalClose").addEventListener("click", () => {
          close(null);
        });
      }

      // Entrée pour valider
      input.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          close(input.value || null);
        } else if (e.key === "Escape") {
          e.preventDefault();
          close(null);
        }
      });

      // Cleanup any stale keydown handler from a previous openModal
      if (_modalKeydownHandler && overlay) {
        overlay.removeEventListener("keydown", _modalKeydownHandler);
        _modalKeydownHandler = null;
      }

      // Afficher la modale
      overlay.classList.add("modal-overlay--visible");

      // Focus sur le champ après ouverture
      setTimeout(() => input.focus(), 100);
    });
  }

  async function requireEditUnlock() {
    // Mode DEMO: pas de mot de passe requis
    if (DEMO_MODE) {
      editPasswordUnlocked = true;
      setEditMode(true, { silent: true });
      return true;
    }
    if (shouldAutoUnlockForTests()) {
      editPasswordUnlocked = true;
      setEditMode(true, { silent: true });
      return true;
    }
    if (editPasswordUnlocked) return true;
    // Server mode with JWT: JWT auth is sufficient, skip local password
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
      editPasswordUnlocked = true;
      return true;
    }
    if (typeof document === "undefined") {
      showReadOnlyNotice();
      return false;
    }
    const input = await showPasswordModal(t('header.authRequired'), t('header.editPasswordPrompt'));
    if (!input) {
      showReadOnlyNotice();
      return false;
    }
    const ok = await verifyEditPassword(input);
    if (!ok) {
      showToast2(t('toasts.wrongPassword'), "error");
      return false;
    }
    editPasswordUnlocked = true;
    const syncPwdInput = document.getElementById("serverSyncPassword");
    if (syncPwdInput) syncPwdInput.value = input;
    // Warn if using factory default password
    if (verifyEditPassword._lastMatchedFactory) {
      setTimeout(function() {
        showToast2(t('toasts.factoryPasswordWarning') || "Vous utilisez le mot de passe par d\u00e9faut. Veuillez le changer dans les param\u00e8tres de s\u00e9curit\u00e9.", "warning");
      }, 500);
    }
    return true;
  }
  function ensureEditMode(actionLabel = "edit") {
    if (!isReadOnlyMode()) return true;
    showReadOnlyNotice(actionLabel);
    return false;
  }
  function initReadOnlyBanner() {
    EDIT_LOCK_IDS.forEach((id) => {
      const el = document.getElementById(id);
      if (el) el.dataset.readonlyLock = "true";
    });
    // En mode DEMO : édition active par défaut (pas de mot de passe)
    // Sinon : démarrage par défaut en mode lecture seule
    if (DEMO_MODE) {
      setEditMode(true, { silent: true });
    } else {
      setEditMode(false, { silent: true });
    }
    updateModeBannerUi();
    updateEditModeButtonUi();
  }
  function showCorporateLocalLicenseWall() {
    var existing = document.getElementById('csiLicenseWall');
    if (existing) return;

    var _t = window.CSI_i18n ? window.CSI_i18n.t.bind(window.CSI_i18n) : function(k, fb) { return fb || k; };

    var overlay = document.createElement('div');
    overlay.id = 'csiLicenseWall';
    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:999999;';

    var modal = document.createElement('div');
    modal.style.cssText = 'background:var(--color-surface,#fff);border-radius:12px;padding:32px;max-width:500px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3);text-align:center;';

    var iconDiv = document.createElement('div');
    iconDiv.style.cssText = 'font-size:48px;margin-bottom:16px;color:var(--color-danger,#dc3545);';
    var iconEl = document.createElement('i');
    iconEl.className = 'fas fa-lock';
    iconDiv.appendChild(iconEl);

    var title = document.createElement('h2');
    title.style.cssText = 'margin:0 0 8px 0;color:var(--color-text,#333);';
    title.textContent = _t('license.wallTitle', 'Période d\'essai terminée');

    var desc = document.createElement('p');
    desc.style.cssText = 'margin:0 0 20px 0;color:var(--color-text-secondary,#666);';
    desc.textContent = _t('license.wallDesc', 'Veuillez entrer votre clé de licence CSI2-... pour continuer à utiliser l\'application.');

    var input = document.createElement('textarea');
    input.id = 'csiLicenseWallKey';
    input.placeholder = 'CSI2-...';
    input.style.cssText = 'width:100%;min-height:80px;padding:12px;border:1px solid var(--color-border,#ddd);border-radius:6px;margin-bottom:12px;font-family:monospace;font-size:0.85rem;resize:vertical;box-sizing:border-box;';

    var msg = document.createElement('div');
    msg.id = 'csiLicenseWallMsg';
    msg.style.cssText = 'margin-bottom:12px;font-size:0.9rem;display:none;padding:8px;border-radius:6px;';

    var btn = document.createElement('button');
    btn.style.cssText = 'padding:12px 24px;border:none;background:var(--color-primary,#3b82f6);color:white;border-radius:6px;cursor:pointer;font-size:1rem;font-weight:600;width:100%;';
    btn.textContent = _t('license.activate', 'Activer la licence');

    btn.addEventListener('click', function() {
        var key = (input.value || '').trim();
        if (!key || key.indexOf('CSI2-') !== 0) {
            msg.style.display = 'block';
            msg.style.background = 'var(--color-danger-bg,#fef2f2)';
            msg.style.color = 'var(--color-danger,#dc3545)';
            msg.textContent = _t('license.error', 'Clé de licence invalide');
            return;
        }
        btn.disabled = true;
        btn.textContent = '...';
        msg.style.display = 'none';

        var TM = window.CSI_TrialManager;
        if (TM) {
            TM.activate(key).then(function(result) {
                msg.style.display = 'block';
                msg.style.background = 'var(--color-success-bg,#f0fdf4)';
                msg.style.color = 'var(--color-success,#22c55e)';
                msg.textContent = _t('license.success', 'Licence activée !') + (result.organization ? ' — ' + result.organization : '');
                setTimeout(function() { location.reload(); }, 1500);
            }).catch(function(err) {
                btn.disabled = false;
                btn.textContent = _t('license.activate', 'Activer la licence');
                msg.style.display = 'block';
                msg.style.background = 'var(--color-danger-bg,#fef2f2)';
                msg.style.color = 'var(--color-danger,#dc3545)';
                msg.textContent = err.message || _t('license.error', 'Clé de licence invalide');
            });
        }
    });

    modal.appendChild(iconDiv);
    modal.appendChild(title);
    modal.appendChild(desc);
    modal.appendChild(input);
    modal.appendChild(msg);
    modal.appendChild(btn);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    setTimeout(function() { input.focus(); }, 100);
  }

  function initTrialBanner() {
    var TM = window.CSI_TrialManager;
    var LTM = window.CSI_LocalTrialManager;
    var ed = window._csiEdition;
    var isCorporateLocal = ed && ed.id === 'corporate-local';

    // Edition sans trial/licence (Community) → pas de bannière
    if (ed && !ed.trial && !ed.license) return;

    // Déterminer le manager à interroger
    var manager = isCorporateLocal ? LTM : TM;
    if (!manager || !manager.isDetected()) return;

    // Remove existing banner if any
    var existing = document.getElementById('csiTrialBanner');
    if (existing) existing.remove();

    if (manager.isLicensed() && !manager.isLicenseExpired()) return;

    // Mur de licence complet pour Corporate Local expiré
    if (isCorporateLocal && manager.needsLicense()) {
        showCorporateLocalLicenseWall();
        return;
    }

    var _t = window.CSI_i18n ? window.CSI_i18n.t.bind(window.CSI_i18n) : function(k, fb) { return fb || k; };
    var banner = document.createElement('div');
    banner.id = 'csiTrialBanner';

    if (manager.isTrialActive()) {
      banner.className = 'csi-trial-banner csi-trial-banner--active';
      var icon = document.createElement('i');
      icon.className = 'fas fa-clock';
      icon.style.marginRight = '6px';
      banner.appendChild(icon);
      banner.appendChild(document.createTextNode(
        _t('license.trialBannerText', 'Essai gratuit \u2014 {days} jours restants sur 14').replace('{days}', manager.getDaysLeft())
      ));
      var link = document.createElement('a');
      link.href = '#';
      link.textContent = ' | ' + _t('license.pasteKey', 'Entrer une clé');
      link.style.cssText = 'color:inherit;text-decoration:underline;cursor:pointer;margin-left:4px;';
      link.addEventListener('click', function(e) {
        e.preventDefault();
        if (window.CSI_LicenseManager) window.CSI_LicenseManager.showActivationModal();
      });
      banner.appendChild(link);
    } else if (manager.isTrialExpired() || manager.isLicenseExpired()) {
      banner.className = 'csi-trial-banner csi-trial-banner--expired';
      banner.textContent = manager.isLicenseExpired()
        ? _t('license.renewNeeded', 'Licence expirée — entrez une nouvelle clé pour réactiver')
        : _t('license.readOnlyNotice', 'Mode lecture seule — entrez une clé de licence pour réactiver');
      var link2 = document.createElement('a');
      link2.href = '#';
      link2.textContent = ' ' + _t('license.activate', 'Activer');
      link2.style.cssText = 'color:inherit;text-decoration:underline;cursor:pointer;margin-left:4px;font-weight:600;';
      link2.addEventListener('click', function(e) {
        e.preventDefault();
        if (window.CSI_LicenseManager) window.CSI_LicenseManager.showActivationModal({ required: true });
      });
      banner.appendChild(link2);
    } else {
      return; // No trial state to show
    }

    document.body.insertBefore(banner, document.body.firstChild);
    // Fallback for browsers without :has() support (e.g., Firefox <121)
    document.body.classList.add('has-trial-banner');
  }

  async function handleEditModeButton() {
    if (isReadOnlyMode()) {
      // Viewer role: read-only — block edit mode with feedback
      if (window.CSI_AuthManager && window.CSI_AuthManager.isViewer()) {
        showToast2(t('header.viewerReadOnly', 'Votre rôle est Lecteur — mode lecture seule permanent.'), 'warning');
        return;
      }
      // En mode serveur, l'utilisateur est déjà authentifié via le login wall
      if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
        // Tenter d'acquérir le verrou d'édition global avant de basculer en mode édition.
        // Si refus (verrou détenu par un autre), afficher un toast et ne pas changer d'état.
        const ELM = window.CSI_EditLockManager;
        // === CSI-CO === (INV-C11) entrer en édition depuis un onglet NEUTRE (dashboard,
        // settings) n'acquiert AUCUNE grappe — rien n'y est éditable ; l'acquisition se fait
        // à la première navigation vers un vrai module (_csiEnsureClusterForTab).
        var _coNeutralEntry = !!(ELM && ELM.isCoedit && ELM.isCoedit()
          && ELM.isNeutralTab && ELM.isNeutralTab(_currentActiveTab));
        if (ELM && ELM.acquire && !_coNeutralEntry) {
          try {
            // === CSI-CO === co-édition par grappes : acquérir la grappe de l'onglet AFFICHÉ
            // (les autres modules restent disponibles pour les collègues). Hors co-édition :
            // acquire() sans argument = verrou unique historique, comportement inchangé.
            var _coClusters = (ELM.isCoedit && ELM.isCoedit() && ELM.clusterForTab)
              ? [ELM.clusterForTab(_currentActiveTab)] : undefined;
            const result = await ELM.acquire(_coClusters);
            if (!result || !result.granted) {
              // === CSI-CO === (décision PO 05/08) un seul onglet éditeur par compte : le
              // verrou serveur ne porte que l'userId, deux onglets s'y confondraient et l'un
              // libérerait les grappes de l'autre. Message dédié — « X édite déjà » serait
              // incompréhensible quand X est soi-même dans un autre onglet.
              if (result && result.otherTab) {
                showToast2(t('modeBanner.acquireOtherTab', "Vous éditez déjà dans une autre fenêtre (autre onglet, autre navigateur ou autre appareil). Quittez-y le mode édition, puis réessayez ici."), 'warning', 12000);
              } else {
                const holder = result && result.lock ? result.lock : null;
                const holderName = holder ? (holder.userName || holder.userId || '?') : '?';
                showToast2(t('modeBanner.acquireFailed', 'Édition impossible : {{name}} édite déjà').replace('{{name}}', holderName), 'warning');
              }
              updateEditModeButtonUi();
              updateModeBannerUi();
              return;
            }
            window._csiClusterReadOnly = false; // === CSI-CO === grappe de l'écran acquise
          } catch (e) {
            void 0;
            // FIX-LOCK-ACQUIRE-FAIL (audit 2026-06-10, bundle.js:5637) : ne JAMAIS entrer
            // en édition sans verrou confirmé. L'ancienne « dégradation gracieuse »
            // permettait l'édition concurrente silencieuse (et, avec EDIT_LOCK_ENFORCED,
            // toutes les écritures seraient refusées en 423).
            showToast2(t('modeBanner.acquireNetworkError', "Impossible de vérifier le verrou d'édition (problème réseau). Réessayez."), 'error');
            return;
          }
        }
        // FIX-EDITLOCK-REPULL (audit 2026-07-02, C1) : re-puller l'état serveur autoritaire
        // À L'ACQUISITION du verrou, AVANT toute édition. Sans ce refresh, un client resté
        // ouvert avec des données périmées (ex. un collègue connecté AVANT un import réalisé
        // par un autre admin) poussait ensuite ses vieilles données par-dessus l'état frais
        // → écrasement silencieux (aucun 409 : après un import les tab_*.json sont purgés, la
        // révision est inconnue → data-push legacy accepté). On recharge via loadData() (même
        // normalisation que le boot) puis on re-rend l'onglet courant. Best-effort : en cas
        // d'échec réseau on continue (comportement pré-fix), le verrou étant déjà détenu.
        try {
          if (typeof loadData === 'function') {
            // B2 : sauvegarder les modifs non confirmées (push refusé en 423) AVANT
            // que loadData() ne remplace appData par l'état serveur autoritaire.
            var _bkCount = 0;
            try { _bkCount = _csiBackupDirtyTabs('editlock-repull'); }
            catch (e0) { void 0; }
            await loadData();
            if (typeof _csiResetTabRevisions === 'function') _csiResetTabRevisions();
            if (_bkCount > 0) {
              // Les onglets sauvegardés ont été remplacés par l'état serveur : purger
              // leurs marqueurs dirty (le fichier de secours conserve la version locale).
              try {
                _csiDirtyTabs = {}; _pendingPushTabs = {};
                if (typeof _updateUnsavedFlag === 'function') _updateUnsavedFlag();
              } catch (e1) { /* best-effort */ }
              showToast2(t('toasts.dirtyBackedUpBeforeRepull',
                '{{n}} modification(s) non enregistrée(s) ont été sauvegardées dans un fichier de secours avant le rechargement de l\'état du serveur.').replace('{{n}}', _bkCount),
                'warning', 12000);
            }
            if (typeof updateDashboard === 'function') updateDashboard();
            var _reCur = (typeof _currentActiveTab !== 'undefined' && _currentActiveTab) ? _currentActiveTab : 'dashboard';
            if (typeof loadModuleContent === 'function') loadModuleContent(_reCur);
          }
        } catch (e) {
          void 0;
        }
        setEditMode(true);
        return;
      }
      // Mode local: mot de passe édition classique
      const ok = await requireEditUnlock();
      if (!ok) return;
      setEditMode(true);
    } else {
      // Quitter le mode édition → sauvegarder les données puis libérer le verrou.
      // FIX-RA-COMMIT-EXITS (vidéo POC 2026-08-18) : committer la saisie DOM du wizard
      // AVANT le saveData — sinon l'étape en cours était jetée pendant que l'indicateur
      // affichait « Sauvegardé sur le serveur » (le push lisait la RAM, jamais le DOM).
      try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
      try { saveData(); } catch(e) { void 0; }
      window._csiClusterReadOnly = false; // === CSI-CO === plus d'édition → plus d'override
      setEditMode(false);
      const ELM = window.CSI_EditLockManager;
      if (ELM && ELM.release && isServerModeUnlocked()) {
        // FIX-LOCK-RELEASE-RACE (audit 2026-06-10, bundle.js:12732) : attendre la fin des
        // push en vol/en attente AVANT de libérer le verrou — sinon, verrou appliqué
        // (EDIT_LOCK_ENFORCED), le push du saveData ci-dessus arriverait après le release
        // et serait refusé en 423.
        try {
          if (typeof _flushServerPushes === 'function') await _flushServerPushes(15000);
        } catch (e) { /* best effort */ }
        try { await ELM.release(); } catch (e) { /* best effort */ }
        updateModeBannerUi();
        updateEditModeButtonUi();
      }
    }
  }

  /**
   * Reprise admin du verrou d'édition (modal de confirmation).
   * Appelé depuis le bouton #modeBannerTakeoverBtn.
   */
  async function handleEditLockTakeover() {
    const ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.takeover) return;
    if (!window.CSI_AuthManager || !window.CSI_AuthManager.isAdmin || !window.CSI_AuthManager.isAdmin()) {
      showToast2(t('modeBanner.takeoverDenied', 'Action réservée aux administrateurs'), 'warning');
      return;
    }
    const holder = ELM.getCurrentHolder() || {};
    const holderName = holder.userName || holder.userId || '?';
    const msg = t('modeBanner.takeoverConfirm', 'Forcer la libération du verrou détenu par {{name}} ? Ses modifications non enregistrées seront perdues.').replace('{{name}}', holderName);
    if (!window.confirm(msg)) return;

    try {
      // === CSI-CO === co-édition : reprendre la grappe du module AFFICHÉ (pas tout le SI).
      var _coTkClusters = (ELM.isCoedit && ELM.isCoedit() && ELM.clusterForTab)
        ? [ELM.clusterForTab(_currentActiveTab)] : undefined;
      const result = await ELM.takeover(_coTkClusters);
      if (result && result.granted) {
        showToast2(t('modeBanner.takeoverSuccess', 'Verrou repris avec succès'), 'success');
        window._csiClusterReadOnly = false; // === CSI-CO ===
        setEditMode(true);
      } else {
        showToast2(t('modeBanner.takeoverFailed', 'Impossible de reprendre le verrou'), 'error');
      }
    } catch (e) {
      void 0;
      showToast2(t('modeBanner.takeoverFailed', 'Impossible de reprendre le verrou'), 'error');
    }
    updateEditModeButtonUi();
    updateModeBannerUi();
  }

  // === CSI-CO === (Lot 4, 2026-07-17) « Demander la main » — côté DEMANDEUR : envoie la
  // demande pour la grappe du module affiché ; le détenteur la reçoit au presence-ping.
  // _csiHandReqSentAt évite le re-spam (bouton « Demande envoyée » pendant le TTL 120 s).
  var _csiHandReqSentAt = {};
  async function handleHandRequestButton() {
    const ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.requestHand || !ELM.clusterForTab) return;
    var cluster = ELM.clusterForTab(_currentActiveTab);
    if (!cluster) return;
    try {
      const res = await ELM.requestHand(cluster);
      if (res && res.ok) {
        _csiHandReqSentAt[cluster] = Date.now();
        showToast2(t('modeBanner.requestHandConfirmed', 'Demande envoyée — {{name}} sera prévenu(e) que vous attendez ce module.')
          .replace('{{name}}', (ELM.getCurrentHolder() || {}).userName || '?'), 'success');
      } else {
        showToast2(t('modeBanner.requestHandFailed', 'Demande impossible pour le moment — réessayez.'), 'warning');
      }
    } catch (e) {
      showToast2(t('modeBanner.requestHandFailed', 'Demande impossible pour le moment — réessayez.'), 'warning');
    }
    updateModeBannerUi();
  }

  // === CSI-CO === (Lot 4) côté DÉTENTEUR : notification qu'un collègue attend une de mes
  // grappes (reçue via presence-ping, cf. lock-manager.js). Anti-spam : une notification
  // par demandeur+grappe toutes les 5 minutes. userName échappé par showToast2 (escapeHtml).
  var _csiHandReqSeen = {};
  window._csiOnHandRequests = function(reqs) {
    if (!Array.isArray(reqs)) return;
    var now = Date.now();
    reqs.forEach(function(r) {
      if (!r || !r.cluster) return;
      var k = (r.userId || '?') + '|' + r.cluster;
      if (_csiHandReqSeen[k] && now - _csiHandReqSeen[k] < 300000) return;
      _csiHandReqSeen[k] = now;
      var label = typeof _csiClusterLabel === 'function' ? _csiClusterLabel(r.cluster) : r.cluster;
      showToast2(t('modeBanner.handRequested', '{{name}} attend le module « {{module}} » — naviguez vers un autre module ou quittez l\'édition pour le libérer.')
        .replace('{{name}}', r.userName || '?').replace('{{module}}', label), 'info', 12000);
    });
  };
  // ============================================================
  // Système de licence serveur
  // ============================================================

  // LIC-003: Legacy HMAC secret key removed — only ECDSA v2.0 accepted

  // Clé publique ECDSA P-256 pour la validation des licences v2.0
  // Seule la clé publique est présente côté client (vérification uniquement)
  const LICENSE_ECDSA_PUBLIC_KEY = {
    kty: "EC",
    x: "mBme0rIcZa0ihwS-oxYuUr9KJX_4_U0C0GpW9ViO2wY",
    y: "i2kRXa86VJvwg191sJetv1W6uYldDnIKlIl13c_fCt4",
    crv: "P-256"
  };

  /**
   * Vérifie une signature ECDSA P-256 via Web Crypto API (version 2.0)
   * @param {string} content - Le contenu signé
   * @param {string} signatureHex - Signature en hexadécimal
   * @param {Object} publicKeyJwk - Clé publique au format JWK
   * @returns {Promise<boolean>}
   */
  async function ecdsaVerify(content, signatureHex, publicKeyJwk) {
    const encoder = new TextEncoder();
    const data = encoder.encode(content);
    const sigBytes = new Uint8Array(signatureHex.match(/.{1,2}/g).map(b => parseInt(b, 16)));

    const key = await crypto.subtle.importKey(
      'jwk',
      publicKeyJwk,
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['verify']
    );

    return crypto.subtle.verify(
      { name: 'ECDSA', hash: 'SHA-256' },
      key,
      sigBytes,
      data
    );
  }

  /**
   * Valide la signature d'un fichier de licence
   * LIC-003: Only ECDSA P-256 v2.0 accepted (legacy HMAC paths removed)
   * @param {Object} licenseData - Données de la licence
   * @param {string} signature - Signature à vérifier
   * @param {Object} meta - Métadonnées de la licence (optionnel)
   * @returns {Promise<boolean>}
   */
  async function validateLicenseSignature(licenseData, signature, meta) {
    try {
      // Reconstruit le contenu signé (ordre déterministe)
      const content = JSON.stringify({
        type: licenseData.type,
        organization: licenseData.organization,
        issuedAt: licenseData.issuedAt,
        expiresAt: licenseData.expiresAt || null,
        features: licenseData.features || ['server-sync']
      });

      // LIC-003: Only accept ECDSA P-256 signatures
      const signatureType = meta && meta.signatureType;
      const version = meta && meta.version;
      if (signatureType !== 'ecdsa-p256' && version !== '2.0') {
        void 0;
        return false;
      }

      return await ecdsaVerify(content, signature, LICENSE_ECDSA_PUBLIC_KEY);
    } catch (e) {
      void 0;
      return false;
    }
  }

  /**
   * Vérifie si une licence est expirée
   * @param {Object} licenseData
   * @returns {boolean}
   */
  function isLicenseExpired(licenseData) {
    if (!licenseData.expiresAt) return false; // Licence permanente
    return new Date(licenseData.expiresAt) < new Date();
  }

  /**
   * Vérifie si le mode serveur est débloqué (licence valide)
   * Interroge CSI_LicenseManager d'abord, fallback localStorage legacy
   * @returns {boolean}
   */
  function isServerModeUnlocked() {
    // Garde: en file:// le mode serveur est impossible par définition.
    // Sans ce guard, un localStorage legacy (smsi_server_license_activated)
    // pouvait faire passer le boot dans la branche serveur → AuthManager stub
    // appelle showLoginModal(cb=false) → Promise jamais résolue → spinner figé.
    if (window.location.protocol === 'file:') return false;

    // Source 1 : licence serveur auto-détectée depuis config/license.json
    var LM = window.CSI_LicenseManager;
    if (LM && LM.isDetected() && LM.isServerMode()) return true;

    // [SUPPRIMÉ — FIX-CORPLOCAL-HTTP, audit 2026-06-10] Source 2 : Corporate Local avec
    // licence active. Cette édition est définie avec serverMode:false et son package ne
    // contient AUCUN backend PHP : la branche ne pouvait jamais se déclencher en file://
    // (guard ci-dessus) et, servie en http(s)/localhost (contournement documenté du
    // problème Firefox+file://), elle enfermait l'application derrière un login JWT
    // pointant vers un users.php inexistant (modal de setup non fermable, spinner figé,
    // données localStorage inaccessibles).
    var ed = window._csiEdition;

    // Source 3 : Corporate Server (edition.js)
    if (ed && ed.id === 'corporate-server') return true;

    // Fallback legacy : licence dans localStorage (sera retiré après Phase 4)
    var Storage = window.CSI_Storage;
    if (!Storage) return false;
    if (!Storage.isServerLicenseActivated()) return false;
    var license = Storage.getServerLicense();
    if (!license) return false;
    if (isLicenseExpired(license)) {
      Storage.deactivateServerLicense();
      return false;
    }
    return true;
  }

  // [REMOVED] showLicenseUploadModal — licence désormais auto-détectée via config/license.json
  // [REMOVED] handleServerSyncButton — sync panel supprimé, remplacé par auto-sync (Phase 3)

  // Stub pour compatibilité temporaire
  function handleServerSyncButton() {}

  // Exposer les fonctions pour usage externe
  window.isServerModeUnlocked = isServerModeUnlocked;
  window.validateLicenseSignature = validateLicenseSignature;
  window.isLicenseExpired = isLicenseExpired;

  window.refreshReadOnlyLocks = refreshReadOnlyLocks;
  window.isReadOnlyMode = isReadOnlyMode;
  window.ensureEditMode = ensureEditMode;
  window.showReadOnlyNotice = showReadOnlyNotice;
  window.handleEditModeButton = handleEditModeButton;
  window.handleServerSyncButton = handleServerSyncButton;
  window.getThemeVars = typeof getThemeVars === "function"
    ? getThemeVars
    : () => ({ textColor: "#2d3e4a", borderColor: "#e9ecef", surface: "#ffffff" });


  // modules/modal.js
  var _modalKeydownHandler = null;
  var _modalMandatory = false;
  var _previousFocusElement = null;
  function openModal(title, fields, onSave, onDelete = null, options = {}) {
    _previousFocusElement = document.activeElement;
    const overlay = document.getElementById("modalOverlay");
    // Cleanup previous keydown handler to prevent accumulation
    if (_modalKeydownHandler && overlay) {
      overlay.removeEventListener("keydown", _modalKeydownHandler);
      _modalKeydownHandler = null;
    }
    const modalTitle = document.getElementById("modalTitle");
    const modalBody = document.getElementById("modalBody");
    const saveBtn = document.getElementById("modalSave");
    const cancelBtn = document.getElementById("modalCancel");
    const closeBtn = document.getElementById("modalClose");
    const deleteBtn = document.getElementById("modalDelete");
    const allowReadOnly = options.allowReadOnly === true;
    const saveLabel = options.saveLabel || t('common.save', 'Enregistrer');
    const cancelLabel = options.cancelLabel || t('common.cancel', 'Annuler');
    const deleteLabel = options.deleteLabel || t('common.delete', 'Supprimer');
    const hideCancel = options.hideCancel === true;
    const hideClose = options.hideClose === true;
    _modalMandatory = (hideCancel && hideClose);
    const message = options.message || "";
    const lockedInitially = isReadOnlyMode() && !allowReadOnly;
    if (!overlay || !modalTitle || !modalBody || !saveBtn || !cancelBtn || !closeBtn || !deleteBtn) {
      void 0;
      return;
    }
    void 0;
    // Nettoyer les handlers onclick stale laissés par showConfirmModal
    saveBtn.onclick = null;
    cancelBtn.onclick = null;
    closeBtn.onclick = null;
    deleteBtn.onclick = null;
    modalTitle.textContent = title;
    modalBody.innerHTML = "";
    if (message) {
      const messageEl = document.createElement("p");
      messageEl.className = "modal__message";
      messageEl.textContent = message;
      modalBody.appendChild(messageEl);
    }
    fields.forEach((f) => {
      const label = document.createElement("label");
      label.textContent = f.label;
      label.htmlFor = `modal-input-${f.name}`;
      label.className = f.required ? "modal__label modal__label--required" : "modal__label";
      let input;
      let fieldWrap;
      let datalistEl = null;
      if (f.type === "textarea") {
        input = document.createElement("textarea");
        input.value = f.value != null ? f.value : "";
      } else if (f.type === "select" || f.type === "multiselect") {
        input = document.createElement("select");
        if (f.type === "multiselect") input.multiple = true;
        (f.options || []).forEach((opt) => {
          const option = document.createElement("option");
          if (typeof opt === "object") {
            option.value = opt.value;
            option.textContent = opt.label;
          } else {
            option.value = opt;
            option.textContent = opt;
          }
          const val = Array.isArray(f.value) ? f.value : [f.value];
          if (val && val.includes(option.value)) option.selected = true;
          input.appendChild(option);
        });
      } else if (f.type === "checkbox") {
        input = document.createElement("input");
        input.type = "checkbox";
        input.checked = !!f.value;
        input.className = "modal__checkbox";
        label.classList.add("modal__label--checkbox");
        fieldWrap = document.createElement("div");
        fieldWrap.className = "modal__checkbox-row";
      } else if (f.type === "tags") {
        // Tags chips field
        var tagsContainer = document.createElement("div");
        tagsContainer.className = "tags-input-container";
        tagsContainer.id = "modal-tags-wrap-" + f.name;
        input = document.createElement("input");
        input.type = "text";
        input.className = "tags-input";
        input.placeholder = f.placeholder || t('projectRisks.tagPlaceholder', 'Ajouter un tag...');
        input.setAttribute("list", "modal-tags-list-" + f.name);
        var datalist = document.createElement("datalist");
        datalist.id = "modal-tags-list-" + f.name;
        (f.suggestions || []).forEach(function(s) { var opt = document.createElement("option"); opt.value = s; datalist.appendChild(opt); });
        // Init existing tags
        var initTags = Array.isArray(f.value) ? f.value : (f.value ? String(f.value).split(",").map(function(t) { return t.trim(); }).filter(Boolean) : []);
        function addTagChip(tag, container, inp) {
          if (!tag) return;
          var existingChips = container.querySelectorAll(".tag-chip");
          for (var i = 0; i < existingChips.length; i++) {
            if (existingChips[i].getAttribute("data-tag") === tag) return;
          }
          var chip = document.createElement("span");
          chip.className = "tag-chip";
          chip.setAttribute("data-tag", tag);
          chip.innerHTML = escapeHtml(tag) + ' <button type="button" class="tag-chip__remove">&times;</button>';
          chip.querySelector(".tag-chip__remove").addEventListener("click", function(e) { e.stopPropagation(); chip.remove(); });
          container.insertBefore(chip, inp);
        }
        initTags.forEach(function(tag) { addTagChip(tag, tagsContainer, input); });
        // Lock tags in readonly mode
        var tagsLocked = (lockedInitially && !f.allowInReadOnly) || f.readOnly;
        if (tagsLocked) {
          input.disabled = true;
          tagsContainer.style.pointerEvents = "none";
          tagsContainer.style.opacity = "0.6";
        }
        input.addEventListener("keydown", function(e) {
          if (e.key === "Enter" || e.key === ",") {
            e.preventDefault();
            var val = input.value.trim().replace(/,$/,"");
            if (val) { addTagChip(val, tagsContainer, input); input.value = ""; }
          }
          if (e.key === "Backspace" && !input.value) {
            var chips = tagsContainer.querySelectorAll(".tag-chip");
            if (chips.length) chips[chips.length - 1].remove();
          }
        });
        tagsContainer.appendChild(input);
        tagsContainer.appendChild(datalist);
        tagsContainer.addEventListener("click", function() { input.focus(); });
        fieldWrap = tagsContainer;
      } else if (f.type === "searchable-select") {
        // Searchable Select: delegates to factory function buildSearchableSelectWidget
        var ssLocked = (lockedInitially && !f.allowInReadOnly) || f.readOnly;
        var ssWidget = buildSearchableSelectWidget(f.name, f.groups || [], f.value || "", f.placeholder, ssLocked);
        input = ssWidget.hiddenInput;
        modalBody.appendChild(label);
        modalBody.appendChild(ssWidget.container);
        return; // Skip normal field append logic
      } else if (f.type === "html") {
        // HTML content field (read-only display)
        var htmlContainer = document.createElement("div");
        htmlContainer.className = "modal__html-content";
        htmlContainer.id = "modal-input-" + f.name;
        htmlContainer.innerHTML = f.value || '';
        // Create a hidden input for the data collection
        input = document.createElement("input");
        input.type = "hidden";
        input.id = "modal-input-" + f.name + "-hidden";
        input.value = f.value || '';
        input.dataset.htmlField = "true";
        modalBody.appendChild(label);
        modalBody.appendChild(htmlContainer);
        modalBody.appendChild(input);
        return; // Skip the normal field append logic below
      } else {
        input = document.createElement("input");
        input.type = f.type || "text";
        input.value = f.value != null ? f.value : "";
      }
      input.dataset.readonlyLock = "true";
      if (f.readOnly) input.dataset.permanentReadonly = "true";
      const lockedField = (lockedInitially && !f.allowInReadOnly) || f.readOnly;
      if (lockedField) input.disabled = true;
      if (f.help) input.title = f.help;
      // Validation côté client
      if (f.required) input.required = true;
      if (f.minLength) input.minLength = f.minLength;
      if (f.maxLength) input.maxLength = f.maxLength;
      if (f.pattern) input.pattern = f.pattern;
      if (f.min !== undefined) input.min = f.min;
      if (f.max !== undefined) input.max = f.max;
      if (f.placeholder) input.placeholder = f.placeholder;
      if (f.suggestions && input && input.tagName === "INPUT" && input.type !== "checkbox" && input.type !== "hidden") {
        var suggestionId = "modal-input-" + f.name + "-suggestions";
        input.setAttribute("list", suggestionId);
        datalistEl = document.createElement("datalist");
        datalistEl.id = suggestionId;
        var seenSuggestion = {};
        (f.suggestions || []).forEach(function(suggestion) {
          var value = typeof suggestion === "string" ? suggestion.trim() : "";
          if (!value || seenSuggestion[value]) return;
          seenSuggestion[value] = true;
          var option = document.createElement("option");
          option.value = value;
          datalistEl.appendChild(option);
        });
      }
      input.id = `modal-input-${f.name}`;
      if (f.type === "checkbox") {
        if (fieldWrap) {
          fieldWrap.append(input, label);
          modalBody.appendChild(fieldWrap);
        }
      } else if (f.type === "tags") {
        modalBody.appendChild(label);
        modalBody.appendChild(fieldWrap);
      } else {
        input.className = "form-control";
        modalBody.appendChild(label);
        modalBody.appendChild(input);
      }
      if (datalistEl) modalBody.appendChild(datalistEl);
      // Hint d'aide sous le champ
      if (f.type !== "checkbox" && f.help) {
        const hint = document.createElement("small");
        hint.className = "form-hint";
        hint.textContent = f.help;
        modalBody.appendChild(hint);
      }
    });
    // M14 (audit 2026-07-10) : suivi d'un formulaire modifié pour éviter la perte silencieuse
    // de saisie (fiche longue : justification SoA, plan de traitement) sur un clic accidentel
    // hors-modale ou un Échap réflexe.
    var _isDirty = false;
    function close() {
      overlay.classList.remove("modal-overlay--visible");
      document.body.style.overflow = '';
      overlay.removeEventListener("keydown", handleKeydown);
      overlay.onclick = null;
      if (modalBody) { modalBody.oninput = null; modalBody.onchange = null; }
      _isDirty = false;
      _modalKeydownHandler = null;
      saveBtn.onclick = null;
      cancelBtn.onclick = null;
      closeBtn.onclick = null;
      deleteBtn.onclick = null;
    }
    // M14 : fermeture GARDÉE pour les chemins ACCIDENTELS (Échap, clic sur le fond). Si le
    // formulaire a été modifié, demander confirmation avant d'abandonner. Les boutons
    // explicites (Annuler, ✕) restent une fermeture directe (intention claire de l'utilisateur).
    function closeGuarded() {
      if (_isDirty && !window.confirm(t('common.discardConfirm', 'Abandonner les modifications non enregistrées ?'))) return;
      close();
    }
    function handleSave() {
      if (isReadOnlyMode() && !allowReadOnly) {
        showReadOnlyNotice("enregistrer");
        return;
      }
      // T3-004: Prevent double-submit
      if (saveBtn && saveBtn.disabled) return;
      if (saveBtn) saveBtn.disabled = true;
      _isDirty = false; // M14 : sauvegarde en cours → plus de garde d'abandon
      const data = {};
      fields.forEach((f) => {
        const inp = document.getElementById(`modal-input-${f.name}`);
        if (!inp) {
          data[f.name] = "";
          return;
        }
        if (f.type === "html") {
          // Read from the hidden input for html fields
          var hiddenInp = document.getElementById("modal-input-" + f.name + "-hidden");
          data[f.name] = hiddenInp ? hiddenInp.value : (inp.innerHTML || '');
          return;
        } else if (f.type === "checkbox") {
          data[f.name] = inp.checked;
        } else if (f.type === "multiselect" || (inp.tagName === "SELECT" && inp.multiple)) {
          data[f.name] = Array.from(inp.selectedOptions).map((o) => o.value);
        } else if (f.type === "tags") {
          var wrap = document.getElementById("modal-tags-wrap-" + f.name);
          var chips = wrap ? wrap.querySelectorAll(".tag-chip") : [];
          data[f.name] = Array.from(chips).map(function(c) { return c.getAttribute("data-tag"); }).filter(Boolean);
          // Also grab any text in the input
          if (inp && inp.value.trim()) {
            data[f.name].push(inp.value.trim().replace(/,$/,""));
          }
        } else {
          data[f.name] = inp.value.trim();
        }
      });
      // FIX-MODAL-REQUIRED (audit 2026-06-10) : appliquer réellement les champs required.
      // openModal posait l'attribut HTML (input.required = true) mais handleSave appelait
      // onSave sans checkValidity ni <form> : un titre/une date vidé(e) corrompait l'état
      // en mémoire et bloquait toutes les sauvegardes suivantes (validateBeforeSave).
      const missingRequired = fields.find((f) => {
        if (!f.required) return false;
        const v = data[f.name];
        if (f.type === "checkbox") return !v;
        if (Array.isArray(v)) return v.length === 0;
        return v === undefined || v === null || String(v).trim() === "";
      });
      if (missingRequired) {
        showToast2(t('toasts.requiredFields', 'Veuillez remplir les champs obligatoires.') + ' (' + missingRequired.label + ')', 'error');
        const missingInput = document.getElementById(`modal-input-${missingRequired.name}`);
        if (missingInput && typeof missingInput.focus === 'function') missingInput.focus();
        if (saveBtn) saveBtn.disabled = false;
        return;
      }
      // PASS2-307: Also check _lastSaveFailed — if saveData() failed inside onSave, don't close
      _lastSaveFailed = false;
      if (onSave(data) !== false && !_lastSaveFailed) {
        close();
      } else {
        if (saveBtn) saveBtn.disabled = false;
      }
    }
    function handleDelete() {
      // === CSI-CO === (audit co-édition 2026-08-05) `lockedInitially` est un INSTANTANÉ pris
      // à l'ouverture de la modale : si la main est perdue pendant qu'elle est ouverte (sortie
      // d'édition, takeover admin, bascule de grappe), la suppression — et ses cascades —
      // partaient quand même. handleSave() réévalue déjà `isReadOnlyMode()` : on aligne le
      // chemin le plus destructeur sur le moins destructeur.
      if (isReadOnlyMode() && !allowReadOnly) {
        showReadOnlyNotice("delete");
        return;
      }
      close();
      if (onDelete) onDelete();
    }
    cancelBtn.textContent = cancelLabel;
    deleteBtn.textContent = deleteLabel;
    if (hideCancel) {
      cancelBtn.classList.add("hidden");
      cancelBtn.disabled = true;
    } else {
      cancelBtn.classList.remove("hidden");
      cancelBtn.disabled = false;
    }
    if (hideClose) {
      closeBtn.classList.add("hidden");
      closeBtn.disabled = true;
    } else {
      closeBtn.classList.remove("hidden");
      closeBtn.disabled = false;
    }
    if (lockedInitially) {
      saveBtn.disabled = true;
      deleteBtn.disabled = true;
      saveBtn.textContent = "Lecture seule";
    } else {
      saveBtn.disabled = false;
      saveBtn.textContent = saveLabel;
    }
    // Focus trap - garder le focus dans la modale
    function handleKeydown(e) {
      if (e.key === "Escape") {
        if (_modalMandatory) return;
        closeGuarded();
        return;
      }
      if (e.key === "Tab") {
        const modal = overlay.querySelector(".modal");
        const focusable = modal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }
    _modalKeydownHandler = handleKeydown;
    overlay.addEventListener("keydown", handleKeydown);
    // Use .onclick to auto-replace previous handlers (prevents accumulation)
    saveBtn.onclick = handleSave;
    cancelBtn.onclick = close;
    closeBtn.onclick = close;
    if (onDelete) {
      deleteBtn.classList.remove("hidden");
      deleteBtn.style.display = '';
      deleteBtn.disabled = lockedInitially;
      deleteBtn.onclick = handleDelete;
    } else {
      deleteBtn.classList.add("hidden");
      deleteBtn.disabled = true;
      deleteBtn.onclick = null;
    }
    overlay.classList.add("modal-overlay--visible");
    document.body.style.overflow = 'hidden';
    // Click-outside-to-close: click on overlay background (not on .modal content) closes the modal
    overlay.onclick = function(evt) {
      if (evt.target === overlay) {
        closeGuarded();
      }
    };
    // M14 : marquer le formulaire « modifié » à la première saisie/changement.
    if (modalBody) {
      modalBody.oninput = function() { _isDirty = true; };
      modalBody.onchange = function() { _isDirty = true; };
    }
    // Focus sur le premier champ de formulaire
    requestAnimationFrame(() => {
      const firstInput = modalBody.querySelector("input:not([type='checkbox']), textarea, select");
      if (firstInput) {
        firstInput.focus();
      }
      // Callback afterOpen pour du contenu personnalisé
      if (typeof options.afterOpen === "function") {
        options.afterOpen();
      }
    });
  }
  function closeModal() {
    const overlay = document.getElementById("modalOverlay");
    const modalTitle = document.getElementById("modalTitle");
    const modalBody = document.getElementById("modalBody");
    const saveBtn = document.getElementById("modalSave");
    const cancelBtn = document.getElementById("modalCancel");
    const closeBtn = document.getElementById("modalClose");
    const deleteBtn = document.getElementById("modalDelete");
    if (!overlay || !modalTitle || !modalBody) return;
    overlay.classList.remove("modal-overlay--visible");
    document.body.style.overflow = '';
    overlay.onclick = null;
    _modalMandatory = false;
    // Cleanup keydown handler
    if (_modalKeydownHandler) {
      overlay.removeEventListener("keydown", _modalKeydownHandler);
      _modalKeydownHandler = null;
    }
    // Restore focus to the element that triggered the modal
    if (_previousFocusElement && typeof _previousFocusElement.focus === 'function') {
      try { _previousFocusElement.focus(); } catch(e) {}
      _previousFocusElement = null;
    }
    modalTitle.textContent = "";
    while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
    // Nettoyer les handlers onclick pour éviter les interférences
    if (saveBtn) saveBtn.onclick = null;
    if (cancelBtn) cancelBtn.onclick = null;
    if (closeBtn) closeBtn.onclick = null;
    if (deleteBtn) deleteBtn.onclick = null;
  }
  window.openModal = openModal;
  window.closeModal = closeModal;
  window.showPasswordModal = showPasswordModal;
  // Custom confirm modal — replaces native confirm()
  function showConfirmModal(title, message, onConfirm, options) {
    _previousFocusElement = document.activeElement;
    options = options || {};
    var type = options.type || 'warning';
    var icon = options.icon || (type === 'danger' ? 'fa-triangle-exclamation' : type === 'warning' ? 'fa-exclamation-circle' : 'fa-info-circle');
    var confirmLabel = options.confirmLabel || t('common.confirm', 'Confirmer');
    var cancelLabel = options.cancelLabel || t('common.cancel', 'Annuler');
    var overlay = document.getElementById("modalOverlay");
    var modalTitle = document.getElementById("modalTitle");
    var modalBody = document.getElementById("modalBody");
    var saveBtn = document.getElementById("modalSave");
    var cancelBtn = document.getElementById("modalCancel");
    var closeBtn = document.getElementById("modalClose");
    var deleteBtn = document.getElementById("modalDelete");
    if (!overlay || !modalTitle || !modalBody || !saveBtn || !cancelBtn) return;
    modalTitle.textContent = title;
    modalBody.innerHTML = '<div class="confirm-modal__icon confirm-modal__icon--' + type + '"><i class="fas ' + icon + '"></i></div>' +
      '<div class="confirm-modal__message">' + escapeHtml(message) + '</div>';
    saveBtn.textContent = confirmLabel;
    saveBtn.className = 'btn ' + (type === 'danger' ? 'btn--danger' : 'btn--primary');
    saveBtn.style.display = '';
    saveBtn.disabled = false;
    cancelBtn.textContent = cancelLabel;
    cancelBtn.style.display = '';
    cancelBtn.disabled = false;
    // FIX-CONFIRM-CANCEL-HIDDEN (audit 2026-06-10) : openModal({hideCancel/hideClose})
    // pose la classe 'hidden' (display:none) sur ces boutons partagés ; le style inline
    // vide ne la surclasse pas — retirer la classe pour restaurer Annuler et la croix.
    cancelBtn.classList.remove('hidden');
    if (closeBtn) { closeBtn.style.display = ''; closeBtn.disabled = false; closeBtn.classList.remove('hidden'); }
    if (deleteBtn) deleteBtn.style.display = 'none';
    // Cleanup any stale keydown handler from a previous modal
    if (_modalKeydownHandler && overlay) {
      overlay.removeEventListener("keydown", _modalKeydownHandler);
      _modalKeydownHandler = null;
    }
    var _confirmEscHandler = function(evt) {
      if (evt.key === 'Escape') {
        overlay.removeEventListener('keydown', _confirmEscHandler);
        _modalKeydownHandler = null;
        closeModal();
        if (options.onCancel) options.onCancel();
      }
    };
    _modalKeydownHandler = _confirmEscHandler;
    overlay.addEventListener('keydown', _confirmEscHandler);
    saveBtn.onclick = function() { overlay.removeEventListener('keydown', _confirmEscHandler); _modalKeydownHandler = null; closeModal(); if (onConfirm) onConfirm(); };
    cancelBtn.onclick = function() { overlay.removeEventListener('keydown', _confirmEscHandler); _modalKeydownHandler = null; closeModal(); if (options.onCancel) options.onCancel(); };
    if (closeBtn) closeBtn.onclick = function() { overlay.removeEventListener('keydown', _confirmEscHandler); _modalKeydownHandler = null; closeModal(); if (options.onCancel) options.onCancel(); };
    overlay.classList.add("modal-overlay--visible");
    document.body.style.overflow = 'hidden';
    // Click-outside-to-close: click on overlay background acts as cancel
    overlay.onclick = function(evt) {
      if (evt.target === overlay) {
        overlay.removeEventListener('keydown', _confirmEscHandler);
        _modalKeydownHandler = null;
        closeModal();
        if (options.onCancel) options.onCancel();
      }
    };
  }
  window.showConfirmModal = showConfirmModal;

  // modules/kpis.js
  function loadKpiTable() {
    const tbody = document.getElementById("kpiTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('kpis')) return;
    tbody.innerHTML = "";
    const _s = SectionToolbar.getSort("kpis");
    const _sortKey = _s ? _s.key : "name";
    const _sortDir = _s ? _s.dir : "asc";
    const sortedKpis = [...appData.kpis].sort((a, b) => {
      var diff = (_sortKey === "value" || _sortKey === "progress") ? SortUtils.numeric(a.progress, b.progress) : SortUtils.text(a.title, b.title);
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedKpis.forEach((kpi) => {
      const row = document.createElement("tr");
      row.innerHTML = `
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(kpi.title)}</div></td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(kpi.description)}</div></td>
            <td><input type="text" class="form-control" data-readonly-lock="true" ${isReadOnlyMode() ? "disabled" : ""} value="${escapeHtml(kpi.comments)}" onchange="updateKpiComment('${escapeHtml(kpi.id)}', this.value)"></td>
            <td><div class="progress-bar" role="progressbar" aria-valuenow="${kpi.progress}" aria-valuemin="0" aria-valuemax="100" aria-label="${escapeHtml(kpi.title)} ${kpi.progress}%"><div class="progress-bar__fill" style="width: ${Math.max(0, Math.min(100, kpi.progress))}%"></div></div><span>${kpi.progress}%</span></td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger")) return;
        // Ne pas ouvrir la modale quand on interagit avec le champ commentaire inline
        // (ni tout autre champ de saisie ajouté ultérieurement dans la ligne).
        if (e.target.closest("input, textarea, select, a, button")) return;
        editKpi(kpi.id);
      });
      tbody.appendChild(row);
    });
  }
  function updateKpiComment(id, value) {
    if (!ensureEditMode("edit a KPI")) {
      refreshReadOnlyLocks();
      return;
    }
    const kpi = appData.kpis.find((k) => k.id === id);
    if (kpi) {
      kpi.comments = value;
      saveData();
    }
  }
  function editKpi(id) {
    const kpi = appData.kpis.find((k) => k.id === id);
    if (!kpi) return;
    openModal(t('modals.editKpi'), [
      { name: "title", label: t('fields.title'), value: kpi.title },
      { name: "description", label: t('fields.description'), value: kpi.description },
      { name: "comments", label: t('fields.comment'), value: kpi.comments },
      { name: "progress", label: "Progression (%)", type: "number", value: kpi.progress }
    ], (data) => {
      kpi.title = data.title;
      kpi.description = data.description;
      kpi.comments = data.comments;
      kpi.progress = Math.max(0, Math.min(100, parseInt(data.progress, 10) || 0));
            addAuditEntry('edit', 'kpis', kpi.title);
      saveData();
      loadKpiTable();
    }, () => deleteKpi(id));
  }
  function addKpi() {
    openModal(t('modals.addKpi'), [
      { name: "title", label: t('fields.title') },
      { name: "description", label: t('fields.description') },
      { name: "comments", label: t('fields.comment') },
      { name: "progress", label: "Progression (%)", type: "number", value: 0 }
    ], (data) => {
      if (!data.title) return false;
      const id = generateId("kpi");
      appData.kpis.push({
        id,
        title: data.title,
        description: data.description || "",
        comments: data.comments || "",
        progress: Math.max(0, Math.min(100, parseInt(data.progress, 10) || 0))
      });
            addAuditEntry('add', 'kpis', data.title);
      saveData();
      loadKpiTable();
      updateDashboard();
    });
  }
  function deleteKpi(id) {
    showConfirmModal(t('confirms.deleteKpi'), t('confirms.deleteKpi'), function() {
      var _kpi = appData.kpis.find(function(k) { return k.id === id; });
      appData.kpis = appData.kpis.filter((k) => k.id !== id);
            addAuditEntry('delete', 'kpis', _kpi ? _kpi.title : id);
      saveData();
      loadKpiTable();
      updateDashboard(); // PASS2-306: Refresh dashboard after KPI delete
    }, { type: 'danger' });
  }
  window.loadKpiTable = loadKpiTable;
  window.updateKpiComment = updateKpiComment;
  window.editKpi = editKpi;
  window.addKpi = addKpi;
  window.deleteKpi = deleteKpi;
  window.toggleTheme = toggleTheme;

  // modules/utils.js
  function escapeHtml(str) {
    if (str === null || str === void 0) return "";
    return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  }
  function isSafeUrl(url) {
    if (!url || typeof url !== 'string') return false;
    return /^https?:\/\//i.test(url.trim());
  }
  function fetchWithTimeout(url, options, timeoutMs) {
    timeoutMs = timeoutMs || 8000;
    options = options || {};
    var controller = new AbortController();
    var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
    options.signal = controller.signal;
    return fetch(url, options).finally(function() { clearTimeout(timer); });
  }
  const CSV_DELIMITER = ";";
  const CSV_BOM = "\uFEFF";
  const EXCEL_XML_BOM = "\uFEFF";
  let _exportBusy = false;
  function encodeUtf16Le(text) {
    const buffer = new ArrayBuffer(text.length * 2);
    const view = new DataView(buffer);
    for (let i = 0; i < text.length; i++) {
      view.setUint16(i * 2, text.charCodeAt(i), true);
    }
    return buffer;
  }
  // === CSI-ME === (audit import-export 2026-07-03, K6) suffixe de périmètre pour TOUS les
  // fichiers exportés — deux exports de deux entités étaient indiscernables par leur nom.
  // Chaîne vide hors mode multi-entités (noms historiques inchangés).
  function _csiExportScopeSlug() {
    try {
      if (appData._csiEntity && appData._csiEntity.id) {
        var nm = String(appData._csiEntity.name || appData._csiEntity.id);
        return '_' + (NORMALIZE(nm).replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || String(appData._csiEntity.id));
      }
      if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity && window.CSI_EntityManager.isMultiEntity()) {
        return '_' + t('entities.exportGroupTag', 'entite-principale');
      }
    } catch (e) { /* hors mode */ }
    return '';
  }
  function normalizeExportFilename(label) {
    const safe = NORMALIZE(label || "export").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
    return (safe || "export") + _csiExportScopeSlug();
  }
  function getExportDateStamp() {
    return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
  }
  function getExportLocale() {
    var lang = (window.CSI_i18n && typeof window.CSI_i18n.getLanguage === 'function')
      ? window.CSI_i18n.getLanguage() : 'fr';
    return { fr: 'fr-FR', en: 'en-GB', nl: 'nl-NL', es: 'es-ES', de: 'de-DE' }[lang] || 'fr-FR';
  }
  function cleanExportText(value) {
    if (value === null || value === void 0) return "";
    return String(value).replace(/\s+/g, " ").trim();
  }
  function escapeCsvValue(value) {
    const clean = cleanExportText(value);
    if (!clean) return "";
    // Anti-injection de formule Excel/CSV (CWE-1236) : neutralise les cellules
    // commençant par =,+,-,@ — réutilise la même protection que l'export .xls
    // (sanitizeExcelString), qui n'était appliquée qu'au chemin XML, pas au CSV.
    const text = sanitizeExcelString(clean);
    const needsQuotes = text.includes(CSV_DELIMITER) || text.includes('"') || /[\r\n]/.test(text);
    const escaped = text.replace(/"/g, '""');
    return needsQuotes ? `"${escaped}"` : escaped;
  }
  function buildCsvContent(headers, rows) {
    const lines = [];
    lines.push(`sep=${CSV_DELIMITER}`);
    if (headers.length) lines.push(headers.map(escapeCsvValue).join(CSV_DELIMITER));
    rows.forEach((row) => {
      lines.push(row.map(escapeCsvValue).join(CSV_DELIMITER));
    });
    return CSV_BOM + lines.join("\r\n");
  }
  function downloadCsvFile(label, headers, rows) {
    // Bloquer l'export en mode démo
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export Excel/CSV');
      return;
    }
    if (!rows.length) {
      showToast2(t('toasts.noDataToExport'), "warning");
      return;
    }
    try {
      const csvContent = buildCsvContent(headers, rows);
      const filename = `${normalizeExportFilename(label)}_${getExportDateStamp()}.csv`;
      const blob = new Blob([encodeUtf16Le(csvContent)], { type: "text/csv;charset=utf-16le;" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      showToast2(t('toasts.csvExportSuccess', 'Export CSV réussi'), "success");
    } catch (error) {
      void 0;
      showToast2(t('toasts.excelExportError'), "error");
    }
  }
  function buildMultiSectionCsvContent(sections) {
    const lines = [];
    lines.push(`sep=${CSV_DELIMITER}`);
    (sections || []).forEach((section, index) => {
      if (!section || !Array.isArray(section.headers)) return;
      if (index > 0) lines.push("");
      lines.push(escapeCsvValue(section.title || "Section"));
      lines.push(section.headers.map(escapeCsvValue).join(CSV_DELIMITER));
      (section.rows || []).forEach((row) => {
        lines.push(row.map(escapeCsvValue).join(CSV_DELIMITER));
      });
    });
    return CSV_BOM + lines.join("\r\n");
  }
  function downloadCsvContent(label, csvContent) {
    if (!csvContent) {
      showToast2(t('toasts.noDataToExport'), "warning");
      return;
    }
    try {
      const filename = `${normalizeExportFilename(label)}_${getExportDateStamp()}.csv`;
      const blob = new Blob([encodeUtf16Le(csvContent)], { type: "text/csv;charset=utf-16le;" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      showToast2(t('toasts.csvExportSuccess', 'Export CSV réussi'), "success");
    } catch (error) {
      void 0;
      showToast2(t('toasts.excelExportError'), "error");
    }
  }
  function stripInvalidXmlChars(text) {
    return String(text).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
  }
  function escapeExcelXml(value) {
    return stripInvalidXmlChars(value)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");
  }
  function sanitizeExcelString(value) {
    const text = String(value);
    return /^[=+\-@]/.test(text) ? `'${text}` : text;
  }
  function sanitizeSheetName(name) {
    const cleaned = cleanExportText(name || "Section").replace(/[\\/?*\[\]:]/g, " ").trim();
    const safe = cleaned || "Section";
    return safe.length > 31 ? safe.slice(0, 31) : safe;
  }
  function getExcelCellData(value) {
    if (value === null || value === void 0) return { type: "String", value: "" };
    if (typeof value === "number" && Number.isFinite(value)) return { type: "Number", value: String(value) };
    if (typeof value === "boolean") return { type: "Boolean", value: value ? "1" : "0" };
    if (value instanceof Date && Number.isFinite(value.getTime())) {
      return { type: "DateTime", value: value.toISOString() };
    }
    const text = sanitizeExcelString(String(value));
    return { type: "String", value: text };
  }
  function buildExcelRowXml(values, columnCount, isHeader) {
    const rowValues = Array.isArray(values) ? values : [];
    const padCount = Math.max(0, columnCount - rowValues.length);
    const padded = rowValues.concat(Array(padCount).fill(""));
    const cells = padded.map((cellValue) => {
      const cell = getExcelCellData(cellValue);
      const style = isHeader ? ' ss:StyleID="sHeader"' : "";
      return `<Cell${style}><Data ss:Type="${cell.type}">${escapeExcelXml(cell.value)}</Data></Cell>`;
    }).join("");
    return `<Row>${cells}</Row>`;
  }
  function buildExcelWorkbookXml(sections) {
    const usedNames = new Set();
    const workbookParts = [];
    workbookParts.push('<?xml version="1.0" encoding="UTF-8"?>');
    workbookParts.push('<?mso-application progid="Excel.Sheet"?>');
    workbookParts.push('<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:html="http://www.w3.org/TR/REC-html40">');
    workbookParts.push('<Styles><Style ss:ID="sHeader"><Font ss:Bold="1"/><Interior ss:Color="#D9E1F2" ss:Pattern="Solid"/></Style></Styles>');
    (sections || []).forEach((section) => {
      if (!section || !Array.isArray(section.headers)) return;
      const baseName = sanitizeSheetName(section.title || "Section");
      let sheetName = baseName;
      if (usedNames.has(sheetName)) {
        let index = 2;
        while (index < 1000) {
          const suffix = ` (${index})`;
          const candidateBase = baseName.slice(0, 31 - suffix.length);
          const candidate = `${candidateBase}${suffix}`;
          if (!usedNames.has(candidate)) {
            sheetName = candidate;
            break;
          }
          index += 1;
        }
      }
      usedNames.add(sheetName);
      const rows = Array.isArray(section.rows) ? section.rows : [];
      const rowLengths = rows.map((row) => Array.isArray(row) ? row.length : 0);
      const columnCount = Math.max(1, section.headers.length || 0, ...rowLengths);
      const totalRows = (section.headers.length ? 1 : 0) + rows.length;
      const rowCount = Math.max(1, totalRows);
      workbookParts.push(`<Worksheet ss:Name="${escapeExcelXml(sheetName)}">`);
      workbookParts.push(`<Table ss:ExpandedColumnCount="${columnCount}" ss:ExpandedRowCount="${rowCount}" x:FullColumns="1" x:FullRows="1">`);
      if (section.headers.length) {
        workbookParts.push(buildExcelRowXml(section.headers, columnCount, true));
      }
      rows.forEach((row) => {
        workbookParts.push(buildExcelRowXml(row, columnCount, false));
      });
      workbookParts.push("</Table>");
      workbookParts.push('<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><FreezePanes/><FrozenNoSplit/><SplitHorizontal>1</SplitHorizontal><TopRowBottomPane>1</TopRowBottomPane><ActivePane>2</ActivePane></WorksheetOptions>');
      workbookParts.push("</Worksheet>");
    });
    workbookParts.push("</Workbook>");
    return workbookParts.join("");
  }
  function downloadExcelWorkbook(label, sections) {
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export Excel');
      return;
    }
    if (!sections || !sections.length) {
      showToast2(t('toasts.noDataToExport'), "warning");
      return;
    }
    try {
      const xmlContent = buildExcelWorkbookXml(sections);
      const filename = `${normalizeExportFilename(label)}_${getExportDateStamp()}.xls`;
      const blob = new Blob([EXCEL_XML_BOM + xmlContent], { type: "application/vnd.ms-excel;charset=utf-8;" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      showToast2(t('toasts.excelExportSuccess'), "success");
    } catch (error) {
      void 0;
      showToast2(t('toasts.excelExportError'), "error");
    }
  }
  function isEmptyExportRow(row) {
    if (!row) return true;
    if (row.querySelector(".nis2-empty")) return true;
    const cells = row.querySelectorAll("td");
    if (cells.length === 1 && cells[0].getAttribute("colspan")) return true;
    return cells.length === 0;
  }
  function exportTableToCsv(tableId, label) {
    const table = document.getElementById(tableId);
    if (!table) {
      showToast2(t('toasts.exportTableNotFound', "Table d'export introuvable"), "error");
      return;
    }
    const headerCells = Array.from(table.querySelectorAll("thead th"));
    const headers = headerCells.map((cell) => cleanExportText(cell.textContent));
    // M8 (audit 2026-07-10) : une cellule dont le contenu est un champ de saisie
    // (<input>/<select>/<textarea>, ex. « Date CODIR » des risques, « Commentaire » des KPI)
    // a un textContent vide → colonne exportée vide pour toutes les lignes. On lit la valeur
    // du champ en repli (le libellé de l'option pour un select).
    const _cellExportText = (cell) => {
      const field = cell.querySelector("input, select, textarea");
      if (field) {
        if (field.tagName === "SELECT") {
          const opt = field.options && field.options[field.selectedIndex];
          return cleanExportText(opt ? opt.text : field.value);
        }
        return cleanExportText(field.value);
      }
      return cleanExportText(cell.textContent);
    };
    const rows = Array.from(table.querySelectorAll("tbody tr"))
      .filter((row) => !isEmptyExportRow(row))
      .map((row) => Array.from(row.querySelectorAll("td")).map((cell) => _cellExportText(cell)));
    downloadCsvFile(label, headers, rows);
  }
  function getNis2ProgramExportData() {
    ensureNis2Plan();
    // Export Budget Cybersécurité : lignes budgétaires autonomes, colonnes d'années
    // dynamiques, sous-totaux par catégorie + total général (cohérent avec l'écran).
    const years = getBudgetYears();
    // 1re colonne « Type » (Ligne / Sous-total / Total) : permet de filtrer les lignes de
    // détail dans Excel et d'éviter qu'une somme de colonne ne double-compte les totaux.
    const headers = [
      t('nis2Program.rowType', 'Type'),
      t('nis2Program.lineLabel', 'Intitulé'),
      t('nis2Program.lineCategory', 'Catégorie'),
      t('nis2Program.lineOwner', 'Responsable'),
      t('nis2Program.lineStatus', 'Statut')
    ].concat(years.slice()).concat([t('nis2Program.lineTotal', 'Total')]);
    const lines = getBudgetLines();
    const cats = getBudgetCategoriesList();
    const order = {};
    cats.forEach((c, i) => { order[c.value] = i; });
    const groups = {};
    lines.forEach((l) => { const c = l.category || 'divers'; (groups[c] = groups[c] || []).push(l); });
    const groupKeys = Object.keys(groups).sort((a, b) => (order[a] != null ? order[a] : 999) - (order[b] != null ? order[b] : 999));
    const rows = [];
    const grandYear = years.map(() => 0);
    let grandTotal = 0;
    groupKeys.forEach((catKey) => {
      const groupLines = groups[catKey];
      const subYear = years.map(() => 0);
      let subTotal = 0;
      groupLines.forEach((line) => {
        const amts = years.map((yr) => getBudgetLineYearAmount(line, yr));
        const total = amts.reduce((a, b) => a + b, 0);
        amts.forEach((v, i) => { subYear[i] += v; grandYear[i] += v; });
        subTotal += total; grandTotal += total;
        rows.push([
          t('nis2Program.rowTypeLine', 'Ligne'),
          line.label || '',
          budgetCategoryLabel(catKey),
          line.owner || '',
          budgetStatusMeta(line.status).label
        ].concat(amts).concat([total]));
      });
      rows.push([
        t('nis2Program.rowTypeSubtotal', 'Sous-total'),
        budgetCategoryLabel(catKey),
        '', '', ''
      ].concat(subYear).concat([subTotal]));
    });
    rows.push([
      t('nis2Program.rowTypeTotal', 'Total'),
      t('nis2Program.grandTotal', 'Total général'),
      '', '', ''
    ].concat(grandYear).concat([grandTotal]));
    return { headers, rows };
  }
  function exportNis2ProgramExcel(label) {
    const { headers, rows } = getNis2ProgramExportData();
    downloadCsvFile(label, headers, rows);
  }
  function getNis2SocleExportData() {
    const headers = [t('excelHeaders.soclePillar'), t('excelHeaders.socleTitle'), t('excelHeaders.socleSubtitle'), t('excelHeaders.socleElement'), t('excelHeaders.socleOwner')];
    const pillars = normalizeSoclePillars(appData.soclePillars);
    const rows = [];
    pillars.forEach((pillar, index) => {
      const pillarLabel = t('labels.pillar', {number: index + 1});
      const items = Array.isArray(pillar.items) ? pillar.items : [];
      if (!items.length) {
        rows.push([
          pillarLabel,
          pillar.title || "",
          pillar.subtitle || "",
          "",
          pillar.owner || ""
        ]);
        return;
      }
      items.forEach((item) => {
        rows.push([
          pillarLabel,
          pillar.title || "",
          pillar.subtitle || "",
          item,
          pillar.owner || ""
        ]);
      });
    });
    return { headers, rows };
  }
  function exportNis2SocleExcel(label) {
    const { headers, rows } = getNis2SocleExportData();
    downloadCsvFile(label, headers, rows);
  }
  function exportCustomExcel(key, label) {
    switch (key) {
      case "nis2-program":
        exportNis2ProgramExcel(label);
        break;
      case "nis2-socle":
        exportNis2SocleExcel(label);
        break;
      default:
        showToast2(t('exportLabels.exportNotSupported'), "error");
        break;
    }
  }
  function setupExcelExportButtons() {
    document.querySelectorAll("[data-export-table]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const tableId = btn.dataset.exportTable || "";
        const label = btn.dataset.exportName || "export";
        if (tableId === 'recyfControlsTable' && typeof exportRecyfControlsCsv === 'function') {
          exportRecyfControlsCsv(label);
        } else if (_exportSectionDataDriven(tableId, label)) {
          // M7 : export piloté par les données (colonnes complètes) — voir helper.
        } else {
          exportTableToCsv(tableId, label);
        }
      });
    });
    document.querySelectorAll("[data-export-custom]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const key = btn.dataset.exportCustom || "";
        const label = btn.dataset.exportName || "export";
        exportCustomExcel(key, label);
      });
    });
  }
  // M7 (audit 2026-07-10) : exporter une section depuis le MODÈLE (getAllSectionExportData)
  // plutôt qu'en scrapant le DOM (exportTableToCsv). Depuis la refonte « ≤7 colonnes », l'export
  // de section perdait les colonnes retirées de l'écran (Risques 7 vs 12, NIS2 7 vs 12 :
  // justification, preuves, fréquence de revue absentes), sortait les textes tronqués à l'écran,
  // et restreignait silencieusement les lignes à un filtre/recherche actif. On aligne donc les
  // boutons de section sur les données complètes (même source que l'export Excel global).
  // Retourne true si la section a été exportée en data-driven, false → repli DOM (exportTableToCsv).
  var _EXPORT_TABLE_TO_SECTION = {
    controlsTable: 'controls', nis2ControlsTable: 'nis2-controls', soaTable: 'soa',
    docReviewTable: 'docReview', actionsTable: 'actions', risksTable: 'risks',
    threatsTable: 'threats', ncTable: 'nonconformities', auditsTable: 'audits',
    reviewsTable: 'reviews', stakeholdersTable: 'stakeholders', documentsTable: 'documents',
    kpiTable: 'kpis', criticalAssetsTable: 'critical-assets', projectRisksTable: 'project-risks'
  };
  function _exportSectionDataDriven(tableId, label) {
    var key = _EXPORT_TABLE_TO_SECTION[tableId];
    if (!key) return false;
    try {
      var sections = getAllSectionExportData();
      var sec = sections.find(function(s) { return s.key === key; });
      if (!sec || !Array.isArray(sec.headers) || !Array.isArray(sec.rows)) return false;
      downloadCsvFile(label, sec.headers, sec.rows);
      return true;
    } catch (e) {
      void 0;
      return false;
    }
  }
  // FIX-RECYF-CSV : hissée au niveau module — exportRecyfControlsCsv() l'appelle aussi,
  // et la définition locale à getAllSectionExportData() lui était invisible
  // (ReferenceError systématique : l'export CSV ReCyF ne produisait aucun fichier).
  function getOptionLabel(options, value) {
    if (!Array.isArray(options)) return value || "";
    const match = options.find((opt) => {
      if (!opt) return false;
      if (typeof opt === "object") return opt.value === value;
      return opt === value;
    });
    if (!match) return value || "";
    return typeof match === "object" ? match.label : match;
  }
  function getAllSectionExportData() {
    ensureNis2Plan();
    ensureCriticalAssets();
    ensureProjectRisks();
    const formatDate = (value, fallback = "N/A") => {
      if (!value) return fallback;
      const date = new Date(value);
      return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
    };
    const formatProjectRiskDate = (value) => {
      if (!value) return "—";
      const date = new Date(value);
      return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
    };
    const getCapabilityLabel = (key) => {
      if (!key) return "";
      const cap = OPERATIONAL_CAPABILITIES.find((c) => c.key === key);
      return cap ? cap.label : String(key).replace(/_/g, " ");
    };
    const formatCapabilities = (value) => {
      if (Array.isArray(value)) {
        return value.map((item) => getCapabilityLabel(item)).filter(Boolean).join(", ");
      }
      return getCapabilityLabel(value);
    };
    const assetLookup = getCriticalAssetLookup();
    const getCriticalAssetLabels = (ids) => {
      const list = Array.isArray(ids) ? ids.filter(Boolean) : [];
      if (!list.length) return [];
      return list.map((id) => {
        const asset = assetLookup.get(id);
        return asset ? getCriticalAssetLabel(asset) : id;
      }).filter(Boolean);
    };
    const getActionLinkLabels = (action) => {
      if (!action) return [];
      const type = action.linkType || (action.controlId ? "control" : "");
      const ids = getActionLinkIds(action);
      if (type === "control") {
        const id = ids[0];
        if (!id) return [];
        const resolved = resolveControlById(id);
        return [resolved ? resolved.label : id];
      }
      if (type === "risk") {
        return ids.map((id) => {
          const risk = (appData.risks || []).find((r) => r.id === id);
          return risk ? risk.title : id;
        }).filter(Boolean);
      }
      if (type === "nc") {
        const id = ids[0];
        if (!id) return [];
        const nc = appData.nonconformities.find((n) => n.id === id);
        return [nc ? nc.title : id];
      }
      if (type === "audit") {
        const id = ids[0];
        if (!id) return [];
        const audit = appData.audits.find((a) => a.id === id);
        return [audit ? audit.title : id];
      }
      if (type === "threat") {
        const id = ids[0];
        if (!id) return [];
        const threat = appData.threats.find((t) => t.id === id);
        return [threat ? threat.title : id];
      }
      if (type === "continuous") {
        return ids.length ? ids.map(() => t('excelHeaders.continuousImprovement')) : [];
      }
      return [];
    };
    const sections = [];
    var _excelIsoControls = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const sortedControls = [..._excelIsoControls].sort((a, b) => SortUtils.reference(a.reference, b.reference));
    const controlRows = sortedControls.map((control) => {
      const actionIds = appData.actions
        .filter((a) => a.linkType === "control" && a.linkId === control.id)
        .map((a) => a.id)
        .join(", ");
      return [
        control.numero || "",
        isoCtrlTitle(control),
        isoCtrlDesc(control),
        formatCapabilities(control.subcategory),
        getOptionLabel(CONTROL_STATUS_OPTIONS, control.status),
        actionIds || t('excelHeaders.none'),
        control.cmm || ""
      ];
    });
    sections.push({
      key: "controls",
      title: t('reportSections.controls'),
      headers: [t('excelHeaders.controlNum'), t('excelHeaders.controlTitle'), t('excelHeaders.controlDescription'), t('excelHeaders.controlCapability'), t('excelHeaders.controlStatus'), t('excelHeaders.controlLinkedActions'), t('excelHeaders.controlCmm')],
      rows: controlRows
    });
    // FIX-EXCEL-RECYF (audit 2026-06-10) : la section NIS2 de l'export global suit le
    // référentiel ACTIF (modèle mutuellement exclusif) — ReCyF actif → feuille
    // « Mesures ReCyF » avec les évaluations réelles ; sinon feuille CyFun inchangée.
    if ((appData.nis2Referential || 'cyfun2025') === 'recyf2025' && typeof recyfControlsInScope === 'function') {
      if (typeof ensureRecyfControls === 'function') ensureRecyfControls();
      const recyfRows = recyfControlsInScope().map((c) => [
        c.ref || "",
        (c.objectiveId != null ? 'Obj. ' + c.objectiveId + ' — ' : '') + (c.objectiveLabel || ""),
        c.theme || "",
        c.content || "",
        c.owner || "",
        c.reviewer || "",
        getOptionLabel(CONTROL_REVIEW_FREQ_OPTIONS, c.reviewFrequency),
        c.lastEvaluation || "",
        c.lastEvaluatedBy || "",
        c.nextReviewDate || "",
        (c.level === 'essentielle') ? 'EE' : 'EI/EE',
        _recyfConformityLabel(c.conformity || 'non_evalue'),
        c.justification || "",
        c.evidence || ""
      ]);
      sections.push({
        key: "nis2-controls",
        title: t('nav.recyfControls', 'Mesures ReCyF'),
        headers: [t('recyfControls.ref', 'Réf.'), t('recyfControls.objective', 'Objectif'), t('recyfControls.theme', 'Thématique'), t('recyfControls.measure', 'Mesure'), t('recyfControls.owner', 'Responsable de la mesure'), t('recyfControls.reviewer', 'Responsable du contrôle'), t('recyfControls.reviewFrequency', 'Fréquence de revue'), t('recyfControls.lastEvaluation', 'Dernière évaluation'), t('excelHeaders.nis2LastEvaluatedBy', 'Évalué par'), t('recyfControls.nextReviewDate', 'Prochaine revue'), t('recyfControls.target', 'Cible'), t('recyfControls.conformity', 'Conformité'), t('recyfControls.justification', 'Justification'), t('recyfControls.evidence', 'Preuves / Artefacts')],
        rows: recyfRows
      });
    } else {
      var _excelNis2Controls = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
      // GOV-NIS2 (2026-08-20) : export complet — réf CCB unique (tri), titre, niveau
      // d'assurance, sous-catégorie et codes fonction/catégorie bruts, en tête de ligne.
      const nis2Rows = _excelNis2Controls.map((control) => [
        control.ref || "",
        control.title || "",
        (control.level || "").toUpperCase(),
        control.subcategoryRef || "",
        control.functionId || "",
        control.categoryId || "",
        control.fonction || "",
        control.categorie || "",
        control.description || "",
        control.owner || "",
        control.reviewer || "",
        // GOV-NIS2 S5 (M16) : libellé localisé, pas la clé interne ('on_change'...).
        getOptionLabel(CONTROL_REVIEW_FREQ_OPTIONS, control.reviewFrequency),
        control.lastEvaluation || "",
        control.lastEvaluatedBy || "",
        control.nextReviewDate || "",
        getOptionLabel(CONTROL_STATUS_OPTIONS, control.status),
        // M-CF1 (audit fond 2026-07-11) : exporter les VRAIES entrées du scoring CCB —
        // maturité documentation (1-5), maturité implémentation (1-5) et flag mesure clé —
        // au lieu du seul CMM dérivé. Sans elles, l'export plat CyFun ne permettait pas de
        // reconstituer/auditer le score de conformité CCB.
        (control.docMaturity != null ? control.docMaturity : 0),
        (control.implMaturity != null ? control.implMaturity : 0),
        (control.isKeyMeasure ? t('common.yes', 'Oui') : t('common.no', 'Non')),
        (control.isManagementLinked ? t('common.yes', 'Oui') : t('common.no', 'Non')),
        // GOV-NIS2 S5 (M16) : cmm stocké en français figé — restitué via le libellé localisé.
        getOptionLabel(CMM_LEVEL_OPTIONS, control.cmm) || control.cmm || "",
        control.justification || "",
        control.evidence || ""
      ]);
      sections.push({
        key: "nis2-controls",
        title: t('reportSections.nis2Controls'),
        headers: [t('excelHeaders.nis2Ref', 'Réf.'), t('excelHeaders.nis2Title', 'Titre'), t('excelHeaders.nis2Level', 'Niveau'), t('excelHeaders.nis2Subcategory', 'Sous-catégorie'), t('excelHeaders.nis2FunctionCode', 'Code fonction'), t('excelHeaders.nis2CategoryCode', 'Code catégorie'), t('excelHeaders.nis2Function'), t('excelHeaders.nis2Category'), t('excelHeaders.nis2Description'), t('controlsNIS2.owner', 'Responsable de la mesure'), t('controlsNIS2.reviewer', 'Responsable du contrôle'), t('controlsNIS2.reviewFrequency', 'Fréquence de revue'), t('controlsNIS2.lastEvaluation', 'Dernière évaluation'), t('excelHeaders.nis2LastEvaluatedBy', 'Évalué par'), t('controlsNIS2.nextReviewDate', 'Prochaine revue'), t('excelHeaders.nis2Status'), t('cyfun.docMaturity', 'Maturité documentation'), t('cyfun.implMaturity', 'Maturité implémentation'), t('cyfun.keyMeasure', 'Mesure clé'), t('excelHeaders.nis2MgmtLinked', 'Liée au management'), t('excelHeaders.nis2Cmm', 'Maturité (dérivée)'), t('excelHeaders.nis2Justification'), t('cyfun.evidence', 'Preuves / Artefacts')],
        rows: nis2Rows
      });
    }
    const soaEntries = Array.isArray(appData.soa) ? appData.soa : [];
    const soaRows = sortedControls.map((control) => {
      const entry = soaEntries.find((e) => e.controlId === control.id);
      const applicable = entry ? entry.applicable : true;
      return [
        control.reference || "",
        isoCtrlTitle(control),
        applicable ? t('excelHeaders.soaApplicable') : t('excelHeaders.soaNonApplicable'),
        (entry && entry.justification) || ""
      ];
    });
    sections.push({
      key: "soa",
      title: t('reportSections.soa'),
      headers: [t('excelHeaders.soaReference'), t('excelHeaders.soaTitle'), t('excelHeaders.soaApplicability'), t('excelHeaders.soaJustification')],
      rows: soaRows
    });
    const docEntries = Array.isArray(appData.documentReview) ? appData.documentReview : [];
    const docRows = [];
    var _xlMgmtFw = _getActiveManagementFramework();
    var _xlIsEn = (typeof currentLang !== 'undefined' && currentLang === 'en') || (document.documentElement.lang === 'en');
    DOCUMENT_REVIEW_ITEMS.forEach((item) => {
      if (item.type === "chapter") {
        var _xlChTitle = item.title || "";
        if (_xlMgmtFw && _xlMgmtFw.chapters) {
          var _xlChId = (item.title || "").match(/^(\d+)/);
          if (_xlChId) {
            var _xlCh = _xlMgmtFw.chapters.find(function(c) { return c.id === _xlChId[1]; });
            if (_xlCh) _xlChTitle = (_xlIsEn && _xlCh.titleEn) ? _xlCh.titleEn : _xlCh.title;
          }
        }
        docRows.push([_xlChTitle, "", "", ""]);
        return;
      }
      const entry = docEntries.find((e) => e.reference === item.reference) || {};
      var _xlClauseNum = (item.reference || "").split(' ')[0];
      var _xlFwClause = _xlMgmtFw ? (_xlMgmtFw.clauses || []).find(function(c) { return c.reference === _xlClauseNum; }) : null;
      var _xlRef = (_xlFwClause && _xlIsEn && _xlFwClause.titleEn) ? (_xlClauseNum + ' ' + _xlFwClause.titleEn) : item.reference;
      var _xlDesc = (_xlFwClause && _xlIsEn && _xlFwClause.descriptionEn) ? _xlFwClause.descriptionEn : item.description;
      docRows.push([
        _xlRef || "",
        _xlDesc || "",
        entry.justification || "",
        entry.tool || ""
      ]);
    });
    sections.push({
      key: "docReview",
      title: t('reportSections.docReview'),
      headers: [t('excelHeaders.docReference'), t('excelHeaders.docExigence'), t('excelHeaders.docJustification'), t('excelHeaders.docTool')],
      rows: docRows
    });
    const actionRows = (appData.actions || []).map((action) => {
      const linkLabels = getActionLinkLabels(action);
      const linkText = linkLabels.length ? linkLabels.join(", ") : t('excelHeaders.na');
      const assetLabels = getCriticalAssetLabels(action.criticalAssetIds);
      const assetText = assetLabels.length ? assetLabels.join(", ") : t('excelHeaders.noAsset');
      const progressRaw = parseInt(action.progress, 10);
      const progress = Number.isFinite(progressRaw) ? Math.max(0, Math.min(100, progressRaw)) : 0;
      return [
        action.id || "",
        action.title || "",
        linkText,
        assetText,
        getOptionLabel(ACTION_PRIORITY_OPTIONS, action.priority),
        `${progress}%`,
        action.dueDate ? formatDate(action.dueDate, "N/A") : "N/A",
        action.assignedTo ? action.assignedTo : "N/A",
        // M6 (audit 2026-07-10) : statut de l'action (planifie/en_cours/termine/annule) —
        // absent de l'export global alors qu'il est badge de la table, champ du formulaire et
        // filtre ; sans lui, une action annulée à échéance passée est indiscernable d'un retard.
        getOptionLabel(ACTION_STATUS_OPTIONS, action.status),
        // Unification 2026-07-10 : exporter le CONTENU des commentaires (avant : simple compte)
        formatCommentsReadonly(action.comments).split('\n').join(' | ')
      ];
    });
    sections.push({
      key: "actions",
      title: t('reportSections.actions'),
      headers: [t('excelHeaders.actionId'), t('excelHeaders.actionTitle'), t('excelHeaders.actionLink'), t('excelHeaders.actionAssets'), t('excelHeaders.actionPriority'), t('excelHeaders.actionProgress'), t('excelHeaders.actionDueDate'), t('excelHeaders.actionOwner'), t('excelHeaders.actionStatus'), t('excelHeaders.actionComments')],
      rows: actionRows
    });
    const riskRows = (appData.risks || []).map((risk) => {
      const impactLabel = IMPACT_LEVEL_LABELS[risk.impact] || risk.impact || "";
      const probLabel = PROBABILITY_LEVEL_LABELS[risk.probability] || risk.probability || "";
      const score = Number.isFinite(risk.score)
        ? risk.score
        : (parseInt(risk.impact, 10) || 0) * (parseInt(risk.probability, 10) || 0);
      const level = risk.level || calculateRiskLevel(score);
      const linkedActions = (appData.actions || []).filter((a) => actionLinksToRisk(a, risk.id));
      const actionsText = linkedActions.length
        ? linkedActions.map((a) => a.title || a.id).join(", ")
        : t('excelHeaders.none');
      const assetLabels = getCriticalAssetLabels(risk.criticalAssetIds);
      const assetText = assetLabels.length ? assetLabels.join(", ") : t('excelHeaders.noAsset');
      const codirDate = risk.codirDate ? formatDate(risk.codirDate, risk.codirDate) : "";
      return [
        risk.id || "",
        risk.title || "",
        risk.description || "",
        impactLabel,
        probLabel,
        score,
        level,
        actionsText,
        assetText,
        risk.treatment || "",
        risk.owner || "", // M17
        codirDate,
        formatCommentsReadonly(risk.comments).split('\n').join(' | ')
      ];
    });
    sections.push({
      key: "risks",
      title: t('reportSections.risks'),
      headers: [t('excelHeaders.riskId'), t('excelHeaders.riskTitle'), t('excelHeaders.riskDescription'), t('excelHeaders.riskImpact'), t('excelHeaders.riskProbability'), t('excelHeaders.riskScore'), t('excelHeaders.riskLevel'), t('excelHeaders.riskLinkedActions'), t('excelHeaders.riskAssets'), t('excelHeaders.riskTreatment'), t('excelHeaders.riskOwner'), t('excelHeaders.riskCodirDate'), t('excelHeaders.riskComments')],
      rows: riskRows
    });
    const threatRows = (appData.threats || []).map((threat) => {
      const impactLabel = IMPACT_LEVEL_LABELS[threat.impact] || threat.impact || "";
      const probLabel = PROBABILITY_LEVEL_LABELS[threat.likelihood] || threat.likelihood || "";
      const score = Number.isFinite(threat.score)
        ? threat.score
        : (parseInt(threat.impact, 10) || 0) * (parseInt(threat.likelihood, 10) || 0);
      const level = threat.level || calculateRiskLevel(score);
      const linkedActionIds = appData.actions
        .filter((a) => a.linkType === "threat" && a.linkId === threat.id)
        .map((a) => a.id)
        .join(", ");
      const assetLabels = getCriticalAssetLabels(threat.criticalAssetIds);
      const assetText = assetLabels.length ? assetLabels.join(", ") : t('excelHeaders.noAsset');
      return [
        threat.title || "",
        threat.description || "",
        impactLabel,
        probLabel,
        score,
        level,
        THREAT_STATUS_LABELS[threat.status] || threat.status || "",
        linkedActionIds || t('excelHeaders.none'),
        assetText
      ];
    });
    sections.push({
      key: "threats",
      title: t('reportSections.threats'),
      headers: [t('excelHeaders.threatTitle'), t('excelHeaders.threatDescription'), t('excelHeaders.threatImpact'), t('excelHeaders.threatProbability'), t('excelHeaders.threatScore'), t('excelHeaders.threatLevel'), t('excelHeaders.threatStatus'), t('excelHeaders.threatLinkedActions'), t('excelHeaders.threatAssets')],
      rows: threatRows
    });
    const auditMap = new Map((appData.audits || []).map((audit) => [audit.id, audit.title || audit.id]));
    const ncRows = (appData.nonconformities || []).map((nc) => {
      const relatedActions = (appData.actions || []).filter((a) => (a.ncIds || []).includes(nc.id) || (a.linkType === "nc" && a.linkId === nc.id));
      const actionsText = relatedActions.length ? relatedActions.map((a) => a.title || a.id).join(", ") : t('excelHeaders.none');
      const auditText = auditMap.get(nc.auditId) || t('excelHeaders.noAsset');
      const assetLabels = getCriticalAssetLabels(nc.criticalAssetIds);
      const assetText = assetLabels.length ? assetLabels.join(", ") : t('excelHeaders.noAsset');
      return [
        nc.title || "",
        nc.type || "",
        nc.status || "",
        nc.detectionDate ? formatDate(nc.detectionDate, "N/A") : "N/A",
        nc.assignedTo ? nc.assignedTo : "N/A",
        auditText,
        actionsText,
        assetText,
        nc.description || ""
      ];
    });
    sections.push({
      key: "nonconformities",
      title: t('reportSections.nonconformities'),
      headers: [t('excelHeaders.ncTitle'), t('excelHeaders.ncType'), t('excelHeaders.ncStatus'), t('excelHeaders.ncDetectionDate'), t('excelHeaders.ncOwner'), t('excelHeaders.ncLinkedAudit'), t('excelHeaders.ncLinkedActions'), t('excelHeaders.ncAssets'), t('excelHeaders.ncOperations')],
      rows: ncRows
    });
    const auditRows = (appData.audits || []).map((audit) => {
      const linkedActionIds = (appData.actions || []).filter((a) => a.linkType === "audit" && a.linkId === audit.id).map((a) => a.id).join(", ");
      return [
        audit.title || "",
        audit.type || "",
        audit.scope || "",
        audit.plannedDate ? formatDate(audit.plannedDate, "N/A") : "N/A",
        audit.executionDate ? formatDate(audit.executionDate, "N/A") : "N/A",
        audit.auditor ? audit.auditor : "N/A",
        audit.status || "",
        audit.findings || "",
        audit.recommendations || "",
        linkedActionIds || t('excelHeaders.none')
      ];
    });
    sections.push({
      key: "audits",
      title: t('reportSections.audits'),
      headers: [t('excelHeaders.auditTitle'), t('excelHeaders.auditType'), t('excelHeaders.auditScope'), t('excelHeaders.auditPlannedDate'), t('excelHeaders.auditExecutionDate', "Date d'exécution"), t('excelHeaders.auditAuditor'), t('excelHeaders.auditStatus'), t('excelHeaders.auditFindings', 'Constatations'), t('excelHeaders.auditRecommendations', 'Recommandations'), t('excelHeaders.auditLinkedActions')],
      rows: auditRows
    });
    const reviewRows = (appData.reviews || []).map((review) => [
      review.date ? formatDate(review.date, "N/A") : "N/A",
      review.participants || "",
      review.inputs || "",
      review.decisions || "",
      formatCommentsReadonly(review.comments).split('\n').join(' | ')
    ]);
    sections.push({
      key: "reviews",
      title: t('reportSections.managementReviews'),
      headers: [t('excelHeaders.reviewDate'), t('excelHeaders.reviewParticipants'), t('excelHeaders.reviewInputs'), t('excelHeaders.reviewDecisions'), t('excelHeaders.reviewComments')],
      rows: reviewRows
    });
    const stakeholderRows = (appData.stakeholders || []).map((st) => [
      st.name || "",
      st.role || "",
      st.contact || "",
      st.notes || ""
    ]);
    sections.push({
      key: "stakeholders",
      title: t('reportSections.stakeholders'),
      headers: [t('excelHeaders.stakeholderName'), t('excelHeaders.stakeholderRole'), t('excelHeaders.stakeholderContact'), t('excelHeaders.stakeholderComments')],
      rows: stakeholderRows
    });
    const documentRows = (appData.documents || []).map((doc) => [
      doc.category || "",
      doc.type || "",
      doc.tool || "",
      doc.link || ""
    ]);
    sections.push({
      key: "documents",
      title: t('reportSections.documents'),
      headers: [t('excelHeaders.documentLevel'), t('excelHeaders.documentType'), t('excelHeaders.documentTool'), t('excelHeaders.documentLink')],
      rows: documentRows
    });
    const kpiRows = (appData.kpis || []).map((kpi) => {
      const progressRaw = parseInt(kpi.progress, 10);
      const progress = Number.isFinite(progressRaw) ? Math.max(0, Math.min(100, progressRaw)) : 0;
      return [
        kpi.title || "",
        kpi.description || "",
        kpi.comments || "",
        `${progress}%`
      ];
    });
    sections.push({
      key: "kpis",
      title: t('reportSections.kpis'),
      headers: [t('excelHeaders.kpiTitle'), t('excelHeaders.kpiDescription'), t('excelHeaders.kpiComment'), t('excelHeaders.kpiProgress')],
      rows: kpiRows
    });
    const nis2ProgramData = getNis2ProgramExportData();
    sections.push({
      key: "nis2-program",
      title: t('reportSections.nis2Program'),
      headers: nis2ProgramData.headers,
      rows: nis2ProgramData.rows
    });
    const nis2SocleData = getNis2SocleExportData();
    sections.push({
      key: "nis2-socle",
      title: t('reportSections.nis2Socle'),
      headers: nis2SocleData.headers,
      rows: nis2SocleData.rows
    });
    const assetRows = (appData.criticalAssets || []).slice().sort((a, b) => SortUtils.numeric(a.priority, b.priority)).map((asset, index) => {
      const tags = Array.isArray(asset.productTags) ? asset.productTags.filter(Boolean) : [];
      const productParts = [];
      if (asset.productCode) productParts.push(asset.productCode);
      if (asset.productName) productParts.push(asset.productName);
      let productLabel = productParts.join(" - ");
      if (tags.length) {
        const tagLabel = tags.join(", ");
        productLabel = productLabel ? `${productLabel} (Tags: ${tagLabel})` : `Tags: ${tagLabel}`;
      }
      const availability = asset.availability ? resolveCriticalAssetLabel(asset.availability, CRITICAL_ASSET_AVAILABILITY_OPTIONS) : "—";
      const availabilityLabel = asset.availabilityNote ? `${availability} (${asset.availabilityNote})` : availability;
      const conf = resolveCriticalAssetLabel(asset.confidentiality, CRITICAL_ASSET_CONF_RESOLVE_OPTIONS) || "—";
      const integrity = resolveCriticalAssetLabel(asset.integrity, CRITICAL_ASSET_INTEGRITY_OPTIONS) || "—";
      const assetType = resolveCriticalAssetLabel(asset.assetType, CRITICAL_ASSET_TYPE_OPTIONS) || "—";
      const importance = resolveCriticalAssetLabel(asset.importance, CRITICAL_ASSET_IMPORTANCE_OPTIONS) || "—";
      const impact = resolveCriticalAssetLabel(asset.impact, CRITICAL_ASSET_IMPACT_OPTIONS) || "—";
      const dacp = resolveCriticalAssetLabel(asset.dacpSensitive, CRITICAL_ASSET_DACP_OPTIONS) || "—";
      const mfaParts = [];
      if (asset.mfaStatus) mfaParts.push(resolveCriticalAssetLabel(asset.mfaStatus, CRITICAL_ASSET_MFA_STATUS_OPTIONS));
      if (asset.mfaDetail) mfaParts.push(asset.mfaDetail);
      const mfaLabel = mfaParts.length ? mfaParts.join(" - ") : "—";
      return [
        asset.priority || index + 1,
        asset.owner || "—",
        asset.beneficiary || "",
        productLabel || "",
        assetType,
        importance,
        resolveCriticalAssetLabel(asset.mtpd, CRITICAL_ASSET_RTO_OPTIONS) || "—",
        resolveCriticalAssetLabel(asset.rto, CRITICAL_ASSET_RTO_OPTIONS) || "—",
        resolveCriticalAssetLabel(asset.rpo, CRITICAL_ASSET_RPO_OPTIONS) || "—",
        availabilityLabel,
        conf,
        integrity,
        impact,
        dacp,
        mfaLabel,
        asset.lastReviewDate || "—",
        (asset.supportSystems || []).join(", ") || "—"
      ];
    });
    sections.push({
      key: "critical-assets",
      title: t('reportSections.criticalAssets'),
      headers: [t('excelHeaders.assetPriority'), t('excelHeaders.assetOwner', 'Propriétaire métier'), t('excelHeaders.assetBeneficiary'), t('excelHeaders.assetProduct'), t('excelHeaders.assetType'), t('excelHeaders.assetImportance'), t('excelHeaders.assetMtpd', 'MTPD'), t('excelHeaders.assetRto'), t('excelHeaders.assetRpo'), t('excelHeaders.assetAvailability'), t('excelHeaders.assetConfidentiality'), t('excelHeaders.assetIntegrity'), t('excelHeaders.assetImpact'), t('excelHeaders.assetDacp'), t('excelHeaders.assetMfa'), t('excelHeaders.assetLastReview', 'Dernière revue'), t('excelHeaders.assetSupportSystems', 'SI supports')],
      rows: assetRows
    });
    const projectRows = (appData.projectRisks || []).map((risk) => {
      const statusMeta = getProjectRiskStatusMeta(risk.status);
      const revision = risk.revisionNumber || "";
      return [
        risk.projectName || "",
        risk.beneficiary || "",
        risk.reportNumber || "",
        revision,
        risk.description || "",
        risk.analysisLink || "—",
        formatProjectRiskDate(risk.completionDate),
        statusMeta.label,
        risk.priority ? t('projectRisks.priority' + risk.priority.charAt(0).toUpperCase() + risk.priority.slice(1)) : ''
      ];
    });
    sections.push({
      key: "project-risks",
      title: t('reportSections.projectRisks'),
      headers: [t('excelHeaders.projectName'), t('excelHeaders.projectBeneficiary'), t('excelHeaders.projectReportNum'), t('excelHeaders.projectRevision'), t('excelHeaders.projectDescription'), t('excelHeaders.projectAnalysisLink'), t('excelHeaders.projectCompletionDate'), t('excelHeaders.projectStatus'), t('excelHeaders.projectPriority')],
      rows: projectRows
    });
    return sections;
  }
  function openGlobalExcelExportModal() {
    const sections = getAllSectionExportData();
    if (!sections.length) {
      showToast2(t('toasts.noDataToExport'), "warning");
      return;
    }
    const fields = sections.map((section) => ({
      name: `export_${section.key}`,
      label: `${section.title} (${section.rows.length})`,
      type: "checkbox",
      value: true,
      allowInReadOnly: true
    }));
    openModal(t('modals.globalExcelExport'), fields, (data) => {
      const selected = sections.filter((section) => data[`export_${section.key}`]);
      if (!selected.length) {
        showToast2(t('exportLabels.selectAtLeastOne'), "warning");
        return false;
      }
      const label = appData.title ? `${appData.title}_export_global` : "export_global";
      downloadExcelWorkbook(label, selected);
      return true;
    }, null, {
      saveLabel: t('exportLabels.exportExcelBtn'),
      cancelLabel: t('exportLabels.cancelBtn'),
      allowReadOnly: true,
      message: t('exportLabels.selectSections')
    });
    const modalBody = document.getElementById("modalBody");
    if (modalBody) {
      const toolbar = document.createElement("div");
      toolbar.className = "settings-actions modal__toolbar";
      const selectAllBtn = document.createElement("button");
      selectAllBtn.type = "button";
      selectAllBtn.className = "btn btn--outline btn--sm";
      selectAllBtn.textContent = t('exportLabels.selectAll');
      const clearBtn = document.createElement("button");
      clearBtn.type = "button";
      clearBtn.className = "btn btn--outline btn--sm";
      clearBtn.textContent = t('exportLabels.deselectAll');
      toolbar.append(selectAllBtn, clearBtn);
      const messageEl = modalBody.querySelector(".modal__message");
      if (messageEl && messageEl.parentNode) {
        messageEl.parentNode.insertBefore(toolbar, messageEl.nextSibling);
      } else if (modalBody.firstChild) {
        modalBody.insertBefore(toolbar, modalBody.firstChild);
      } else {
        modalBody.appendChild(toolbar);
      }
      const setAll = (checked) => {
        fields.forEach((field) => {
          const input = document.getElementById(`modal-input-${field.name}`);
          if (input && input.type === "checkbox") input.checked = checked;
        });
      };
      selectAllBtn.addEventListener("click", () => setAll(true));
      clearBtn.addEventListener("click", () => setAll(false));
    }
  }
  function exportAllSectionsExcel() {
    try {
      openGlobalExcelExportModal();
    } catch (error) {
      void 0;
      showToast2(t('toasts.excelExportError'), "error");
    }
  }
  function exportData() {
    // Bloquer l'export en mode démo
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export des données');
      return;
    }
    try {
      ensureNis2Plan();
      ensureCriticalAssets();
      ensureProjectRisks();
      const data = {
        controls: appData.controls,
        actions: appData.actions,
        criticalAssets: appData.criticalAssets,
        criticalAssetsMeta: appData.criticalAssetsMeta,
        projectRisks: appData.projectRisks,
        risks: appData.risks,
        threats: appData.threats,
        nonconformities: appData.nonconformities,
        audits: appData.audits,
        kpis: appData.kpis,
        reviews: appData.reviews,
        stakeholders: appData.stakeholders,
        documents: appData.documents,
        documentReview: appData.documentReview,
        nis2Controls: appData.nis2Controls,
        nis2Plan: normalizeNis2Plan(appData.nis2Plan),
        recyfControls: appData.recyfControls,
        soclePillars: appData.soclePillars,
        targetMaturity: appData.targetMaturity,
        soa: appData.soa,
        riskAnalyses: appData.riskAnalyses,
        impactMatrices: appData.impactMatrices,
        riskThresholds: appData.riskThresholds,
        frameworkMapper: appData.frameworkMapper,
        kpiHistory: appData.kpiHistory,
        settings: appData.settings,
        nextActionId: appData.nextActionId,
        title: appData.title,
        cyfunLevel: appData.cyfunLevel,
        cyfunLang: appData.cyfunLang,
        recyfLevel: appData.recyfLevel,
        nis2Referential: appData.nis2Referential,
        // FIX-CYFUN-ARCHIVE-EXPORT (audit 2026-07-02) : l'archive des évaluations CyFun par
        // niveau (hors niveau courant) est synchronisée au serveur mais était absente de
        // l'export → perdue à la migration locale→serveur. applyImportedData la restaure déjà.
        _cyfunEvalArchive: appData._cyfunEvalArchive,
        _cyfunLevelChosen: appData._cyfunLevelChosen,
        // FIX-CYFUN-LOCK-PORTABILITY: inclure le verrou de niveau dans l'export
        // pour qu'un re-import ailleurs (autre instance, restauration, etc.)
        // préserve le verrou. Sans ce champ, l'export-import perdait le verrou.
        _cyfunLevelLocked: appData._cyfunLevelLocked,
        // Verrou du référentiel NIS2 (CyFun/ReCyF) : posé au 1er choix confirmé
        // (wizard/modale). Une fois vrai, le sélecteur de référentiel des Réglages est
        // désactivé — seule une réinitialisation permet d'en changer. Persisté/synchronisé
        // pour rester cohérent en local ET en mode serveur multi-utilisateurs.
        _nis2ReferentialLocked: appData._nis2ReferentialLocked,
        actionsHistory: appData.actionsHistory,
        auditLog: appData.auditLog,
        exportDate: (/* @__PURE__ */ new Date()).toISOString(),
        version: "2.0"
      };
      // === CSI-ME === (audit import-export) : estampiller l'ORIGINE du fichier AVANT le hash
      // d'intégrité. Un export fait en périmètre sous-entité porte _csiEntity (id+nom, jamais
      // la matrice de droits) ; un export du périmètre principal en mode multi-entités porte
      // _csiExportScope:'group'. La garde serveur CROSS_IMPORT_MISMATCH peut ainsi détecter
      // un réimport vers un autre périmètre (deux fichiers d'entités différentes étaient
      // indiscernables). Un export hors mode multi-entités reste inchangé.
      var _csiScopeTag = '';
      if (appData._csiEntity && appData._csiEntity.id) {
        data._csiEntity = { id: String(appData._csiEntity.id), name: appData._csiEntity.name ? String(appData._csiEntity.name) : '' };
        _csiScopeTag = '_' + String(appData._csiEntity.id);
      } else if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity && window.CSI_EntityManager.isMultiEntity()) {
        data._csiExportScope = 'group';
        _csiScopeTag = '_' + (t('entities.exportGroupTag', 'entite-principale'));
      }
      // F-04: Hash d'intégrité pour détecter la corruption
      if (window.CSI_LegacyImport && typeof window.CSI_LegacyImport.computeIntegrityHash === 'function') {
        data._integrity = window.CSI_LegacyImport.computeIntegrityHash(data);
      }
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      const cryptoObj = (typeof crypto !== "undefined" && crypto) || (typeof window !== "undefined" ? window.crypto : void 0);
      const exportId = cryptoObj && typeof cryptoObj.randomUUID === "function" ? cryptoObj.randomUUID() : Math.random().toString(36).slice(2);
      a.download = `CyberAssistant_Export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}${_csiScopeTag}_${exportId}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      addAuditEntry('export', 'system', a.download);
      showToast2(t('toasts.exportSuccess'), "success");
    } catch (error) {
      void 0;
      showToast2(t('toasts.exportError', "Impossible d'exporter les données."), "error");
    }
  }
  function formatImportDate(data) {
    const candidates = [
      data?.exportDate,
      data?.lastUpdated,
      data?.updatedAt,
      data?.lastModified
    ];
    for (const candidate of candidates) {
      if (!candidate) continue;
      const ts = Date.parse(candidate);
      if (Number.isFinite(ts)) return new Date(ts).toLocaleString();
    }
    return "Inconnue";
  }
  function buildImportSummary(data, sourceLabel) {
    const controlsCount = Array.isArray(data?.controls) ? data.controls.length : 0;
    const actionsCount = Array.isArray(data?.actions) ? data.actions.length : 0;
    const risksCount = Array.isArray(data?.risks) ? data.risks.length : 0;
    const projectRisksCount = Array.isArray(data?.projectRisks) ? data.projectRisks.length : 0;
    const nis2ControlsCount = Array.isArray(data?.nis2Controls) ? data.nis2Controls.length : 0;
    const hasNis2Plan = !!data?.nis2Plan;
    const dateLabel = formatImportDate(data);
    const lines = [
      `Source: ${sourceLabel}`,
      `Contrôles ISO: ${controlsCount}`,
      `Mesures NIS2: ${nis2ControlsCount}`,
      `Actions: ${actionsCount}`,
      `Risques: ${risksCount}`,
      `Risques projets: ${projectRisksCount}`,
      `Analyses de risques: ${Array.isArray(data?.riskAnalyses) ? data.riskAnalyses.length : 0}`,
      `Matrices d'impacts: ${Array.isArray(data?.impactMatrices?.matrices) ? data.impactMatrices.matrices.length : 0}`,
      `Plan NIS2: ${hasNis2Plan ? 'Oui (avec budgets)' : 'Non (sera initialisé)'}`,
      `Dernière MAJ: ${dateLabel}`,
      ""
    ];
    if (!hasNis2Plan && nis2ControlsCount > 0) {
      lines.push("⚠️ " + t('import.oldFormatDetected', 'Ancien format détecté : le plan NIS2 et les budgets'));
      lines.push("   " + t('import.willBeInitialized', 'seront initialisés avec les valeurs par défaut.'));
      lines.push("");
    }
    lines.push(t('import.warningOverwrite', 'Attention : cette opération va écraser les données locales.'));
    return lines.join("\n");
  }
  // === CSI-CO === (audit 2026-08-05) Sas des opérations GLOBALES : acquérir les 4 grappes
  // AVANT toute mutation de appData. Sans lui, l'état importé/restauré/vidé entrait en RAM,
  // le POST import était refusé 423 (le serveur exige les 4 grappes, l'UI n'en acquiert
  // qu'une) et l'autosave poussait ensuite l'état non validé section par section.
  // Résout {ok:true, borrowed:[...]} ou {ok:false} APRÈS avoir prévenu l'utilisateur.
  function _csiBeginGlobalOperation(labelKey, labelDefault) {
    var ELM = window.CSI_EditLockManager;
    if (!ELM || !ELM.isCoedit || !ELM.isCoedit()) return Promise.resolve({ ok: true, borrowed: [] });
    return _csiAcquireAllClusters().then(function (acq) {
      if (acq.granted) return { ok: true, borrowed: acq.acquired };
      var who = _csiBlockedByLabel(acq.blockedBy);
      showToast2(who
        ? t('toasts.globalOpLockedBy', "{{op}} impossible : {{user}} édite actuellement une partie de l'application. Aucune donnée n'a été modifiée — réessayez quand la main sera libre.")
            .replace('{{op}}', t(labelKey, labelDefault)).replace('{{user}}', who)
        : t('toasts.globalOpLocked', "{{op}} impossible : le verrou d'édition complet n'a pas pu être obtenu. Aucune donnée n'a été modifiée.")
            .replace('{{op}}', t(labelKey, labelDefault)), 'error', 14000);
      return { ok: false, borrowed: [] };
    });
  }

  function applyImportedData(data) {
    // === CSI-ME === (audit import-export) : origine du FICHIER, transmise telle quelle au
    // serveur (champ importOrigin du push post-import). Le push porte le _csiEntity du
    // périmètre COURANT (ré-ingéré), pas celui du fichier — sans ce fil explicite, la garde
    // anti-import-croisé ne voyait jamais l'origine réelle (origin==target à tous les coups).
    var _csiImportOrigin = null;
    try {
      if (data && data._csiEntity && data._csiEntity.id) _csiImportOrigin = String(data._csiEntity.id);
      else if (data && data._csiExportScope === 'group') _csiImportOrigin = 'group';
    } catch (e) { /* marqueur absent — fichier antérieur à la feature */ }
    try {
      addHistorySnapshot(t('snapshots.backupBeforeImport', 'Sauvegarde avant import'), { tag: "auto" });
    } catch (err) {
      void 0;
    }
    // Normaliser les contrôles (gestion de l'ancien format avec selected/justification)
    appData.controls = normalizeControls((data.controls || []).map(c => {
      const { selected, justification, ...rest } = c;
      if (typeof rest.justificationControl === "undefined") rest.justificationControl = "";
      return rest;
    }));

    // Normaliser SOA (compatibilité avec ancien format à 3 champs)
    if (Array.isArray(data.soa)) {
      appData.soa = normalizeSoa(data.soa);
    } else {
      appData.soa = normalizeSoa((data.controls || []).map(c => ({
        controlId: c.id,
        applicable: c.selected !== undefined ? c.selected : true,
        justification: c.justification || ""
      })));
    }

    // Normaliser toutes les autres sections avec compatibilité ascendante
    appData.actions = normalizeActions(data.actions || []);
    appData.criticalAssets = normalizeCriticalAssets(data.criticalAssets || []);
    appData.criticalAssetsMeta = normalizeCriticalAssetsMeta(data.criticalAssetsMeta);
    appData.projectRisks = normalizeProjectRisks(data.projectRisks || []);
    appData.risks = normalizeRisks(data.risks || []);
    ensureRiskIds();
    appData.threats = normalizeThreats(data.threats || []);
    // Recalculate risk scores and levels after import
    if (typeof calculateRiskLevel === 'function') {
      (appData.risks || []).forEach(function(risk) {
        if (risk.impact && risk.probability) {
          risk.score = (typeof risk.impact === 'number' ? risk.impact : parseInt(risk.impact, 10) || 0) * (typeof risk.probability === 'number' ? risk.probability : parseInt(risk.probability, 10) || 0);
          risk.level = calculateRiskLevel(risk.score);
        }
      });
      appData.threats.forEach(function(threat) {
        if (threat.impact && threat.likelihood) {
          threat.score = (typeof threat.impact === 'number' ? threat.impact : parseInt(threat.impact, 10) || 0) * (typeof threat.likelihood === 'number' ? threat.likelihood : parseInt(threat.likelihood, 10) || 0);
          threat.level = calculateRiskLevel(threat.score);
        }
      });
    }
    appData.nonconformities = normalizeNonconformities(data.nonconformities || []);
    appData.audits = normalizeAudits(data.audits || []);
    appData.kpis = normalizeKpis(data.kpis || []);
    appData.reviews = normalizeReviews(data.reviews || []);
    appData.stakeholders = normalizeStakeholders(data.stakeholders || []);
    appData.documents = normalizeDocuments(data.documents || []);
    appData.documentReview = createDefaultDocumentReview(data.documentReview || []);
    const tm = data.targetMaturity || {};
    appData.targetMaturity = {
      iso27002: typeof tm.iso27002 === "number" ? tm.iso27002 : parseInt(tm.iso27002, 10) || 3,
      nis2: buildNis2Maturity(tm.nis2 || {}),
      nis2Target: typeof tm.nis2Target === "number" ? tm.nis2Target : parseInt(tm.nis2Target, 10) || 3
    };
    const nis2PlanDefaults = getEmptyNis2Plan();
    let nis2PlanMigrated = false;
    if (data.nis2Plan) {
      const plan = data.nis2Plan;
      appData.nis2Plan = {
        phases: Array.isArray(plan.phases) && plan.phases.length ? plan.phases : nis2PlanDefaults.phases,
        maturity: {
          labels: Array.isArray(plan.maturity?.labels) && plan.maturity.labels.length ? plan.maturity.labels : nis2PlanDefaults.maturity.labels,
          current: Array.isArray(plan.maturity?.current) && plan.maturity.current.length ? plan.maturity.current : nis2PlanDefaults.maturity.current,
          target2027: typeof plan.maturity?.target2027 === "number" ? plan.maturity.target2027 : nis2PlanDefaults.maturity.target2027,
          target2028: typeof plan.maturity?.target2028 === "number" ? plan.maturity.target2028 : nis2PlanDefaults.maturity.target2028
        },
        statuses: Array.isArray(plan.statuses) ? plan.statuses : nis2PlanDefaults.statuses,
        budgetBlocks: Array.isArray(plan.budgetBlocks) ? plan.budgetBlocks : nis2PlanDefaults.budgetBlocks,
        customBlocks: Array.isArray(plan.customBlocks) ? plan.customBlocks : nis2PlanDefaults.customBlocks,
        domains: Array.isArray(plan.domains) ? plan.domains : nis2PlanDefaults.domains,
        // Budget Cybersécurité : préserver catégories, années et lignes à l'import.
        // (undefined => normalizeNis2Plan applique les défauts / migre depuis les actions)
        budgetCategories: Array.isArray(plan.budgetCategories) ? plan.budgetCategories : undefined,
        budgetYears: Array.isArray(plan.budgetYears) ? plan.budgetYears : undefined,
        budgetLines: Array.isArray(plan.budgetLines) ? plan.budgetLines : undefined
      };
    } else {
      // Migration automatique des anciens formats sans nis2Plan
      nis2PlanMigrated = true;
      appData.nis2Plan = cloneDefaultNis2Plan({ includeActions: true });

      // Tenter de récupérer la maturité depuis nis2Controls si disponible
      if (Array.isArray(data.nis2Controls) && data.nis2Controls.length > 0) {
        // Mapper les CMM des contrôles NIS2 vers des valeurs numériques
        const cmmToValue = { 'Initial': 1, 'Répétable': 2, 'Défini': 3, 'Géré': 4, 'Optimisé': 5 };
        const maturityValues = data.nis2Controls.map(ctrl => {
          const cmmVal = cmmToValue[ctrl.cmm] || 0;
          return cmmVal;
        });
        // Calculer une moyenne globale pour target2027/2028
        const avgMaturity = maturityValues.length > 0
          ? Math.round(maturityValues.reduce((a, b) => a + b, 0) / maturityValues.length)
          : 0;
        appData.nis2Plan.maturity.target2027 = Math.min(avgMaturity + 1, 5);
        appData.nis2Plan.maturity.target2028 = Math.min(avgMaturity + 2, 5);
      }

      void 0;
    }
    appData.soclePillars = normalizeSoclePillars(data.soclePillars);
    if (Array.isArray(data.riskAnalyses)) {
      appData.riskAnalyses = data.riskAnalyses.map(function(a) {
        return typeof window.CSI_RiskAnalysis !== 'undefined' && window.CSI_RiskAnalysis.normalizeAnalysis
          ? window.CSI_RiskAnalysis.normalizeAnalysis(a) : a;
      });
    } else {
      // AUDIT-H2: Reset à [] quand le fichier importé n'a pas de riskAnalyses.
      // Avant: le champ n'était pas remis à zéro, les analyses de la session
      // précédente survivaient silencieusement à l'import.
      appData.riskAnalyses = [];
    }
    if (data.impactMatrices && typeof normalizeImpactMatrices === 'function') {
      appData.impactMatrices = normalizeImpactMatrices(data.impactMatrices);
    } else {
      appData.impactMatrices = data.impactMatrices || appData.impactMatrices || null;
    }
    // AUDIT-M3: Normaliser riskThresholds et reset quand absent.
    // Avant: assignation brute sans validation, et pas de reset si absent
    // (les anciens seuils de la session survivaient silencieusement à l'import).
    if (data.riskThresholds && typeof data.riskThresholds === 'object') {
      appData.riskThresholds = data.riskThresholds;
    } else {
      appData.riskThresholds = null;
    }
    if (data.frameworkMapper) {
      appData.frameworkMapper = {
        enabledFrameworks: Array.isArray(data.frameworkMapper.enabledFrameworks) ? data.frameworkMapper.enabledFrameworks : _defaultEnabledFrameworks(),
        customMappings: Array.isArray(data.frameworkMapper.customMappings) ? data.frameworkMapper.customMappings : [],
        userNotes: (data.frameworkMapper.userNotes && typeof data.frameworkMapper.userNotes === 'object') ? data.frameworkMapper.userNotes : {}
      };
    }
    // Normalize imported enabledFrameworks for the current edition
    if (appData.frameworkMapper && Array.isArray(appData.frameworkMapper.enabledFrameworks)) {
      var _impFws = window._csiFrameworks || {};
      var _impEf = appData.frameworkMapper.enabledFrameworks;
      [['iso27002', 'open-security-controls'], ['iso27001', 'open-security-management']].forEach(function(pair) {
        var absent = !_impFws[pair[0]] ? pair[0] : (!_impFws[pair[1]] ? pair[1] : null);
        var present = absent === pair[0] ? pair[1] : pair[0];
        if (!absent) return;
        var idx = _impEf.indexOf(absent);
        if (idx >= 0) {
          if (_impEf.indexOf(present) < 0) _impEf[idx] = present;
          else _impEf.splice(idx, 1);
        }
      });
    }
    // Auto-detect: if imported data has NIS2/CyFun controls, enable framework
    if (Array.isArray(data.nis2Controls) && data.nis2Controls.length > 0) {
      if (!appData.frameworkMapper) appData.frameworkMapper = { enabledFrameworks: [_getActiveControlsFrameworkId() || 'iso27002'], customMappings: [], userNotes: {} };
      var _efws = appData.frameworkMapper.enabledFrameworks;
      if (_efws.indexOf('nis2') < 0) _efws.push('nis2');
      if (_efws.indexOf('cyfun2025') < 0) _efws.push('cyfun2025');
    }
    ensureNis2Plan();
    ensureCriticalAssets();
    normalizeCriticalAssetLinks(appData.actions);
    normalizeCriticalAssetLinks(appData.risks);
    normalizeCriticalAssetLinks(appData.threats);
    normalizeCriticalAssetLinks(appData.nonconformities);
    // Restore actionsHistory and auditLog if present
    if (data.actionsHistory && typeof data.actionsHistory === 'object') appData.actionsHistory = data.actionsHistory;
    if (Array.isArray(data.auditLog)) appData.auditLog = data.auditLog;
    // Restore CyFun settings from imported data BEFORE loading controls
    if (data.cyfunLevel !== undefined) appData.cyfunLevel = data.cyfunLevel;
    if (data._cyfunEvalArchive !== undefined) appData._cyfunEvalArchive = data._cyfunEvalArchive; // FIX-CYFUN-LEVEL-LOSS
    // GOV-NIS2 S4 (audit 2026-08-20, MAJEUR M10) : un fichier SANS archive doit la VIDER —
    // même doctrine que riskAnalyses (AUDIT-H2). Sinon l'archive du dossier précédent
    // survivait à l'import et ses évaluations ressuscitaient dans le nouveau dossier à la
    // prochaine montée de niveau (contamination inter-dossiers, silencieuse).
    else appData._cyfunEvalArchive = {};
    if (data.cyfunLang !== undefined) appData.cyfunLang = data.cyfunLang;
    if (data._cyfunLevelChosen !== undefined) appData._cyfunLevelChosen = data._cyfunLevelChosen;
    // FIX-CYFUN-LOCK-PORTABILITY: restaurer le verrou de niveau au re-import
    if (data._cyfunLevelLocked !== undefined) appData._cyfunLevelLocked = data._cyfunLevelLocked;
    if (data._nis2ReferentialLocked !== undefined) appData._nis2ReferentialLocked = data._nis2ReferentialLocked;
    const defaultNis2 = loadNis2Controls();
    if (Array.isArray(data.nis2Controls) && data.nis2Controls.length > 0) {
      // AUDIT-C3: Détecter le format legacy NIS2 (23 contrôles v1.0 avec fonction/categorie, sans ref).
      // Avant ce fix, les contrôles legacy n'avaient ni ref ni id compatible avec CyFun 2025,
      // donc found={} pour chaque contrôle → perte SILENCIEUSE de toutes les valeurs utilisateur.
      var _nis2Format = typeof detectLegacyNis2Format === 'function'
        ? detectLegacyNis2Format(data.nis2Controls) : 'unknown';
      var _legacyCategoryMaturity = null;
      if (_nis2Format === 'legacy_23') {
        _legacyCategoryMaturity = typeof migrateLegacyNis2Controls === 'function'
          ? migrateLegacyNis2Controls(data.nis2Controls) : {};
        void 0;
      }

      appData.nis2Controls = defaultNis2.map(d => {
        // Pour le format legacy: récupérer la maturité par catégorie NIST CSF
        if (_legacyCategoryMaturity) {
          var catMatch = (d.ref || '').match(/^([A-Z]{2}\.[A-Z]{2})/);
          var cat = catMatch ? catMatch[1] : '';
          var migratedCmm = _legacyCategoryMaturity[cat] || 0;
          // GOV-NIS2 S3 (M6) : status/cmm DÉRIVÉS des maturités migrées — l'ancien code
          // écrivait un ENTIER dans cmm (champ libellé partout ailleurs) et un statut
          // incohérent avec la grille d'editNis2Control.
          var _migrated = { ...d, docMaturity: migratedCmm, implMaturity: migratedCmm,
            justification: '', evidence: '',
            owner: '', reviewer: '', reviewFrequency: '', lastEvaluation: '', nextReviewDate: '' };
          deriveNis2StatusCmm(_migrated);
          return _migrated;
        }
        // GOV-NIS2 S4 (audit 2026-08-20, MAJEUR M8) : appariement par ref STRICT quand la
        // cible porte une ref — l'ancien OR `(ref===) || (id===)` laissait un faux positif
        // d'id (ids historiquement INDEXÉS, réordonnés à chaque niveau) l'emporter sur le
        // vrai match par ref → évaluations croisées entre exigences au round-trip
        // export/import. Le repli par id ne sert plus qu'aux données sans ref (legacy).
        const found = data.nis2Controls.find(c => (d.ref ? (c && c.ref === d.ref) : (c && c.id === d.id))) || {};
        const merged = { ...d, docMaturity: found.docMaturity || 0, implMaturity: found.implMaturity || 0,
          justification: found.justification || '', evidence: found.evidence || '',
          owner: found.owner || '', reviewer: found.reviewer || '',
          reviewFrequency: found.reviewFrequency || '',
          lastEvaluation: found.lastEvaluation || '',
          // GOV-NIS2 S4 (M9) : l'évaluateur voyage avec l'export/import.
          lastEvaluatedBy: found.lastEvaluatedBy || '',
          nextReviewDate: found.nextReviewDate || '',
          // FIX-NIS2-COMMENTS-IMPORT (audit 2026-07-02) : préserver les commentaires du
          // contrôle NIS2/CyFun (manageComments) — omis de la liste fermée, ils étaient
          // perdus à l'import (les chemins load/level-change les gardent via {...d,...found}).
          comments: window.normalizeComments(Array.isArray(found.comments) && found.comments.length ? found.comments : (found.comments || d.comments)),
          status: found.status || d.status, cmm: found.cmm || d.cmm };
        // GOV-NIS2 S4 (M9) : même filet générique que FIX-CYFUN-LEVEL-LOSS — tout champ
        // utilisateur inconnu de la liste ci-dessus survit à l'import (futurs champs inclus).
        Object.keys(found).forEach(function(k) { if (!(k in merged)) merged[k] = found[k]; });
        return merged;
      });
      if (_legacyCategoryMaturity) {
        showToast2(t('toasts.nis2LegacyMigrated', 'Mesures NIS2 migrées depuis l\'ancien format (23 mesures). Vérifiez les scores.'), 'info', 8000);
      }
    } else {
      appData.nis2Controls = defaultNis2;
    }
    // Restore ReCyF (France) settings + measures from imported data
    if (data.recyfLevel !== undefined) appData.recyfLevel = data.recyfLevel;
    if (data.nis2Referential !== undefined) appData.nis2Referential = data.nis2Referential;
    if (!appData.recyfLevel) appData.recyfLevel = 'importante';
    // Modèle NIS2 mutuellement exclusif : aligner enabledFrameworks sur le référentiel importé
    // (le bloc d'auto-détection plus haut a pu activer cyfun2025 alors que la France est active).
    if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
    if (typeof window.rebuildRecyfThemes === 'function') window.rebuildRecyfThemes(appData.recyfLevel);
    if (Array.isArray(data.recyfControls) && data.recyfControls.length > 0 && typeof loadRecyfControls === 'function') {
      var _impRecyfByRef = {};
      data.recyfControls.forEach(function(c) { if (c && c.ref) _impRecyfByRef[c.ref] = c; });
      appData.recyfControls = loadRecyfControls().map(function(c) {
        var found = _impRecyfByRef[c.ref];
        if (found) {
          // GOV-NIS2 S5 : valeur de conformité VALIDÉE contre le référentiel des statuts —
          // une valeur inconnue ('compliant'…) gonflait le dénominateur sans apparaître
          // dans aucune chip et laissait le select sans sélection.
          var _confOk = (window.RECYF_CONFORMITY_OPTIONS || []).some(function(o) { return o.value === found.conformity; });
          c.conformity = _confOk ? found.conformity : 'non_evalue';
          c.justification = found.justification || '';
          c.owner = found.owner || '';
          c.reviewer = found.reviewer || '';
          c.reviewFrequency = found.reviewFrequency || '';
          c.lastEvaluation = found.lastEvaluation || '';
          // GOV-NIS2 S4 (M9) : l'évaluateur voyage avec l'export/import.
          c.lastEvaluatedBy = found.lastEvaluatedBy || '';
          c.nextReviewDate = found.nextReviewDate || '';
          c.evidence = found.evidence || '';
          // Même classe de préservation que FIX-NIS2-COMMENTS-IMPORT côté CyFun :
          // sans cette ligne, les commentaires ReCyF seraient perdus à chaque import.
          c.comments = window.normalizeComments(found.comments);
          // GOV-NIS2 S4 (M9) : filet générique — tout champ utilisateur inconnu survit.
          Object.keys(found).forEach(function(k) { if (!(k in c)) c[k] = found[k]; });
        }
        return c;
      });
    } else {
      appData.recyfControls = [];
    }
    // AUDIT-H1: Sécuriser nextActionId contre les collisions d'ID.
    // Avant: `data.nextActionId || 0` mettait la valeur à 0 quand absent/null,
    // causant des doublons d'ID si des actions existaient déjà.
    var _importedNextId = (typeof data.nextActionId === 'number' && data.nextActionId > 0) ? data.nextActionId : 0;
    var _maxExistingActionId = 0;
    if (Array.isArray(appData.actions) && appData.actions.length > 0) {
      appData.actions.forEach(function(a) {
        if (a && a.id) {
          var n = parseInt(String(a.id).replace(/\D/g, ''), 10);
          if (Number.isFinite(n) && n > _maxExistingActionId) _maxExistingActionId = n;
        }
      });
    }
    appData.nextActionId = Math.max(_importedNextId, _maxExistingActionId + 1);
    if (_importedNextId < _maxExistingActionId + 1) {
      void 0;
    }
    if (data.kpiHistory) {
      appData.kpiHistory = data.kpiHistory;
    }
    if (data.settings && typeof data.settings === 'object') {
      appData.settings = data.settings;
    }
    appData.title = data.title || appData.title;
    updateAppTitle();
    warnMissingSubcategories();
    // F-03: Validation cross-references post-import
    if (typeof window.validateBeforeSave === 'function') {
      try {
        var _postValidation = window.validateBeforeSave(appData);
        if (_postValidation.warnings && _postValidation.warnings.length > 0) {
          void 0;
          showToast2(t('toasts.importIntegrityWarnings', 'Import terminé avec ' + _postValidation.warnings.length + ' avertissement(s) d\'intégrité'), "warning");
        }
      } catch (_ve) { void 0; }
    }
    // F-03: Post-import validation via legacy-import module
    if (window.CSI_LegacyImport && typeof window.CSI_LegacyImport.validatePostImport === 'function') {
      try {
        var _postReport = window.CSI_LegacyImport.validatePostImport(appData);
        if (_postReport.warnings && _postReport.warnings.length > 0) {
          void 0;
        }
      } catch (_le) { /* non bloquant */ }
    }
        addAuditEntry('import', 'system', t('audit.importFile', 'Import fichier'));
    updateDashboard();
    loadKpiTable();
    if (nis2PlanMigrated) {
      showToast2(t('toasts.importNis2Success', 'Import réussi - Plan NIS2 initialisé (ancien format)'), "info");
      setTimeout(() => {
        showToast2(t('toasts.importNis2Hint', "Retrouvez vos budgets dans l'onglet Budget Cybersécurité (Gouvernance)"), "info");
      }, 2500);
    } else {
      showToast2(t('toasts.importSuccess'), "success");
    }
    // PASS2-001: Check saveData return — don't reload if save failed (data would be lost)
    if (!saveData()) {
      showToast2(t('toasts.saveFailed', 'Erreur de sauvegarde — les données importées n\'ont pas été persistées. Réessayez.'), 'error', 10000);
      return;
    }
    // AUDIT-C1: Force sync serveur AVANT le reload (le debounce de 2s dans requestServerSync
    // était systématiquement tué par le reload à 100ms — les collaborateurs ne recevaient jamais
    // les données importées). On utilise forceServerSync() qui est synchrone/immédiat.
    var reloadDelay = nis2PlanMigrated ? 4500 : 100;
    if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode && window.CSI_SyncManager.isServerMode()
        && window.CSI_SyncManager.forceServerSync) {
      // FIX-IMPORT-RELOAD-GUARD (audit 2026-07-02, C2) : ne recharger QUE si le push serveur
      // a réellement réussi. En mode serveur, loadData() repart du serveur au reload ; recharger
      // après un échec (423 verrou perdu, 402 licence, 429, réseau, 'Sync in progress'...) jette
      // les données importées (jamais écrites en localStorage en mode serveur). forceServerSync
      // RÉSOUT {success:false} sur ces échecs (il ne rejette pas) → on doit tester success.
      var _importSyncFailMsg = function(m) {
        return t('toasts.importSyncFailed', "Import appliqué en mémoire mais NON enregistré sur le serveur : {{msg}}. Reprenez la main (mode édition) puis réimportez ; ne fermez pas l'onglet.").replace('{{msg}}', m || t('toasts.syncGenericError', 'échec de synchronisation'));
      };
      var _postImportSync = function(opts) {
        // === CSI-ME === (audit import-export) : transmettre l'origine du fichier importé —
        // c'est elle (et non le _csiEntity du périmètre courant) que la garde serveur compare.
        var _o = opts || {};
        if (_csiImportOrigin && !_o.importOrigin) _o.importOrigin = _csiImportOrigin;
        return window.CSI_SyncManager.forceServerSync(undefined, _o).then(function(res) {
          // === CSI-ME === (audit 2026-07-03) rattachement changé pendant l'import : recharger
          // (le /me ré-ingère le nouveau contexte) — l'import devra être refait au bon périmètre.
          if (res && res.entityContextChanged) {
            showToast2(t('toasts.entityContextChanged', "Votre rattachement d'entité a changé — l'application va se recharger pour appliquer votre nouveau périmètre. Une copie de vos modifications non enregistrées a été téléchargée."), 'warning', 8000);
            setTimeout(function() { try { window.onbeforeunload = null; } catch (e) {} location.reload(); }, 3000);
            return;
          }
          // === CSI-ME === (audit C1) fichier d'un autre périmètre : confirmation explicite.
          if (res && res.crossImport) {
            var _ok = window.confirm(t('entities.crossImportConfirm', 'Ce fichier provient d\'un autre périmètre ({{origin}}).\n\nImporter ici REMPLACERA les données du périmètre courant ({{target}}). Confirmer ?').replace('{{origin}}', res.origin || '?').replace('{{target}}', res.target || '?'));
            if (_ok) { _postImportSync({ allowCrossImport: true }); }
            else { showToast2(t('toasts.crossImportCancelled', 'Import inter-périmètre annulé — données inchangées.'), 'info', 8000); }
            return;
          }
          if (res && res.success === false) {
            showToast2(_importSyncFailMsg(res.message), 'error', 15000);
            // (audit 2026-08-05) Ne PLUS conserver l'import en RAM seule : l'autosave et les
            // changements d'onglet le poussaient ensuite section par section, fabriquant un
            // état serveur hybride pendant que le message affirmait « non enregistré ».
            // Tout-ou-rien : on repart de l'état autoritaire, l'utilisateur réimporte.
            return _csiRecoverFromFailedGlobalPush();
          }
          // === CSI-ME === (audit 2026-07-03, K5) sections du fichier écartées par les
          // droits du périmètre : le dire — « Import réussi » seul était trompeur.
          if (res && res.ignoredSections && res.ignoredSections.length) {
            var _igList = res.ignoredSections.map(function(sid) {
              return (window.CSI_EntityUI && window.CSI_EntityUI.sectionLabel) ? window.CSI_EntityUI.sectionLabel(sid) : sid;
            }).join(', ');
            showToast2(t('toasts.importIgnoredSections', 'Sections en lecture seule pour votre périmètre, non modifiées par l\'import : {{list}}').replace('{{list}}', _igList), 'info', 12000);
            reloadDelay = Math.max(reloadDelay, 5000); // laisser lire le toast avant le reload
          }
          setTimeout(function() { location.reload(); }, reloadDelay);
        });
      };
      // Les 4 grappes ont été acquises par le sas AVANT toute mutation (voir onchange) ;
      // on relâche ici celles empruntées pour l'occasion, quel que soit l'issue.
      var _impBorrowed = window._csiGlobalOpBorrowed || [];
      _postImportSync().catch(function(err) {
        void 0;
        showToast2(_importSyncFailMsg(err && err.message), 'error', 15000);
        return _csiRecoverFromFailedGlobalPush();
      }).then(function() {
        window._csiGlobalOpBorrowed = null;
        return _csiReleaseBorrowedClusters(_impBorrowed);
      });
    } else {
      setTimeout(function() { location.reload(); }, reloadDelay);
    }
  }
  function importData() {
    // Bloquer l'import en mode démo
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'import des données');
      return;
    }
    if (!ensureEditMode("import data")) return;
    const fileInput = document.getElementById("fileInput");
    if (!fileInput) { void 0; return; }
    fileInput.click();
    fileInput.onchange = function(event) {
      const file = event.target.files[0];
      if (!file) return;
      // Validate file extension (.json preferred, .smsi accepted for backward compat)
      var ext = (file.name || '').split('.').pop().toLowerCase();
      if (ext !== 'json' && ext !== 'smsi' && ext !== 'analyse') {
        showToast2(t('toasts.invalidFileType', 'Format de fichier invalide. Veuillez sélectionner un fichier .json ou .smsi.'), "error");
        fileInput.value = "";
        return;
      }
      // F-02: Guard taille fichier (max 10 MB)
      if (file.size > 10 * 1024 * 1024) {
        showToast2(t('toasts.fileTooLarge', 'Le fichier est trop volumineux (max 10 MB).'), "error");
        fileInput.value = "";
        return;
      }
      const reader = new FileReader();
      reader.onload = async function(e) {
        // === CSI-CO === (audit 2026-08-05) Sas de grappes AVANT toute mutation : l'import
        // pousse l'état complet, que le serveur n'accepte qu'avec les 4 grappes détenues.
        // Le fichier .analyse (importeur dédié, écriture d'une seule section) n'est pas concerné.
        var _impGate = { ok: true, borrowed: [] };
        try {
          var _peek = JSON.parse(e.target.result);
          if (!_peek || _peek.type !== 'cyber-assistant-risk-analysis') {
            _impGate = await _csiBeginGlobalOperation('dataManagement.import', 'Import');
            if (!_impGate.ok) { fileInput.value = ""; return; }
          }
        } catch (errGate) { /* JSON invalide : le parse ci-dessous produira le message dédié */ }
        window._csiGlobalOpBorrowed = _impGate.borrowed;
        try {
          const data = JSON.parse(e.target.result);
          // Route .analyse files to dedicated importer
          if (data.type === 'cyber-assistant-risk-analysis') {
            if (typeof window.raImportAnalyseFromData === 'function') {
              window.raImportAnalyseFromData(data, file.name);
            } else {
              showToast2(t('toasts.riskAnalysisModuleNotLoaded', "Module d'analyse de risques non chargé."), "error");
            }
            fileInput.value = "";
            return;
          }
          // === CSI-ME === (import/export entités, 2026-07-09) Import d'une SAUVEGARDE
          // COMPLÈTE (bundle csi-backup-all-v1) : choisir QUELLE entité charger dans cet
          // espace de travail (utile en Corporate Local mono-poste ; dossier §6.3/R9).
          // DOIT précéder la garde data.controls (un bundle n'a pas de controls top-level).
          // Noms d'entités rendus via <option>.textContent (openModal) — anti-XSS.
          if (data._format === 'csi-backup-all-v1') {
            var _bSrcs = [];
            if (data.group && Array.isArray(data.group.controls)) _bSrcs.push({ value: 'group', label: t('entities.mainEntity', 'Entité principale') });
            var _bEnts = data.entities || {};
            for (var _bk in _bEnts) { if (Object.prototype.hasOwnProperty.call(_bEnts, _bk) && _bEnts[_bk] && Array.isArray(_bEnts[_bk].controls)) _bSrcs.push({ value: _bk, label: _bk }); }
            if (!_bSrcs.length) {
              showToast2(t('toasts.bundleNoData', 'La sauvegarde ne contient aucune entité exploitable.'), 'error');
              fileInput.value = ''; return;
            }
            var _applyChosen = function(key) {
              var chosen = (key === 'group') ? data.group : (data.entities && data.entities[key]);
              if (!chosen || !Array.isArray(chosen.controls)) { showToast2(t('toasts.importError'), 'error'); return; }
              // Strip défensif des méta serveur / marqueurs de périmètre (hygiène : l'allowlist
              // d'applyImportedData ne les persiste pas, mais on nettoie le dataset choisi).
              var clean = {}; for (var ck in chosen) { if (Object.prototype.hasOwnProperty.call(chosen, ck)) clean[ck] = chosen[ck]; }
              delete clean._dataEpoch; delete clean._serverSyncDate; delete clean._restoredFrom; delete clean._rev;
              delete clean._csiEntity; delete clean._csiExportScope; delete clean._csiGroupView; delete clean._format; delete clean._createdAt;
              if (window.CSI_LegacyImport && typeof window.CSI_LegacyImport.analyzeImport === 'function') {
                var rep = window.CSI_LegacyImport.analyzeImport(clean);
                window.CSI_LegacyImport.showMigrationModal(rep, clean, function(migrated) {
                  try { applyImportedData(migrated); } catch (err) { void 0; showToast2(t('toasts.importError'), 'error'); }
                }, function() { /* annulé */ });
              } else {
                try { applyImportedData(clean); } catch (err) { void 0; showToast2(t('toasts.importError'), 'error'); }
              }
            };
            if (_bSrcs.length === 1) { _applyChosen(_bSrcs[0].value); }
            else {
              openModal(t('entities.pickEntityToLoadTitle', 'Charger une entité'), [
                { name: 'entityPick', label: t('entities.pickEntityToLoad', 'Quelle entité charger dans cet espace de travail ?'), type: 'select', options: _bSrcs, value: _bSrcs[0].value }
              ], function(vals) { _applyChosen(vals && vals.entityPick ? vals.entityPick : _bSrcs[0].value); },
                null, { saveLabel: t('import.importBtn', 'Importer'), cancelLabel: t('common.cancel', 'Annuler') });
            }
            fileInput.value = '';
            return;
          }
          if (!data.controls || !Array.isArray(data.controls)) throw new Error("Format de fichier invalide");
          // F-05: Délégation au module legacy-import si disponible
          if (window.CSI_LegacyImport && typeof window.CSI_LegacyImport.analyzeImport === 'function') {
            var _importReport = window.CSI_LegacyImport.analyzeImport(data);
            window.CSI_LegacyImport.showMigrationModal(_importReport, data, function(migratedData) {
              try {
                // === CSI-ME === (audit import-export) : préserver les marqueurs d'origine du
                // FICHIER à travers la migration legacy (la garde anti-import-croisé en dépend).
                if (migratedData && data && data._csiEntity && !migratedData._csiEntity) migratedData._csiEntity = data._csiEntity;
                if (migratedData && data && data._csiExportScope && !migratedData._csiExportScope) migratedData._csiExportScope = data._csiExportScope;
                applyImportedData(migratedData);
              } catch (error) {
                void 0;
                showToast2(t('toasts.importError'), "error");
              }
            }, function() { /* annulé */ });
          } else {
          // Fallback: flow standard si le module n'est pas chargé
          const summary = buildImportSummary(data, file.name ? `${t('import.file', 'Fichier')} ${file.name}` : t('import.importedFile', 'Fichier importé'));
          openModal(t('modals.confirmImport'), [
            { name: "summary", label: t('import.summaryBeforeImport', 'Résumé avant import'), type: "textarea", value: summary, readOnly: true }
          ], () => {
            try {
              applyImportedData(data);
            } catch (error) {
              void 0;
              showToast2(t('toasts.importError'), "error");
              return false;
            }
          }, null, { saveLabel: t('import.importBtn', 'Importer'), cancelLabel: t('common.cancel', 'Annuler') });
          }
        } catch (error) {
          void 0;
          showToast2(t('toasts.importError'), "error");
        } finally {
          fileInput.value = "";
        }
      };
      reader.readAsText(file);
    };
  }
  function runTestSuite() {
    showToast2(t('toasts.testsRunning', 'Exécution des tests en cours...'), "info");

    const tests = [];
    let passed = 0;
    let failed = 0;

    // Helper pour ajouter un test
    function addTest(name, fn) {
      tests.push({ name, fn });
    }

    // Test 1: Vérification de la structure appData
    addTest("Structure appData valide", () => {
      return appData && Array.isArray(appData.controls) && Array.isArray(appData.soa);
    });

    // Test 2: Fonction isControlNonApplicable existe
    addTest("Fonction isControlNonApplicable existe", () => {
      return typeof isControlNonApplicable === "function";
    });

    // Test 3: Fonction getSoaJustification existe
    addTest("Fonction getSoaJustification existe", () => {
      return typeof getSoaJustification === "function";
    });

    // Test 4: Fonction navigateToSoaControl existe
    addTest("Fonction navigateToSoaControl existe", () => {
      return typeof navigateToSoaControl === "function";
    });

    // Test 5: Test isControlNonApplicable avec contrôle applicable
    addTest("isControlNonApplicable retourne false pour contrôle applicable", () => {
      // Créer une entrée SoA temporaire pour le test
      const testControlId = "TEST_CONTROL_APPLICABLE";
      const originalSoa = [...appData.soa];
      appData.soa.push({ controlId: testControlId, applicable: true, justification: "" });
      const result = isControlNonApplicable(testControlId) === false;
      appData.soa = originalSoa;
      return result;
    });

    // Test 6: Test isControlNonApplicable avec contrôle non applicable
    addTest("isControlNonApplicable retourne true pour contrôle non applicable", () => {
      const testControlId = "TEST_CONTROL_NON_APPLICABLE";
      const originalSoa = [...appData.soa];
      appData.soa.push({ controlId: testControlId, applicable: false, justification: "Test" });
      const result = isControlNonApplicable(testControlId) === true;
      appData.soa = originalSoa;
      return result;
    });

    // Test 7: Test getSoaJustification
    addTest("getSoaJustification retourne la justification", () => {
      const testControlId = "TEST_CONTROL_JUSTIF";
      const originalSoa = [...appData.soa];
      appData.soa.push({ controlId: testControlId, applicable: false, justification: "Ma justification test" });
      const result = getSoaJustification(testControlId) === "Ma justification test";
      appData.soa = originalSoa;
      return result;
    });

    // Test 8: Contrôles ISO existent
    addTest("Contrôles ISO 27002 présents", () => {
      return (appData.controls || []).length > 0;
    });

    // Test 9: Chaque contrôle a un ID
    addTest("Chaque contrôle ISO a un ID", () => {
      return (appData.controls || []).every(c => c.id && typeof c.id === "string");
    });

    // Test 10: Entrées SoA correspondent aux contrôles
    addTest("Entrées SoA valides (controlId existe)", () => {
      const controlIds = (appData.controls || []).map(c => c.id);
      return appData.soa.every(s => controlIds.includes(s.controlId));
    });

    // Test 11: Navigation module dashboard fonctionne
    addTest("Module dashboard existe", () => {
      return document.getElementById("dashboard") !== null;
    });

    // Test 12: Module contrôles existe
    addTest("Module contrôles ISO existe", () => {
      return document.getElementById("controls") !== null;
    });

    // Test 13: Module SoA existe
    addTest("Module SoA existe", () => {
      return document.getElementById("soa") !== null;
    });

    // Test 14: Table contrôles body existe
    addTest("Table contrôles body existe", () => {
      return document.getElementById("controlsTableBody") !== null;
    });

    // Test 15: Table SoA body existe
    addTest("Table SoA body existe", () => {
      return document.getElementById("soaTableBody") !== null;
    });

    // Test 16: Fonction openModal existe
    addTest("Fonction openModal existe", () => {
      return typeof openModal === "function";
    });

    // Test 17: Fonction closeModal existe
    addTest("Fonction closeModal existe", () => {
      return typeof closeModal === "function";
    });

    // Test 18: Fonction loadControlsTable existe
    addTest("Fonction loadControlsTable existe", () => {
      return typeof loadControlsTable === "function";
    });

    // Test 19: Fonction loadSoaTable existe
    addTest("Fonction loadSoaTable existe", () => {
      return typeof loadSoaTable === "function";
    });

    // Test 20: Fonction escapeHtml existe
    addTest("Fonction escapeHtml existe", () => {
      return typeof escapeHtml === "function";
    });

    // Test 21: Fonction editSoaControl existe
    addTest("Fonction editSoaControl existe", () => {
      return typeof editSoaControl === "function";
    });

    // Test 22: Fonction renderSoaRiskBadges existe
    addTest("Fonction renderSoaRiskBadges existe", () => {
      return typeof renderSoaRiskBadges === "function";
    });

    // Test 23: Entrées SoA ont linkedRiskIds
    addTest("Entrées SoA supportent linkedRiskIds", () => {
      if (appData.soa.length === 0) return true;
      // Vérifier qu'au moins une entrée a linkedRiskIds défini
      return appData.soa.some(s => Array.isArray(s.linkedRiskIds));
    });

    // Test 24: renderSoaRiskBadges retourne HTML valide
    addTest("renderSoaRiskBadges retourne HTML pour risques vides", () => {
      const result = renderSoaRiskBadges([]);
      return result.includes("soa-risks__empty");
    });

    // Exécuter les tests
    setTimeout(() => {
      tests.forEach(test => {
        try {
          if (test.fn()) {
            passed++;
          } else {
            failed++;
            void 0;
          }
        } catch (e) {
          failed++;
          void 0;
        }
      });

      const total = tests.length;
      // FIX-I18N-AUTOSAVE (audit 2026-06-10, bundle.js:12866) : toasts traduits
      if (failed === 0) {
        showToast2(t('toasts.testsCompleted', 'Tests terminés : {passed}/{total} réussis')
          .replace('{passed}', passed).replace('{total}', total), "success");
      } else {
        showToast2(t('toasts.testsCompletedFailed', 'Tests terminés : {passed}/{total} réussis, {failed} échoué(s)')
          .replace('{passed}', passed).replace('{total}', total).replace('{failed}', failed), "warning");
      }
    }, 500);
  }
  function showToast2(message, type = "info", duration = 5000) {
    const container = document.getElementById("toastContainer");
    if (!container) return;
    // FIX-A: Deduplicate — skip if an identical toast is already visible
    var _existingMsgs = container.querySelectorAll('.toast .toast__message');
    for (var i = 0; i < _existingMsgs.length; i++) {
      if (_existingMsgs[i].textContent === message) return;
    }
    // O-004: Limit stacked toasts to 5
    var existingToasts = container.querySelectorAll('.toast');
    if (existingToasts.length >= 5) {
      var oldest = existingToasts[0];
      if (container.contains(oldest)) container.removeChild(oldest);
    }
    const toast = document.createElement("div");
    toast.className = `toast toast--${type}`;
    toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');
    const icons = { success: "check-circle", error: "exclamation-triangle", warning: "exclamation-circle", info: "info-circle" };
    toast.innerHTML = `<div class="toast__icon"><i class="fas fa-${icons[type]}"></i></div><div class="toast__content"><div class="toast__message">${escapeHtml(message)}</div></div><button class="toast__close" aria-label="${t('common.close', 'Fermer')}"><i class="fas fa-times"></i></button>`;
    container.appendChild(toast);
    setTimeout(() => {
      toast.classList.add("toast--visible");
    }, 10);
    const closeBtn = toast.querySelector(".toast__close");
    closeBtn.addEventListener("click", () => {
      toast.classList.remove("toast--visible");
      setTimeout(() => {
        container.removeChild(toast);
      }, 300);
    });
    setTimeout(() => {
      if (container.contains(toast)) {
        toast.classList.remove("toast--visible");
        setTimeout(() => {
          if (container.contains(toast)) container.removeChild(toast);
        }, 300);
      }
    }, duration);
  }
  let missingSubcategoryWarned = false;
  function warnMissingSubcategories() {
    const missing = (appData.controls || []).filter(c =>
      !c.subcategory || (Array.isArray(c.subcategory) && c.subcategory.length === 0)
    );
    if (missing.length > 0 && !missingSubcategoryWarned) {
      missingSubcategoryWarned = true;
      // FIX-I18N-AUTOSAVE (audit 2026-06-10, bundle.js:12866) : la clé
      // warnings.missingSubcategory existait dans LES DEUX locales mais n'était pas branchée.
      showToast2(t('warnings.missingSubcategory', { count: missing.length }), "warning");
    }
  }
  function hideLoadingSpinner2() {
    const spinner = document.getElementById("loadingSpinner");
    if (spinner) spinner.classList.add("hidden");
  }

  function updateAppTitle() {
    const el = document.getElementById("appTitle");
    if (el) {
      // vMajor : chiffres uniquement (version.json contrôlé), injecté après escapeHtml du titre.
      const vRaw = (window.CSI && window.CSI.version) ? String(window.CSI.version).split('.')[0] : '3';
      const vMajor = /^\d+$/.test(vRaw) ? vRaw : '3';
      el.innerHTML = '<i class="fas fa-shield-alt"></i> ' + escapeHtml(appData.title) +
        ' <span class="header__version-badge" id="appVersionBadge" title="Version ' + vMajor + '" aria-label="Version ' + vMajor + '">V' + vMajor + '</span>';
    }
  }

  function openManual() {
    // Le manuel vit désormais sur le site (page dédiée avec recherche intégrée).
    // Nouvel onglet pour ne pas interrompre la session de travail.
    window.open("https://cyber-assistant.com/manuel", "_blank", "noopener");
  }

  // Store for settings section content generators
  let settingsSectionGenerators = null;
  let currentSettingsSection = "general";

  function renderSettingsPage() {
    const container = document.getElementById("settingsContent");
    if (!container) return;
    container.innerHTML = "";

    // Create main layout with sidebar
    const layout = document.createElement("div");
    layout.className = "settings-layout";

    // Create sidebar navigation
    const sidebar = document.createElement("nav");
    sidebar.className = "settings-sidebar";
    const nav = document.createElement("ul");
    nav.className = "settings-nav";

    // Define navigation items
    const navItems = [
      { id: "general", icon: "fa-sliders", label: t('settings.nav.general', 'Général') },
      { id: "security", icon: "fa-shield-halved", label: t('settings.nav.security', 'Sécurité') },
      { id: "data", icon: "fa-database", label: t('settings.nav.data', 'Données') },
      { id: "storage", icon: "fa-hard-drive", label: t('settings.nav.storage', 'Stockage') }
    ];
    // Add license if unlocked
    // FIX-LICENSE-NAV-CORPLOCAL (audit 2026-06-10) : isServerModeUnlocked() est toujours
    // false en file:// — afficher aussi la section Licence pour l'édition Corporate Local
    // (statut + renouvellement anticipé : renderLicenseSection s'appuie sur
    // CSI_TrialManager, qui délègue à CSI_LocalTrialManager pour cette édition).
    var _edLicNav = window._csiEdition;
    if (isServerModeUnlocked() || (_edLicNav && _edLicNav.id === 'corporate-local')) {
      navItems.push({ id: "license", icon: "fa-key", label: t('settings.nav.license', 'Licence'), badge: "PRO" });
    }
    // Add users management if admin + server mode
    var AM = window.CSI_AuthManager;
    if (isServerModeUnlocked() && AM && AM.isAdmin()) {
      navItems.push({ id: "users", icon: "fa-users", label: t('settings.users', 'Utilisateurs'), badge: "ADMIN" });
    }
    // === CSI-ME === carte Multi-entités (admin + mode serveur ; la carte gère
    // elle-même l'état licence/activation).
    if (isServerModeUnlocked() && AM && AM.isAdmin()) {
      navItems.push({ id: "entities", icon: "fa-sitemap", label: t('settings.entities', 'Multi-entités') });
    } else if (!isServerModeUnlocked() && !DEMO_MODE) {
      // === CSI-MCP === Vitrines INERTES en editions locales (Community + Pro) : aucune
      // fonction MCP ni multi-entites n'y existe, seulement un teaser vers l'edition Serveur.
      navItems.push({ id: "entities", icon: "fa-sitemap", label: t('settings.entities', 'Multi-entités'), showcase: true });
      navItems.push({ id: "mcp", icon: "fa-wand-magic-sparkles", label: t('settings.nav.mcp', 'Accès IA'), showcase: true });
      // === CSI-UPD === vitrine inerte « Maintenance serveur » (decision PO 2026-08-03).
      navItems.push({ id: "maintenance", icon: "fa-cloud-arrow-up", label: t('settings.nav.maintenance', 'Maintenance serveur'), showcase: true });
    }
    // === CSI-UPD === Mise a jour du serveur par paquet ZIP signe : admin + mode serveur
    // uniquement (meme gate que Utilisateurs). La section se re-verifie elle-meme.
    if (isServerModeUnlocked() && AM && AM.isAdmin()) {
      navItems.push({ id: "maintenance", icon: "fa-cloud-arrow-up", label: t('settings.nav.maintenance', 'Maintenance serveur'), badge: "ADMIN" });
    }
    navItems.push(
      { id: "frameworks", icon: "fa-arrows-left-right", label: t('mapping.settings.title', 'Référentiels de sécurité') },
      { id: "advanced", icon: "fa-flask-vial", label: t('settings.nav.advanced', 'Avancé') }
    );
    // Zone Danger (réinitialisation) : en mode serveur, réservée aux administrateurs.
    // En mode local/file:// il n'y a pas de comptes — elle reste accessible.
    if (!isServerModeUnlocked() || (AM && AM.isAdmin())) {
      navItems.push({ id: "reset", icon: "fa-triangle-exclamation", label: t('settings.nav.danger', 'Zone Danger'), danger: true });
    } else if (currentSettingsSection === 'reset') {
      currentSettingsSection = 'general';
    }

    navItems.forEach(item => {
      const li = document.createElement("li");
      li.className = "settings-nav__item";
      if (item.danger) li.classList.add("settings-nav__item--danger");
      if (item.id === currentSettingsSection) li.classList.add("settings-nav__item--active");
      li.dataset.section = item.id;

      const icon = document.createElement("span");
      icon.className = "settings-nav__icon";
      icon.innerHTML = `<i class="fas ${item.icon}"></i>`;

      const label = document.createElement("span");
      label.textContent = item.label;

      li.appendChild(icon);
      li.appendChild(label);

      if (item.badge) {
        const badge = document.createElement("span");
        badge.className = "settings-nav__badge";
        badge.textContent = item.badge;
        li.appendChild(badge);
      }
      // === CSI-MCP === entree vitrine (fonction disponible en edition Serveur seulement).
      // Le modificateur --locked existe deja dans style.css (10336) et n'etait pas utilise.
      if (item.showcase) {
        const badge = document.createElement("span");
        badge.className = "settings-nav__badge settings-nav__badge--locked";
        badge.textContent = t('users.mcpShowcaseBadgeShort', 'Serveur');
        li.appendChild(badge);
      }

      li.addEventListener("click", () => {
        currentSettingsSection = item.id;
        document.querySelectorAll(".settings-nav__item").forEach(el => el.classList.remove("settings-nav__item--active"));
        li.classList.add("settings-nav__item--active");
        renderSettingsContent();
      });

      nav.appendChild(li);
    });

    sidebar.appendChild(nav);

    // Create content area
    const contentArea = document.createElement("div");
    contentArea.id = "settingsContentArea";
    contentArea.className = "settings-content-area";

    layout.appendChild(sidebar);
    layout.appendChild(contentArea);
    container.appendChild(layout);

    // Initialize section generators
    initSettingsSectionGenerators();

    // Render current section
    renderSettingsContent();
  }

  function renderSettingsContent() {
    const contentArea = document.getElementById("settingsContentArea");
    if (!contentArea || !settingsSectionGenerators) return;

    contentArea.classList.add("settings-content-area--transitioning");

    setTimeout(() => {
      contentArea.innerHTML = "";
      const generator = settingsSectionGenerators[currentSettingsSection];
      if (generator) {
        generator(contentArea);
      }
      contentArea.classList.remove("settings-content-area--transitioning");
      refreshReadOnlyLocks();
    }, 100);
  }

  function createSettingsCard({ title, description, icon, tone, status }) {
    const card = document.createElement("div");
    card.className = "settings-card";
    if (tone) card.classList.add(`settings-card--${tone}`);

    const header = document.createElement("div");
    header.className = "settings-card__header";

    const iconWrap = document.createElement("div");
    iconWrap.className = "settings-card__icon-wrap";
    iconWrap.innerHTML = `<i class="fas ${icon}"></i>`;

    const headerText = document.createElement("div");
    headerText.className = "settings-card__header-text";

    const titleEl = document.createElement("h3");
    titleEl.className = "settings-card__title";
    titleEl.textContent = title;

    const descEl = document.createElement("p");
    descEl.className = "settings-card__desc";
    descEl.textContent = description;

    headerText.appendChild(titleEl);
    headerText.appendChild(descEl);

    header.appendChild(iconWrap);
    header.appendChild(headerText);

    if (status !== undefined) {
      const statusEl = document.createElement("div");
      statusEl.className = `settings-card__status${status ? " settings-card__status--ok" : ""}`;
      header.appendChild(statusEl);
    }

    const body = document.createElement("div");
    body.className = "settings-card__body";

    card.appendChild(header);
    card.appendChild(body);

    return { card, body };
  }

  function initSettingsSectionGenerators() {
    settingsSectionGenerators = {
      general: renderGeneralSection,
      security: renderSecuritySection,
      data: renderDataSection,
      storage: renderStorageSection,
      license: renderLicenseSection,
      users: renderUsersSection,
      frameworks: renderFrameworksSection,
      advanced: renderAdvancedSection,
      reset: renderResetSection,
      // === CSI-ME === section Multi-entités (déléguée au module entity-ui.js).
      // === CSI-MCP === en editions locales, la meme entree rend une vitrine inerte.
      entities: function(container) {
        if (!isServerModeUnlocked()) { renderEntitiesShowcaseSection(container); return; }
        if (window.CSI_EntityUI) window.CSI_EntityUI.renderSettingsSection(container);
      },
      // === CSI-MCP === Acces IA : vitrine inerte en local, jamais la vraie carte.
      mcp: function(container) { renderMcpShowcaseSection(container); },
      // === CSI-UPD === Maintenance serveur : vitrine inerte hors mode serveur.
      maintenance: function(container) {
        if (!isServerModeUnlocked()) { renderUpdateShowcaseSection(container); return; }
        renderMaintenanceSection(container);
      }
    };
  }

  // Settings helper functions
  function createSettingsInputGroup({ id, label, value = "", type = "text", placeholder = "", help = "", readonly = true }) {
    const wrap = document.createElement("div");
    wrap.className = "settings-form-group";
    const lbl = document.createElement("label");
    lbl.className = "settings-form-label";
    lbl.setAttribute("for", id);
    lbl.textContent = label;
    const input = document.createElement("input");
    input.id = id;
    input.type = type;
    input.className = "form-control";
    input.value = value || "";
    if (placeholder) input.placeholder = placeholder;
    if (readonly) input.dataset.readonlyLock = "true";
    wrap.appendChild(lbl);
    wrap.appendChild(input);
    if (help) {
      const helpText = document.createElement("p");
      helpText.className = "settings-form-help";
      helpText.textContent = help;
      wrap.appendChild(helpText);
    }
    return { wrap, input };
  }

  function createSettingsPasswordGroup({ id, label, value = "", placeholder = "", help = "" }) {
    const wrap = document.createElement("div");
    wrap.className = "settings-form-group";
    const lbl = document.createElement("label");
    lbl.className = "settings-form-label";
    lbl.setAttribute("for", id);
    lbl.textContent = label;
    const row = document.createElement("div");
    row.className = "settings-input-row";
    const input = document.createElement("input");
    input.id = id;
    input.type = "password";
    input.className = "form-control";
    input.value = value || "";
    if (placeholder) input.placeholder = placeholder;
    input.dataset.readonlyLock = "true";
    const toggle = document.createElement("button");
    toggle.type = "button";
    toggle.className = "btn btn--outline btn--sm settings-toggle-btn";
    toggle.innerHTML = '<i class="fas fa-eye"></i>';
    toggle.setAttribute("aria-pressed", "false");
    toggle.addEventListener("click", () => {
      const isHidden = input.type === "password";
      input.type = isHidden ? "text" : "password";
      toggle.innerHTML = isHidden ? '<i class="fas fa-eye-slash"></i>' : '<i class="fas fa-eye"></i>';
      toggle.setAttribute("aria-pressed", String(isHidden));
    });
    row.append(input, toggle);
    wrap.appendChild(lbl);
    wrap.appendChild(row);
    if (help) {
      const helpText = document.createElement("p");
      helpText.className = "settings-form-help";
      helpText.textContent = help;
      wrap.appendChild(helpText);
    }
    return { wrap, input, toggle };
  }

  function createSettingsSelectGroup({ id, label, value = "", options = [], help = "" }) {
    const wrap = document.createElement("div");
    wrap.className = "settings-form-group";
    const lbl = document.createElement("label");
    lbl.className = "settings-form-label";
    lbl.setAttribute("for", id);
    lbl.textContent = label;
    const select = document.createElement("select");
    select.id = id;
    select.className = "form-control";
    select.dataset.readonlyLock = "true";
    options.forEach((opt) => {
      const option = document.createElement("option");
      option.value = opt.value;
      option.textContent = opt.label;
      select.appendChild(option);
    });
    select.value = value || "";
    wrap.appendChild(lbl);
    wrap.appendChild(select);
    if (help) {
      const helpText = document.createElement("p");
      helpText.className = "settings-form-help";
      helpText.textContent = help;
      wrap.appendChild(helpText);
    }
    return { wrap, select };
  }

  // === SECTION: GENERAL ===
  function renderGeneralSection(container) {
    const legacyTitle = "Système de Management de la Sécurité";
    const safeTitle = !appData.title || appData.title === legacyTitle ? "Cyber-Assistant" : appData.title;
    if (appData.title !== safeTitle) {
      appData.title = safeTitle;
      updateAppTitle();
    }

    const { card, body } = createSettingsCard({
      title: t('settings.generalTitle', 'Configuration générale'),
      description: t('settings.generalDesc', 'Personnalisez le titre, le thème et les cibles de maturité.'),
      icon: "fa-sliders"
    });

    const formGrid = document.createElement("div");
    formGrid.className = "settings-form-grid";

    const { wrap: titleWrap, input: titleInput } = createSettingsInputGroup({
      id: "settingsTitle",
      label: t('settings.appTitleLabel', "Titre de l'application"),
      value: safeTitle
    });
    titleWrap.classList.add("settings-form-group--full");

    const { wrap: themeWrap, select: themeSelect } = createSettingsSelectGroup({
      id: "settingsTheme",
      label: t('settings.themeLabel', 'Thème'),
      value: currentTheme,
      options: THEME_OPTIONS
    });

    const langOptions = window.CSI_i18n ? Object.entries(window.CSI_i18n.getAvailableLanguages()).map(([code, info]) => ({
      value: code,
      label: `${info.flag} ${info.name}`
    })) : [
      { value: "fr", label: "🇫🇷 Français" },
      { value: "en", label: "🇬🇧 English" }
    ];
    const currentLang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : "fr";
    const { wrap: langWrap, select: langSelect } = createSettingsSelectGroup({
      id: "settingsLanguage",
      label: "Langue / Language / Taal",
      value: currentLang,
      options: langOptions
    });

    const { wrap: isoWrap, input: isoInput } = createSettingsInputGroup({
      id: "settingsIsoTarget",
      label: t('settings.isoTarget', 'Cible ISO'),
      type: "number",
      value: appData.targetMaturity.iso27002,
      help: t('settings.isoTargetHelp', 'Niveau de maturité visé (1-5).')
    });
    isoInput.min = "1";
    isoInput.max = "5";

    const { wrap: nis2Wrap, input: nis2Input } = createSettingsInputGroup({
      id: "settingsNis2Target",
      label: t('settings.nis2Target', 'Cible NIS2'),
      type: "number",
      value: appData.targetMaturity.nis2Target,
      help: t('settings.nis2TargetHelp', 'Valeur cible (1-5) pour toutes les catégories.')
    });
    nis2Input.min = "1";
    nis2Input.max = "5";

    formGrid.append(titleWrap, themeWrap, langWrap);
    body.appendChild(formGrid);

    const targetGrid = document.createElement("div");
    targetGrid.className = "settings-form-grid settings-form-grid--targets";
    targetGrid.append(isoWrap, nis2Wrap);
    body.appendChild(targetGrid);

    const actions = document.createElement("div");
    actions.className = "settings-card__actions";
    const btnSave = document.createElement("button");
    btnSave.id = "settingsSave";
    btnSave.className = "btn btn--primary btn--sm";
    btnSave.textContent = t('settings.saveBtn', 'Enregistrer les paramètres');
    btnSave.dataset.readonlyLock = "true";
    actions.appendChild(btnSave);
    body.appendChild(actions);

    btnSave.addEventListener("click", () => {
      if (!ensureEditMode(t('settings.saveBtn', 'Enregistrer les paramètres'))) {
        refreshReadOnlyLocks();
        return;
      }
      const newTitle = titleInput.value.trim();
      if (newTitle) {
        appData.title = newTitle;
        updateAppTitle();
      }
      if (themeSelect.value) setTheme(themeSelect.value);
      const isoTarget = parseInt(isoInput.value, 10);
      if (!isNaN(isoTarget)) appData.targetMaturity.iso27002 = isoTarget;
      const nis2Target = parseInt(nis2Input.value, 10);
      if (!isNaN(nis2Target)) appData.targetMaturity.nis2Target = nis2Target;
      if (!appData.targetMaturity.nis2) {
        appData.targetMaturity.nis2 = Object.assign({}, DEFAULT_NIS2_MATURITY);
      }
      const newLang = langSelect.value;
      if (newLang && window.CSI_i18n && newLang !== window.CSI_i18n.getLanguage()) {
        window.CSI_i18n.setLanguage(newLang);
        window.CSI_i18n.translatePage();
        // FEAT-USER-LANGUAGE (2026-07-10) : en mode serveur, la langue suit le compte.
        // Push best-effort — en cas d'échec, localStorage reste la référence locale.
        if (window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn && window.CSI_AuthManager.isLoggedIn()) {
          window.CSI_AuthManager.setUserLanguage(newLang).catch(function(e) {
            void 0;
          });
        }
      }
            addAuditEntry('config', 'settings', appData.title || 'Settings');
      updateDashboard();
      // FIX-GLOBAL-SYNC: Settings contain global properties (title, targets, theme)
      // that are not tab-specific. Push them to server so other users receive them.
      // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
      // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4) : le push direct était
      // refusé 423 dès qu'on éditait (grappes incomplètes) et le .catch ne voyait rien —
      // « Paramètres enregistrés » menteur, titre perdu. _persistConfigWithLock acquiert.
      // FIX-FRICTION-CONFIG-TOAST (simulation friction 2026-07-19) : n'annoncer le succès
      // qu'APRÈS confirmation du serveur. Avant, « Paramètres enregistrés » (succès) partait
      // inconditionnellement et se superposait à « Configuration non enregistrée… » (échec,
      // émis par _persistConfigWithLock quand un collègue tient la grappe config) → deux
      // messages CONTRADICTOIRES en 0,1 s. En local ou config libre : succès normal.
      _persistConfigWithLock(['title', 'targetMaturity']).then(function(r) {
        if (r && r.success === false) return; // le message d'échec a déjà été affiché
        showToast2(t('toasts.settingsSaved'), "success");
        btnSave.classList.add("btn--success-flash");
        setTimeout(() => btnSave.classList.remove("btn--success-flash"), 600);
      });
    });

    container.appendChild(card);

    // === Logo card ===
    const { card: logoCard, body: logoBody } = createSettingsCard({
      title: t('settings.logoTitle'),
      description: t('settings.logoDescription'),
      icon: "fa-image"
    });

    const logoGrid = document.createElement("div");
    logoGrid.className = "settings-form-grid";

    const previewWrap = document.createElement("div");
    previewWrap.className = "settings-form-group settings-form-group--full settings-logo-preview-wrap";

    function refreshLogoPreview() {
      previewWrap.innerHTML = "";
      const currentLogo = (appData.settings && appData.settings.logo) || null;
      if (currentLogo) {
        const img = document.createElement("img");
        img.src = currentLogo;
        img.className = "settings-logo-preview";
        img.alt = "Logo";
        previewWrap.appendChild(img);
        btnRemoveLogo.style.display = "";
      } else {
        const placeholder = document.createElement("div");
        placeholder.className = "settings-logo-placeholder";
        placeholder.innerHTML = '<i class="fas fa-image"></i><span>' + t('settings.logoNone') + '</span>';
        previewWrap.appendChild(placeholder);
        btnRemoveLogo.style.display = "none";
      }
    }

    const fileWrap = document.createElement("div");
    fileWrap.className = "settings-form-group settings-form-group--full";
    const fileLabel = document.createElement("label");
    fileLabel.textContent = t('settings.logoUpload');
    fileLabel.className = "settings-label";
    const logoFileInput = document.createElement("input");
    logoFileInput.type = "file";
    logoFileInput.accept = "image/png,image/jpeg,image/svg+xml";
    logoFileInput.className = "form-control";
    logoFileInput.dataset.readonlyLock = "true";
    fileWrap.appendChild(fileLabel);
    fileWrap.appendChild(logoFileInput);

    const logoActions = document.createElement("div");
    logoActions.className = "settings-card__actions";
    const btnSaveLogo = document.createElement("button");
    btnSaveLogo.className = "btn btn--primary btn--sm";
    btnSaveLogo.textContent = t('settings.logoSave');
    btnSaveLogo.dataset.readonlyLock = "true";
    const btnRemoveLogo = document.createElement("button");
    btnRemoveLogo.className = "btn btn--outline btn--sm";
    btnRemoveLogo.textContent = t('settings.logoRemove');
    btnRemoveLogo.dataset.readonlyLock = "true";
    btnRemoveLogo.style.display = (appData.settings && appData.settings.logo) ? "" : "none";
    logoActions.append(btnSaveLogo, btnRemoveLogo);

    btnSaveLogo.addEventListener("click", () => {
      if (!ensureEditMode(t('settings.logoSave', 'Enregistrer le logo'))) { refreshReadOnlyLocks(); return; }
      const file = logoFileInput.files[0];
      if (!file) { showToast2(t('settings.logoNone'), "warning"); return; }
      if (file.size > 512000) { showToast2(t('settings.logoTooLarge'), "warning"); return; }
      const reader = new FileReader();
      reader.onload = function(ev) {
        if (!appData.settings) appData.settings = {};
        appData.settings.logo = ev.target.result;
                addAuditEntry('config', 'settings', t('audit.logoUploaded', 'Logo uploadé'));
        refreshLogoPreview();
        logoFileInput.value = "";
        // (audit co-édition 2026-08-05, MAJEUR) `settings` appartient à la section app-config
        // (grappe `config`) : sans persistance, la mutation ne vivait qu'en RAM et disparaissait
        // au rechargement — avec un « Logo enregistré » affiché quoi qu'il arrive. Même patron
        // que « Enregistrer les paramètres » : le succès n'est annoncé qu'après le 200.
        _persistConfigWithLock(['settings']).then(function(r) {
          if (r && r.success === false) return;   // message d'échec déjà affiché
          showToast2(t('settings.logoSaved'), "success");
        });
      };
      reader.readAsDataURL(file);
    });

    btnRemoveLogo.addEventListener("click", () => {
      if (!ensureEditMode(t('settings.logoRemove', 'Supprimer le logo'))) { refreshReadOnlyLocks(); return; }
      if (appData.settings) delete appData.settings.logo;
            addAuditEntry('config', 'settings', t('audit.logoRemoved', 'Logo supprimé'));
      refreshLogoPreview();
      logoFileInput.value = "";
      _persistConfigWithLock(['settings']).then(function(r) {   // idem : suppression persistée
        if (r && r.success === false) return;
        showToast2(t('settings.logoRemoved'), "info");
      });
    });

    logoGrid.append(previewWrap, fileWrap);
    logoBody.append(logoGrid, logoActions);
    container.appendChild(logoCard);
    refreshLogoPreview();

    // === Second card: Display options ===
    const { card: displayCard, body: displayBody } = createSettingsCard({
      title: t('settings.displayTitle', "Options d'affichage"),
      description: t('settings.displayDesc', "Personnalisez l'apparence et la densité de l'interface."),
      icon: "fa-desktop"
    });

    const displayGrid = document.createElement("div");
    displayGrid.className = "settings-form-grid";

    // Compact mode toggle
    const compactWrap = document.createElement("div");
    compactWrap.className = "settings-form-group settings-form-group--full";
    const compactLabel = document.createElement("label");
    compactLabel.className = "settings-checkbox";
    const compactInput = document.createElement("input");
    compactInput.type = "checkbox";
    compactInput.id = "settingsCompactMode";
    compactInput.checked = document.body.classList.contains("compact-mode");
    compactLabel.appendChild(compactInput);
    compactLabel.appendChild(document.createTextNode(' ' + t('settings.compactMode', 'Mode compact')));
    const compactHelp = document.createElement("p");
    compactHelp.className = "settings-field-help";
    compactHelp.textContent = t('settings.compactModeHelp', "Réduit l'espacement et la taille des éléments pour afficher plus d'informations.");
    compactWrap.appendChild(compactLabel);
    compactWrap.appendChild(compactHelp);

    compactInput.addEventListener("change", () => {
      document.body.classList.toggle("compact-mode", compactInput.checked);
      try {
        localStorage.setItem("smsi_compact_mode", compactInput.checked ? "1" : "0");
      } catch (e) {
        void 0;
      }
      showToast2(compactInput.checked ? t('settings.compactEnabled', 'Mode compact activé') : t('settings.compactDisabled', 'Mode compact désactivé'), "info");
    });

    displayGrid.appendChild(compactWrap);
    displayBody.appendChild(displayGrid);
    container.appendChild(displayCard);
  }

  // === SECTION: SECURITY ===
  function renderSecuritySection(container) {
   // En mode serveur, le mot de passe d'édition locale (Phase 1) est inutile — l'auth JWT suffit
   if (!isServerModeUnlocked()) {
    const { card, body } = createSettingsCard({
      title: t('settings.editPasswordTitle', 'Mot de passe du mode édition'),
      description: t('settings.editPasswordDesc', `Minimum ${MIN_EDIT_PASSWORD_LENGTH} caractères, 1 majuscule, 1 minuscule, 1 caractère spécial.`).replace('{{min}}', MIN_EDIT_PASSWORD_LENGTH),
      icon: "fa-shield-halved",
      status: !!getStoredEditPasswordHash()
    });

    const formGrid = document.createElement("div");
    formGrid.className = "settings-form-grid";

    const { wrap: editPwdWrap, input: editPwdInput } = createSettingsPasswordGroup({
      id: "settingsEditPassword",
      label: t('settings.newPassword', 'Nouveau mot de passe'),
      placeholder: t('settings.minCharsPlaceholder', `Minimum ${MIN_EDIT_PASSWORD_LENGTH} caractères`).replace('{{min}}', MIN_EDIT_PASSWORD_LENGTH)
    });

    const { wrap: editPwdConfirmWrap, input: editPwdConfirmInput } = createSettingsPasswordGroup({
      id: "settingsEditPasswordConfirm",
      label: t('settings.confirmation', 'Confirmation'),
      placeholder: t('settings.retypePassword', 'Ressaisir le mot de passe')
    });

    formGrid.append(editPwdWrap, editPwdConfirmWrap);
    body.appendChild(formGrid);

    const { wrap: policyWrap, list: policyList } = createPasswordPolicyList();
    body.appendChild(policyWrap);

    const passwordStatus = document.createElement("p");
    passwordStatus.className = "settings-form-help";
    passwordStatus.textContent = getStoredEditPasswordHash()
      ? t('settings.customPasswordActive', 'Mot de passe personnalisé actif.')
      : t('settings.defaultPasswordActive', 'Mot de passe par défaut actif.');
    body.appendChild(passwordStatus);

    const updatePolicy = () => updatePasswordPolicyList(policyList, editPwdInput.value);
    editPwdInput.addEventListener("input", updatePolicy);
    updatePolicy();

    const actions = document.createElement("div");
    actions.className = "settings-card__actions";
    const btnUpdatePassword = document.createElement("button");
    btnUpdatePassword.className = "btn btn--primary btn--sm";
    btnUpdatePassword.textContent = t('settings.updatePassword', 'Mettre à jour le mot de passe');
    btnUpdatePassword.dataset.readonlyLock = "true";
    actions.appendChild(btnUpdatePassword);
    body.appendChild(actions);

    btnUpdatePassword.addEventListener("click", () => {
      if (!ensureEditMode(t('settings.updatePassword', 'Mettre à jour le mot de passe'))) {
        refreshReadOnlyLocks();
        return;
      }
      const password = editPwdInput.value || "";
      const confirm = editPwdConfirmInput.value || "";
      if (!password) {
        showToast2(t('toasts.enterPassword'), "warning");
        return;
      }
      const policy = getEditPasswordPolicyStatus(password);
      if (!policy.isValid) {
        const detail = policy.missing.length ? t('passwordPolicy.missingRequirements', 'Exigences manquantes') + ': ' + policy.missing.join(", ") + '.' : "";
        showToast2((t('settings.passwordNonCompliant', 'Mot de passe non conforme.') + ' ' + detail).trim(), "warning");
        return;
      }
      if (password !== confirm) {
        showToast2(t('toasts.passwordMismatch'), "error");
        return;
      }
      btnUpdatePassword.disabled = true;
      const initialLabel = btnUpdatePassword.textContent;
      btnUpdatePassword.textContent = t('common.saving', 'Enregistrement...');
      hashEditPassword(password)
        .then((hash) => {
          setStoredEditPasswordHash(hash);
          addAuditEntry('config', 'settings', t('audit.passwordChanged', 'Mot de passe modifié'));
          editPasswordUnlocked = true;
          editPwdInput.value = "";
          editPwdConfirmInput.value = "";
          passwordStatus.textContent = t('settings.customPasswordActive', 'Mot de passe personnalisé actif.');
          showToast2(t('toasts.passwordUpdated'), "success");
        })
        .catch((err) => {
          void 0;
          showToast2(t('toasts.passwordSaveError', "Impossible d'enregistrer le mot de passe"), "error");
        })
        .finally(() => {
          btnUpdatePassword.disabled = false;
          btnUpdatePassword.textContent = initialLabel;
        });
    });

    container.appendChild(card);
   } // fin if (!isServerModeUnlocked())

    // Server mode: "Change my password" card
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
      var { card: srvPwdCard, body: srvPwdBody } = createSettingsCard({
        title: t('settings.changeServerPassword', 'Changer mon mot de passe'),
        description: t('settings.changeServerPasswordDesc', 'Modifiez votre mot de passe de connexion.'),
        icon: 'fa-key'
      });
      var btnChangeSrvPwd = document.createElement('button');
      btnChangeSrvPwd.className = 'btn btn--primary btn--sm';
      btnChangeSrvPwd.textContent = t('settings.changePasswordBtn', 'Changer le mot de passe');
      btnChangeSrvPwd.addEventListener('click', function() {
        window.CSI_AuthManager.showChangePasswordModal();
      });
      srvPwdBody.appendChild(btnChangeSrvPwd);
      container.appendChild(srvPwdCard);
    }

    // ── Card A: Tableau de bord sécurité (both modes) ──
    (function renderSecurityDashboardCard() {
      var isServer = isServerModeUnlocked();
      var checks = [];
      var totalOk = 0;

      var hasCrypto = !!(window.crypto && window.crypto.subtle);
      checks.push({ label: t('adminUi.checkWebCrypto', 'Web Crypto API disponible'), ok: hasCrypto });
      if (hasCrypto) totalOk++;

      var hasEditPwd = !!getStoredEditPasswordHash();
      checks.push({ label: t('adminUi.checkEditPassword', "Mot de passe d'édition défini"), ok: hasEditPwd });
      if (hasEditPwd) totalOk++;

      checks.push({ label: t('adminUi.checkHashAlgo', 'Algorithme de hachage (PBKDF2 600k)'), ok: true });
      totalOk++;

      var hasRandomUUID = !!(window.crypto && typeof window.crypto.randomUUID === 'function');
      checks.push({ label: t('adminUi.checkRandomUuid', 'crypto.randomUUID() supporté'), ok: hasRandomUUID });
      if (hasRandomUUID) totalOk++;

      var proto = window.location.protocol;
      var secureProto = (proto === 'file:' || proto === 'https:');
      checks.push({ label: t('adminUi.checkSecureProtocol', 'Protocole sécurisé (file:// ou https://)'), ok: secureProto });
      if (secureProto) totalOk++;

      var totalChecks = checks.length;

      var _buildDashboardCard = function(serverChecks, serverStats) {
        var allChecks = checks.slice();
        if (serverChecks && typeof serverChecks === 'object') {
          var keys = Object.keys(serverChecks);
          for (var si = 0; si < keys.length; si++) {
            var sk = keys[si];
            var sOk = !!serverChecks[sk];
            allChecks.push({ label: sk.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }), ok: sOk });
            totalChecks++;
            if (sOk) totalOk++;
          }
        }

        var scoreText = totalOk + '/' + totalChecks + ' ' + t('adminUi.checksOkSuffix', 'contrôles OK');
        var allOk = (totalOk === totalChecks);

        var dashRes = createSettingsCard({
          title: t('settings.securityDashboard', 'Tableau de bord sécurité'),
          description: scoreText,
          icon: 'fa-shield-halved',
          status: allOk
        });
        var dashCard = dashRes.card;
        var dashBody = dashRes.body;

        var dashGrid = document.createElement('div');
        dashGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px;';

        for (var ci = 0; ci < allChecks.length; ci++) {
          var chk = allChecks[ci];
          var item = document.createElement('div');
          item.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:6px;background:var(--bg-secondary,#f5f5f5);';

          var ico = document.createElement('i');
          if (chk.ok) {
            ico.className = 'fas fa-check-circle';
            ico.style.color = '#16a34a';
          } else {
            ico.className = 'fas fa-exclamation-triangle';
            ico.style.color = '#d97706';
          }
          var lbl = document.createElement('span');
          lbl.style.cssText = 'font-size:0.85rem;';
          lbl.textContent = chk.label;

          item.appendChild(ico);
          item.appendChild(lbl);
          dashGrid.appendChild(item);
        }

        dashBody.appendChild(dashGrid);

        if (serverStats && typeof serverStats === 'object') {
          var statsWrap = document.createElement('div');
          statsWrap.style.cssText = 'margin-top:12px;padding:10px;border-radius:6px;background:var(--bg-secondary,#f5f5f5);';
          var statsTitle = document.createElement('strong');
          statsTitle.textContent = t('adminUi.serverStats', 'Statistiques serveur');
          statsTitle.style.cssText = 'display:block;margin-bottom:6px;font-size:0.85rem;';
          statsWrap.appendChild(statsTitle);

          var statKeys = Object.keys(serverStats);
          for (var sti = 0; sti < statKeys.length; sti++) {
            var statLine = document.createElement('div');
            statLine.style.cssText = 'display:flex;justify-content:space-between;font-size:0.8rem;padding:2px 0;';
            var statLabel = document.createElement('span');
            statLabel.textContent = statKeys[sti].replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
            var statVal = document.createElement('span');
            statVal.style.fontWeight = '600';
            statVal.textContent = String(serverStats[statKeys[sti]]);
            statLine.appendChild(statLabel);
            statLine.appendChild(statVal);
            statsWrap.appendChild(statLine);
          }
          dashBody.appendChild(statsWrap);
        }

        container.appendChild(dashCard);
      };

      if (isServer && window.CSI_AuthManager && typeof window.CSI_AuthManager.getSecurityStatus === 'function') {
        window.CSI_AuthManager.getSecurityStatus()
          .then(function(result) {
            _buildDashboardCard(result && result.checks, result && result.stats);
          })
          .catch(function() {
            _buildDashboardCard(null, null);
          });
      } else {
        _buildDashboardCard(null, null);
      }
    })();

    // ── Card B: Recommandations de sécurité (both modes) ──
    (function renderSecurityRecommendationsCard() {
      var isServer = isServerModeUnlocked();
      var recoRes = createSettingsCard({
        title: t('settings.securityRecommendations', 'Recommandations de sécurité'),
        description: t('settings.securityRecommendationsDesc', 'Conseils contextuels basés sur votre posture de sécurité.'),
        icon: 'fa-lightbulb'
      });
      var recoCard = recoRes.card;
      var recoBody = recoRes.body;

      var recoList = document.createElement('ul');
      recoList.style.cssText = 'list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;';

      var _addReco = function(text, level) {
        var li = document.createElement('li');
        li.style.cssText = 'display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border-radius:6px;background:var(--bg-secondary,#f5f5f5);font-size:0.85rem;';
        var rIco = document.createElement('i');
        if (level === 'warning') {
          rIco.className = 'fas fa-exclamation-triangle';
          rIco.style.color = '#d97706';
        } else {
          rIco.className = 'fas fa-info-circle';
          rIco.style.color = '#2563eb';
        }
        var rTxt = document.createElement('span');
        rTxt.textContent = text;
        li.appendChild(rIco);
        li.appendChild(rTxt);
        recoList.appendChild(li);
      };

      if (!isServer) {
        if (!getStoredEditPasswordHash()) {
          _addReco(t('adminUi.recoEditPassword', "Définissez un mot de passe d'édition robuste pour protéger vos données."), 'warning');
        }
        _addReco(t('adminUi.recoExportBackup', 'Exportez régulièrement vos données en tant que sauvegarde.'), 'info');
        _addReco(t('adminUi.recoModernBrowser', 'Utilisez un navigateur moderne avec support de Web Crypto API.'), 'info');
      } else {
        if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
          _addReco(t('adminUi.recoHttps', 'Activez HTTPS pour des communications chiffrées.'), 'warning');
        }
        _addReco(t('adminUi.recoCors', 'Configurez une origine CORS spécifique plutôt que * (wildcard).'), 'warning');
        _addReco(t('adminUi.recoUpdates', 'Mettez à jour régulièrement le serveur et ses dépendances.'), 'info');
        _addReco(t('adminUi.recoBackups', 'Activez les sauvegardes automatiques sur le serveur.'), 'info');
        _addReco(t('adminUi.recoAuditLogs', "Vérifiez les journaux d'audit régulièrement."), 'info');
      }

      recoBody.appendChild(recoList);
      container.appendChild(recoCard);
    })();

    // ── Card C: Journal d'audit (server, admin only) ──
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isAdmin()) {
      (function renderAuditLogCard() {
        var eventColors = {
          login_success: '#16a34a',
          login_failed: '#dc2626',
          logout: '#6b7280',
          user_created: '#2563eb',
          user_deleted: '#dc2626',
          user_suspended: '#d97706',
          user_unsuspended: '#16a34a',
          role_changed: '#7c3aed',
          password_changed: '#2563eb',
          password_reset: '#d97706',
          data_import: '#0891b2',
          data_push: '#0891b2',
          lock_acquired: '#6b7280',
          license_activated: '#16a34a',
          setup_completed: '#16a34a',
          rate_limit_hit: '#dc2626',
          account_locked: '#dc2626',
          brute_force_detected: '#dc2626'
        };

        var auditRes = createSettingsCard({
          title: t('settings.auditLog', "Journal d'audit"),
          description: t('settings.auditLogDesc', 'Historique des événements de sécurité du serveur.'),
          icon: 'fa-clipboard-list'
        });
        var auditCard = auditRes.card;
        var auditBody = auditRes.body;

        var filterRow = document.createElement('div');
        filterRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:10px;';

        var filterLabel = document.createElement('label');
        filterLabel.textContent = t('adminUi.filterByType', 'Filtrer par type :');
        filterLabel.style.cssText = 'font-size:0.85rem;font-weight:500;';

        var filterSelect = document.createElement('select');
        filterSelect.style.cssText = 'padding:4px 8px;border:1px solid var(--border-color,#ccc);border-radius:4px;font-size:0.8rem;';
        var optAll = document.createElement('option');
        optAll.value = '';
        optAll.textContent = t('adminUi.allEvents', 'Tous les événements');
        filterSelect.appendChild(optAll);

        var eventTypes = Object.keys(eventColors);
        for (var ei = 0; ei < eventTypes.length; ei++) {
          var opt = document.createElement('option');
          opt.value = eventTypes[ei];
          opt.textContent = eventTypes[ei].replace(/_/g, ' ');
          filterSelect.appendChild(opt);
        }

        filterRow.appendChild(filterLabel);
        filterRow.appendChild(filterSelect);
        auditBody.appendChild(filterRow);

        var tableWrap = document.createElement('div');
        tableWrap.style.cssText = 'max-height:350px;overflow-y:auto;border:1px solid var(--border-color,#ddd);border-radius:6px;';

        var table = document.createElement('table');
        table.style.cssText = 'width:100%;border-collapse:collapse;font-size:0.8rem;';
        var thead = document.createElement('thead');
        var headRow = document.createElement('tr');
        headRow.style.cssText = 'background:var(--bg-secondary,#f5f5f5);position:sticky;top:0;';
        var headCols = [t('adminUi.colDate', 'Date'), t('adminUi.colEvent', 'Événement'), t('adminUi.colUser', 'Utilisateur'), t('adminUi.colIp', 'IP'), t('adminUi.colDetails', 'Détails')];
        for (var hi = 0; hi < headCols.length; hi++) {
          var th = document.createElement('th');
          th.textContent = headCols[hi];
          th.style.cssText = 'padding:8px 10px;text-align:left;font-weight:600;border-bottom:2px solid var(--border-color,#ddd);white-space:nowrap;';
          headRow.appendChild(th);
        }
        thead.appendChild(headRow);
        table.appendChild(thead);

        var tbody = document.createElement('tbody');
        table.appendChild(tbody);
        tableWrap.appendChild(table);
        auditBody.appendChild(tableWrap);

        var currentOffset = 0;
        var currentFilter = '';

        var _renderRows = function(entries) {
          for (var ri = 0; ri < entries.length; ri++) {
            var entry = entries[ri];
            var tr = document.createElement('tr');
            tr.style.cssText = 'border-bottom:1px solid var(--border-color,#eee);';

            var tdDate = document.createElement('td');
            tdDate.style.cssText = 'padding:6px 10px;white-space:nowrap;';
            var dateStr = entry.timestamp || entry.date || '';
            if (dateStr) {
              try { dateStr = new Date(dateStr).toLocaleString('fr-FR'); } catch(e) {}
            }
            tdDate.textContent = dateStr;
            tr.appendChild(tdDate);

            var tdEvent = document.createElement('td');
            tdEvent.style.cssText = 'padding:6px 10px;';
            var badge = document.createElement('span');
            var evtType = entry.eventType || entry.event || entry.type || '';
            badge.textContent = evtType.replace(/_/g, ' ');
            var badgeColor = eventColors[evtType] || '#6b7280';
            badge.style.cssText = 'display:inline-block;padding:2px 8px;border-radius:10px;font-size:0.75rem;font-weight:500;color:#fff;background:' + badgeColor + ';';
            tdEvent.appendChild(badge);
            tr.appendChild(tdEvent);

            var tdUser = document.createElement('td');
            tdUser.style.cssText = 'padding:6px 10px;';
            tdUser.textContent = entry.userName || entry.username || entry.user || '-';
            tr.appendChild(tdUser);

            var tdIP = document.createElement('td');
            tdIP.style.cssText = 'padding:6px 10px;font-family:monospace;font-size:0.75rem;';
            tdIP.textContent = entry.ip || '-';
            tr.appendChild(tdIP);

            var tdDetails = document.createElement('td');
            tdDetails.style.cssText = 'padding:6px 10px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
            tdDetails.textContent = entry.details || entry.message || '-';
            tdDetails.title = entry.details || entry.message || '';
            tr.appendChild(tdDetails);

            tbody.appendChild(tr);
          }
        };

        var _loadEntries = function(offset, eventType) {
          if (typeof window.CSI_AuthManager.getAuditLog !== 'function') return;
          window.CSI_AuthManager.getAuditLog(50, offset, eventType || undefined)
            .then(function(result) {
              var entries = Array.isArray(result) ? result : (result && result.entries ? result.entries : []);
              _renderRows(entries);
              currentOffset = offset + entries.length;
              if (entries.length < 50) {
                btnLoadMore.style.display = 'none';
              } else {
                btnLoadMore.style.display = '';
              }
            })
            .catch(function(err) {
              void 0;
              var trErr = document.createElement('tr');
              var tdErr = document.createElement('td');
              tdErr.setAttribute('colspan', '5');
              tdErr.style.cssText = 'padding:12px;text-align:center;color:#dc2626;';
              tdErr.textContent = t('adminUi.auditLoadError', 'Erreur lors du chargement du journal.');
              trErr.appendChild(tdErr);
              tbody.appendChild(trErr);
            });
        };

        var loadMoreWrap = document.createElement('div');
        loadMoreWrap.style.cssText = 'margin-top:8px;text-align:center;';
        var btnLoadMore = document.createElement('button');
        btnLoadMore.className = 'btn btn--secondary btn--sm';
        btnLoadMore.textContent = t('adminUi.loadMore', 'Charger plus');
        btnLoadMore.addEventListener('click', function() {
          _loadEntries(currentOffset, currentFilter);
        });
        loadMoreWrap.appendChild(btnLoadMore);
        auditBody.appendChild(loadMoreWrap);

        filterSelect.addEventListener('change', function() {
          currentFilter = filterSelect.value;
          currentOffset = 0;
          while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
          _loadEntries(0, currentFilter);
        });

        _loadEntries(0, '');

        container.appendChild(auditCard);
      })();
    }

    // ── Card D: Filtrage IP (server, admin only) ──
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isAdmin()) {
      (function renderIPFilteringCard() {
        var ipRes = createSettingsCard({
          title: t('settings.ipFiltering', 'Filtrage IP'),
          description: t('settings.ipFilteringDesc', "Restreindre l'accès par adresse IP (supporte la notation CIDR)."),
          icon: 'fa-network-wired'
        });
        var ipCard = ipRes.card;
        var ipBody = ipRes.body;

        var modeGroup = document.createElement('div');
        modeGroup.style.cssText = 'display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap;';

        // FIX-SECCONFIG-CONTRACT (audit 2026-06-10) : valeurs alignées sur le serveur
        // (users.php n'accepte que disabled / allowlist-only / denylist-only).
        var modes = [
          { value: 'disabled', label: t('adminUi.ipModeDisabled', 'Désactivé') },
          { value: 'allowlist-only', label: t('adminUi.ipModeAllowlist', 'Liste blanche (Allowlist)') },
          { value: 'denylist-only', label: t('adminUi.ipModeDenylist', 'Liste noire (Denylist)') }
        ];

        var radioName = 'csi_ip_filter_mode_' + Date.now();

        for (var mi = 0; mi < modes.length; mi++) {
          var modeWrap = document.createElement('label');
          modeWrap.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:0.85rem;cursor:pointer;';

          var radio = document.createElement('input');
          radio.type = 'radio';
          radio.name = radioName;
          radio.value = modes[mi].value;
          if (mi === 0) radio.checked = true;

          var modeLbl = document.createElement('span');
          modeLbl.textContent = modes[mi].label;

          modeWrap.appendChild(radio);
          modeWrap.appendChild(modeLbl);
          modeGroup.appendChild(modeWrap);
        }

        ipBody.appendChild(modeGroup);

        var ipLabel = document.createElement('label');
        ipLabel.textContent = t('adminUi.ipAddressesLabel', 'Adresses IP (une par ligne, notation CIDR supportée) :');
        ipLabel.style.cssText = 'display:block;font-size:0.85rem;font-weight:500;margin-bottom:4px;';
        ipBody.appendChild(ipLabel);

        var ipTextarea = document.createElement('textarea');
        ipTextarea.style.cssText = 'width:100%;min-height:100px;padding:8px;border:1px solid var(--border-color,#ccc);border-radius:6px;font-family:monospace;font-size:0.8rem;resize:vertical;box-sizing:border-box;';
        ipTextarea.placeholder = '192.168.1.0/24\n10.0.0.1\n2001:db8::/32';
        ipBody.appendChild(ipTextarea);

        var ipActions = document.createElement('div');
        ipActions.className = 'settings-card__actions';
        ipActions.style.marginTop = '10px';

        var btnSaveIP = document.createElement('button');
        btnSaveIP.className = 'btn btn--primary btn--sm';
        btnSaveIP.textContent = t('adminUi.save', 'Enregistrer');
        btnSaveIP.addEventListener('click', function() {
          var selectedRadio = modeGroup.querySelector('input[type="radio"]:checked');
          var mode = selectedRadio ? selectedRadio.value : 'disabled';
          var ips = ipTextarea.value.split('\n').map(function(line) { return line.trim(); }).filter(function(line) { return line.length > 0; });

          btnSaveIP.disabled = true;
          btnSaveIP.textContent = t('adminUi.saving', 'Enregistrement...');

          // FIX-SECCONFIG-CONTRACT (audit 2026-06-10) : l'ancien payload
          // {ipFiltering:{mode,addresses}} n'existait pas côté serveur (users.php attend
          // ipFilterMode/ipAllowlist/ipDenylist à plat) → PHP retombait sur les défauts
          // « désactivé » en répondant ok:true : le filtrage n'était JAMAIS appliqué.
          var payload = { ipFilterMode: mode };
          if (mode === 'allowlist-only') payload.ipAllowlist = ips;
          if (mode === 'denylist-only') payload.ipDenylist = ips;
          window.CSI_AuthManager.updateSecurityConfig(payload)
          .then(function(res) {
            if (!res || res.ok === false) {
              var srvMsg = (res && res.data && res.data.error) ? res.data.error : t('adminUi.serverRefusedConfig', "Le serveur a refusé la configuration.");
              showToast2(t('adminUi.ipFilterErrorPrefix', 'Erreur filtrage IP : ') + srvMsg, 'error', 8000);
              return;
            }
            showToast2(t('adminUi.ipFilterSaved', 'Configuration de filtrage IP enregistrée.'), 'success');
          })
          .catch(function(err) {
            void 0;
            showToast2(t('adminUi.ipFilterSaveError', "Erreur lors de l'enregistrement du filtrage IP."), 'error');
          })
          .finally(function() {
            btnSaveIP.disabled = false;
            btnSaveIP.textContent = t('adminUi.save', 'Enregistrer');
          });
        });

        ipActions.appendChild(btnSaveIP);
        ipBody.appendChild(ipActions);

        // FIX-SECCONFIG-CONTRACT : précharger la configuration existante (la carte
        // s'affichait toujours sur « Désactivé » avec un textarea vide, et une simple
        // re-sauvegarde écrasait la configuration réelle).
        if (window.CSI_AuthManager.getSecurityStatus) {
          window.CSI_AuthManager.getSecurityStatus().then(function(st) {
            var checks = st && st.checks;
            if (!checks) return;
            var currentMode = checks.ipFilterMode || 'disabled';
            var radios = modeGroup.querySelectorAll('input[type="radio"]');
            for (var ri = 0; ri < radios.length; ri++) {
              radios[ri].checked = (radios[ri].value === currentMode);
            }
            var list = currentMode === 'denylist-only' ? checks.ipDenylist : checks.ipAllowlist;
            if (Array.isArray(list) && list.length) ipTextarea.value = list.join('\n');
          }).catch(function() {});
        }

        container.appendChild(ipCard);
      })();
    }

    // ── Card E: Politique de mots de passe (server, admin only) ──
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isAdmin()) {
      (function renderPasswordPolicyCard() {
        var polRes = createSettingsCard({
          title: t('settings.passwordPolicy', 'Politique de mots de passe'),
          description: t('settings.passwordPolicyDesc', 'Configurez les règles de sécurité des mots de passe.'),
          icon: 'fa-lock'
        });
        var polCard = polRes.card;
        var polBody = polRes.body;

        var policyReminder = document.createElement('div');
        policyReminder.style.cssText = 'padding:10px;border-radius:6px;background:var(--bg-secondary,#f5f5f5);margin-bottom:12px;font-size:0.85rem;';
        var policyReminderTitle = document.createElement('strong');
        policyReminderTitle.textContent = t('adminUi.currentPolicy', 'Politique actuelle :');
        policyReminderTitle.style.display = 'block';
        var policyReminderText = document.createElement('span');
        policyReminderText.textContent = t('adminUi.policyRules', '18 caractères minimum, 1 majuscule, 1 minuscule, 1 caractère spécial obligatoire.');
        policyReminder.appendChild(policyReminderTitle);
        policyReminder.appendChild(policyReminderText);
        polBody.appendChild(policyReminder);

        var expRow = document.createElement('div');
        expRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;';

        var expLabel = document.createElement('label');
        expLabel.textContent = t('adminUi.passwordExpiration', 'Expiration des mots de passe :');
        expLabel.style.cssText = 'font-size:0.85rem;font-weight:500;';

        var expSelect = document.createElement('select');
        expSelect.style.cssText = 'padding:4px 8px;border:1px solid var(--border-color,#ccc);border-radius:4px;font-size:0.85rem;';

        var expOptions = [
          { value: '0', label: t('adminUi.expirationDisabled', "Désactivée (pas d'expiration)") },
          { value: '30', label: t('adminUi.days30', '30 jours') },
          { value: '60', label: t('adminUi.days60', '60 jours') },
          { value: '90', label: t('adminUi.days90', '90 jours') }
        ];
        for (var oi = 0; oi < expOptions.length; oi++) {
          var expOpt = document.createElement('option');
          expOpt.value = expOptions[oi].value;
          expOpt.textContent = expOptions[oi].label;
          expSelect.appendChild(expOpt);
        }

        expRow.appendChild(expLabel);
        expRow.appendChild(expSelect);
        polBody.appendChild(expRow);

        var polActions = document.createElement('div');
        polActions.className = 'settings-card__actions';

        var btnSavePol = document.createElement('button');
        btnSavePol.className = 'btn btn--primary btn--sm';
        btnSavePol.textContent = t('adminUi.save', 'Enregistrer');
        btnSavePol.addEventListener('click', function() {
          var expDays = parseInt(expSelect.value, 10);

          btnSavePol.disabled = true;
          btnSavePol.textContent = t('adminUi.saving', 'Enregistrement...');

          // FIX-SECCONFIG-CONTRACT (audit 2026-06-10) : l'ancien payload
          // {passwordPolicy:{expirationDays,...}} n'existait pas côté serveur (users.php
          // attend passwordMaxAgeDays à plat) → l'expiration n'était JAMAIS appliquée.
          window.CSI_AuthManager.updateSecurityConfig({
            passwordMaxAgeDays: expDays
          })
          .then(function(res) {
            if (!res || res.ok === false) {
              var srvMsg = (res && res.data && res.data.error) ? res.data.error : t('adminUi.serverRefusedConfig', "Le serveur a refusé la configuration.");
              showToast2(t('adminUi.policyErrorPrefix', 'Erreur politique de mots de passe : ') + srvMsg, 'error', 8000);
              return;
            }
            showToast2(t('adminUi.policySaved', 'Politique de mots de passe enregistrée.'), 'success');
          })
          .catch(function(err) {
            void 0;
            showToast2(t('adminUi.policySaveError', "Erreur lors de l'enregistrement de la politique."), 'error');
          })
          .finally(function() {
            btnSavePol.disabled = false;
            btnSavePol.textContent = t('adminUi.save', 'Enregistrer');
          });
        });

        polActions.appendChild(btnSavePol);
        polBody.appendChild(polActions);

        // FIX-SECCONFIG-CONTRACT : précharger l'expiration configurée (le select
        // s'affichait toujours sur « Désactivée »).
        if (window.CSI_AuthManager.getSecurityStatus) {
          window.CSI_AuthManager.getSecurityStatus().then(function(st) {
            var checks = st && st.checks;
            if (checks && typeof checks.passwordMaxAgeDays !== 'undefined') {
              expSelect.value = String(checks.passwordMaxAgeDays);
            }
          }).catch(function() {});
        }

        container.appendChild(polCard);
      })();
    }

    // ── Card F: Configuration SSO (OIDC) (server, admin only) ──
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isAdmin()) {
      (function renderSsoConfigCard() {
        var AM = window.CSI_AuthManager;
        var ssoRes = createSettingsCard({
          title: t('users.ssoCardTitle', 'Authentification SSO (OIDC)'),
          description: t('adminUi.ssoCardDesc', 'Connectez un fournisseur d’identité (Azure AD, Google Workspace, Okta…) pour l’authentification unique.'),
          icon: 'fa-building'
        });
        var ssoCard = ssoRes.card;
        var ssoBody = ssoRes.body;

        var ssoLoading = document.createElement('div');
        ssoLoading.textContent = t('users.ssoLoading', 'Chargement de la configuration SSO…');
        ssoLoading.style.cssText = 'padding:12px 0;color:#6b7280;font-size:0.9rem;';
        ssoBody.appendChild(ssoLoading);

        AM.getSsoConfig().then(function(res) {
          var cfg = (res && res.config) ? res.config : {};
          // (audit 2026-07-11) Config vierge (jamais configurée) : les options de sécurité
          // recommandées (vérif signature, email vérifié) sont proposées ACTIVÉES par défaut.
          // Une config existante conserve strictement ses valeurs (clé absente = OFF = comportement
          // historique → zéro régression).
          var isFresh = !(res && res.config);
          ssoBody.removeChild(ssoLoading);

          var form = document.createElement('div');
          form.className = 'settings-form-grid settings-form-grid--single-col';

          // --- Toggle: SSO enabled ---
          var toggleRow = document.createElement('label');
          toggleRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;font-weight:600;';
          var chkEnabled = document.createElement('input');
          chkEnabled.type = 'checkbox';
          chkEnabled.checked = !!cfg.enabled;
          var toggleLabel = document.createElement('span');
          toggleLabel.textContent = t('users.ssoEnable', 'Activer l\'authentification SSO');
          toggleRow.appendChild(chkEnabled);
          toggleRow.appendChild(toggleLabel);
          form.appendChild(toggleRow);

          // --- Helper: create labelled input ---
          function makeSsoField(labelText, type, placeholder, value, mono) {
            var wrap = document.createElement('div');
            wrap.style.cssText = 'margin-bottom:10px;';
            var lbl = document.createElement('label');
            lbl.style.cssText = 'display:block;font-size:0.85rem;font-weight:600;margin-bottom:4px;color:#374151;';
            lbl.textContent = labelText;
            var inp = document.createElement('input');
            inp.type = type;
            inp.placeholder = placeholder || '';
            inp.value = value || '';
            inp.className = 'settings-input';
            inp.style.cssText = 'width:100%;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;' + (mono ? 'font-family:monospace;' : '');
            wrap.appendChild(lbl);
            wrap.appendChild(inp);
            return { wrap: wrap, input: inp };
          }

          // --- Helper: create labelled select ---
          function makeSsoSelect(labelText, options, selectedValue) {
            var wrap = document.createElement('div');
            wrap.style.cssText = 'margin-bottom:10px;';
            var lbl = document.createElement('label');
            lbl.style.cssText = 'display:block;font-size:0.85rem;font-weight:600;margin-bottom:4px;color:#374151;';
            lbl.textContent = labelText;
            var sel = document.createElement('select');
            sel.className = 'settings-input';
            sel.style.cssText = 'width:100%;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;';
            for (var i = 0; i < options.length; i++) {
              var opt = document.createElement('option');
              opt.value = options[i].value;
              opt.textContent = options[i].label;
              if (options[i].value === selectedValue) opt.selected = true;
              sel.appendChild(opt);
            }
            wrap.appendChild(lbl);
            wrap.appendChild(sel);
            return { wrap: wrap, select: sel };
          }

          // --- Helper: répéteur de mapping (clé texte → valeur select) (audit 2026-07-11) ---
          function makeMapRepeater(labelText, rightOptions, existingMap, keyPlaceholder) {
            var wrap = document.createElement('div');
            wrap.style.cssText = 'margin-bottom:10px;';
            var lbl = document.createElement('label');
            lbl.style.cssText = 'display:block;font-size:0.85rem;font-weight:600;margin-bottom:4px;color:#374151;';
            lbl.textContent = labelText;
            wrap.appendChild(lbl);
            var rowsBox = document.createElement('div');
            wrap.appendChild(rowsBox);
            function addRow(k, v) {
              var row = document.createElement('div');
              row.style.cssText = 'display:flex;gap:6px;margin-bottom:6px;align-items:center;';
              var keyInp = document.createElement('input');
              keyInp.type = 'text';
              keyInp.placeholder = keyPlaceholder || 'Groupe IdP';
              keyInp.value = k || '';
              keyInp.className = 'settings-input sso-map-key';
              keyInp.style.cssText = 'flex:1;padding:6px 8px;border:1px solid #d1d5db;border-radius:6px;font-size:0.85rem;';
              var valSel = document.createElement('select');
              valSel.className = 'settings-input sso-map-val';
              valSel.style.cssText = 'flex:1;padding:6px 8px;border:1px solid #d1d5db;border-radius:6px;font-size:0.85rem;';
              rightOptions.forEach(function(o) {
                var op = document.createElement('option');
                op.value = o.value; op.textContent = o.label;
                if (o.value === v) op.selected = true;
                valSel.appendChild(op);
              });
              var del = document.createElement('button');
              del.type = 'button'; del.className = 'btn btn--outline'; del.textContent = '✕';
              del.style.cssText = 'padding:4px 8px;font-size:0.8rem;';
              del.addEventListener('click', function() { rowsBox.removeChild(row); });
              row.appendChild(keyInp); row.appendChild(valSel); row.appendChild(del);
              rowsBox.appendChild(row);
            }
            if (existingMap && typeof existingMap === 'object') {
              Object.keys(existingMap).forEach(function(k) { addRow(k, existingMap[k]); });
            }
            var addBtn = document.createElement('button');
            addBtn.type = 'button'; addBtn.className = 'btn btn--outline';
            addBtn.style.cssText = 'font-size:0.8rem;padding:4px 12px;margin-top:2px;';
            addBtn.textContent = t('users.ssoMapAddRow', '+ Ajouter une règle');
            addBtn.addEventListener('click', function() { addRow('', rightOptions[0] ? rightOptions[0].value : ''); });
            wrap.appendChild(addBtn);
            function collect() {
              var map = {};
              for (var i = 0; i < rowsBox.children.length; i++) {
                var r = rowsBox.children[i];
                var kEl = r.querySelector('.sso-map-key');
                var vEl = r.querySelector('.sso-map-val');
                if (!kEl || !vEl) continue;
                var k = kEl.value.trim();
                if (k) map[k] = vEl.value;
              }
              return map;
            }
            return { wrap: wrap, collect: collect };
          }

          var issuerField = makeSsoField(t('users.ssoIssuer', 'Issuer URL'), 'url', 'https://login.microsoftonline.com/{tenant}/v2.0', cfg.issuer, true);
          form.appendChild(issuerField.wrap);

          var clientIdField = makeSsoField(t('users.ssoClientId', 'Client ID'), 'text', '', cfg.clientId, true);
          form.appendChild(clientIdField.wrap);

          var clientSecretField = makeSsoField(t('users.ssoClientSecret', 'Client Secret'), 'password', '', cfg.clientSecret, true);
          form.appendChild(clientSecretField.wrap);

          var modeSelect = makeSsoSelect(t('users.ssoMode', 'Mode d\'authentification'), [
            { value: 'sso_only', label: t('users.ssoModeOnly', 'SSO uniquement (recommandé)') },
            { value: 'sso_and_password', label: t('users.ssoModeMixed', 'SSO + mot de passe') }
          ], cfg.mode || 'sso_only');
          form.appendChild(modeSelect.wrap);

          var defaultRoleSelect = makeSsoSelect(t('users.ssoDefaultRole', 'Rôle par défaut des nouveaux utilisateurs SSO'), [
            { value: 'editor', label: t('users.ssoRoleEditor', 'Éditeur') },
            { value: 'viewer', label: t('users.ssoRoleViewer', 'Lecteur') }
          ], cfg.defaultRole || 'editor');
          form.appendChild(defaultRoleSelect.wrap);

          // === CSI-ME === (Lot 3a) Sous-entité par défaut des nouveaux comptes SSO.
          // Visible seulement en mode multi-entités actif ; appliquée à la création
          // uniquement (l'appartenance d'un compte existant n'est jamais modifiée par le SSO).
          var defaultEntitySelect = null;
          if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
            var _deOpts = [{ value: '', label: t('users.ssoDefaultEntityNone', 'Entité principale (aucune)') }];
            window.CSI_EntityManager.entities().forEach(function(e) {
              if (e && !e.archived) _deOpts.push({ value: e.id, label: e.name || e.id });
            });
            defaultEntitySelect = makeSsoSelect(t('users.ssoDefaultEntity', 'Sous-entité par défaut des nouveaux utilisateurs SSO'), _deOpts, cfg.defaultEntityId || '');
            form.appendChild(defaultEntitySelect.wrap);
          }

          // M31 (audit 2026-07-10) : défaut = plafond produit (100), pas 50 — enregistrer la
          // carte SSO sans toucher ce champ n'abaisse plus silencieusement le plafond global.
          // Max borné au plafond produit côté serveur (getSsoMaxUsers/validation).
          var maxUsersField = makeSsoField(t('users.ssoMaxUsers', 'Nombre max. d\'utilisateurs (tous comptes)'), 'number', '100', cfg.maxUsers != null ? String(cfg.maxUsers) : '100', false);
          maxUsersField.input.min = '5';
          maxUsersField.input.max = '100';
          form.appendChild(maxUsersField.wrap);

          // M32 (audit 2026-07-10) : allowlist de domaines email pour le provisioning SSO.
          // Vide = aucune restriction. Sépare par virgules ou retours ligne.
          var _allowedDomVal = (cfg.allowedDomains && Array.isArray(cfg.allowedDomains)) ? cfg.allowedDomains.join(', ') : '';
          var allowedDomainsField = makeSsoField(t('users.ssoAllowedDomains', 'Domaines email autorisés (vide = tous)'), 'text', 'exemple.be, exemple.fr', _allowedDomVal, false);
          form.appendChild(allowedDomainsField.wrap);

          // (audit 2026-07-11, finding 2.1) Avertissement : allowlist de domaines vide + SSO activé.
          var domainsWarn = document.createElement('div');
          domainsWarn.style.cssText = 'background:#fffbeb;border:1px solid #fde68a;border-radius:4px;padding:6px 10px;font-size:0.8rem;color:#92400e;margin:-4px 0 10px;display:none;';
          domainsWarn.textContent = t('users.ssoAllowedDomainsWarn', 'Aucun domaine autorisé : tout compte renvoyé par l\'IdP peut obtenir un accès. Renseignez vos domaines, surtout avec un IdP multi-tenant (Google, Azure « common »).');
          form.appendChild(domainsWarn);
          function refreshDomainsWarn() {
            domainsWarn.style.display = (chkEnabled.checked && allowedDomainsField.input.value.trim() === '') ? 'block' : 'none';
          }
          allowedDomainsField.input.addEventListener('input', refreshDomainsWarn);
          chkEnabled.addEventListener('change', refreshDomainsWarn);
          refreshDomainsWarn();

          // ============================================================
          // (audit 2026-07-11, finding 1.1) Mapping des groupes IdP → rôle / entité (RBAC)
          // ============================================================
          var mapToggle = document.createElement('button');
          mapToggle.type = 'button'; mapToggle.className = 'btn btn--outline';
          mapToggle.style.cssText = 'margin:12px 0 6px;font-size:0.82rem;padding:4px 12px;';
          var _mapLabel = t('users.ssoMapSection', 'Mapping des groupes (rôles & entités)');
          mapToggle.textContent = _mapLabel + ' ▼';
          var mapPanel = document.createElement('div');
          mapPanel.style.cssText = 'display:none;border:1px solid #e5e7eb;border-radius:6px;padding:12px;margin-bottom:10px;background:#fafafa;';
          mapToggle.addEventListener('click', function() {
            var vis = mapPanel.style.display !== 'none';
            mapPanel.style.display = vis ? 'none' : 'block';
            mapToggle.textContent = _mapLabel + ' ' + (vis ? '▼' : '▲');
          });
          form.appendChild(mapToggle);

          var mapInfo = document.createElement('div');
          mapInfo.style.cssText = 'background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:8px 10px;margin-bottom:10px;font-size:0.82rem;color:#1e40af;';
          mapInfo.textContent = t('users.ssoMapInfo', 'Attribuez automatiquement un rôle et/ou une entité selon les groupes envoyés par l\'IdP. Sans correspondance, le rôle/entité par défaut s\'applique.');
          mapPanel.appendChild(mapInfo);

          var groupClaimField = makeSsoField(t('users.ssoGroupClaim', 'Nom du claim de groupe (ex. groups, roles)'), 'text', 'groups', cfg.groupClaim || '', true);
          mapPanel.appendChild(groupClaimField.wrap);

          var scopesField = makeSsoField(t('users.ssoScopes', 'Scopes OIDC (vide = openid email profile)'), 'text', 'openid email profile groups', cfg.scopes || '', true);
          mapPanel.appendChild(scopesField.wrap);

          var roleMapRepeater = makeMapRepeater(t('users.ssoGroupToRole', 'Groupe → rôle'), [
            { value: 'editor', label: t('users.ssoRoleEditor', 'Éditeur') },
            { value: 'viewer', label: t('users.ssoRoleViewer', 'Lecteur') }
          ], cfg.groupToRole || {}, t('users.ssoGroupPlaceholder', 'Groupe IdP'));
          mapPanel.appendChild(roleMapRepeater.wrap);

          var entityMapRepeater = null;
          if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
            var _emOpts = [];
            window.CSI_EntityManager.entities().forEach(function(e) {
              if (e && !e.archived) _emOpts.push({ value: e.id, label: e.name || e.id });
            });
            if (_emOpts.length) {
              entityMapRepeater = makeMapRepeater(t('users.ssoGroupToEntity', 'Groupe → entité'), _emOpts, cfg.groupToEntity || {}, t('users.ssoGroupPlaceholder', 'Groupe IdP'));
              mapPanel.appendChild(entityMapRepeater.wrap);
            }
          }

          var chkStrict = document.createElement('input');
          (function() {
            var strictWrap = document.createElement('div');
            strictWrap.style.cssText = 'margin:8px 0;display:flex;align-items:center;gap:8px;';
            chkStrict.type = 'checkbox'; chkStrict.checked = !!cfg.strictMapping; chkStrict.id = 'sso-strict';
            var l = document.createElement('label'); l.htmlFor = 'sso-strict';
            l.style.cssText = 'font-size:0.85rem;color:#374151;cursor:pointer;';
            l.textContent = t('users.ssoStrictMapping', 'Refuser les comptes sans groupe mappé (strict)');
            strictWrap.appendChild(chkStrict); strictWrap.appendChild(l); mapPanel.appendChild(strictWrap);
          })();

          var chkReconcile = document.createElement('input');
          (function() {
            var recWrap = document.createElement('div');
            recWrap.style.cssText = 'margin:8px 0;display:flex;align-items:center;gap:8px;';
            chkReconcile.type = 'checkbox'; chkReconcile.checked = !!cfg.reconcileOnLogin; chkReconcile.id = 'sso-reconcile';
            var l = document.createElement('label'); l.htmlFor = 'sso-reconcile';
            l.style.cssText = 'font-size:0.85rem;color:#374151;cursor:pointer;';
            l.textContent = t('users.ssoReconcile', 'Ré-appliquer le mapping à chaque connexion');
            recWrap.appendChild(chkReconcile); recWrap.appendChild(l); mapPanel.appendChild(recWrap);
          })();

          form.appendChild(mapPanel);
          if (cfg.groupClaim || (cfg.groupToRole && Object.keys(cfg.groupToRole).length)
              || (cfg.groupToEntity && Object.keys(cfg.groupToEntity).length) || cfg.strictMapping || cfg.reconcileOnLogin) {
            mapPanel.style.display = 'block';
            mapToggle.textContent = _mapLabel + ' ▲';
          }

          // ============================================================
          // (audit 2026-07-11, findings 2.1/2.2/2.4) Sécurité renforcée
          // ============================================================
          var secToggle = document.createElement('button');
          secToggle.type = 'button'; secToggle.className = 'btn btn--outline';
          secToggle.style.cssText = 'margin:6px 0;font-size:0.82rem;padding:4px 12px;';
          var _secLabel = t('users.ssoSecSection', 'Sécurité renforcée (recommandé)');
          secToggle.textContent = _secLabel + ' ▼';
          var secPanel = document.createElement('div');
          secPanel.style.cssText = 'display:none;border:1px solid #e5e7eb;border-radius:6px;padding:12px;margin-bottom:10px;background:#fafafa;';
          secToggle.addEventListener('click', function() {
            var vis = secPanel.style.display !== 'none';
            secPanel.style.display = vis ? 'none' : 'block';
            secToggle.textContent = _secLabel + ' ' + (vis ? '▼' : '▲');
          });
          form.appendChild(secToggle);
          function makeSecCheck(id, labelText, checked) {
            var w = document.createElement('div'); w.style.cssText = 'margin:6px 0;display:flex;align-items:center;gap:8px;';
            var c = document.createElement('input'); c.type = 'checkbox'; c.id = id; c.checked = checked;
            var l = document.createElement('label'); l.htmlFor = id; l.style.cssText = 'font-size:0.85rem;color:#374151;cursor:pointer;'; l.textContent = labelText;
            w.appendChild(c); w.appendChild(l); secPanel.appendChild(w); return c;
          }
          var _defVerifySig = (cfg.verifyIdTokenSignature != null ? !!cfg.verifyIdTokenSignature : isFresh);
          var _defReqEmail = (cfg.requireEmailVerified != null ? !!cfg.requireEmailVerified : isFresh);
          var chkVerifySig = makeSecCheck('sso-verifysig', t('users.ssoVerifySig', 'Vérifier la signature des jetons (JWKS) — recommandé'), _defVerifySig);
          var chkReqEmailVer = makeSecCheck('sso-reqemail', t('users.ssoRequireEmailVerified', 'Exiger un email vérifié pour créer un compte — recommandé'), _defReqEmail);
          var chkIdpLogout = makeSecCheck('sso-idplogout', t('users.ssoIdpLogout', 'Déconnexion aussi côté fournisseur (SSO logout)'), !!cfg.idpLogout);
          form.appendChild(secPanel);
          if (_defVerifySig || _defReqEmail || cfg.idpLogout) {
            secPanel.style.display = 'block';
            secToggle.textContent = _secLabel + ' ▲';
          }

          // --- Advanced: Enterprise IAM options (collapsible) ---
          var advancedToggle = document.createElement('button');
          advancedToggle.type = 'button';
          advancedToggle.className = 'btn btn--outline';
          advancedToggle.style.cssText = 'margin:12px 0 6px;font-size:0.82rem;padding:4px 12px;';
          advancedToggle.textContent = t('users.ssoAdvanced', 'Options avancées (IAM local / on-premise)') + ' \u25BC';
          var advancedPanel = document.createElement('div');
          advancedPanel.style.cssText = 'display:none;border:1px solid #e5e7eb;border-radius:6px;padding:12px;margin-bottom:10px;background:#fafafa;';
          advancedToggle.addEventListener('click', function() {
            var visible = advancedPanel.style.display !== 'none';
            advancedPanel.style.display = visible ? 'none' : 'block';
            advancedToggle.textContent = t('users.ssoAdvanced', 'Options avancées (IAM local / on-premise)') + ' ' + (visible ? '\u25BC' : '\u25B2');
          });
          form.appendChild(advancedToggle);

          // Allow HTTP checkbox
          var httpWrap = document.createElement('div');
          httpWrap.style.cssText = 'margin-bottom:8px;display:flex;align-items:center;gap:8px;';
          var chkHttp = document.createElement('input');
          chkHttp.type = 'checkbox';
          chkHttp.checked = !!cfg.allowHttp;
          chkHttp.id = 'sso-allow-http';
          var lblHttp = document.createElement('label');
          lblHttp.htmlFor = 'sso-allow-http';
          lblHttp.style.cssText = 'font-size:0.85rem;color:#374151;cursor:pointer;';
          lblHttp.textContent = t('users.ssoAllowHttp', 'Autoriser HTTP (IAM local sans certificat TLS)');
          httpWrap.appendChild(chkHttp);
          httpWrap.appendChild(lblHttp);
          advancedPanel.appendChild(httpWrap);

          // CA Certificate path
          var caField = makeSsoField(t('users.ssoCaPath', 'Chemin du certificat CA (PKI interne)'), 'text', '/etc/ssl/certs/enterprise-ca.crt', cfg.caCertPath || '', false);
          advancedPanel.appendChild(caField.wrap);

          // Insecure skip verify checkbox
          var insecureWrap = document.createElement('div');
          insecureWrap.style.cssText = 'margin-bottom:4px;display:flex;align-items:center;gap:8px;';
          var chkInsecure = document.createElement('input');
          chkInsecure.type = 'checkbox';
          chkInsecure.checked = !!cfg.insecureSkipVerify;
          chkInsecure.id = 'sso-insecure';
          var lblInsecure = document.createElement('label');
          lblInsecure.htmlFor = 'sso-insecure';
          lblInsecure.style.cssText = 'font-size:0.85rem;color:#991b1b;cursor:pointer;';
          lblInsecure.textContent = t('users.ssoSkipVerify', 'Désactiver la vérification TLS (dev/test uniquement !)');
          insecureWrap.appendChild(chkInsecure);
          insecureWrap.appendChild(lblInsecure);
          advancedPanel.appendChild(insecureWrap);

          // Warning for insecure mode
          var insecureWarn = document.createElement('div');
          insecureWarn.style.cssText = 'background:#fef2f2;border:1px solid #fca5a5;border-radius:4px;padding:6px 10px;font-size:0.8rem;color:#991b1b;margin-top:4px;display:' + (chkInsecure.checked ? 'block' : 'none') + ';';
          insecureWarn.textContent = t('users.ssoSkipVerifyWarn', 'Attention : désactiver la vérification TLS expose les échanges SSO à des attaques man-in-the-middle. À utiliser uniquement en environnement de test.');
          chkInsecure.addEventListener('change', function() {
            insecureWarn.style.display = chkInsecure.checked ? 'block' : 'none';
          });
          advancedPanel.appendChild(insecureWarn);

          form.appendChild(advancedPanel);

          // Show advanced panel if any option is non-default
          if (cfg.allowHttp || cfg.caCertPath || cfg.insecureSkipVerify) {
            advancedPanel.style.display = 'block';
            advancedToggle.textContent = t('users.ssoAdvanced', 'Options avancées (IAM local / on-premise)') + ' \u25B2';
          }

          // --- Info note ---
          var infoNote = document.createElement('div');
          infoNote.style.cssText = 'background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:10px 12px;margin:10px 0;font-size:0.85rem;color:#1e40af;';
          infoNote.textContent = t('users.ssoPasswordNote', 'Le login par mot de passe reste toujours disponible pour le compte administrateur, même en mode SSO uniquement.');
          form.appendChild(infoNote);

          // --- Callback URL display ---
          var callbackUrl = window.location.origin + window.location.pathname.replace(/\/[^\/]*$/, '/data/users.php?action=sso-callback');
          var cbWrap = document.createElement('div');
          cbWrap.style.cssText = 'margin-bottom:10px;';
          var cbLabel = document.createElement('label');
          cbLabel.style.cssText = 'display:block;font-size:0.85rem;font-weight:600;margin-bottom:4px;color:#374151;';
          cbLabel.textContent = t('users.ssoCallbackUrl', 'Callback URL (à configurer dans votre IdP)');
          var cbDisplay = document.createElement('div');
          cbDisplay.style.cssText = 'background:#f3f4f6;border:1px solid #d1d5db;border-radius:6px;padding:8px 10px;font-family:monospace;font-size:0.85rem;word-break:break-all;color:#374151;user-select:all;';
          cbDisplay.textContent = callbackUrl;
          cbWrap.appendChild(cbLabel);
          cbWrap.appendChild(cbDisplay);
          form.appendChild(cbWrap);

          ssoBody.appendChild(form);

          // --- Buttons ---
          var btnRow = document.createElement('div');
          btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;';

          var btnTest = document.createElement('button');
          btnTest.className = 'btn btn--outline';
          btnTest.textContent = t('users.ssoTestBtn', 'Tester la connexion');
          btnTest.addEventListener('click', function() {
            var issuer = issuerField.input.value.trim();
            if (!issuer) {
              showToast2(t('users.ssoIssuerRequired', 'Veuillez renseigner l\'Issuer URL.'), 'error');
              return;
            }
            btnTest.disabled = true;
            btnTest.textContent = t('users.ssoTesting', 'Test en cours…');
            AM.testSsoConnection(issuer).then(function(res) {
              // FIX-SSO-FALSE-OK (audit 2026-06-10) : _fetch resout {ok:false} sur erreur
              // HTTP (4xx/5xx) sans rejeter — sans ce test, un echec serveur affichait
              // quand meme « Connexion SSO OK » et l'admin activait un SSO casse.
              if (!res || res.ok === false) {
                var srvMsg = (res && res.data && res.data.error) ? res.data.error : t('adminUi.ssoTestRefused', 'Le serveur a refusé le test SSO.');
                showToast2(t('users.ssoTestFailedPrefix', 'Échec du test SSO : ') + srvMsg, 'error');
                return;
              }
              var msg = t('adminUi.ssoTestOk', 'Connexion SSO OK : le fournisseur a répondu.');
              if (res && res.data && res.data.certAutoFetched) {
                msg += ' ' + t('adminUi.ssoCertAutoFetched', 'Certificat TLS récupéré automatiquement') + ' (' + (res.data.certAutoFetched.subject || '?') + ').';
              }
              showToast2(msg, 'success');
            }).catch(function(err) {
              // Map server error messages (from JSON response) vs network errors (from JS exceptions)
              var errMsg = (err && err.data && err.data.error) ? err.data.error : t('adminUi.ssoUnreachable', 'Impossible de contacter le fournisseur SSO. Vérifiez l\'URL et la connectivité réseau.');
              showToast2(t('users.ssoTestFailedPrefix', 'Échec du test SSO : ') + errMsg, 'error');
            }).finally(function() {
              btnTest.disabled = false;
              btnTest.textContent = t('users.ssoTestBtn', 'Tester la connexion');
            });
          });
          btnRow.appendChild(btnTest);

          var btnSave = document.createElement('button');
          btnSave.className = 'btn btn--primary';
          btnSave.textContent = t('users.ssoSaveBtn', 'Enregistrer');
          btnSave.addEventListener('click', function() {
            var enabled = chkEnabled.checked;
            var issuer = issuerField.input.value.trim();
            var clientId = clientIdField.input.value.trim();
            var clientSecret = clientSecretField.input.value.trim();

            if (enabled && (!issuer || !clientId || !clientSecret)) {
              showToast2(t('users.ssoRequiredFields', 'Issuer URL, Client ID et Client Secret sont requis pour activer le SSO.'), 'error');
              return;
            }

            // Validate caCertPath format
            var caPath = caField.input.value.trim();
            if (caPath && (caPath.indexOf('..') !== -1 || caPath.length > 512 || caPath.charAt(0) !== '/')) {
              showToast2(t('users.ssoCaInvalid', 'Chemin CA invalide. Utilisez un chemin absolu sans ".." (ex: /etc/ssl/certs/ca.pem)'), 'error');
              return;
            }

            btnSave.disabled = true;
            btnSave.textContent = t('users.ssoSaving', 'Enregistrement…');
            AM.saveSsoConfig({
              enabled: enabled,
              issuer: issuer,
              clientId: clientId,
              clientSecret: clientSecret,
              mode: modeSelect.select.value,
              defaultRole: defaultRoleSelect.select.value,
              // === CSI-ME === (audit SSO 2026-07-03) : si le sélecteur n'est pas rendu
              // (feature off/indisponible au rendu), PRÉSERVER la valeur stockée — envoyer
              // '' effaçait silencieusement defaultEntityId à chaque enregistrement.
              defaultEntityId: defaultEntitySelect ? defaultEntitySelect.select.value : ((cfg && cfg.defaultEntityId) || ''),
              maxUsers: parseInt(maxUsersField.input.value, 10) || 100,
              allowedDomains: allowedDomainsField.input.value, // M32 : le serveur parse/normalise
              allowHttp: chkHttp.checked,
              caCertPath: caPath,
              insecureSkipVerify: chkInsecure.checked,
              // (audit 2026-07-11) Mapping RBAC + sécurité renforcée.
              scopes: scopesField.input.value.trim(),
              groupClaim: groupClaimField.input.value.trim(),
              groupToRole: roleMapRepeater.collect(),
              // Préserver la map stockée si le répéteur d'entité n'est pas rendu (multi-entités off).
              groupToEntity: entityMapRepeater ? entityMapRepeater.collect() : ((cfg && cfg.groupToEntity) || {}),
              strictMapping: chkStrict.checked,
              reconcileOnLogin: chkReconcile.checked,
              requireEmailVerified: chkReqEmailVer.checked,
              verifyIdTokenSignature: chkVerifySig.checked,
              idpLogout: chkIdpLogout.checked
            }).then(function(res) {
              // FIX-SSO-FALSE-OK (audit 2026-06-10) : voir bouton « Tester la connexion » —
              // un refus serveur (maxUsers hors bornes, licence expiree, erreur disque...)
              // affichait quand meme « Configuration SSO enregistree. ».
              if (!res || res.ok === false) {
                var srvMsg = (res && res.data && res.data.error) ? res.data.error : t('adminUi.serverRefusedConfig', 'Le serveur a refusé la configuration.');
                showToast2(t('users.ssoErrorPrefix', 'Erreur : ') + srvMsg, 'error');
                return;
              }
              showToast2(t('users.ssoSaved', 'Configuration SSO enregistrée.'), 'success');
            }).catch(function(err) {
              showToast2(t('users.ssoErrorPrefix', 'Erreur : ') + (err.message || err), 'error');
            }).finally(function() {
              btnSave.disabled = false;
              btnSave.textContent = t('users.ssoSaveBtn', 'Enregistrer');
            });
          });
          btnRow.appendChild(btnSave);

          ssoBody.appendChild(btnRow);
        }).catch(function(err) {
          ssoLoading.textContent = t('users.ssoLoadFailed', 'Impossible de charger la configuration SSO.');
          void 0;
        });

        container.appendChild(ssoCard);
      })();
    }

    // === CSI-MCP === Card G — Accès IA (MCP). Même garde que la carte SSO :
    // mode serveur + admin. Invisible en local/file:// par construction.
    if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isAdmin()) {
      renderMcpSection(container);
    }
  }

  // === CSI-MCP === Vide un conteneur sans passer par innerHTML.
  function csiClearNode(el) {
    while (el && el.firstChild) el.removeChild(el.firstChild);
  }

  // === CSI-MCP === Carte « Accès IA (MCP) » (mode serveur, admin).
  // Textes de référence : RAPPORT-ETUDE-MCP-2026-07-27.md §8.
  function renderMcpSection(container) {
    const AM = window.CSI_AuthManager;
    const { card: mcpCard, body: mcpBody } = createSettingsCard({
      title: t('users.mcpCardTitle', 'Accès IA (MCP)'),
      description: t('users.mcpCardDesc', 'Ouvrez un accès en lecture seule à vos données de gouvernance pour un assistant IA.'),
      icon: 'fa-wand-magic-sparkles'
    });

    const mcpLoading = document.createElement('p');
    mcpLoading.textContent = t('common.loading', 'Chargement…');
    mcpLoading.style.cssText = 'color:var(--color-text-secondary,#666);font-size:0.9em;';
    mcpBody.appendChild(mcpLoading);
    container.appendChild(mcpCard);

    function para(text, extraCss) {
      const p = document.createElement('p');
      p.textContent = text;
      p.style.cssText = 'margin:0 0 8px 0;line-height:1.55;font-size:0.92em;' + (extraCss || '');
      return p;
    }
    function subhead(text) {
      const h = document.createElement('div');
      h.textContent = text;
      h.style.cssText = 'font-weight:600;margin:14px 0 6px 0;font-size:0.95em;';
      return h;
    }

    AM.getMcpConfig().then(function(res) {
      csiClearNode(mcpBody);

      // Skew de déploiement : mcp.php pas encore téléversé sur le serveur.
      if (res && res.notDeployed) {
        mcpBody.appendChild(para(t('users.mcpNotDeployed', 'mcp.php n\'est pas deploye sur le serveur.')));
        return;
      }
      if (!res || res.ok === false || !res.data || !res.data.config) {
        mcpBody.appendChild(para(t('users.mcpLoadFailed', 'Chargement de la configuration MCP impossible.')));
        return;
      }
      const cfg = res.data.config;

      if (cfg.enabled) {
        const banner = document.createElement('div');
        banner.textContent = t('users.mcpBanner', 'Acces IA actif.');
        banner.style.cssText = 'background:var(--color-warning-bg,#fef3c7);border:1px solid var(--color-warning,#f59e0b);'
          + 'border-radius:8px;padding:10px 12px;margin-bottom:14px;font-size:0.88em;line-height:1.5;';
        mcpBody.appendChild(banner);
      }

      // --- LA VALEUR D'ABORD : exemples concrets de ce que l'IA saura repondre. ----------
      // Sans ce bloc, la carte se lit comme une notice de conformite et ne donne pas envie.
      mcpBody.appendChild(subhead(t('users.mcpAskTitle', 'Ce que vous pourrez demander a votre IA')));
      const askWrap = document.createElement('div');
      askWrap.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));'
        + 'gap:8px;margin-bottom:10px;';
      [t('users.mcpAsk1', ''), t('users.mcpAsk2', ''), t('users.mcpAsk3', ''), t('users.mcpAsk4', '')]
        .forEach(function(q) {
          const box = document.createElement('div');
          box.style.cssText = 'display:flex;align-items:flex-start;gap:8px;background:var(--color-surface-alt,#f8f9fa);'
            + 'border:1px solid var(--color-border,#e2e8f0);border-radius:8px;padding:10px 12px;'
            + 'font-size:0.88em;line-height:1.45;font-style:italic;';
          const ic = document.createElement('i');
          ic.className = 'fas fa-comment-dots';
          ic.setAttribute('aria-hidden', 'true');
          ic.style.cssText = 'color:var(--color-primary,#6366f1);margin-top:2px;flex-shrink:0;';
          const sp = document.createElement('span');
          sp.textContent = q;
          box.appendChild(ic); box.appendChild(sp);
          askWrap.appendChild(box);
        });
      mcpBody.appendChild(askWrap);
      mcpBody.appendChild(para(t('users.mcpAskFoot', ''),
        'color:var(--color-text-secondary,#666);font-size:0.86em;'));

      // --- Comment ca marche (avec icones) ------------------------------------------------
      mcpBody.appendChild(subhead(t('users.mcpHowTitle', 'Comment cela fonctionne')));
      const howWrap = document.createElement('div');
      howWrap.style.cssText = 'margin:0 0 10px 0;';
      [['fa-plug', t('users.mcpHow1', '')],
       ['fa-eye', t('users.mcpHow2', '')],
       ['fa-user-shield', t('users.mcpHow3', '')]].forEach(function(pair) {
        const row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin-bottom:7px;'
          + 'font-size:0.9em;line-height:1.5;';
        const ic = document.createElement('i');
        ic.className = 'fas ' + pair[0];
        ic.setAttribute('aria-hidden', 'true');
        ic.style.cssText = 'color:var(--color-primary,#6366f1);margin-top:3px;width:16px;flex-shrink:0;text-align:center;';
        const sp = document.createElement('span');
        sp.textContent = pair[1];
        row.appendChild(ic); row.appendChild(sp);
        howWrap.appendChild(row);
      });
      mcpBody.appendChild(howWrap);

      // --- Responsabilite partagee : 2 colonnes, pour que le partage SE VOIE -------------
      mcpBody.appendChild(subhead(t('users.mcpSharedTitle', 'Modele de responsabilite partagee')));
      const shWrap = document.createElement('div');
      shWrap.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));'
        + 'gap:10px;margin-bottom:8px;';
      [['fa-shield-halved', t('users.mcpSharedUs', ''), t('users.mcpSharedUsList', '')],
       ['fa-network-wired', t('users.mcpSharedYou', ''), t('users.mcpSharedYouList', '')]
      ].forEach(function(col) {
        const box = document.createElement('div');
        box.style.cssText = 'background:var(--color-surface-alt,#f8f9fa);border:1px solid var(--color-border,#e2e8f0);'
          + 'border-radius:8px;padding:12px;';
        const head = document.createElement('div');
        head.style.cssText = 'display:flex;align-items:center;gap:8px;font-weight:600;font-size:0.88em;margin-bottom:6px;';
        const ic = document.createElement('i');
        ic.className = 'fas ' + col[0];
        ic.setAttribute('aria-hidden', 'true');
        ic.style.cssText = 'color:var(--color-primary,#6366f1);';
        const ht = document.createElement('span');
        ht.textContent = col[1];
        head.appendChild(ic); head.appendChild(ht);
        const body = document.createElement('div');
        body.textContent = col[2];
        body.style.cssText = 'font-size:0.84em;line-height:1.5;color:var(--color-text-secondary,#666);';
        box.appendChild(head); box.appendChild(body);
        shWrap.appendChild(box);
      });
      mcpBody.appendChild(shWrap);
      mcpBody.appendChild(para(t('users.mcpSharedReco', ''),
        'font-style:italic;color:var(--color-text-secondary,#666);font-size:0.86em;'));

      // --- Choix de l'IA : encart, car c'est la decision qui engage la gouvernance -------
      const choiceBox = document.createElement('div');
      choiceBox.style.cssText = 'display:flex;align-items:flex-start;gap:10px;'
        + 'background:var(--color-info-light,#eff6ff);border-left:4px solid var(--color-info,#3b82f6);'
        + 'border-radius:8px;padding:12px 14px;margin:14px 0 4px 0;';
      const cIc = document.createElement('i');
      cIc.className = 'fas fa-shield-heart';
      cIc.setAttribute('aria-hidden', 'true');
      cIc.style.cssText = 'color:var(--color-info,#3b82f6);margin-top:2px;flex-shrink:0;';
      const cTxt = document.createElement('div');
      const cH = document.createElement('div');
      cH.textContent = t('users.mcpChoiceTitle', 'Choix de l\'assistant IA');
      cH.style.cssText = 'font-weight:600;font-size:0.9em;margin-bottom:4px;';
      const cB = document.createElement('div');
      cB.textContent = t('users.mcpChoiceBody', '');
      cB.style.cssText = 'font-size:0.87em;line-height:1.5;';
      cTxt.appendChild(cH); cTxt.appendChild(cB);
      choiceBox.appendChild(cIc); choiceBox.appendChild(cTxt);
      mcpBody.appendChild(choiceBox);

      const toggleRow = document.createElement('label');
      toggleRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:16px 0 6px 0;cursor:pointer;font-weight:600;';
      const chk = document.createElement('input');
      chk.type = 'checkbox';
      chk.checked = !!cfg.enabled;
      const chkLabel = document.createElement('span');
      chkLabel.textContent = t('users.mcpEnableLabel', 'Activer l\'acces IA (MCP)');
      toggleRow.appendChild(chk);
      toggleRow.appendChild(chkLabel);
      mcpBody.appendChild(toggleRow);

      function persist(enabled) {
        return AM.saveMcpConfig({ enabled: enabled }).then(function(r) {
          // FIX-SSO-FALSE-OK : _mcpFetch ne rejette jamais — tester r.ok explicitement.
          if (!r || r.ok === false) {
            showToast2(t('users.mcpSaveFailed', 'Enregistrement impossible.'), 'error');
            chk.checked = !enabled;
            return false;
          }
          showToast2(t('users.mcpSaved', 'Configuration enregistree.'), 'success');
          renderSettingsContent();
          return true;
        }).catch(function(err) {
          void 0;
          showToast2(t('users.mcpSaveFailed', 'Enregistrement impossible.'), 'error');
          chk.checked = !enabled;
          return false;
        });
      }

      chk.addEventListener('change', function() {
        if (!chk.checked) { persist(false); return; }
        // Activation -> modale de validation obligatoire. L'etat ne bascule qu'apres
        // confirmation explicite (rollback visuel sinon).
        chk.checked = false;
        showMcpConsentModal(function(confirmed) {
          if (!confirmed) return;
          chk.checked = true;
          persist(true);
        });
      });

      // FIX-MCP-UI-TOKENS : un `return` ici masquait la liste des jetons des que la
      // fonction etait desactivee — donc impossible de les auditer ou de les revoquer,
      // alors que le toggle ne touche PAS mcp-tokens.json : une reactivation ressuscitait
      // silencieusement tous les jetons emis. La liste est desormais TOUJOURS rendue ;
      // seuls l'adresse de l'endpoint et la creation sont masquees hors activation.
      const mcpEnabled = cfg.enabled === true;

      if (mcpEnabled) {
        mcpBody.appendChild(subhead(t('users.mcpEndpointLabel', 'Adresse de l\'interface MCP')));
        const urlBox = document.createElement('div');
        urlBox.textContent = AM.getMcpEndpointUrl();
        urlBox.style.cssText = 'font-family:monospace;font-size:0.85em;background:var(--color-bg-alt,#f5f5f5);'
          + 'padding:8px 10px;border-radius:6px;user-select:all;word-break:break-all;margin-bottom:6px;';
        mcpBody.appendChild(urlBox);
        mcpBody.appendChild(para(t('users.mcpEndpointHint', ''), 'color:var(--color-text-secondary,#666);font-size:0.85em;'));
      }

      mcpBody.appendChild(subhead(t('users.mcpTokensTitle', 'Jetons d\'acces')));
      const tokensZone = document.createElement('div');
      mcpBody.appendChild(tokensZone);

      function fmtDate(ts) {
        if (!ts) return t('users.mcpTokenNever', 'Jamais');
        try { return new Date(ts * 1000).toLocaleDateString(); } catch (e) { return String(ts); }
      }

      function renderTokens() {
        csiClearNode(tokensZone);
        AM.listMcpTokens().then(function(r) {
          // FIX-MCP-UI-FAUXOK : _mcpFetch ne rejette JAMAIS, il renvoie {ok,status,data}.
          // Sans ce test, un 401/403/500 (JWT expire, compte retrograde) produisait un
          // corps sans champ `tokens` -> liste vide -> message « Aucun jeton pour le
          // moment » RASSURANT ET FAUX, alors que des jetons vivants donnent acces aux
          // donnees. L'admin en concluait qu'aucun acces IA n'etait ouvert.
          if (!r || r.ok === false) {
            const msg = (r && r.data && r.data.error)
              ? String(r.data.error)
              : t('users.mcpTokensLoadFailed', 'Impossible de lire la liste des jetons.');
            tokensZone.appendChild(para(msg, 'color:var(--color-danger,#dc2626);'));
            const retry = document.createElement('button');
            retry.className = 'btn btn--outline btn--sm';
            retry.textContent = t('common.retry', 'Reessayer');
            retry.addEventListener('click', renderTokens);
            tokensZone.appendChild(retry);
            return;
          }
          const list = (r && r.data && r.data.tokens) ? r.data.tokens : [];
          if (!list.length) {
            tokensZone.appendChild(para(t('users.mcpTokensEmpty', 'Aucun jeton pour le moment.')));
            return;
          }
          const table = document.createElement('table');
          table.className = 'csi-table';
          table.style.cssText = 'width:100%;font-size:0.85em;border-collapse:collapse;';
          const thead = document.createElement('thead');
          const htr = document.createElement('tr');
          [t('users.mcpTokenLabel', 'Libelle'), t('users.mcpTokenCreated', 'Cree le'),
           t('users.mcpTokenExpires', 'Expire le'), t('users.mcpTokenLastUsed', 'Dernier usage'), ''
          ].forEach(function(h) {
            const th = document.createElement('th');
            th.textContent = h;
            th.style.cssText = 'text-align:left;padding:6px 8px;border-bottom:1px solid var(--color-border,#ddd);';
            htr.appendChild(th);
          });
          thead.appendChild(htr);
          table.appendChild(thead);
          const tbody = document.createElement('tbody');
          list.forEach(function(tk) {
            const tr = document.createElement('tr');
            // textContent partout : les libelles sont des donnees utilisateur.
            [tk.label || '', fmtDate(tk.createdAt), fmtDate(tk.expiresAt), fmtDate(tk.lastUsedAt)].forEach(function(v) {
              const td = document.createElement('td');
              td.textContent = v;
              td.style.cssText = 'padding:6px 8px;border-bottom:1px solid var(--color-border,#eee);';
              if (tk.revoked) td.style.opacity = '0.5';
              tr.appendChild(td);
            });
            const tdAct = document.createElement('td');
            tdAct.style.cssText = 'padding:6px 8px;border-bottom:1px solid var(--color-border,#eee);text-align:right;';
            if (tk.revoked) {
              const span = document.createElement('span');
              span.textContent = t('users.mcpTokenRevoked', 'Revoque');
              span.style.cssText = 'opacity:0.6;font-style:italic;';
              tdAct.appendChild(span);
            } else {
              const btn = document.createElement('button');
              btn.className = 'btn btn--outline btn--sm';
              btn.textContent = t('users.mcpTokenRevoke', 'Revoquer');
              btn.addEventListener('click', function() {
                showConfirmModal(t('users.mcpTokenRevoke', 'Revoquer'),
                  t('users.mcpTokenRevokeConfirm', 'Revoquer ce jeton ?'), function() {
                    AM.revokeMcpToken(tk.id).then(function(rr) {
                      if (!rr || rr.ok === false) { showToast2(t('users.mcpSaveFailed', ''), 'error'); return; }
                      showToast2(t('users.mcpTokenRevokedOk', 'Jeton revoque.'), 'success');
                      renderTokens();
                    }).catch(function(err) {
                      // Sans ce .catch, une revocation qui echoue (reseau) ne donnait AUCUN
                      // retour : l'admin croyait le jeton revoque alors qu'il restait actif.
                      showToast2(t('users.mcpSaveFailed', ''), 'error');
                      void 0;
                    });
                  });
              });
              tdAct.appendChild(btn);
            }
            tr.appendChild(tdAct);
            tbody.appendChild(tr);
          });
          table.appendChild(tbody);
          tokensZone.appendChild(table);
        }).catch(function(err) {
          // Rejet reel (abort du timeout, coupure reseau) : sans ce .catch la zone
          // restait MUETTE apres avoir ete videe, avec une unhandled rejection.
          csiClearNode(tokensZone);
          tokensZone.appendChild(para(t('users.mcpTokensLoadFailed', 'Impossible de lire la liste des jetons.'),
            'color:var(--color-danger,#dc2626);'));
          void 0;
        });
      }
      renderTokens();

      if (!mcpEnabled) {
        mcpBody.appendChild(para(t('users.mcpDisabledTokensHint',
          'La fonction est desactivee : l\'interface ne repond plus. Ces jetons ne sont PAS supprimes pour autant — ils redeviendront valides a la reactivation. Revoquez ceux qui ne doivent plus servir.'),
          'color:var(--color-text-secondary,#666);font-size:0.85em;'));
        return;
      }

      const btnRow = document.createElement('div');
      btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;';

      const btnCreate = document.createElement('button');
      btnCreate.className = 'btn btn--primary';
      btnCreate.textContent = t('users.mcpTokenCreate', 'Generer un jeton');
      btnCreate.addEventListener('click', function() {
        showMcpTokenModal(function() { renderTokens(); });
      });
      btnRow.appendChild(btnCreate);

      const btnTest = document.createElement('button');
      btnTest.className = 'btn btn--outline';
      btnTest.textContent = t('users.mcpTest', 'Tester');
      btnTest.addEventListener('click', function() {
        btnTest.disabled = true;
        AM.testMcpEndpoint().then(function(r) {
          if (!r || r.ok === false) { showToast2(t('users.mcpTestFail', 'Test impossible.'), 'error'); return; }
          // FIX-MCP-UI-CHECKS : le selftest renvoie TOUJOURS ok:true, le diagnostic reel
          // etant dans `checks`. L'ignorer affichait un toast vert « joignable » alors que
          // la licence pouvait etre invalide (chaque requete IA recevant alors un 402) ou
          // que .htaccess/.user.ini manquaient — jetons et journal exposes en HTTP.
          const c = (r.data && r.data.checks) ? r.data.checks : null;
          csiClearNode(checksZone);
          if (!c) { showToast2(t('users.mcpTestOk', 'Interface MCP joignable.'), 'success'); return; }
          const rows = [
            [c.enabled, t('users.mcpCheckEnabled', 'Fonction activee')],
            [c.licenseValid, t('users.mcpCheckLicense', 'Licence valide')],
            [c.dataDirHtaccess, t('users.mcpCheckHtaccess', 'Fichiers de donnees proteges (.htaccess)')],
            [c.userIniPresent, t('users.mcpCheckUserIni', 'Durcissement PHP present (.user.ini)')]
          ];
          rows.forEach(function(row) {
            const line = document.createElement('div');
            line.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:0.88em;margin-bottom:4px;';
            const ic = document.createElement('i');
            ic.className = row[0] ? 'fas fa-circle-check' : 'fas fa-circle-xmark';
            ic.setAttribute('aria-hidden', 'true');
            ic.style.cssText = 'color:' + (row[0] ? 'var(--color-success,#16a34a)' : 'var(--color-danger,#dc2626)') + ';';
            const sp = document.createElement('span');
            sp.textContent = row[1];
            line.appendChild(ic); line.appendChild(sp);
            checksZone.appendChild(line);
          });
          const allGreen = rows.every(function(row) { return !!row[0]; });
          showToast2(allGreen ? t('users.mcpTestOk', 'Interface MCP joignable.')
                              : t('users.mcpTestPartial', 'Interface joignable, mais des points restent a corriger.'),
                     allGreen ? 'success' : 'warning');
        }).catch(function() {
          showToast2(t('users.mcpTestFail', 'Test impossible.'), 'error');
        }).finally(function() { btnTest.disabled = false; });
      });
      btnRow.appendChild(btnTest);

      mcpBody.appendChild(btnRow);
      // Zone de restitution du diagnostic du selftest (FIX-MCP-UI-CHECKS).
      const checksZone = document.createElement('div');
      checksZone.style.cssText = 'margin-top:10px;';
      mcpBody.appendChild(checksZone);
    }).catch(function(err) {
      mcpLoading.textContent = t('users.mcpLoadFailed', 'Chargement de la configuration MCP impossible.');
      void 0;
    });
  }


  // === CSI-MCP === Modale de validation d'activation.
  // showConfirmModal() ECHAPPE son message : inutilisable pour une case a cocher.
  // Gabarit a11y calque sur modules/entity-ui.js:73-116 (role=dialog, piege Tab,
  // Echap en capture, restauration du focus).
  function showMcpConsentModal(callback) {
    const prevFocus = document.activeElement;
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay modal-overlay--visible';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;'
      + 'align-items:center;justify-content:center;z-index:10000;padding:16px;';

    const box = document.createElement('div');
    box.setAttribute('role', 'dialog');
    box.setAttribute('aria-modal', 'true');
    box.style.cssText = 'background:var(--color-bg,#fff);color:var(--color-text,#222);border-radius:12px;'
      + 'max-width:620px;width:100%;max-height:85vh;overflow:auto;padding:24px;box-shadow:0 10px 40px rgba(0,0,0,0.3);';

    const h = document.createElement('h3');
    h.textContent = t('users.mcpModalTitle', 'Activer l\'acces IA (MCP) ?');
    h.style.cssText = 'margin:0 0 14px 0;font-size:1.15em;';
    box.appendChild(h);

    [['users.mcpModalIntro', ''], ['users.mcpModalControl', ''],
     ['users.mcpModalArchi', ''], ['users.mcpModalGov', '']].forEach(function(pair) {
      const p = document.createElement('p');
      p.textContent = t(pair[0], pair[1]);
      p.style.cssText = 'margin:0 0 12px 0;line-height:1.6;font-size:0.92em;';
      box.appendChild(p);
    });

    const chkRow = document.createElement('label');
    chkRow.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin:16px 0;cursor:pointer;'
      + 'padding:12px;background:var(--color-bg-alt,#f5f5f5);border-radius:8px;font-size:0.92em;line-height:1.5;';
    const chk = document.createElement('input');
    chk.type = 'checkbox';
    chk.style.cssText = 'margin-top:3px;flex-shrink:0;';
    const chkTxt = document.createElement('span');
    chkTxt.textContent = t('users.mcpModalCheck', 'J\'ai pris connaissance de ces elements.');
    chkRow.appendChild(chk);
    chkRow.appendChild(chkTxt);
    box.appendChild(chkRow);

    const row = document.createElement('div');
    row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px;';
    const btnCancel = document.createElement('button');
    btnCancel.className = 'btn btn--outline';
    btnCancel.textContent = t('users.mcpModalCancel', 'Annuler');
    const btnOk = document.createElement('button');
    btnOk.className = 'btn btn--primary';
    btnOk.textContent = t('users.mcpModalConfirm', 'Activer');
    btnOk.disabled = true; // inactif tant que la case n'est pas cochee
    chk.addEventListener('change', function() { btnOk.disabled = !chk.checked; });
    row.appendChild(btnCancel);
    row.appendChild(btnOk);
    box.appendChild(row);
    overlay.appendChild(box);
    document.body.appendChild(overlay);

    function close(result) {
      document.removeEventListener('keydown', onKey, true);
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
      if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch (e) {} }
      if (callback) callback(result);
    }
    function onKey(e) {
      if (e.key === 'Escape') { e.preventDefault(); close(false); return; }
      if (e.key !== 'Tab') return;
      const f = box.querySelectorAll('button:not([disabled]), input, [href], select, textarea');
      if (!f.length) return;
      const first = f[0], last = f[f.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    }
    document.addEventListener('keydown', onKey, true);
    btnCancel.addEventListener('click', function() { close(false); });
    btnOk.addEventListener('click', function() { close(true); });
    overlay.addEventListener('click', function(e) { if (e.target === overlay) close(false); });
    chk.focus();
  }

  // === CSI-MCP === Modale de creation de jeton. Le secret n'est affiche QU'UNE FOIS.
  function showMcpTokenModal(onCreated) {
    const AM = window.CSI_AuthManager;
    const prevFocus = document.activeElement;
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay modal-overlay--visible';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;'
      + 'align-items:center;justify-content:center;z-index:10000;padding:16px;';
    const box = document.createElement('div');
    box.setAttribute('role', 'dialog');
    box.setAttribute('aria-modal', 'true');
    box.style.cssText = 'background:var(--color-bg,#fff);color:var(--color-text,#222);border-radius:12px;'
      + 'max-width:560px;width:100%;padding:24px;box-shadow:0 10px 40px rgba(0,0,0,0.3);';

    const h = document.createElement('h3');
    h.textContent = t('users.mcpTokenCreate', 'Generer un jeton');
    h.style.cssText = 'margin:0 0 16px 0;font-size:1.1em;';
    box.appendChild(h);

    const lblLabel = document.createElement('label');
    lblLabel.textContent = t('users.mcpTokenLabel', 'Libelle');
    lblLabel.style.cssText = 'display:block;font-weight:600;margin-bottom:4px;font-size:0.9em;';
    const inLabel = document.createElement('input');
    inLabel.type = 'text';
    inLabel.className = 'form-input';
    inLabel.style.cssText = 'width:100%;margin-bottom:12px;';
    box.appendChild(lblLabel);
    box.appendChild(inLabel);

    const lblUser = document.createElement('label');
    lblUser.textContent = t('users.mcpTokenAccount', 'Compte lie');
    lblUser.style.cssText = 'display:block;font-weight:600;margin-bottom:4px;font-size:0.9em;';
    const selUser = document.createElement('select');
    selUser.className = 'form-input';
    selUser.style.cssText = 'width:100%;margin-bottom:16px;';
    box.appendChild(lblUser);
    box.appendChild(selUser);

    const result = document.createElement('div');
    box.appendChild(result);

    // FIX-MCP-UI-USERLIST — seul appel du flux MCP qui n'avait pas de .catch : une panne
    // reseau laissait le select VIDE, et le clic sur « Generer » ne faisait alors rien du
    // tout (garde `if (!label || !userId) return;` plus bas), sans le moindre message.
    // Un bouton muet se lit comme un bug de l'application, pas comme une panne.
    AM.listUsers().then(function(users) {
      const list = (users || []).filter(function(u) { return !u.suspended; });
      list.forEach(function(u) {
        const o = document.createElement('option');
        o.value = u.id;
        o.textContent = (u.name || u.id) + ' (' + (u.role || '') + ')';
        selUser.appendChild(o);
      });
      if (!list.length) failUserList(t('users.mcpTokenNoAccount', 'Aucun compte eligible.'));
    }).catch(function() {
      failUserList(t('users.mcpTokenUsersFailed', 'Liste des comptes indisponible.'));
    });

    function failUserList(msg) {
      selUser.disabled = true;
      btnOk.disabled = true;
      result.textContent = msg;
      result.style.cssText = 'color:var(--danger,#c0392b);font-size:0.9em;margin-bottom:12px;';
    }

    const row = document.createElement('div');
    row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
    const btnCancel = document.createElement('button');
    btnCancel.className = 'btn btn--outline';
    btnCancel.textContent = t('users.mcpModalCancel', 'Annuler');
    const btnOk = document.createElement('button');
    btnOk.className = 'btn btn--primary';
    btnOk.textContent = t('users.mcpTokenCreate', 'Generer');
    row.appendChild(btnCancel);
    row.appendChild(btnOk);
    box.appendChild(row);
    overlay.appendChild(box);
    document.body.appendChild(overlay);

    function close() {
      document.removeEventListener('keydown', onKey, true);
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
      if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch (e) {} }
    }
    function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
    document.addEventListener('keydown', onKey, true);
    btnCancel.addEventListener('click', close);

    btnOk.addEventListener('click', function() {
      const label = inLabel.value.trim();
      const userId = selUser.value;
      if (!label || !userId) return;
      btnOk.disabled = true;
      AM.createMcpToken(label, userId, 90).then(function(r) {
        if (!r || r.ok === false || !r.data || !r.data.token) {
          showToast2(t('users.mcpSaveFailed', 'Enregistrement impossible.'), 'error');
          btnOk.disabled = false;
          return;
        }
        // Affichage unique du secret : ni journalise, ni re-affichable.
        const warn = document.createElement('p');
        warn.textContent = t('users.mcpTokenOnce', 'Copiez ce jeton maintenant : il ne sera plus affiche.');
        warn.style.cssText = 'font-weight:600;margin:0 0 8px 0;font-size:0.9em;color:var(--color-warning,#b45309);';
        const tokenBox = document.createElement('div');
        tokenBox.textContent = r.data.token;
        tokenBox.style.cssText = 'font-family:monospace;font-size:0.82em;background:var(--color-bg-alt,#f5f5f5);'
          + 'padding:10px;border-radius:6px;user-select:all;word-break:break-all;margin-bottom:12px;';
        result.appendChild(warn);
        result.appendChild(tokenBox);
        inLabel.disabled = true;
        selUser.disabled = true;
        btnOk.style.display = 'none';
        btnCancel.textContent = t('common.close', 'Fermer');
        showToast2(t('users.mcpTokenCreatedOk', 'Jeton cree.'), 'success');
        if (onCreated) onCreated();
      }).catch(function(err) {
        void 0;
        showToast2(t('users.mcpSaveFailed', 'Enregistrement impossible.'), 'error');
        btnOk.disabled = false;
      });
    });
    inLabel.focus();
  }


  // === CSI-MCP === Vitrines INERTES (editions locales Community + Pro).
  // Aucune fonction MCP ni multi-entites n'existe en local : ces cartes ne font AUCUN
  // appel reseau et ne portent aucun etat. Leur seul role est de montrer la valeur de
  // l'edition Serveur — d'ou une mise en forme soignee (grille de capacites, exemples
  // concrets), et non un simple paragraphe « disponible ailleurs ».

  /** Badge « Édition Serveur » place en tete du corps de carte. */
  function _showcaseBadge(body) {
    const badge = document.createElement('span');
    badge.className = 'csi-badge';
    badge.textContent = t('users.mcpShowcaseBadge', 'Édition Serveur');
    badge.style.cssText = 'display:inline-block;background:var(--color-info-bg,#eff6ff);'
      + 'color:var(--color-info,#3b82f6);border:1px solid var(--color-info,#3b82f6);'
      + 'border-radius:999px;padding:2px 10px;font-size:0.75em;font-weight:600;margin-bottom:12px;';
    body.appendChild(badge);
  }

  function _showcaseSubhead(body, text) {
    const h = document.createElement('div');
    h.textContent = text;
    h.style.cssText = 'font-weight:600;margin:14px 0 8px 0;font-size:0.95em;';
    body.appendChild(h);
    return h;
  }

  function _showcasePara(body, text, extraCss) {
    const p = document.createElement('p');
    p.textContent = text;
    p.style.cssText = 'margin:0 0 10px 0;line-height:1.6;font-size:0.92em;' + (extraCss || '');
    body.appendChild(p);
    return p;
  }

  /** Grille de capacites : [icone, titre, description]. */
  function _showcaseGrid(body, items) {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));'
      + 'gap:10px;margin-bottom:10px;';
    items.forEach(function(it) {
      const box = document.createElement('div');
      box.style.cssText = 'background:var(--color-surface-alt,#f8f9fa);border:1px solid var(--color-border,#e2e8f0);'
        + 'border-radius:8px;padding:12px;';
      const head = document.createElement('div');
      head.style.cssText = 'display:flex;align-items:center;gap:8px;font-weight:600;font-size:0.88em;margin-bottom:5px;';
      const ic = document.createElement('i');
      ic.className = 'fas ' + it[0];
      ic.setAttribute('aria-hidden', 'true');
      ic.style.cssText = 'color:var(--color-info,#3b82f6);flex-shrink:0;';
      const ht = document.createElement('span');
      ht.textContent = it[1];
      head.appendChild(ic); head.appendChild(ht);
      const desc = document.createElement('div');
      desc.textContent = it[2];
      desc.style.cssText = 'font-size:0.84em;line-height:1.5;color:var(--color-text-secondary,#666);';
      box.appendChild(head); box.appendChild(desc);
      wrap.appendChild(box);
    });
    body.appendChild(wrap);
  }

  /** Liste a puces iconifiee. */
  function _showcaseList(body, icon, texts) {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'margin:0 0 10px 0;';
    texts.forEach(function(txt) {
      const row = document.createElement('div');
      row.style.cssText = 'display:flex;align-items:flex-start;gap:9px;margin-bottom:6px;font-size:0.89em;line-height:1.5;';
      const ic = document.createElement('i');
      ic.className = 'fas ' + icon;
      ic.setAttribute('aria-hidden', 'true');
      ic.style.cssText = 'color:var(--color-info,#3b82f6);margin-top:3px;width:15px;flex-shrink:0;text-align:center;';
      const sp = document.createElement('span');
      sp.textContent = txt;
      row.appendChild(ic); row.appendChild(sp);
      wrap.appendChild(row);
    });
    body.appendChild(wrap);
  }

  /** Encart de disponibilite + CTA vers le site. */
  function _showcaseFooter(body, extraKey, extraFb) {
    const foot = document.createElement('div');
    foot.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin-top:14px;'
      + 'background:var(--color-info-light,#eff6ff);border-left:4px solid var(--color-info,#3b82f6);'
      + 'border-radius:8px;padding:12px 14px;';
    const ic = document.createElement('i');
    ic.className = 'fas fa-circle-info';
    ic.setAttribute('aria-hidden', 'true');
    ic.style.cssText = 'color:var(--color-info,#3b82f6);margin-top:2px;flex-shrink:0;';
    const txt = document.createElement('div');
    const strong = document.createElement('div');
    strong.textContent = t('users.mcpShowcaseAvail', 'Disponible en édition Serveur.');
    strong.style.cssText = 'font-weight:600;font-size:0.9em;margin-bottom:3px;';
    txt.appendChild(strong);
    if (extraKey) {
      const p = document.createElement('div');
      p.textContent = t(extraKey, extraFb);
      p.style.cssText = 'font-size:0.86em;line-height:1.5;';
      txt.appendChild(p);
    }
    foot.appendChild(ic); foot.appendChild(txt);
    body.appendChild(foot);
  }

  function renderMcpShowcaseSection(container) {
    const { card, body } = createSettingsCard({
      title: t('users.mcpShowcaseTitle', 'Accès IA (MCP)'),
      description: t('users.mcpShowcaseBody', ''),
      icon: 'fa-wand-magic-sparkles'
    });
    _showcaseBadge(body);

    // La VALEUR d'abord : les memes exemples que la carte serveur.
    _showcaseSubhead(body, t('users.mcpAskTitle', 'Ce que vous pourrez demander à votre IA'));
    const askWrap = document.createElement('div');
    askWrap.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:8px;margin-bottom:10px;';
    [t('users.mcpAsk1', ''), t('users.mcpAsk2', ''), t('users.mcpAsk3', ''), t('users.mcpAsk4', '')].forEach(function(q) {
      const box = document.createElement('div');
      box.style.cssText = 'display:flex;align-items:flex-start;gap:8px;background:var(--color-surface-alt,#f8f9fa);'
        + 'border:1px solid var(--color-border,#e2e8f0);border-radius:8px;padding:10px 12px;'
        + 'font-size:0.88em;line-height:1.45;font-style:italic;';
      const ic = document.createElement('i');
      ic.className = 'fas fa-comment-dots';
      ic.setAttribute('aria-hidden', 'true');
      ic.style.cssText = 'color:var(--color-info,#3b82f6);margin-top:2px;flex-shrink:0;';
      const sp = document.createElement('span');
      sp.textContent = q;
      box.appendChild(ic); box.appendChild(sp);
      askWrap.appendChild(box);
    });
    body.appendChild(askWrap);
    _showcasePara(body, t('users.mcpAskFoot', ''), 'color:var(--color-text-secondary,#666);font-size:0.86em;');

    _showcaseSubhead(body, t('users.mcpShowcaseHowTitle', 'Comment cela marchera'));
    _showcaseList(body, 'fa-check', [
      t('users.mcpShowcaseHow1', ''), t('users.mcpShowcaseHow2', ''), t('users.mcpShowcaseHow3', '')
    ]);

    _showcaseFooter(body, 'users.mcpShowcaseMeanwhile', '');
    container.appendChild(card);
  }

  // === CSI-UPD === Vitrine INERTE « Maintenance serveur » pour les éditions locales.
  // Zéro réseau, zéro état : un simple teaser vers l'édition Serveur (pattern MCP).
  function renderUpdateShowcaseSection(container) {
    const { card, body } = createSettingsCard({
      title: t('serverUpdate.showcaseTitle', 'Mise à jour du serveur'),
      description: t('serverUpdate.showcaseBody', ''),
      icon: 'fa-cloud-arrow-up'
    });
    _showcaseBadge(body);
    _showcaseSubhead(body, t('serverUpdate.showcaseHowTitle', 'Comment cela fonctionne'));
    _showcaseList(body, 'fa-check', [
      t('serverUpdate.showcaseHow1', ''), t('serverUpdate.showcaseHow2', ''), t('serverUpdate.showcaseHow3', '')
    ]);
    _showcaseFooter(body, 'serverUpdate.showcaseMeanwhile', '');
    container.appendChild(card);
  }

  /** Bouton DOM-pur (aucun innerHTML) : icône + libellé texte. */
  function _updBtn(cls, iconClass, label) {
    const b = document.createElement('button');
    b.className = cls;
    b.dataset.readonlyLock = 'true';
    const ic = document.createElement('i');
    ic.className = 'fas ' + iconClass;
    ic.setAttribute('aria-hidden', 'true');
    b.appendChild(ic);
    b.appendChild(document.createTextNode(' ' + label));
    return b;
  }

  // === CSI-UPD === Section « Maintenance serveur » (édition Serveur, admin).
  // Trois cartes : Version installée · Mise à jour par paquet · Retour arrière.
  // Contrat réseau : les wrappers d'auth-manager ne rejettent JAMAIS — tout appelant
  // teste res.ok === false explicitement (pièges FIX-SSO-FALSE-OK / FIX-MCP-UI-FAUXOK).
  function renderMaintenanceSection(container) {
    const AM = window.CSI_AuthManager;
    // Défense en profondeur : la nav gate déjà, mais la section se re-vérifie (pattern
    // renderUsersSection) — un changement de rôle en cours de session ne doit rien ouvrir.
    if (!isServerModeUnlocked() || !AM || !AM.isAdmin()) {
      const p = document.createElement('p');
      p.className = 'settings-card__desc';
      p.textContent = t('serverUpdate.adminOnly', 'Réservé aux administrateurs en mode serveur.');
      container.appendChild(p);
      return;
    }

    const loading = document.createElement('div');
    loading.textContent = t('common.loading', 'Chargement…');
    loading.style.cssText = 'padding:12px 0;color:#6b7280;font-size:0.9rem;';
    container.appendChild(loading);

    AM.getUpdateStatus().then(function(res) {
      if (loading.parentNode) loading.parentNode.removeChild(loading);
      if (!res || res.ok === false) {
        const msg = (res && res.notDeployed)
          ? t('serverUpdate.notDeployedHelp', "Cette installation ne dispose pas encore du module de mise à jour (update.php). Déployez-le une première fois manuellement ; les mises à jour suivantes se feront depuis cet écran.")
          : ((res && res.data && res.data.error) || t('serverUpdate.unavailable', 'Service de mise à jour indisponible'));
        const box = document.createElement('div');
        box.className = 'info-box';
        box.textContent = msg;
        container.appendChild(box);
        return;
      }
      _renderUpdateCards(container, res.data);
    });
  }

  function _updFmtBytes(n) {
    if (!n && n !== 0) return '—';
    if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' Go';
    return Math.round(n / 1048576) + ' Mo';
  }

  function _renderUpdateCards(container, status) {
    const AM = window.CSI_AuthManager;
    const inst = (status && status.installed) || {};
    const caps = (status && status.capabilities) || {};

    // ---- Carte 1 : version installée + prérequis
    const c1 = createSettingsCard({
      title: t('serverUpdate.cardVersionTitle', 'Version installée'),
      description: t('serverUpdate.cardVersionDesc', "État de l'installation et prérequis techniques."),
      icon: 'fa-circle-info'
    });
    const dl = document.createElement('div');
    dl.style.cssText = 'display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:0.92em;margin-bottom:8px;';
    const rows = [
      [t('serverUpdate.installedVersion', 'Version'), inst.version ? ('v' + inst.version) : t('serverUpdate.unknownVersion', 'inconnue')],
      [t('serverUpdate.lastUpdate', 'Dernière mise à jour'), inst.appliedAt ? new Date(inst.appliedAt).toLocaleString() : t('serverUpdate.never', 'jamais par cet écran')],
      [t('serverUpdate.capZip', 'Extension ZIP'), caps.zip ? t('common.yes', 'Oui') : t('common.no', 'Non')],
      [t('serverUpdate.capWrite', "Droits d'écriture"), (caps.writeWebroot && caps.writeData) ? t('common.yes', 'Oui') : t('common.no', 'Non')],
      [t('serverUpdate.capDisk', 'Espace disque libre'), _updFmtBytes(caps.diskFreeBytes)]
    ];
    rows.forEach(function(r) {
      const k = document.createElement('div');
      k.style.cssText = 'color:var(--color-text-secondary,#666);';
      k.textContent = r[0];
      const v = document.createElement('div');
      v.style.cssText = 'font-weight:600;';
      v.textContent = r[1];
      dl.appendChild(k); dl.appendChild(v);
    });
    c1.body.appendChild(dl);
    if (!caps.zip) {
      const warn = document.createElement('div');
      warn.className = 'info-box';
      warn.textContent = t('serverUpdate.errors.zipUnavailable', "L'extension PHP « zip » est absente de cet hébergement : la mise à jour par paquet n'est pas disponible ici (procédure manuelle dans le guide serveur).");
      c1.body.appendChild(warn);
    }
    container.appendChild(c1.card);

    // ---- Carte 2 : téléverser un paquet
    const c2 = createSettingsCard({
      title: t('serverUpdate.cardUploadTitle', 'Mettre à jour par paquet'),
      description: t('serverUpdate.cardUploadDesc', "Téléversez le paquet .zip fourni par l'éditeur. Il est vérifié (signature et empreintes) avant toute modification."),
      icon: 'fa-cloud-arrow-up'
    });
    const statusEl = document.createElement('div');
    statusEl.style.cssText = 'font-size:0.9em;margin:8px 0;min-height:1.2em;';
    const planEl = document.createElement('div');
    planEl.style.cssText = 'margin:8px 0;';

    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.accept = '.zip,application/zip';
    fileInput.style.display = 'none';

    const actions = document.createElement('div');
    actions.className = 'settings-card__actions';
    const pickBtn = _updBtn('btn btn--primary btn--sm', 'fa-folder-open', t('serverUpdate.chooseFile', 'Choisir un paquet…'));
    pickBtn.disabled = !caps.zip;
    pickBtn.addEventListener('click', function() { fileInput.click(); });
    actions.appendChild(pickBtn);

    fileInput.addEventListener('change', function() {
      const f = fileInput.files && fileInput.files[0];
      fileInput.value = ''; // permet de re-choisir le même fichier après un échec
      if (!f) return;
      const ext = (f.name || '').split('.').pop().toLowerCase();
      if (ext !== 'zip') {
        showToast2(t('serverUpdate.notAZip', 'Le fichier doit être une archive .zip.'), 'error');
        return;
      }
      // Le corps part en base64 (+33 %) et le serveur plafonne à 9 Mo.
      if (f.size > 6.5 * 1024 * 1024) {
        showToast2(t('serverUpdate.fileTooBig', "Paquet trop volumineux pour les limites de cet hébergement (6,5 Mo maximum)."), 'error', 8000);
        return;
      }
      csiClearNode(planEl);
      statusEl.textContent = t('serverUpdate.checking', 'Vérification du paquet…');
      pickBtn.disabled = true;
      _updReadZipAsBase64(f).then(function(b64) {
        return AM.uploadUpdatePackage(b64);
      }).then(function(res) {
        pickBtn.disabled = false;
        if (!res || res.ok === false) {
          statusEl.textContent = '';
          showToast2(_updErrorMessage((res && res.data) || {}), 'error', 9000);
          return;
        }
        statusEl.textContent = '';
        _renderUpdatePlan(planEl, res.data.plan, statusEl);
      }).catch(function(err) {
        pickBtn.disabled = false;
        statusEl.textContent = '';
        void 0;
        showToast2(t('serverUpdate.readError', 'Lecture du fichier impossible.'), 'error');
      });
    });

    c2.body.appendChild(statusEl);
    c2.body.appendChild(planEl);
    c2.body.appendChild(actions);
    c2.body.appendChild(fileInput);
    container.appendChild(c2.card);

    // ---- Carte 3 : retour arrière
    const bk = (status && status.backup) || {};
    const c3 = createSettingsCard({
      title: t('serverUpdate.cardRollbackTitle', 'Retour arrière'),
      description: t('serverUpdate.cardRollbackDesc', 'Restaure le code de la version précédente. Les données ne sont pas modifiées.'),
      icon: 'fa-rotate-left',
      tone: 'danger'
    });
    if (!bk.available) {
      const p = document.createElement('p');
      p.className = 'settings-card__desc';
      p.textContent = t('serverUpdate.noBackup', 'Aucune sauvegarde disponible.');
      c3.body.appendChild(p);
    } else {
      const p = document.createElement('p');
      p.className = 'settings-card__desc';
      p.textContent = t('serverUpdate.backupAvailable', 'Version sauvegardée :') + ' v' + (bk.version || '?');
      c3.body.appendChild(p);
      const a3 = document.createElement('div');
      a3.className = 'settings-card__actions';
      const rbBtn = _updBtn('btn btn--outline btn--sm', 'fa-rotate-left', t('serverUpdate.rollbackBtn', 'Restaurer la version précédente'));
      rbBtn.addEventListener('click', function() {
        showConfirmModal(
          t('serverUpdate.rollbackConfirmTitle', 'Restaurer la version précédente ?'),
          t('serverUpdate.rollbackConfirmBody', 'Le code du serveur sera remplacé par la version sauvegardée. Les données et les comptes ne sont pas modifiés.'),
          function() {
            rbBtn.disabled = true;
            AM.rollbackUpdate().then(function(res) {
              rbBtn.disabled = false;
              if (!res || res.ok === false) {
                showToast2(_updErrorMessage((res && res.data) || {}), 'error', 9000);
                return;
              }
              showToast2(t('serverUpdate.rollbackOk', 'Version précédente restaurée.'), 'success');
              _updOfferReload(c3.body);
            });
          },
          { type: 'danger', confirmLabel: t('serverUpdate.rollbackBtn', 'Restaurer la version précédente') }
        );
      });
      a3.appendChild(rbBtn);
      c3.body.appendChild(a3);
    }
    container.appendChild(c3.card);
  }

  /** Messages d'erreur actionnables — le serveur renvoie {error, code}. */
  function _updErrorMessage(d) {
    const code = d && d.code;
    const known = code ? t('serverUpdate.errors.' + code, '') : '';
    if (known) return known;
    return (d && d.error) || t('serverUpdate.unavailable', 'Service de mise à jour indisponible');
  }

  /**
   * === CSI-UPD === Premier chemin BINAIRE du client (jusqu'ici : readAsText pour le
   * JSON, readAsDataURL pour le logo). Base64 dans le canal JSON existant — le
   * multipart serait refusé par la garde 415 de PHP (sync.php:340-345).
   */
  function _updReadZipAsBase64(file) {
    return new Promise(function(resolve, reject) {
      const reader = new FileReader();
      reader.onerror = function() { reject(new Error('read')); };
      reader.onload = function() {
        const bytes = new Uint8Array(reader.result);
        const chunk = 0x8000;
        let bin = '';
        // Par tranches : String.fromCharCode.apply sur ~3 Mo dépasse la pile d'appels.
        for (let i = 0; i < bytes.length; i += chunk) {
          bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
        }
        resolve(btoa(bin));
      };
      reader.readAsArrayBuffer(file);
    });
  }

  function _updLine(parent, label, value, strong) {
    const d = document.createElement('div');
    d.style.cssText = 'font-size:0.92em;margin:2px 0;' + (strong ? 'font-weight:600;' : '');
    d.textContent = label + ' ' + value;
    parent.appendChild(d);
    return d;
  }

  /** Écran de plan : ce qui va changer, avant toute écriture. */
  function _renderUpdatePlan(planEl, plan, statusEl) {
    csiClearNode(planEl);
    if (!plan) return;

    const box = document.createElement('div');
    box.className = 'info-box';

    _updLine(box, t('serverUpdate.planFrom', 'Version installée :'), (plan.from && plan.from.version) ? ('v' + plan.from.version) : t('serverUpdate.unknownVersion', 'inconnue'));
    _updLine(box, t('serverUpdate.planTo', 'Version du paquet :'), 'v' + (plan.to ? plan.to.version : '?'), true);
    _updLine(box, t('serverUpdate.planChanged', 'Fichiers remplacés :'), String((plan.changed || []).length));
    _updLine(box, t('serverUpdate.planAdded', 'Fichiers ajoutés :'), String((plan.added || []).length));
    _updLine(box, t('serverUpdate.planRemoved', 'Fichiers supprimés :'), String((plan.removed || []).length));
    _updLine(box, t('serverUpdate.planUnchanged', 'Fichiers inchangés :'), String(plan.unchangedCount || 0));

    (plan.warnings || []).forEach(function(w) {
      const d = document.createElement('div');
      d.style.cssText = 'font-size:0.88em;margin-top:6px;color:var(--color-warning,#b45309);';
      d.textContent = '⚠ ' + w;
      box.appendChild(d);
    });

    // Notes de version : affichées SEULEMENT si l'éditeur en a fourni (décision PO).
    const lang = (window.CSI_i18n && window.CSI_i18n.getLanguage) ? window.CSI_i18n.getLanguage() : 'fr';
    const notes = plan.notes && (plan.notes[lang] || plan.notes.fr);
    if (notes) {
      const n = document.createElement('div');
      n.style.cssText = 'margin-top:8px;padding-top:8px;border-top:1px solid var(--color-border,#e2e8f0);font-size:0.9em;white-space:pre-wrap;';
      n.textContent = notes;
      box.appendChild(n);
    }

    planEl.appendChild(box);

    if ((plan.blockers || []).length) {
      plan.blockers.forEach(function(b) {
        const d = document.createElement('div');
        d.style.cssText = 'font-size:0.9em;margin-top:6px;color:var(--color-danger,#dc2626);font-weight:600;';
        d.textContent = b.message;
        planEl.appendChild(d);
      });
      return; // rien à appliquer
    }

    const act = document.createElement('div');
    act.className = 'settings-card__actions';
    const applyBtn = _updBtn('btn btn--primary btn--sm', 'fa-play', t('serverUpdate.applyBtn', 'Appliquer la mise à jour'));
    applyBtn.addEventListener('click', function() {
      showConfirmModal(
        t('serverUpdate.confirmTitle', 'Appliquer la mise à jour ?'),
        t('serverUpdate.confirmBody', "Une sauvegarde du code et un instantané des données sont créés avant toute écriture. En cas d'échec, la version précédente est restaurée automatiquement."),
        function() { _updDoApply(applyBtn, statusEl, planEl, false); },
        { type: 'danger', confirmLabel: t('serverUpdate.applyBtn', 'Appliquer la mise à jour') }
      );
    });
    act.appendChild(applyBtn);
    planEl.appendChild(act);
  }

  function _updDoApply(btn, statusEl, planEl, force) {
    const AM = window.CSI_AuthManager;
    btn.disabled = true;
    statusEl.textContent = t('serverUpdate.applying', 'Mise à jour en cours — ne fermez pas cette page…');
    AM.applyUpdate(force).then(function(res) {
      btn.disabled = false;
      const d = (res && res.data) || {};
      if (!res || res.ok === false) {
        statusEl.textContent = '';
        // Éditeurs actifs : proposer explicitement de forcer plutôt que d'échouer sec.
        if (d.code === 'editorsActive') {
          showConfirmModal(
            t('serverUpdate.editorsActiveTitle', 'Des utilisateurs éditent en ce moment'),
            (d.error || '') + ' ' + t('serverUpdate.forceHelp', 'Forcer la mise à jour interrompra leur édition en cours.'),
            function() { _updDoApply(btn, statusEl, planEl, true); },
            { type: 'danger', confirmLabel: t('serverUpdate.forceAnyway', 'Forcer la mise à jour') }
          );
          return;
        }
        showToast2(_updErrorMessage(d), 'error', 10000);
        return;
      }
      statusEl.textContent = '';
      csiClearNode(planEl);
      const mism = (d.health && d.health.mismatches) || [];
      if (mism.length) {
        showToast2(t('serverUpdate.applyPartial', 'Mise à jour appliquée mais des fichiers ne correspondent pas au manifeste — vérifiez le serveur.'), 'warning', 12000);
      } else {
        showToast2(t('serverUpdate.applyOk', 'Mise à jour appliquée.') + ' v' + (d.applied ? d.applied.version : ''), 'success', 8000);
      }
      _updOfferReload(planEl);
    });
  }

  /**
   * Après une mise à jour, le code servi a changé : proposer un rechargement PROPRE
   * (flush des écritures en attente + libération du verrou), séquence identique à celle
   * de la bannière de nouvelle version (_csiNotifyServerBuild). Les autres onglets, eux,
   * reçoivent la bannière dans les 30 s via presence-ping (serverBuild).
   */
  function _updOfferReload(parent) {
    const wrap = document.createElement('div');
    wrap.className = 'settings-card__actions';
    const btn = _updBtn('btn btn--primary btn--sm', 'fa-rotate', t('serverUpdate.reloadNow', "Recharger l'application"));
    btn.addEventListener('click', function() {
      btn.disabled = true;
      Promise.resolve()
        .then(function() { try { saveData(); } catch (e) {} })
        .then(function() { return (typeof _flushServerPushes === 'function') ? _flushServerPushes(15000) : null; })
        .then(function() {
          try {
            if (window.CSI_EditLockManager && window.CSI_EditLockManager.release) return window.CSI_EditLockManager.release();
          } catch (e) {}
          return null;
        })
        .then(function() { window.location.reload(); })
        .catch(function() { window.location.reload(); });
    });
    wrap.appendChild(btn);
    parent.appendChild(wrap);
  }

  function renderEntitiesShowcaseSection(container) {
    const { card, body } = createSettingsCard({
      title: t('users.entShowcaseTitle', 'Multi-entités'),
      description: t('users.entShowcaseBody', ''),
      icon: 'fa-sitemap'
    });
    _showcaseBadge(body);

    // « Pour qui ? » d'abord : le lecteur doit se reconnaitre avant de lire les capacites.
    _showcaseSubhead(body, t('users.entShowcaseWhoTitle', 'Pour qui ?'));
    _showcaseList(body, 'fa-user-group', [
      t('users.entShowcaseWho1', ''), t('users.entShowcaseWho2', ''), t('users.entShowcaseWho3', '')
    ]);

    _showcaseSubhead(body, t('users.mcpHowTitle', 'Comment cela fonctionne'));
    _showcaseGrid(body, [
      ['fa-layer-group', t('users.entShowcaseFeat1', ''), t('users.entShowcaseFeat1d', '')],
      ['fa-chart-line', t('users.entShowcaseFeat2', ''), t('users.entShowcaseFeat2d', '')],
      ['fa-lock', t('users.entShowcaseFeat3', ''), t('users.entShowcaseFeat3d', '')],
      ['fa-user-shield', t('users.entShowcaseFeat4', ''), t('users.entShowcaseFeat4d', '')],
      ['fa-file-export', t('users.entShowcaseFeat5', ''), t('users.entShowcaseFeat5d', '')],
      ['fa-compass', t('users.entShowcaseFeat6', ''), t('users.entShowcaseFeat6d', '')]
    ]);

    _showcaseFooter(body, null, null);
    container.appendChild(card);
  }

  // === SECTION: DATA (Backup, Export, Snapshots) ===
  function renderDataSection(container) {
    // Backup Card
    const { card: backupCard, body: backupBody } = createSettingsCard({
      title: t('settings.backupTitle', 'Sauvegarde'),
      description: t('settings.backupDesc', 'Exportez ou importez vos données SMSI.'),
      icon: "fa-download"
    });

    const quickActions = document.createElement("div");
    quickActions.className = "quick-actions";

    const exportAction = document.createElement("div");
    exportAction.className = "quick-action";
    exportAction.id = "settingsExport";
    exportAction.innerHTML = `
      <div class="quick-action__icon"><i class="fas fa-download"></i></div>
      <span class="quick-action__label">${t('settings.export')}</span>
      <span class="quick-action__hint">.json</span>
    `;
    exportAction.addEventListener("click", exportData);

    const importAction = document.createElement("div");
    importAction.className = "quick-action";
    importAction.id = "settingsImport";
    importAction.dataset.readonlyLock = "true";
    importAction.innerHTML = `
      <div class="quick-action__icon"><i class="fas fa-upload"></i></div>
      <span class="quick-action__label">${t('settings.import')}</span>
      <span class="quick-action__hint">.json</span>
    `;
    importAction.addEventListener("click", importData);

    const pdfAction = document.createElement("div");
    pdfAction.className = "quick-action";
    pdfAction.id = "settingsPdfReport";
    pdfAction.innerHTML = `
      <div class="quick-action__icon"><i class="fas fa-file-pdf"></i></div>
      <span class="quick-action__label">${t('settings.pdfReport')}</span>
      <span class="quick-action__hint">${t('settings.complete')}</span>
    `;
    pdfAction.addEventListener("click", () => {
      if (typeof generatePdfReportWithFeedback === "function") {
        generatePdfReportWithFeedback();
      } else {
        showToast2(t('exportLabels.pdfUnavailable'), "error");
      }
    });

    const excelAction = document.createElement("div");
    excelAction.className = "quick-action";
    excelAction.id = "settingsExcelAll";
    excelAction.innerHTML = `
      <div class="quick-action__icon"><i class="fas fa-file-excel"></i></div>
      <span class="quick-action__label">${t('settings.excelExport')}</span>
      <span class="quick-action__hint">${t('settings.global')}</span>
    `;
    excelAction.addEventListener("click", exportAllSectionsExcel);

    const analyseImportAction = document.createElement("div");
    analyseImportAction.className = "quick-action";
    analyseImportAction.dataset.readonlyLock = "true";
    analyseImportAction.innerHTML = `
      <div class="quick-action__icon"><i class="fas fa-file-import"></i></div>
      <span class="quick-action__label">${t('settings.importAnalyse') || 'Import Analyse'}</span>
      <span class="quick-action__hint">.analyse</span>
    `;
    analyseImportAction.addEventListener("click", () => {
      if (typeof window.raImportAnalyseFile === 'function') window.raImportAnalyseFile();
      else showToast2(t('toasts.riskAnalysisModuleNotLoaded', 'Module d\'analyse de risques non chargé.'), "error");
    });

    quickActions.append(exportAction, importAction, analyseImportAction, pdfAction, excelAction);
    backupBody.appendChild(quickActions);
    container.appendChild(backupCard);

    // Snapshots Card
    const { card: snapshotCard, body: snapshotBody } = createSettingsCard({
      title: t('settings.snapshotTitle', 'Historique & Snapshots'),
      description: t('settings.snapshotDesc', 'Créez des points de sauvegarde locaux.'),
      icon: "fa-history"
    });

    const snapshotGrid = document.createElement("div");
    snapshotGrid.className = "settings-form-grid";

    const { wrap: snapshotLabelWrap, input: snapshotLabelInput } = createSettingsInputGroup({
      id: "snapshotLabel",
      label: t('settings.snapshotLabel', 'Nom du snapshot'),
      placeholder: t('settings.snapshotPlaceholder', 'Ex. : comité sécurité T2'),
      readonly: false
    });

    snapshotGrid.appendChild(snapshotLabelWrap);
    snapshotBody.appendChild(snapshotGrid);

    const snapshotActions = document.createElement("div");
    snapshotActions.className = "settings-card__actions";

    const btnValidate = document.createElement("button");
    btnValidate.id = "settingsValidate";
    btnValidate.className = "btn btn--outline btn--sm";
    btnValidate.innerHTML = '<i class="fas fa-search"></i> ' + escapeHtml(t('settings.analyze', 'Analyser'));
    btnValidate.addEventListener("click", validateDataQuality);

    const btnSnapshot = document.createElement("button");
    btnSnapshot.id = "settingsSnapshot";
    btnSnapshot.className = "btn btn--primary btn--sm";
    btnSnapshot.innerHTML = '<i class="fas fa-save"></i> ' + escapeHtml(t('settings.createSnapshot', 'Créer'));
    btnSnapshot.dataset.readonlyLock = "true";
    btnSnapshot.addEventListener("click", () => {
      const label = snapshotLabelInput.value.trim() || t('settings.manualSnapshot', 'Snapshot manuel');
      addHistorySnapshot(label);
      snapshotLabelInput.value = "";
    });

    const btnSnapshotClear = document.createElement("button");
    btnSnapshotClear.className = "btn btn--outline btn--sm";
    btnSnapshotClear.textContent = t('settings.cleanSnapshots', 'Nettoyer');
    btnSnapshotClear.dataset.readonlyLock = "true";
    btnSnapshotClear.addEventListener("click", clearHistorySnapshots);

    snapshotActions.append(btnValidate, btnSnapshot, btnSnapshotClear);
    snapshotBody.appendChild(snapshotActions);

    // Snapshots Timeline
    const historyList = document.createElement("div");
    historyList.id = "historyList";
    historyList.className = "snapshots-timeline";
    snapshotBody.appendChild(historyList);

    container.appendChild(snapshotCard);
    renderHistoryList();
  }

  // === SECTION: STORAGE ===
  function renderStorageSection(container) {
    var inServerMode = isServerModeUnlocked();
    // Server mode: show server storage card first
    if (inServerMode) {
      var { card: srvStorageCard, body: srvStorageBody } = createSettingsCard({
        title: t('settings.storageServerTitle', 'Stockage serveur'),
        description: t('settings.storageServerDesc', 'Vos données sont stockées et synchronisées sur le serveur.'),
        icon: 'fa-server',
        status: true
      });
      var srvInfo = document.createElement('div');
      srvInfo.className = 'settings-info-banner';
      var srvInfoIcon = document.createElement('i');
      srvInfoIcon.className = 'fas fa-check-circle';
      var srvInfoText = document.createElement('span');
      srvInfoText.textContent = ' ' + t('settings.storageServerActive', 'Le serveur gère le stockage principal. Toutes les modifications sont synchronisées automatiquement.');
      srvInfo.appendChild(srvInfoIcon);
      srvInfo.appendChild(srvInfoText);
      srvStorageBody.appendChild(srvInfo);

      // Show last sync timestamps
      var srvSyncStats = document.createElement('div');
      srvSyncStats.className = 'storage-stats';
      var formatSyncDate = function(kind) {
        // FIX-SYNCMETA-DISPLAY (audit 2026-06-10, bundle.js:9645) : en mode serveur les
        // timestamps de sync ne sont conservés QU'EN MÉMOIRE (CSI_Storage.getSyncTimestamps) —
        // la lecture directe de localStorage affichait toujours « Jamais ».
        try {
          var meta = (window.CSI_Storage && window.CSI_Storage.getSyncTimestamps) ? window.CSI_Storage.getSyncTimestamps() : {};
          var val = kind === 'pull' ? meta.lastPull : meta.lastPush;
          var ts = Date.parse(val || '');
          return Number.isFinite(ts) ? new Date(ts).toLocaleString() : t('common.never', 'Jamais');
        } catch(e) { return t('common.never', 'Jamais'); }
      };
      var createSyncStat = function(label, value) {
        var stat = document.createElement('div');
        stat.className = 'storage-stat';
        var valEl = document.createElement('div');
        valEl.className = 'storage-stat__value';
        valEl.textContent = value;
        var lblEl = document.createElement('div');
        lblEl.className = 'storage-stat__label';
        lblEl.textContent = label;
        stat.append(valEl, lblEl);
        return stat;
      };
      srvSyncStats.append(
        createSyncStat(t('settings.lastServerPull', 'Dernière réception'), formatSyncDate('pull')),
        createSyncStat(t('settings.lastServerPush', 'Dernier envoi'), formatSyncDate('push'))
      );
      srvStorageBody.appendChild(srvSyncStats);
      container.appendChild(srvStorageCard);
    }

    // Note : en mode serveur, la carte « Stockage serveur » ci-dessus couvre déjà l'information
    // (une seconde carte redondante du même titre a été retirée — audit 2026-07-10). La carte
    // « Stockage & Santé » (localStorage) qui suit reste masquée en mode serveur.
    const { card, body } = createSettingsCard({
      title: t('settings.storageTitle', 'Stockage & Santé'),
      description: t('settings.storageDesc', "Surveillance de l'occupation localStorage."),
      icon: "fa-hard-drive"
    });
    if (inServerMode) {
      card.style.display = 'none';
    }

    const gaugeWrap = document.createElement("div");
    gaugeWrap.style.marginBottom = "var(--spacing-md)";

    const gaugeLabel = document.createElement("div");
    gaugeLabel.className = "settings-form-label";
    gaugeLabel.textContent = inServerMode ? t('settings.storageUsageServer', 'Occupation du cache local') : t('settings.storageUsage', 'Occupation localStorage');
    gaugeLabel.style.marginBottom = "var(--spacing-xs)";

    const gauge = document.createElement("div");
    gauge.className = "storage-gauge";
    const gaugeBar = document.createElement("div");
    gaugeBar.id = "storageUsageBar";
    gaugeBar.className = "storage-gauge__fill";
    gauge.appendChild(gaugeBar);

    const gaugeText = document.createElement("div");
    gaugeText.id = "storageUsageText";
    gaugeText.className = "settings-form-help";
    gaugeText.style.marginTop = "var(--spacing-xs)";

    gaugeWrap.append(gaugeLabel, gauge, gaugeText);
    body.appendChild(gaugeWrap);

    const stats = document.createElement("div");
    stats.className = "storage-stats";

    const createStat = (label, id) => {
      const stat = document.createElement("div");
      stat.className = "storage-stat";
      const value = document.createElement("div");
      value.id = id;
      value.className = "storage-stat__value";
      value.textContent = "—";
      const lbl = document.createElement("div");
      lbl.className = "storage-stat__label";
      lbl.textContent = label;
      stat.append(value, lbl);
      return stat;
    };

    stats.append(
      createStat(t('settings.dataSize', 'Taille données'), "storageDatasetSize"),
      createStat(t('settings.lastAutosave', 'Dernier autosave'), "storageLastSave")
    );
    body.appendChild(stats);

    const warning = document.createElement("div");
    warning.id = "storageHealthWarning";
    warning.className = "danger-warning";
    warning.hidden = true;
    warning.innerHTML = `
      <i class="fas fa-exclamation-triangle danger-warning__icon"></i>
      <span class="danger-warning__text">${escapeHtml(t('settings.storageLow', 'Espace de stockage faible. Exportez vos données.'))}</span>
    `;
    body.appendChild(warning);

    container.appendChild(card);
    refreshStorageHealth();
  }

  // === SECTION: LICENSE ===
  function renderLicenseSection(container) {
    var TM = window.CSI_TrialManager;

    // --- Card 1: License Status ---
    var { card: statusCard, body: statusBody } = createSettingsCard({
      title: t('settings.license.statusTitle', 'Statut de la licence'),
      description: t('settings.license.statusDesc', 'Informations sur votre licence ou période d\'essai.'),
      icon: 'fa-id-badge'
    });

    var statusDiv = document.createElement('div');
    statusDiv.className = 'license-status-display';
    statusDiv.style.cssText = 'display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);border-radius:var(--radius-md,8px);';

    var statusIcon = document.createElement('div');
    statusIcon.style.cssText = 'font-size:2rem;flex-shrink:0;';

    var statusInfo = document.createElement('div');
    statusInfo.style.cssText = 'flex:1;';

    var statusTitle = document.createElement('div');
    statusTitle.style.cssText = 'font-weight:600;font-size:1.05rem;margin-bottom:4px;';

    var statusDetail = document.createElement('div');
    statusDetail.style.cssText = 'font-size:0.9rem;color:var(--color-text-secondary,#666);';

    if (TM && TM.isLicensed() && !TM.isLicenseExpired()) {
      // Licensed
      statusDiv.style.background = 'var(--color-success-bg,#f0fdf4)';
      statusDiv.style.border = '1px solid var(--color-success-light,#86efac)';
      statusIcon.textContent = '\u2705';
      statusTitle.textContent = t('license.active', 'Licence active');
      statusTitle.style.color = 'var(--color-success,#22c55e)';
      var org = TM.getOrganization();
      var state = TM.getState();
      var details = [];
      if (org) details.push(t('license.organization', 'Organisation') + ' : ' + org);
      if (state && state.expiresAt) details.push(t('license.expiresAt', 'Expire le {date}').replace('{date}', new Date(state.expiresAt).toLocaleDateString()));
      statusDetail.textContent = details.join(' — ') || '';
    } else if (TM && TM.isLicenseExpired()) {
      // License expired
      statusDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
      statusDiv.style.border = '1px solid var(--color-danger-light,#fca5a5)';
      statusIcon.textContent = '\u26A0\uFE0F';
      statusTitle.textContent = t('license.expired', 'Licence expirée');
      statusTitle.style.color = 'var(--color-danger,#dc3545)';
      statusDetail.textContent = t('license.renewNeeded', 'Licence expirée — entrez une nouvelle clé pour réactiver');
    } else if (TM && TM.isTrialActive()) {
      // Trial active
      var daysLeft = TM.getDaysLeft();
      statusDiv.style.background = 'var(--color-warning-bg,#fffbeb)';
      statusDiv.style.border = '1px solid var(--color-warning-light,#fde68a)';
      statusIcon.textContent = '\u23F3';
      statusTitle.textContent = t('license.trialActive', 'Période d\'essai active');
      statusTitle.style.color = 'var(--color-warning-dark,#92400e)';
      statusDetail.textContent = t('license.trialDetail', 'Essai gratuit — {days} jours restants sur 14. Toutes les fonctionnalités sont disponibles.').replace('{days}', daysLeft);
    } else if (TM && TM.isTrialExpired()) {
      // Trial expired
      statusDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
      statusDiv.style.border = '1px solid var(--color-danger-light,#fca5a5)';
      statusIcon.textContent = '\u26A0\uFE0F';
      statusTitle.textContent = t('license.trialExpired', 'Période d\'essai expirée');
      statusTitle.style.color = 'var(--color-danger,#dc3545)';
      statusDetail.textContent = t('license.renewNeeded', 'Licence expirée — entrez une nouvelle clé pour réactiver');
    } else if (TM && TM.isDetected()) {
      // Detected but no active trial/license
      statusDiv.style.background = 'var(--color-surface-alt,#f9fafb)';
      statusDiv.style.border = '1px solid var(--color-border,#e5e7eb)';
      statusIcon.textContent = '\u2139\uFE0F';
      statusTitle.textContent = t('license.noLicense', 'Aucune licence active');
      statusDetail.textContent = t('license.noLicenseDetail', 'Activez une clé de licence ci-dessous.');
    } else {
      // TM null or not yet detected (should not happen after await fix)
      statusDiv.style.background = 'var(--color-surface-alt,#f9fafb)';
      statusDiv.style.border = '1px solid var(--color-border,#e5e7eb)';
      statusIcon.textContent = '\u2139\uFE0F';
      statusTitle.textContent = t('license.detecting', 'Détection de la licence...');
      statusDetail.textContent = '';
    }

    statusInfo.appendChild(statusTitle);
    statusInfo.appendChild(statusDetail);
    statusDiv.appendChild(statusIcon);
    statusDiv.appendChild(statusInfo);
    statusBody.appendChild(statusDiv);
    container.appendChild(statusCard);

    // --- Card 2: License Activation ---
    var { card: activateCard, body: activateBody } = createSettingsCard({
      title: t('settings.license.activateTitle', 'Activer une licence'),
      description: t('settings.license.activateDesc', 'Collez votre clé CSI2-... pour activer ou renouveler votre licence.'),
      icon: 'fa-key'
    });

    var keyTextarea = document.createElement('textarea');
    keyTextarea.className = 'csi-license-textarea';
    keyTextarea.placeholder = 'CSI2-...';
    keyTextarea.style.cssText = 'width:100%;min-height:80px;padding:12px;border:1px solid var(--color-border,#ddd);border-radius:var(--radius-md,6px);margin-bottom:var(--spacing-sm);font-family:monospace;font-size:0.85rem;resize:vertical;box-sizing:border-box;background:var(--color-surface,#fff);color:var(--color-text,#333);';

    var msgDiv = document.createElement('div');
    msgDiv.style.cssText = 'margin-bottom:var(--spacing-sm);font-size:0.9rem;display:none;padding:10px;border-radius:var(--radius-md,6px);';

    var btnRow = document.createElement('div');
    btnRow.className = 'settings-card__actions';

    var activateBtn = document.createElement('button');
    activateBtn.className = 'btn btn--primary btn--sm';
    activateBtn.textContent = t('license.activate', 'Activer la licence');

    function showActivateMsg(text, type) {
      msgDiv.style.display = 'block';
      msgDiv.textContent = text;
      if (type === 'error') {
        msgDiv.style.background = 'var(--color-danger-bg,#fef2f2)';
        msgDiv.style.color = 'var(--color-danger,#dc3545)';
      } else {
        msgDiv.style.background = 'var(--color-success-bg,#f0fdf4)';
        msgDiv.style.color = 'var(--color-success,#22c55e)';
      }
    }

    activateBtn.addEventListener('click', function() {
      var key = (keyTextarea.value || '').trim();
      if (!key || key.indexOf('CSI2-') !== 0) {
        showActivateMsg(t('license.error', 'Clé de licence invalide'), 'error');
        return;
      }
      activateBtn.disabled = true;
      activateBtn.textContent = '...';
      msgDiv.style.display = 'none';

      if (TM) {
        TM.activate(key).then(function(result) {
          showActivateMsg(t('license.success', 'Licence activée avec succès') + (result.organization ? ' — ' + result.organization : ''), 'success');
          setTimeout(function() {
            // Refresh the section to show the new status
            container.textContent = '';
            renderLicenseSection(container);
          }, 1500);
        }).catch(function(err) {
          activateBtn.disabled = false;
          activateBtn.textContent = t('license.activate', 'Activer la licence');
          showActivateMsg(err.message || t('license.error', 'Clé de licence invalide'), 'error');
        });
      } else {
        activateBtn.disabled = false;
        activateBtn.textContent = t('license.activate', 'Activer la licence');
        showActivateMsg('TrialManager non disponible', 'error');
      }
    });

    btnRow.appendChild(activateBtn);
    activateBody.appendChild(keyTextarea);
    activateBody.appendChild(msgDiv);
    activateBody.appendChild(btnRow);
    container.appendChild(activateCard);
  }

  // === SECTION: USERS ===
  function renderUsersSection(container) {
    var AM = window.CSI_AuthManager;
    if (!AM || !AM.isAdmin()) {
      container.textContent = t('users.noAccess', 'Accès réservé aux administrateurs.');
      return;
    }

    // --- Card 1: User list ---
    var { card: listCard, body: listBody } = createSettingsCard({
      title: t('users.title', 'Gestion des utilisateurs'),
      description: t('users.description', 'Créer, suspendre ou supprimer des comptes utilisateurs.'),
      icon: 'fa-users'
    });

    var listStatus = document.createElement('div');
    listStatus.className = 'users-status';
    listStatus.textContent = t('common.loading', 'Chargement...');
    listBody.appendChild(listStatus);

    // Recherche utilisateurs (utile dès quelques dizaines de comptes, jusqu'au plafond 100/200).
    // Filtrage 100 % client sur les lignes déjà rendues (nom, email, rôle, statut) — pas de
    // requête réseau par frappe, pas de restructuration du rendu de ligne existant.
    var _lastFullCountText = '';
    var usersSearchWrap = document.createElement('div');
    usersSearchWrap.className = 'users-search-wrap';
    usersSearchWrap.style.margin = 'var(--spacing-sm, 8px) 0';
    var usersSearchInput = document.createElement('input');
    usersSearchInput.type = 'search';
    usersSearchInput.className = 'form-control users-search';
    usersSearchInput.placeholder = t('users.searchPlaceholder', 'Rechercher (nom, email, rôle)…');
    usersSearchInput.setAttribute('aria-label', t('users.searchPlaceholder', 'Rechercher (nom, email, rôle)…'));
    usersSearchWrap.appendChild(usersSearchInput);
    listBody.appendChild(usersSearchWrap);

    var tableWrap = document.createElement('div');
    tableWrap.className = 'users-table-wrap';
    listBody.appendChild(tableWrap);

    function filterUserRows() {
      var q = (usersSearchInput.value || '').trim().toLowerCase();
      var rows = tableWrap.querySelectorAll('tbody tr');
      var shown = 0;
      rows.forEach(function(tr) {
        var match = !q || tr.textContent.toLowerCase().indexOf(q) >= 0;
        tr.style.display = match ? '' : 'none';
        if (match) shown++;
      });
      if (q) {
        listStatus.textContent = t('users.countFiltered', '{shown} / {count} utilisateurs (filtrés)')
          .replace('{shown}', shown).replace('{count}', rows.length);
      } else if (_lastFullCountText) {
        listStatus.textContent = _lastFullCountText;
      }
    }
    usersSearchInput.addEventListener('input', filterUserRows);

    function refreshUserList() {
      listStatus.textContent = t('common.loading', 'Chargement...');
      tableWrap.innerHTML = '';
      // FIX-MAXUSERS-STATUS (audit 2026-06-10) : la limite d'utilisateurs est annoncée par
      // le serveur (status.maxUsers, configurable 5-200 via SSO) — ne plus coder 5 en dur
      // pour l'affichage « N / max » (repli 5 si le statut est indisponible).
      Promise.all([
        AM.listUsers(),
        AM.getStatus().catch(function() { return null; })
      ]).then(function(_userListRes) {
        var users = _userListRes[0];
        var _srvStatus = _userListRes[1];
        if (!users || !users.length) {
          listStatus.textContent = t('users.noUsers', 'Aucun utilisateur trouvé.');
          return;
        }
        var maxUsers = (_srvStatus && typeof _srvStatus.maxUsers === 'number' && _srvStatus.maxUsers > 0) ? _srvStatus.maxUsers : 5;
        listStatus.textContent = t('users.count', '{count} / {max} utilisateurs')
          .replace('{count}', users.length).replace('{max}', maxUsers);
        _lastFullCountText = listStatus.textContent;

        var table = document.createElement('table');
        table.className = 'users-table';

        // Header
        var thead = document.createElement('thead');
        var hRow = document.createElement('tr');
        [t('users.name', 'Nom'), t('users.role', 'Rôle'), t('users.status', 'Statut'), t('users.actions', 'Actions')].forEach(function(h) {
          var th = document.createElement('th');
          th.textContent = h;
          hRow.appendChild(th);
        });
        thead.appendChild(hRow);
        table.appendChild(thead);

        var tbody = document.createElement('tbody');
        var currentUser = AM.getCurrentUser();

        users.forEach(function(user) {
          var tr = document.createElement('tr');
          tr.className = 'users-table__row';
          if (user.suspended) tr.classList.add('users-table__row--suspended');

          // Name
          var tdName = document.createElement('td');
          tdName.textContent = user.name;
          // FIX-SSO-EMAIL-LINK (audit 2026-06-10, complément client) : affichage de
          // l'email du compte (liaison SSO par email) s'il existe.
          if (user.email) {
            var emailLine = document.createElement('div');
            emailLine.style.cssText = 'font-size:0.78em;color:var(--color-text-secondary,#888);';
            emailLine.textContent = user.email;
            tdName.appendChild(emailLine);
          }
          tr.appendChild(tdName);

          // Role
          var tdRole = document.createElement('td');
          var roleBadge = document.createElement('span');
          roleBadge.className = 'users-badge users-badge--' + user.role;
          var roleLabels = {
            admin: t('users.roleAdmin', 'Admin'),
            editor: t('users.roleEditor', 'Éditeur'),
            viewer: t('users.roleViewer', 'Lecteur')
          };
          roleBadge.textContent = roleLabels[user.role] || user.role;
          tdRole.appendChild(roleBadge);
          tr.appendChild(tdRole);

          // Status
          var tdStatus = document.createElement('td');
          var statusBadge = document.createElement('span');
          statusBadge.className = user.suspended ? 'users-badge users-badge--suspended' : 'users-badge users-badge--active';
          statusBadge.textContent = user.suspended ? t('users.suspended', 'Suspendu') : t('users.active', 'Actif');
          tdStatus.appendChild(statusBadge);
          if (user.mustChangePassword) {
            var tmpBadge = document.createElement('span');
            tmpBadge.className = 'users-badge users-badge--temp';
            tmpBadge.textContent = t('users.tempPassword', 'MdP temp.');
            tdStatus.appendChild(tmpBadge);
          }
          if (user.sso) {
            var ssoBadge = document.createElement('span');
            ssoBadge.className = 'users-badge users-badge--sso';
            ssoBadge.textContent = 'SSO';
            tdStatus.appendChild(ssoBadge);
          }
          tr.appendChild(tdStatus);

          // Actions
          var tdActions = document.createElement('td');
          tdActions.className = 'users-actions';

          var isSelf = currentUser && currentUser.id === user.id;

          // FIX-SSO-EMAIL-LINK (audit 2026-06-10, complément client) : modale de
          // modification de l'email (update-user accepte 'email', '' = suppression).
          // Masqué pour les comptes SSO (email géré par le fournisseur d'identité).
          if (!user.sso) {
            var btnEmail = document.createElement('button');
            btnEmail.className = 'btn btn--xs btn--outline';
            btnEmail.textContent = t('users.editEmail', 'Email');
            btnEmail.title = t('users.editEmailTitle', 'Modifier l\'email (liaison SSO)');
            btnEmail.addEventListener('click', function() {
              showEditEmailModal(user);
            });
            tdActions.appendChild(btnEmail);
          }

          // === CSI-ME === (audit M8) réassignation d'entité(s) d'un compte EXISTANT.
          // Mode multi-entités actif, cible non-admin (V12 : un admin n'a jamais d'entityIds).
          var _emUsers = window.CSI_EntityManager;
          if (_emUsers && _emUsers.isMultiEntity && _emUsers.isMultiEntity() && user.role !== 'admin') {
            var btnEnt = document.createElement('button');
            btnEnt.className = 'btn btn--xs btn--outline';
            btnEnt.textContent = t('users.editEntities', 'Entité(s)');
            btnEnt.title = t('users.editEntitiesTitle', 'Modifier le rattachement aux sous-entités');
            btnEnt.addEventListener('click', function() { showEditEntitiesModal(user, refreshUserList); });
            tdActions.appendChild(btnEnt);
          }

          if (!isSelf) {
            // Suspend/Reactivate
            var btnSuspend = document.createElement('button');
            btnSuspend.className = 'btn btn--xs btn--outline';
            btnSuspend.textContent = user.suspended ? t('users.reactivate', 'Réactiver') : t('users.suspend', 'Suspendre');
            btnSuspend.addEventListener('click', function() {
              var msg = user.suspended
                ? t('users.confirmReactivate', 'Réactiver cet utilisateur ?')
                : t('users.confirmSuspend', 'Suspendre cet utilisateur ?');
              showConfirmModal(
                user.suspended ? t('users.reactivateTitle', 'Réactiver') : t('users.suspendTitle', 'Suspendre'),
                msg,
                function() {
                  AM.suspendUser(user.id).then(function(res) {
                    if (!res.ok) throw new Error(res.data && res.data.error || t('common.error', 'Erreur'));
                    showToast(user.suspended ? t('users.reactivateSuccess', 'Utilisateur réactivé') : t('users.suspendSuccess', 'Utilisateur suspendu'), 'success');
                    refreshUserList();
                  }).catch(function(e) { showToast(e.message || t('common.error', 'Erreur'), 'error'); });
                },
                { type: 'warning' }
              );
            });
            tdActions.appendChild(btnSuspend);

            // Reset password (hidden for SSO users)
            if (!user.sso) {
              var btnReset = document.createElement('button');
              btnReset.className = 'btn btn--xs btn--outline';
              btnReset.textContent = t('users.resetPassword', 'Réinit. MdP');
              btnReset.addEventListener('click', function() {
                showResetPasswordModal(user);
              });
              tdActions.appendChild(btnReset);
            }

            // Role toggle button (editor ↔ viewer, not for admins)
            if (user.role !== 'admin') {
              var btnRole = document.createElement('button');
              btnRole.className = 'btn btn--xs btn--outline';
              var newRole = user.role === 'editor' ? 'viewer' : 'editor';
              var newRoleLabel = newRole === 'editor'
                ? t('users.roleEditor', 'Éditeur')
                : t('users.roleViewer', 'Lecteur');
              btnRole.textContent = '→ ' + newRoleLabel;
              btnRole.title = t('users.changeRoleTitle', 'Changer le rôle');
              btnRole.addEventListener('click', function() {
                showConfirmModal(
                  t('users.changeRoleTitle', 'Changer le rôle'),
                  t('users.changeRoleConfirm', 'Changer le rôle de {name} en {role} ?')
                    .replace('{name}', user.name).replace('{role}', newRoleLabel),
                  function() {
                    AM.updateUser(user.id, user.name, newRole).then(function(res) {
                      if (!res.ok) throw new Error(res.data && res.data.error || 'Erreur');
                      showToast(t('users.roleChangeSuccess', 'Rôle modifié'), 'success');
                      refreshUserList();
                    }).catch(function(e) { showToast(e.message, 'error'); });
                  },
                  { type: 'warning' }
                );
              });
              tdActions.appendChild(btnRole);
            }

            // Delete button removed — admin can only suspend accounts
          } else {
            var selfLabel = document.createElement('span');
            selfLabel.className = 'users-self-label';
            selfLabel.textContent = t('users.you', '(vous)');
            tdActions.appendChild(selfLabel);
          }

          tr.appendChild(tdActions);
          tbody.appendChild(tr);
        });

        table.appendChild(tbody);
        tableWrap.appendChild(table);
        // Réappliquer un filtre de recherche actif après reconstruction du tableau.
        if (usersSearchInput.value) filterUserRows();
      }).catch(function(e) {
        listStatus.textContent = t('users.loadError', 'Impossible de charger les utilisateurs.');
        void 0;
      });
    }

    refreshUserList();
    container.appendChild(listCard);

    // --- Card 2: Create user ---
    var { card: createCard, body: createBody } = createSettingsCard({
      title: t('users.create', 'Créer un utilisateur'),
      description: t('users.createDesc', 'Ajoutez un nouveau compte utilisateur.'),
      icon: 'fa-user-plus'
    });

    var createForm = document.createElement('div');
    createForm.className = 'settings-form-grid settings-form-grid--single-col';

    var { wrap: nameWrap, input: nameInput } = createSettingsInputGroup({
      id: 'createUserName',
      label: t('users.name', 'Nom'),
      value: '',
      placeholder: t('adminUi.userPlaceholder', 'utilisateur'),
      readonly: false
    });

    // FIX-SSO-EMAIL-LINK (audit 2026-06-10, complément client) : email optionnel à la
    // création — permet la liaison « match by email » du SSO entreprise (users.php
    // valide filter_var + unicité côté serveur).
    var { wrap: emailWrap, input: emailInput } = createSettingsInputGroup({
      id: 'createUserEmail',
      label: t('users.emailOptional', 'Email (optionnel)'),
      value: '',
      type: 'email',
      placeholder: t('users.emailPlaceholder', 'utilisateur@exemple.com'),
      help: t('users.emailHelp', 'Permet la liaison du compte avec le SSO entreprise.'),
      readonly: false
    });

    var { wrap: roleWrap, select: roleSelect } = createSettingsSelectGroup({
      id: 'createUserRole',
      label: t('users.roleLabel', 'Rôle'),
      value: 'editor',
      options: [
        { value: 'editor', label: t('users.roleEditor', 'Éditeur') },
        { value: 'viewer', label: t('users.roleViewer', 'Lecteur') }
      ],
      help: t('users.roleHelp', 'Éditeur = peut modifier les données. Lecteur = lecture seule.')
    });
    // Remove data-readonly-lock since this is an admin-only form
    delete roleSelect.dataset.readonlyLock;

    // === CSI-ME === sélecteur d'entité (mode multi-entités actif) : « Entité
    // principale » ou une/plusieurs sous-entités (Lot 3c : multi-appartenance —
    // le compte bascule ensuite parmi SES entités). Passé à createUser en entityIds.
    var entitySelect = null;
    var EMcu = window.CSI_EntityManager;
    if (EMcu && EMcu.isMultiEntity()) {
      var _entOpts = [{ value: '', label: t('users.entityMain', 'Entité principale') }];
      EMcu.entities().forEach(function(e) { if (e && !e.archived && !e.disabled) _entOpts.push({ value: e.id, label: e.name || e.id }); });
      var entGroup = createSettingsSelectGroup({ id: 'createUserEntity', label: t('users.entityLabel', 'Entité(s)'), value: '', options: _entOpts, help: t('users.entityHelpMulti', 'Une sous-entité ne voit que ses propres encodages. Sélection multiple possible (Ctrl/Cmd) — « Entité principale » ne se combine pas.') });
      var entityWrap = entGroup.wrap; entitySelect = entGroup.select;
      delete entitySelect.dataset.readonlyLock;
      entitySelect.multiple = true;
      entitySelect.size = Math.min(4, _entOpts.length);
      // Exclusivité : « Entité principale » (valeur vide) ne se combine pas avec des
      // sous-entités — on désélectionne l'autre camp au changement.
      entitySelect.addEventListener('change', function() {
        var opts = entitySelect.options, mainSel = false, subSel = false, k;
        for (k = 0; k < opts.length; k++) { if (opts[k].selected) { if (opts[k].value === '') mainSel = true; else subSel = true; } }
        if (mainSel && subSel) {
          // Le dernier geste gagne : si la principale vient d'être cliquée, on garde elle seule.
          var keepMain = (document.activeElement === entitySelect && entitySelect.value === '');
          for (k = 0; k < opts.length; k++) {
            if (opts[k].value === '') opts[k].selected = keepMain;
            else if (keepMain) opts[k].selected = false;
          }
        }
      });
    }

    var { wrap: pwdWrap, input: pwdInput } = createSettingsPasswordGroup({
      id: 'createUserPwd',
      label: t('users.password', 'Mot de passe'),
      value: '',
      placeholder: t('users.pwdMinPlaceholder', '18 caractères min.'),
      help: t('users.passwordHelp', 'Minimum 18 caractères, 1 majuscule, 1 minuscule, 1 caractère spécial.')
    });

    var { wrap: pwd2Wrap, input: pwd2Input } = createSettingsPasswordGroup({
      id: 'createUserPwd2',
      label: t('users.confirmPassword', 'Confirmer'),
      value: '',
      placeholder: t('users.confirmPwdPlaceholder', 'Confirmer le mot de passe')
    });
    // FIX-USERS-PWD-LOCK (audit 2026-06-10) : la gestion des utilisateurs est une action
    // d'ADMINISTRATION, pas une édition de données — retirer le verrou lecture seule posé
    // par createSettingsPasswordGroup (même traitement que le select de rôle ci-dessus),
    // sinon les champs mot de passe restent grisés hors mode édition.
    delete pwdInput.dataset.readonlyLock;
    delete pwd2Input.dataset.readonlyLock;

    createForm.append(nameWrap, emailWrap, roleWrap, pwdWrap, pwd2Wrap);
    if (entitySelect && typeof entityWrap !== 'undefined') createForm.appendChild(entityWrap); // === CSI-ME ===
    createBody.appendChild(createForm);

    var createMsgDiv = document.createElement('div');
    createMsgDiv.className = 'users-create-msg';
    createBody.appendChild(createMsgDiv);

    var createActions = document.createElement('div');
    createActions.className = 'settings-card__actions';
    var btnCreate = document.createElement('button');
    btnCreate.className = 'btn btn--primary btn--sm';
    btnCreate.textContent = t('users.createBtn', 'Créer le compte');
    btnCreate.addEventListener('click', function() {
      var name = (nameInput.value || '').trim();
      var email = (emailInput.value || '').trim();
      var pwd = pwdInput.value || '';
      var pwd2 = pwd2Input.value || '';

      createMsgDiv.textContent = '';
      createMsgDiv.className = 'users-create-msg';

      if (!name || name.length < 2) {
        createMsgDiv.textContent = t('users.errorNameShort', 'Le nom doit contenir au moins 2 caractères.');
        createMsgDiv.classList.add('users-create-msg--error');
        return;
      }
      if (pwd.length < 18) {
        createMsgDiv.textContent = t('users.errorPwdShort', 'Le mot de passe doit contenir au moins 18 caractères.');
        createMsgDiv.classList.add('users-create-msg--error');
        return;
      }
      if (!/[A-Z]/.test(pwd) || !/[a-z]/.test(pwd) || !/[^a-zA-Z0-9]/.test(pwd)) {
        createMsgDiv.textContent = t('users.errorPwdWeak', 'Le mot de passe doit contenir majuscule, minuscule et caractère spécial.');
        createMsgDiv.classList.add('users-create-msg--error');
        return;
      }
      if (pwd !== pwd2) {
        createMsgDiv.textContent = t('users.errorPwdMismatch', 'Les mots de passe ne correspondent pas.');
        createMsgDiv.classList.add('users-create-msg--error');
        return;
      }

      btnCreate.disabled = true;
      btnCreate.textContent = '...';
      // FIX-SSO-EMAIL-LINK : transmission de l'email optionnel ; l'erreur serveur
      // (email invalide / déjà utilisé) est affichée par le catch ci-dessous.
      // === CSI-ME === assignation d'entité si le sélecteur est présent.
      // === CSI-ME === (Lot 3c) collecter TOUTES les sous-entités sélectionnées
      // (multi-appartenance) ; aucune sélection ou « Entité principale » = compte principal.
      var _entIdsCu = [];
      if (entitySelect) {
        for (var _ei = 0; _ei < entitySelect.options.length; _ei++) {
          var _eo = entitySelect.options[_ei];
          if (_eo.selected && _eo.value) _entIdsCu.push(_eo.value);
        }
      }
      var _entOptsCu = _entIdsCu.length ? { entityIds: _entIdsCu } : undefined;
      AM.createUser(name, pwd, roleSelect.value, email, _entOptsCu).then(function(res) {
        if (!res.ok) throw new Error(res.data && res.data.error || t('common.error', 'Erreur'));
        showToast(t('users.createSuccess', 'Utilisateur créé'), 'success');
        nameInput.value = '';
        emailInput.value = '';
        pwdInput.value = '';
        pwd2Input.value = '';
        createMsgDiv.textContent = '';
        refreshUserList();
      }).catch(function(e) {
        createMsgDiv.textContent = e.message || t('common.error', 'Erreur');
        createMsgDiv.classList.add('users-create-msg--error');
      }).finally(function() {
        btnCreate.disabled = false;
        btnCreate.textContent = t('users.createBtn', 'Créer le compte');
      });
    });
    createActions.appendChild(btnCreate);
    createBody.appendChild(createActions);

    container.appendChild(createCard);

    // --- Reset password modal helper ---
    function showResetPasswordModal(user) {
      var overlay = document.createElement('div');
      overlay.className = 'csi-modal-overlay';
      overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:99999;';

      var modal = document.createElement('div');
      modal.style.cssText = 'background:var(--color-surface,#fff);border-radius:12px;padding:32px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3);';

      var title = document.createElement('h3');
      title.style.cssText = 'margin:0 0 16px 0;color:var(--color-text,#333);';
      title.textContent = t('users.resetPasswordFor', 'Réinitialiser le mot de passe de') + ' ' + user.name;
      modal.appendChild(title);

      var pwdField = document.createElement('input');
      pwdField.type = 'password';
      pwdField.className = 'form-control';
      pwdField.placeholder = t('users.newPassword', 'Nouveau mot de passe (18+ car.)');
      pwdField.style.cssText = 'width:100%;margin-bottom:12px;box-sizing:border-box;';
      modal.appendChild(pwdField);

      var pwd2Field = document.createElement('input');
      pwd2Field.type = 'password';
      pwd2Field.className = 'form-control';
      pwd2Field.placeholder = t('users.confirmPassword', 'Confirmer');
      pwd2Field.style.cssText = 'width:100%;margin-bottom:16px;box-sizing:border-box;';
      modal.appendChild(pwd2Field);

      var msgDiv = document.createElement('div');
      msgDiv.className = 'users-create-msg';
      modal.appendChild(msgDiv);

      var btnRow = document.createElement('div');
      btnRow.style.cssText = 'display:flex;gap:12px;justify-content:flex-end;margin-top:12px;';

      var btnCancel = document.createElement('button');
      btnCancel.className = 'btn btn--outline btn--sm';
      btnCancel.textContent = t('common.cancel', 'Annuler');
      btnCancel.addEventListener('click', function() { overlay.remove(); });

      var btnConfirm = document.createElement('button');
      btnConfirm.className = 'btn btn--primary btn--sm';
      btnConfirm.textContent = t('users.resetPassword', 'Réinit. MdP');
      btnConfirm.addEventListener('click', function() {
        var pwd = pwdField.value || '';
        var pwd2 = pwd2Field.value || '';
        msgDiv.textContent = '';
        msgDiv.className = 'users-create-msg';

        if (pwd.length < 18) {
          msgDiv.textContent = t('users.errorPwdShort', 'Le mot de passe doit contenir au moins 18 caractères.');
          msgDiv.classList.add('users-create-msg--error');
          return;
        }
        if (pwd !== pwd2) {
          msgDiv.textContent = t('users.errorPwdMismatch', 'Les mots de passe ne correspondent pas.');
          msgDiv.classList.add('users-create-msg--error');
          return;
        }
        btnConfirm.disabled = true;
        btnConfirm.textContent = '...';
        AM.resetPassword(user.id, pwd).then(function(res) {
          if (!res.ok) throw new Error(res.data && res.data.error || t('common.error', 'Erreur'));
          showToast(t('users.resetSuccess', 'Mot de passe réinitialisé'), 'success');
          overlay.remove();
        }).catch(function(e) {
          msgDiv.textContent = e.message || t('common.error', 'Erreur');
          msgDiv.classList.add('users-create-msg--error');
          btnConfirm.disabled = false;
          btnConfirm.textContent = t('users.resetPassword', 'Réinit. MdP');
        });
      });

      btnRow.append(btnCancel, btnConfirm);
      modal.appendChild(btnRow);
      overlay.appendChild(modal);
      document.body.appendChild(overlay);
      setTimeout(function() { pwdField.focus(); }, 100);
    }

    // --- Edit email modal helper ---
    // FIX-SSO-EMAIL-LINK (audit 2026-06-10, complément client) : modification de l'email
    // d'un compte (update-user) — chaîne vide = suppression. La validation (filter_var)
    // et l'unicité sont faites côté serveur ; l'erreur serveur est affichée telle quelle.
    function showEditEmailModal(user) {
      var overlay = document.createElement('div');
      overlay.className = 'csi-modal-overlay';
      overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:99999;';

      var modal = document.createElement('div');
      modal.style.cssText = 'background:var(--color-surface,#fff);border-radius:12px;padding:32px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3);';

      var title = document.createElement('h3');
      title.style.cssText = 'margin:0 0 16px 0;color:var(--color-text,#333);';
      title.textContent = t('users.editEmailTitle', 'Modifier l\'email (liaison SSO)') + ' — ' + user.name;
      modal.appendChild(title);

      var emailField = document.createElement('input');
      emailField.type = 'email';
      emailField.className = 'form-control';
      emailField.value = user.email || '';
      emailField.placeholder = t('users.emailPlaceholder', 'utilisateur@exemple.com');
      emailField.style.cssText = 'width:100%;margin-bottom:8px;box-sizing:border-box;';
      modal.appendChild(emailField);

      var helpP = document.createElement('p');
      helpP.style.cssText = 'margin:0 0 12px;font-size:0.8rem;color:var(--color-text-secondary,#888);';
      helpP.textContent = t('users.emailClearHint', 'Laissez vide puis enregistrez pour supprimer l\'email.');
      modal.appendChild(helpP);

      var msgDiv = document.createElement('div');
      msgDiv.className = 'users-create-msg';
      modal.appendChild(msgDiv);

      var btnRow = document.createElement('div');
      btnRow.style.cssText = 'display:flex;gap:12px;justify-content:flex-end;margin-top:12px;';

      var btnCancel = document.createElement('button');
      btnCancel.className = 'btn btn--outline btn--sm';
      btnCancel.textContent = t('common.cancel', 'Annuler');
      btnCancel.addEventListener('click', function() { overlay.remove(); });

      var btnConfirm = document.createElement('button');
      btnConfirm.className = 'btn btn--primary btn--sm';
      btnConfirm.textContent = t('common.save', 'Enregistrer');
      btnConfirm.addEventListener('click', function() {
        var email = (emailField.value || '').trim();
        msgDiv.textContent = '';
        msgDiv.className = 'users-create-msg';
        btnConfirm.disabled = true;
        btnConfirm.textContent = '...';
        // null pour name/role : mise à jour de l'email uniquement
        AM.updateUser(user.id, null, null, email).then(function(res) {
          if (!res.ok) throw new Error(res.data && res.data.error || t('common.error', 'Erreur'));
          showToast(t('users.emailUpdateSuccess', 'Email mis à jour'), 'success');
          overlay.remove();
          refreshUserList();
        }).catch(function(e) {
          msgDiv.textContent = e.message || t('common.error', 'Erreur');
          msgDiv.classList.add('users-create-msg--error');
          btnConfirm.disabled = false;
          btnConfirm.textContent = t('common.save', 'Enregistrer');
        });
      });

      btnRow.append(btnCancel, btnConfirm);
      modal.appendChild(btnRow);
      overlay.appendChild(modal);
      document.body.appendChild(overlay);
      setTimeout(function() { emailField.focus(); }, 100);
    }

    // === CSI-ME === (audit M8) Modale de réassignation des sous-entités d'un compte.
    function showEditEntitiesModal(user, onDone) {
      var EM = window.CSI_EntityManager;
      var overlay = document.createElement('div');
      overlay.className = 'csi-modal-overlay';
      overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:99999;';
      var modal = document.createElement('div');
      modal.style.cssText = 'background:var(--color-surface,#fff);border-radius:12px;padding:32px;max-width:460px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3);';
      var title = document.createElement('h3');
      title.style.cssText = 'margin:0 0 16px 0;color:var(--color-text);';
      title.textContent = t('users.editEntitiesTitle', 'Modifier le rattachement aux sous-entités') + ' — ' + user.name;
      modal.appendChild(title);

      var current = (user.entityIds && user.entityIds.length) ? user.entityIds : [];
      var sel = document.createElement('select');
      sel.multiple = true;
      sel.className = 'form-control';
      sel.size = 5;
      var optMain = document.createElement('option');
      optMain.value = '';
      optMain.textContent = t('users.entityMain', 'Entité principale');
      optMain.selected = (current.length === 0);
      sel.appendChild(optMain);
      (EM && EM.entities ? EM.entities() : []).forEach(function(e) {
        if (!e || e.archived) return;
        var o = document.createElement('option');
        o.value = e.id; o.textContent = e.name || e.id;
        o.selected = current.indexOf(e.id) >= 0;
        sel.appendChild(o);
      });
      // Exclusivité principal ↔ sous-entités.
      sel.addEventListener('change', function() {
        var opts = sel.options, mainSel = false, subSel = false, k;
        for (k = 0; k < opts.length; k++) { if (opts[k].selected) { if (opts[k].value === '') mainSel = true; else subSel = true; } }
        if (mainSel && subSel) {
          var keepMain = (document.activeElement === sel && sel.value === '');
          for (k = 0; k < opts.length; k++) {
            if (opts[k].value === '') opts[k].selected = keepMain;
            else if (keepMain) opts[k].selected = false;
          }
        }
      });
      modal.appendChild(sel);

      var help = document.createElement('p');
      help.style.cssText = 'margin:8px 0 12px;font-size:0.8rem;color:var(--color-text-secondary);';
      help.textContent = t('users.editEntitiesHint', 'Sélection multiple possible (Ctrl/Cmd). « Entité principale » = aucun rattachement. Retirer la dernière sous-entité suspend le compte (fail-closed).');
      modal.appendChild(help);

      // === CSI-ME === (audit co-édition 2026-08-05, MAJEUR) L'édition transverse
      // (canEditSubEntities) n'était accordable par AUCUN écran — seulement par l'API — alors
      // que le dossier d'architecture la prévoyait. Case réservée aux comptes du PÉRIMÈTRE
      // PRINCIPAL (le serveur refuse la combinaison avec une sous-entité) et aux non-viewers.
      var crossWrap = document.createElement('label');
      crossWrap.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin:0 0 12px;font-size:0.82rem;cursor:pointer;';
      var crossCb = document.createElement('input');
      crossCb.type = 'checkbox';
      crossCb.checked = !!(user && user.canEditSubEntities);
      crossCb.disabled = (user && user.role === 'viewer');
      var crossTxt = document.createElement('span');
      crossTxt.textContent = t('users.canEditSubEntities', 'Peut éditer les données des sous-entités (compte du périmètre principal uniquement)');
      crossWrap.appendChild(crossCb); crossWrap.appendChild(crossTxt);
      modal.appendChild(crossWrap);
      var _syncCrossState = function() {
        var anySub = false;
        for (var z = 0; z < sel.options.length; z++) { if (sel.options[z].selected && sel.options[z].value) anySub = true; }
        crossCb.disabled = anySub || (user && user.role === 'viewer');
        crossWrap.style.opacity = crossCb.disabled ? '0.5' : '1';
        if (anySub) crossCb.checked = false;
      };
      sel.addEventListener('change', _syncCrossState);
      _syncCrossState();

      var msgDiv = document.createElement('div');
      msgDiv.className = 'users-create-msg';
      modal.appendChild(msgDiv);

      var btnRow = document.createElement('div');
      btnRow.style.cssText = 'display:flex;gap:12px;justify-content:flex-end;margin-top:12px;';
      var btnCancel = document.createElement('button');
      btnCancel.className = 'btn btn--outline btn--sm';
      btnCancel.textContent = t('common.cancel', 'Annuler');
      btnCancel.addEventListener('click', function() { overlay.remove(); });
      var btnSave = document.createElement('button');
      btnSave.className = 'btn btn--primary btn--sm';
      btnSave.textContent = t('common.save', 'Enregistrer');
      btnSave.addEventListener('click', function() {
        var ids = [];
        for (var i = 0; i < sel.options.length; i++) { if (sel.options[i].selected && sel.options[i].value) ids.push(sel.options[i].value); }
        // Détachement de la dernière sous-entité (→ périmètre principal) : le serveur
        // exige detachConfirm. Le client sait le détecter (ids vide + compte assigné)
        // et confirme en amont — pas de dépendance à un code d'erreur serveur.
        var isDetach = (ids.length === 0 && current.length > 0);
        if (isDetach && !window.confirm(t('users.lastEntityDetach', 'Ce compte n\'aura plus aucune sous-entité et obtiendrait un accès au périmètre principal. Confirmer ?'))) {
          return;
        }
        msgDiv.textContent = ''; msgDiv.className = 'users-create-msg';
        btnSave.disabled = true; btnSave.textContent = '...';
        // email=undefined → inchangé ; entOpts porte entityIds (+ detachConfirm au besoin).
        AM.updateUser(user.id, null, null, undefined, { entityIds: ids, detachConfirm: isDetach, canEditSubEntities: !!crossCb.checked }).then(function(res) {
          if (!res.ok) throw new Error(res.data && res.data.error || t('common.error', 'Erreur'));
          showToast(t('users.entitiesUpdateSuccess', 'Rattachement mis à jour'), 'success');
          overlay.remove();
          if (typeof onDone === 'function') onDone();
        }).catch(function(e) {
          msgDiv.textContent = e.message || t('common.error', 'Erreur');
          msgDiv.classList.add('users-create-msg--error');
          btnSave.disabled = false; btnSave.textContent = t('common.save', 'Enregistrer');
        });
      });
      btnRow.append(btnCancel, btnSave);
      modal.appendChild(btnRow);
      overlay.appendChild(modal);
      document.body.appendChild(overlay);
    }
  }

  // === SECTION: ADVANCED ===
  // === SECTION: FRAMEWORKS ===
  function renderFrameworksSection(container) {
    var mapper = window.CSI_FrameworkMapper;
    if (!mapper) {
      var msg = document.createElement('p');
      msg.textContent = t('mapping.notAvailable', 'Module de mapping non disponible.');
      container.appendChild(msg);
      return;
    }

    var frameworks = mapper.listFrameworks();

    frameworks.forEach(function(fw) {
      var fwData = mapper.getFramework(fw.id);
      var isCore = !!(fwData && fwData.meta && fwData.meta.mandatory);
      // En Community, listFrameworks renvoie le canonique (iso27002/iso27001) car
      // open-security-controls/management en sont des alias (meta.aliasOf). Pour l'AFFICHAGE,
      // préférer le framework réellement utilisé par l'édition courante (« Open Security… »).
      // fw.id reste le canonique pour la logique (mappings, toggles, stats).
      var _dispId = fw.id;
      if (!_isFrameworkAllowed(fw.id)) {
        var _fwsAll = window._csiFrameworks || {};
        for (var _aid in _fwsAll) {
          if (_fwsAll.hasOwnProperty(_aid) && _fwsAll[_aid].meta &&
              _fwsAll[_aid].meta.aliasOf === fw.id && _isFrameworkAllowed(_aid)) { _dispId = _aid; break; }
        }
      }
      var meta = mapper.getFramework(_dispId) || fwData;
      var _dispName = (meta && meta.meta && meta.meta.name) ? meta.meta.name : fw.name;
      var controlCount = (meta && meta.meta ? (meta.meta.controlCount || meta.meta.clauseCount) : (fw.controlCount || fw.clauseCount)) || 0;
      var iconClass = (meta && meta.meta ? meta.meta.icon : 'fa-shield-halved') || 'fa-shield-halved';
      var cardOpts = {
        title: _dispName,
        description: controlCount + ' ' + t('mapping.settings.controls', 'contrôles'),
        icon: iconClass
      };
      var result = createSettingsCard(cardOpts);
      var body = result.body;

      // Toggle or "always active" label
      var toggleWrap = document.createElement('div');
      toggleWrap.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:8px;';

      if (isCore) {
        var badge = document.createElement('span');
        badge.className = 'badge badge--success';
        var lockIcon = document.createElement('i');
        lockIcon.className = 'fas fa-lock';
        badge.appendChild(lockIcon);
        badge.appendChild(document.createTextNode(' ' + t('mapping.settings.alwaysActive', 'Toujours actif')));
        toggleWrap.appendChild(badge);
      } else if (fw.id === 'cyfun2025' || fw.id === 'recyf2025') {
        // Référentiels NIS2 (Belgique/CyFun & France/ReCyF) : mutuellement exclusifs. Pas de
        // bascule activé/désactivé indépendante (c'était la source de l'incohérence). L'unique
        // commande est le sélecteur « Référentiel NIS2 actif » ci-dessous. On affiche ici le
        // statut actif/inactif, dérivé de appData.nis2Referential.
        var _isActiveRef = (typeof appData !== 'undefined' && (appData.nis2Referential || 'cyfun2025') === fw.id);
        var refBadge = document.createElement('span');
        var rbIcon = document.createElement('i');
        if (_isActiveRef) {
          refBadge.className = 'badge badge--success';
          rbIcon.className = 'fas fa-circle-check';
          refBadge.appendChild(rbIcon);
          refBadge.appendChild(document.createTextNode(' ' + t('settings.nis2RefActive', 'Référentiel NIS2 actif')));
        } else {
          refBadge.className = 'badge';
          refBadge.style.cssText = 'background:var(--color-surface-alt,#eceff3);color:var(--text-secondary,#6b7280);';
          rbIcon.className = 'far fa-circle';
          refBadge.appendChild(rbIcon);
          refBadge.appendChild(document.createTextNode(' ' + t('settings.nis2RefInactive', 'Référentiel NIS2 — non sélectionné')));
        }
        toggleWrap.appendChild(refBadge);
      } else {
        var toggleLabel = document.createElement('label');
        toggleLabel.className = 'toggle';
        toggleLabel.style.marginBottom = '0';
        var input = document.createElement('input');
        input.type = 'checkbox';
        input.checked = fw.enabled;
        input.addEventListener('change', (function(fwId) {
          return function(e) {
            mapper.setEnabled(fwId, e.target.checked);
            if (e.target.checked && typeof initFrameworkData === 'function') initFrameworkData(fwId);
            mapper.applyFrameworkVisibility();
            if (typeof updateDashboard === 'function') updateDashboard();
            if (typeof saveData === 'function') saveData();
            showToast2(t('settings.saved', 'Paramètres sauvegardés'), 'success');
          };
        })(fw.id));
        var slider = document.createElement('span');
        slider.className = 'toggle__slider';
        toggleLabel.appendChild(input);
        toggleLabel.appendChild(slider);
        toggleWrap.appendChild(toggleLabel);

        var toggleText = document.createElement('span');
        toggleText.textContent = fw.enabled ? t('common.enabled', 'Activé') : t('common.disabled', 'Désactivé');
        toggleWrap.appendChild(toggleText);
      }

      body.appendChild(toggleWrap);

      // Mapping stats
      var enabledFws = mapper.listEnabledFrameworks();
      var statsDiv = document.createElement('div');
      statsDiv.style.cssText = 'margin-top:12px;font-size:0.85rem;color:var(--text-secondary);';
      var mappingCount = 0;
      enabledFws.forEach(function(other) {
        if (other.id !== fw.id) {
          var stats = mapper.getCoverageStats(fw.id, other.id);
          if (stats) mappingCount += stats.totalMappings;
        }
      });
      if (mappingCount > 0) {
        var linkIcon = document.createElement('i');
        linkIcon.className = 'fas fa-link';
        linkIcon.style.marginRight = '4px';
        statsDiv.appendChild(linkIcon);
        statsDiv.appendChild(document.createTextNode(mappingCount + ' ' + t('mapping.settings.mappingsLoaded', 'correspondances chargées')));
      }
      body.appendChild(statsDiv);

      // NIS2/CyFun assurance level selector — carte cyfun2025, et seulement quand la Belgique
      // est le référentiel NIS2 actif (sinon ce réglage de niveau n'a pas lieu d'être affiché).
      if (fw.id === 'cyfun2025' && (appData.nis2Referential || 'cyfun2025') === 'cyfun2025') {
        var levelWrap = document.createElement('div');
        levelWrap.className = 'form-group';
        levelWrap.id = 'cyfunLevelSetting';
        levelWrap.style.cssText = 'margin-top:16px;';

        var levelLabel = document.createElement('label');
        levelLabel.setAttribute('for', 'cyfunLevelSelect');
        levelLabel.textContent = t('settings.cyfunLevel', 'Niveau d\'assurance NIS2');
        levelWrap.appendChild(levelLabel);

        var levelSelect = document.createElement('select');
        levelSelect.id = 'cyfunLevelSelect';
        levelSelect.className = 'form-control';
        [
          { value: 'basic', text: 'BASIC (34 ' + t('common.requirements', 'exigences') + ')' },
          { value: 'important', text: 'IMPORTANT (133 ' + t('common.requirements', 'exigences') + ')' },
          { value: 'essential', text: 'ESSENTIAL (218 ' + t('common.requirements', 'exigences') + ')' }
        ].forEach(function(opt) {
          var option = document.createElement('option');
          option.value = opt.value;
          option.textContent = opt.text;
          levelSelect.appendChild(option);
        });
        levelSelect.value = (typeof appData !== 'undefined' && appData.cyfunLevel) || 'basic';
        // Verrou « lecture seule » : refreshReadOnlyLocks() désactive ce sélecteur pour un
        // utilisateur non-éditeur (viewer en mode serveur). Le verrou org-wide ci-dessous le
        // rend définitif (permanentReadonly) même pour un éditeur.
        levelSelect.dataset.readonlyLock = 'true';

        // FIX-CYFUN-LEVEL-LOCK: si _cyfunLevelLocked === true dans server-data.json,
        // le niveau est verrouillé (configuration définitive). Le sélecteur devient
        // read-only avec un message explicatif. Évite tout downgrade accidentel et le
        // data-loss associé. Pour déverrouiller : éditer server-data.json côté serveur.
        var _isCyfunLocked = (typeof appData !== 'undefined' && appData._cyfunLevelLocked === true);
        if (_isCyfunLocked) {
          levelSelect.disabled = true;
          // permanentReadonly : reste désactivé même hors mode lecture seule (verrou définitif).
          levelSelect.dataset.permanentReadonly = 'true';
          levelSelect.title = t('settings.cyfunLevelLocked', 'Niveau verrouillé pour cette organisation. Pour déverrouiller, contactez l\'administrateur.');
          var lockNote = document.createElement('div');
          lockNote.style.cssText = 'margin-top:6px;font-size:12px;color:#888;font-style:italic;';
          lockNote.textContent = '🔒 ' + t('settings.cyfunLevelLockedNote',
            'Niveau verrouillé pour cette organisation. Modification désactivée.');
          levelWrap.appendChild(lockNote);
        }

        levelSelect.addEventListener('change', function() {
          // FIX-CYFUN-LEVEL-LOCK: garde-fou côté event handler (au cas où le disabled
          // serait contourné via DevTools ou autre)
          if (typeof appData !== 'undefined' && appData._cyfunLevelLocked === true) {
            this.value = appData.cyfunLevel || 'basic';  // restaure la valeur
            showToast2(t('settings.cyfunLevelLockedToast',
              'Niveau verrouillé : modification refusée.'), 'warning');
            return;
          }
          var newLevel = this.value;
          if (typeof setCyfunAssuranceLevel === 'function') setCyfunAssuranceLevel(newLevel);
          if (typeof recalcNis2Maturity === 'function') recalcNis2Maturity();
          if (typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable();
          if (typeof updateNis2RadarChart === 'function') updateNis2RadarChart();
          if (typeof saveData === 'function') saveData();
          // FIX-CYFUN-PERSIST: en mode serveur, saveData est no-op sur l'onglet Settings
          // (bundle.js:12197). Forcer un push immédiat pour persister cyfunLevel + nis2Controls.
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
              window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
            // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4)
            setTimeout(function() {
              _persistConfigWithLock(['cyfunLevel', 'cyfunLang', '_cyfunLevelChosen', 'nis2Controls', '_cyfunEvalArchive', 'targetMaturity']);
            }, 500);
          }
          if (typeof updateDashboard === 'function') updateDashboard();
          showToast2(t('settings.cyfunLevelChanged', 'Niveau NIS2 changé : ') + newLevel.toUpperCase(), 'info');
        });

        levelWrap.appendChild(levelSelect);
        body.appendChild(levelWrap);

        // FEAT-CYFUN-NL (2026-07-10) : langue du contenu CyFun modifiable après le 1er
        // lancement (le wizard/la modale sont one-shot). Changer la langue resnapshotte
        // les textes (refreshNis2ControlTexts) sans toucher aux évaluations — pas soumis
        // au verrou de niveau (_cyfunLevelLocked), qui ne protège que du data-loss de niveau.
        var langWrap = document.createElement('div');
        langWrap.className = 'form-group';
        langWrap.style.cssText = 'margin-top:12px;';
        var langLabel = document.createElement('label');
        langLabel.setAttribute('for', 'cyfunLangSelect');
        langLabel.textContent = t('settings.cyfunLang', 'Langue du référentiel CyFun');
        langWrap.appendChild(langLabel);
        var langSelect = document.createElement('select');
        langSelect.id = 'cyfunLangSelect';
        langSelect.className = 'form-control';
        [
          { value: 'en', text: 'English' },
          { value: 'fr', text: 'Français' },
          { value: 'nl', text: 'Nederlands' }
        ].forEach(function(opt) {
          var option = document.createElement('option');
          option.value = opt.value;
          option.textContent = opt.text;
          langSelect.appendChild(option);
        });
        langSelect.value = (typeof appData !== 'undefined' && ['en', 'fr', 'nl'].indexOf(appData.cyfunLang) >= 0) ? appData.cyfunLang : 'en';
        langSelect.dataset.readonlyLock = 'true';
        langSelect.addEventListener('change', function() {
          appData.cyfunLang = this.value;
          if (typeof refreshNis2ControlTexts === 'function') refreshNis2ControlTexts();
          if (typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable();
          if (typeof updateNis2RadarChart === 'function') updateNis2RadarChart();
          if (typeof saveData === 'function') saveData();
          if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
              window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
            // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4)
            setTimeout(function() {
              _persistConfigWithLock(['cyfunLang', 'nis2Controls']);
            }, 500);
          }
          showToast2(t('settings.cyfunLangChanged', 'Langue du référentiel CyFun changée.'), 'info');
        });
        langWrap.appendChild(langSelect);
        body.appendChild(langWrap);
      }

      // ReCyF (France) — sélecteur de degré EI/EE — carte recyf2025, et seulement quand la
      // France est le référentiel NIS2 actif.
      if (fw.id === 'recyf2025' && appData.nis2Referential === 'recyf2025') {
        var fwR = (window._csiFrameworks || {})['recyf2025'];
        var _cntEI = fwR ? fwR.requirements.filter(function(r) { return r.level === 'importante'; }).length : 76;
        var _cntEE = fwR ? fwR.requirements.length : 152;
        var recyfWrap = document.createElement('div');
        recyfWrap.className = 'form-group';
        recyfWrap.style.cssText = 'margin-top:16px;';
        var degLabel = document.createElement('label');
        degLabel.setAttribute('for', 'recyfDegreeSelect');
        degLabel.textContent = t('settings.recyfDegree', "Degré de l'entité (ReCyF)");
        recyfWrap.appendChild(degLabel);
        var degSelect = document.createElement('select');
        degSelect.id = 'recyfDegreeSelect';
        degSelect.className = 'form-control';
        (window.RECYF_LEVELS || []).forEach(function(lvl) {
          var option = document.createElement('option');
          option.value = lvl.id;
          option.textContent = lvl.short + ' — ' + lvl.label + ' (' + (lvl.id === 'essentielle' ? _cntEE : _cntEI) + ' ' + t('common.measures', 'mesures') + ')';
          degSelect.appendChild(option);
        });
        degSelect.value = appData.recyfLevel || 'importante';
        // Verrou lecture seule : désactivé pour un viewer (mode serveur) via refreshReadOnlyLocks.
        degSelect.dataset.readonlyLock = 'true';
        degSelect.addEventListener('change', function() {
          if (typeof setRecyfDegree === 'function') setRecyfDegree(this.value);
          if (typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable();
          if (typeof updateDashboard === 'function') updateDashboard();
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
              window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
            // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4)
            setTimeout(function() { _persistConfigWithLock(['recyfLevel', 'recyfControls']); }, 500);
          }
          showToast2(t('settings.recyfDegreeChanged', 'Degré ReCyF : ') + this.value.toUpperCase(), 'info');
        });
        recyfWrap.appendChild(degSelect);
        body.appendChild(recyfWrap);
      }

      // Bascule du référentiel NIS2 actif (Belgique/CyFun ↔ France/ReCyF) — sur les deux cartes NIS2
      if (fw.id === 'cyfun2025' || fw.id === 'recyf2025') {
        var refWrap = document.createElement('div');
        refWrap.className = 'form-group';
        refWrap.style.cssText = 'margin-top:16px;padding-top:12px;border-top:1px dashed var(--color-border,#e5e7eb);';
        var refLabel = document.createElement('label');
        refLabel.setAttribute('for', 'nis2ReferentialSelect_' + fw.id);
        refLabel.textContent = t('settings.nis2Referential', 'Référentiel NIS2 actif');
        refWrap.appendChild(refLabel);
        var refSelect = document.createElement('select');
        refSelect.id = 'nis2ReferentialSelect_' + fw.id;
        refSelect.className = 'form-control';
        [
          { value: 'cyfun2025', text: t('settings.nis2RefBE', 'NIS2 Belgique — CyFun 2025 (CCB)') },
          { value: 'recyf2025', text: t('settings.nis2RefFR', 'NIS2 France — ReCyF (ANSSI)') }
        ].forEach(function(opt) {
          var option = document.createElement('option');
          option.value = opt.value; option.textContent = opt.text;
          refSelect.appendChild(option);
        });
        refSelect.value = appData.nis2Referential || 'cyfun2025';
        // Verrou lecture seule : désactivé pour un viewer (mode serveur) via refreshReadOnlyLocks.
        refSelect.dataset.readonlyLock = 'true';
        // Verrou « référentiel figé » : une fois le référentiel choisi (wizard/modale), la
        // bascule CyFun<->ReCyF est désactivée pour ne pas masquer par erreur un encodage
        // existant. Seule une réinitialisation rouvre le choix. Le niveau reste modifiable.
        var _isRefLocked = (typeof isNis2ReferentialLocked === 'function') && isNis2ReferentialLocked();
        if (_isRefLocked) {
          refSelect.disabled = true;
          // permanentReadonly : reste désactivé même hors mode lecture seule (verrou définitif).
          refSelect.dataset.permanentReadonly = 'true';
          refSelect.title = t('settings.nis2RefLocked', 'Référentiel verrouillé. Une réinitialisation est nécessaire pour en changer.');
        }
        refSelect.addEventListener('change', function() {
          // Garde-fou (au cas où le disabled serait contourné via DevTools) : refuser toute
          // bascule si le référentiel est verrouillé, et restaurer la valeur affichée.
          if (typeof isNis2ReferentialLocked === 'function' && isNis2ReferentialLocked()) {
            this.value = appData.nis2Referential || 'cyfun2025';
            showToast2(t('settings.nis2RefLockedToast', 'Référentiel verrouillé : une réinitialisation est nécessaire pour en changer.'), 'warning');
            return;
          }
          appData.nis2Referential = this.value;
          // Modèle exclusif : aligner enabledFrameworks sur le référentiel choisi, puis
          // ré-appliquer TOUTE la visibilité (catégorie + onglets + widgets dashboard), pas
          // seulement le filtrage des onglets NIS2.
          if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
          if (this.value === 'recyf2025' && typeof ensureRecyfControls === 'function') ensureRecyfControls();
          if (typeof saveData === 'function') saveData();
          // Ré-applique toute la visibilité (catégorie + onglets + widgets dashboard).
          // applyFrameworkVisibility appelle déjà applyNis2Referential(false) en interne. On NE
          // navigue PAS ici : le sélecteur est dans les Réglages, l'utilisateur y reste pour voir
          // les cartes mises à jour (renderSettingsContent ci-dessous). Évite toute redirection
          // parasite vers le tableau de bord.
          if (window.CSI_FrameworkMapper && typeof CSI_FrameworkMapper.applyFrameworkVisibility === 'function') {
            CSI_FrameworkMapper.applyFrameworkVisibility();
          } else if (typeof applyNis2Referential === 'function') {
            applyNis2Referential(false);
          }
          if (typeof updateDashboard === 'function') updateDashboard();
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
              window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
            // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4)
            setTimeout(function() { _persistConfigWithLock(['nis2Referential', 'cyfunLevel', 'recyfLevel', 'nis2Controls', '_cyfunEvalArchive', 'recyfControls', 'frameworkMapper']); }, 500);
          }
          showToast2(t('settings.nis2ReferentialChanged', 'Référentiel NIS2 actif : ') + (this.value === 'recyf2025' ? 'France (ReCyF)' : 'Belgique (CyFun)'), 'info');
          // Re-render la section Réglages pour refléter le statut actif/inactif des deux cartes
          // NIS2 et afficher le bon réglage (niveau CyFun ou degré ReCyF).
          if (typeof renderSettingsContent === 'function') renderSettingsContent();
        });
        refWrap.appendChild(refSelect);
        var refHint = document.createElement('div');
        refHint.style.cssText = 'margin-top:6px;font-size:12px;color:#888;';
        refHint.textContent = t('settings.nis2ReferentialHint', 'Détermine les onglets NIS2 affichés (CyFun Belgique ou ReCyF France).');
        refWrap.appendChild(refHint);
        if (_isRefLocked) {
          var refLockNote = document.createElement('div');
          refLockNote.style.cssText = 'margin-top:6px;font-size:12px;color:#888;font-style:italic;';
          refLockNote.textContent = '🔒 ' + t('settings.nis2RefLockedNote', 'Référentiel verrouillé. Pour en changer, réinitialisez l\'application (les données seront effacées).');
          refWrap.appendChild(refLockNote);
        }
        body.appendChild(refWrap);
      }

      container.appendChild(result.card);
    });
  }

  // === SECTION: ADVANCED ===
  function renderAdvancedSection(container) {
    // Tests Card
    const { card: testCard, body: testBody } = createSettingsCard({
      title: t('settings.testsTitle', 'Tests'),
      description: t('settings.testsDesc', 'Vérification rapide des fonctions principales.'),
      icon: "fa-flask-vial"
    });

    const testActions = document.createElement("div");
    testActions.className = "settings-card__actions";
    const btnTests = document.createElement("button");
    btnTests.id = "settingsTests";
    btnTests.className = "btn btn--secondary btn--sm";
    btnTests.innerHTML = '<i class="fas fa-flask"></i> ' + escapeHtml(t('settings.runTests', 'Lancer les tests'));
    btnTests.addEventListener("click", runTestSuite);
    testActions.appendChild(btnTests);
    testBody.appendChild(testActions);
    container.appendChild(testCard);

  }

  // === SECTION: RESET ===
  function renderResetSection(container) {
    // En mode DEMO, afficher une section spécifique pour recharger les données démo
    if (DEMO_MODE) {
      const { card, body } = createSettingsCard({
        title: t('settings.demoResetTitle', 'Réinitialisation des données démo'),
        description: t('settings.demoResetDesc', 'Recharge les données de démonstration originales.'),
        icon: "fa-sync-alt",
        tone: "warning"
      });

      const info = document.createElement("div");
      info.className = "info-box info-box--warning";
      info.innerHTML = `
        <i class="fas fa-info-circle info-box__icon"></i>
        <div class="info-box__text">
          <strong>${escapeHtml(t('settings.demoModeLabel', 'Mode démonstration'))}</strong><br>
          ${escapeHtml(t('settings.demoResetInfo', 'Les données sont automatiquement rechargées toutes les heures. Utilisez ce bouton pour réinitialiser manuellement.'))}
        </div>
      `;
      body.appendChild(info);

      const actions = document.createElement("div");
      actions.className = "settings-card__actions";
      const btnReloadDemo = document.createElement("button");
      btnReloadDemo.id = "settingsReloadDemo";
      btnReloadDemo.className = "btn btn--warning btn--sm";
      btnReloadDemo.innerHTML = '<i class="fas fa-sync-alt"></i> ' + escapeHtml(t('settings.reloadDemoBtn', 'Recharger les données démo'));
      btnReloadDemo.addEventListener("click", () => {
        showConfirmModal(t('common.confirm', 'Confirmer'), t('confirms.loadDemoData', 'Charger les données de démonstration ? Les données actuelles seront écrasées.'), function() {
          loadDemoDataSilent();
          showToast2(t('toasts.demoDataReloaded'), "success");
        }, { type: 'warning' });
      });
      actions.appendChild(btnReloadDemo);
      body.appendChild(actions);

      container.appendChild(card);
      return;
    }

    // Mode normal : réinitialisation complète
    const { card, body } = createSettingsCard({
      title: t('settings.resetTitle', 'Réinitialisation'),
      description: t('settings.resetDesc', "Réinitialise l'application aux paramètres d'origine."),
      icon: "fa-triangle-exclamation",
      tone: "danger"
    });

    const warning = document.createElement("div");
    warning.className = "danger-warning";
    warning.innerHTML = `
      <i class="fas fa-exclamation-triangle danger-warning__icon"></i>
      <div class="danger-warning__text">
        <strong>${escapeHtml(t('settings.resetWarningTitle', 'Attention :'))}</strong> ${escapeHtml(t('settings.resetWarningText', 'Cette action supprimera toutes vos données. Un snapshot de sécurité sera créé automatiquement avant la réinitialisation.'))}
      </div>
    `;
    body.appendChild(warning);

    const actions = document.createElement("div");
    actions.className = "settings-card__actions";
    const btnReset = document.createElement("button");
    btnReset.id = "settingsReset";
    btnReset.className = "btn btn--danger btn--sm";
    btnReset.innerHTML = '<i class="fas fa-trash"></i> ' + escapeHtml(t('settings.resetBtn', "Réinitialiser l'application"));
    btnReset.dataset.readonlyLock = "true";
    btnReset.addEventListener("click", resetApplication);
    actions.appendChild(btnReset);
    body.appendChild(actions);

    container.appendChild(card);
  }

  // ========================================
  // SETTINGS PAGE OPEN FUNCTION
  // ========================================

  function openSettings() {
    closeModal();
    const navItems = document.querySelectorAll(".nav__item");
    const modules = document.querySelectorAll(".module");
    navItems.forEach(nav => nav.classList.remove("nav__item--active"));
    modules.forEach(m => m.classList.remove("module--active"));
    const settings = document.getElementById("settings");
    if (settings) settings.classList.add("module--active");
    renderSettingsPage();
  }
  async function resetApplication() {
    // En mode serveur, la réinitialisation est réservée aux administrateurs
    // (défense en profondeur : l'entrée « Zone Danger » est déjà masquée aux non-admins).
    var AMreset = window.CSI_AuthManager;
    if (isServerModeUnlocked() && !(AMreset && AMreset.isAdmin())) {
      showToast2(t('settings.resetAdminOnly', 'Réinitialisation réservée à un compte administrateur.'), "warning");
      return;
    }
    if (!ensureEditMode("reset the application")) return;
    const runReset = async () => {
      // === CSI-CO === (audit 2026-08-05, BLOQUANT) Sas de grappes AVANT de vider appData :
      // le POST import de fin exige les 4 grappes → 423 déterministe, et l'état VIDE restait
      // en RAM puis partait section par section (autosave, changement d'onglet) = effacement
      // partiel progressif du serveur, alors que le message annonçait l'inverse.
      const _rstGate = await _csiBeginGlobalOperation('settings.resetBtn', "Réinitialisation");
      if (!_rstGate.ok) return;
      try {
        try {
          addHistorySnapshot(t('snapshots.backupBeforeReset', 'Sauvegarde avant réinitialisation'), { tag: "auto" });
        } catch (err) {
          void 0;
        }
        appData.controls = await loadISO27002Controls();
        appData.soa = (appData.controls || []).map(c => ({ controlId: c.id, applicable: true, justification: "" }));
        appData.nis2Plan = getEmptyNis2Plan();
        appData.soclePillars = cloneDefaultSoclePillars();
        appData.actions = [];
        appData.actionsHistory = {};
        appData.criticalAssets = [];
        appData.criticalAssetsMeta = getDefaultCriticalAssetsMeta();
        appData.projectRisks = [];
        appData.risks = [];
        appData.threats = [];
        appData.nonconformities = [];
        appData.audits = [];
        appData.kpis = [];
        appData.reviews = [];
        appData.stakeholders = [];
        appData.documents = [];
        appData.targetMaturity = {
          iso27002: 3.5,
          nis2: Object.assign({}, DEFAULT_NIS2_MATURITY),
          nis2Target: 0
        };
        appData.nextActionId = 0;
        appData.documentReview = [];
        appData.riskAnalyses = [];
        appData.impactMatrices = {};
        appData.riskThresholds = {};
        appData.frameworkMapper = { enabledFrameworks: [], customMappings: [], userNotes: {} };
        appData.kpiHistory = {};
        appData.auditLog = [];
        appData.cyfunLevel = '';
        appData.cyfunLang = '';
        appData._cyfunLevelChosen = false;
        appData.title = '';
        // FIX-RESET-RECYF (audit 2026-06-10) : la réinitialisation oubliait les
        // évaluations ReCyF, le référentiel actif et les settings (logo, nom d'orga).
        appData.recyfControls = [];
        appData.recyfLevel = 'importante';
        appData.nis2Referential = 'cyfun2025';
        appData._nis2ReferentialLocked = false; // Reset = seul chemin pour rouvrir le choix du référentiel
        appData.settings = {};
        appData._cyfunEvalArchive = {}; // FIX-CYFUN-LEVEL-LOSS : purger l'archive aussi
        // GOV-NIS2 S5 (audit 2026-08-20) : régénérer les mesures APRÈS la remise à zéro du
        // niveau — l'ancien ordre les construisait avec l'ANCIEN niveau (218 mesures avec
        // cyfunLevel vidé ensuite) puis archivait 184 coquilles vides au premier choix.
        // reconcileNis2Frameworks réaligne enabledFrameworks sur le référentiel par défaut
        // (l'état « référentiel actif sans framework activé » durait jusqu'au rechargement).
        appData.nis2Controls = loadNis2Controls();
        if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
        const tbody = document.getElementById("controlsTableBody");
        if (tbody) tbody.innerHTML = "";
        saveData();
        // FIX-RESET-SYNC (audit 2026-06-10) : en mode serveur, saveData() ne pousse que
        // l'onglet courant (et ignore dashboard/settings) — la réinitialisation n'était
        // jamais propagée et les anciennes données revenaient du serveur au rechargement.
        // Remplacement complet intentionnel → forceServerSync() sans clés (l'état vient
        // d'être entièrement réinitialisé en mémoire).
        // M9 (audit 2026-07-10) : ATTENDRE le résultat. executeServerSync peut résoudre
        // {success:false} SANS throw (non authentifié, sync en cours, syncEnabled=false) —
        // le .catch fire-and-forget ne le voyait jamais, le toast « terminé » mentait alors
        // que le serveur conservait l'état antérieur (enjeu RGPD/fin de PoC).
        var _serverResetOk = true;
        if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
            window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
          try {
            var _resetPush = await window.CSI_SyncManager.forceServerSync();
            if (_resetPush && _resetPush.success === false) _serverResetOk = false;
          } catch (e) {
            _serverResetOk = false;
            void 0;
          }
        }
        loadControlsTable();
        loadNis2ControlsTable();
        // GOV-NIS2 S5 : la table ReCyF gardait les 152 lignes évaluées d'avant le reset —
        // un clic sur un select fantôme mutait un objet détaché (modification perdue).
        if (typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable();
        loadSoaTable();
        loadActionsTable();
        loadCriticalAssetsTable();
        loadProjectRisksTable();
        loadRisksTable();
        updateRiskMatrix();
        loadThreatsTable();
        loadNonConformitiesTable();
        loadAuditsTable();
        loadReviewsTable();
        loadStakeholdersTable();
        loadDocumentsTable();
        loadKpiTable();
        updateActionsChart();
        updateDashboard();
        // M9 : ne revendiquer le succès que si le serveur a bien été mis à jour.
        if (_serverResetOk) {
          showToast2(t('toasts.resetDone'), "success");
        } else {
          showToast2(t('toasts.resetServerFailed', "Réinitialisation locale effectuée, mais le serveur n'a pas pu être mis à jour. Reprenez la main (mode édition) et réessayez, sinon les anciennes données réapparaîtront au rechargement."), "error", 14000);
          // (audit 2026-08-05) L'ancien message DISAIT vrai puis se trompait : appData restant
          // vide, l'autosave effaçait ensuite le serveur section par section. On revient à
          // l'état serveur pour que l'échec soit réellement sans effet.
          await _csiRecoverFromFailedGlobalPush();
        }
        await _csiReleaseBorrowedClusters(_rstGate.borrowed);
      } catch (error) {
        void 0;
        showToast2(t('toasts.resetError', "Impossible de réinitialiser les données."), "error");
      }
    };
    openModal(
      t('settings.confirmReset', 'Confirmer la réinitialisation'),
      [],
      () => {
        runReset();
      },
      null,
      {
        saveLabel: t('common.yes', 'Oui'),
        cancelLabel: t('common.no', 'Non'),
        hideClose: true,
        message: t('settings.confirmResetMessage', 'Cette action va réinitialiser l\'application à son état initial. Toutes vos données seront définitivement supprimées. Êtes-vous sûr ?')
      }
    );
  }

  /**
   * Charge les données de démonstration silencieusement (pour mode DEMO)
   * @param {boolean} silent - Si true, pas de confirmation ni de vérification edit mode
   */
  function loadDemoDataSilent() {
    try {
      if (!demoData) {
        void 0;
        return false;
      }
      appData = JSON.parse(JSON.stringify(demoData));
      window.appData = appData;
      appData.soclePillars = normalizeSoclePillars(appData.soclePillars);
      ensureNis2Plan();
      ensureCriticalAssets();
      ensureProjectRisks();
      normalizeCriticalAssetLinks(appData.actions);
      normalizeCriticalAssetLinks(appData.risks);
      normalizeCriticalAssetLinks(appData.threats);
      normalizeCriticalAssetLinks(appData.nonconformities);
      appData.targetMaturity = {
        iso27002: 4,
        nis2: buildNis2Maturity(demoNis2Maturity),
        nis2Target: 4
      };
      if (!appData.actionsHistory) appData.actionsHistory = {};
      NIS2_CATEGORIES.forEach(cat => {
        if (typeof appData.targetMaturity.nis2[cat] !== 'number') {
          appData.targetMaturity.nis2[cat] = 0;
        }
      });
      const defaultNis2 = loadNis2Controls();
      if (Array.isArray(appData.nis2Controls)) {
        appData.nis2Controls = defaultNis2.map(d => {
          const found = appData.nis2Controls.find(c => c.id === d.id) || {};
          return { ...d, ...found };
        });
      } else {
        appData.nis2Controls = defaultNis2;
      }
      saveData();
      void 0;
      return true;
    } catch (e) {
      void 0;
      return false;
    }
  }
  window.loadDemoDataSilent = loadDemoDataSilent;

  function loadDemoData(options = {}) {
    if (!demoData) {
      void 0;
      showToast2(t('adminUi.demoDataUnavailable', 'Données de démonstration non disponibles'), 'warning');
      return;
    }
    const allowReadOnly = options.allowReadOnly === true;
    if (!allowReadOnly && !ensureEditMode("load demo data")) return;
    showConfirmModal(t('common.confirm', 'Confirmer'), t('confirms.loadDemoData', 'Charger les données de démonstration ? Les données actuelles seront écrasées.'), function() {
    try {
      try {
        addHistorySnapshot(t('snapshots.backupBeforeDemo', 'Sauvegarde avant données de démonstration'), { tag: "auto" });
      } catch (err) {
        void 0;
      }
      appData = JSON.parse(JSON.stringify(demoData));
      window.appData = appData;
      appData.soclePillars = normalizeSoclePillars(appData.soclePillars);
      ensureNis2Plan();
      ensureCriticalAssets();
      ensureProjectRisks();
      normalizeCriticalAssetLinks(appData.actions);
      normalizeCriticalAssetLinks(appData.risks);
      normalizeCriticalAssetLinks(appData.threats);
      normalizeCriticalAssetLinks(appData.nonconformities);
      appData.targetMaturity = {
        iso27002: 4,
        nis2: buildNis2Maturity(demoNis2Maturity),
        nis2Target: 4
      };
      if (!appData.actionsHistory) appData.actionsHistory = {};
      if (!appData.targetMaturity) {
        appData.targetMaturity = { iso27002: 3, nis2: Object.assign({}, DEFAULT_NIS2_MATURITY), nis2Target: 3 };
      } else if (!appData.targetMaturity.nis2) {
        appData.targetMaturity.nis2 = Object.assign({}, DEFAULT_NIS2_MATURITY);
      } else {
        NIS2_CATEGORIES.forEach(cat => {
          if (typeof appData.targetMaturity.nis2[cat] !== 'number') {
            appData.targetMaturity.nis2[cat] = 0;
          }
        });
      }
      if (typeof appData.targetMaturity.nis2Target !== 'number') {
        const v = parseInt(appData.targetMaturity.nis2Target, 10);
        appData.targetMaturity.nis2Target = isNaN(v) ? 3 : v;
      }
      const defaultNis2 = loadNis2Controls();
      if (Array.isArray(appData.nis2Controls)) {
        appData.nis2Controls = defaultNis2.map(d => {
          const found = appData.nis2Controls.find(c => c.id === d.id) || {};
          return { ...d, ...found };
        });
      } else {
        appData.nis2Controls = defaultNis2;
      }
      appData.nextActionId = Array.isArray(appData.actions) ? appData.actions.length : 0;
      saveData();
      loadControlsTable();
      loadNis2ControlsTable();
      loadSoaTable();
      loadActionsTable();
      loadCriticalAssetsTable();
      loadProjectRisksTable();
      loadRisksTable();
      updateRiskMatrix();
      loadThreatsTable();
      loadNonConformitiesTable();
      loadAuditsTable();
      loadReviewsTable();
      loadStakeholdersTable();
      loadDocumentsTable();
      loadKpiTable();
      updateActionsChart();
      updateDashboard();
      showToast2(t('toasts.demoDataLoaded'), "success");
      // Re-queue password setup (may have been skipped while confirm dialog was visible)
      queueEditPasswordSetup();
    } catch (error) {
      void 0;
      showToast2(t('toasts.demoDataLoadError', "Impossible de charger les données."), "error");
    }
    }, { type: 'info' });
  }
  window.exportData = exportData;
  window.importData = importData;
  window.runTestSuite = runTestSuite;
  window.showToast = showToast2;
  window.showToast2 = showToast2;
  window.hideLoadingSpinner = hideLoadingSpinner2;
  window.openSettings = openSettings;
  window.resetApplication = resetApplication;
  window.loadDemoData = loadDemoData;
  window.updateAppTitle = updateAppTitle;
  window.escapeHtml = escapeHtml;
  window.fetchWithTimeout = fetchWithTimeout;
  window.saveData = saveData;

  // iso27002-data.js

var iso27002_data_default = [
  {
    "numero": "5.1",
    "titre": "Politiques de sécurité de l'information",
    "mesure de sécurité": "Il convient de définir une politique de sécurité de l'information ainsi que des politiques spécifiques par thématique, de les faire approuver par la direction, de les publier, de les communiquer aux personnels et parties intéressées concernés en attestant de leur prise de connaissance, et de les examiner à intervalles planifiés ainsi qu'en cas de changements significatifs.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.2",
    "titre": "Fonctions et responsabilités liées à la sécurité de l'information",
    "mesure de sécurité": "Il convient de définir et d'attribuer les rôles et responsabilités en matière de sécurité de l'information en fonction des besoins de l'organisation.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.3",
    "titre": "Séparation des tâches",
    "mesure de sécurité": "Il convient de séparer les tâches et les domaines de responsabilité incompatibles.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.4",
    "titre": "Responsabilités de la direction",
    "mesure de sécurité": "Il convient que la direction exige de l'ensemble du personnel l'application de la sécurité de l'information conformément à la politique de sécurité de l'information établie, aux politiques thématiques spécifiques et aux procédures de l'organisation.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.5",
    "titre": "Contacts avec les autorités",
    "mesure de sécurité": "Il convient d'établir et de maintenir des contacts avec les autorités appropriées (p. ex. autorités réglementaires ou forces de l'ordre) en matière de sécurité de l'information, afin de pouvoir agir efficacement en cas d'incident de sécurité de l'information.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.6",
    "titre": "Contacts avec des groupes d’intérêt spécifiques",
    "mesure de sécurité": "Il convient que l'organisation établisse et maintienne des contacts avec des groupes d’intérêt spécifiques, forums spécialisés ou associations professionnelles pertinents en sécurité de l'information, afin de se tenir informée des bonnes pratiques et des menaces émergentes.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.7",
    "titre": "Renseignements sur les menaces",
    "mesure de sécurité": "Il convient de collecter et d'analyser les renseignements relatifs aux menaces pour la sécurité de l'information, afin de produire des renseignements exploitables sur ces menaces (intelligence des menaces).",
    "capability": [
      "Gouvernance",
      "Gestion_des_menaces_et_des_vulnérabilités"
    ]
  },
  {
    "numero": "5.8",
    "titre": "Sécurité de l'information dans la gestion de projet",
    "mesure de sécurité": "Il convient d'intégrer la sécurité de l'information dans la gestion de projet, afin de s'assurer que les risques de sécurité liés aux projets et aux livrables sont pris en compte dès le début et tout au long du projet.",
    "capability": [
      "Gouvernance"
    ]
  },
  {
    "numero": "5.9",
    "titre": "Inventaire des informations et autres actifs associés",
    "mesure de sécurité": "Il convient d'établir et de tenir à jour un inventaire des informations et des autres actifs associés, à un niveau de granularité approprié, afin de savoir en permanence quels actifs doivent être protégés.",
    "capability": [
      "Gestion_des_actifs"
    ]
  },
  {
    "numero": "5.10",
    "titre": "Utilisation correcte des informations et autres actifs associés",
    "mesure de sécurité": "Il convient d'identifier, de documenter et de mettre en œuvre des règles d'utilisation acceptable des informations et autres actifs associés, et de s'assurer que ces règles sont communiquées aux utilisateurs concernés.",
    "capability": [
      "Gestion_des_actifs"
    ]
  },
  {
    "numero": "5.11",
    "titre": "Restitution des actifs",
    "mesure de sécurité": "Il convient de s'assurer que tous les actifs de l'organisation en possession du personnel sont restitués lors de la cessation ou du changement d'emploi de ce personnel, conformément aux conditions établies.",
    "capability": [
      "Gestion_des_actifs"
    ]
  },
  {
    "numero": "5.12",
    "titre": "Classification des informations",
    "mesure de sécurité": "Il convient de classifier les informations en fonction de leur valeur, de leur sensibilité et de leur criticité, conformément aux besoins métier de l'organisation en matière de sécurité de l'information.",
    "capability": [
      "Gestion_des_actifs"
    ]
  },
  {
    "numero": "5.13",
    "titre": "Marquage des informations",
    "mesure de sécurité": "Il convient de marquer les informations conformément à leur classification, au moyen de procédés d'étiquetage appropriés, de sorte que leur niveau de sensibilité soit clairement indiqué.",
    "capability": [
      "Gestion_des_actifs"
    ]
  },
  {
    "numero": "5.14",
    "titre": "Transfert des informations",
    "mesure de sécurité": "Il convient de protéger le transfert des informations par des politiques, des procédures et des mesures de sécurité appropriées, afin de prévenir tout accès, altération ou perte non autorisés pendant le transfert.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "5.15",
    "titre": "Contrôle d'accès",
    "mesure de sécurité": "Il convient de contrôler l'accès aux informations et aux autres actifs associés, de façon à ce que seules les entités autorisées (utilisateurs, processus, dispositifs) puissent y accéder, conformément aux exigences de sécurité de l'organisation.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "5.16",
    "titre": "Gestion des identités",
    "mesure de sécurité": "Il convient de gérer les identités des utilisateurs et autres entités tout au long de leur cycle de vie (création, gestion, suppression), de manière à permettre une authentification et un contrôle d'accès appropriés.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "5.17",
    "titre": "Informations d'authentification",
    "mesure de sécurité": "Il convient de gérer et de protéger de manière appropriée les informations d'authentification (ex. mots de passe, clés, dispositifs d'authentification) pendant tout leur cycle de vie, afin d'empêcher tout accès non autorisé.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "5.18",
    "titre": "Droits d'accès",
    "mesure de sécurité": "Il convient de gérer l'octroi, la modification et la révocation des droits d'accès aux informations et autres actifs associés, conformément aux politiques de sécurité et au principe du moindre privilège, et de tenir à jour les enregistrements correspondants.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "5.19",
    "titre": "Sécurité de l'information dans les relations avec les fournisseurs",
    "mesure de sécurité": "Il convient de définir et de mettre en œuvre des exigences de sécurité de l'information pour les relations avec les fournisseurs, afin d'assurer la protection des informations échangées ou auxquelles les fournisseurs ont accès.",
    "capability": [
      "Sécurité_des_relations_fournisseurs"
    ]
  },
  {
    "numero": "5.20",
    "titre": "Sécurité de l'information dans les accords avec les fournisseurs",
    "mesure de sécurité": "Il convient d'inclure des exigences de sécurité de l'information dans les accords conclus avec les fournisseurs, de façon à formaliser les obligations de chaque partie en matière de protection des informations.",
    "capability": [
      "Sécurité_des_relations_fournisseurs"
    ]
  },
  {
    "numero": "5.21",
    "titre": "Gestion de la sécurité de l'information dans la chaîne d'approvisionnement TIC",
    "mesure de sécurité": "Il convient de mettre en place des processus pour gérer la sécurité de l'information dans la chaîne d'approvisionnement des technologies de l'information et de la communication (TIC), en abordant les risques de sécurité liés aux fournisseurs de produits et services TIC.",
    "capability": [
      "Sécurité_des_relations_fournisseurs"
    ]
  },
  {
    "numero": "5.22",
    "titre": "Surveillance, révision et gestion des changements des services fournisseurs",
    "mesure de sécurité": "Il convient de surveiller et de réviser régulièrement les services fournis par les fournisseurs, et de gérer les changements apportés à ces services, afin de s'assurer que les contrôles de sécurité convenus restent efficaces et que les exigences de sécurité de l'information continuent d'être respectées.",
    "capability": [
      "Sécurité_des_relations_fournisseurs"
    ]
  },
  {
    "numero": "5.23",
    "titre": "Sécurité de l'information dans l'utilisation de services en nuage",
    "mesure de sécurité": "Il convient d'établir des processus pour l'acquisition, l'utilisation, la gestion et la cessation des services en nuage, conformément aux exigences de sécurité de l'information de l'organisation.",
    "capability": [
      "Sécurité_des_relations_fournisseurs"
    ]
  },
  {
    "numero": "5.24",
    "titre": "Planification et préparation de la gestion des incidents de sécurité de l'information",
    "mesure de sécurité": "Il convient de planifier et de préparer la gestion des incidents de sécurité de l'information en élaborant des procédures et des plans d'intervention, de façon à pouvoir réagir efficacement aux incidents et limiter leurs impacts.",
    "capability": [
      "Réglementation_et_conformité"
    ]
  },
  {
    "numero": "5.25",
    "titre": "Évaluation des événements de sécurité de l'information et prise de décision",
    "mesure de sécurité": "Il convient d'évaluer les événements de sécurité de l'information et de décider s'ils doivent être considérés comme des incidents de sécurité de l'information, afin de déclencher, le cas échéant, les procédures de réponse appropriées.",
    "capability": [
      "Gestion_des_événements_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.26",
    "titre": "Réponse aux incidents de sécurité de l'information",
    "mesure de sécurité": "Il convient de répondre aux incidents de sécurité de l'information en appliquant les procédures de gestion des incidents définies, afin de maîtriser l'incident, d'en minimiser l'impact et d'en informer les parties concernées dans les meilleurs délais.",
    "capability": [
      "Gestion_des_événements_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.27",
    "titre": "Tirer des enseignements des incidents de sécurité de l'information",
    "mesure de sécurité": "Il convient de tirer des enseignements de chaque incident de sécurité de l'information en analysant ses causes et son traitement, puis de mettre en œuvre les améliorations nécessaires des contrôles de sécurité pour éviter qu'il ne se reproduise.",
    "capability": [
      "Gestion_des_événements_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.28",
    "titre": "Collecte des preuves",
    "mesure de sécurité": "Il convient d'établir des procédures pour la collecte, la conservation, l'analyse et la présentation des preuves relatives aux incidents de sécurité de l'information, de façon à pouvoir soutenir d'éventuelles actions juridiques ou enquêtes ultérieures.",
    "capability": [
      "Gestion_des_événements_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.29",
    "titre": "Sécurité de l'information pendant une perturbation",
    "mesure de sécurité": "Il convient de s'assurer que la sécurité de l'information est maintenue pendant une perturbation des activités de l'organisation, en prévoyant des mesures de continuité appropriées pour protéger les informations et actifs critiques durant cette perturbation.",
    "capability": [
      "Continuité"
    ]
  },
  {
    "numero": "5.30",
    "titre": "Préparation des TIC pour la continuité d'activité",
    "mesure de sécurité": "Il convient de planifier, de mettre en œuvre, de maintenir et de tester la préparation des technologies de l'information et de la communication (TIC) en vue de la continuité d'activité, sur la base des objectifs de continuité d'activité et des exigences de continuité des TIC.",
    "capability": [
      "Continuité"
    ]
  },
  {
    "numero": "5.31",
    "titre": "Exigences légales, statutaires, réglementaires et contractuelles",
    "mesure de sécurité": "Il convient d'identifier et de documenter les exigences légales, statutaires, réglementaires et contractuelles applicables à la sécurité de l'information, ainsi que la démarche adoptée par l'organisation pour satisfaire à ces exigences.",
    "capability": [
      "Réglementation_et_conformité"
    ]
  },
  {
    "numero": "5.32",
    "titre": "Droits de propriété intellectuelle",
    "mesure de sécurité": "Il convient de mettre en œuvre des procédures appropriées pour garantir le respect des droits de propriété intellectuelle et des licences, en particulier lors de l'utilisation de logiciels et d'informations protégés par le droit d'auteur ou d'autres droits.",
    "capability": [
      "Réglementation_et_conformité"
    ]
  },
  {
    "numero": "5.33",
    "titre": "Protection des enregistrements",
    "mesure de sécurité": "Il convient de protéger les enregistrements (données archivées, journaux, documents officiels, etc.) contre la perte, la destruction, la falsification, l'accès non autorisé et la divulgation non autorisée, pendant toute leur durée de conservation.",
    "capability": [
      "Réglementation_et_conformité"
    ]
  },
  {
    "numero": "5.34",
    "titre": "Protection de la vie privée et des DCP",
    "mesure de sécurité": "Il convient d'assurer la confidentialité et la protection des données à caractère personnel (DCP) conformément aux lois et réglementations en vigueur, notamment en appliquant les principes de privacy by design et en mettant en œuvre des mesures techniques et organisationnelles appropriées.",
    "capability": [
      "Réglementation_et_conformité"
    ]
  },
  {
    "numero": "5.35",
    "titre": "Révision indépendante de la sécurité de l'information",
    "mesure de sécurité": "Il convient de faire procéder à des révisions indépendantes de la sécurité de l'information à intervalles planifiés, ou à la suite de changements significatifs, afin d'évaluer l'efficacité et la conformité du système de management de la sécurité de l'information de l'organisation.",
    "capability": [
      "Gouvernance",
      "Assurance_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.36",
    "titre": "Conformité aux politiques, règles et normes de sécurité de l'information",
    "mesure de sécurité": "Il convient de s'assurer de la conformité aux politiques, règles et normes de sécurité de l'information de l'organisation, en réalisant des vérifications régulières et des audits internes, et en prenant des mesures correctives en cas de non-conformité.",
    "capability": [
      "Gouvernance",
      "Assurance_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "5.37",
    "titre": "Procédures d'exploitation documentées",
    "mesure de sécurité": "Il convient de documenter les procédures d'exploitation et de les tenir à jour. Ces procédures d'exploitation documentées doivent être examinées régulièrement et mises à jour lorsque nécessaire. Les modifications apportées aux procédures doivent être autorisées et, lorsque cela est techniquement possible, les systèmes d'information doivent être gérés de manière cohérente en utilisant les mêmes procédures, outils et utilitaires.",
    "capability": [
      "Configuration_sécurisée"
    ]
  },
  {
    "numero": "6.1",
    "titre": "Sélection des candidats",
    "mesure de sécurité": "Il convient d'effectuer des vérifications d'antécédents pour tous les candidats avant leur entrée en fonction, et de façon périodique par la suite, en tenant compte des lois, réglementations et principes éthiques applicables, et en proportionnant le niveau de vérification aux exigences métier, à la sensibilité des informations qui seront accessibles et aux risques perçus.",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.2",
    "titre": "Termes et conditions du contrat de travail",
    "mesure de sécurité": "Il convient que le contrat de travail définisse les responsabilités de l'employé et de l'organisation en matière de sécurité de l'information, notamment l'obligation de respecter les politiques et procédures de sécurité de l'information de l'organisation.",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.3",
    "titre": "Sensibilisation, enseignement et formation en sécurité de l'information",
    "mesure de sécurité": "Il convient de mettre en œuvre des programmes de sensibilisation, d'enseignement et de formation à la sécurité de l'information pour tout le personnel de l'organisation, avec des mises à jour régulières, de sorte que chacun dispose des connaissances et compétences nécessaires adaptées à son rôle pour appliquer correctement les mesures de sécurité.",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.4",
    "titre": "Processus disciplinaire",
    "mesure de sécurité": "Il convient de définir un processus disciplinaire permettant de prendre des sanctions appropriées en cas de manquement d'un employé aux politiques ou aux mesures de sécurité de l'information.",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.5",
    "titre": "Responsabilités après la fin ou le changement d’un emploi",
    "mesure de sécurité": "Il convient de définir et de communiquer à la personne concernée les responsabilités en matière de sécurité de l'information qui continuent de s'appliquer après la fin ou le changement de son emploi (par exemple, la confidentialité à respecter, la restitution des actifs, etc.).",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.6",
    "titre": "Accords de confidentialité ou de non-divulgation",
    "mesure de sécurité": "Il convient d'identifier les besoins de l'organisation en matière de confidentialité et de non-divulgation, et de mettre en place des accords de confidentialité ou de non-divulgation conformes à ces besoins. Ces accords doivent être revus régulièrement afin de s'assurer qu'ils demeurent adaptés aux exigences de sécurité de l'information.",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.7",
    "titre": "Travail à distance",
    "mesure de sécurité": "Il convient de mettre en œuvre des mesures de sécurité pour le travail à distance, afin de protéger les informations consultées, traitées ou stockées en dehors des locaux de l'organisation (par exemple, utilisation de VPN, chiffrement des communications et des dispositifs, politiques de sécurité pour les postes de travail à distance, etc.).",
    "capability": [
      "Sécurité_des_ressources_humaines"
    ]
  },
  {
    "numero": "6.8",
    "titre": "Déclaration des événements de sécurité de l'information",
    "mesure de sécurité": "Il convient d'exiger de tous les employés et contractuels qu'ils déclarent rapidement, par les canaux appropriés, tout événement de sécurité de l'information observé ou suspecté, de manière à permettre une évaluation et une réponse efficaces à ces événements.",
    "capability": [
      "Sécurité_des_ressources_humaines",
      "Gestion_des_événements_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "7.1",
    "titre": "Périmètres de sécurité physique",
    "mesure de sécurité": "Il convient de définir des périmètres de sécurité physique autour des locaux et des zones critiques contenant des informations sensibles ou des actifs importants, et de mettre en place des protections appropriées à ces périmètres (p. ex. clôtures, murs, contrôles d'accès aux entrées) pour empêcher toute intrusion non autorisée.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.2",
    "titre": "Les entrées physiques",
    "mesure de sécurité": "Il convient de contrôler les points d'entrée physiques des zones sécurisées au moyen de dispositifs de sécurité appropriés, de manière à ce que seules les personnes autorisées puissent pénétrer dans ces zones.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.3",
    "titre": "Sécurisation des bureaux, des salles et des installations",
    "mesure de sécurité": "Il convient de mettre en œuvre des mesures de sécurité physique pour protéger les bureaux, les salles et les installations de l'organisation contenant des informations ou des actifs sensibles, par exemple en verrouillant les locaux, en utilisant des systèmes d'alarme et en limitant l'accès aux seules personnes autorisées.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.4",
    "titre": "Surveillance de la sécurité physique",
    "mesure de sécurité": "Il convient de surveiller la sécurité physique des locaux de l'organisation (par exemple, par des systèmes de vidéosurveillance CCTV, des dispositifs de détection d'intrusion ou des rondes de sécurité) afin de détecter et dissuader toute tentative d'accès physique non autorisé ou tout incident de sécurité physique.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.5",
    "titre": "Protection contre les menaces physiques et environnementales",
    "mesure de sécurité": "Il convient de mettre en place des mesures de protection contre les menaces physiques et environnementales – telles que les catastrophes naturelles, les incidents environnementaux (incendie, inondation) ou les actes malveillants – afin de réduire les risques pour les actifs de l'organisation. Par exemple, installer des détecteurs de fumée et des extincteurs, des systèmes anti-inondation, un contrôle de la température et de l'humidité, etc.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.6",
    "titre": "Travail dans les zones sécurisées",
    "mesure de sécurité": "Il convient d'établir des procédures pour le travail dans les zones sécurisées, de façon à ce que seules les personnes autorisées y travaillent et qu'elles respectent des protocoles de sécurité spécifiques (par exemple, surveillance accrue, enregistrement des entrées/sorties, interdiction d'introduire des dispositifs non autorisés).",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.7",
    "titre": "Bureau vide et écran vide",
    "mesure de sécurité": "Il convient d'adopter une politique de bureau vide et d'écran vide, afin de s'assurer qu'aucune information sensible n'est laissée sans protection sur les bureaux ou affichée sur les écrans en l'absence des utilisateurs. Concrètement, les documents papier et supports amovibles doivent être rangés en lieu sûr lorsque non utilisés, et les sessions utilisateur verrouillées ou terminées dès qu'un poste de travail est inoccupé.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.8",
    "titre": "Emplacement et protection du matériel",
    "mesure de sécurité": "Il convient d'installer le matériel informatique et les autres équipements dans des emplacements appropriés et de les protéger pour réduire les risques liés à des accès non autorisés ou à des menaces environnementales. Par exemple, placer les serveurs dans des salles dédiées avec contrôle d'accès et conditions environnementales maîtrisées, afin de préserver la sécurité et le bon fonctionnement des équipements.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.9",
    "titre": "Sécurité des actifs hors des locaux",
    "mesure de sécurité": "Il convient d'assurer la sécurité des actifs (informations et équipements) lorsqu'ils sont utilisés ou stockés en dehors des locaux de l'organisation. Des mesures appropriées doivent être appliquées, telles que le chiffrement des données sur les ordinateurs portables, l'utilisation de dispositifs sécurisés pour le transport de documents, et des politiques encadrant le stockage d'informations à l'extérieur.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.10",
    "titre": "Supports de stockage",
    "mesure de sécurité": "Il convient de gérer les supports de stockage (tels que disques durs, clés USB, CD/DVD, bandes magnétiques) tout au long de leur cycle de vie de manière sécurisée. Cela inclut de contrôler leur accès, de les stocker dans des conditions sûres, de les chiffrer si nécessaire, et de procéder à leur élimination ou réutilisation de manière sécurisée (par exemple par destruction physique ou effacement sécurisé des données).",
    "capability": [
      "Gestion_des_actifs",
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.11",
    "titre": "Services supports",
    "mesure de sécurité": "Il convient de protéger les services supports essentiels (tels que l'alimentation électrique, le réseau électrique, le système de climatisation, l'alimentation en eau et autres utilités) qui permettent le fonctionnement des systèmes d'information. Des solutions de secours ou de redondance doivent être prévues (comme des onduleurs, générateurs, doubles alimentations, etc.) afin d'assurer la continuité des opérations en cas de défaillance de ces services.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.12",
    "titre": "Sécurité du câblage",
    "mesure de sécurité": "Il convient de protéger le câblage des réseaux et télécommunications contre les écoutes, les interférences ou les détériorations. Par exemple, utiliser des conduits ou chemins de câbles sécurisés, réduire les émissions électromagnétiques ou mettre en place des mécanismes de détection de coupure ou d'interception, afin d'empêcher qu'un tiers ne puisse compromettre la confidentialité ou l'intégrité des données en manipulant les câbles.",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.13",
    "titre": "Maintenance du matériel",
    "mesure de sécurité": "Il convient d'assurer une maintenance régulière et adéquate du matériel informatique, conformément aux recommandations du fabricant ou aux besoins de sécurité. Cette maintenance doit être réalisée par du personnel autorisé, et tout équipement de remplacement temporaire doit offrir un niveau de sécurité équivalent. L'objectif est de garantir que les équipements fonctionnent correctement et ne compromettent pas la sécurité de l'information (par exemple à cause de pannes non corrigées).",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "7.14",
    "titre": "Élimination ou recyclage sécurisé(e) du matériel",
    "mesure de sécurité": "Il convient d'éliminer ou de recycler le matériel de manière sécurisée lorsqu'il arrive en fin de vie ou qu'il n'est plus utilisé. Cela implique de s'assurer que toutes les informations et logiciels sensibles qu'il contient sont définitivement supprimés ou rendus inaccessibles avant sa mise au rebut, son recyclage ou sa réutilisation (par exemple en effectuant un effacement sécurisé des données ou en détruisant physiquement le support de stockage).",
    "capability": [
      "Sécurité_physique"
    ]
  },
  {
    "numero": "8.1",
    "titre": "Terminaux finaux des utilisateurs",
    "mesure de sécurité": "Il convient de mettre en œuvre des mesures de sécurité pour protéger les terminaux finaux des utilisateurs (postes de travail, ordinateurs portables, smartphones, tablettes, etc.) et les informations auxquelles ils ont accès. Par exemple, installer des logiciels de sécurité (antivirus, pare-feu personnel), appliquer des correctifs à jour, chiffrer les données sensibles stockées sur ces appareils, et contrôler les accès aux ports et interfaces.",
    "capability": [
      "Sécurité_système_et_réseau"
    ]
  },
  {
    "numero": "8.2",
    "titre": "Droits d'accès privilégiés",
    "mesure de sécurité": "Il convient de restreindre et de gérer l'attribution et l'utilisation des droits d'accès privilégiés (administrateurs systèmes, comptes avec privilèges élevés). Des processus formels doivent encadrer l'octroi de ces droits, et un suivi régulier doit être effectué pour vérifier leur utilisation appropriée et retirer les privilèges non nécessaires.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.3",
    "titre": "Restrictions d'accès aux informations",
    "mesure de sécurité": "Il convient de restreindre l'accès aux informations et aux fonctions des systèmes en fonction des besoins et des autorisations de chaque utilisateur, conformément à la politique de contrôle d'accès de l'organisation. Cela signifie que chaque utilisateur ou entité ne doit pouvoir accéder qu'aux informations et fonctionnalités qui lui sont explicitement autorisées.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.4",
    "titre": "Accès aux codes source",
    "mesure de sécurité": "Il convient de restreindre et de contrôler strictement l'accès aux codes source des logiciels, de manière à ce que seuls les développeurs et personnels autorisés puissent y accéder. Des mécanismes de gestion du code source (dépôts sécurisés, contrôle de versions avec gestion des droits) doivent être en place pour prévenir toute modification non autorisée ou fuite de propriété intellectuelle.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "8.5",
    "titre": "Authentification sécurisée",
    "mesure de sécurité": "Il convient de mettre en place des mécanismes d'authentification sécurisés pour contrôler l'accès aux systèmes et aux informations. Par exemple, utiliser des méthodes robustes telles que l'authentification multi-facteur (MFA) lorsque cela est approprié, gérer les informations d'authentification (mots de passe, certificats, tokens) de façon sécurisée, et s'assurer que les procédures d'authentification répondent aux exigences de sécurité de l'organisation.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "8.6",
    "titre": "Dimensionnement",
    "mesure de sécurité": "Il convient de surveiller et de gérer la capacité et les performances des ressources informatiques (processeurs, mémoire, stockage, bande passante réseau, etc.) afin de répondre aux besoins de l'organisation. Des ajustements proactifs doivent être réalisés pour anticiper les évolutions de charge et éviter toute saturation ou dégradation des performances qui pourrait affecter la disponibilité des services.",
    "capability": [
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "8.7",
    "titre": "Protection contre les programmes malveillants (malware)",
    "mesure de sécurité": "Il convient de déployer des contrôles de sécurité pour prévenir et détecter les programmes malveillants (malwares) et pour s'en remettre. Cela inclut l'installation de logiciels anti-malware à jour, l'analyse régulière des systèmes, la sensibilisation des utilisateurs aux risques liés aux logiciels malveillants (phishing, pièces jointes suspectes, etc.), et la mise en place de mesures de confinement et de récupération en cas d'infection (par exemple, plans de reprise).",
    "capability": [
      "Sécurité_système_et_réseau"
    ]
  },
  {
    "numero": "8.8",
    "titre": "Gestion des vulnérabilités techniques",
    "mesure de sécurité": "Il convient de gérer les vulnérabilités techniques des systèmes d'information en se tenant informé des vulnérabilités connues affectant les matériels, logiciels et services utilisés, et en appliquant en temps utile des mesures correctives (comme l'installation de correctifs de sécurité ou l'application de contournements) pour atténuer les risques associés.",
    "capability": [
      "Configuration_sécurisée",
      "Gestion_des_menaces_et_des_vulnérabilités"
    ]
  },
  {
    "numero": "8.9",
    "titre": "Gestion des configurations",
    "mesure de sécurité": "Il convient de définir, de documenter, de contrôler et de tenir à jour la configuration de tous les composants critiques des systèmes d'information (équipements, logiciels, services). Un processus de gestion des configurations doit s'assurer que seules des configurations approuvées sont utilisées, que tout changement est évalué, approuvé et suivi, et que des configurations sécurisées par défaut sont appliquées pour réduire les vulnérabilités.",
    "capability": [
      "Configuration_sécurisée"
    ]
  },
  {
    "numero": "8.10",
    "titre": "Suppression des informations",
    "mesure de sécurité": "Il convient de supprimer des systèmes d'information les données et informations qui ne sont plus nécessaires, de manière sécurisée et irréversible, sauf si leur conservation est requise par la loi ou par les politiques internes. Des procédures d'effacement sécurisé ou de destruction doivent être utilisées en fonction de la sensibilité des informations, afin d'empêcher toute restitution ou reconstruction ultérieure des données supprimées.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.11",
    "titre": "Masquage des données",
    "mesure de sécurité": "Il convient d'utiliser le masquage des données (techniques d'anonymisation ou de pseudonymisation) pour limiter l'exposition des données sensibles ou personnelles lorsque cela est pertinent, notamment dans les environnements de test, de développement ou lors de partages d'informations. Le masquage réduit le risque de divulgation non autorisée en remplaçant les données réelles par des données fictives mais cohérentes.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.12",
    "titre": "Prévention de la fuite de données",
    "mesure de sécurité": "Il convient de mettre en place des mesures de prévention des fuites de données (Data Loss Prevention, DLP) sur les systèmes et les réseaux de l'organisation, afin de détecter et prévenir la divulgation non autorisée d'informations sensibles. Par exemple, déployer des outils DLP qui contrôlent les transferts de données (courriels, téléchargements, copies sur supports amovibles) et bloquent ou alertent en cas de tentative d'exfiltration de données confidentielles.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.13",
    "titre": "Sauvegarde des informations",
    "mesure de sécurité": "Il convient de maintenir des copies de sauvegarde des informations, des logiciels et des systèmes, et de tester régulièrement ces sauvegardes, conformément à la politique de sauvegarde convenue.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.14",
    "titre": "Redondance des moyens de traitement de l'information",
    "mesure de sécurité": "Il convient de prévoir des moyens redondants pour les installations de traitement de l'information critiques, afin d'assurer la continuité des services en cas de défaillance d'un composant. Par exemple, mettre en place des serveurs redondants, des liaisons réseau doubles ou des centres de données de secours, de sorte qu'une panne matérielle ou logicielle n'affecte pas la disponibilité globale des systèmes.",
    "capability": [
      "Sécurité_système_et_réseau",
      "Continuité"
    ]
  },
  {
    "numero": "8.15",
    "titre": "Journalisation",
    "mesure de sécurité": "Il convient d'activer la journalisation des événements de sécurité et des activités significatives sur les systèmes d'information, et de protéger les journaux ainsi produits contre toute altération ou accès non autorisé. De plus, ces journaux doivent être examinés régulièrement pour détecter des événements inhabituels ou des signes d'incidents de sécurité nécessitant une enquête.",
    "capability": [
      "Assurance_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "8.16",
    "titre": "Activités de surveillance",
    "mesure de sécurité": "Il convient de mettre en place des activités de surveillance continues (monitoring) des réseaux, systèmes et applications de l'organisation, afin de détecter en temps opportun les comportements anormaux, les tentatives d'accès non autorisé ou autres événements de sécurité. Ceci peut inclure l'utilisation de systèmes de détection d'intrusion (IDS/IPS), de systèmes de gestion des informations et des événements de sécurité (SIEM) et d'outils d'analyse des journaux.",
    "capability": [
      "Assurance_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "8.17",
    "titre": "Synchronisation des horloges",
    "mesure de sécurité": "Il convient de synchroniser les horloges de tous les systèmes informatiques pertinents de l'organisation sur une source de temps commune de référence, afin que les horodatages des événements consignés dans les journaux soient cohérents et précis. Cette synchronisation facilite la corrélation des événements de sécurité à travers différents systèmes et peut être réalisée, par exemple, via le protocole NTP.",
    "capability": [
      "Assurance_de_sécurité_de_l_information"
    ]
  },
  {
    "numero": "8.18",
    "titre": "Utilisation de programmes utilitaires à privilèges",
    "mesure de sécurité": "Il convient de restreindre et de contrôler l'utilisation des programmes utilitaires à privilèges (par exemple, outils d'administration système capables de contourner les contrôles de sécurité). Ces programmes ne doivent être accessibles qu'aux personnes autorisées et pour des besoins justifiés, et leur utilisation doit être surveillée et enregistrée afin d'éviter tout abus ou usage malveillant.",
    "capability": [
      "Configuration_sécurisée"
    ]
  },
  {
    "numero": "8.19",
    "titre": "Installation de logiciels sur des systèmes opérationnels",
    "mesure de sécurité": "Il convient de contrôler l'installation de logiciels sur les systèmes opérationnels par le biais de procédures formelles. Toute installation de logiciel doit être autorisée, testée et approuvée préalablement afin de garantir qu'elle n'introduit pas de vulnérabilités ou de perturbations, et de vérifier qu'elle respecte les politiques de l'organisation (notamment en matière de licences et de sécurité).",
    "capability": [
      "Configuration_sécurisée"
    ]
  },
  {
    "numero": "8.20",
    "titre": "Sécurité des réseaux",
    "mesure de sécurité": "Il convient de gérer et de protéger les réseaux de l'organisation afin de préserver la confidentialité, l'intégrité et la disponibilité des informations qui y transitent. Des mesures de sécurité appropriées doivent être mises en œuvre, telles que la segmentation du réseau, l'utilisation de pare-feux, le chiffrement des communications sensibles et la surveillance du trafic réseau pour détecter d'éventuelles activités malveillantes.",
    "capability": [
      "Sécurité_système_et_réseau"
    ]
  },
  {
    "numero": "8.21",
    "titre": "Sécurité des services réseau",
    "mesure de sécurité": "Il convient de définir les exigences de sécurité, les niveaux de service attendus et les responsabilités de gestion pour chaque service réseau utilisé par l'organisation (par exemple, service de télécommunication, hébergement internet, services cloud). Ces éléments doivent être formalisés dans les contrats ou accords de service correspondants, que les services réseau soient fournis en interne ou externalisés, afin d'assurer un niveau de sécurité conforme aux besoins de l'organisation.",
    "capability": [
      "Sécurité_système_et_réseau",
      "Gestion_des_identités_et_des_accès"
    ]
  },
  {
    "numero": "8.22",
    "titre": "Cloisonnement des réseaux",
    "mesure de sécurité": "Il convient de cloisonner les réseaux de l'organisation en zones ou segments, selon les besoins de sécurité, afin d'isoler les systèmes et informations sensibles. Ce cloisonnement (via des VLAN, des sous-réseaux séparés, des pare-feux entre zones, etc.) vise à limiter la propagation des incidents et à appliquer des règles de sécurité spécifiques par zone en fonction du niveau de confiance ou de criticité des actifs qui y résident.",
    "capability": [
      "Sécurité_système_et_réseau"
    ]
  },
  {
    "numero": "8.23",
    "titre": "Filtrage web",
    "mesure de sécurité": "Il convient de mettre en place un mécanisme de filtrage web pour contrôler et restreindre l'accès des utilisateurs aux sites web, en accord avec la politique de l'organisation. Par exemple, cela peut inclure le blocage des sites malveillants ou inappropriés, la catégorisation des sites autorisés/interdits, et la surveillance des activités web pour prévenir les menaces en ligne (comme l'accès à des sites de phishing ou de malware).",
    "capability": [
      "Sécurité_système_et_réseau"
    ]
  },
  {
    "numero": "8.24",
    "titre": "Utilisation de la cryptographie",
    "mesure de sécurité": "Il convient d'utiliser des techniques cryptographiques appropriées pour protéger la confidentialité, l'intégrité et, le cas échéant, l'authenticité des informations. Une politique d'utilisation de la cryptographie doit être définie, couvrant des aspects tels que les algorithmes autorisés, la gestion des clés cryptographiques, et les cas d'utilisation (chiffrement des données stockées, chiffrement des communications, signatures numériques, etc.).",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.25",
    "titre": "Cycle de vie de développement sécurisé",
    "mesure de sécurité": "Il convient d'intégrer des pratiques de sécurité tout au long du cycle de vie de développement des systèmes d'information. Cela implique d'établir des règles et contrôles de sécurité lors des phases de spécification, conception, développement, test, déploiement et maintenance, afin de s'assurer que les logiciels et systèmes sont conçus et développés en respectant les principes de sécurité (par exemple, en effectuant des revues de code, des tests de sécurité applicative, en suivant des guides de codage sécurisé, etc.).",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.26",
    "titre": "Exigences de sécurité des applications",
    "mesure de sécurité": "Il convient d'identifier les exigences de sécurité pertinentes pour les nouvelles applications ou évolutions d'applications dès les premières étapes de leur acquisition ou développement, et de s'assurer que ces exigences sont prises en compte dans les spécifications et mises en œuvre dans les solutions finales.",
    "capability": [
      "Sécurité_système_et_réseau",
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.27",
    "titre": "Principes d'ingénierie et d'architecture des systèmes sécurisés",
    "mesure de sécurité": "Il convient de définir, documenter et appliquer des principes d'architecture et d'ingénierie pour la conception de systèmes sécurisés. Ces principes (par exemple, le principe du moindre privilège, la défense en profondeur, la simplicité, la modularité, la résilience) doivent guider le développement ou l'acquisition de nouvelles technologies afin de s'assurer que la sécurité est intégrée de manière cohérente et efficace dans l'architecture des systèmes d'information.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.28",
    "titre": "Codage sécurisé",
    "mesure de sécurité": "Il convient d'appliquer des pratiques de codage sécurisé lors du développement de logiciels, de façon à réduire les vulnérabilités dans le code. Cela peut inclure le respect de normes de codage, l'utilisation d'outils d'analyse de code statique pour détecter les failles courantes, la sensibilisation des développeurs aux erreurs de programmation susceptibles d'affecter la sécurité, et la réalisation de revues de code focalisées sur la sécurité.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.29",
    "titre": "Tests de sécurité dans le développement et l'acceptation",
    "mesure de sécurité": "Il convient d'effectuer des tests de sécurité lors des phases de développement et d'acceptation des nouveaux systèmes ou des modifications système. Ces tests (tels que des tests d'intrusion, des analyses de vulnérabilités, des tests de charge pour vérifier la résilience) doivent permettre de vérifier que les contrôles de sécurité fonctionnent comme prévu et que le système répond aux critères de sécurité définis avant sa mise en production.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.30",
    "titre": "Développement externalisé",
    "mesure de sécurité": "Il convient de prendre en compte les exigences de sécurité lorsque le développement de logiciels ou de systèmes est externalisé. L'organisation doit s'assurer que les prestataires respectent des critères de sécurité équivalents (via des clauses contractuelles, des contrôles réguliers, des droits d'audit) et doit suivre l'avancement du projet pour vérifier que les mesures de sécurité convenues sont bien appliquées pendant le développement externalisé.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.31",
    "titre": "Séparation des environnements de développement, de test et opérationnels",
    "mesure de sécurité": "Il convient de séparer les environnements de développement, de test et d'exploitation (production) afin de réduire les risques de modifications non autorisées ou d'accès inappropriés aux données de production. Chaque environnement doit être isolé avec des jeux de données distincts (les tests ne devant pas se faire sur des données de production réelles sans précautions), et les droits d'accès des personnels doivent être limités à l'environnement dans lequel ils opèrent.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.32",
    "titre": "Gestion des changements",
    "mesure de sécurité": "Il convient de contrôler et d'approuver formellement tous les changements apportés aux systèmes d'information, aux logiciels ou aux services, au moyen d'un processus de gestion des changements. Ce processus doit inclure l'évaluation préalable des impacts de sécurité des changements, l'autorisation par les instances appropriées, le test et la documentation des modifications, et un suivi pour s'assurer que les changements sont correctement implémentés et qu'ils n'ont pas d'effets indésirables sur la sécurité.",
    "capability": [
      "Sécurité_des_applications"
    ]
  },
  {
    "numero": "8.33",
    "titre": "Informations de test",
    "mesure de sécurité": "Il convient de sélectionner soigneusement les informations utilisées pour les tests (jeux de données de test) et de les protéger au même titre que des données réelles si elles sont sensibles. Idéalement, des données fictives ou anonymisées devraient être utilisées. Toute information de test doit être gérée et effacée une fois les tests terminés, afin de prévenir toute fuite ou utilisation abusive.",
    "capability": [
      "Protection_des_informations"
    ]
  },
  {
    "numero": "8.34",
    "titre": "Protection des systèmes d'information pendant les tests d'audit",
    "mesure de sécurité": "Il convient de contrôler l'accès aux systèmes d'information lors de la réalisation de tests d'audit (internes ou externes) afin de s'assurer que ces tests n'altèrent pas la sécurité ou la disponibilité des systèmes et des données. Par exemple, les auditeurs ne devraient accéder qu'aux informations et ressources nécessaires à leur audit, et leurs activités de test devraient être surveillées. Des mesures doivent être en place pour restaurer l'état normal du système après les tests d'audit et corriger toute faiblesse identifiée.",
    "capability": [
      "Sécurité_des_applications"
    ]
  }
];

// ---- CyFun 2025 / NIS2 framework helpers ----
// The old nis2_controls_data (23 category-level controls) is replaced by the CyFun 2025 framework
// loaded from data/frameworks/cyfun2025.js. Legacy nis2_controls_data kept as empty for compat.
var nis2_controls_data = []; // legacy stub - data now comes from _csiFrameworks['cyfun2025']

// CATEGORY_LABELS is now built dynamically from the CyFun framework
// FEAT-CYFUN-NL (2026-07-10) : résolution trilingue du contenu CyFun (textes officiels CCB).
// Exigences : base EN (title/description) + suffixes Fr/Nl. Libellés fonctions/catégories :
// base FR (label) + labelEn/labelNl. Cascade : langue demandée -> EN -> base.
function cyfunContentLang() {
  return (typeof appData !== 'undefined' && appData.cyfunLang) || 'en';
}
function cyfunReqText(req, base) {
  if (!req) return '';
  var lang = cyfunContentLang();
  if (lang === 'fr' && req[base + 'Fr']) return req[base + 'Fr'];
  if (lang === 'nl' && req[base + 'Nl']) return req[base + 'Nl'];
  return req[base] || '';
}
function cyfunLabelText(obj) {
  if (!obj) return '';
  var lang = cyfunContentLang();
  if (lang === 'fr') return obj.label;
  if (lang === 'nl' && obj.labelNl) return obj.labelNl;
  return obj.labelEn || obj.label;
}

var CATEGORY_LABELS = {};
function rebuildCategoryLabels() {
  CATEGORY_LABELS = {};
  var fw = (window._csiFrameworks || {})['cyfun2025'];
  if (!fw || !fw.meta || !fw.meta.categories) return;
  fw.meta.categories.forEach(function(cat) {
    var lbl = cyfunLabelText(cat);
    CATEGORY_LABELS[cat.id] = lbl + ' (' + cat.id + ')';
  });
}
// Build labels once framework is available (called during init)

// Get the CyFun function label for a functionId
function getCyfunFunctionLabel(functionId) {
  var fn = (window.CYFUN_FUNCTIONS || []).find(function(f) { return f.id === functionId; });
  return fn ? cyfunLabelText(fn) + ' (' + fn.id + ')' : functionId;
}

// Get the CyFun category label for a categoryId
function getCyfunCategoryLabel(categoryId) {
  return CATEGORY_LABELS[categoryId] || categoryId;
}

// FEAT-CYFUN-NL (2026-07-10) : rafraîchit les textes snapshotés dans nis2Controls depuis
// le référentiel (langue CyFun courante). Idempotent, ne touche jamais aux évaluations.
// Nécessaire pour que les installations EXISTANTES reçoivent les textes officiels CCB
// (FR révisé, NL) sans re-choisir le niveau — appelé à chaque rendu du tableau NIS2.
function refreshNis2ControlTexts() {
  if ((appData.nis2Referential || 'cyfun2025') !== 'cyfun2025') return 0;
  var fw = (window._csiFrameworks || {})['cyfun2025'];
  if (!fw || !fw.requirements || !Array.isArray(appData.nis2Controls)) return 0;
  var byRef = {};
  fw.requirements.forEach(function(r) { byRef[r.ref] = r; });
  var changed = 0;
  appData.nis2Controls.forEach(function(c) {
    var req = c && c.ref ? byRef[c.ref] : null;
    if (!req) return;
    var funcObj = (fw.meta.functions || []).find(function(f) { return f.id === req.functionId; });
    var catObj = (fw.meta.categories || []).find(function(k) { return k.id === req.categoryId; });
    var fresh = {
      title: cyfunReqText(req, 'title'),
      titleEn: req.title || '',
      description: cyfunReqText(req, 'description'),
      descriptionEn: req.description || '',
      functionLabel: funcObj ? (cyfunLabelText(funcObj) + ' (' + funcObj.id + ')') : req.functionId,
      categoryLabel: catObj ? (cyfunLabelText(catObj) + ' (' + catObj.id + ')') : req.categoryId
    };
    fresh.fonction = fresh.functionLabel;
    fresh.categorie = fresh.categoryLabel;
    Object.keys(fresh).forEach(function(k) {
      if (c[k] !== fresh[k]) { c[k] = fresh[k]; changed++; }
    });
  });
  if (changed) rebuildCategoryLabels();
  return changed;
}

// CyFun assurance level management
function setCyfunAssuranceLevel(level) {
  if (!level || !['basic', 'important', 'essential'].includes(level)) level = 'basic';
  appData.cyfunLevel = level;
  var fw = (window._csiFrameworks || {})['cyfun2025'];
  if (!fw || !fw.requirements) return;

  // Rebuild categories for this level
  if (typeof window.rebuildCyfunCategories === 'function') {
    window.rebuildCyfunCategories(level);
  }
  rebuildCategoryLabels();

  // Filter requirements by cumulative level
  var levelOrder = { basic: 1, important: 2, essential: 3 };
  var maxOrder = levelOrder[level];
  var requirements = fw.requirements.filter(function(r) {
    return (levelOrder[r.level] || 99) <= maxOrder;
  });

  // Preserve existing scores by ref
  var _FM = window.CSI_FrameworkMapper;
  var existingMap = {};
  var _existingControls = _FM ? _FM.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
  _existingControls.forEach(function(c) {
    if (c.ref) existingMap[c.ref] = c;
  });

  // FIX-CYFUN-LEVEL-LOSS (audit 2026-06-10) : un passage à un niveau inférieur supprimait
  // définitivement les évaluations des exigences important/essential (aller-retour
  // essential→basic→essential = ~99/133 évaluations perdues). Les évaluations des
  // exigences hors du niveau choisi sont désormais archivées par ref et restaurées
  // automatiquement quand le niveau les réintègre.
  if (!appData._cyfunEvalArchive || typeof appData._cyfunEvalArchive !== 'object' || Array.isArray(appData._cyfunEvalArchive)) {
    appData._cyfunEvalArchive = {};
  }
  var _archive = appData._cyfunEvalArchive;

  // Rebuild nis2Controls from filtered requirements
  var _newNis2Controls = requirements.map(function(req, index) {
    var existing = existingMap[req.ref] || _archive[req.ref] || {};
    var funcObj = (fw.meta.functions || []).find(function(f) { return f.id === req.functionId; });
    var catObj = (fw.meta.categories || []).find(function(c) { return c.id === req.categoryId; });
    // Trilingue : textes snapshotés dans la langue CyFun courante (helpers FEAT-CYFUN-NL)
    var displayTitle = cyfunReqText(req, 'title');
    var displayDesc = cyfunReqText(req, 'description');
    var funcLabel = funcObj ? (cyfunLabelText(funcObj) + ' (' + funcObj.id + ')') : req.functionId;
    var catLabel = catObj ? (cyfunLabelText(catObj) + ' (' + catObj.id + ')') : req.categoryId;
    var rebuilt = {
      // FIX-CYFUN-DUP-ID (audit 2026-06-10) : les nouveaux ids étaient dérivés de l'index
      // de la liste filtrée ('CYFUN-001'...) et entraient en collision avec les ids
      // préservés après une montée de niveau (26 paires dupliquées sur basic→important
      // → modale et sauvegarde sur la MAUVAISE exigence). Les nouveaux ids sont
      // désormais dérivés de la ref, unique et stable ('CYFUN-GV.OC-01.1').
      id: existing.id || 'CYFUN-' + req.ref,
      ref: req.ref,
      title: displayTitle,
      titleEn: req.title || '',
      description: displayDesc,
      descriptionEn: req.description || '',
      functionId: req.functionId,
      functionLabel: funcLabel,
      categoryId: req.categoryId,
      categoryLabel: catLabel,
      subcategoryRef: req.subcategoryRef || '',
      level: req.level,
      isKeyMeasure: req.isKeyMeasure || false,
      isManagementLinked: req.isManagementLinked || false,
      docMaturity: existing.docMaturity || 0,
      implMaturity: existing.implMaturity || 0,
      justification: existing.justification || '',
      evidence: existing.evidence || '',
      owner: existing.owner || '',
      reviewer: existing.reviewer || '',
      reviewFrequency: existing.reviewFrequency || '',
      lastEvaluation: existing.lastEvaluation || '',
      lastEvaluatedBy: existing.lastEvaluatedBy || '',
      nextReviewDate: existing.nextReviewDate || '',
      // Legacy compat fields (computed)
      fonction: funcLabel,
      categorie: catLabel,
      status: existing.status || 'non_implemente',
      cmm: existing.cmm || 'Initial'
    };
    // FIX-CYFUN-LEVEL-LOSS : préserver les champs non structurels existants
    // (comments, lastEvaluation, ...) qui étaient effacés à chaque changement de niveau.
    Object.keys(existing).forEach(function(k) {
      if (!(k in rebuilt)) rebuilt[k] = existing[k];
    });
    return rebuilt;
  });

  // FIX-CYFUN-LEVEL-LOSS : archiver les évaluations des exigences qui sortent du
  // périmètre du niveau choisi, et retirer de l'archive celles qui viennent d'être
  // restaurées dans la liste active.
  var _keptRefs = {};
  requirements.forEach(function(r) { _keptRefs[r.ref] = true; });
  _existingControls.forEach(function(c) {
    if (c.ref && !_keptRefs[c.ref]) _archive[c.ref] = c;
  });
  requirements.forEach(function(r) {
    if (!existingMap[r.ref] && _archive[r.ref]) delete _archive[r.ref];
  });
  // Store via FrameworkMapper if available, otherwise direct assignment
  if (window.CSI_FrameworkMapper) {
    CSI_FrameworkMapper.setAppDataControls('cyfun2025', _newNis2Controls);
  } else {
    appData.nis2Controls = _newNis2Controls;
  }

  // Rebuild DEFAULT_NIS2_MATURITY
  window.DEFAULT_NIS2_MATURITY = {};
  CYFUN_CATEGORIES.forEach(function(catId) {
    window.DEFAULT_NIS2_MATURITY[catId] = 0;
  });

  // Update targetMaturity.nis2 to match new categories
  if (appData.targetMaturity) {
    var oldNis2 = appData.targetMaturity.nis2 || {};
    appData.targetMaturity.nis2 = {};
    CYFUN_CATEGORIES.forEach(function(catId) {
      appData.targetMaturity.nis2[catId] = typeof oldNis2[catId] === 'number' ? oldNis2[catId] : 0;
    });
  }

  // Recalculate maturity averages from controls
  recalcNis2Maturity();
  saveData();
}

// Recalculate maturity averages from nis2Controls
function recalcNis2Maturity() {
  if (!appData.targetMaturity) appData.targetMaturity = {};
  var sums = {};
  var counts = {};
  CYFUN_CATEGORIES.forEach(function(c) { sums[c] = 0; counts[c] = 0; });
  var _fmNis2 = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
  // GOV-NIS2 S3 (audit 2026-08-20, MAJEUR M2) : convention coverage-aware au niveau MESURE —
  // une mesure non évaluée compte 0 dans la moyenne de sa catégorie (méthode CCB), comme le
  // fait déjà le verdict (computeCyfunScoring, M4). L'ancien dénominateur « évaluées
  // seulement » affichait 100 % au dashboard avec 22 mesures évaluées sur 218, pendant que
  // le verdict du même jeu de données disait 0,5/5 NON CONFORME.
  _fmNis2.forEach(function(c) {
    var catId = c.categoryId || '';
    if (!sums.hasOwnProperty(catId)) return;
    var doc = c.docMaturity || 0;
    var impl = c.implMaturity || 0;
    sums[catId] += (doc + impl) / 2;
    counts[catId]++;
  });
  var averages = {};
  CYFUN_CATEGORIES.forEach(function(c) {
    averages[c] = counts[c] > 0 ? +(sums[c] / counts[c]).toFixed(2) : 0;
  });
  appData.targetMaturity.nis2 = averages;
}

// CyFun scoring engine
function computeCyfunScoring() {
  var level = appData.cyfunLevel || 'basic';
  var controls = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
  var fw = (window._csiFrameworks || {})['cyfun2025'];
  if (!fw) return null;
  var levelConfig = (fw.meta.assuranceLevels || []).find(function(l) { return l.id === level; });
  if (!levelConfig) return null;

  var totalDoc = 0, totalImpl = 0, scoredCount = 0, totalCount = controls.length;
  var keyDoc = 0, keyImpl = 0, keyCount = 0, keyScoredCount = 0;
  // GOV-NIS2 : mesures clés dont la moyenne (doc+impl)/2 reste sous le seuil
  // key-measures du niveau — les non évaluées comptent 0 (méthode CCB), donc dedans.
  var keyBelowThreshold = 0;
  var _keyThreshold = (levelConfig.thresholds || {}).keyMeasures || 0;
  var categoryScores = {};

  controls.forEach(function(c) {
    var doc = c.docMaturity || 0;
    var impl = c.implMaturity || 0;
    if (doc > 0 || impl > 0) {
      totalDoc += doc;
      totalImpl += impl;
      scoredCount++;
    }
    if (c.isKeyMeasure) {
      keyCount++;
      if (((doc + impl) / 2) < _keyThreshold) keyBelowThreshold++;
      if (doc > 0 || impl > 0) {
        keyDoc += doc;
        keyImpl += impl;
        keyScoredCount++;
      }
    }
    var catId = c.categoryId;
    if (!categoryScores[catId]) categoryScores[catId] = { docSum: 0, implSum: 0, count: 0, scored: 0 };
    categoryScores[catId].count++;
    if (doc > 0 || impl > 0) {
      categoryScores[catId].docSum += doc;
      categoryScores[catId].implSum += impl;
      categoryScores[catId].scored++;
    }
  });

  // M4 (audit 2026-07-10) : verdict de conformité « coverage-aware » conforme à la méthode
  // CCB (une mesure non évaluée compte pour 0). Le dénominateur est le nombre TOTAL de
  // mesures (et de mesures clés), pas seulement les évaluées — sinon une seule mesure à 5/5
  // suffirait à franchir le seuil (même travers que le KPI dashboard corrigé en M3). La
  // moyenne par catégorie utilise déjà cs.count → l'ensemble du moteur est cohérent.
  var avgDoc = totalCount > 0 ? +(totalDoc / totalCount).toFixed(2) : 0;
  var avgImpl = totalCount > 0 ? +(totalImpl / totalCount).toFixed(2) : 0;
  var avgTotal = totalCount > 0 ? +((totalDoc + totalImpl) / (2 * totalCount)).toFixed(2) : 0;
  var avgKeyDoc = keyCount > 0 ? +(keyDoc / keyCount).toFixed(2) : 0;
  var avgKeyImpl = keyCount > 0 ? +(keyImpl / keyCount).toFixed(2) : 0;
  var avgKeyTotal = keyCount > 0 ? +((keyDoc + keyImpl) / (2 * keyCount)).toFixed(2) : 0;
  // GOV-NIS2 S3 (audit 2026-08-20, MAJEUR M1) : les seuils CCB se comparent aux moyennes
  // BRUTES, jamais aux valeurs arrondies à l'affichage — toFixed(2) rendait le verdict
  // CONFORME dès 2,9962 (seuil 3, IMPORTANT) ou 3,4977 (seuil 3,5, ESSENTIAL).
  var rawAvgTotal = totalCount > 0 ? (totalDoc + totalImpl) / (2 * totalCount) : 0;
  var rawKeyTotal = keyCount > 0 ? (keyDoc + keyImpl) / (2 * keyCount) : 0;

  var thresholds = levelConfig.thresholds || {};
  var passes = {
    keyMeasures: rawKeyTotal >= (thresholds.keyMeasures || 0),
    total: rawAvgTotal >= (thresholds.total || 0),
    categories: true
  };

  // Per-category scores and category minimum check (essential only)
  var catDetails = {};
  Object.keys(categoryScores).forEach(function(catId) {
    var cs = categoryScores[catId];
    // GOV-NIS2 S3 (M1) : comparaison au seuil sur la moyenne BRUTE, affichage arrondi.
    var rawCatAvg = cs.count > 0 ? (cs.docSum + cs.implSum) / (2 * cs.count) : 0;
    var catAvg = +rawCatAvg.toFixed(2);
    catDetails[catId] = { avg: catAvg, doc: cs.count > 0 ? +(cs.docSum / cs.count).toFixed(2) : 0, impl: cs.count > 0 ? +(cs.implSum / cs.count).toFixed(2) : 0, count: cs.count, scored: cs.scored };
    if (thresholds.categoryMin && rawCatAvg < thresholds.categoryMin && cs.count > 0) {
      passes.categories = false;
    }
  });

  // Detect unevaluated categories for UI warnings
  var unevaluatedCategories = [];
  Object.keys(categoryScores).forEach(function(catId) {
    if (categoryScores[catId].count > 0 && categoryScores[catId].scored === 0) {
      unevaluatedCategories.push(catId);
    }
  });

  var overallPass = passes.keyMeasures && passes.total && passes.categories;

  return {
    level: level,
    levelLabel: levelConfig.label,
    totalRequirements: totalCount,
    scoredRequirements: scoredCount,
    avgDoc: avgDoc,
    avgImpl: avgImpl,
    avgTotal: avgTotal,
    keyMeasureCount: keyCount,
    keyScoredCount: keyScoredCount,
    keyBelowThreshold: keyBelowThreshold,
    avgKeyDoc: avgKeyDoc,
    avgKeyImpl: avgKeyImpl,
    avgKeyTotal: avgKeyTotal,
    categoryScores: catDetails,
    thresholds: thresholds,
    passes: passes,
    overallPass: overallPass,
    unevaluatedCategories: unevaluatedCategories
  };
}

// Detect legacy NIS2 format (old 23-control format)
function detectLegacyNis2Format(controls) {
  if (!Array.isArray(controls) || controls.length === 0) return 'empty';
  var first = controls[0];
  // Old format: has 'fonction' and 'categorie' fields, no 'ref' or 'docMaturity'
  if (first.fonction && first.categorie && !first.ref) return 'legacy_23';
  // New CyFun format
  if (first.ref && typeof first.docMaturity !== 'undefined') return 'cyfun';
  return 'unknown';
}

// Migrate old 23-control NIS2 data to CyFun format
function migrateLegacyNis2Controls(oldControls) {
  // Map old NIST CSF 1.1 category codes to new NIST CSF 2.0 codes
  var migrationMap = {
    'ID.AM': 'ID.AM', 'ID.BE': 'GV.OC', 'ID.GV': 'GV.PO', 'ID.RA': 'ID.RA',
    'ID.RM': 'GV.RM', 'ID.SC': 'GV.SC', 'PR.AC': 'PR.AA', 'PR.AT': 'PR.AT',
    'PR.DS': 'PR.DS', 'PR.IP': 'PR.PS', 'PR.MA': 'PR.PS', 'PR.PT': 'PR.IR',
    'DE.AE': 'DE.AE', 'DE.CM': 'DE.CM', 'DE.DP': 'GV.OV',
    'RS.RP': 'RS.MA', 'RS.CO': 'RS.CO', 'RS.AN': 'RS.AN', 'RS.MI': 'RS.MI',
    'RS.IM': 'ID.IM', 'RC.RP': 'RC.RP', 'RC.IM': 'ID.IM', 'RC.CO': 'RC.CO'
  };
  var cmmToVal = { 'Initial': 1, 'R\u00e9p\u00e9table': 2, 'D\u00e9fini': 3, 'G\u00e9r\u00e9': 4, 'Optimis\u00e9': 5 };
  var categoryMaturity = {};
  (oldControls || []).forEach(function(c) {
    var match = (c.categorie || '').match(/\(([^)]+)\)/);
    var oldCat = match ? match[1] : '';
    var newCat = migrationMap[oldCat];
    if (!newCat) return;
    var val = typeof c.cmm === 'number' ? c.cmm : (cmmToVal[c.cmm] || 0);
    if (!categoryMaturity[newCat] || val > categoryMaturity[newCat]) {
      categoryMaturity[newCat] = val;
    }
  });
  return categoryMaturity;
}

// ============================================================
// DEMO DATA - Extracted to demo-data.js (loaded only in WEBSITE/app/)
// In DEMO_MODE, deps are exposed on window for demo-data.js to use.
// ============================================================
var demoData = null;
var demoNis2Maturity = null;

// Setter for external demo-data.js injection
window._csiSetDemoData = function(data, maturity) {
  demoData = data;
  demoNis2Maturity = maturity;
};

// Expose deps for demo-data.js (used by WEBSITE/app/ demo only).
// Always exposed: demo-data.js is never bundled outside of demo packages,
// so this has no effect in Community/Corporate builds.
window._csiDemoDeps = {
  iso27002_data_default: iso27002_data_default,
  normalizeCapabilityKey: normalizeCapabilityKey,
  DOMAIN_BY_PREFIX: DOMAIN_BY_PREFIX,
  loadNis2Controls: loadNis2Controls,
  cloneDefaultNis2Plan: cloneDefaultNis2Plan
};

// Legacy compat: keep demoRiskIds for any remaining references
const demoRiskIds = [
  "risk-demo-1", "risk-demo-2", "risk-demo-3", "risk-demo-4", "risk-demo-5",
  "risk-demo-6", "risk-demo-7", "risk-demo-8", "risk-demo-9", "risk-demo-10"
];

// END DEMO DATA STUB
// (Original ~920 lines of demo data removed - see demo-data.js)

const _demoDataStubEnd = null; // anchor for search - do not remove
  // modules/data.js
  var appData = {
    controls: [],
    actions: [],
    actionsHistory: {},
    criticalAssets: [],
    criticalAssetsMeta: getDefaultCriticalAssetsMeta(),
    projectRisks: [],
    risks: [],
    threats: [],
    nonconformities: [],
    audits: [],
    kpis: [],
    reviews: [],
    stakeholders: [],
    documents: [],
    documentReview: [],
    nis2Controls: [],
    nis2Plan: getEmptyNis2Plan(),
    recyfControls: [],
    recyfLevel: 'importante',
    nis2Referential: 'cyfun2025',
    _nis2ReferentialLocked: false,
    soclePillars: cloneDefaultSoclePillars(),
    soa: [],
    frameworkMapper: { enabledFrameworks: _defaultEnabledFrameworks(), customMappings: [], userNotes: {} },
    nextActionId: 0,
    title: "Cyber-Assistant",
    targetMaturity: {
      iso27002: 3,
      nis2: Object.assign({}, DEFAULT_NIS2_MATURITY),
      nis2Target: 0
    }
  };
  window.appData = appData;
  var charts = {};
  window.charts = charts;
  async function loadData() {
    if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) index recherche périmé au (re)chargement
    var _isServerMode = window.CSI_SyncManager && window.CSI_SyncManager.isServerMode && window.CSI_SyncManager.isServerMode()
        && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn();
    var _serverPullHasData = false; // FIX-SERVER-PULL-AUTHORITY (revue concurrence 2026-06-11)

    // ──────────────────────────────────────────────────────────
    // MODE SERVEUR : charger UNIQUEMENT depuis le serveur (pas de localStorage)
    // ──────────────────────────────────────────────────────────
    if (_isServerMode) {
      // FIX-RETRY: Retry une fois avant de déclarer le serveur inaccessible
      var pulled = null;
      for (var _pullAttempt = 0; _pullAttempt < 2; _pullAttempt++) {
        try {
          pulled = await window.CSI_SyncManager.pullFromServer();
          if (pulled !== null && pulled !== undefined) break;
          if (_pullAttempt === 0) {
            void 0;
            await new Promise(function(r) { setTimeout(r, 1000); });
          }
        } catch(e) {
          void 0;
          pulled = null;
          if (_pullAttempt === 0) await new Promise(function(r) { setTimeout(r, 1000); });
        }
      }
      if (pulled && typeof pulled === 'object' && !Array.isArray(pulled)) {
        // FIX-H2: Accepter les réponses vides comme valides (nouvelle installation)
        appData = pulled;
        window.appData = appData;
        // FIX-SERVER-PULL-AUTHORITY (revue concurrence 2026-06-11) : un pull serveur qui
        // ramène des données métier (≥1 clé) fait AUTORITÉ — même sans `controls`. Sinon le
        // garde `_hasLoadedData` (basé sur controls.length) déclenchait initializeDefaultData()
        // qui ÉCRASAIT les autres données pullées (actions, risques…) d'un utilisateur
        // rejoignant un SI dont le référentiel de contrôles n'est pas (encore) peuplé.
        _serverPullHasData = Object.keys(pulled).length > 0;
      } else if (pulled === null || pulled === undefined) {
        // FIX-SETUP: Après setup du premier admin, null = installation fraîche (pas d'erreur)
        var _justSetup = window.CSI_AuthManager && window.CSI_AuthManager.wasSetup && window.CSI_AuthManager.wasSetup();
        if (_justSetup) {
          void 0;
        } else {
          void 0;
          window._csiServerLoadFailed = true;
        }
      }
    }

    // ──────────────────────────────────────────────────────────
    // MODE LOCAL : charger depuis localStorage (comportement inchangé)
    // ──────────────────────────────────────────────────────────
    if (!_isServerMode) {
      // FIX-CORRUPT-BACKUP (audit 2026-06-10) : un blob smsi_data corrompu était chargé
      // en silence (app vide sans explication) puis écrasé définitivement à la première
      // sauvegarde. Désormais : téléchargement de secours du blob brut + toast erreur
      // persistant, et plus de return prématuré qui sautait la normalisation post-chargement.
      var _rawSaved = null;
      try {
        _rawSaved = localStorage.getItem("smsi_data");
      } catch(e) {
        void 0;
      }
      if (_rawSaved) {
        var _smsiCorrupted = false;
        try {
          var parsed = JSON.parse(_rawSaved);
          if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
            void 0;
            _smsiCorrupted = true;
          } else {
            appData = parsed;
            window.appData = appData;
          }
        } catch(e) {
          void 0;
          _smsiCorrupted = true;
        }
        if (_smsiCorrupted) {
          // Copie de secours du blob d'origine AVANT qu'une sauvegarde puisse l'écraser
          downloadJsonBackup(_rawSaved, 'smsi_backup_corrupted.json');
          setTimeout(function() {
            if (typeof showToast2 === 'function') {
              showToast2(
                t('toasts.dataCorrupted',
                  'Données locales corrompues ou illisibles : une copie de secours (smsi_backup_corrupted.json) a été téléchargée. L\'application a démarré vide — ne sauvegardez pas avant d\'avoir mis cette copie à l\'abri.'),
                'error', 600000);
            }
          }, 500);
        }
      }
    }

    // ──────────────────────────────────────────────────────────
    // POST-LOAD : normalisation des données (commun aux deux modes)
    // ──────────────────────────────────────────────────────────
    try {
        if (typeof window.validateBeforeSave === 'function') {
          try {
            var loadValidation = window.validateBeforeSave(appData);
            if (!loadValidation.valid) {
              void 0;
            }
          } catch(e) { void 0; }
        }
        if (!appData.actionsHistory) appData.actionsHistory = {};
        // FIX-KPIHISTORY: garantir les sous-clés attendues par les sparklines du dashboard
        // (bundle.js:3174-3178). Sans ces arrays, drawSparkline crash sur data.length undefined.
        if (!appData.kpiHistory || typeof appData.kpiHistory !== 'object') appData.kpiHistory = {};
        if (!Array.isArray(appData.kpiHistory.conformity)) appData.kpiHistory.conformity = [];
        if (!Array.isArray(appData.kpiHistory.controls))   appData.kpiHistory.controls   = [];
        if (!Array.isArray(appData.kpiHistory.actions))    appData.kpiHistory.actions    = [];
        if (!Array.isArray(appData.kpiHistory.risks))      appData.kpiHistory.risks      = [];
        if (!Array.isArray(appData.kpiHistory.nc))         appData.kpiHistory.nc         = [];
        if (!appData.reviews) appData.reviews = [];
        if (!appData.stakeholders) appData.stakeholders = [];
        if (!appData.documents) appData.documents = [];
        if (!appData.documentReview) appData.documentReview = createDefaultDocumentReview();
        else appData.documentReview = createDefaultDocumentReview(appData.documentReview);
        if (!appData.projectRisks) appData.projectRisks = [];
        if (!appData.riskAnalyses) appData.riskAnalyses = [];
        if (!appData.riskThresholds) appData.riskThresholds = null;
        if (!appData.auditLog) appData.auditLog = [];
        if (!appData.impactMatrices) appData.impactMatrices = null;
        if (!appData.threats) appData.threats = [];
        var _fwEnabled = (appData.frameworkMapper && appData.frameworkMapper.enabledFrameworks) || _defaultEnabledFrameworks();
        if (_fwEnabled.indexOf('nis2') >= 0 || _fwEnabled.indexOf('cyfun2025') >= 0) {
          // Detect and migrate legacy 23-control format on load
          var _legacyFmt = detectLegacyNis2Format(appData.nis2Controls);
          // GOV-NIS2 S5 : un format inconnu était traité en silence comme du CyFun moderne
          // (maturités absentes → 0). Au moins le dire dans la console d'investigation.
          if (_legacyFmt === 'unknown') void 0;
          if (_legacyFmt === 'legacy_23') {
            void 0;
            var _legacyMaturity = migrateLegacyNis2Controls(appData.nis2Controls);
            appData.cyfunLevel = appData.cyfunLevel || 'basic';
            var _cyfLevel2 = appData.cyfunLevel;
            if (typeof window.rebuildCyfunCategories === 'function') window.rebuildCyfunCategories(_cyfLevel2);
            rebuildCategoryLabels();
            appData.nis2Controls = loadNis2Controls();
            // Apply migrated maturity values
            appData.nis2Controls.forEach(function(c) {
              if (_legacyMaturity[c.categoryId]) {
                c.docMaturity = _legacyMaturity[c.categoryId];
                c.implMaturity = _legacyMaturity[c.categoryId];
                // GOV-NIS2 S3 (M6) : sans dérivation, une mesure migrée 4/4 restait
                // étiquetée « Non implémenté »/« Initial » dans exports et PDF.
                deriveNis2StatusCmm(c);
              }
            });
            if (typeof showToast === 'function') {
              setTimeout(function() { showToast(t('toasts.nis2Migration', 'Migration vers NIS2 terminée. Veuillez vérifier vos scores.'), 'info'); }, 1000);
            }
            // FIX-NIS2-MIGRATION-PERSIST: persister la migration au serveur pour ne pas la
            // redéclencher à chaque chargement. Sans ce push, server-data.json garde les 23
            // items legacy et la détection legacy_23 se relance sans fin → toast récurrent.
            // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
            if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
                window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
              setTimeout(function() {
                // FIX-MIGRATION-LOCK (revue 2026-06-11) : avec EDIT_LOCK_ENFORCED, un push
                // hors mode édition est refusé (423) → la migration (et son toast) se
                // rejouait à chaque chargement. On acquiert brièvement le verrou : si un
                // autre utilisateur édite, on n'insiste pas (lui ou le prochain éditeur
                // persistera la migration).
                var _doMigrationPush = function(releaseAfter) {
                  window.CSI_SyncManager.forceServerSync(['nis2Controls', '_cyfunEvalArchive', 'cyfunLevel', 'cyfunLang', 'targetMaturity']).then(function(r) {
                    if (r && r.success) void 0;
                    else void 0;
                  }).catch(function(e) {
                    void 0;
                  }).then(function() {
                    if (releaseAfter && window.CSI_EditLockManager && window.CSI_EditLockManager.release) {
                      window.CSI_EditLockManager.release().catch(function() {});
                    }
                  });
                };
                // === CSI-CO === (audit co-édition 2026-08-05, MAJEUR) La branche « déjà en
                // édition » poussait SANS acquisition : `forceServerSync(keys)` reste un POST
                // import, soumis aux 4 grappes — or l'édition n'en donne qu'une → 423, et la
                // migration se rejouait à CHAQUE entrée en mode édition (toast récurrent, le
                // symptôme que FIX-MIGRATION-LOCK voulait justement éteindre).
                // `_persistConfigWithLock` acquiert ce qui manque, route par data-push-multi
                // (nis2-controls → coeur, app-config → config) et relâche l'emprunt.
                if (typeof _persistConfigWithLock === 'function') {
                  _persistConfigWithLock(['nis2Controls', '_cyfunEvalArchive', 'cyfunLevel', 'cyfunLang', 'targetMaturity'])
                    .then(function(r) {
                      if (r && r.success === false) void 0;
                      else void 0;
                    })
                    .catch(function(e) { void 0; });
                } else {
                  var ELM = window.CSI_EditLockManager;
                  if (!ELM || !ELM.acquire) _doMigrationPush(false);
                  else {
                    ELM.acquire().then(function(res) {
                      if (res && res.granted) _doMigrationPush(true);
                      else void 0;
                    }).catch(function() {
                      void 0;
                    });
                  }
                }
              }, 2500);
            }
          } else {
            // Always rebuild CyFun categories for the current level
            var _cyfLevel = appData.cyfunLevel || 'basic';
            if (typeof window.rebuildCyfunCategories === 'function') window.rebuildCyfunCategories(_cyfLevel);
            rebuildCategoryLabels();
            if (!Array.isArray(appData.nis2Controls) || appData.nis2Controls.length === 0) appData.nis2Controls = loadNis2Controls();
          }
        } else {
          appData.nis2Controls = appData.nis2Controls || [];
        }
        // ReCyF (France) — degré + référentiel actif ; migre les mesures vers l'ensemble complet
        // (152) en préservant les évaluations par réf. Le degré ne filtre que l'affichage/score,
        // jamais le stockage (aucune perte d'évaluation lors d'un changement de degré).
        if (!appData.recyfLevel) appData.recyfLevel = 'importante';
        if (!appData.nis2Referential) appData.nis2Referential = 'cyfun2025';
        if (typeof window.rebuildRecyfThemes === 'function') window.rebuildRecyfThemes(appData.recyfLevel);
        if (!Array.isArray(appData.recyfControls)) appData.recyfControls = [];
        // Construit/complète les 152 mesures si ReCyF est le référentiel NIS2 actif OU si des
        // évaluations existent déjà (n'alourdit pas les installations 100 % CyFun). Garantit
        // que la table ReCyF a ses données dès le chargement quand la France est sélectionnée.
        if ((appData.recyfControls.length > 0 || appData.nis2Referential === 'recyf2025') && typeof ensureRecyfControls === 'function') ensureRecyfControls();
        if (!appData.soa) appData.soa = [];
        if (typeof appData.nextActionId !== "number") appData.nextActionId = 0;
        if (!appData.title) appData.title = "Cyber-Assistant";
        if (!appData.targetMaturity) {
          appData.targetMaturity = {
            iso27002: 3,
            nis2: Object.assign({}, DEFAULT_NIS2_MATURITY),
            nis2Target: 0
          };
        } else {
          const tm = appData.targetMaturity;
          if (typeof tm.iso27002 === "object") {
            const values = Object.values(tm.iso27002)
              .map(v => parseInt(v, 10))
              .filter(v => !isNaN(v));
            tm.iso27002 = values.length
              ? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
              : 3;
          } else if (typeof tm.iso27002 !== "number") {
            const v = parseInt(tm.iso27002, 10);
            tm.iso27002 = isNaN(v) ? 3 : v;
          }
          tm.nis2 = buildNis2Maturity(tm.nis2 || {});
          const hasNonZeroNis2 = Object.values(tm.nis2 || {}).some(v => typeof v === "number" && v > 0);
          const rawNis2Target = tm.nis2Target;
          const parsedNis2Target = typeof rawNis2Target === "number"
            ? rawNis2Target
            : parseInt(rawNis2Target, 10);
          tm.nis2Target = Number.isFinite(parsedNis2Target) ? parsedNis2Target : (hasNonZeroNis2 ? 3 : 0);
          if (tm.nis2Target < 0) tm.nis2Target = 0;
          if (!tm.iso27002 || tm.iso27002 <= 0) tm.iso27002 = 3;
        }
        // Framework mapper initialization
        if (!appData.frameworkMapper) {
          appData.frameworkMapper = JSON.parse(JSON.stringify(window.FRAMEWORK_MAPPER_DEFAULTS || { enabledFrameworks: _defaultEnabledFrameworks(), customMappings: [], userNotes: {} }));
        }
        // Normalize enabledFrameworks: ensure the controls framework ID matches the loaded edition
        // (fixes stale saved data referencing 'iso27002' in Community or 'open-security-controls' in Corporate)
        (function _normalizeEnabledFws() {
          var ef = appData.frameworkMapper.enabledFrameworks;
          if (!Array.isArray(ef)) return;
          var fws = window._csiFrameworks || {};
          var activeCtrl = _getActiveControlsFrameworkId();
          var activeMgmt = _getActiveManagementFrameworkId();
          // Replace iso27002 <-> open-security-controls depending on what's loaded
          var ctrlPairs = [['iso27002', 'open-security-controls']];
          var mgmtPairs = [['iso27001', 'open-security-management']];
          [ctrlPairs, mgmtPairs].forEach(function(pairs) {
            pairs.forEach(function(pair) {
              var absent = !fws[pair[0]] ? pair[0] : (!fws[pair[1]] ? pair[1] : null);
              var present = absent === pair[0] ? pair[1] : pair[0];
              if (!absent) return; // both loaded (Corporate has all)
              var idx = ef.indexOf(absent);
              if (idx >= 0) {
                // Replace with the loaded variant if not already present
                if (ef.indexOf(present) < 0) {
                  ef[idx] = present;
                } else {
                  ef.splice(idx, 1);
                }
              }
            });
          });
        })();
        // Forcer les frameworks de l'édition (écrase les données sauvegardées)
        var _edForce = window._csiEdition;
        if (_edForce && _edForce.forcedFrameworks) {
          appData.frameworkMapper.enabledFrameworks = _defaultEnabledFrameworks();
        }
        // Modèle NIS2 mutuellement exclusif : aligner enabledFrameworks sur le référentiel NIS2
        // actif dès le chargement. Auto-répare un état sauvegardé/edition obsolète où le
        // référentiel choisi (ex. ReCyF/France) n'apparaissait pas dans la liste activée — cause
        // du symptôme « aucun onglet NIS2 affiché alors que la France est sélectionnée ».
        if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
        if (window.CSI_FrameworkMapper && typeof CSI_FrameworkMapper.onAppDataLoaded === 'function') {
          CSI_FrameworkMapper.onAppDataLoaded();
        }
        if (_fwEnabled.indexOf('nis2') >= 0 || _fwEnabled.indexOf('cyfun2025') >= 0) ensureNis2Plan();
        ensureCriticalAssets();
        ensureProjectRisks();
        appData.soclePillars = normalizeSoclePillars(appData.soclePillars);
        if (Array.isArray(appData.controls)) {
          const mapNumToLevel = { 1: "Initial", 2: "Répétable", 3: "Défini", 4: "Géré", 5: "Optimisé" };
          const legacyCategories = {
            "Organisationnelle": "Organisationnel",
            "Organisationnelles": "Organisationnel",
            "Organisationnels": "Organisationnel",
            "Humaines": "Humain",
            "Humaine": "Humain",
            "Humains": "Humain",
            "Physiques": "Physique",
            "Physique": "Physique",
            "Technologiques": "Technologique",
            "Technologique": "Technologique",
            "Techniques": "Technologique",
            "Technique": "Technologique",
            "Autre": "Non classé",
            "Autres": "Non classé"
          };
          const validCategories = new Set([...Object.values(DOMAIN_BY_PREFIX), "Autre", "Non classé"]);
          const needsSoaMigration = appData.soa.length === 0;
          (appData.controls || []).forEach(c => {
            if (typeof c.cmm === "undefined") {
              c.cmm = "Initial";
            } else if (typeof c.cmm === "number") {
              c.cmm = mapNumToLevel[c.cmm] || "Initial";
            }
            if (needsSoaMigration && (c.selected !== undefined || c.justification !== undefined)) {
              appData.soa.push({
                controlId: c.id,
                applicable: c.selected !== undefined ? c.selected : true,
                justification: c.justification || ""
              });
            }
            delete c.selected;
            delete c.justification;
            if (typeof c.justificationControl === "undefined") c.justificationControl = "";
            delete c.maturity;
            if (typeof c.category === "string") {
              let cat = c.category.trim();
              if (cat === "ISO27002") {
                c.category = "Non classé";
              } else if (legacyCategories[cat]) {
                c.category = legacyCategories[cat];
              } else if (!validCategories.has(cat)) {
                c.category = "Non classé";
              } else {
                c.category = cat;
              }
            } else {
              c.category = "Non classé";
            }
            if (!c.category || c.category === "Non classé") {
              c.category = inferIso27002Category(c);
            }
            if (typeof c.subcategory === "string") {
              c.subcategory = [c.subcategory];
            }
            if (Array.isArray(c.subcategory)) {
              c.subcategory = c.subcategory.map(sc => normalizeCapabilityKey(sc));
            }
            if (!c.subcategory || (Array.isArray(c.subcategory) && c.subcategory.length === 0)) {
              const numero = String(c.numero || "").trim();
              const title = String(c.title || "").trim();
              const match = iso27002_data_default.find(
                item => item.numero === numero || item.titre === title
              );
              if (match) {
                if (Array.isArray(match.capability) && match.capability.length > 0) {
                  c.subcategory = match.capability.map(sc => normalizeCapabilityKey(sc));
                } else if (typeof match.capability === "string" && match.capability) {
                  c.subcategory = [normalizeCapabilityKey(match.capability)];
                }
              }
              if (!c.subcategory || (Array.isArray(c.subcategory) && c.subcategory.length === 0)) {
                c.subcategory = [];
              }
            }
          });
        }
        if (Array.isArray(appData.actions)) {
          appData.actions.forEach(a => {
            // Unification 2026-07-10 : conversion legacy string -> [{text,date}] au lieu
            // d'écraser en [] (perte des commentaires d'actions des très anciens exports).
            a.comments = window.normalizeComments(a.comments);
          });
        }
        if (Array.isArray(appData.risks)) {
          appData.risks.forEach(r => {
            r.comments = window.normalizeComments(r.comments);
            r.score = (r.impact || 1) * (r.probability || 1);
            r.level = calculateRiskLevel(r.score);
          });
        }
        ensureRiskIds();
        if (Array.isArray(appData.reviews)) {
          appData.reviews.forEach(r => {
            r.comments = window.normalizeComments(r.comments);
          });
        }
        if (Array.isArray(appData.audits)) {
          appData.audits.forEach(a => {
            if (!a.type) a.type = "interne";
          });
        }
        if (Array.isArray(appData.nonconformities)) {
          appData.nonconformities.forEach(nc => {
            if (!nc.auditId) nc.auditId = "";
          });
        }
        // loadThreats() removed — normalizeThreats() at line ~9018 handles normalization
        normalizeCriticalAssetLinks(appData.actions);
        normalizeCriticalAssetLinks(appData.risks);
        normalizeCriticalAssetLinks(appData.threats);
        normalizeCriticalAssetLinks(appData.nonconformities);

        // Normalisation complète de toutes les sections pour compatibilité ascendante
        if (typeof normalizeSoa === 'function') appData.soa = normalizeSoa(appData.soa || []);
        if (typeof normalizeActions === 'function') appData.actions = normalizeActions(appData.actions || []);
        if (typeof normalizeRisks === 'function') appData.risks = normalizeRisks(appData.risks || []);
        if (typeof normalizeThreats === 'function') appData.threats = normalizeThreats(appData.threats || []);
        if (typeof normalizeNonconformities === 'function') appData.nonconformities = normalizeNonconformities(appData.nonconformities || []);
        if (typeof normalizeAudits === 'function') appData.audits = normalizeAudits(appData.audits || []);
        if (typeof normalizeKpis === 'function') appData.kpis = normalizeKpis(appData.kpis || []);
        if (typeof normalizeReviews === 'function') appData.reviews = normalizeReviews(appData.reviews || []);
        if (typeof normalizeStakeholders === 'function') appData.stakeholders = normalizeStakeholders(appData.stakeholders || []);
        if (typeof normalizeDocuments === 'function') appData.documents = normalizeDocuments(appData.documents || []);
        if (typeof normalizeControls === 'function') appData.controls = normalizeControls(appData.controls || []);
        // Enrich existing saved controls with localized fields from framework definition
        var _fwDef = _getActiveControlsFramework();
        var _fwCtrls = _fwDef && _fwDef.controls ? _fwDef.controls : [];
        if (_fwCtrls.length > 0) {
          (appData.controls || []).forEach(function(c) {
            var fwCtrl = _fwCtrls.find(function(fc) { return fc.ref === c.numero; });
            if (fwCtrl) {
              if (fwCtrl.title) { c.title = fwCtrl.title; c.reference = c.numero + ' ' + fwCtrl.title; }
              if (fwCtrl.description) { c.description = fwCtrl.description; c.measure = fwCtrl.description; }
              if (!c.titleEn) c.titleEn = fwCtrl.titleEn || '';
              if (!c.descriptionEn) c.descriptionEn = fwCtrl.descriptionEn || '';
            }
          });
        }

      // Vérifier si des données ont été chargées (serveur ou local)
      // FIX-SERVER-PULL-AUTHORITY : un pull serveur non vide fait autorité (voir plus haut).
      var _hasLoadedData = (appData && appData.controls && appData.controls.length > 0) || _serverPullHasData;
      if (!_hasLoadedData) {
        await initializeDefaultData();
      } else if (_serverPullHasData) {
        // SI serveur partiellement initialisé : garantir la FORME des collections essentielles
        // (tableaux/objets vides) SANS écraser ce que le serveur a renvoyé.
        ['controls', 'soa', 'nis2Controls', 'recyfControls', 'actions', 'risks', 'threats',
         'nonconformities', 'audits', 'kpis', 'reviews', 'stakeholders', 'documents',
         'documentReview', 'criticalAssets', 'projectRisks', 'soclePillars'].forEach(function(_k) {
          if (!Array.isArray(appData[_k])) appData[_k] = [];
        });
        if (typeof appData.nextActionId !== 'number') appData.nextActionId = (appData.actions || []).length;
        if (!appData.title) appData.title = 'Cyber-Assistant';
      }
    } catch (error) {
      void 0;
      await initializeDefaultData();
    }
      // PASS2-008: Don't block on validation errors during initial load — save silently
      // En mode serveur, pas de save initial (les données viennent du serveur, pas besoin de les repusher)
      if (!_isServerMode) {
        try { saveData(); } catch(e) { void 0; }
      }
      loadControlsTable();
      loadNis2ControlsTable();
      // updateDashboard() is called later via initDashboard() in the main init sequence
    }
  function ensureNis2Plan() {
    appData.nis2Plan = normalizeNis2Plan(appData.nis2Plan);
    // Normalize legacy budget field: if 'budget' exists but 'annualBudget' does not, copy it
    if (appData.nis2Plan) {
      if (appData.nis2Plan.budget && !appData.nis2Plan.annualBudget) {
        appData.nis2Plan.annualBudget = appData.nis2Plan.budget;
      }
    }
  }
  async function initializeDefaultData() {
    appData.controls = await loadISO27002Controls();
    appData.soa = (appData.controls || []).map(c => ({ controlId: c.id, applicable: true, justification: "" }));
    appData.cyfunLevel = appData.cyfunLevel || 'basic';
    appData.nis2Controls = loadNis2Controls();
    appData.nis2Plan = getEmptyNis2Plan();
    appData.soclePillars = cloneDefaultSoclePillars();
    appData.title = "Cyber-Assistant";
    appData.actions = [];
    appData.criticalAssets = [];
    appData.criticalAssetsMeta = getDefaultCriticalAssetsMeta();
    appData.projectRisks = [];
    appData.risks = [];
    appData.nonconformities = [];
    appData.audits = [];
    appData.kpis = [];
    appData.reviews = [];
      appData.stakeholders = [];
      appData.documents = [];
      appData.documentReview = createDefaultDocumentReview();
      appData.threats = [];
        appData.targetMaturity = {
          iso27002: 3.5,
          nis2: buildNis2Maturity(),
          nis2Target: 0
        };
      appData.nextActionId = 0;
      saveData();
    }
async function loadISO27002Controls() {
  // Look up bilingual data from the framework definition if available
  var fwDef = _getActiveControlsFramework();
  var fwControls = fwDef && fwDef.controls ? fwDef.controls : [];
  return iso27002_data_default.map((item, index) => {
    // Match framework control by ref number for localized fields
    var fwCtrl = fwControls.find(function(c) { return c.ref === item.numero; });
    var displayTitle = (fwCtrl && fwCtrl.title) ? fwCtrl.title : item.titre;
    var displayDesc = (fwCtrl && fwCtrl.description) ? fwCtrl.description : item["mesure de s\xE9curit\xE9"];
    return {
      id: `CTRL-${String(index + 1).padStart(3, "0")}`,
      reference: item.numero ? `${item.numero} ${displayTitle}` : displayTitle,
      numero: item.numero || String(index + 1),
      title: displayTitle,
      titleEn: fwCtrl ? fwCtrl.titleEn || '' : '',
      measure: displayDesc,
      description: displayDesc,
      descriptionEn: fwCtrl ? fwCtrl.descriptionEn || '' : '',
      recommendations: item.recommandations || [],
      category: DOMAIN_BY_PREFIX[item.numero?.split('.')[0]] || "Non classé",
      subcategory: Array.isArray(item.capability)
        ? item.capability.map(c => normalizeCapabilityKey(c))
        : [normalizeCapabilityKey(item.capability)],
      status: "non_implemente",
      cmm: "Initial",
      comments: "",
      evidence: "",
      justificationControl: ""
    };
  });
}

function loadNis2Controls() {
  // Load CyFun 2025 requirements for the current assurance level
  var level = appData.cyfunLevel || 'basic';
  var fw = (window._csiFrameworks || {})['cyfun2025'];
  if (!fw || !fw.requirements) {
    void 0;
    return [];
  }
  // Rebuild categories and labels
  if (typeof window.rebuildCyfunCategories === 'function') {
    window.rebuildCyfunCategories(level);
  }
  rebuildCategoryLabels();

  var levelOrder = { basic: 1, important: 2, essential: 3 };
  var maxOrder = levelOrder[level] || 1;
  var requirements = fw.requirements.filter(function(r) {
    return (levelOrder[r.level] || 99) <= maxOrder;
  });
  return requirements.map(function(req) {
    var funcObj = (fw.meta.functions || []).find(function(f) { return f.id === req.functionId; });
    var catObj = (fw.meta.categories || []).find(function(c) { return c.id === req.categoryId; });
    // Trilingue : textes snapshotés dans la langue CyFun courante (helpers FEAT-CYFUN-NL)
    var displayTitle = cyfunReqText(req, 'title');
    var displayDesc = cyfunReqText(req, 'description');
    var funcLabel = funcObj ? (cyfunLabelText(funcObj) + ' (' + funcObj.id + ')') : req.functionId;
    var catLabel = catObj ? (cyfunLabelText(catObj) + ' (' + catObj.id + ')') : req.categoryId;
    return {
      // GOV-NIS2 S4 (audit 2026-08-20, MAJEUR M8) : id dérivé de la ref — STABLE quel que
      // soit le niveau, aligné sur FIX-CYFUN-DUP-ID (setCyfunAssuranceLevel). Les ids
      // indexés ('CYFUN-001'…) changeaient d'exigence à chaque changement de niveau et
      // croisaient les évaluations au round-trip export/import.
      id: 'CYFUN-' + req.ref,
      ref: req.ref,
      title: displayTitle,
      titleEn: req.title || '',
      description: displayDesc,
      descriptionEn: req.description || '',
      functionId: req.functionId,
      functionLabel: funcLabel,
      categoryId: req.categoryId,
      categoryLabel: catLabel,
      subcategoryRef: req.subcategoryRef || '',
      level: req.level,
      isKeyMeasure: req.isKeyMeasure || false,
      isManagementLinked: req.isManagementLinked || false,
      docMaturity: 0,
      implMaturity: 0,
      justification: '',
      evidence: '',
      // Legacy compat
      fonction: funcLabel,
      categorie: catLabel,
      status: 'non_implemente',
      cmm: 'Initial'
    };
  });
}

  /**
   * Initialize data for a framework when activated via Settings.
   * Ensures controls, plan and target maturity exist.
   */
  function initFrameworkData(frameworkId) {
    if (frameworkId === 'nis2') {
      // Detect and migrate legacy 23-control format
      var format = detectLegacyNis2Format(appData.nis2Controls);
      if (format === 'legacy_23') {
        void 0;
        var legacyMaturity = migrateLegacyNis2Controls(appData.nis2Controls);
        appData.cyfunLevel = appData.cyfunLevel || 'basic';
        appData.nis2Controls = loadNis2Controls();
        // Apply migrated maturity values
        appData.nis2Controls.forEach(function(c) {
          if (legacyMaturity[c.categoryId]) {
            var val = legacyMaturity[c.categoryId];
            c.docMaturity = val;
            c.implMaturity = val;
            // GOV-NIS2 S3 (M6) : status/cmm dérivés des maturités migrées.
            deriveNis2StatusCmm(c);
          }
        });
        if (typeof showToast === 'function') {
          showToast(t('toasts.nis2Migration', 'Migration vers NIS2 terminée. Veuillez vérifier vos scores.'), 'info');
        }
      } else if (format === 'empty' || !Array.isArray(appData.nis2Controls) || appData.nis2Controls.length === 0) {
        appData.cyfunLevel = appData.cyfunLevel || 'basic';
        appData.nis2Controls = loadNis2Controls();
      } else if (format === 'cyfun') {
        // Already in CyFun format, just ensure categories are rebuilt
        var level = appData.cyfunLevel || 'basic';
        if (typeof window.rebuildCyfunCategories === 'function') {
          window.rebuildCyfunCategories(level);
        }
        rebuildCategoryLabels();
      }
      if (!appData.nis2Plan) appData.nis2Plan = getEmptyNis2Plan();
      ensureNis2Plan();
      if (!appData.targetMaturity) appData.targetMaturity = {};
      if (!appData.targetMaturity.nis2) {
        appData.targetMaturity.nis2 = {};
        CYFUN_CATEGORIES.forEach(function(c) { appData.targetMaturity.nis2[c] = 0; });
        appData.targetMaturity.nis2Target = 0;
      }
      recalcNis2Maturity();
    }
    // Future frameworks: add else-if here
  }

  function getLocalStorageUsage() {
    let totalSize = 0;
    for (let key in localStorage) {
      if (localStorage.hasOwnProperty(key)) {
        totalSize += (localStorage[key].length + key.length) * 2; // UTF-16 = 2 bytes per char
      }
    }
    return totalSize;
  }

  function checkLocalStorageQuota(dataSize) {
    const QUOTA_WARNING_THRESHOLD = 4 * 1024 * 1024; // 4MB warning
    const QUOTA_LIMIT = 5 * 1024 * 1024; // 5MB typical limit
    const currentUsage = getLocalStorageUsage();
    // AUDIT-M4: Soustraire la taille actuelle de smsi_data car elle sera REMPLACÉE, pas ajoutée.
    // Avant: projectedUsage = currentUsage + dataSize incluait l'ancien smsi_data,
    // causant des faux positifs de quota exceeded (ex: 3.8MB existant + 3.8MB nouveau = 7.6MB
    // alors que l'usage réel resterait 3.8MB après remplacement).
    var existingSmsiSize = 0;
    try {
      var existingSmsi = localStorage.getItem("smsi_data");
      if (existingSmsi) existingSmsiSize = (existingSmsi.length + "smsi_data".length) * 2;
    } catch(e) { /* ignore */ }
    const projectedUsage = (currentUsage - existingSmsiSize) + dataSize;

    if (projectedUsage > QUOTA_LIMIT) {
      return { ok: false, usage: currentUsage, projected: projectedUsage, limit: QUOTA_LIMIT };
    }
    if (projectedUsage > QUOTA_WARNING_THRESHOLD) {
      return { ok: true, warning: true, usage: currentUsage, projected: projectedUsage, limit: QUOTA_LIMIT };
    }
    return { ok: true, warning: false, usage: currentUsage, projected: projectedUsage, limit: QUOTA_LIMIT };
  }

  function refreshStorageHealth() {
    // FIX-STORAGEHEALTH-MODE (audit 2026-06-10) : garde aligné sur l'édition/licence
    // (isServerModeUnlocked) et non sur le protocole — une édition locale servie en http(s)
    // conserve ses données en localStorage et doit donc afficher la jauge de stockage.
    if (typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()) return;
    const usageText = document.getElementById("storageUsageText");
    const usageBar = document.getElementById("storageUsageBar");
    const datasetSizeEl = document.getElementById("storageDatasetSize");
    const lastSaveEl = document.getElementById("storageLastSave");
    const warningEl = document.getElementById("storageHealthWarning");
    if (!usageText && !usageBar && !datasetSizeEl && !lastSaveEl && !warningEl) return;

    let usage = 0;
    let limit = 5 * 1024 * 1024;
    let warn = false;
    try {
      const quota = checkLocalStorageQuota(0);
      usage = quota.usage || 0;
      limit = quota.limit || limit;
      warn = !!quota.warning;
    } catch (e) {
      try {
        usage = getLocalStorageUsage();
      } catch (err) {
        usage = 0;
      }
    }

    const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 5 * 1024 * 1024;
    const percent = safeLimit ? Math.min(100, Math.round((usage / safeLimit) * 100)) : 0;
    if (usageText) {
      usageText.textContent = `${formatSnapshotSize(usage)} / ${formatSnapshotSize(safeLimit)} (${percent}%)`;
    }
    if (usageBar) {
      usageBar.style.width = `${percent}%`;
      usageBar.classList.toggle("storage-meter__bar--warn", percent >= 80);
      usageBar.classList.toggle("storage-meter__bar--danger", percent >= 95);
    }

    let dataSize = 0;
    try {
      const saved = localStorage.getItem("smsi_data") || "";
      dataSize = saved.length * 2;
    } catch (e) {
      dataSize = 0;
    }
    if (datasetSizeEl) datasetSizeEl.textContent = formatSnapshotSize(dataSize);

    let lastSaveRaw = "";
    try {
      lastSaveRaw = localStorage.getItem("smsi_last_save") || "";
    } catch (e) {
      lastSaveRaw = "";
    }
    let lastSaveText = t('settings.never', 'Jamais');
    if (lastSaveRaw) {
      const ts = Date.parse(lastSaveRaw);
      if (Number.isFinite(ts)) lastSaveText = new Date(ts).toLocaleString();
    }
    if (lastSaveEl) lastSaveEl.textContent = lastSaveText;

    if (warningEl) {
      const nearLimit = warn || percent >= 80;
      if (nearLimit) {
        warningEl.textContent = t('settings.storageFull', 'Stockage presque plein : exportez vos données ou supprimez des snapshots.');
        warningEl.hidden = false;
      } else {
        warningEl.hidden = true;
      }
    }
  }

  // ============================================================
  // SERVER-ONLY MODE: Push helper with retry + beforeunload guard
  // ============================================================
  window._csiUnsavedServerData = false;

  /**
   * Push des données d'un tab au serveur avec retry (backoff exponentiel).
   * @param {string} tabId
   * @param {Object} tabData
   * @param {Object} lockMgr
   * @param {number} maxRetries
   * @returns {Promise<boolean>} true si push réussi
   */
  // FIX-D: Prevent concurrent push operations (multiple saveData calls in flight)
  var _pushInProgress = false;
  // FIX-PUSH-QUEUE (audit 2026-06-10, bundle.js:12710) : un push demandé pendant qu'un
  // autre était en vol était silencieusement JETÉ (mutex), puis le pull au retour sur
  // l'onglet écrasait les modifications locales jamais poussées — perte définitive.
  // Désormais : les onglets modifiés sont suivis (_csiDirtyTabs), un push refusé par le
  // mutex est rejoué automatiquement à la fin du push en vol (_pendingPushTabs, données
  // re-extraites FRAÎCHES d'appData au moment du rejeu), et le pull d'onglet ne remplace
  // jamais les clés d'un onglet encore « dirty » (voir loadModuleContent).
  var _csiDirtyTabs = {};     // tabId -> true : modifs locales non confirmées par le serveur
  var _pendingPushTabs = {};  // tabId -> true : push à rejouer dès la fin du push en vol
  // FIX-OPTIMISTIC-REV (2026-06-11) : numéro de révision serveur connu par onglet. Envoyé
  // comme baseRev au push ; mis à jour à chaque pull/push réussi. Permet au serveur de
  // détecter une écriture basée sur une version périmée (409) → parachute (pas d'écrasement).
  var _tabRevisions = {};     // tabId -> dernier _rev serveur connu
  var GLOBAL_META_TAB = 'global-meta';
  var GLOBAL_META_KEYS = ['auditLog', 'kpiHistory'];

  function _updateUnsavedFlag() {
    window._csiUnsavedServerData = Object.keys(_csiDirtyTabs).length > 0;
  }

  function _extractTabData(tabId) {
    if (!window.appData) return null;
    var keys;
    if (tabId === GLOBAL_META_TAB) {
      keys = GLOBAL_META_KEYS;
    } else {
      var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
      keys = mgr && mgr.getTabDataKeys ? mgr.getTabDataKeys()[tabId] : null;
    }
    if (!keys) return null;
    var data = {};
    keys.forEach(function(key) { data[key] = window.appData[key]; });
    return data;
  }

  // === CSI-ME === FIX-IMPOSED-PUSH (retour client SPF 2026-08-17) Clés d'échelle IMPOSÉES
  // par l'entité principale — miroir de CSI_IMPOSED_SCALE_KEYS serveur (dossier §8.1).
  // Depuis un périmètre sous-entité (compte dédié OU admin transverse basculé), le serveur
  // strippe silencieusement ces clés de toute écriture : les pousser ne fait que
  // désynchroniser l'état local (parachutes 409 « autre utilisateur » à répétition,
  // activation de matrice « enregistrée » puis perdue, analyse neuve éjectée en 68 ms —
  // reproduit au banc). On ne les envoie donc JAMAIS depuis un périmètre scopé. Défense en
  // profondeur : même un futur écran sans garde ne peut plus les faire fuir.
  var CSI_IMPOSED_SCALE_KEYS = ['riskThresholds', 'impactMatrices'];
  function _csiScopedSubEntity() {
    var EM = window.CSI_EntityManager;
    return !!(EM && EM.isMultiEntity && EM.isMultiEntity()
      && ((EM.isSubEntityUser && EM.isSubEntityUser()) || (EM.activeScope && EM.activeScope())));
  }
  window._csiScopedSubEntity = _csiScopedSubEntity;
  // Retourne les données filtrées, ou null s'il ne reste RIEN à pousser (l'appelant
  // considère alors le push comme réussi et nettoie le flag dirty : il n'y a par
  // construction rien que le serveur accepterait).
  function _csiStripImposedForPush(data) {
    if (!data || !_csiScopedSubEntity()) return data;
    var out = {};
    var kept = 0, removed = false;
    for (var k in data) {
      if (!Object.prototype.hasOwnProperty.call(data, k)) continue;
      if (CSI_IMPOSED_SCALE_KEYS.indexOf(k) >= 0) { removed = true; continue; }
      out[k] = data[k];
      kept++;
    }
    // (audit du lot) null = « vidé PAR LE FILTRE » uniquement. Un payload d'entrée déjà
    // vide (ou sans clé imposée) ressort tel quel : sinon un {} légitime serait traité en
    // faux succès (dirty nettoyé, protection beforeunload perdue).
    if (!removed) return data;
    return kept ? out : null;
  }

  function _drainPendingPushes() {
    var tabs = Object.keys(_pendingPushTabs);
    if (!tabs.length) return;
    var tabId = tabs[0];
    delete _pendingPushTabs[tabId];
    var data = _extractTabData(tabId);
    if (data) {
      _serverDataPushWithRetry(tabId, data, window.CSI_PresenceManager || window.CSI_LockManager, 3);
    } else {
      delete _csiDirtyTabs[tabId];
      _updateUnsavedFlag();
      _drainPendingPushes();
    }
  }

  /**
   * FIX-PUSH-QUEUE : re-pousse tous les onglets dirty (filet appelé par l'autosave).
   */
  function _retryDirtyServerPushes() {
    if (!(typeof isServerModeUnlocked === 'function' && isServerModeUnlocked())) return;
    if (!window.CSI_AuthManager || !window.CSI_AuthManager.isLoggedIn()) return;
    var dirty = Object.keys(_csiDirtyTabs);
    if (!dirty.length) return;
    dirty.forEach(function(tb) { _pendingPushTabs[tb] = true; });
    if (!_pushInProgress) _drainPendingPushes();
  }

  /**
   * FIX-LOCK-RELEASE-RACE (audit 2026-06-10, bundle.js:12732) : attend la fin de tous les
   * push (en vol + en attente + dirty) avant de rendre la main — utilisé à la sortie du
   * mode édition pour ne pas libérer le verrou pendant qu'un push est encore en route.
   * Résout false au-delà du timeout (on ne retient pas le verrou indéfiniment).
   */
  function _flushServerPushes(timeoutMs) {
    return new Promise(function(resolve) {
      var deadline = Date.now() + (timeoutMs || 15000);
      (function tick() {
        var idle = !_pushInProgress
          && Object.keys(_pendingPushTabs).length === 0
          && Object.keys(_csiDirtyTabs).length === 0;
        if (idle) return resolve(true);
        if (Date.now() > deadline) return resolve(false);
        if (!_pushInProgress) {
          Object.keys(_csiDirtyTabs).forEach(function(tb) { _pendingPushTabs[tb] = true; });
          _drainPendingPushes();
        }
        setTimeout(tick, 300);
      })();
    });
  }

  // FIX-OPTIMISTIC-REV (2026-06-11) : l'import full (POST sans action) purge tous les
  // tab_*.json côté serveur → les révisions repartent de 0. Le client doit oublier ses
  // révisions mémorisées (sinon un baseRev élevé > révision serveur réinitialisée
  // empêcherait la détection de conflit). Appelé par sync-manager après chaque full-sync.
  window._csiResetTabRevisions = function() { _tabRevisions = {}; };

  // === CSI-ME === Abandonne toute écriture en file. Utilisé par la bascule de
  // périmètre APRÈS un flush réussi (ou en consultation où rien n'est légitimement
  // en attente) : on re-pull l'état autoritaire, donc l'état RAM courant est
  // remplacé — laisser des onglets « dirty » ferait boucler l'autosave en 423.
  window._csiDiscardPendingPushes = function() {
    _csiDirtyTabs = {};
    _pendingPushTabs = {};
    if (typeof _updateUnsavedFlag === 'function') _updateUnsavedFlag();
  };

  // === CSI-ME === Bascule de périmètre (compte principal). Passe en CONSULTATION
  // (aucun verrou pris — le verrou n'est acquis qu'à l'entrée en édition explicite,
  // décision PO), puis re-pull autoritaire de la partition cible + re-render.
  // Sécurité anti-perte : si on ÉDITAIT (verrou détenu), on flushe les écritures
  // AVANT de relâcher le verrou (tant qu'on peut encore pousser) ; un flush qui
  // échoue interrompt la bascule (données préservées). En consultation, il n'y a
  // rien de légitime à pousser → on abandonne la file résiduelle.
  // entityId : id de sous-entité, ou null/'' pour revenir au périmètre principal.
  window._csiSwitchScope = async function(entityId) {
    var EM = window.CSI_EntityManager;
    // === CSI-ME === (audit M5) accepte le compte principal ET un compte de sous-entité
    // MULTI-APPARTENANCE (sélecteur « Mon entité »). setActiveScope re-valide l'appartenance
    // (refuse tout id hors entityIds pour une sous-entité) → bascule sûre dans les deux cas.
    if (!EM || !EM.isMultiEntity() || (!EM.isGroupUser() && !EM.isSubEntityUser())) return { ok: false, reason: 'not-multi' };
    // === CSI-ME === (audit, mineur) garde de réentrance : deux bascules concurrentes
    // (double clic / sélecteur + tuile) se marcheraient dessus (re-pull entrelacés).
    if (window._csiScopeSwitching) return { ok: false, reason: 'busy' };
    var wasEditing = (typeof isReadOnlyMode === 'function') ? !isReadOnlyMode() : false;
    // FIX-RA-COMMIT-EXITS : committer la saisie du wizard AVANT le drapeau anti-push et
    // AVANT setActiveScope — le push du commit part avec l'empreinte de l'ANCIENNE
    // partition (le flush wasEditing ci-dessous attend sa fin).
    if (wasEditing) { try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; } }
    window._csiScopeSwitching = true;
    try {
      return await _csiDoSwitchScope(EM, entityId, wasEditing);
    } finally {
      window._csiScopeSwitching = false;
    }
  };

  async function _csiDoSwitchScope(EM, entityId, wasEditing) {
    if (wasEditing && typeof _flushServerPushes === 'function') {
      var flushed = await _flushServerPushes(15000);
      if (flushed === false) {
        if (typeof showToast === 'function') showToast(t('entities.switchFlushPending', 'Des modifications sont encore en cours d\'envoi — réessayez dans un instant.'), 'warning');
        return { ok: false, reason: 'flush-pending' };
      }
    }
    // === CSI-ME === (audit co-édition 2026-08-05, MAJEUR) Travail non enregistré à la bascule.
    // Le flush ci-dessus ne couvre QUE `wasEditing` : un écran passé en lecture seule de grappe
    // (_csiClusterReadOnly) ou une sortie d'édition récente laissaient des onglets « dirty »
    // que le _csiDiscardPendingPushes() plus bas jetait SANS parachute ni message — travail
    // perdu en silence. Décision PO (05/08) : bloquer et demander confirmation en nommant les
    // écrans concernés. Si l'utilisateur confirme, on dépose quand même une sauvegarde locale
    // (défense en profondeur : la confirmation ne doit pas être un piège).
    var _dirtyNow = [];
    try {
      var _seenDirty = {};
      [(typeof _csiDirtyTabs !== 'undefined' ? _csiDirtyTabs : {}),
       (typeof _pendingPushTabs !== 'undefined' ? _pendingPushTabs : {})].forEach(function(src) {
        for (var k in src) {
          if (Object.prototype.hasOwnProperty.call(src, k) && src[k] && k !== GLOBAL_META_TAB && !_seenDirty[k]) {
            _seenDirty[k] = true; _dirtyNow.push(k);
          }
        }
      });
    } catch (e) { /* état indisponible : on ne bloque pas la bascule */ }
    if (_dirtyNow.length) {
      var _labels = _dirtyNow.map(function(tb) {
        return (window.CSI_EntityUI && window.CSI_EntityUI.sectionLabel) ? window.CSI_EntityUI.sectionLabel(tb) : tb;
      }).join(', ');
      var _goOn = window.confirm(
        t('entities.switchDirtyConfirm',
          'Des modifications ne sont pas encore enregistrées sur le serveur :\n\n{{list}}\n\nChanger de périmètre les abandonnera (une copie de secours sera téléchargée).\n\nContinuer quand même ?')
          .replace('{{list}}', _labels));
      if (!_goOn) return { ok: false, reason: 'dirty-cancelled' };
      try {
        var _nb = (typeof _csiBackupDirtyTabs === 'function') ? _csiBackupDirtyTabs('scope-changed') : 0;
        if (_nb && typeof showToast2 === 'function') {
          showToast2(t('entities.switchDirtyBackedUp', 'Copie de secours de vos modifications non enregistrées : {{n}} fichier(s).').replace('{{n}}', _nb), 'info', 9000);
        }
      } catch (e) { void 0; }
    }
    // Relâcher le verrou du périmètre courant + passer en consultation.
    try { if (window.CSI_EditLockManager && window.CSI_EditLockManager.release) await window.CSI_EditLockManager.release(); } catch (e) {}
    if (typeof setEditMode === 'function') { try { setEditMode(false); } catch (e) {} }
    // Cibler la nouvelle partition PUIS abandonner toute file résiduelle (le re-pull
    // qui suit fait autorité — aucun push ne doit partir vers le nouveau périmètre).
    // (audit 2026-07-03) mémoriser le périmètre courant pour ROLLBACK si le re-pull échoue.
    var _prevScope = EM.activeScope();
    EM.setActiveScope(entityId || null);
    // FIX-SCOPE-RESTORE : périmètre mémorisé PAR ONGLET (purgé au retour principal).
    try {
      if (entityId) sessionStorage.setItem('csi_scope', String(entityId));
      else sessionStorage.removeItem('csi_scope');
    } catch (e) { /* privé/incognito : non bloquant */ }
    window._csiDiscardPendingPushes();
    // === CSI-ME === (audit C2) L'historique d'annulation (undo) vit en RAM par état
    // appData courant. Après bascule, appData est REMPLACÉ par la partition cible :
    // conserver l'historique de la partition précédente permettrait, via « restaurer »,
    // d'injecter des données d'un AUTRE périmètre → fuite inter-partitions. On le purge.
    try {
      _ramHistorySnapshots = [];
      if (typeof renderHistoryList === 'function') renderHistoryList();
    } catch (e) {}
    var _switchFailed = null;
    try {
      if (typeof loadData === 'function') {
        // (audit 2026-07-03, MAJEUR) un pull raté ne LÈVE pas en mode serveur : loadData
        // pose _csiServerLoadFailed et laisse appData INCHANGÉ. Sans ce test, la bascule
        // « réussissait » en état hybride silencieux (bannière/titre = cible, données =
        // ancienne partition) — lecture des mauvais chiffres, écritures inter-partitions.
	        window._csiServerLoadFailed = false;
	        await loadData();
	        if (window._csiServerLoadFailed) throw new Error('server-pull-failed');
	        if (typeof _csiResetTabRevisions === 'function') _csiResetTabRevisions();
	        window._csiDiscardPendingPushes();
	        if (window.CSI_EntityConsolidation && window.CSI_EntityConsolidation.clear) window.CSI_EntityConsolidation.clear();
      }
    } catch (e) {
      _switchFailed = e;   // SEUL un échec de CHARGEMENT fait échouer la bascule
    }
    // === CSI-ME === (audit co-édition 2026-08-05, MAJEUR) Le rendu est SORTI du try qui décide
    // du rollback. Avant, une exception de updateDashboard/loadModuleContent/applyNis2Referential
    // — trois fonctions sans protection interne — déclenchait le rollback ALORS QUE le pull avait
    // réussi : le périmètre repassait sur l'ancien pendant que appData portait déjà l'intégralité
    // de la partition CIBLE. Écran, bannière et écritures suivantes se retrouvaient sous l'identité
    // de la mauvaise entité. Décision PO (05/08) : un échec de RENDU n'est pas un échec de bascule
    // — il est isolé écran par écran, la bascule reste acquise.
    if (!_switchFailed) {
      var _renderErr = null;
      try { if (typeof updateDashboard === 'function') updateDashboard(); }
      catch (e) { _renderErr = e; void 0; }
      try {
        var cur = (typeof _currentActiveTab !== 'undefined' && _currentActiveTab) ? _currentActiveTab : 'dashboard';
        if (typeof loadModuleContent === 'function') loadModuleContent(cur);
      } catch (e) { _renderErr = e; void 0; }
      // === CSI-ME === (certification 2026-07-03) navigate=TRUE : si la partition cible
      // utilise un AUTRE référentiel NIS2 (CyFun↔ReCyF) et que l'onglet actif est le
      // panneau de contrôles de l'ancien référentiel, replier vers le panneau primaire du
      // référentiel actif (sinon le panneau de l'ancien référentiel restait affiché).
      try { if (typeof applyNis2Referential === 'function') applyNis2Referential(true); }
      catch (e) { _renderErr = e; void 0; }
      if (_renderErr && typeof showToast2 === 'function') {
        showToast2(t('entities.switchRenderFailed', 'Périmètre changé, mais un écran n\'a pas pu s\'afficher correctement — rechargez la page si l\'affichage semble incohérent.'), 'warning', 10000);
      }
    }
    if (_switchFailed) {
      void 0;
      // ROLLBACK : périmètre inchangé + message explicite (l'échec était totalement
      // silencieux : sélecteur sur la cible, écran sur l'ancienne partition).
      EM.setActiveScope(_prevScope || null);
      try {
        if (_prevScope) sessionStorage.setItem('csi_scope', String(_prevScope));
        else sessionStorage.removeItem('csi_scope');
      } catch (e) {}
      window._csiDiscardPendingPushes();
      var _failName = entityId ? entityId : t('entities.mainEntity', 'Entité principale');
      EM.entities().forEach(function(e) { if (entityId && e && e.id === entityId) _failName = e.name || e.id; });
      if (typeof showToast2 === 'function') {
        showToast2(t('entities.switchFailed', 'Bascule vers « {{name}} » impossible (serveur injoignable) — périmètre inchangé.').replace('{{name}}', _failName), 'error', 10000);
      }
      return { ok: false, reason: 'reload-failed' };
    }
    // === CSI-ME === (audit 2026-07-03, MAJEUR) recherche globale : vider l'input et le
    // panneau de résultats — les actions par id des résultats de l'ANCIEN périmètre se
    // résolvent dans le NOUVEAU appData (ids séquentiels par partition → un clic ouvrait
    // un objet homonyme de l'autre partition), et la note de périmètre devenait fausse.
    try {
      var _gsInput = document.getElementById('globalSearchInput');
      if (_gsInput) _gsInput.value = '';
      if (typeof renderSearchResults === 'function') renderSearchResults([]);
    } catch (e) {}
    // === CSI-ME === (Lot 3c, anti-confusion) : armer la confirmation de première
    // entrée en édition sur le nouveau périmètre (hors retour au périmètre par défaut).
    if (entityId) {
      var _scopeLabel = entityId;
      EM.entities().forEach(function(e) { if (e && e.id === entityId) _scopeLabel = e.name || e.id; });
      window._csiConfirmScopeEdit = _scopeLabel;
    } else {
      window._csiConfirmScopeEdit = null;
    }
    return { ok: true, scope: entityId || 'group' };
  };

  // FIX-OPTIMISTIC-REV (2026-06-11) : clés appData d'un onglet (ou du pseudo-onglet global-meta).
  function _keysForTab(tabId) {
    if (tabId === GLOBAL_META_TAB) return GLOBAL_META_KEYS;
    var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
    return (mgr && mgr.getTabDataKeys) ? (mgr.getTabDataKeys()[tabId] || null) : null;
  }

  // FIX-CASCADE-PERSIST (2026-06-29) : une suppression en cascade nettoie plusieurs sections en
  // RAM (ex. deleteRisk → risks + actions + soa), mais saveData() en mode serveur ne pousse que
  // l'onglet courant (+ global-meta). Sans ceci, les nettoyages des sections NON-courantes ne
  // sont jamais persistés et réapparaissent au prochain pull (références orphelines durables —
  // bug confirmé en E2E). On pousse explicitement chaque section mutée via le chemin éprouvé
  // _serverDataPushWithRetry (verrou 423, baseRev/409, parachute). _currentActiveTab et
  // global-meta sont déjà gérés par saveData() → on les saute pour éviter un double push.
  // No-op hors mode serveur (en file://, saveData persiste déjà tout appData en localStorage).
  function _tabsForKeys(keys) {
    if (!keys || !keys.length) return [];
    var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
    var map = (mgr && mgr.getTabDataKeys) ? mgr.getTabDataKeys() : null;
    var seen = {}, result = [];
    keys.forEach(function(key) {
      var tabId = null;
      if (key === 'auditLog' || key === 'kpiHistory') {
        tabId = GLOBAL_META_TAB;
      } else if (map) {
        for (var tb in map) {
          if (map.hasOwnProperty(tb) && map[tb] && map[tb].indexOf(key) !== -1) { tabId = tb; break; }
        }
      }
      if (tabId && !seen[tabId]) { seen[tabId] = true; result.push(tabId); }
    });
    return result;
  }

  // FIX-CASCADE-ATOMIC (Lot 3, 2026-07-17) : pousse N sections en UNE requête atomique
  // tout-ou-rien via l'endpoint data-push-multi (écrit et testé côté serveur depuis la
  // co-édition P0, jamais branché côté client — dette D2/INV-C4 du dossier). Contrat :
  // marque les onglets dirty à l'entrée ; succès → revs mémorisées + dirty levés (sauf
  // pending) ; échec → dirty conservés (rejeu autosave par le chemin mono éprouvé).
  // Prend le mutex _pushInProgress (les pushes mono en vol → repli séquentiel du caller).
  // Résout {success, code?, cluster?} — ne rejette jamais. global-meta INTERDIT ici
  // (l'endpoint multi REMPLACE la section au lieu de la fusionner).
  // GOV-NIS2 S2 (audit 2026-08-20, BLOQUANT B4) — fusion pure pour la section app-config :
  // base = état serveur FRAIS, surchargé des SEULES clés déclarées par l'appelant (la RAM).
  // Une clé absente du frais (section jamais écrite) garde la valeur RAM. Sans ce contrat,
  // chaque push de configuration réécrivait les 12 clés depuis une RAM datant du login —
  // écrasant niveau CyFun, archive d'évaluations et verrous posés par les collègues.
  function _csiMergeConfigPayload(extract, fresh, declaredKeys) {
    var merged = {};
    Object.keys(extract || {}).forEach(function(k) {
      var declared = (declaredKeys || []).indexOf(k) >= 0;
      merged[k] = (!declared && fresh && Object.prototype.hasOwnProperty.call(fresh, k)) ? fresh[k] : extract[k];
    });
    return merged;
  }

  async function _csiAtomicMultiPush(tabIds, declaredKeys) {
    if (window._csiScopeSwitching) return { success: false, code: 'scope' };
    // GOV-NIS2 S2 (audit 2026-08-20, MAJEUR M14) : empreinte du périmètre à l'entrée —
    // même idiome que le push mono (fix 42d3dbe). Les await ci-dessous (attente du mutex,
    // pull frais app-config, amorçage de révisions) ouvrent une fenêtre pendant laquelle
    // une bascule d'entité remplace appData ; sans ce garde, l'écho re-résolu après coup
    // validait lui-même l'écriture des données de A dans la partition de B.
    var _scopeAtSend = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
      ? window.CSI_EntityManager.echoId() : null;
    var _scopeChanged = function() {
      var now = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
        ? window.CSI_EntityManager.echoId() : null;
      return now !== _scopeAtSend;
    };
    if (window.CSI_SyncManager && window.CSI_SyncManager.isSyncEnabled
        && !window.CSI_SyncManager.isSyncEnabled()) {
      // Aligné sur le push mono : sync désactivée (licence expirée) → les données restent
      // FLAGGÉES non sauvegardées (beforeunload protège), pas d'oubli silencieux.
      tabIds.forEach(function(tb0) { if (tb0 !== GLOBAL_META_TAB) _csiDirtyTabs[tb0] = true; });
      _updateUnsavedFlag();
      return { success: false, code: 'sync-off' };
    }
    // Attente bornée (≤3 s) du push mono en vol : les cascades suivent saveData() de
    // l'onglet courant — sans cette attente, le multi cédait presque toujours la place
    // au repli séquentiel (mutex occupé) et perdait son atomicité.
    var _busyWait = 0;
    while (_pushInProgress && _busyWait < 30) {
      await new Promise(function(r) { setTimeout(r, 100); });
      _busyWait++;
    }
    if (_pushInProgress) return { success: false, code: 'busy' };
    _pushInProgress = true;
    try {
      if (window._csiScopeSwitching || _scopeChanged()) {
        void 0;
        return { success: false, code: 'scope' };
      }
      var pushes = [];
      for (var i = 0; i < tabIds.length; i++) {
        var tb = tabIds[i];
        if (tb === GLOBAL_META_TAB) continue;
        // === CSI-ME === FIX-IMPOSED-PUSH : mêmes règles que le push mono — une cascade ne
        // pousse jamais les échelles imposées depuis un périmètre sous-entité. Onglet vidé
        // par le filtre → retiré de la cascade (pousser {} écraserait le fichier d'onglet).
        var _mExtract = _extractTabData(tb);
        // GOV-NIS2 S2 (audit 2026-08-20, BLOQUANT B4) : app-config est une section
        // « fourre-tout » jamais repull en session — la pousser entière depuis la RAM
        // écrase les écritures des collègues (niveau CyFun, _cyfunEvalArchive, verrous,
        // titre…). Contrat rétabli du chemin legacy (FIX-FULLSYNC-STALE) : pull FRAIS du
        // tab, surcharge des seules clés déclarées, adoption de la révision serveur comme
        // baseRev (protège aussi les rejeux mono par 409). Échec du pull → abandon
        // fail-closed (le toast configPersistFailed de l'appelant prévient).
        if (tb === 'app-config' && declaredKeys && declaredKeys.length && _mExtract) {
          var _cfgSM = window.CSI_SyncManager;
          if (_cfgSM && typeof _cfgSM.pullTabFromServer === 'function') {
            var _cfgFresh;
            try { _cfgFresh = await _cfgSM.pullTabFromServer(tb); } catch (eCfg) { _cfgFresh = undefined; }
            if (_cfgFresh === undefined) {
              // GOV-NIS2 S2 (revue adverse 2026-08-20) : abandon fail-closed, mais le LOT
              // ENTIER doit rester dirty — ce return interrompt la boucle avant le marquage,
              // et les sections métier du lot (ex. nis2Controls rebuildé par un changement
              // de niveau) n'ont AUCUN autre chemin de persistance : sans ce filet, le toast
              // « re-tentée à la prochaine synchronisation » mentait et le changement était
              // perdu au rechargement. Même précaution que le chemin sync-off ci-dessus.
              tabIds.forEach(function(tbX) { if (tbX !== GLOBAL_META_TAB) _csiDirtyTabs[tbX] = true; });
              _updateUnsavedFlag();
              return { success: false, code: 'config-pull-failed' };
            }
            _tabRevisions[tb] = (_cfgFresh && typeof _cfgFresh._rev === 'number') ? _cfgFresh._rev : 0;
            _mExtract = _csiMergeConfigPayload(_mExtract, _cfgFresh, declaredKeys);
          }
        }
        var _mData = _csiStripImposedForPush(_mExtract);
        if (_mExtract !== null && _mData === null) {
          delete _csiDirtyTabs[tb];
          continue;
        }
        _csiDirtyTabs[tb] = true;
        // === CSI-ME === gate V7 : en partition scopée le serveur exige baseRev — amorcer
        // la révision des onglets de fond jamais visités (même règle que le push mono).
        if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity() && typeof _tabRevisions[tb] !== 'number') {
          await _csiEnsureTabRevision(tb);
        }
        var it = { tabId: tb, data: _mData || {} };
        if (typeof _tabRevisions[tb] === 'number') it.baseRev = _tabRevisions[tb];
        pushes.push(it);
      }
      _updateUnsavedFlag();
      if (!pushes.length) return { success: true };
      // GOV-NIS2 S2 (M14) : dernier contrôle avant l'envoi — les extractions et pulls
      // ci-dessus ont pu être traversés par une bascule de périmètre.
      if (window._csiScopeSwitching || _scopeChanged()) {
        void 0;
        return { success: false, code: 'scope' };
      }
      var body = { pushes: pushes };
      if (window.appData && typeof window.appData._dataEpoch === 'number') body.dataEpoch = window.appData._dataEpoch;
      var url = './data/sync.php?action=data-push-multi';
      if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
        var _emExtra = window.CSI_EntityManager.pushBodyExtra();
        for (var _ek in _emExtra) { if (Object.prototype.hasOwnProperty.call(_emExtra, _ek)) body[_ek] = _emExtra[_ek]; }
        url = window.CSI_EntityManager.appendEntityParam(url);
      }
      var resp = await fetchWithTimeout(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': window.CSI_AuthManager.getAuthHeader()
        },
        body: JSON.stringify(body)
      });
      var respBody = null;
      try { respBody = await resp.json(); } catch (e) { /* corps non-JSON */ }
      if (resp && resp.ok) {
        if (respBody && respBody.revs) {
          for (var rt in respBody.revs) {
            if (Object.prototype.hasOwnProperty.call(respBody.revs, rt) && typeof respBody.revs[rt] === 'number') _tabRevisions[rt] = respBody.revs[rt];
          }
        }
        tabIds.forEach(function(tb2) { if (!_pendingPushTabs[tb2]) delete _csiDirtyTabs[tb2]; });
        _updateUnsavedFlag();
        if (window.CSI_Storage) window.CSI_Storage.updateLastPush();
        void 0;
        return { success: true };
      }
      if (resp && resp.status === 423) {
        var _mCluster = respBody && respBody.cluster;
        // Toast contextuel — pour la grappe 'config' (aucun écran ne lui correspond), le
        // message générique de configuration est le seul honnête.
        if (_mCluster === 'config') {
          showToast2(t('toasts.configNotPersisted', "Configuration non enregistrée sur le serveur (un autre utilisateur édite) — elle sera appliquée à votre prochaine entrée en mode édition."), 'warning', 9000);
        } else if (_mCluster && typeof _csiClusterLabel === 'function') {
          var _mHolder = respBody.lock && respBody.lock.userName;
          if (_mHolder) {
            showToast2(t('toasts.editLockPushRefusedClusterBusy', 'Module « {{module}} » occupé par {{name}} — vos modifications restent en mémoire et seront enregistrées via un écran de ce module quand il sera libre.').replace('{{module}}', _csiClusterLabel(_mCluster)).replace('{{name}}', _mHolder), 'error', 12000);
          } else {
            showToast2(t('toasts.editLockPushRefusedCluster', 'Module « {{module}} » non détenu — vos modifications restent en mémoire. Ouvrez un écran de ce module pour les enregistrer.').replace('{{module}}', _csiClusterLabel(_mCluster)), 'error', 12000);
          }
        } else {
          showToast2(t('toasts.editLockPushRefused', 'Verrou d\'édition non détenu — vos modifications restent en mémoire et ne sont pas enregistrées sur le serveur. Reprenez la main (mode édition) pour les enregistrer ; ne fermez pas l\'onglet avant.'), 'error', 12000);
        }
        if (window.CSI_EditLockManager && window.CSI_EditLockManager.refreshStatus) window.CSI_EditLockManager.refreshStatus();
        return { success: false, code: 423, cluster: _mCluster };
      }
      if (resp && resp.status === 409) {
        // Conflit de révision/époque : RIEN n'a été écrit (tout-ou-rien serveur). Les onglets
        // restent dirty — le repli séquentiel mono (parachute par onglet éprouvé) tranchera.
        return { success: false, code: 409, conflicts: respBody && respBody.conflicts };
      }
      if (resp && resp.status === 403) {
        // GOV-NIS2 S1 (audit 2026-08-20, BLOQUANT B3) : un 403 (droits) n'était pas traité —
        // les onglets restaient dirty, rejoués indéfiniment avec un toast « erreur réseau »
        // mensonger. Le serveur ne l'acceptera JAMAIS : message honnête + sortie de la file.
        var _mfCode = respBody && respBody.code;
        if (_mfCode === 'SECTION_READONLY') {
          showToast2(t('toasts.sectionReadonly', "Cette section est en LECTURE SEULE pour votre périmètre — vos modifications n'ont pas été enregistrées."), 'error', 12000);
        } else if (_mfCode === 'ENTITY_ARCHIVED') {
          showToast2(t('toasts.pushEntityArchived', "Ce périmètre est ARCHIVÉ : il est en lecture seule. Un administrateur doit le réactiver dans Réglages ▸ Multi-entités pour permettre les modifications."), 'error', 14000);
        } else if (_mfCode === 'CROSS_EDIT_FORBIDDEN') {
          showToast2(t('toasts.pushCrossEdit', "Votre compte n'est pas habilité à modifier les données des sous-entités. Un administrateur peut vous l'accorder dans Réglages ▸ Utilisateurs ▸ Entité(s)."), 'error', 14000);
        } else {
          showToast2(t('toasts.pushForbidden', 'Écriture refusée par le serveur (droits insuffisants) — modifications non enregistrées.'), 'error', 10000);
        }
        tabIds.forEach(function(tb3) { delete _csiDirtyTabs[tb3]; delete _pendingPushTabs[tb3]; });
        _updateUnsavedFlag();
        return { success: false, code: 403 };
      }
      return { success: false, code: resp ? resp.status : 0 };
    } catch (e) {
      void 0;
      return { success: false, code: 0 };
    } finally {
      _pushInProgress = false;
      // Rejouer les pushes mono mis en attente pendant le multi (même filet que le mono).
      setTimeout(function() { _drainPendingPushes(); }, 0);
    }
  }

  function _csiPersistAffectedTabs(keys) {
    if (!(typeof isServerModeUnlocked === 'function' && isServerModeUnlocked())) return;
    var mgr = window.CSI_PresenceManager || window.CSI_LockManager;
    var _elmC = window.CSI_EditLockManager;
    var tabs = _tabsForKeys(keys).filter(function(tabId) {
      return tabId !== _currentActiveTab && tabId !== GLOBAL_META_TAB; // déjà poussés par saveData()
    });
    if (!tabs.length) return;
    // FIX-CASCADE-ATOMIC (Lot 3, 2026-07-17) : sous co-édition, une cascade multi-sections
    // part en UN push atomique — après acquisition silencieuse des grappes manquantes
    // (l'opération embrasse ses grappes : remède (b)+(c) du rapport d'audit §3, INV-C1
    // rétabli en pratique). Les grappes acquises pour l'occasion sont rendues aussitôt
    // (sauf travail en attente). Fini les 3× 423 + rejeu infini du renommage de partie
    // prenante. En cas de refus/conflit : repli sur le chemin séquentiel éprouvé.
    // NB : les onglets du push atomique réussi ne sont PAS marqués « travaillés » (INV-C9
    // protège le travail EN COURS ; une cascade aboutie est persistée — la protection
    // résiduelle vient du marquage dirty en cas d'échec, via _csiClusterHasPendingWork).
    if (_elmC && _elmC.isCoedit && _elmC.isCoedit() && _elmC.acquire && _elmC.heldClusters) {
      var needed = {};
      tabs.forEach(function(tabId) { var c = _elmC.clusterForTab(tabId); if (c) needed[c] = true; });
      var held = _elmC.heldClusters();
      var missing = Object.keys(needed).filter(function(c) { return held.indexOf(c) === -1; });
      var acquirePromise = missing.length ? _elmC.acquire(missing) : Promise.resolve({ granted: true });
      acquirePromise.catch(function() { return { granted: false }; }).then(function() {
        // Même si l'acquisition a échoué, tenter le multi : le serveur revalide et son 423
        // (toast contextuel + dirty) vaut mieux qu'un abandon silencieux.
        return _csiAtomicMultiPush(tabs);
      }).then(function(r) {
        if (r && r.success === false && (r.code === 'busy' || r.code === 409)) {
          // Repli séquentiel : file mono éprouvée (parachute par onglet pour les conflits).
          tabs.forEach(function(tabId) {
            var data = _extractTabData(tabId);
            if (data) {
              if (_elmC.markTabTouched) _elmC.markTabTouched(tabId);
              _serverDataPushWithRetry(tabId, data, mgr, 3);
            }
          });
        }
      }).then(function() {
        if (missing.length && _elmC.releaseClusters) {
          _elmC.releaseClusters(missing.filter(function(c) { return !_csiClusterHasPendingWork(c); }));
        }
      }).catch(function() { /* best-effort — dirty + rejeu autosave couvrent */ });
      return;
    }
    // Legacy (flag co-édition off / vieux serveur) : comportement historique inchangé.
    tabs.forEach(function(tabId) {
      var data = _extractTabData(tabId);
      if (data) {
        // === CSI-CO === (courtoisie §12) une cascade = travail réel → grappe touchée.
        if (_elmC && _elmC.markTabTouched) _elmC.markTabTouched(tabId);
        _serverDataPushWithRetry(tabId, data, mgr, 3);
      }
    });
  }
  // Exposé pour les modules hors bundle (ex. impact-matrices.js).
  window._csiPersistAffectedTabs = function(keys) { _csiPersistAffectedTabs(keys); };

  /**
   * FIX-OPTIMISTIC-REV (2026-06-11, durci par la revue concurrence) : PARACHUTE sur
   * conflit de révision (HTTP 409). Règle absolue : AUCUNE donnée ne disparaît — ni celles
   * du serveur (jamais écrasées en aveugle), ni celles saisies localement (backup ou rejeu).
   * Trois cas :
   *  A) Des modifications locales PLUS RÉCENTES attendent en file (_pendingPushTabs) :
   *     on adopte la révision serveur et on laisse le rejeu repousser l'état local le plus
   *     frais sur la bonne base (succès, nouveau 409, ou 423 — données préservées à chaque
   *     issue). Pas d'écrasement de l'écran, pas de perte de la saisie en cours.
   *  B) global-meta (auditLog/kpiHistory, append-only) : FUSION locale⊕serveur puis rejeu —
   *     les entrées des deux côtés coexistent, rien à sauvegarder en fichier.
   *  C) Cas standard : backup de l'état local actuel (le plus frais), application de l'état
   *     serveur (fourni dans la réponse, sinon récupéré par un pull), révision adoptée,
   *     onglet sorti de « dirty » uniquement une fois l'état autoritaire appliqué.
   */
  // === CSI-ME === (audit 2026-07-03, K9) qualifier les sauvegardes de conflit par
  // périmètre : la clé localStorage et le nom de fichier étaient partagés entre
  // partitions — un conflit sur risks du périmètre principal ÉCRASAIT le backup du
  // même onglet d'une sous-entité (et le fichier téléchargé ne disait pas d'où il venait).
  function _csiBackupScopeTag() {
    try {
      var EM = window.CSI_EntityManager;
      if (!EM || !EM.isMultiEntity || !EM.isMultiEntity()) return '';
      var id = EM.echoId && EM.echoId();
      return '_' + (id || 'principal');
    } catch (e) { return ''; }
  }
  // B2 (audit 2026-07-10) : parachute des onglets modifiés non confirmés AVANT un
  // remplacement autoritaire de appData (re-pull à l'acquisition du verrou). Réutilise
  // le filet du conflit 409 (Cas C) : copie de secours localStorage + téléchargement
  // fichier, par onglet dirty. Sans ceci, FIX-EDITLOCK-REPULL exécute loadData() et
  // écrase silencieusement les saisies faites après un push refusé en 423 — à rebours
  // du toast qui promet leur conservation. Retourne le nombre d'onglets sauvegardés.
  function _csiBackupDirtyTabs(reason) {
    var dirty = Object.keys(_csiDirtyTabs || {});
    if (!dirty.length) return 0;
    var tag = _csiBackupScopeTag();
    var stamp = new Date().toISOString();
    var n = 0;
    dirty.forEach(function(tabId) {
      try {
        var keys = _keysForTab(tabId) || [];
        var snap = {};
        keys.forEach(function(k) { snap[k] = appData[k]; });
        var backup = JSON.stringify({ _tabId: tabId, _reason: reason || 'repull', _backupAt: stamp, data: snap }, null, 2);
        try { localStorage.setItem('smsi_conflict_' + tabId + tag, backup); }
        catch (e2) { void 0; }
        if (typeof downloadJsonBackup === 'function') {
          downloadJsonBackup(backup, 'smsi_secours_' + tabId + tag + '.json');
        }
        n++;
      } catch (e) { void 0; }
    });
    return n;
  }

  function _handlePushConflict(tabId, localData, conflict) {
    var keys = _keysForTab(tabId);
    var fresh = conflict && conflict.fresh && conflict.fresh.data ? conflict.fresh.data : null;
    var _adoptRev = function() {
      if (conflict && typeof conflict.currentRev === 'number') _tabRevisions[tabId] = conflict.currentRev;
      else if (conflict && conflict.fresh && typeof conflict.fresh._rev === 'number') _tabRevisions[tabId] = conflict.fresh._rev;
      else delete _tabRevisions[tabId]; // révision inconnue → prochain push en mode legacy
    };
    var _who = (conflict && conflict.fresh && conflict.fresh._lastModifiedBy && conflict.fresh._lastModifiedBy.userName) ? (' (' + conflict.fresh._lastModifiedBy.userName + ')') : '';

    try {
      // ── Cas B : global-meta — fusionner (append-only), puis repousser l'union ──
      if (tabId === GLOBAL_META_TAB) {
        if (fresh) {
          if (Array.isArray(fresh.auditLog)) {
            var _byId = {};
            (fresh.auditLog || []).concat(appData.auditLog || []).forEach(function(e2) {
              if (e2 && e2.id && !_byId[e2.id]) _byId[e2.id] = e2;
            });
            var _merged = Object.keys(_byId).map(function(id) { return _byId[id]; });
            _merged.sort(function(a, b) { return String(a.timestamp || '').localeCompare(String(b.timestamp || '')); });
            appData.auditLog = _merged.slice(-100);
          }
          if (fresh.kpiHistory && (!appData.kpiHistory || !appData.kpiHistory._lastUpdate
              || (fresh.kpiHistory._lastUpdate || 0) > (appData.kpiHistory._lastUpdate || 0))) {
            appData.kpiHistory = fresh.kpiHistory;
          }
        }
        _adoptRev();
        // Repousser l'union sur la bonne base (rien n'est perdu — pas de backup nécessaire)
        _pendingPushTabs[tabId] = true;
        return;
      }

      // ── Cas A : des modifs locales plus récentes attendent — ne PAS écraser l'écran ──
      if (_pendingPushTabs[tabId]) {
        // (audit co-édition 2026-08-05, BLOQUANT) Le rejeu qui suit repousse l'état LOCAL avec
        // la révision du collègue adoptée : son travail est donc écrasé — silencieusement, et
        // sans la sauvegarde que le Cas C produit systématiquement. La règle affichée en tête
        // de cette fonction (« AUCUNE donnée ne disparaît, ni celles du serveur ») n'était
        // donc pas tenue sur ce chemin. On met la version DISTANTE de côté avant de l'écraser,
        // exactement comme le Cas C le fait pour la version locale.
        if (fresh) {
          try {
            var _srvBackup = JSON.stringify({ _tabId: tabId, _conflictAt: new Date().toISOString(),
              _overwrittenFrom: (conflict && conflict.fresh && conflict.fresh._lastModifiedBy) || null,
              data: fresh }, null, 2);
            try {
              localStorage.setItem('smsi_conflit_serveur_' + tabId + _csiBackupScopeTag(), _srvBackup);
            } catch (e3) { void 0; }
            if (typeof downloadJsonBackup === 'function') {
              downloadJsonBackup(_srvBackup, 'smsi_conflit_serveur_' + tabId + _csiBackupScopeTag() + '.json');
            }
          } catch (e4) { void 0; }
        }
        _adoptRev();
        // dirty + pending conservés : le drain (finally du push) rejouera l'état local le
        // plus frais avec la révision corrigée. Le travail en cours reste à l'écran.
        showToast2(
          t('toasts.pushConflictRetryBackup',
            'Ces données ont été modifiées par un autre utilisateur' + _who + ' — votre version va être enregistrée ; une copie de la version distante a été mise de côté (fichier « smsi_conflit_serveur_… »).'),
          'warning', 10000);
        return;
      }

      // ── Cas C : standard — backup de l'état local ACTUEL puis bascule sur le serveur ──
      // FIX-CONFLICT-BACKUP-SAFETY (revue prod 2026-06-11) : le téléchargement automatique
      // peut être bloqué par le navigateur (déclenché hors geste utilisateur). On écrit donc
      // AUSSI une copie de secours dans localStorage (récupérable même si le download est
      // bloqué) — c'est un filet, distinct des données métier qui, elles, vivent au serveur.
      try {
        var _localNow = {};
        (keys || []).forEach(function(k) { _localNow[k] = appData[k]; });
        var _backup = JSON.stringify({ _tabId: tabId, _conflictAt: new Date().toISOString(), data: keys ? _localNow : localData }, null, 2);
        try {
          localStorage.setItem('smsi_conflict_' + tabId + _csiBackupScopeTag(), _backup);
        } catch (e2) { void 0; }
        if (typeof downloadJsonBackup === 'function') {
          downloadJsonBackup(_backup, 'smsi_conflit_' + tabId + _csiBackupScopeTag() + '.json');
        }
      } catch (e) { void 0; }

      _adoptRev();

      var _applyAuthoritative = function(srvData) {
        if (srvData && keys) {
          keys.forEach(function(k) {
            if (srvData[k] !== undefined) appData[k] = srvData[k];
          });
          if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) appData muté par parachute conflit 409 → index recherche périmé
        }
        // Sorti de « dirty » seulement maintenant : l'état autoritaire est appliqué
        delete _csiDirtyTabs[tabId];
        delete _pendingPushTabs[tabId];
        _updateUnsavedFlag();
        if (tabId === _currentActiveTab && typeof _rerenderCurrentTab === 'function') {
          _rerenderCurrentTab(tabId);
        }
        if (typeof updateDashboard === 'function') updateDashboard();
      };

      if (fresh) {
        _applyAuthoritative(fresh);
      } else if (window.CSI_SyncManager && window.CSI_SyncManager.pullFromServer) {
        // fresh absent (tab purgé par un import, ou fichier illisible) : récupérer l'état
        // autoritaire complet (server-data.json + tabs fusionnés). En cas d'échec du pull,
        // l'onglet RESTE dirty (données locales protégées, re-tentées par l'autosave).
        window.CSI_SyncManager.pullFromServer().then(function(full) {
          if (full && typeof full === 'object') _applyAuthoritative(full);
          else void 0;
        }).catch(function() {
          void 0;
        });
      }
    } catch (e) {
      void 0;
    }
    // FIX-CONFLICT-BACKUP-SAFETY : message générique (clé de locale sans interpolation
    // de l'auteur — fr+en présentes). La copie de secours est en fichier ET en localStorage.
    showToast2(
      t('toasts.pushConflict', 'Ces données ont été modifiées par un autre utilisateur. Vos modifications n\'ont pas été écrasées : elles ont été téléchargées dans un fichier de secours, et l\'écran affiche la version du serveur.'),
      'warning', 15000);
  }

  /**
   * FIX-CONFIG-PERSIST-LOCK (revue prod 2026-06-11) : persiste une configuration globale
   * (niveau/référentiel NIS2…) au serveur en respectant EDIT_LOCK_ENFORCED. Si l'utilisateur
   * n'est pas déjà en mode édition, acquiert brièvement le verrou autour du push puis le
   * libère — sinon le push serait refusé en 423 et le choix perdu au rechargement (les
   * appelants historiques avalaient le 423 résolu dans un .catch jamais déclenché).
   * Hors mode serveur, simple forceServerSync. Affiche un message si le verrou est occupé.
   */
  // FIX-CONFIG-SERIALIZE (revue adversariale 2026-07-17) : deux écritures de configuration
  // rapprochées se percutaient (mutex _syncInProgress → « Sync in progress » silencieux ;
  // release des extras de la 1re pendant le push de la 2e → 423 mensonger). File d'attente :
  // chaque appel démarre quand le précédent a fini (acquisition, push ET libération comprises).
  var _persistConfigQueue = Promise.resolve();
  function _persistConfigWithLock(keys) {
    var SM = window.CSI_SyncManager;
    // FIX-CONFIG-LOCK-EDITING (audit co-édition 2026-07-17, §4) : résout une promesse
    // {success} (les appelants historiques l'ignorent — compat fire-and-forget).
    // FIX-CONFIG-CHANNEL (Lot 3) : sous co-édition, canal de sections atomique (voir run) ;
    // en legacy, chemin import historique avec acquisition brève du verrou unique.
    if (!SM || !SM.forceServerSync) return Promise.resolve({ success: true, local: true });
    if (!(SM.isServerMode && SM.isServerMode())) {
      return SM.forceServerSync(keys).catch(function() {}).then(function() { return { success: true, local: true }; });
    }
    var run = function() {
      var ELM = window.CSI_EditLockManager;
      // FIX-CONFIG-CHANNEL (Lot 3, 2026-07-17) : sous co-édition, la configuration part par
      // le canal de sections ('app-config' + éventuelles sections d'onglet des clés, ex.
      // nis2Controls) en UN push atomique data-push-multi — plus AUCUN POST import :
      // n'exige que les grappes des clés poussées (un titre = la seule grappe 'config',
      // plus jamais les 4), ne bump pas l'époque, ne purge ni onglets ni révisions →
      // les collègues en session ne voient plus ni 409 epochConflict ni parachute.
      if (ELM && ELM.isCoedit && ELM.isCoedit() && ELM.acquire && ELM.heldClusters) {
        var tabs = _tabsForKeys(keys).filter(function(tb) { return tb !== GLOBAL_META_TAB; });
        if (!tabs.length) return Promise.resolve({ success: true });
        var needed = {};
        tabs.forEach(function(tb) { var c = ELM.clusterForTab(tb); if (c) needed[c] = true; });
        var held = ELM.heldClusters();
        var missing = Object.keys(needed).filter(function(c) { return held.indexOf(c) === -1; });
        var acq = missing.length ? ELM.acquire(missing) : Promise.resolve({ granted: true });
        return acq.catch(function() { return { granted: false }; }).then(function() {
          // Même si l'acquisition a échoué : tenter — le serveur revalide, son 423 nourrit
          // le toast + laisse les sections dirty (rejeu autosave dès la grappe libre).
          // GOV-NIS2 S2 (B4) : les clés déclarées descendent jusqu'au push — app-config
          // n'est plus jamais poussée entière depuis la RAM.
          return _csiAtomicMultiPush(tabs, keys);
        }).then(function(r) {
          if (r && r.success === false && r.code !== 423 && r.code !== 409) {
            // 423 : toast contextuel déjà affiché par _csiAtomicMultiPush ; 409 : sections
            // dirty → rejeu mono/parachute. Autres échecs (réseau, 5xx, busy) : prévenir.
            showToast2(t('toasts.configPersistFailed', "Configuration non enregistrée sur le serveur (erreur réseau ou serveur) — elle sera re-tentée à la prochaine synchronisation."), 'warning', 9000);
          }
          return r || { success: false };
        }).then(function(r) {
          if (missing.length && ELM.releaseClusters) {
            ELM.releaseClusters(missing.filter(function(c) { return !_csiClusterHasPendingWork(c); }));
          }
          return r;
        });
      }
      // Legacy (flag co-édition off / vieux serveur) : chemin import historique inchangé.
      // (revue adversariale 2026-07-17) editModeEnabled, PAS isReadOnlyMode() : un écran en
      // lecture seule de grappe (_csiClusterReadOnly) pendant la session d'édition prenait la
      // branche « hors édition » → release() COMPLET des grappes travaillées après le push.
      var alreadyEditing = (typeof editModeEnabled !== 'undefined')
        ? !!editModeEnabled
        : !(typeof isReadOnlyMode === 'function' && isReadOnlyMode());
      var doPush = function(releaseAfter) {
        return SM.forceServerSync(keys).then(function(r) {
          if (r && r.success === false) {
            if (r.code === 423) {
              showToast2(t('toasts.configNotPersisted', "Configuration non enregistrée sur le serveur (un autre utilisateur édite) — elle sera appliquée à votre prochaine entrée en mode édition."), 'warning', 9000);
            } else {
              // (revue adversariale 2026-07-17) échec non-423 (réseau, 5xx…) : sans ce toast,
              // l'échec était totalement silencieux depuis que le toast de succès attend le 200.
              showToast2(t('toasts.configPersistFailed', "Configuration non enregistrée sur le serveur (erreur réseau ou serveur) — elle sera re-tentée à la prochaine synchronisation."), 'warning', 9000);
            }
          }
          return r || { success: false };
        }).catch(function() { return { success: false }; }).then(function(r) {
          if (releaseAfter && ELM && ELM.release) ELM.release().catch(function() {});
          return r;
        });
      };
      if (alreadyEditing || !ELM || !ELM.acquire) return doPush(false);
      return ELM.acquire().then(function(res) { return doPush(!!(res && res.granted)); }).catch(function() { return doPush(false); });
    };
    var p = _persistConfigQueue.then(run, run);
    _persistConfigQueue = p.then(function() {}, function() {});
    return p;
  }

  // === CSI-ME === (certification 2026-07-03, BLOQUANT) Amorcer la révision d'un onglet
  // dont la rev serveur est INCONNUE, sans toucher appData. Nécessaire pour les cascades :
  // à l'acquisition du verrou _csiResetTabRevisions() vide toutes les révisions ; seul
  // l'onglet re-navigué est ré-amorcé. Les onglets de FOND d'une cascade (deleteRisk →
  // actions/soa) n'ont donc pas de baseRev → le gate serveur V7 (entEnforcePush) les refuse
  // en 400 LEGACY_CLIENT → cascade partielle → références orphelines durables (contexte
  // PARTITION uniquement). pullTabFromServer est un fetch PUR (retourne {_rev,data} sans
  // écrire dans appData) : on ne récupère que _rev, la donnée RAM nettoyée reste la source.
  async function _csiEnsureTabRevision(tabId) {
    if (typeof _tabRevisions[tabId] === 'number') return;
    var SyncMgr = window.CSI_SyncManager;
    if (!SyncMgr || typeof SyncMgr.pullTabFromServer !== 'function') return;
    try {
      var t = await SyncMgr.pullTabFromServer(tabId);
      // FIX-REV-FAILCLOSED (audit grappe analyse 2026-08-17, C2) : trois issues distinctes —
      //   objet {_rev} → adopter ; objet sans _rev (entrée synthétisée rev 0) ou null
      //   (réponse SAINE sans entrée = section jamais écrite) → 0 légitime ;
      //   undefined (échec transport : timeout/429/5xx) → rev INCONNUE conservée.
      // L'ancien « tout → 0 » transformait un aléa réseau en 409 « parachute » : baseRev 0
      // poussé contre la vraie révision, toast « autre utilisateur » sans autre utilisateur.
      if (t === undefined) return;
      _tabRevisions[tabId] = (t && typeof t._rev === 'number') ? t._rev : 0;
    } catch (e) { /* best-effort : le filet 400 LEGACY_CLIENT ci-dessous ré-amorce et rejoue */ }
  }

  async function _serverDataPushWithRetry(tabId, tabData, lockMgr, maxRetries) {
    // === CSI-ME === pendant une bascule de périmètre, aucun push ne doit partir
    // (l'état RAM est en cours de remplacement par le re-pull autoritaire). Évite
    // des 423 parasites vers le périmètre en transition.
    if (window._csiScopeSwitching) return false;
    // === CSI-ME === FIX-IMPOSED-PUSH : échelles imposées jamais poussées depuis un
    // périmètre sous-entité. Payload vidé → rien à envoyer, ce n'est PAS un échec
    // (le serveur aurait tout strippé de toute façon).
    tabData = _csiStripImposedForPush(tabData);
    if (tabData === null) {
      delete _csiDirtyTabs[tabId];
      _updateUnsavedFlag();
      return true;
    }
    _csiDirtyTabs[tabId] = true;
    _updateUnsavedFlag();
    // FIX-SYNC-DISABLED (audit 2026-06-10) : respecter la désactivation de la sync
    // (licence/trial expiré → handleLicenseExpired, ou setAutoSync(false)).
    if (window.CSI_SyncManager && window.CSI_SyncManager.isSyncEnabled
        && !window.CSI_SyncManager.isSyncEnabled()) {
      void 0;
      return false;
    }
    if (_pushInProgress) {
      // FIX-PUSH-QUEUE : rejouer ce push à la fin de celui en vol au lieu de le perdre
      _pendingPushTabs[tabId] = true;
      void 0;
      return false;
    }
    _pushInProgress = true;
    try {
      // === CSI-ME === (certification 2026-07-03) en mode partition, le serveur EXIGE baseRev
      // (gate V7). Si la révision de cet onglet est inconnue (onglet de fond d'une cascade,
      // jamais re-navigué depuis l'acquisition du verrou), on l'amorce AVANT de pousser —
      // sinon 400 LEGACY_CLIENT et orphelins. No-op hors mode multi-entités et si déjà connue.
      if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity() && typeof _tabRevisions[tabId] !== 'number') {
        await _csiEnsureTabRevision(tabId);
      }
      // GOV-NIS2 S2 (audit 2026-08-20, BLOQUANT B4) : app-config exige TOUJOURS un baseRev,
      // même hors multi-entités — la section « fourre-tout » n'est jamais repull en session,
      // et un rejeu mono basé sur une RAM périmée écrasait silencieusement les 12 clés
      // (niveau CyFun, archive d'évaluations, verrous). Avec baseRev, le serveur répond
      // 409 et le parachute prend le relais au lieu d'accepter l'écrasement.
      if (tabId === 'app-config' && typeof _tabRevisions[tabId] !== 'number') {
        await _csiEnsureTabRevision(tabId);
      }
      var delays = [1000, 2000, 4000]; // backoff exponentiel
      // === CSI-ME === (audit co-édition 2026-08-05, BLOQUANT) Empreinte du PÉRIMÈTRE à
      // l'entrée. `tabData` est figé par l'appelant, mais l'URL (entity=) et l'écho étaient
      // re-résolus à CHAQUE tentative : avec 4 tentatives × 8 s de timeout + backoff, une
      // bascule de périmètre pendant la fenêtre (~39 s) faisait écrire les données de la
      // partition A dans la partition B — l'écho, recalculé, validait lui-même l'erreur.
      // Même idiome que la présence (lock-manager.js:109/120), qui garde déjà correctement.
      var _scopeAtSend = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
        ? window.CSI_EntityManager.echoId() : null;
      var _scopeChanged = function() {
        var now = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
          ? window.CSI_EntityManager.echoId() : null;
        return now !== _scopeAtSend;
      };
      for (var attempt = 0; attempt <= maxRetries; attempt++) {
        // Abandon SANS écrire : l'onglet reste « dirty » (rien n'est perdu, le parachute
        // et le prochain cycle d'édition du bon périmètre le reprendront).
        if (window._csiScopeSwitching || _scopeChanged()) {
          void 0;
          _csiDirtyTabs[tabId] = true; _updateUnsavedFlag();
          return false;
        }
        try {
          // FIX-OPTIMISTIC-REV (2026-06-11) : joindre la révision serveur connue (baseRev)
          var _pushBody = { tabId: tabId, data: tabData };
          if (typeof _tabRevisions[tabId] === 'number') _pushBody.baseRev = _tabRevisions[tabId];
          // FIX-DATA-EPOCH (audit 2026-07-02, C1) : joindre l'époque de l'état sur lequel on
          // s'est basé — le serveur refuse (409 epochConflict) si un import/restore a eu lieu depuis.
          if (window.appData && typeof window.appData._dataEpoch === 'number') _pushBody.dataEpoch = window.appData._dataEpoch;
          // === CSI-ME === écho d'identité de partition (V16) + entity= ciblé (compte
          // principal en périmètre sous-entité). No-op hors mode multi-entités.
          var _emPushUrl = './data/sync.php?action=data-push';
          if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
            var _emExtra = window.CSI_EntityManager.pushBodyExtra();
            for (var _ek in _emExtra) { if (Object.prototype.hasOwnProperty.call(_emExtra, _ek)) _pushBody[_ek] = _emExtra[_ek]; }
            _emPushUrl = window.CSI_EntityManager.appendEntityParam(_emPushUrl);
          }
          var resp = await fetchWithTimeout(_emPushUrl, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': window.CSI_AuthManager.getAuthHeader()
            },
            body: JSON.stringify(_pushBody)
          });
          if (resp && resp.ok) {
            if (window.CSI_Storage) window.CSI_Storage.updateLastPush();
            // FIX-OPTIMISTIC-REV : mémoriser la nouvelle révision confirmée par le serveur
            try {
              var _okBody = await resp.json();
              if (_okBody && typeof _okBody.rev === 'number') _tabRevisions[tabId] = _okBody.rev;
              // FIX-REV-FAILCLOSED (audit 2026-08-17, C5) : 200 sans rev lisible — le serveur
              // a écrit N+1 mais on ne le sait pas. Garder N garantirait un 409 au prochain
              // push ; on OUBLIE la révision → ré-amorçage propre via _csiEnsureTabRevision.
              else delete _tabRevisions[tabId];
            } catch (e) { delete _tabRevisions[tabId]; /* corps non-JSON : même règle */ }
            // Ne nettoyer que si aucune NOUVELLE modification n'attend pour cet onglet
            if (!_pendingPushTabs[tabId]) {
              delete _csiDirtyTabs[tabId];
              _updateUnsavedFlag();
            }
            void 0;
            return true;
          }
          if (resp && resp.status === 423) {
            // FIX-LOCK-423 (audit 2026-06-10, bundle.js:12732) : avec EDIT_LOCK_ENFORCED=true
            // le serveur répond 423 (verrou non détenu) — pas de retry aveugle : message
            // dédié + resynchronisation de l'état du verrou (si la perte est confirmée,
            // l'événement lock-lost fera sortir du mode édition). Données conservées en
            // mémoire (onglet reste dirty) — aucune perte.
            // FIX-TOAST-423-CLUSTER (audit co-édition 2026-07-17, §7) : en mode édition,
            // « Reprenez la main » était absurde (on l'a déjà) et déclenchait le chemin qui
            // remplace l'écran. Le serveur nomme la grappe refusée ({cluster}, + {lock} si
            // détenue) : on dit QUEL module ouvrir pour que la file en attente s'y vide
            // (vérifié au banc : naviguer vers un écran de la grappe suffit).
            var _lockBody = null;
            try { _lockBody = await resp.json(); } catch (e) { /* corps absent : message générique */ }
            var _refusedCluster = _lockBody && _lockBody.cluster;
            if (typeof editModeEnabled !== 'undefined' && editModeEnabled && _refusedCluster === 'config') {
              // (Lot 3) la grappe 'config' n'a AUCUN écran — « ouvrez un écran de ce
              // module » serait un conseil impossible. Message de configuration dédié
              // (rejeu automatique par l'autosave dès que la grappe se libère).
              showToast2(t('toasts.configNotPersisted', "Configuration non enregistrée sur le serveur (un autre utilisateur édite) — elle sera appliquée à votre prochaine entrée en mode édition."), 'warning', 9000);
            } else if (typeof editModeEnabled !== 'undefined' && editModeEnabled && _refusedCluster && typeof _csiClusterLabel === 'function') {
              var _clusterName = _csiClusterLabel(_refusedCluster);
              var _holderName = _lockBody.lock && _lockBody.lock.userName;
              if (_holderName) {
                showToast2(t('toasts.editLockPushRefusedClusterBusy', 'Module « {{module}} » occupé par {{name}} — vos modifications restent en mémoire et seront enregistrées via un écran de ce module quand il sera libre.').replace('{{module}}', _clusterName).replace('{{name}}', _holderName), 'error', 12000);
              } else {
                showToast2(t('toasts.editLockPushRefusedCluster', 'Module « {{module}} » non détenu — vos modifications restent en mémoire. Ouvrez un écran de ce module pour les enregistrer.').replace('{{module}}', _clusterName), 'error', 12000);
              }
            } else {
              showToast2(t('toasts.editLockPushRefused', 'Verrou d\'édition non détenu — vos modifications restent en mémoire et ne sont pas enregistrées sur le serveur. Reprenez la main (mode édition) pour les enregistrer ; ne fermez pas l\'onglet avant.'), 'error', 12000);
            }
            if (window.CSI_EditLockManager && window.CSI_EditLockManager.refreshStatus) {
              window.CSI_EditLockManager.refreshStatus();
            }
            return false;
          }
          if (resp && resp.status === 403) {
            // === CSI-ME === (audit co-édition 2026-07-03, MAJEUR) 403 sur data-push :
            // pas de retry (l'autosave rejouait un toast générique trompeur en boucle).
            var _forbidden = null;
            try { _forbidden = await resp.json(); } catch (e) { /* ignore */ }
            // GOV-NIS2 S1 (audit 2026-08-20, MAJEUR M13) : le journal (global-meta) est un
            // canal best-effort — un viewer, une entité archivée, un droit de section ou un
            // compte non habilité au transverse ne pourront JAMAIS le pousser. Sans purge,
            // l'onglet restait dirty : toast d'erreur rejoué à chaque autosave + faux
            // « modifications non enregistrées » à la fermeture, pour une pure consultation.
            // Purge SILENCIEUSE quel que soit le code : les entrées restent locales.
            if (tabId === GLOBAL_META_TAB) {
              delete _csiDirtyTabs[tabId]; delete _pendingPushTabs[tabId]; _updateUnsavedFlag();
              void 0;
              return false;
            }
            if (_forbidden && _forbidden.code === 'SECTION_READONLY') {
              // Droits par section d'une sous-entité : le serveur n'acceptera JAMAIS cette
              // écriture — message dédié, sortir l'onglet de la file dirty, re-pull de
              // l'état autoritaire pour que l'écran cesse d'afficher un état non enregistré.
              showToast2(t('toasts.sectionReadonly', "Cette section est en LECTURE SEULE pour votre périmètre — vos modifications n'ont pas été enregistrées."), 'error', 12000);
              delete _csiDirtyTabs[tabId]; delete _pendingPushTabs[tabId]; _updateUnsavedFlag();
              try {
                if (typeof loadData === 'function') {
                  await loadData();
                  if (typeof _csiResetTabRevisions === 'function') _csiResetTabRevisions();
                  if (typeof _rerenderCurrentTab === 'function' && typeof _currentActiveTab !== 'undefined' && _currentActiveTab) _rerenderCurrentTab(_currentActiveTab);
                }
              } catch (e) { /* re-pull best-effort */ }
              return false;
            }
            // === CSI-ME === (P5, analyse de cohérence 2026-08-05) Le serveur nomme
            // précisément la cause du refus ; le client la rendait comme un « droits
            // insuffisants » opaque. Avec plusieurs entités et des dizaines de comptes,
            // ces deux cas sont FRÉQUENTS et ACTIONNABLES : les dire économise un ticket.
            var _fcode = _forbidden && _forbidden.code;
            if (_fcode === 'ENTITY_ARCHIVED') {
              showToast2(t('toasts.pushEntityArchived', "Ce périmètre est ARCHIVÉ : il est en lecture seule. Un administrateur doit le réactiver dans Réglages ▸ Multi-entités pour permettre les modifications."), 'error', 14000);
            } else if (_fcode === 'ENTITY_UNKNOWN') {
              // Cas RÉEL et vérifié au banc : l'administrateur archive une sous-entité
              // pendant que l'un de ses comptes y travaille. Le compte n'est plus
              // résoluble et TOUTES ses écritures échouent — sans ce message il ne
              // voyait qu'un « droits insuffisants » incompréhensible, et son travail
              // en cours restait à l'écran sans jamais partir.
              showToast2(t('toasts.pushEntityUnknown', "Votre périmètre n'est plus disponible (il vient d'être archivé ou supprimé). Vos modifications ne peuvent plus être enregistrées — contactez un administrateur avant de fermer l'onglet."), 'error', 20000);
            } else if (_fcode === 'CROSS_EDIT_FORBIDDEN') {
              showToast2(t('toasts.pushCrossEdit', "Votre compte n'est pas habilité à modifier les données des sous-entités. Un administrateur peut vous l'accorder dans Réglages ▸ Utilisateurs ▸ Entité(s)."), 'error', 14000);
            } else {
              showToast2(t('toasts.pushForbidden', 'Écriture refusée par le serveur (droits insuffisants) — modifications non enregistrées.'), 'error', 10000);
            }
            return false;
          }
          if (resp && resp.status === 409) {
            // FIX-OPTIMISTIC-REV (2026-06-11) : conflit de révision — un autre utilisateur a
            // modifié cet onglet. PARACHUTE : on n'écrase JAMAIS la version plus récente.
            // Sauvegarde locale des modifications refusées + rechargement de l'état frais.
            var _conflict = null;
            try { _conflict = await resp.json(); } catch (e) { /* ignore */ }
            if (_conflict && _conflict.code === 'ENTITY_CONTEXT_CHANGED') {
              // === CSI-ME === (audit co-édition 2026-07-03, MAJEUR) le rattachement du compte
              // a changé (réassignation par l'admin) — ce n'est PAS un conflit d'édition. Le
              // parachute générique affichait « modifiées par un autre utilisateur » (cause
              // fausse) et rechargeait les données de la NOUVELLE partition sous l'ancien
              // bandeau. Sauvegarde de l'onglet puis reload : le /me ré-ingère le contexte.
              try {
                var _eccBkp = JSON.stringify({ _tabId: tabId, _entityContextChangedAt: new Date().toISOString(), data: tabData }, null, 2);
                try { localStorage.setItem('smsi_conflict_' + tabId + _csiBackupScopeTag(), _eccBkp); } catch (e2) { /* quota */ }
                if (typeof downloadJsonBackup === 'function') downloadJsonBackup(_eccBkp, 'smsi_conflit_rattachement_' + tabId + _csiBackupScopeTag() + '.json');
              } catch (e) { /* best-effort */ }
              showToast2(t('toasts.entityContextChanged', "Votre rattachement d'entité a changé — l'application va se recharger pour appliquer votre nouveau périmètre. Une copie de vos modifications non enregistrées a été téléchargée."), 'warning', 8000);
              setTimeout(function() { try { window.onbeforeunload = null; } catch (e) {} location.reload(); }, 3000);
              return false;
            }
            if (_conflict && _conflict.epochConflict) {
              // FIX-DATA-EPOCH (audit 2026-07-02, C1) : un import/restauration serveur a remplacé
              // TOUT l'état → re-pull autoritaire complet (loadData rafraîchit appData._dataEpoch,
              // ce qui évite la boucle de 409) après avoir sauvegardé l'onglet en cours. On ne
              // passe PAS par _handlePushConflict (parachute par onglet inadapté à un remplacement full).
              var _epBkp = null;
              try {
                _epBkp = JSON.stringify({ _tabId: tabId, _epochConflictAt: new Date().toISOString(), data: tabData }, null, 2);
                try { localStorage.setItem('smsi_conflict_' + tabId + _csiBackupScopeTag(), _epBkp); } catch (e2) { /* quota */ }
              } catch (e) { /* backup best-effort */ }
              var _epReloaded = false;
              try {
                if (typeof loadData === 'function') {
                  await loadData();
                  _epReloaded = true;
                  if (typeof _csiResetTabRevisions === 'function') _csiResetTabRevisions();
                  delete _csiDirtyTabs[tabId]; delete _pendingPushTabs[tabId]; _updateUnsavedFlag();
                  if (typeof updateDashboard === 'function') updateDashboard();
                  var _ecTab = (typeof _currentActiveTab !== 'undefined' && _currentActiveTab) ? _currentActiveTab : 'dashboard';
                  if (typeof _rerenderCurrentTab === 'function') _rerenderCurrentTab(_ecTab);
                }
              } catch (e) { void 0; }
              // FIX-EPOCH-QUIET (revue adversariale 2026-07-17) : ne télécharger le fichier de
              // secours et n'alerter QUE si le push refusé portait un VRAI delta par rapport à
              // l'état serveur re-pullé. Depuis que les écritures de configuration aboutissent
              // (FIX-CONFIG-LOCK-EDITING), un simple réglage d'un collègue incrémente l'époque :
              // les pushes mécaniques sans changement déclenchaient un téléchargement mystère et
              // un toast anxiogène pour rien. Le filet localStorage est conservé dans tous les cas.
              var _epHasDelta = true;
              if (_epReloaded) {
                try {
                  var _epFresh = {};
                  Object.keys(tabData || {}).forEach(function(k) { _epFresh[k] = appData[k]; });
                  _epHasDelta = JSON.stringify(tabData) !== JSON.stringify(_epFresh);
                } catch (e) { /* comparaison impossible : prudence, alerter */ }
              }
              if (_epHasDelta) {
                try { if (_epBkp && typeof downloadJsonBackup === 'function') downloadJsonBackup(_epBkp, 'smsi_conflit_import_' + tabId + _csiBackupScopeTag() + '.json'); } catch (e) { /* best-effort */ }
                showToast2(t('toasts.importEpochConflict', "Les données ont été rechargées suite à un import, une restauration ou une modification de configuration d'un autre utilisateur. Une copie de vos modifications non enregistrées a été sauvegardée."), 'warning', 15000);
              }
              return false;
            }
            _handlePushConflict(tabId, tabData, _conflict);
            return false; // pas de retry : le parachute a re-synchronisé l'onglet
          }
          if (resp && resp.status === 402) {
            showToast2(t('toasts.licenseExpired', 'Licence expirée — mode lecture seule.'), 'error', 10000);
            return false;
          }
          if (resp && resp.status === 400) {
            // === CSI-ME === (certification 2026-07-03) filet anti-orphelin : le serveur a
            // refusé un push scopé sans baseRev (gate V7 LEGACY_CLIENT) — l'amorçage a dû
            // échouer (réseau). On ré-amorce la révision et on rejoue (JAMAIS d'orphelin
            // silencieux : sans ce filet, une cascade partielle laissait des liens cassés).
            var _b400 = null;
            try { _b400 = await resp.json(); } catch (e) { /* corps non-JSON */ }
            if (_b400 && _b400.code === 'LEGACY_CLIENT') {
              delete _tabRevisions[tabId];
              await _csiEnsureTabRevision(tabId);
              if (typeof _tabRevisions[tabId] === 'number' && attempt < maxRetries) {
                continue; // rev désormais connue → rejouer avec baseRev
              }
            }
            // (P5) KEYS_OUT_OF_SECTION = payload hors carte canonique : c'est un DÉFAUT
            // CLIENT, pas une erreur utilisateur. Il était totalement muet (log console) :
            // l'utilisateur voyait sa saisie « non enregistrée » sans la moindre explication
            // et sans que personne ne soit alerté. On le dit, et on sort l'onglet de la file.
            if (_b400 && _b400.code === 'KEYS_OUT_OF_SECTION') {
              void 0;
              showToast2(t('toasts.pushKeysOutOfSection', "Anomalie applicative : le serveur a refusé le contenu de cette section. Vos modifications ne sont pas enregistrées — signalez-le au support avec le nom de l'écran."), 'error', 15000);
              delete _csiDirtyTabs[tabId]; delete _pendingPushTabs[tabId]; _updateUnsavedFlag();
            }
            void 0;
            return false; // autre 400 (clé hors section, tabId invalide…) : pas de retry aveugle
          }
          void 0;
        } catch (err) {
          void 0;
        }
        // Retry avec délai si pas le dernier essai
        if (attempt < maxRetries) {
          await new Promise(function(resolve) { setTimeout(resolve, delays[attempt] || 4000); });
        }
      }
      // Tous les retries épuisés
      showToast2(t('toasts.syncFailedRetry', 'Échec de synchronisation serveur — vos données sont en mémoire. Ne fermez pas le navigateur.'), 'error', 15000);
      return false;
    } finally {
      _pushInProgress = false;
      // FIX-PUSH-QUEUE : rejouer les push mis en attente pendant celui-ci
      _drainPendingPushes();
    }
  }

  // beforeunload : avertissement navigateur si données non sauvegardées
  if (window.location.protocol !== 'file:') {
    window.addEventListener('beforeunload', function(e) {
      // FIX-RA-COMMIT-EXITS : la saisie DOM du wizard ne marquait jamais « dirty » — F5
      // fermait sans avertir. Le commit mute appData et pose le flag SYNCHRONEMENT :
      // le prompt du navigateur s'arme, et le push best-effort part (il aboutit si
      // l'utilisateur annule la fermeture).
      try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
      if (!window._csiUnsavedServerData) return;
      e.preventDefault();
      e.returnValue = '';
    });
  }

  // M28 (audit 2026-07-10) : garde multi-onglets en MODE LOCAL. Deux onglets ouverts sur le
  // même navigateur écrivent chacun le blob COMPLET (smsi_data) ; sans coordination, l'onglet
  // resté en arrière-plan écrase silencieusement les sauvegardes de l'autre. On écoute
  // 'storage' (émis uniquement dans les AUTRES onglets) : bannière non destructive invitant à
  // recharger (« refus propre > automatisme malin »), et parachute de la version de l'autre
  // onglet avant tout écrasement (voir saveData). Aucun effet en mode serveur (déjà couvert
  // par les révisions _rev/409 et le verrou d'édition).
  var _csiOtherTabWrote = false;
  function _csiIsLocalMode() {
    try { return !(typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()); }
    catch (e) { return window.location.protocol === 'file:'; }
  }
  function _csiShowMultiTabBanner() {
    if (document.getElementById('csiMultiTabBanner') || !document.body) return;
    var b = document.createElement('div');
    b.id = 'csiMultiTabBanner';
    b.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:99999;background:#d97706;color:#fff;padding:10px 16px;font-size:0.9rem;text-align:center;box-shadow:0 -2px 8px rgba(0,0,0,.25);';
    var msg = document.createElement('span');
    msg.textContent = (typeof t === 'function') ? t('toasts.multiTabConflict', 'Données modifiées dans un autre onglet. Rechargez cette page pour éviter d\'écraser votre travail.') : 'Données modifiées dans un autre onglet — rechargez.';
    var btn = document.createElement('button');
    btn.textContent = (typeof t === 'function') ? t('common.reload', 'Recharger') : 'Recharger';
    btn.style.cssText = 'margin-left:12px;padding:4px 12px;border:1px solid #fff;border-radius:6px;background:transparent;color:#fff;cursor:pointer;';
    btn.onclick = function() { try { window.onbeforeunload = null; } catch (e) {} location.reload(); };
    b.appendChild(msg); b.appendChild(btn);
    document.body.appendChild(b);
  }
  window.addEventListener('storage', function(ev) {
    if (!ev || (ev.key !== 'smsi_data' && ev.key !== 'smsi_last_save')) return;
    if (!_csiIsLocalMode()) return;
    _csiOtherTabWrote = true;
    _csiShowMultiTabBanner();
  });

  // PASS2-307: Track last save result for modal close guard
  var _lastSaveFailed = false;
  // M13 (audit 2026-07-10) : évite l'inondation du dossier Téléchargements. Sans ce flag,
  // chaque saveData() en situation de quota plein (autosave 30 s + ~89 sites UI) téléchargeait
  // un backup identique. Un seul backup par ÉPISODE de saturation ; remis à zéro dès qu'une
  // sauvegarde réussit.
  var _quotaBackupDownloaded = false;
  function saveData() {
    _lastSaveFailed = false;
    if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) index recherche périmé après mutation
    // Protection anti-abus en mode démo
    if (DEMO_MODE) {
      if (!demoRateLimiter.checkAction('save')) {
        demoRateLimiter.showRateLimitMessage();
        _lastSaveFailed = true; return false;
      }
      if (!checkDemoStorageLimit()) {
        _lastSaveFailed = true; return false;
      }
    }

    // BUG-301: Validation blocks save on critical errors
    if (typeof window.validateBeforeSave === 'function') {
      const validation = window.validateBeforeSave(appData);
      if (!validation.valid && validation.errors && validation.errors.length > 0) {
        void 0;
        var criticalErrors = validation.errors.filter(function(e) { return e && (typeof e === 'object' ? e.severity === 'critical' : /requis|missing|corrupt/i.test(e)); });
        if (criticalErrors.length > 0) {
          showToast2(t('validation.blocked', 'Sauvegarde bloquée : ') + (typeof criticalErrors[0] === 'string' ? criticalErrors[0] : criticalErrors[0].message), 'error');
          _lastSaveFailed = true; return false;
        }
        // Non-critical errors: warn but allow save
        showToast2(t('validation.warnings', 'Données sauvegardées avec avertissements'), 'warning');
      }
      if (validation.warnings && validation.warnings.length > 0) {
        void 0;
      }
    }

    warnMissingSubcategories();
    ensureNis2Plan();
    ensureCriticalAssets();

    // ──────────────────────────────────────────────────────────
    // MODE SERVEUR : push direct au serveur, PAS de localStorage
    // ──────────────────────────────────────────────────────────
    var _isServer = typeof isServerModeUnlocked === 'function' && isServerModeUnlocked();
    if (_isServer) {
      const autoSaveStatus = document.getElementById("autoSaveStatus");
      if (autoSaveStatus) {
        // FIX-I18N-AUTOSAVE (audit 2026-06-10, bundle.js:12866) : indicateur traduit
        autoSaveStatus.textContent = t('autoSave.saving', 'Sauvegarde en cours...');
        autoSaveStatus.style.opacity = "1";
      }

      if (window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
        var presenceMgr = window.CSI_PresenceManager || window.CSI_LockManager;
        var currentTab = _currentActiveTab || 'dashboard';
        // FIX-GLOBALMETA-SYNC (audit 2026-06-10, lock-manager.js:19) : auditLog et
        // kpiHistory n'appartiennent à aucun onglet et n'étaient JAMAIS poussés par la
        // sauvegarde granulaire — le journal d'audit disparaissait au rechargement et
        // n'était jamais visible des autres utilisateurs. Push d'un pseudo-onglet
        // 'global-meta' (fusionné comme les autres tab_*.json au pull complet du login).
        if (window._csiGlobalMetaDirty) {
          window._csiGlobalMetaDirty = false;
          // global-meta reste poussé même en consultation : c'est le canal APPEND-ONLY
          // (fusion serveur sans verrou) — un lecteur doit pouvoir journaliser son export
          // ou sa connexion sans détenir la moindre grappe.
          _serverDataPushWithRetry(GLOBAL_META_TAB, _extractTabData(GLOBAL_META_TAB), presenceMgr, 3);
        }
        // === CSI-CO === (audit co-édition 2026-08-05, MAJEUR — famille « écriture depuis un
        // chemin de lecture ») En consultation, ne JAMAIS pousser l'onglet courant. Plusieurs
        // chemins de pure lecture appellent saveData() via addAuditEntry (export JSON, Ctrl+S,
        // seed des matrices d'impact au rendu…) : le push partait sans verrou → 423, toast
        // d'erreur d'écriture et onglet gelé (marqué « dirty ») pour un simple lecteur.
        // Le serveur l'aurait de toute façon refusé : on s'arrête AVANT, sans bruit.
        // Corollaire : un handler d'écriture qui aurait oublié sa garde devient inoffensif.
        var _roSave = (typeof isReadOnlyMode === 'function') && isReadOnlyMode();
        if (_roSave) {
          if (autoSaveStatus) { autoSaveStatus.textContent = ''; autoSaveStatus.style.opacity = '0'; }
          return true;   // rien à pousser : ce n'est pas un échec
        }
        if (currentTab !== 'dashboard' && currentTab !== 'settings') {
          var tabKeys = presenceMgr && presenceMgr.getTabDataKeys ? presenceMgr.getTabDataKeys()[currentTab] : null;
          if (tabKeys) {
            var tabData = {};
            tabKeys.forEach(function(key) { tabData[key] = appData[key]; });
            // === CSI-CO === (courtoisie §12) + FIX-COURTESY-TOUCH (2026-07-17, N3) :
            // saveData ne vaut « travail RÉEL » que si les données de CET onglet ont changé
            // depuis son affichage — l'autosave 30 s passait ici sur un delta d'une AUTRE
            // grappe et marquait l'onglet affiché à tort (grappes collantes). Le push
            // mécanique de bascule d'onglet applique le même critère de delta.
            var _elmT = window.CSI_EditLockManager;
            if (_elmT && _elmT.markTabTouched && _elmT.isCoedit && _elmT.isCoedit() && _csiTabDataChanged(currentTab)) _elmT.markTabTouched(currentTab);
            // Push direct — l'arbitrage de l'édition concurrente est géré par CSI_EditLockManager
            // (voir modules/edit-lock-manager.js + sync.php endpoint edit-lock-acquire).
            // FIX-PUSH-QUEUE : _csiUnsavedServerData est désormais dérivé des onglets
            // dirty (géré dans _serverDataPushWithRetry), plus d'écrasement direct ici.
            // Refonte header 2026-07 : pastille sync en « saving » (pulse) pendant le push
            // asynchrone — c'est le seul point d'accroche réel (le reste de saveData est
            // synchrone, un état « saving » n'y serait jamais peint).
            _syncDotSetState("saving", t('header.syncSaving', 'Sauvegarde…'));
            _serverDataPushWithRetry(currentTab, tabData, presenceMgr, 3).then(function(ok) {
              if (autoSaveStatus) {
                // FIX-I18N-AUTOSAVE (audit 2026-06-10) : indicateur traduit
                autoSaveStatus.textContent = ok ? t('autoSave.savedToServer', 'Sauvegardé sur le serveur') : t('autoSave.syncFailed', 'Échec synchronisation');
                setTimeout(function() { autoSaveStatus.style.opacity = "0"; }, 2000);
              }
              if (ok) updateAutoSaveIndicator();
              else _syncDotSetState("error", t('header.syncError', 'Échec sync'));
            });
          } else {
            if (autoSaveStatus) {
              autoSaveStatus.textContent = "";
              autoSaveStatus.style.opacity = "0";
            }
          }
        }
      }
      return true;
    }

    // ──────────────────────────────────────────────────────────
    // MODE LOCAL : sauvegarde localStorage (comportement inchangé)
    // ──────────────────────────────────────────────────────────
    // (audit global 2026-07-03, perf) sérialisation COMPLÈTE d'appData nécessaire ICI seulement
    // (quota + localStorage). En mode serveur (return ci-dessus) elle était calculée puis jetée
    // à CHAQUE saveData() (autosave 30s + 89 sites) — gaspillage O(taille appData) inutile.
    const serialized = JSON.stringify(appData);
    const dataSize = serialized.length * 2; // UTF-16
    const quotaCheck = checkLocalStorageQuota(dataSize);
    if (!quotaCheck.ok) {
      const usageMB = (quotaCheck.usage / (1024 * 1024)).toFixed(2);
      const limitMB = (quotaCheck.limit / (1024 * 1024)).toFixed(2);
      showToast2(t('settings.storageInsufficient', 'Espace de stockage insuffisant').replace('{{usage}}', usageMB).replace('{{limit}}', limitMB) + ` (${usageMB}MB / ${limitMB}MB)`, "error");
      // M13 : un seul backup par épisode de saturation (pas à chaque tick d'autosave).
      if (!_quotaBackupDownloaded) {
        downloadJsonBackup(serialized, "smsi_backup_quota_exceeded.json");
        _quotaBackupDownloaded = true;
      }
      _lastSaveFailed = true; return false;
    }
    if (quotaCheck.warning) {
      const usageMB = (quotaCheck.projected / (1024 * 1024)).toFixed(2);
      showToast2(t('settings.storageWarning', 'Attention : stockage presque plein') + ` (${usageMB}MB). ` + t('settings.storageExportHint', 'Pensez à exporter vos données.'), "warning");
    }

    try {
      // M28 : si un autre onglet a écrit depuis, sauvegarder SA version avant de l'écraser
      // (aucune perte silencieuse — l'utilisateur récupère les deux versions).
      if (_csiOtherTabWrote) {
        try {
          var _otherRaw = localStorage.getItem("smsi_data");
          if (_otherRaw && _otherRaw !== serialized) {
            localStorage.setItem("smsi_multitab_backup", JSON.stringify({ _at: new Date().toISOString(), data: _otherRaw }));
            if (typeof downloadJsonBackup === 'function') downloadJsonBackup(_otherRaw, "smsi_autre_onglet.json");
          }
        } catch (e) { /* quota : la version courante reste prioritaire */ }
        _csiOtherTabWrote = false;
      }
      localStorage.setItem("smsi_data", serialized);
      localStorage.setItem("smsi_last_save", (/* @__PURE__ */ new Date()).toISOString());
      const autoSaveStatus = document.getElementById("autoSaveStatus");
      if (autoSaveStatus) {
        // FIX-I18N-AUTOSAVE (audit 2026-06-10) : indicateur traduit
        autoSaveStatus.textContent = t('autoSave.saved', 'Sauvegarde réalisée');
        autoSaveStatus.style.opacity = "1";
        setTimeout(() => {
          autoSaveStatus.style.opacity = "0";
        }, 2e3);
      }
      refreshStorageHealth();
      _quotaBackupDownloaded = false; // M13 : sauvegarde OK → réarmer le backup pour un futur épisode
      return true;
    } catch (error) {
      void 0;
      const isQuotaError = error.name === 'QuotaExceededError' ||
                          error.code === 22 ||
                          error.code === 1014 ||
                          (error.name === 'NS_ERROR_DOM_QUOTA_REACHED');
      if (isQuotaError) {
        showToast2(t('toasts.quotaExceeded', 'Quota localStorage dépassé. Vos données ont été téléchargées en backup.'), "error");
      } else {
        showToast2(t('toasts.saveError', 'La sauvegarde a échoué. Vos données sont conservées localement.'), "error");
      }
      // M13 : idem — un seul backup d'urgence par épisode (le catch se répète à chaque autosave).
      if (!_quotaBackupDownloaded) {
        downloadJsonBackup(serialized, "smsi_backup_emergency.json");
        _quotaBackupDownloaded = true;
      }
      _lastSaveFailed = true; return false;
    }
  }
  function loadThreats() {
    // En mode serveur, les données sont déjà dans appData (chargées par loadData())
    var _isServer = window.CSI_Storage && window.CSI_Storage.isServerMode();
    var threats;
    if (_isServer) {
      threats = appData.threats || [];
    } else {
      const saved = localStorage.getItem("smsi_data");
      if (!saved) return;
      try {
        const data = JSON.parse(saved);
        threats = data.threats || [];
      } catch (e) {
        void 0;
        return;
      }
    }
    appData.threats = threats.map(t => {
      const prob = parseInt(t.likelihood, 10) ||
        (t.likelihood === "low" ? 3 : t.likelihood === "medium" ? 4 : t.likelihood === "high" ? 5 : 1);
      const imp = parseInt(t.impact, 10) ||
        (t.impact === "minor" ? 2 : t.impact === "major" ? 4 : t.impact === "critical" ? 5 : 1);
      const score = imp * prob;
      return { ...t, likelihood: prob, impact: imp, score, level: calculateRiskLevel(score) };
    });
  }
  function saveThreats() {
    saveData();
  }

  // modules/controls.js
function getControlStatusOptions() {
  return [
    { value: "non_implemente", label: t('controlStatuses.nonImplemente') },
    { value: "en_cours", label: t('controlStatuses.enCours') },
    { value: "implemente", label: t('controlStatuses.implemente') },
    { value: "verifie", label: t('controlStatuses.verifie') }
  ];
}
var CONTROL_STATUS_OPTIONS = getControlStatusOptions();
function getDomainOptions() {
  return [
    { value: "Organisationnel", label: t('domains.organisationnel') },
    { value: "Humain", label: t('domains.humain') },
    { value: "Physique", label: t('domains.physique') },
    { value: "Technologique", label: t('domains.technologique') },
    { value: "Non classé", label: t('domains.nonClasse') }
  ];
}
let DOMAIN_OPTIONS = getDomainOptions();
function getCmmLevelOptions() {
  return [
    { value: "Initial", label: t('cmmLevels.initial') },
    { value: "Répétable", label: t('cmmLevels.repetable') },
    { value: "Défini", label: t('cmmLevels.defini') },
    { value: "Géré", label: t('cmmLevels.gere') },
    { value: "Optimisé", label: t('cmmLevels.optimise') }
  ];
}
var CMM_LEVEL_OPTIONS = getCmmLevelOptions();

  // Vérifie si un contrôle est déclaré non-applicable dans la SoA
  function isControlNonApplicable(controlId) {
    const soaEntry = appData.soa.find(e => e.controlId === controlId);
    return soaEntry && soaEntry.applicable === false;
  }

  // Récupère la justification SoA pour un contrôle
  function getSoaJustification(controlId) {
    const soaEntry = appData.soa.find(e => e.controlId === controlId);
    return soaEntry ? soaEntry.justification || "" : "";
  }

  // Navigue vers l'onglet SoA et met en évidence le contrôle
  function navigateToSoaControl(controlId) {
    // Naviguer vers l'onglet SoA
    const soaNavBtn = document.querySelector('.nav__item[data-tab="soa"]');
    if (soaNavBtn) {
      soaNavBtn.click();
      // Après navigation, mettre en évidence la ligne du contrôle
      setTimeout(() => {
        const soaRow = document.querySelector(`tr[data-control-id="${controlId}"]`);
        if (soaRow) {
          soaRow.scrollIntoView({ behavior: "smooth", block: "center" });
          soaRow.classList.add("highlight-row");
          setTimeout(() => soaRow.classList.remove("highlight-row"), 2500);
        }
      }, 150);
    }
  }

  function loadControlsTable() {
    const tbody = document.getElementById("controlsTableBody");
    if (!tbody) return;
    tbody.innerHTML = "";

    const _f = SectionToolbar.getFilters("controls");
    const _q = (SectionToolbar.getSearch("controls") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const catFilter = _f.category || "";
    const statusFilter = _f.status || "";
    const cmmFilter = _f.cmm || "";
    const capabilityFilter = "";

    var isoControls = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const filtered = isoControls.filter(c => {
      const capabilityMatch = !capabilityFilter || (Array.isArray(c.subcategory) ? c.subcategory.includes(capabilityFilter) : c.subcategory === capabilityFilter);
      return (!catFilter || c.category === catFilter) &&
             (!statusFilter || c.status === statusFilter) &&
             (!cmmFilter || c.cmm === cmmFilter) &&
             (!_q || ((c.reference || "") + " " + (c.title || "")).toLowerCase().indexOf(_q) >= 0) &&
             capabilityMatch;
    });

    const _s = SectionToolbar.getSort("controls");
    const _sortKey = _s ? _s.key : "reference";
    const _sortDir = _s ? _s.dir : "asc";
    const sortedControls = [...filtered].sort((a, b) => {
      let diff = 0;
      const statusOrder = { non_implemente: 0, en_cours: 1, implemente: 2, verifie: 3 };
      const cmmOrder = { "Initial": 0, "R\u00e9p\u00e9table": 1, "D\u00e9fini": 2, "G\u00e9r\u00e9": 3, "Optimis\u00e9": 4 };
      switch (_sortKey) {
        case "title": diff = SortUtils.text(a.title, b.title); break;
        case "status": diff = SortUtils.orderedMap(statusOrder, 99)(a.status, b.status); break;
        case "maturity": diff = SortUtils.orderedMap(cmmOrder, 99)(a.cmm, b.cmm); break;
        default: diff = SortUtils.reference(a.reference, b.reference); break;
      }
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedControls.forEach((control) => {
      const row = document.createElement("tr");
      const actionIds = appData.actions
        .filter((a) => a.linkType === "control" && a.linkId === control.id)
        .map((a) => a.id)
        .join(", ");
      const statusClasses = {
        "non_implemente": "status-badge--non_implemente",
        "en_cours": "status-badge--en_cours",
        "implemente": "status-badge--implemente",
        "verifie": "status-badge--verifie"
      };
      const statusLabels = {};
      CONTROL_STATUS_OPTIONS.forEach(function(o) { statusLabels[o.value] = o.label; });
      const capabilityDisplay = Array.isArray(control.subcategory)
        ? control.subcategory.map(sc => {
            const cap = OPERATIONAL_CAPABILITIES.find((c) => c.key === sc);
            return cap ? cap.label : sc.replace(/_/g, " ");
          }).join(", ")
        : (() => {
            const cap = OPERATIONAL_CAPABILITIES.find((c) => c.key === control.subcategory);
            return cap ? cap.label : (control.subcategory || "").replace(/_/g, " ");
          })();

      // Vérifier si le contrôle est déclaré non-applicable dans la SoA
      const isNonApplicable = isControlNonApplicable(control.id);
      const nonApplicableBadge = isNonApplicable
        ? `<span class="status-badge status-badge--non-applicable" title="${escapeHtml(t('soa.declaredNonApplicable', 'Déclaré non applicable dans la DdA'))}">
             <i class="fas fa-ban"></i> ${escapeHtml(t('soa.nonApplicable', 'Non applicable'))}
           </span>
           <a href="#" class="soa-link" data-control-id="${escapeHtml(control.id)}" title="${escapeHtml(t('soa.viewInSoa', 'Voir dans la Déclaration d\'applicabilité'))}">
             <i class="fas fa-external-link-alt"></i>
           </a>`
        : "";

      // Attributs ISO 27002:2022
      const domain = getControlDomain(control.reference);
      const domainBadgeClass = domain.toLowerCase().replace(/\s+/g, '-');

      // Efficacité
      const effectivenessLabels = {
        effective: "✓ " + t('effectiveness.effective', 'Efficace'),
        partially: "◐ " + t('effectiveness.partial', 'Partiel'),
        ineffective: "✗ " + t('effectiveness.ineffective', 'Non efficace'),
        not_tested: "○ " + t('effectiveness.notTested', 'Non testé')
      };
      const effectivenessClasses = {
        effective: "effectiveness--good",
        partially: "effectiveness--medium",
        ineffective: "effectiveness--bad",
        not_tested: "effectiveness--neutral"
      };

      const controlTypeLabel = control.isPreventive ? t('controlType.preventive', 'Préventif') : control.isDetective ? t('controlType.detective', 'Détectif') : control.isCorrective ? t('controlType.corrective', 'Correctif') : '';
      const controlTypeIcon = control.isPreventive ? 'fa-shield-halved' : control.isDetective ? 'fa-magnifying-glass' : control.isCorrective ? 'fa-wrench' : '';

      row.innerHTML = `
            <td><strong>${escapeHtml(control.numero)}</strong></td>
            <td>
              <div class="control-title">${escapeHtml(isoCtrlTitle(control))} <i class="fas fa-caret-down control-expand-icon" title="${escapeHtml(t('controls.clickToExpand', 'Cliquer pour développer/réduire'))}"></i></div>
              <div class="control-description control-description--collapsed">${escapeHtml(isoCtrlDesc(control))}</div>
              ${nonApplicableBadge ? `<div class="control-soa-indicator">${nonApplicableBadge}</div>` : ""}
              <div class="control-attrs">
                <span class="badge badge--domain badge--${domainBadgeClass}">${escapeHtml(getTranslatedDomain(domain))}</span>
                ${controlTypeLabel ? `<span class="badge badge--type" title="${escapeHtml(controlTypeLabel)}"><i class="fas ${controlTypeIcon}"></i> ${escapeHtml(controlTypeLabel)}</span>` : ''}
                ${capabilityDisplay ? `<span class="control-capability" title="${escapeHtml(capabilityDisplay)}">${escapeHtml(capabilityDisplay.substring(0, 25))}${capabilityDisplay.length > 25 ? '...' : ''}</span>` : ''}
              </div>
            </td>
            <td>${escapeHtml(control.owner || '-')}</td>
            <td><span class="status-badge ${statusClasses[control.status]}">${statusLabels[control.status]}</span></td>
            <td class="cmm-cell">
              <span class="cmm-badge cmm-badge--${escapeHtml((control.cmm || 'initial').toLowerCase().replace('é', 'e'))}">${escapeHtml(control.cmm || 'Initial')}</span>
              ${control.effectiveness ? `<div class="effectiveness ${effectivenessClasses[control.effectiveness] || ''}">${effectivenessLabels[control.effectiveness] || '-'}</div>` : ''}
            </td>
            <td>${escapeHtml(control.lastEvaluation || '-')}</td>
            <td class="evidence-cell">
              ${control.evidence ? `<span class="evidence-yes" title="${escapeHtml(control.evidence)}"><i class="fas fa-check-circle"></i> ${escapeHtml(t('common.yes', 'Oui'))}</span>` : `<span class="evidence-no"><i class="fas fa-times-circle"></i> ${escapeHtml(t('common.no', 'Non'))}</span>`}
              ${control.kpiRef ? `<span class="kpi-ref" title="KPI: ${escapeHtml(control.kpiRef)}"><i class="fas fa-chart-line"></i></span>` : ''}
            </td>
        `;

      // Gestionnaire pour l'expansion de la description
      const descDiv = row.querySelector('.control-description');
      const expandIcon = row.querySelector('.control-expand-icon');
      const toggleDesc = function(e) {
        e.stopPropagation();
        if (descDiv) {
          descDiv.classList.toggle('control-description--collapsed');
          if (expandIcon) expandIcon.classList.toggle('expanded');
        }
      };
      if (descDiv) descDiv.addEventListener('click', toggleDesc);
      if (expandIcon) expandIcon.addEventListener('click', toggleDesc);

      // Gestionnaire pour le lien vers la SoA
      const soaLink = row.querySelector(".soa-link");
      if (soaLink) {
        soaLink.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
          navigateToSoaControl(control.id);
        });
      }

      row.classList.add("row-clickable");
      row.addEventListener("click", () => {
        editControl(control.id);
      });
      tbody.appendChild(row);
    });
  }
  // Génère le HTML des badges de risques liés pour la SoA
  function renderSoaRiskBadges(linkedRiskIds) {
    if (!linkedRiskIds || linkedRiskIds.length === 0) {
      return '<span class="soa-risks__empty">—</span>';
    }
    return linkedRiskIds.map(riskId => {
      const risk = (appData.risks || []).find(r => r.id === riskId);
      if (!risk) return '';
      const rawLevelClass = (risk.level || 'insignifiant').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
      const levelClass = /^[a-zA-Z0-9-]+$/.test(rawLevelClass) ? rawLevelClass : 'unknown';
      return `<span class="soa-risks__badge soa-risks__badge--${levelClass}" title="${escapeHtml(risk.id)}">${escapeHtml(risk.title)}</span>`;
    }).join('');
  }

  // Ouvre la modale pour éditer un contrôle SOA complet (même pattern que editAction/editRisk)
  function editSoaControl(controlId) {
    const entry = appData.soa.find(e => e.controlId === controlId);
    var _isoCtrlsSoa = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const control = _isoCtrlsSoa.find(c => c.id === controlId);
    if (!entry || !control) return;

    // Options des risques pour multiselect
    const riskOptions = (appData.risks || []).map(r => ({
      value: r.id,
      label: `[${r.level}] ${r.title}`
    }));

    // Commentaires existants en lecture seule
    const commentsDisplay = formatCommentsReadonly(entry.comments);

    const fields = [
      { name: "description", label: t('fields.description', 'Description'), type: "textarea", value: isoCtrlTitle(control), readOnly: true },
      { name: "applicable", label: t('soa.colApplicable', 'Applicabilité'), type: "checkbox", value: entry.applicable },
      { name: "source", label: t('soa.colSource', "Source de l'exigence"), type: "select", value: entry.source, options: getSoaSourceOptions() },
      { name: "implStatus", label: t('soa.colImplStatus', "État d'implémentation"), type: "select", value: entry.implStatus, options: getSoaImplStatusOptions() },
      { name: "implDate", label: t('soa.implDate', "Date d'implémentation"), type: "date", value: entry.implDate },
      { name: "owner", label: t('fields.owner', 'Responsable'), type: "text", value: entry.owner, placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(entry.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "linkedRiskIds", label: t('soa.colRisks', 'Risques liés'), type: "multiselect", value: entry.linkedRiskIds || [], options: riskOptions, help: t('soa.multiSelectHelp', 'Maintenez Ctrl (ou Cmd) pour sélectionner plusieurs risques') },
      { name: "evidenceRef", label: t('soa.evidenceRef', 'Référence preuves'), type: "text", value: entry.evidenceRef },
      { name: "lastReviewDate", label: t('soa.lastReviewDate', 'Dernière revue'), type: "date", value: entry.lastReviewDate },
      { name: "justification", label: t('fields.justification', 'Justification'), type: "textarea", value: entry.justification },
      { name: "exclusionReason", label: t('soa.exclusionReason', "Motif d'exclusion"), type: "textarea", value: entry.exclusionReason }
    ];

    // Ajouter commentaires si existants
    if (commentsDisplay) {
      fields.push({ name: "existingComments", label: t('fields.existingComments', 'Commentaires existants'), type: "textarea", value: commentsDisplay, readOnly: true });
    }
    fields.push({ name: "newComment", label: t('fields.newComment', 'Nouveau commentaire'), type: "textarea" });

    openModal(
      t('modals.editSoaControl', 'Modifier le contrôle') + ' — ' + control.reference,
      fields,
      (data) => {
        if (!ensureEditMode("update the SoA")) return false;
        entry.applicable = !!data.applicable;
        entry.source = data.source || '';
        entry.implStatus = data.implStatus || '';
        entry.implDate = data.implDate || '';
        entry.owner = data.owner || '';
        entry.linkedRiskIds = Array.isArray(data.linkedRiskIds) ? data.linkedRiskIds : [];
        entry.evidenceRef = data.evidenceRef || '';
        entry.lastReviewDate = data.lastReviewDate || '';
        entry.justification = data.justification || '';
        entry.exclusionReason = entry.applicable ? '' : (data.exclusionReason || '');
        if (appendComment(entry, data.newComment, 10) === 'max') {
          showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
        }
        addAuditEntry('edit', 'soa', control.reference);
        saveData();
        loadSoaTable();
        updateDashboard();
      }
    );
  }

  // ============================================================
  // Normalisation des entrées SOA pour compatibilité ascendante
  // ============================================================

  /**
   * Normalise une entrée SOA pour s'assurer que tous les champs requis existent
   * Gère la compatibilité avec les anciennes versions qui n'avaient que:
   * - controlId, applicable, justification
   */
  function normalizeSoaEntry(entry) {
    if (!entry || typeof entry !== 'object') {
      return {
        controlId: '',
        applicable: true,
        justification: '',
        linkedRiskIds: [],
        source: '',
        implStatus: '',
        implDate: '',
        exclusionReason: '',
        owner: '',
        evidenceRef: '',
        lastReviewDate: '',
        comments: []
      };
    }
    return {
      controlId: entry.controlId || '',
      applicable: typeof entry.applicable === 'boolean' ? entry.applicable : true,
      justification: entry.justification || '',
      // Nouveaux champs avec valeurs par défaut
      linkedRiskIds: Array.isArray(entry.linkedRiskIds) ? entry.linkedRiskIds : [],
      source: entry.source || '',
      implStatus: entry.implStatus || '',
      implDate: entry.implDate || '',
      exclusionReason: entry.exclusionReason || '',
      owner: entry.owner || '',
      evidenceRef: entry.evidenceRef || '',
      lastReviewDate: entry.lastReviewDate || '',
      comments: Array.isArray(entry.comments) ? entry.comments : []
    };
  }

  /**
   * Normalise un tableau d'entrées SOA
   */
  function normalizeSoa(soaArray) {
    if (!Array.isArray(soaArray)) return [];
    return soaArray.map(entry => normalizeSoaEntry(entry));
  }

  /**
   * S'assure que toutes les entrées SOA dans appData sont normalisées
   */
  function ensureSoaNormalized() {
    if (Array.isArray(appData.soa)) {
      appData.soa = normalizeSoa(appData.soa);
    } else {
      appData.soa = [];
    }
  }

  // Export des fonctions de normalisation SOA
  window.normalizeSoaEntry = normalizeSoaEntry;
  window.normalizeSoa = normalizeSoa;
  window.ensureSoaNormalized = ensureSoaNormalized;

  // ============================================================
  // Normalisation pour TOUTES les autres sections de l'application
  // ============================================================

  /**
   * Normalise une entrée Action
   */
  function normalizeActionEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    const result = {
      id: entry.id || '',
      linkType: entry.linkType || 'control',
      linkId: entry.linkId || entry.controlId || '',
      controlId: entry.controlId || '',
      ncIds: Array.isArray(entry.ncIds) ? entry.ncIds : [],
      title: entry.title || '',
      description: entry.description || '',
      priority: (function(p) { var _pm = { 'normale': 'medium', 'haute': 'high', 'basse': 'low', 'critique': 'critical' }; return _pm[p] || p || 'medium'; })(entry.priority),
      progress: typeof entry.progress === 'number' ? entry.progress : (parseInt(entry.progress, 10) || 0),
      dueDate: entry.dueDate || '',
      assignedTo: entry.assignedTo || '',
      status: entry.status || 'planifie',
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : []),
      criticalAssetIds: Array.isArray(entry.criticalAssetIds) ? entry.criticalAssetIds : []
    };
    // Préserver les champs budget si présents (compatibilité NIS2 Plan actions)
    if (entry.budget2026 !== undefined) result.budget2026 = entry.budget2026;
    if (entry.budget2027 !== undefined) result.budget2027 = entry.budget2027;
    if (entry.budget2028 !== undefined) result.budget2028 = entry.budget2028;
    if (entry.budgetCategory) result.budgetCategory = entry.budgetCategory;
    if (entry.workType) result.workType = entry.workType;
    if (entry.owner) result.owner = entry.owner;
    if (entry.phase !== undefined) result.phase = entry.phase;
    return result;
  }

  /**
   * Normalise un tableau d'Actions
   */
  function normalizeActions(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeActionEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Risque
   */
  function normalizeRiskEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    const impact = parseInt(entry.impact, 10) || 1;
    const probability = parseInt(entry.probability, 10) || 1;
    const score = impact * probability;
    return {
      id: entry.id || '',
      title: entry.title || '',
      description: entry.description || '',
      impact: impact,
      probability: probability,
      score: score,
      level: entry.level || calculateRiskLevel(score),
      treatment: entry.treatment || '',
      owner: entry.owner || '', // M17 : propriétaire de risque (sinon perdu à la normalisation)
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : []),
      codirDate: entry.codirDate || '',
      // L3bis : provenance d'une promotion depuis une analyse de risques
      sourceAnalysisId: entry.sourceAnalysisId || '',
      origin: entry.origin || '',
      criticalAssetIds: Array.isArray(entry.criticalAssetIds) ? entry.criticalAssetIds : []
    };
  }

  /**
   * Normalise un tableau de Risques
   */
  function normalizeRisks(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeRiskEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Menace
   */
  function normalizeThreatEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || '',
      title: entry.title || '',
      description: entry.description || '',
      likelihood: entry.likelihood || 1,
      impact: entry.impact || 1,
      score: entry.score || 1,
      level: entry.level || '',
      category: entry.category || '',
      status: entry.status || 'reduction',
      context: entry.context || '',
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : []),
      criticalAssetIds: Array.isArray(entry.criticalAssetIds) ? entry.criticalAssetIds : [],
      origin: entry.origin || '', // L3 : provenance pack (packId:threatId), préservée à la normalisation
      createdAt: entry.createdAt || '',
      updatedAt: entry.updatedAt || ''
    };
  }

  /**
   * Normalise un tableau de Menaces
   */
  function normalizeThreats(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeThreatEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Non-conformité
   */
  function normalizeNcEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || '',
      title: entry.title || '',
      description: entry.description || '',
      type: entry.type || 'Mineure',
      status: entry.status || 'detection',
      detectionDate: entry.detectionDate || '',
      assignedTo: entry.assignedTo || '',
      auditId: entry.auditId || '',
      rootCause: entry.rootCause || '',
      correctiveAction: entry.correctiveAction || '',
      closureDate: entry.closureDate || '',
      criticalAssetIds: Array.isArray(entry.criticalAssetIds) ? entry.criticalAssetIds : [],
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : [])
    };
  }

  /**
   * Normalise un tableau de Non-conformités
   */
  function normalizeNonconformities(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeNcEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Audit
   */
  function normalizeAuditEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || '',
      title: entry.title || '',
      type: entry.type || 'interne',
      description: entry.description || '',
      scope: entry.scope || '',
      plannedDate: entry.plannedDate || '',
      executionDate: entry.executionDate || '',
      auditor: entry.auditor || '',
      status: entry.status || 'planifie',
      findings: entry.findings || '',
      recommendations: entry.recommendations || '',
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : [])
    };
  }

  /**
   * Normalise un tableau d'Audits
   */
  function normalizeAudits(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeAuditEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée KPI
   */
  function normalizeKpiEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || '',
      title: entry.title || '',
      description: entry.description || '',
      target: entry.target || '',
      current: entry.current || '',
      unit: entry.unit || '',
      frequency: entry.frequency || 'monthly',
      lastUpdate: entry.lastUpdate || '',
      trend: entry.trend || 'stable',
      comments: typeof entry.comments === 'string' ? entry.comments : (Array.isArray(entry.comments) && entry.comments.length ? entry.comments.map(function(c) { return typeof c === 'object' ? (c.text || '') : String(c); }).join('; ') : ''),
      progress: typeof entry.progress === 'number' ? Math.max(0, Math.min(100, entry.progress)) : Math.max(0, Math.min(100, parseInt(entry.progress, 10) || 0)),
      history: Array.isArray(entry.history) ? entry.history : []
    };
  }

  /**
   * Normalise un tableau de KPIs
   */
  function normalizeKpis(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeKpiEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Revue de direction
   */
  function normalizeReviewEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || '',
      date: entry.date || '',
      participants: entry.participants || '',
      inputs: entry.inputs || '',
      decisions: entry.decisions || '',
      comments: Array.isArray(entry.comments) ? entry.comments : (typeof entry.comments === 'string' && entry.comments ? [{ text: entry.comments, date: new Date().toISOString() }] : []),
      nextReviewDate: entry.nextReviewDate || ''
    };
  }

  /**
   * Normalise un tableau de Revues
   */
  function normalizeReviews(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeReviewEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Partie prenante
   */
  function normalizeStakeholderEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || ('stakeholder_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5)),
      name: entry.name || '',
      role: entry.role || '',
      contact: entry.contact || '',
      notes: entry.notes || '',
      category: entry.category || '',
      influence: entry.influence || '',
      expectations: entry.expectations || '',
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : [])
    };
  }

  /**
   * Normalise un tableau de Parties prenantes
   */
  function normalizeStakeholders(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeStakeholderEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Document
   */
  function normalizeDocumentEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    return {
      id: entry.id || ('doc_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5)),
      type: entry.type || '',
      title: entry.title || '',
      reference: entry.reference || '',
      version: entry.version || '',
      status: entry.status || 'draft',
      owner: entry.owner || '',
      lastReview: entry.lastReview || '',
      nextReview: entry.nextReview || '',
      location: entry.location || '',
      notes: entry.notes || '',
      category: entry.category || entry.level || '',
      tool: entry.tool || '',
      link: entry.link || '',
      comments: Array.isArray(entry.comments) ? entry.comments :
        (typeof entry.comments === 'string' && entry.comments ?
          [{ text: entry.comments, date: new Date().toISOString() }] : [])
    };
  }

  /**
   * Normalise un tableau de Documents
   */
  function normalizeDocuments(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeDocumentEntry(e)).filter(Boolean);
  }

  /**
   * Normalise une entrée Control ISO 27002
   */
  function normalizeControlEntry(entry) {
    if (!entry || typeof entry !== 'object') return null;
    var normalized = {
      id: entry.id || '',
      reference: entry.reference || '',
      numero: entry.numero || '',
      title: entry.title || '',
      titleEn: entry.titleEn || '',
      measure: entry.measure || '',
      description: entry.description || '',
      descriptionEn: entry.descriptionEn || '',
      recommendations: Array.isArray(entry.recommendations) ? entry.recommendations : [],
      category: entry.category || 'Non classé',
      status: entry.status || 'non_implemente',
      cmm: entry.cmm || 'Initial',
      comments: Array.isArray(entry.comments) ? entry.comments : (typeof entry.comments === 'string' && entry.comments ? [{ text: entry.comments, date: new Date().toISOString() }] : []),
      evidence: entry.evidence || '',
      justificationControl: entry.justificationControl || '',
      subcategory: Array.isArray(entry.subcategory) ? entry.subcategory : [],
      owner: entry.owner || '',
      effectiveness: entry.effectiveness || '',
      implementationDate: entry.implementationDate || '',
      verificationDate: entry.verificationDate || '',
      documentLink: entry.documentLink || '',
      implStatus: entry.implStatus || '',
      lastEvaluation: entry.lastEvaluation || ''
    };
    // FIX-CONTROL-FIELD-LOSS (audit 2026-06-10) : préserver les champs non listés
    // ci-dessus (kpiRef « KPI associé », reviewFrequency « Fréquence de revue », ...),
    // saisis via editControl mais supprimés à chaque normalizeControls() au chargement.
    // Même pattern que FIX-CYFUN-LEVEL-LOSS dans setCyfunAssuranceLevel.
    Object.keys(entry).forEach(function(k) {
      if (!(k in normalized)) normalized[k] = entry[k];
    });
    return normalized;
  }

  /**
   * Normalise un tableau de Controls
   */
  function normalizeControls(list) {
    if (!Array.isArray(list)) return [];
    return list.map(e => normalizeControlEntry(e)).filter(Boolean);
  }

  /**
   * Fonction globale de normalisation de toutes les données importées
   */
  function normalizeAllImportedData(data) {
    return {
      controls: normalizeControls(data.controls),
      soa: normalizeSoa(data.soa),
      actions: normalizeActions(data.actions),
      risks: normalizeRisks(data.risks),
      threats: normalizeThreats(data.threats),
      nonconformities: normalizeNonconformities(data.nonconformities),
      audits: normalizeAudits(data.audits),
      kpis: normalizeKpis(data.kpis),
      reviews: normalizeReviews(data.reviews),
      stakeholders: normalizeStakeholders(data.stakeholders),
      documents: normalizeDocuments(data.documents),
      // Ces sections ont déjà leurs propres normalisations
      criticalAssets: data.criticalAssets,
      projectRisks: data.projectRisks,
      nis2Controls: data.nis2Controls,
      documentReview: data.documentReview,
      nis2Plan: data.nis2Plan,
      soclePillars: data.soclePillars,
      targetMaturity: data.targetMaturity,
      criticalAssetsMeta: data.criticalAssetsMeta,
      riskAnalyses: data.riskAnalyses,
      impactMatrices: data.impactMatrices,
      riskThresholds: data.riskThresholds,
      nextActionId: data.nextActionId,
      title: data.title,
      actionsHistory: data.actionsHistory
    };
  }

  // Export des fonctions de normalisation globales
  window.normalizeActions = normalizeActions;
  window.normalizeRisks = normalizeRisks;
  window.normalizeThreats = normalizeThreats;
  window.normalizeNonconformities = normalizeNonconformities;
  window.normalizeAudits = normalizeAudits;
  window.normalizeKpis = normalizeKpis;
  window.normalizeReviews = normalizeReviews;
  window.normalizeStakeholders = normalizeStakeholders;
  window.normalizeDocuments = normalizeDocuments;
  window.normalizeControls = normalizeControls;
  window.normalizeAllImportedData = normalizeAllImportedData;

  // ============================================================
  // Système générique de commentaires
  // ============================================================
  // Registre des sections supportant les commentaires.
  // Pour ajouter une section : 1) ajouter une entrée ici, 2) normaliser comments:[] dans la section, 3) ajouter un bouton UI
  const COMMENT_SECTIONS = {
    actions:        { dataKey: 'actions',         findBy: 'id',        reload: 'loadActionsTable' },
    risks:          { dataKey: 'risks',           findBy: 'id',        reload: 'loadRisksTable' },
    threats:        { dataKey: 'threats',          findBy: 'id',        reload: 'loadThreatsTable' },
    reviews:        { dataKey: 'reviews',          findBy: 'id',        reload: 'loadReviewsTable' },
    controls:       { dataKey: 'controls',         findBy: 'id',        reload: 'loadControlsTable' },
    nonconformities:{ dataKey: 'nonconformities',   findBy: 'id',        reload: 'loadNonConformitiesTable' },
    audits:         { dataKey: 'audits',           findBy: 'id',        reload: 'loadAuditsTable' },
    kpis:           { dataKey: 'kpis',             findBy: 'id',        reload: 'loadKpiTable' },
    stakeholders:   { dataKey: 'stakeholders',     findBy: 'id',        reload: 'loadStakeholdersTable' },
    documents:      { dataKey: 'documents',        findBy: 'id',        reload: 'loadDocumentsTable' },
    criticalAssets: { dataKey: 'criticalAssets',   findBy: 'id',        reload: 'loadCriticalAssetsTable' },
    soa:            { dataKey: 'soa',              findBy: 'controlId', reload: 'loadSoaTable' },
    docReview:      { dataKey: 'documentReview',   findBy: 'reference', reload: 'loadDocReviewTable' },
    nis2Controls:   { dataKey: 'nis2Controls',     findBy: 'id',        reload: 'loadNis2ControlsTable' },
    recyfControls:  { dataKey: 'recyfControls',    findBy: 'id',        reload: 'loadRecyfControlsTable' },
    projectRisks:   { dataKey: 'projectRisks',     findBy: 'id',        reload: 'loadProjectRisksTable' }
  };

  // --- Socle commun des commentaires (unification 2026-07-10) ---------------
  // Modèle : { text, date ISO, id?, author? } — id et author sont ADDITIFS :
  // les commentaires historiques sans ces champs restent valides partout.

  // Auteur courant (mode serveur multi-utilisateurs) ; '' en mode local.
  function currentCommentAuthor() {
    try {
      var u = window.CSI_AuthManager && window.CSI_AuthManager.getCurrentUser && window.CSI_AuthManager.getCurrentUser();
      if (!u) return '';
      return u.displayName || u.username || u.name || u.email || '';
    } catch (e) { return ''; }
  }

  // Normalisation idempotente d'un champ comments : legacy string -> [{text,date:''}],
  // tableau garanti, entrées invalides filtrées. Ne fabrique jamais de date.
  function normalizeComments(value) {
    if (Array.isArray(value)) {
      return value.filter(function(c) { return c && typeof c === 'object' && typeof c.text === 'string' && c.text; });
    }
    if (typeof value === 'string' && value.trim()) {
      return [{ text: value.trim(), date: '' }];
    }
    return [];
  }

  // Date d'un commentaire pour affichage — '' si absente/corrompue (jamais « Invalid Date »).
  function formatCommentDate(d) {
    if (!d) return '';
    var dt = new Date(d);
    return isNaN(dt.getTime()) ? '' : dt.toLocaleString();
  }

  // Ajout contrôlé d'un commentaire. Retourne true, 'empty' ou 'max'.
  function appendComment(item, text, maxComments) {
    var txt = (text || '').trim();
    if (!txt) return 'empty';
    item.comments = normalizeComments(item.comments);
    if (item.comments.length >= (maxComments || 10)) return 'max';
    var c = { id: generateId('comment'), text: txt, date: new Date().toISOString() };
    var author = currentCommentAuthor();
    if (author) c.author = author;
    item.comments.push(c);
    return true;
  }

  // Historique « [date — auteur] texte » pour les textarea lecture seule des modales d'édition.
  function formatCommentsReadonly(comments) {
    return normalizeComments(comments).map(function(c) {
      var meta = [formatCommentDate(c.date), c.author || ''].filter(Boolean).join(' — ');
      return (meta ? '[' + meta + '] ' : '') + c.text;
    }).join('\n');
  }

  window.normalizeComments = normalizeComments;
  window.appendComment = appendComment;
  window.formatCommentsReadonly = formatCommentsReadonly;

  /**
   * Gestion générique des commentaires pour toutes les sections.
   * @param {string} sectionKey - Clé dans COMMENT_SECTIONS
   * @param {string} itemId - Identifiant de l'élément (id, controlId, reference selon findBy)
   * @param {object} [options] - { titlePrefix, maxComments }
   */
  function manageComments(sectionKey, itemId, options) {
    var cfg = COMMENT_SECTIONS[sectionKey];
    if (!cfg) { void 0; return; }
    var opts = options || {};
    var maxComments = opts.maxComments || 10;
    var titlePrefix = opts.titlePrefix || t('comments.manage', 'Commentaires');

    if (!ensureEditMode("manage comments")) return;

    var dataArray = appData[cfg.dataKey];
    if (!Array.isArray(dataArray)) return;
    var item = dataArray.find(function(e) { return e[cfg.findBy] === itemId; });
    if (!item) return;
    item.comments = normalizeComments(item.comments);

    var commentsHtml = '<div class="csi-comments-list">';
    if (item.comments.length === 0) {
      commentsHtml += '<p class="csi-comments-empty">' + t('comments.none', 'Aucun commentaire.') + '</p>';
    } else {
      item.comments.forEach(function(c, i) {
        var meta = [formatCommentDate(c.date), c.author || ''].filter(Boolean).join(' — ');
        commentsHtml += '<div class="csi-comment-item">';
        commentsHtml += '<div class="csi-comment-item__body"><small class="csi-comment-item__date">' + escapeHtml(meta) + '</small>';
        commentsHtml += '<p class="csi-comment-item__text">' + escapeHtml(c.text) + '</p></div>';
        // data-cid (id du commentaire) prioritaire ; data-idx = repli pour les commentaires legacy sans id
        commentsHtml += '<button type="button" class="icon-btn icon-btn--danger csi-comment-delete" data-cid="' + escapeHtml(c.id || '') + '" data-idx="' + i + '" title="' + t('common.delete', 'Supprimer') + '"><i class="fas fa-trash"></i></button>';
        commentsHtml += '</div>';
      });
    }
    commentsHtml += '</div>';

    openModal(titlePrefix, [
      { name: '_commentsList', label: '', type: 'html', value: commentsHtml },
      { name: 'newComment', label: t('comments.new', 'Nouveau commentaire'), type: 'textarea' }
    ], function(data) {
      var res = appendComment(item, data.newComment, maxComments);
      if (res === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', maxComments), 'warning');
        return false;
      }
      if (res === true) {
        saveData();
        if (typeof window[cfg.reload] === 'function') window[cfg.reload]();
        showToast2(t('comments.added', 'Commentaire ajouté.'), 'success');
      }
    }, null, {
      saveLabel: t('comments.add', 'Ajouter le commentaire'),
      cancelLabel: t('comments.close', 'Fermer')
    });

    setTimeout(function() {
      var modalBody = document.getElementById('modalBody');
      if (!modalBody) return;
      modalBody.querySelectorAll('.csi-comment-delete').forEach(function(btn) {
        btn.addEventListener('click', function() {
          // Suppression par id de commentaire (robuste en co-édition) ; repli sur
          // l'index de position pour les commentaires legacy sans id.
          var cid = btn.getAttribute('data-cid');
          var idx = -1;
          if (cid) idx = item.comments.findIndex(function(c) { return c && c.id === cid; });
          if (idx < 0) idx = parseInt(btn.getAttribute('data-idx'), 10);
          if (idx >= 0 && idx < item.comments.length) {
            showConfirmModal(
              t('confirms.deleteTitle', 'Supprimer ?'),
              t('comments.deleteConfirm', 'Supprimer ce commentaire ?'),
              function() {
                item.comments.splice(idx, 1);
                saveData();
                closeModal();
                manageComments(sectionKey, itemId, options);
              },
              { type: 'danger' }
            );
          }
        });
      });
    }, 100);
  }
  window.manageComments = manageComments;
  window.COMMENT_SECTIONS = COMMENT_SECTIONS;

  // Options pour les sources de sélection SOA (ISO 27001:2022 clause 6.1.3)
  function getSoaSourceOptions() {
    return [
      { value: "", label: t('soaSources.select') },
      { value: "risk", label: t('soaSources.risk') },
      { value: "legal", label: t('soaSources.legal') },
      { value: "contractual", label: t('soaSources.contractual') },
      { value: "best_practice", label: t('soaSources.bestPractice') },
      { value: "regulatory", label: t('soaSources.regulatory') },
      { value: "business", label: t('soaSources.business') }
    ];
  }
  // Options pour l'état d'implémentation SOA
  function getSoaImplStatusOptions() {
    return [
      { value: "", label: t('soaImplStatuses.select') },
      { value: "not_started", label: t('soaImplStatuses.notStarted') },
      { value: "planned", label: t('soaImplStatuses.planned') },
      { value: "in_progress", label: t('soaImplStatuses.inProgress') },
      { value: "implemented", label: t('soaImplStatuses.implemented') },
      { value: "verified", label: t('soaImplStatuses.verified') }
    ];
  }
  // (audit global 2026-07-03) Statut d'implémentation EFFECTIF d'une entrée SoA. implStatus est
  // un champ SoA-spécifique optionnel (peut différer intentionnellement) ; s'il est VIDE (cas
  // courant : le RSSI renseigne l'implémentation dans l'onglet Contrôles, pas la SoA), on DÉRIVE
  // depuis control.status — sinon la Déclaration d'Applicabilité (livrable ISO 27001 §6.1.3)
  // affichait 0% implémenté alors que le SMSI l'était. L'override explicite reste prioritaire.
  function _soaEffectiveImplStatus(entry, control) {
    if (entry && entry.implStatus) return entry.implStatus;
    var cs = control && control.status;
    if (cs === 'verifie') return 'verified';
    if (cs === 'implemente') return 'implemented';
    if (cs === 'en_cours') return 'in_progress';
    return '';
  }
  // Export SoA option functions and dynamic getters (labels refresh after language change)
  window.getSoaSourceOptions = getSoaSourceOptions;
  window.getSoaImplStatusOptions = getSoaImplStatusOptions;
  Object.defineProperty(window, 'SOA_SOURCE_OPTIONS', { get: getSoaSourceOptions, configurable: true });
  Object.defineProperty(window, 'SOA_IMPL_STATUS_OPTIONS', { get: getSoaImplStatusOptions, configurable: true });

  // Fonction pour obtenir le domaine d'un contrôle
  function getControlDomain(reference) {
    if (!reference) return "Non classé";
    const prefix = reference.split('.')[0];
    return DOMAIN_BY_PREFIX[prefix] || "Non classé";
  }

  function loadSoaTable() {
    const tbody = document.getElementById("soaTableBody");
    if (!tbody) return;

    // Protection : s'assurer que le module SOA reste actif pendant le rechargement
    const soaModule = document.getElementById("soa");
    const wasActive = soaModule && soaModule.classList.contains("module--active");

    tbody.innerHTML = "";

    const _f = SectionToolbar.getFilters("soa");
    const _q = (SectionToolbar.getSearch("soa") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const applicabilityFilter = _f.applicability || "";
    const domainFilter = _f.domain || "";

    const _s = SectionToolbar.getSort("soa");
    const _sortKey = _s ? _s.key : "reference";
    const _sortDir = _s ? _s.dir : "asc";
    const sortedControls = [...appData.controls].sort((a, b) => {
      let diff = 0;
      switch (_sortKey) {
        case "applicability": {
          const entryA = appData.soa.find(e => e.controlId === a.id);
          const entryB = appData.soa.find(e => e.controlId === b.id);
          diff = SortUtils.text(entryA?.applicable ? "1" : "0", entryB?.applicable ? "1" : "0");
          break;
        }
        case "implementation": {
          const entryA = appData.soa.find(e => e.controlId === a.id);
          const entryB = appData.soa.find(e => e.controlId === b.id);
          diff = SortUtils.text(entryA?.implStatus || "", entryB?.implStatus || "");
          break;
        }
        default: diff = SortUtils.reference(a.reference, b.reference); break;
      }
      return _sortDir === "desc" ? -diff : diff;
    });

    // Stats pour les indicateurs de couverture
    let applicableCount = 0;
    let notApplicableCount = 0;
    let implementedCount = 0;
    const domainStats = {};

    sortedControls.forEach((control) => {
      let entry = appData.soa.find(e => e.controlId === control.id);
      if (!entry) {
        entry = {
          controlId: control.id,
          applicable: true,
          justification: "",
          linkedRiskIds: [],
          source: "",
          implStatus: "",
          implDate: "",
          exclusionReason: "",
          owner: "",
          evidenceRef: "",
          lastReviewDate: ""
        };
        appData.soa.push(entry);
      }
      // S'assurer que les nouveaux champs existent
      if (!entry.linkedRiskIds) entry.linkedRiskIds = [];
      if (!entry.source) entry.source = "";
      if (!entry.implStatus) entry.implStatus = "";
      if (!entry.implDate) entry.implDate = "";
      if (!entry.exclusionReason) entry.exclusionReason = "";
      if (!entry.owner) entry.owner = "";
      if (!entry.evidenceRef) entry.evidenceRef = "";
      if (!entry.lastReviewDate) entry.lastReviewDate = "";

      const domain = getControlDomain(control.reference);

      // Stats
      var _effImpl = _soaEffectiveImplStatus(entry, control); // (audit 2026-07-03) fallback control.status
      if (entry.applicable) {
        applicableCount++;
        if (_effImpl === "implemented" || _effImpl === "verified") {
          implementedCount++;
        }
      } else {
        notApplicableCount++;
      }

      // Stats par domaine
      if (!domainStats[domain]) {
        domainStats[domain] = { total: 0, applicable: 0, implemented: 0 };
      }
      domainStats[domain].total++;
      if (entry.applicable) {
        domainStats[domain].applicable++;
        if (_effImpl === "implemented" || _effImpl === "verified") {
          domainStats[domain].implemented++;
        }
      }

      // Filtres
      if (applicabilityFilter === "selected" && !entry.applicable) return;
      if (applicabilityFilter === "not_selected" && entry.applicable) return;
      if (domainFilter && domain !== domainFilter) return;
      if (_q && ((control.reference || "") + " " + (control.title || "")).toLowerCase().indexOf(_q) < 0) return;

      const riskBadgesHtml = renderSoaRiskBadges(entry.linkedRiskIds);

      const row = document.createElement("tr");
      row.className = entry.applicable ? "soa-row--applicable" : "soa-row--not-applicable";
      row.dataset.controlId = control.id;
      row.style.cursor = "pointer";

      // Ensure comments array exists
      if (!Array.isArray(entry.comments)) entry.comments = [];
      var soaCommentsBadge = entry.comments.length > 0 ? '<span class="csi-comment-count">' + entry.comments.length + '</span>' : '';

      // Labels lisibles pour applicabilité, source et implémentation
      var _applicableBadge = entry.applicable
        ? '<span class="badge badge--success">\u2713 ' + escapeHtml(t('soa.applicable', 'Applicable')) + '</span>'
        : '<span class="badge badge--muted">\u2717 ' + escapeHtml(t('soa.nonApplicable', 'Non applicable')) + '</span>';
      var _sourceOpt = getSoaSourceOptions().find(function(o) { return o.value === entry.source; });
      var _sourceText = _sourceOpt && _sourceOpt.value ? _sourceOpt.label : '';
      var _implOpt = getSoaImplStatusOptions().find(function(o) { return o.value === entry.implStatus; });
      var _implText = _implOpt && _implOpt.value ? _implOpt.label : '';
      var _justifPreview = (entry.justification || '').substring(0, 80);
      var _justifSuffix = (entry.justification || '').length > 80 ? '\u2026' : '';

      // Structure à 6 colonnes — affichage lecture seule (édition via modale au clic)
      var _cellsHtml = '<td class="soa-cell-control">'
        + '<div class="soa-control-header">'
        + '<strong class="soa-control-ref">' + escapeHtml(control.numero + ' ' + isoCtrlTitle(control)) + '</strong>'
        + ' <span class="badge badge--domain badge--' + domain.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') + '">' + escapeHtml(getTranslatedDomain(domain)) + '</span>'
        + '</div>'
        + '</td>';
      _cellsHtml += '<td class="soa-cell-applicability">' + _applicableBadge
        + (_sourceText ? '<div class="soa-meta-line">' + escapeHtml(_sourceText) + '</div>' : '')
        + (!entry.applicable && entry.exclusionReason ? '<div class="soa-meta-line text-muted">' + escapeHtml((entry.exclusionReason || '').substring(0, 60)) + ((entry.exclusionReason || '').length > 60 ? '\u2026' : '') + '</div>' : '')
        + '</td>';
      _cellsHtml += '<td class="soa-cell-impl">'
        + (_implText ? '<span class="badge badge--outline">' + escapeHtml(_implText) + '</span>' : '<span class="text-muted">\u2014</span>')
        + (entry.owner ? '<div class="soa-meta-line">' + escapeHtml(entry.owner) + '</div>' : '')
        + (entry.implDate ? '<div class="soa-meta-line">' + escapeHtml(entry.implDate) + '</div>' : '')
        + '</td>';
      _cellsHtml += '<td class="soa-cell-risks"><div class="soa-risks">' + riskBadgesHtml + '</div></td>';
      _cellsHtml += '<td class="soa-cell-docs">'
        + (entry.evidenceRef ? escapeHtml(entry.evidenceRef) : '<span class="text-muted">\u2014</span>')
        + (entry.lastReviewDate ? '<div class="soa-meta-line">' + escapeHtml(entry.lastReviewDate) + '</div>' : '')
        + (soaCommentsBadge ? '<span class="soa-meta-line">' + soaCommentsBadge + '</span>' : '')
        + '</td>';
      _cellsHtml += '<td class="soa-cell-justification">'
        + (_justifPreview ? escapeHtml(_justifPreview) + _justifSuffix : '<span class="text-muted">\u2014</span>')
        + '</td>';
      row.innerHTML = _cellsHtml;

      // Clic sur la ligne → édition modale (même pattern que editAction/editRisk)
      row.classList.add("row-clickable");
      row.addEventListener("click", function() {
        editSoaControl(control.id);
      });

      tbody.appendChild(row);
    });

    // Mettre à jour les indicateurs de couverture
    updateSoaCoverageStats(applicableCount, notApplicableCount, implementedCount, domainStats);

    // Protection : restaurer l'état actif du module SOA si nécessaire
    if (wasActive && soaModule && !soaModule.classList.contains("module--active")) {
      void 0;
      document.querySelectorAll(".module").forEach(m => m.classList.remove("module--active"));
      soaModule.classList.add("module--active");
      const soaNav = document.querySelector('.nav__item[data-tab="soa"]');
      if (soaNav) {
        document.querySelectorAll(".nav__item").forEach(n => n.classList.remove("nav__item--active"));
        soaNav.classList.add("nav__item--active");
      }
    }
  }

  // Mettre à jour les statistiques de couverture SOA
  function updateSoaCoverageStats(applicable, notApplicable, implemented, domainStats) {
    safeSetText("soaApplicableCount", applicable);
    safeSetText("soaNotApplicableCount", notApplicable);

    const implPercent = applicable > 0 ? Math.round((implemented / applicable) * 100) : 0;
    safeSetText("soaImplementedCount", implPercent + "%");

    const implBar = document.getElementById("soaImplementedBar");
    if (implBar) {
      implBar.style.width = implPercent + "%";
      implBar.className = "soa-coverage__fill";
      if (implPercent >= 80) implBar.classList.add("soa-coverage__fill--good");
      else if (implPercent >= 50) implBar.classList.add("soa-coverage__fill--medium");
      else implBar.classList.add("soa-coverage__fill--low");
    }

    // Couverture par domaine
    const domainsContainer = document.getElementById("soaDomainsCoverage");
    if (domainsContainer) {
      domainsContainer.innerHTML = Object.entries(domainStats).map(([domain, stats]) => {
        const pct = stats.applicable > 0 ? Math.round((stats.implemented / stats.applicable) * 100) : 0;
        return `<div class="soa-domain-stat">
          <span class="soa-domain-stat__label">${escapeHtml(getTranslatedDomain(domain))}</span>
          <span class="soa-domain-stat__value">${pct}%</span>
        </div>`;
      }).join("");
    }
  }

  // Options pour le statut de conformité Doc Review
  function getDocReviewStatusOptions() {
    return [
      { value: "", label: t('docReviewStatuses.select') },
      { value: "conforme", label: t('docReviewStatuses.conforme'), icon: "check-circle", class: "success" },
      { value: "partiel", label: t('docReviewStatuses.partiel'), icon: "exclamation-circle", class: "warning" },
      { value: "non_conforme", label: t('docReviewStatuses.nonConforme'), icon: "times-circle", class: "danger" },
      { value: "na", label: t('docReviewStatuses.na'), icon: "minus-circle", class: "muted" }
    ];
  }
  // DOC_REVIEW_STATUS_OPTIONS computed dynamically (like SOA options) to support language switching

  // Retourne true si dateStr (AAAA-MM-JJ ou date parseable) est strictement antérieure à
  // aujourd'hui. Même critère que le calcul overdue du plan d'actions, factorisé pour
  // homogénéiser la mise en évidence des échéances dépassées (.cell-overdue).
  function _csiIsPastDate(dateStr) {
    if (!dateStr) return false;
    var s = String(dateStr);
    if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
      var now = new Date();
      var todayIso = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0");
      return s.slice(0, 10) < todayIso;
    }
    var d = new Date(s);
    if (Number.isNaN(d.getTime())) return false;
    d.setHours(0, 0, 0, 0);
    var t0 = new Date(); t0.setHours(0, 0, 0, 0);
    return d < t0;
  }

  function loadDocReviewTable() {
    const tbody = document.getElementById("docReviewTableBody");
    if (!tbody) return;
    tbody.innerHTML = "";

    const _f = SectionToolbar.getFilters("docReview");
    const statusFilter = _f.status || "";

    // Stats pour les indicateurs
    const _s = SectionToolbar.getSort("docReview");
    let conformeCount = 0;
    let partielCount = 0;
    let nonConformeCount = 0;
    let totalItems = 0;

    // Tri (la toolbar propose Clause / Statut / Dernière revue). L'ordre « clause » (défaut)
    // conserve le groupement par chapitre du référentiel ; trier par statut ou dernière revue
    // n'a de sens qu'à plat, on retire donc les en-têtes de chapitre dans ces cas.
    let _itemsToRender = DOCUMENT_REVIEW_ITEMS;
    if (_s && (_s.key === "status" || _s.key === "lastReview")) {
      const _dir = _s.dir === "desc" ? -1 : 1;
      const _statusRank = { conforme: 0, partiel: 1, non_conforme: 2, na: 3, "": 4 };
      const _entryOf = (it) => appData.documentReview.find((e) => e.reference === it.reference) || {};
      _itemsToRender = DOCUMENT_REVIEW_ITEMS
        .filter((it) => it.type !== "chapter")
        .slice()
        .sort((a, b) => {
          const ea = _entryOf(a), eb = _entryOf(b);
          if (_s.key === "status") {
            const ra = _statusRank[ea.status] != null ? _statusRank[ea.status] : 5;
            const rb = _statusRank[eb.status] != null ? _statusRank[eb.status] : 5;
            return (ra - rb) * _dir;
          }
          // lastReview : chaînes date comparables ; les valeurs vides toujours en dernier
          const va = ea.lastReview || "", vb = eb.lastReview || "";
          if (!va && !vb) return 0;
          if (!va) return 1;
          if (!vb) return -1;
          return (va < vb ? -1 : va > vb ? 1 : 0) * _dir;
        });
    }

    _itemsToRender.forEach((item, _drIdx) => {
      if (item.type === "chapter") {
        // Resolve chapter title from framework data (bilingual) or fallback to item.title
        var _chTitle = item.title;
        var _mgmtFwCh = _getActiveManagementFramework();
        if (_mgmtFwCh && _mgmtFwCh.clauses && _mgmtFwCh.clauses[_drIdx]) {
          var _chData = _mgmtFwCh.clauses[_drIdx];
          if (_chData.type === 'chapter' && _chData.chapterId) {
            var _isEnCh = (typeof currentLang !== 'undefined' && currentLang === 'en') || (document.documentElement.lang === 'en');
            var _chInfo = (_mgmtFwCh.chapters || []).find(function(c) { return c.id === _chData.chapterId; });
            if (_chInfo) _chTitle = (_isEnCh && _chInfo.titleEn) ? _chInfo.titleEn : _chInfo.title;
          }
        }
        if (!_chTitle) _chTitle = item.title;
        const row = document.createElement("tr");
        row.className = "table-section";
        // escapeHtml sanitizes _chTitle before insertion (existing pattern)
        row.innerHTML = '<td colspan="5" class="doc-review-chapter-header">' + escapeHtml(_chTitle) + '</td>';
        tbody.appendChild(row);
      } else {
        let entry = appData.documentReview.find(e => e.reference === item.reference);
        if (!entry) {
          entry = {
            reference: item.reference,
            justification: "",
            tool: "",
            status: "",
            responsible: "",
            lastReview: "",
            nextReview: "",
            documentLink: "",
            comments: []
          };
          appData.documentReview.push(entry);
        }
        // S'assurer que les nouveaux champs existent
        if (!entry.status) entry.status = "";
        if (!entry.responsible) entry.responsible = "";
        if (!entry.lastReview) entry.lastReview = "";
        if (!entry.nextReview) entry.nextReview = "";
        if (!entry.documentLink) entry.documentLink = "";
        if (!Array.isArray(entry.comments)) entry.comments = [];

        // Stats
        totalItems++;
        if (entry.status === "conforme") conformeCount++;
        else if (entry.status === "partiel") partielCount++;
        else if (entry.status === "non_conforme") nonConformeCount++;

        // Filtre par statut
        if (statusFilter && entry.status !== statusFilter) return;

        // Classe de ligne selon statut
        var rowClass = "";
        if (entry.status === "conforme") rowClass = "doc-review-row--conforme";
        else if (entry.status === "partiel") rowClass = "doc-review-row--partiel";
        else if (entry.status === "non_conforme") rowClass = "doc-review-row--non-conforme";
        else if (entry.status === "na") rowClass = "doc-review-row--na";

        // Extraire le numéro de clause (ex: "4.1" de "4.1 Compréhension de l'organisation...")
        var refParts = item.reference.split(' ');
        var clauseNum = refParts[0] || item.reference;
        // Look up clause data from active management framework (bilingual)
        var _mgmtFw = _getActiveManagementFramework();
        var _fwClause = _mgmtFw ? (_mgmtFw.clauses || []).find(function(c) { return c.reference === clauseNum; }) : null;
        // i18n: framework data takes priority, then reference text as fallback
        var _isEn = (typeof currentLang !== 'undefined' && currentLang === 'en') || (document.documentElement.lang === 'en');
        var clauseTitle = (_fwClause && _isEn && _fwClause.titleEn) ? _fwClause.titleEn : (_fwClause ? _fwClause.title : '') || refParts.slice(1).join(' ') || '';
        var clauseDesc = (_fwClause && _isEn && _fwClause.descriptionEn) ? _fwClause.descriptionEn : (_fwClause ? _fwClause.description : '') || item.description;

        var commentsBadge = entry.comments.length > 0 ? '<span class="csi-comment-count">' + entry.comments.length + '</span>' : '';

        // Labels lisibles pour statut
        var _statusOpt = getDocReviewStatusOptions().find(function(o) { return o.value === entry.status; });
        var _statusText = _statusOpt && _statusOpt.value ? _statusOpt.label : '';
        var _statusIcon = _statusOpt && _statusOpt.icon ? '<i class="fas fa-' + _statusOpt.icon + '"></i> ' : '';
        var _statusBadgeClass = _statusOpt && _statusOpt.class ? 'badge--' + _statusOpt.class : '';
        var _justifPreview = (entry.justification || '').substring(0, 80);
        var _justifSuffix = (entry.justification || '').length > 80 ? '\u2026' : '';
        var _toolPreview = (entry.tool || '').substring(0, 60);
        var _toolSuffix = (entry.tool || '').length > 60 ? '\u2026' : '';

        var row = document.createElement("tr");
        row.className = rowClass;
        row.dataset.ref = item.reference;
        row.style.cursor = "pointer";

        // Structure à 5 colonnes — affichage lecture seule (édition via modale au clic)
        var _drCells = '<td class="doc-review-cell-clause">'
          + '<span class="doc-review-clause-ref">' + escapeHtml(clauseNum) + '</span>'
          + (clauseTitle ? '<div class="doc-review-clause-title" style="font-weight:600;margin-bottom:2px">' + escapeHtml(clauseTitle) + '</div>' : '')
          + '<div class="doc-review-clause-desc">' + escapeHtml(clauseDesc) + '</div>'
          + '</td>';
        _drCells += '<td>'
          + (_statusText ? '<span class="badge ' + _statusBadgeClass + '">' + _statusIcon + escapeHtml(_statusText) + '</span>' : '<span class="text-muted">\u2014</span>')
          + (entry.responsible ? '<div class="soa-meta-line">' + escapeHtml(entry.responsible) + '</div>' : '')
          + '</td>';
        _drCells += '<td>'
          + (_justifPreview ? escapeHtml(_justifPreview) + _justifSuffix : '<span class="text-muted">\u2014</span>')
          + '</td>';
        _drCells += '<td>'
          + (_toolPreview ? escapeHtml(_toolPreview) + _toolSuffix : '<span class="text-muted">\u2014</span>')
          + (entry.documentLink && isSafeUrl(entry.documentLink) ? '<br><a href="' + escapeHtml(entry.documentLink) + '" target="_blank" rel="noopener noreferrer" class="doc-review-link" onclick="event.stopPropagation()"><i class="fas fa-external-link-alt"></i> ' + escapeHtml(t('common.open', 'Ouvrir')) + '</a>' : '')
          + '</td>';
        var _drNextOverdue = _csiIsPastDate(entry.nextReview);
        _drCells += (_drNextOverdue ? '<td class="cell-overdue" title="' + escapeHtml(t('docReview.reviewOverdue', 'Revue en retard')) + '">' : '<td>')
          + (entry.lastReview ? escapeHtml(entry.lastReview) : '<span class="text-muted">\u2014</span>')
          + (entry.nextReview ? '<div class="soa-meta-line">' + escapeHtml(t('docReview.nextReviewLabel', 'Prochaine')) + ': ' + escapeHtml(entry.nextReview) + '</div>' : '')
          + (commentsBadge ? ' ' + commentsBadge : '')
          + '</td>';
        row.innerHTML = _drCells;

        // Clic sur la ligne → édition modale (même pattern que editAction/editRisk)
        row.classList.add("row-clickable");
        row.addEventListener("click", function() {
          editDocReviewEntry(item.reference);
        });

        tbody.appendChild(row);
      }
    });

    // Mettre à jour les statistiques
    updateDocReviewStats(conformeCount, partielCount, nonConformeCount, totalItems);
  }

  // Mettre à jour les statistiques Doc Review
  function updateDocReviewStats(conforme, partiel, nonConforme, total) {
    safeSetText("docReviewConformeCount", conforme);
    safeSetText("docReviewPartielCount", partiel);
    safeSetText("docReviewNonConformeCount", nonConforme);

    const conformePercent = total > 0 ? Math.round((conforme / total) * 100) : 0;
    safeSetText("docReviewProgressValue", conformePercent + "%");

    const progressBar = document.getElementById("docReviewProgressBar");
    if (progressBar) {
      progressBar.style.width = conformePercent + "%";
      progressBar.className = "doc-review-stats__fill";
      if (conformePercent >= 80) progressBar.classList.add("doc-review-stats__fill--good");
      else if (conformePercent >= 50) progressBar.classList.add("doc-review-stats__fill--medium");
      else progressBar.classList.add("doc-review-stats__fill--low");
    }
  }

  // Ouvre la modale pour éditer une clause de la revue normative (même pattern que editAction/editRisk)
  function editDocReviewEntry(reference) {
    var item = DOCUMENT_REVIEW_ITEMS.find(function(i) { return i.reference === reference; });
    var entry = appData.documentReview.find(function(e) { return e.reference === reference; });
    if (!item || !entry) return;

    // Commentaires existants en lecture seule
    var commentsDisplay = formatCommentsReadonly(entry.comments);

    var refParts = reference.split(' ');
    var clauseNum = refParts[0] || reference;

    var fields = [
      { name: "status", label: t('docReview.status', 'Statut de conformité'), type: "select", value: entry.status, options: getDocReviewStatusOptions() },
      { name: "responsible", label: t('fields.owner', 'Responsable'), type: "text", value: entry.responsible, placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(entry.responsible), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "justification", label: t('fields.justification', 'Justification'), type: "textarea", value: entry.justification },
      { name: "tool", label: t('docReview.tool', 'Document / preuve'), type: "textarea", value: entry.tool },
      { name: "documentLink", label: t('docReview.documentLink', 'Lien document'), type: "text", value: entry.documentLink },
      { name: "lastReview", label: t('docReview.lastReview', 'Dernière revue'), type: "date", value: entry.lastReview },
      { name: "nextReview", label: t('docReview.nextReview', 'Prochaine revue'), type: "date", value: entry.nextReview }
    ];

    // Ajouter commentaires si existants
    if (commentsDisplay) {
      fields.push({ name: "existingComments", label: t('fields.existingComments', 'Commentaires existants'), type: "textarea", value: commentsDisplay, readOnly: true });
    }
    fields.push({ name: "newComment", label: t('fields.newComment', 'Nouveau commentaire'), type: "textarea" });

    openModal(
      t('modals.editDocReview', 'Modifier la clause') + ' \u2014 ' + clauseNum,
      fields,
      function(data) {
        if (!ensureEditMode("update the normative review")) return false;
        entry.status = data.status || '';
        entry.responsible = data.responsible || '';
        entry.justification = data.justification || '';
        entry.tool = data.tool || '';
        entry.documentLink = data.documentLink || '';
        entry.lastReview = data.lastReview || '';
        entry.nextReview = data.nextReview || '';
        if (appendComment(entry, data.newComment, 10) === 'max') {
          showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
        }
        addAuditEntry('edit', 'docReview', clauseNum);
        saveData();
        loadDocReviewTable();
        updateDashboard();
      }
    );
  }

  window.loadDocReviewTable = loadDocReviewTable;
  window.editDocReviewEntry = editDocReviewEntry;

  // M4 (audit 2026-07-10) : rebranche le moteur de scoring CyFun (computeCyfunScoring —
  // seuils CCB 2.5/3/3.5 + minimum par catégorie) sur un encart verdict VISIBLE, tel que
  // le wizard le promet à l'onboarding. Sans cela overallPass était calculé mais jamais
  // affiché (fonction morte). Rendu 100 % DOM (aucune injection HTML).
  function renderCyfunVerdict() {
    var host = document.getElementById('cyfunSummary');
    if (!host) return;
    host.textContent = '';
    var nis2Enabled = window.CSI_FrameworkMapper && CSI_FrameworkMapper.isEnabled('nis2');
    if (!nis2Enabled || (appData.nis2Referential || 'cyfun2025') !== 'cyfun2025') return;
    var s = (typeof computeCyfunScoring === 'function') ? computeCyfunScoring() : null;
    if (!s) return;
    var pass = !!s.overallPass;
    var okColor = '#059669', koColor = '#dc2626', warnColor = '#d97706';
    var wrap = document.createElement('div');
    wrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin:6px 0 14px;padding:12px 16px;border:1px solid var(--color-border,#e5e7eb);border-radius:10px;background:var(--color-surface,#fff);';

    var verdictBox = document.createElement('div');
    verdictBox.style.cssText = 'display:flex;flex-direction:column;align-items:center;min-width:120px;';
    var badge = document.createElement('div');
    badge.style.cssText = 'font-size:1.05rem;font-weight:800;padding:4px 12px;border-radius:12px;color:#fff;background:' + (pass ? okColor : koColor) + ';';
    badge.textContent = pass ? t('cyfun.compliant', 'CONFORME') : t('cyfun.nonCompliant', 'NON CONFORME');
    var lvl = document.createElement('div');
    lvl.style.cssText = 'font-size:0.72rem;color:var(--color-text-secondary,#6b7280);margin-top:4px;text-align:center;';
    lvl.textContent = (s.levelLabel || s.level || '') + ' · ' + t('cyfun.threshold', 'Seuil de conformité') + ' ' + (s.thresholds.total || 0) + '/5';
    verdictBox.appendChild(badge); verdictBox.appendChild(lvl);
    wrap.appendChild(verdictBox);

    var metrics = document.createElement('div');
    metrics.style.cssText = 'display:flex;flex-wrap:wrap;gap:14px;font-size:0.82rem;';
    // GOV-NIS2 S5 (M23) : troisième état 'warn' — un compteur non nul de mesures clés
    // sous le seuil n'est PAS une non-conformité CCB tant que la moyenne passe.
    var addMetric = function(label, value, ok) {
      var m = document.createElement('div');
      m.style.cssText = 'display:flex;flex-direction:column;gap:2px;';
      var v = document.createElement('span');
      var _mColor = (ok === 'warn') ? warnColor : (ok ? okColor : koColor);
      v.style.cssText = 'font-weight:700;color:' + _mColor + ';';
      v.textContent = value;
      var l = document.createElement('span');
      l.style.cssText = 'color:var(--color-text-secondary,#6b7280);';
      l.textContent = label;
      m.appendChild(v); m.appendChild(l);
      metrics.appendChild(m);
    };
    addMetric(t('cyfun.avgScore', 'Score moyen'), s.avgTotal + '/5', s.passes.total);
    if (s.keyMeasureCount > 0) addMetric(t('cyfun.keyMeasuresScore', 'Score mesures clés'), s.avgKeyTotal + '/5 (≥ ' + (s.thresholds.keyMeasures || 0) + ')', s.passes.keyMeasures);
    // GOV-NIS2 : nombre exact de mesures clés du périmètre + celles restant
    // sous le seuil CCB du niveau (= « à implémenter »), toujours visible dans le verdict.
    if (s.keyMeasureCount > 0) addMetric(t('cyfun.keyMeasuresBelow', 'Mesures clés sous le seuil'), s.keyBelowThreshold + ' / ' + s.keyMeasureCount, s.keyBelowThreshold === 0 ? true : (s.passes.keyMeasures ? 'warn' : false));
    if (s.thresholds.categoryMin) addMetric(t('cyfun.categoryMin', 'Minimum par catégorie'), '≥ ' + s.thresholds.categoryMin, s.passes.categories);
    addMetric(t('cyfun.coverage', 'Couverture'), s.scoredRequirements + ' / ' + s.totalRequirements, s.scoredRequirements > 0);
    wrap.appendChild(metrics);
    host.appendChild(wrap);

    if (s.unevaluatedCategories && s.unevaluatedCategories.length) {
      var warn = document.createElement('div');
      warn.style.cssText = 'margin-top:-8px;margin-bottom:12px;font-size:0.78rem;color:' + warnColor + ';';
      warn.textContent = t('cyfun.unevaluatedWarn', 'Mesure non évaluée = 0 dans le calcul du seuil (méthode CCB). Catégories non évaluées : {{n}}.').replace('{{n}}', s.unevaluatedCategories.length);
      host.appendChild(warn);
    }
  }

  function loadNis2ControlsTable() {
    const tbody = document.getElementById("nis2ControlsTableBody");
    if (!tbody) return;
    // FEAT-CYFUN-NL : réaligne les textes snapshotés sur le référentiel (langue courante).
    if (typeof refreshNis2ControlTexts === 'function') refreshNis2ControlTexts();
    var nis2Controls = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
    nis2Controls.forEach(function(c) {
      if (!c) return;
      if (c.owner === undefined) c.owner = '';
      if (c.reviewer === undefined) c.reviewer = '';
      if (c.reviewFrequency === undefined) c.reviewFrequency = '';
      if (c.lastEvaluation === undefined) c.lastEvaluation = '';
      if (c.lastEvaluatedBy === undefined) c.lastEvaluatedBy = '';
      if (c.nextReviewDate === undefined) c.nextReviewDate = '';
      if (c.evidence === undefined) c.evidence = '';
    });
    const _f = SectionToolbar.getFilters("nis2-controls");
    const _q = (SectionToolbar.getSearch("nis2-controls") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte

    // Populate function filter dynamically
    var funcs = [...new Set(nis2Controls.map(c => c.functionId).filter(Boolean))];
    var funcOrder = { 'GV': 1, 'ID': 2, 'PR': 3, 'DE': 4, 'RS': 5, 'RC': 6 };
    funcs.sort(SortUtils.orderedMap(funcOrder, 99));
    SectionToolbar.updateFilterOptions('nis2-controls', 'function', funcs.map(function(fId) {
      var fn = (window.CYFUN_FUNCTIONS || []).find(function(f) { return f.id === fId; });
      return { value: fId, label: fn ? cyfunLabelText(fn) + ' (' + fId + ')' : fId };
    }));
    // Populate category filter dynamically
    var cats = [...new Set(nis2Controls.map(c => c.categoryId).filter(Boolean))];
    cats.sort(SortUtils.text);
    SectionToolbar.updateFilterOptions('nis2-controls', 'category', cats.map(function(catId) {
      return { value: catId, label: CATEGORY_LABELS[catId] || catId };
    }));
    SectionToolbar.updateFilterOptions('nis2-controls', 'owner', _frameworkOwnerFilterOptions(nis2Controls, 'controlsNIS2'));
    SectionToolbar.updateFilterOptions('nis2-controls', 'reviewer', _frameworkReviewerFilterOptions(nis2Controls, 'controlsNIS2'));
    SectionToolbar.updateFilterOptions('nis2-controls', 'reviewDue', _frameworkReviewFilterOptions('controlsNIS2'));

    const fVal = _f.function || "";
    const cVal = _f.category || "";
    const sVal = _f.scoring || "";
    const lvlVal = _f.level || "";
    const keyVal = _f.keyMeasure || "";
    const ownerVal = _f.owner || "";
    const reviewerVal = _f.reviewer || "";
    const reviewVal = _f.reviewDue || "";

    const filtered = nis2Controls.filter(c =>
      (!fVal || c.functionId === fVal) &&
      (!cVal || c.categoryId === cVal) &&
      (!sVal || (sVal === "scored" ? (c.docMaturity > 0 || c.implMaturity > 0) : sVal === "unscored" ? (c.docMaturity === 0 && c.implMaturity === 0) : false)) &&
      (!lvlVal || c.level === lvlVal) &&
      (!keyVal || (keyVal === "key" ? c.isKeyMeasure : !c.isKeyMeasure)) &&
      _frameworkMatchesOwnerFilter(c, ownerVal) &&
      _frameworkMatchesReviewerFilter(c, reviewerVal) &&
      _frameworkMatchesReviewFilter(c, reviewVal) &&
      (!_q || ((c.ref || "") + " " + (c.title || "") + " " + (c.description || "") + " " + (c.owner || "") + " " + (c.reviewer || "")).toLowerCase().indexOf(_q) >= 0)
    );
    var _s = SectionToolbar.getSort("nis2-controls");
    var _sortKey = (_s && _s.key) || "reference";
    var _sortDir = (_s && _s.dir) || "asc";
    var sorted = filtered.slice().sort(function(a, b) {
      var diff;
      switch (_sortKey) {
        case "function": diff = SortUtils.text(a.functionId || "", b.functionId || ""); break;
        case "owner": diff = SortUtils.text(a.owner || "", b.owner || ""); break;
        case "reviewer": diff = SortUtils.text(a.reviewer || "", b.reviewer || ""); break;
        case "nextReviewDate": diff = SortUtils.date(a.nextReviewDate || "", b.nextReviewDate || ""); break;
        case "docMaturity": diff = SortUtils.numeric(a.docMaturity || 0, b.docMaturity || 0); break;
        case "implMaturity": diff = SortUtils.numeric(a.implMaturity || 0, b.implMaturity || 0); break;
        default: diff = SortUtils.reference(a.ref || a.id || "", b.ref || b.id || "");
      }
      return _sortDir === "desc" ? -diff : diff;
    });

    // Level badge colors
    var levelColors = { basic: '#059669', important: '#d97706', essential: '#dc2626' };

    tbody.textContent = "";
    sorted.forEach(c => {
      var row = document.createElement("tr");
      if (c.isKeyMeasure) row.classList.add("nis2-control-row--key-measure");
      row.style.cursor = "pointer";

      // Col 1: Ref + icons + catégorie en sous-ligne (Lot 1b : colonne Catégorie fusionnée ici)
      var td1 = document.createElement("td");
      var code = document.createElement("code");
      code.style.fontSize = "0.8em";
      code.textContent = c.ref || '';
      td1.appendChild(code);
      if (c.isKeyMeasure) {
        var star = document.createElement("i");
        star.className = "fas fa-star";
        star.style.cssText = "color:var(--color-warning-dark, #d97706);margin-left:4px";
        star.title = t('cyfun.keyMeasure', 'Mesure cl\u00e9');
        td1.appendChild(star);
      }
      if (c.isManagementLinked) {
        var bld = document.createElement("i");
        bld.className = "fas fa-building";
        bld.style.cssText = "color:var(--color-text-secondary, #6b7280);margin-left:4px";
        bld.title = t('cyfun.managementLinked', 'Mesure li\u00e9e au management');
        td1.appendChild(bld);
      }
      // Catégorie (bilingue) en sous-ligne discrète sous la réf
      var fw = (window._csiFrameworks || {})['cyfun2025'];
      var catDef = fw && fw.meta ? fw.meta.categories.find(function(ct) { return ct.id === c.categoryId; }) : null;
      var catText = catDef ? cyfunLabelText(catDef) : (c.categoryLabel || c.categoryId || '');
      if (catText) {
        var catLine = document.createElement("div");
        catLine.className = "framework-cat-subline";
        catLine.textContent = catText;
        catLine.title = catText;
        td1.appendChild(catLine);
      }
      row.appendChild(td1);

      // Col 2: Function badge (code seul, libellé complet en title)
      var td2 = document.createElement("td");
      var fnDef = (window.CYFUN_FUNCTIONS || []).find(function(f) { return f.id === c.functionId; });
      var badge = document.createElement("span");
      var fnColor = fnDef ? fnDef.color : '#6b7280';
      badge.style.cssText = "display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:12px;font-size:0.8em;font-weight:600;white-space:nowrap;background:" + fnColor + "15;color:" + fnColor;
      if (fnDef && fnDef.icon) {
        var ico = document.createElement("i");
        ico.className = "fas " + fnDef.icon;
        ico.style.fontSize = "0.85em";
        badge.appendChild(ico);
      }
      var fnLabel = fnDef ? cyfunLabelText(fnDef) : '';
      badge.appendChild(document.createTextNode(c.functionId || ''));
      badge.title = fnLabel ? c.functionId + ' - ' + fnLabel : (c.functionId || '');
      td2.appendChild(badge);
      // Level indicator
      var lvlBadge = document.createElement("span");
      var lvlColor = levelColors[c.level] || '#6b7280';
      lvlBadge.style.cssText = "display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;font-size:0.7em;font-weight:600;background:" + lvlColor + "18;color:" + lvlColor;
      lvlBadge.textContent = (c.level || '').toUpperCase().charAt(0);
      lvlBadge.title = c.level ? c.level.charAt(0).toUpperCase() + c.level.slice(1) : '';
      td2.appendChild(lvlBadge);
      row.appendChild(td2);

      // Col 3: Description
      var td4 = document.createElement("td");
      td4.className = "nis2-desc-cell";
      td4.title = c.description || '';
      td4.textContent = c.title || c.description || '';
      row.appendChild(td4);

      // Col 4-5: Responsables (GOV-NIS2 : R. Mesure ligne 1 + R. Contrôle ligne 2, la
      // colonne unique préserve le standard ≤7 colonnes) / Prochaine revue
      var tdResp = document.createElement('td');
      tdResp.className = 'framework-owner-cell';
      tdResp.style.whiteSpace = 'nowrap';
      var ownLine = document.createElement('div');
      ownLine.textContent = c.owner || '—';
      // GOV-NIS2 S5 : teinte secondaire — en thème sombre, --color-text-light est PLUS
      // clair que le texte normal et faisait ressortir le tiret plus que les noms réels.
      if (!c.owner) ownLine.style.color = 'var(--color-text-secondary, #6b7280)';
      ownLine.title = t('controlsNIS2.owner', 'Responsable de la mesure') + (c.owner ? ' : ' + c.owner : '');
      tdResp.appendChild(ownLine);
      if (c.reviewer) {
        var revLine = document.createElement('div');
        revLine.style.cssText = 'font-size:0.78em;color:var(--color-text-secondary,#6b7280);';
        var revIco = document.createElement('i');
        revIco.className = 'fas fa-user-check';
        revIco.style.cssText = 'margin-right:4px;font-size:0.9em;';
        revLine.appendChild(revIco);
        revLine.appendChild(document.createTextNode(c.reviewer));
        revLine.title = t('controlsNIS2.reviewer', 'Responsable du contrôle') + ' : ' + c.reviewer;
        tdResp.appendChild(revLine);
      }
      row.appendChild(tdResp);
      _frameworkAppendTextCell(row, c.nextReviewDate || '', { nowrap: true, className: 'framework-review-cell' });

      // Col 6-7: Doc / Impl (Lot 1b : colonne « Moy. » supprimée, couleur toujours basée sur la moyenne)
      var docVal = c.docMaturity || 0;
      var implVal = c.implMaturity || 0;
      var avg = docVal > 0 || implVal > 0 ? +((docVal + implVal) / 2).toFixed(1) : 0;
      var matColor = avg >= 3.5 ? '#059669' : avg >= 2.5 ? '#d97706' : avg > 0 ? '#dc2626' : '#9ca3af';

      [docVal, implVal].forEach(function(val) {
        var td = document.createElement("td");
        td.style.textAlign = "center";
        var sp = document.createElement("span");
        sp.style.color = matColor;
        sp.style.fontWeight = "600";
        sp.textContent = val > 0 ? String(val) : '—';
        td.appendChild(sp);
        row.appendChild(td);
      });

      row.classList.add("row-clickable");
      row.addEventListener("click", function() { editNis2Control(c.id); });
      tbody.appendChild(row);
    });

    // Update counts
    var countEl = document.getElementById("nis2ControlsCount");
    if (countEl) countEl.textContent = filtered.length + ' / ' + nis2Controls.length;

    // M4 : rafraîchir l'encart verdict de conformité CyFun (seuils CCB).
    if (typeof renderCyfunVerdict === 'function') renderCyfunVerdict();
  }

  // GOV-NIS2 (2026-08-20) — trace d'évaluation CyFun : si une maturité change, la date de
  // dernière évaluation suit automatiquement (sauf saisie manuelle dans la même sauvegarde)
  // et l'évaluateur courant est mémorisé (lastEvaluatedBy, '' en mode local sans compte).
  // Retourne '' si aucune maturité n'a changé, sinon le détail à journaliser dans l'audit.
  // GOV-NIS2 S3 (audit 2026-08-20, MAJEUR M6) — dérivation UNIQUE des champs legacy
  // status/cmm depuis les maturités. Les chemins de migration/import écrivaient les
  // maturités sans re-dériver (mesure 4/4 exportée « Non implémenté »/« Initial »,
  // voire un ENTIER dans cmm côté import legacy).
  function deriveNis2StatusCmm(control) {
    var avg = ((control.docMaturity || 0) + (control.implMaturity || 0)) / 2;
    if (avg >= 4.5) control.cmm = 'Optimisé';
    else if (avg >= 3.5) control.cmm = 'Géré';
    else if (avg >= 2.5) control.cmm = 'Défini';
    else if (avg >= 1.5) control.cmm = 'Répétable';
    else control.cmm = 'Initial';
    control.status = avg >= 3 ? 'implemente' : avg >= 1 ? 'en_cours' : 'non_implemente';
  }

  function nis2ApplyEvaluationTrace(control, oldDoc, oldImpl, prevLastEval, todayIso, evaluator) {
    var changed = (control.docMaturity !== oldDoc) || (control.implMaturity !== oldImpl);
    if (!changed) return '';
    if (!control.lastEvaluation || control.lastEvaluation === prevLastEval) {
      control.lastEvaluation = todayIso;
    }
    // GOV-NIS2 S4 (M15) : l'évaluateur reflète TOUJOURS l'auteur du changement — '' quand
    // il est inconnu (mode local sans compte). L'ancien garde `if (evaluator)` conservait
    // le nom du précédent évaluateur : attribution fausse dans la modale et les exports.
    control.lastEvaluatedBy = evaluator || '';
    return 'Doc ' + oldDoc + '→' + control.docMaturity + ' · Impl ' + oldImpl + '→' + control.implMaturity;
  }

  // GOV-NIS2 S4 (audit 2026-08-20, BLOQUANT B6) — trace d'évaluation ReCyF factorisée :
  // appelée par la MODALE et par le select inline du tableau (le chemin d'évaluation
  // principal, qui contournait totalement journal / auto-date / évaluateur). Retourne ''
  // si la conformité n'a pas changé, sinon le détail à journaliser.
  function recyfApplyEvaluationTrace(control, oldConf, prevLastEval, todayIso, evaluator) {
    var newConf = control.conformity || 'non_evalue';
    if (newConf === (oldConf || 'non_evalue')) return '';
    if (!control.lastEvaluation || control.lastEvaluation === prevLastEval) {
      control.lastEvaluation = todayIso;
    }
    control.lastEvaluatedBy = evaluator || '';
    return 'Conformité ' + (oldConf || 'non_evalue') + ' → ' + newConf;
  }

  function editNis2Control(id) {
    var _nis2CtrlsEdit = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls('cyfun2025') : (appData.nis2Controls || []);
    const control = _nis2CtrlsEdit.find(c => c.id === id);
    if (!control) return;
    // Build maturity options (1-5)
    const maturityOptions = [
      { value: "0", label: "\u2014 " + t('controlsISO.notEvaluated', 'Non évalué') + " \u2014" },
      { value: "1", label: "1 - " + t('controlsISO.initial', 'Initial') },
      { value: "2", label: "2 - " + t('controlsISO.repeatable', 'Répétable') },
      { value: "3", label: "3 - " + t('controlsISO.defined', 'Défini') },
      { value: "4", label: "4 - " + t('controlsISO.managed', 'Géré') },
      { value: "5", label: "5 - " + t('controlsISO.optimized', 'Optimisé') }
    ];
    const fields = [
      { name: "ref", label: t('controlsNIS2.ref', 'Réf.'), value: control.ref || '', readOnly: true },
      { name: "functionLabel", label: t('controlsNIS2.function', 'Fonction'), value: (function() {
        var fn = (window.CYFUN_FUNCTIONS || []).find(function(f) { return f.id === control.functionId; });
        return fn ? control.functionId + ' - ' + cyfunLabelText(fn) : control.functionLabel || control.functionId || '';
      })(), readOnly: true },
      { name: "categoryLabel", label: t('controlsNIS2.category', 'Catégorie'), value: (function() {
        var fw = (window._csiFrameworks || {})['cyfun2025'];
        var cat = fw && fw.meta ? fw.meta.categories.find(function(c2) { return c2.id === control.categoryId; }) : null;
        return cat ? control.categoryId + ' - ' + cyfunLabelText(cat) : control.categoryLabel || control.categoryId || '';
      })(), readOnly: true },
      { name: "description", label: t('fields.description'), type: "textarea", value: control.description, readOnly: true },
      // GOV-NIS2 : libellés NIS2 dédiés (clés controlsNIS2.*, PAS fields.controlOwner qui
      // est partagée avec ISO 27002) — « Responsable de la mesure » vs « Responsable du
      // contrôle » pour matérialiser la séparation des rôles (anti conflit d'intérêts).
      { name: "owner", label: t('controlsNIS2.owner', 'Responsable de la mesure'), value: control.owner || "", placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(control.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "reviewer", label: t('controlsNIS2.reviewer', 'Responsable du contrôle'), value: control.reviewer || "", placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(control.reviewer), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "lastEvaluation", label: t('fields.lastEvaluation', 'Dernière évaluation'), type: "date", value: control.lastEvaluation || "" },
      { name: "nextReviewDate", label: t('fields.nextReviewDate', 'Prochaine revue'), type: "date", value: control.nextReviewDate || "" },
      { name: "reviewFrequency", label: t('fields.reviewFrequency', 'Fréquence de revue'), type: "select", value: control.reviewFrequency || "", options: CONTROL_REVIEW_FREQ_OPTIONS },
      { name: "docMaturity", label: t('cyfun.docMaturity', 'Maturité documentation') + " (1-5)", type: "select", value: String(control.docMaturity || 0), options: maturityOptions },
      { name: "implMaturity", label: t('cyfun.implMaturity', 'Maturité implémentation') + " (1-5)", type: "select", value: String(control.implMaturity || 0), options: maturityOptions },
      { name: "justification", label: t('controlsNIS2.justification', 'Justification'), type: "textarea", value: control.justification || "" },
      { name: "evidence", label: t('cyfun.evidence', 'Preuves / Artefacts'), type: "textarea", value: control.evidence || "", placeholder: t('cyfun.evidencePlaceholder', 'Documents, logs, captures...') },
      // Unification commentaires 2026-07-10 : même pattern que les actions (historique + ajout).
      // Le champ comments existait déjà dans le modèle CyFun (préservé au rebuild/import) mais sans UI.
      { name: "existingComments", label: t('fields.existingComments'), type: "textarea", value: formatCommentsReadonly(control.comments), readOnly: true },
      { name: "newComment", label: t('fields.newComment'), type: "textarea" }
    ];
    // Add badges info
    if (control.isKeyMeasure) {
      fields.splice(1, 0, { name: "_keyMeasure", label: t('cyfun.keyMeasure', 'Mesure clé'), value: "\u2b50 " + t('cyfun.keyMeasureInfo', 'Oui - Cette mesure est critique pour la conformité'), readOnly: true });
    }
    if (control.isManagementLinked) {
      fields.splice(control.isKeyMeasure ? 2 : 1, 0, { name: "_mgmtLinked", label: t('cyfun.managementLinked', 'Mesure liée au management'), value: "\ud83c\udfe2 " + t('cyfun.mgmtLinkedInfo', 'Oui - Revue obligatoire à chaque audit'), readOnly: true });
    }
    // GOV-NIS2 : évaluateur mémorisé (lecture seule, affiché seulement s'il existe).
    if (control.lastEvaluatedBy) {
      var _lePos = fields.findIndex(function(f) { return f.name === 'nextReviewDate'; });
      if (_lePos > 0) fields.splice(_lePos, 0, { name: "lastEvaluatedBy", label: t('cyfun.lastEvaluatedBy', 'Évalué par'), value: control.lastEvaluatedBy, readOnly: true });
    }
    openModal(t('modals.nis2ControlTracking', 'Suivi de la mesure de sécurité'), fields, (data) => {
      // GOV-NIS2 : état avant édition, pour la trace d'audit et l'auto-date d'évaluation.
      var _oldDoc = control.docMaturity || 0;
      var _oldImpl = control.implMaturity || 0;
      var _prevLastEval = control.lastEvaluation || '';
      var _oldRolesEqual = !!(control.owner && control.reviewer && control.owner.trim() &&
        control.owner.trim().toLowerCase() === control.reviewer.trim().toLowerCase());
      control.docMaturity = parseInt(data.docMaturity, 10) || 0;
      control.implMaturity = parseInt(data.implMaturity, 10) || 0;
      control.owner = data.owner || '';
      control.reviewer = data.reviewer || '';
      control.lastEvaluation = data.lastEvaluation || '';
      control.nextReviewDate = data.nextReviewDate || '';
      control.reviewFrequency = data.reviewFrequency || '';
      control.justification = data.justification || '';
      control.evidence = data.evidence || '';
      if (appendComment(control, data.newComment, 10) === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
      }
      // GOV-NIS2 S3 (M6) : dérivation unique des champs legacy status/cmm.
      deriveNis2StatusCmm(control);
      // GOV-NIS2 : journal d'audit sur tout changement de maturité (qui/quoi/quand) +
      // avertissement NON bloquant si le même nom occupe les deux rôles (séparation des
      // fonctions — un correcteur ne devrait pas contrôler sa propre mesure).
      var _traceDetails = nis2ApplyEvaluationTrace(control, _oldDoc, _oldImpl, _prevLastEval, new Date().toISOString().slice(0, 10), currentCommentAuthor());
      if (_traceDetails && typeof addAuditEntry === 'function') addAuditEntry('edit', 'nis2', control.ref || control.id, _traceDetails);
      // GOV-NIS2 S5 (M21) : averti uniquement quand le cumul des rôles est INTRODUIT par
      // cette sauvegarde — un état déjà connu et accepté ne re-toaste plus à chaque édition.
      var _rolesEqual = !!(control.owner && control.reviewer && control.owner.trim() &&
        control.owner.trim().toLowerCase() === control.reviewer.trim().toLowerCase());
      if (_rolesEqual && !_oldRolesEqual) {
        showToast2(t('controlsNIS2.sameOwnerReviewerWarn', 'Responsable de la mesure et responsable du contrôle identiques — séparation des rôles recommandée.'), 'warning');
      }
      // GOV-NIS2 S3 (B5) : recalculer la maturité dérivée AVANT de sérialiser — l'ancien
      // ordre sauvegardait la valeur périmée (KPI dashboard faux après rechargement).
      recalcNis2Maturity();
      saveData();
      loadNis2ControlsTable();
      updateDashboard();
    });
  }

  // ============================================================================
  // ReCyF (Référentiel Cyber France / ANSSI) — NIS2 « France »
  // Référentiel de CONFORMITÉ indépendant de CyFun (« Belgique »), qui reste
  // totalement inchangé. Modèle d'évaluation = statut de conformité par mesure
  // (moyens acceptables de conformité ANSSI), pas de maturité 1-5.
  //   appData.recyfControls   : mesures évaluées { conformity, justification }
  //   appData.recyfLevel      : 'importante' (EI, obj. 1-15) | 'essentielle' (EE, obj. 1-20)
  //   appData.nis2Referential : 'cyfun2025' (défaut) | 'recyf2025'
  // ============================================================================

  function _recyfUiLang() {
    return (window.CSI_i18n && CSI_i18n.getLanguage && CSI_i18n.getLanguage()) || 'fr';
  }
  function getRecyfControls() {
    return window.CSI_FrameworkMapper
      ? CSI_FrameworkMapper.getAppDataControls('recyf2025')
      : (appData.recyfControls || []);
  }
  function setRecyfControlsData(controls) {
    if (window.CSI_FrameworkMapper) CSI_FrameworkMapper.setAppDataControls('recyf2025', controls);
    else appData.recyfControls = controls;
  }
  function _recyfConformityOption(value) {
    return (window.RECYF_CONFORMITY_OPTIONS || []).find(function(o) { return o.value === value; }) || null;
  }
  // GOV-NIS2 S5 (audit 2026-08-20, MAJEUR M18) : libellé de conformité TRILINGUE — toute
  // langue ≠ en retombait sur le français (cellules FR sous des en-têtes NL).
  function _recyfOptLabel(o) {
    if (!o) return '';
    var lang = _recyfUiLang();
    if (lang === 'en' && o.labelEn) return o.labelEn;
    if (lang === 'nl' && o.labelNl) return o.labelNl;
    return o.label;
  }

  function _recyfConformityLabel(value) {
    var o = _recyfConformityOption(value);
    if (!o) return value || '';
    return _recyfOptLabel(o);
  }

  function _frameworkTodayIso() {
    return new Date().toISOString().slice(0, 10);
  }

  function _frameworkAddDaysIso(days) {
    var d = new Date();
    d.setDate(d.getDate() + days);
    return d.toISOString().slice(0, 10);
  }

  function _frameworkMatchesReviewFilter(control, filterValue) {
    var next = control && control.nextReviewDate ? String(control.nextReviewDate).slice(0, 10) : '';
    if (!filterValue) return true;
    if (!next) return false;
    var today = _frameworkTodayIso();
    if (filterValue === 'overdue') return next < today;
    if (filterValue === 'next30') return next >= today && next <= _frameworkAddDaysIso(30);
    return true;
  }

  function _frameworkOwnerFilterOptions(controls, i18nRoot) {
    var seen = {};
    // GOV-NIS2 S5 : valeurs TRIMÉES — le matcher compare des .trim(), une option « Bob »
    // avec espace final ne matchait jamais (filtre à 0 ligne, sans message).
    (appData.stakeholders || []).forEach(function(st) {
      var n = st && st.name ? String(st.name).trim() : '';
      if (n) seen[n] = true;
    });
    (controls || []).forEach(function(control) {
      var n = control && control.owner ? String(control.owner).trim() : '';
      if (n) seen[n] = true;
    });
    var options = Object.keys(seen).sort(SortUtils.text).map(function(name) {
      return { value: name, label: name };
    });
    options.unshift({ value: '__unassigned', label: t(i18nRoot + '.unassignedOwner', 'Sans responsable') });
    return options;
  }

  function _stakeholderNameSuggestions(currentValue) {
    var seen = {};
    var names = [];
    function add(value) {
      var name = typeof value === 'string' ? value.trim() : '';
      if (!name || seen[name]) return;
      seen[name] = true;
      names.push(name);
    }
    (appData.stakeholders || []).forEach(function(st) { if (st) add(st.name); });
    add(currentValue || '');
    return names.sort(SortUtils.text);
  }

  function _frameworkReviewFilterOptions(i18nRoot) {
    return [
      { value: 'overdue', label: t(i18nRoot + '.reviewOverdue', 'Revue échue') },
      { value: 'next30', label: t(i18nRoot + '.reviewNext30', 'Revue à 30 jours') }
    ];
  }

  function _frameworkMatchesOwnerFilter(control, ownerFilter) {
    if (!ownerFilter) return true;
    var owner = control && control.owner ? String(control.owner).trim() : '';
    if (ownerFilter === '__unassigned') return !owner;
    return owner === ownerFilter;
  }

  // GOV-NIS2 — filtre « Responsable du contrôle » (miroir exact du filtre responsable).
  function _frameworkReviewerFilterOptions(controls, i18nRoot) {
    var seen = {};
    (appData.stakeholders || []).forEach(function(st) {
      var n = st && st.name ? String(st.name).trim() : '';
      if (n) seen[n] = true;
    });
    (controls || []).forEach(function(control) {
      var n = control && control.reviewer ? String(control.reviewer).trim() : '';
      if (n) seen[n] = true;
    });
    var options = Object.keys(seen).sort(SortUtils.text).map(function(name) {
      return { value: name, label: name };
    });
    options.unshift({ value: '__unassigned', label: t(i18nRoot + '.unassignedReviewer', 'Sans responsable du contrôle') });
    return options;
  }

  function _frameworkMatchesReviewerFilter(control, reviewerFilter) {
    if (!reviewerFilter) return true;
    var reviewer = control && control.reviewer ? String(control.reviewer).trim() : '';
    if (reviewerFilter === '__unassigned') return !reviewer;
    return reviewer === reviewerFilter;
  }

  function _frameworkAppendTextCell(row, value, options) {
    var td = document.createElement('td');
    if (options && options.className) td.className = options.className;
    var text = value ? String(value) : '—';
    td.textContent = text;
    td.title = text === '—' ? '' : text;
    if (!value) td.style.color = 'var(--color-text-light, #9ca3af)';
    if (options && options.nowrap) td.style.whiteSpace = 'nowrap';
    row.appendChild(td);
    return td;
  }

  // Construit la liste des mesures ReCyF pour le degré courant (cumulatif EI ⊂ EE)
  var _RECYF_LEVEL_ORDER = { importante: 1, essentielle: 2 };
  function _recyfMaxOrder() { return _RECYF_LEVEL_ORDER[appData.recyfLevel || 'importante'] || 1; }

  // Construit TOUTES les mesures ReCyF (152). Le degré EI/EE ne filtre QUE l'affichage et
  // le score (cf. recyfControlsInScope) — jamais le stockage. Ainsi, changer de degré ne
  // perd jamais d'évaluation (ex. EE→EI→EE conserve les mesures EE). L'id est dérivé de la
  // réf (stable et unique), pas de l'index (évite tout doublon d'id lors d'un changement).
  function loadRecyfControls() {
    var fw = (window._csiFrameworks || {})['recyf2025'];
    if (!fw || !fw.requirements) {
      void 0;
      return [];
    }
    if (typeof window.rebuildRecyfThemes === 'function') window.rebuildRecyfThemes(appData.recyfLevel || 'importante');
    return fw.requirements.map(function(req) {
      return {
        id: 'RECYF-' + String(req.ref).replace(/[^A-Za-z0-9]+/g, '_'),
        ref: req.ref,
        objectiveId: req.objectiveId,
        objectiveLabel: req.objectiveLabel,
        theme: req.theme,
        themeId: req.themeId,
        target: req.target,
        targets: req.targets || [],
        level: req.level,
        content: req.content || '',
        conformity: 'non_evalue',
        justification: '',
        owner: '',
        reviewer: '',
        reviewFrequency: '',
        lastEvaluation: '',
        nextReviewDate: '',
        evidence: '',
        comments: [] // Unification 2026-07-10 : alignement sur CyFun ({text,date,id?,author?})
      };
    });
  }

  // Mesures dans le périmètre du degré courant (EI = importante uniquement ; EE = tout)
  function recyfControlsInScope() {
    var maxO = _recyfMaxOrder();
    return getRecyfControls().filter(function(c) { return (_RECYF_LEVEL_ORDER[c.level] || 99) <= maxO; });
  }

  // Génère/migre recyfControls vers l'ensemble COMPLET (152), en préservant les évaluations par réf.
  // Migre aussi les anciennes données filtrées (76) vers l'ensemble complet sans perte.
  function ensureRecyfControls() {
    var fw = (window._csiFrameworks || {})['recyf2025'];
    var full = (fw && fw.requirements) ? fw.requirements.length : 152;
    var c = getRecyfControls();
    if (!Array.isArray(c) || c.length < full) {
      var byRef = {};
      (c || []).forEach(function(x) { if (x && x.ref) byRef[x.ref] = x; });
      var rebuilt = loadRecyfControls().map(function(x) {
        var p = byRef[x.ref];
        if (p) {
          x.conformity = p.conformity || 'non_evalue';
          x.justification = p.justification || '';
          x.owner = p.owner || '';
          x.reviewer = p.reviewer || '';
          x.reviewFrequency = p.reviewFrequency || '';
          x.lastEvaluation = p.lastEvaluation || '';
          // GOV-NIS2 (revue adverse) : préserver aussi l'évaluateur au rebuild —
          // même invariant « sans perte » que les autres champs de gouvernance.
          x.lastEvaluatedBy = p.lastEvaluatedBy || '';
          x.nextReviewDate = p.nextReviewDate || '';
          x.evidence = p.evidence || '';
          x.comments = normalizeComments(p.comments);
          // GOV-NIS2 S5 : même filet générique que FIX-CYFUN-LEVEL-LOSS — tout champ
          // utilisateur inconnu de la liste survit au rebuild (futurs champs inclus).
          Object.keys(p).forEach(function(k) { if (!(k in x)) x[k] = p[k]; });
        }
        return x;
      });
      setRecyfControlsData(rebuilt);
    } else {
      c.forEach(function(x) {
        if (!x) return;
        if (x.owner === undefined) x.owner = '';
        if (x.reviewer === undefined) x.reviewer = '';
        if (x.reviewFrequency === undefined) x.reviewFrequency = '';
        if (x.lastEvaluation === undefined) x.lastEvaluation = '';
        if (x.lastEvaluatedBy === undefined) x.lastEvaluatedBy = '';
        if (x.nextReviewDate === undefined) x.nextReviewDate = '';
        if (x.evidence === undefined) x.evidence = '';
        if (!Array.isArray(x.comments)) x.comments = normalizeComments(x.comments);
      });
    }
  }

  // Change le degré (EI/EE) : n'affecte QUE l'affichage et le score, jamais les évaluations stockées.
  function setRecyfDegree(level) {
    if (!level || ['importante', 'essentielle'].indexOf(level) < 0) level = 'importante';
    appData.recyfLevel = level;
    if (typeof window.rebuildRecyfThemes === 'function') window.rebuildRecyfThemes(level);
    ensureRecyfControls();
    if (typeof saveData === 'function') saveData();
  }

  // Statistiques de conformité ReCyF
  function computeRecyfConformity() {
    var controls = recyfControlsInScope();
    var opts = window.RECYF_CONFORMITY_OPTIONS || [];
    var weightByValue = {};
    opts.forEach(function(o) { weightByValue[o.value] = o.weight; });
    var counts = {};
    opts.forEach(function(o) { counts[o.value] = 0; });
    var evaluated = 0, weightSum = 0, na = 0;
    controls.forEach(function(c) {
      var v = c.conformity || 'non_evalue';
      if (counts[v] === undefined) counts[v] = 0;
      counts[v]++;
      if (v === 'non_applicable') { na++; return; }
      var w = weightByValue[v];
      if (w === null || w === undefined) return; // non évalué
      evaluated++;
      weightSum += w;
    });
    var percent = evaluated > 0 ? Math.round((weightSum / evaluated) * 100) : 0;
    // M3 : taux « coverage-aware » (dénominateur = mesures applicables, les non évaluées
    // comptent pour 0). Utilisé par le KPI global du dashboard ; `percent` (évalué-based)
    // reste affiché dans le module ReCyF avec sa couverture explicite.
    var _applicable = controls.length - na;
    var percentApplicable = _applicable > 0 ? Math.round((weightSum / _applicable) * 100) : 0;
    return {
      total: controls.length,
      counts: counts,
      evaluated: evaluated,
      na: na,
      applicable: _applicable,
      percent: percent,
      percentApplicable: percentApplicable
    };
  }

  // Taux de conformité par objectif de sécurité (pour le radar/araignée du dashboard).
  // Renvoie un tableau ordonné des objectifs DANS LE PÉRIMÈTRE du degré courant.
  function computeRecyfConformityByObjective() {
    var controls = recyfControlsInScope();
    var opts = window.RECYF_CONFORMITY_OPTIONS || [];
    var w = {}; opts.forEach(function(o) { w[o.value] = o.weight; });
    var byObj = {};
    controls.forEach(function(c) {
      var o = c.objectiveId;
      if (!byObj[o]) byObj[o] = { objectiveId: o, objectiveLabel: c.objectiveLabel || '', wsum: 0, evaluated: 0, total: 0, na: 0 };
      byObj[o].total++;
      var v = c.conformity || 'non_evalue';
      if (v === 'non_applicable') { byObj[o].na++; return; }
      var ww = w[v];
      if (ww === null || ww === undefined) return; // non évalué
      byObj[o].evaluated++;
      byObj[o].wsum += ww;
    });
    var fw = (window._csiFrameworks || {})['recyf2025'];
    var order = (fw && fw.meta && fw.meta.objectives) ? fw.meta.objectives.map(function(o) { return o.id; }) : Object.keys(byObj);
    return order.filter(function(id) { return byObj[id]; }).map(function(id) {
      var b = byObj[id];
      // percent = conformité des mesures ÉVALUÉES (pour affichage détaillé du module).
      b.percent = b.evaluated > 0 ? Math.round((b.wsum / b.evaluated) * 100) : 0;
      // M-RC3 (audit fond 2026-07-11) : percentApplicable = coverage-aware (dénominateur =
      // mesures applicables, non évaluées comptées 0). Utilisé par le RADAR du dashboard pour
      // rester COHÉRENT avec le KPI global (lui aussi coverage-aware depuis M3) — sinon 100 %
      // au radar vs 13 % à la jauge sur donnée identique.
        var _applicable = b.total - b.na;
      b.applicable = _applicable;
      b.percentApplicable = _applicable > 0 ? Math.round((b.wsum / _applicable) * 100) : 0;
      return b;
    });
  }

  function updateRecyfCount(shown, total) {
    var el = document.getElementById('recyfControlsCount');
    if (!el) return;
    if (typeof shown !== 'number') { total = recyfControlsInScope().length; shown = total; }
    el.textContent = shown + ' / ' + total;
  }

  function _recyfForceSync() {
    // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
    // FIX-CONFIG-CHANNEL (Lot 3, 2026-07-17) : dernier site direct — les évaluations ReCyF
    // partaient par l'import complet avec .catch muet (perte silencieuse sur 423, canal
    // relevé à l'audit). _persistConfigWithLock route désormais recyfControls par sa section
    // (grappe coeur, généralement déjà détenue pendant l'édition du tableau) et recyfLevel
    // par app-config, atomiquement, avec toasts honnêtes en échec.
    if (window.CSI_SyncManager && window.CSI_SyncManager.isServerMode &&
        window.CSI_SyncManager.isServerMode() && window.CSI_SyncManager.forceServerSync) {
      setTimeout(function() {
        // GOV-NIS2 S2 (audit 2026-08-20) : une évaluation ne change QUE recyfControls —
        // déclarer recyfLevel ici entraînait la section app-config entière dans CHAQUE
        // push d'évaluation (contention de la grappe config + fenêtre d'écrasement B4).
        // Le degré a son propre site de persistance (sélecteur des Réglages).
        _persistConfigWithLock(['recyfControls']);
      }, 500);
    }
  }

  // Synthèse de conformité (score global + degré + répartition par statut)
  function renderRecyfSummary() {
    var host = document.getElementById('recyfSummary');
    if (!host) return;
    ensureRecyfControls();
    var stats = computeRecyfConformity();
    var opts = window.RECYF_CONFORMITY_OPTIONS || [];
    var degree = appData.recyfLevel || 'importante';
    var degInfo = (window.RECYF_LEVELS || []).find(function(l) { return l.id === degree; }) || { short: 'EI', label: 'Entité Importante' };

    host.textContent = '';
    var wrap = document.createElement('div');
    wrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin:6px 0 14px;padding:12px 16px;border:1px solid var(--color-border,#e5e7eb);border-radius:10px;background:var(--color-surface,#fff);';

    // Score
    var scoreBox = document.createElement('div');
    scoreBox.style.cssText = 'display:flex;flex-direction:column;align-items:center;min-width:96px;';
    var pct = document.createElement('div');
    var pColor = stats.percent >= 80 ? '#059669' : stats.percent >= 50 ? '#d97706' : '#dc2626';
    pct.style.cssText = 'font-size:1.8rem;font-weight:800;line-height:1;color:' + pColor + ';';
    pct.textContent = stats.percent + '%';
    var pLbl = document.createElement('div');
    pLbl.style.cssText = 'font-size:0.72rem;color:var(--color-text-secondary,#6b7280);margin-top:4px;text-align:center;';
    pLbl.textContent = t('recyfControls.conformityRate', 'Conformité (mesures évaluées)');
    scoreBox.appendChild(pct);
    scoreBox.appendChild(pLbl);
    wrap.appendChild(scoreBox);

    // Degré + progression
    var meta = document.createElement('div');
    meta.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
    var degBadge = document.createElement('span');
    degBadge.style.cssText = 'align-self:flex-start;padding:2px 10px;border-radius:12px;font-size:0.78rem;font-weight:700;background:#0055A415;color:#0055A4;';
    degBadge.textContent = (degInfo.short || '') + ' — ' + (degInfo.label || '');
    var prog = document.createElement('span');
    prog.style.cssText = 'font-size:0.8rem;color:var(--color-text-secondary,#6b7280);';
    prog.textContent = t('recyfControls.evaluatedCount', 'Évaluées') + ' : ' + stats.evaluated + ' / ' + stats.applicable +
      '  ·  ' + t('recyfControls.naCount', 'N/A') + ' : ' + stats.na +
      '  ·  ' + t('recyfControls.totalCount', 'Total') + ' : ' + stats.total;
    meta.appendChild(degBadge);
    meta.appendChild(prog);
    wrap.appendChild(meta);

    // Chips par statut
    var chips = document.createElement('div');
    chips.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-left:auto;';
    opts.forEach(function(o) {
      var n = stats.counts[o.value] || 0;
      if (o.value === 'non_evalue' && n === 0) return;
      var chip = document.createElement('span');
      chip.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:14px;font-size:0.76rem;font-weight:600;background:' + (o.color || '#6b7280') + '18;color:' + (o.color || '#374151') + ';';
      var dot = document.createElement('span');
      dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:' + (o.color || '#6b7280') + ';';
      chip.appendChild(dot);
      chip.appendChild(document.createTextNode(_recyfOptLabel(o) + ' : ' + n));
      chips.appendChild(chip);
    });
    wrap.appendChild(chips);
    host.appendChild(wrap);
  }

  // Tableau des mesures ReCyF
  function loadRecyfControlsTable() {
    var tbody = document.getElementById('recyfControlsTableBody');
    if (!tbody) return;
    ensureRecyfControls();
    var controls = recyfControlsInScope();
    var opts = window.RECYF_CONFORMITY_OPTIONS || [];
    var colorByValue = {};
    opts.forEach(function(o) { colorByValue[o.value] = o.color; });

    var _f = (typeof SectionToolbar !== 'undefined' && SectionToolbar.getFilters) ? SectionToolbar.getFilters('recyf-controls') : {};
    var _q = ((typeof SectionToolbar !== 'undefined' && SectionToolbar.getSearch) ? SectionToolbar.getSearch('recyf-controls') : '').trim().toLowerCase(); // Toolbar v2 : recherche plein texte

    // Options de filtres dynamiques
    if (typeof SectionToolbar !== 'undefined' && SectionToolbar.updateFilterOptions) {
      var fw = (window._csiFrameworks || {})['recyf2025'];
      var objSeen = {}, objOpts = [];
      controls.forEach(function(c) {
        if (!objSeen[c.objectiveId]) { objSeen[c.objectiveId] = true; objOpts.push({ value: c.objectiveId, label: 'Obj. ' + c.objectiveId + ' — ' + (c.objectiveLabel || ''), _n: parseInt(c.objectiveId, 10) }); }
      });
      objOpts.sort(function(a, b) { return a._n - b._n; });
      SectionToolbar.updateFilterOptions('recyf-controls', 'objective', objOpts);
      var thSeen = {}, thOpts = [];
      controls.forEach(function(c) {
        if (!thSeen[c.themeId]) { thSeen[c.themeId] = true; thOpts.push({ value: c.themeId, label: c.theme }); }
      });
      SectionToolbar.updateFilterOptions('recyf-controls', 'theme', thOpts);
      SectionToolbar.updateFilterOptions('recyf-controls', 'owner', _frameworkOwnerFilterOptions(controls, 'recyfControls'));
      SectionToolbar.updateFilterOptions('recyf-controls', 'reviewer', _frameworkReviewerFilterOptions(controls, 'recyfControls'));
      SectionToolbar.updateFilterOptions('recyf-controls', 'reviewDue', _frameworkReviewFilterOptions('recyfControls'));
    }

    var objVal = _f.objective || '';
    var thVal = _f.theme || '';
    var tgVal = _f.target || '';
    var cfVal = _f.conformity || '';
    var ownerVal = _f.owner || '';
    var reviewerVal = _f.reviewer || '';
    var reviewVal = _f.reviewDue || '';

    var filtered = controls.filter(function(c) {
      return (!objVal || c.objectiveId === objVal) &&
             (!thVal || c.themeId === thVal) &&
             (!tgVal || (tgVal === 'EE_only' ? c.level === 'essentielle' : (c.targets || []).indexOf(tgVal) >= 0)) &&
             (!cfVal || (c.conformity || 'non_evalue') === cfVal) &&
             _frameworkMatchesOwnerFilter(c, ownerVal) &&
             _frameworkMatchesReviewerFilter(c, reviewerVal) &&
             _frameworkMatchesReviewFilter(c, reviewVal) &&
             (!_q || ((c.ref || '') + ' ' + (c.theme || '') + ' ' + (c.content || '') + ' ' + (c.owner || '') + ' ' + (c.reviewer || '')).toLowerCase().indexOf(_q) >= 0);
    });

    tbody.textContent = '';
    var _s = (typeof SectionToolbar !== 'undefined' && SectionToolbar.getSort) ? SectionToolbar.getSort('recyf-controls') : null;
    var _sortKey = (_s && _s.key) || 'reference';
    var _sortDir = (_s && _s.dir) || 'asc';
    var sorted = filtered.slice().sort(function(a, b) {
      var diff;
      switch (_sortKey) {
        case 'objective': diff = SortUtils.numeric(parseInt(a.objectiveId, 10) || 0, parseInt(b.objectiveId, 10) || 0); break;
        case 'owner': diff = SortUtils.text(a.owner || '', b.owner || ''); break;
        case 'reviewer': diff = SortUtils.text(a.reviewer || '', b.reviewer || ''); break;
        case 'nextReviewDate': diff = SortUtils.date(a.nextReviewDate || '', b.nextReviewDate || ''); break;
        default: diff = SortUtils.reference(a.ref || a.id || '', b.ref || b.id || '');
      }
      return _sortDir === 'desc' ? -diff : diff;
    });

    sorted.forEach(function(c) {
      var row = document.createElement('tr');
      row.style.cursor = 'pointer';

      // Col 1 : Réf
      var td1 = document.createElement('td');
      var code = document.createElement('code');
      code.style.fontSize = '0.8em';
      code.textContent = c.ref || '';
      td1.appendChild(code);
      row.appendChild(td1);

      // Col 2 : Objectif
      var td2 = document.createElement('td');
      var objBadge = document.createElement('span');
      objBadge.style.cssText = 'display:inline-block;padding:2px 8px;border-radius:12px;font-size:0.78em;font-weight:600;background:#0055A415;color:#0055A4;white-space:nowrap;';
      objBadge.textContent = 'Obj. ' + c.objectiveId;
      objBadge.title = c.objectiveLabel || '';
      td2.appendChild(objBadge);
      row.appendChild(td2);

      // Lot 1b : colonne « Thématique » retirée de la liste (visible dans la modale editRecyfControl)

      // Col 3 : Mesure (contenu)
      var td4 = document.createElement('td');
      td4.className = 'nis2-desc-cell';
      td4.title = c.content || '';
      td4.textContent = c.content || '';
      row.appendChild(td4);

      // Col 4-5 : Responsable / Prochaine revue
      // GOV-NIS2 S5 (M20) : cellule Responsables combinée (miroir CyFun) — le responsable
      // du contrôle était saisissable en modale mais invisible de la table.
      var tdResp = document.createElement('td');
      tdResp.className = 'framework-owner-cell';
      tdResp.style.whiteSpace = 'nowrap';
      var ownLine = document.createElement('div');
      ownLine.textContent = c.owner || '—';
      if (!c.owner) ownLine.style.color = 'var(--color-text-secondary, #6b7280)';
      ownLine.title = t('recyfControls.owner', 'Responsable de la mesure') + (c.owner ? ' : ' + c.owner : '');
      tdResp.appendChild(ownLine);
      if (c.reviewer) {
        var revLine = document.createElement('div');
        revLine.style.cssText = 'font-size:0.78em;color:var(--color-text-secondary,#6b7280);';
        var revIco = document.createElement('i');
        revIco.className = 'fas fa-user-check';
        revIco.style.cssText = 'margin-right:4px;font-size:0.9em;';
        revLine.appendChild(revIco);
        revLine.appendChild(document.createTextNode(c.reviewer));
        revLine.title = t('recyfControls.reviewer', 'Responsable du contrôle') + ' : ' + c.reviewer;
        tdResp.appendChild(revLine);
      }
      row.appendChild(tdResp);
      _frameworkAppendTextCell(row, c.nextReviewDate || '', { nowrap: true, className: 'framework-review-cell' });

      // Col 6 : Cible EI/EE
      var td5 = document.createElement('td');
      var isEEonly = (c.level === 'essentielle');
      var tg = document.createElement('span');
      tg.style.cssText = 'display:inline-block;padding:1px 6px;border-radius:8px;font-size:0.72em;font-weight:700;background:' + (isEEonly ? '#dc262618' : '#05966918') + ';color:' + (isEEonly ? '#dc2626' : '#059669') + ';';
      tg.textContent = isEEonly ? 'EE' : 'EI/EE';
      tg.title = isEEonly ? t('recyfControls.eeOnly', 'Entité Essentielle uniquement') : t('recyfControls.eiEe', 'Entités Importante et Essentielle');
      td5.appendChild(tg);
      row.appendChild(td5);

      // Col 7 : Conformité (select inline)
      var td6 = document.createElement('td');
      var sel = document.createElement('select');
      sel.className = 'form-control';
      var curConf = c.conformity || 'non_evalue';
      sel.style.cssText = 'min-width:150px;font-size:0.82em;font-weight:600;color:' + (colorByValue[curConf] || '#374151') + ';';
      opts.forEach(function(o) {
        var op = document.createElement('option');
        op.value = o.value;
        op.textContent = _recyfOptLabel(o);
        if (curConf === o.value) op.selected = true;
        sel.appendChild(op);
      });
      // === CSI-CO === (audit co-édition 2026-08-05, MAJEUR) Doctrine FIX-RA-READONLY : ce
      // select était le SEUL contrôle d'évaluation jamais désactivé (la table CyFun jumelle,
      // elle, l'est). Sans `data-readonly-lock`, refreshReadOnlyLocks() ne le voyait pas : il
      // restait interactif en lecture seule, mutait appData, appelait saveData() et prenait
      // silencieusement la grappe `coeur` — écriture hors verrou puis 423/perte au re-pull.
      sel.dataset.readonlyLock = 'true';
      if (typeof isReadOnlyMode === 'function' && isReadOnlyMode()) sel.disabled = true;
      sel.addEventListener('click', function(e) { e.stopPropagation(); });
      sel.addEventListener('change', function(e) {
        e.stopPropagation();
        // Garde de handler (défense en profondeur : couvre le passage en lecture seule
        // survenu APRÈS le rendu, sans re-rendu de la table).
        if (typeof isReadOnlyMode === 'function' && isReadOnlyMode()) {
          sel.value = c.conformity || 'non_evalue';
          if (typeof showReadOnlyNotice === 'function') showReadOnlyNotice(t('recyfControls.conformity', 'Conformité'));
          if (typeof refreshReadOnlyLocks === 'function') refreshReadOnlyLocks();
          return;
        }
        // GOV-NIS2 S4 (audit 2026-08-20, BLOQUANT B6) : ce select est LE chemin d'évaluation
        // principal ReCyF — il contournait journal d'audit, auto-date et évaluateur (seule
        // la modale traçait). Même trace factorisée que la modale.
        var _oldConfInline = c.conformity || 'non_evalue';
        var _prevEvalInline = c.lastEvaluation || '';
        c.conformity = sel.value;
        sel.style.color = colorByValue[sel.value] || '#374151';
        var _inlineTrace = recyfApplyEvaluationTrace(c, _oldConfInline, _prevEvalInline, new Date().toISOString().slice(0, 10), currentCommentAuthor());
        if (_inlineTrace && typeof addAuditEntry === 'function') addAuditEntry('edit', 'nis2', c.ref || c.id, _inlineTrace);
        if (typeof saveData === 'function') saveData();
        renderRecyfSummary();
        updateRecyfCount(filtered.length, controls.length);
        if (typeof updateDashboard === 'function') updateDashboard();
        // Un filtre « Conformité » ou « Évaluation » actif rendrait la ligne/compteur périmés :
        // re-rendre la table dans ce cas (sinon, préserver le contexte de saisie en rafale).
        var _rcF = SectionToolbar.getFilters('recyf-controls') || {};
        if (_rcF.conformity) loadRecyfControlsTable();
        _recyfForceSync();
      });
      td6.appendChild(sel);
      // Lot 1b : colonne « Justification » supprimée (clic-ligne ouvre la même modale) ;
      // indicateur discret si la justification est absente.
      if (!(c.justification && c.justification.trim())) {
        var jmiss = document.createElement('i');
        jmiss.className = 'far fa-comment';
        jmiss.style.cssText = 'color:#9ca3af;margin-left:6px;font-size:0.85em;';
        jmiss.title = t('recyfControls.justificationMissing', 'Justification manquante');
        td6.appendChild(jmiss);
      }
      row.appendChild(td6);

      row.classList.add("row-clickable");
      row.addEventListener('click', function() { editRecyfControl(c.id); });
      tbody.appendChild(row);
    });

    updateRecyfCount(filtered.length, controls.length);
    renderRecyfSummary();
  }

  // Modale de détail / évaluation d'une mesure ReCyF (réutilise openModal générique)
  function editRecyfControl(id) {
    var control = getRecyfControls().find(function(c) { return c.id === id; });
    if (!control) return;
    var conformityOptions = (window.RECYF_CONFORMITY_OPTIONS || []).map(function(o) {
      return { value: o.value, label: _recyfOptLabel(o) };
    });
    var targetLabel = (control.level === 'essentielle')
      ? t('recyfControls.eeOnly', 'Entité Essentielle uniquement') + ' (EE)'
      : t('recyfControls.eiEe', 'Entités Importante et Essentielle') + ' (EI/EE)';
    var fields = [
      { name: 'ref', label: t('recyfControls.ref', 'Réf.'), value: control.ref || '', readOnly: true },
      { name: 'objective', label: t('recyfControls.objective', 'Objectif'), value: 'Obj. ' + control.objectiveId + ' — ' + (control.objectiveLabel || ''), readOnly: true },
      { name: 'theme', label: t('recyfControls.theme', 'Thématique'), value: control.theme || '', readOnly: true },
      { name: 'target', label: t('recyfControls.target', 'Cible'), value: targetLabel, readOnly: true },
      { name: 'content', label: t('recyfControls.measure', 'Mesure'), type: 'textarea', value: control.content || '', readOnly: true },
      // GOV-NIS2 : mêmes libellés de séparation des rôles que CyFun.
      { name: 'owner', label: t('recyfControls.owner', 'Responsable de la mesure'), value: control.owner || '', placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(control.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: 'reviewer', label: t('recyfControls.reviewer', 'Responsable du contrôle'), value: control.reviewer || '', placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(control.reviewer), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: 'lastEvaluation', label: t('recyfControls.lastEvaluation', 'Dernière évaluation'), type: 'date', value: control.lastEvaluation || '' },
      { name: 'nextReviewDate', label: t('recyfControls.nextReviewDate', 'Prochaine revue'), type: 'date', value: control.nextReviewDate || '' },
      { name: 'reviewFrequency', label: t('recyfControls.reviewFrequency', 'Fréquence de revue'), type: 'select', value: control.reviewFrequency || '', options: CONTROL_REVIEW_FREQ_OPTIONS },
      { name: 'conformity', label: t('recyfControls.conformity', 'Conformité'), type: 'select', value: control.conformity || 'non_evalue', options: conformityOptions },
      { name: 'justification', label: t('recyfControls.justification', 'Justification'), type: 'textarea', value: control.justification || '', placeholder: t('recyfControls.justificationPlaceholder', 'Justification, mesure alternative mise en œuvre, plan d\'action…') },
      { name: 'evidence', label: t('recyfControls.evidence', 'Preuves / Artefacts'), type: 'textarea', value: control.evidence || '', placeholder: t('recyfControls.evidencePlaceholder', 'Documents, logs, captures…') },
      // Unification commentaires 2026-07-10 : parité avec CyFun et les actions.
      { name: 'existingComments', label: t('fields.existingComments'), type: 'textarea', value: formatCommentsReadonly(control.comments), readOnly: true },
      { name: 'newComment', label: t('fields.newComment'), type: 'textarea' }
    ];
    // GOV-NIS2 : évaluateur mémorisé (lecture seule, affiché seulement s'il existe).
    if (control.lastEvaluatedBy) {
      var _rLePos = fields.findIndex(function(f) { return f.name === 'nextReviewDate'; });
      if (_rLePos > 0) fields.splice(_rLePos, 0, { name: 'lastEvaluatedBy', label: t('cyfun.lastEvaluatedBy', 'Évalué par'), value: control.lastEvaluatedBy, readOnly: true });
    }
    openModal(t('recyfControls.modalTitle', 'Mesure ReCyF'), fields, function(data) {
      // GOV-NIS2 : trace d'évaluation (miroir CyFun) — conformité = l'évaluation ReCyF.
      var _oldConf = control.conformity || 'non_evalue';
      var _prevLastEval = control.lastEvaluation || '';
      var _oldRolesEqual = !!(control.owner && control.reviewer && control.owner.trim() &&
        control.owner.trim().toLowerCase() === control.reviewer.trim().toLowerCase());
      control.owner = data.owner || '';
      control.reviewer = data.reviewer || '';
      control.lastEvaluation = data.lastEvaluation || '';
      control.nextReviewDate = data.nextReviewDate || '';
      control.reviewFrequency = data.reviewFrequency || '';
      control.conformity = data.conformity || 'non_evalue';
      control.justification = data.justification || '';
      control.evidence = data.evidence || '';
      if (appendComment(control, data.newComment, 10) === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
      }
      // GOV-NIS2 S4 (B6) : trace factorisée — même chemin que le select inline du tableau.
      var _rTrace = recyfApplyEvaluationTrace(control, _oldConf, _prevLastEval, new Date().toISOString().slice(0, 10), currentCommentAuthor());
      if (_rTrace && typeof addAuditEntry === 'function') addAuditEntry('edit', 'nis2', control.ref || control.id, _rTrace);
      // GOV-NIS2 S5 (M21) : averti uniquement quand le cumul est INTRODUIT (miroir CyFun).
      var _rolesEqual = !!(control.owner && control.reviewer && control.owner.trim() &&
        control.owner.trim().toLowerCase() === control.reviewer.trim().toLowerCase());
      if (_rolesEqual && !_oldRolesEqual) {
        showToast2(t('controlsNIS2.sameOwnerReviewerWarn', 'Responsable de la mesure et responsable du contrôle identiques — séparation des rôles recommandée.'), 'warning');
      }
      if (typeof saveData === 'function') saveData();
      loadRecyfControlsTable();
      if (typeof updateDashboard === 'function') updateDashboard();
      _recyfForceSync();
    });
  }

  // Applique le référentiel NIS2 actif : montre/masque les onglets BE (CyFun) vs FR (ReCyF)
  // Modèle NIS2 « mutuellement exclusif + toujours un référentiel actif » : aligne la liste
  // des frameworks activés (appData.frameworkMapper.enabledFrameworks) sur le référentiel NIS2
  // choisi (appData.nis2Referential). L'alias 'nis2' reste TOUJOURS activé (il pilote la carte
  // générique du dashboard data-framework="nis2", référentiel-aware) ; le référentiel actif
  // (cyfun2025 OU recyf2025) est ajouté, l'inactif est retiré. Fonction pure (données
  // uniquement) — aucune écriture DOM, aucun saveData : l'appelant s'en charge. Idempotente.
  function reconcileNis2Frameworks() {
    if (typeof appData === 'undefined' || !appData.frameworkMapper) return;
    var list = appData.frameworkMapper.enabledFrameworks;
    if (!Array.isArray(list)) return;
    var ref = appData.nis2Referential;
    if (ref !== 'recyf2025' && ref !== 'cyfun2025') { ref = 'cyfun2025'; appData.nis2Referential = ref; }
    function add(id) { if (list.indexOf(id) < 0) list.push(id); }
    function rm(id) { var i = list.indexOf(id); if (i >= 0) list.splice(i, 1); }
    add('nis2'); // NIS2 toujours actif (carte dashboard générique + compat. checks indexOf('nis2'))
    if (ref === 'recyf2025') { add('recyf2025'); rm('cyfun2025'); }
    else { add('cyfun2025'); rm('recyf2025'); }
  }
  window.reconcileNis2Frameworks = reconcileNis2Frameworks;

  // Verrou du référentiel NIS2 : une fois le référentiel choisi/confirmé (wizard B11 ou
  // modale B13), on n'autorise plus la bascule CyFun<->ReCyF via les Réglages — seule une
  // réinitialisation le rouvre. Objectif : éviter qu'un clic sur le sélecteur ne masque
  // brutalement un encodage pré-existant (l'utilisateur croit avoir tout perdu). Le NIVEAU
  // (cyfunLevel / recyfLevel) reste, lui, modifiable.
  //   - _nis2ReferentialLocked === true  -> verrouillé
  //   - _nis2ReferentialLocked === false -> déverrouillé (après un Reset, ou install neuve)
  //   - undefined (installs antérieures à cette version) -> verrou DÉRIVÉ de _cyfunLevelChosen,
  //     qui vaut true dès qu'un référentiel a été confirmé une fois. Protège rétroactivement
  //     les données déjà encodées sans migration explicite.
  function isNis2ReferentialLocked() {
    if (typeof appData === 'undefined') return false;
    if (appData._nis2ReferentialLocked === true) return true;
    if (appData._nis2ReferentialLocked === false) return false;
    return appData._cyfunLevelChosen === true;
  }
  window.isNis2ReferentialLocked = isNis2ReferentialLocked;

  function applyNis2Referential(navigate) {
    if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
    var ref = (typeof appData !== 'undefined' && appData.nis2Referential) || 'cyfun2025';
    // « Toujours un référentiel actif » : la catégorie et le panneau NIS2 restent visibles
    // quel que soit le référentiel (Belgique ou France). Garantit qu'aucun appel direct à
    // applyNis2Referential ne laisse la catégorie masquée.
    var nis2Cat = document.querySelector('.nav-ribbon__cat[data-category="nis2"]');
    var nis2Panel = document.querySelector('.nav-ribbon__panel[data-panel="nis2"]');
    if (nis2Cat) nis2Cat.style.display = '';
    if (nis2Panel) nis2Panel.style.display = '';
    var hiddenActive = null;
    document.querySelectorAll('.nav__item[data-nis2-ref]').forEach(function(tab) {
      var refs = String(tab.getAttribute('data-nis2-ref') || '').split(/\s+/).filter(Boolean);
      var match = refs.indexOf(ref) >= 0;
      tab.style.display = match ? '' : 'none';
      if (!match && tab.classList.contains('nav__item--active')) hiddenActive = tab;
    });
    // === CSI-ME === masquer les sections interdites à une sous-entité (nav + bandeau).
    // GOV-NIS2 S1 (audit 2026-08-20, BLOQUANT B2) : cet appel était fait AVANT la boucle
    // data-nis2-ref ci-dessus, qui ré-affichait systématiquement les 3 onglets NIS2
    // (tab.style.display = '') même à droit 'none'. Le masquage par droits doit toujours
    // avoir le DERNIER mot — il est donc rejoué ici, après la visibilité par référentiel.
    if (window.CSI_EntityUI) window.CSI_EntityUI.applySubEntityView();
    // Si l'onglet actif vient d'être masqué, basculer vers l'onglet principal du référentiel actif
    if (navigate && hiddenActive) {
      var primary = (ref === 'recyf2025') ? 'recyf-controls' : 'nis2-controls';
      var target = document.querySelector('.nav__item[data-tab="' + primary + '"]');
      if (target) target.click();
    }
    if (typeof updateDashboard === 'function') updateDashboard();
  }
  // Export CSV/Excel dédié (évite le dump des <select> par l'export générique)
  function exportRecyfControlsCsv(label) {
    ensureRecyfControls();
    var controls = recyfControlsInScope();
    var headers = [
      t('recyfControls.ref', 'Réf.'),
      t('recyfControls.objective', 'Objectif'),
      t('recyfControls.theme', 'Thématique'),
      t('recyfControls.measure', 'Mesure'),
      t('recyfControls.owner', 'Responsable de la mesure'),
      t('recyfControls.reviewer', 'Responsable du contrôle'),
      t('recyfControls.reviewFrequency', 'Fréquence de revue'),
      t('recyfControls.lastEvaluation', 'Dernière évaluation'),
      t('excelHeaders.nis2LastEvaluatedBy', 'Évalué par'),
      t('recyfControls.nextReviewDate', 'Prochaine revue'),
      t('recyfControls.target', 'Cible'),
      t('recyfControls.conformity', 'Conformité'),
      t('recyfControls.justification', 'Justification'),
      t('recyfControls.evidence', 'Preuves / Artefacts')
    ];
    var rows = controls.map(function(c) {
      return [
        c.ref || '',
        (c.objectiveId != null ? 'Obj. ' + c.objectiveId + ' — ' : '') + (c.objectiveLabel || ''),
        c.theme || '',
        c.content || '',
        c.owner || '',
        c.reviewer || '',
        getOptionLabel(CONTROL_REVIEW_FREQ_OPTIONS, c.reviewFrequency),
        c.lastEvaluation || '',
        c.lastEvaluatedBy || '',
        c.nextReviewDate || '',
        (c.level === 'essentielle') ? 'EE' : 'EI/EE',
        _recyfConformityLabel(c.conformity || 'non_evalue'),
        c.justification || '',
        c.evidence || ''
      ];
    });
    downloadCsvFile(label || 'Mesures ReCyF', headers, rows);
  }

  window.applyNis2Referential = applyNis2Referential;
  window.loadRecyfControlsTable = loadRecyfControlsTable;
  window.setRecyfDegree = setRecyfDegree;
  window.editRecyfControl = editRecyfControl;
  window.computeRecyfConformity = computeRecyfConformity;
  window.computeRecyfConformityByObjective = computeRecyfConformityByObjective;
  window.exportRecyfControlsCsv = exportRecyfControlsCsv;

  // Options pour la fréquence de revue des contrôles
  function getControlReviewFreqOptions() { return [
    { value: "", label: "\u2014 " + t('common.select', 'Sélectionner') + " \u2014" },
    { value: "monthly", label: t('reviewFreq.monthly', 'Mensuelle') },
    { value: "quarterly", label: t('reviewFreq.quarterly', 'Trimestrielle') },
    { value: "biannual", label: t('reviewFreq.biannual', 'Semestrielle') },
    { value: "annual", label: t('reviewFreq.annual', 'Annuelle') },
    { value: "on_change", label: t('reviewFreq.onChange', 'Sur changement') }
  ]; }
  var CONTROL_REVIEW_FREQ_OPTIONS = getControlReviewFreqOptions();

  // Options pour l'efficacité mesurée
  const CONTROL_EFFECTIVENESS_OPTIONS = [
    { value: "", label: "— " + t('effectiveness.notEvaluated', 'Non évaluée') + " —" },
    { value: "effective", label: "✓ " + t('effectiveness.effective', 'Efficace') },
    { value: "partially", label: "◐ " + t('effectiveness.partiallyEffective', 'Partiellement efficace') },
    { value: "ineffective", label: "✗ " + t('effectiveness.ineffective', 'Non efficace') },
    { value: "not_tested", label: "○ " + t('effectiveness.notTested', 'Non testé') }
  ];

  function editControl(controlId) {
    var _isoCtrlsEdit = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const control = _isoCtrlsEdit.find((c) => c.id === controlId);
    if (!control) return;

    // Vérifier le statut SoA
    const isNonApplicable = isControlNonApplicable(control.id);
    const soaJustification = getSoaJustification(control.id);

    // S'assurer que les nouveaux champs existent
    if (!control.owner) control.owner = "";
    if (!control.lastEvaluation) control.lastEvaluation = "";
    if (!control.reviewFrequency) control.reviewFrequency = "";
    if (!control.effectiveness) control.effectiveness = "";
    if (!control.evidence) control.evidence = "";
    if (!control.kpiRef) control.kpiRef = "";

    // Construire les champs de base
    const fields = [
      { name: "numero", label: t('fields.numbering', 'Numérotation'), value: control.numero, readOnly: true },
      { name: "reference", label: t('fields.reference', 'Référence'), value: control.reference, readOnly: true },
      { name: "title", label: t('fields.title'), value: isoCtrlTitle(control), readOnly: true },
      { name: "measure", label: t('fields.measure', 'Mesure'), type: 'textarea', value: isoCtrlDesc(control), readOnly: true },
      { name: "category", label: t('fields.category', 'Catégorie'), type: "select", value: control.category, options: DOMAIN_OPTIONS },
      { name: "status", label: t('fields.status'), type: "select", value: control.status, options: CONTROL_STATUS_OPTIONS },
      { name: "cmm", label: t('fields.cmmLevel', 'Niveau de maturité CMM'), type: "select", value: control.cmm, options: CMM_LEVEL_OPTIONS },
      { name: "owner", label: t('fields.controlOwner', 'Propriétaire du contrôle'), value: control.owner || "", placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(control.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "lastEvaluation", label: t('fields.lastEvaluation', 'Dernière évaluation'), type: "date", value: control.lastEvaluation || "" },
      { name: "reviewFrequency", label: t('fields.reviewFrequency', 'Fréquence de revue'), type: "select", value: control.reviewFrequency, options: CONTROL_REVIEW_FREQ_OPTIONS },
      { name: "effectiveness", label: t('fields.effectiveness', 'Efficacité mesurée'), type: "select", value: control.effectiveness, options: CONTROL_EFFECTIVENESS_OPTIONS },
      { name: "evidence", label: t('fields.evidence', 'Preuves / Artefacts'), type: "textarea", value: control.evidence || "", placeholder: t('fields.evidencePlaceholder', 'Documents, logs, captures...') },
      { name: "kpiRef", label: t('fields.kpiRef', 'KPI associé'), value: control.kpiRef || "", placeholder: t('fields.kpiPlaceholder', 'Référence du KPI mesurant l\'efficacité') },
      { name: "justificationControl", label: t('fields.comments', 'Commentaires'), type: "textarea", value: control.justificationControl || "" }
    ];

    openModal(t('modals.controlTracking'), fields, (data) => {
      control.category = data.category;
      control.status = data.status;
      control.cmm = data.cmm || control.cmm;
      control.owner = data.owner || "";
      control.lastEvaluation = data.lastEvaluation || "";
      control.reviewFrequency = data.reviewFrequency || "";
      control.effectiveness = data.effectiveness || "";
      control.evidence = data.evidence || "";
      control.kpiRef = data.kpiRef || "";
      control.justificationControl = data.justificationControl || control.justificationControl;
            addAuditEntry('edit', 'controls', control.title);
      saveData();
      loadControlsTable();
      loadSoaTable();
      updateDashboard();
    }, null, {
      // Callback pour ajouter du contenu personnalisé après ouverture de la modale
      afterOpen: () => {
        if (isNonApplicable) {
          const modalBody = document.getElementById("modalBody");
          if (modalBody) {
            const alertDiv = document.createElement("div");
            alertDiv.className = "soa-alert soa-alert--warning";
            alertDiv.innerHTML = `
              <div class="soa-alert__icon"><i class="fas fa-exclamation-triangle"></i></div>
              <div class="soa-alert__content">
                <strong>${escapeHtml(t('soa.controlNotApplicable', 'Ce contrôle est déclaré non applicable'))}</strong>
                ${soaJustification ? `<p class="soa-alert__justification">${escapeHtml(t('soa.justificationLabel', 'Justification'))} : ${escapeHtml(soaJustification)}</p>` : ""}
                <a href="#" class="soa-alert__link" id="goToSoaLink">
                  <i class="fas fa-external-link-alt"></i> ${escapeHtml(t('soa.seeInDeclaration', 'Voir dans la Déclaration d\'applicabilité'))}
                </a>
              </div>
            `;
            modalBody.insertBefore(alertDiv, modalBody.firstChild);

            // Gestionnaire pour le lien
            document.getElementById("goToSoaLink")?.addEventListener("click", (e) => {
              e.preventDefault();
              closeModal();
              navigateToSoaControl(control.id);
            });
          }
        }
      }
    });
  }
  function viewControlDetails(controlId) {
    var _isoCtrlsView = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
    const control = _isoCtrlsView.find((c) => c.id === controlId);
    if (!control) return;
    const actionList = appData.actions
      .filter((a) => a.linkType === "control" && a.linkId === control.id)
      .map((a) => a.id)
      .join(", ");
    const recommendations = Array.isArray(control.recommendations) ? control.recommendations.join("\n- ") : "";
    openModal(t('modals.controlDetails', 'Détails du contrôle'), [
      { name: 'reference', label: t('fields.reference', 'Référence'), type: 'text', value: control.reference || '', readOnly: true },
      { name: 'title', label: t('fields.title', 'Titre'), type: 'text', value: isoCtrlTitle(control), readOnly: true },
      { name: 'measure', label: t('fields.measure', 'Mesure'), type: 'textarea', value: isoCtrlDesc(control), readOnly: true },
      { name: 'recommendations', label: t('fields.recommendations', 'Recommandations'), type: 'textarea', value: recommendations ? '- ' + recommendations : '', readOnly: true },
      { name: 'category', label: t('fields.category', 'Catégorie'), type: 'text', value: control.category || '', readOnly: true },
      { name: 'status', label: t('fields.status', 'Statut'), type: 'text', value: control.status || '', readOnly: true },
      { name: 'cmm', label: t('fields.cmm', 'Niveau CMM'), type: 'text', value: control.cmm || '', readOnly: true },
      { name: 'actions', label: t('fields.linkedActions', 'Actions liées'), type: 'text', value: actionList || t('labels.none', 'Aucune'), readOnly: true }
    ], null, null, { hideCancel: true, saveLabel: t('common.close', 'Fermer') });
  }
  function addControl() {
    openModal(t('modals.addControl'), [
      { name: "numero", label: t('fields.numbering', 'Numérotation') },
      { name: "reference", label: t('sort.reference', 'Réf.'), required: true },
      { name: "title", label: t('fields.title'), required: true },
      { name: "category", label: t('fields.category', 'Catégorie'), type: "select", value: "Organisationnel", options: DOMAIN_OPTIONS },
      { name: "status", label: t('fields.status'), type: "select", value: "non_implemente", options: CONTROL_STATUS_OPTIONS },
      { name: "cmm", label: t('controlsISO.cmmLevel', 'Niveau de maturité CMM'), type: "select", value: "Initial", options: CMM_LEVEL_OPTIONS }
    ], (data) => {
      if (!data.reference || !data.title) return false;
      var _isoCtrlsAdd = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
      if (_isoCtrlsAdd.some((c) => c.id === data.reference || c.reference === data.reference)) {
        showToast2(t('toasts.duplicateControlRef', "Un contrôle avec cette référence existe déjà."), "error");
        return false;
      }
      appData.controls.push({
        id: data.reference,
        numero: data.numero || "",
        reference: data.reference,
        title: data.title,
        description: "",
        category: data.category,
        status: data.status || "non_implemente",
        cmm: data.cmm || "Initial",
        comments: "",
        evidence: "",
        justificationControl: ""
      });
      appData.soa.push({ controlId: data.reference, applicable: true, justification: "" });
            addAuditEntry('add', 'controls', data.title);
      saveData();
      loadControlsTable();
      loadSoaTable();
      updateDashboard();
    });
  }
  function deleteControl(controlId) {
    showConfirmModal(t('common.delete', 'Supprimer'), t('confirms.deleteControl', 'Supprimer ce contrôle ?'), function() {
      var _isoCtrlsDel = window.CSI_FrameworkMapper ? CSI_FrameworkMapper.getAppDataControls(_getActiveControlsFrameworkId() || 'iso27002') : (appData.controls || []);
      var _ctrl = _isoCtrlsDel.find(function(c) { return c.id === controlId; });
      appData.controls = (appData.controls || []).filter((c) => c.id !== controlId);
      appData.soa = (appData.soa || []).filter((e) => e.controlId !== controlId);
      appData.actions = (appData.actions || []).filter((a) => !(a.linkType === "control" && a.linkId === controlId));
            addAuditEntry('delete', 'controls', _ctrl ? _ctrl.title : controlId);
      saveData();
      _csiPersistAffectedTabs(['controls', 'soa', 'actions']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      loadControlsTable();
      loadSoaTable();
      loadActionsTable(); // PASS2-301: Refresh actions table after cascade delete
      updateDashboard();
    }, { type: 'danger' });
  }
  window.loadControlsTable = loadControlsTable;
  window.loadNis2ControlsTable = loadNis2ControlsTable;
  window.loadSoaTable = loadSoaTable;
  window.editNis2Control = editNis2Control;
  window.editControl = editControl;
  window.viewControlDetails = viewControlDetails;
  window.addControl = addControl;
  window.deleteControl = deleteControl;
  // Fonctions SoA helper pour indicateur non-applicable et risques liés
  window.isControlNonApplicable = isControlNonApplicable;
  window.getSoaJustification = getSoaJustification;
  window.navigateToSoaControl = navigateToSoaControl;
  window.editSoaControl = editSoaControl;
  window.renderSoaRiskBadges = renderSoaRiskBadges;

  // modules/actions.js
  function getActionPriorityOptions() {
    return [
      { value: "critical", label: t('actionPriorities.critical') },
      { value: "high", label: t('actionPriorities.high') },
      { value: "medium", label: t('actionPriorities.medium') },
      { value: "low", label: t('actionPriorities.low') }
    ];
  }
  var ACTION_PRIORITY_OPTIONS = getActionPriorityOptions();
  function getActionStatusOptions() {
    return [
      { value: "planifie", label: t('actionStatuses.planifie') },
      { value: "en_cours", label: t('actionStatuses.enCours') },
      { value: "termine", label: t('actionStatuses.termine') },
      { value: "annule", label: t('actionStatuses.annule') }
    ];
  }
  var ACTION_STATUS_OPTIONS = getActionStatusOptions();
  function loadActionsTable() {
    const tbody = document.getElementById("actionsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('actions')) return;
    tbody.innerHTML = "";
    const _s = SectionToolbar.getSort("actions");
    const sort = _s ? _s.key : "priority";
    const sortDir = _s ? _s.dir : "asc";
    updateActionsFilterOptions();
    const _f = SectionToolbar.getFilters("actions");
    const _q = (SectionToolbar.getSearch("actions") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const filterType = _f.filterType || "";
    const filterValue = _f.filterValue || "";
    const filterStatus = _f.status || ""; // (audit global 2026-07-03) filtre par statut d'action
    const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
    const getActionIdNumber = (value) => {
      const text = value ? String(value) : "";
      const match = text.match(/(\d+)(?!.*\d)/);
      return match ? parseInt(match[1], 10) : NaN;
    };
    const getActionLinkSortKey = (action) => {
      const type = action.linkType || (action.controlId ? "control" : "");
      const ids = getActionLinkIds(action);
      const byId = (items, id) => items.find((item) => item.id === id);
      if (type === "risk") {
        const labels = ids
          .map((id) => {
            const r = byId(appData.risks, id);
            return r ? r.title : id;
          })
          .filter(Boolean);
        const idKey = ids.slice().sort().join(",");
        return `risk:${idKey}:${labels.join(" | ")}`;
      }
      if (type === "audit") {
        const audit = byId(appData.audits, ids[0]);
        const label = audit ? audit.title : ids[0];
        const key = ids[0] ? `audit:${ids[0]}` : "audit:missing";
        return `${key}:${label || ""}`;
      }
      if (type === "nc") {
        const nc = byId(appData.nonconformities, ids[0]);
        const auditId = nc && nc.auditId ? nc.auditId : "";
        if (auditId) {
          const audit = byId(appData.audits, auditId);
          const label = audit ? audit.title : auditId;
          return `audit:${auditId}:${label || ""}`;
        }
        const label = nc ? nc.title : ids[0];
        return `nc:${ids[0] || ""}:${label || ""}`;
      }
      if (type === "control") {
        const ctrl = byId(appData.controls, ids[0]);
        const label = ctrl ? ctrl.reference : ids[0];
        return `control:${ids[0] || ""}:${label || ""}`;
      }
      if (type === "threat") {
        const th = byId(appData.threats, ids[0]);
        const label = th ? th.title : ids[0];
        return `threat:${ids[0] || ""}:${label || ""}`;
      }
      if (type === "continuous") {
        const label = "Am\xE9lioration continue";
        return `continuous:${ids[0] || ""}:${label}`;
      }
      return `other:${type || ""}`;
    };
    let sourceActions = filterType
      ? (appData.actions || []).filter((action) => matchesActionFilter(action, filterType, filterValue))
      : (appData.actions || []);
    if (filterStatus) sourceActions = sourceActions.filter((action) => (action.status || 'planifie') === filterStatus);
    if (_q) sourceActions = sourceActions.filter((action) => ((action.title || "") + " " + (action.id || "") + " " + (action.assignedTo || "")).toLowerCase().indexOf(_q) >= 0);
    const sortedActions = [...sourceActions].sort((a, b) => {
      switch (sort) {
        case "id": {
          const aNum = getActionIdNumber(a.id);
          const bNum = getActionIdNumber(b.id);
          if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum;
          return SortUtils.reference(a.id, b.id);
        }
        case "link": {
          const keyA = getActionLinkSortKey(a);
          const keyB = getActionLinkSortKey(b);
          const cmp = SortUtils.reference(keyA, keyB);
          if (cmp !== 0) return cmp;
          const fallbackA = getActionIdNumber(a.id);
          const fallbackB = getActionIdNumber(b.id);
          if (Number.isFinite(fallbackA) && Number.isFinite(fallbackB)) return fallbackA - fallbackB;
          return SortUtils.reference(a.id, b.id);
        }
        case "dueDate":
          return SortUtils.date(a.dueDate, b.dueDate);
        case "status": {
          var _so = { planifie: 0, en_cours: 1, termine: 2, annule: 3 };
          return (_so[a.status] ?? 0) - (_so[b.status] ?? 0);
        }
        case "progress":
          return SortUtils.numeric(a.progress, b.progress);
        default:
          return SortUtils.orderedMap(priorityOrder)(a.priority, b.priority);
      }
    });
    if (sortDir === "desc") sortedActions.reverse();
    if (sortedActions.length === 0) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '8'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-tasks';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noActions', 'Aucune action en cours')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    sortedActions.forEach((action) => {
      const row = document.createElement("tr");
      let linkLabels = [];
      let linkClass = "";
      const type = action.linkType || (action.controlId ? "control" : "");
      const ids = getActionLinkIds(action);
      switch (type) {
        case "control": {
          const id = ids[0];
          if (id) {
            const resolved = resolveControlById(id);
            linkLabels = [resolved ? resolved.label : id];
            linkClass = "link-badge--control";
          }
          break;
        }
        case "risk": {
          linkLabels = ids
            .map((id) => {
              const r = (appData.risks || []).find((rr) => rr.id === id);
              return r ? r.title : id;
            })
            .filter(Boolean);
          if (linkLabels.length) linkClass = "link-badge--risk";
          break;
        }
        case "nc": {
          const id = ids[0];
          if (id) {
            const nc = appData.nonconformities.find((n) => n.id === id);
            linkLabels = [nc ? nc.title : id];
            linkClass = "link-badge--nc";
          }
          break;
        }
        case "audit": {
          const id = ids[0];
          if (id) {
            const au = appData.audits.find((a) => a.id === id);
            linkLabels = [au ? au.title : id];
            linkClass = "link-badge--audit";
          }
          break;
        }
        case "threat": {
          const id = ids[0];
          if (id) {
            const th = appData.threats.find((t) => t.id === id);
            linkLabels = [th ? th.title : id];
            linkClass = "link-badge--threat";
          }
          break;
        }
        case "continuous": {
          linkLabels = ids.length ? ids.map(() => "Am\xE9lioration continue") : [];
          if (linkLabels.length) linkClass = "link-badge--continuous";
          break;
        }
      }
      const linkHtml = linkLabels.length
        ? linkLabels.map((label) => `<span class="link-badge ${linkClass}">${escapeHtml(label)}</span>`).join(" ")
        : "—";
      const priorityClasses = { critical: "priority--critical", high: "priority--high", medium: "priority--medium", low: "priority--low" };
      const priorityLabels = { critical: t('actionPriorities.critical'), high: t('actionPriorities.high'), medium: t('actionPriorities.medium'), low: t('actionPriorities.low') };
      // Échéance dépassée (comparaison de dates locales, sans heure) — actions terminées/annulées exclues
      let actionOverdue = false;
      if (action.dueDate && action.status !== "termine" && action.status !== "annule" && action.status !== "done") {
        const dueStr = String(action.dueDate);
        if (/^\d{4}-\d{2}-\d{2}/.test(dueStr)) {
          const now = new Date();
          const todayIso = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0");
          actionOverdue = dueStr.slice(0, 10) < todayIso;
        } else {
          const due = new Date(dueStr);
          if (!Number.isNaN(due.getTime())) {
            due.setHours(0, 0, 0, 0);
            const today0 = new Date();
            today0.setHours(0, 0, 0, 0);
            actionOverdue = due < today0;
          }
        }
      }
      const assetBadgesLine = buildCriticalAssetBadgesLine(action.criticalAssetIds);
      row.innerHTML = `
            <td>${escapeHtml(action.id)}</td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(action.title)}</div>${assetBadgesLine}</td>
            <td><div class="cell-clamp cell-clamp--2">${linkHtml}</div></td>
            <td><span class="status-badge ${priorityClasses[action.priority]}">${priorityLabels[action.priority]}</span></td>
            <td><span class="status-badge status-action status-action--${escapeHtml(action.status || 'planifie')}">${escapeHtml(getStatusLabel(action.status || 'planifie'))}</span></td>
            <td><div class="progress-bar" role="progressbar" aria-valuenow="${action.progress}" aria-valuemin="0" aria-valuemax="100" aria-label="${escapeHtml(action.title)} ${action.progress}%"><div class="progress-bar__fill" style="width: ${action.progress}%"></div></div><span>${action.progress}%</span></td>
            <td${actionOverdue ? ` class="cell-overdue" title="${escapeHtml(t('actions.overdue', 'En retard'))}"` : ""}>${action.dueDate ? new Date(action.dueDate).toLocaleDateString() : "—"}</td>
            <td>${action.assignedTo ? escapeHtml(action.assignedTo) : "—"}</td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger") || e.target.closest(".critical-asset-link")) return;
        editAction(action.id);
      });
      attachCriticalAssetLinkHandlers(row);
      tbody.appendChild(row);
    });
  }

  /**
   * Build grouped control options from all enabled frameworks.
   * Returns { flat: [{value, label, searchText}], groups: [{id, label, color, subgroups}] }
   * Reusable anywhere a control selector is needed.
   */
  function getControlLinkOptions() {
    var FM = window.CSI_FrameworkMapper;
    var result = { flat: [], groups: [] };

    // --- Controls framework (iso27002 or open-security-controls) ---
    var isoControls = appData.controls || [];
    var _ctrlFwId = _getActiveControlsFrameworkId() || 'iso27002';
    var isoFw = FM ? FM.getFramework(_ctrlFwId) : null;
    if (!isoFw) { var _ctrlFwObj = _getActiveControlsFramework(); if (_ctrlFwObj) isoFw = _ctrlFwObj; }
    var isoMeta = isoFw ? isoFw.meta : null;
    var isoCategories = isoMeta ? isoMeta.categories : [
      { id: 'org', label: 'Organisationnel', prefix: '5' },
      { id: 'hum', label: 'Humain', prefix: '6' },
      { id: 'phy', label: 'Physique', prefix: '7' },
      { id: 'tech', label: 'Technologique', prefix: '8' }
    ];
    var isoGroup = {
      id: _ctrlFwId,
      label: isoMeta ? (isoMeta.shortName || isoMeta.name) : 'ISO 27002',
      color: isoMeta ? isoMeta.color : '#2563eb',
      subgroups: []
    };
    isoCategories.forEach(function(cat) {
      var catControls = isoControls.filter(function(c) {
        if (c.category === cat.label) return true;
        if (c.numero && c.numero.startsWith(cat.prefix + '.')) return true;
        return false;
      });
      if (!catControls.length) return;
      var catDisplayLabel = isoCatLabel(cat);
      var subgroup = {
        id: cat.id,
        label: catDisplayLabel + ' (' + cat.prefix + '.x)',
        options: catControls.map(function(c) {
          var lbl = c.reference || (c.numero + ' ' + isoCtrlTitle(c));
          return {
            value: c.id,
            label: lbl,
            searchText: ((c.numero || '') + ' ' + isoCtrlTitle(c) + ' ' + (c.reference || '') + ' ' + catDisplayLabel).toLowerCase()
          };
        })
      };
      isoGroup.subgroups.push(subgroup);
      result.flat = result.flat.concat(subgroup.options);
    });
    // Controls not matching any category
    var categorized = new Set();
    isoGroup.subgroups.forEach(function(sg) { sg.options.forEach(function(o) { categorized.add(o.value); }); });
    var uncatISO = isoControls.filter(function(c) { return !categorized.has(c.id); });
    if (uncatISO.length) {
      var uncatSub = {
        id: 'other',
        label: t('common.other', 'Autres'),
        options: uncatISO.map(function(c) {
          var lbl = c.reference || (c.numero + ' ' + isoCtrlTitle(c));
          return { value: c.id, label: lbl, searchText: ((c.numero || '') + ' ' + isoCtrlTitle(c) + ' ' + (c.reference || '')).toLowerCase() };
        })
      };
      isoGroup.subgroups.push(uncatSub);
      result.flat = result.flat.concat(uncatSub.options);
    }
    if (isoGroup.subgroups.length) result.groups.push(isoGroup);

    // --- NIS2 / CyFun 2025 (if enabled) ---
    // GOV-NIS2 S5 (M24) : gaté par le référentiel actif — sous ReCyF, 'nis2' reste
    // toujours activé (reconcileNis2Frameworks) et le catalogue CyFun d'onglets masqués
    // était proposé en plus des mesures France.
    if (FM && FM.isEnabled('nis2') && (appData.nis2Referential || 'cyfun2025') === 'cyfun2025') {
      var nis2Controls = appData.nis2Controls || [];
      var nis2Fw = FM.getFramework('cyfun2025');
      if (nis2Fw && nis2Controls.length > 0) {
        var functions = nis2Fw.meta.functions || [];
        var nis2Group = {
          id: 'cyfun2025',
          label: nis2Fw.meta.name || 'CyFun 2025',
          color: nis2Fw.meta.color || '#059669',
          subgroups: []
        };
        functions.forEach(function(func) {
          var funcControls = nis2Controls.filter(function(c) { return c.functionId === func.id; });
          if (!funcControls.length) return;
          var funcLabel = cyfunLabelText(func) + ' (' + func.id + ')';
          var subgroup = {
            id: func.id,
            label: funcLabel,
            options: funcControls.map(function(c) {
              var lbl = c.ref + ' \u2014 ' + (c.title || c.categorie || c.description || '');
              return {
                value: c.id,
                label: lbl,
                searchText: (c.ref + ' ' + (c.title || '') + ' ' + (c.categorie || '') + ' ' + (c.description || '') + ' ' + funcLabel).toLowerCase()
              };
            })
          };
          nis2Group.subgroups.push(subgroup);
          result.flat = result.flat.concat(subgroup.options);
        });
        if (nis2Group.subgroups.length) result.groups.push(nis2Group);
      }
    }

    // --- ReCyF (France) : branche dédiée ---
    // GOV-NIS2 S5 (audit 2026-08-20, MAJEUR M24) : la branche générique libellait les
    // mesures « 1.1 —  » (title inexistant dans le modèle ReCyF), listait les 152 mesures
    // sans filtre de degré et ne donnait aucun texte cherchable. Regroupement par
    // objectif, périmètre EI/EE, libellé ref — thème.
    if (FM && FM.isEnabled('recyf2025') && (appData.nis2Referential || 'cyfun2025') === 'recyf2025'
        && typeof recyfControlsInScope === 'function') {
      var recyfCtrls = recyfControlsInScope();
      var recyfFw = FM.getFramework('recyf2025');
      if (recyfFw && recyfFw.meta && recyfCtrls.length) {
        var recyfGroup = { id: 'recyf2025', label: recyfFw.meta.shortName || recyfFw.meta.name || 'ReCyF', color: recyfFw.meta.color || '#0055A4', subgroups: [] };
        (recyfFw.meta.objectives || []).forEach(function(obj) {
          var objCtrls = recyfCtrls.filter(function(c) { return c.objectiveId === obj.id; });
          if (!objCtrls.length) return;
          var objLabel = 'Obj. ' + obj.id + ' \u2014 ' + (obj.label || '');
          recyfGroup.subgroups.push({
            id: 'obj-' + obj.id,
            label: objLabel,
            options: objCtrls.map(function(c) {
              var lbl = c.ref + ' \u2014 ' + (c.theme || String(c.content || '').slice(0, 60));
              return { value: c.id, label: lbl, searchText: (c.ref + ' ' + (c.theme || '') + ' ' + (c.content || '') + ' ' + objLabel).toLowerCase() };
            })
          });
        });
        recyfGroup.subgroups.forEach(function(sg) { result.flat = result.flat.concat(sg.options); });
        if (recyfGroup.subgroups.length) result.groups.push(recyfGroup);
      }
    }

    // --- Future frameworks: automatic inclusion ---
    if (FM) {
      var known = { iso27002: true, cyfun2025: true, nis2: true, recyf2025: true };
      FM.listEnabledFrameworks().forEach(function(fw) {
        if (known[fw.id]) return;
        var fwDef = FM.getFramework(fw.id);
        var fwControls = FM.getAppDataControls(fw.id);
        if (!fwDef || !fwControls || !fwControls.length) return;
        var fwGroup = { id: fw.id, label: fw.name, color: fw.color || '#6b7280', subgroups: [] };
        var categories = (fwDef.meta.categories || []);
        if (categories.length) {
          categories.forEach(function(cat) {
            var catCtrls = fwControls.filter(function(c) { return c.categoryId === cat.id || c.category === cat.id; });
            if (!catCtrls.length) return;
            fwGroup.subgroups.push({
              id: cat.id,
              label: cat.label || cat.id,
              options: catCtrls.map(function(c) {
                var lbl = (c.ref || c.numero || c.id) + ' \u2014 ' + isoCtrlTitle(c);
                return { value: c.id, label: lbl, searchText: (lbl + ' ' + (cat.label || '')).toLowerCase() };
              })
            });
          });
        } else {
          fwGroup.subgroups.push({
            id: 'all',
            label: fw.name,
            options: fwControls.map(function(c) {
              var lbl = (c.ref || c.numero || c.id) + ' \u2014 ' + isoCtrlTitle(c);
              return { value: c.id, label: lbl, searchText: lbl.toLowerCase() };
            })
          });
        }
        fwGroup.subgroups.forEach(function(sg) { result.flat = result.flat.concat(sg.options); });
        if (fwGroup.subgroups.length) result.groups.push(fwGroup);
      });
    }

    return result;
  }

  /**
   * Resolve a control ID across all framework appData arrays.
   * Returns { control, framework, label } or null.
   */
  function resolveControlById(id) {
    if (!id) return null;
    var ctrl = (appData.controls || []).find(function(c) { return c.id === id; });
    if (ctrl) return { control: ctrl, framework: _getActiveControlsFrameworkId() || 'iso27002', label: ctrl.reference || (ctrl.numero + ' ' + isoCtrlTitle(ctrl)) };
    var nis2 = (appData.nis2Controls || []).find(function(c) { return c.id === id; });
    if (nis2) return { control: nis2, framework: 'cyfun2025', label: nis2.ref + ' \u2014 ' + (nis2.categorie || nis2.description || nis2.titre || '') };
    // GOV-NIS2 S5 (M24) : liens vers les mesures ReCyF résolus (libellé ref — thème).
    var recyfC = (appData.recyfControls || []).find(function(c) { return c.id === id; });
    if (recyfC) return { control: recyfC, framework: 'recyf2025', label: recyfC.ref + ' \u2014 ' + (recyfC.theme || recyfC.objectiveLabel || '') };
    return null;
  }

  /**
   * Factory: create a searchable-select widget (standalone, reusable).
   * @param {string} name - field name (used for IDs)
   * @param {Array} groups - [{id, label, color, subgroups: [{id, label, options: [{value, label, searchText}]}]}]
   * @param {string} value - initial selected value
   * @param {string} placeholder - trigger placeholder
   * @param {boolean} locked - if true, disable interaction
   * @returns {{ container: HTMLElement, hiddenInput: HTMLElement, destroy: Function }}
   */
  function buildSearchableSelectWidget(name, groups, value, placeholder, locked) {
    var ssContainer = document.createElement("div");
    ssContainer.className = "searchable-select";
    ssContainer.id = "modal-ss-wrap-" + name;

    var hiddenInput = document.createElement("input");
    hiddenInput.type = "hidden";
    hiddenInput.id = "modal-input-" + name;
    hiddenInput.value = value || "";

    var ssTrigger = document.createElement("input");
    ssTrigger.type = "text";
    ssTrigger.className = "form-control searchable-select__trigger";
    ssTrigger.readOnly = true;
    ssTrigger.placeholder = placeholder || t('common.search', 'Rechercher...') + "\u2026";
    ssTrigger.id = "modal-ss-trigger-" + name;

    var ssDropdown = document.createElement("div");
    ssDropdown.className = "searchable-select__dropdown";
    var ssSearch = document.createElement("input");
    ssSearch.type = "text";
    ssSearch.className = "searchable-select__search";
    ssSearch.placeholder = t('common.filter', 'Filtrer') + "\u2026";
    ssSearch.autocomplete = "off";
    var ssList = document.createElement("div");
    ssList.className = "searchable-select__list";
    var ssEmpty = document.createElement("div");
    ssEmpty.className = "searchable-select__empty";
    ssEmpty.textContent = t('search.noResults', 'Aucun résultat trouvé');
    ssEmpty.style.display = "none";

    var ssAllOptions = [];
    (groups || []).forEach(function(group) {
      var groupEl = document.createElement("div");
      groupEl.className = "searchable-select__group";
      groupEl.dataset.groupId = group.id;
      var groupHeader = document.createElement("div");
      groupHeader.className = "searchable-select__group-header";
      if (group.color) {
        var dot = document.createElement("span");
        dot.className = "searchable-select__fw-dot";
        dot.style.backgroundColor = group.color;
        groupHeader.appendChild(dot);
      }
      groupHeader.appendChild(document.createTextNode(group.label));
      groupEl.appendChild(groupHeader);

      (group.subgroups || []).forEach(function(subgroup) {
        var subgroupEl = document.createElement("div");
        subgroupEl.className = "searchable-select__subgroup";
        subgroupEl.dataset.subgroupId = subgroup.id;
        var subHeader = document.createElement("div");
        subHeader.className = "searchable-select__subgroup-header";
        subHeader.textContent = subgroup.label;
        subgroupEl.appendChild(subHeader);
        (subgroup.options || []).forEach(function(opt) {
          var optEl = document.createElement("div");
          optEl.className = "searchable-select__option";
          optEl.dataset.value = opt.value;
          optEl.textContent = opt.label;
          optEl.title = opt.label;
          optEl.dataset.searchText = opt.searchText || opt.label.toLowerCase();
          subgroupEl.appendChild(optEl);
          ssAllOptions.push({ el: optEl, subgroupEl: subgroupEl, groupEl: groupEl, searchText: optEl.dataset.searchText, value: opt.value, label: opt.label });
        });
        groupEl.appendChild(subgroupEl);
      });
      ssList.appendChild(groupEl);
    });
    ssList.appendChild(ssEmpty);
    ssDropdown.appendChild(ssSearch);
    ssDropdown.appendChild(ssList);
    ssContainer.appendChild(ssTrigger);
    ssContainer.appendChild(ssDropdown);
    ssContainer.appendChild(hiddenInput);

    // Set initial display
    var initOpt = ssAllOptions.find(function(o) { return o.value === (value || ""); });
    if (initOpt) {
      ssTrigger.value = initOpt.label;
      initOpt.el.classList.add("searchable-select__option--selected");
    }

    // Normalize for accent-insensitive search
    var ssNormalize = function(str) {
      return str.normalize ? str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() : str.toLowerCase();
    };

    // Filter
    var ssFilter = function() {
      var query = ssNormalize(ssSearch.value);
      var anyVisible = false;
      var visSub = {}, visGrp = {};
      ssAllOptions.forEach(function(item) {
        var match = !query || ssNormalize(item.searchText).indexOf(query) >= 0;
        item.el.style.display = match ? "" : "none";
        if (match) { anyVisible = true; visSub[item.subgroupEl.dataset.subgroupId] = true; visGrp[item.groupEl.dataset.groupId] = true; }
      });
      ssList.querySelectorAll(".searchable-select__subgroup").forEach(function(el) { el.style.display = visSub[el.dataset.subgroupId] ? "" : "none"; });
      ssList.querySelectorAll(".searchable-select__group").forEach(function(el) { el.style.display = visGrp[el.dataset.groupId] ? "" : "none"; });
      ssEmpty.style.display = anyVisible ? "none" : "";
    };

    // Open/Close
    var ssIsOpen = false;
    var ssOpen = function() {
      if (ssIsOpen) return;
      ssIsOpen = true;
      ssContainer.classList.add("searchable-select--open");
      var mb = ssContainer.closest(".modal__body");
      if (mb) mb.style.overflow = "visible";
      ssSearch.value = "";
      ssFilter();
      requestAnimationFrame(function() { ssSearch.focus(); });
      var sel = ssList.querySelector(".searchable-select__option--selected");
      if (sel) sel.scrollIntoView({ block: "nearest" });
    };
    var ssClose = function() {
      if (!ssIsOpen) return;
      ssIsOpen = false;
      ssContainer.classList.remove("searchable-select--open");
      var mb = ssContainer.closest(".modal__body");
      if (mb) mb.style.overflow = "";
      ssFocusedIdx = -1;
      ssAllOptions.forEach(function(o) { o.el.classList.remove("searchable-select__option--focused"); });
    };

    // Select
    var ssSelect = function(val, lbl) {
      hiddenInput.value = val;
      ssTrigger.value = lbl;
      ssAllOptions.forEach(function(o) { o.el.classList.remove("searchable-select__option--selected"); });
      var m = ssAllOptions.find(function(o) { return o.value === val; });
      if (m) m.el.classList.add("searchable-select__option--selected");
      ssClose();
    };

    // Keyboard
    var ssFocusedIdx = -1;
    var ssGetVisible = function() { return ssAllOptions.filter(function(o) { return o.el.style.display !== "none"; }); };
    var ssSetFocus = function(idx) {
      var vis = ssGetVisible();
      ssAllOptions.forEach(function(o) { o.el.classList.remove("searchable-select__option--focused"); });
      if (idx < 0 || idx >= vis.length) { ssFocusedIdx = -1; return; }
      ssFocusedIdx = idx;
      vis[idx].el.classList.add("searchable-select__option--focused");
      vis[idx].el.scrollIntoView({ block: "nearest" });
    };

    // Events
    ssTrigger.addEventListener("click", function(e) { e.stopPropagation(); ssIsOpen ? ssClose() : ssOpen(); });
    ssTrigger.addEventListener("keydown", function(e) {
      if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { e.preventDefault(); ssOpen(); }
    });
    ssSearch.addEventListener("input", function() { ssFilter(); ssFocusedIdx = -1; });
    ssSearch.addEventListener("keydown", function(e) {
      var vis = ssGetVisible();
      if (e.key === "ArrowDown") { e.preventDefault(); ssSetFocus(Math.min(ssFocusedIdx + 1, vis.length - 1)); }
      else if (e.key === "ArrowUp") { e.preventDefault(); ssSetFocus(Math.max(ssFocusedIdx - 1, 0)); }
      else if (e.key === "Enter") { e.preventDefault(); if (ssFocusedIdx >= 0 && ssFocusedIdx < vis.length) ssSelect(vis[ssFocusedIdx].value, vis[ssFocusedIdx].label); }
      else if (e.key === "Escape") { e.preventDefault(); ssClose(); ssTrigger.focus(); }
    });
    ssList.addEventListener("click", function(e) {
      var optEl = e.target.closest(".searchable-select__option");
      if (optEl) ssSelect(optEl.dataset.value, optEl.textContent);
    });

    // Click outside handler
    var ssOutsideHandler = function(e) {
      if (!ssContainer.contains(e.target)) ssClose();
      if (!document.body.contains(ssContainer)) document.removeEventListener("click", ssOutsideHandler);
    };
    requestAnimationFrame(function() { document.addEventListener("click", ssOutsideHandler); });

    // Lock
    if (locked) {
      ssTrigger.disabled = true;
      ssContainer.style.pointerEvents = "none";
      ssContainer.style.opacity = "0.6";
    }

    return {
      container: ssContainer,
      hiddenInput: hiddenInput,
      destroy: function() { document.removeEventListener("click", ssOutsideHandler); ssClose(); }
    };
  }

  /**
   * Shared handler: bind linkType select to linkId field with dynamic swap.
   * Handles switching between searchable-select (for "control") and native select.
   * @param {string} initialLinkType - current linkType value
   * @param {*} initialSelected - current linkId value (string or array)
   */
  function bindLinkTypeToLinkId(initialLinkType, initialSelected) {
    var typeSelect = document.getElementById("modal-input-linkType");
    if (!typeSelect) return;
    var modalBody = document.getElementById("modalBody");
    if (!modalBody) return;
    var _currentSsWidget = null;

    function findFieldElements() {
      var inp = document.getElementById("modal-input-linkId");
      if (!inp) return null;
      var wrap = inp.closest(".searchable-select");
      var fieldEl = wrap || inp;
      return { fieldEl: fieldEl, inp: inp };
    }

    function removeCurrentField() {
      if (_currentSsWidget) { _currentSsWidget.destroy(); _currentSsWidget = null; }
      var found = findFieldElements();
      if (found) found.fieldEl.remove();
    }

    function getInsertionPoint() {
      // Find the label for linkId by checking all labels
      var labels = modalBody.querySelectorAll("label");
      for (var i = 0; i < labels.length; i++) {
        if (labels[i].htmlFor === "modal-input-linkId") return labels[i];
      }
      return null;
    }

    function rebuild(selected) {
      var type = typeSelect.value;
      var label = getInsertionPoint();
      removeCurrentField();
      if (!label) return;

      if (type === "control") {
        var controlOpts = getControlLinkOptions();
        var selVal = Array.isArray(selected) ? selected[0] : (selected || "");
        // Validate value exists
        if (selVal && !controlOpts.flat.some(function(o) { return o.value === selVal; })) {
          selVal = controlOpts.flat.length ? controlOpts.flat[0].value : "";
        }
        _currentSsWidget = buildSearchableSelectWidget("linkId", controlOpts.groups, selVal, t('common.searchControl', 'Rechercher un contrôle') + "\u2026", false);
        label.after(_currentSsWidget.container);
      } else {
        var options = getLinkOptions(type);
        var sel = document.createElement("select");
        sel.id = "modal-input-linkId";
        sel.className = "form-control";
        sel.dataset.readonlyLock = "true";
        var selectedArr = type === "risk" ? (Array.isArray(selected) ? selected : [selected].filter(Boolean)) : [Array.isArray(selected) ? selected[0] : selected].filter(Boolean);
        if (type === "risk") {
          sel.multiple = true;
          sel.size = Math.min(6, Math.max(3, options.length));
        }
        options.forEach(function(opt) {
          var o = document.createElement("option");
          o.value = opt.value;
          o.textContent = opt.label;
          if (selectedArr.includes(opt.value)) o.selected = true;
          sel.appendChild(o);
        });
        if (!sel.multiple && options.length && !selectedArr.length) {
          sel.value = options[0].value;
        }
        label.after(sel);
      }
    }

    // Initial build
    var initSel = Array.isArray(initialSelected) ? initialSelected : [initialSelected].filter(Boolean);
    rebuild(initialLinkType === "risk" ? initSel : initSel);

    // Re-build on linkType change
    typeSelect.addEventListener("change", function() { rebuild(); });
  }

  function getLinkOptions(type) {
    switch (type) {
      case "control":
        return getControlLinkOptions().flat;
      case "risk":
        return (appData.risks || []).map(r => ({ value: r.id, label: r.title }));
      case "nc":
        return (appData.nonconformities || []).map(nc => ({ value: nc.id, label: nc.title }));
      case "audit":
        return appData.audits.map(a => ({ value: a.id, label: a.title }));
      case "threat":
        return appData.threats.map(t => ({ value: t.id, label: t.title }));
      case "continuous":
        return [
          { value: "continuous_improvement", label: "Am\xE9lioration continue" }
        ];
      default:
        return [];
    }
  }
  function getActionFilterOptions(type) {
    switch (type) {
      case "risk":
        return (appData.risks || []).map(r => ({ value: r.id, label: r.title }));
      case "nc":
        return (appData.nonconformities || []).map(nc => ({ value: nc.id, label: nc.title }));
      case "audit":
        return appData.audits.map(a => ({ value: a.id, label: a.title }));
      case "threat":
        return appData.threats.map(t => ({ value: t.id, label: t.title }));
      case "owner": {
        const names = new Set();
        const addName = (value) => {
          const cleaned = typeof value === "string" ? value.trim() : "";
          if (cleaned) names.add(cleaned);
        };
        (appData.actions || []).forEach((action) => addName(action.assignedTo));
        (appData.stakeholders || []).forEach((st) => addName(st.name));
        const options = Array.from(names)
          .sort(SortUtils.text)
          .map((name) => ({ value: name, label: name }));
        if ((appData.actions || []).some((action) => !action.assignedTo)) {
          options.unshift({ value: "unassigned", label: "Non assigné" });
        }
        return options;
      }
      case "continuous":
        return [
          { value: "continuous_improvement", label: "Am\xE9lioration continue" }
        ];
      default:
        return [];
    }
  }
  function updateActionsFilterOptions() {
    const _f = SectionToolbar.getFilters("actions");
    const type = _f.filterType || "";
    const options = type ? getActionFilterOptions(type) : [];
    SectionToolbar.updateFilterOptions("actions", "filterValue", options);
  }
  function matchesActionFilter(action, filterType, filterValue) {
    if (!action || !filterType || filterType === "all") return true;
    // Type-only filtering: filterValue is "all" → match all actions of this type
    if (!filterValue || filterValue === "all") {
      if (filterType === "owner") return true;
      if (filterType === "audit") {
        if (action.linkType === "audit") return true;
        const ncIds = [];
        if (action.linkType === "nc") ncIds.push(...getActionLinkIds(action));
        if (Array.isArray(action.ncIds)) ncIds.push(...action.ncIds);
        return ncIds.some((ncId) => {
          const nc = appData.nonconformities.find((n) => n.id === ncId);
          return nc && nc.auditId;
        });
      }
      return action.linkType === filterType;
    }
    // Specific-value filtering
    if (filterType === "risk") {
      return action.linkType === "risk" && getActionLinkIds(action).includes(filterValue);
    }
    if (filterType === "nc") {
      if (action.linkType === "nc" && getActionLinkIds(action).includes(filterValue)) return true;
      return (action.ncIds || []).includes(filterValue);
    }
    if (filterType === "audit") {
      if (action.linkType === "audit" && getActionLinkIds(action).includes(filterValue)) return true;
      const ncIds = [];
      if (action.linkType === "nc") ncIds.push(...getActionLinkIds(action));
      if (Array.isArray(action.ncIds)) ncIds.push(...action.ncIds);
      if (!ncIds.length) return false;
      return ncIds.some((ncId) => {
        const nc = appData.nonconformities.find((n) => n.id === ncId);
        return nc && nc.auditId === filterValue;
      });
    }
    if (filterType === "threat") {
      return action.linkType === "threat" && getActionLinkIds(action).includes(filterValue);
    }
    if (filterType === "owner") {
      const assignedTo = (action.assignedTo || "").trim();
      if (filterValue === "unassigned") return !assignedTo;
      return assignedTo === filterValue;
    }
    if (filterType === "continuous") {
      return action.linkType === "continuous" && getActionLinkIds(action).includes(filterValue);
    }
    return true;
  }
  function normalizeActionLinkValue(type, value) {
    if (type === "risk") {
      if (Array.isArray(value)) return value.filter(Boolean);
      if (value) return [value];
      return [];
    }
    if (Array.isArray(value)) return value[0] || "";
    return value || "";
  }
  function getActionLinkIds(action) {
    if (!action) return [];
    const type = action.linkType || (action.controlId ? "control" : "");
    if (type === "risk") return normalizeActionLinkValue("risk", action.linkId);
    const id = action.linkId || action.controlId;
    return id ? [id] : [];
  }
  function actionLinksToRisk(action, riskId) {
    if (!action || action.linkType !== "risk") return false;
    return getActionLinkIds(action).includes(riskId);
  }
  function normalizeActionProgressStatus(rawProgress, rawStatus) {
    const parsed = parseInt(rawProgress, 10);
    const progress = Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : 0;
    let status = rawStatus || "planifie";
    if (progress >= 100 && status !== "annule") status = "termine";
    if (status === "termine" && progress < 100) return { progress: 100, status };
    return { progress, status };
  }
  function bindActionStatusProgressSync() {
    const progressInput = document.getElementById("modal-input-progress");
    const statusSelect = document.getElementById("modal-input-status");
    if (!progressInput || !statusSelect) return;
    const getProgress = () => {
      const raw = parseInt(progressInput.value, 10);
      if (!Number.isFinite(raw)) return null;
      const value = Math.max(0, Math.min(100, raw));
      if (String(value) !== String(progressInput.value)) progressInput.value = String(value);
      return value;
    };
    const syncFromProgress = () => {
      const value = getProgress();
      if (value === null) return;
      if (value >= 100 && statusSelect.value !== "termine" && statusSelect.value !== "annule") {
        statusSelect.value = "termine";
      }
    };
    const syncFromStatus = () => {
      if (statusSelect.value === "termine") {
        progressInput.value = "100";
      }
    };
    progressInput.addEventListener("input", syncFromProgress);
    progressInput.addEventListener("change", syncFromProgress);
    statusSelect.addEventListener("change", syncFromStatus);
    syncFromStatus();
    syncFromProgress();
  }
  function editAction(actionId) {
    const action = (appData.actions || []).find((a) => a.id === actionId);
    if (!action) return;
    const linkType = action.linkType || "control";
    const currentLinkId = action.linkId || action.controlId || "";
    const normalizedValue = normalizeActionLinkValue(linkType, currentLinkId);
    const criticalAssetIds = Array.isArray(action.criticalAssetIds) ? action.criticalAssetIds : [];
    const criticalAssetOptions = getCriticalAssetOptions(criticalAssetIds);
    // linkId rendered as empty select placeholder — bindLinkTypeToLinkId replaces it immediately
    openModal(t('modals.editAction'), [
      { name: "title", label: t('actions.colTitle', 'Titre'), value: action.title },
      { name: "linkType", label: t('actions.linkType', 'Type de lien'), type: "select", value: linkType, options: [
          { value: "control", label: t('actionLinkTypes.control', 'Contrôle') },
          { value: "risk", label: t('actionLinkTypes.risk', 'Risque') },
          { value: "nc", label: t('actionLinkTypes.nc', 'Non-conformité') },
          { value: "audit", label: t('actionLinkTypes.audit', 'Audit') },
          { value: "threat", label: t('actionLinkTypes.threat', 'Menace') },
          { value: "continuous", label: t('actionLinkTypes.continuous', 'Amélioration continue') }
        ] },
      { name: "linkId", label: t('actions.linkedElement', 'Élément lié'), type: "select", value: "", options: [] },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: criticalAssetIds, options: criticalAssetOptions },
      {
        name: "assignedTo",
        label: t('fields.owner'),
        type: "select",
        value: action.assignedTo || "",
        // FIX-ACTION-OWNER-RESET (audit 2026-06-10) : option vide + valeur courante
        // conservée si absente de la liste des intervenants. Sans elles, le navigateur
        // sélectionnait le PREMIER intervenant pour une action non assignée (ou dont le
        // responsable a été supprimé/renommé) et la sauvegarde réassignait en silence.
        options: (() => {
          const opts = [{ value: '', label: '— ' + t('common.select', 'Sélectionner') + ' —' }]
            .concat(appData.stakeholders.map((s) => ({ value: s.name, label: s.name })));
          const cur = action.assignedTo || "";
          if (cur && !appData.stakeholders.some((s) => s.name === cur)) {
            opts.push({ value: cur, label: cur });
          }
          return opts;
        })()
      },
      { name: "priority", label: t('actions.priority', 'Priorité'), type: "select", value: action.priority, options: ACTION_PRIORITY_OPTIONS },
      { name: "dueDate", label: t('actions.dueDate', 'Échéance'), type: "date", value: action.dueDate },
      { name: "status", label: t('fields.status'), type: "select", value: action.status, options: ACTION_STATUS_OPTIONS },
      { name: "progress", label: t('actions.progress', 'Avancement') + " (%)", value: action.progress, type: "number" },
      { name: "existingComments", label: t('fields.existingComments'), type: "textarea", value: formatCommentsReadonly(action.comments), readOnly: true },
      { name: "newComment", label: t('fields.newComment'), type: "textarea" }
    ], (data) => {
      const resolvedLinkType = data.linkType || "control";
      const resolvedLinkValue = normalizeActionLinkValue(resolvedLinkType, data.linkId);
      const normalized = normalizeActionProgressStatus(data.progress, data.status);
      action.title = data.title;
      action.linkType = resolvedLinkType;
      action.linkId = resolvedLinkType === "risk" ? resolvedLinkValue : (resolvedLinkValue || "");
      action.controlId = resolvedLinkType === "control" ? (resolvedLinkValue || "") : "";
      action.progress = normalized.progress;
      action.priority = data.priority;
      action.assignedTo = data.assignedTo;
      action.dueDate = data.dueDate;
      action.status = normalized.status;
      action.criticalAssetIds = Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [];
      // Unification 2026-07-10 : même logique que manageComments (trim + plafond 10 + id/auteur)
      if (appendComment(action, data.newComment, 10) === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
      }
            addAuditEntry('edit', 'actions', action.title);
      saveData();
      loadActionsTable();
      updateDashboard();
      loadNonConformitiesTable();
      updateActionsChart();
    }, () => deleteAction(actionId));
    // Shared handler: dynamic swap between searchable-select (control) and native select (other types)
    bindLinkTypeToLinkId(linkType, normalizedValue);
    bindActionStatusProgressSync();
  }
  function deleteAction(actionId) {
    showConfirmModal(t('confirms.deleteAction'), t('confirms.deleteAction'), function() {
      var _act = (appData.actions || []).find(function(a) { return a.id === actionId; });
      appData.actions = (appData.actions || []).filter((a) => a.id !== actionId);
            addAuditEntry('delete', 'actions', _act ? _act.title : actionId);
      saveData();
      loadActionsTable();
      updateDashboard();
      loadNonConformitiesTable();
      updateActionsChart();
    }, { type: 'danger' });
  }
  function addAction(defaultNcIds = []) {
    if (!Array.isArray(defaultNcIds)) defaultNcIds = [];
    const criticalAssetOptions = getCriticalAssetOptions();
    openModal(t('modals.addAction'), [
      { name: "title", label: t('actions.colTitle', 'Titre') },
      { name: "linkType", label: t('actions.linkType', 'Type de lien'), type: "select", value: "control", options: [
          { value: "control", label: t('actionLinkTypes.control', 'Contrôle') },
          { value: "risk", label: t('actionLinkTypes.risk', 'Risque') },
          { value: "nc", label: t('actionLinkTypes.nc', 'Non-conformité') },
          { value: "audit", label: t('actionLinkTypes.audit', 'Audit') },
          { value: "threat", label: t('actionLinkTypes.threat', 'Menace') },
          { value: "continuous", label: t('actionLinkTypes.continuous', 'Amélioration continue') }
        ] },
      { name: "linkId", label: t('actions.linkedElement', 'Élément lié'), type: "select", value: "", options: [] },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: [], options: criticalAssetOptions },
      {
        name: "assignedTo",
        label: t('fields.owner'),
        type: "select",
        options: [{ value: '', label: '\u2014 ' + t('common.select', 'Sélectionner') + ' \u2014' }].concat(appData.stakeholders.map((s) => ({ value: s.name, label: s.name })))
      },
      { name: "priority", label: t('actions.priority', 'Priorité'), type: "select", value: "medium", options: ACTION_PRIORITY_OPTIONS },
      { name: "dueDate", label: t('actions.dueDate', 'Échéance'), type: "date" },
      { name: "status", label: t('fields.status'), type: "select", value: "planifie", options: ACTION_STATUS_OPTIONS },
      { name: "progress", label: t('actions.progress', 'Avancement') + " (%)", type: "number", value: "0" },
      { name: "comment", label: t('fields.comment'), type: "textarea" }
    ], (data) => {
      const resolvedLinkType = data.linkType || "control";
      const resolvedLinkValue = normalizeActionLinkValue(resolvedLinkType, data.linkId);
      const normalized = normalizeActionProgressStatus(data.progress, data.status);
      if (!data.title) return false;
      // FIX-ACTIONID-SYNC (audit 2026-06-10) : si le compteur est désynchronisé
      // (ancien état serveur, reset partiel), avancer jusqu'au premier id libre au
      // lieu de réutiliser l'id d'une action existante.
      let id = "action-" + appData.nextActionId;
      appData.nextActionId++;
      while ((appData.actions || []).some(a => a.id === id)) {
        id = "action-" + appData.nextActionId;
        appData.nextActionId++;
      }
      const ncIds = Array.isArray(defaultNcIds) ? defaultNcIds.slice() : [];
      const _newAction = {
        id,
        linkType: resolvedLinkType,
        linkId: resolvedLinkType === "risk" ? resolvedLinkValue : (resolvedLinkValue || ""),
        controlId: resolvedLinkType === "control" ? (resolvedLinkValue || "") : "",
        ncIds,
        title: data.title,
        description: "",
        priority: data.priority || "medium",
        progress: normalized.progress,
        dueDate: data.dueDate || "",
        assignedTo: data.assignedTo || "",
        status: normalized.status,
        criticalAssetIds: Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [],
        comments: []
      };
      appendComment(_newAction, data.comment, 10); // trim + id/auteur, no-op si vide
      appData.actions.push(_newAction);
            addAuditEntry('add', 'actions', data.title);
      saveData();
      loadActionsTable();
      updateDashboard();
      loadNonConformitiesTable();
      updateActionsChart();
    });
    // Shared handler: dynamic swap between searchable-select (control) and native select (other types)
    bindLinkTypeToLinkId("control", "");
    bindActionStatusProgressSync();
  }

  function manageActionComments(actionId) {
    manageComments('actions', actionId, { titlePrefix: t('actions.manageComments', 'Commentaires de l\'action') });
  }
  window.loadActionsTable = loadActionsTable;
  window.editAction = editAction;
  window.deleteAction = deleteAction;
  window.manageActionComments = manageActionComments;
  window.addAction = addAction;

  // modules/nis2-program.js
  function getNis2StatusMeta() {
    return {
      todo: { label: t('nis2Statuses.todo'), className: "status-badge status-badge--non_implemente" },
      in_progress: { label: t('nis2Statuses.inProgress'), className: "status-badge status-badge--en_cours" },
      suspended: { label: t('nis2Statuses.suspended'), className: "status-badge status-badge--en_cours" },
      done: { label: t('nis2Statuses.done'), className: "status-badge status-badge--verifie" }
    };
  }
  // FIX-I18N-NIS2-ENUMS (audit 2026-07-10) : `let` (et non `const`) pour pouvoir être
  // réévalués dans le handler 'languageChange' — sinon les badges statut/urgence/type du
  // plan NIS2 gardaient la langue de démarrage jusqu'au rechargement complet.
  let NIS2_STATUS_META = getNis2StatusMeta();
  function getNis2UrgencyLabels() {
    return { high: t('nis2Urgency.high'), medium: t('nis2Urgency.medium'), standard: t('nis2Urgency.standard') };
  }
  let NIS2_URGENCY_LABELS = getNis2UrgencyLabels();
  function getNis2ActionTypeOptions() {
    return [
      { value: "service", label: t('nis2ActionTypes.service') },
      { value: "projet", label: t('nis2ActionTypes.projet') },
      { value: "initiative", label: t('nis2ActionTypes.initiative') },
      { value: "programme", label: t('nis2ActionTypes.programme') }
    ];
  }
  let NIS2_ACTION_TYPE_OPTIONS = getNis2ActionTypeOptions();
  function getNis2ActionTypeLabels() {
    return NIS2_ACTION_TYPE_OPTIONS.reduce((acc, opt) => {
      acc[opt.value] = opt.label;
      return acc;
    }, {});
  }
  let NIS2_ACTION_TYPE_LABELS = getNis2ActionTypeLabels();
  const NIS2_DOMAIN_ICONS = {
    [NORMALIZE("Gouvernance & responsabilité")]: "fa-user-shield",
    [NORMALIZE("Gestion des risques (SMSI)")]: "fa-scale-balanced",
    [NORMALIZE("Gestion des incidents & notification")]: "fa-bell",
    [NORMALIZE("Gestion des incidents et notification")]: "fa-bell",
    [NORMALIZE("Continuité d’activité & gestion de crise (BCP/DRP)")]: "fa-life-ring",
    [NORMALIZE("Sécurité de la chaîne d’approvisionnement")]: "fa-truck-fast",
    [NORMALIZE("Sécurité dans l’acquisition, le développement & la maintenance")]: "fa-code-branch",
    [NORMALIZE("Traçabilité et auditabilité")]: "fa-magnifying-glass-chart",
    [NORMALIZE("Gestion des vulnérabilités")]: "fa-bug-slash",
    [NORMALIZE("Sensibilisation & formation")]: "fa-chalkboard-user",
    [NORMALIZE("Cryptographie")]: "fa-lock",
    [NORMALIZE("Sécurité physique")]: "fa-building-lock",
    [NORMALIZE("Gestion des accès au SI")]: "fa-key"
  };
  const ensureNis2Phases = () => {
    if (!appData.nis2Plan) appData.nis2Plan = {};
    if (!Array.isArray(appData.nis2Plan.phases) || appData.nis2Plan.phases.length === 0) {
      appData.nis2Plan.phases = [...DEFAULT_NIS2_PHASES];
    }
    const map = new Map();
    appData.nis2Plan.phases.forEach((p) => {
      const id = p.id || generateId("phase");
      if (!map.has(id)) map.set(id, { id, label: p.label || id, order: typeof p.order === "number" ? p.order : 99, fallback: !!p.fallback });
    });
    const hasFallback = [...map.values()].some((p) => p.fallback);
    if (!hasFallback) {
      map.set("phase-unplanned", { id: "phase-unplanned", label: t('nis2Phases.outOfPhase', 'Hors phase'), order: 99, fallback: true });
    }
    appData.nis2Plan.phases = [...map.values()].sort((a, b) => SortUtils.numeric(a.order, b.order, 0));
    return appData.nis2Plan.phases;
  };
  function normalizePhaseId(value) {
    const phases = ensureNis2Phases();
    const fallback = phases.find((p) => p.fallback) || phases[phases.length - 1];
    const str = value === undefined || value === null ? "" : value.toString();
    if (phases.some((p) => p.id === str)) return str;
    const asNum = parseInt(str, 10);
    if (!isNaN(asNum)) {
      const numericMatch = phases.find((p) => parseInt(p.order, 10) === asNum || p.id === `phase-${asNum}`);
      if (numericMatch) return numericMatch.id;
    }
    return fallback?.id || "phase-unplanned";
  }
  function parseBudgetValue(value) {
    if (typeof value === "number") return value;
    if (!value || typeof value !== "string") return NaN;
    // Tenter d'abord une interprétation numérique PROPRE (gère la notation scientifique
    // « 1e9 », les décimales…) avant le nettoyage agressif des caractères, qui sinon
    // transformerait « 1e9 » en « 19 ».
    const cleaned = value.replace(/\s/g, "").replace(",", ".");
    const direct = Number(cleaned);
    if (Number.isFinite(direct)) return direct;
    const sanitized = cleaned.replace(/[^0-9.\-]/g, "");
    const parsed = parseFloat(sanitized);
    return isNaN(parsed) ? NaN : parsed;
  }
  const formatCurrency = (val) => {
    if (!Number.isFinite(val)) return "—";
    try {
      // Montants budgétaires : toujours en clair, PAS de notation compacte — préserver la
      // précision (1 234 567 € et non « 1,2 M€ »), indispensable pour un suivi de budget.
      return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(val) + " €";
    } catch (e) {
      const locale = window.CSI_i18n ? { fr: 'fr-FR', en: 'en-GB' }[window.CSI_i18n.getLanguage()] || 'fr-FR' : 'fr-FR';
      return val.toLocaleString(locale) + " €";
    }
  };
  function renderSocleGrid(containerId, limit = null) {
    const container = document.getElementById(containerId);
    if (!container) return;
    const pillars = normalizeSoclePillars(appData.soclePillars);
    appData.soclePillars = pillars;
    const items = typeof limit === "number" ? pillars.slice(0, limit) : pillars;
    container.innerHTML = "";
    items.forEach((pillar, idx) => {
      const pillarIndex = idx;
      const card = document.createElement("div");
      card.className = `socle-card socle-card--pillar${pillarIndex + 1}`;
      const listItems = (pillar.items || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
      card.innerHTML = `
        <div class="socle-card__header">
          <div class="socle-card__icon"><i class="fas ${escapeHtml(pillar.icon || "")}"></i></div>
          <div class="socle-card__meta">
            <p class="socle-card__eyebrow">Pilier ${pillarIndex + 1}</p>
            <h3 class="socle-card__title">${escapeHtml(pillar.title)}</h3>
            <p class="socle-card__subtitle">${escapeHtml(pillar.subtitle)}</p>
          </div>
          <button class="icon-btn socle-card__edit" type="button" data-readonly-lock="true" data-pillar-index="${pillarIndex}" title="${t('nis2Socle.editPillar', 'Modifier le pilier')}">
            <i class="fas fa-pen"></i>
          </button>
        </div>
        <div class="socle-card__body">
          <h4>Éléments clefs</h4>
          <ul>${listItems}</ul>
          <div class="socle-card__footer"><i class="fas fa-user-tie"></i> ${escapeHtml(pillar.owner)}</div>
        </div>
      `;
      container.appendChild(card);
    });
    container.querySelectorAll(".socle-card__edit").forEach((btn) => {
      btn.addEventListener("click", () => {
        const index = parseInt(btn.dataset.pillarIndex, 10);
        if (Number.isInteger(index)) openSoclePillarEditor(index);
      });
    });
  }
  function renderSocleGrids() {
    renderSocleGrid("nis2SocleInline", 3);
    renderSocleGrid("nis2SocleFull");
    refreshReadOnlyLocks();
  }
  function loadNis2Program() {
    ensureNis2Plan();
    renderBudgetTab();
  }

  // ===================== Budget Cybersécurité (onglet générique) =====================
  // Onglet de pilotage budgétaire décorrélé de NIS2 : lignes budgétaires autonomes
  // (intitulé, catégorie, responsable, statut, montant par année configurable).
  function makeBudgetLineId() {
    return 'bl-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36);
  }
  function getBudgetYears() {
    const p = appData && appData.nis2Plan;
    let yrs = (p && Array.isArray(p.budgetYears)) ? p.budgetYears.slice() : null;
    if (!yrs || !yrs.length) yrs = defaultBudgetYears();
    return yrs;
  }
  function getBudgetLines() {
    const p = appData && appData.nis2Plan;
    return (p && Array.isArray(p.budgetLines)) ? p.budgetLines : [];
  }
  function getBudgetCategoriesList() {
    const p = appData && appData.nis2Plan;
    const cats = (p && Array.isArray(p.budgetCategories) && p.budgetCategories.length)
      ? p.budgetCategories
      : [
          { value: "infra", label: t('budgetCategories.infra', 'Infrastructure') },
          { value: "cyber", label: t('budgetCategories.cyber', 'Cybersécurité') },
          { value: "divers", label: t('budgetCategories.divers', 'Divers') }
        ];
    return cats.map(c => ({ value: c.value || c.label || '', label: c.label || c.value || '' }));
  }
  function budgetCategoryLabel(value) {
    const cat = getBudgetCategoriesList().find(c => c.value === value);
    return cat ? cat.label : (value || t('budgetCategories.divers', 'Divers'));
  }
  function budgetStatusMeta(status) {
    const s = normalizeBudgetStatus(status);
    if (s === 'engage') return { value: 'engage', label: t('budgetStatus.engage', 'Engagé'), className: 'budget-status--engage' };
    if (s === 'realise') return { value: 'realise', label: t('budgetStatus.realise', 'Réalisé'), className: 'budget-status--realise' };
    return { value: 'planifie', label: t('budgetStatus.planifie', 'Planifié'), className: 'budget-status--planifie' };
  }
  function getBudgetLineYearAmount(line, year) {
    if (!line || !line.amounts) return 0;
    const n = parseBudgetValue(line.amounts[year]);
    // (audit global 2026-07-03) borner sur l'invariant de saisie (>= 0) — un montant négatif
    // injecté (import/donnée corrompue) faussait sinon les totaux budgétaires au rendu.
    return (Number.isFinite(n) && n >= 0) ? n : 0;
  }
  function getBudgetLineTotal(line) {
    return getBudgetYears().reduce((sum, yr) => sum + getBudgetLineYearAmount(line, yr), 0);
  }

  function renderBudgetTab() {
    ensureNis2Plan();
    populateBudgetFilters();
    renderBudgetKpis();
    renderBudgetByYearChart();
    renderBudgetByCategoryChart();
    renderBudgetLinesTable();
    if (typeof refreshReadOnlyLocks === 'function') refreshReadOnlyLocks();
  }

  function getBudgetFilterState() {
    const catSel = document.getElementById('budgetCategoryFilter');
    const statusSel = document.getElementById('budgetStatusFilter');
    return {
      category: (catSel && catSel.value) || 'all',
      status: (statusSel && statusSel.value) || 'all'
    };
  }
  function getFilteredBudgetLines() {
    const { category, status } = getBudgetFilterState();
    return getBudgetLines().filter(line => {
      if (category !== 'all' && (line.category || 'divers') !== category) return false;
      if (status !== 'all' && normalizeBudgetStatus(line.status) !== status) return false;
      return true;
    });
  }
  function populateBudgetFilters() {
    const catSel = document.getElementById('budgetCategoryFilter');
    if (catSel) {
      const current = catSel.value || 'all';
      const cats = getBudgetCategoriesList();
      const opts = ['<option value="all">' + escapeHtml(t('nis2Program.allCategories', 'Toutes les catégories')) + '</option>']
        .concat(cats.map(c => '<option value="' + escapeHtml(c.value) + '">' + escapeHtml(c.label) + '</option>'));
      catSel.innerHTML = opts.join('');
      catSel.value = Array.prototype.some.call(catSel.options, o => o.value === current) ? current : 'all';
    }
    const tb = document.getElementById('budgetToolbar');
    if (tb && !tb.dataset.bound) {
      tb.dataset.bound = 'true';
      ['budgetCategoryFilter', 'budgetStatusFilter'].forEach(id => {
        const el = document.getElementById(id);
        if (el) el.addEventListener('change', () => renderBudgetLinesTable());
      });
    }
  }

  function renderBudgetKpis() {
    const grid = document.getElementById('budgetKpiGrid');
    if (!grid) return;
    const years = getBudgetYears();
    const lines = getBudgetLines();
    const grandTotal = lines.reduce((sum, l) => sum + getBudgetLineTotal(l), 0);
    const yearTotals = years.map(yr => lines.reduce((sum, l) => sum + getBudgetLineYearAmount(l, yr), 0));
    const statusTotals = { planifie: 0, engage: 0, realise: 0 };
    lines.forEach(l => { statusTotals[normalizeBudgetStatus(l.status)] += getBudgetLineTotal(l); });
    const yearColors = ['kpi-card--success', 'kpi-card--warning', 'kpi-card--info', 'kpi-card--purple', 'kpi-card--cyan', 'kpi-card--orange'];
    const lineWord = lines.length > 1 ? t('nis2Program.linesPlural', 'lignes') : t('nis2Program.lineSingular', 'ligne');
    let html = `
      <div class="kpi-card kpi-card--primary budget-kpi-card">
        <div class="kpi-card__icon"><i class="fas fa-sack-dollar"></i></div>
        <div class="kpi-card__content">
          <p class="kpi-card__eyebrow">${escapeHtml(t('nis2Program.totalBudget', 'Budget total'))}</p>
          <div class="kpi-card__value">${formatCurrency(grandTotal)}</div>
          <div class="kpi-card__detail">${lines.length} ${escapeHtml(lineWord)}</div>
        </div>
      </div>`;
    years.forEach((yr, idx) => {
      const pct = grandTotal ? Math.round(yearTotals[idx] / grandTotal * 100) : 0;
      html += `
      <div class="kpi-card ${yearColors[idx % yearColors.length]} budget-kpi-card">
        <div class="kpi-card__icon"><i class="fas fa-calendar-alt"></i></div>
        <div class="kpi-card__content">
          <p class="kpi-card__eyebrow">${escapeHtml(yr)}</p>
          <div class="kpi-card__value">${formatCurrency(yearTotals[idx])}</div>
          <div class="kpi-card__detail">${pct}%</div>
        </div>
      </div>`;
    });
    const chips = ['planifie', 'engage', 'realise'].map(budgetStatusMeta);
    html += `
      <div class="budget-status-chips">
        ${chips.map(m => `<span class="budget-status-chip ${m.className}"><span class="budget-status-chip__dot"></span>${escapeHtml(m.label)} <strong>${formatCurrency(statusTotals[m.value])}</strong></span>`).join('')}
      </div>`;
    grid.innerHTML = html;
  }

  function renderBudgetByYearChart() {
    const canvas = document.getElementById('budgetByYearChart');
    if (!canvas || typeof Chart === 'undefined') return;
    if (charts.budgetByYear) charts.budgetByYear.destroy();
    const themeVars = getThemeVars();
    const years = getBudgetYears();
    const lines = getBudgetLines();
    const statuses = ['planifie', 'engage', 'realise'];
    const statusColors = { planifie: themeVars.secondary, engage: themeVars.warning, realise: themeVars.success };
    const datasets = statuses.map(s => ({
      label: budgetStatusMeta(s).label,
      data: years.map(yr => lines.filter(l => normalizeBudgetStatus(l.status) === s).reduce((sum, l) => sum + getBudgetLineYearAmount(l, yr), 0)),
      backgroundColor: withAlpha(statusColors[s], 0.85),
      borderColor: withAlpha(statusColors[s], 1),
      borderWidth: 1,
      borderRadius: 4
    }));
    charts.budgetByYear = new Chart(canvas, {
      type: 'bar',
      data: { labels: years, datasets },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        scales: {
          x: { stacked: true, grid: { display: false }, ticks: { color: themeVars.textSecondary } },
          y: { stacked: true, beginAtZero: true, ticks: { color: themeVars.textSecondary, callback: (v) => formatCurrency(v) }, grid: { color: withAlpha(themeVars.borderColor, 0.6) } }
        },
        plugins: {
          legend: { position: 'bottom', labels: { usePointStyle: true, padding: 14, color: themeVars.textColor } },
          tooltip: { callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.raw || 0)}` } }
        }
      }
    });
  }

  function renderBudgetByCategoryChart() {
    const canvas = document.getElementById('budgetByCategoryChart');
    const legendEl = document.getElementById('budgetByCategoryLegend');
    if (!canvas || typeof Chart === 'undefined') return;
    if (charts.budgetByCategory) { charts.budgetByCategory.destroy(); charts.budgetByCategory = null; }
    const themeVars = getThemeVars();
    const totalsByCat = {};
    getBudgetCategoriesList().forEach(c => { totalsByCat[c.value] = 0; });
    getBudgetLines().forEach(l => {
      const cat = l.category || 'divers';
      if (!(cat in totalsByCat)) totalsByCat[cat] = 0;
      totalsByCat[cat] += getBudgetLineTotal(l);
    });
    const entries = Object.keys(totalsByCat).filter(k => totalsByCat[k] > 0);
    const data = entries.map(k => totalsByCat[k]);
    const total = data.reduce((a, b) => a + b, 0);
    if (!data.length) {
      const ctx2 = canvas.getContext('2d');
      if (ctx2) ctx2.clearRect(0, 0, canvas.width, canvas.height);
      if (legendEl) legendEl.innerHTML = `<p class="budget-empty-hint">${escapeHtml(t('nis2Program.noBudgetData', 'Aucune donnée budgétaire à afficher'))}</p>`;
      return;
    }
    const palette = [themeVars.primary, themeVars.secondary, themeVars.warning, themeVars.success, themeVars.info, themeVars.danger];
    const colors = entries.map((_, i) => withAlpha(palette[i % palette.length], 0.85));
    const borderColors = entries.map((_, i) => withAlpha(palette[i % palette.length], 1));
    const labels = entries.map(budgetCategoryLabel);
    charts.budgetByCategory = new Chart(canvas, {
      type: 'doughnut',
      data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: borderColors, borderWidth: 2, hoverOffset: 10 }] },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        cutout: '58%',
        plugins: {
          legend: { display: false },
          tooltip: { callbacks: { label: (ctx) => { const v = ctx.raw || 0; const pct = total ? Math.round(v / total * 100) : 0; return `${ctx.label}: ${formatCurrency(v)} (${pct}%)`; } } }
        }
      }
    });
    if (legendEl) {
      legendEl.innerHTML = entries.map((k, i) => `<span class="budget-legend__item"><span class="budget-legend__dot" style="background:${borderColors[i]}"></span>${escapeHtml(budgetCategoryLabel(k))} <strong>${formatCurrency(data[i])}</strong></span>`).join('');
    }
  }

  function renderBudgetLinesTable() {
    const container = document.getElementById('budgetLinesContainer');
    if (!container) return;
    const years = getBudgetYears();
    const allLines = getBudgetLines();
    const lines = getFilteredBudgetLines();
    const readonly = isReadOnlyMode && typeof isReadOnlyMode === 'function' ? isReadOnlyMode() : false;
    if (!allLines.length) {
      container.innerHTML = `<div class="budget-empty">
        <i class="fas fa-coins"></i>
        <p class="budget-empty__title">${escapeHtml(t('nis2Program.budgetEmptyTitle', 'Aucune ligne budgétaire'))}</p>
        <p class="budget-empty__hint">${escapeHtml(t('nis2Program.budgetEmptyHint', 'Ajoutez votre première ligne de budget cybersécurité avec le bouton « Nouvelle ligne ».'))}</p>
      </div>`;
      return;
    }
    const cats = getBudgetCategoriesList();
    const order = {};
    cats.forEach((c, i) => { order[c.value] = i; });
    const groups = {};
    lines.forEach(l => { const c = l.category || 'divers'; (groups[c] = groups[c] || []).push(l); });
    const groupKeys = Object.keys(groups).sort((a, b) => (order[a] != null ? order[a] : 999) - (order[b] != null ? order[b] : 999));
    const colspanLead = 3;
    const yearHeaders = years.map(yr => `<th class="text-right">${escapeHtml(yr)}</th>`).join('');
    const grandYearTotals = years.map(() => 0);
    let grandTotal = 0;
    let rowsHtml = '';
    groupKeys.forEach(catKey => {
      const groupLines = groups[catKey];
      const groupYearTotals = years.map(yr => groupLines.reduce((s, l) => s + getBudgetLineYearAmount(l, yr), 0));
      const groupTotal = groupLines.reduce((s, l) => s + getBudgetLineTotal(l), 0);
      groupYearTotals.forEach((v, i) => { grandYearTotals[i] += v; });
      grandTotal += groupTotal;
      rowsHtml += `<tr class="budget-group-row"><td colspan="${colspanLead}">${escapeHtml(budgetCategoryLabel(catKey))}</td>${groupYearTotals.map(v => `<td class="text-right">${formatCurrency(v)}</td>`).join('')}<td class="text-right">${formatCurrency(groupTotal)}</td>${readonly ? '' : '<td></td>'}</tr>`;
      groupLines.forEach(line => {
        const meta = budgetStatusMeta(line.status);
        // Lot 1b : ligne cliquable (ouvre la modale d'édition) sauf en lecture seule.
        rowsHtml += `<tr${readonly ? '' : ` class="budget-line-row" data-budget-row-id="${escapeHtml(line.id)}" style="cursor:pointer"`}>
          <td><div class="budget-line-label">${escapeHtml(line.label || t('nis2Program.untitledLine', 'Sans intitulé'))}</div></td>
          <td>${line.owner ? escapeHtml(line.owner) : '—'}</td>
          <td><span class="budget-status ${meta.className}">${escapeHtml(meta.label)}</span></td>
          ${years.map(yr => `<td class="text-right">${formatCurrency(getBudgetLineYearAmount(line, yr))}</td>`).join('')}
          <td class="text-right"><strong>${formatCurrency(getBudgetLineTotal(line))}</strong></td>
          ${readonly ? '' : `<td class="text-right budget-line-actions">
            <button class="icon-btn icon-btn--danger" type="button" data-budget-action="delete" data-line-id="${escapeHtml(line.id)}" title="${escapeHtml(t('common.delete', 'Supprimer'))}"><i class="fas fa-trash"></i></button>
          </td>`}
        </tr>`;
      });
    });
    const filtered = lines.length !== allLines.length;
    if (!rowsHtml && filtered) {
      // Des lignes existent mais aucune ne passe les filtres : message explicite.
      const totalCols = colspanLead + years.length + 1 + (readonly ? 0 : 1);
      rowsHtml = `<tr class="budget-no-match"><td colspan="${totalCols}">${escapeHtml(t('nis2Program.noFilterMatch', 'Aucune ligne ne correspond aux filtres sélectionnés.'))}</td></tr>`;
    }
    container.innerHTML = `
      <table class="table budget-line-table">
        <thead>
          <tr>
            <th>${escapeHtml(t('nis2Program.lineLabel', 'Intitulé'))}</th>
            <th>${escapeHtml(t('nis2Program.lineOwner', 'Responsable'))}</th>
            <th>${escapeHtml(t('nis2Program.lineStatus', 'Statut'))}</th>
            ${yearHeaders}
            <th class="text-right">${escapeHtml(t('nis2Program.lineTotal', 'Total'))}</th>
            ${readonly ? '' : `<th class="text-right">${escapeHtml(t('common.actions', 'Actions'))}</th>`}
          </tr>
        </thead>
        <tbody>${rowsHtml}</tbody>
        <tfoot>
          <tr class="budget-total-row">
            <td colspan="${colspanLead}">${escapeHtml(filtered ? t('nis2Program.filteredTotal', 'Total (filtré)') : t('nis2Program.grandTotal', 'Total général'))}</td>
            ${grandYearTotals.map(v => `<td class="text-right">${formatCurrency(v)}</td>`).join('')}
            <td class="text-right"><strong>${formatCurrency(grandTotal)}</strong></td>
            ${readonly ? '' : '<td></td>'}
          </tr>
        </tfoot>
      </table>`;
    if (!container.dataset.bound) {
      container.dataset.bound = 'true';
      container.addEventListener('click', (event) => {
        const btn = event.target.closest('[data-budget-action]');
        if (btn) {
          const id = btn.getAttribute('data-line-id');
          const act = btn.getAttribute('data-budget-action');
          if (act === 'delete') deleteBudgetLine(id);
          return;
        }
        // Lot 1b : clic-ligne = édition (l'attribut n'est posé qu'en mode édition).
        const rowEl = event.target.closest('tr[data-budget-row-id]');
        if (rowEl) openBudgetLineModal(rowEl.getAttribute('data-budget-row-id'));
      });
    }
  }

  function openBudgetLineModal(lineId) {
    if (!ensureEditMode(t('editMode.editBudgetLine', 'éditer une ligne budgétaire'))) return;
    ensureNis2Plan();
    const lines = getBudgetLines();
    const existing = lineId ? lines.find(l => l.id === lineId) : null;
    const years = getBudgetYears();
    const cats = getBudgetCategoriesList();
    const fields = [
      { name: 'label', label: t('nis2Program.lineLabel', 'Intitulé'), value: existing ? existing.label : '' },
      { name: 'category', label: t('nis2Program.lineCategory', 'Catégorie'), type: 'select', value: existing ? (existing.category || 'divers') : (cats[0] ? cats[0].value : 'divers'), options: cats },
      { name: 'owner', label: t('nis2Program.lineOwner', 'Responsable'), value: existing ? (existing.owner || '') : '', placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(existing ? existing.owner : ''), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: 'status', label: t('nis2Program.lineStatus', 'Statut'), type: 'select', value: existing ? normalizeBudgetStatus(existing.status) : 'planifie', options: [
        { value: 'planifie', label: t('budgetStatus.planifie', 'Planifié') },
        { value: 'engage', label: t('budgetStatus.engage', 'Engagé') },
        { value: 'realise', label: t('budgetStatus.realise', 'Réalisé') }
      ] }
    ];
    years.forEach(yr => {
      fields.push({ name: 'amount_' + yr, label: t('nis2Program.amountForYear', 'Montant') + ' ' + yr + ' (€)', type: 'number', min: 0, value: existing ? getBudgetLineYearAmount(existing, yr) : 0 });
    });
    const title = existing ? t('modals.editBudgetLine', 'Modifier la ligne budgétaire') : t('modals.addBudgetLine', 'Nouvelle ligne budgétaire');
    openModal(title, fields, (data) => {
      const label = (data.label || '').trim();
      if (!label) { showToast(t('toasts.budgetLineLabelRequired', 'Un intitulé est requis'), 'warning'); return false; }
      const amounts = {};
      years.forEach(yr => {
        const n = parseBudgetValue(data['amount_' + yr]);
        // Montants bornés à >= 0 (un budget négatif n'a pas de sens et casserait le donut).
        amounts[yr] = (Number.isFinite(n) && n > 0) ? n : 0;
      });
      const clean = {
        id: existing ? existing.id : makeBudgetLineId(),
        label,
        category: data.category || 'divers',
        owner: (data.owner || '').trim(),
        status: normalizeBudgetStatus(data.status),
        amounts
      };
      if (existing) {
        const idx = lines.findIndex(l => l.id === existing.id);
        if (idx >= 0) lines[idx] = clean; else lines.push(clean);
      } else {
        lines.push(clean);
      }
      appData.nis2Plan.budgetLines = lines;
      saveData();
      renderBudgetTab();
      showToast(t('toasts.budgetLineSaved', 'Ligne budgétaire enregistrée'), 'success');
      return true;
    }, existing ? () => deleteBudgetLine(existing.id) : null);
  }

  function deleteBudgetLine(lineId) {
    if (!ensureEditMode(t('editMode.deleteBudgetLine', 'supprimer une ligne budgétaire'))) return;
    ensureNis2Plan();
    const lines = getBudgetLines();
    const idx = lines.findIndex(l => l.id === lineId);
    if (idx < 0) return;
    showConfirmModal(t('common.delete', 'Supprimer'), t('confirms.deleteBudgetLine', 'Supprimer cette ligne budgétaire ?'), function() {
      lines.splice(idx, 1);
      appData.nis2Plan.budgetLines = lines;
      saveData();
      renderBudgetTab();
      showToast(t('toasts.budgetLineDeleted', 'Ligne budgétaire supprimée'), 'success');
    }, { type: 'danger' });
  }

  function remapBudgetLineYears(oldYears, newYears) {
    const positional = oldYears.length === newYears.length;
    getBudgetLines().forEach(line => {
      const old = line.amounts || {};
      const next = {};
      newYears.forEach((yr, i) => {
        let raw;
        if (positional) {
          // Même nombre d'années = renommage / décalage de fenêtre : mapping PUREMENT
          // positionnel depuis l'instantané `old`. Tester d'abord hasOwnProperty(yr)
          // corromprait un décalage (2026/27/28 → 2027/28/29) : le libellé « 2027 »
          // chevauchant lirait l'ancienne valeur de 2027 au lieu de celle de 2026.
          raw = old[oldYears[i]];
        } else if (Object.prototype.hasOwnProperty.call(old, yr)) {
          // Longueurs différentes = ajout / retrait : conservation par libellé d'année.
          raw = old[yr];
        }
        const v = parseBudgetValue(raw);
        next[yr] = Number.isFinite(v) ? v : 0;
      });
      line.amounts = next;
    });
  }

  function openBudgetYearsModal() {
    if (!ensureEditMode(t('editMode.manageBudgetYears', 'gérer les années budgétaires'))) return;
    ensureNis2Plan();
    const years = getBudgetYears();
    openModal(t('modals.manageYears', 'Gérer les années budgétaires'), [
      { name: 'years', label: t('fields.budgetYearsList', 'Une année par ligne (min. 1)'), type: 'textarea', value: years.join('\n'), help: t('fields.budgetYearsHelp', 'Ajoutez, retirez ou renommez des années (une par ligne). Les montants restent associés au libellé de chaque année ; un renommage à même position conserve la valeur.') }
    ], (data) => {
      let newYears = (data.years || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
      newYears = newYears.filter((y, i) => newYears.indexOf(y) === i);
      if (!newYears.length) { showToast(t('toasts.budgetYearMin', 'Au moins une année requise'), 'warning'); return false; }
      const oldYears = getBudgetYears();
      remapBudgetLineYears(oldYears, newYears);
      appData.nis2Plan.budgetYears = newYears;
      saveData();
      renderBudgetTab();
      showToast(t('toasts.budgetYearsUpdated', 'Années budgétaires mises à jour'), 'success');
      return true;
    });
  }
  function openSoclePillarEditor(index) {
    if (!ensureEditMode("edit a pillar")) {
      refreshReadOnlyLocks();
      return;
    }
    const pillars = normalizeSoclePillars(appData.soclePillars);
    appData.soclePillars = pillars;
    const pillar = pillars[index];
    if (!pillar) return;
    openModal(t('modals.editPillar', 'Modifier le pilier') + ' ' + (index + 1), [
      { name: "title", label: t('fields.title'), value: pillar.title },
      { name: "subtitle", label: t('fields.subtitle', 'Sous-titre'), value: pillar.subtitle },
      { name: "items", label: t('fields.keyItems', 'Éléments clefs (un par ligne)'), type: "textarea", value: (pillar.items || []).join("\n") },
      { name: "owner", label: t('fields.projectLead', 'Chef de projet'), value: pillar.owner, placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(pillar.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') }
    ], (data) => {
      if (!data.title) return false;
      pillar.title = data.title;
      pillar.subtitle = data.subtitle || "";
      pillar.items = data.items
        ? data.items.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
        : [];
      pillar.owner = data.owner || "";
      saveData();
      renderSocleGrids();
    });
  }
  function openBudgetCategoriesModal() {
    if (!ensureEditMode(t('editMode.manageBudgetCategories', 'gérer les catégories de budget'))) return;
    ensureNis2Plan();
    var cats = Array.isArray(appData.nis2Plan.budgetCategories) && appData.nis2Plan.budgetCategories.length
      ? appData.nis2Plan.budgetCategories
      : [{ value: "infra", label: "Infrastructure" }, { value: "cyber", label: "Cybersécurité" }, { value: "divers", label: "Divers" }];
    var catsText = cats.map(function(c) { return c.label; }).join('\n');
    openModal(t('modals.budgetCategories', 'Catégories de budget'), [
      { name: "categories", label: t('fields.budgetCategoriesList', 'Une catégorie par ligne (min. 1)'), type: "textarea", value: catsText }
    ], function(data) {
      var lines = (data.categories || '').split('\n').map(function(l) { return l.trim(); }).filter(function(l) { return l.length > 0; });
      if (lines.length === 0) { showToast(t('toasts.budgetCatMin', 'Au moins une catégorie requise'), 'warning'); return false; }
      appData.nis2Plan.budgetCategories = lines.map(function(label) {
        // Try to match existing value key, otherwise generate from label
        var existing = cats.find(function(c) { return c.label === label; });
        var value = existing ? existing.value : label.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
        return { value: value, label: label };
      });
      saveData();
      renderBudgetTab();
      showToast(t('toasts.budgetCatUpdated', 'Catégories de budget mises à jour'), 'success');
      return true;
    });
  }
  window.loadNis2Program = loadNis2Program;

  // modules/critical-assets.js
  function getCriticalAssetAvailabilityOptions() {
    return [
      { value: "level1", label: t('assetAvailability.level1') },
      { value: "level2", label: t('assetAvailability.level2') },
      { value: "level3", label: t('assetAvailability.level3') },
      { value: "level4", label: t('assetAvailability.level4') }
    ];
  }
  let CRITICAL_ASSET_AVAILABILITY_OPTIONS = getCriticalAssetAvailabilityOptions();
  // Lot 1b actifs (2026-09, D4) : classification de l'information ORDONNÉE (ISO 27002 5.12),
  // 4 rangs fixes — pas de « Secret » (information classifiée étatique, hors périmètre de
  // l'app : loi belge 11/12/1998, IGI 1300 FR). L'ancienne échelle (sensible/très sensible/
  // restreint/public, non ordonnée) reste AFFICHABLE via les options legacy — aucune
  // migration en masse (D5) : la bascule se fait actif par actif à l'édition.
  function getCriticalAssetConfOptions() {
    return [
      { value: "public", label: t('assetConfidentiality.public') },
      { value: "interne", label: t('assetConfidentiality.interne') },
      { value: "confidentiel", label: t('assetConfidentiality.confidentiel') },
      { value: "strictementConfidentiel", label: t('assetConfidentiality.strictementConfidentiel') }
    ];
  }
  function getCriticalAssetConfLegacyOptions() {
    return [
      { value: "sensible", label: t('assetConfidentiality.sensible') },
      { value: "tresSensible", label: t('assetConfidentiality.tresSensible') },
      { value: "restreint", label: t('assetConfidentiality.restreint') }
    ];
  }
  let CRITICAL_ASSET_CONF_OPTIONS = getCriticalAssetConfOptions();
  // Résolution d'affichage : nouvelle échelle + clés legacy (exports, échos).
  let CRITICAL_ASSET_CONF_RESOLVE_OPTIONS = CRITICAL_ASSET_CONF_OPTIONS.concat(getCriticalAssetConfLegacyOptions());
  function getCriticalAssetIntegrityOptions() {
    return [
      { value: "faible", label: t('assetIntegrity.faible') },
      { value: "moderee", label: t('assetIntegrity.moderee') },
      { value: "elevee", label: t('assetIntegrity.elevee') },
      { value: "critique", label: t('assetIntegrity.critique') }
    ];
  }
  let CRITICAL_ASSET_INTEGRITY_OPTIONS = getCriticalAssetIntegrityOptions();
  function getCriticalAssetTypeOptions() {
    // Typologie des biens supports alignée ISO/IEC 27005 & EBIOS RM (liste à plat, sans sous-niveaux).
    return [
      { value: "hardware", label: t('assetType.hardware') },
      { value: "software", label: t('assetType.software') },
      { value: "sourceCode", label: t('assetType.sourceCode') },
      { value: "network", label: t('assetType.network') },
      { value: "service", label: t('assetType.service') },
      { value: "document", label: t('assetType.document') },
      { value: "employee", label: t('assetType.employee') },
      { value: "site", label: t('assetType.site') },
      { value: "organization", label: t('assetType.organization') }
    ];
  }
  let CRITICAL_ASSET_TYPE_OPTIONS = getCriticalAssetTypeOptions();
  function getCriticalAssetImportanceOptions() {
    return [
      { value: "normal", label: t('assetImportance.normal') },
      { value: "important", label: t('assetImportance.important') },
      { value: "critique", label: t('assetImportance.critique') }
    ];
  }
  let CRITICAL_ASSET_IMPORTANCE_OPTIONS = getCriticalAssetImportanceOptions();
  function getCriticalAssetImpactOptions() {
    return [
      { value: "societal", label: t('assetImpact.societal') },
      { value: "operationnel", label: t('assetImpact.operationnel') },
      { value: "financier", label: t('assetImpact.financier') },
      { value: "reputation", label: t('assetImpact.reputation') },
      { value: "reglementaire", label: t('assetImpact.reglementaire') }
    ];
  }
  let CRITICAL_ASSET_IMPACT_OPTIONS = getCriticalAssetImpactOptions();
  function getCriticalAssetMfaOptions() {
    return [
      { value: "nonImplemente", label: t('assetMfa.nonImplemente') },
      { value: "partiel", label: t('assetMfa.partiel') },
      { value: "implemente", label: t('assetMfa.implemente') },
      { value: "externe", label: t('assetMfa.externe') }
    ];
  }
  let CRITICAL_ASSET_MFA_STATUS_OPTIONS = getCriticalAssetMfaOptions();
  function getCriticalAssetDacpOptions() {
    return [
      { value: "oui", label: t('assetDacp.oui') },
      { value: "non", label: t('assetDacp.non') }
    ];
  }
  let CRITICAL_ASSET_DACP_OPTIONS = getCriticalAssetDacpOptions();
  // Lot 3 actifs (2026-09, D6/F3) : RTO/RPO/MTPD en PALIERS fermés (triables, alertables) —
  // la fausse précision (« RTO 3h47 ») est un anti-pattern BIA reconnu. Cran < 1 h côté RTO
  // (3 actifs de démo sur 5 tombaient sinon dans le même cran < 4 h). Les valeurs legacy en
  // texte libre restent affichées telles quelles et basculent actif par actif à l'édition
  // (« (valeur actuelle) », D5 — zéro parsing, zéro migration en masse).
  function getCriticalAssetRtoOptions() {
    return [
      { value: "lt1h", label: t('assetRto.lt1h') },
      { value: "lt4h", label: t('assetRto.lt4h') },
      { value: "lt24h", label: t('assetRto.lt24h') },
      { value: "lt72h", label: t('assetRto.lt72h') },
      { value: "lt1w", label: t('assetRto.lt1w') },
      { value: "gt1w", label: t('assetRto.gt1w') }
    ];
  }
  let CRITICAL_ASSET_RTO_OPTIONS = getCriticalAssetRtoOptions();
  function getCriticalAssetRpoOptions() {
    return [
      { value: "rpo0", label: t('assetRpo.rpo0') },
      { value: "lt1h", label: t('assetRpo.lt1h') },
      { value: "lt24h", label: t('assetRpo.lt24h') },
      { value: "lt1w", label: t('assetRpo.lt1w') }
    ];
  }
  let CRITICAL_ASSET_RPO_OPTIONS = getCriticalAssetRpoOptions();
  // Rang des paliers RTO/MTPD (cohérence informative — jamais bloquante).
  const CRITICAL_ASSET_RTO_RANK = { lt1h: 1, lt4h: 2, lt24h: 3, lt72h: 4, lt1w: 5, gt1w: 6 };
  // FIX-REVUE ACTIFS (2026-09) : résolveur par NOM de champ, pour les consommateurs hors du
  // module (schéma consolidé bundle:1485+, pdf-report.js via window). Fonction hoistée : elle
  // peut être référencée à la définition du schéma ; les listes d'options (let) ne sont lues
  // qu'à l'APPEL, après l'initialisation du module. Valeur inconnue/legacy = inchangée.
  function _csiAssetFieldValue(field) {
    return function(value) {
      var map = {
        rto: CRITICAL_ASSET_RTO_OPTIONS,
        rpo: CRITICAL_ASSET_RPO_OPTIONS,
        mtpd: CRITICAL_ASSET_RTO_OPTIONS,
        availability: CRITICAL_ASSET_AVAILABILITY_OPTIONS,
        confidentiality: CRITICAL_ASSET_CONF_RESOLVE_OPTIONS,
        integrity: CRITICAL_ASSET_INTEGRITY_OPTIONS,
        assetType: CRITICAL_ASSET_TYPE_OPTIONS,
        importance: CRITICAL_ASSET_IMPORTANCE_OPTIONS,
        impact: CRITICAL_ASSET_IMPACT_OPTIONS
      };
      return resolveCriticalAssetLabel(value, map[field] || []) || value || '';
    };
  }
  window._csiAssetFieldValue = _csiAssetFieldValue;
  // Identifiants nus pour le schéma consolidé (bundle:1485+) — le banc de parité des colonnes
  // supprime les `format: identifiant` par regex, un appel inline le casserait. Déclarations
  // de fonctions (hoistées) : référençables à la définition du schéma.
  function _csiAssetTypeValue(v) { return _csiAssetFieldValue('assetType')(v); }
  function _csiAssetImportanceValue(v) { return _csiAssetFieldValue('importance')(v); }
  function _csiAssetRtoValue(v) { return _csiAssetFieldValue('rto')(v); }
  function _csiAssetRpoValue(v) { return _csiAssetFieldValue('rpo')(v); }
  function _csiAssetAvailabilityValue(v) { return _csiAssetFieldValue('availability')(v); }
  function _csiAssetImpactValue(v) { return _csiAssetFieldValue('impact')(v); }
  // Résout une valeur d'actif stockée (clé énum 'software' OU libellé 'Logiciel') vers son
  // libellé d'affichage. RÉTRO-COMPATIBLE : si la valeur n'est pas une clé connue (ex. libellés
  // des données de démo, ou valeur libre), elle est renvoyée inchangée. Utilisé au rendu ET aux
  // exports pour la fidélité (un actif édité au modal stocke la clé, il faut afficher le libellé).
  function resolveCriticalAssetLabel(value, options) {
    if (!value || !Array.isArray(options)) return value;
    for (var _rcaI = 0; _rcaI < options.length; _rcaI++) {
      if (options[_rcaI] && options[_rcaI].value === value) return options[_rcaI].label;
    }
    return value;
  }
  function getDefaultCriticalAssetsMeta() {
    return {
      sectionTitle: "Actifs critiques",
      tableTitle: "Actifs critiques",
      subtitle: "Services et processus critiques (valeurs métier) — inventaire et exigences de continuité.",
      metaLine: ""
    };
  }
  function normalizeCriticalAssetsMeta(meta) {
    const defaults = getDefaultCriticalAssetsMeta();
    const clean = { ...defaults };
    if (meta && typeof meta === "object") {
      ["sectionTitle", "tableTitle", "subtitle", "metaLine"].forEach((key) => {
        if (typeof meta[key] === "string") {
          const trimmed = meta[key].trim();
          if (key === "sectionTitle" || key === "tableTitle") {
            clean[key] = trimmed || defaults[key];
          } else {
            clean[key] = trimmed;
          }
        }
      });
    }
    return clean;
  }
  function normalizeCriticalAssetEntry(entry = {}, index = 0) {
    const clean = typeof entry === "object" && entry ? { ...entry } : {};
    if (!clean.id) clean.id = generateId("asset");
    const priority = parseInt(clean.priority, 10);
    clean.priority = Number.isFinite(priority) && priority > 0 ? priority : index + 1;
    const toText = (value) => typeof value === "string" ? value.trim() : "";
    clean.beneficiary = toText(clean.beneficiary);
    // Lot 1a actifs (2026-09) : propriétaire métier (ISO 27002 5.9 « including owners »,
    // ReCyF 1.1 « identifie un responsable ») + date de dernière revue (le « maintained » de 5.9)
    clean.owner = toText(clean.owner);
    clean.lastReviewDate = toText(clean.lastReviewDate);
    clean.productCode = toText(clean.productCode);
    clean.productName = toText(clean.productName);
    if (Array.isArray(clean.productTags)) {
      clean.productTags = clean.productTags.map((tag) => String(tag).trim()).filter(Boolean);
    } else if (typeof clean.productTags === "string") {
      clean.productTags = clean.productTags.split(",").map((tag) => tag.trim()).filter(Boolean);
    } else {
      clean.productTags = [];
    }
    clean.rto = toText(clean.rto);
    clean.rpo = toText(clean.rpo);
    clean.mtpd = toText(clean.mtpd); // Lot 3 (2026-09) : durée max d'interruption admissible (ISO 22301)
    // Lot 4 (2026-09, D12) : SI supports — liste déclarative (ReCyF 1.1 « liste les systèmes
    // d'information supportant chaque activité »), miroir exact du traitement productTags.
    if (Array.isArray(clean.supportSystems)) {
      clean.supportSystems = clean.supportSystems.map((s) => String(s).trim()).filter(Boolean);
    } else if (typeof clean.supportSystems === "string") {
      clean.supportSystems = clean.supportSystems.split(",").map((s) => s.trim()).filter(Boolean);
    } else {
      clean.supportSystems = [];
    }
    clean.availability = toText(clean.availability);
    clean.availabilityNote = toText(clean.availabilityNote);
    clean.confidentiality = toText(clean.confidentiality);
    clean.integrity = toText(clean.integrity);
    clean.assetType = toText(clean.assetType);
    clean.importance = toText(clean.importance);
    clean.impact = toText(clean.impact);
    const dacpRaw = clean.dacpSensitive;
    if (typeof dacpRaw === "boolean") {
      clean.dacpSensitive = dacpRaw ? "oui" : "non";
    } else {
      const dacpText = toText(dacpRaw);
      clean.dacpSensitive = (dacpText || "non").toLowerCase() === "oui" ? "oui" : "non";
    }
    clean.mfaStatus = toText(clean.mfaStatus);
    clean.mfaDetail = toText(clean.mfaDetail);
    clean.comments = Array.isArray(clean.comments) ? clean.comments : [];
    return clean;
  }
  function normalizeCriticalAssets(list) {
    if (!Array.isArray(list)) return [];
    return list.map((entry, index) => normalizeCriticalAssetEntry(entry, index));
  }
  function normalizeCriticalAssetLinks(list) {
    if (!Array.isArray(list)) return;
    list.forEach((item) => {
      if (!item) return;
      if (Array.isArray(item.criticalAssetIds)) {
        item.criticalAssetIds = item.criticalAssetIds.map((id) => String(id || "").trim()).filter(Boolean);
      } else if (typeof item.criticalAssetIds === "string") {
        item.criticalAssetIds = item.criticalAssetIds.split(",").map((id) => id.trim()).filter(Boolean);
      } else {
        item.criticalAssetIds = [];
      }
    });
  }
  function ensureCriticalAssets() {
    appData.criticalAssets = normalizeCriticalAssets(appData.criticalAssets || []);
    appData.criticalAssetsMeta = normalizeCriticalAssetsMeta(appData.criticalAssetsMeta);
  }
  function getCriticalAssetLabel(asset) {
    if (!asset) return "";
    const code = asset.productCode ? asset.productCode.trim() : "";
    const name = asset.productName ? asset.productName.trim() : "";
    const beneficiary = asset.beneficiary ? asset.beneficiary.trim() : "";
    const parts = [];
    if (code) parts.push(code);
    if (name) parts.push(name);
    if (parts.length) return parts.join(" — ");
    if (beneficiary) return beneficiary;
    return asset.id || "";
  }
  function getCriticalAssetLookup() {
    return new Map((appData.criticalAssets || []).map((asset) => [asset.id, asset]));
  }
  function getCriticalAssetOptions(selectedIds = []) {
    const options = (appData.criticalAssets || []).map((asset) => ({
      value: asset.id,
      label: getCriticalAssetLabel(asset)
    }));
    const existing = new Set(options.map((opt) => opt.value));
    (Array.isArray(selectedIds) ? selectedIds : []).forEach((id) => {
      const clean = String(id || "").trim();
      if (clean && !existing.has(clean)) {
        options.push({ value: clean, label: clean });
        existing.add(clean);
      }
    });
    return options;
  }
  function buildCriticalAssetBadges(assetIds) {
    const ids = Array.isArray(assetIds) ? assetIds.filter(Boolean) : [];
    if (!ids.length) return "—";
    const lookup = getCriticalAssetLookup();
    return ids.map((id) => {
      const asset = lookup.get(id);
      const label = asset ? getCriticalAssetLabel(asset) : id;
      return `<a href="#" class="link-badge link-badge--asset critical-asset-link" data-asset-id="${escapeHtml(id)}" title="${t('criticalAssets.openAsset', 'Voir l\'actif')}">${escapeHtml(label || id)}</a>`;
    }).join(" ");
  }
  // Lot 0 actifs (2026-09, D7) : sous-ligne de badges dans la cellule titre existante (pas de
  // 8e colonne, standard ≤7 colonnes). À insérer comme SŒUR du div .cell-clamp, jamais dedans
  // (le line-clamp la tronquerait). Vide quand aucun actif lié.
  function buildCriticalAssetBadgesLine(assetIds) {
    const badges = buildCriticalAssetBadges(assetIds);
    return (!badges || badges === "—") ? "" : `<div class="asset-badges-line">${badges}</div>`;
  }
  function attachCriticalAssetLinkHandlers(container) {
    if (!container) return;
    container.querySelectorAll(".critical-asset-link").forEach((link) => {
      link.addEventListener("click", (event) => {
        event.preventDefault();
        event.stopPropagation();
        const assetId = link.dataset.assetId;
        switchModule("critical-assets");
        setTimeout(() => {
          openCriticalAssetModal(assetId);
        }, 200);
      });
    });
  }
  // Lot 0 actifs (2026-09, D15) : les défauts FR historiques persistés dans la donnée par
  // normalizeCriticalAssetsMeta sont localisés à l'AFFICHAGE uniquement (jamais réécrits) —
  // toute valeur personnalisée par l'utilisateur est affichée telle quelle.
  function _criticalAssetsMetaDisplay(value, i18nKey, knownDefaults) {
    if (!value) return "";
    return knownDefaults.indexOf(value) >= 0 ? t(i18nKey, value) : value;
  }
  const _CRITICAL_ASSETS_META_TITLE_DEFAULTS = ["Actifs critiques"];
  const _CRITICAL_ASSETS_META_SUBTITLE_DEFAULTS = [
    "Inventaire des actifs critiques et exigences de continuité.",
    "Services et processus critiques (valeurs métier) — inventaire et exigences de continuité."
  ];
  function renderCriticalAssetsMeta() {
    ensureCriticalAssets();
    const meta = appData.criticalAssetsMeta || getDefaultCriticalAssetsMeta();
    safeSetText("criticalAssetsSectionTitle",
      _criticalAssetsMetaDisplay(meta.sectionTitle, 'criticalAssets.title', _CRITICAL_ASSETS_META_TITLE_DEFAULTS) || t('criticalAssets.title', "Actifs critiques"));
    safeSetText("criticalAssetsTableTitle",
      _criticalAssetsMetaDisplay(meta.tableTitle, 'criticalAssets.title', _CRITICAL_ASSETS_META_TITLE_DEFAULTS) || t('criticalAssets.title', "Actifs critiques"));
    safeSetText("criticalAssetsSectionSubtitle",
      _criticalAssetsMetaDisplay(meta.subtitle, 'criticalAssets.subtitle', _CRITICAL_ASSETS_META_SUBTITLE_DEFAULTS));
    const metaLine = document.getElementById("criticalAssetsMetaLine");
    if (metaLine) metaLine.textContent = meta.metaLine || "";
  }
  function updateCriticalAssetsFilterOptions() {
    const beneficiaries = new Set();
    const availability = new Set();
    const impacts = new Set();
    (appData.criticalAssets || []).forEach((asset) => {
      if (asset.beneficiary) beneficiaries.add(asset.beneficiary);
      if (asset.availability) availability.add(asset.availability);
      if (asset.impact) impacts.add(asset.impact);
    });
    // Lot 0 actifs (2026-09, F14) : la VALEUR de filtre reste la donnée brute (le filtrage
    // compare asset.availability === valeur), seul le LIBELLÉ est résolu clé→texte.
    var toOpts = function(values, resolveOptions) {
      return Array.from(values).sort(SortUtils.text).map(function(v) {
        return { value: v, label: resolveOptions ? (resolveCriticalAssetLabel(v, resolveOptions) || v) : v };
      });
    };
    SectionToolbar.updateFilterOptions("critical-assets", "beneficiary", toOpts(beneficiaries));
    SectionToolbar.updateFilterOptions("critical-assets", "availability", toOpts(availability, CRITICAL_ASSET_AVAILABILITY_OPTIONS));
    SectionToolbar.updateFilterOptions("critical-assets", "impact", toOpts(impacts, CRITICAL_ASSET_IMPACT_OPTIONS));
  }
  function resetCriticalAssetsFilters() {
    if (typeof SectionToolbar !== 'undefined') {
      // Lot 0 actifs (2026-09) : align\u00e9 sur l'init i18n de r\u00e9f\u00e9rence (cf. bloc FIX-I18N-TOOLBARS)
      SectionToolbar.init("critical-assets", {
        search: true,
        filters: [
          { key: "beneficiary", label: t('criticalAssets.filterBeneficiary', 'B\u00e9n\u00e9ficiaire'), options: [] },
          { key: "availability", label: t('criticalAssets.filterAvailability', 'Disponibilit\u00e9'), options: [] },
          { key: "impact", label: t('criticalAssets.filterImpact', 'Impact'), options: [] }
        ],
        sort: {
          options: [
            { key: "priority", label: t('sort.priority', 'Priorit\u00e9'), default: true, dir: "asc" },
            { key: "name", label: t('sort.name', 'Nom') }
          ]
        },
        onUpdate: function() { loadCriticalAssetsTable(); }
      });
    }
    loadCriticalAssetsTable();
  }
  function getAssetTagClass(tag) {
    const lower = String(tag || "").toLowerCase();
    if (lower.includes("obsol")) return "asset-tag asset-tag--alert";
    return "asset-tag";
  }
  function loadCriticalAssetsTable() {
    const tbody = document.getElementById("criticalAssetsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('critical-assets')) {
      // FIX-REVUE ACTIFS (2026-09) : en vue consolidée, l'encart calculé sur la partition
      // locale serait mensonger — on le masque au lieu de le laisser figé.
      const _cl = document.getElementById("criticalAssetsComplianceLine");
      if (_cl) { _cl.hidden = true; _cl.textContent = ""; }
      return;
    }
    ensureCriticalAssets();
    renderCriticalAssetsMeta();
    updateCriticalAssetsFilterOptions();
    // FIX-REVUE ACTIFS (2026-09) : couverture + encart calculés AVANT l'early-return de
    // l'état vide — sinon l'encart gardait les chiffres de la partition précédente
    // (bascule d'entité) ou d'un inventaire supprimé.
    const _assetAnalysisCoverage = {};
    (appData.riskAnalyses || []).forEach((an) => {
      if (!an || !Array.isArray(an.criticalAssetIds)) return;
      an.criticalAssetIds.forEach((id) => { _assetAnalysisCoverage[id] = (_assetAnalysisCoverage[id] || 0) + 1; });
    });
    renderCriticalAssetsComplianceLine(_assetAnalysisCoverage);
    const _f = SectionToolbar.getFilters("critical-assets");
    const _q = (SectionToolbar.getSearch("critical-assets") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const beneficiaryFilter = _f.beneficiary || "";
    const availabilityFilter = _f.availability || "";
    const impactFilter = _f.impact || "";
    const filtered = (appData.criticalAssets || []).filter((asset) => {
      if (beneficiaryFilter && asset.beneficiary !== beneficiaryFilter) return false;
      if (availabilityFilter && asset.availability !== availabilityFilter) return false;
      if (impactFilter && asset.impact !== impactFilter) return false;
      if (_q && ((asset.productName || "") + " " + (asset.productCode || "") + " " + (asset.beneficiary || "")).toLowerCase().indexOf(_q) < 0) return false;
      return true;
    });
    const _s = SectionToolbar.getSort("critical-assets");
    const _sortKey = _s ? _s.key : "priority";
    const _sortDir = _s ? _s.dir : "asc";
    const sorted = filtered.slice().sort((a, b) => {
      let diff = _sortKey === "name" ? SortUtils.text(a.productName, b.productName) : SortUtils.numeric(a.priority, b.priority);
      return _sortDir === "desc" ? -diff : diff;
    });
    tbody.innerHTML = "";
    if (!sorted.length) {
      const row = document.createElement("tr");
      row.innerHTML = `<td colspan="9"><div class="nis2-empty"></div></td>`;
      row.querySelector(".nis2-empty").textContent = t('emptyStates.noCriticalAssets', 'Aucun actif critique');
      tbody.appendChild(row);
      return;
    }
    sorted.forEach((asset, index) => {
      const row = document.createElement("tr");
      const tagsHtml = (asset.productTags || []).map((tag) => `<span class="${getAssetTagClass(tag)}">${escapeHtml(tag)}</span>`).join(" ");
      const _covCount = _assetAnalysisCoverage[asset.id] || 0;
      const coverageHtml = _covCount
        ? `<div class="asset-product__coverage">${escapeHtml(t('criticalAssets.coveredByAnalyses', 'Couvert par {n} analyse(s) de risques').replace('{n}', _covCount))}</div>`
        : "";
      const productHtml = `
        <div class="asset-product">
          ${tagsHtml ? `<div class="asset-product__tags">${tagsHtml}</div>` : ""}
          <div class="asset-product__code">${escapeHtml(asset.productCode || "")}</div>
          <div class="asset-product__name">${escapeHtml(asset.productName || "")}</div>
          ${coverageHtml}
        </div>
      `;
      // Lot 1a (refonte UX) : availabilityNote n'est plus affichée en liste (reste éditable en modale)
      const availabilityHtml = `
        <div>${escapeHtml(resolveCriticalAssetLabel(asset.availability, CRITICAL_ASSET_AVAILABILITY_OPTIONS) || "")}</div>
      `;
      const assetTypeHtml = asset.assetType ? `<span class="asset-tag">${escapeHtml(resolveCriticalAssetLabel(asset.assetType, CRITICAL_ASSET_TYPE_OPTIONS))}</span>` : "—";
      const impactHtml = asset.impact ? `<span class="asset-tag asset-tag--impact">${escapeHtml(resolveCriticalAssetLabel(asset.impact, CRITICAL_ASSET_IMPACT_OPTIONS))}</span>` : "—";
      const dacpValue = resolveCriticalAssetLabel(asset.dacpSensitive, CRITICAL_ASSET_DACP_OPTIONS) || "";
      // FIX-DACP-BADGE (audit 2026-06-10) : normalizeCriticalAssetEntry stocke toujours
      // 'oui'/'non' en minuscules — la comparaison stricte à "Oui" n'était jamais vraie.
      // Comparaison insensible à la casse, valeurs FR ('oui') et EN ('yes') acceptées.
      const _dacpNorm = String(dacpValue).trim().toLowerCase();
      const dacpClass = (_dacpNorm === "oui" || _dacpNorm === "yes") ? "asset-tag asset-tag--yes" : "asset-tag asset-tag--no";
      const dacpHtml = dacpValue ? `<span class="${dacpClass}">${escapeHtml(dacpValue)}</span>` : "—";
      const mfaStatus = resolveCriticalAssetLabel(asset.mfaStatus, CRITICAL_ASSET_MFA_STATUS_OPTIONS) || "";
      let mfaStatusClass = "asset-tag";
      if (mfaStatus) {
        // La couleur du badge se calcule sur la valeur BRUTE stockée (clé 'partiel' /
        // 'nonImplemente', neutre en langue) — PAS sur le libellé résolu, sinon en anglais
        // ('Partial' / 'Not implemented') la comparaison sur sous-chaînes FR échouerait.
        const lower = String(asset.mfaStatus || "").toLowerCase();
        mfaStatusClass = lower.includes("non") || lower.includes("partiel") ? "asset-tag asset-tag--mfa" : "asset-tag asset-tag--yes";
      }
      // Lot 1a (refonte UX) : mfaDetail n'est plus affiché en liste (reste éditable en modale)
      const mfaStatusHtml = mfaStatus ? `<span class="${mfaStatusClass}">${escapeHtml(mfaStatus)}</span>` : "";
      const mfaHtml = mfaStatusHtml
        ? `<div class="asset-mfa">${mfaStatusHtml}</div>`
        : "—";
      // Lot 1a (refonte UX) : RTO et RPO fusionnés en une colonne (deux pills empilées)
      // Lot 3 (2026-09, A9) : paliers résolus clé→libellé ; les valeurs legacy libres
      // (« 2 h ») passent inchangées (contrat resolveCriticalAssetLabel).
      const _rtoLabel = resolveCriticalAssetLabel(asset.rto, CRITICAL_ASSET_RTO_OPTIONS);
      const _rpoLabel = resolveCriticalAssetLabel(asset.rpo, CRITICAL_ASSET_RPO_OPTIONS);
      const rtoRpoHtml = (asset.rto || asset.rpo)
        ? `<div class="asset-rto-rpo">${asset.rto ? `<span class="asset-pill">${escapeHtml(_rtoLabel)}</span>` : "—"} / ${asset.rpo ? `<span class="asset-pill">${escapeHtml(_rpoLabel)}</span>` : "—"}</div>`
        : "—";
      row.innerHTML = `
        <td><span class="asset-priority">${escapeHtml(asset.priority || index + 1)}</span></td>
        <td>${escapeHtml(asset.beneficiary || "")}</td>
        <td>${productHtml}</td>
        <td>${assetTypeHtml}</td>
        <td>${rtoRpoHtml}</td>
        <td>${availabilityHtml}</td>
        <td>${impactHtml}</td>
        <td>${dacpHtml}</td>
        <td>${mfaHtml}</td>
      `;
      row.classList.add("row-clickable");
      row.addEventListener("click", () => {
        openCriticalAssetModal(asset.id);
      });
      tbody.appendChild(row);
    });
  }
  // Lot 4 actifs (2026-09, D11) : encart de conformité en tête de module — calcul client,
  // lecture seule, AUCUN renderer de référentiel touché (les encarts dans les mesures
  // ID.AM/ISO 5.9/ReCyF restent différés, règle des 3 demandes). Chiffres sur le dataset
  // COMPLET (pas la vue filtrée). textContent uniquement.
  function renderCriticalAssetsComplianceLine(coverageMap) {
    const el = document.getElementById("criticalAssetsComplianceLine");
    if (!el) return;
    const assets = appData.criticalAssets || [];
    if (!assets.length) { el.hidden = true; el.textContent = ""; return; }
    const withOwner = assets.filter((a) => a && a.owner).length;
    const covered = assets.filter((a) => a && coverageMap[a.id]).length;
    let oldestReview = "";
    assets.forEach((a) => {
      if (a && a.lastReviewDate && (!oldestReview || a.lastReviewDate < oldestReview)) oldestReview = a.lastReviewDate;
    });
    const parts = [
      t('criticalAssets.compliance.total', '{n} actif(s)').replace('{n}', assets.length),
      t('criticalAssets.compliance.withOwner', '{n} avec propriétaire').replace('{n}', withOwner),
      t('criticalAssets.compliance.covered', '{n} couvert(s) par une analyse de risques').replace('{n}', covered)
    ];
    if (oldestReview) parts.push(t('criticalAssets.compliance.oldestReview', 'plus ancienne revue : {d}').replace('{d}', oldestReview));
    el.textContent = parts.join(' · ') + ' — ' + t('criticalAssets.compliance.refs', 'contribue à CyFun ID.AM · ISO 27002 5.9–5.12 · ReCyF obj. 1');
    el.hidden = false;
  }
  function openCriticalAssetsTitlesModal() {
    ensureCriticalAssets();
    const meta = appData.criticalAssetsMeta || getDefaultCriticalAssetsMeta();
    openModal(t('modals.renameSection'), [
      { name: "sectionTitle", label: t('criticalAssets.renameFields.sectionTitle', 'Titre de la section'), value: meta.sectionTitle },
      { name: "tableTitle", label: t('criticalAssets.renameFields.tableTitle', 'Titre du tableau'), value: meta.tableTitle },
      { name: "subtitle", label: t('criticalAssets.renameFields.subtitle', 'Sous-titre'), value: meta.subtitle },
      { name: "metaLine", label: t('criticalAssets.renameFields.metaLine', 'Ligne d\'information'), value: meta.metaLine }
    ], (data) => {
      appData.criticalAssetsMeta = normalizeCriticalAssetsMeta(data);
      saveData();
      renderCriticalAssetsMeta();
      return true;
    });
  }
  function ensureSelectOptions(options, value) {
    if (!value) return options;
    return options.some((opt) => opt.value === value)
      ? options
      : [...options, { value, label: value }];
  }
  // Lot 1b actifs (2026-09, D5/A7) : variante « (valeur actuelle) » — la valeur legacy est
  // conservée telle quelle, affichée avec son libellé résolu + suffixe (pattern rôles
  // parties prenantes, clé stakeholderRoles.currentValue). Ajoutée en FIN de liste. La
  // bascule vers la nouvelle échelle est un choix explicite de l'utilisateur à l'édition.
  function ensureSelectOptionsCurrent(options, value, resolveOptions) {
    if (!value) return options;
    if (options.some((opt) => opt.value === value)) return options;
    const resolved = resolveCriticalAssetLabel(value, resolveOptions || options) || value;
    return [...options, { value, label: resolved + ' ' + t('stakeholderRoles.currentValue', '(valeur actuelle)') }];
  }
  // Lot 3 actifs (2026-09) : option vide en tête des selects OPTIONNELS — sans elle, le
  // navigateur pré-sélectionne la 1re option et la sauvegarde MUTAIT silencieusement une
  // valeur vide (latent avant, aggravé par les paliers RTO/RPO). dacpSensitive exclu
  // (toujours normalisé oui/non).
  function withEmptyOption(options) {
    return [{ value: "", label: "—" }, ...options];
  }
  function openCriticalAssetModal(assetId = "") {
    ensureCriticalAssets();
    const asset = (appData.criticalAssets || []).find((a) => a.id === assetId);
    const isEdit = !!asset;
    const current = asset || normalizeCriticalAssetEntry({}, (appData.criticalAssets || []).length);
    const availabilityOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_AVAILABILITY_OPTIONS, current.availability));
    const confOptions = withEmptyOption(ensureSelectOptionsCurrent(CRITICAL_ASSET_CONF_OPTIONS, current.confidentiality, CRITICAL_ASSET_CONF_RESOLVE_OPTIONS));
    const integrityOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_INTEGRITY_OPTIONS, current.integrity));
    const assetTypeOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_TYPE_OPTIONS, current.assetType));
    const importanceOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_IMPORTANCE_OPTIONS, current.importance));
    const impactOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_IMPACT_OPTIONS, current.impact));
    const mfaOptions = withEmptyOption(ensureSelectOptions(CRITICAL_ASSET_MFA_STATUS_OPTIONS, current.mfaStatus));
    const dacpOptions = ensureSelectOptions(CRITICAL_ASSET_DACP_OPTIONS, current.dacpSensitive);
    const rtoOptions = withEmptyOption(ensureSelectOptionsCurrent(CRITICAL_ASSET_RTO_OPTIONS, current.rto));
    const rpoOptions = withEmptyOption(ensureSelectOptionsCurrent(CRITICAL_ASSET_RPO_OPTIONS, current.rpo));
    const mtpdOptions = withEmptyOption(ensureSelectOptionsCurrent(CRITICAL_ASSET_RTO_OPTIONS, current.mtpd));
    openModal(isEdit ? t('modals.editCriticalAsset') : t('modals.addCriticalAsset'), [
      { name: "priority", label: t('criticalAssets.form.priority', 'Priorité'), type: "number", value: current.priority, help: t('criticalAssets.form.priorityHelp', 'Ordre de reprise — tri à l\'intérieur d\'une même classe de reprise.') },
      { name: "owner", label: t('criticalAssets.form.owner', 'Propriétaire métier'), type: "text", value: current.owner, placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(current.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "beneficiary", label: t('criticalAssets.form.beneficiary', 'Bénéficiaires / usagers du service'), value: current.beneficiary },
      { name: "productCode", label: t('criticalAssets.form.productCode', 'Produit (code)'), value: current.productCode },
      { name: "productName", label: t('criticalAssets.form.productName', 'Produit (nom)'), value: current.productName },
      { name: "productTags", label: t('criticalAssets.form.productTags', 'Tags produit (séparés par des virgules)'), value: (current.productTags || []).join(", ") },
      { name: "supportSystems", label: t('criticalAssets.form.supportSystems', 'Systèmes d\'information supports (séparés par des virgules)'), value: (current.supportSystems || []).join(", "), help: t('criticalAssets.form.supportSystemsHelp', 'Les SI qui portent ce service (ReCyF obj. 1) — liste libre, séparée par des virgules.') },
      { name: "assetType", label: t('fields.assetType'), type: "select", value: current.assetType, options: assetTypeOptions },
      { name: "importance", label: t('fields.importance'), type: "select", value: current.importance, options: importanceOptions },
      { name: "mtpd", label: t('criticalAssets.form.mtpd', 'MTPD (interruption max admissible)'), type: "select", value: current.mtpd, options: mtpdOptions },
      { name: "rto", label: "RTO", type: "select", value: current.rto, options: rtoOptions },
      { name: "rpo", label: "RPO", type: "select", value: current.rpo, options: rpoOptions },
      { name: "availability", label: t('criticalAssets.form.availability', 'Classe de reprise'), type: "select", value: current.availability, options: availabilityOptions },
      { name: "availabilityNote", label: t('criticalAssets.form.availabilityNote', 'Note disponibilité'), value: current.availabilityNote },
      { name: "confidentiality", label: t('criticalAssets.form.confidentiality', 'Classification de l\'information'), type: "select", value: current.confidentiality, options: confOptions },
      { name: "integrity", label: t('fields.integrity'), type: "select", value: current.integrity, options: integrityOptions },
      { name: "impact", label: t('fields.impact'), type: "select", value: current.impact, options: impactOptions },
      { name: "dacpSensitive", label: t('criticalAssets.form.dacpSensitive', 'DACP sensible'), type: "select", value: current.dacpSensitive, options: dacpOptions },
      { name: "mfaStatus", label: t('criticalAssets.form.mfaStatus', 'Obligation MFA'), type: "select", value: current.mfaStatus, options: mfaOptions },
      { name: "mfaDetail", label: t('criticalAssets.form.mfaDetail', 'Détail MFA'), value: current.mfaDetail },
      { name: "lastReviewDate", label: t('criticalAssets.form.lastReviewDate', 'Dernière revue'), type: "date", value: current.lastReviewDate }
    ], (data) => {
      const clean = normalizeCriticalAssetEntry({
        id: current.id,
        priority: data.priority || current.priority,
        owner: data.owner,
        lastReviewDate: data.lastReviewDate,
        beneficiary: data.beneficiary,
        productCode: data.productCode,
        productName: data.productName,
        productTags: data.productTags,
        supportSystems: data.supportSystems,
        mtpd: data.mtpd,
        rto: data.rto,
        rpo: data.rpo,
        availability: data.availability,
        availabilityNote: data.availabilityNote,
        confidentiality: data.confidentiality,
        integrity: data.integrity,
        assetType: data.assetType,
        importance: data.importance,
        impact: data.impact,
        dacpSensitive: data.dacpSensitive,
        mfaStatus: data.mfaStatus,
        mfaDetail: data.mfaDetail,
        // FIX-REVUE ACTIFS (2026-09) : sans cette ligne, normalizeCriticalAssetEntry posait
        // comments:[] et le spread-merge ÉCRASAIT les commentaires existants à chaque
        // sauvegarde de la modale (bug dormant — perte silencieuse sur données importées).
        comments: current.comments
      }, 0);
      if (isEdit) {
        const idx = appData.criticalAssets.findIndex((a) => a.id === assetId);
        if (idx >= 0) appData.criticalAssets[idx] = { ...appData.criticalAssets[idx], ...clean };
      } else {
        appData.criticalAssets.push(clean);
      }
      ensureCriticalAssets();
            addAuditEntry(isEdit ? 'edit' : 'add', 'criticalAssets', clean.productName);
      saveData();
      loadCriticalAssetsTable();
      loadActionsTable();
      loadRisksTable();
      loadThreatsTable();
      loadNonConformitiesTable();
      return true;
    }, isEdit ? () => deleteCriticalAsset(assetId) : null);
    attachCriticalAssetCoherenceHints();
  }
  // Lot 3 actifs (2026-09, A8) : cohérence INFORMATIVE (jamais bloquante) entre classe de
  // reprise, RTO et MTPD — calculée uniquement sur les paliers normalisés ; une valeur
  // legacy libre (« 2 h ») ou vide n'émet jamais d'avertissement. Pattern post-openModal
  // (précédent : listeners d'editRisk).
  function attachCriticalAssetCoherenceHints() {
    const rtoEl = document.getElementById("modal-input-rto");
    const rpoEl = document.getElementById("modal-input-rpo");
    const mtpdEl = document.getElementById("modal-input-mtpd");
    const availEl = document.getElementById("modal-input-availability");
    if (!rtoEl || !mtpdEl || !availEl) return;
    let hint = document.getElementById("criticalAssetCoherenceHint");
    if (!hint) {
      hint = document.createElement("div");
      hint.id = "criticalAssetCoherenceHint";
      hint.className = "form-hint asset-coherence-hint";
      // Les champs de modale sont appendus À PLAT dans modalBody (label puis input) :
      // insertion juste après le select RTO.
      if (rtoEl.parentNode) rtoEl.parentNode.insertBefore(hint, rtoEl.nextSibling);
    }
    // Rang de palier RTO maximal indicatif par classe de reprise (niveau 4 : sans plafond).
    const classMaxRank = { level1: 2, level2: 3, level3: 4, level4: 6 };
    const refresh = () => {
      const msgs = [];
      const rtoRank = CRITICAL_ASSET_RTO_RANK[rtoEl.value];
      const mtpdRank = CRITICAL_ASSET_RTO_RANK[mtpdEl.value];
      if (rtoRank && mtpdRank && rtoRank > mtpdRank) {
        msgs.push(t('criticalAssets.hintRtoMtpd', 'RTO supérieur au MTPD : l\'objectif de reprise dépasse l\'interruption maximale admissible.'));
      }
      const maxRank = classMaxRank[availEl.value];
      if (rtoRank && maxRank && rtoRank > maxRank) {
        msgs.push(t('criticalAssets.hintClassRto', 'RTO inhabituel pour cette classe de reprise — vérifiez la cohérence.'));
      }
      hint.textContent = msgs.join(" ");
      hint.style.display = msgs.length ? "" : "none";
    };
    [rtoEl, rpoEl, mtpdEl, availEl].forEach((el) => { if (el) el.addEventListener("change", refresh); });
    refresh();
  }
  function deleteCriticalAsset(assetId) {
    showConfirmModal(t('confirms.deleteAsset'), t('confirms.deleteAsset'), function() {
      var _asset = (appData.criticalAssets || []).find(function(a) { return a.id === assetId; });
      appData.criticalAssets = (appData.criticalAssets || []).filter((asset) => asset.id !== assetId);
      if (Array.isArray(appData.actions)) {
        appData.actions.forEach((action) => {
          action.criticalAssetIds = (action.criticalAssetIds || []).filter((id) => id !== assetId);
        });
      }
      if (Array.isArray(appData.risks)) {
        appData.risks.forEach((risk) => {
          risk.criticalAssetIds = (risk.criticalAssetIds || []).filter((id) => id !== assetId);
        });
      }
      if (Array.isArray(appData.threats)) {
        appData.threats.forEach((threat) => {
          threat.criticalAssetIds = (threat.criticalAssetIds || []).filter((id) => id !== assetId);
        });
      }
      if (Array.isArray(appData.nonconformities)) {
        appData.nonconformities.forEach((nc) => {
          nc.criticalAssetIds = (nc.criticalAssetIds || []).filter((id) => id !== assetId);
        });
      }
      ensureCriticalAssets();
            addAuditEntry('delete', 'criticalAssets', _asset ? _asset.productName : assetId);
      saveData();
      _csiPersistAffectedTabs(['criticalAssets', 'actions', 'risks', 'threats', 'nonconformities']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      // === RA-ACTIFS (2026-09, D9) === les analyses de risques (grappe « analyse ») ne sont
      // JAMAIS nettoyées en cascade depuis ici (grappe « cœur » — cascade inter-grappes
      // interdite) : leurs ids orphelins sont tolérés et filtrés au rendu. Toast informatif
      // en LECTURE seule pour prévenir l'utilisateur.
      var _linkedAnalyses = (appData.riskAnalyses || []).filter(function(an) {
        return an && Array.isArray(an.criticalAssetIds) && an.criticalAssetIds.indexOf(assetId) >= 0;
      }).length;
      if (_linkedAnalyses > 0) {
        showToast2(t('criticalAssets.deleteLinkedAnalyses', 'Cet actif était référencé par {n} analyse(s) de risques ; ces liens seront ignorés.').replace('{n}', _linkedAnalyses), 'info');
      }
      loadCriticalAssetsTable();
      loadActionsTable();
      loadRisksTable();
      loadThreatsTable();
      loadNonConformitiesTable();
      if (typeof updateDashboard === 'function') updateDashboard();
      showToast2(t('toasts.itemDeleted'), "success");
    }, { type: 'danger' });
  }
  window.loadCriticalAssetsTable = loadCriticalAssetsTable;
  window.openCriticalAssetModal = openCriticalAssetModal;

  // modules/project-risks.js
  function getProjectRiskStatusOptions() {
    return [
      { value: "red", label: t('projectRiskStatuses.red') },
      { value: "yellow", label: t('projectRiskStatuses.yellow') },
      { value: "green", label: t('projectRiskStatuses.green') },
      { value: "todo", label: t('projectRiskStatuses.todo') },
      { value: "suspended", label: t('projectRiskStatuses.suspended') }
    ];
  }
  let PROJECT_RISK_STATUS_OPTIONS = getProjectRiskStatusOptions();
  function getProjectRiskStatusLabels() {
    return {
      red: t('projectRiskStatuses.red'),
      yellow: t('projectRiskStatuses.yellow'),
      green: t('projectRiskStatuses.green'),
      todo: t('projectRiskStatuses.todo'),
      suspended: t('projectRiskStatuses.suspended')
    };
  }
  let PROJECT_RISK_STATUS_LABELS = getProjectRiskStatusLabels();
  const PROJECT_RISK_STATUS_BADGES = {
    red: "status-badge--rouge",
    yellow: "status-badge--jaune",
    green: "status-badge--vert",
    todo: "status-badge--a_realiser",
    suspended: "status-badge--suspendue"
  };
  function normalizeProjectRiskStatus(status) {
    const raw = NORMALIZE(String(status || "").trim()).replace(/[_-]+/g, " ");
    if (["rouge", "red"].includes(raw)) return "red";
    if (["jaune", "yellow"].includes(raw)) return "yellow";
    if (["vert", "green"].includes(raw)) return "green";
    if (["a realiser", "todo"].includes(raw)) return "todo";
    if (["suspendue", "suspendu", "suspended"].includes(raw)) return "suspended";
    return "todo";
  }
  function normalizeProjectRiskRecommendations(list) {
    if (!Array.isArray(list)) return [];
    return list.map((rec) => {
      const clean = typeof rec === "object" && rec ? { ...rec } : {};
      if (!clean.id) clean.id = generateId("proj-reco");
      clean.title = typeof clean.title === "string" ? clean.title.trim() : "";
      clean.text = typeof clean.text === "string" ? clean.text.trim() : "";
      clean.implemented = !!clean.implemented;
      return clean;
    }).filter((rec) => rec.title || rec.text);
  }
  function normalizeProjectRiskEntry(entry = {}, index = 0) {
    const clean = typeof entry === "object" && entry ? { ...entry } : {};
    if (!clean.id) clean.id = generateId("proj-risk");
    const toText = (value) => typeof value === "string" ? value.trim() : "";
    clean.projectName = toText(clean.projectName);
    clean.beneficiary = toText(clean.beneficiary);
    clean.reportNumber = toText(clean.reportNumber);
    const revision = parseInt(clean.revisionNumber, 10);
    clean.revisionNumber = Number.isFinite(revision) ? revision : 0;
    clean.description = toText(clean.description);
    clean.analysisLink = toText(clean.analysisLink);
    clean.completionDate = toText(clean.completionDate);
    clean.status = normalizeProjectRiskStatus(clean.status);
    clean.recommendations = normalizeProjectRiskRecommendations(clean.recommendations);
    clean.sourceAnalysisId = clean.sourceAnalysisId || null;
    // Ignorer les tags importés — on ne conserve que le champ priority
    delete clean.tags;
    // AUDIT-H3: Migrer les priorités legacy (française) avant validation.
    // Avant: sans le module legacy-import, les priorités 'haute'/'normale'/'basse'/'critique'
    // n'étaient pas dans la whitelist et devenaient '' (chaîne vide) → perte de données.
    var _prioMigration = { 'normale': 'medium', 'haute': 'high', 'basse': 'low', 'critique': 'critical' };
    if (clean.priority && _prioMigration[clean.priority]) {
      clean.priority = _prioMigration[clean.priority];
    }
    var validPriorities = ['high', 'medium', 'low', 'critical'];
    clean.priority = validPriorities.indexOf(clean.priority) >= 0 ? clean.priority : '';
    clean.note = typeof clean.note === 'string' ? clean.note.trim() : '';
    clean.comments = Array.isArray(clean.comments) ? clean.comments : [];
    return clean;
  }
  function normalizeProjectRisks(list) {
    if (!Array.isArray(list)) return [];
    return list.map((entry, index) => normalizeProjectRiskEntry(entry, index));
  }
  function ensureProjectRisks() {
    appData.projectRisks = normalizeProjectRisks(appData.projectRisks || []);
  }
  function updateProjectRiskFilterOptions() {
    // Toolbar v2 : le filtre Bénéficiaire (dynamique) vit dans la SectionToolbar.
    if (typeof SectionToolbar === 'undefined' || !SectionToolbar.updateFilterOptions) return;
    const beneficiaries = new Set();
    (appData.projectRisks || []).forEach((risk) => {
      if (risk.beneficiary) beneficiaries.add(risk.beneficiary);
    });
    const options = Array.from(beneficiaries).sort(SortUtils.text).map((value) => ({ value, label: value }));
    SectionToolbar.updateFilterOptions("project-risks", "beneficiary", options);
  }
  var PROJECT_RISK_STATUS_ICONS = { green: 'fa-check', yellow: 'fa-exclamation', red: 'fa-xmark', todo: 'fa-circle', suspended: 'fa-pause' };
  function getProjectRiskStatusMeta(status) {
    const key = normalizeProjectRiskStatus(status);
    return {
      key,
      label: PROJECT_RISK_STATUS_LABELS[key] || key,
      badge: PROJECT_RISK_STATUS_BADGES[key] || "status-badge",
      icon: PROJECT_RISK_STATUS_ICONS[key] || 'fa-circle'
    };
  }
  function buildProjectRiskRecommendationRow(rec = {}, options = {}) {
    const row = document.createElement("div");
    row.className = "project-risk-reco";
    row.dataset.recoId = rec.id || generateId("proj-reco");
    row.innerHTML = `
      <div class="project-risk-reco__row">
        <label class="modal__label">${t('projectRisks.recoTitleLabel')}</label>
        <input type="text" class="form-control project-risk-reco__title" value="${escapeHtml(rec.title || "")}" data-readonly-lock="true">
      </div>
      <div class="project-risk-reco__row">
        <label class="modal__label">${t('projectRisks.recoTextLabel')}</label>
        <textarea class="form-control project-risk-reco__text" data-readonly-lock="true">${escapeHtml(rec.text || "")}</textarea>
      </div>
      <div class="project-risk-reco__meta">
        <label class="project-risk-reco__check">
          <input type="checkbox" class="project-risk-reco__implemented" ${rec.implemented ? "checked" : ""} data-readonly-lock="true">
          ${t('projectRisks.recoImplemented')}
        </label>
        <button type="button" class="btn btn--outline btn--sm project-risk-reco__remove" data-readonly-lock="true">
          <i class="fas fa-trash"></i> ${t('projectRisks.recoRemove')}
        </button>
      </div>
    `;
    const removeBtn = row.querySelector(".project-risk-reco__remove");
    if (options.readOnly && removeBtn) {
      removeBtn.disabled = true;
    } else if (removeBtn) {
      removeBtn.addEventListener("click", () => {
        row.remove();
        if (typeof options.onRemove === "function") options.onRemove();
      });
    }
    return row;
  }
  function renderProjectRiskRecommendations(container, recommendations = []) {
    const section = document.createElement("div");
    section.className = "project-risk-recos";
    section.innerHTML = `
      <div class="project-risk-recos__header">
        <h4 class="project-risk-recos__title">${t('projectRisks.recoTitle')}</h4>
        <button type="button" class="btn btn--outline btn--sm project-risk-recos__add" data-readonly-lock="true">
          <i class="fas fa-plus"></i> ${t('projectRisks.recoAdd')}
        </button>
      </div>
      <div class="project-risk-recos__list"></div>
      <p class="project-risk-recos__empty">${t('projectRisks.recoEmpty')}</p>
    `;
    const list = section.querySelector(".project-risk-recos__list");
    const empty = section.querySelector(".project-risk-recos__empty");
    const addBtn = section.querySelector(".project-risk-recos__add");
    const readOnly = typeof isReadOnlyMode === "function" && isReadOnlyMode();
    const updateEmpty = () => {
      if (empty) empty.hidden = list.children.length > 0;
    };
    const addRow = (rec) => {
      list.appendChild(buildProjectRiskRecommendationRow(rec, { readOnly, onRemove: updateEmpty }));
      updateEmpty();
    };
    recommendations.forEach(addRow);
    updateEmpty();
    if (addBtn) {
      if (readOnly) {
        addBtn.disabled = true;
      } else {
        addBtn.addEventListener("click", () => addRow({}));
      }
    }
    container.appendChild(section);
    refreshReadOnlyLocks();
    return section;
  }
  function collectProjectRiskRecommendations(section) {
    if (!section) return [];
    const rows = section.querySelectorAll(".project-risk-reco");
    const recommendations = [];
    rows.forEach((row) => {
      const title = row.querySelector(".project-risk-reco__title")?.value.trim() || "";
      const text = row.querySelector(".project-risk-reco__text")?.value.trim() || "";
      const implemented = !!row.querySelector(".project-risk-reco__implemented")?.checked;
      if (!title && !text) return;
      recommendations.push({
        id: row.dataset.recoId || generateId("proj-reco"),
        title,
        text,
        implemented
      });
    });
    return recommendations;
  }
  function updateProjectRisksKpi() {
    const risks = appData.projectRisks || [];
    const total = risks.length;
    // Completed = any analysis with a traffic light (green, yellow, red)
    const completedStatuses = ["green", "yellow", "red"];
    const done = risks.filter(r => completedStatuses.includes(r.status)).length;
    // Per-status counts
    const statusCounts = { red: 0, yellow: 0, green: 0, todo: 0, suspended: 0 };
    risks.forEach(r => {
      const s = r.status || "todo";
      if (statusCounts.hasOwnProperty(s)) statusCounts[s]++;
    });
    let totalRecos = 0, implRecos = 0;
    risks.forEach(r => {
      (r.recommendations || []).forEach(rec => {
        totalRecos++;
        if (rec.implemented) implRecos++;
      });
    });
    const analysesDoneEl = document.getElementById("prKpiAnalysesDone");
    const analysesTotalEl = document.getElementById("prKpiAnalysesTotal");
    const analysesBar = document.getElementById("prKpiAnalysesBar");
    if (analysesDoneEl) analysesDoneEl.textContent = done;
    if (analysesTotalEl) analysesTotalEl.textContent = total;
    if (analysesBar) analysesBar.style.width = total ? `${(done / total * 100)}%` : "0%";
    const recosImplEl = document.getElementById("prKpiRecosImpl");
    const recosTotalEl = document.getElementById("prKpiRecosTotal");
    const recosBar = document.getElementById("prKpiRecosBar");
    if (recosImplEl) recosImplEl.textContent = implRecos;
    if (recosTotalEl) recosTotalEl.textContent = totalRecos;
    if (recosBar) recosBar.style.width = totalRecos ? `${(implRecos / totalRecos * 100)}%` : "0%";
    // Status breakdown badges
    const breakdownEl = document.getElementById("prKpiStatusBreakdown");
    if (breakdownEl) {
      const statusConfig = [
        { key: "green",     icon: "fa-check",        cssClass: "pr-status-count--green" },
        { key: "yellow",    icon: "fa-exclamation",  cssClass: "pr-status-count--yellow" },
        { key: "red",       icon: "fa-xmark",        cssClass: "pr-status-count--red" },
        { key: "todo",      icon: "fa-circle",       cssClass: "pr-status-count--todo" },
        { key: "suspended", icon: "fa-pause",        cssClass: "pr-status-count--suspended" }
      ];
      breakdownEl.innerHTML = statusConfig
        .filter(s => statusCounts[s.key] > 0)
        .map(s => {
          const label = t(`projectRiskStatuses.${s.key}`);
          return `<span class="pr-status-count ${s.cssClass}" title="${label}"><i class="fas ${s.icon}"></i> ${statusCounts[s.key]}</span>`;
        }).join("");
    }
  }
  var _prClickDelegate = null;
  function loadProjectRisksTable() {
    const tbody = document.getElementById("projectRisksTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('project-risks')) return;
    // Remove previous delegated click handler to avoid accumulation
    if (_prClickDelegate) {
      tbody.removeEventListener("click", _prClickDelegate);
      _prClickDelegate = null;
    }
    ensureProjectRisks();
    updateProjectRiskFilterOptions();
    tbody.innerHTML = "";
    // Toolbar v2 : recherche + filtres lus depuis la SectionToolbar ('' = tous)
    const _prf = SectionToolbar.getFilters("project-risks");
    const searchQuery = NORMALIZE((SectionToolbar.getSearch("project-risks") || "").trim());
    const beneficiaryFilter = _prf.beneficiary || "";
    const statusFilter = _prf.status || "";
    const pendingOnly = _prf.pending === "pending";
    const filtered = (appData.projectRisks || []).filter((risk) => {
      if (beneficiaryFilter && risk.beneficiary !== beneficiaryFilter) return false;
      if (statusFilter && normalizeProjectRiskStatus(risk.status) !== statusFilter) return false;
      if (pendingOnly) {
        const recs = risk.recommendations || [];
        if (!recs.some((rec) => !rec.implemented)) return false;
      }
      if (searchQuery) {
        const haystack = NORMALIZE([
          risk.projectName,
          risk.beneficiary,
          risk.reportNumber,
          risk.description,
          risk.analysisLink,
          risk.completionDate,
          risk.priority ? t('projectRisks.priority' + risk.priority.charAt(0).toUpperCase() + risk.priority.slice(1)) : '',
          risk.note
        ].filter(Boolean).join(" "));
        if (!haystack.includes(searchQuery)) return false;
      }
      return true;
    });
    const _stb = SectionToolbar.getSort("project-risks");
    const _prSortDir = _stb ? _stb.dir : "asc";
    filtered.sort((a, b) => {
      let diff = 0;
      const _sk = _stb ? _stb.key : "name";
      switch (_sk) {
        case "level":
        case "priority": {
          var pOrder = { high: 0, medium: 1, low: 2 };
          diff = SortUtils.orderedMap(pOrder)(a.priority, b.priority);
          break;
        }
        case "title":
        case "name":
          diff = SortUtils.text(a.projectName, b.projectName); break;
        case "beneficiary":
          diff = SortUtils.text(a.beneficiary, b.beneficiary); break;
        case "status": {
          const order = { red: 0, yellow: 1, todo: 2, suspended: 3, green: 4 };
          diff = SortUtils.orderedMap(order)(normalizeProjectRiskStatus(a.status), normalizeProjectRiskStatus(b.status));
          break;
        }
        case "date":
          diff = SortUtils.date(a.completionDate, b.completionDate); break;
        default:
          diff = SortUtils.text(a.projectName, b.projectName); break;
      }
      return _prSortDir === "desc" ? -diff : diff;
    });
    if (!filtered.length) {
      const row = document.createElement("tr");
      const message = appData.projectRisks.length
        ? t('projectRisks.noResults')
        : t('projectRisks.noData');
      row.innerHTML = `<td colspan="7"><div class="nis2-empty">${message}</div></td>`;
      tbody.appendChild(row);
      updateProjectRisksKpi();
      return;
    }
    filtered.forEach((risk) => {
      const row = document.createElement("tr");
      const statusMeta = getProjectRiskStatusMeta(risk.status);
      row.classList.add("project-risk-row", `project-risk-row--${statusMeta.key}`);
      row.dataset.riskId = risk.id;
      // Direct onclick handler (primary mechanism) — closure captures risk.id
      (function(riskId, riskName) {
        row.onclick = function(e) {
          // (audit 2026-07-03, revue effets de bord N) inclure .pr-ra-open : la pastille « Auto »
          // (autoBadge) porte pr-ra-open SANS pr-link-btn ; sans ce garde, row.onclick faisait
          // stopPropagation avant que le clic n'atteigne le délégué tbody → clic « Auto » ouvrait
          // la fiche du risque au lieu de l'analyse liée. Les deux ancres (lien interne + Auto)
          // laissent désormais bouillonner l'événement jusqu'au handler délégué _prClickDelegate.
          if (e.target && e.target.closest && e.target.closest(".pr-link-btn, .pr-ra-open")) return;
          e.stopPropagation(); // Prevent delegation handler from also firing
          // Ensure modal overlay is properly closed before reopening
          var ov = document.getElementById("modalOverlay");
          if (ov && ov.classList.contains("modal-overlay--visible")) {
            if (typeof closeModal === 'function') closeModal();
            else ov.classList.remove("modal-overlay--visible");
          }
          openProjectRiskModal(riskId);
        };
      })(risk.id, risk.projectName);
      // Build link button (compact icon in name column)
      let linkBtn = '';
      if (risk.analysisLink) {
        const internalMatch = risk.analysisLink.match(/^#risk-analysis:(.+)$/);
        if (internalMatch && typeof window.raOpenWizard === 'function') {
          // (audit 2026-07-03, BLOQUANT XSS) id porté par un data-attribut ÉCHAPPÉ (contexte
          // attribut, sûr) + handler délégué — l'ancien onclick interpolait la valeur dans du
          // JS-dans-attribut : escapeHtml(') = &#39; que le parseur HTML re-décode en ' AVANT
          // l'exécution JS → break-out → XSS stocké via analysisLink chez tout co-éditeur.
          linkBtn = `<a class="pr-link-btn pr-ra-open" href="javascript:void(0)" data-ra-id="${escapeHtml(internalMatch[1])}" title="${escapeHtml(t('projectRisks.riskAnalysisLink'))}"><i class="fas fa-external-link-alt"></i></a>`;
        } else {
          linkBtn = isSafeUrl(risk.analysisLink) ? `<a class="pr-link-btn" href="${escapeHtml(risk.analysisLink)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(risk.analysisLink)}" onclick="event.stopPropagation()"><i class="fas fa-external-link-alt"></i></a>` : '';
        }
      }
      const rawDate = risk.completionDate || "";
      let dateLabel = "—";
      if (rawDate) {
        const dateValue = new Date(rawDate);
        dateLabel = !Number.isNaN(dateValue.getTime()) ? dateValue.toLocaleDateString() : rawDate;
      }
      let autoBadge = '';
      if (risk.sourceAnalysisId) {
        const autoMatch = risk.analysisLink ? risk.analysisLink.match(/^#risk-analysis:(.+)$/) : null;
        if (autoMatch && typeof window.raOpenWizard === 'function') {
          autoBadge = `<a class="pr-auto-badge pr-auto-badge--link pr-ra-open" href="javascript:void(0)" data-ra-id="${escapeHtml(autoMatch[1])}" title="${escapeHtml(t('projectRisks.openAnalysis'))}"><i class="fas fa-link"></i> Auto</a>`;
        } else {
          autoBadge = `<span class="pr-auto-badge"><i class="fas fa-link"></i> Auto</span>`;
        }
      }
      row.innerHTML = `
        <td>
          <div class="project-risk-name">
            <span class="project-risk-indicator project-risk-indicator--${statusMeta.key}" aria-hidden="true"><i class="fas ${statusMeta.icon}"></i></span>
            <strong>${escapeHtml(risk.projectName || "")}</strong>${autoBadge}${linkBtn}
          </div>
        </td>
        <td>${escapeHtml(risk.beneficiary || "")}</td>
        <td><div class="project-risk-desc">${escapeHtml(risk.description || "")}</div></td>
        <td>${escapeHtml(dateLabel)}</td>
        <td><span class="status-badge ${statusMeta.badge}"><i class="fas ${statusMeta.icon}"></i> ${escapeHtml(statusMeta.label)}</span></td>
        <td>${risk.priority ? `<span class="pr-priority-badge pr-priority-badge--${risk.priority}">${escapeHtml(t('projectRisks.priority' + risk.priority.charAt(0).toUpperCase() + risk.priority.slice(1)))}</span>` : '—'}</td>
        <td><div class="project-risk-note">${escapeHtml(risk.note || '')}</div></td>
      `;
      tbody.appendChild(row);
    });
    // Event delegation: backup click handler on tbody for all rows
    _prClickDelegate = function(e) {
      // (audit 2026-07-03) ouverture de l'analyse liée : lit l'id depuis data-ra-id (jamais
      // interpolé dans du JS) et le passe en variable — remplace l'onclick vulnérable au XSS.
      var raEl = e.target && e.target.closest(".pr-ra-open");
      if (raEl && raEl.dataset.raId) {
        if (typeof window.raOpenWizard === 'function') window.raOpenWizard(raEl.dataset.raId);
        var _ram = document.querySelector('[data-module="risk-analysis"]');
        if (_ram) _ram.click();
        return;
      }
      if (e.target && e.target.closest(".pr-link-btn")) return;
      const row = e.target.closest("tr.project-risk-row");
      if (!row || !row.dataset.riskId) return;
      openProjectRiskModal(row.dataset.riskId);
    };
    tbody.addEventListener("click", _prClickDelegate);
    updateProjectRisksKpi();
  }
  function openProjectRiskModal(riskId = "") {
    ensureProjectRisks();
    const risk = (appData.projectRisks || []).find((r) => r.id === riskId);
    const isEdit = !!risk;
    const current = risk || normalizeProjectRiskEntry({}, (appData.projectRisks || []).length);
    const isAutoSync = !!(current.sourceAnalysisId);
    let recommendationsSection = null;
    const fields = [
      { name: "projectName", label: t('projectRisks.projectName'), value: current.projectName, readOnly: isAutoSync },
      { name: "beneficiary", label: t('projectRisks.beneficiaryLabel'), value: current.beneficiary },
      { name: "reportNumber", label: t('projectRisks.reportNumber'), value: current.reportNumber, readOnly: isAutoSync },
      { name: "revisionNumber", label: t('projectRisks.revisionNumber'), type: "number", value: current.revisionNumber || 0 },
      { name: "description", label: t('projectRisks.projectDescription'), type: "textarea", value: current.description },
      { name: "analysisLink", label: t('projectRisks.riskAnalysisLink'), type: "url", value: current.analysisLink, readOnly: isAutoSync },
      { name: "completionDate", label: t('projectRisks.completionDate'), type: "date", value: current.completionDate, readOnly: isAutoSync },
      { name: "status", label: t('fields.status'), type: "select", value: current.status, options: PROJECT_RISK_STATUS_OPTIONS, readOnly: isAutoSync },
      { name: "priority", label: t('projectRisks.priorityLabel'), type: "select", value: current.priority || '', options: [
        { value: '', label: '—' },
        // FIX-I18N-PRIORITY-CRITICAL (audit 2026-06-10, bundle.js:18244) : la migration
        // AUDIT-H3 produit la valeur 'critical' (depuis 'critique') et validPriorities
        // l'accepte, mais l'option manquait ici — toute re-sauvegarde de la fiche
        // réinitialisait silencieusement la priorité à ''.
        { value: 'critical', label: t('projectRisks.priorityCritical') },
        { value: 'high', label: t('projectRisks.priorityHigh') },
        { value: 'medium', label: t('projectRisks.priorityMedium') },
        { value: 'low', label: t('projectRisks.priorityLow') }
      ] },
      { name: "note", label: t('projectRisks.note'), type: "textarea", value: current.note || '' }
    ];
    var modalTitle = isEdit ? (isReadOnlyMode() ? t('modals.viewProjectRisk') : t('modals.editProjectRisk')) : t('modals.addProjectRisk');
    openModal(modalTitle, fields, (data) => {
      if (!data.projectName || !data.projectName.trim()) { showToast2(t('toasts.requiredFields') || 'Veuillez remplir les champs obligatoires.', 'warning'); return false; }
      const recommendations = collectProjectRiskRecommendations(recommendationsSection);
      const clean = normalizeProjectRiskEntry({
        id: current.id,
        projectName: isAutoSync ? current.projectName : data.projectName,
        beneficiary: data.beneficiary,
        reportNumber: isAutoSync ? current.reportNumber : data.reportNumber,
        revisionNumber: data.revisionNumber,
        description: data.description,
        analysisLink: isAutoSync ? current.analysisLink : data.analysisLink,
        completionDate: isAutoSync ? current.completionDate : data.completionDate,
        status: isAutoSync ? current.status : data.status,
        priority: data.priority || '',
        note: data.note || '',
        recommendations,
        sourceAnalysisId: current.sourceAnalysisId
      }, 0);
      if (isEdit) {
        const idx = appData.projectRisks.findIndex((r) => r.id === riskId);
        if (idx >= 0) appData.projectRisks[idx] = { ...appData.projectRisks[idx], ...clean };
      } else {
        appData.projectRisks.push(clean);
      }
            addAuditEntry(isEdit ? 'edit' : 'add', 'projectRisks', clean.projectName || current.projectName);
      saveData();
      loadProjectRisksTable();
    }, isEdit ? () => deleteProjectRisk(riskId) : null);
    const modalBody = document.getElementById("modalBody");
    if (modalBody) {
      // Add auto-sync banner if applicable
      if (isAutoSync) {
        const banner = document.createElement("div");
        banner.className = "pr-auto-sync-banner";
        banner.innerHTML = `<i class="fas fa-link"></i> ${t('projectRisks.autoSyncBanner').replace('{title}', escapeHtml(current.projectName))}`;
        modalBody.insertBefore(banner, modalBody.firstChild);
      }
      recommendationsSection = renderProjectRiskRecommendations(modalBody, current.recommendations || []);
    }
  }
  function addProjectRisk() {
    openProjectRiskModal();
  }
  function deleteProjectRisk(riskId) {
    showConfirmModal(t('confirms.deleteProjectRisk'), t('confirms.deleteProjectRisk'), function() {
      var _pr = (appData.projectRisks || []).find(function(r) { return r.id === riskId; });
      appData.projectRisks = (appData.projectRisks || []).filter((risk) => risk.id !== riskId);
            addAuditEntry('delete', 'projectRisks', _pr ? _pr.projectName : riskId);
      saveData();
      loadProjectRisksTable();
      if (typeof updateDashboard === 'function') updateDashboard();
      showToast2(t('toasts.itemDeleted'), "success");
    }, { type: 'danger' });
  }
  window.loadProjectRisksTable = loadProjectRisksTable;
  window.openProjectRiskModal = openProjectRiskModal;
  window.addProjectRisk = addProjectRisk;
  window.deleteProjectRisk = deleteProjectRisk;

  // modules/risks.js
  function getImpactLevelOptions() {
    return [
      { value: 1, label: t('impactLevels.1') },
      { value: 2, label: t('impactLevels.2') },
      { value: 3, label: t('impactLevels.3') },
      { value: 4, label: t('impactLevels.4') },
      { value: 5, label: t('impactLevels.5') }
    ];
  }
  var IMPACT_LEVEL_OPTIONS = getImpactLevelOptions();
  function getImpactLevelLabels() {
    return {
      1: t('impactLevels.1'),
      2: t('impactLevels.2'),
      3: t('impactLevels.3'),
      4: t('impactLevels.4'),
      5: t('impactLevels.5')
    };
  }
  var IMPACT_LEVEL_LABELS = getImpactLevelLabels();
  function getProbabilityLevelOptions() {
    return [
      { value: 1, label: t('probabilityLevels.1') },
      { value: 2, label: t('probabilityLevels.2') },
      { value: 3, label: t('probabilityLevels.3') },
      { value: 4, label: t('probabilityLevels.4') },
      { value: 5, label: t('probabilityLevels.5') }
    ];
  }
  var PROBABILITY_LEVEL_OPTIONS = getProbabilityLevelOptions();
  function getProbabilityLevelLabels() {
    return {
      1: t('probabilityLevels.1'),
      2: t('probabilityLevels.2'),
      3: t('probabilityLevels.3'),
      4: t('probabilityLevels.4'),
      5: t('probabilityLevels.5')
    };
  }
  var PROBABILITY_LEVEL_LABELS = getProbabilityLevelLabels();
  function getTreatmentOptions() {
    return [
      { value: "Réduction", label: t('treatments.reduction') },
      { value: "Acceptation", label: t('treatments.acceptation') },
      { value: "Transfert", label: t('treatments.transfert') },
      { value: "Évitement", label: t('treatments.evitement') }
    ];
  }
  var TREATMENT_OPTIONS = getTreatmentOptions();
  // Returns canonical French risk level (stored in data)
  function calculateRiskLevel(score) {
    if (score >= 20) return "Critique";
    if (score >= 15) return "Grave";
    if (score >= 10) return "Significatif";
    if (score >= 5) return "Mineur";
    return "Insignifiant";
  }
  // Returns translated risk level for display
  const RISK_LEVEL_I18N = {
    'Critique': 'riskLevels.critical',
    'Grave': 'riskLevels.high',
    'Significatif': 'riskLevels.medium',
    'Mineur': 'riskLevels.low',
    'Insignifiant': 'riskLevels.negligible'
  };
  function translateRiskLevel(level) {
    return RISK_LEVEL_I18N[level] ? t(RISK_LEVEL_I18N[level]) : level;
  }
  function generateRiskId() {
    return generateId('risk');
  }
  function ensureRiskIds() {
    if (!Array.isArray(appData.risks)) appData.risks = [];
    appData.risks.forEach(risk => {
      if (!risk.id) risk.id = generateRiskId();
    });
  }
  function loadRisksTable() {
    const tbody = document.getElementById("risksTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('risks')) return;
    tbody.innerHTML = "";
    const levelOrder = {
      "Critique": 0,
      "Grave": 1,
      "Significatif": 2,
      "Mineur": 3,
      "Insignifiant": 4
    };
    if (!Array.isArray(appData.risks) || appData.risks.length === 0) {
      var emptyRow = document.createElement('tr');
      var emptyTd = document.createElement('td');
      emptyTd.setAttribute('colspan', '7');
      emptyTd.className = 'table__empty-state';
      var emptyIcon = document.createElement('i');
      emptyIcon.className = 'fas fa-exclamation-triangle';
      emptyIcon.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      emptyTd.appendChild(emptyIcon);
      emptyTd.appendChild(document.createTextNode(t('emptyStates.noRisks', 'Aucun risque identifié.')));
      emptyRow.appendChild(emptyTd);
      tbody.appendChild(emptyRow);
      return;
    }
    const _s = SectionToolbar.getSort("risks");
    const _q = (SectionToolbar.getSearch("risks") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const _sortKey = _s ? _s.key : "level";
    const _sortDir = _s ? _s.dir : "asc";
    const sortedRisks = [...appData.risks]
      .filter((r) => !_q || ((r.title || "") + " " + (r.description || "")).toLowerCase().indexOf(_q) >= 0).sort((a, b) => {
      let diff = 0;
      switch (_sortKey) {
        case "title": diff = SortUtils.text(a.title, b.title); break;
        case "impact": diff = SortUtils.numeric(a.impact, b.impact); break;
        case "probability": diff = SortUtils.numeric(a.probability, b.probability); break;
        default: diff = SortUtils.orderedMap(levelOrder, 999)(a.level, b.level); break;
      }
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedRisks.forEach((risk) => {
      const row = document.createElement("tr");
      row.classList.add('risk-row--' + (risk.level || '').toLowerCase());
      const linkedActions = (appData.actions || []).filter((a) => actionLinksToRisk(a, risk.id));
      const linkedActionsHtml = linkedActions.length
        ? linkedActions.map((action) => `
          <a href="#" class="link-badge link-badge--risk risk-action-link" data-action-id="${escapeHtml(action.id)}" title="${escapeHtml(t('labels.openAction', 'Ouvrir l\'action'))}">
            ${escapeHtml(action.title || action.id)}
          </a>
        `).join(" ")
        : "—";
      const assetBadgesLine = buildCriticalAssetBadgesLine(risk.criticalAssetIds);
        const levelClasses = {
          "Critique": {
            badge: "status-badge--critique",
            icon: "fas fa-bolt"
          },
          "Grave": {
            badge: "status-badge--grave",
            icon: "fas fa-exclamation-triangle"
          },
          "Significatif": {
            badge: "status-badge--significatif",
            icon: "fas fa-exclamation-circle"
          },
          "Mineur": {
            badge: "status-badge--mineur",
            icon: "fas fa-info-circle"
          },
          "Insignifiant": {
            badge: "status-badge--insignifiant",
            icon: "fas fa-check"
          }
        };
      const cls = levelClasses[risk.level] || { badge: "status-badge--mineur", icon: "fas fa-minus" };
      row.innerHTML = `
            <td><div class="cell-clamp cell-clamp--2"><strong>${escapeHtml(risk.title)}</strong></div>${assetBadgesLine}</td>
            <td>${escapeHtml(String(IMPACT_LEVEL_LABELS[risk.impact] || risk.impact || ''))}</td>
            <td>${escapeHtml(String(PROBABILITY_LEVEL_LABELS[risk.probability] || risk.probability || ''))}</td>
            <td><span class="status-badge ${cls.badge}"><i class="${cls.icon}"></i>${escapeHtml(translateRiskLevel(risk.level))}</span></td>
            <td><div class="cell-clamp cell-clamp--2">${linkedActionsHtml}</div></td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(risk.treatment || "")}</div></td>
            <td><input type="date" class="risk-codir-date" data-risk-id="${escapeHtml(risk.id)}" value="${escapeHtml(risk.codirDate || '')}"></td>
        `;
      row.querySelectorAll(".risk-action-link").forEach((link) => {
        link.addEventListener("click", (event) => {
          event.preventDefault();
          event.stopPropagation();
          const actionId = link.dataset.actionId;
          if (!actionId) return;
          switchModule("actions");
          setTimeout(() => {
            editAction(actionId);
          }, 200);
        });
      });
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger") || e.target.closest('.risk-codir-date') || e.target.closest(".critical-asset-link")) return;
        editRisk(risk.id);
      });
      attachCriticalAssetLinkHandlers(row);
      tbody.appendChild(row);
    });
    tbody.querySelectorAll('.risk-codir-date').forEach((input) => {
      input.dataset.readonlyLock = "true";
      if (isReadOnlyMode()) input.disabled = true;
      input.addEventListener('input', (e) => {
        if (isReadOnlyMode()) {
          showReadOnlyNotice("mettre à jour la date CODIR");
          refreshReadOnlyLocks();
          return;
        }
        const r = (appData.risks || []).find((rr) => rr.id === input.dataset.riskId);
        if (r) {
          r.codirDate = input.value;
          saveData();
        }
      });
    });
  }
  function editRisk(riskId) {
    const risk = (appData.risks || []).find((r) => r.id === riskId);
    if (!risk) return;
    const criticalAssetIds = Array.isArray(risk.criticalAssetIds) ? risk.criticalAssetIds : [];
    const criticalAssetOptions = getCriticalAssetOptions(criticalAssetIds);
    openModal(t('modals.editRisk'), [
      { name: "title", label: "Titre du risque", value: risk.title },
      { name: "description", label: t('fields.description'), type: "textarea", value: risk.description },
      { name: "impact", label: t('fields.impact'), type: "select", value: String(risk.impact), options: IMPACT_LEVEL_OPTIONS },
      { name: "probability", label: "Probabilit\xE9", type: "select", value: String(risk.probability), options: PROBABILITY_LEVEL_OPTIONS },
      { name: "level", label: t('fields.level'), value: translateRiskLevel(calculateRiskLevel(risk.impact * risk.probability)), readOnly: true },
      { name: "treatment", label: t('fields.treatment'), type: "select", value: risk.treatment, options: TREATMENT_OPTIONS },
      // M17 (audit 2026-07-10) : propriétaire de risque (ISO 27001 6.1.2 c) 2)) — champ hybride
      // (nom libre OU partie prenante existante), même patron que les responsables NC/contrôles.
      { name: "owner", label: t('fields.riskOwner', 'Propriétaire du risque'), type: "text", value: risk.owner || "", placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(risk.owner), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: criticalAssetIds, options: criticalAssetOptions },
      { name: "existingComments", label: t('fields.existingComments'), type: "textarea", value: formatCommentsReadonly(risk.comments), readOnly: true },
      { name: "newComment", label: t('fields.newComment'), type: "textarea" }
    ], (data) => {
      risk.title = data.title;
      risk.description = data.description;
      risk.impact = parseInt(data.impact, 10) || 1;
      risk.probability = parseInt(data.probability, 10) || 1;
      risk.score = risk.impact * risk.probability;
      risk.level = calculateRiskLevel(risk.score);
      risk.treatment = data.treatment || "";
      risk.owner = data.owner || ""; // M17
      risk.criticalAssetIds = Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [];
      if (appendComment(risk, data.newComment, 10) === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
      }
            addAuditEntry('edit', 'risks', risk.title);
      saveData();
      loadRisksTable();
      updateDashboard();
    }, () => deleteRisk(riskId));
    const impactInput = document.getElementById("modal-input-impact");
    const probInput = document.getElementById("modal-input-probability");
    const levelInput = document.getElementById("modal-input-level");
    const updateLvl = () => {
      const score = (parseInt(impactInput.value, 10) || 1) * (parseInt(probInput.value, 10) || 1);
      levelInput.value = translateRiskLevel(calculateRiskLevel(score));
    };
    if (impactInput && probInput && levelInput) {
      impactInput.addEventListener("input", updateLvl);
      probInput.addEventListener("input", updateLvl);
    }
  }
  function viewRiskDetails(riskId) {
    const risk = (appData.risks || []).find((r) => r.id === riskId);
    if (!risk) return;
    const actionList = (appData.actions || [])
      .filter((a) => actionLinksToRisk(a, risk.id))
      .map((a) => a.title || a.id)
      .join(", ");
    openModal(t('modals.riskDetails', 'Détails du risque'), [
      { name: 'title', label: t('fields.title', 'Titre'), type: 'text', value: risk.title || '', readOnly: true },
      { name: 'description', label: t('fields.description', 'Description'), type: 'textarea', value: risk.description || '', readOnly: true },
      { name: 'impact', label: t('fields.impact', 'Impact'), type: 'text', value: (IMPACT_LEVEL_LABELS[risk.impact] || risk.impact || ''), readOnly: true },
      { name: 'probability', label: t('fields.probability', 'Probabilité'), type: 'text', value: (PROBABILITY_LEVEL_LABELS[risk.probability] || risk.probability || ''), readOnly: true },
      { name: 'score', label: t('fields.score', 'Score'), type: 'text', value: String(risk.score || ''), readOnly: true },
      { name: 'level', label: t('fields.level', 'Niveau'), type: 'text', value: risk.level || '', readOnly: true },
      { name: 'treatment', label: t('fields.treatment', 'Traitement'), type: 'text', value: risk.treatment || '', readOnly: true },
      { name: 'owner', label: t('fields.riskOwner', 'Propriétaire du risque'), type: 'text', value: risk.owner || t('labels.none', 'Aucun'), readOnly: true }, // M17
      { name: 'actions', label: t('fields.linkedActions', 'Actions liées'), type: 'text', value: actionList || t('labels.none', 'Aucune'), readOnly: true }
    ], null, null, { hideCancel: true, saveLabel: t('common.close', 'Fermer') });
  }
  function addRisk() {
    const criticalAssetOptions = getCriticalAssetOptions();
    openModal(t('modals.addRisk'), [
      { name: "title", label: "Titre du risque" },
      { name: "description", label: t('fields.description'), type: "textarea" },
      { name: "impact", label: t('fields.impact'), type: "select", value: "1", options: IMPACT_LEVEL_OPTIONS },
      { name: "probability", label: "Probabilit\xE9", type: "select", value: "1", options: PROBABILITY_LEVEL_OPTIONS },
      { name: "level", label: t('fields.level'), value: translateRiskLevel(calculateRiskLevel(1)), readOnly: true },
      { name: "treatment", label: t('fields.treatment'), type: "select", options: TREATMENT_OPTIONS },
      // M17 : propriétaire de risque (hybride nom libre / partie prenante).
      { name: "owner", label: t('fields.riskOwner', 'Propriétaire du risque'), type: "text", value: "", placeholder: t('fields.ownerOrStakeholderPlaceholder', 'Nom libre ou partie prenante'), suggestions: _stakeholderNameSuggestions(''), help: t('fields.ownerOrStakeholderHelp', 'Saisissez un nom libre ou choisissez une partie prenante existante.') },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: [], options: criticalAssetOptions },
      { name: "comment", label: t('fields.comment'), type: "textarea" }
    ], (data) => {
      if (!data.title) return false;
      const id = generateRiskId();
      const impact = parseInt(data.impact, 10) || 1;
      const probability = parseInt(data.probability, 10) || 1;
      const score = impact * probability;
      appData.risks.push({
        id,
        title: data.title,
        description: data.description || "",
        impact,
        probability,
        score,
        level: calculateRiskLevel(score),
        treatment: data.treatment || "",
        owner: data.owner || "", // M17
        codirDate: "",
        criticalAssetIds: Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [],
        comments: data.comment ? [{ text: data.comment, date: new Date().toISOString() }] : []
      });
            addAuditEntry('add', 'risks', data.title);
      saveData();
      loadRisksTable();
      updateRiskMatrix();
      updateDashboard();
    });
    const impactInput = document.getElementById("modal-input-impact");
    const probInput = document.getElementById("modal-input-probability");
    const levelInput = document.getElementById("modal-input-level");
    const updateLvl = () => {
      const score = (parseInt(impactInput.value, 10) || 1) * (parseInt(probInput.value, 10) || 1);
      levelInput.value = translateRiskLevel(calculateRiskLevel(score));
    };
    if (impactInput && probInput && levelInput) {
      impactInput.addEventListener("input", updateLvl);
      probInput.addEventListener("input", updateLvl);
    }
  }
  function deleteRisk(riskId) {
    showConfirmModal(t('confirms.deleteRisk'), t('confirms.deleteRisk'), function() {
      var _risk = (appData.risks || []).find(function(r) { return r.id === riskId; });
      appData.risks = (appData.risks || []).filter((r) => r.id !== riskId);
      (appData.actions || []).forEach(function(a) {
        if (a.linkType === "risk" && Array.isArray(a.linkId)) {
          a.linkId = a.linkId.filter(function(id) { return id !== riskId; });
        }
      });
      appData.actions = (appData.actions || []).filter(function(a) {
        if (a.linkType === "risk") return Array.isArray(a.linkId) ? a.linkId.length > 0 : (a.linkId && a.linkId !== riskId);
        return true;
      });
      if (Array.isArray(appData.soa)) {
        appData.soa.forEach(function(entry) {
          if (Array.isArray(entry.linkedRiskIds)) {
            entry.linkedRiskIds = entry.linkedRiskIds.filter(function(id) { return id !== riskId; });
          }
        });
      }
            addAuditEntry('delete', 'risks', _risk ? _risk.title : riskId);
      saveData();
      _csiPersistAffectedTabs(['risks', 'actions', 'soa']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      loadRisksTable();
      loadActionsTable(); // PASS2-301: Refresh actions table after cascade delete
      updateRiskMatrix();
      updateDashboard();
    }, { type: 'danger' });
  }

  function manageRiskComments(riskId) {
    manageComments('risks', riskId, { titlePrefix: t('risks.manageComments', 'Commentaires du risque') });
  }
  window.loadRisksTable = loadRisksTable;
  window.editRisk = editRisk;
  window.viewRiskDetails = viewRiskDetails;
  window.addRisk = addRisk;
  window.deleteRisk = deleteRisk;
  window.manageRiskComments = manageRiskComments;

  // modules/threats.js
function getThreatStatusLabels() {
    return {
      reduction: t('threatStatuses.reduction'),
      acceptation: t('threatStatuses.acceptation'),
      transfert: t('threatStatuses.transfert'),
      evitement: t('threatStatuses.evitement')
    };
}
var THREAT_STATUS_LABELS = getThreatStatusLabels();
function getThreatStatusOptions() {
  return [
    { value: "reduction", label: t('threatStatuses.reduction') },
    { value: "acceptation", label: t('threatStatuses.acceptation') },
    { value: "transfert", label: t('threatStatuses.transfert') },
    { value: "evitement", label: t('threatStatuses.evitement') }
  ];
}
var THREAT_STATUS_OPTIONS = getThreatStatusOptions();
  function loadThreatsTable() {
    const tbody = document.getElementById("threatsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('threats-section')) return;
    tbody.innerHTML = "";
    const levelOrder = {
      "Critique": 0,
      "Grave": 1,
      "Significatif": 2,
      "Mineur": 3,
      "Insignifiant": 4
    };
    if (!Array.isArray(appData.threats) || appData.threats.length === 0) {
      var emptyRow = document.createElement('tr');
      var emptyTd = document.createElement('td');
      emptyTd.setAttribute('colspan', '7');
      emptyTd.className = 'table__empty-state';
      var emptyIcon = document.createElement('i');
      emptyIcon.className = 'fas fa-shield-halved';
      emptyIcon.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      emptyTd.appendChild(emptyIcon);
      emptyTd.appendChild(document.createTextNode(t('emptyStates.noThreats', 'Aucune menace identifiée. Cliquez sur "Nouvelle Menace" pour commencer.')));
      emptyRow.appendChild(emptyTd);
      tbody.appendChild(emptyRow);
      return;
    }
    const _s = SectionToolbar.getSort("threats-section");
    const _q = (SectionToolbar.getSearch("threats-section") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const _sortKey = _s ? _s.key : "level";
    const _sortDir = _s ? _s.dir : "asc";
    const sortedThreats = [...appData.threats]
      .filter((th) => !_q || ((th.title || "") + " " + (th.description || "")).toLowerCase().indexOf(_q) >= 0).sort((a, b) => {
      let diff = 0;
      switch (_sortKey) {
        case "title": diff = SortUtils.text(a.title, b.title); break;
        case "score": diff = SortUtils.numeric((a.impact || 0) * (a.likelihood || 0), (b.impact || 0) * (b.likelihood || 0)); break;
        case "status": diff = SortUtils.text(a.status || "", b.status || ""); break;
        default: diff = SortUtils.orderedMap(levelOrder, 999)(a.level, b.level); break;
      }
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedThreats.forEach((threat) => {
      const row = document.createElement("tr");
      // Lot 1a (refonte UX) : « Actions liées » + « Actifs critiques » fusionnés en compteurs compacts
      const linkedActionsCount = appData.actions
        .filter((a) => a.linkType === "threat" && a.linkId === threat.id)
        .length;
      const linkedAssetsCount = Array.isArray(threat.criticalAssetIds) ? threat.criticalAssetIds.length : 0;
      const linkParts = [];
      if (linkedActionsCount) linkParts.push(t('threats.linkedActionsCount', '{n} action(s)').replace('{n}', linkedActionsCount));
      if (linkedAssetsCount) linkParts.push(t('threats.linkedAssetsCount', '{n} actif(s)').replace('{n}', linkedAssetsCount));
      const linksText = linkParts.length ? linkParts.join(" · ") : "—";
      const levelClasses = {
        "Critique": "status-badge--critique",
        "Grave": "status-badge--grave",
        "Significatif": "status-badge--significatif",
        "Mineur": "status-badge--mineur",
        "Insignifiant": "status-badge--insignifiant"
      };
      row.innerHTML = `
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(threat.title)}</div></td>
            <td>${escapeHtml(String(IMPACT_LEVEL_LABELS[threat.impact] || threat.impact || ''))}</td>
            <td>${escapeHtml(String(PROBABILITY_LEVEL_LABELS[threat.likelihood] || threat.likelihood || ''))}</td>
            <td>${threat.score}</td>
            <td><span class="status-badge ${levelClasses[threat.level]}">${escapeHtml(translateRiskLevel(threat.level))}</span></td>
            <td>${escapeHtml(THREAT_STATUS_LABELS[threat.status] || threat.status || "")}</td>
            <td>${escapeHtml(linksText)}</td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger") || e.target.closest(".critical-asset-link")) return;
        editThreat(threat.id);
      });
      attachCriticalAssetLinkHandlers(row);
      tbody.appendChild(row);
    });
  }
  function showThreatForm(threatId = null) {
    const threat = threatId ? appData.threats.find((t) => t.id === threatId) : null;
    const isEdit = !!threat;
    const likelihood = threat ? threat.likelihood : 1;
    const impact = threat ? threat.impact : 1;
    const criticalAssetIds = threat && Array.isArray(threat.criticalAssetIds) ? threat.criticalAssetIds : [];
    const criticalAssetOptions = getCriticalAssetOptions(criticalAssetIds);
    openModal(isEdit ? t('modals.editThreat') : t('modals.addThreat'), [
      { name: "title", label: t('fields.title'), value: threat ? threat.title : "", required: true },
      { name: "context", label: t('fields.context'), value: threat ? threat.context : "" },
      { name: "description", label: t('fields.description'), type: "textarea", value: threat ? threat.description : "" },
      { name: "likelihood", label: t('fields.likelihood'), type: "select", value: String(likelihood), options: PROBABILITY_LEVEL_OPTIONS },
      { name: "impact", label: t('fields.impact'), type: "select", value: String(impact), options: IMPACT_LEVEL_OPTIONS },
      { name: "level", label: t('fields.level'), value: translateRiskLevel(calculateRiskLevel(likelihood * impact)), readOnly: true },
      { name: "status", label: t('fields.status'), type: "select", value: threat ? threat.status : "reduction", options: THREAT_STATUS_OPTIONS },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: criticalAssetIds, options: criticalAssetOptions }
    ], (data) => {
      const li = parseInt(data.likelihood, 10) || 1;
      const im = parseInt(data.impact, 10) || 1;
      const score = li * im;
      if (isEdit) {
        threat.title = data.title;
        threat.context = data.context;
        threat.description = data.description;
        threat.likelihood = li;
        threat.impact = im;
        threat.score = score;
        threat.level = calculateRiskLevel(score);
        threat.status = data.status;
        threat.criticalAssetIds = Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [];
        threat.updatedAt = new Date().toISOString();
      } else {
        const newThreat = {
          id: generateId("threat"),
          title: data.title,
          context: data.context || "",
          description: data.description || "",
          likelihood: li,
          impact: im,
          score,
          level: calculateRiskLevel(score),
          status: data.status || "reduction",
          criticalAssetIds: Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [],
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString()
        };
        appData.threats.push(newThreat);
      }
            addAuditEntry(isEdit ? 'edit' : 'add', 'threats', data.title);
      saveData();
      loadThreatsTable();
      updateDashboard();
    }, threat ? () => deleteThreat(threatId) : null);
    const likInput = document.getElementById("modal-input-likelihood");
    const impInput = document.getElementById("modal-input-impact");
    const lvlInput = document.getElementById("modal-input-level");
    const updateLvl = () => {
      const sc = (parseInt(impInput.value, 10) || 1) * (parseInt(likInput.value, 10) || 1);
      lvlInput.value = translateRiskLevel(calculateRiskLevel(sc));
    };
    if (likInput && impInput && lvlInput) {
      likInput.addEventListener("input", updateLvl);
      impInput.addEventListener("input", updateLvl);
    }
  }
  function editThreat(threatId) {
    showThreatForm(threatId);
  }
  function viewThreatDetails(threatId) {
    const threat = appData.threats.find((t2) => t2.id === threatId);
    if (!threat) return;
    const actionList = appData.actions
      .filter((a) => a.linkType === "threat" && a.linkId === threat.id)
      .map((a) => a.title || a.id)
      .join(", ");
    openModal(t('modals.threatDetails', 'Détails de la menace'), [
      { name: 'title', label: t('fields.title', 'Titre'), type: 'text', value: threat.title || '', readOnly: true },
      { name: 'context', label: t('fields.context', 'Contexte'), type: 'text', value: threat.context || '', readOnly: true },
      { name: 'description', label: t('fields.description', 'Description'), type: 'textarea', value: threat.description || '', readOnly: true },
      { name: 'probability', label: t('fields.probability', 'Probabilité'), type: 'text', value: (PROBABILITY_LEVEL_LABELS[threat.likelihood] || threat.likelihood || ''), readOnly: true },
      { name: 'impact', label: t('fields.impact', 'Impact'), type: 'text', value: (IMPACT_LEVEL_LABELS[threat.impact] || threat.impact || ''), readOnly: true },
      { name: 'score', label: t('fields.score', 'Score'), type: 'text', value: String(threat.score || ''), readOnly: true },
      { name: 'level', label: t('fields.level', 'Niveau'), type: 'text', value: threat.level || '', readOnly: true },
      { name: 'status', label: t('fields.status', 'Statut'), type: 'text', value: (THREAT_STATUS_LABELS[threat.status] || threat.status || ''), readOnly: true },
      { name: 'actions', label: t('fields.linkedActions', 'Actions liées'), type: 'text', value: actionList || t('labels.none', 'Aucune'), readOnly: true }
    ], null, null, { hideCancel: true, saveLabel: t('common.close', 'Fermer') });
  }
  function deleteThreat(threatId) {
    showConfirmModal(t('confirms.deleteThreat'), t('confirms.deleteThreat'), function() {
      var _thr = appData.threats.find(function(th) { return th.id === threatId; });
      appData.threats = appData.threats.filter((t) => t.id !== threatId);
      appData.actions = (appData.actions || []).filter((a) => !(a.linkType === "threat" && a.linkId === threatId));
            addAuditEntry('delete', 'threats', _thr ? _thr.title : threatId);
      saveData();
      _csiPersistAffectedTabs(['threats', 'actions']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      loadThreatsTable();
      loadActionsTable(); // PASS2-301: Refresh actions table after cascade delete
      updateDashboard();
    }, { type: 'danger' });
  }
  window.loadThreatsTable = loadThreatsTable;
  window.showThreatForm = showThreatForm;

  // === L3 (étude 2026-08-04 §7) — Proposer des menaces depuis un référentiel ===
  // Le module Menaces était en saisie 100 % manuelle. Les packs (threat-catalog)
  // fournissent des ÉVÉNEMENTS de menace ; cotation proposée par les defaults du
  // pack, TOUJOURS revue par l'utilisateur avant insertion (jamais de 1×1
  // silencieux). Provenance tracée via le champ origin (packId:threatId), dédup.
  function _tcModal(id, title, bodyHtml, applyLabel, onApply) {
    var ex = document.getElementById(id); if (ex) ex.remove();
    var ov = document.createElement('div');
    ov.id = id;
    ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px';
    ov.innerHTML = '<div style="background:var(--color-surface,#fff);border-radius:10px;max-width:640px;width:100%;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,0.25)">' +
      '<div style="padding:14px 18px;border-bottom:1px solid var(--color-border,#e5e7eb);font-weight:700">' + escapeHtml(title) + '</div>' +
      '<div style="padding:14px 18px;overflow-y:auto" id="' + id + 'Body">' + bodyHtml + '</div>' +
      '<div style="padding:12px 18px;border-top:1px solid var(--color-border,#e5e7eb);text-align:right">' +
      '<button class="btn btn--sm" id="' + id + 'Cancel">' + escapeHtml(t('common.cancel') || 'Annuler') + '</button> ' +
      '<button class="btn btn--primary btn--sm" id="' + id + 'Apply">' + escapeHtml(applyLabel) + '</button></div></div>';
    document.body.appendChild(ov);
    document.getElementById(id + 'Cancel').addEventListener('click', function() { ov.remove(); });
    document.getElementById(id + 'Apply').addEventListener('click', function() { onApply(ov); });
    return ov;
  }
  window.showProposeThreatsModal = function() {
    if (!window.CSI_ThreatCatalog) return;
    var packs = window.CSI_ThreatCatalog.list();
    if (!packs.length) return;
    var existing = {};
    // Dédup de secours par titre : un ancien client (pré-chantier) strippe origin
    // au chargement (normalizeThreatEntry à liste fermée) — sans ce repli, une
    // flotte mixte re-proposerait les mêmes menaces en doublon (audit 2026-08-04).
    var existingTitles = {};
    (appData.threats || []).forEach(function(th) {
      if (th.origin) existing[th.origin] = true;
      if (th.title) existingTitles[String(th.title).trim().toLowerCase()] = true;
    });
    function threatDup(packId, th) {
      if (existing[packId + ':' + th.id]) return true;
      return [th.label, th.labelEn, th.labelNl].some(function(l) {
        return l && existingTitles[String(l).trim().toLowerCase()];
      });
    }
    function packOptions(selId) {
      return packs.map(function(m) {
        return '<option value="' + escapeHtml(m.id) + '"' + (m.id === selId ? ' selected' : '') + '>' + escapeHtml((m.name || m.id) + (m.version ? ' (' + m.version + ')' : '')) + '</option>';
      }).join('');
    }
    function listHtml(packId) {
      var pack = window.CSI_ThreatCatalog.get(packId);
      if (!pack) return '';
      return (pack.threats || []).map(function(th) {
        var dup = threatDup(packId, th);
        var d = th.defaults || {};
        var lbl = window.CSI_ThreatCatalog.pickLabel(th, 'label');
        return '<label style="display:block;margin:3px 0;font-weight:normal' + (dup ? ';opacity:0.5' : '') + '">' +
          '<input type="checkbox" class="tc-prop" value="' + escapeHtml(th.id) + '"' + (dup ? ' disabled' : '') + '> ' +
          escapeHtml(lbl) + ' <small style="color:var(--color-text-light,#9ca3af)">(' + (Number(d.likelihood) || 3) + '×' + (Number(d.impact) || 3) + (dup ? ' — ' + escapeHtml(t('threats.alreadyImported') || 'déjà importée') : '') + ')</small></label>';
      }).join('');
    }
    var body = '<div class="form-group"><select id="tcPropPack" class="form-control">' + packOptions(packs[0].id) + '</select></div>' +
      '<p style="font-size:0.82rem;color:var(--color-text-light,#9ca3af)">' + escapeHtml(t('threats.proposeHint') || 'Cotation proposée (vraisemblance×impact) à revoir après insertion.') + '</p>' +
      '<div id="tcPropList">' + listHtml(packs[0].id) + '</div>';
    _tcModal('tcProposeModal', t('threats.proposeTitle') || 'Proposer des menaces depuis un référentiel', body,
      t('threats.proposeApply') || 'Ajouter la sélection', function(ov) {
        var packId = document.getElementById('tcPropPack').value;
        var pack = window.CSI_ThreatCatalog.get(packId);
        var added = 0;
        ov.querySelectorAll('.tc-prop:checked').forEach(function(cb) {
          var th = (pack.threats || []).find(function(x) { return x.id === cb.value; });
          if (!th) return;
          var origin = packId + ':' + th.id;
          if (threatDup(packId, th)) return;
          var d = th.defaults || {};
          var lk = Number(d.likelihood) || 3, im = Number(d.impact) || 3;
          appData.threats.push({
            id: generateId('threat'), title: window.CSI_ThreatCatalog.pickLabel(th, 'label'),
            description: th.ref || '', context: '', likelihood: lk, impact: im,
            score: lk * im, level: calculateRiskLevel(lk * im), status: 'reduction',
            origin: origin, comments: [], criticalAssetIds: [],
            createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()
          });
          existing[origin] = true;
          existingTitles[String(window.CSI_ThreatCatalog.pickLabel(th, 'label')).trim().toLowerCase()] = true;
          added++;
        });
        if (added > 0) {
          addAuditEntry('add', 'threats', added + ' menace(s) — pack ' + packId);
          saveData();
          if (window._csiPersistAffectedTabs) window._csiPersistAffectedTabs(['threats']);
          loadThreatsTable(); updateDashboard();
          if (window.showToast2) showToast2((t('threats.proposeDone') || 'Menaces ajoutées : ') + added, 'success');
        }
        ov.remove();
      });
    document.getElementById('tcPropPack').addEventListener('change', function() {
      document.getElementById('tcPropList').innerHTML = listHtml(this.value);
    });
  };

  // === L3bis — Promotion : analyses de risques → registre des risques SMSI ===
  // Ferme le flux de gouvernance analyses → registre → SoA → revue de direction
  // (pull côté écran Risques : l'utilisateur y détient la grappe coeur — aucune
  // écriture inter-grappes). Provenance : sourceAnalysisId + origin, dédup.
  window.showImportRisksModal = function() {
    var analyses = (appData.riskAnalyses || []).filter(function(an) { return (an.risks || []).length > 0; });
    if (!analyses.length) { if (window.showToast2) showToast2(t('risks.importNoAnalysis') || 'Aucune analyse avec des risques.', 'info'); return; }
    // Backfill du snapshot _threat/_scenario : une analyse d'avant le chantier
    // jamais rouverte n'en a pas encore (il ne se pose que dans findAnalysis).
    if (window.raEnsureAnalysisSchema) analyses.forEach(function(an) { try { window.raEnsureAnalysisSchema(an); } catch (e) { /* analyse atypique : on affiche telle quelle */ } });
    var existing = {};
    // Dédup de secours titre+description : un ancien client strippe origin et
    // sourceAnalysisId au chargement (normalizeRiskEntry à liste fermée) — sans
    // ce repli, une flotte mixte re-promouvrait les mêmes risques (audit 2026-08-04).
    var existingKeys = {};
    (appData.risks || []).forEach(function(rk) {
      if (rk.origin) existing[rk.origin] = true;
      if (rk.title) existingKeys[(String(rk.title).trim() + '|' + String(rk.description || '').trim()).toLowerCase()] = true;
    });
    function riskDup(anId, rk) {
      if (existing['analysis:' + anId + ':' + rk.id]) return true;
      var key = (String(rk._threat || rk.customTitle || '?').trim() + '|' + String(rk._scenario || '').trim()).toLowerCase();
      return !!existingKeys[key];
    }
    function anOptions(selId) {
      return analyses.map(function(an) {
        return '<option value="' + escapeHtml(an.id) + '"' + (an.id === selId ? ' selected' : '') + '>' + escapeHtml((an.meta && an.meta.title) || an.id) + '</option>';
      }).join('');
    }
    function listHtml(anId) {
      var an = analyses.find(function(x) { return x.id === anId; });
      if (!an) return '';
      return (an.risks || []).map(function(rk) {
        if (rk.treatmentDecision === 'na') return ''; // écarté justifié : ne se promeut pas
        var dup = riskDup(anId, rk);
        var lbl = rk._threat || rk.customTitle || '?';
        var scen = rk._scenario || '';
        return '<label style="display:block;margin:3px 0;font-weight:normal' + (dup ? ';opacity:0.5' : '') + '">' +
          '<input type="checkbox" class="tc-imp" value="' + escapeHtml(rk.id) + '"' + (dup ? ' disabled' : '') + '> ' +
          escapeHtml(lbl) + (scen ? ' <small style="color:var(--color-text-light,#9ca3af)">' + escapeHtml(scen.substring(0, 60)) + '</small>' : '') +
          ' <small>(' + (Number(rk.likelihood) || 3) + '×' + (Number(rk.impact) || 3) + (dup ? ' — ' + escapeHtml(t('risks.alreadyImported') || 'déjà promu') : '') + ')</small></label>';
      }).join('');
    }
    var body = '<div class="form-group"><select id="tcImpAn" class="form-control">' + anOptions(analyses[0].id) + '</select></div>' +
      '<div id="tcImpList">' + listHtml(analyses[0].id) + '</div>';
    _tcModal('tcImportModal', t('risks.importTitle') || 'Promouvoir des risques d\'analyse au registre SMSI', body,
      t('risks.importApply') || 'Promouvoir la sélection', function(ov) {
        var anId = document.getElementById('tcImpAn').value;
        var an = analyses.find(function(x) { return x.id === anId; });
        // Valeurs CANONIQUES de TREATMENT_OPTIONS (stockées en français quelle que
        // soit la langue UI) — un libellé localisé ne matcherait aucune option du
        // select d'édition et casserait filtres/consolidation.
        var treatMap = { reduce: 'Réduction', accept: 'Acceptation', transfer: 'Transfert', avoid: 'Évitement' };
        var added = 0;
        ov.querySelectorAll('.tc-imp:checked').forEach(function(cb) {
          var rk = (an.risks || []).find(function(x) { return x.id === cb.value; });
          if (!rk || rk.treatmentDecision === 'na') return;
          var origin = 'analysis:' + anId + ':' + rk.id;
          if (riskDup(anId, rk)) return;
          var im = Number(rk.impact) || 3, pb = Number(rk.likelihood) || 3;
          appData.risks.push({
            id: generateId('risk'), title: rk._threat || rk.customTitle || '?',
            description: rk._scenario || '', impact: im, probability: pb,
            score: im * pb, level: calculateRiskLevel(im * pb),
            treatment: treatMap[rk.treatmentDecision] || '', owner: rk.owner || '',
            // === RA-ACTIFS (2026-09, D8) === le périmètre d'actifs déclaré par l'ANALYSE est
            // propagé au risque promu (pré-rempli, éditable ensuite dans la modale du risque) —
            // sans quoi le lien se perdait au franchissement du pont analyse→registre.
            comments: [], codirDate: '', criticalAssetIds: (Array.isArray(an.criticalAssetIds) ? an.criticalAssetIds.slice() : []),
            sourceAnalysisId: anId, origin: origin
          });
          existing[origin] = true;
          existingKeys[(String(rk._threat || rk.customTitle || '?').trim() + '|' + String(rk._scenario || '').trim()).toLowerCase()] = true;
          added++;
        });
        if (added > 0) {
          addAuditEntry('add', 'risks', added + ' risque(s) promu(s) — analyse ' + ((an.meta && an.meta.title) || anId));
          saveData();
          if (window._csiPersistAffectedTabs) window._csiPersistAffectedTabs(['risks']);
          if (window.loadRisksTable) window.loadRisksTable(); updateDashboard();
          if (window.showToast2) showToast2((t('risks.importDone') || 'Risques promus : ') + added, 'success');
        }
        ov.remove();
      });
    document.getElementById('tcImpAn').addEventListener('change', function() {
      document.getElementById('tcImpList').innerHTML = listHtml(this.value);
    });
  };
  window.editThreat = editThreat;
  window.viewThreatDetails = viewThreatDetails;
  window.deleteThreat = deleteThreat;

  // modules/nonconformities.js
function getNcStatusOptions() {
  return [
    { value: "detection", label: t('ncStatuses.detection') },
    { value: "analyse", label: t('ncStatuses.analyse') },
    { value: "action", label: t('ncStatuses.action') },
    { value: "cloture", label: t('ncStatuses.cloture') }
  ];
}
var STATUS_OPTIONS = getNcStatusOptions();
var NC_STATUS_LABELS = {};
STATUS_OPTIONS.forEach(function(opt) { NC_STATUS_LABELS[opt.value] = opt.label; });
function getNcTypeOptions() {
  return [
    { value: "Majeure", label: t('ncTypes.majeure') },
    { value: "Mineure", label: t('ncTypes.mineure') }
  ];
}
var NC_TYPE_OPTIONS = getNcTypeOptions();
  function loadNonConformitiesTable() {
    const tbody = document.getElementById("ncTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('nonconformities')) return;
    tbody.innerHTML = "";
    updateNcAuditFilterOptions();
    updateNcOwnerFilterOptions();
    const _f = SectionToolbar.getFilters("nonconformities");
    const _q = (SectionToolbar.getSearch("nonconformities") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const auditFilter = _f.audit || "";
    const ownerFilter = _f.owner || "";
    const _s = SectionToolbar.getSort("nonconformities");
    const sortBy = _s ? _s.key : "type";
    const sortDir = _s ? _s.dir : "asc";
    const typeOrder = { Majeure: 0, Mineure: 1 };
    const statusOrder = STATUS_OPTIONS.reduce((acc, option, index) => {
      acc[option.value] = index;
      return acc;
    }, {});
    const auditMap = new Map((appData.audits || []).map((audit) => [audit.id, audit.title || audit.id]));
    const getAuditTitle = (nc) => auditMap.get(nc.auditId) || "";
    const sortedNCs = [...appData.nonconformities].sort((a, b) => {
      let diff = 0;
      switch (sortBy) {
        case "status":
          diff = SortUtils.orderedMap(statusOrder, 99)(a.status, b.status);
          break;
        case "date":
          diff = SortUtils.date(b.detectionDate, a.detectionDate);
          break;
        case "audit":
          diff = SortUtils.text(getAuditTitle(a), getAuditTitle(b));
          break;
        case "owner":
          diff = SortUtils.text(a.assignedTo, b.assignedTo);
          break;
        case "type":
        default:
          diff = SortUtils.orderedMap(typeOrder, 99)(a.type, b.type);
          break;
      }
      return diff !== 0 ? diff : SortUtils.text(a.title, b.title);
    });
    if (sortDir === "desc") sortedNCs.reverse();
    if (!sortedNCs.length) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '7'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-clipboard-check';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noNonconformities', 'Aucune non-conformité')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    sortedNCs.forEach((nc) => {
      if (auditFilter && nc.auditId !== auditFilter) return;
      if (_q && ((nc.title || "") + " " + (nc.assignedTo || "")).toLowerCase().indexOf(_q) < 0) return;
      if (ownerFilter) {
        const assignedTo = (nc.assignedTo || "").trim();
        if (ownerFilter === "unassigned") {
          if (assignedTo) return;
        } else if (assignedTo !== ownerFilter) {
          return;
        }
      }
      const row = document.createElement("tr");
      const typeClass = nc.type === "Majeure" ? "status-badge--eleve" : "status-badge--moyen";
      const relatedActions = (appData.actions || []).filter((a) => {
        return (a.ncIds || []).includes(nc.id) || a.linkType === "nc" && a.linkId === nc.id;
      });
      // Lot 1a (refonte UX) : « Actions liées » = compteur compact ; « — » quand vide
      const actionsText = relatedActions.length
        ? t('nc.linkedActionsCount', '{n} action(s)').replace('{n}', relatedActions.length)
        : "—";
      const audit = appData.audits.find(a => a.id === nc.auditId);
      const auditText = audit ? audit.title : "—";
      // FIX-I18N-NCTYPE-CASE (audit 2026-06-10, bundle.js:18974) : nc.type est stocké
      // "Majeure"/"Mineure" mais les clés de locale sont ncTypes.majeure/mineure —
      // resolveKey étant sensible à la casse, le badge n'était jamais traduit.
      const assetBadgesLine = buildCriticalAssetBadgesLine(nc.criticalAssetIds);
      row.innerHTML = `
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(nc.title)}</div>${assetBadgesLine}</td>
            <td><span class="status-badge ${typeClass}">${escapeHtml(t('ncTypes.' + String(nc.type || '').toLowerCase(), nc.type))}</span></td>
            <td>${escapeHtml(NC_STATUS_LABELS[nc.status] || nc.status || "")}</td>
            <td>${nc.detectionDate ? new Date(nc.detectionDate).toLocaleDateString() : "—"}</td>
            <td>${nc.assignedTo ? escapeHtml(nc.assignedTo) : "—"}</td>
            <td>${escapeHtml(auditText)}</td>
            <td>${escapeHtml(actionsText)}</td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger") || e.target.closest(".critical-asset-link")) return;
        editNC(nc.id);
      });
      attachCriticalAssetLinkHandlers(row);
      tbody.appendChild(row);
    });
  }
  function editNC(ncId) {
    const nc = appData.nonconformities.find((n) => n.id === ncId);
    if (!nc) return;
    const linkedActions = (appData.actions || []).filter((a) => (a.ncIds || []).includes(nc.id)).map((a) => a.id);
    const criticalAssetIds = Array.isArray(nc.criticalAssetIds) ? nc.criticalAssetIds : [];
    const criticalAssetOptions = getCriticalAssetOptions(criticalAssetIds);
    openModal(t('modals.editNc'), [
      { name: "title", label: t('fields.title'), value: nc.title, required: true },
      { name: "type", label: t('fields.type', 'Type'), type: "select", value: nc.type, options: NC_TYPE_OPTIONS },
      { name: "status", label: t('fields.status'), type: "select", value: nc.status, options: STATUS_OPTIONS },
      { name: "detectionDate", label: t('fields.detectionDate'), type: "date", value: nc.detectionDate },
      {
        name: "assignedTo",
        label: t('fields.owner'),
        type: "select",
        value: nc.assignedTo,
        options: [{ value: '', label: '\u2014 ' + t('common.select', 'Sélectionner') + ' \u2014' }].concat(appData.stakeholders.map((s) => ({ value: s.name, label: s.name })))
      },
      { name: "auditId", label: t('fields.linkedAudit', 'Audit lié'), type: "select", value: nc.auditId, options: [{ value: '', label: '\u2014 ' + t('common.select', 'Sélectionner') + ' \u2014' }].concat(appData.audits.map(a => ({ value: a.id, label: a.title }))) },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: criticalAssetIds, options: criticalAssetOptions },
      { name: "description", label: t('fields.operations', 'Opérations'), type: "textarea", value: nc.description },
    ], (data) => {
      nc.title = data.title;
      nc.type = data.type;
      nc.status = data.status;
      nc.detectionDate = data.detectionDate;
      nc.assignedTo = data.assignedTo;
      nc.auditId = data.auditId;
      nc.description = data.description;
      nc.criticalAssetIds = Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : [];
            addAuditEntry('edit', 'nc', nc.title);
      saveData();
      loadNonConformitiesTable();
      updateDashboard();
    }, () => deleteNC(ncId));
  }
  function updateNcAuditFilterOptions() {
    const audits = Array.isArray(appData.audits) ? appData.audits : [];
    SectionToolbar.updateFilterOptions("nonconformities", "audit", audits.map(function(audit) {
      return { value: audit.id, label: audit.title || audit.id };
    }));
  }
  function updateNcOwnerFilterOptions() {
    const names = new Set();
    const addName = (value) => {
      const cleaned = typeof value === "string" ? value.trim() : "";
      if (cleaned) names.add(cleaned);
    };
    (Array.isArray(appData.stakeholders) ? appData.stakeholders : []).forEach((st) => addName(st.name));
    (Array.isArray(appData.nonconformities) ? appData.nonconformities : []).forEach((nc) => addName(nc.assignedTo));
    const sorted = Array.from(names).sort(SortUtils.text);
    const hasUnassigned = (Array.isArray(appData.nonconformities) ? appData.nonconformities : []).some((nc) => !nc.assignedTo);
    var opts = [];
    if (hasUnassigned) opts.push({ value: "unassigned", label: "Non assign\u00e9" });
    sorted.forEach(function(name) { opts.push({ value: name, label: name }); });
    SectionToolbar.updateFilterOptions("nonconformities", "owner", opts);
  }
  function deleteNC(ncId) {
    showConfirmModal(t('confirms.deleteNc'), t('confirms.deleteNc'), function() {
      var _nc = appData.nonconformities.find(function(n) { return n.id === ncId; });
      appData.nonconformities = appData.nonconformities.filter((n) => n.id !== ncId);
      (appData.actions || []).forEach(function(a) {
        if (Array.isArray(a.ncIds)) a.ncIds = a.ncIds.filter(function(id) { return id !== ncId; });
      });
      appData.actions = (appData.actions || []).filter((a) => !(a.linkType === "nc" && a.linkId === ncId));
            addAuditEntry('delete', 'nc', _nc ? _nc.title : ncId);
      saveData();
      _csiPersistAffectedTabs(['nonconformities', 'actions']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      loadNonConformitiesTable();
      loadActionsTable(); // PASS2-301: Refresh actions table after cascade delete
      updateDashboard();
    }, { type: 'danger' });
  }
  function addNC() {
    const criticalAssetOptions = getCriticalAssetOptions();
    openModal(t('modals.addNc'), [
      { name: "title", label: t('fields.title'), required: true },
      { name: "type", label: t('fields.type', 'Type'), type: "select", value: "Mineure", options: NC_TYPE_OPTIONS },
      { name: "status", label: t('fields.status'), type: "select", value: "detection", options: STATUS_OPTIONS },
      { name: "detectionDate", label: t('fields.detectionDate'), type: "date", value: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10) },
      {
        name: "assignedTo",
        label: t('fields.owner'),
        type: "select",
        options: [{ value: '', label: '-- ' + t('fields.select', 'Sélectionner') + ' --' }].concat(
            appData.stakeholders.map(function(s) { return { value: s.name, label: s.name }; })
        )
      },
      { name: "auditId", label: t('fields.linkedAudit', 'Audit lié'), type: "select", options: [{ value: '', label: '\u2014 ' + t('labels.select', 'Sélectionner') + ' \u2014' }].concat(appData.audits.map(a => ({ value: a.id, label: a.title }))) },
      { name: "criticalAssetIds", label: t('fields.criticalAssets'), type: "multiselect", value: [], options: criticalAssetOptions },
      { name: "description", label: t('fields.operations', 'Opérations'), type: "textarea" }
    ], (data) => {
      if (!data.title) return false;
      const id = generateId("nc");
      appData.nonconformities.push({
        id,
        title: data.title,
        description: data.description || "",
        type: data.type || "Mineure",
        status: data.status || "detection",
        detectionDate: data.detectionDate || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
        assignedTo: data.assignedTo || "",
        auditId: data.auditId || "",
        criticalAssetIds: Array.isArray(data.criticalAssetIds) ? data.criticalAssetIds : []
      });
            addAuditEntry('add', 'nc', data.title);
      saveData();
      loadNonConformitiesTable();
      updateDashboard();
    });
  }
  window.loadNonConformitiesTable = loadNonConformitiesTable;
  window.editNC = editNC;
  window.deleteNC = deleteNC;
  window.addNC = addNC;

  // modules/audits.js
function getAuditStatusOptions() {
    return [
      { value: "planifie", label: t('auditStatuses.planifie') },
      { value: "en_cours", label: t('auditStatuses.enCours', 'En cours') },
      { value: "realise", label: t('auditStatuses.realise') },
      { value: "annule", label: t('auditStatuses.annule', 'Annulé') }
    ];
}
var AUDIT_STATUS_OPTIONS = getAuditStatusOptions();
var AUDIT_STATUS_LABELS = {};
AUDIT_STATUS_OPTIONS.forEach(function(opt) { AUDIT_STATUS_LABELS[opt.value] = opt.label; });
// Badge de statut Audits — cohérence avec les autres sections (contrôles/actions) qui
// affichent une pastille colorée et non du texte brut. Classes CSS existantes réutilisées.
var AUDIT_STATUS_BADGE = {
  planifie: "status-badge--a_realiser",
  en_cours: "status-badge--en_cours",
  realise: "status-badge--implemente",
  annule: "status-badge--suspendue"
};
function getAuditTypeOptions() {
    return [
      { value: "interne", label: t('auditTypes.interne') },
      { value: "externe", label: t('auditTypes.externe') },
      { value: "certification", label: t('auditTypes.certification', 'Certification') },
      { value: "surveillance", label: t('auditTypes.surveillance', 'Surveillance') }
    ];
}
var AUDIT_TYPE_OPTIONS = getAuditTypeOptions();
var AUDIT_TYPE_LABELS = {};
AUDIT_TYPE_OPTIONS.forEach(function(opt) { AUDIT_TYPE_LABELS[opt.value] = opt.label; });
  function loadAuditsTable() {
    const tbody = document.getElementById("auditsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('audits')) return;
    tbody.innerHTML = "";
    if (!Array.isArray(appData.audits) || appData.audits.length === 0) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '7'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-search';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noAudits', 'Aucun audit enregistré. Cliquez sur "Nouvel Audit" pour planifier un audit.')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    const _s = SectionToolbar.getSort("audits");
    const _q = (SectionToolbar.getSearch("audits") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const _sortKey = _s ? _s.key : "date";
    const _sortDir = _s ? _s.dir : "desc";
    const sortedAudits = [...appData.audits]
      .filter((a) => !_q || ((a.title || "") + " " + (a.scope || "") + " " + (a.auditor || "")).toLowerCase().indexOf(_q) >= 0).sort((a, b) => {
      let diff = 0;
      switch (_sortKey) {
        case "title": diff = SortUtils.text(a.title, b.title); break;
        case "type": diff = SortUtils.text(a.type || "", b.type || ""); break;
        case "status": diff = SortUtils.text(a.status || "", b.status || ""); break;
        default: diff = SortUtils.date(a.plannedDate, b.plannedDate); break;
      }
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedAudits.forEach((audit) => {
      const row = document.createElement("tr");
      const linkedActionIds = appData.actions
        .filter((a) => a.linkType === "audit" && a.linkId === audit.id)
        .map((a) => a.title || a.id)
        .join(", ");
      const _auditOverdue = _csiIsPastDate(audit.plannedDate) && audit.status !== "realise" && audit.status !== "annule";
      row.innerHTML = `
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(audit.title)}</div></td>
            <td>${escapeHtml(AUDIT_TYPE_LABELS[audit.type] || audit.type || "")}</td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(audit.scope)}</div></td>
            <td${_auditOverdue ? ` class="cell-overdue" title="${escapeHtml(t('audits.overdue', 'Audit en retard'))}"` : ""}>${audit.plannedDate ? new Date(audit.plannedDate).toLocaleDateString() : "—"}</td>
            <td>${audit.auditor ? escapeHtml(audit.auditor) : "—"}</td>
            <td>${audit.status ? `<span class="status-badge ${AUDIT_STATUS_BADGE[audit.status] || ""}">${escapeHtml(AUDIT_STATUS_LABELS[audit.status] || audit.status)}</span>` : "—"}</td>
            <td>${escapeHtml(linkedActionIds) || "—"}</td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger")) return;
        editAudit(audit.id);
      });
      tbody.appendChild(row);
    });
  }
  function editAudit(auditId) {
    const audit = appData.audits.find((a) => a.id === auditId);
    if (!audit) return;
    openModal(t('modals.editAudit'), [
      { name: "title", label: t('fields.auditTitle'), value: audit.title, required: true },
      { name: "type", label: t('fields.type'), type: "select", value: audit.type, options: AUDIT_TYPE_OPTIONS },
      { name: "scope", label: t('fields.scope'), value: audit.scope },
      { name: "plannedDate", label: t('fields.plannedDate'), value: audit.plannedDate, type: "date" },
      { name: "executionDate", label: t('fields.executionDate', "Date d'exécution"), type: "date", value: audit.executionDate },
      { name: "auditor", label: t('fields.auditor'), value: audit.auditor },
      { name: "status", label: t('fields.status'), type: "select", value: audit.status, options: AUDIT_STATUS_OPTIONS },
      { name: "findings", label: t('fields.findings', 'Constatations'), type: "textarea", value: audit.findings },
      { name: "recommendations", label: t('fields.recommendations', 'Recommandations'), type: "textarea", value: audit.recommendations }
    ], (data) => {
      audit.title = data.title;
      audit.type = data.type;
      audit.scope = data.scope;
      audit.plannedDate = data.plannedDate;
      audit.executionDate = data.executionDate;
      audit.auditor = data.auditor;
      audit.status = data.status;
      audit.findings = data.findings;
      audit.recommendations = data.recommendations;
            addAuditEntry('edit', 'audits', audit.title);
      saveData();
      loadAuditsTable();
      updateDashboard();
    }, () => deleteAudit(auditId));
  }
  function deleteAudit(auditId) {
    showConfirmModal(t('confirms.deleteAudit'), t('confirms.deleteAudit'), function() {
      var _aud = appData.audits.find(function(a) { return a.id === auditId; });
      appData.audits = appData.audits.filter((a) => a.id !== auditId);
      (appData.nonconformities || []).forEach(function(nc) { if (nc.auditId === auditId) nc.auditId = ""; });
      appData.actions = (appData.actions || []).filter((a) => !(a.linkType === "audit" && a.linkId === auditId));
            addAuditEntry('delete', 'audits', _aud ? _aud.title : auditId);
      saveData();
      _csiPersistAffectedTabs(['audits', 'nonconformities', 'actions']); // FIX-CASCADE-PERSIST : persiste les sections croisées nettoyées
      loadAuditsTable();
      loadNonConformitiesTable();
      loadActionsTable();
      updateDashboard();
    }, { type: 'danger' });
  }
  function addAudit() {
    openModal(t('modals.addAudit'), [
      { name: "title", label: t('fields.auditTitle'), required: true },
      { name: "type", label: t('fields.type'), type: "select", value: "interne", options: AUDIT_TYPE_OPTIONS },
      { name: "scope", label: t('fields.scope') },
      { name: "plannedDate", label: t('fields.plannedDate'), type: "date" },
      { name: "auditor", label: t('fields.auditor') },
      { name: "status", label: t('fields.status'), type: "select", value: "planifie", options: AUDIT_STATUS_OPTIONS }
    ], (data) => {
      if (!data.title) return false;
      const id = generateId("audit");
      appData.audits.push({
        id,
        title: data.title,
        type: data.type || "interne",
        description: "",
        scope: data.scope,
        plannedDate: data.plannedDate,
        auditor: data.auditor,
        status: data.status || "planifie"
      });
            addAuditEntry('add', 'audits', data.title);
      saveData();
      loadAuditsTable();
      updateDashboard();
    });
  }
  window.loadAuditsTable = loadAuditsTable;
  window.editAudit = editAudit;
  window.deleteAudit = deleteAudit;
  window.addAudit = addAudit;

  // modules/reviews.js
  function loadReviewsTable() {
    const tbody = document.getElementById("reviewsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('managementReviews')) return;
    tbody.innerHTML = "";
    if (!Array.isArray(appData.reviews) || appData.reviews.length === 0) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '5'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-user-tie';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noReviews', 'Aucune revue de direction. Cliquez sur "Nouvelle Revue" pour en créer une.')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    const _s = SectionToolbar.getSort("managementReviews");
    const _sortDir = _s ? _s.dir : "desc";
    const sortedReviews = [...appData.reviews].sort((a, b) => {
      var diff = SortUtils.date(a.date, b.date);
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedReviews.forEach((review) => {
      const row = document.createElement("tr");
      row.innerHTML = `
            <td>${review.date ? new Date(review.date).toLocaleDateString() : "—"}</td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(review.participants || "")}</div></td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(review.inputs || "")}</div></td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(review.decisions || "")}</div></td>
            <td>${(review.comments || []).length}</td>`;
      row.classList.add("row-clickable");
      row.addEventListener("click", () => {
        editReview(review.id);
      });
      tbody.appendChild(row);
    });
  }
  function addReview() {
    openModal(t('modals.addReview'), [
      { name: "date", label: "Date", type: "date", value: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), required: true },
      { name: "participants", label: t('fields.participants') },
      { name: "inputs", label: t('fields.reviewInputs', 'Entrées de la revue') },
      { name: "decisions", label: t('fields.reviewDecisions', 'Décisions et actions') },
      { name: "comment", label: t('fields.comment'), type: "textarea" }
    ], (data) => {
      if (!data.date) return false;
      const id = generateId("review");
      appData.reviews.push({
        id,
        date: data.date,
        participants: data.participants,
        inputs: data.inputs,
        decisions: data.decisions,
        comments: data.comment ? [{ text: data.comment, date: new Date().toISOString() }] : []
      });
            addAuditEntry('add', 'reviews', data.date);
      saveData();
      loadReviewsTable();
      updateDashboard();
    });
  }
  function editReview(reviewId) {
    const review = appData.reviews.find((r) => r.id === reviewId);
    if (!review) return;
    openModal(t('modals.editReview'), [
      { name: "date", label: "Date", type: "date", value: review.date, required: true },
      { name: "participants", label: t('fields.participants'), value: review.participants },
      { name: "inputs", label: t('fields.reviewInputs', 'Entrées de la revue'), value: review.inputs },
      { name: "decisions", label: t('fields.reviewDecisions', 'Décisions et actions'), value: review.decisions },
      { name: "existingComments", label: t('fields.existingComments'), type: "textarea", value: formatCommentsReadonly(review.comments), readOnly: true },
      { name: "newComment", label: t('fields.newComment'), type: "textarea" }
    ], (data) => {
      review.date = data.date;
      review.participants = data.participants;
      review.inputs = data.inputs;
      review.decisions = data.decisions;
      if (appendComment(review, data.newComment, 10) === 'max') {
        showToast2(t('comments.max', 'Maximum {n} commentaires atteint.').replace('{n}', 10), 'warning');
      }
            addAuditEntry('edit', 'reviews', review.date);
      saveData();
      loadReviewsTable();
      updateDashboard();
    }, () => deleteReview(reviewId));
  }
  function deleteReview(reviewId) {
    showConfirmModal(t('confirms.deleteReview'), t('confirms.deleteReview'), function() {
      var _rev = appData.reviews.find(function(r) { return r.id === reviewId; });
      appData.reviews = appData.reviews.filter((r) => r.id !== reviewId);
            addAuditEntry('delete', 'reviews', _rev ? _rev.date : reviewId);
      saveData();
      loadReviewsTable();
      updateDashboard();
    }, { type: 'danger' });
  }
  // Unification 2026-07-10 : implémentation dédiée remplacée par le système générique
  // (même modale, mêmes garde-fous — id/auteur, plafond, suppression par id).
  function manageReviewComments(reviewId) {
    manageComments('reviews', reviewId, { titlePrefix: t('reviews.manageComments', 'Commentaires de revue') });
  }
  window.loadReviewsTable = loadReviewsTable;
  window.addReview = addReview;
  window.editReview = editReview;
  window.deleteReview = deleteReview;
  window.manageReviewComments = manageReviewComments;

  // modules/stakeholders.js
  // Lot 1b : rôles normalisés des parties prenantes (valeurs stockées = slugs stables).
  // Rétrocompatibilité : les données existantes en texte libre restent affichées telles
  // quelles et sont proposées comme option « (valeur actuelle) » dans le select.
  const STAKEHOLDER_ROLE_SLUGS = ['internal', 'supplier', 'client', 'authority', 'partner', 'other'];
  function stakeholderRoleLabel(role) {
    if (!role) return '';
    return STAKEHOLDER_ROLE_SLUGS.indexOf(role) >= 0 ? t('stakeholderRoles.' + role, role) : role;
  }
  function stakeholderRoleOptions(current) {
    const opts = [{ value: '', label: '—' }].concat(STAKEHOLDER_ROLE_SLUGS.map(function(s) {
      return { value: s, label: t('stakeholderRoles.' + s, s) };
    }));
    if (current && current !== '' && STAKEHOLDER_ROLE_SLUGS.indexOf(current) < 0) {
      opts.push({ value: current, label: current + ' ' + t('stakeholderRoles.currentValue', '(valeur actuelle)') });
    }
    return opts;
  }
  function loadStakeholdersTable() {
    const tbody = document.getElementById("stakeholdersTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('stakeholders')) return;
    tbody.innerHTML = "";
    // Lot 1b : options du filtre Rôle (slugs + valeurs libres héritées présentes)
    if (typeof SectionToolbar !== 'undefined' && SectionToolbar.updateFilterOptions) {
      var _roleOpts = STAKEHOLDER_ROLE_SLUGS.map(function(s) {
        return { value: s, label: t('stakeholderRoles.' + s, s) };
      });
      (appData.stakeholders || []).forEach(function(st) {
        if (st && st.role && STAKEHOLDER_ROLE_SLUGS.indexOf(st.role) < 0 &&
            !_roleOpts.some(function(o) { return o.value === st.role; })) {
          _roleOpts.push({ value: st.role, label: st.role });
        }
      });
      SectionToolbar.updateFilterOptions('stakeholders', 'role', _roleOpts);
    }
    if (!Array.isArray(appData.stakeholders) || appData.stakeholders.length === 0) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '4'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-users';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noStakeholders', 'Aucune partie prenante. Cliquez sur "Nouvelle Partie prenante" pour en ajouter.')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    const _s = SectionToolbar.getSort("stakeholders");
    const _sortDir = _s ? _s.dir : "asc";
    const _filters = (typeof SectionToolbar !== 'undefined' && SectionToolbar.getFilters) ? SectionToolbar.getFilters("stakeholders") : {};
    const _roleFilter = _filters.role || "";
    const _q = (SectionToolbar.getSearch("stakeholders") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const sortedStakeholders = [...appData.stakeholders]
      .filter((st) => !_roleFilter || st.role === _roleFilter)
      .filter((st) => !_q || ((st.name || "") + " " + (st.contact || "")).toLowerCase().indexOf(_q) >= 0)
      .sort((a, b) => {
        var diff = SortUtils.text(a.name, b.name);
        return _sortDir === "desc" ? -diff : diff;
      });
    sortedStakeholders.forEach((st) => {
      const row = document.createElement("tr");
      row.innerHTML = `
            <td>${escapeHtml(st.name || "")}</td>
            <td>${st.role ? escapeHtml(stakeholderRoleLabel(st.role)) : "—"}</td>
            <td>${escapeHtml(st.contact || "")}</td>
            <td><div class="cell-clamp cell-clamp--2">${escapeHtml(st.notes || "")}</div></td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger")) return;
        editStakeholder(st.id);
      });
      tbody.appendChild(row);
    });
  }
  function addStakeholder() {
    openModal(t('modals.addStakeholder'), [
      { name: "name", label: t('fields.name') },
      { name: "role", label: t('fields.role'), type: "select", value: "", options: stakeholderRoleOptions("") },
      { name: "contact", label: t('fields.contact') },
      { name: "notes", label: t('fields.comments') }
    ], (data) => {
      if (!data.name) return false;
      const id = generateId("stakeholder");
      appData.stakeholders.push({
        id,
        name: data.name,
        role: data.role,
        contact: data.contact,
        notes: data.notes
      });
            addAuditEntry('add', 'stakeholders', data.name);
      saveData();
      loadStakeholdersTable();
    });
  }
  function editStakeholder(stakeholderId) {
    const st = appData.stakeholders.find((s) => s.id === stakeholderId);
    if (!st) return;
    const oldName = st.name;
    openModal(t('modals.editStakeholder'), [
      { name: "name", label: t('fields.name'), value: st.name },
      { name: "role", label: t('fields.role'), type: "select", value: st.role || "", options: stakeholderRoleOptions(st.role || "") },
      { name: "contact", label: t('fields.contact'), value: st.contact },
      { name: "notes", label: t('fields.comments'), value: st.notes }
    ], (data) => {
      st.name = data.name;
      st.role = data.role;
      st.contact = data.contact;
      st.notes = data.notes;
      if (oldName !== data.name) {
        (appData.actions || []).forEach(a => {
          if (a.assignedTo === oldName) a.assignedTo = data.name;
        });
        appData.nonconformities.forEach(nc => {
          if (nc.assignedTo === oldName) nc.assignedTo = data.name;
        });
        (appData.risks || []).forEach(function(r) { if (r.owner === oldName) r.owner = data.name; });
        (appData.threats || []).forEach(function(th) { if (th.owner === oldName) th.owner = data.name; });
        (appData.nis2Controls || []).forEach(function(c) {
          if (c.owner === oldName) c.owner = data.name;
          if (c.reviewer === oldName) c.reviewer = data.name;
        });
        (appData.recyfControls || []).forEach(function(c) {
          if (c.owner === oldName) c.owner = data.name;
          if (c.reviewer === oldName) c.reviewer = data.name;
        });
        // FIX-STAKEHOLDER-CASCADE (audit 2026-07-10) : sections « responsable » qui
        // proposent désormais les parties prenantes en datalist — le renommage doit
        // suivre pour éviter les références orphelines.
        (appData.soa || []).forEach(function(e) { if (e.owner === oldName) e.owner = data.name; });
        (appData.documentReview || []).forEach(function(e) { if (e.responsible === oldName) e.responsible = data.name; });
        (appData.controls || []).forEach(function(c) { if (c.owner === oldName) c.owner = data.name; });
        (appData.soclePillars || []).forEach(function(p) { if (p && p.owner === oldName) p.owner = data.name; });
        // Lot 1a actifs (2026-09) : propriétaire métier des actifs critiques (datalist parties
        // prenantes) — même grappe « cœur », le renommage doit suivre.
        (appData.criticalAssets || []).forEach(function(a) { if (a.owner === oldName) a.owner = data.name; });
        if (appData.nis2Plan) {
          (appData.nis2Plan.domains || []).forEach(function(d) {
            (d.actions || []).forEach(function(a) { if (a.owner === oldName) a.owner = data.name; });
          });
          (appData.nis2Plan.budgetLines || []).forEach(function(l) { if (l.owner === oldName) l.owner = data.name; });
        }
      }
            addAuditEntry('edit', 'stakeholders', data.name);
      saveData();
      // (audit global 2026-07-03 ; complété revue effets de bord) le renommage cascade sur
      // actions/nonconformities (assignedTo) ET risks/threats (owner) — sections DISTINCTES de
      // stakeholders, toutes mutées ci-dessus. En mode serveur saveData() ne pousse que l'onglet
      // courant : sans ceci, ces références restent l'ancien nom côté serveur (orphelines
      // durables). Pattern FIX-CASCADE-PERSIST des autres cascades. No-op hors mode serveur.
      _csiPersistAffectedTabs(['actions', 'nonconformities', 'risks', 'threats', 'nis2Controls', 'recyfControls', 'soa', 'documentReview', 'controls', 'soclePillars', 'nis2Plan', 'criticalAssets']);
      loadStakeholdersTable();
      loadActionsTable();
      loadNonConformitiesTable();
      loadCriticalAssetsTable();
      if (typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable();
      if (typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable();
    }, () => deleteStakeholder(stakeholderId));
  }
  function deleteStakeholder(stakeholderId) {
    showConfirmModal(t('confirms.deleteStakeholder'), t('confirms.deleteStakeholder'), function() {
      const st = appData.stakeholders.find((s) => s.id === stakeholderId);
      appData.stakeholders = appData.stakeholders.filter((s) => s.id !== stakeholderId);
      if (st) {
        (appData.actions || []).forEach(a => {
          if (a.assignedTo === st.name) a.assignedTo = "";
        });
        (appData.nonconformities || []).forEach(nc => {
          if (nc.assignedTo === st.name) nc.assignedTo = "";
        });
        // Purge references in risks and threats
        (appData.risks || []).forEach(function(r) {
          if (r.owner === st.name) r.owner = '';
        });
        appData.threats.forEach(function(th) {
          if (th.owner === st.name) th.owner = '';
        });
        (appData.audits || []).forEach(function(a) {
          if (a.auditor === st.name) a.auditor = '';
        });
        (appData.nis2Controls || []).forEach(function(c) {
          if (c.owner === st.name) c.owner = '';
          if (c.reviewer === st.name) c.reviewer = '';
        });
        (appData.recyfControls || []).forEach(function(c) {
          if (c.owner === st.name) c.owner = '';
          if (c.reviewer === st.name) c.reviewer = '';
        });
        // FIX-STAKEHOLDER-CASCADE (audit 2026-07-10) : purge des sections « responsable »
        // désormais reliées aux parties prenantes (datalist).
        (appData.soa || []).forEach(function(e) { if (e.owner === st.name) e.owner = ''; });
        (appData.documentReview || []).forEach(function(e) { if (e.responsible === st.name) e.responsible = ''; });
        (appData.controls || []).forEach(function(c) { if (c.owner === st.name) c.owner = ''; });
        (appData.soclePillars || []).forEach(function(p) { if (p && p.owner === st.name) p.owner = ''; });
        // Lot 1a actifs (2026-09) : purge du propriétaire métier des actifs critiques.
        (appData.criticalAssets || []).forEach(function(a) { if (a.owner === st.name) a.owner = ''; });
        if (appData.nis2Plan) {
          (appData.nis2Plan.domains || []).forEach(function(d) {
            (d.actions || []).forEach(function(a) { if (a.owner === st.name) a.owner = ''; });
          });
          (appData.nis2Plan.budgetLines || []).forEach(function(l) { if (l.owner === st.name) l.owner = ''; });
        }
      }
            addAuditEntry('delete', 'stakeholders', st ? st.name : stakeholderId);
      saveData();
      // (audit global 2026-07-03 ; complété revue effets de bord) la suppression cascade sur
      // actions/nonconformities (assignedTo), audits (auditor) ET risks/threats (owner) — toutes
      // purgées ci-dessus, sections distinctes. Persistance explicite en mode serveur (sinon
      // références orphelines durables vers un intervenant supprimé). No-op hors mode serveur.
      _csiPersistAffectedTabs(['actions', 'nonconformities', 'audits', 'risks', 'threats', 'nis2Controls', 'recyfControls', 'soa', 'documentReview', 'controls', 'soclePillars', 'nis2Plan', 'criticalAssets']);
      loadStakeholdersTable();
      loadActionsTable();
      loadNonConformitiesTable();
      loadCriticalAssetsTable();
      if (typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable();
      if (typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable();
      if (typeof updateDashboard === 'function') updateDashboard();
    }, { type: 'danger' });
  }
  window.loadStakeholdersTable = loadStakeholdersTable;
  window.addStakeholder = addStakeholder;
  window.editStakeholder = editStakeholder;
function loadDocumentsTable() {
    const tbody = document.getElementById("documentsTableBody");
    if (!tbody) return;
    if (_csiRenderConsolidatedSectionIfNeeded('documents')) return;
    tbody.innerHTML = "";
    if (!Array.isArray(appData.documents) || appData.documents.length === 0) {
      var _er = document.createElement('tr'); var _et = document.createElement('td');
      _et.setAttribute('colspan', '4'); _et.className = 'table__empty-state';
      var _ei = document.createElement('i'); _ei.className = 'fas fa-book';
      _ei.style.cssText = 'opacity:0.3;font-size:1.5rem;display:block;margin-bottom:8px';
      _et.appendChild(_ei); _et.appendChild(document.createTextNode(t('emptyStates.noDocuments', 'Aucun document enregistré. Cliquez sur "Nouveau Document" pour commencer.')));
      _er.appendChild(_et); tbody.appendChild(_er); return;
    }
    const _s = SectionToolbar.getSort("documents");
    const _q = (SectionToolbar.getSearch("documents") || "").trim().toLowerCase(); // Toolbar v2 : recherche plein texte
    const _sortDir = _s ? _s.dir : "asc";
    const _sortKey = (_s && _s.key) || "level";
    // Lot 1b : tri par défaut = Niveau selon la pyramide documentaire, puis Type.
    const _docLevelOrder = { "Politique": 1, "Processus": 2, "Procédure": 3, "Enregistrement": 4 };
    const sortedDocuments = [...appData.documents]
      .filter((d) => !_q || ((d.category || "") + " " + (d.type || "") + " " + (d.tool || "")).toLowerCase().indexOf(_q) >= 0)
      .sort((a, b) => {
      var diff;
      if (_sortKey === "type") {
        diff = SortUtils.text(a.type || "", b.type || "");
      } else {
        diff = ((_docLevelOrder[a.category] || 99) - (_docLevelOrder[b.category] || 99))
          || SortUtils.text(a.type || "", b.type || "");
      }
      return _sortDir === "desc" ? -diff : diff;
    });
    sortedDocuments.forEach((doc) => {
      const row = document.createElement("tr");
      // Lot 1b : Niveau en badge ; Lien = bouton-icône « Ouvrir » (URL complète en title).
      row.innerHTML = `
            <td>${doc.category ? `<span class="audit-cat-badge">${escapeHtml(doc.category)}</span>` : "—"}</td>
            <td>${escapeHtml(doc.type || "")}</td>
            <td>${escapeHtml(doc.tool || "")}</td>
            <td>${doc.link && isSafeUrl(doc.link) ? `<a class="pr-link-btn" href="${escapeHtml(doc.link)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(doc.link)}"><i class="fas fa-external-link-alt"></i> ${escapeHtml(t('common.open', 'Ouvrir'))}</a>` : (doc.link ? escapeHtml(doc.link) : "—")}</td>
        `;
      row.classList.add("row-clickable");
      row.addEventListener("click", (e) => {
        if (e.target.closest(".icon-btn--danger")) return;
        if (e.target.closest("a")) return; // Don't open edit modal when clicking a link
        editDocument(doc.id);
      });
      tbody.appendChild(row);
    });
}
function addDocument() {
    openModal(t('modals.addDocument'), [
      { name: "category", label: t('fields.level'), type: "select", options: [
        { value: "Politique", label: t('docCategories.policy', 'Politique') },
        { value: "Processus", label: t('docCategories.process', 'Processus') },
        { value: "Procédure", label: t('docCategories.procedure', 'Procédure') },
        { value: "Enregistrement", label: t('docCategories.record', 'Enregistrement') }
      ] },
      { name: "type", label: t('fields.deliverableType', 'Type de livrable') },
      { name: "tool", label: t('fields.toolUsed', 'Outil utilisé') },
      { name: "link", label: t('fields.accessLink', 'Lien d\'accès') }
    ], (data) => {
      if (!data.type) return false;
      const id = generateId("doc");
      appData.documents.push({ id, category: data.category, type: data.type, tool: data.tool, link: data.link });
            addAuditEntry('add', 'documents', data.type);
      saveData();
      loadDocumentsTable();
      updateDashboard();
    });
}
function editDocument(id) {
    const doc = appData.documents.find((d) => d.id === id);
    if (!doc) return;
    var docCategoryOptions = [
      { value: "Politique", label: t('docCategories.policy', 'Politique') },
      { value: "Processus", label: t('docCategories.process', 'Processus') },
      { value: "Procédure", label: t('docCategories.procedure', 'Procédure') },
      { value: "Enregistrement", label: t('docCategories.record', 'Enregistrement') }
    ];
    if (doc.category && !docCategoryOptions.some(function(o) { return o.value === doc.category; })) {
      docCategoryOptions.push({ value: doc.category, label: doc.category });
    }
    openModal(t('modals.editDocument'), [
      { name: "category", label: t('fields.level'), type: "select", options: docCategoryOptions, value: doc.category },
      { name: "type", label: t('fields.deliverableType', 'Type de livrable'), value: doc.type },
      { name: "tool", label: t('fields.toolUsed', 'Outil utilisé'), value: doc.tool },
      { name: "link", label: t('fields.accessLink', 'Lien d\'accès'), value: doc.link }
    ], (data) => {
      doc.type = data.type;
      doc.category = data.category;
      doc.tool = data.tool;
      doc.link = data.link;
            addAuditEntry('edit', 'documents', doc.type);
      saveData();
      loadDocumentsTable();
      updateDashboard();
    }, () => deleteDocument(id));
}
function deleteDocument(id) {
    showConfirmModal(t('confirms.deleteDocument'), t('confirms.deleteDocument'), function() {
      var _doc = appData.documents.find(function(d) { return d.id === id; });
      appData.documents = appData.documents.filter((d) => d.id !== id);
            addAuditEntry('delete', 'documents', _doc ? _doc.type : id);
      saveData();
      loadDocumentsTable();
      updateDashboard();
    }, { type: 'danger' });
}
window.loadDocumentsTable = loadDocumentsTable;
window.addDocument = addDocument;
window.editDocument = editDocument;
window.deleteDocument = deleteDocument;
  window.deleteStakeholder = deleteStakeholder;

  // modules/setup.js
  function setupNavigation() {
    const navItems = document.querySelectorAll(".nav__item");
    const modules = document.querySelectorAll(".module");
    navItems.forEach((item) => {
      item.addEventListener("click", function() {
        const tabId = this.getAttribute("data-tab");
        navItems.forEach((nav) => { nav.classList.remove("nav__item--active"); nav.setAttribute('aria-selected', 'false'); });
        this.classList.add("nav__item--active");
        this.setAttribute('aria-selected', 'true');
        modules.forEach((module) => module.classList.remove("module--active"));
        const targetModule = document.getElementById(tabId);
        if (targetModule) {
          targetModule.classList.add("module--active");
          loadModuleContent(tabId);
        } else {
          void 0;
        }
        // Update ribbon: show parent category & move active dot
        updateRibbonActiveCategory(this);
      });
    });
    const title = document.getElementById("appTitle");
    if (title) {
      title.addEventListener("click", () => {
        const dashboardBtn = document.querySelector('.nav__item[data-tab="dashboard"]');
        if (dashboardBtn) dashboardBtn.click();
      });
      title.addEventListener("keypress", (e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          const dashboardBtn = document.querySelector('.nav__item[data-tab="dashboard"]');
          if (dashboardBtn) dashboardBtn.click();
        }
      });
    }
    setupRibbonNavigation();
  }

  function setupRibbonNavigation() {
    const cats = document.querySelectorAll('.nav-ribbon__cat');
    const panels = document.querySelectorAll('.nav-ribbon__panel');
    cats.forEach(function(cat) {
      cat.addEventListener('click', function() {
        cats.forEach(function(c) { c.classList.remove('nav-ribbon__cat--selected'); });
        cat.classList.add('nav-ribbon__cat--selected');
        panels.forEach(function(p) { p.classList.remove('nav-ribbon__panel--active'); });
        var target = cat.dataset.category;
        var panel = document.querySelector('[data-panel="' + target + '"]');
        if (panel) panel.classList.add('nav-ribbon__panel--active');
      });
    });
  }

  function updateRibbonActiveCategory(activeItem) {
    var panel = activeItem.closest('.nav-ribbon__panel');
    if (!panel) return;
    var categoryId = panel.dataset.panel;
    // Move the has-active dot
    var cats = document.querySelectorAll('.nav-ribbon__cat');
    cats.forEach(function(c) { c.classList.remove('nav-ribbon__cat--has-active'); });
    var activeCat = document.querySelector('.nav-ribbon__cat[data-category="' + categoryId + '"]');
    if (activeCat) activeCat.classList.add('nav-ribbon__cat--has-active');
    // Also reveal that category's panel
    cats.forEach(function(c) { c.classList.remove('nav-ribbon__cat--selected'); });
    if (activeCat) activeCat.classList.add('nav-ribbon__cat--selected');
    var panels = document.querySelectorAll('.nav-ribbon__panel');
    panels.forEach(function(p) { p.classList.remove('nav-ribbon__panel--active'); });
    panel.classList.add('nav-ribbon__panel--active');
  }
  function setupKpiCardNavigation() {
    const cards = document.querySelectorAll(".kpi-card[data-nav-target]");
    const goTo = (target) => {
      if (!target) return;
      if (_csiDashboardConsolidatedActive() && _csiIsSectionConsolidable(target)) {
        _csiOpenConsolidatedSection(target);
        return;
      }
      if (typeof switchModule === "function") {
        switchModule(target);
        return;
      }
      const navBtn = document.querySelector(`.nav__item[data-tab="${target}"]`);
      if (navBtn) navBtn.click();
      else loadModuleContent(target);
    };
    cards.forEach((card) => {
      const target = card.dataset.navTarget;
      const handler = () => goTo(target);
      card.addEventListener("click", handler);
      card.addEventListener("keypress", (e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          handler();
        }
      });
    });
  }
  function openAuditLogModule() {
    document.querySelectorAll('.nav__item').forEach(n => n.classList.remove('nav__item--active'));
    document.querySelectorAll('.module').forEach(m => m.classList.remove('module--active'));
    var mod = document.getElementById('auditLog');
    if (mod) mod.classList.add('module--active');
    loadModuleContent('auditLog');
  }

  function initSectionToolbars() {
    if (typeof SectionToolbar === 'undefined') return;

    // 1. Risks (sort-only)
    SectionToolbar.init("risks", {
      search: true,
      sort: {
        options: [
          { key: "level", label: t('sort.level', 'Niveau'), default: true, dir: "asc" },
          { key: "title", label: t('sort.title', 'Titre') },
          { key: "impact", label: t('sort.impact', 'Impact') },
          { key: "probability", label: t('sort.probability', 'Probabilité') }
        ]
      },
      onUpdate: function() { loadRisksTable(); }
    });

    // 2. Threats (sort-only)
    SectionToolbar.init("threats-section", {
      search: true,
      sort: {
        options: [
          { key: "level", label: t('sort.level', 'Niveau'), default: true, dir: "asc" },
          { key: "title", label: t('sort.title', 'Titre') },
          { key: "score", label: t('sort.score', 'Score') },
          { key: "status", label: t('sort.status', 'Statut') }
        ]
      },
      onUpdate: function() { loadThreatsTable(); }
    });

    // 3. Audits (sort-only)
    SectionToolbar.init("audits", {
      search: true,
      sort: {
        options: [
          { key: "date", label: t('sort.date', 'Date'), default: true, dir: "desc" },
          { key: "title", label: t('sort.title', 'Titre') },
          { key: "type", label: t('sort.type', 'Type') },
          { key: "status", label: t('sort.status', 'Statut') }
        ]
      },
      onUpdate: function() { loadAuditsTable(); }
    });

    // 4. Management Reviews (sort-only)
    SectionToolbar.init("managementReviews", {
      sort: {
        options: [
          { key: "date", label: t('sort.date', 'Date'), default: true, dir: "desc" }
        ]
      },
      onUpdate: function() { loadReviewsTable(); }
    });

    // 5. Stakeholders — Lot 1b : + filtre par rôle
    SectionToolbar.init("stakeholders", {
      search: true,
      filters: [
        { key: "role", label: t('stakeholders.filterRole', 'Rôle'), options: [] }
      ],
      sort: {
        options: [
          { key: "name", label: t('sort.name', 'Nom'), default: true, dir: "asc" }
        ]
      },
      onUpdate: function() { loadStakeholdersTable(); }
    });

    // 6. Documents (sort-only) — Lot 1b : défaut = Niveau (pyramide documentaire) puis Type
    SectionToolbar.init("documents", {
      search: true,
      sort: {
        options: [
          { key: "level", label: t('sort.level', 'Niveau'), default: true, dir: "asc" },
          { key: "type", label: t('documents.typeCol', 'Type') }
        ]
      },
      onUpdate: function() { loadDocumentsTable(); }
    });

    // 7. KPIs (sort-only)
    SectionToolbar.init("kpis", {
      sort: {
        options: [
          { key: "name", label: t('sort.name', 'Nom'), default: true, dir: "asc" },
          { key: "value", label: t('sort.value', 'Valeur') }
        ]
      },
      onUpdate: function() { loadKpiTable(); }
    });

    // 8. Project Risks (search + filters + sort) — Toolbar v2 : remplace la barre
    // legacy .project-risks__filters (recherche, Bénéficiaire, Statut, checkbox
    // « Recommandations non implémentées » devenue un select à 1 option, Réinitialiser).
    SectionToolbar.init("project-risks", {
      search: true,
      filters: [
        { key: "beneficiary", label: t('projectRisks.beneficiaryLabel', 'Bénéficiaire'), allLabel: t('projectRisks.allBeneficiaries', 'Tous les bénéficiaires'), options: [] },
        { key: "status", label: t('projectRisks.statusFilterLabel', 'Statut'), allLabel: t('projectRisks.allStatuses', 'Tous les statuts'), options: PROJECT_RISK_STATUS_OPTIONS },
        { key: "pending", label: t('projectRisks.recoTitle', 'Recommandations'), options: [
          { value: "pending", label: t('projectRisks.pendingFilter', 'Recommandations non implémentées') }
        ]}
      ],
      sort: {
        options: [
          { key: "priority", label: t('projectRisks.sortPriority', 'Priorité'), default: true, dir: "asc" },
          { key: "name", label: t('projectRisks.sortName', 'Nom du projet') },
          { key: "beneficiary", label: t('projectRisks.sortBeneficiary', 'Bénéficiaire') },
          { key: "status", label: t('projectRisks.sortStatus', 'Statut') },
          { key: "date", label: t('projectRisks.sortDate', 'Date') }
        ]
      },
      onUpdate: function() { loadProjectRisksTable(); }
    });

    // 9. Controls (filters + sort)
    SectionToolbar.init("controls", {
      search: true,
      filters: [
        { key: "category", label: t('controlsISO.allDomains', 'Tous les domaines'), options: [
          { value: "Organisationnel", label: t('domains.organisationnel', 'Organisationnel') + " (5.x)" },
          { value: "Humain", label: t('domains.humain', 'Humain') + " (6.x)" },
          { value: "Physique", label: t('domains.physique', 'Physique') + " (7.x)" },
          { value: "Technologique", label: t('domains.technologique', 'Technologique') + " (8.x)" }
        ]},
        { key: "status", label: t('fields.status', 'Statut'), options: CONTROL_STATUS_OPTIONS },
        { key: "cmm", label: t('sort.maturity', 'Maturité'), options: CMM_LEVEL_OPTIONS }
      ],
      sort: {
        options: [
          { key: "reference", label: t('sort.reference', 'Réf.'), default: true, dir: "asc" },
          { key: "title", label: t('fields.title', 'Titre') },
          { key: "status", label: t('fields.status', 'Statut') },
          { key: "maturity", label: t('sort.maturity', 'Maturité') }
        ]
      },
      onUpdate: function() { loadControlsTable(); }
    });

    // 10. NIS2 Controls (filters + sort)
    SectionToolbar.init("nis2-controls", {
      search: true,
      filters: [
        { key: "function", label: t('controlsNIS2.filterFunction', 'Fonction'), options: [] },
        { key: "category", label: t('controlsNIS2.filterCategory', 'Catégorie'), advanced: true, options: [] },
        { key: "level", label: t('controlsNIS2.filterLevel', 'Niveau'), advanced: true, options: [
          { value: "basic", label: t('cyfun.levelBasic', 'BASIC') },
          { value: "important", label: t('cyfun.levelImportant', 'IMPORTANT') },
          { value: "essential", label: t('cyfun.levelEssential', 'ESSENTIAL') }
        ]},
        { key: "keyMeasure", label: t('controlsNIS2.filterMeasure', 'Mesure'), advanced: true, options: [
          { value: "key", label: t('controlsNIS2.keyMeasures', 'Mesures clés') },
          { value: "other", label: t('controlsNIS2.otherMeasures', 'Autres mesures') }
        ]},
        { key: "scoring", label: t('controlsNIS2.filterScoring', 'Évaluation'), options: [
          { value: "scored", label: t('controlsNIS2.scored', 'Évalués') },
          { value: "unscored", label: t('controlsNIS2.unscored', 'Non évalués') }
        ]},
        { key: "owner", label: t('controlsNIS2.filterOwner', 'Resp. mesure'), advanced: true, options: [] },
        { key: "reviewer", label: t('controlsNIS2.filterReviewer', 'Resp. contrôle'), advanced: true, options: [] },
        { key: "reviewDue", label: t('controlsNIS2.filterReview', 'Revue'), advanced: true, options: [] }
      ],
      sort: {
        options: [
          { key: "reference", label: t('sort.reference', 'Réf.'), default: true, dir: "asc" },
          { key: "function", label: t('sort.function', 'Fonction') },
          { key: "owner", label: t('controlsNIS2.filterOwner', 'Resp. mesure') },
          { key: "reviewer", label: t('controlsNIS2.filterReviewer', 'Resp. contrôle') },
          { key: "nextReviewDate", label: t('controlsNIS2.nextReviewDate', 'Prochaine revue') },
          { key: "docMaturity", label: t('sort.docMaturity', 'Doc.') },
          { key: "implMaturity", label: t('sort.implMaturity', 'Impl.') }
        ]
      },
      onUpdate: function() { loadNis2ControlsTable(); }
    });

    // 10b. ReCyF Controls (France / ANSSI) — filtres + tri
    SectionToolbar.init("recyf-controls", {
      search: true,
      filters: [
        { key: "objective", label: t('recyfControls.filterObjective', 'Objectif'), options: [] },
        { key: "theme", label: t('recyfControls.filterTheme', 'Thématique'), advanced: true, options: [] },
        { key: "target", label: t('recyfControls.filterTarget', 'Cible'), advanced: true, options: [
          { value: "EI", label: t('recyfControls.eiEe', 'Entités Importante et Essentielle') },
          { value: "EE_only", label: t('recyfControls.eeOnly', 'Entité Essentielle uniquement') }
        ]},
        { key: "conformity", label: t('recyfControls.filterConformity', 'Conformité'), options: (window.RECYF_CONFORMITY_OPTIONS || []).map(function(o) {
          return { value: o.value, label: _recyfOptLabel(o) };
        }) },
        { key: 'owner', label: t('recyfControls.filterOwner', 'Resp. mesure'), advanced: true, options: [] },
        { key: 'reviewer', label: t('recyfControls.filterReviewer', 'Resp. contrôle'), advanced: true, options: [] },
        { key: 'reviewDue', label: t('recyfControls.filterReview', 'Revue'), advanced: true, options: [] }
      ],
      sort: {
        options: [
          { key: "reference", label: t('sort.reference', 'Réf.'), default: true, dir: "asc" },
          { key: "objective", label: t('recyfControls.objective', 'Objectif') },
          { key: "owner", label: t('recyfControls.filterOwner', 'Resp. mesure') },
          { key: "reviewer", label: t('recyfControls.filterReviewer', 'Resp. contrôle') },
          { key: "nextReviewDate", label: t('recyfControls.nextReviewDate', 'Prochaine revue') }
        ]
      },
      onUpdate: function() { loadRecyfControlsTable(); }
    });

    // 11. SoA (filters + sort)
    SectionToolbar.init("soa", {
      search: true,
      filters: [
        { key: "applicability", label: t('soa.applicability', 'Applicabilité'), options: [
          { value: "selected", label: t('soa.applicable', 'Applicable') },
          { value: "not_selected", label: t('soa.nonApplicable', 'Non applicable') }
        ]},
        { key: "domain", label: t('controlsISO.allDomains', 'Tous les domaines'), options: [
          { value: "Organisationnel", label: t('domains.organisationnel', 'Organisationnel') },
          { value: "Humain", label: t('domains.humain', 'Humain') },
          { value: "Physique", label: t('domains.physique', 'Physique') },
          { value: "Technologique", label: t('domains.technologique', 'Technologique') }
        ]}
      ],
      sort: {
        options: [
          { key: "reference", label: t('sort.reference', 'Réf.'), default: true, dir: "asc" },
          { key: "applicability", label: t('soa.applicability', 'Applicabilité') },
          { key: "implementation", label: t('sort.implementation', 'Implémentation') }
        ]
      },
      onUpdate: function() { loadSoaTable(); }
    });

    // 12. Document Review (filters + sort)
    SectionToolbar.init("docReview", {
      filters: [
        { key: "status", label: t('sort.status', 'Statut'), options: [
          { value: "conforme", label: t('docReview.conforming', 'Conformes') },
          { value: "partiel", label: t('docReview.partial', 'Partiels') },
          { value: "non_conforme", label: t('docReview.nonConforming', 'Non conformes') },
          { value: "na", label: t('soa.notApplicable', 'Non applicable') }
        ]}
      ],
      sort: {
        options: [
          { key: "clause", label: t('sort.clause', 'Clause'), default: true, dir: "asc" },
          { key: "status", label: t('sort.status', 'Statut') },
          { key: "lastReview", label: t('sort.lastReview', 'Dernière revue') }
        ]
      },
      onUpdate: function() { loadDocReviewTable(); }
    });

    // 13. Actions (filters + sort)
    SectionToolbar.init("actions", {
      search: true,
      filters: [
        { key: "filterType", label: t('sort.type', 'Type'), options: [
          { value: "risk", label: t('actionLinkTypes.risk', 'Risque') },
          { value: "nc", label: t('actionLinkTypes.nc', 'Non-conformité') },
          { value: "audit", label: t('actionLinkTypes.audit', 'Audit') },
          { value: "threat", label: t('actionLinkTypes.threat', 'Menace') },
          { value: "owner", label: t('sort.owner', 'Responsable') },
          { value: "continuous", label: t('actionLinkTypes.continuous', 'Amélioration continue') }
        ]},
        { key: "filterValue", label: t('sort.value', 'Valeur'), options: [] },
        { key: "status", label: t('sort.status', 'Statut'), options: [
          { value: "planifie", label: t('actionStatuses.planifie', 'Planifié') },
          { value: "en_cours", label: t('actionStatuses.enCours', 'En cours') },
          { value: "termine", label: t('actionStatuses.termine', 'Terminé') },
          { value: "annule", label: t('actionStatuses.annule', 'Annulé') }
        ]}
      ],
      sort: {
        options: [
          { key: "priority", label: t('sort.priority', 'Priorité'), default: true, dir: "asc" },
          { key: "id", label: t('sort.id', 'ID') },
          { key: "status", label: t('sort.status', 'Statut') },
          { key: "dueDate", label: t('sort.dueDate', 'Échéance') },
          { key: "progress", label: t('sort.progress', 'Progression') },
          { key: "link", label: t('sort.link', 'Lien') }
        ]
      },
      onUpdate: function() { loadActionsTable(); }
    });

    // 14. Non-conformities (filters + sort)
    SectionToolbar.init("nonconformities", {
      search: true,
      filters: [
        { key: "audit", label: t('sort.audit', 'Audit'), options: [] },
        { key: "owner", label: t('sort.owner', 'Responsable'), options: [] }
      ],
      sort: {
        options: [
          { key: "type", label: t('sort.type', 'Type'), default: true, dir: "asc" },
          { key: "status", label: t('sort.status', 'Statut') },
          { key: "date", label: t('sort.date', 'Date') },
          { key: "audit", label: t('sort.audit', 'Audit') },
          { key: "owner", label: t('sort.owner', 'Responsable') }
        ]
      },
      onUpdate: function() { loadNonConformitiesTable(); }
    });

    // 15. Critical Assets (filters + sort)
    SectionToolbar.init("critical-assets", {
      search: true,
      filters: [
        { key: "beneficiary", label: t('criticalAssets.filterBeneficiary', 'Bénéficiaire'), options: [] },
        { key: "availability", label: t('criticalAssets.filterAvailability', 'Disponibilité'), options: [] },
        { key: "impact", label: t('criticalAssets.filterImpact', 'Impact'), options: [] }
      ],
      sort: {
        options: [
          { key: "priority", label: t('sort.priority', 'Priorité'), default: true, dir: "asc" },
          { key: "name", label: t('sort.name', 'Nom') }
        ]
      },
      onUpdate: function() { loadCriticalAssetsTable(); }
    });
  }

  // ── Refonte header 2026-07 : menu utilisateur ─────────────────────────────
  // Clone du pattern accessible de _buildScopeSwitcher (modules/entity-ui.js) :
  // aria-haspopup/expanded, role=menu/menuitem, Échap + refocus, flèches haut/bas,
  // fermeture au clic externe (mousedown capture). Le déclencheur est un avatar
  // (initiales du user courant en mode serveur, icône « ⋯ » en local sans login).
  function _userInitials(name) {
    var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
    var ini = parts.length >= 2
      ? parts[0].charAt(0) + parts[1].charAt(0)
      : String(name || "").trim().slice(0, 2);
    return ini.toUpperCase();
  }

  function refreshUserMenuIdentity() {
    var avatar = document.getElementById("userMenuAvatar");
    var nameEl = document.getElementById("userMenuName");
    var head = document.getElementById("userMenuHead");
    var headName = document.getElementById("userMenuHeadName");
    var headRole = document.getElementById("userMenuHeadRole");
    var trigger = document.getElementById("userMenuBtn");
    var user = null;
    if (typeof isServerModeUnlocked === "function" && isServerModeUnlocked()
        && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn && window.CSI_AuthManager.isLoggedIn()) {
      user = window.CSI_AuthManager.getCurrentUser();
    }
    if (user && user.name) {
      // textContent uniquement : le nom vient du serveur, jamais interprété en HTML.
      // className remis à l'état "avatar" : on peut arriver ici depuis l'état menu (login).
      if (avatar) { avatar.className = "csi-avatar"; avatar.textContent = _userInitials(user.name); }
      if (nameEl) nameEl.textContent = user.name;
      var roleKey = user.role === "admin" ? "users.roleAdmin"
        : (user.role === "viewer" ? "users.roleViewer" : "users.roleEditor");
      if (head) head.style.display = "";
      if (headName) headName.textContent = user.name;
      if (headRole) headRole.textContent = t(roleKey);
      if (trigger) trigger.title = user.name;
    } else {
      // Aucun compte connecté (éditions locales) : ce n'est pas un avatar mais un bouton de
      // menu applicatif (Paramètres, Traçabilité, Manuel, Thème) → engrenage, traitement allégé.
      if (avatar) {
        avatar.className = "csi-avatar csi-avatar--menu";
        avatar.textContent = "";
        var _menuIcon = document.createElement("i");
        _menuIcon.className = "fas fa-gear";
        _menuIcon.setAttribute("aria-hidden", "true");
        avatar.appendChild(_menuIcon);
      }
      if (nameEl) nameEl.textContent = "";
      if (head) head.style.display = "none";
      if (trigger) trigger.title = "";
    }
  }

  function initUserMenu() {
    var wrap = document.getElementById("csiUserMenu");
    if (!wrap || wrap._csiUserMenuInit) { refreshUserMenuIdentity(); return; }
    wrap._csiUserMenuInit = true;
    var trigger = document.getElementById("userMenuBtn");
    var menu = wrap.querySelector(".csi-usermenu__menu");
    if (!trigger || !menu) return;
    var isOpen = false;
    function onDocDown(ev) {
      if (!wrap.contains(ev.target)) close(false);
    }
    function close(refocus) {
      if (!isOpen) return;
      isOpen = false;
      wrap.classList.remove("csi-usermenu--open");
      trigger.setAttribute("aria-expanded", "false");
      document.removeEventListener("mousedown", onDocDown, true);
      if (refocus) { try { trigger.focus(); } catch (e) { /* environnement sans focus */ } }
    }
    function openMenu() {
      if (isOpen) return;
      isOpen = true;
      wrap.classList.add("csi-usermenu--open");
      trigger.setAttribute("aria-expanded", "true");
      document.addEventListener("mousedown", onDocDown, true);
    }
    function _navItems() {
      var all = menu.querySelectorAll("button.csi-usermenu__item");
      var out = [];
      for (var i = 0; i < all.length; i++) {
        if (all[i].offsetParent !== null || all[i].getClientRects().length) out.push(all[i]);
      }
      return out;
    }
    trigger.addEventListener("click", function() { if (isOpen) close(true); else openMenu(); });
    // Un clic sur un item ferme le menu — SAUF le thème : il cycle 4 thèmes, on laisse
    // le menu ouvert pour enchaîner (le libellé « Thème : … » se met à jour en direct).
    menu.addEventListener("click", function(ev) {
      var node = ev.target;
      while (node && node !== menu && !(node.classList && node.classList.contains("csi-usermenu__item"))) {
        node = node.parentNode;
      }
      if (!node || node === menu) return;
      if (node.id === "themeToggleBtn") return;
      close(true);
    });
    wrap.addEventListener("keydown", function(ev) {
      if (ev.key === "Escape") {
        if (isOpen) { ev.preventDefault(); ev.stopPropagation(); close(true); }
        return;
      }
      if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
      if (!isOpen) { if (ev.key === "ArrowDown") { ev.preventDefault(); openMenu(); } return; }
      var items = _navItems();
      if (!items.length) return;
      ev.preventDefault();
      var idx = -1;
      for (var i = 0; i < items.length; i++) { if (items[i] === document.activeElement) { idx = i; break; } }
      var next = (ev.key === "ArrowDown") ? (idx + 1) % items.length : (idx <= 0 ? items.length - 1 : idx - 1);
      try { items[next].focus(); } catch (e) { /* environnement sans focus */ }
    });
    refreshUserMenuIdentity();
  }

  function setupEventListeners() {
    const mapping = [
      { id: "addControlBtn", handler: addControl },
      { id: "addActionBtn", handler: addAction },
      { id: "addCriticalAssetBtn", handler: () => openCriticalAssetModal() },
      { id: "editCriticalAssetsTitlesBtn", handler: openCriticalAssetsTitlesModal },
      { id: "addProjectRiskBtn", handler: addProjectRisk },
      { id: "manageBudgetCategoriesBtn", handler: () => openBudgetCategoriesModal() },
      { id: "manageBudgetYearsBtn", handler: () => openBudgetYearsModal() },
      { id: "addBudgetLineBtn", handler: () => openBudgetLineModal() },
      { id: "addRiskBtn", handler: addRisk },
      { id: "importRisksBtn", handler: () => window.showImportRisksModal() },
      { id: "addThreatBtn", handler: () => showThreatForm() },
      { id: "proposeThreatsBtn", handler: () => window.showProposeThreatsModal() },
      { id: "addNcBtn", handler: addNC },
      { id: "addAuditBtn", handler: addAudit },
      { id: "addReviewBtn", handler: addReview },
      { id: "addStakeholderBtn", handler: addStakeholder },
      { id: "addDocumentBtn", handler: addDocument },
      { id: "addKpiBtn", handler: addKpi },
      { id: "manualBtn", handler: openManual },
      { id: "settingsBtn", handler: openSettings },
      { id: "serverSyncBtn", handler: handleServerSyncButton },
      { id: "editModeBtn", handler: handleEditModeButton },
      { id: "modeBannerTakeoverBtn", handler: handleEditLockTakeover },
      { id: "modeBannerRequestBtn", handler: handleHandRequestButton }, // === CSI-CO === (Lot 4)
      { id: "auditLogBtn", handler: openAuditLogModule },
      { id: "dashboardActivity", handler: openAuditLogModule },
      { id: "themeToggleBtn", handler: toggleTheme },
      { id: "exportSoaPdfPublic", handler: () => exportSoaPdf("public") },
      { id: "exportSoaPdfAudit", handler: () => exportSoaPdf("audit") },
      { id: "exportDocReviewPdfAudit", handler: exportDocReviewPdf }
    ];
    mapping.forEach(({ id, handler }) => {
      const el = document.getElementById(id);
      if (el) el.addEventListener("click", handler);
    });
    // CyFun level selection is now rendered dynamically in renderFrameworksSection()
    // CyFun level popup - confirm button
    // Prépare la modale NIS2 (1ʳᵉ config) : ajoute le choix du référentiel Belgique/France
    // si ReCyF est disponible. Sans ReCyF → modale CyFun pure (comportement inchangé).
    function prepareNis2LevelModal() {
      var modal = document.getElementById('cyfunLevelModal');
      if (!modal) return;
      // ReCyF est proposé dès qu'il est DISPONIBLE (chargé), pas seulement « activé » : dans le
      // modèle mutuellement exclusif, recyf2025 n'est dans enabledFrameworks que si la France est
      // déjà active — utiliser isEnabled empêcherait un utilisateur Belgique de choisir la France.
      var recyfEnabled = !!(window._csiFrameworks && window._csiFrameworks['recyf2025']);
      var refBlock = document.getElementById('cyfunModalRefBlock');
      var beBlock = document.getElementById('cyfunModalBeBlock');
      var frBlock = document.getElementById('cyfunModalFrBlock');
      var titleEl = document.getElementById('cyfunLevelModalTitle');
      if (!recyfEnabled) {
        if (refBlock) refBlock.style.display = 'none';
        if (frBlock) frBlock.style.display = 'none';
        if (beBlock) beBlock.style.display = '';
        return;
      }
      if (refBlock) refBlock.style.display = '';
      if (titleEl) titleEl.textContent = t('wizard.nis2SetupTitle', 'Référentiel NIS2');
      var curRef = (typeof appData !== 'undefined' && appData.nis2Referential) || 'cyfun2025';
      var refRadio = modal.querySelector('input[name="cyfunModalRefChoice"][value="' + curRef + '"]');
      if (refRadio) refRadio.checked = true;
      var degRadio = modal.querySelector('input[name="cyfunModalDegreeChoice"][value="' + ((typeof appData !== 'undefined' && appData.recyfLevel) || 'importante') + '"]');
      if (degRadio) degRadio.checked = true;
      function syncBlocks() {
        var sel = modal.querySelector('input[name="cyfunModalRefChoice"]:checked');
        var v = sel ? sel.value : 'cyfun2025';
        if (beBlock) beBlock.style.display = (v === 'cyfun2025') ? '' : 'none';
        if (frBlock) frBlock.style.display = (v === 'recyf2025') ? '' : 'none';
      }
      if (!modal._nis2RefWired) {
        var radios = modal.querySelectorAll('input[name="cyfunModalRefChoice"]');
        for (var i = 0; i < radios.length; i++) radios[i].addEventListener('change', syncBlocks);
        modal._nis2RefWired = true;
      }
      syncBlocks();
    }
    window.prepareNis2LevelModal = prepareNis2LevelModal;

    const cyfunLevelConfirmBtn = document.getElementById("cyfunLevelConfirmBtn");
    if (cyfunLevelConfirmBtn) {
      cyfunLevelConfirmBtn.addEventListener("click", function() {
        // Référentiel NIS2 : France (ReCyF) si choisi dans la modale, sinon Belgique (CyFun).
        var _refBlock = document.getElementById('cyfunModalRefBlock');
        var _refChoiceEl = (_refBlock && _refBlock.style.display !== 'none')
          ? document.querySelector('input[name="cyfunModalRefChoice"]:checked') : null;
        if (_refChoiceEl && _refChoiceEl.value === 'recyf2025') {
          var _degEl = document.querySelector('input[name="cyfunModalDegreeChoice"]:checked');
          var _degree = _degEl ? _degEl.value : 'importante';
          appData.nis2Referential = 'recyf2025';
          appData._nis2ReferentialLocked = true; // 1er choix confirmé -> référentiel figé (Reset pour changer)
          if (typeof setRecyfDegree === 'function') setRecyfDegree(_degree);
          appData._cyfunLevelChosen = true;
          if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
          if (window.CSI_FrameworkMapper && typeof CSI_FrameworkMapper.applyFrameworkVisibility === 'function') CSI_FrameworkMapper.applyFrameworkVisibility();
          else if (typeof applyNis2Referential === 'function') applyNis2Referential(false);
          if (typeof saveData === 'function') saveData();
          // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
          // FIX-CONFIG-PERSIST-LOCK (revue prod 2026-06-11)
          var _recyfPersist = _persistConfigWithLock(['nis2Referential', '_nis2ReferentialLocked', 'recyfLevel', 'recyfControls', '_cyfunLevelChosen', 'frameworkMapper']);
          if (typeof updateDashboard === 'function') updateDashboard();
          var _m = document.getElementById("cyfunLevelModal");
          if (_m) _m.classList.remove("modal-overlay--visible");
          // FIX-N1 (audit co-édition 2026-07-17) : succès annoncé seulement si le serveur a
          // accepté (sinon le warning de _persistConfigWithLock a déjà parlé — toast menteur avant).
          _recyfPersist.then(function(r) {
            if (r && r.success === false) return;
            showToast(t('toasts.recyfConfigured', 'Référentiel NIS2 : France (ReCyF) — {{deg}}').replace('{{deg}}', _degree === 'essentielle' ? 'EE' : 'EI'), 'success');
          });
          return;
        }
        appData.nis2Referential = 'cyfun2025';
        appData._nis2ReferentialLocked = true; // 1er choix confirmé -> référentiel figé (Reset pour changer)
        var selected = document.querySelector('input[name="cyfunLevelChoice"]:checked');
        var level = selected ? selected.value : 'basic';
        // Language choice
        var langSelected = document.querySelector('input[name="cyfunLangChoice"]:checked');
        var lang = langSelected ? langSelected.value : 'en';
        appData.cyfunLang = lang;
        setCyfunAssuranceLevel(level);
        recalcNis2Maturity();
        appData._cyfunLevelChosen = true;
        if (typeof reconcileNis2Frameworks === 'function') reconcileNis2Frameworks();
        if (window.CSI_FrameworkMapper && typeof CSI_FrameworkMapper.applyFrameworkVisibility === 'function') CSI_FrameworkMapper.applyFrameworkVisibility();
        else if (typeof applyNis2Referential === 'function') applyNis2Referential(false);
        // Sync settings dropdown
        var _s = document.getElementById('cyfunLevelSelect');
        if (_s) _s.value = level;
        // Reset filters via SectionToolbar
        SectionToolbar.updateFilterOptions('nis2-controls', 'function', []);
        SectionToolbar.updateFilterOptions('nis2-controls', 'category', []);
        loadNis2ControlsTable();
        if (typeof updateNis2RadarChart === 'function') updateNis2RadarChart();
        saveData();
        // FIX-GLOBAL-SYNC: Push cyfunLevel choice to server for other users
        // FIX-FULLSYNC-STALE: clés déclarées — ne pas écraser l'état serveur complet
        // FIX-N1 (audit co-édition 2026-07-17) : cette branche poussait en DIRECT via
        // forceServerSync + .catch muet — or un POST config exige les 4 grappes côté serveur :
        // 423 systématique en mode serveur, choix jamais persisté, modale en boucle à chaque
        // rechargement. Alignée sur la branche ReCyF : _persistConfigWithLock acquiert les
        // grappes nécessaires, et le toast de succès attend la réponse du serveur.
        var _cyfunPersist = _persistConfigWithLock(['nis2Referential', '_nis2ReferentialLocked', 'cyfunLevel', 'cyfunLang', '_cyfunLevelChosen', 'nis2Controls', '_cyfunEvalArchive', 'targetMaturity', 'frameworkMapper']);
        if (typeof updateDashboard === 'function') updateDashboard();
        // Hide modal
        var modal = document.getElementById("cyfunLevelModal");
        if (modal) { modal.classList.remove("modal-overlay--visible"); }
        _cyfunPersist.then(function(r) {
          if (r && r.success === false) return; // le warning de _persistConfigWithLock a parlé
          showToast(t('toasts.nis2LevelConfigured', 'Niveau NIS2 configuré : {{level}} ({{lang}})').replace('{{level}}', level.toUpperCase()).replace('{{lang}}', (lang === 'fr' ? 'Fran\u00e7ais' : (lang === 'nl' ? 'Nederlands' : 'English'))), 'success');
        });
      });
    }
    // CyFun level modal — add Skip button and Escape key handler
    (function() {
      var cyfunModal = document.getElementById("cyfunLevelModal");
      if (!cyfunModal) return;
      // Add "Passer" (Skip) button next to confirm button
      var footer = cyfunModal.querySelector(".modal__footer");
      if (footer && !document.getElementById("cyfunLevelSkipBtn")) {
        var skipBtn = document.createElement("button");
        skipBtn.id = "cyfunLevelSkipBtn";
        skipBtn.className = "btn btn--secondary";
        skipBtn.type = "button";
        skipBtn.textContent = t('common.skip', 'Passer');
        skipBtn.style.marginRight = "8px";
        footer.insertBefore(skipBtn, footer.firstChild);
        skipBtn.addEventListener("click", function() {
          cyfunModal.classList.remove("modal-overlay--visible");
        });
      }
      // Escape key handler for cyfunLevelModal
      cyfunModal._cyfunEscHandler = function(evt) {
        if (evt.key === "Escape" && cyfunModal.classList.contains("modal-overlay--visible")) {
          cyfunModal.classList.remove("modal-overlay--visible");
          evt.preventDefault();
          evt.stopPropagation();
        }
      };
      document.addEventListener("keydown", cyfunModal._cyfunEscHandler);
    })();
    // Toolbar v2 : la barre legacy Risques projets a été retirée — recherche,
    // filtres, tri et réinitialisation sont gérés par la SectionToolbar.
    setupExcelExportButtons();
    setupKpiCardNavigation();
    setupDashboardInteractions();
    initSectionToolbars();
    // beforeunload handler is registered in initAutoSave() - no duplicate needed here
  }

  // Interactions Dashboard (filtres, export, refresh)
  function setupDashboardInteractions() {
    // Dashboard time filters (30j, 90j, 1an) — masqués tant que l'échantillonnage
    // historique par période n'est pas implémenté (ils ne filtraient rien et
    // paraissaient cassés grisés). À ré-afficher le jour où les périodes seront gérées.
    const chartFilters = document.getElementById('chartFilters');
    if (chartFilters) {
      chartFilters.style.display = 'none';
    }

    // Bouton export PDF
    const exportBtn = document.getElementById('exportDashboardBtn');
    if (exportBtn) {
      exportBtn.addEventListener('click', exportDashboardPDF);
    }
  }

  // Export Dashboard en PDF
  function exportDashboardPDF() {
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export Dashboard');
      return;
    }
    if (_exportBusy) return;
    _exportBusy = true;
    showToast2(t('exportLabels.preparingPdf'), 'info');
    var _dashBtn = document.getElementById('exportDashboardBtn');
    if (_dashBtn) _dashBtn.disabled = true;

    // (audit 2026-07-10) html2canvas n'est PAS chargé par index.html : il l'était seulement en
    // lazy par l'export NIS2, donc sur une session fraîche le bouton retombait silencieusement sur
    // l'impression au lieu d'un vrai PDF. On le charge à la demande (comme nis2-pdf-client.js) et on
    // ne bascule sur fallbackPrintPDF que si le chargement échoue réellement.
    var _runDashCapture = function() {
      const dashboard = document.getElementById('dashboard');
      // (audit global 2026-07-03, thème×impression) html2canvas capture le DOM VIVANT — @media
      // print ne s'y applique pas. On neutralise le thème sombre le temps de la capture (fond
      // blanc, texte foncé) puis on restaure, pour un PDF de dashboard lisible et sobre.
      var _prevTheme = document.body.className.match(/theme-[a-z-]+/g) || [];
      var _restoreTheme = function() {
        document.body.classList.remove('theme-light');
        _prevTheme.forEach(function(c) { document.body.classList.add(c); });
      };
      _prevTheme.forEach(function(c) { document.body.classList.remove(c); });
      document.body.classList.add('theme-light');
      html2canvas(dashboard, {
        scale: 2,
        useCORS: true,
        logging: false,
        backgroundColor: '#ffffff'
      }).then(canvas => {
        _restoreTheme();
        const dateStamp = new Date().toISOString().split('T')[0];
        const { jsPDF } = window.jspdf || {};
        if (jsPDF) {
          // Génerer un vrai PDF avec l'image du dashboard
          const imgData = canvas.toDataURL('image/png');
          const pxW = canvas.width;
          const pxH = canvas.height;
          const orientation = pxW > pxH ? 'landscape' : 'portrait';
          const pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' });
          const pageW = pdf.internal.pageSize.getWidth();
          const pageH = pdf.internal.pageSize.getHeight();
          const margin = 10;
          const maxW = pageW - 2 * margin;
          const maxH = pageH - 2 * margin;
          const ratio = Math.min(maxW / pxW, maxH / pxH);
          const imgW = pxW * ratio;
          const imgH = pxH * ratio;
          const x = (pageW - imgW) / 2;
          const y = (pageH - imgH) / 2;
          pdf.addImage(imgData, 'PNG', x, y, imgW, imgH);
          pdf.save('dashboard-smsi' + _csiExportScopeSlug() + '-' + dateStamp + '.pdf');
        } else {
          // Fallback PNG si jsPDF non disponible
          const link = document.createElement('a');
          link.download = 'dashboard-smsi' + _csiExportScopeSlug() + '-' + dateStamp + '.png';
          link.href = canvas.toDataURL('image/png');
          link.click();
        }
        showToast2(t('toasts.dashboardExported', 'Dashboard exporté'), 'success');
        _exportBusy = false;
        if (_dashBtn) _dashBtn.disabled = false;
      }).catch(err => {
        _restoreTheme(); // (audit 2026-07-03) restaurer le thème même si la capture échoue
        void 0;
        _exportBusy = false;
        if (_dashBtn) _dashBtn.disabled = false;
        fallbackPrintPDF();
      });
    };

    var _giveUpToPrint = function() {
      _exportBusy = false;
      if (_dashBtn) _dashBtn.disabled = false;
      fallbackPrintPDF();
    };

    if (typeof html2canvas !== 'undefined') {
      _runDashCapture();
    } else {
      var _h2cScript = document.createElement('script');
      _h2cScript.src = 'vendor/html2canvas.min.js';
      _h2cScript.onload = function() {
        if (typeof html2canvas !== 'undefined') _runDashCapture();
        else _giveUpToPrint();
      };
      _h2cScript.onerror = _giveUpToPrint;
      document.head.appendChild(_h2cScript);
    }
  }

  function fallbackPrintPDF() {
    // Créer une version imprimable
    const dashboard = document.getElementById('dashboard');
    if (!dashboard) return;
    const printContent = dashboard.cloneNode(true);

    // Créer une fenêtre d'impression
    const printWindow = window.open('', '_blank');
    if (!printWindow) {
      showToast2(t('toasts.popupBlocked', 'Veuillez autoriser les popups pour exporter en PDF.'), 'error');
      return;
    }
    printWindow.document.write(`
      <!DOCTYPE html>
      <html>
      <head>
        <title>Dashboard SMSI - Export</title>
        <link rel="stylesheet" href="style.css">
        <style>
          body { background: white; padding: 20px; }
          .dashboard-footer { display: none; }
          @media print {
            body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
          }
        </style>
      </head>
      <body class="${document.body.className}">
        ${printContent.outerHTML}
      </body>
      </html>
    `);
    printWindow.document.close();

    setTimeout(() => {
      printWindow.print();
      showToast2(t('toasts.useSaveAsPdf', 'Utilisez "Enregistrer en PDF" dans la boîte de dialogue'), 'info');
    }, 500);
  }

  // ============================================================
  // EXPORT PDF - SOA (Déclaration d'applicabilité) - ISO 27001:2022 Clause 6.1.3
  // ============================================================

  function exportSoaPdf(type = "public") {
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export SOA PDF');
      return;
    }
    if (_exportBusy) return;
    _exportBusy = true;
    // FIX-EXPORT-CURRENTLANG (audit 2026-06-10) : currentLang n'existait nulle part en
    // portée → ReferenceError au clic, et _exportBusy restait bloqué à true (tous les
    // exports PDF devenaient des no-ops jusqu'au rechargement).
    const currentLang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : "fr";
    const isAudit = type === "audit";
    const orgName = appData.settings?.organizationName || "Organisation";
    const today = new Date().toLocaleDateString(getExportLocale());
    const docVersion = appData.settings?.soaVersion || "1.0";
    const docDate = new Date().toISOString().split('T')[0];

    // Préparer les données
    const sortedControls = [...appData.controls].sort((a, b) => SortUtils.reference(a.reference, b.reference));

    // Stats avancées
    let applicable = 0, notApplicable = 0, implemented = 0, verified = 0;
    let withOwner = 0, withEvidence = 0, withRisks = 0, reviewedLast6Months = 0;
    const domainStats = {};
    const sourceStats = {};
    const sixMonthsAgo = new Date();
    sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);

    sortedControls.forEach(control => {
      const entry = appData.soa.find(e => e.controlId === control.id) || {};
      const domain = getControlDomain(control.reference);
      if (!domainStats[domain]) domainStats[domain] = { total: 0, applicable: 0, implemented: 0, verified: 0 };
      domainStats[domain].total++;

      if (entry.applicable !== false) {
        applicable++;
        domainStats[domain].applicable++;
        var _effImplPdf = _soaEffectiveImplStatus(entry, control); // (audit 2026-07-03) fallback control.status
        if (_effImplPdf === "implemented" || _effImplPdf === "verified") {
          implemented++;
          domainStats[domain].implemented++;
          if (_effImplPdf === "verified") {
            verified++;
            domainStats[domain].verified++;
          }
        }
        if (entry.owner) withOwner++;
        if (entry.evidenceRef) withEvidence++;
        if (entry.linkedRiskIds && entry.linkedRiskIds.length > 0) withRisks++;
        if (entry.lastReviewDate && new Date(entry.lastReviewDate) >= sixMonthsAgo) reviewedLast6Months++;
        if (entry.source) {
          sourceStats[entry.source] = (sourceStats[entry.source] || 0) + 1;
        }
      } else {
        notApplicable++;
      }
    });

    const implPercent = applicable > 0 ? Math.round((implemented / applicable) * 100) : 0;
    const verifiedPercent = applicable > 0 ? Math.round((verified / applicable) * 100) : 0;
    const ownerPercent = applicable > 0 ? Math.round((withOwner / applicable) * 100) : 0;
    const evidencePercent = applicable > 0 ? Math.round((withEvidence / applicable) * 100) : 0;

    // Labels
    const sourceLabels = {
      risk: t('soaSourceLabels.risk'), legal: t('soaSourceLabels.legal'), contractual: t('soaSourceLabels.contractual'),
      best_practice: t('soaSourceLabels.bestPractice'), regulatory: t('soaSourceLabels.regulatory'), business: t('soaSourceLabels.business')
    };
    const implLabels = {
      not_started: t('soaImplStatuses.notStarted'), planned: t('soaImplStatuses.planned'), in_progress: t('soaImplStatuses.inProgress'),
      implemented: t('soaImplStatuses.implemented'), verified: t('soaImplStatuses.verified')
    };

    // Construire le HTML
    let html = `
      <!DOCTYPE html>
      <html lang="${currentLang || 'fr'}">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:">
        <title>${escapeHtml(t('pdfSoa.title', 'Déclaration d\'applicabilité (SoA)'))} - ${escapeHtml(orgName)}</title>
        <style>
          * { box-sizing: border-box; margin: 0; padding: 0; }
          body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 9pt; line-height: 1.4; color: #333; padding: 15mm; }
          .header { text-align: center; margin-bottom: 15px; padding-bottom: 12px; border-bottom: 3px solid #0f9b7a; }
          .header h1 { font-size: 20pt; color: #0f9b7a; margin-bottom: 3px; }
          .header .subtitle { font-size: 11pt; color: #666; }
          .header .org { font-size: 16pt; font-weight: 700; margin: 8px 0; color: #1a1a1a; }
          .header .date { font-size: 9pt; color: #888; }
          .header .version { font-size: 10pt; color: #0f9b7a; font-weight: 600; margin-top: 5px; }

          /* Document Info Box */
          .doc-info { background: linear-gradient(135deg, #f8fffe 0%, #f0fdf4 100%); border: 1px solid #d1fae5; padding: 12px; border-radius: 8px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 15px; }
          .doc-info-item { flex: 1; min-width: 150px; }
          .doc-info-item .label { font-size: 7pt; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
          .doc-info-item .value { font-size: 10pt; font-weight: 600; color: #166534; }

          /* Executive Summary */
          .exec-summary { background: #1e3a5f; color: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; }
          .exec-summary h2 { font-size: 12pt; margin-bottom: 10px; color: #93c5fd; }
          .exec-summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; }
          .exec-summary-item { text-align: center; }
          .exec-summary-item .number { font-size: 24pt; font-weight: 700; line-height: 1; }
          .exec-summary-item .number.good { color: #4ade80; }
          .exec-summary-item .number.medium { color: #fbbf24; }
          .exec-summary-item .number.low { color: #f87171; }
          .exec-summary-item .label { font-size: 8pt; color: #bfdbfe; margin-top: 3px; }

          /* Stats boxes */
          .stats-row { display: flex; gap: 10px; margin-bottom: 15px; }
          .stat-box { flex: 1; background: #f8f9fa; padding: 10px; border-radius: 6px; border-left: 3px solid #0f9b7a; }
          .stat-box h3 { font-size: 9pt; color: #666; margin-bottom: 8px; }
          .stat-box .items { display: flex; flex-wrap: wrap; gap: 5px; }
          .stat-item { background: white; padding: 4px 8px; border-radius: 4px; font-size: 8pt; }
          .stat-item strong { color: #0f9b7a; }

          /* Domain coverage matrix */
          .domain-matrix { margin-bottom: 15px; }
          .domain-matrix h3 { font-size: 10pt; margin-bottom: 8px; color: #1e3a5f; }
          .domain-matrix table { width: 100%; border-collapse: collapse; font-size: 8pt; }
          .domain-matrix th, .domain-matrix td { padding: 6px 8px; text-align: center; border: 1px solid #e5e7eb; }
          .domain-matrix th { background: #f3f4f6; font-weight: 600; }
          .domain-matrix .domain-name { text-align: left; font-weight: 600; }
          .progress-bar { width: 60px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; display: inline-block; }
          .progress-bar .fill { height: 100%; border-radius: 4px; }
          .progress-bar .fill.good { background: #22c55e; }
          .progress-bar .fill.medium { background: #f59e0b; }
          .progress-bar .fill.low { background: #ef4444; }

          /* Main table */
          table.soa-table { width: 100%; border-collapse: collapse; font-size: 8pt; margin-top: 10px; }
          table.soa-table th { background: #0f9b7a; color: white; padding: 6px 4px; text-align: left; font-weight: 600; font-size: 7pt; }
          table.soa-table td { padding: 5px 4px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
          table.soa-table tr:nth-child(even) { background: #fafafa; }
          table.soa-table tr.not-applicable { background: #f5f5f5; color: #888; }

          .badge { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 7pt; font-weight: 600; }
          .badge-applicable { background: #dcfce7; color: #166534; }
          .badge-not-applicable { background: #f1f5f9; color: #64748b; }
          .badge-org { background: #dbeafe; color: #1e40af; }
          .badge-hum { background: #f3e8ff; color: #7c3aed; }
          .badge-phy { background: #dcfce7; color: #166534; }
          .badge-tech { background: #ffedd5; color: #c2410c; }

          .impl-status { font-size: 7pt; padding: 1px 4px; border-radius: 2px; }
          .impl-verified { background: #22c55e; color: white; }
          .impl-implemented { background: #3b82f6; color: white; }
          .impl-in_progress { background: #f59e0b; color: white; }
          .impl-planned { background: #8b5cf6; color: white; }
          .impl-not_started { background: #94a3b8; color: white; }

          .source-badge { font-size: 6pt; background: #e0e7ff; color: #3730a3; padding: 1px 3px; border-radius: 2px; }
          .risk-badge { display: inline-block; padding: 1px 3px; border-radius: 2px; margin: 1px; font-size: 6pt; }
          .risk-critique { background: #fee2e2; color: #991b1b; }
          .risk-grave { background: #ffedd5; color: #c2410c; }
          .risk-significatif { background: #fef9c3; color: #854d0e; }
          .risk-mineur { background: #dcfce7; color: #166534; }

          /* Signatures section */
          .signatures { margin-top: 25px; padding-top: 15px; border-top: 2px solid #e5e7eb; }
          .signatures h3 { font-size: 10pt; margin-bottom: 15px; color: #1e3a5f; }
          .signatures-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
          .signature-box { border: 1px solid #d1d5db; padding: 15px; border-radius: 6px; min-height: 100px; }
          .signature-box .role { font-size: 9pt; font-weight: 600; color: #374151; margin-bottom: 5px; }
          .signature-box .line { border-bottom: 1px solid #9ca3af; margin: 30px 0 5px 0; }
          .signature-box .fields { font-size: 8pt; color: #6b7280; }

          /* Version history */
          .version-history { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 6px; }
          .version-history h3 { font-size: 9pt; margin-bottom: 8px; color: #374151; }
          .version-history table { width: 100%; font-size: 8pt; border-collapse: collapse; }
          .version-history th, .version-history td { padding: 4px 8px; text-align: left; border-bottom: 1px solid #e5e7eb; }
          .version-history th { background: #e5e7eb; font-weight: 600; }

          .footer { margin-top: 20px; padding-top: 12px; border-top: 1px solid #ddd; font-size: 8pt; color: #888; text-align: center; }
          .footer .ref { font-weight: 600; color: #0f9b7a; }
          .footer .confidential { color: #dc2626; font-weight: 700; margin-top: 5px; }

          .page-break { page-break-after: always; }
          @media print {
            body { padding: 10mm; }
            .no-print { display: none; }
            .page-break { page-break-after: always; }
          }
        </style>
      </head>
      <body>
        <!-- Page 1: Header + Executive Summary -->
        <div class="header">
          <h1>${escapeHtml(t('pdfSoa.title', 'Déclaration d\'applicabilité (SoA)'))}</h1>
          <div class="subtitle">${escapeHtml((() => { var _fw = _getActiveManagementFramework(); return _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) + ' - Clause 6.1.3' : t('pdfSoa.subtitle', 'ISO 27001:2022 - Clause 6.1.3 - Annexe A'); })())}</div>
          <div class="org">${escapeHtml(orgName)}</div>
          <div class="version">Version ${escapeHtml(docVersion)} | ${isAudit ? escapeHtml(t('pdfSoa.fullAuditVersion', 'DOCUMENT AUDIT COMPLET')) : escapeHtml(t('pdfSoa.publicVersion', 'VERSION PUBLIQUE'))}</div>
          <div class="date">${escapeHtml(t('pdfSoa.issueDate', "Date d'émission:"))} ${today}</div>
        </div>

        <!-- Document Information -->
        <div class="doc-info">
          <div class="doc-info-item">
            <div class="label">${escapeHtml(t('pdfSoa.docRef', 'Référence document'))}</div>
            <div class="value">SOA-${docDate.replace(/-/g, '')}</div>
          </div>
          <div class="doc-info-item">
            <div class="label">${escapeHtml(t('pdfSoa.scope', 'Périmètre'))}</div>
            <div class="value">SMSI ${escapeHtml(orgName)}</div>
          </div>
          <div class="doc-info-item">
            <div class="label">${escapeHtml(t('pdfSoa.refStandard', 'Norme de référence'))}</div>
            <div class="value">ISO/IEC 27001:2022</div>
          </div>
          <div class="doc-info-item">
            <div class="label">${escapeHtml(t('pdfSoa.classification', 'Classification'))}</div>
            <div class="value">${isAudit ? escapeHtml(t('pdfSoa.confidential', 'Confidentiel')) : escapeHtml(t('pdfSoa.internal', 'Interne'))}</div>
          </div>
        </div>

        <!-- Executive Summary -->
        <div class="exec-summary">
          <h2>${escapeHtml(t('pdfSoa.execSummary', 'Synthèse exécutive'))}</h2>
          <div class="exec-summary-grid">
            <div class="exec-summary-item">
              <div class="number">${applicable}/${sortedControls.length}</div>
              <div class="label">${escapeHtml(t('pdfSoa.applicableControls', 'Contrôles applicables'))}</div>
            </div>
            <div class="exec-summary-item">
              <div class="number ${implPercent >= 80 ? 'good' : implPercent >= 50 ? 'medium' : 'low'}">${implPercent}%</div>
              <div class="label">${escapeHtml(t('pdfSoa.implRate', "Taux d'implémentation"))}</div>
            </div>
            <div class="exec-summary-item">
              <div class="number ${verifiedPercent >= 70 ? 'good' : verifiedPercent >= 40 ? 'medium' : 'low'}">${verifiedPercent}%</div>
              <div class="label">${escapeHtml(t('pdfSoa.verifiedControls', 'Contrôles vérifiés'))}</div>
            </div>
            <div class="exec-summary-item">
              <div class="number">${notApplicable}</div>
              <div class="label">${escapeHtml(t('pdfSoa.notApplicable', 'Non applicables'))}</div>
            </div>
          </div>
        </div>

        ${isAudit ? `
        <!-- Detailed Stats for Audit -->
        <div class="stats-row">
          <div class="stat-box">
            <h3>${escapeHtml(t('pdfSoa.docQuality', 'Qualité de la documentation'))}</h3>
            <div class="items">
              <div class="stat-item"><strong>${ownerPercent}%</strong> ${escapeHtml(t('pdfSoa.withOwner', 'avec propriétaire'))}</div>
              <div class="stat-item"><strong>${evidencePercent}%</strong> ${escapeHtml(t('pdfSoa.withEvidence', 'avec preuves'))}</div>
              <div class="stat-item"><strong>${withRisks}</strong> ${escapeHtml(t('pdfSoa.linkedToRisks', 'liés à des risques'))}</div>
              <div class="stat-item"><strong>${reviewedLast6Months}</strong> ${escapeHtml(t('pdfSoa.reviewedLast6m', 'revus <6 mois'))}</div>
            </div>
          </div>
          <div class="stat-box">
            <h3>${escapeHtml(t('pdfSoa.selectionSources', 'Sources de sélection'))}</h3>
            <div class="items">
              ${Object.entries(sourceStats).map(([src, count]) =>
                `<div class="stat-item"><strong>${count}</strong> ${sourceLabels[src] || escapeHtml(src)}</div>`
              ).join('')}
            </div>
          </div>
        </div>
        ` : ''}

        <!-- Domain Coverage Matrix -->
        <div class="domain-matrix">
          <h3>${escapeHtml((() => { var _fw = _getActiveControlsFramework(); var _n = _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27002:2022'; return t('pdfSoa.domainCoverage', 'Couverture par domaine ' + _n); })())}</h3>
          <table>
            <thead>
              <tr>
                <th class="domain-name">${escapeHtml(t('pdfSoa.domain', 'Domaine'))}</th>
                <th>${escapeHtml(t('pdfSoa.total', 'Total'))}</th>
                <th>${escapeHtml(t('pdfSoa.applicable', 'Applicables'))}</th>
                <th>${escapeHtml(t('pdfSoa.implemented', 'Implémentés'))}</th>
                <th>${escapeHtml(t('pdfSoa.verified', 'Vérifiés'))}</th>
                <th>${escapeHtml(t('pdfSoa.progression', 'Progression'))}</th>
              </tr>
            </thead>
            <tbody>
              ${Object.entries(domainStats).map(([domain, stats]) => {
                const pct = stats.applicable > 0 ? Math.round((stats.implemented / stats.applicable) * 100) : 0;
                return `
                  <tr>
                    <td class="domain-name">${domain}</td>
                    <td>${stats.total}</td>
                    <td>${stats.applicable}</td>
                    <td>${stats.implemented}</td>
                    <td>${stats.verified || 0}</td>
                    <td>
                      <div class="progress-bar">
                        <div class="fill ${pct >= 80 ? 'good' : pct >= 50 ? 'medium' : 'low'}" style="width:${pct}%"></div>
                      </div>
                      ${pct}%
                    </td>
                  </tr>
                `;
              }).join('')}
            </tbody>
          </table>
        </div>

        <div class="page-break"></div>

        <!-- Main SOA Table -->
        <h3 style="font-size:11pt;color:#1e3a5f;margin-bottom:10px;">${escapeHtml((() => { var _fw = _getActiveControlsFramework(); var _n = _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27002:2022'; return t('pdfSoa.controlDetails', 'Détail des contrôles ' + _n); })())}</h3>
        <table class="soa-table">
          <thead>
            <tr>
              <th style="width:40px;">${escapeHtml(t('pdfSoa.ref', 'Réf.'))}</th>
              <th style="width:22%;">${escapeHtml(t('pdfSoa.control', 'Contrôle'))}</th>
              <th style="width:55px;">${escapeHtml(t('pdfSoa.domain', 'Domaine'))}</th>
              <th style="width:50px;">${escapeHtml(t('pdfSoa.applic', 'Applic.'))}</th>
              ${isAudit ? '<th style="width:60px;">' + escapeHtml(t('pdfSoa.source', 'Source')) + '</th>' : ''}
              <th style="width:60px;">${escapeHtml(t('pdfSoa.state', 'État'))}</th>
              ${isAudit ? '<th style="width:70px;">' + escapeHtml(t('pdfSoa.owner', 'Propriétaire')) + '</th>' : ''}
              ${isAudit ? '<th style="width:70px;">' + escapeHtml(t('pdfSoa.evidence', 'Preuves')) + '</th>' : ''}
              ${isAudit ? '<th style="width:60px;">' + escapeHtml(t('pdfSoa.risks', 'Risques')) + '</th>' : ''}
              <th>${escapeHtml(t('pdfSoa.justification', 'Justification'))}</th>
            </tr>
          </thead>
          <tbody>
    `;

    sortedControls.forEach(control => {
      const entry = appData.soa.find(e => e.controlId === control.id) || {};
      const domain = getControlDomain(control.reference);
      const isApplicable = entry.applicable !== false;

      // Risques liés
      let risksHtml = "";
      if (isAudit && entry.linkedRiskIds && entry.linkedRiskIds.length > 0) {
        risksHtml = entry.linkedRiskIds.slice(0, 3).map(riskId => {
          const risk = (appData.risks || []).find(r => r.id === riskId);
          if (!risk) return "";
          const rawLevel = risk.level ? risk.level.toLowerCase() : "";
          const levelClass = rawLevel && /^[a-zA-Z0-9-]+$/.test(rawLevel) ? `risk-${rawLevel}` : "";
          return `<span class="risk-badge ${levelClass}">${escapeHtml((risk.title || risk.id).substring(0, 15))}</span>`;
        }).join("");
        if (entry.linkedRiskIds.length > 3) risksHtml += `<span style="font-size:6pt;">+${entry.linkedRiskIds.length - 3}</span>`;
      }

      html += `
        <tr class="${isApplicable ? '' : 'not-applicable'}">
          <td><strong>${escapeHtml(control.reference)}</strong></td>
          <td>${escapeHtml(isoCtrlTitle(control))}</td>
          <td><span class="badge badge-${domain.substring(0,3).toLowerCase()}">${domain.substring(0,4)}</span></td>
          <td><span class="badge ${isApplicable ? 'badge-applicable' : 'badge-not-applicable'}">${isApplicable ? '✓' : '✗'}</span></td>
          ${isAudit ? `<td>${entry.source ? `<span class="source-badge">${escapeHtml(String(sourceLabels[entry.source] || entry.source).substring(0, 10))}</span>` : '-'}</td>` : ''}
          <td>${(function(){var _e=_soaEffectiveImplStatus(entry, control);var _cls=/^[a-z_]+$/.test(_e)?_e:'';return _e ? `<span class="impl-status impl-${_cls}">${escapeHtml((implLabels[_e] || '').substring(0, 8))}</span>` : '-';})()}</td>
          ${isAudit ? `<td style="font-size:7pt;">${entry.owner ? escapeHtml(entry.owner.substring(0, 15)) : '-'}</td>` : ''}
          ${isAudit ? `<td style="font-size:7pt;">${entry.evidenceRef ? escapeHtml(entry.evidenceRef.substring(0, 15)) : '-'}</td>` : ''}
          ${isAudit ? `<td>${risksHtml || '-'}</td>` : ''}
          <td style="font-size:7pt;">
            ${entry.justification ? escapeHtml(entry.justification.substring(0, 80)) + (entry.justification.length > 80 ? '...' : '') : '-'}
            ${!isApplicable && entry.exclusionReason ? `<div style="color:#dc2626;font-style:italic;">${escapeHtml(t('pdfSoa.excluded', 'Exclu:'))} ${escapeHtml(entry.exclusionReason.substring(0, 50))}</div>` : ''}
            ${Array.isArray(entry.comments) && entry.comments.length > 0 ? '<div style="color:#3b82f6;font-size:7pt;">💬 ' + entry.comments.length + '</div>' : ''}
          </td>
        </tr>
      `;
    });

    html += `
          </tbody>
        </table>

        ${isAudit ? `
        <div class="page-break"></div>

        <!-- Signatures Section -->
        <div class="signatures">
          <h3>${escapeHtml(t('pdfSoa.approvals', 'Approbations et signatures'))}</h3>
          <div class="signatures-grid">
            <div class="signature-box">
              <div class="role">${escapeHtml(t('pdfSoa.ismsManager', 'Responsable SMSI'))}</div>
              <div class="line"></div>
              <div class="fields">
                ${escapeHtml(t('pdfSoa.nameField', 'Nom: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.dateField', 'Date: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.signatureField', 'Signature:'))}
              </div>
            </div>
            <div class="signature-box">
              <div class="role">${escapeHtml(t('pdfSoa.generalManagement', 'Direction Générale'))}</div>
              <div class="line"></div>
              <div class="fields">
                ${escapeHtml(t('pdfSoa.nameField', 'Nom: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.dateField', 'Date: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.signatureField', 'Signature:'))}
              </div>
            </div>
            <div class="signature-box">
              <div class="role">${escapeHtml(t('pdfSoa.securityOfficer', 'Responsable Sécurité'))}</div>
              <div class="line"></div>
              <div class="fields">
                ${escapeHtml(t('pdfSoa.nameField', 'Nom: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.dateField', 'Date: ________________________________'))}<br>
                ${escapeHtml(t('pdfSoa.signatureField', 'Signature:'))}
              </div>
            </div>
          </div>
        </div>

        <!-- Version History -->
        <div class="version-history">
          <h3>${escapeHtml(t('pdfSoa.versionHistory', 'Historique des versions'))}</h3>
          <table>
            <thead>
              <tr>
                <th>${escapeHtml(t('pdfSoa.version', 'Version'))}</th>
                <th>${escapeHtml(t('pdfSoa.date', 'Date'))}</th>
                <th>${escapeHtml(t('pdfSoa.author', 'Auteur'))}</th>
                <th>${escapeHtml(t('pdfSoa.changes', 'Modifications'))}</th>
              </tr>
            </thead>
            <tbody>
              <tr>
                <td>${escapeHtml(docVersion)}</td>
                <td>${today}</td>
                <td>RSSI</td>
                <td>${escapeHtml(t('pdfSoa.currentVersionAuto', 'Version actuelle - Génération automatique'))}</td>
              </tr>
              <tr>
                <td>0.9</td>
                <td>-</td>
                <td>-</td>
                <td>${escapeHtml(t('pdfSoa.initialRevision', 'Révision initiale'))}</td>
              </tr>
            </tbody>
          </table>
        </div>
        ` : ''}

        <div class="footer">
          <div class="ref">${escapeHtml(t('pdfSoa.footerRef', 'Référence:'))} SOA-${escapeHtml(orgName.replace(/\s+/g, '-').toUpperCase())}-${docDate}</div>
          <div>${escapeHtml((() => { var _fw = _getActiveManagementFramework(); var _fwName = _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27001:2022'; return t('pdfSoa.footerGenerated', 'Document généré par Cyber-Assistant | Conforme ' + _fwName + ' Clause 6.1.3'); })())}</div>
          ${isAudit ? '<div class="confidential">' + escapeHtml(t('pdfSoa.footerConfidential', 'DOCUMENT CONFIDENTIEL - DIFFUSION RESTREINTE - USAGE AUDIT UNIQUEMENT')) + '</div>' : '<div>© ' + new Date().getFullYear() + ' ' + escapeHtml(orgName) + '</div>'}
        </div>
      </body>
      </html>
    `;

    // Ouvrir dans une nouvelle fenêtre pour impression/PDF
    const printWindow = window.open('', '_blank');
    if (!printWindow) {
      showToast2(t('toasts.popupBlocked', 'Veuillez autoriser les popups pour exporter en PDF.'), 'error');
      _exportBusy = false;
      return;
    }
    printWindow.document.write(html);
    printWindow.document.close();

    setTimeout(() => {
      try {
        printWindow.print();
        showToast2(t('toasts.soaPdfReady', 'SOA prête — Utilisez "Enregistrer en PDF"'), 'success');
      } catch (e) {
        void 0;
      }
      _exportBusy = false;
    }, 300);
  }

  // ============================================================
  // EXPORT PDF - Revue Documentaire (Pack Audit ISO 27001)
  // ============================================================

  function exportDocReviewPdf() {
    if (DEMO_MODE) {
      showDemoBlockedMessage('L\'export PDF');
      return;
    }
    if (_exportBusy) return;
    _exportBusy = true;
    // FIX-EXPORT-CURRENTLANG (audit 2026-06-10) : voir exportSoaPdf — même ReferenceError.
    const currentLang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : "fr";
    const orgName = appData.settings?.organizationName || "Organisation";
    const today = new Date().toLocaleDateString(getExportLocale());

    // Stats
    let conforme = 0, partiel = 0, nonConforme = 0, total = 0;
    DOCUMENT_REVIEW_ITEMS.filter(i => i.reference).forEach(item => {
      const entry = appData.documentReview.find(e => e.reference === item.reference) || {};
      total++;
      if (entry.status === "conforme") conforme++;
      else if (entry.status === "partiel") partiel++;
      else if (entry.status === "non_conforme") nonConforme++;
    });
    const conformePercent = total > 0 ? Math.round((conforme / total) * 100) : 0;

    let html = `
      <!DOCTYPE html>
      <html lang="${currentLang || 'fr'}">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:">
        <title>${escapeHtml(t('pdfDocReview.title', 'Revue documentaire SMSI'))} - ${escapeHtml(orgName)}</title>
        <style>
          * { box-sizing: border-box; margin: 0; padding: 0; }
          body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 10pt; line-height: 1.4; color: #333; padding: 20mm; }
          .header { text-align: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #3b82f6; }
          .header h1 { font-size: 18pt; color: #3b82f6; margin-bottom: 5px; }
          .header .subtitle { font-size: 11pt; color: #666; }
          .header .org { font-size: 14pt; font-weight: 600; margin: 10px 0; }
          .header .date { font-size: 9pt; color: #888; }
          .header .audit-label { background: #dc2626; color: white; padding: 4px 12px; border-radius: 4px; display: inline-block; margin-top: 10px; font-weight: 600; }
          .meta-box { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; flex-wrap: wrap; gap: 20px; }
          .meta-item { flex: 1; min-width: 100px; text-align: center; }
          .meta-item .label { font-size: 8pt; color: #666; text-transform: uppercase; }
          .meta-item .value { font-size: 18pt; font-weight: 700; }
          .meta-item .value.good { color: #22c55e; }
          .meta-item .value.warning { color: #f59e0b; }
          .meta-item .value.danger { color: #ef4444; }
          .chapter { background: #1e40af; color: white; padding: 8px 12px; margin: 15px 0 10px 0; font-weight: 600; font-size: 11pt; border-radius: 4px; }
          table { width: 100%; border-collapse: collapse; font-size: 9pt; }
          th { background: #3b82f6; color: white; padding: 8px 6px; text-align: left; font-weight: 600; }
          td { padding: 6px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
          tr:nth-child(even) { background: #fafafa; }
          tr.conforme { background: rgba(34, 197, 94, 0.08); }
          tr.partiel { background: rgba(245, 158, 11, 0.08); }
          tr.non-conforme { background: rgba(239, 68, 68, 0.08); }
          tr.na { background: #f5f5f5; opacity: 0.7; }
          .status { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 8pt; font-weight: 600; }
          .status-conforme { background: #22c55e; color: white; }
          .status-partiel { background: #f59e0b; color: white; }
          .status-non-conforme { background: #ef4444; color: white; }
          .status-na { background: #94a3b8; color: white; }
          .status-empty { background: #e5e7eb; color: #6b7280; }
          .ref-col { width: 80px; font-weight: 600; }
          .desc-col { width: 30%; }
          .dates { font-size: 8pt; color: #666; }
          .footer { margin-top: 30px; padding-top: 15px; border-top: 1px solid #ddd; font-size: 8pt; color: #888; text-align: center; }
          .checklist { margin-top: 30px; page-break-before: always; }
          .checklist h2 { color: #1e40af; margin-bottom: 15px; }
          .checklist-item { display: flex; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid #eee; }
          .checklist-box { width: 18px; height: 18px; border: 2px solid #3b82f6; border-radius: 3px; margin-right: 10px; flex-shrink: 0; }
          .checklist-text { flex: 1; }
          .checklist-status { font-size: 9pt; padding: 2px 6px; border-radius: 3px; }
          @media print {
            body { padding: 10mm; }
          }
        </style>
      </head>
      <body>
        <div class="header">
          <h1>${escapeHtml(t('pdfDocReview.title', 'Revue documentaire SMSI'))}</h1>
          <div class="subtitle">${escapeHtml((() => { var _fw = _getActiveManagementFramework(); return _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) + ' - Clauses 4-10' : t('pdfDocReview.subtitle', 'ISO 27001:2022 - Clauses 4 à 10'); })())}</div>
          <div class="org">${escapeHtml(orgName)}</div>
          <div class="date">${escapeHtml(t('pdfDocReview.generatedOn', 'Généré le'))} ${today}</div>
          <div class="audit-label">${escapeHtml(t('pdfDocReview.auditLabel', 'PACK AUDIT CERTIFICATION'))}</div>
        </div>

        <div class="meta-box">
          <div class="meta-item">
            <div class="label">${escapeHtml(t('pdfDocReview.conforming', 'Conformes'))}</div>
            <div class="value good">${conforme}</div>
          </div>
          <div class="meta-item">
            <div class="label">${escapeHtml(t('pdfDocReview.partial', 'Partiels'))}</div>
            <div class="value warning">${partiel}</div>
          </div>
          <div class="meta-item">
            <div class="label">${escapeHtml(t('pdfDocReview.nonConforming', 'Non conformes'))}</div>
            <div class="value danger">${nonConforme}</div>
          </div>
          <div class="meta-item">
            <div class="label">${escapeHtml(t('pdfDocReview.conformityRate', 'Taux de conformité'))}</div>
            <div class="value ${conformePercent >= 80 ? 'good' : conformePercent >= 50 ? 'warning' : 'danger'}">${conformePercent}%</div>
          </div>
        </div>

        <table>
          <thead>
            <tr>
              <th class="ref-col">${escapeHtml(t('pdfDocReview.clause', 'Clause'))}</th>
              <th class="desc-col">${escapeHtml(t('pdfDocReview.requirement', 'Exigence'))}</th>
              <th style="width:80px;">${escapeHtml(t('pdfDocReview.status', 'Statut'))}</th>
              <th style="width:80px;">${escapeHtml(t('pdfDocReview.responsible', 'Responsable'))}</th>
              <th>${escapeHtml(t('pdfDocReview.justificationEvidence', 'Justification / Preuve'))}</th>
              <th style="width:90px;">${escapeHtml(t('pdfDocReview.review', 'Revue'))}</th>
            </tr>
          </thead>
          <tbody>
    `;

    let currentChapter = "";
    var _pdfMgmtFw = _getActiveManagementFramework();
    var _pdfIsEn = (typeof currentLang !== 'undefined' && currentLang === 'en') || (document.documentElement.lang === 'en');
    DOCUMENT_REVIEW_ITEMS.forEach(item => {
      if (item.type === "chapter") {
        // Resolve bilingual chapter title from framework data
        var _pdfChTitle = item.title;
        if (_pdfMgmtFw && _pdfMgmtFw.chapters) {
          var _pdfChId = item.title.match(/^(\d+)/);
          if (_pdfChId) {
            var _pdfCh = _pdfMgmtFw.chapters.find(function(c) { return c.id === _pdfChId[1]; });
            if (_pdfCh) _pdfChTitle = (_pdfIsEn && _pdfCh.titleEn) ? _pdfCh.titleEn : _pdfCh.title;
          }
        }
        currentChapter = _pdfChTitle;
        html += '<tr><td colspan="6" class="chapter">' + escapeHtml(_pdfChTitle) + '</td></tr>';
      } else {
        const entry = appData.documentReview.find(e => e.reference === item.reference) || {};
        const statusLabels = {
          conforme: t('docReview.conforming', 'Conformes'), partiel: t('docReview.partial', 'Partiels'), non_conforme: t('docReview.nonConforming', 'Non conformes'), na: "N/A"
        };
        const safeStatus = /^[a-zA-Z0-9_-]+$/.test(entry.status) ? entry.status : '';
        const statusClass = safeStatus || "empty";
        var _pdfClauseNum = item.reference.split(' ')[0] || '';
        var _pdfFwClause = _pdfMgmtFw ? (_pdfMgmtFw.clauses || []).find(function(c) { return c.reference === _pdfClauseNum; }) : null;
        var _pdfRef = (_pdfFwClause && _pdfIsEn && _pdfFwClause.titleEn) ? (_pdfClauseNum + ' ' + _pdfFwClause.titleEn) : (_pdfFwClause ? (_pdfClauseNum + ' ' + _pdfFwClause.title) : item.reference);
        var _pdfDesc = (_pdfFwClause && _pdfIsEn && _pdfFwClause.descriptionEn) ? _pdfFwClause.descriptionEn : (_pdfFwClause ? _pdfFwClause.description : item.description);

        html += `
          <tr class="${safeStatus}">
            <td class="ref-col">${escapeHtml(_pdfRef)}</td>
            <td class="desc-col">${escapeHtml(_pdfDesc)}</td>
            <td><span class="status status-${statusClass}">${statusLabels[entry.status] || escapeHtml(t('pdfDocReview.toEvaluate', 'À évaluer'))}</span></td>
            <td>${escapeHtml(entry.responsible || '-')}</td>
            <td>
              ${entry.justification ? escapeHtml(entry.justification) : '-'}
              ${entry.tool ? `<br><em style="color:#666;">${escapeHtml(t('pdfDocReview.evidence', 'Preuve:'))} ${escapeHtml(entry.tool)}</em>` : ''}
              ${entry.documentLink && isSafeUrl(entry.documentLink) ? `<br><a href="${escapeHtml(entry.documentLink)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;font-size:8pt;">${escapeHtml(t('pdfDocReview.document', 'Document'))}</a>` : ''}
              ${Array.isArray(entry.comments) && entry.comments.length > 0 ? '<br><em style="color:#3b82f6;font-size:8pt;">' + entry.comments.length + ' ' + escapeHtml(t('pdfDocReview.comments', 'commentaire(s)')) + '</em>' : ''}
            </td>
            <td class="dates">
              ${entry.lastReview ? escapeHtml(t('pdfDocReview.lastReview', 'Dernière:')) + ' ' + escapeHtml(entry.lastReview) : '-'}
              ${entry.nextReview ? '<br>' + escapeHtml(t('pdfDocReview.nextReview', 'Prochaine:')) + ' ' + escapeHtml(entry.nextReview) : ''}
            </td>
          </tr>
        `;
      }
    });

    html += `
          </tbody>
        </table>

        <!-- Checklist Auditeur -->
        <div class="checklist">
          <h2>${escapeHtml((() => { var _fw = _getActiveManagementFramework(); var _n = _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27001:2022'; return t('pdfDocReview.checklistTitle', 'Checklist Préparation Audit ' + _n); })())}</h2>
          <p style="margin-bottom:15px;color:#666;">${escapeHtml(t('pdfDocReview.checklistSubtitle', "Points de contrôle pour l'auditeur externe"))}</p>
    `;

    const auditChecklist = [
      { ref: "4", label: t('pdfDocReview.checkContext', "Contexte de l'organisation"), items: t('pdfDocReview.checkContextItems', "Enjeux internes/externes documentés|Parties intéressées identifiées|Domaine d'application défini").split('|') },
      { ref: "5", label: t('pdfDocReview.checkLeadership', 'Leadership'), items: t('pdfDocReview.checkLeadershipItems', "Politique SSI signée et communiquée|Rôles et responsabilités attribués|Engagement de la direction documenté").split('|') },
      { ref: "6", label: t('pdfDocReview.checkPlanning', 'Planification'), items: t('pdfDocReview.checkPlanningItems', 'Processus d\'appréciation des risques|Plan de traitement des risques|Déclaration d\'applicabilité (SoA)|Objectifs SSI mesurables').split('|') },
      { ref: "7", label: t('pdfDocReview.checkSupport', 'Supports'), items: t('pdfDocReview.checkSupportItems', "Ressources allouées|Compétences documentées|Programme de sensibilisation|Plan de communication|Maîtrise documentaire").split('|') },
      { ref: "8", label: t('pdfDocReview.checkOperation', 'Fonctionnement'), items: t('pdfDocReview.checkOperationItems', "Processus opérationnels documentés|Appréciations des risques à jour|Traitement des risques en cours").split('|') },
      { ref: "9", label: t('pdfDocReview.checkPerformance', 'Évaluation de la performance'), items: t('pdfDocReview.checkPerformanceItems', "Programme d'audit interne|Indicateurs de performance (KPI)|Revue de direction planifiée|Procès-verbaux de revue").split('|') },
      { ref: "10", label: t('pdfDocReview.checkImprovement', 'Amélioration'), items: t('pdfDocReview.checkImprovementItems', "Processus d'amélioration continue|Gestion des non-conformités|Actions correctives tracées").split('|') }
    ];

    auditChecklist.forEach(section => {
      html += `<div style="margin-top:15px;"><strong style="color:#1e40af;">Clause ${section.ref} - ${section.label}</strong></div>`;
      section.items.forEach(item => {
        // Vérifier si on a des données pour cet item
        const relatedEntries = appData.documentReview.filter(e =>
          e.reference && e.reference.startsWith(section.ref + ".")
        );
        const hasConforme = relatedEntries.some(e => e.status === "conforme");

        html += `
          <div class="checklist-item">
            <div class="checklist-box"></div>
            <div class="checklist-text">${item}</div>
            ${hasConforme ? '<span class="checklist-status" style="background:#dcfce7;color:#166534;">' + escapeHtml(t('pdfDocReview.documented', 'Documenté')) + '</span>' : ''}
          </div>
        `;
      });
    });

    html += `
        </div>

        <div class="footer">
          <div style="font-weight:600;">${escapeHtml((() => { var _fw = _getActiveManagementFramework(); var _n = _fw && _fw.meta ? (_fw.meta.shortName || _fw.meta.name) : 'ISO 27001:2022'; return t('pdfDocReview.footerTitle', 'Pack Audit Certification ' + _n); })())}</div>
          <div>${escapeHtml(t('pdfDocReview.footerRef', 'Référence:'))} AUDIT-${escapeHtml(orgName.replace(/\s+/g, '-').toUpperCase())}-${new Date().toISOString().split('T')[0]}</div>
          <div>${escapeHtml(t('pdfDocReview.footerGenerated', 'Document généré par Cyber-Assistant'))}</div>
          <div style="color:#dc2626;font-weight:600;margin-top:5px;">${escapeHtml(t('pdfDocReview.footerConfidential', 'DOCUMENT CONFIDENTIEL - PRÉPARATION AUDIT'))}</div>
        </div>
      </body>
      </html>
    `;

    const printWindow = window.open('', '_blank');
    if (!printWindow) {
      showToast2(t('toasts.popupBlocked', 'Veuillez autoriser les popups pour exporter en PDF.'), "error");
      _exportBusy = false;
      return;
    }
    printWindow.document.write(html);
    printWindow.document.close();

    setTimeout(() => {
      try {
        printWindow.print();
        showToast2(t('toasts.auditPackReady', 'Pack Audit Doc Review prêt — Utilisez "Enregistrer en PDF"'), 'success');
      } catch (e) {
        void 0;
      }
      _exportBusy = false;
    }, 300);
  }

  // Exports des fonctions PDF après leur définition
  window.exportSoaPdf = exportSoaPdf;
  window.exportDocReviewPdf = exportDocReviewPdf;

  // Track current active tab for lock management
  var _currentActiveTab = 'dashboard';
  window._currentActiveTab = _currentActiveTab;
  // AUDIT-CO-H1: Mutex pour sérialiser les navigations de tab (anti-race condition)
  var _tabSwitchInProgress = false;
  // FIX-TABNAV-QUEUE (audit 2026-06-10, bundle.js:21075) : dernière navigation demandée
  // pendant qu'un changement d'onglet est en cours — rejouée à la fin au lieu d'être ignorée.
  var _queuedTabSwitch = null;

  // AUDIT-CO-C3: Re-render le tab courant après un pull de données fraîches
  // Les noms de fonctions sont alignés sur le switch de loadModuleContent (L18962+)
  function _rerenderCurrentTab(moduleId) {
    switch (moduleId) {
      case 'controls': if (typeof loadControlsTable === 'function') loadControlsTable(); break;
      case 'nis2-controls': if (typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable(); break;
      case 'recyf-controls': if (typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable(); break;
      case 'nis2-program': if (typeof loadNis2Program === 'function') loadNis2Program(); break;
      case 'actions': if (typeof loadActionsTable === 'function') loadActionsTable(); break;
      case 'risks':
        if (typeof loadRisksTable === 'function') loadRisksTable();
        if (typeof updateRiskMatrix === 'function') updateRiskMatrix();
        break;
      case 'threats-section': if (typeof loadThreatsTable === 'function') loadThreatsTable(); break;
      case 'nonconformities': if (typeof loadNonConformitiesTable === 'function') loadNonConformitiesTable(); break;
      case 'soa': if (typeof loadSoaTable === 'function') loadSoaTable(); break;
      case 'docReview': if (typeof loadDocReviewTable === 'function') loadDocReviewTable(); break;
      case 'audits': if (typeof loadAuditsTable === 'function') loadAuditsTable(); break;
      case 'managementReviews': if (typeof loadReviewsTable === 'function') loadReviewsTable(); break;
      case 'stakeholders': if (typeof loadStakeholdersTable === 'function') loadStakeholdersTable(); break;
      case 'documents': if (typeof loadDocumentsTable === 'function') loadDocumentsTable(); break;
      case 'kpis': if (typeof loadKpiTable === 'function') loadKpiTable(); break;
      case 'critical-assets': if (typeof loadCriticalAssetsTable === 'function') loadCriticalAssetsTable(); break;
      case 'project-risks': if (typeof loadProjectRisksTable === 'function') loadProjectRisksTable(); break;
      case 'nis2-socle': if (typeof renderSocleGrids === 'function') renderSocleGrids(); break;
      case 'risk-analysis': if (typeof renderRiskAnalysisModule === 'function') renderRiskAnalysisModule(); break;
      case 'impact-matrices': if (typeof renderImpactMatricesModule === 'function') renderImpactMatricesModule(); break;
      case 'framework-mapping': if (typeof loadFrameworkMappingView === 'function') loadFrameworkMappingView(); break;
      case 'auditLog': if (typeof renderAuditLogModule === 'function') renderAuditLogModule(); break;
      default: break;
    }
  }

  // FIX-READONLY-REFRESH (2026-06-30) : pull + applique l'état serveur d'un onglet SANS push ni
  // opération de verrou. Utilisé par les utilisateurs en LECTURE SEULE (navigation + tick
  // périodique) pour voir les modifications des autres — sinon ils restent figés sur l'instantané
  // de leur login. Réutilise l'endpoint data-pull existant (aucun pré-requis serveur → portable).
  // onApplied() n'est appelé que si une révision RÉELLEMENT plus récente a été appliquée.
  function _pullAndApplyTab(tabId, onApplied) {
    if (!tabId || tabId === 'dashboard' || tabId === 'settings') return;
    var SyncMgr = window.CSI_SyncManager;
    var PresenceMgr = window.CSI_PresenceManager || window.CSI_LockManager;
    if (!(SyncMgr && SyncMgr.pullTabFromServer)) return;
    // === CSI-ME === (audit 2026-08-05) Empreinte du périmètre AVANT l'envoi : une bascule
    // pendant le vol faisait appliquer les données de la partition quittée à l'écran de la
    // nouvelle (l'onglet, lui, ne change pas à la bascule et les dirty viennent d'être vidés,
    // donc les deux gardes existantes laissaient passer). Idiome lock-manager.js:109/120.
    var _pullScope = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
      ? window.CSI_EntityManager.echoId() : null;
    SyncMgr.pullTabFromServer(tabId).then(function(tabData) {
      var _nowScope = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
        ? window.CSI_EntityManager.echoId() : null;
      if (_nowScope !== _pullScope) return;               // réponse d'un périmètre déjà quitté
      if (_currentActiveTab !== tabId) return;            // l'utilisateur a navigué ailleurs entre-temps
      if (_csiDirtyTabs[tabId]) return;                   // ne JAMAIS écraser des modifs locales en attente
      if (!tabData || typeof tabData !== 'object') return;
      if (typeof tabData._rev === 'number') {
        if (_tabRevisions[tabId] === tabData._rev) return; // rien de neuf → pas de re-render inutile
        // FIX-REV-MONOTONIC (audit grappe analyse 2026-08-17, chemin C4) : une réponse à
        // révision INFÉRIEURE est un pull TARDIF (parti avant notre dernier push abouti) —
        // l'adopter rétrograderait la révision (409 au push suivant) et écraserait l'écran
        // avec l'état d'avant (l'analyse tout juste créée « disparaissait »). On l'ignore.
        if (typeof _tabRevisions[tabId] === 'number' && tabData._rev < _tabRevisions[tabId]) return;
        _tabRevisions[tabId] = tabData._rev;
      }
      var tabDataKeys = PresenceMgr && PresenceMgr.getTabDataKeys ? PresenceMgr.getTabDataKeys()[tabId] : null;
      if (tabDataKeys && tabData.data) {
        tabDataKeys.forEach(function(key) { if (tabData.data[key] !== undefined) appData[key] = tabData.data[key]; });
        if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) appData muté par pull serveur → index recherche périmé
        _rerenderCurrentTab(tabId);
        // FIX-COURTESY-TOUCH (revue adversariale 2026-07-17) : empreinte re-prise après le pull
        // (un delta venu du serveur n'est pas un travail de l'utilisateur).
        _csiSnapshotTabEntry(tabId);
        if (typeof onApplied === 'function') onApplied();
      }
    }).catch(function(err) { void 0; });
  }

  // FIX-READONLY-REFRESH (2026-06-30) : rafraîchissement périodique de l'onglet courant pour un
  // viewer en LECTURE SEULE. Branché sur le tick d'autosave (qui ne pousse rien en lecture seule)
  // → pas de timer supplémentaire. Très conservateur : seulement si l'onglet navigateur est
  // visible, aucune modale ouverte, et un onglet de données actif.
  function _readOnlyRefreshTick() {
    if (!(typeof isServerModeUnlocked === 'function' && isServerModeUnlocked())) return;
    if (!(window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn())) return;
    if (document.hidden) return;
    var ov = document.getElementById('modalOverlay');
    if (ov && ov.offsetParent !== null) return;          // ne pas perturber une modale ouverte
    var tab = _currentActiveTab;
    if (!tab || tab === 'dashboard' || tab === 'settings') return;
    _pullAndApplyTab(tab, function() {
      showToast2(t('toasts.dataRefreshed', 'Données actualisées (modifiées par un autre utilisateur)'), 'info');
    });
  }

  function loadModuleContent(moduleId) {
    // === CSI-ME === (audit rôles 2026-07-03, MAJEUR) section en droit 'read' pour un
    // compte de sous-entité : avertir à l'OUVERTURE (une fois par section et par session).
    // L'UI d'édition reste globale ; le serveur refuse l'écriture (403 SECTION_READONLY,
    // branche dédiée au push) — sans cet avertissement l'utilisateur découvrait la
    // lecture seule après avoir saisi ses modifications.
    try {
      var _emRo = window.CSI_EntityManager;
      if (_emRo && _emRo.isMultiEntity() && _emRo.isSubEntityUser() && _emRo.sectionRight(moduleId) === 'read') {
        window._csiRoWarnedTabs = window._csiRoWarnedTabs || {};
        if (!window._csiRoWarnedTabs[moduleId] && typeof showToast2 === 'function') {
          window._csiRoWarnedTabs[moduleId] = true;
          showToast2(t('entities.readonlySectionNote', 'Section en lecture seule pour votre périmètre — les modifications n\'y seront pas enregistrées.'), 'info', 7000);
        }
      }
    } catch (e) { /* informatif */ }
    // FIX-RA-COMMIT-EXITS : la saisie du wizard part AVEC l'onglet quitté (la garde interne
    // du commit vérifie que _currentActiveTab est encore 'risk-analysis' — ici il l'est,
    // la réaffectation n'a pas encore eu lieu).
    try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
    // Tab switch with data sync (server mode) or local save
    var AuthMgr = window.CSI_AuthManager;
    if (isServerModeUnlocked() && AuthMgr && AuthMgr.isLoggedIn() && !isReadOnlyMode()) {
      // AUDIT-CO-H1: Sérialiser les navigations (empêche les release/acquire entrelacés)
      // FIX-TABNAV-QUEUE : la navigation n'est plus perdue — la dernière destination
      // demandée est mémorisée et rejouée dès la fin du changement en cours.
      if (_tabSwitchInProgress) {
        _queuedTabSwitch = moduleId;
        void 0;
        return;
      }
      _tabSwitchInProgress = true;
      var prevTab = _currentActiveTab;
      var PresenceMgr = window.CSI_PresenceManager || window.CSI_LockManager;

      // Push données du tab précédent avant navigation
      function _proceedToNewTab() {
       try {
        _currentActiveTab = moduleId;
        window._currentActiveTab = moduleId;
        _tabSwitchInProgress = false;

        // FIX-TABNAV-QUEUE : rejouer la dernière navigation demandée pendant ce changement
        if (_queuedTabSwitch && _queuedTabSwitch !== moduleId) {
          var _nextTab = _queuedTabSwitch;
          _queuedTabSwitch = null;
          // FIX-COURTESY-TOUCH (revue adversariale 2026-07-17) : l'onglet intermédiaire n'atteint
          // jamais _csiEnsureClusterForTab — poser son empreinte ici, sinon le replay le verrait
          // « sans empreinte » → marqué travaillé par prudence pour un simple double-clic.
          _csiSnapshotTabEntry(moduleId);
          setTimeout(function() { loadModuleContent(_nextTab); }, 0);
          return;
        }
        _queuedTabSwitch = null;

        // Notifier le presence manager du changement d'onglet
        if (PresenceMgr && PresenceMgr.notifyTabChange) {
          PresenceMgr.notifyTabChange(moduleId);
        }

        // === CSI-CO === garantir la grappe du nouvel onglet (acquisition silencieuse si
        // libre ; refus → écran en lecture seule + bandeau nominatif). No-op hors co-édition.
        _csiEnsureClusterForTab(moduleId);

        // Pull données fraîches en mode serveur (sauf dashboard/settings)
        if (moduleId !== 'dashboard' && moduleId !== 'settings') {
          var SyncMgr = window.CSI_SyncManager;
          if (SyncMgr && SyncMgr.pullTabFromServer) {
            var _pullTargetTab = moduleId;
            // === CSI-ME === (audit 2026-08-05) même garde de périmètre que _pullAndApplyTab :
            // sans elle, la réponse d'une partition quittée s'appliquait à la nouvelle.
            var _navScope = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
              ? window.CSI_EntityManager.echoId() : null;
            SyncMgr.pullTabFromServer(_pullTargetTab).then(function(tabData) {
              var _navNow = (window.CSI_EntityManager && window.CSI_EntityManager.echoId)
                ? window.CSI_EntityManager.echoId() : null;
              if (_navNow !== _navScope) return;
              if (_currentActiveTab !== _pullTargetTab) return;
              // FIX-PUSH-QUEUE (audit 2026-06-10) : ne JAMAIS écraser les clés d'un onglet
              // dont des modifications locales attendent encore d'être poussées — c'était
              // le 2e étage de la perte de données (push jeté puis pull écraseur).
              if (_csiDirtyTabs[_pullTargetTab]) {
                void 0;
                _retryDirtyServerPushes();
                return;
              }
              if (tabData && typeof tabData === 'object') {
                // FIX-OPTIMISTIC-REV (2026-06-11) : mémoriser la révision serveur de l'onglet
                // pour que le prochain push parte de la bonne base (détection de conflit).
                // FIX-REV-MONOTONIC (audit grappe analyse 2026-08-17, C4) : réponse TARDIVE à
                // révision inférieure → ne rien adopter, ne rien appliquer (pull parti avant
                // notre dernier push abouti — l'adopter rétrograde la rev et efface l'écran).
                if (typeof tabData._rev === 'number' && typeof _tabRevisions[moduleId] === 'number'
                    && tabData._rev < _tabRevisions[moduleId]) return;
                if (typeof tabData._rev === 'number') _tabRevisions[moduleId] = tabData._rev;
                var tabDataKeys = PresenceMgr && PresenceMgr.getTabDataKeys ? PresenceMgr.getTabDataKeys()[moduleId] : null;
                if (tabDataKeys && tabData.data) {
                  tabDataKeys.forEach(function(key) {
                    if (tabData.data[key] !== undefined) {
                      appData[key] = tabData.data[key];
                    }
                  });
                  if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) appData muté par pull serveur → index recherche périmé
                  _rerenderCurrentTab(moduleId);
                  // FIX-COURTESY-TOUCH (revue adversariale 2026-07-17) : RE-prendre l'empreinte
                  // APRÈS l'application du pull — sinon les modifications d'un collègue fusionnées
                  // ici comptaient comme un « delta réel » et re-marquaient la grappe travaillée
                  // sans aucune saisie (courtoisie de nouveau inerte, N3 réintroduit).
                  _csiSnapshotTabEntry(moduleId);
                }
              }
            }).catch(function(err) {
              void 0;
            });
          }
        }
       } catch(err) {
        void 0;
        _tabSwitchInProgress = false;
       }
      }

      // Push données du tab précédent avant de changer
      if (prevTab && prevTab !== 'dashboard' && prevTab !== 'settings') {
        var _isServerPush = typeof isServerModeUnlocked === 'function' && isServerModeUnlocked()
            && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn();
        if (_isServerPush) {
          // Mode serveur : push les données de l'ancien onglet
          var _tabKeys = PresenceMgr && PresenceMgr.getTabDataKeys ? PresenceMgr.getTabDataKeys()[prevTab] : null;
          var _pushPromise = Promise.resolve();
          if (_tabKeys && window.appData) {
            var _tabData = {};
            _tabKeys.forEach(function(key) { _tabData[key] = window.appData[key]; });
            // FIX-COURTESY-TOUCH (2026-07-17, N3) : l'onglet quitté porte-t-il un delta réel ?
            // Ferme la fenêtre « saisie puis navigation avant le tick d'autosave » (sinon la
            // grappe éditée serait relâchée par courtoisie alors qu'on va y revenir). Un push
            // de bascule SANS delta ne marque toujours pas (contrat historique conservé).
            var _elmSw = window.CSI_EditLockManager;
            if (_elmSw && _elmSw.markTabTouched && _elmSw.isCoedit && _elmSw.isCoedit() && _csiTabDataChanged(prevTab)) _elmSw.markTabTouched(prevTab);
            _pushPromise = _serverDataPushWithRetry(prevTab, _tabData, PresenceMgr, 3);
          }
          _pushPromise.then(_proceedToNewTab).catch(_proceedToNewTab);
        } else {
          // Mode local : saveData synchrone
          try { saveData(); } catch(e) { void 0; }
          _proceedToNewTab();
        }
      } else {
        _proceedToNewTab();
      }
    } else {
      _currentActiveTab = moduleId;
      window._currentActiveTab = moduleId;
      // FIX-READONLY-REFRESH (2026-06-30) : en LECTURE SEULE + mode serveur, rafraîchir aussi les
      // données de l'onglet au changement (le bloc d'édition ci-dessus est gardé par !isReadOnlyMode).
      // Aucun push, aucune opération de verrou. Sans ceci, le viewer ne voit jamais les modifs des autres.
      if (isServerModeUnlocked() && AuthMgr && AuthMgr.isLoggedIn()) {
        _pullAndApplyTab(moduleId, null);
      }
      // === CSI-CO === on passe ici quand l'onglet PRÉCÉDENT était en lecture seule de grappe
      // (_csiClusterReadOnly=true → isReadOnlyMode()=true). Réévaluer pour le NOUVEL onglet :
      // retour sur une grappe détenue/libre → redevient éditable. No-op si editModeEnabled=false.
      _csiEnsureClusterForTab(moduleId);
    }

    // Cleanup chart instances when navigating away from risk-analysis
    if (moduleId !== 'risk-analysis' && typeof window.cleanupRiskAnalysisModule === 'function') {
      window.cleanupRiskAnalysisModule();
    }

    switch (moduleId) {
      case "dashboard":
        updateDashboard();
        break;
      case "controls":
        loadControlsTable();
        break;
      case "nis2-controls":
        loadNis2ControlsTable();
        break;
      case "recyf-controls":
        loadRecyfControlsTable();
        break;
      case "nis2-program":
        loadNis2Program();
        break;
      case "actions":
        loadActionsTable();
        break;
      case "risks":
        loadRisksTable();
        updateRiskMatrix();
        break;
      case "threats-section":
        loadThreatsTable();
        break;
      case "nonconformities":
        loadNonConformitiesTable();
        break;
      case "soa":
        loadSoaTable();
        break;
      case "docReview":
        loadDocReviewTable();
        break;
      case "audits":
        loadAuditsTable();
        break;
      case "managementReviews":
        loadReviewsTable();
        break;
      case "stakeholders":
        loadStakeholdersTable();
        break;
      case "documents":
        loadDocumentsTable();
        break;
      case "kpis":
        loadKpiTable();
        break;
      case "critical-assets":
        loadCriticalAssetsTable();
        break;
      case "project-risks":
        loadProjectRisksTable();
        break;
      case "nis2-socle":
        renderSocleGrids();
        break;
      case "risk-analysis":
        if (typeof renderRiskAnalysisModule === 'function') renderRiskAnalysisModule();
        break;
      case "impact-matrices":
        if (typeof renderImpactMatricesModule === 'function') renderImpactMatricesModule();
        break;
      case "framework-mapping":
        if (typeof loadFrameworkMappingView === 'function') loadFrameworkMappingView();
        break;
      case "auditLog":
        renderAuditLogModule();
        break;
      case "manual":
        break;
      case "settings":
        renderSettingsPage();
        break;
    }
    refreshReadOnlyLocks();
  }

  // ========================================
  // RACCOURCIS CLAVIER GLOBAUX
  // ========================================
  var _keyboardShortcutsSetup = false;
  function setupKeyboardShortcuts() {
    if (_keyboardShortcutsSetup) return;
    _keyboardShortcutsSetup = true;
    document.addEventListener("keydown", (e) => {
      // Ignorer si on est dans un champ de saisie
      const target = e.target;
      const isInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable;

      // Escape - Fermer les modals (sauf modales obligatoires)
      if (e.key === "Escape") {
        const openModal = document.querySelector(".modal-overlay--visible, .modal.show, .modal--visible");
        if (openModal) {
          // Ne pas fermer si la modale est obligatoire (hideCancel + hideClose)
          if (_modalMandatory) {
            e.preventDefault();
            return;
          }
          closeModal();
          e.preventDefault();
          return;
        }
      }

      // Ctrl/Cmd + S - Sauvegarder
      if ((e.ctrlKey || e.metaKey) && e.key === "s") {
        e.preventDefault();
        // === CSI-CO === (audit co-édition 2026-08-05) Le raccourci est posé sur `document` :
        // il était donc actif pour TOUS les rôles, viewer compris, sans aucune garde — et
        // annonçait « Données enregistrées » alors que saveData() retourne true de façon
        // inconditionnelle en mode serveur. Message honnête en consultation.
        if (typeof isReadOnlyMode === 'function' && isReadOnlyMode()) {
          if (typeof showReadOnlyNotice === 'function') showReadOnlyNotice(t('common.save', 'Enregistrer'));
          return;
        }
        if (typeof saveData === "function") {
          const success = saveData();
          if (success) {
            showToast2(t('toasts.dataSaved'), "success");
          }
        }
        return;
      }

      // Ctrl/Cmd + E - Export rapide (JSON)
      if ((e.ctrlKey || e.metaKey) && e.key === "e" && !isInput) {
        e.preventDefault();
        if (typeof exportData === "function") {
          exportData();
        }
        return;
      }

      // Ctrl/Cmd + P - Export PDF
      if ((e.ctrlKey || e.metaKey) && e.key === "p" && !isInput) {
        e.preventDefault();
        if (typeof generatePdfReportWithFeedback === "function") {
          generatePdfReportWithFeedback();
        }
        return;
      }

      // Ctrl/Cmd + , - Ouvrir les paramètres
      if ((e.ctrlKey || e.metaKey) && e.key === ",") {
        e.preventDefault();
        if (typeof openSettings === "function") {
          openSettings();
        }
        return;
      }

      // Ctrl/Cmd + D - Aller au Dashboard
      if ((e.ctrlKey || e.metaKey) && e.key === "d" && !isInput) {
        e.preventDefault();
        const dashNav = document.querySelector('[data-tab="dashboard"]');
        if (dashNav) dashNav.click();
        return;
      }
    });

    void 0;
  }

  // app.js
  // Système de sauvegarde automatique
  let _autoSaveInterval = null;
  let _lastSavedHash = null;
  let _autoSavePaused = false;

  function computeDataHash() {
    // Optimized hash: no per-char variable allocation, bitwise OR coerces to int
    var str = JSON.stringify(appData);
    var hash = 0;
    var len = str.length;
    for (var i = 0; i < len; i++) {
      hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
    }
    return hash;
  }

  function autoSave() {
    if (_autoSavePaused) return;
    if (typeof isReadOnlyMode === 'function' && isReadOnlyMode()) {
      // FIX-READONLY-REFRESH (2026-06-30) : en lecture seule, l'autosave ne pousse rien — on
      // réutilise son créneau pour rafraîchir l'onglet courant (voir les modifs des autres).
      _readOnlyRefreshTick();
      return;
    }

    const currentHash = computeDataHash();
    if (currentHash !== _lastSavedHash) {
      const success = saveData();
      if (success) {
        _lastSavedHash = currentHash;
        // Refonte header 2026-07 : en mode serveur, saveData() pilote lui-même la pastille
        // (« saving » pendant le push asynchrone, puis HH:MM ou erreur dans le .then) —
        // un appel synchrone ici écraserait l'état « saving » avant tout rendu.
        if (!(typeof isServerModeUnlocked === 'function' && isServerModeUnlocked())) {
          updateAutoSaveIndicator();
        }
      }
    }
    // FIX-PUSH-QUEUE (audit 2026-06-10) : filet de l'autosave — re-pousser les onglets
    // dont un push précédent a échoué (réseau, mutex) même si le hash global n'a pas
    // changé depuis (les données sont sinon bloquées en mémoire jusqu'au rechargement).
    if (typeof _retryDirtyServerPushes === 'function') _retryDirtyServerPushes();
  }

  // Refonte header 2026-07 : pastille de synchronisation (.csi-sync-dot).
  // États : neutre/synchronisé (point vert), --saving (point warning + pulse),
  // --error (point danger). Le texte reste court : « Synchronisé » au démarrage,
  // puis « HH:MM » de la dernière sauvegarde ; le détail complet vit dans title.
  function _syncDotSetState(state, text) {
    var indicator = document.getElementById("autoSaveIndicator");
    if (!indicator) return null;
    indicator.classList.remove("csi-sync-dot--saving", "csi-sync-dot--error");
    if (state) indicator.classList.add("csi-sync-dot--" + state);
    if (text != null) {
      var textEl = indicator.querySelector(".csi-sync-dot__text");
      if (textEl) {
        textEl.removeAttribute("data-i18n");
        textEl.textContent = text;
      } else {
        indicator.textContent = text;
      }
    }
    return indicator;
  }

  function updateAutoSaveIndicator() {
    const now = new Date();
    let shortTime;
    try {
      shortTime = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    } catch (e) {
      shortTime = now.toLocaleTimeString();
    }
    const indicator = _syncDotSetState(null, shortTime);
    if (!indicator) return;
    indicator.title = `${t('common.savedAt')} ${now.toLocaleTimeString()}`;
    indicator.removeAttribute("data-i18n-title");
    indicator.classList.add("autosave-flash");
    setTimeout(() => indicator.classList.remove("autosave-flash"), 1000);
  }

  function initAutoSave(intervalMs = 30000) {
    // Calcul du hash initial
    _lastSavedHash = computeDataHash();

    // Démarrer l'intervalle d'autosave
    if (_autoSaveInterval) clearInterval(_autoSaveInterval);
    _autoSaveInterval = setInterval(autoSave, intervalMs);

    // Sauvegarder aussi avant de quitter la page
    window.addEventListener("beforeunload", () => {
      // FIX-RA-COMMIT-EXITS : en mode local/file:// saveData est synchrone — le commit ici
      // rend la sauvegarde de fermeture réellement complète (no-op si déjà commité).
      try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
      autoSave();
      _autoSavePaused = true; // Éviter double sauvegarde après
    });

    // Sauvegarder quand la page perd le focus (changement d'onglet)
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        autoSave();
      }
    });

    void 0;
  }

  function pauseAutoSave() {
    _autoSavePaused = true;
  }

  function resumeAutoSave() {
    _autoSavePaused = false;
  }

  window.initAutoSave = initAutoSave;
  window.pauseAutoSave = pauseAutoSave;
  window.resumeAutoSave = resumeAutoSave;

  /**
   * Crée et affiche le bandeau DEMO discret en haut de page
   */
  function initDemoBanner() {
    if (!DEMO_MODE) return;

    const banner = document.createElement('div');
    banner.id = 'demoBanner';
    banner.className = 'demo-banner';
    const _t = window.CSI_i18n ? window.CSI_i18n.t.bind(window.CSI_i18n) : (k) => k;
    banner.innerHTML = `
      <div class="demo-banner__content">
        <span class="demo-banner__text">
          <i class="fas fa-flask"></i>
          <span data-i18n="demo.banner.text">${_t('demo.banner.text')}</span>
        </span>
        <a href="${DEMO_WEBSITE_URL}" target="_blank" rel="noopener" class="demo-banner__link">
          <i class="fas fa-arrow-left"></i>
          <span data-i18n="demo.banner.backWebsite">${_t('demo.banner.backWebsite')}</span>
        </a>
        <a href="mailto:${DEMO_CONTACT_EMAIL}" class="demo-banner__link">
          <i class="fas fa-envelope"></i>
          <span data-i18n="demo.banner.contact">${_t('demo.banner.contact')}</span>
        </a>
      </div>
    `;

    // Insérer au tout début du body
    document.body.insertBefore(banner, document.body.firstChild);

    // Ajouter les styles inline pour le bandeau
    const style = document.createElement('style');
    style.textContent = `
      .demo-banner {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        z-index: 100000;
        background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
        color: #e8e8e8;
        padding: 8px 16px;
        font-size: 0.85rem;
        box-shadow: 0 2px 8px rgba(0,0,0,0.15);
      }
      .demo-banner__content {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 24px;
        max-width: 1200px;
        margin: 0 auto;
      }
      .demo-banner__text {
        display: flex;
        align-items: center;
        gap: 8px;
        opacity: 0.9;
      }
      .demo-banner__text i {
        color: #64b5f6;
      }
      .demo-banner__link {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        background: rgba(255,255,255,0.1);
        color: #90caf9;
        padding: 6px 14px;
        border-radius: 20px;
        text-decoration: none;
        font-weight: 500;
        transition: all 0.2s ease;
        border: 1px solid rgba(144,202,249,0.3);
      }
      .demo-banner__link:hover {
        background: rgba(255,255,255,0.15);
        color: #fff;
        border-color: rgba(255,255,255,0.4);
        transform: translateY(-1px);
      }
      .demo-banner__link .fa-external-link-alt {
        font-size: 0.7em;
        opacity: 0.7;
      }
      /* Décaler le contenu principal pour le bandeau */
      body.demo-mode {
        padding-top: 44px !important;
      }
      body.demo-mode .app-header {
        top: 44px;
      }
      body.demo-mode .sidebar {
        top: calc(var(--header-height, 60px) + 44px);
        height: calc(100vh - var(--header-height, 60px) - 44px);
      }
      @media (max-width: 768px) {
        .demo-banner__content {
          flex-direction: column;
          gap: 8px;
        }
        .demo-banner {
          padding: 10px 12px;
        }
      }
    `;
    document.head.appendChild(style);

    // Ajouter classe au body
    document.body.classList.add('demo-mode');
  }

  function loadAppVersion() {
    // no-store : version.json n'est pas couvert par le no-cache du .htaccess (js|css|index.html),
    // et sans en-tête explicite le navigateur lui applique un cache heuristique (~10 % de l'âge du
    // fichier, soit plusieurs jours). Le label repassait alors à l'ancienne version APRÈS un F5,
    // qui revalide le document mais sert les sous-ressources depuis le cache. Ne pas dépendre
    // ici du .htaccess : ses règles sont sous <IfModule mod_headers.c> donc inertes si absent.
    fetchWithTimeout('version.json', { cache: 'no-store' }, 5000)
      .then(function(r) { return r.ok ? r.json() : null; })
      .then(function(data) {
        if (data && data.version) {
          var v = data.version;
          if (window.CSI) window.CSI.version = v;
          var el = document.getElementById('appVersionFooter');
          if (el) el.textContent = 'V' + v;
          var badge = document.getElementById('appVersionBadge');
          if (badge) badge.textContent = 'V' + String(v).split('.')[0];
        }
      })
      .catch(function() { /* version.json optionnel */ });
  }

  async function initApp() {
    try {
      // Mode DEMO: toujours charger les données démo (écrase le cache localStorage)
      if (DEMO_MODE) {
        void 0;
        loadDemoDataSilent();

        // Activer le mode édition par défaut en DEMO (pas de mot de passe)
        editModeEnabled = true;
      }

      // Login wall: In server mode, require JWT authentication before showing any data
      // Ensure LicenseManager detection is complete before checking server mode
      // (detect() is async and may not have resolved yet from server-setup.js's setTimeout)
      if (!DEMO_MODE && window.location.protocol !== 'file:') {
        var _lm = window.CSI_LicenseManager;
        if (_lm && !_lm.isDetected()) {
          await _lm.detect().catch(function() {});
        }
        // Ensure TrialManager detection is complete before UI rendering (wizard + license section)
        var _tm = window.CSI_TrialManager;
        if (_tm && !_tm.isDetected()) {
          await _tm.detect().catch(function() {});
        }
      }
      // Détecter LocalTrialManager + TrialManager pour Corporate Local
      var _edInit = window._csiEdition;
      if (!DEMO_MODE && _edInit && _edInit.id === 'corporate-local') {
        var _ltmInit = window.CSI_LocalTrialManager;
        if (_ltmInit) {
          // Détecter d'abord le LocalTrialManager (licence/trial local)
          if (!_ltmInit.isDetected()) {
            await _ltmInit.detect().catch(function() {});
          }
          // Puis le TrialManager global (qui délègue au LTM en corporate-local)
          var _tmLocal = window.CSI_TrialManager;
          if (_tmLocal && !_tmLocal.isDetected()) {
            await _tmLocal.detect().catch(function() {});
          }
        }
      }
      if (!DEMO_MODE && isServerModeUnlocked()) {
        var _authMgr = window.CSI_AuthManager;
        // FIX-SSO-BOOT-RACE: attendre que l'init auth ait fini (sso-redeem async notamment)
        // avant de tester isLoggedIn(). Sinon, en arrivant avec #sso-code=XXX dans l'URL,
        // le boot appelle validateToken() AVANT que sso-redeem ait stocké le token →
        // showLoginModal apparaît à tort, l'utilisateur voit la page de login malgré
        // une authentification SSO réussie. onAuthReady attend la fin du flow d'init.
        if (_authMgr && typeof _authMgr.onAuthReady === 'function') {
          await new Promise(function(resolve) { _authMgr.onAuthReady(resolve); });
        }
        if (_authMgr && !_authMgr.isLoggedIn()) {
          // Try to validate existing token first
          var _validUser = await _authMgr.validateToken();
          if (!_validUser) {
            // No valid token — show login modal (opaque background, non-dismissible)
            await new Promise(function(resolve) {
              _authMgr.showLoginModal(function(loggedIn) {
                if (!loggedIn) return; // modal is non-dismissible, should not happen
                if (_authMgr.mustChangePassword()) {
                  _authMgr.showForcePasswordChangeModal(function() { resolve(); });
                } else {
                  resolve();
                }
              });
            });
          } else if (_authMgr.mustChangePassword()) {
            await new Promise(function(resolve) {
              _authMgr.showForcePasswordChangeModal(function() { resolve(); });
            });
          }
        } else if (_authMgr && _authMgr.mustChangePassword()) {
          await new Promise(function(resolve) {
            _authMgr.showForcePasswordChangeModal(function() { resolve(); });
          });
        }
      }

      const hasCachedData = (() => {
        // En mode serveur, les données viendront du serveur (pas de cache local)
        if (window.CSI_Storage && window.CSI_Storage.isServerMode()) return true;
        try {
          return !!localStorage.getItem("smsi_data");
        } catch (e) {
          return false;
        }
      })();
      const showWelcome = shouldShowWelcome(hasCachedData);
      await loadData();

      // Étape 6: Blocage si serveur injoignable au chargement (mode serveur uniquement)
      if (window._csiServerLoadFailed && window.CSI_Storage && window.CSI_Storage.isServerMode()) {
        var overlay = document.createElement('div');
        overlay.id = 'csi-server-unreachable';
        overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:white;font-family:system-ui;';

        var icon = document.createElement('i');
        icon.className = 'fas fa-server';
        icon.style.cssText = 'font-size:3rem;color:#ef4444;';
        overlay.appendChild(icon);

        var title = document.createElement('h2');
        title.style.cssText = 'margin:0;font-size:1.5rem;';
        title.textContent = t('errors.serverUnreachable', 'Serveur inaccessible');
        overlay.appendChild(title);

        var desc = document.createElement('p');
        desc.style.cssText = 'margin:0;max-width:400px;text-align:center;opacity:0.8;';
        desc.textContent = t('errors.serverUnreachableDesc', 'Impossible de charger les données depuis le serveur. Vérifiez votre connexion réseau et que le serveur est en cours d\'exécution.');
        overlay.appendChild(desc);

        var retryBtn = document.createElement('button');
        retryBtn.style.cssText = 'margin-top:8px;padding:10px 24px;background:#3b82f6;color:white;border:none;border-radius:6px;cursor:pointer;font-size:14px;';
        retryBtn.textContent = t('common.retry', 'Réessayer');
        retryBtn.addEventListener('click', function() { location.reload(); });
        overlay.appendChild(retryBtn);

        document.body.appendChild(overlay);
        return; // Bloquer le reste de l'initialisation
      }

      initThemeToggle();
      // Restore compact mode
      try {
        if (localStorage.getItem("smsi_compact_mode") === "1") {
          document.body.classList.add("compact-mode");
        }
      } catch (e) { /* ignore */ }

      // Mode DEMO: initialiser le bandeau et skip les popups de sécurité
      if (DEMO_MODE) {
        initDemoBanner();
        // Démarrer le rechargement automatique des données (toutes les heures)
        startDemoAutoReload();
        // Marquer le setup serveur comme fait (mais pas le welcome)
        try {
          localStorage.setItem('smsi_setup_completed', '1');
          localStorage.setItem('smsi_deployment_mode', 'local');
          // Note: Ne pas marquer WELCOME_STORAGE_KEY ici, le wizard le fera
        } catch (e) { void 0; }
      }

      // Global unhandled promise rejection handler
      window.addEventListener('unhandledrejection', function(event) {
        void 0;
      });

      initReadOnlyBanner();
      initTrialBanner();
      updateAppTitle();
      setupNavigation();
      if (window.CSI_FrameworkMapper) CSI_FrameworkMapper.applyFrameworkVisibility();
      if (typeof applyNis2Referential === 'function') applyNis2Referential(false);
      setupEventListeners();
      setupKeyboardShortcuts();
      setupGlobalSearch();
      initDashboard();
      loadAppVersion();
      initAutoSave(30000); // Autosave toutes les 30 secondes

      // FIX-SCOPE-RESTORE (vidéo POC 2026-08-18) : un reload (bandeau « Mettre à jour »,
      // F5, reload auto post-409) ramenait TOUJOURS le périmètre à l'entité principale —
      // l'utilisateur en sous-entité retrouvait l'écran d'une AUTRE partition (« champs
      // vidés », « contenu précédent ») en croyant tout perdu. Le périmètre est mémorisé
      // PAR ONGLET (sessionStorage — un nouvel onglet ou une nouvelle session repart du
      // principal, l'esprit anti-confusion §11.3 est conservé) et restauré ici via le flux
      // de bascule ÉPROUVÉ (_csiSwitchScope : re-pull autoritaire + re-render + bandeau),
      // avec re-validation : id inconnu/archivé → purge silencieuse, on reste au principal.
      try {
        var _restoreScope = sessionStorage.getItem('csi_scope');
        var _emRestore = window.CSI_EntityManager;
        if (_restoreScope && _emRestore && _emRestore.isMultiEntity && _emRestore.isMultiEntity()
            && typeof window._csiSwitchScope === 'function') {
          var _known = (_emRestore.entities() || []).some(function(en) { return en && en.id === _restoreScope; });
          if (_known) {
            window._csiSwitchScope(_restoreScope).then(function(res) {
              if (!res || !res.ok) { try { sessionStorage.removeItem('csi_scope'); } catch (e) {} }
            });
          } else {
            sessionStorage.removeItem('csi_scope');
          }
        }
      } catch (e) { /* sessionStorage indisponible : comportement historique */ }

      // Start PresenceManager in server mode
      if (window.CSI_PresenceManager && typeof CSI_PresenceManager.start === 'function' && isServerModeUnlocked()) {
        CSI_PresenceManager.start();
      }

      // Start EditLockManager (verrou d'édition global) + abonnement aux events
      if (window.CSI_EditLockManager && typeof CSI_EditLockManager.start === 'function' && isServerModeUnlocked()) {
        CSI_EditLockManager.start();
        CSI_EditLockManager.subscribe(function (event, payload) {
          if (event === 'lock-lost') {
            // Le serveur nous a retiré le verrou (expiration ou takeover admin).
            // (audit co-édition 2026-07-03, MAJEUR) distinguer les DEUX causes : si personne
            // ne détient le verrou (payload.remoteLock absent), c'est une EXPIRATION (veille,
            // perte réseau > TTL) — accuser « l'administrateur a repris la main » était faux
            // et source de conflits d'équipe. La clé lockExpired existait sans être utilisée.
            if (!isReadOnlyMode()) {
              setEditMode(false, { silent: true });
              var _lockHolderNow = payload && payload.remoteLock;
              showToast2(_lockHolderNow
                ? t('modeBanner.lockStolen', "L'administrateur a repris la main. Vous êtes en lecture seule.")
                : t('modeBanner.lockExpired', "Votre verrou d'édition a expiré (perte de connexion). Repassez en mode édition pour continuer."), 'warning');
            }
            updateEditModeButtonUi();
            updateModeBannerUi();
          } else if (event === 'inactivity-auto-quit') {
            // Auto-quit après 5 min sans modification
            if (!isReadOnlyMode()) {
              // FIX-RA-COMMIT-EXITS : la saisie du wizard laissée ouverte part AVEC la
              // sortie (et le release attend le flush, sinon le push du commit prenait 423).
              try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
              setEditMode(false, { silent: true });
              if (CSI_EditLockManager.release) {
                try {
                  var _aqRelease = function() { try { CSI_EditLockManager.release(); } catch (e) {} };
                  if (typeof _flushServerPushes === 'function') _flushServerPushes(8000).then(_aqRelease, _aqRelease);
                  else _aqRelease();
                } catch (e) {}
              }
              showToast2(t('modeBanner.autoQuitInactivity', 'Mode édition désactivé après 5 min sans modification'), 'info');
            }
            updateEditModeButtonUi();
            updateModeBannerUi();
          } else if (event === 'cluster-lost') {
            // === CSI-CO === une grappe (pas toutes) a été reprise (takeover ciblé). Si c'est
            // celle de l'écran affiché → lecture seule immédiate + bandeau ; sinon simple
            // rafraîchissement (l'écran courant reste éditable sur sa propre grappe).
            if (editModeEnabled && CSI_EditLockManager.isCoedit && CSI_EditLockManager.isCoedit()
                && CSI_EditLockManager.isHeldByMeFor && !CSI_EditLockManager.isHeldByMeFor(_currentActiveTab)) {
              window._csiClusterReadOnly = true;
              refreshReadOnlyLocks();
              showToast2(t('modeBanner.lockStolen', "L'administrateur a repris la main. Vous êtes en lecture seule."), 'warning');
            }
            updateEditModeButtonUi();
            updateModeBannerUi();
          } else if (event === 'holder-changed' || event === 'state-updated') {
            updateEditModeButtonUi();
            updateModeBannerUi();
          }
        });
        // Document-wide listener pour notifier les éditions à EditLockManager (anti auto-quit)
        document.addEventListener('input', function () {
          if (CSI_EditLockManager.isHeldByMe && CSI_EditLockManager.isHeldByMe()) {
            CSI_EditLockManager.notifyEdit();
          }
        }, true);
        document.addEventListener('change', function () {
          if (CSI_EditLockManager.isHeldByMe && CSI_EditLockManager.isHeldByMe()) {
            CSI_EditLockManager.notifyEdit();
          }
        }, true);
      }

      // Mode local : la pastille ne « synchronise » rien — libellé neutre de
      // sauvegarde automatique (l'heure remplace le texte dès la 1re autosave).
      if (!isServerModeUnlocked()) {
        var _syncTextEl = document.querySelector('#autoSaveIndicator .csi-sync-dot__text');
        if (_syncTextEl) {
          _syncTextEl.setAttribute('data-i18n', 'header.syncLocal');
          _syncTextEl.textContent = t('header.syncLocal', 'Sauvegarde auto');
        }
      }

      // Server mode: show user indicator + logout button in header
      if (isServerModeUnlocked() && window.CSI_AuthManager && window.CSI_AuthManager.isLoggedIn()) {
        var _sui = document.getElementById('serverUserIndicator');
        if (_sui) {
          var _currentUsr = window.CSI_AuthManager.getCurrentUser();
          var _nameSpan = _sui.querySelector('.server-user-indicator__name');
          if (_nameSpan && _currentUsr) _nameSpan.textContent = _currentUsr.name;
          _sui.style.display = '';
        }
        var _logoutBtn = document.getElementById('serverLogoutBtn');
        if (_logoutBtn) {
          _logoutBtn.addEventListener('click', function() {
            _logoutBtn.disabled = true;
            // FIX-RA-COMMIT-EXITS : la saisie du wizard part avant la fin de session.
            try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
            var _doLogout = function() {
              window.CSI_AuthManager.logout().then(function() {
                location.reload();
              }).catch(function() {
                location.reload();
              });
            };
            if (typeof _flushServerPushes === 'function') { _flushServerPushes(8000).then(_doLogout, _doLogout); return; }
            window.CSI_AuthManager.logout().then(function() {
              location.reload();
            }).catch(function() {
              location.reload();
            });
          });
        }
      }

      // Refonte header 2026-07 : menu utilisateur (avatar + items) — après l'init auth
      // ci-dessus pour connaître l'utilisateur courant (mode serveur) ou son absence (local).
      initUserMenu();
      // Palier étroit ≤1120px : le bouton édition devient icône seule → reposer title/aria
      // quand on franchit le seuil (updateEditModeButtonUi n'est sinon rappelé que sur
      // changement d'état). addListener = repli vieux moteurs (file://).
      try {
        var _mqNarrowHeader = window.matchMedia("(max-width: 1120px)");
        var _mqNarrowHandler = function() { updateEditModeButtonUi(); };
        if (_mqNarrowHeader.addEventListener) _mqNarrowHeader.addEventListener("change", _mqNarrowHandler);
        else if (_mqNarrowHeader.addListener) _mqNarrowHeader.addListener(_mqNarrowHandler);
      } catch (e) { /* matchMedia absent : titles posés au prochain changement d'état */ }

      // i18n: translate static HTML elements (data-i18n attributes) on initial load
      if (window.CSI_i18n && typeof window.CSI_i18n.translatePage === 'function') {
        window.CSI_i18n.translatePage();
      }

      // Toggle manual language on initial load
      var _initLang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : 'fr';
      var _frManual = document.getElementById('manualContentFR');
      var _enManual = document.getElementById('manualContentEN');
      if (_frManual) _frManual.style.display = (_initLang === 'fr') ? '' : 'none';
      if (_enManual) _enManual.style.display = (_initLang !== 'fr') ? '' : 'none';

      hideLoadingSpinner();
      // FIX-FRICTION-LOGIN-NOISE (simulation friction 2026-07-19) : le toast « Application
      // chargée avec succès » était du bruit pur (l'app est visiblement chargée — le spinner
      // vient d'être masqué) qui s'empilait avec l'info « Dernière connexion » et un éventuel
      // avertissement de verrou. Retiré. L'info « Dernière connexion / IP » (valeur sécurité)
      // est CONSERVÉE (auth-manager.js) et ne s'empile plus.

      // Show setup wizard on first launch (combines language + welcome + demo)
      // Chain modals sequentially to prevent stacking (BUG-701)
      function _showCyfunModalIfNeeded() {
        var _fwEnabled = (appData.frameworkMapper && appData.frameworkMapper.enabledFrameworks) || [];
        // m-RC4 (audit fond 2026-07-11) : inclure recyf2025 — la modale fallback de choix de
        // niveau/degré ne se déclenchait jamais pour un utilisateur ReCyF (garde CyFun-only).
        var _nis2Enabled = _fwEnabled.indexOf('nis2') >= 0 || _fwEnabled.indexOf('cyfun2025') >= 0 || _fwEnabled.indexOf('recyf2025') >= 0;
        // FIX: If cyfunLevel is already set (configured by admin or another user),
        // treat _cyfunLevelChosen as true to prevent showing the wizard again.
        // m-RC4 (revu) : NE PAS étendre ce court-circuit à recyfLevel — il reçoit un défaut
        // 'importante' à CHAQUE chargement (normalisation), donc « défini » ≠ « choisi » :
        // l'étendre supprimait la modale de choix de degré pour un import ReCyF legacy.
        // cyfunLevel, lui, reste '' tant qu'aucun choix n'a été fait (marqueur fiable).
        if (_nis2Enabled && appData.cyfunLevel && !appData._cyfunLevelChosen) {
          appData._cyfunLevelChosen = true;
        }
        if (_nis2Enabled && !appData._cyfunLevelChosen) {
          setTimeout(function() {
            var modal = document.getElementById("cyfunLevelModal");
            if (modal) {
              var curLang = appData.cyfunLang || 'en';
              var langRadio = modal.querySelector('input[name="cyfunLangChoice"][value="' + curLang + '"]');
              if (langRadio) langRadio.checked = true;
              if (window.prepareNis2LevelModal) window.prepareNis2LevelModal();
              modal.classList.add("modal-overlay--visible");
            }
          }, 300);
        }
      }

      function _showPasswordSetupThenCyfun() {
        if (typeof queueEditPasswordSetup === 'function' && typeof shouldPromptEditPasswordSetup === 'function' && shouldPromptEditPasswordSetup()) {
          // Wait for modalOverlay to be dismissed before showing CyFun
          // Uses MutationObserver on the actual modalOverlay element (not phantom ID)
          var _overlay = document.getElementById('modalOverlay');
          if (_overlay) {
            var _pwdObserver = new MutationObserver(function(mutations) {
              if (!_overlay.classList.contains('modal-overlay--visible')) {
                _pwdObserver.disconnect();
                setTimeout(_showCyfunModalIfNeeded, 300);
              }
            });
            _pwdObserver.observe(_overlay, { attributes: true, attributeFilter: ['class'] });
            queueEditPasswordSetup();
            // Timeout fallback in case observer never fires
            setTimeout(function() { _pwdObserver.disconnect(); _showCyfunModalIfNeeded(); }, 30000);
          } else {
            queueEditPasswordSetup();
            setTimeout(_showCyfunModalIfNeeded, 2000);
          }
        } else {
          _showCyfunModalIfNeeded();
        }
      }

      if (DEMO_MODE) {
        showDemoWelcomeWizard();
      } else if (showWelcome) {
        // Chain: wizard → password setup → CyFun modal (sequential, no stacking)
        showSetupWizard(function() {
          setTimeout(_showPasswordSetupThenCyfun, 300);
        });
        // If wizard was skipped (not first launch), still check password + CyFun
        // The wizard calls onComplete immediately if skipped
      } else {
        // No wizard needed, proceed directly
        setTimeout(_showPasswordSetupThenCyfun, 300);
      }
      // Sync settings dropdown with current level
      var _cyfLvlSel = document.getElementById("cyfunLevelSelect");
      if (_cyfLvlSel) _cyfLvlSel.value = appData.cyfunLevel || 'basic';
    } catch (error) {
      void 0;
      hideLoadingSpinner();
      showToast(t('toasts.appLoadError', 'Erreur lors du chargement : {{message}}').replace('{{message}}', error.message), "error");
    }
  }

  /**
   * Wizard de bienvenue simplifié pour le mode DEMO
   * Affiche uniquement le choix de langue puis une page d'accueil
   */
  function showDemoWelcomeWizard() {
    // Vérifier si déjà vu (utiliser une clé spécifique pour la démo)
    const DEMO_WELCOME_KEY = 'smsi_demo_welcome_seen';
    try {
      if (localStorage.getItem(DEMO_WELCOME_KEY) === '1') return;
    } catch (e) {}

    if (document.getElementById("demoWelcomeWizard")) return;

    // Force English for initial wizard render (first slide always in English)
    if (window.CSI_i18n) window.CSI_i18n.setLanguage('en');
    const _t = window.CSI_i18n ? window.CSI_i18n.t.bind(window.CSI_i18n) : (k) => k;
    const currentLang = 'en';
    const overlay = document.createElement("div");
    overlay.id = "demoWelcomeWizard";
    overlay.className = "wizard-overlay";
    overlay.innerHTML = `
      <div class="wizard-modal" role="dialog" aria-modal="true" aria-labelledby="demoWizardTitle" style="max-width: 480px; overflow-y: auto;">
        <div style="padding: 28px;">
          <div style="text-align: center; margin-bottom: 20px;">
            <div style="font-size: 2.5rem; margin-bottom: 12px;">
              <i class="fas fa-shield-alt" style="color: var(--color-primary, #3b82f6);"></i>
            </div>
            <h2 id="demoWizardTitle" style="margin: 0 0 6px 0; font-size: 1.35rem; color: var(--color-text);">
              ${_t('demo.welcome.title')}
            </h2>
            <p id="demoWizardSubtitle" style="margin: 0; color: var(--color-text-secondary); font-size: 0.9rem;">
              ${_t('demo.welcome.subtitle')}
            </p>
          </div>

          <div style="margin-bottom: 20px;">
            <label id="demoWizardLangLabel" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--color-text); font-size: 0.9rem;">
              <i class="fas fa-globe"></i> ${_t('demo.welcome.languageLabel')}
            </label>
            <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
              ${window.CSI_i18n ? Object.entries(window.CSI_i18n.getAvailableLanguages()).map(([code, info]) => `
                <button class="wizard-lang-btn ${code === currentLang ? 'wizard-lang-btn--selected' : ''}" type="button" data-lang="${code}" style="padding: 10px 8px; border: 2px solid var(--color-border); border-radius: 8px; background: var(--color-surface); cursor: pointer; transition: all 0.2s;">
                  <span style="font-size: 1.3rem; display: block;">${info.flag}</span>
                  <span style="font-size: 0.8rem; color: var(--color-text);">${info.name}</span>
                </button>
              `).join("") : ''}
            </div>
          </div>

          <div style="background: var(--color-surface-hover, #f8f9fa); border-radius: 8px; padding: 14px; margin-bottom: 16px;">
            <p id="demoWizardInfo" style="margin: 0; color: var(--color-text); font-size: 0.85rem;">
              <i class="fas fa-info-circle" style="color: var(--color-primary);"></i>
              <strong>${_t('demo.welcome.infoLabel')}</strong> — ${_t('demo.welcome.info')}
            </p>
          </div>

          <div style="background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(139, 92, 246, 0.08)); border-radius: 8px; padding: 14px; margin-bottom: 20px; border: 1px solid rgba(59, 130, 246, 0.2);">
            <p id="demoWizardVersions" style="margin: 0 0 10px 0; color: var(--color-text); font-size: 0.85rem; font-weight: 500;">
              <i class="fas fa-download" style="color: var(--color-primary);"></i> ${_t('demo.welcome.versions')}
            </p>
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
              <div style="background: var(--color-surface); border-radius: 6px; padding: 8px; text-align: center;">
                <i class="fas fa-laptop" style="color: var(--color-primary); font-size: 1rem;"></i>
                <p id="demoWizardLocal" style="margin: 4px 0 0 0; font-size: 0.75rem; color: var(--color-text);"><strong>${_t('demo.welcome.local')}</strong></p>
              </div>
              <div style="background: var(--color-surface); border-radius: 6px; padding: 8px; text-align: center;">
                <i class="fas fa-cloud" style="color: var(--color-success, #22c55e); font-size: 1rem;"></i>
                <p id="demoWizardServer" style="margin: 4px 0 0 0; font-size: 0.75rem; color: var(--color-text);"><strong>${_t('demo.welcome.server')}</strong></p>
              </div>
            </div>
          </div>

          <div style="text-align: center;">
            <button id="demoWizardStart" type="button" style="background: var(--color-primary, #3b82f6); color: white; border: none; padding: 12px 28px; border-radius: 8px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s;">
              <i class="fas fa-rocket"></i> <span id="demoWizardStartText">${_t('demo.welcome.start')}</span>
            </button>
          </div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    // Afficher l'overlay avec animation
    requestAnimationFrame(() => {
      overlay.classList.add('is-visible');
    });

    // Gestion du choix de langue
    const langBtns = overlay.querySelectorAll('.wizard-lang-btn');
    langBtns.forEach(btn => {
      btn.addEventListener('click', () => {
        langBtns.forEach(b => {
          b.classList.remove('wizard-lang-btn--selected');
          b.style.borderColor = 'var(--color-border)';
          b.style.background = 'var(--color-surface)';
        });
        btn.classList.add('wizard-lang-btn--selected');
        btn.style.borderColor = 'var(--color-primary)';
        btn.style.background = 'var(--color-primary-light, #eff6ff)';

        // Appliquer la langue
        if (window.CSI_i18n) {
          window.CSI_i18n.setLanguage(btn.dataset.lang);
          window.CSI_i18n.markLanguageChosen();
          window.CSI_i18n.translatePage();
          // Re-translate wizard popup
          const _t2 = window.CSI_i18n.t.bind(window.CSI_i18n);
          const titleEl = document.getElementById('demoWizardTitle');
          if (titleEl) titleEl.textContent = _t2('demo.welcome.title');
          const subtitleEl = document.getElementById('demoWizardSubtitle');
          if (subtitleEl) subtitleEl.textContent = _t2('demo.welcome.subtitle');
          const infoEl = document.getElementById('demoWizardInfo');
          if (infoEl) infoEl.innerHTML = '<i class="fas fa-info-circle" style="color: var(--color-primary);"></i> <strong>' + _t2('demo.welcome.infoLabel') + '</strong> — ' + _t2('demo.welcome.info');
          const versionsEl = document.getElementById('demoWizardVersions');
          if (versionsEl) versionsEl.innerHTML = '<i class="fas fa-download" style="color: var(--color-primary);"></i> ' + _t2('demo.welcome.versions');
          const localEl = document.getElementById('demoWizardLocal');
          if (localEl) localEl.innerHTML = '<strong>' + _t2('demo.welcome.local') + '</strong>';
          const serverEl = document.getElementById('demoWizardServer');
          if (serverEl) serverEl.innerHTML = '<strong>' + _t2('demo.welcome.server') + '</strong>';
          const startEl = document.getElementById('demoWizardStartText');
          if (startEl) startEl.textContent = _t2('demo.welcome.start');
        }
      });
    });

    // Bouton démarrer
    document.getElementById('demoWizardStart').addEventListener('click', () => {
      // Marquer comme vu
      try {
        localStorage.setItem(DEMO_WELCOME_KEY, '1');
        if (window.CSI_i18n) {
          window.CSI_i18n.markLanguageChosen();
        }
      } catch (e) { void 0; }
      overlay.remove();
      // Rafraîchir le dashboard
      if (typeof initDashboard === 'function') {
        initDashboard();
      }
    });
  }

  document.addEventListener("DOMContentLoaded", initApp);

  // i18n: Refresh all translated constants and re-render UI on language change
  window.addEventListener('languageChange', function() {
    if (window._csiInvalidateSearchIndex) window._csiInvalidateSearchIndex(); // (audit 2026-07-03) libellés i18n de l'index recherche
    // Refresh all option arrays that use t()
    CONTROL_STATUS_OPTIONS = getControlStatusOptions();
    DOMAIN_OPTIONS = getDomainOptions();
    CMM_LEVEL_OPTIONS = getCmmLevelOptions();
    ACTION_PRIORITY_OPTIONS = getActionPriorityOptions();
    ACTION_STATUS_OPTIONS = getActionStatusOptions();
    TREATMENT_OPTIONS = getTreatmentOptions();
    THREAT_STATUS_LABELS = getThreatStatusLabels();
    THREAT_STATUS_OPTIONS = getThreatStatusOptions();
    STATUS_OPTIONS = getNcStatusOptions();
    NC_STATUS_LABELS = {};
    STATUS_OPTIONS.forEach(function(opt) { NC_STATUS_LABELS[opt.value] = opt.label; });
    NC_TYPE_OPTIONS = getNcTypeOptions();
    AUDIT_STATUS_OPTIONS = getAuditStatusOptions();
    AUDIT_STATUS_LABELS = {};
    AUDIT_STATUS_OPTIONS.forEach(function(opt) { AUDIT_STATUS_LABELS[opt.value] = opt.label; });
    AUDIT_TYPE_OPTIONS = getAuditTypeOptions();
    AUDIT_TYPE_LABELS = {};
    AUDIT_TYPE_OPTIONS.forEach(function(opt) { AUDIT_TYPE_LABELS[opt.value] = opt.label; });
    CONTROL_REVIEW_FREQ_OPTIONS = getControlReviewFreqOptions();
    OPERATIONAL_CAPABILITIES = getOperationalCapabilities();
    IMPACT_LEVEL_OPTIONS = getImpactLevelOptions();
    IMPACT_LEVEL_LABELS = getImpactLevelLabels();
    PROBABILITY_LEVEL_OPTIONS = getProbabilityLevelOptions();
    PROBABILITY_LEVEL_LABELS = getProbabilityLevelLabels();
    PROJECT_RISK_STATUS_OPTIONS = getProjectRiskStatusOptions();
    PROJECT_RISK_STATUS_LABELS = getProjectRiskStatusLabels();
    // Lot 0 actifs (2026-09) : les listes d'options actifs utilisent t() — refresh au changement de langue
    CRITICAL_ASSET_AVAILABILITY_OPTIONS = getCriticalAssetAvailabilityOptions();
    CRITICAL_ASSET_CONF_OPTIONS = getCriticalAssetConfOptions();
    CRITICAL_ASSET_CONF_RESOLVE_OPTIONS = CRITICAL_ASSET_CONF_OPTIONS.concat(getCriticalAssetConfLegacyOptions());
    CRITICAL_ASSET_INTEGRITY_OPTIONS = getCriticalAssetIntegrityOptions();
    CRITICAL_ASSET_TYPE_OPTIONS = getCriticalAssetTypeOptions();
    CRITICAL_ASSET_IMPORTANCE_OPTIONS = getCriticalAssetImportanceOptions();
    CRITICAL_ASSET_IMPACT_OPTIONS = getCriticalAssetImpactOptions();
    CRITICAL_ASSET_MFA_STATUS_OPTIONS = getCriticalAssetMfaOptions();
    CRITICAL_ASSET_DACP_OPTIONS = getCriticalAssetDacpOptions();
    CRITICAL_ASSET_RTO_OPTIONS = getCriticalAssetRtoOptions();
    CRITICAL_ASSET_RPO_OPTIONS = getCriticalAssetRpoOptions();
    // FIX-I18N-NIS2-ENUMS (audit 2026-07-10) : badges statut/urgence/type du plan NIS2
    NIS2_STATUS_META = getNis2StatusMeta();
    NIS2_URGENCY_LABELS = getNis2UrgencyLabels();
    NIS2_ACTION_TYPE_OPTIONS = getNis2ActionTypeOptions();
    NIS2_ACTION_TYPE_LABELS = getNis2ActionTypeLabels();

    // FIX-I18N-TOOLBARS (audit 2026-06-10, bundle.js:22082) : re-init de TOUTES les barres
    // d'outils (SectionToolbar.init est documenté ré-entrant : destroy + rebuild) — must run
    // BEFORE translatePage so the new DOM elements get their data-i18n attributes translated.
    // L'ancien code ne ré-initialisait que controls et soa : les toolbars des ~14 autres
    // modules (risks, threats, audits, kpis, nis2-controls, recyf-controls…) gardaient les
    // libellés de l'ancienne langue jusqu'au rechargement de la page. Les options de filtre
    // dynamiques (fonctions/catégories NIS2, objectifs ReCyF…) sont repeuplées par les
    // load*Table() au prochain affichage de chaque module, comme au démarrage.
    if (typeof initSectionToolbars === 'function') {
      initSectionToolbars();
    }

    // Translate the page (data-i18n attributes) — runs after SectionToolbar rebuild
    if (window.CSI_i18n && typeof window.CSI_i18n.translatePage === 'function') {
      window.CSI_i18n.translatePage();
    }

    // Refonte header 2026-07 : libellés dynamiques du header (hors data-i18n) —
    // item « Thème : … », rôle du menu utilisateur, title/aria du bouton édition.
    updateThemeToggleUi();
    refreshUserMenuIdentity();
    updateEditModeButtonUi();

    // Toggle manual language (FR / EN)
    var manualLang = window.CSI_i18n ? window.CSI_i18n.getLanguage() : 'fr';
    var frManual = document.getElementById('manualContentFR');
    var enManual = document.getElementById('manualContentEN');
    if (frManual) frManual.style.display = (manualLang === 'fr') ? '' : 'none';
    if (enManual) enManual.style.display = (manualLang !== 'fr') ? '' : 'none';

    // Re-render visible module
    // FIX-I18N-RERENDER-DEFER (revue audit 2026-06-11) : différer d'un tick. setLanguage()
    // dispatche 'languageChange' de façon SYNCHRONE ; quand la langue est changée via le
    // select de Paramètres > Général, renderSettingsPage() reconstruirait la page (et
    // détruirait le select) AU MILIEU du handler 'change' en cours — réentrance fragile et
    // flash de succès du bouton « Enregistrer » perdu. Le report laisse le handler courant
    // se terminer ; translatePage() ci-dessus a déjà traité les attributs data-i18n.
    setTimeout(function() {
    try {
      const activeTab = document.querySelector('.module--active');
      if (activeTab) {
        const tabId = activeTab.id;
        if (tabId === 'dashboard' && typeof updateDashboard === 'function') updateDashboard();
        else if (tabId === 'controls' && typeof loadControlsTable === 'function') loadControlsTable();
        else if (tabId === 'nis2-controls' && typeof loadNis2ControlsTable === 'function') loadNis2ControlsTable();
        else if (tabId === 'recyf-controls' && typeof loadRecyfControlsTable === 'function') loadRecyfControlsTable();
        else if (tabId === 'soa' && typeof loadSoaTable === 'function') loadSoaTable();
        else if (tabId === 'docReview' && typeof loadDocReviewTable === 'function') loadDocReviewTable();
        else if (tabId === 'actions' && typeof loadActionsTable === 'function') loadActionsTable();
        else if (tabId === 'risks' && typeof loadRisksTable === 'function') loadRisksTable();
        else if (tabId === 'threats-section' && typeof loadThreatsTable === 'function') loadThreatsTable();
        else if (tabId === 'nonconformities' && typeof loadNonConformitiesTable === 'function') loadNonConformitiesTable();
        else if (tabId === 'audits' && typeof loadAuditsTable === 'function') loadAuditsTable();
        else if (tabId === 'managementReviews' && typeof loadReviewsTable === 'function') loadReviewsTable();
        else if (tabId === 'stakeholders' && typeof loadStakeholdersTable === 'function') loadStakeholdersTable();
        else if (tabId === 'documents' && typeof loadDocumentsTable === 'function') loadDocumentsTable();
        else if (tabId === 'kpis' && typeof loadKpiTable === 'function') loadKpiTable();
        else if (tabId === 'nis2-program' && typeof loadNis2Program === 'function') loadNis2Program();
        else if (tabId === 'critical-assets' && typeof loadCriticalAssetsTable === 'function') loadCriticalAssetsTable();
        else if (tabId === 'project-risks' && typeof loadProjectRisksTable === 'function') loadProjectRisksTable();
        // FIX-I18N-SETTINGS-RERENDER (audit 2026-06-10, bundle.js:22142) : la page Paramètres
        // (et les onglets ci-dessous, absents de la liste) est construite via t() en
        // textContent sans data-i18n — translatePage() ne peut pas la retraduire. Re-rendu
        // explicite au changement de langue (mêmes fonctions que le switch de switchTab).
        else if (tabId === 'settings' && typeof renderSettingsPage === 'function') renderSettingsPage();
        else if (tabId === 'nis2-socle' && typeof renderSocleGrids === 'function') renderSocleGrids();
        else if (tabId === 'risk-analysis' && typeof renderRiskAnalysisModule === 'function') {
          // FIX-RA-COMMIT-EXITS : le re-rendu régénère le wizard depuis la RAM — committer
          // la saisie en cours d'abord (no-op en lecture seule / sans delta).
          try { if (typeof window.raCommitWizardEntries === 'function') window.raCommitWizardEntries(); } catch (e) { void 0; }
          renderRiskAnalysisModule();
        }
        else if (tabId === 'impact-matrices' && typeof renderImpactMatricesModule === 'function') renderImpactMatricesModule();
        else if (tabId === 'framework-mapping' && typeof loadFrameworkMappingView === 'function') loadFrameworkMappingView();
        else if (tabId === 'auditLog' && typeof renderAuditLogModule === 'function') renderAuditLogModule();
      }
    } catch (e) {
      void 0;
    }
    }, 0);
  });
})();
