/**
 * Cyber-Assistant - Module Analyse de Risques
 * Analyse pragmatique alignée ISO 27005
 * Wizard 8 étapes, scoring, recommandations, export PDF
 */
(function() {
  'use strict';

  // ============================================================
  // HELPERS
  // ============================================================
  function _t(key, fallback) {
    if (typeof window.t === 'function') {
      var val = window.t(key);
      return (val && val !== key) ? val : (fallback || key);
    }
    return fallback || key;
  }
  var _esc = window.escapeHtml;
  function _id() {
    return 'ra-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
  }
  function _now() { return new Date().toISOString(); }
  function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
  function _deepClone(o) { try { return JSON.parse(JSON.stringify(o)); } catch(e) { void 0; return o; } }
  function _hexToRgb(hex) {
    // FIX-AUDIT-XSS : une couleur de seuil/niveau vient d'un .analyse importable — hex
    // strict obligatoire, repli gris neutre sinon (ferme la TypeError et la corruption
    // du PDF sur seuil forgé ; #abc étendu en #aabbcc).
    var h = String(hex == null ? '' : hex).replace('#', '');
    if (/^[0-9a-fA-F]{3}$/.test(h)) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
    if (!/^[0-9a-fA-F]{6}$/.test(h)) h = 'cccccc';
    return [parseInt(h.substr(0,2),16), parseInt(h.substr(2,2),16), parseInt(h.substr(4,2),16)];
  }
  function _generateReportNumber() {
    var d = new Date();
    var ymd = d.toISOString().slice(0, 10).replace(/-/g, '');
    var rand;
    try { rand = Array.from(crypto.getRandomValues(new Uint8Array(2))).map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase(); }
    catch(e) { rand = Math.random().toString(16).slice(2, 6).toUpperCase(); }
    return 'RA-' + ymd + '-' + rand;
  }
  /** Returns current UI language: 'en' or 'fr' (default 'fr') */
  function _lang() {
    return (window.CSI_i18n && typeof window.CSI_i18n.getLanguage === 'function') ? window.CSI_i18n.getLanguage() : 'fr';
  }
  /** Pick bilingual field: returns EN value when lang=en if available, otherwise FR value */
  // FIX-RA-LEVEL-I18N (audit 2026-08-17, échelles#7) : les niveaux de seuil portent
  // label (EN) + labelFr — `_bi(lvl,'label','labelEn')` renvoyait TOUJOURS l'anglais
  // (labelEn n'existe pas sur ces objets). Helper dédié avec repli dans les deux sens.
  function _lvlLabel(lv) {
    if (!lv) return '';
    return (_lang() === 'en') ? (lv.label || lv.labelFr || '') : (lv.labelFr || lv.label || '');
  }
  function _bi(obj, frField, enField) {
    return (_lang() === 'en' && obj[enField]) ? obj[enField] : obj[frField];
  }

  // ============================================================
  // RISK THRESHOLDS — Data model + CRUD + scoring
  // ============================================================
  var DEFAULT_RISK_THRESHOLD = {
    id: 'rt-default-v1',
    name: 'Standard 5×5 Matrix',
    nameFr: 'Matrice standard 5×5',
    description: 'Default thresholds — Probability × Severity',
    descriptionFr: 'Seuils par défaut — Probabilité × Gravité',
    createdAt: '2026-02-09T00:00:00.000Z',
    updatedAt: '2026-02-09T00:00:00.000Z',
    probabilityLabels: ['Unknown','Very Low','Low','Medium','High'],
    probabilityLabelsFr: ['Inconnue','Très faible','Basse','Moyenne','Haute'],
    gravityLabels: ['Insignificant','Minor','Significant','Severe','Critical'],
    gravityLabelsFr: ['Insignifiant','Mineur','Significatif','Grave','Critique'],
    levels: [
      { id: 'minimum',    label: 'Minimum',     labelFr: 'Minimum',      color: '#22c55e' },
      { id: 'faible',     label: 'Low',          labelFr: 'Faible',       color: '#84cc16' },
      { id: 'modere',     label: 'Moderate',     labelFr: 'Modéré',       color: '#f97316' },
      { id: 'eleve',      label: 'High',         labelFr: 'Élevé',        color: '#ea580c' },
      { id: 'tres_eleve', label: 'Very High',    labelFr: 'Très élevé',   color: '#ef4444' }
    ],
    cells: {
      '1_1':'minimum','1_2':'minimum','1_3':'minimum','1_4':'faible','1_5':'modere',
      '2_1':'minimum','2_2':'faible','2_3':'faible','2_4':'modere','2_5':'eleve',
      '3_1':'minimum','3_2':'faible','3_3':'modere','3_4':'eleve','3_5':'tres_eleve',
      '4_1':'faible','4_2':'modere','4_3':'eleve','4_4':'eleve','4_5':'tres_eleve',
      '5_1':'modere','5_2':'eleve','5_3':'tres_eleve','5_4':'tres_eleve','5_5':'tres_eleve'
    },
    // === RA-UX (B2) === critères d'acceptation du risque (ISO 27005) : plancher parmi les
    // niveaux de la grille — décision de direction, définie sur l'entité principale (comme
    // le reste des seuils, §8.1). null = aucun seuil (comportement historique).
    acceptance: { floorLevelId: null, decisionRef: '' }
  };

  var LEVEL_TO_TRAFFIC = { minimum: 'green', faible: 'green', modere: 'yellow', eleve: 'red', tres_eleve: 'red' };

  // FIX-TRAFFIC-PROTO (audit progressif des lots A/B, 2026-08-26) : l'indexation nue
  // LEVEL_TO_TRAFFIC[lvl.id] traversait Object.prototype. Un `.analyse` forgé portant
  // `levels[].id = 'constructor'` (doctrine « imports adversariaux tolérés ») remontait la
  // fonction Object native — TRUTHY, donc le repli `|| 'yellow'` ne se déclenchait jamais.
  // Reproduit de bout en bout par l'entrée publique raImportAnalyseFromData : un risque
  // 5×5 = 25 avec 100 % des contrôles à « non » ressortait `globalLight: green`. FAUX VERT.
  // Aucune normalisation n'assainit `levels[].id` d'un seuil (il n'existe pas de
  // normalizeThreshold, contrairement aux matrices) : la garde est donc posée AU RÉSOLVEUR,
  // là où toutes les lectures passent, et le repli reste PESSIMISTE ('yellow', jamais vert).
  function _trafficForLevelId(id) {
    var k = String(id == null ? '' : id);
    return Object.prototype.hasOwnProperty.call(LEVEL_TO_TRAFFIC, k) ? LEVEL_TO_TRAFFIC[k] : 'yellow';
  }

  function ensureThresholdsInit() {
    if (!window.appData.riskThresholds || !window.appData.riskThresholds.items || !window.appData.riskThresholds.items.length) {
      window.appData.riskThresholds = { schemaVersion: 1, activeId: DEFAULT_RISK_THRESHOLD.id, items: [_deepClone(DEFAULT_RISK_THRESHOLD)] };
      // FIX-IMPOSED-PUSH : en sous-entité, seed en RAM UNIQUEMENT — ce _save() poussait
      // riskThresholds à chaque rendu, le serveur strippait (§8.1), et la divergence
      // locale/serveur déclenchait les parachutes 409 en série (reproduit au banc).
      // FIX-AUDIT-RO-SEED (audit v2, 2026-08-25) : la garde de LECTURE SEULE manquait — le
      // jumeau impact-matrices.js:ensureInitialized la porte depuis 2026-08-05. Un simple
      // rendu du tableau de bord en consultation (updateRiskMatrix → getActiveThreshold)
      // écrivait tout appData ; en local/file:// aucun filet ne rattrape ce push.
      // Le seed RAM reste posé : l'affichage est correct, seule la persistance est retenue.
      if (!_scopedSub() && !_ro()) _save();
    }
    return window.appData.riskThresholds;
  }

  function getActiveThreshold() {
    var store = ensureThresholdsInit();
    var t = store.items.find(function(i) { return i.id === store.activeId; });
    return t || store.items[0];
  }

  function getRiskLevelForCell(p, g, threshold) {
    var t = threshold || getActiveThreshold();
    // (audit 2026-07-03, BLOQUANT) borne réelle de la grille de cotation (standard 5x5 ;
    // respecte une grille personnalisée). Une valeur HORS grille — ex. un impact issu d'une
    // matrice d'impact à >5 niveaux (max des niveaux = 6, 7…) — produisait une clé absente
    // (« 5_7 ») → fallback 'minimum' = VERT : un risque catastrophique s'affichait vert
    // (faux-négatif). On clampe p et g à [1, axisMax] : une valeur extrême mappe sur la PIRE
    // cellule atteignable, jamais sur le vert.
    var axisMax = 5;
    for (var ck in t.cells) {
      if (t.cells.hasOwnProperty(ck)) {
        var cc = ck.split('_'); var a = +cc[0], b = +cc[1];
        if (a > axisMax) axisMax = a; if (b > axisMax) axisMax = b;
      }
    }
    var cp = Math.min(axisMax, Math.max(1, Math.round(Number(p) || 3)));
    var cg = Math.min(axisMax, Math.max(1, Math.round(Number(g) || 3)));
    var levelId = (t.cells || {})[cp + '_' + cg] || 'minimum';
    var lvl = (t.levels || []).find(function(l) { return l && l.id === levelId; });
    // FIX-AUDIT-SEUIL (audit 2026-08-25) : un seuil FORGÉ ou tronqué (levels vides — .analyse
    // importé, cf. doctrine « imports adversariaux tolérés ») renvoyait undefined, et tout
    // appelant déréférençant .id/.color jetait. Repli NEUTRE et PESSIMISTE : id inconnu de
    // LEVEL_TO_TRAFFIC, donc feu 'yellow' par défaut — jamais de faux vert (FIX-RA-NA-GREEN).
    return lvl || (t.levels || [])[0] || { id: '_unknown', label: '—', labelFr: '—', color: '#cccccc' };
  }

  // === RA-UX (B2) === Critères d'acceptation du risque — helpers purs.
  // Backfill en RAM uniquement (aucun save depuis un chemin de lecture, doctrine 2026-08-05).
  function _thAcceptance(t) {
    return (t && t.acceptance) ? t.acceptance : { floorLevelId: null, decisionRef: '' };
  }
  function _thLevelIndex(t, levelId) {
    if (!t || !Array.isArray(t.levels) || !levelId) return -1;
    for (var i = 0; i < t.levels.length; i++) { if (t.levels[i].id === levelId) return i; }
    return -1;
  }
  function getAcceptanceFloorIndex(t) {
    return _thLevelIndex(t, _thAcceptance(t).floorLevelId);
  }
  // Prédicat partagé : un niveau (déjà résolu) est-il au niveau ou au-delà du plancher ?
  function _isLevelBeyondFloor(t, levelId, floorIdx) {
    var fi = (floorIdx === undefined) ? getAcceptanceFloorIndex(t) : floorIdx;
    if (fi < 0) return false;
    return _thLevelIndex(t, levelId) >= fi;
  }
  function isCellBeyondAcceptance(t, p, g, floorIdx) {
    var lvl = getRiskLevelForCell(p, g, t);
    return _isLevelBeyondFloor(t, lvl && lvl.id, floorIdx);
  }
  // Niveau du risque RÉSIDUEL : la mitigation réduit la probabilité à impact constant —
  // p' = ⌈résiduel ∕ impact⌉ (arrondi supérieur : jamais sous-estimer), cellule (p', impact).
  // FIX-REVUE RA-UX : les majorations plates (+1 Internet/CIA/RTO) gonflent le score sans
  // être multiplicatives — sans borne, un risque L=1 pouvait dériver un niveau résiduel PIRE
  // que l'inhérent. p' est donc plafonné à la vraisemblance inhérente (le traitement ne rend
  // jamais un risque plus probable qu'il ne l'était).
  function residualLevelForRisk(residual, impact, threshold, inherentLikelihood) {
    var t = threshold || getActiveThreshold();
    var g = Math.max(1, Math.round(Number(impact) || 3));
    var res = Math.max(1, Number(residual) || 1);
    var p = Math.ceil(res / g);
    var pCap = Math.max(1, Math.round(Number(inherentLikelihood) || 5));
    if (p > pCap) p = pCap;
    return getRiskLevelForCell(p, g, t);
  }
  function isResidualBeyondAcceptance(residual, impact, threshold, inherentLikelihood) {
    var t = threshold || getActiveThreshold();
    var lvl = residualLevelForRisk(residual, impact, t, inherentLikelihood);
    return _isLevelBeyondFloor(t, lvl && lvl.id);
  }

  // FIX-REVUE RA-UX : une analyse importée (.analyse) est liée à son seuil embarqué via
  // a.riskThresholdId (AUDIT-M2) — le calcul doit lire CE seuil (et ses critères
  // d'acceptation) plutôt que le seuil actif du poste, sinon la politique d'acceptation
  // de l'organisation d'origine est silencieusement remplacée par la politique locale.
  function getThresholdForAnalysis(a) {
    if (a && a.riskThresholdId) {
      var store = ensureThresholdsInit();
      var linked = store.items.find(function(i) { return i.id === a.riskThresholdId; });
      if (linked) return linked;
    }
    return getActiveThreshold();
  }

  function riskLevelFromScore(likelihood, impact) {
    var lvl = getRiskLevelForCell(likelihood || 3, impact || 3);
    return { id: lvl.id, label: _lvlLabel(lvl), labelFr: lvl.labelFr || lvl.label, labelEn: lvl.labelEn || lvl.label, color: lvl.color, trafficLight: _trafficForLevelId(lvl.id) };
  }

  function createThreshold(name) {
    var t = _deepClone(DEFAULT_RISK_THRESHOLD);
    t.id = 'rt-' + Date.now() + '-' + Math.random().toString(36).substr(2, 4);
    t.name = name || _t('riskAnalysis.thresholds.newName', 'Nouveau seuil');
    t.createdAt = _now(); t.updatedAt = _now();
    ensureThresholdsInit().items.push(t);
    _save();
    return t;
  }

  function duplicateThreshold(id) {
    var store = ensureThresholdsInit();
    var src = store.items.find(function(i) { return i.id === id; });
    if (!src) return null;
    var t = _deepClone(src);
    t.id = 'rt-' + Date.now() + '-' + Math.random().toString(36).substr(2, 4);
    t.name = (t.name || '') + _t('common.copySuffix', ' (copie)'); // écrit dans la donnée : fige la langue du rédacteur (déjà le cas, en français, avant ce correctif)
    t.createdAt = _now(); t.updatedAt = _now();
    store.items.push(t);
    _save();
    return t;
  }

  function deleteThreshold(id) {
    var store = ensureThresholdsInit();
    if (store.items.length <= 1) return false;
    store.items = store.items.filter(function(i) { return i.id !== id; });
    if (store.activeId === id) store.activeId = store.items[0].id;
    _save();
    return true;
  }

  function activateThreshold(id) {
    var store = ensureThresholdsInit();
    if (store.items.some(function(i) { return i.id === id; })) {
      store.activeId = id;
      _save();
    }
  }

  // Public API for other modules (dashboard)
  window.CSI_RiskThresholds = {
    getActiveThreshold: getActiveThreshold,
    getRiskLevelForCell: getRiskLevelForCell,
    ensureInit: ensureThresholdsInit,
    // === RA-UX (B2) ===
    residualLevelForRisk: residualLevelForRisk,
    isResidualBeyondAcceptance: isResidualBeyondAcceptance,
    getAcceptanceFloorIndex: getAcceptanceFloorIndex
  };

  // Normalize imported risk analysis (ensure all fields exist)
  // FIX-RA-IMPORT-ROBUST : un .analyse forgé/corrompu peut porter n'importe quel bloc au
  // MAUVAIS type (cia:"high", risks:{}, context:42…). Les gardes historiques `if (!a.X)`
  // ne rattrapaient que l'absence : une valeur truthy non-objet passait, puis un
  // consommateur jetait (ensureAnalysisSchema→a.risks.forEach, _ciaEnsureScale→a.cia.xxx)
  // et l'analyse devenait inouvrable. On coerce chaque bloc vers son type attendu. Pour
  // une analyse valide, _plainObj/Array renvoient la valeur inchangée : aucun effet.
  function _plainObj(v) { return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; }
  function normalizeRiskAnalysis(a) {
    if (!a) return a;
    a.meta = _plainObj(a.meta);
    a.spot = _plainObj(a.spot);
    if (a.spot.avisOverrideLight === undefined) a.spot.avisOverrideLight = null;
    if (a.spot.avisOverrideJustification === undefined) a.spot.avisOverrideJustification = '';
    a.finalRecs = _plainObj(a.finalRecs);
    if (!Array.isArray(a.finalRecs.items)) a.finalRecs.items = [];
    a.context = _plainObj(a.context);
    a.cia = _plainObj(a.cia);
    a.continuity = _plainObj(a.continuity);
    a.architecture = _plainObj(a.architecture);
    a.architecture.iam = _plainObj(a.architecture.iam);
    // FIX-AUDIT-NORM (audit v2, 2026-08-25) : trois blocs échappaient à la coercition.
    // (a) a.identity non textuel faisait échouer le rendu ; (b) un élément NON-OBJET dans
    // a.risks faisait planter ensureAnalysisSchema/computeAllResults ; (c) un
    // residualOverrideScore typé CHAÎNE basculait toute l'analyse en « Avis défavorable ».
    a.identity = _plainObj(a.identity);
    ['encoder', 'validator', 'controlBody', 'beneficiary', 'projectManager'].forEach(function(k) {
      if (typeof a.identity[k] !== 'string') a.identity[k] = a.identity[k] == null ? '' : String(a.identity[k]);
    });
    if (a.spot.residualOverrideScore != null) {
      var _ros = parseInt(a.spot.residualOverrideScore, 10);
      a.spot.residualOverrideScore = (_ros >= 1 && _ros <= 5) ? _ros : null;
    }
    if (a.spot.openRiskAdjustment != null) {
      var _ora = parseInt(a.spot.openRiskAdjustment, 10);
      a.spot.openRiskAdjustment = (_ora >= 0 && _ora <= 2) ? _ora : 0;
    }
    if (['green', 'yellow', 'red'].indexOf(a.spot.avisOverrideLight) < 0) a.spot.avisOverrideLight = null;
    if (!Array.isArray(a.risks)) a.risks = [];
    a.risks = a.risks.filter(function(rk) { return rk && typeof rk === 'object' && !Array.isArray(rk); });
    a.controls = _plainObj(a.controls);
    _raNormalizeProjectionFields(a); // === RA-PROJ === liens/cibles coercés + backfill ids
    _raNormalizeAssetLinks(a); // === RA-ACTIFS === périmètre d'actifs coercé
    return a;
  }

  window.CSI_RiskAnalysis = window.CSI_RiskAnalysis || {};
  window.CSI_RiskAnalysis.normalizeAnalysis = normalizeRiskAnalysis;

  // ============================================================
  // SEED DATA
  // ============================================================
  const RA_DOMAINS = [
    { id: 'IAM', label: 'Identité & Accès', labelEn: 'Identity & Access', icon: 'fas fa-user-shield' },
    { id: 'SDLC', label: 'Développement Sécurisé', labelEn: 'Secure Development', icon: 'fas fa-code' },
    { id: 'VULN', label: 'Vulnérabilités', labelEn: 'Vulnerability Management', icon: 'fas fa-bug' },
    { id: 'LOG', label: 'Logs & Détection', labelEn: 'Logging & Detection', icon: 'fas fa-clipboard-list' },
    { id: 'DATA', label: 'Protection des Données', labelEn: 'Data Protection', icon: 'fas fa-database' },
    { id: 'INFRA', label: 'Infrastructure', labelEn: 'Infrastructure', icon: 'fas fa-server' },
    { id: 'NETCRYPTO', label: 'Réseau & Crypto', labelEn: 'Network & Cryptography', icon: 'fas fa-network-wired' },
    { id: 'BCP', label: 'Continuité', labelEn: 'Business Continuity', icon: 'fas fa-life-ring' }
  ];

  const RA_CONTROLS_SEED = [
    { domainId: "IAM", id: "IAM_MFA", cia: ['C','I'], text: "MFA activée pour les comptes privilégiés et les accès distants", textEn: "MFA enabled for privileged accounts and remote access", hint: "Admin, VPN, console cloud, CI/CD, bastion", hintEn: "Admin, VPN, cloud console, CI/CD, bastion" },
    { domainId: "IAM", id: "IAM_RBAC", cia: ['C','I'], text: "RBAC/least privilege appliqué (droits minimaux + séparation des rôles)", textEn: "RBAC/least privilege applied (minimal rights + role separation)", hint: "", hintEn: "" },
    { domainId: "IAM", id: "IAM_REVIEW", cia: ['C'], text: "Revue périodique des droits et suppression des comptes orphelins", textEn: "Periodic access review and orphan account removal", hint: "", hintEn: "" },
    { domainId: "SDLC", id: "SDLC_CODE", cia: ['I'], text: "Code versionné + revues de code systématiques", textEn: "Version-controlled code + systematic code reviews", hint: "PR obligatoires, règles de merge", hintEn: "Mandatory PRs, merge rules" },
    { domainId: "SDLC", id: "SDLC_SEC_TEST", cia: ['C','I'], text: "Tests sécurité (SAST/DAST/pentest ou équivalent) planifiés", textEn: "Security testing (SAST/DAST/pentest or equivalent) scheduled", hint: "Au minimum à chaque release majeure", hintEn: "At minimum for each major release" },
    { domainId: "SDLC", id: "SDLC_SECRETS", cia: ['C'], text: "Gestion des secrets (pas de secrets en clair dans le code; rotation)", textEn: "Secrets management (no cleartext secrets in code; rotation)", hint: "Vault/KMS/Secret manager, rotation", hintEn: "Vault/KMS/Secret manager, rotation" },
    { domainId: "VULN", id: "VULN_INVENTORY", cia: ['C','I'], text: "Inventaire des composants/dépendances + suivi des versions", textEn: "Component/dependency inventory + version tracking", hint: "SBOM, dépendances applicatives et images", hintEn: "SBOM, application dependencies and images" },
    { domainId: "VULN", id: "VULN_SCANS", cia: ['C','I'], text: "Scans vulnérabilités réguliers + traitement priorisé", textEn: "Regular vulnerability scans + prioritized remediation", hint: "CI/CD + scans runtime si applicable", hintEn: "CI/CD + runtime scans if applicable" },
    { domainId: "VULN", id: "VULN_PATCH", cia: ['C','I'], text: "Process patching/updates avec SLA selon criticité", textEn: "Patching/update process with SLA based on criticality", hint: "SLA CVSS élevé, suivi des exceptions", hintEn: "High CVSS SLA, exception tracking" },
    { domainId: "LOG", id: "LOG_CENTRAL", cia: ['C','I','A'], text: "Logs centralisés (app, auth, admin, sécurité) avec horodatage fiable", textEn: "Centralized logging (app, auth, admin, security) with reliable timestamps", hint: "Centralisation + NTP", hintEn: "Centralization + NTP" },
    { domainId: "LOG", id: "LOG_ALERTS", cia: ['C','I','A'], text: "Alertes/détection sur événements critiques (auth, privilèges, erreurs, WAF)", textEn: "Alerting/detection on critical events (auth, privileges, errors, WAF)", hint: "Use-cases minimum", hintEn: "Minimum use-cases" },
    { domainId: "LOG", id: "LOG_RETENTION", cia: ['C','I','A'], text: "Politique de rétention et protection d'intégrité des logs", textEn: "Log retention policy and integrity protection", hint: "Rétention adaptée, accès restreint", hintEn: "Appropriate retention, restricted access" },
    { domainId: "DATA", id: "DATA_CLASSIF", cia: ['C'], text: "Classification des données + règles d'accès associées", textEn: "Data classification + associated access rules", hint: "Données sensibles/PII, niveaux", hintEn: "Sensitive data/PII, classification levels" },
    { domainId: "DATA", id: "DATA_ENCRYPT_REST", cia: ['C'], text: "Chiffrement au repos des données sensibles (ou équivalent compensatoire)", textEn: "Encryption at rest for sensitive data (or compensating control)", hint: "DB/volumes/objets + gestion clefs", hintEn: "DB/volumes/objects + key management" },
    { domainId: "DATA", id: "DATA_RETENTION", cia: ['C'], text: "Rétention/purge/effacement (privacy) définis et appliqués", textEn: "Retention/purge/erasure (privacy) defined and enforced", hint: "Durées, purge auto, preuve", hintEn: "Durations, automated purge, evidence" },
    { domainId: "INFRA", id: "INFRA_HARDEN", cia: ['C','I'], text: "Hardening et configuration sécurisée (baseline) appliqués", textEn: "Hardening and secure configuration (baseline) applied", hint: "CIS benchmarks, IaC", hintEn: "CIS benchmarks, IaC" },
    { domainId: "INFRA", id: "INFRA_SEGMENT", cia: ['C','A'], text: "Segmentation environnements (dev/préprod/prod) et restrictions réseau", textEn: "Environment segmentation (dev/staging/prod) and network restrictions", hint: "Séparation + ACL/SG", hintEn: "Separation + ACL/SG" },
    { domainId: "INFRA", id: "INFRA_BACKUP_PROTECT", cia: ['I','A'], text: "Sauvegardes protégées (immutables/offline) ou mesures anti-ransomware", textEn: "Protected backups (immutable/offline) or anti-ransomware measures", hint: "Immutabilité, compte séparé", hintEn: "Immutability, separate account" },
    { domainId: "NETCRYPTO", id: "NET_TLS", cia: ['C','I'], text: "TLS en transit (versions modernes) + configuration sécurisée", textEn: "TLS in transit (modern versions) + secure configuration", hint: "TLS 1.2+ / 1.3, ciphers", hintEn: "TLS 1.2+ / 1.3, ciphers" },
    { domainId: "NETCRYPTO", id: "NET_WAF", cia: ['C','I','A'], text: "Protection périmétrique (WAF/API Gateway/rate-limit) pour endpoints exposés", textEn: "Perimeter protection (WAF/API Gateway/rate-limit) for exposed endpoints", hint: "WAF, rate-limit, bot protection", hintEn: "WAF, rate-limit, bot protection" },
    { domainId: "NETCRYPTO", id: "NET_KEYS", cia: ['C','I'], text: "Gestion des clefs (KMS/HSM/rotation/accès contrôlé)", textEn: "Key management (KMS/HSM/rotation/controlled access)", hint: "Rotation, séparation des rôles", hintEn: "Rotation, role separation" },
    { domainId: "BCP", id: "BCP_BACKUP", cia: ['A'], text: "Sauvegardes régulières alignées sur RPO", textEn: "Regular backups aligned with RPO", hint: "Fréquence conforme RPO", hintEn: "RPO-compliant frequency" },
    { domainId: "BCP", id: "BCP_RESTORE_TEST", cia: ['A'], text: "Tests de restauration et preuves de réussite", textEn: "Restore tests and proof of success", hint: "Test au moins trimestriel", hintEn: "At least quarterly testing" },
    { domainId: "BCP", id: "BCP_DR", cia: ['A'], text: "Procédure PRA/PCA alignée sur RTO (plan + responsabilités)", textEn: "DRP/BCP procedure aligned with RTO (plan + responsibilities)", hint: "Runbook + exercices", hintEn: "Runbook + exercises" }
  ];

  const RA_RISK_CATALOG = [
    { id: "RISK_EXT_ATTACK", threat: "Attaque externe", threatEn: "External Attack", scenario: "Exploitation d'une vulnérabilité applicative sur un endpoint exposé", scenarioEn: "Exploitation of an application vulnerability on an exposed endpoint", ciaTargets: ['C','I'], domainHints: ["NETCRYPTO","SDLC","VULN","LOG"] },
    { id: "RISK_DATA_LEAK", threat: "Divulgation", threatEn: "Data Disclosure", scenario: "Accès non autorisé à des données sensibles via défaut IAM ou configuration", scenarioEn: "Unauthorized access to sensitive data via IAM or configuration flaw", ciaTargets: ['C'], domainHints: ["IAM","DATA","INFRA","LOG"] },
    { id: "RISK_PRIV_ESC", threat: "Élévation de privilèges", threatEn: "Privilege Escalation", scenario: "Abus de comptes privilégiés ou droits excessifs", scenarioEn: "Abuse of privileged accounts or excessive permissions", ciaTargets: ['C','I'], domainHints: ["IAM","LOG"] },
    { id: "RISK_RANSOM", threat: "Ransomware", threatEn: "Ransomware", scenario: "Chiffrement/destruction de données et indisponibilité prolongée", scenarioEn: "Data encryption/destruction and extended unavailability", ciaTargets: ['I','A'], domainHints: ["INFRA","BCP","LOG"] },
    { id: "RISK_SUPPLY", threat: "Supply chain", threatEn: "Supply Chain Attack", scenario: "Compromission d'une dépendance/CI-CD introduisant du code malveillant", scenarioEn: "Compromise of a dependency/CI-CD pipeline introducing malicious code", ciaTargets: ['I','C'], domainHints: ["SDLC","VULN","IAM","LOG"] },
    { id: "RISK_MISCONFIG", threat: "Mauvaise configuration", threatEn: "Misconfiguration", scenario: "Exposition involontaire (bucket public, règles réseau, ports)", scenarioEn: "Unintended exposure (public bucket, network rules, ports)", ciaTargets: ['C'], domainHints: ["INFRA","NETCRYPTO","LOG"] },
    { id: "RISK_DDOS", threat: "Déni de service", threatEn: "Denial of Service", scenario: "Saturation rendant le service indisponible au-delà du RTO", scenarioEn: "Saturation rendering the service unavailable beyond RTO", ciaTargets: ['A'], domainHints: ["NETCRYPTO","BCP"] },
    { id: "RISK_LOG_GAP", threat: "Détection insuffisante", threatEn: "Insufficient Detection", scenario: "Incident non détecté faute de logs/alerting", scenarioEn: "Incident undetected due to lack of logging/alerting", ciaTargets: ['C','I','A'], domainHints: ["LOG"] },
    { id: "RISK_PRIVACY", threat: "Non-conformité privacy", threatEn: "Privacy Non-Compliance", scenario: "Rétention excessive ou absence d'effacement conduisant à incident/sanction", scenarioEn: "Excessive retention or lack of erasure leading to incident/sanction", ciaTargets: ['C'], domainHints: ["DATA","IAM"], adversarial: false },
    // FIX-RA-ADVERSARIAL-BUILTIN (audit référentiels 2026-08-17, constat 5) : la
    // neutralisation L2 du +1 « exposition Internet » n'existait de facto que pour les
    // packs — les deux entrées builtin NON adverses sont désormais annotées, alignées
    // sur leurs équivalents de packs (NIST_22 « Erreur d'utilisateur », etc.).
    { id: "RISK_INSIDER", threat: "Interne/erreur", threatEn: "Internal/Human Error", scenario: "Erreur de manipulation ou action malveillante interne", scenarioEn: "Operational error or malicious insider action", ciaTargets: ['C','I'], domainHints: ["IAM","LOG","DATA"], adversarial: false }
  ];

  const RA_THREATS_SEED = [
    { id: "THREAT_EXT_ATTACK", label: "Cyberattaque via périmètre externe (exploitation, injection, API abuse)", labelEn: "Cyberattack via external perimeter (exploitation, injection, API abuse)" },
    { id: "THREAT_MALWARE", label: "Malware ou ransomware affectant disponibilité et intégrité", labelEn: "Malware or ransomware affecting availability and integrity" },
    { id: "THREAT_DATA_LEAK", label: "Divulgation de données (accès non autorisé, exfiltration)", labelEn: "Data disclosure (unauthorized access, exfiltration)" },
    { id: "THREAT_PRIV_ESC", label: "Élévation de privilèges et abus d'accès", labelEn: "Privilege escalation and access abuse" },
    { id: "THREAT_DDOS", label: "Déni de service et indisponibilité prolongée", labelEn: "Denial of service and extended unavailability" },
    { id: "THREAT_SUPPLY_CHAIN", label: "Risque supply chain (dépendances, composants, CI/CD)", labelEn: "Supply chain risk (dependencies, components, CI/CD)" },
    { id: "THREAT_MISCONFIG", label: "Mauvaise configuration et exposition involontaire", labelEn: "Misconfiguration and unintended exposure" },
    { id: "THREAT_LOG_GAP", label: "Absence de traçabilité empêchant détection et réponse", labelEn: "Lack of traceability preventing detection and response" },
    { id: "THREAT_BACKUP_FAIL", label: "Sauvegardes insuffisantes ou non restaurables (RTO/RPO non tenus)", labelEn: "Insufficient or non-restorable backups (RTO/RPO not met)" },
    { id: "THREAT_COMPLIANCE", label: "Non-conformité (privacy, exigences internes) générant incident ou sanction", labelEn: "Non-compliance (privacy, internal requirements) leading to incident or sanction" }
  ];

  const RA_RECOMMENDATIONS_RULES = [{"id":"REC_IAM_MFA_001","semanticKey":"iam.mfa","when":{"any":[{"path":"architecture.iam.mfa","op":"eq","value":false},{"path":"controls.IAM.IAM_MFA","op":"in","value":["no","partial"]}]},"then":{"title":"Activer la MFA pour les comptes privilégiés et les accès sensibles","domainId":"IAM","priority":"P1","effort":"S","rationale":"L'absence de MFA augmente le risque de compromission de compte et d'abus de privilèges.","actions":["Activer MFA sur comptes admin et accès distants","Imposer MFA pour actions sensibles (changement droits, export données)","Définir un processus d'exception documenté et revu"],"doneWhen":"MFA activée pour 100% des comptes privilégiés; exceptions revues mensuellement.","titleEn":"Enable MFA for privileged accounts and sensitive access","rationaleEn":"Lack of MFA increases the risk of account compromise and privilege abuse.","actionsEn":["Enable MFA on admin accounts and remote access","Enforce MFA for sensitive actions (permission changes, data export)","Define a documented and reviewed exception process"],"doneWhenEn":"MFA enabled for 100% of privileged accounts; exceptions reviewed monthly."}},{"id":"REC_IAM_RBAC_001","semanticKey":"iam.rbac","when":{"any":[{"path":"controls.IAM.IAM_RBAC","op":"in","value":["no","partial"]}]},"then":{"title":"Appliquer le moindre privilège (RBAC) et séparer les rôles sensibles","domainId":"IAM","priority":"P1","effort":"M","rationale":"Des droits excessifs augmentent l'impact d'un compte compromis ou d'une erreur interne.","actions":["Définir des rôles standards (RBAC) et supprimer les droits individuels non justifiés","Séparer administration et usage courant; limiter les comptes 'break-glass'","Tracer et approuver les élévations temporaires"],"doneWhen":"Rôles RBAC en place; 90%+ des comptes rattachés à un rôle; élévations tracées.","titleEn":"Apply least privilege (RBAC) and separate sensitive roles","rationaleEn":"Excessive permissions increase the impact of a compromised account or internal error.","actionsEn":["Define standard roles (RBAC) and remove unjustified individual permissions","Separate administration and day-to-day usage; limit break-glass accounts","Trace and approve temporary privilege elevations"],"doneWhenEn":"RBAC roles in place; 90%+ of accounts assigned to a role; elevations traced."}},{"id":"REC_IAM_REVIEW_001","semanticKey":"iam.access_review","when":{"any":[{"path":"controls.IAM.IAM_REVIEW","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place une revue périodique des accès et la gestion des comptes orphelins","domainId":"IAM","priority":"P2","effort":"S","rationale":"Sans revue, les droits s'accumulent et les comptes inactifs persistent, augmentant la surface d'attaque.","actions":["Programmer une revue trimestrielle des accès (owners métier/tech)","Définir un processus de suppression des comptes inactifs","Conserver une preuve de revue (audit)"],"doneWhen":"Revue trimestrielle effectuée; comptes inactifs supprimés sous 30 jours.","titleEn":"Implement periodic access reviews and orphan account management","rationaleEn":"Without reviews, permissions accumulate and inactive accounts persist, increasing the attack surface.","actionsEn":["Schedule quarterly access reviews (business/tech owners)","Define a process for removing inactive accounts","Retain proof of review (audit)"],"doneWhenEn":"Quarterly review completed; inactive accounts removed within 30 days."}},{"id":"REC_IAM_ADMINSEP_001","semanticKey":"iam.admin_separation","when":{"any":[{"path":"architecture.iam.adminSeparation","op":"eq","value":false}]},"then":{"title":"Séparer les comptes administrateurs des comptes nominaux","domainId":"IAM","priority":"P2","effort":"S","rationale":"La séparation réduit le risque d'abus de privilèges via compromission d'un compte quotidien.","actions":["Créer des comptes admin dédiés (nominatifs) pour actions privilégiées","Interdire la navigation/mail sur comptes admin","Journaliser toutes les actions admin et les revoir"],"doneWhen":"Comptes admin dédiés en place; usage contrôlé; logs admin consultables.","titleEn":"Separate administrator accounts from standard user accounts","rationaleEn":"Separation reduces the risk of privilege abuse through compromise of a daily-use account.","actionsEn":["Create dedicated admin accounts (named) for privileged actions","Prohibit browsing/email on admin accounts","Log all admin actions and review them"],"doneWhenEn":"Dedicated admin accounts in place; controlled usage; admin logs accessible."}},{"id":"REC_SDLC_REVIEW_001","semanticKey":"sdlc.code_review","when":{"any":[{"path":"controls.SDLC.SDLC_CODE","op":"in","value":["no","partial"]}]},"then":{"title":"Formaliser les revues de code et règles de merge","domainId":"SDLC","priority":"P2","effort":"S","rationale":"La revue réduit l'introduction de vulnérabilités et de changements non maîtrisés.","actions":["Imposer PR + approbation (2 eyes) sur branches protégées","Ajouter checklists sécurité (auth, input, logging, crypto)","Bloquer le merge si tests clés échouent"],"doneWhen":"Branches protégées; PR obligatoires; checklist sécurité utilisée.","titleEn":"Formalize code reviews and merge rules","rationaleEn":"Code reviews reduce the introduction of vulnerabilities and uncontrolled changes.","actionsEn":["Enforce PR + approval (2 eyes) on protected branches","Add security checklists (auth, input, logging, crypto)","Block merge if key tests fail"],"doneWhenEn":"Protected branches; mandatory PRs; security checklist in use."}},{"id":"REC_SDLC_SAST_001","semanticKey":"sdlc.security_testing","when":{"any":[{"path":"controls.SDLC.SDLC_SEC_TEST","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place des tests sécurité (SAST/DAST/pentest) selon un calendrier","domainId":"SDLC","priority":"P1","effort":"M","rationale":"Sans tests, des vulnérabilités peuvent persister jusqu'à la production.","actions":["Intégrer SAST dans CI/CD (au minimum sur PR et releases)","Planifier DAST sur environnements préprod","Programmer un pentest périodique selon criticité"],"doneWhen":"SAST actif dans CI; DAST/pentest planifiés; findings suivis jusqu'à correction.","titleEn":"Implement security testing (SAST/DAST/pentest) on a schedule","rationaleEn":"Without testing, vulnerabilities may persist into production.","actionsEn":["Integrate SAST in CI/CD (at minimum on PRs and releases)","Schedule DAST on staging environments","Plan periodic pentests based on criticality"],"doneWhenEn":"SAST active in CI; DAST/pentest scheduled; findings tracked to remediation."}},{"id":"REC_SDLC_SECRETS_001","semanticKey":"sdlc.secrets","when":{"any":[{"path":"controls.SDLC.SDLC_SECRETS","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place une gestion robuste des secrets (stockage, rotation, détection)","domainId":"SDLC","priority":"P1","effort":"M","rationale":"Les secrets exposés permettent compromission immédiate de systèmes et données.","actions":["Stocker secrets dans un gestionnaire (Vault/KMS/Secrets Manager)","Activer détection de secrets dans le code et l'historique","Définir une politique de rotation (et rotation après incident)"],"doneWhen":"Aucun secret en clair; détection active; rotation documentée et appliquée.","titleEn":"Implement robust secrets management (storage, rotation, detection)","rationaleEn":"Exposed secrets allow immediate compromise of systems and data.","actionsEn":["Store secrets in a manager (Vault/KMS/Secrets Manager)","Enable secret detection in code and history","Define a rotation policy (and rotate after incident)"],"doneWhenEn":"No cleartext secrets; active detection; documented and enforced rotation."}},{"id":"REC_VULN_SBOM_001","semanticKey":"vuln.inventory","when":{"any":[{"path":"controls.VULN.VULN_INVENTORY","op":"in","value":["no","partial"]}]},"then":{"title":"Maintenir un inventaire des composants (SBOM) et versions","domainId":"VULN","priority":"P2","effort":"M","rationale":"Sans inventaire, le traitement des vulnérabilités est incomplet et lent.","actions":["Générer un SBOM sur chaque build/release","Inventorier images, libs, runtime et dépendances transverses","Associer propriétaires et cycle de vie"],"doneWhen":"SBOM généré à chaque release; inventaire consultable; owners identifiés.","titleEn":"Maintain a component inventory (SBOM) and version tracking","rationaleEn":"Without an inventory, vulnerability management is incomplete and slow.","actionsEn":["Generate an SBOM on each build/release","Inventory images, libraries, runtimes and transverse dependencies","Assign owners and lifecycle"],"doneWhenEn":"SBOM generated per release; queryable inventory; owners identified."}},{"id":"REC_VULN_SCANS_001","semanticKey":"vuln.scanning","when":{"any":[{"path":"controls.VULN.VULN_SCANS","op":"in","value":["no","partial"]}]},"then":{"title":"Automatiser les scans de vulnérabilités et prioriser la remédiation","domainId":"VULN","priority":"P1","effort":"M","rationale":"Les vulnérabilités connues sont une cause fréquente d'intrusion.","actions":["Scanner dépendances et images (CI) + scanner infra si applicable","Définir un triage (CVSS, exploitabilité, exposition Internet)","Créer un backlog de remédiation avec SLA"],"doneWhen":"Scans réguliers; SLA défini; top vuln critiques corrigées dans les délais.","titleEn":"Automate vulnerability scans and prioritize remediation","rationaleEn":"Known vulnerabilities are a frequent cause of intrusion.","actionsEn":["Scan dependencies and images (CI) + infrastructure if applicable","Define triage (CVSS, exploitability, Internet exposure)","Create a remediation backlog with SLA"],"doneWhenEn":"Regular scans; SLA defined; top critical vulnerabilities remediated on time."}},{"id":"REC_VULN_PATCH_001","semanticKey":"vuln.patch_sla","when":{"any":[{"path":"controls.VULN.VULN_PATCH","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place un processus de patching avec SLA selon criticité","domainId":"VULN","priority":"P1","effort":"M","rationale":"Sans SLA, les correctifs critiques sont retardés, augmentant la fenêtre d'exploitation.","actions":["Définir SLA (critique/haut/moyen) et un owner de remédiation","Mesurer le temps de correction et publier un tableau de bord","Gérer les exceptions (risque accepté) avec justification"],"doneWhen":"SLA documenté; suivi métriques; exceptions formalisées.","titleEn":"Implement a patching process with SLA based on criticality","rationaleEn":"Without SLA, critical patches are delayed, increasing the exploitation window.","actionsEn":["Define SLA (critical/high/medium) and a remediation owner","Measure correction time and publish a dashboard","Manage exceptions (accepted risk) with justification"],"doneWhenEn":"SLA documented; metrics tracked; exceptions formalized."}},{"id":"REC_LOG_CENTRAL_001","semanticKey":"log.centralization","when":{"any":[{"path":"controls.LOG.LOG_CENTRAL","op":"in","value":["no","partial"]}]},"then":{"title":"Centraliser les logs (app, auth, admin) et garantir l'horodatage","domainId":"LOG","priority":"P1","effort":"M","rationale":"Sans logs centralisés, la détection et l'investigation d'incidents sont limitées.","actions":["Centraliser logs applicatifs, authentification, actions admin, WAF/API GW","Synchroniser le temps (NTP) et normaliser formats","Restreindre l'accès et protéger l'intégrité"],"doneWhen":"Logs centralisés couvrant auth/admin; NTP en place; accès restreint.","titleEn":"Centralize logs (app, auth, admin) and ensure reliable timestamps","rationaleEn":"Without centralized logs, incident detection and investigation are limited.","actionsEn":["Centralize application, authentication, admin, and WAF/API GW logs","Synchronize time (NTP) and normalize formats","Restrict access and protect integrity"],"doneWhenEn":"Centralized logs covering auth/admin; NTP in place; access restricted."}},{"id":"REC_LOG_ALERTS_001","semanticKey":"log.alerting","when":{"any":[{"path":"controls.LOG.LOG_ALERTS","op":"in","value":["no","partial"]}]},"then":{"title":"Définir des use-cases de détection et des alertes sur événements critiques","domainId":"LOG","priority":"P1","effort":"M","rationale":"Sans alertes, les intrusions peuvent durer longtemps sans détection.","actions":["Créer alertes: échecs auth, MFA bypass, élévations privilèges, erreurs 5xx anormales","Configurer seuils et suppression de bruit (tuning)","Définir processus de réponse (on-call, escalade)"],"doneWhen":"Use-cases actifs; on-call défini; tests d'alertes réalisés.","titleEn":"Define detection use-cases and alerts on critical events","rationaleEn":"Without alerts, intrusions can persist undetected for extended periods.","actionsEn":["Create alerts: auth failures, MFA bypass, privilege elevations, abnormal 5xx errors","Configure thresholds and noise suppression (tuning)","Define response process (on-call, escalation)"],"doneWhenEn":"Use-cases active; on-call defined; alert tests performed."}},{"id":"REC_LOG_RETENTION_001","semanticKey":"log.retention_integrity","when":{"any":[{"path":"controls.LOG.LOG_RETENTION","op":"in","value":["no","partial"]}]},"then":{"title":"Définir rétention des logs et protection d'intégrité","domainId":"LOG","priority":"P2","effort":"S","rationale":"La rétention et l'intégrité des logs sont nécessaires pour audit et investigation.","actions":["Définir durée de rétention adaptée (conformité, besoin forensic)","Protéger contre suppression/modification non autorisée (WORM/immutabilité si possible)","Tracer les accès aux logs"],"doneWhen":"Rétention configurée; protections intégrité; accès aux logs audité.","titleEn":"Define log retention and integrity protection","rationaleEn":"Log retention and integrity are necessary for audit and investigation.","actionsEn":["Define appropriate retention period (compliance, forensic needs)","Protect against unauthorized deletion/modification (WORM/immutability if possible)","Trace access to logs"],"doneWhenEn":"Retention configured; integrity protections in place; log access audited."}},{"id":"REC_DATA_CLASSIF_001","semanticKey":"data.classification","when":{"any":[{"path":"controls.DATA.DATA_CLASSIF","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place une classification des données et règles d'accès associées","domainId":"DATA","priority":"P2","effort":"S","rationale":"La classification permet d'adapter les protections aux données réellement sensibles.","actions":["Définir niveaux (public/interne/sensible) et exemples","Mapper niveaux aux exigences (chiffrement, logs, accès, rétention)","Appliquer sur les datasets clés"],"doneWhen":"Classification publiée; datasets clés classifiés; règles d'accès appliquées.","titleEn":"Implement data classification and associated access rules","rationaleEn":"Classification allows adapting protections to genuinely sensitive data.","actionsEn":["Define levels (public/internal/sensitive) and examples","Map levels to requirements (encryption, logs, access, retention)","Apply to key datasets"],"doneWhenEn":"Classification published; key datasets classified; access rules applied."}},{"id":"REC_DATA_ENCRYPT_001","semanticKey":"data.encryption_rest","when":{"any":[{"path":"controls.DATA.DATA_ENCRYPT_REST","op":"in","value":["no","partial"]}]},"then":{"title":"Assurer le chiffrement au repos des données sensibles","domainId":"DATA","priority":"P1","effort":"M","rationale":"Le chiffrement réduit l'impact d'une fuite de support ou d'un accès non autorisé à la couche stockage.","actions":["Activer chiffrement au repos (DB, volumes, objets)","Gérer les clefs via KMS et limiter les accès","Documenter exceptions/compensations si contraintes"],"doneWhen":"Chiffrement au repos actif; clefs gérées et rotées; exceptions documentées.","titleEn":"Ensure encryption at rest for sensitive data","rationaleEn":"Encryption reduces the impact of media leakage or unauthorized access to the storage layer.","actionsEn":["Enable encryption at rest (DB, volumes, objects)","Manage keys via KMS and restrict access","Document exceptions/compensations if constraints exist"],"doneWhenEn":"Encryption at rest active; keys managed and rotated; exceptions documented."}},{"id":"REC_DATA_RETENTION_001","semanticKey":"data.retention","when":{"any":[{"path":"controls.DATA.DATA_RETENTION","op":"in","value":["no","partial"]}]},"then":{"title":"Définir et appliquer une politique de rétention et d'effacement (privacy)","domainId":"DATA","priority":"P1","effort":"M","rationale":"Une rétention excessive augmente l'exposition et le risque de non-conformité.","actions":["Définir durées de conservation par type de données","Mettre en place purge/effacement automatisés et traçables","Tester et prouver l'effacement (échantillons)"],"doneWhen":"Durées validées; purge auto active; preuve d'effacement disponible.","titleEn":"Define and enforce a retention and erasure policy (privacy)","rationaleEn":"Excessive retention increases exposure and non-compliance risk.","actionsEn":["Define retention durations by data type","Implement automated and traceable purge/erasure","Test and prove erasure (samples)"],"doneWhenEn":"Durations validated; automated purge active; proof of erasure available."}},{"id":"REC_INFRA_HARDEN_001","semanticKey":"infra.hardening","when":{"any":[{"path":"controls.INFRA.INFRA_HARDEN","op":"in","value":["no","partial"]}]},"then":{"title":"Appliquer un durcissement (hardening) et des baselines de configuration","domainId":"INFRA","priority":"P2","effort":"M","rationale":"Des configurations faibles facilitent l'exploitation et les mouvements latéraux.","actions":["Définir baseline (CIS/équivalent) et la rendre vérifiable","Automatiser via IaC et contrôles de conformité","Traiter les écarts avec exceptions documentées"],"doneWhen":"Baseline définie; scans de conformité; taux d'écarts en baisse.","titleEn":"Apply hardening and configuration baselines","rationaleEn":"Weak configurations facilitate exploitation and lateral movement.","actionsEn":["Define baseline (CIS/equivalent) and make it verifiable","Automate via IaC and compliance controls","Address deviations with documented exceptions"],"doneWhenEn":"Baseline defined; compliance scans; deviation rate decreasing."}},{"id":"REC_INFRA_SEGMENT_001","semanticKey":"infra.segmentation","when":{"any":[{"path":"controls.INFRA.INFRA_SEGMENT","op":"in","value":["no","partial"]}]},"then":{"title":"Renforcer la segmentation réseau et la séparation des environnements","domainId":"INFRA","priority":"P1","effort":"M","rationale":"La segmentation limite les mouvements latéraux et l'impact d'une compromission.","actions":["Séparer dev/préprod/prod et limiter les flux inter-zones","Appliquer des règles 'deny by default' et inventorier flux nécessaires","Mettre en place revues périodiques des règles"],"doneWhen":"Environnements séparés; flux justifiés; revues régulières des règles.","titleEn":"Strengthen network segmentation and environment separation","rationaleEn":"Segmentation limits lateral movement and the impact of a compromise.","actionsEn":["Separate dev/staging/prod and limit inter-zone traffic","Apply deny-by-default rules and inventory required flows","Implement periodic rule reviews"],"doneWhenEn":"Environments separated; flows justified; regular rule reviews."}},{"id":"REC_INFRA_IMMUTABLE_BACKUP_001","semanticKey":"infra.backup_protection","when":{"any":[{"path":"controls.INFRA.INFRA_BACKUP_PROTECT","op":"in","value":["no","partial"]}]},"then":{"title":"Protéger les sauvegardes contre ransomware (immutabilité / isolation)","domainId":"INFRA","priority":"P1","effort":"M","rationale":"Des sauvegardes accessibles et modifiables peuvent être chiffrées par un attaquant.","actions":["Activer immutabilité / WORM / offline selon capacités","Séparer comptes/permissions de sauvegarde (least privilege)","Surveiller l'activité anormale sur stockage de sauvegarde"],"doneWhen":"Sauvegardes immuables ou isolées; comptes séparés; surveillance active.","titleEn":"Protect backups against ransomware (immutability / isolation)","rationaleEn":"Accessible and modifiable backups can be encrypted by an attacker.","actionsEn":["Enable immutability / WORM / offline as available","Separate backup accounts/permissions (least privilege)","Monitor abnormal activity on backup storage"],"doneWhenEn":"Backups immutable or isolated; accounts separated; active monitoring."}},{"id":"REC_NET_TLS_001","semanticKey":"net.tls","when":{"any":[{"path":"controls.NETCRYPTO.NET_TLS","op":"in","value":["no","partial"]}]},"then":{"title":"Renforcer TLS (versions modernes, configuration, HSTS si web)","domainId":"NETCRYPTO","priority":"P1","effort":"S","rationale":"TLS faible augmente le risque d'interception et d'attaque MITM.","actions":["Activer TLS 1.2+ (idéalement 1.3) et ciphers robustes","Automatiser le renouvellement certificats","Vérifier configuration via tests récurrents"],"doneWhen":"TLS moderne actif; renouvellement auto; tests TLS passés.","titleEn":"Strengthen TLS (modern versions, configuration, HSTS if web)","rationaleEn":"Weak TLS increases the risk of interception and MITM attacks.","actionsEn":["Enable TLS 1.2+ (ideally 1.3) and strong ciphers","Automate certificate renewal","Verify configuration via recurring tests"],"doneWhenEn":"Modern TLS active; auto-renewal; TLS tests passed."}},{"id":"REC_NET_WAF_001","semanticKey":"net.waf","when":{"all":[{"path":"architecture.internetExposed","op":"eq","value":true},{"path":"controls.NETCRYPTO.NET_WAF","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place une protection périmétrique (WAF/API Gateway/rate-limit)","domainId":"NETCRYPTO","priority":"P1","effort":"M","rationale":"L'exposition Internet sans contrôle périmétrique augmente l'exploitation et l'abus d'API.","actions":["Déployer WAF ou API Gateway devant endpoints publics","Configurer rate limiting et protection contre bots","Journaliser et alerter sur anomalies (4xx/5xx, patterns OWASP)"],"doneWhen":"WAF/API GW actif; rate-limit configuré; alerting sur anomalies en place.","titleEn":"Implement perimeter protection (WAF/API Gateway/rate-limit)","rationaleEn":"Internet exposure without perimeter controls increases exploitation and API abuse.","actionsEn":["Deploy WAF or API Gateway in front of public endpoints","Configure rate limiting and bot protection","Log and alert on anomalies (4xx/5xx, OWASP patterns)"],"doneWhenEn":"WAF/API GW active; rate-limit configured; anomaly alerting in place."}},{"id":"REC_NET_KEYS_001","semanticKey":"net.key_management","when":{"any":[{"path":"controls.NETCRYPTO.NET_KEYS","op":"in","value":["no","partial"]}]},"then":{"title":"Structurer la gestion des clefs (KMS/HSM, rotation, accès contrôlé)","domainId":"NETCRYPTO","priority":"P2","effort":"M","rationale":"Une gestion faible des clefs compromet le chiffrement et la confidentialité.","actions":["Centraliser clefs dans KMS/HSM si possible","Définir rotation et séparation des rôles d'accès","Journaliser l'usage des clefs et alerter sur anomalies"],"doneWhen":"Clefs gérées via KMS/HSM; rotation active; logs d'usage consultables.","titleEn":"Structure key management (KMS/HSM, rotation, controlled access)","rationaleEn":"Weak key management compromises encryption and confidentiality.","actionsEn":["Centralize keys in KMS/HSM if possible","Define rotation and access role separation","Log key usage and alert on anomalies"],"doneWhenEn":"Keys managed via KMS/HSM; rotation active; usage logs accessible."}},{"id":"REC_NET_RATE_LIMIT_001","semanticKey":"net.rate_limit","when":{"all":[{"path":"architecture.internetExposed","op":"eq","value":true},{"path":"controls.NETCRYPTO.NET_WAF","op":"in","value":["no","partial"]}]},"then":{"title":"Mettre en place un rate limiting et une protection anti-abus d'API","domainId":"NETCRYPTO","priority":"P2","effort":"S","rationale":"La limitation réduit les attaques par brute force et abus de ressources.","actions":["Définir quotas et limites par client/clé API","Appliquer protection brute force sur auth","Tracer les dépassements et bloquer progressivement"],"doneWhen":"Quotas en place; brute force protégé; métriques de dépassement suivies.","titleEn":"Implement rate limiting and API abuse protection","rationaleEn":"Rate limiting reduces brute force attacks and resource abuse.","actionsEn":["Define quotas and limits per client/API key","Apply brute force protection on auth","Trace overages and progressively block"],"doneWhenEn":"Quotas in place; brute force protected; overage metrics tracked."}},{"id":"REC_BCP_BACKUP_001","semanticKey":"bcp.backups_rpo","when":{"any":[{"path":"controls.BCP.BCP_BACKUP","op":"in","value":["no","partial"]}]},"then":{"title":"Aligner la stratégie de sauvegardes sur le RPO","domainId":"BCP","priority":"P1","effort":"S","rationale":"Des sauvegardes trop espacées empêchent de tenir l'objectif de perte acceptable (RPO).","actions":["Définir fréquence de sauvegarde conforme RPO","Vérifier couverture (DB, fichiers, config, secrets si applicable)","Surveiller succès/échecs de jobs"],"doneWhen":"Fréquence conforme RPO; couverture validée; taux de succès > 99%.","titleEn":"Align backup strategy with RPO","rationaleEn":"Infrequent backups prevent meeting the acceptable data loss objective (RPO).","actionsEn":["Define backup frequency compliant with RPO","Verify coverage (DB, files, config, secrets if applicable)","Monitor job success/failure"],"doneWhenEn":"RPO-compliant frequency; validated coverage; success rate > 99%."}},{"id":"REC_BCP_RESTORE_TEST_001","semanticKey":"bcp.restore_tests","when":{"any":[{"path":"controls.BCP.BCP_RESTORE_TEST","op":"in","value":["no","partial"]}]},"then":{"title":"Tester régulièrement les restaurations et conserver des preuves","domainId":"BCP","priority":"P1","effort":"S","rationale":"Sans tests, une sauvegarde peut être inutilisable au moment critique.","actions":["Planifier tests de restauration (trimestriels minimum selon criticité)","Vérifier intégrité données et temps de restauration","Archiver les preuves (logs, comptes-rendus)"],"doneWhen":"Tests réalisés selon calendrier; temps mesuré; preuves archivées.","titleEn":"Regularly test restores and retain proof of success","rationaleEn":"Without tests, a backup may be unusable when critically needed.","actionsEn":["Schedule restore tests (quarterly minimum based on criticality)","Verify data integrity and restoration time","Archive evidence (logs, reports)"],"doneWhenEn":"Tests performed on schedule; time measured; evidence archived."}},{"id":"REC_BCP_DR_001","semanticKey":"bcp.dr_plan","when":{"any":[{"path":"controls.BCP.BCP_DR","op":"in","value":["no","partial"]}]},"then":{"title":"Formaliser un PRA/PCA et l'aligner sur le RTO","domainId":"BCP","priority":"P1","effort":"M","rationale":"Sans plan, le RTO est rarement tenable en situation de crise.","actions":["Rédiger runbook PRA (responsabilités, séquence, critères)","Exercer le plan (simulation) et améliorer","S'assurer de la disponibilité des accès/clefs/contacts en crise"],"doneWhen":"Runbook disponible; exercice réalisé; actions d'amélioration suivies.","titleEn":"Formalize a DRP/BCP and align it with RTO","rationaleEn":"Without a plan, RTO is rarely achievable in a crisis situation.","actionsEn":["Draft DRP runbook (responsibilities, sequence, criteria)","Exercise the plan (simulation) and improve","Ensure availability of access/keys/contacts during crisis"],"doneWhenEn":"Runbook available; exercise performed; improvement actions tracked."}},{"id":"REC_CTX_INTERNET_MINIMIZE_001","semanticKey":"ctx.minimize_exposure","when":{"all":[{"path":"architecture.internetExposed","op":"eq","value":true}]},"then":{"title":"Réduire l'exposition Internet au strict nécessaire","domainId":"NETCRYPTO","priority":"P2","effort":"S","rationale":"Moins d'exposition réduit mécaniquement la surface d'attaque.","actions":["Inventorier endpoints publics et supprimer ceux non nécessaires","Restreindre par IP/VPN/Zero Trust quand possible","Durcir auth et monitoring sur endpoints restants"],"doneWhen":"Inventaire à jour; endpoints inutiles supprimés; restrictions appliquées.","titleEn":"Reduce Internet exposure to the strict minimum","rationaleEn":"Less exposure mechanically reduces the attack surface.","actionsEn":["Inventory public endpoints and remove unnecessary ones","Restrict by IP/VPN/Zero Trust where possible","Harden auth and monitoring on remaining endpoints"],"doneWhenEn":"Inventory up to date; unnecessary endpoints removed; restrictions applied."}},{"id":"REC_CTX_CIA_HIGH_HARDEN_001","semanticKey":"ctx.cia_high_hardening","when":{"all":[{"path":"context.dataSensitivity","op":"eq","value":"high"}]},"then":{"title":"Appliquer un niveau renforcé de contrôles pour données sensibles","domainId":"DATA","priority":"P1","effort":"M","rationale":"Données sensibles exigent des protections renforcées.","actions":["Restreindre accès aux datasets sensibles (RBAC + justification)","Renforcer logs/alerting sur accès aux données","Revoir rétention et minimisation des données"],"doneWhen":"Accès restreints; logs/alertes sur accès; rétention revue.","titleEn":"Apply enhanced controls for sensitive data","rationaleEn":"Sensitive data requires reinforced protections.","actionsEn":["Restrict access to sensitive datasets (RBAC + justification)","Strengthen logging/alerting on data access","Review retention and data minimization"],"doneWhenEn":"Access restricted; logs/alerts on access; retention reviewed."}},{"id":"REC_CTX_RTO_STRICT_DRILL_001","semanticKey":"ctx.rto_drill","when":{"any":[{"path":"continuity.rtoHours","op":"lte","value":8},{"path":"continuity.rpoHours","op":"lte","value":6}]},"then":{"title":"Exercer régulièrement la reprise et mesurer les temps (RTO/RPO)","domainId":"BCP","priority":"P1","effort":"M","rationale":"Des objectifs serrés nécessitent des exercices et mesures pour être crédibles.","actions":["Organiser un exercice PRA/PCA (au moins annuel)","Mesurer temps de reprise et perte de données","Mettre à jour le plan et les dépendances"],"doneWhen":"Exercice réalisé; RTO/RPO mesurés; actions correctives suivies.","titleEn":"Regularly exercise recovery and measure times (RTO/RPO)","rationaleEn":"Tight objectives require exercises and measurements to be credible.","actionsEn":["Organize a DRP/BCP exercise (at least annually)","Measure recovery time and data loss","Update plan and dependencies"],"doneWhenEn":"Exercise performed; RTO/RPO measured; corrective actions tracked."}},{"id":"REC_LOG_ADMIN_AUDIT_001","semanticKey":"log.admin_audit","when":{"any":[{"path":"domainTrafficLight.LOG","op":"in","value":["yellow","red"]}]},"then":{"title":"Surveiller les actions administrateur et les changements sensibles","domainId":"LOG","priority":"P2","effort":"S","rationale":"Les actions admin sont souvent la source d'incidents ou d'abus.","actions":["Tracer actions admin (création droits, exports, changements config)","Alerter sur activités anormales (heures, volumes, pays)","Revoir périodiquement les changements"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Monitor administrator actions and sensitive changes","rationaleEn":"Admin actions are often the source of incidents or abuse.","actionsEn":["Trace admin actions (permission creation, exports, config changes)","Alert on abnormal activities (hours, volumes, countries)","Periodically review changes"],"doneWhenEn":"Measure implemented and verified."}},{"id":"REC_VULN_EXPOSED_FASTPATCH_001","semanticKey":"vuln.exposed_fastpatch","when":{"any":[{"path":"domainTrafficLight.VULN","op":"in","value":["yellow","red"]}]},"then":{"title":"Accélérer la remédiation des vulnérabilités sur surfaces exposées","domainId":"VULN","priority":"P1","effort":"M","rationale":"Les vulnérabilités exposées Internet sont plus exploitables.","actions":["Tagger les composants exposés","Appliquer SLA plus court sur CVE critiques exposées","Mettre en place une fenêtre de déploiement rapide"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Accelerate vulnerability remediation on exposed surfaces","rationaleEn":"Internet-exposed vulnerabilities are more exploitable.","actionsEn":["Tag exposed components","Apply shorter SLA for critical exposed CVEs","Implement a rapid deployment window"],"doneWhenEn":"Measure implemented and verified."}},{"id":"REC_SDLC_RELEASE_GATES_001","semanticKey":"sdlc.release_gates","when":{"any":[{"path":"domainTrafficLight.SDLC","op":"in","value":["yellow","red"]}]},"then":{"title":"Mettre en place des 'gates' de release (tests + vulnérabilités)","domainId":"SDLC","priority":"P2","effort":"S","rationale":"Amélioration priorisée au vu de la posture actuelle.","actions":["Bloquer release si vulnérabilités critiques non justifiées","Exiger tests sécurité minimum sur release","Tracer les dérogations"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Implement release gates (tests + vulnerabilities)","rationaleEn":"Prioritized improvement based on current security posture.","actionsEn":["Block release if unjustified critical vulnerabilities exist","Require minimum security tests on release","Trace waivers"],"doneWhenEn":"Measure implemented and verified."}},{"id":"REC_DATA_BACKUP_SCOPE_001","semanticKey":"data.backup_scope","when":{"any":[{"path":"domainTrafficLight.BCP","op":"in","value":["yellow","red"]}]},"then":{"title":"Vérifier la couverture des sauvegardes (données + configuration + secrets)","domainId":"BCP","priority":"P2","effort":"S","rationale":"Amélioration priorisée au vu de la posture actuelle.","actions":["Lister les éléments à sauvegarder (DB, fichiers, config, IaC)","Vérifier restauration complète (pas seulement DB)","Documenter la procédure"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Verify backup coverage (data + configuration + secrets)","rationaleEn":"Prioritized improvement based on current security posture.","actionsEn":["List elements to back up (DB, files, config, IaC)","Verify complete restoration (not just DB)","Document the procedure"],"doneWhenEn":"Measure implemented and verified."}},{"id":"REC_NET_ZERO_TRUST_001","semanticKey":"net.zero_trust","when":{"any":[{"path":"domainTrafficLight.NETCRYPTO","op":"in","value":["yellow","red"]}]},"then":{"title":"Restreindre l'administration via accès conditionnel / bastion","domainId":"NETCRYPTO","priority":"P2","effort":"M","rationale":"Amélioration priorisée au vu de la posture actuelle.","actions":["Imposer un bastion ou accès conditionnel","Limiter l'accès admin par réseau et identité","Tracer toutes les sessions admin"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Restrict administration via conditional access / bastion","rationaleEn":"Prioritized improvement based on current security posture.","actionsEn":["Enforce a bastion or conditional access","Limit admin access by network and identity","Trace all admin sessions"],"doneWhenEn":"Measure implemented and verified."}},{"id":"REC_INFRA_IAC_POLICY_001","semanticKey":"infra.iac_policy","when":{"any":[{"path":"domainTrafficLight.INFRA","op":"in","value":["yellow","red"]}]},"then":{"title":"Contrôler l'infrastructure as code (policies, revues, drift)","domainId":"INFRA","priority":"P2","effort":"M","rationale":"Amélioration priorisée au vu de la posture actuelle.","actions":["Imposer revue IaC","Appliquer policy-as-code (deny misconfig)","Détecter et corriger le drift"],"doneWhen":"Mesure mise en œuvre et vérifiée.","titleEn":"Control infrastructure as code (policies, reviews, drift)","rationaleEn":"Prioritized improvement based on current security posture.","actionsEn":["Enforce IaC review","Apply policy-as-code (deny misconfig)","Detect and fix drift"],"doneWhenEn":"Measure implemented and verified."}}];

  // ============================================================
  // DATA MODEL
  // ============================================================
  // === L1 SOCLE PACKS (2026-08-04) — résolution unifiée du catalogue =========
  // Une entrée de risque provient : (1) du catalogue builtin RA_RISK_CATALOG
  // (catalogId) ; (2) d'un pack de référentiel (packId + threatId/scenarioId,
  // registre window._csiThreatCatalogs servi par modules/threat-catalog.js) ;
  // (3) de nulle part (pack absent : .analyse importé, désinstallation) — on
  // retombe alors sur le DÉNORMALISÉ figé à la sélection, jamais sur « ? » au
  // PDF ni sur une perte d'abattement du résiduel (garde-fou n°1 de l'étude
  // ETUDE-REFERENTIELS-ANALYSE-RISQUES-2026-08-04.md §4).
  function _raSnapshotFields(cat) {
    return {
      _threat: cat.threat || '', _threatEn: cat.threatEn || cat.threat || '',
      _scenario: cat.scenario || '', _scenarioEn: cat.scenarioEn || cat.scenario || '',
      _domainHints: (cat.domainHints || []).slice(),
      _ciaTargets: (cat.ciaTargets || []).slice(), // === RA-UX (B4) ===
      _adversarial: cat.adversarial !== false,
      _ref: cat.ref || '', // FIX-AUDIT-REF (2026-08-25) : référence normative (A01:2025, T1566, E-x) figée — sinon perdue si le pack est retiré
      _packVersion: cat.packVersion || 'builtin'
    };
  }
  // FIX-RA-SENS-I18N (audit référentiels 2026-08-17, constat 7) : l'énum interne
  // (low/medium/high) s'imprimait brute dans l'aperçu et le PDF.
  function _raSensLabel(v) {
    if (v === 'low') return _t('riskAnalysis.context.sensitivityLow', 'Faible');
    if (v === 'high') return _t('riskAnalysis.context.sensitivityHigh', 'Élevée');
    if (v === 'medium') return _t('riskAnalysis.context.sensitivityMedium', 'Moyenne');
    return v || 'N/A';
  }
  // Libellés lisibles pour l'aperçu et le PDF (méthode + catalogue résolu).
  function _raMethodLabel(a) {
    var m = (a.context || {}).analysisMethod || '';
    if (m === 'ebios-iso27005') return _t('riskAnalysis.context.methodEbios', 'EBIOS RM (compatible ISO 27005)');
    if (m === 'nist-800-30') return _t('riskAnalysis.context.methodNist', 'NIST SP 800-30');
    return _t('riskAnalysis.context.methodDefault', 'Approche pragmatique Cyber-Assistant');
  }
  // FIX-AUDIT-LICENCE (audit 2026-08-25) : les packs portent un disclaimer/licence
  // (CC BY-SA pour OWASP, mention de permission pour MITRE) qu'AUCUN consommateur ne lisait,
  // alors que le rapport reproduit les intitulés verbatim — obligation d'attribution.
  function _raPackDisclaimer(a) {
    var sf = (a && a.context || {}).sourceFramework || '';
    if (!sf || !window.CSI_ThreatCatalog) return '';
    var pack = window.CSI_ThreatCatalog.get(sf);
    if (!pack || !pack.meta) return '';
    return _bi(pack.meta, 'disclaimer', 'disclaimerEn') || pack.meta.licence || pack.meta.license || '';
  }

  // FIX-AUDIT-IMPORT-SIG (audit v2, 2026-08-25) : la déduplication à l'import comparait les
  // ids — or les ids builtin sont des CONSTANTES partagées entre organisations, et les objets
  // sont éditables EN PLACE sans changement d'id. Résultat : ORG A important une analyse
  // d'ORG B se voyait attribuer SA PROPRE politique d'acceptation (faux-vert de scoring), en
  // violation de la doctrine posée par FIX-RA-THRESHOLD-EXPORT. On ne réutilise donc un objet
  // local que s'il est SÉMANTIQUEMENT IDENTIQUE à l'embarqué.
  function _stableStringify(v) {
    if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
    if (Array.isArray(v)) return '[' + v.map(_stableStringify).join(',') + ']';
    return '{' + Object.keys(v).sort().map(function(k) {
      return JSON.stringify(k) + ':' + _stableStringify(v[k]);
    }).join(',') + '}';
  }
  // Clés de POLITIQUE uniquement : id / _importedFromId / createdAt / updatedAt exclus.
  function _scaleSignature(o) {
    if (!o || typeof o !== 'object') return '';
    var vue = {};
    ['name', 'nameFr', 'description', 'descriptionFr', 'levels', 'cells', 'dimensions',
     'acceptance', 'probabilityLabels', 'probabilityLabelsFr', 'gravityLabels',
     'gravityLabelsFr', 'meta'].forEach(function(k) {
      if (o[k] === undefined) return;
      // Le MARQUEUR d'import « (importé) » n'est pas de la politique : sans ce retrait, le
      // clone marqué (FIX-IMPORT-FORK-LABEL) aurait une signature différente de la matrice
      // embarquée dont il provient, et le ré-import ré-accumulerait des orphelines — le
      // défaut que FIX-IMPORT-SIG-NORM vient de fermer. On compare des politiques, pas des
      // libellés d'origine.
      vue[k] = (k === 'name' || k === 'nameFr') ? _sansSuffixeImporte(o[k]) : o[k];
    });
    return _stableStringify(vue);
  }

  // Marque un libellé cloné à l'import, sans jamais empiler le suffixe.
  // Le suffixe est résolu par _t : sa forme dépend de la langue du poste qui importe.
  // Pour la comparaison de politique, on retire TOUTES les formes connues.
  var _SUFFIXES_IMPORT = [' (importé)', ' (imported)', ' (geïmporteerd)'];
  function _suffixeImporte(nom) {
    var base = String(nom == null ? '' : nom);
    var suf = ' (' + _t('riskAnalysis.controls.imported', 'importé') + ')';
    return base.slice(-suf.length) === suf ? base : (base + suf);
  }
  function _sansSuffixeImporte(nom) {
    if (typeof nom !== 'string') return nom;
    var out = nom;
    var change = true;
    while (change) {
      change = false;
      for (var i = 0; i < _SUFFIXES_IMPORT.length; i++) {
        var suf = _SUFFIXES_IMPORT[i];
        if (out.slice(-suf.length) === suf) { out = out.slice(0, -suf.length); change = true; }
      }
    }
    return out;
  }

  // Signature d'un jeu de résultats, invariante à l'horodatage ET à la langue.
  //
  // `computedAt` change à chaque calcul et rendrait tout cache « périmé ».
  // Mais `computeAllResults` n'est PAS pur non plus vis-à-vis de la langue : _bi() et
  // _lvlLabel() résolvent leurs libellés via _lang(). Comparer le JSON brut faisait donc
  // d'une simple bascule FR→EN un « changement de données » — écriture, updatedAt, et en
  // mode serveur un push, pour zéro modification réelle (régression du lot B, trouvée par
  // l'audit progressif et reproduite : saveData=1 sur un simple changement de langue).
  //
  // On compare donc la DÉCISION, pas sa formulation : les libellés localisés sont écartés.
  // Leur liste est verrouillée par ra-results-cache.test.js, qui calcule la même analyse
  // en fr et en en et exige une signature IDENTIQUE — tout nouveau libellé localisé oublié
  // ici fera rougir ce test.
  var _CLES_LOCALISEES = { text: 1, hint: 1, label: 1, levelLabel: 1, residualLevelLabel: 1,
    title: 1, rationale: 1, actions: 1, doneWhen: 1, avisLabel: 1, floorLabel: 1,
    autoAvisLabel: 1, computedAt: 1 };

  function _signatureResultats(res) {
    if (!res) return '';
    var vu = [];
    function net(n) {
      if (Array.isArray(n)) return n.map(net);
      if (n && typeof n === 'object') {
        if (vu.indexOf(n) !== -1) return null; // garde anti-cycle
        vu.push(n);
        var out = {};
        Object.keys(n).sort().forEach(function(k) {
          if (_CLES_LOCALISEES[k]) return;
          out[k] = net(n[k]);
        });
        return out;
      }
      return n;
    }
    try { return JSON.stringify(net(res)); } catch (e) { return ''; }
  }

  // Conservé pour compatibilité de lecture : compare sans l'horodatage seulement.
  function _sansHorodatage(res) {
    if (!res) return '';
    var copie = {};
    Object.keys(res).forEach(function(k) { if (k !== 'computedAt') copie[k] = res[k]; });
    try { return JSON.stringify(copie); } catch (e) { return ''; }
  }

  // FIX-AUDIT-JOURNAL (audit v2, 2026-08-25) : le module n'écrivait RIEN dans le journal
  // d'audit — les catégories `riskAnalysis` et `impactMatrices` étaient déclarées ET
  // traduites sans le moindre émetteur (deux options de filtre qui ne matchaient jamais).
  // Contradiction interne : raFinish bloque la finalisation faute de justification « pour la
  // traçabilité », puis ne trace pas la finalisation ; raDelete efface l'analyse ET ses
  // risques projets en cascade, en silence total.
  // Best-effort : le journal ne doit JAMAIS faire échouer une action métier.
  // Libellé de seuil selon la langue — promu au niveau module (était local au rendu des cartes)
  function _thName(t) { if (!t) return ''; return (_lang() === 'en') ? (t.name || t.nameFr || '') : (t.nameFr || t.name || ''); }

  function _audit(action, target, details) {
    try {
      if (typeof window.addAuditEntry === 'function') window.addAuditEntry(action, 'riskAnalysis', target, details);
    } catch (e) { /* journal indisponible : sans effet sur l'action en cours */ }
  }

  function _raCatalogLabel(a) {
    var sf = (a.context || {}).sourceFramework || '';
    if (!sf) return _t('riskAnalysis.context.catalogBuiltin', 'Catalogue générique Cyber-Assistant');
    if (window.CSI_ThreatCatalog) {
      var pack = window.CSI_ThreatCatalog.get(sf);
      if (pack && pack.meta) return (pack.meta.name || sf) + (pack.meta.version ? ' (' + pack.meta.version + ')' : ''); // FIX-AUDIT-PROTO : garde alignée sur _raPackDisclaimer
    }
    return sf; // legacy : libellé libre cité dans le rapport
  }

  function _raEbiosCoupleLabels(a) {
    var ids = (a.context || {}).riskSources || [];
    var all = (window._csiEbiosArchetypes || {}).couples || [];
    return ids.map(function(id) {
      var cp = null;
      for (var i = 0; i < all.length; i++) { if (all[i].id === id) { cp = all[i]; break; } }
      return cp ? _bi(cp, 'label', 'labelEn') : id;
    });
  }

  function _raResolveCat(r) {
    if (!r) return null;
    if (r.catalogId) {
      var hit = RA_RISK_CATALOG.find(function(c) { return c.id === r.catalogId; });
      if (hit) return hit;
    }
    if (r.packId && window.CSI_ThreatCatalog) {
      var entry = window.CSI_ThreatCatalog.resolveEntry(r.packId, r.threatId, r.scenarioId);
      if (entry) return entry;
    }
    if (r._threat || r._scenario) {
      return { id: r.catalogId || r.scenarioId || null,
               threat: r._threat || '', threatEn: r._threatEn || r._threat || '',
               scenario: r._scenario || '', scenarioEn: r._scenarioEn || r._scenario || '',
               domainHints: (r._domainHints || []).slice(),
               ciaTargets: (r._ciaTargets || []).slice(), // === RA-UX (B4) ===
               ref: r._ref || '', // FIX-AUDIT-REF : la référence normative survit à l'absence du pack
               adversarial: r._adversarial !== false, _fromSnapshot: true };
    }
    return null;
  }

  // === RA-UX (A4) === une recommandation « existe » dès qu'un de ses champs est rempli
  // (titre, détail, risques couverts ou atténuation attendue) — prédicat partagé
  // saisie/aperçu/PDF pour que l'auto-nettoyage ne jette jamais une saisie partielle.
  // === RA-PROJ (Groupe D) === étendu aux liens structurés (coveredRiskIds) : une reco
  // « liens seulement » ne doit jamais être auto-nettoyée. Effet assumé (étude §4.1d,
  // testé) : un tel item compte dans l'avis SPOT comme toute reco pendante.
  function _frHasContent(it) {
    return !!(it && (it.title || it.details || it.coveredRisks || it.expectedMitigation
      || (Array.isArray(it.coveredRiskIds) && it.coveredRiskIds.length)));
  }

  // ============================================================
  // === RA-PROJ (Groupe D, ETUDE-PROJECTION-RECOMMANDATIONS-RISQUES-2026-08-24) ===
  // Liens recommandations↔risques + projection déclarative. Doctrine : la position
  // cible est TOUJOURS déclarée par l'analyste, JAMAIS calculée ; le moteur ci-dessous
  // est PUR et n'est jamais persisté (rien dans a.results — précédent
  // FIX-RA-STALE-RESULTS —, rien à fusionner en co-édition, round-trip .analyse
  // identique). Aucun effet sur computeAllResults/feu/avis/registre.
  // ============================================================

  // Prédicat centralisé : une reco compte-t-elle dans la projection ? v1 : toutes —
  // la vue est conditionnelle (« si mise en œuvre »), cohérent avec l'avis jaune.
  // L'énumération C2 actée ne contient aucun état de refus ; si un état d'abandon
  // apparaît un jour, l'exclusion se code ici, en une ligne.
  function _recCountsInProjection(item) { return !!item; }

  // Libellé d'un risque lié (chips, tableau de projection, PDF) — même résolution que
  // le registre (catalogue → snapshot), « — » si le lien est pendant (doctrine « — »).
  function _frLinkedRiskLabel(a, riskId) {
    var r = ((a && a.risks) || []).find(function(x) { return x && x.id === riskId; });
    if (!r) return '—';
    var cat = _raResolveCat(r);
    return cat ? _bi(cat, 'threat', 'threatEn') : (r.customTitle || '?');
  }

  // Libellé d'affichage d'une reco (aperçu, synthèse et section 10 du PDF) : mêmes
  // replis que l'historique, PLUS un repli dérivé des risques liés — sans lui, une reco
  // « liens seulement » imprimait une puce vide dans la synthèse management (la classe
  // de bug que FIX-REVUE l.4198 avait fermée pour les champs texte).
  function _frDisplayLabel(a, item) {
    var t = item.title || (item.details || '').split('\n')[0] || item.coveredRisks || item.expectedMitigation || '';
    if (t) return t;
    var names = (Array.isArray(item.coveredRiskIds) ? item.coveredRiskIds : [])
      .map(function(rid) { return _frLinkedRiskLabel(a, rid); })
      .filter(function(n) { return n && n !== '—'; });
    if (names.length) return _t('riskAnalysis.projection.coversPrefix', 'Couvre :') + ' ' + names.join(', ');
    return '';
  }

  // === RA-PROJ === toast NON bloquant quand un risque retiré est référencé par ≥1
  // recommandation clef (étude §4.7) : le lien devient pendant, et un retrait/re-ajout
  // régénère l'id — les liens ne se retrouvent pas.
  function _frWarnIfLinked(a, riskId) {
    if (!a || !riskId) return;
    var n = 0;
    (((a.finalRecs || {}).items) || []).forEach(function(it) {
      if (it && Array.isArray(it.coveredRiskIds) && it.coveredRiskIds.indexOf(riskId) >= 0) n++;
    });
    if (n > 0 && typeof window.showToast2 === 'function') {
      window.showToast2(_t('riskAnalysis.projection.unlinkWarning', 'Ce risque était couvert par une recommandation clef — le lien est retiré.'), 'warning');
    }
  }

  // Moteur pur de la projection : { [riskId]: {fromP, fromG, p, g, covered, hasTarget,
  // moved, clamped} }. Risques NA exclus (comme les scores et les matrices). Un risque
  // couvert par ≥1 reco ET porteur d'une cible est projeté à sa cible, clampée à
  // l'inhérent courant (jamais-pire, même doctrine que le plafonnement p′ l.156) ; une
  // cible ORPHELINE (plus aucune reco couvrante) n'est PAS projetée — une projection
  // sans plan derrière elle est indéfendable (arbitrage PO n°2) ; la donnée est
  // conservée et signalée dans le tableau de projection.
  function computeProjectedPositions(a) {
    // FIX-PROJ-PROTO (audit progressif, 2026-08-26) : ces deux cartes sont indexées par des
    // ids de risque VENANT DE LA DONNÉE (catalogue, saisie, .analyse importé). Un littéral
    // `{}` les exposait à Object.prototype et rouvrait la classe que le lot A ferme ailleurs.
    // Quatre effets mesurés : une reco liée à un id fantôme « constructor » comptait dans la
    // légende ; un risque d'id « toString » ressortait `covered:true` SANS aucune reco ; et
    // le pire — un risque 5×5 porteur d'une cible orpheline était projeté à 1×1 avec
    // `anyMoved:true`, propagé à la matrice projetée et au PDF, en violation directe de
    // l'arbitrage n°2 (« une projection sans plan derrière elle est indéfendable »).
    // Cartes SANS prototype : une clé absente reste absente, quel que soit son nom.
    var out = Object.create(null);
    if (!a) return out;
    var covered = Object.create(null);
    (((a.finalRecs || {}).items) || []).forEach(function(it) {
      if (!it || !_recCountsInProjection(it)) return;
      (Array.isArray(it.coveredRiskIds) ? it.coveredRiskIds : []).forEach(function(id) {
        if (id) covered[id] = true;
      });
    });
    (a.risks || []).forEach(function(r) {
      if (!r || !r.id || r.treatmentDecision === 'na') return;
      // FIX-AUDIT-AXIS (audit 2026-08-25) : likelihood/impact inhérents ne sont bornés NULLE
      // PART (normalizeRiskAnalysis ne les touche pas, _projClampAxis ne borne que projected) —
      // un .analyse forgé à 1e308 s'affichait brut à l'écran et dans le PDF. Bornage à la source :
      // seul un entier 1-5 circule ensuite dans la projection.
      var fromP = _projClampAxis(r.likelihood) || 3, fromG = _projClampAxis(r.impact) || 3;
      var isCov = !!covered[r.id];
      var p = fromP, g = fromG, clamped = false, hasTarget = false;
      if (isCov && r.projected) {
        hasTarget = true;
        p = Math.min(r.projected.likelihood, fromP);
        g = Math.min(r.projected.impact, fromG);
        clamped = (r.projected.likelihood > fromP) || (r.projected.impact > fromG);
      }
      out[r.id] = { fromP: fromP, fromG: fromG, p: p, g: g, covered: isCov,
        hasTarget: hasTarget, moved: (p !== fromP || g !== fromG), clamped: clamped };
    });
    return out;
  }

  // Agrégats PURS de la projection (bannière B2 scindée, liste des déplacements,
  // condition d'affichage des matrices). Jamais persistés — mêmes garanties que
  // computeProjectedPositions. `resultsOpt` évite un double computeAllResults quand
  // l'appelant en a déjà un frais.
  function computeProjectionSummary(a, resultsOpt) {
    var t = getThresholdForAnalysis(a);
    var floorIdx = getAcceptanceFloorIndex(t);
    var proj = computeProjectedPositions(a);
    var results = resultsOpt || computeAllResults(a);
    var out = { linkedRecCount: 0, anyMoved: false, coveredBeyond: 0, uncoveredBeyond: 0,
      broughtUnder: 0, floorDefined: floorIdx >= 0, moves: [] };
    var items = (((a && a.finalRecs) || {}).items) || [];
    // FIX-AUDIT-COUNT (audit v2, 2026-08-25) : le compteur s'adosse désormais à `proj`, la
    // MÊME source que le moteur — un risque « non applicable » en est exclu, comme partout
    // ailleurs. Avant, une recommandation liée uniquement à des risques écartés gonflait le
    // « complète des N recommandation(s) clefs » de la légende ET de la synthèse management
    // du PDF (la page la plus lue). Le filtre par ids connus ne suffisait pas : un id NA est
    // « connu » mais n'est pas projeté.
    items.forEach(function(it) {
      if (!it || !_recCountsInProjection(it) || !Array.isArray(it.coveredRiskIds)) return;
      if (it.coveredRiskIds.some(function(id) { return !!proj[id]; })) out.linkedRecCount++;
    });
    ((a && a.risks) || []).forEach(function(r) {
      var pr = r && r.id && proj[r.id];
      if (!pr) return;
      if (pr.moved) out.anyMoved = true;
      if (floorIdx >= 0) {
        // Bannière SCINDÉE (revue adverse) : « resteraient au-delà malgré le plan »
        // (couverts) vs « non couverts par le plan » — deux diagnostics, deux décisions.
        if (pr.covered && isCellBeyondAcceptance(t, pr.p, pr.g, floorIdx)) out.coveredBeyond++;
        if (!pr.covered && isCellBeyondAcceptance(t, pr.fromP, pr.fromG, floorIdx)) out.uncoveredBeyond++;
        // « X risques SERAIENT ramenés sous les critères » (ligne/paire de synthèse) :
        // couvert, au-delà à l'inhérent, plus au-delà à la cible déclarée.
        if (pr.covered && pr.hasTarget && isCellBeyondAcceptance(t, pr.fromP, pr.fromG, floorIdx)
          && !isCellBeyondAcceptance(t, pr.p, pr.g, floorIdx)) out.broughtUnder++;
      }
      if (pr.moved) {
        var rs = (results.riskScores || []).find(function(s) { return s.id === r.id; }) || {};
        var refs = [];
        items.forEach(function(it, i) {
          if (it && _recCountsInProjection(it) && Array.isArray(it.coveredRiskIds) && it.coveredRiskIds.indexOf(r.id) >= 0) refs.push('#' + (i + 1));
        });
        out.moves.push({
          id: r.id, label: _frLinkedRiskLabel(a, r.id),
          fromP: pr.fromP, fromG: pr.fromG, p: pr.p, g: pr.g,
          inherent: pr.fromP * pr.fromG, target: pr.p * pr.g,
          residual: (rs.residual != null ? rs.residual : null),
          // Tri par gain sur ACTUEL→CIBLE (revue adverse : le gain inhérent→cible
          // attribuerait au plan une réduction déjà acquise par les contrôles).
          gain: (rs.residual != null ? rs.residual : pr.fromP * pr.fromG) - (pr.p * pr.g),
          fromLevel: _lvlLabel(getRiskLevelForCell(pr.fromP, pr.fromG, t)),
          toLevel: _lvlLabel(getRiskLevelForCell(pr.p, pr.g, t)),
          recRefs: refs, clamped: pr.clamped
        });
      }
    });
    out.moves.sort(function(x, y) { return y.gain - x.gain; });
    return out;
  }

  // Normalisation durcie des nouveaux champs (façon FIX-RA-IMPORT-ROBUST) : un .analyse
  // forgé peut porter coveredRiskIds/projected au mauvais type ou hors bornes — on
  // coerce, on clampe, jamais d'undefined ni d'exception. Les ids PENDANTS (risque
  // supprimé) sont TOLÉRÉS ici (filtrés au rendu/calcul ; élagage paresseux à la
  // collecte de l'étape 6, en édition seulement) — aucune écriture silencieuse.
  // Backfill des ids manquants/dupliqués de risques et de recos (aucun n'existait :
  // une map par riskId collisionnerait sur undefined) — RAM-only, patron
  // ensureAnalysisSchema, persisté au prochain saveData().
  function _projClampAxis(v) {
    var n = parseInt(v, 10);
    return (n >= 1 && n <= 5) ? n : null;
  }
  function _riskNormalizeProjection(r) {
    if (!r || typeof r !== 'object') return;
    // FIX-NA-TARGET-KEEP (audit progressif des lots A/B, 2026-08-26) : le lot B purgeait
    // ici la cible d'un risque « non applicable ». Trois raisons de retirer cette purge :
    //  - elle DÉTRUIT une saisie valide de l'analyste, alors que toutes les autres branches
    //    de cette fonction se contentent de coercer/clamper des données INVALIDES ;
    //  - elle s'exécute depuis un chemin de PURE LECTURE (ensureAnalysisSchema ← findAnalysis,
    //    sans garde _ro()/_scopedSub()) : un compte en consultation détruisait la donnée, qui
    //    partait au premier saveData() venu — exactement la classe fermée par FIX-AUDIT-RO-SEED ;
    //  - elle contredit l'arbitrage n°2 de l'étude, TRANCHÉ : « la donnée `projected` est
    //    conservée, visible dans le tableau avec flag + bouton de retrait ; re-lier une reco
    //    réactive ». La « résurrection » invoquée comme motif EST le comportement voulu.
    // Le « fantôme » diagnostiqué était réel mais mal soigné : le tableau excluait les NA. On
    // élargit donc son périmètre (voir _renderProjectionBlock) au lieu d'effacer la donnée.
    var p = r.projected;
    if (p === null || p === undefined) { r.projected = null; return; }
    if (typeof p !== 'object' || Array.isArray(p)) { r.projected = null; return; }
    var lk = _projClampAxis(p.likelihood), im = _projClampAxis(p.impact);
    if (lk === null || im === null) { r.projected = null; return; }
    r.projected = { likelihood: lk, impact: im, note: (typeof p.note === 'string') ? p.note : '' };
  }
  function _frNormalizeLinks(item) {
    if (!item || typeof item !== 'object') return;
    if (!Array.isArray(item.coveredRiskIds)) { item.coveredRiskIds = []; return; }
    item.coveredRiskIds = item.coveredRiskIds.filter(function(id, i, arr) {
      return typeof id === 'string' && id !== '' && arr.indexOf(id) === i;
    });
  }
  // === RA-ACTIFS (2026-09) === même coercition défensive que _frNormalizeLinks, sur le
  // périmètre d'actifs de l'analyse (tableau de strings, dédupliqué, jamais null).
  function _raNormalizeAssetLinks(a) {
    if (!a || typeof a !== 'object') return;
    if (!Array.isArray(a.criticalAssetIds)) { a.criticalAssetIds = []; return; }
    a.criticalAssetIds = a.criticalAssetIds.filter(function(id, i, arr) {
      return typeof id === 'string' && id !== '' && arr.indexOf(id) === i;
    });
  }
  function _raNormalizeProjectionFields(a) {
    if (!a) return;
    if (!a.finalRecs || typeof a.finalRecs !== 'object' || Array.isArray(a.finalRecs)) a.finalRecs = { items: [] };
    if (!Array.isArray(a.finalRecs.items)) a.finalRecs.items = [];
    // FIX-DEDUP-PROTO (audit progressif, 2026-08-26) : cartes indexées par des ids VENANT
    // DE LA DONNÉE. La portée est quasi nulle — tous les ids de l'application sortent de
    // _id(), seul un .analyse édité à la main peut porter « constructor » — et le résultat
    // (un lien pendant) est le comportement documenté et toléré juste au-dessus. Mais un id
    // légitime vu comme DOUBLON dès sa PREMIÈRE occurrence est une régénération gratuite,
    // qui orpheline les liens de recommandation. Fermeture triviale, même geste que le
    // moteur de projection.
    var seen = Object.create(null);
    a.finalRecs.items.forEach(function(it) {
      if (!it || typeof it !== 'object') return;
      if (!it.id || typeof it.id !== 'string' || seen[it.id]) it.id = _id();
      seen[it.id] = true;
      _frNormalizeLinks(it);
    });
    if (!Array.isArray(a.risks)) return;
    var seenR = Object.create(null); // FIX-DEDUP-PROTO : idem
    a.risks.forEach(function(r) {
      if (!r || typeof r !== 'object') return;
      if (!r.id || typeof r.id !== 'string' || seenR[r.id]) r.id = _id();
      seenR[r.id] = true;
      _riskNormalizeProjection(r);
    });
  }

  // FIX-REVUE RA-UX : une couleur de niveau peut provenir d'un .analyse tiers — seule une
  // couleur hex stricte atteint un attribut style (même doctrine que le garde de
  // levelColor du registre, plus stricte).
  // FIX-AUDIT-XSS-ID (audit v2, 2026-08-25) : a.id / t.id sont injectés dans des
  // onclick="raX('...')". escapeHtml produit &#39; que le parseur HTML re-décode AVANT de
  // compiler le handler : un id forgé s'évade donc du littéral JS. C'est mot pour mot le
  // BLOQUANT B1 de l'audit 2026-07-03 (analysisLink), corrigé côté Risques projets et jamais
  // porté ici. Whitelist stricte : tout id légitime de l'app y est conforme.
  var RA_ID_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
  function _raSafeId(v) { var x = String(v == null ? '' : v); return RA_ID_RE.test(x) ? x : ''; }

  // FIX-AUDIT-RTO-AFFICHAGE (audit v2) : 0 h est une exigence de continuité LÉGITIME
  // (et la plus stricte). Seuls null/undefined/NaN justifient le défaut de 24 h.
  function _hOr24(v) {
    // '' et false ne signifient PAS « 0 heure » mais « non renseigné » : Number('') === 0
    // les aurait affichés comme l'exigence la plus stricte (défaut trouvé par le fuzz v2).
    if (v === '' || v === null || v === undefined || typeof v === 'boolean') return 24;
    var n = Number(v);
    return isFinite(n) && n >= 0 ? n : 24;
  }

  function _safeCssColor(c) {
    return /^#[0-9a-fA-F]{3,8}$/.test(c || '') ? c : '';
  }
  // FIX-RA-PDF-WINANSI : les polices standard jsPDF sont en WinAnsi — les flèches et le
  // moins mathématique saisis par l'utilisateur (placeholders, copier-coller) sortiraient
  // en glyphes corrompus dans le rapport. Même correctif que la formule de scoring.
  function _pdfSafe(str) {
    return String(str == null ? '' : str)
      .replace(/\u2192/g, '->').replace(/\u2190/g, '<-').replace(/\u2212/g, '-')
      // FIX-AUDIT-PDF (2026-08-25) : U+2264/U+2265 ne sont PAS dans WinAnsi — un seul de ces
      // caracteres bascule TOUTE la ligne en UTF-16BE contre la police standard jsPDF
      // (annexe Methode corrompue dans les 3 locales).
      .replace(/\u2264/g, '<=').replace(/\u2265/g, '>=')
      // FIX-RA-PDF-BREAKS : caracteres invisibles de separation/controle (copier-coller
      // depuis le web ou un PDF). splitTextToSize ET autoTable ne coupent QUE sur '\n' et
      // ne mesurent pas ces caracteres -> le texte debordait hors du champ (bug remonte sur
      // les recommandations clefs). On ramene les separateurs a un vrai '\n' (wrappe), la
      // tabulation a une espace, et on supprime les controles a largeur nulle.
      .replace(/\r\n?/g, '\n')                                  // CRLF / CR isole -> LF
      .replace(/[\u2028\u2029\u000B\u000C]/g, '\n')            // separateurs ligne/paragraphe, VT, FF -> LF
      .replace(/\t/g, ' ')                                       // tabulation -> espace
      .replace(/[\u0000-\u0008\u000E-\u001F\u007F-\u009F\u00AD\u200B-\u200D\uFEFF]/g, ''); // C0/C1, trait d'union conditionnel, largeur nulle, BOM
  }
  // Variante « ligne unique » : pour les champs censes tenir sur une ligne (intitules de
  // recommandation) — apres _pdfSafe, aplatit tout blanc (y compris '\n') en une espace.
  function _pdfSafeLine(str) {
    return _pdfSafe(str).replace(/\s+/g, ' ').trim();
  }
  // FIX-REVUE RA-UX : suffixe « — réf. X » unique (bannière, légende, PDF) — une seule
  // concaténation à maintenir dans les trois langues.
  function _acceptanceRefSuffix(acc) {
    return (acc && acc.decisionRef) ? ' — ' + _t('riskAnalysis.thresholds.acceptanceRefShort', 'réf.') + ' ' + acc.decisionRef : '';
  }

  // === RA-UX (B1) === Échelle CIA ↔ matrice d'impacts de l'analyse.
  // L'étape « Classification CIA » évalue la valeur de l'actif sur les MÊMES niveaux que
  // la matrice d'impacts liée à l'analyse (une seule source de vérité pour les échelles,
  // valuation d'actif ISO 27005). Sans matrice résoluble (méthode pragmatique, mode local
  // vierge, file://), repli bit-à-bit sur l'échelle historique à 3 crans.
  function _ciaMatrix(a) {
    return (a && a.impactMatrixId && window.CSI_ImpactMatrices && window.CSI_ImpactMatrices.getMatrixById)
      ? window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId) : null;
  }
  function _ciaLegacyLevels() {
    return [
      { v: 1, label: _t('riskAnalysis.cia.level1', 'Faible'), band: 'low', desc: _t('riskAnalysis.cia.level1Desc', 'Impact limité.') },
      { v: 2, label: _t('riskAnalysis.cia.level2', 'Moyen'), band: 'medium', desc: _t('riskAnalysis.cia.level2Desc', 'Impact significatif.') },
      { v: 3, label: _t('riskAnalysis.cia.level3', 'Élevé'), band: 'high', desc: _t('riskAnalysis.cia.level3Desc', 'Impact critique.') }
    ];
  }
  function _ciaScale(a) {
    var m = _ciaMatrix(a);
    if (m && Array.isArray(m.levels) && m.levels.length >= 2) {
      return {
        max: m.levels.length,
        fromMatrix: true,
        unresolved: false,
        matrixName: m.name || '',
        levels: m.levels.map(function(lv, i) { return { v: i + 1, label: lv.label || ('' + (i + 1)), band: lv.band || '', desc: '' }; })
      };
    }
    // FIX-AUDIT-B1 : matrice RÉFÉRENCÉE mais irrésoluble ≠ pas de matrice. Le magasin peut
    // simplement ne pas être encore servi (sous-entité avant read-through du principal,
    // pull en cours au chargement). Retomber sur 3 crans ici ET remapper détruirait les
    // valeurs (5→3→5 : 2 et 4 ne survivent pas). On affiche une échelle neutre de la
    // taille MÉMORISÉE (scaleMax) et on interdit tout remap tant que ça ne se résout pas.
    if (a && a.impactMatrixId) {
      var n = (a.cia && a.cia.scaleMax) || 3;
      if (n !== 3) {
        var lvls = [];
        for (var i = 1; i <= n; i++) lvls.push({ v: i, label: '' + i, band: i === n ? 'high' : '', desc: '' });
        return { max: n, fromMatrix: false, unresolved: true, matrixName: '', levels: lvls };
      }
      return { max: 3, fromMatrix: false, unresolved: true, matrixName: '', levels: _ciaLegacyLevels() };
    }
    return { max: 3, fromMatrix: false, unresolved: false, matrixName: '', levels: _ciaLegacyLevels() };
  }
  // « Élevé » = segment CONTIGU de bandes high/very-high au SOMMET de l'échelle (décision
  // 20/08 : la bande devient fonctionnelle) ; repli = niveau maximum (= comportement
  // historique). FIX-AUDIT-B1 : une bande high isolée en MILIEU d'échelle (île accidentelle
  // — ex. niveaux ajoutés après coup avec bande medium par défaut) ne doit pas abaisser le
  // plancher : seul le bloc supérieur compte.
  function _ciaHighFloor(scale) {
    var lv = scale.levels;
    var topBand = lv[lv.length - 1] && lv[lv.length - 1].band;
    if (topBand !== 'high' && topBand !== 'very-high') return scale.max;
    var floor = lv[lv.length - 1].v;
    for (var i = lv.length - 2; i >= 0; i--) {
      var b = lv[i].band;
      if (b === 'high' || b === 'very-high') floor = lv[i].v; else break;
    }
    return floor;
  }
  function _ciaIsHigh(scale, v) { return (Number(v) || 0) >= _ciaHighFloor(scale); }
  // FIX-AUDIT-B1 : les VALEURS s'interprètent toujours sur LEUR échelle de saisie
  // (cia.scaleMax). Tant que la migration n'a pas eu lieu (lecture seule, matrice pas
  // encore servie), l'échelle cible peut différer — afficher ou scorer les anciennes
  // valeurs sur la nouvelle échelle mentirait (ex : 3/3 « Élevé » lu comme 3/5 « Modéré »).
  function _ciaScaleForValues(a) {
    var scale = _ciaScale(a);
    var m = (a && a.cia && a.cia.scaleMax) || 3;
    if (scale.max === m) return scale;
    if (m === 3) return { max: 3, fromMatrix: false, unresolved: scale.unresolved, pending: true, matrixName: '', levels: _ciaLegacyLevels() };
    var lvls = [];
    for (var i = 1; i <= m; i++) lvls.push({ v: i, label: '' + i, band: i === m ? 'high' : '', desc: '' });
    return { max: m, fromMatrix: false, unresolved: scale.unresolved, pending: true, matrixName: '', levels: lvls };
  }
  function _ciaAnyHigh(analysis) {
    var scale = _ciaScaleForValues(analysis);
    var cia = (analysis && analysis.cia) || {};
    return _ciaIsHigh(scale, cia.confidentiality) || _ciaIsHigh(scale, cia.integrity) || _ciaIsHigh(scale, cia.availability);
  }
  // Remap proportionnel (décision 20/08) — arrondi DEMI-VERS-LE-BAS : sans lui, le défaut
  // « Moyen » (2/3) atterrissait sur le niveau à bande high d'une matrice 4 niveaux et
  // déclenchait la majoration de scoring sur toute analyse fraîchement liée.
  function _ciaRemap(v, fromMax, toMax) {
    var n = Number(v) || 0;
    if (!n) return 1;
    if (fromMax === toMax || fromMax < 2 || toMax < 2) return _clamp(n, 1, toMax);
    return _clamp(Math.ceil(1 + (n - 1) * (toMax - 1) / (fromMax - 1) - 0.5), 1, toMax);
  }
  // Aligne les valeurs CIA d'une analyse sur son échelle courante. Mutation en RAM
  // (persistée au prochain saveData) — même patron que les backfills d'ensureAnalysisSchema.
  // FIX-AUDIT-B1 : le remap N'A LIEU QUE si l'échelle est réellement RÉSOLUE (matrice
  // servie, ou aucune matrice référencée). Une matrice référencée mais irrésoluble
  // (magasin pas encore peuplé) ne déclenche RIEN — c'est ce qui rend l'appel sûr depuis
  // les chemins de lecture : la seule mutation possible est la migration correcte.
  function _ciaEnsureScale(a) {
    if (!a) return false;
    if (!a.cia) a.cia = { justification: '' };
    // Backfill additif des valeurs manquantes (aperçu affichait 2/3 pendant que le
    // scoring lisait 1 — un .analyse sans bloc cia produisait cette contradiction).
    ['confidentiality', 'integrity', 'availability'].forEach(function(k) {
      if (!Number(a.cia[k])) a.cia[k] = 2;
    });
    var scale = _ciaScale(a);
    if (scale.unresolved) return false;
    var from = a.cia.scaleMax || 3;
    if (from === scale.max) {
      if (a.cia.scaleMax === undefined) a.cia.scaleMax = scale.max;
      return false;
    }
    // FIX-AUDIT-B1 : en consultation (globale ou droit de section 'read'), la migration
    // attend un contexte d'écriture — l'affichage et le scoring passent par
    // _ciaScaleForValues, qui interprète les valeurs sur leur échelle mémorisée.
    if (_ro()) return false;
    ['confidentiality', 'integrity', 'availability'].forEach(function(k) {
      a.cia[k] = _ciaRemap(a.cia[k] || 2, from, scale.max);
    });
    a.cia.scaleMax = scale.max;
    return true;
  }

  function createBlankAnalysis() {
    return {
      id: _id(),
      meta: { title: '', status: 'draft', createdAt: _now(), updatedAt: _now(), schemaVersion: 1 },
      impactMatrixId: null,
      // === RA-ACTIFS (2026-09) === périmètre déclaré : ids d'actifs critiques de l'inventaire
      // (grappe cœur, LECTURE seule ici — aucun recalcul, aucun impact sur le scoring ; ids
      // orphelins tolérés et filtrés au rendu, jamais nettoyés en cascade inter-grappes).
      criticalAssetIds: [],
      identity: { encoder: '', validator: '', controlBody: '', beneficiary: '', projectManager: '' },
      context: { perimeter: '', description: '', stakeholders: '', personalData: false, dataSensitivity: 'medium', sourceFramework: '', analysisMethod: '' },
      cia: { confidentiality: 2, integrity: 2, availability: 2, scaleMax: 3, justification: '' }, // === RA-UX (B1) === scaleMax = échelle de saisie
      gdpr: { personalData: false, sensitiveData: false, processingRegistry: 'no' },
      continuity: { rtoHours: 24, rpoHours: 24, comment: '' },
      architecture: { hosting: 'cloud', internetExposed: false, iam: { sso: false, mfa: false, adminSeparation: false }, architectureDoc: false },
      controls: {},
      risks: [],
      results: null,
      finalRecs: { items: [] },
      spot: {
        avisComment: '',
        residualCorrectedComment: '',
        openRiskAdjustment: 0,
        residualOverrideScore: null,
        residualOverrideJustification: '',
        avisOverrideLight: null,
        avisOverrideJustification: ''
      }
    };
  }

  function getAnalyses() { return (window.appData && window.appData.riskAnalyses) || []; }
  function ensureAnalysisSchema(a) {
    if (!a) return a;
    if (!a.identity) a.identity = { encoder: '', validator: '', controlBody: '', beneficiary: '', projectManager: '' };
    if (a.architecture && a.architecture.architectureDoc === undefined) a.architecture.architectureDoc = false;
    if (!a.gdpr) a.gdpr = { personalData: !!(a.context && a.context.personalData), sensitiveData: false, processingRegistry: 'no' };
    _ciaEnsureScale(a); // === RA-UX (B1) === réaligne les valeurs CIA sur l'échelle courante
    // L2 : migration douce du champ unique historique — les MÉTHODES déclarées
    // en libellé libre passent sur analysisMethod ; sourceFramework (catalogue)
    // garde sa valeur legacy telle quelle (affichée « cité dans le rapport »).
    if (a.context && a.context.analysisMethod === undefined) {
      var _sf = a.context.sourceFramework || '';
      a.context.analysisMethod = (_sf === 'EBIOS RM' || _sf === 'ISO/IEC 27005') ? 'ebios-iso27005'
        : (_sf === 'NIST SP 800-30') ? 'nist-800-30' : '';
    }
    if (a.risks) a.risks.forEach(function(r) {
      if (r.owner === undefined) r.owner = '';
      // === RA-UX (B4) === objectifs C/I/A menacés : backfill depuis le catalogue résolu.
      if (!Array.isArray(r.ciaTargets)) {
        var _c4 = _raResolveCat(r);
        r.ciaTargets = (_c4 && Array.isArray(_c4.ciaTargets)) ? _c4.ciaTargets.slice() : [];
      }
      // L1 : backfill du snapshot dénormalisé pour les analyses antérieures —
      // tant que le catalogue résout, on fige les libellés/domainHints ; ainsi
      // une future absence de pack ne produira ni « ? » ni perte d'abattement.
      if (!r._threat && (r.catalogId || r.packId)) {
        var cat = _raResolveCat(r);
        if (cat && !cat._fromSnapshot) Object.assign(r, _raSnapshotFields(cat));
      }
      // Compat descendante (audit 2026-08-04) : titre figé pour les clients
      // antérieurs qui ne résolvent pas les packs (sinon « ? » chez eux).
      if (r.packId && !r.customTitle && r._threat) r.customTitle = r._threat;
    });
    _raNormalizeProjectionFields(a); // === RA-PROJ === liens/cibles coercés + backfill ids
    _raNormalizeAssetLinks(a); // === RA-ACTIFS === périmètre d'actifs coercé
    return a;
  }
  function findAnalysis(id) { var a = getAnalyses().find(function(x) { return x.id === id; }); return ensureAnalysisSchema(a); }
  // L3bis : la modale « Promouvoir des risques » (bundle.js) lit les analyses SANS
  // passer par findAnalysis — sans normalisation, une analyse d'avant le chantier
  // jamais rouverte n'a pas de snapshot _threat et se promeut en « ? ».
  // Idempotente, aucune sauvegarde : le backfill part au prochain saveData().
  window.raEnsureAnalysisSchema = ensureAnalysisSchema;
  function _save() { if (typeof window.saveData === 'function') window.saveData(); }
  // FIX-RA-READONLY (audit 2026-07-17) : source unique de l'état lecture seule du module.
  // Avant, seul renderStepContent le calculait et le passait en argument aux étapes 0→5 ;
  // renderStepMesures (étape « Mesures & Revue ») ne le recevait pas → ses champs restaient
  // actifs hors mode édition. Une saisie y mutait appData puis partait en data-push sans
  // verrou → 423 « Verrou d'édition non détenu » en solo, travail perdu au re-pull.
  // Les rendus calculent désormais eux-mêmes (aucun appelant à ne pas oublier) et les
  // handlers d'écriture se gardent (défense en profondeur : couvre les contrôles qu'on ne
  // peut pas désactiver, ex. <div> cliquable, et tout futur champ oublié).
  // FIX-RA-SECTION-READ (audit droits 2026-08-17, constat 1) : pour un compte de
  // sous-entité au droit 'read' sur risk-analysis, l'UI restait pleinement éditable et le
  // 403 serveur DÉTRUISAIT la saisie (purge + re-pull). Le droit de section entre dans
  // _ro() : tout le module (rendus, gels, handlers, saveCurrentStep) suit d'un coup.
  function _sectionRO() {
    var EM = window.CSI_EntityManager;
    return !!(EM && EM.isMultiEntity && EM.isMultiEntity() && EM.isSubEntityUser && EM.isSubEntityUser()
      && EM.sectionRight && EM.sectionRight('risk-analysis') === 'read');
  }
  function _ro() { return (typeof window.isReadOnlyMode === 'function' && window.isReadOnlyMode()) || _sectionRO(); }
  function _roDis() { return _ro() ? ' disabled' : ''; }
  // === CSI-ME === FIX-IMPOSED-PUSH (retour client SPF 2026-08-17) : vrai si le périmètre
  // courant est une SOUS-ENTITÉ (compte dédié ou admin transverse basculé). Les échelles
  // (riskThresholds, impactMatrices) y sont imposées par l'entité principale (§8.1) : on ne
  // les seed qu'en RAM et on ne les persiste jamais depuis ce périmètre.
  function _scopedSub() {
    if (typeof window._csiScopedSubEntity === 'function') return window._csiScopedSubEntity();
    var EM = window.CSI_EntityManager;
    return !!(EM && EM.isMultiEntity && EM.isMultiEntity()
      && ((EM.isSubEntityUser && EM.isSubEntityUser()) || (EM.activeScope && EM.activeScope())));
  }
  // FIX-RA-CLICKABLE (retour client SPF 2026-08-17) : balisage `disabled` + verrou vivant des
  // étapes de l'assistant. À utiliser PARTOUT où l'on rendait `dis` — y compris sur les
  // re-rendus internes, qui passaient '' et perdaient donc `data-readonly-lock`.
  // FIX-AUDIT-RO (2026-08-25) : quand le blocage vient du DROIT DE SECTION ('read'), le gel
  // doit être PERMANENT — refreshReadOnlyLocks (bundle.js) ré-active tout [data-readonly-lock]
  // non marqué data-permanent-readonly dès que le mode édition s'active : l'UI redevenait
  // saisissable alors que le serveur refuse (403 SECTION_READONLY) et la saisie était perdue.
  function _permDis() { return _sectionRO() ? ' data-permanent-readonly="true"' : ''; }
  function _stepDis() { return _roDis() + ' data-readonly-lock="true"' + _permDis(); }
  // Gel des contrôles NON désactivables (<div> cliquable : tuiles CIA, cartes du catalogue de
  // risques). Se calcule TOUJOURS en direct : la chaîne `dis` n'est plus un booléen depuis
  // qu'elle porte data-readonly-lock (commit b750808, 2026-08-05) et la tester rendait ces
  // contrôles inertes AUSSI en mode édition.
  // FIX-AUDIT-A11Y-KB (audit 2026-08-25) : les contrôles <div onclick> (tuiles CIA, cartes de
  // risque) n'étaient NI focusables NI activables au clavier — 0 occurrence de tabindex dans
  // tout le fichier avant ce correctif. Helper global : le markup reste sans guillemets.
  window.raKbActivate = function(ev) {
    if (!ev || (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar')) return;
    ev.preventDefault();
    if (ev.currentTarget && typeof ev.currentTarget.click === 'function') ev.currentTarget.click();
  };
  var _KB_ACT = ' role="button" tabindex="0" onkeydown="raKbActivate(event)"';

  function _roFreeze() { return _ro() ? ' style="pointer-events:none;opacity:0.6"' : ''; }

  // FIX-RA-MATRIX-SEED (retour client SPF 2026-08-17) : le magasin de matrices d'impact
  // n'était amorcé qu'au RENDU du module « Matrices d'impact » (impact-matrices.js,
  // ensureInitialized). Tant qu'on n'y était jamais allé, appData.impactMatrices valait null :
  //   - le sélecteur « Matrice d'impact » de l'étape Contexte n'apparaissait pas (liste vide) ;
  //   - le choix de méthode posait quand même impactMatrixId = 'imx-ebios-rm-v1' (mapping
  //     ci-dessous), id que personne ne pouvait plus corriger ni résoudre ;
  //   - « Évaluer via matrice » répondait donc « Matrice introuvable ».
  // On amorce ici en RAM UNIQUEMENT (normalize() ne persiste pas) : l'affichage est correct et
  // l'écriture suivra la première modification légitime — pas de save depuis un chemin de
  // lecture (doctrine audit co-édition 2026-08-05).
  function _ensureMatrixStore() {
    if (!window.CSI_ImpactMatrices || !window.appData) return;
    // FIX-IMPOSED-PUSH : en sous-entité, ne JAMAIS seeder — l'affichage reflète le
    // read-through du principal (servi par le pull complet). Seeder ici créerait un
    // catalogue local fantôme divergent de celui du groupe.
    if (_scopedSub()) return;
    if (!window.appData.impactMatrices && typeof window.CSI_ImpactMatrices.normalize === 'function') {
      window.appData.impactMatrices = window.CSI_ImpactMatrices.normalize(null);
    }
  }

  // ============================================================
  // SCORING ENGINE
  // ============================================================
  function computeDomainMaturity(controls, domainId) {
    var questions = RA_CONTROLS_SEED.filter(q => q.domainId === domainId);
    var total = 0, max = 0, plannedCount = 0;
    questions.forEach(function(q) {
      var c = controls[q.id];
      var status = c ? (c.status || 'no') : 'no';
      if (status === 'na') return;
      max += 3;
      if (status === 'implemented') total += 3;
      else if (status === 'partial') total += 2;
      else if (status === 'planned') { total += 2; plannedCount++; }
      else if (status === 'toVerify') total += 1;
    });
    // FIX-RA-NA-GREEN (audit 2026-08-04, P0) : un domaine dont TOUTES les questions sont
    // « N/A » renvoyait { maturity: 3, percent: 100, light: 'green' } — un domaine hors
    // périmètre s'affichait donc comme parfaitement maîtrisé (radar, posture, PDF).
    // Désormais : hors périmètre = null/gris, et chaque consommateur l'exclut de ses
    // moyennes (posture, abattement du résiduel) via _raDomainNA.
    if (max === 0) return { maturity: null, percent: null, light: 'grey', plannedCount: 0, notApplicable: true };
    var maturity = Math.round((total / max) * 3);
    var percent = Math.round((total / max) * 100);
    var light = (maturity >= 2 && percent >= 70) ? 'green' : (maturity < 1 || percent < 40) ? 'red' : 'yellow';
    return { maturity: maturity, percent: percent, light: light, plannedCount: plannedCount };
  }

  function _raDomainNA(ds) { return !!(ds && ds.maturity === null); }

  function computeAllDomainScores(analysis) {
    var scores = {};
    RA_DOMAINS.forEach(function(d) {
      scores[d.id] = computeDomainMaturity(analysis.controls || {}, d.id);
    });
    return scores;
  }

  // FIX-AUDIT-AXIS-SCORING (fuzzing 2026-08-25) : les axes n'étaient bornés NI ici NI en
  // amont — un .analyse forgé (99, -1, NaN, Infinity, '3') propageait un inhérent hors
  // [1,25] jusqu'aux scores AFFICHÉS et au PDF (_clamp laisse passer NaN). Le bornage du
  // lot #40 ne couvrait que la projection : voici le point de passage du SCORING.
  function computeInherentScore(likelihood, impact) {
    return _clamp(parseInt(likelihood, 10) || 3, 1, 5) * _clamp(parseInt(impact, 10) || 3, 1, 5);
  }

  function computeResidualScore(inherent, controlsMaturity, analysis, adversarial) {
    var reduction = 1 - (controlsMaturity / 3) * 0.40;
    var base = Math.round(inherent * reduction);
    // L2 : la majoration « exposition Internet » n'a pas de sens pour un événement
    // NON ADVERSE (incendie, panne, erreur) — un sinistre physique ne devient pas
    // plus probable parce que l'app est exposée. adversarial:false la neutralise ;
    // les majorations CIA et RTO/RPO (conséquence) restent appliquées.
    if (adversarial !== false && analysis.architecture && analysis.architecture.internetExposed) base += 1;
    if (_ciaAnyHigh(analysis)) base += 1; // === RA-UX (B1) === « élevé » = bande high de l'échelle
    var cont = analysis.continuity || {};
    if (cont.rtoHours <= 8 || cont.rpoHours <= 6) base += 1;
    return _clamp(base, 1, 25);
  }

  function riskTrafficLight(score) {
    return score <= 8 ? 'green' : score <= 15 ? 'yellow' : 'red';
  }

  /** Resolve the 5-level risk level for a given likelihood + impact pair */
  function resolveRiskLevel(likelihood, impact, threshold) {
    // FIX-AUDIT-SEUIL (audit 2026-08-25) : sans threshold, getRiskLevelForCell retombait sur
    // le seuil ACTIF DU POSTE — sur une analyse importée liée à son seuil embarqué, la colonne
    // « Niveau » affichait « Élevé » pendant que la grille du MÊME écran disait « Très élevé »
    // (mesuré). Même famille que FIX-RA-THRESHOLD-PDF, non propagée ici à l'époque.
    var lvl = getRiskLevelForCell(likelihood || 3, impact || 3, threshold);
    var tl = _trafficForLevelId(lvl.id);
    return { id: lvl.id, label: _lvlLabel(lvl), labelFr: lvl.labelFr || lvl.label, labelEn: lvl.labelEn || lvl.label, color: lvl.color, trafficLight: tl, light: tl };
  }

  function computeGlobalTrafficLight(riskScores) {
    if (riskScores.some(function(r) { return r.light === 'red'; })) return 'red';
    if (riskScores.some(function(r) { return r.light === 'yellow'; })) return 'yellow';
    return 'green';
  }

  function computeGlobalScore(globalLight, analysis, domainScores) {
    var base = globalLight === 'green' ? 2 : globalLight === 'yellow' ? 3 : 4;
    if (_ciaAnyHigh(analysis) && analysis.architecture && analysis.architecture.internetExposed) base += 1; // === RA-UX (B1) ===
    var cont = analysis.continuity || {};
    if ((cont.rtoHours <= 8 || cont.rpoHours <= 6) && domainScores.BCP && domainScores.BCP.light === 'red') base += 1;
    return _clamp(base, 1, 5);
  }

  function computeAllResults(analysis) {
    var domainScores = computeAllDomainScores(analysis);
    // Aggregate planned controls info
    var totalPlannedControls = 0;
    var plannedControlsList = [];
    RA_DOMAINS.forEach(function(d) {
      var ds = domainScores[d.id];
      if (ds && ds.plannedCount > 0) {
        totalPlannedControls += ds.plannedCount;
        RA_CONTROLS_SEED.filter(function(q) { return q.domainId === d.id; }).forEach(function(q) {
          var c = (analysis.controls || {})[q.id];
          if (c && c.status === 'planned') {
            plannedControlsList.push({ questionId: q.id, domainId: d.id, text: _bi(q, 'text', 'textEn'), comment: c.comment || '' });
          }
        });
      }
    });
    // Build comprehensive controls list (all controls by domain) for preview/PDF
    var allControlsList = [];
    RA_DOMAINS.forEach(function(d) {
      var domainCtrls = [];
      RA_CONTROLS_SEED.filter(function(q) { return q.domainId === d.id; }).forEach(function(q) {
        var c = (analysis.controls || {})[q.id];
        domainCtrls.push({
          questionId: q.id,
          domainId: d.id,
          cia: (q.cia || []).slice(), // === RA-UX (B4) ===
          text: _bi(q, 'text', 'textEn'),
          hint: _bi(q, 'hint', 'hintEn'),
          status: c ? (c.status || 'no') : 'no',
          comment: c ? (c.comment || '') : ''
        });
      });
      allControlsList.push({ domainId: d.id, label: _bi(d, 'label', 'labelEn'), icon: d.icon, controls: domainCtrls });
    });
    var riskScores = [];
    var _thAct = getThresholdForAnalysis(analysis); // === RA-UX (B2) === une résolution par calcul
    var _floorIdxAct = getAcceptanceFloorIndex(_thAct);
    (analysis.risks || []).forEach(function(r) {
      // L2 — non-applicable justifié (exigence ISO 27001 6.1.3.d) : un risque
      // marqué 'na' reste au registre (trace + justification) mais ne compte
      // ni dans les scores ni dans le feu global.
      if (r.treatmentDecision === 'na') return;
      var inh = computeInherentScore(r.likelihood || 3, r.impact || 3);
      var avgMat = 0, cnt = 0;
      var cat = _raResolveCat(r);
      if (cat && cat.domainHints) {
        cat.domainHints.forEach(function(d) { if (domainScores[d] && !_raDomainNA(domainScores[d])) { avgMat += domainScores[d].maturity; cnt++; } });
      }
      if (cnt > 0) avgMat = avgMat / cnt;
      var res = computeResidualScore(inh, avgMat, analysis, !(cat && cat.adversarial === false));
      var lvl = resolveRiskLevel(r.likelihood || 3, r.impact || 3, _thAct); // FIX-AUDIT-SEUIL : seuil de l'analyse
      // === RA-UX (B2) === niveau du résiduel + position vs critères d'acceptation
      // (résolu UNE fois, réutilisé pour le drapeau — FIX-REVUE RA-UX).
      var _resLvl = residualLevelForRisk(res, r.impact || 3, _thAct, r.likelihood || 3);
      var _beyondAcc = _isLevelBeyondFloor(_thAct, _resLvl && _resLvl.id, _floorIdxAct);
      riskScores.push({ id: r.id, inherent: inh, residual: res, light: lvl.light, levelId: lvl.id, levelLabel: lvl.label, levelColor: lvl.color, trafficLight: lvl.trafficLight,
        residualLevelId: _resLvl ? _resLvl.id : null, residualLevelLabel: _resLvl ? _lvlLabel(_resLvl) : '', beyondAcceptance: _beyondAcc });
    });
    // FIX-AUDIT-NORISK (audit v2, 2026-08-25) : « aucun risque coté » n'est pas « aucun
    // risque ». Une analyse vierge — ou dont 100 % des risques sont « non applicable » —
    // sortait VERTE, « Avis favorable », finalisable, et créait un miroir projet au vert,
    // trois lignes au-dessus d'un « mesures : 0 % ». Même famille que le P0 FIX-RA-NA-GREEN,
    // jamais propagée au niveau ANALYSE. L'absence d'évaluation est une RÉSERVE, pas un avis.
    // CORRECTION DE JUSTIFICATION (audit progressif, 2026-08-26) : le commentaire d'origine
    // affirmait qu'« aucun 'grey' n'existe ni en CSS ni dans computeGlobalScore ». C'est
    // FAUX pour la moitié CSS — `.ra-light--grey` existe (style.css) et le module s'en sert
    // lui-même pour une analyse sans résultats, et `grey` est un état de premier ordre au
    // niveau DOMAINE et RISQUE, jusqu'à une couleur PDF dédiée.
    // La raison RÉELLE de rester en tri-état est l'autre moitié, elle exacte : la chaîne de
    // scoring ne connaît que green/yellow/red — computeGlobalTrafficLight ne renvoie rien
    // d'autre, et computeGlobalScore ferait tomber un 'grey' dans sa branche « pire » (4/5)
    // sans le dire. Introduire 'grey' au niveau ANALYSE demanderait donc de traiter cette
    // chaîne, pas seulement d'ajouter une classe : c'est un chantier, pas un correctif.
    var _noRiskAssessed = riskScores.length === 0;
    var globalLight = _noRiskAssessed ? 'yellow' : computeGlobalTrafficLight(riskScores);
    var globalScore = computeGlobalScore(globalLight, analysis, domainScores);
    var recommendations = generateRecommendations(analysis, domainScores);
    var spotSummary = computeSpotSummary(analysis, { domainScores: domainScores, riskScores: riskScores, noRiskAssessed: _noRiskAssessed }); // FIX-AUDIT-NORISK : le drapeau doit atteindre l'avis
    // Preserve auto values, then apply manual override if present
    var autoGlobalLight = globalLight;
    var autoGlobalScore = globalScore;
    if (spotSummary.avisOverridden) {
      globalLight = spotSummary.light;
      globalScore = computeGlobalScore(spotSummary.light, analysis, domainScores);
    }
    // === RA-UX (B2) === synthèse des critères d'acceptation pour l'UI et le PDF.
    var _accAct = _thAcceptance(_thAct);
    var _floorLvl = _floorIdxAct >= 0 ? _thAct.levels[_floorIdxAct] : null;
    var acceptanceSummary = {
      floorLevelId: _floorLvl ? _floorLvl.id : null,
      floorLabel: _floorLvl ? _lvlLabel(_floorLvl) : '',
      decisionRef: _accAct.decisionRef || '',
      beyondCount: riskScores.filter(function(rs) { return rs.beyondAcceptance; }).length
    };
    return {
      domainScores: domainScores,
      riskScores: riskScores,
      acceptance: acceptanceSummary,
      globalLight: globalLight,
      globalScore: globalScore,
      autoGlobalLight: autoGlobalLight,
      autoGlobalScore: autoGlobalScore,
      recommendations: recommendations,
      spotSummary: spotSummary,
      totalPlannedControls: totalPlannedControls,
      plannedControlsList: plannedControlsList,
      allControlsList: allControlsList,
      noRiskAssessed: _noRiskAssessed, // FIX-AUDIT-NORISK : consommé par l'avis, le miroir et le PDF
      computedAt: _now()
    };
  }

  function computeSpotSummary(analysis, results) {
    var cia = analysis.cia || {};
    var ctx = analysis.context || {};
    var arch = analysis.architecture || {};
    var spot = analysis.spot || {};
    var finalRecs = analysis.finalRecs || { items: [] };

    // 1. Inherent criticality (1..5)
    // === RA-UX (B1) === proportionnel à l'échelle courante — identique à l'historique
    // sur 3 crans (1→2, 2→3, 3→4).
    var _ciaScl = _ciaScaleForValues(analysis);
    var maxCIA = _clamp(Math.max(cia.confidentiality || 1, cia.integrity || 1, cia.availability || 1), 1, _ciaScl.max);
    var base = 2 + Math.round(((maxCIA - 1) / Math.max(1, _ciaScl.max - 1)) * 2);
    if (arch.internetExposed) base += 1;
    if (ctx.dataSensitivity === 'high' || ctx.personalData === true) base += 1;
    var inherentCriticalityScore = _clamp(base, 1, 5);

    // 2. Measures implementation percent
    var totalP = 0, domC = 0;
    RA_DOMAINS.forEach(function(d) {
      if (results.domainScores && results.domainScores[d.id] && !_raDomainNA(results.domainScores[d.id])) { totalP += results.domainScores[d.id].percent; domC++; }
    });
    var measuresImplementationPercent = domC > 0 ? Math.round(totalP / domC) : 0;
    var measuresNotApplicable = (domC === 0); // aucun domaine évaluable : hors périmètre, pas « 0 % »

    // 3. Residual score (1..5) from worst risk score (1..25)
    var worstResidual = 1;
    if (results.riskScores && results.riskScores.length > 0) {
      results.riskScores.forEach(function(rs) { if (rs.residual > worstResidual) worstResidual = rs.residual; });
    }
    var residualScore;
    if (worstResidual <= 5) residualScore = 1;
    else if (worstResidual <= 8) residualScore = 2;
    else if (worstResidual <= 12) residualScore = 3;
    else if (worstResidual <= 15) residualScore = 4;
    else residualScore = 5;
    // Override
    if (spot.residualOverrideScore != null && spot.residualOverrideScore >= 1 && spot.residualOverrideScore <= 5) {
      residualScore = spot.residualOverrideScore;
    }

    // 4. Residual + open risk
    var openAdj = _clamp(spot.openRiskAdjustment || 0, 0, 2);
    var residualPlusOpenScore = _clamp(residualScore + openAdj, 1, 5);

    // 5. Traffic light + avis (automatic)
    var validItems = finalRecs.items.filter(_frHasContent) // FIX-REVUE RA-UX : même prédicat que l'auto-nettoyage (A4);
    var anyTodo = validItems.some(function(it) { return it.status !== 'done'; });
    var autoLight, autoAvisLabel;
    if (residualPlusOpenScore >= 5) {
      autoLight = 'red'; autoAvisLabel = _t('riskAnalysis.spot.avisLabels.red', 'Avis défavorable');
    } else if (results && results.noRiskAssessed) {
      // FIX-AUDIT-NORISK : aucun risque coté — l'avis ne peut pas être « favorable ».
      // FIX-NORISK-ORDRE (audit progressif, 2026-08-26) : cette branche venait APRÈS
      // « recommandations pendantes », donc une analyse SANS aucun risque évalué mais
      // portant une reco `todo` annonçait « Avis favorable moyennant… » — reproduit sur
      // une analyse vierge comme sur une analyse 100 % « non applicable ». L'absence
      // d'évaluation prime : elle est une réserve, pas un avis.
      autoLight = 'yellow'; autoAvisLabel = _t('riskAnalysis.spot.avisLabels.noRisk', 'Avis en attente — aucun risque évalué');
    } else if (validItems.length > 0 && anyTodo) {
      autoLight = 'yellow'; autoAvisLabel = _t('riskAnalysis.spot.avisLabels.yellow', 'Avis favorable moyennant la mise en œuvre des recommandations clefs');
    } else if (residualPlusOpenScore >= 4) {
      // FIX-AUDIT-AVIS (audit 2026-08-25) : il n'existait AUCUN état entre « recommandations
      // pendantes » (jaune) et residualPlusOpenScore >= 5 (rouge) — mesuré : L5xI5 avec TOUS
      // les contrôles implémentés donne un résiduel 4/5 et sortait « Avis favorable » (vert)
      // en ligne 1 de la synthèse management, trois lignes au-dessus d'un « 4/5 » en rouge.
      autoLight = 'yellow'; autoAvisLabel = _t('riskAnalysis.spot.avisLabels.yellowResidual', 'Avis réservé — risque résiduel élevé');
    } else {
      autoLight = 'green'; autoAvisLabel = _t('riskAnalysis.spot.avisLabels.green', 'Avis favorable');
    }

    // 6. Manual override of avis
    var light = autoLight, avisLabel = autoAvisLabel;
    var avisOverride = spot.avisOverrideLight;
    if (avisOverride === 'green' || avisOverride === 'yellow' || avisOverride === 'red') {
      light = avisOverride;
      avisLabel = _t('riskAnalysis.spot.avisLabels.' + avisOverride, autoAvisLabel);
    }

    return {
      inherentCriticalityScore: inherentCriticalityScore,
      measuresImplementationPercent: measuresImplementationPercent,
      measuresNotApplicable: measuresNotApplicable,
      residualScore: residualScore,
      residualPlusOpenScore: residualPlusOpenScore,
      autoLight: autoLight,
      autoAvisLabel: autoAvisLabel,
      light: light,
      avisLabel: avisLabel,
      avisOverridden: light !== autoLight
    };
  }

  // ============================================================
  // RECOMMENDATION ENGINE
  // ============================================================
  function resolvePath(analysis, path, indexes) {
    if (path.startsWith('controls.')) {
      var parts = path.split('.');
      var c = analysis.controls || {};
      var ctrl = c[parts[2]];
      return ctrl ? (ctrl.status || 'no') : 'no';
    }
    if (path.startsWith('domainTrafficLight.')) {
      var dom = path.split('.')[1];
      return indexes.domainLights[dom] || 'grey';
    }
    var segs = path.split('.'), val = analysis;
    for (var i = 0; i < segs.length; i++) {
      if (val == null) return undefined;
      val = val[segs[i]];
    }
    return val;
  }

  function evaluateCondition(analysis, cond, indexes) {
    var actual = resolvePath(analysis, cond.path, indexes);
    switch (cond.op) {
      case 'eq': return actual === cond.value;
      case 'in':
        if (Array.isArray(cond.value) && cond.value.indexOf(actual) >= 0) return true;
        // 'planned' controls match rules that check for 'partial' (attenuated recommendations)
        if (actual === 'planned' && Array.isArray(cond.value) && cond.value.indexOf('partial') >= 0) return true;
        if (actual === 'toVerify' && Array.isArray(cond.value) && cond.value.indexOf('no') >= 0) return true;
        return false;
      case 'lte': return typeof actual === 'number' && actual <= cond.value;
      case 'gte': return typeof actual === 'number' && actual >= cond.value;
      default: return false;
    }
  }

  function evaluateRule(analysis, rule, indexes) {
    var when = rule.when;
    if (when.all) return when.all.every(function(c) { return evaluateCondition(analysis, c, indexes); });
    if (when.any) return when.any.some(function(c) { return evaluateCondition(analysis, c, indexes); });
    return false;
  }

  function generateRecommendations(analysis, domainScores) {
    var indexes = { domainLights: {} };
    RA_DOMAINS.forEach(function(d) { indexes.domainLights[d.id] = domainScores[d.id] ? domainScores[d.id].light : 'grey'; });
    var candidates = [];
    RA_RECOMMENDATIONS_RULES.forEach(function(rule) {
      if (evaluateRule(analysis, rule, indexes)) {
        var t = rule.then;
        var score = t.priority === 'P1' ? 300 : t.priority === 'P2' ? 200 : 100;
        // Bumps
        var linkedRisks = (analysis.risks || []).filter(function(r) {
          if (r.treatmentDecision === 'na') return false; // FIX-RA-NA-MAPS : un risque déclaré non applicable ne fait plus monter les recommandations
          var cat = _raResolveCat(r);
          return cat && cat.domainHints && cat.domainHints.indexOf(t.domainId) >= 0;
        });
        // FIX-AUDIT-RECO (audit 2026-08-25) : bump PLAFONNÉ à +50. Sans plafond, le domaine
        // LOG — présent dans les domainHints de 8 des 10 risques builtin — gagnait jusqu'à
        // +200, soit le double de l'écart P1→P2 (100) : des P2 « rétention des logs » et
        // « surveillance admin » évinçaient des P1 (secrets, scans de vulnérabilités,
        // patching SLA) hors du top 12 du rapport. Mesuré : top 12 passe de 10 P1 + 2 P2
        // à 12 P1. Le cumul max des bumps (50+20+10=80) ne peut plus combler l'écart P1→P2.
        score += Math.min(linkedRisks.length, 2) * 25;
        if (analysis.architecture && analysis.architecture.internetExposed && ['NETCRYPTO','VULN','LOG'].indexOf(t.domainId) >= 0) score += 20;
        var cont = analysis.continuity || {};
        if ((cont.rtoHours <= 8 || cont.rpoHours <= 6) && t.domainId === 'BCP') score += 20;
        if (_ciaAnyHigh(analysis) && ['IAM','DATA','NETCRYPTO','LOG'].indexOf(t.domainId) >= 0) score += 10; // === RA-UX (B1) ===
        // Check if recommendation triggered by a 'planned' control — attenuate score
        var isPlanned = false;
        var conds = rule.when.all || rule.when.any || [];
        conds.forEach(function(cond) {
          if (cond.path && cond.path.startsWith('controls.')) {
            var actual = resolvePath(analysis, cond.path, indexes);
            if (actual === 'planned') isPlanned = true;
          }
        });
        if (isPlanned) score -= 75;
        candidates.push({
          id: rule.id, semanticKey: rule.semanticKey,
          title: _bi(t, 'title', 'titleEn'),
          domainId: t.domainId, priority: t.priority, effort: t.effort,
          rationale: _bi(t, 'rationale', 'rationaleEn'),
          actions: (_lang() === 'en' && t.actionsEn) ? t.actionsEn : t.actions,
          doneWhen: _bi(t, 'doneWhen', 'doneWhenEn'),
          score: score, isPlanned: isPlanned
        });
      }
    });
    // Dedup by semanticKey
    var seen = Object.create(null); // FIX-DEDUP-PROTO : alignement (clés de catalogue, même nature)
    var deduped = [];
    candidates.sort(function(a, b) { return SortUtils.numeric(b.score, a.score); });
    candidates.forEach(function(c) {
      if (!seen[c.semanticKey]) { seen[c.semanticKey] = true; deduped.push(c); }
    });
    return deduped.slice(0, 12);
  }

  // ============================================================
  // WIZARD STATE
  // ============================================================
  var wizardState = { analysisId: null, step: 0, resultTab: 'synthese' };
  // === RA-UX (B3) === ordre méthodologique : identification des risques AVANT l'évaluation
  // de la posture des contrôles (qui alimente le résiduel). Ordre purement d'affichage —
  // wizardState n'est pas persisté, aucune donnée ne migre.
  var STEP_KEYS = ['context','cia','continuity','architecture','risks','controls','mesures','results','preview','exportPdf'];

  // ============================================================
  // ENTRY POINT
  // ============================================================
  window.renderRiskAnalysisModule = function() {
    var container = document.getElementById('riskAnalysisContent');
    if (!container) return;
    var cons = window.CSI_EntityConsolidation;
    if (cons && cons.getSectionMode && cons.getSectionMode('risk-analysis') === 'consolidated') {
      wizardState.analysisId = null;
      wizardState.step = 0;
    }
    if (window._csiRenderConsolidatedSectionIfNeeded &&
        window._csiRenderConsolidatedSectionIfNeeded('risk-analysis', { contentId: 'riskAnalysisContent' })) {
      SectionToolbar.destroy('risk-analysis');
      var btnConsolidated = document.getElementById('addRiskAnalysisBtn');
      if (btnConsolidated) btnConsolidated.onclick = null;
      return;
    }
    // FIX-RA-SECTION-READ : les deux boutons d'en-tête vivent dans le HTML statique —
    // en droit 'read' ils restent visibles mais définitivement inertes (le mécanisme
    // data-permanent-readonly est honoré par refreshReadOnlyLocks même en mode édition).
    ['addRiskAnalysisBtn', 'importAnalyseBtn'].forEach(function(bid) {
      var b = document.getElementById(bid);
      if (b && _sectionRO()) { b.disabled = true; b.dataset.permanentReadonly = 'true'; }
    });
    if (wizardState.analysisId) {
      SectionToolbar.destroy('risk-analysis');
      renderWizard(container);
      return;
    }
    renderList(container);
    // Initialise SectionToolbar only if it does not already exist in the DOM
    if (!document.getElementById('toolbar-risk-analysis')) {
      SectionToolbar.init('risk-analysis', {
        search: true,
        filters: [
          {
            key: 'status',
            label: _t('riskAnalysis.filters.status', 'Statut'),
            defaultValue: raListFilters.status !== 'all' ? raListFilters.status : '',
            options: [
              { value: 'draft', label: _t('riskAnalysis.filters.statusDraft', 'Brouillon') },
              { value: 'complete', label: _t('riskAnalysis.filters.statusComplete', 'Terminée') }
            ]
          }
        ],
        sort: {
          options: [
            { key: 'date', label: _t('riskAnalysis.filters.sortDateDesc', 'Date'), default: true, dir: 'desc' },
            { key: 'title', label: _t('riskAnalysis.filters.sortTitle', 'Titre') },
            { key: 'score', label: _t('riskAnalysis.filters.sortScore', 'Score'), dir: 'desc' }
          ]
        },
        onUpdate: window.raFilterList
      });
    }
    var btn = document.getElementById('addRiskAnalysisBtn');
    if (btn) btn.onclick = function() {
      if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode(_t('editMode.createAnalysis', 'create an analysis'))) return;
      createNewAnalysis();
    };
  };

  window.cleanupRiskAnalysisModule = function() {
    SectionToolbar.destroy('risk-analysis');
    Object.keys(_raChartInstances).forEach(function(key) {
      if (_raChartInstances[key]) {
        try { _raChartInstances[key].destroy(); } catch(e) {}
        _raChartInstances[key] = null;
      }
    });
    if (_raObserver) { _raObserver.disconnect(); _raObserver = null; }
  };

  // ============================================================
  // LIST VIEW
  // ============================================================
  var thresholdPanelOpen = false;
  var _lastRenderScope = null; // périmètre du dernier rendu de liste (repli du panneau seuils à la bascule)
  var thresholdEditId = null;

  var raListFilters = { status: 'all' };

  // Lot 1b : petit menu « ⋯ » par ligne (dupliquer / PDF / export / sync).
  // Fermeture au clic extérieur et à Escape ; un seul menu ouvert à la fois.
  window.raCloseMoreMenus = function() {
    var menus = document.querySelectorAll('.ra-more-menu');
    for (var i = 0; i < menus.length; i++) {
      menus[i].hidden = true;
      var btn = menus[i].parentNode ? menus[i].parentNode.querySelector('[aria-haspopup]') : null;
      if (btn) btn.setAttribute('aria-expanded', 'false');
    }
  };
  window.raToggleMoreMenu = function(btn) {
    var menu = btn.parentNode ? btn.parentNode.querySelector('.ra-more-menu') : null;
    if (!menu) return;
    var willOpen = menu.hidden;
    window.raCloseMoreMenus();
    menu.hidden = !willOpen;
    btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
    if (typeof window.refreshReadOnlyLocks === 'function') window.refreshReadOnlyLocks();
  };
  document.addEventListener('click', function(e) {
    if (!e.target || !e.target.closest || !e.target.closest('.ra-more-wrap')) window.raCloseMoreMenus();
  });
  document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape' || e.key === 'Esc') window.raCloseMoreMenus();
  });

  window.raFilterList = function() {
    var filters = SectionToolbar.getFilters('risk-analysis');
    if (filters && filters.status !== undefined) raListFilters.status = filters.status || 'all';
    window.renderRiskAnalysisModule();
  };

  function renderList(container) {
    var analyses = getAnalyses();
    // (retour client 2026-08-17) l'état « panneau seuils ouvert » survivait à la bascule de
    // périmètre : ouvert au principal, il s'affichait grand ouvert (en lecture seule) en
    // arrivant dans une sous-entité, au-dessus de la liste. On le replie à chaque
    // changement de périmètre — l'utilisateur le rouvre s'il veut consulter la grille.
    var _scopeKey = (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity
      && window.CSI_EntityManager.isMultiEntity())
      ? (window.CSI_EntityManager.activeScope() || 'group') : 'local';
    if (_lastRenderScope !== null && _lastRenderScope !== _scopeKey) thresholdPanelOpen = false;
    _lastRenderScope = _scopeKey;
    var html = '';

    // Threshold toggle button
    html += '<div class="ra-list-toolbar">';
    html += '<button class="btn btn--sm' + (thresholdPanelOpen ? ' btn--active' : '') + '" onclick="raToggleThresholdPanel()"><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.thresholds.title','Seuils de risque')) + '</button>';
    html += '</div>';

    // Threshold management panel (collapsible)
    if (thresholdPanelOpen) {
      html += renderThresholdPanel();
    }

    // SectionToolbar placeholder — actual toolbar is inserted by SectionToolbar.init
    // into the #risk-analysis module div (between .module__header and content)

    if (!analyses.length) {
      html += '<div class="ra-empty-state"><i class="fas fa-magnifying-glass-chart"></i><p>' + _esc(_t('riskAnalysis.noAnalyses', 'Aucune analyse de risques.')) + '</p><p>' + _esc(_t('riskAnalysis.noAnalysesHint', 'Créez votre première analyse.')) + '</p></div>';
      container.innerHTML = html;
      if (typeof window.refreshReadOnlyLocks === 'function') window.refreshReadOnlyLocks();
      return;
    }

    // Apply filters
    var searchQuery = SectionToolbar.getSearch('risk-analysis').toLowerCase();
    var filtered = analyses.filter(function(a) {
      if (searchQuery) {
        var hay = [(a.meta && a.meta.title) || '', (a.identity && a.identity.beneficiary) || '', (a.meta && a.meta.reportNumber) || ''].join(' ').toLowerCase();
        if (hay.indexOf(searchQuery) < 0) return false;
      }
      if (raListFilters.status !== 'all') {
        var st = (a.meta && a.meta.status === 'complete') ? 'complete' : 'draft';
        if (st !== raListFilters.status) return false;
      }
      return true;
    });

    // Apply sort
    var sortState = SectionToolbar.getSort('risk-analysis');
    var sortKey = sortState ? sortState.key : 'date';
    var sortDir = sortState ? sortState.dir : 'desc';
    filtered.sort(function(a, b) {
      switch (sortKey) {
        case 'date':
          return sortDir === 'asc'
            ? SortUtils.date((a.meta && a.meta.createdAt) || '', (b.meta && b.meta.createdAt) || '')
            : SortUtils.date((b.meta && b.meta.createdAt) || '', (a.meta && a.meta.createdAt) || '');
        case 'title':
          return sortDir === 'asc'
            ? SortUtils.text((a.meta && a.meta.title) || '', (b.meta && b.meta.title) || '')
            : SortUtils.text((b.meta && b.meta.title) || '', (a.meta && a.meta.title) || '');
        case 'score':
          return sortDir === 'desc'
            ? SortUtils.numeric((b.results && b.results.globalScore) || 0, (a.results && a.results.globalScore) || 0)
            : SortUtils.numeric((a.results && a.results.globalScore) || 0, (b.results && b.results.globalScore) || 0);
        default:
          return SortUtils.date((b.meta && b.meta.createdAt) || '', (a.meta && a.meta.createdAt) || '');
      }
    });

    if (!filtered.length) {
      html += '<div class="ra-empty-state"><i class="fas fa-filter"></i><p>' + _esc(_t('riskAnalysis.noResults','Aucun résultat.')) + '</p></div>';
      container.innerHTML = html;
      if (typeof window.refreshReadOnlyLocks === 'function') window.refreshReadOnlyLocks();
      return;
    }

    html += '<table class="ra-list-table"><thead><tr>';
    html += '<th>' + _esc(_t('riskAnalysis.listColumns.title','Titre')) + '</th>';
    html += '<th>' + _esc(_t('riskAnalysis.listColumns.created','Créée le')) + '</th>';
    html += '<th>' + _esc(_t('riskAnalysis.listColumns.status','Statut')) + '</th>';
    html += '<th>' + _esc(_t('riskAnalysis.listColumns.score','Score')) + '</th>';
    html += '<th>' + _esc(_t('riskAnalysis.reviewDate.column','Revue')) + '</th>';
    html += '<th>' + _esc(_t('riskAnalysis.listColumns.actions','Actions')) + '</th>';
    html += '</tr></thead><tbody>';
    filtered.forEach(function(a) {
      var dateStr = a.meta && a.meta.createdAt ? new Date(a.meta.createdAt).toLocaleDateString() : '—';
      var status = a.meta && a.meta.status === 'complete' ? 'complete' : 'draft';
      var statusLabel = status === 'complete' ? _t('riskAnalysis.statusComplete','Terminée') : _t('riskAnalysis.statusDraft','Brouillon');
      var r = a.results;
      var lightClass = r ? 'ra-light--' + r.globalLight : 'ra-light--grey';
      html += '<tr class="ra-list-row" onclick="raOpenWizard(\'' + _raSafeId(a.id) + '\')">';
      html += '<td><strong>' + _esc((a.meta && a.meta.title) || _t('riskAnalysis.pdfUntitled', 'Sans titre')) + '</strong></td>';
      html += '<td>' + _esc(dateStr) + '</td>';
      html += '<td><span class="ra-status-badge ra-status-badge--' + status + '">' + _esc(statusLabel) + '</span></td>';
      // Lot 1b : colonne « Feu » fusionnée dans « Score » (pastille + valeur)
      html += '<td class="ra-score-cell"><span class="ra-light ' + lightClass + '"></span> ' + (r ? r.globalScore + '/5' : '—') + '</td>';
      // Review date column
      var reviewDate = a.meta && a.meta.nextReviewDate;
      var reviewHtml = '—';
      if (reviewDate) {
        try {
          var rd = new Date(reviewDate);
          var now = new Date();
          var diffDays = Math.round((rd - now) / (1000 * 60 * 60 * 24));
          reviewHtml = rd.toLocaleDateString();
          if (diffDays < 0) reviewHtml = '<span class="ra-review-overdue">' + reviewHtml + '</span>';
          else if (diffDays <= 30) reviewHtml = '<span class="ra-review-upcoming">' + reviewHtml + '</span>';
        } catch(e) { reviewHtml = _esc(reviewDate); }
      }
      html += '<td>' + reviewHtml + '</td>';
      html += '<td class="ra-list-actions">';
      // Lot 1b : bouton « éditer » supprimé (redondant avec le clic-ligne) ; dupliquer/PDF/
      // export/sync regroupés derrière un menu « ⋯ » ; « supprimer » reste visible.
      html += '<div class="ra-more-wrap">';
      html += '<button class="btn btn--sm" onclick="event.stopPropagation();raToggleMoreMenu(this)" aria-haspopup="true" aria-expanded="false" title="' + _esc(_t('riskAnalysis.moreActions', 'Plus d’actions')) + '"><i class="fas fa-ellipsis"></i></button>';
      html += '<div class="ra-more-menu" hidden>';
      // FIX-RA-I18N (audit 2026-06-10) : 'common.duplicate' n'existe dans aucune locale →
      // réutilise la clé existante 'riskAnalysis.thresholds.duplicate'.
      html += '<button class="btn btn--sm" onclick="event.stopPropagation();raCloseMoreMenus();raDuplicate(\'' + _raSafeId(a.id) + '\')"><i class="fas fa-copy"></i> ' + _esc(_t('riskAnalysis.thresholds.duplicate', 'Dupliquer')) + '</button>';
      html += '<button class="btn btn--sm" onclick="event.stopPropagation();raCloseMoreMenus();raExportPdf(\'' + _raSafeId(a.id) + '\')"><i class="fas fa-file-pdf"></i> PDF</button>';
      html += '<button class="btn btn--sm" onclick="event.stopPropagation();raCloseMoreMenus();raExportAnalyseFile(\'' + _raSafeId(a.id) + '\')"><i class="fas fa-file-export"></i> ' + _esc(_t('riskAnalysis.controls.exportAnalyseFile','Export .analyse')) + '</button>';
      if (status === 'complete') {
        html += '<button class="btn btn--sm ra-sync-btn" onclick="event.stopPropagation();raCloseMoreMenus();raSyncToProject(\'' + _raSafeId(a.id) + '\')" data-readonly-lock="true"><i class="fas fa-arrow-right-to-bracket"></i> ' + _esc(_t('riskAnalysis.syncToProject','Sync vers Risques projets')) + '</button>';
      }
      html += '</div></div>';
      html += '<button class="btn btn--danger btn--sm" onclick="event.stopPropagation();raDelete(\'' + _raSafeId(a.id) + '\')" data-readonly-lock="true" title="' + _esc(_t('common.delete', 'Delete')) + '"><i class="fas fa-trash"></i></button>';
      html += '</td></tr>';
    });
    html += '</tbody></table>';
    container.innerHTML = html;
    if (typeof window.refreshReadOnlyLocks === 'function') window.refreshReadOnlyLocks();
  }

  // ============================================================
  // THRESHOLD MANAGEMENT PANEL
  // ============================================================
  function renderThresholdPanel() {
    var store = ensureThresholdsInit();
    var activeT = getActiveThreshold();
    var editT = thresholdEditId ? store.items.find(function(i) { return i.id === thresholdEditId; }) : activeT;
    if (!editT) editT = activeT;
    thresholdEditId = editT.id;
    // (audit du lot 2026-08-17) le read-through sert désormais la valeur du groupe SANS
    // normalisation : un seuil legacy/malformé (sans levels/cells) ne doit pas crasher le
    // panneau — repli d'AFFICHAGE sur la grille par défaut, sans muter le magasin.
    if (!Array.isArray(editT.levels) || !editT.levels.length || !editT.cells) {
      editT = Object.assign({}, DEFAULT_RISK_THRESHOLD, editT,
        { levels: (Array.isArray(editT.levels) && editT.levels.length) ? editT.levels : DEFAULT_RISK_THRESHOLD.levels,
          cells: editT.cells || DEFAULT_RISK_THRESHOLD.cells });
    }

    // FIX-IMPOSED-PUSH (audit du lot, 2026-08-17) : les seuils sont la 2e clé d'échelle
    // imposée (§8.1). Sans ce gel, un éditeur de sous-entité personnalisait sa grille,
    // voyait « Sauvegardé » (le filtre au push retire riskThresholds sans erreur), puis
    // le read-through écrasait tout au pull suivant — perte muette, pire que le 409.
    var _thScoped = _scopedSub();
    // (retour client 2026-08-17) libellés localisés : le seuil par défaut porte name/description
    // en anglais + nameFr/descriptionFr — le panneau affichait le champ brut anglais dans une
    // UI française. Cartes : toujours localisées. Champs de l'éditeur : localisés UNIQUEMENT
    // en consultation (sous-entité) — au principal on édite le champ de base, valeur brute.
    var _thPickName = _thName;
    var _thPickDesc = function(t) { return (_lang() === 'en') ? (t.description || t.descriptionFr || '') : (t.descriptionFr || t.description || ''); };
    var html = '<div class="ra-threshold-panel">';
    if (_thScoped) {
      html += '<div class="imx-imposed-banner">'
        + '<i class="fas fa-lock"></i><span>'
        + _esc(_t('impactMatrices.imposedBanner', 'Échelles imposées par l\'entité principale — consultation seule dans une sous-entité.'))
        + '</span></div>';
    }
    html += '<div class="ra-threshold-panel__header">';
    html += '<h4 class="ra-section-title"><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.thresholds.title','Seuils de risque')) + '</h4>';
    html += '<div class="ra-threshold-panel__actions">';
    var _thLock = _thScoped ? ' data-readonly-lock="true" data-permanent-readonly="true" disabled' : ' data-readonly-lock="true"';
    html += (_thScoped ? '' : '<button class="btn btn--sm" onclick="raCreateThreshold()"' + _thLock + '><i class="fas fa-plus"></i> ' + _esc(_t('riskAnalysis.thresholds.new','Nouveau')) + '</button>');
    html += '</div></div>';

    // List of threshold configs
    html += '<div class="ra-threshold-list">';
    store.items.forEach(function(t) {
      var isActive = t.id === store.activeId;
      var isEditing = t.id === thresholdEditId;
      html += '<div class="ra-threshold-card' + (isEditing ? ' ra-threshold-card--selected' : '') + '">';
      html += '<div class="ra-threshold-card__info" onclick="raSelectThresholdEdit(\'' + _raSafeId(t.id) + '\')">';
      html += '<strong>' + _esc(_thPickName(t)) + '</strong>';
      if (isActive) html += ' <span class="ra-threshold-badge-active"><i class="fas fa-check-circle"></i> ' + _esc(_t('riskAnalysis.thresholds.active','Actif')) + '</span>';
      html += '</div>';
      html += '<div class="ra-threshold-card__actions">';
      if (!_thScoped) {
        if (!isActive) html += '<button class="btn btn--sm" onclick="raActivateThreshold(\'' + _raSafeId(t.id) + '\')" data-readonly-lock="true" title="' + _esc(_t('riskAnalysis.thresholds.activate','Activer')) + '"><i class="fas fa-check"></i></button>';
        html += '<button class="btn btn--sm" onclick="raDuplicateThreshold(\'' + _raSafeId(t.id) + '\')" data-readonly-lock="true" title="' + _esc(_t('riskAnalysis.thresholds.duplicate','Dupliquer')) + '"><i class="fas fa-copy"></i></button>';
        if (store.items.length > 1) html += '<button class="btn btn--danger btn--sm" onclick="raDeleteThreshold(\'' + _raSafeId(t.id) + '\')" data-readonly-lock="true" title="' + _esc(_t('riskAnalysis.thresholds.delete','Supprimer')) + '"><i class="fas fa-trash"></i></button>';
      }
      html += '</div></div>';
    });
    html += '</div>';

    // Editor for selected threshold
    html += '<div class="ra-threshold-editor">';
    html += '<div class="ra-form-group"><label class="ra-form-label">' + _esc(_t('riskAnalysis.thresholds.editName','Nom')) + '</label>';
    html += '<input type="text" id="raThreshName" value="' + _esc(_thScoped ? _thPickName(editT) : editT.name) + '" onchange="raUpdateThreshField(\'name\',this.value)"' + _thLock + '></div>';
    html += '<div class="ra-form-group"><label class="ra-form-label">' + _esc(_t('riskAnalysis.thresholds.editDescription','Description')) + '</label>';
    html += '<input type="text" id="raThreshDesc" value="' + _esc(_thScoped ? _thPickDesc(editT) : (editT.description || '')) + '" onchange="raUpdateThreshField(\'description\',this.value)"' + _thLock + '></div>';

    // Levels editor
    html += '<div class="ra-threshold-levels">';
    html += '<label class="ra-form-label">' + _esc(_t('riskAnalysis.thresholds.riskLevels','Niveaux de risque')) + '</label>';
    html += '<div class="ra-threshold-levels-list">';
    editT.levels.forEach(function(lv, idx) {
      html += '<div class="ra-threshold-level-item">';
      html += '<input type="color" value="' + _esc(lv.color) + '" onchange="raUpdateThreshLevel(' + idx + ',\'color\',this.value)"' + _thLock + ' class="ra-color-input">';
      html += '<input type="text" value="' + _esc(_thScoped ? (_bi(lv, 'labelFr', 'label') || lv.label) : lv.label) + '" onchange="raUpdateThreshLevel(' + idx + ',\'label\',this.value)"' + _thLock + ' class="ra-level-input">';
      html += '</div>';
    });
    html += '</div></div>';

    // === RA-UX (B2) === Critères d'acceptation du risque (ISO 27005).
    var _accept = _thAcceptance(editT);
    html += '<div class="ra-threshold-acceptance">';
    html += '<label class="ra-form-label">' + _esc(_t('riskAnalysis.thresholds.acceptanceTitle', 'Critères d\'acceptation du risque')) + '</label>';
    html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.thresholds.acceptanceHint', 'Seuil défini par la direction (ISO 27005) : tout risque résiduel atteignant ce niveau est signalé comme dépassant les critères d\'acceptation. Purement indicatif — aucun recalcul.')) + '</div>';
    html += '<div class="ra-threshold-acceptance__fields">';
    html += '<select id="raThreshAcceptFloor" onchange="raUpdateThreshAcceptance(\'floorLevelId\',this.value)"' + _thLock + '>';
    html += '<option value=""' + (!_accept.floorLevelId ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.thresholds.acceptanceNone', '— aucun seuil défini')) + '</option>';
    (editT.levels || []).forEach(function(lv) {
      html += '<option value="' + _esc(lv.id) + '"' + (_accept.floorLevelId === lv.id ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.thresholds.acceptanceFrom', 'Inacceptable à partir de')) + ' : ' + _esc(_lvlLabel(lv)) + '</option>';
    });
    html += '</select>';
    html += '<input type="text" id="raThreshAcceptRef" value="' + _esc(_accept.decisionRef || '').replace(/"/g,'&quot;') + '" placeholder="' + _esc(_t('riskAnalysis.thresholds.acceptanceRefPlaceholder', 'Référence de la décision (ex : décision de direction du 12/05/2026)')) + '" onchange="raUpdateThreshAcceptance(\'decisionRef\',this.value)"' + _thLock + '>';
    html += '</div></div>';

    // 5×5 matrix grid
    html += '<div class="ra-threshold-matrix-section">';
    html += '<label class="ra-form-label">' + _esc(_t('riskAnalysis.thresholds.matrix','Matrice Probabilité × Gravité')) + '</label>';
    html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.thresholds.clickToChange','Cliquez sur une cellule pour changer son niveau')) + '</div>';
    html += renderThresholdMatrix(editT);
    html += '</div>';

    // Legend
    html += '<div class="ra-threshold-legend">';
    editT.levels.forEach(function(lv) {
      html += '<span class="ra-threshold-legend-item"><span class="ra-threshold-legend-dot" style="background:' + _safeCssColor(lv.color) + '"></span>' + _esc(_lvlLabel(lv)) + '</span>'; // FIX-AUDIT-XSS : couleur de seuil importable
    });
    html += '</div>';

    html += '</div></div>';
    return html;
  }

  function renderThresholdMatrix(t) {
    var lang = _lang();
    var _thFloorIdx = getAcceptanceFloorIndex(t); // FIX-REVUE RA-UX : hoisté hors des 25 cellules
    var gLabels = (lang === 'en' && t.gravityLabels) ? t.gravityLabels : (t.gravityLabelsFr || t.gravityLabels);
    var pLabels = (lang === 'en' && t.probabilityLabels) ? t.probabilityLabels : (t.probabilityLabelsFr || t.probabilityLabels);
    var html = '<div class="ra-threshold-grid">';
    // Header row: empty corner + gravity labels
    html += '<div class="ra-tg-corner"><span class="ra-tg-axis-label ra-tg-axis-g">' + _esc(_t('riskAnalysis.thresholds.gravity','Impact')) + ' →</span><span class="ra-tg-axis-label ra-tg-axis-p">↑ ' + _esc(_t('riskAnalysis.thresholds.probability','Vraisemblance')) + '</span></div>';
    for (var g = 1; g <= 5; g++) {
      html += '<div class="ra-tg-header">' + g + '<br><small>' + _esc(gLabels[g-1] || '') + '</small></div>';
    }
    // Rows: probability 5→1 (top to bottom)
    for (var p = 5; p >= 1; p--) {
      html += '<div class="ra-tg-rowlabel">' + p + '<br><small>' + _esc(pLabels[p-1] || '') + '</small></div>';
      for (var g2 = 1; g2 <= 5; g2++) {
        var key = p + '_' + g2;
        var levelId = t.cells[key] || 'minimum';
        var lvl = t.levels.find(function(l) { return l.id === levelId; });
        var color = _safeCssColor(lvl && lvl.color) || '#ccc';
        var score = p * g2;
        // === RA-UX (B2) === marquage de la zone au-delà des critères d'acceptation
        // (plancher résolu UNE fois hors boucle — FIX-REVUE RA-UX ; le niveau de la cellule
        // est celui déjà résolu ici, jamais une 2e résolution divergente).
        var _beyond = _isLevelBeyondFloor(t, levelId, _thFloorIdx);
        var _beyondTitle = _beyond ? ' — ' + _t('riskAnalysis.thresholds.acceptanceBeyondBadge', 'Au-delà des critères d\'acceptation') : '';
        html += '<div class="ra-tg-cell' + (_beyond ? ' ra-tg-cell--beyond' : '') + '" style="background:' + color + '" onclick="raThreshCellCycle(' + p + ',' + g2 + ')" title="P' + p + ' × G' + g2 + ' = ' + score + ' → ' + _esc((lvl ? _lvlLabel(lvl) : '') + _beyondTitle) + '">';
        html += '<span class="ra-tg-score">' + score + '</span>';
        html += '</div>';
      }
    }
    html += '</div>';
    return html;
  }

  // Global functions for threshold management
  window.raToggleThresholdPanel = function() {
    thresholdPanelOpen = !thresholdPanelOpen;
    window.renderRiskAnalysisModule();
  };
  window.raSelectThresholdEdit = function(id) {
    thresholdEditId = id;
    window.renderRiskAnalysisModule();
  };
  window.raCreateThreshold = function() {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode(_t('editMode.createThreshold', 'create a threshold'))) return;
    var t = createThreshold();
    thresholdEditId = t.id;
    window.renderRiskAnalysisModule();
  };
  window.raDuplicateThreshold = function(id) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('duplicate a threshold')) return;
    var t = duplicateThreshold(id);
    if (t) thresholdEditId = t.id;
    window.renderRiskAnalysisModule();
  };
  window.raDeleteThreshold = function(id) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    showConfirmModal(
      _t('riskAnalysis.thresholds.confirmDelete','Supprimer ce seuil ?'),
      _t('riskAnalysis.thresholds.confirmDeleteMsg','Cette action est irréversible.'),
      function() {
        deleteThreshold(id);
        thresholdEditId = null;
        window.renderRiskAnalysisModule();
      },
      { type: 'danger' }
    );
  };
  window.raActivateThreshold = function(id) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('activate a threshold')) return;
    activateThreshold(id);
    var _st = ensureThresholdsInit();
    var _th = _st && _st.items && _st.items.find(function(i) { return i.id === id; });
    _audit('config', _thName(_th) || id, _t('riskAnalysis.audit.thresholdActivated', 'Seuil de risque activé'));
    window.renderRiskAnalysisModule();
  };
  window.raUpdateThreshField = function(field, value) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    if (!thresholdEditId) return;
    var store = ensureThresholdsInit();
    var t = store.items.find(function(i) { return i.id === thresholdEditId; });
    if (!t) return;
    t[field] = value;
    // FIX-RA-LEVEL-I18N : les cartes affichent nameFr/descriptionFr en UI française —
    // écrire les DEUX champs, sinon un renommage semblait ignoré à l'écran.
    if (field === 'name') t.nameFr = value;
    if (field === 'description') t.descriptionFr = value;
    t.updatedAt = _now();
    _save();
  };
  window.raUpdateThreshLevel = function(idx, field, value) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    if (!thresholdEditId) return;
    var store = ensureThresholdsInit();
    var t = store.items.find(function(i) { return i.id === thresholdEditId; });
    if (!t || !Array.isArray(t.levels) || !t.levels[idx]) return;
    t.levels[idx][field] = value;
    if (field === 'label') t.levels[idx].labelFr = value; // FIX-RA-LEVEL-I18N : même règle que le nom
    t.updatedAt = _now();
    _save();
    window.renderRiskAnalysisModule();
  };
  // === RA-UX (B2) === critères d'acceptation : mêmes gardes que les autres champs de seuil.
  window.raUpdateThreshAcceptance = function(field, value) {
    if (_scopedSub()) return; // seuils imposés par l'entité principale (§8.1)
    if (_ro()) return;
    if (!thresholdEditId) return;
    var store = ensureThresholdsInit();
    var t = store.items.find(function(i) { return i.id === thresholdEditId; });
    if (!t) return;
    if (!t.acceptance) t.acceptance = { floorLevelId: null, decisionRef: '' };
    t.acceptance[field] = (field === 'floorLevelId') ? (value || null) : value;
    t.updatedAt = _now();
    _save();
    window.renderRiskAnalysisModule();
  };
  window.raThreshCellCycle = function(p, g) {
    if (_scopedSub()) return; // FIX-IMPOSED-PUSH : seuils imposés par l'entité principale (§8.1)
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode(_t('editMode.editThreshold', 'edit a threshold'))) return;
    if (!thresholdEditId) return;
    var store = ensureThresholdsInit();
    var t = store.items.find(function(i) { return i.id === thresholdEditId; });
    if (!t) return;
    // FIX-RA-THRESH-FALLBACK (audit 2026-08-17) : le panneau peut afficher un REPLI pour
    // un seuil hérité sans levels/cells — un clic de cellule jetait TypeError sans toast.
    if (!t.cells || !Array.isArray(t.levels) || !t.levels.length) return;
    var key = p + '_' + g;
    var currentId = t.cells[key] || 'minimum';
    var ids = t.levels.map(function(l) { return l.id; });
    var curIdx = ids.indexOf(currentId);
    var nextIdx = (curIdx + 1) % ids.length;
    t.cells[key] = ids[nextIdx];
    t.updatedAt = _now();
    _save();
    window.renderRiskAnalysisModule();
  };

  function createNewAnalysis() {
    if (_ro()) return; // FIX-RA-SECTION-READ : couvre lecture seule ET droit de section 'read'
    // (audit 2026-08-17, wizard#2) le bouton d'en-tête reste actif pendant le wizard :
    // sauver l'étape en cours AVANT de créer, sinon la saisie non naviguée est jetée.
    if (wizardState.analysisId) saveCurrentStep();
    var a = createBlankAnalysis();
    // FIX-RA-MATRIX-NONE (audit 2026-08-17, échelles#1) : le défaut « matrice active » est
    // posé EXPLICITEMENT à la création — le rendu de l'étape Contexte affiche désormais la
    // valeur STOCKÉE telle quelle, si bien que « Aucune (évaluation manuelle 1-5) » n'est
    // plus silencieusement ré-attachée à la matrice active à chaque repassage.
    _ensureMatrixStore();
    if (window.CSI_ImpactMatrices) {
      var _actM = window.CSI_ImpactMatrices.getActiveMatrix(); // en sous-entité : matrice héritée
      if (_actM) a.impactMatrixId = _actM.id;
    }
    // FIX-RA-I18N (audit 2026-06-10) : la clé 'risk.newAnalysis' n'existe dans aucune locale
    // → toute nouvelle analyse créée en UI française recevait le titre anglais « New analysis N »
    // (persisté dans les données). La clé correcte est 'riskAnalysis.newAnalysis' (fr+en).
    a.meta.title = _t('riskAnalysis.newAnalysis', 'Nouvelle analyse') + ' ' + (getAnalyses().length + 1);
    if (!window.appData.riskAnalyses) window.appData.riskAnalyses = [];
    window.appData.riskAnalyses.push(a);
    _save();
    if (typeof window.updateDashboard === 'function') window.updateDashboard();
    wizardState.analysisId = a.id;
    _audit('add', a.meta.title, _t('riskAnalysis.audit.created', 'Analyse de risques créée'));
    wizardState.step = 0; wizardState.resultTab = 'synthese'; // FIX-AUDIT-TAB (2026-08-25)
    window.renderRiskAnalysisModule();
  }

  window.raOpenWizard = function(id) { wizardState.analysisId = id; wizardState.step = 0; wizardState.resultTab = 'synthese'; window.renderRiskAnalysisModule(); }; // FIX-AUDIT-TAB (2026-08-25) : l'onglet Résultats fuyait d'une analyse à l'autre
  window.raDuplicate = function(id) {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('duplicate an analysis')) return; // FIX-RA-READONLY
    var a = findAnalysis(id); if (!a) return;
    var clone = _deepClone(a);
    clone.id = _id();
    clone.meta.title = (clone.meta.title || '') + _t('common.copySuffix', ' (copie)'); // idem : réserve donnée assumée
    clone.meta.status = 'draft';
    clone.meta.createdAt = _now();
    clone.meta.updatedAt = _now();
    clone.results = null;
    if (clone.meta) { delete clone.meta.reportNumber; delete clone.meta.reportDate; }
    window.appData.riskAnalyses.push(clone);
    _audit('add', clone.meta.title, _t('riskAnalysis.audit.duplicated', 'Analyse dupliquée depuis') + ' : ' + (a.meta.title || a.id));
    _save();
    if (typeof window.updateDashboard === 'function') window.updateDashboard();
    if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.duplicated','Analyse dupliquée.'), 'success');
    window.renderRiskAnalysisModule();
  };
  window.raDelete = function(id) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-17) : seul handler destructif sans garde
    showConfirmModal(
      _t('riskAnalysis.confirmDelete','Supprimer cette analyse de risques ?'),
      _t('riskAnalysis.confirmDeleteMsg','L\'analyse sera définitivement supprimée ainsi que les risques projets associés.'),
      function() {
        // Titre capturé AVANT le filter : après, l'analyse n'existe plus.
        var _gone = findAnalysis(id);
        var _goneTitle = (_gone && _gone.meta && (_gone.meta.title || _gone.meta.reportNumber)) || id;
        window.appData.riskAnalyses = getAnalyses().filter(a => a.id !== id);
        removeProjectRiskForAnalysis(id);
        _audit('delete', _goneTitle, _t('riskAnalysis.audit.deleted', 'Analyse supprimée, ainsi que les risques projets associés (cascade)'));
        _save();
        if (typeof window.updateDashboard === 'function') window.updateDashboard();
        if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.deleted','Analyse supprimée.'), 'info');
        window.renderRiskAnalysisModule();
      },
      { type: 'danger' }
    );
  };
  window.raExportPdf = function(id) { var a = findAnalysis(id); if (a) generateRiskAnalysisPDF(a); };

  // ============================================================
  // MANUAL SYNC TO PROJECT RISKS
  // ============================================================
  window.raSyncToProject = function(analysisId) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(analysisId);
    if (!a) return;
    if (a.results) a.results = computeAllResults(a); // FIX-AUDIT-STALE : ne jamais propager un cache périmé au registre projet
    if (!window.appData.projectRisks) window.appData.projectRisks = [];
    var pr = window.appData.projectRisks;

    // Check if already synced by sourceAnalysisId
    var existingBySource = pr.find(function(r) { return r.sourceAnalysisId === analysisId; });
    if (existingBySource) {
      syncAnalysisToProjectRisks(a);
      _save();
      if (typeof window.updateDashboard === 'function') window.updateDashboard();
      if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.syncAlreadySynced', 'Risque projet mis à jour.'), 'success');
      return;
    }

    // Check for name duplicate (case-insensitive)
    var analysisTitle = (a.meta && a.meta.title) || '';
    var existingByName = pr.find(function(r) {
      return (r.projectName || '').toLowerCase() === analysisTitle.toLowerCase() && analysisTitle !== '';
    });

    if (existingByName) {
      // Close any existing modal before showing sync dialog
      if (typeof window.closeModal === 'function') window.closeModal();
      // Show 3-option dialog via custom modal body
      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) return;

      modalTitle.textContent = _t('riskAnalysis.syncDuplicateTitle', 'Doublon détecté');
      modalBody.innerHTML = '<div class="confirm-modal__icon confirm-modal__icon--warning"><i class="fas fa-exclamation-circle"></i></div>' +
        '<div class="confirm-modal__message">' + _esc(_t('riskAnalysis.syncDuplicateMsg', 'Un risque projet avec le même nom existe déjà. Que souhaitez-vous faire ?')) + '</div>' +
        '<div style="display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap;">' +
        '<button class="btn" id="raSyncCancel">' + _esc(_t('riskAnalysis.syncOptionCancel', 'Annuler')) + '</button>' +
        '<button class="btn btn--primary" id="raSyncNew">' + _esc(_t('riskAnalysis.syncOptionNew', 'Créer nouveau')) + '</button>' +
        '<button class="btn btn--warning" id="raSyncOverwrite">' + _esc(_t('riskAnalysis.syncOptionOverwrite', 'Écraser existant')) + '</button>' +
        '</div>';
      if (saveBtn) saveBtn.style.display = 'none';
      if (cancelBtn) cancelBtn.style.display = 'none';
      if (closeBtn) { closeBtn.style.display = ''; closeBtn.disabled = false; closeBtn.onclick = function() { window.closeModal(); }; }
      if (deleteBtn) deleteBtn.style.display = 'none';
      overlay.classList.add('modal-overlay--visible');

      document.getElementById('raSyncCancel').onclick = function() { window.closeModal(); };
      document.getElementById('raSyncNew').onclick = function() {
        window.closeModal();
        syncAnalysisToProjectRisks(a);
        _save();
        if (typeof window.updateDashboard === 'function') window.updateDashboard();
        if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.syncSuccess', 'Risque projet créé avec succès.'), 'success');
      };
      document.getElementById('raSyncOverwrite').onclick = function() {
        window.closeModal();
        // Remove existing by name, then sync (creates new with sourceAnalysisId)
        window.appData.projectRisks = window.appData.projectRisks.filter(function(r) { return r.id !== existingByName.id; });
        syncAnalysisToProjectRisks(a);
        _save();
        if (typeof window.updateDashboard === 'function') window.updateDashboard();
        if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.syncSuccess', 'Risque projet créé avec succès.'), 'success');
      };
      return;
    }

    // No duplicate — sync directly
    syncAnalysisToProjectRisks(a);
    _save();
    if (typeof window.updateDashboard === 'function') window.updateDashboard();
    if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.syncSuccess', 'Risque projet créé avec succès.'), 'success');
  };

  // ============================================================
  // EXPORT / IMPORT .analyse FILE
  // ============================================================
  window.raExportAnalyseFile = function(id) {
    var a = findAnalysis(id);
    if (!a) return;
    var envelope = {
      type: 'cyber-assistant-risk-analysis',
      version: '1.0',
      exportDate: new Date().toISOString(),
      analysis: _deepClone(a)
    };
    // Embed linked impact matrix if present
    if (a.impactMatrixId && window.CSI_ImpactMatrices) {
      var m = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
      if (m) envelope.impactMatrix = _deepClone(m);
    }
    // FIX-RA-THRESHOLD-EXPORT (revue adverse Groupe D, 2026-08-24) : l'enveloppe
    // embarquait le seuil ACTIF du poste — un ré-export d'une analyse importée liée à
    // son seuil (a.riskThresholdId, AUDIT-M2) substituait silencieusement la politique
    // locale à celle de l'organisation d'origine. Même famille que le fix de la grille
    // PDF (getThresholdForAnalysis résout le seuil lié, sinon l'actif : inchangé pour
    // une analyse locale).
    var t = getThresholdForAnalysis(a);
    if (t) envelope.riskThreshold = _deepClone(t);
    var json = JSON.stringify(envelope, null, 2);
    var blob = new Blob([json], { type: 'application/json' });
    var url = URL.createObjectURL(blob);
    var el = document.createElement('a');
    el.href = url;
    var slug = (a.meta.title || 'analyse').replace(/[^a-zA-Z0-9àâäéèêëïîôùûüçÀÂÄÉÈÊËÏÎÔÙÛÜÇ]/g, '_').substring(0, 40);
    el.download = 'RA_' + slug + '_' + new Date().toISOString().slice(0, 10) + '.analyse';
    document.body.appendChild(el);
    el.click();
    document.body.removeChild(el);
    URL.revokeObjectURL(url);
    if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.controls.analyseExported', 'Fichier .analyse exporté.'), 'success');
  };

  // Import from file picker
  window.raImportAnalyseFile = function() {
    if (_ro()) return; // FIX-RA-SECTION-READ
    if (wizardState.analysisId) saveCurrentStep(); // (wizard#2) saisie en cours sauvée avant l'import
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode(_t('editMode.importAnalysis', 'import an analysis'))) return;
    var input = document.getElementById('analyseFileInput');
    if (!input) return;
    input.value = '';
    input.click();
    input.onchange = function(e) {
      var file = e.target.files[0];
      if (!file) return;
      var reader = new FileReader();
      reader.onload = function(ev) {
        try {
          var data = JSON.parse(ev.target.result);
          window.raImportAnalyseFromData(data, file.name);
        } catch (err) {
          if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.controls.importAnalyseError', 'Fichier .analyse invalide.'), 'error');
        }
      };
      reader.readAsText(file);
    };
  };

  // Import from parsed data (used by both file picker and global import routing)
  window.raImportAnalyseFromData = function(data, fileName) {
    // FIX-AUDIT-RO-IMPORT (audit v2, 2026-08-25) : cette fonction ÉCRIT (analyse, matrice et
    // seuil embarqués, puis _save) et n'avait AUCUNE garde — le défaut était invisible parce
    // que le banc ne l'exécutait jamais (showConfirmModal en identifiant global nu, voir
    // ra-readonly-invariant.test.js). Un compte en consultation, ou au droit de section
    // 'read', pouvait donc importer : le serveur refuse (423/403) et la saisie est perdue.
    if (_ro()) return;
    if (!data || data.type !== 'cyber-assistant-risk-analysis' || !data.analysis) {
      if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.controls.importAnalyseError', 'Fichier .analyse invalide.'), 'error');
      return;
    }
    var a = _deepClone(data.analysis);
    // Build summary for confirmation
    var title = (a.meta && a.meta.title) || _t('riskAnalysis.pdfUntitled', 'Sans titre');
    var status = (a.meta && a.meta.status) || 'draft';
    var risksCount = Array.isArray(a.risks) ? a.risks.length : 0;
    var controlsCount = a.controls ? Object.keys(a.controls).length : 0;
    var summary = _t('riskAnalysis.controls.importAnalyseSummary', 'Résumé de l\'analyse à importer :') + '\n\n';
    summary += '• ' + _t('riskAnalysis.listColumns.title', 'Titre') + ': ' + title + '\n';
    summary += '• ' + _t('riskAnalysis.listColumns.status', 'Statut') + ': ' + status + '\n';
    summary += '• ' + _t('riskAnalysis.risks.title', 'Risques') + ': ' + risksCount + '\n';
    summary += '• ' + _t('riskAnalysis.controls.title', 'Contrôles') + ': ' + controlsCount + '\n';
    if (data.impactMatrix) summary += '• ' + _t('riskAnalysis.pdfImpactMatrix', 'Matrice d\'impact') + ': ' + (data.impactMatrix.name || 'Oui') + '\n';
    if (fileName) summary += '\n' + _t('riskAnalysis.controls.importSource', 'Source') + ': ' + fileName;
    showConfirmModal(
      _t('riskAnalysis.controls.importConfirm', 'Importer cette analyse ?'),
      summary,
      function() {
        // Assign new ID (always create a copy)
        a.id = _id();
        if (!a.meta) a.meta = {};
        // FIX-RA-I18N (audit 2026-06-10) : 'riskAnalysis.imported' n'existe dans aucune locale
        // (le fallback français « importé » s'affichait en UI anglaise). La clé existante est
        // 'riskAnalysis.controls.imported' (« importé » fr / « imported » en).
        a.meta.title = (a.meta.title || '') + ' (' + _t('riskAnalysis.controls.imported', 'importé') + ')';
        a.meta.createdAt = _now();
        a.meta.updatedAt = _now();
        delete a.meta.reportNumber;
        delete a.meta.reportDate;
        // Normalize
        a = normalizeRiskAnalysis(a);
        // Périmètre du poste : en sous-entité, matrice ET seuil sont IMPOSÉS par l'entité
        // principale (§8.1). Résolu une seule fois, avant les deux blocs — laisser la
        // déclaration dans le bloc matrice rendait la garde du bloc seuil inopérante
        // (`undefined`) quand l'enveloppe ne portait pas de matrice.
        var _sousEntite = _scopedSub();
        // AUDIT-M1: Import embedded impact matrix — avec détection de doublons améliorée.
        // Avant: le check utilisait l'ID original du fichier, mais le clone avait un nouvel ID.
        // Importer le même .analyse 2x créait N matrices identiques orphelines.
        // Fix: chercher aussi par _importedFromId pour détecter les clones existants.
        if (data.impactMatrix && window.CSI_ImpactMatrices) {
          // FIX-AUDIT-IMPORT : sur un poste où le magasin de matrices n'a jamais été
          // initialisé (jamais ouvert l'onglet Matrices), l'import poussait dans un
          // magasin absent → les 3 matrices embarquées étaient perdues. Forcer l'init.
          if (typeof window.CSI_ImpactMatrices.ensureInitialized === 'function') window.CSI_ImpactMatrices.ensureInitialized();
          var matrices = (window.appData.impactMatrices && window.appData.impactMatrices.matrices) || [];
          // FIX-AUDIT-IMPORT-SIG : réutiliser un objet local UNIQUEMENT s'il porte la même
          // politique. L'anti-duplication AUDIT-M1 (import répété) reste entière : un vrai
          // ré-import a, par construction, la même signature.
          // FIX-IMPORT-SIG-NORM (audit progressif des lots A/B, 2026-08-26) : le magasin
          // local passe par normalizeImpactMatrices à chaque ensureInitialized (coercition
          // level/responsePriority, cellules complétées, meta/description par défaut).
          // Comparer une matrice BRUTE de fichier à un objet NORMALISÉ rendait la signature
          // toujours différente : l'anti-duplication AUDIT-M1 était cassée pour toute
          // matrice embarquée non canonique. Mesuré sur 3 imports du même fichier :
          // 4 / 5 / 6 matrices (forme canonique : 4 / 4 / 4, seul cas qui dédupliquait).
          // Ce n'est pas hypothétique : `responsePriority` n'existe que depuis 0678465 et
          // n'est complété par la normalisation que depuis 6d87384 — tout .analyse exporté
          // avant cela tombe dans le cas fautif, comme un fichier édité à la main.
          // On normalise la matrice embarquée AVANT de signer ET on pousse la version
          // normalisée : les deux côtés deviennent comparables et le magasin ne contient
          // plus d'objet brut.
          var _mxEmbed = data.impactMatrix;
          if (typeof window.CSI_ImpactMatrices.normalize === 'function') {
            try {
              var _nrm = window.CSI_ImpactMatrices.normalize({ matrices: [_deepClone(data.impactMatrix)], activeMatrixId: null });
              if (_nrm && Array.isArray(_nrm.matrices) && _nrm.matrices[0]) _mxEmbed = _nrm.matrices[0];
            } catch (e) { /* normalisation indisponible : on retombe sur l'objet brut */ }
          }
          var _sigM = _scaleSignature(_mxEmbed);
          // FIX-IMPOSED-RELINK (audit progressif des lots A/B, 2026-08-26) : en SOUS-ENTITÉ,
          // le catalogue est imposé par l'entité principale (§8.1) et le magasin local porte
          // la matrice imposée AVEC SON ID D'ORIGINE. Exiger en plus une signature identique
          // — ce que le lot A a introduit — cassait le lien dès que le principal avait ajusté
          // sa politique d'un seul libellé : `existing` devenait null, on retombait sur
          // `impactMatrixId = null`, et le dégât dépasse le lien : _ciaScale renvoie alors
          // l'échelle historique 3 crans avec unresolved:false, si bien que _ciaEnsureScale
          // REMAPPE les valeurs CIA de 5 vers 3 crans EN ÉCRITURE — perte irréversible des
          // crans 4 et 5. Le toast affirmait de surcroît que les échelles sont imposées alors
          // que la matrice imposée était présente et sélectionnable.
          // La garde de signature protège contre la réutilisation d'une politique LOCALE
          // divergente : ce risque n'existe pas ici, la sous-entité n'a pas de politique
          // propre. On rattache donc par id seul dans ce périmètre.
          var existing = matrices.find(function(m) {
            var memeId = (m.id === _mxEmbed.id || m._importedFromId === _mxEmbed.id);
            if (!memeId) return false;
            return _sousEntite || _scaleSignature(m) === _sigM;
          }) || null;
          if (existing) {
            // Réutiliser la matrice existante — mettre à jour le lien
            a.impactMatrixId = existing.id;
          } else if (_sousEntite) {
            // FIX-IMPOSED-PUSH (audit du lot, 2026-08-17) : en sous-entité le catalogue est
            // imposé (§8.1) — le clone ne serait JAMAIS persisté (filtre au push) alors que
            // l'analyse, elle, le serait : impactMatrixId pendouillerait pour tout le monde
            // (« Matrice introuvable » durable, la famille de bugs que ce lot ferme). On
            // retombe sur l'évaluation manuelle 1-5 et on prévient l'utilisateur.
            a.impactMatrixId = null;
            if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.controls.importMatrixImposed', 'Matrice embarquée ignorée : les échelles sont imposées par l\'entité principale (évaluation 1-5).'), 'warning', 8000);
          } else {
            var cloneM = _deepClone(_mxEmbed); // NORMALISÉE : sinon le prochain import re-diverge
            cloneM._importedFromId = data.impactMatrix.id;
            cloneM.id = _id();
            // FIX-IMPORT-FORK-LABEL (audit progressif, 2026-08-26) : le fork lui-même est
            // légitime — deux politiques différentes doivent donner deux matrices — mais il
            // était INVISIBLE : deux entrées au nom rigoureusement identique dans le
            // sélecteur, sans marqueur, y compris pour un ré-import INTRA-organisation après
            // une simple édition locale d'une cellule.
            cloneM.name = _suffixeImporte(cloneM.name);
            matrices.push(cloneM);
            if (!window.appData.impactMatrices) window.appData.impactMatrices = { matrices: [] };
            window.appData.impactMatrices.matrices = matrices;
            a.impactMatrixId = cloneM.id;
          }
        }
        // AUDIT-M2: Import embedded risk threshold — même fix anti-duplication + linking.
        // Avant: le seuil était importé dans le store mais JAMAIS lié à l'analyse.
        if (data.riskThreshold) {
          var store = ensureThresholdsInit();
          // FIX-AUDIT-IMPORT-SIG : idem matrice — un seuil builtin PERSONNALISÉ par
          // l'organisation source ne doit plus être écrasé par l'homonyme local.
          // FIX-IMPOSED-RELINK : même règle que la matrice — en sous-entité le seuil est
          // imposé et présent localement avec son id d'origine ; exiger en plus une signature
          // identique cassait le lien au premier ajustement de politique du principal.
          var _sigT = _scaleSignature(data.riskThreshold);
          var existingT = store.items.find(function(i) {
            var memeId = (i.id === data.riskThreshold.id || i._importedFromId === data.riskThreshold.id);
            if (!memeId) return false;
            return _sousEntite || _scaleSignature(i) === _sigT;
          }) || null;
          if (existingT) {
            a.riskThresholdId = existingT.id;
          } else if (_sousEntite) {
            // FIX-IMPOSED-PUSH : même règle que la matrice embarquée ci-dessus — le seuil
            // cloné ne serait jamais persisté ; l'analyse retombe sur le seuil actif hérité.
            a.riskThresholdId = null;
          } else {
            var cloneT = _deepClone(data.riskThreshold);
            cloneT._importedFromId = data.riskThreshold.id;
            cloneT.id = _id();
            // FIX-IMPORT-FORK-LABEL : idem matrice. Les DEUX libellés sont marqués — les
            // cartes de seuil affichent nameFr en UI française, name en anglaise.
            cloneT.name = _suffixeImporte(cloneT.name);
            if (cloneT.nameFr) cloneT.nameFr = _suffixeImporte(cloneT.nameFr);
            store.items.push(cloneT);
            a.riskThresholdId = cloneT.id;
          }
        }
        // FIX-AUDIT-IMPORT-STALE (audit v2, 2026-08-25) : l'import était le SEUL point d'entrée
        // qui conservait `results` verbatim — liste, tableau de bord et miroir projet
        // affichaient donc le verdict de l'organisation SOURCE (jusqu'aux recommandations
        // d'un tiers), pendant que le PDF, lui, recalcule toujours. Recalcul APRÈS la
        // résolution matrice/seuil (ordre load-bearing) et AVANT le push. La garde
        // `if (a.results)` reprend celle des 3 autres points : un brouillon n'en gagne pas.
        if (a.results) a.results = computeAllResults(a);
        // Add to analyses
        if (!window.appData.riskAnalyses) window.appData.riskAnalyses = [];
        window.appData.riskAnalyses.push(a);
        _audit('import', (a.meta && a.meta.title) || a.id, _t('riskAnalysis.audit.imported', 'Analyse importée depuis un fichier') + (fileName ? ' : ' + fileName : ''));
        _save();
        // Sync to project risks
        syncAnalysisToProjectRisks(a);
        _save();
        // M1 : l'import a pu muter impactMatrices/riskThresholds (clones embarqués) —
        // les pousser explicitement en mode serveur (syncAnalysisToProjectRisks ne gère
        // que projectRisks). No-op hors mode serveur.
        _persistAffected(['impactMatrices', 'riskThresholds']);
        if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.controls.importAnalyseSuccess', 'Analyse importée avec succès.'), 'success');
        window.renderRiskAnalysisModule();
      },
      { type: 'info' }
    );
  };

  // ============================================================
  // AUTO-SYNC TO PROJECT RISKS
  // ============================================================
  // M2 (audit 2026-07-10) : fusionne les recommandations recalculées (top-3) avec celles
  // déjà présentes au lieu de les ÉCRASER. Préserve l'état 'implemented', le texte édité par
  // l'utilisateur, les id stables et les ajouts manuels. Rapprochement par titre normalisé.
  // FIX-MERGE-SEMANTIC (audit progressif, 2026-08-26) : le rapprochement se faisait sur le
  // TITRE, qui est localisé (_bi le résout via _lang) — une bascule FR→EN suivie d'un
  // « Terminer » ou d'un clic « Synchroniser » dupliquait les trois recommandations du
  // registre projet, mesuré 3 → 6. La clé sémantique ('iam.mfa', 'iam.rbac'…) était
  // disponible dans results.recommendations et n'était simplement pas transportée.
  // Elle prime désormais ; le titre normalisé reste le repli pour les entrées héritées et
  // pour celles ajoutées à la main par l'utilisateur, qui n'ont pas de clé.
  // Cartes SANS prototype : les clés viennent de la donnée.
  function mergeProjectRecommendations(oldRecs, newRecs) {
    var out = Array.isArray(oldRecs) ? oldRecs.slice() : [];
    var norm = function(s) { return String(s || '').trim().toLowerCase(); };
    var cle = function(r) {
      if (!r) return '';
      var sk = String(r.semanticKey || '').trim();
      return sk ? ('sk:' + sk.toLowerCase()) : ('t:' + norm(r.title));
    };
    var byTitle = Object.create(null);
    out.forEach(function(r) {
      var k = cle(r);
      if (k !== 't:' && k !== 'sk:') byTitle[k] = r;
      // Une entrée héritée (sans clé sémantique) reste rapprochable par son titre, et une
      // entrée à clé reste rapprochable par son titre si la nouvelle n'en porte pas.
      if (r && r.title) { var kt = 't:' + norm(r.title); if (!byTitle[kt]) byTitle[kt] = r; }
    });
    (Array.isArray(newRecs) ? newRecs : []).forEach(function(nr) {
      var key = cle(nr);
      var match = (key !== 't:' && key !== 'sk:') ? byTitle[key] : null;
      if (!match && nr && nr.title) match = byTitle['t:' + norm(nr.title)];
      if (match) {
        // Adopter la clé sémantique sur une entrée héritée : le prochain rapprochement
        // deviendra indépendant de la langue sans migration explicite.
        if (nr.semanticKey && !match.semanticKey) match.semanticKey = nr.semanticKey;
        // Rafraîchir le titre dans la langue courante ne clobber rien d'édité : le titre
        // auto n'est pas un champ que l'utilisateur modifie (il retire ou ajoute des lignes).
        if (nr.semanticKey && match.semanticKey === nr.semanticKey && nr.title) match.title = nr.title;
        // Rafraîchir le texte auto seulement s'il était vide (ne pas clobber l'édition) ;
        // conserver id + implemented existants.
        if (nr.text && !match.text) match.text = nr.text;
      } else {
        out.push(nr);
        if (key !== 't:' && key !== 'sk:') byTitle[key] = nr;
        if (nr && nr.title) byTitle['t:' + norm(nr.title)] = nr;
      }
    });
    return out;
  }

  // M1 (audit 2026-07-10) : en mode serveur, saveData() ne pousse que l'onglet courant
  // (risk-analysis) + global-meta. Les mutations d'autres onglets (projectRisks,
  // impactMatrices, riskThresholds) doivent être poussées explicitement, sinon elles ne sont
  // jamais persistées et sont écrasées au prochain pull (même famille que FIX-CASCADE-PERSIST).
  // No-op hors mode serveur (_csiPersistAffectedTabs est gardé par isServerModeUnlocked).
  function _persistAffected(keys) {
    try {
      if (typeof window._csiPersistAffectedTabs === 'function') window._csiPersistAffectedTabs(keys);
    } catch (e) { /* best-effort */ }
  }

  // FIX-RA-MIRROR-RIGHT (audit droits 2026-08-17, constat 6) : risk-analysis 'write' +
  // project-risks 'read'/'none' est une config valide — la cascade partait alors en 403
  // SECTION_READONLY avec un toast incompréhensible et un re-pull en plein wizard. On
  // saute proprement le miroir et on le dit UNE fois.
  function _prMirrorWritable() {
    var EM = window.CSI_EntityManager;
    if (!(EM && EM.isMultiEntity && EM.isMultiEntity() && EM.isSubEntityUser && EM.isSubEntityUser())) return true;
    return EM.sectionRight && EM.sectionRight('project-risks') === 'write';
  }
  var _prMirrorWarned = false;
  function _prMirrorSkipNotice() {
    if (_prMirrorWarned) return;
    _prMirrorWarned = true;
    if (typeof window.showToast2 === 'function') {
      window.showToast2(_t('riskAnalysis.controls.mirrorSkippedRight', 'Risques projets en lecture seule pour votre périmètre — le miroir projet de cette analyse n\'a pas été mis à jour.'), 'info', 8000);
    }
  }

  // FIX-AUDIT-MIRROR-STALE (audit v2, 2026-08-25) : le miroir « Risques projets » n'était
  // rafraîchi qu'au bouton Sync, au « Terminer » et à l'entrée en étape Résultats. Une
  // analyse finalisée VERTE, rouvertе et dégradée (cotation portée à 5×5, contrôles remis à
  // « non ») restait donc VERTE au registre et au tableau de bord — divergence sur le statut,
  // le nom et la date. Ce n'est pas un choix : bundle.js force ces champs en lecture seule
  // dès qu'il y a un sourceAnalysisId, avec une bannière « auto-sync » ; l'UI interdit donc
  // de corriger à la main précisément ce que le code laissait diverger.
  // On ne rafraîchit QUE les miroirs DÉJÀ existants : le bouton Sync reste le seul point de
  // CRÉATION (sinon un simple passage en consultation fabriquerait des risques projet).
  function _refreshExistingProjectMirror(a) {
    if (!a || !a.id || !a.results) return;
    if (_ro()) return;
    var pr = window.appData && window.appData.projectRisks;
    if (!Array.isArray(pr)) return;
    var _m = pr.find(function(r) { return r && r.sourceAnalysisId === a.id; });
    if (!_m) return;
    // FIX-MIRROR-WRITE-AMP (audit progressif, 2026-08-26) : saveCurrentStep appelle ce
    // rafraîchissement à CHAQUE navigation d'étape, sans delta-gate — mesuré 6 pushes
    // `projectRisks` pour 6 clics « Suivant » sur une analyse déjà miroitée. La gravité
    // annoncée est réfutée (project-risks et risk-analysis sont dans la MÊME grappe
    // `analyse`, déjà détenue par l'éditeur : aucune acquisition, aucun 423 ; et la
    // navigation écrivait déjà projectRisks avant le lot B, à l'entrée des étapes 7/8).
    // Il reste une amplification d'écriture inutile : on sort si rien de ce que le miroir
    // recopie n'a bougé. Les recommandations ne sont PAS comparées : ce chemin n'y touche
    // plus (FIX-MIRROR-RECO-CREEP).
    var _glCible = 'todo';
    if (a.meta && a.meta.status === 'complete' && a.results) {
      var _g = a.results.globalLight;
      if (_g === 'green' || _g === 'yellow' || _g === 'red') _glCible = _g;
    }
    var _dateCible = '';
    try { _dateCible = new Date((a.meta && a.meta.updatedAt) || _now()).toISOString().slice(0, 10); } catch (e) { _dateCible = ''; }
    if (_m.status === _glCible
        && _m.completionDate === _dateCible
        && _m.projectName === ((a.meta && a.meta.title) || _t('riskAnalysis.pdfUntitled', 'Sans titre'))
        && _m.reportNumber === ((a.meta && a.meta.reportNumber) || '')
        && _m.nextReviewDate === ((a.meta && a.meta.nextReviewDate) || null)) {
      return; // le miroir est déjà à jour : ne rien écrire, ne rien pousser
    }
    // FIX-MIRROR-RECO-CREEP (régression du lot B, trouvée par l'audit progressif) : le
    // rafraîchissement PASSIF ne touche pas aux recommandations. mergeProjectRecommendations
    // est append-only (rapprochement par titre, aucune suppression) et le top-3 change à
    // chaque contrôle saisi : branché sur chaque navigation d'étape, il empilait des recos
    // périmées — mesuré 3 → 5 → 7 → 9 sur HEAD contre 3 → 3 → 3 → 3 avant le lot B, avec
    // 6 entrées sur 9 absentes du top réel et toutes `implemented:false`, ce qui gonfle
    // durablement le dénominateur du KPI « recommandations mises en œuvre » du registre.
    // Les points EXPLICITES (bouton Sync, « Terminer », entrée en Résultats/Prévisualisation,
    // import) continuent de propager les recommandations : eux sont des actes délibérés.
    syncAnalysisToProjectRisks(a, { skipRecommendations: true });
  }

  function syncAnalysisToProjectRisks(analysis, opts) {
    if (!_prMirrorWritable()) { _prMirrorSkipNotice(); return; }
    if (!analysis || !analysis.id) return;
    if (!window.appData.projectRisks) window.appData.projectRisks = [];
    var pr = window.appData.projectRisks;
    var existing = pr.find(function(r) { return r.sourceAnalysisId === analysis.id; });
    // Determine status from global light
    var prStatus = 'todo';
    // FIX-MIRROR-VERDICT (audit progressif, 2026-08-26) : le gate portait sur le drapeau BRUT
    // `noRiskAssessed` alors que `globalLight` porte DÉJÀ la doctrine (absence d'évaluation =
    // réserve jaune) ET l'override manuel justifié. Résultats mesurés avant correctif : une
    // analyse `complete` sans risque coté retombait en 'todo' — confondue avec un brouillon
    // jamais démarré et exclue du compteur « terminées » — et un avis forcé au ROUGE restait
    // invisible dans le registre, que l'utilisateur ne peut pas corriger (statut verrouillé
    // pour un miroir auto-synchronisé).
    if (analysis.meta && analysis.meta.status === 'complete' && analysis.results) {
      var gl = analysis.results.globalLight;
      if (gl === 'green') prStatus = 'green';
      else if (gl === 'yellow') prStatus = 'yellow';
      else if (gl === 'red') prStatus = 'red';
    }
    // Build recommendations from top 3
    var recs = [];
    if (analysis.results && analysis.results.recommendations) {
      analysis.results.recommendations.slice(0, 3).forEach(function(rec) {
        recs.push({
          id: 'proj-reco-' + Math.random().toString(36).slice(2, 10),
          // FIX-MERGE-SEMANTIC (audit progressif, 2026-08-26) : identité STABLE de la
          // recommandation, indépendante de la langue. Sans elle, le rapprochement se
          // faisait sur le titre LOCALISÉ — une bascule FR→EN suivie d'un « Terminer »
          // dupliquait les trois recommandations du registre (mesuré 3 → 6).
          semanticKey: rec.semanticKey || '',
          title: rec.title || '',
          text: rec.rationale || '',
          implemented: false
        });
      });
    }
    // Format date
    var updatedAt = (analysis.meta && analysis.meta.updatedAt) || _now();
    var dateStr = '';
    try { dateStr = new Date(updatedAt).toISOString().slice(0, 10); } catch(e) { dateStr = ''; }
    if (existing) {
      // Update existing entry (preserve user-edited fields: beneficiary, tags additions)
      existing.projectName = (analysis.meta && analysis.meta.title) || _t('riskAnalysis.pdfUntitled', 'Sans titre'); // repli écrit dans la donnée : fige la langue du rédacteur (déjà le cas, en français, avant ce correctif)
      existing.reportNumber = (analysis.meta && analysis.meta.reportNumber) || '';
      existing.description = (analysis.context && (analysis.context.perimeter || analysis.context.description)) || '';
      existing.completionDate = dateStr;
      existing.status = prStatus;
      if (!(opts && opts.skipRecommendations)) existing.recommendations = mergeProjectRecommendations(existing.recommendations, recs);
      existing.nextReviewDate = (analysis.meta && analysis.meta.nextReviewDate) || null;
      // Keep beneficiary and user-edited priority
    } else {
      // Create new entry
      pr.push({
        id: 'proj-risk-' + Math.random().toString(36).slice(2, 10),
        projectName: (analysis.meta && analysis.meta.title) || _t('riskAnalysis.pdfUntitled', 'Sans titre'), // idem : réserve donnée assumée
        beneficiary: '',
        reportNumber: (analysis.meta && analysis.meta.reportNumber) || '',
        revisionNumber: 0,
        description: (analysis.context && (analysis.context.perimeter || analysis.context.description)) || '',
        analysisLink: '#risk-analysis:' + analysis.id,
        completionDate: dateStr,
        status: prStatus,
        priority: '',
        recommendations: recs,
        sourceAnalysisId: analysis.id,
        nextReviewDate: (analysis.meta && analysis.meta.nextReviewDate) || null,
        note: ''
      });
    }
    // M1 : persister l'onglet project-risks muté (mode serveur).
    _persistAffected(['projectRisks']);
  }

  function removeProjectRiskForAnalysis(analysisId) {
    if (!_prMirrorWritable()) { _prMirrorSkipNotice(); return; }
    if (!window.appData.projectRisks) return;
    window.appData.projectRisks = window.appData.projectRisks.filter(function(r) {
      return r.sourceAnalysisId !== analysisId;
    });
    // M1 : persister la suppression du risque projet miroir (mode serveur).
    _persistAffected(['projectRisks']);
  }

  // ============================================================
  // WIZARD RENDER
  // ============================================================
  function renderWizard(container) {
    var a = findAnalysis(wizardState.analysisId);
    if (!a) { wizardState.analysisId = null; renderList(container); return; }
    var html = '<div class="ra-wizard">';
    // Sticky top wrapper
    html += '<div class="ra-wizard__sticky-top">';
    // Header
    html += '<div class="ra-wizard__header"><h3>' + _esc(a.meta.title || 'Analyse') + '</h3>';
    html += '<button class="btn btn--primary btn--sm ra-back-btn" onclick="raCloseWizard()"><i class="fas fa-arrow-left"></i> ' + _esc(_t('riskAnalysis.nav.backToList','Retour à la liste')) + '</button></div>';
    // Progress
    html += '<div class="ra-wizard__progress">';
    var stepLabels = [_t('riskAnalysis.steps.context','Contexte'),_t('riskAnalysis.steps.cia','CIA'),_t('riskAnalysis.steps.continuity','Continuité'),_t('riskAnalysis.steps.architecture','Architecture'),_t('riskAnalysis.steps.risks','Risques'),_t('riskAnalysis.steps.controls','Posture contrôles'),_t('riskAnalysis.steps.mesures','Mesures & Revue'),_t('riskAnalysis.steps.results','Résultats'),_t('riskAnalysis.steps.preview','Prévisualisation'),_t('riskAnalysis.steps.exportPdf','Export')];
    for (var i = 0; i < 10; i++) {
      if (i > 0) html += '<div class="ra-step-connector' + (i <= wizardState.step ? ' ra-step-connector--completed' : '') + '"></div>';
      var dotClass = i === wizardState.step ? 'ra-step-dot--active' : i < wizardState.step ? 'ra-step-dot--completed' : '';
      var labelClass = i === wizardState.step ? ' ra-step-label--active' : '';
      html += '<div class="ra-step-item" onclick="raGoStep(' + i + ')"><div class="ra-step-dot ' + dotClass + '">' + (i+1) + '</div><span class="ra-step-label' + labelClass + '">' + _esc(stepLabels[i]) + '</span></div>';
    }
    html += '</div>';
    html += '</div>'; // close sticky-top
    // Body
    html += '<div class="ra-wizard__body" id="raWizardBody"></div>';
    // Footer
    html += '<div class="ra-wizard__footer">';
    html += wizardState.step > 0 ? '<button class="btn btn--sm" onclick="raGoStep(' + (wizardState.step-1) + ')"><i class="fas fa-chevron-left"></i> ' + _esc(_t('riskAnalysis.nav.previous','Précédent')) + '</button>' : '<span></span>';
    html += wizardState.step < 9 ? '<button class="btn btn--primary btn--sm" onclick="raGoStep(' + (wizardState.step+1) + ')">' + _esc(_t('riskAnalysis.nav.next','Suivant')) + ' <i class="fas fa-chevron-right"></i></button>' : '<button class="btn btn--primary btn--sm" onclick="raFinish()">' + _esc(_t('riskAnalysis.nav.finish','Terminer')) + '</button>';
    html += '</div></div>';
    container.innerHTML = html;
    renderStepContent(a);
  }

  window.raCloseWizard = function() {
    saveCurrentStep();
    wizardState.analysisId = null;
    wizardState.step = 0;
    window.renderRiskAnalysisModule();
  };
  window.raGoStep = function(s) {
    saveCurrentStep();
    var a = findAnalysis(wizardState.analysisId);
    // Validate step 0: perimeter is required before advancing
    if (wizardState.step === 0 && s > 0 && a && !a.context.perimeter) {
      if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.perimeterRequired','Le périmètre est obligatoire.'), 'warning');
      return;
    }
    wizardState.step = _clamp(s, 0, 9);
    if (a && wizardState.step === 4) _raSeedRisksOnce(a); // FIX-RA-SEED-ON-ENTER (=== RA-UX B3 : l'étape Risques est désormais l'indice 4)
    if (a && (wizardState.step === 7 || wizardState.step === 8)) {
      // Le calcul reste fait hors édition : il alimente l'AFFICHAGE des étapes Résultats /
      // Prévisualisation (un lecteur doit les voir).
      a.results = computeAllResults(a);
      // FIX-RA-READONLY : en revanche la propagation vers projectRisks et le push serveur
      // sont des ÉCRITURES — sans verrou, elles partaient en 423 « Verrou d'édition non
      // détenu » au simple fait de naviguer vers Résultats en consultation.
      if (!_ro()) {
        syncAnalysisToProjectRisks(a);
        _save();
      }
    }
    window.renderRiskAnalysisModule();
  };
  window.raFinish = function() {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('finish an analysis')) return; // FIX-RA-READONLY
    saveCurrentStep();
    var a = findAnalysis(wizardState.analysisId);
    // FIX-AUDIT-OVERRIDE (audit 2026-08-25) : l'UI annonce une justification OBLIGATOIRE pour
    // les corrections manuelles d'avis et de résiduel — rien ne l'exigeait, et un avis forcé
    // pouvait être finalisé sans aucune trace dans le rapport. On bloque la finalisation.
    if (a && a.spot) {
      var _needJustif = [];
      if (a.spot.avisOverrideLight && !(a.spot.avisOverrideJustification || '').trim()) {
        _needJustif.push(_t('riskAnalysis.spot.avisOverrideJustifLabel', 'Justification correction avis'));
      }
      if (a.spot.residualOverrideScore != null && !(a.spot.residualOverrideJustification || '').trim()) {
        _needJustif.push(_t('riskAnalysis.spot.residualOverrideJustifLabel', 'Justification correction résiduel'));
      }
      if (_needJustif.length) {
        if (typeof window.showToast2 === 'function') {
          window.showToast2(_t('riskAnalysis.spot.overrideJustifRequired', 'Justification obligatoire pour finaliser') + ' : ' + _needJustif.join(', '), 'error', 8000);
        }
        return;
      }
    }
    if (a) { a.meta.status = 'complete'; a.meta.updatedAt = _now(); a.results = computeAllResults(a); syncAnalysisToProjectRisks(a); _audit('edit', a.meta.title || a.meta.reportNumber || a.id, _t('riskAnalysis.audit.finished', 'Analyse finalisée') + (a.meta.reportNumber ? ' (' + a.meta.reportNumber + ')' : '')); _save(); if (typeof window.updateDashboard === 'function') window.updateDashboard(); }
    wizardState.analysisId = null; wizardState.step = 0; wizardState.resultTab = 'synthese'; // FIX-AUDIT-TAB
    if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.saved','Analyse sauvegardée.'), 'success');
    window.renderRiskAnalysisModule();
  };

  // ============================================================
  // STEP RENDERING
  // ============================================================
  function renderStepContent(a) {
    var body = document.getElementById('raWizardBody');
    if (!body) return;
    body.classList.remove('ra-step-slide-in');
    void body.offsetWidth;
    body.classList.add('ra-step-slide-in');
    var readOnly = typeof window.isReadOnlyMode === 'function' && window.isReadOnlyMode();
    // FIX-RA-READONLY (complété — audit co-édition 2026-08-05) : `disabled` seul fige l'état AU
    // RENDU. Or la résolution de grappe est ASYNCHRONE (_csiEnsureClusterForTab pose
    // _csiClusterReadOnly dans un .then) et le mode édition peut basculer sans re-rendu :
    // les étapes 0→5 restaient donc éditables alors que la main était perdue (saisie poussée
    // sans verrou → 423, travail perdu au re-pull). `data-readonly-lock` rend l'état VIVANT :
    // refreshReadOnlyLocks() active/désactive ces champs sans re-rendre l'assistant — c'est
    // déjà ce que font les étapes 6 et 7 depuis l'audit du 2026-07-17.
    var dis = _stepDis();
    switch (wizardState.step) {
      case 0: renderStepContext(body, a, dis); break;
      case 1: renderStepCIA(body, a, dis); break;
      case 2: renderStepContinuity(body, a, dis); break;
      case 3: renderStepArchitecture(body, a, dis); break;
      case 4: renderStepRisks(body, a, dis); break;   // === RA-UX (B3) === risques d'abord
      case 5: renderStepControls(body, a, dis); break;
      case 6: renderStepMesures(body, a); break;
      case 7: renderStepResults(body, a); break;
      case 8: renderStepPreview(body, a); break;
      case 9: renderStepExport(body, a); break;
    }
  }

  // === RA-ACTIFS (2026-09) === libellé d'actif : MIROIR local de getCriticalAssetLabel
  // (bundle.js, interne à son IIFE — aucune exposition window, vérifié à l'audit du plan).
  // À maintenir en phase avec l'original : code — nom, replis bénéficiaire puis id.
  function _raAssetLabel(asset) {
    if (!asset) return '';
    var code = (typeof asset.productCode === 'string') ? asset.productCode.trim() : '';
    var name = (typeof asset.productName === 'string') ? asset.productName.trim() : '';
    var parts = [];
    if (code) parts.push(code);
    if (name) parts.push(name);
    if (parts.length) return parts.join(' — ');
    if (typeof asset.beneficiary === 'string' && asset.beneficiary.trim()) return asset.beneficiary.trim();
    return asset.id || '';
  }
  function _raAssetInventory() {
    return (window.appData && Array.isArray(window.appData.criticalAssets)) ? window.appData.criticalAssets : [];
  }
  // Valeur d'attribut d'actif (classification/intégrité) résolue en libellé : les clés énum
  // passent par les clés i18n du bundle (assetConfidentiality.*, assetIntegrity.*) ; une
  // valeur legacy libre (« Sensible ») est affichée telle quelle — même contrat que
  // resolveCriticalAssetLabel côté bundle.
  function _raAssetAttrLabel(prefix, value) {
    if (!value || typeof value !== 'string') return '';
    return _t(prefix + '.' + value, value);
  }
  // Chips + select natif (pattern _frRenderRiskLinks) : le périmètre de l'analyse référence
  // l'inventaire d'actifs critiques — DÉCLARATIF, aucun effet sur la cotation. Ids orphelins
  // (actif supprimé, ou vue consolidée d'une autre partition) filtrés silencieusement au
  // rendu (D9/F5 — jamais l'id brut), jamais nettoyés en cascade inter-grappes.
  function _raRenderAssetLinks(a, dis) {
    var assets = _raAssetInventory();
    var ids = Array.isArray(a.criticalAssetIds) ? a.criticalAssetIds : [];
    var html = '<div class="ra-form-group" id="raAssetLinks"><label class="ra-form-label">' + _esc(_t('riskAnalysis.context.linkedAssets', 'Actifs critiques concernés')) + '</label>';
    html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.context.linkedAssetsHint', 'Référence l\'inventaire « Actifs critiques » (valeurs métier) — lien déclaratif, sans effet sur la cotation.')) + '</div>';
    if (!assets.length && !ids.length) {
      html += '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.context.noAssets', 'Inventaire vide : renseignez l\'onglet Actifs critiques pour pouvoir les lier ici.')) + '</p></div>';
      return html;
    }
    html += '<div class="ra-fr-links__chips">';
    ids.forEach(function(aid) {
      var asset = null;
      for (var i = 0; i < assets.length; i++) { if (assets[i] && assets[i].id === aid) { asset = assets[i]; break; } }
      if (!asset) return; // orphelin : toléré, filtré au rendu (jamais réécrit ici)
      html += '<span class="ra-fr-chip">' + _esc(_raAssetLabel(asset));
      html += '<button type="button" class="ra-fr-chip__remove" data-aid="' + _esc(aid) + '" onclick="raToggleAnalysisAsset(this.getAttribute(\'data-aid\'))" title="' + _esc(_t('common.delete', 'Delete')) + '"' + dis + '>&times;</button></span>';
    });
    html += '</div>';
    var opts = '';
    assets.forEach(function(asset) {
      if (!asset || !asset.id) return;
      if (ids.indexOf(asset.id) >= 0) return;
      opts += '<option value="' + _esc(asset.id) + '">' + _esc(_raAssetLabel(asset)) + '</option>';
    });
    if (opts) {
      html += '<select class="ra-fr-links__select" onchange="if(this.value){raToggleAnalysisAsset(this.value);}"' + dis + '>';
      html += '<option value="">' + _esc(_t('riskAnalysis.context.linkAsset', '+ Lier un actif critique…')) + '</option>' + opts + '</select>';
    }
    html += '</div>';
    return html;
  }
  // Actifs liés RÉSOLUS (orphelins exclus) — utilisé par l'écho CIA, l'aperçu et le PDF.
  function _raLinkedAssets(a) {
    var assets = _raAssetInventory();
    var ids = Array.isArray(a && a.criticalAssetIds) ? a.criticalAssetIds : [];
    var out = [];
    ids.forEach(function(aid) {
      for (var i = 0; i < assets.length; i++) {
        if (assets[i] && assets[i].id === aid) { out.push(assets[i]); return; }
      }
    });
    return out;
  }

  // Step 0: Context
  function renderStepContext(body, a, dis) {
    var c = a.context || {};
    var ident = a.identity || {};
    // L2 (étude 2026-08-04 §3) — deux notions distinctes, enfin séparées :
    // MÉTHODE d'analyse (pilote la matrice d'impact) × CATALOGUE de risques
    // (pilote la grille de l'étape Risques, servi par les packs). L'ancien
    // champ unique mélangeait les deux et n'avait AUCUN effet.
    var _raMethods = [
      { id: '', label: _t('riskAnalysis.context.methodDefault', 'Approche pragmatique Cyber-Assistant') },
      { id: 'ebios-iso27005', label: _t('riskAnalysis.context.methodEbios', 'EBIOS RM (compatible ISO 27005)') },
      { id: 'nist-800-30', label: _t('riskAnalysis.context.methodNist', 'NIST SP 800-30') }
    ];
    var _raMethodOptions = '';
    _raMethods.forEach(function(m) {
      _raMethodOptions += '<option value="' + m.id + '"' + ((c.analysisMethod || '') === m.id ? ' selected' : '') + '>' + _esc(m.label) + '</option>';
    });
    var _raCatMatched = !c.sourceFramework;
    var _raFwOptions = '<option value="">' + _esc(_t('riskAnalysis.context.catalogBuiltin', 'Catalogue générique Cyber-Assistant')) + '</option>';
    if (window.CSI_ThreatCatalog) {
      window.CSI_ThreatCatalog.list().forEach(function(m) {
        var sel = c.sourceFramework === m.id;
        if (sel) _raCatMatched = true;
        var vLabel = (m.name || m.id) + (m.version ? ' (' + m.version + ')' : '') + ' — ' + _t('riskAnalysis.context.catalogIntegrated', 'catalogue intégré');
        _raFwOptions += '<option value="' + _esc(m.id) + '"' + (sel ? ' selected' : '') + '>' + _esc(vLabel) + '</option>';
      });
    }
    if (c.sourceFramework && !_raCatMatched) {
      // Valeur legacy (libellé libre d'avant les packs) : conservée et affichée
      // telle quelle — citée dans le rapport, sans effet de filtre.
      _raFwOptions += '<option value="' + _esc(c.sourceFramework) + '" selected>' + _esc(c.sourceFramework) + ' — ' + _esc(_t('riskAnalysis.context.catalogLegacy', 'cité dans le rapport')) + '</option>';
    }
    body.innerHTML =
      '<h3>' + _esc(_t('riskAnalysis.identity.sectionTitle','Identité du projet')) + '</h3>' +
      fg(_t('riskAnalysis.identity.beneficiary','Nom du bénéficiaire/client'), '<input type="text" id="raBeneficiary" value="' + _esc(ident.beneficiary) + '"' + dis + '>') +
      fg(_t('riskAnalysis.identity.controlBody','Nom de l\'organisme de contrôle'), '<input type="text" id="raControlBody" value="' + _esc(ident.controlBody) + '"' + dis + '>') +
      fg(_t('riskAnalysis.identity.projectManager','Nom du chef de projet'), '<input type="text" id="raProjectManager" value="' + _esc(ident.projectManager) + '"' + dis + '>') +
      fg(_t('riskAnalysis.identity.encoder','Encodage de l\'analyse (Nom - Prénom)'), '<input type="text" id="raEncoder" value="' + _esc(ident.encoder) + '"' + dis + '>') +
      fg(_t('riskAnalysis.identity.validator','Validation de l\'analyse (Nom - Prénom)'), '<input type="text" id="raValidator" value="' + _esc(ident.validator) + '"' + dis + '>') +
      '<hr class="ra-section-divider" style="margin:var(--spacing-lg) 0;border:none;border-top:1px solid var(--color-border,#e9ecef)">' +
      '<h3>' + _esc(_t('riskAnalysis.steps.context','Contexte')) + '</h3>' +
      '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.context.subtitle','Décrivez le contexte et le périmètre de l\'analyse.')) + '</p>' +
      fg(_t('riskAnalysis.context.titleField','Titre'), '<input type="text" id="raTitle" value="' + _esc(a.meta.title) + '"' + dis + '>', true) +
      fg(_t('riskAnalysis.context.perimeter','Périmètre'), '<textarea id="raPerimeter" placeholder="' + _esc(_t('riskAnalysis.context.perimeterPlaceholder','Ex: Application web de gestion des stocks, serveurs et bases de données.')) + '"' + dis + '>' + _esc(c.perimeter) + '</textarea>', true, _t('riskAnalysis.context.perimeterHint','')) +
      _raRenderAssetLinks(a, dis) + // === RA-ACTIFS === périmètre déclaré depuis l'inventaire
      fg(_t('riskAnalysis.context.description','Description'), '<textarea id="raDesc" placeholder="' + _esc(_t('riskAnalysis.context.descriptionPlaceholder','Ex: Analyse réalisée dans le cadre de la mise en conformité ISO 27001.')) + '"' + dis + '>' + _esc(c.description) + '</textarea>') +
      fg(_t('riskAnalysis.context.stakeholders','Parties prenantes'), '<input type="text" id="raStake" value="' + _esc(c.stakeholders) + '"' + dis + '>', false, _t('riskAnalysis.context.stakeholdersHint','')) +
      fg(_t('riskAnalysis.context.analysisMethod','Méthode d\'analyse'), '<select id="raMethod" onchange="raMethodChanged()"' + dis + '>' + _raMethodOptions + '</select>', false, _t('riskAnalysis.context.analysisMethodHint','Présélectionne la matrice d\'impact correspondante ; le choix de matrice reste modifiable.')) +
      fg(_t('riskAnalysis.context.sourceFramework','Catalogue de risques'), '<select id="raSourceFramework"' + dis + '>' + _raFwOptions + '</select>', false, _t('riskAnalysis.context.sourceFrameworkHint','Alimente les risques proposés à l\'étape Risques. « Catalogue intégré » = entrées fournies ; sinon la méthode est simplement citée dans le rapport.')) +
      ((c.analysisMethod === 'ebios-iso27005' && window._csiEbiosArchetypes) ? (function() {
        // L4 — atelier 2 EBIOS RM : couples source de risque × objectif visé.
        // CONTEXTE uniquement (jamais coté, jamais instancié en risque) — le
        // guide v1.5 recommande d'en retenir 3 à 6.
        var sel = c.riskSources || [];
        var h = '<div class="ra-form-group"><label>' + _esc(_t('riskAnalysis.context.ebiosCouples', 'Couples source de risque × objectif visé (EBIOS RM, atelier 2)')) + '</label>';
        h += '<div class="ra-ebios-couples">';
        (window._csiEbiosArchetypes.couples || []).forEach(function(cp) {
          var ck = sel.indexOf(cp.id) >= 0 ? ' checked' : '';
          h += '<label style="display:block;font-weight:normal;margin:2px 0"><input type="checkbox" class="ra-ebios-couple" value="' + _esc(cp.id) + '"' + ck + dis + '> ' + _esc(_bi(cp, 'label', 'labelEn')) + '</label>';
        });
        h += '</div><p class="ra-form-hint">' + _esc(_t('riskAnalysis.context.ebiosCouplesHint', 'Sélection recommandée : 3 à 6 couples (guide ANSSI v1.5). Repris dans l\'aperçu et le rapport, sans effet sur la cotation.')) + '</p></div>';
        return h;
      })() : '') +
      fg(_t('riskAnalysis.context.dataSensitivity','Sensibilité'), '<select id="raSensitivity"' + dis + '><option value="low"' + (c.dataSensitivity==='low'?' selected':'') + '>' + _esc(_t('riskAnalysis.context.sensitivityLow','Faible')) + '</option><option value="medium"' + (c.dataSensitivity==='medium'?' selected':'') + '>' + _esc(_t('riskAnalysis.context.sensitivityMedium','Moyenne')) + '</option><option value="high"' + (c.dataSensitivity==='high'?' selected':'') + '>' + _esc(_t('riskAnalysis.context.sensitivityHigh','Élevée')) + '</option></select>', true);
    // Impact matrix selector (only if module available)
    if (window.CSI_ImpactMatrices) {
      _ensureMatrixStore();
      var matrices = window.CSI_ImpactMatrices.listMatrices();
      if (matrices.length > 0) {
        var matOpts = '<option value="">' + _esc(_t('riskAnalysis.impactMatrix.noMatrix','Aucune (évaluation manuelle 1-5)')) + '</option>';
        var activeM = window.CSI_ImpactMatrices.getActiveMatrix();
        // FIX-RA-MATRIX-NONE : la valeur STOCKÉE fait foi — null = « Aucune » assumé
        // (le défaut « matrice active » est posé à la création, plus au rendu).
        var selectedMatId = a.impactMatrixId || '';
        matrices.forEach(function(m) {
          matOpts += '<option value="' + _esc(m.id) + '"' + (m.id === selectedMatId ? ' selected' : '') + '>' + _esc(m.name) + '</option>';
        });
        body.innerHTML += fg(
          _t('riskAnalysis.impactMatrix.selectMatrix','Matrice d\'impact'),
          '<select id="raMatrixSelect"' + dis + '>' + matOpts + '</select>',
          false,
          _t('riskAnalysis.impactMatrix.selectHint','Choisissez une matrice pour évaluer l\'impact de chaque risque selon plusieurs dimensions (ex: financier, réputation, juridique…). Si aucune n\'est sélectionnée, l\'impact sera évalué sur une simple échelle de 1 à 5.')
        );
      } else {
        body.innerHTML += '<div class="ra-info-hint" style="margin-top:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);background:var(--color-surface-secondary);border-radius:var(--border-radius-md,8px);font-size:0.85rem;color:var(--color-text-light)"><i class="fas fa-info-circle" style="margin-right:6px;color:var(--color-primary)"></i>' + _esc(_t('riskAnalysis.impactMatrix.configureHint','Vous pouvez configurer des matrices d\'impacts dans la section dédiée pour évaluer les risques selon plusieurs dimensions.')) + '</div>';
      }
    }
  }

  function fg(label, input, required, hint) {
    // FIX-AUDIT-A11Y (2026-08-25) : associer le label à son contrôle (for=) — l'id est
    // extrait du markup fourni, sans changer aucun appelant. Sans association, les lecteurs
    // d'écran n'annoncent pas le champ et le clic sur le libellé ne donne pas le focus.
    var m = /\bid="([^"]+)"/.exec(input || '');
    var forAttr = m ? ' for="' + _esc(m[1]) + '"' : '';
    return '<div class="ra-form-group"><label class="ra-form-label"' + forAttr + '>' + _esc(label) + (required ? ' <span class="ra-required">*</span>' : '') + '</label>' + (hint ? '<div class="ra-form-hint">' + _esc(hint) + '</div>' : '') + input + '</div>';
  }

  // Step 1: CIA
  function renderStepCIA(body, a, dis) {
    _ensureMatrixStore(); // === RA-UX (B1) === la matrice doit se résoudre en mode local vierge
    _ciaEnsureScale(a);
    var cia = a.cia || {};
    var dims = [
      { key: 'confidentiality', label: _t('riskAnalysis.cia.confidentiality','Confidentialité'), icon: 'fas fa-lock' },
      { key: 'integrity', label: _t('riskAnalysis.cia.integrity','Intégrité'), icon: 'fas fa-shield-halved' },
      { key: 'availability', label: _t('riskAnalysis.cia.availability','Disponibilité'), icon: 'fas fa-clock' }
    ];
    // === RA-UX (B1) === l'échelle vient de la matrice d'impacts de l'analyse ;
    // repli : les 3 crans historiques. FIX-AUDIT-B1 : échelle DES VALEURS — si la
    // migration est suspendue (lecture seule), on montre l'échelle de saisie, pas la cible.
    var scale = _ciaScaleForValues(a);
    var levels = scale.levels;
    var html = '<h3>' + _esc(_t('riskAnalysis.cia.title','Classification CIA')) + '</h3><p class="ra-form-hint">' + _esc(_t('riskAnalysis.cia.subtitle','')) + '</p>';
    if (scale.fromMatrix) {
      html += '<p class="ra-form-hint"><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.cia.scaleFromMatrix', 'Échelle : niveaux de gravité de la matrice d\'impacts') + ' « ' + scale.matrixName + ' »') + '</p>';
    }
    // === RA-ACTIFS (2026-09, D3) === écho LECTURE SEULE des attributs des actifs liés —
    // libellés uniquement, jamais de rang numérique (l'échelle de la fiche actif n'est PAS
    // celle de la matrice : afficher « 3/4 » à côté d'un C coté sur 5 se lirait comme une
    // contradiction). Pas d'écho D : la « classe de reprise » de la fiche est une exigence
    // de PCA, pas un besoin de disponibilité. La classification reste l'attribut statique
    // de l'actif (ISO 5.12) ; la cotation CIA ci-dessous est la valuation de CETTE étude.
    (function() {
      var linked = _raLinkedAssets(a);
      if (!linked.length) return;
      var rows = '';
      linked.forEach(function(asset) {
        var attrs = [];
        var conf = _raAssetAttrLabel('assetConfidentiality', asset.confidentiality);
        var integ = _raAssetAttrLabel('assetIntegrity', asset.integrity);
        if (conf) attrs.push(_t('riskAnalysis.cia.assetConf', 'Classification :') + ' ' + conf);
        if (integ) attrs.push(_t('riskAnalysis.cia.assetInteg', 'Intégrité :') + ' ' + integ);
        rows += '<li>' + _esc(_raAssetLabel(asset)) + (attrs.length ? ' — ' + _esc(attrs.join(' · ')) : '') + '</li>';
      });
      html += '<div class="ra-info-hint" id="raCiaAssetEcho" style="margin:var(--spacing-sm) 0;padding:var(--spacing-sm) var(--spacing-md);background:var(--color-surface-secondary);border-radius:var(--border-radius-md,8px);font-size:0.85rem;color:var(--color-text-light)">';
      html += '<i class="fas fa-cubes" style="margin-right:6px;color:var(--color-primary)"></i>' + _esc(_t('riskAnalysis.cia.assetEchoTitle', 'Actifs liés (attributs de l\'inventaire, lecture seule) :'));
      html += '<ul style="margin:4px 0 0 18px;padding:0">' + rows + '</ul>';
      html += '<p class="ra-form-hint" style="margin:4px 0 0">' + _esc(_t('riskAnalysis.cia.assetEchoHint', 'La classification est l\'attribut permanent de l\'actif ; la cotation ci-dessous est la valuation propre à cette analyse, sur l\'échelle de sa matrice.')) + '</p></div>';
    })();
    html += '<div class="ra-cia-grid">';
    dims.forEach(function(d) {
      html += '<div class="ra-cia-card"><i class="' + d.icon + '"></i><h4>' + _esc(d.label) + '</h4><div class="ra-cia-options">';
      levels.forEach(function(l) {
        var sel = (cia[d.key] === l.v) ? ' ra-cia-option--selected' : '';
        html += '<div class="ra-cia-option' + sel + '" data-cia="' + d.key + '" data-val="' + l.v + '" onclick="raCiaSelect(this)"' + _KB_ACT + _roFreeze() + '><strong>' + _esc(l.label) + '</strong>' + (l.desc ? ' — ' + _esc(l.desc) : '') + '</div>';
      });
      html += '</div></div>';
    });
    html += '</div>' + fg(_t('riskAnalysis.cia.justification','Justification'), '<textarea id="raCiaJustif"' + dis + '>' + _esc(cia.justification || '') + '</textarea>');
    // RGPD block
    var gdpr = a.gdpr || {};
    html += '<hr class="ra-section-divider">';
    html += '<h3><i class="fas fa-user-shield"></i> ' + _esc(_t('riskAnalysis.gdpr.title','RGPD — Protection des données')) + '</h3>';
    html += '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.gdpr.subtitle','Évaluez les implications en matière de protection des données personnelles.')) + '</p>';
    html += '<div class="ra-gdpr-block">';
    html += '<div class="ra-gdpr-item"><label><input type="checkbox" id="raGdprPersonal"' + (gdpr.personalData ? ' checked' : '') + dis + '> ' + _esc(_t('riskAnalysis.gdpr.personalData','Présence de données à caractère personnel')) + '</label>';
    html += '<p class="ra-gdpr-explain">' + _esc(_t('riskAnalysis.gdpr.personalDataExplain','Toute information se rapportant à une personne physique identifiée ou identifiable (article 4 RGPD).')) + '</p></div>';
    html += '<div class="ra-gdpr-item"><label><input type="checkbox" id="raGdprSensitive"' + (gdpr.sensitiveData ? ' checked' : '') + dis + '> ' + _esc(_t('riskAnalysis.gdpr.sensitiveData','Présence de données à caractère sensible')) + '</label>';
    html += '<p class="ra-gdpr-explain">' + _esc(_t('riskAnalysis.gdpr.sensitiveDataExplain','Données révélant l\'origine raciale/ethnique, opinions politiques, données de santé, etc. (article 9 RGPD).')) + '</p></div>';
    html += '<div class="ra-gdpr-item"><label>' + _esc(_t('riskAnalysis.gdpr.processingRegistry','Registre des traitements réalisé pour ce projet ?')) + '</label>';
    html += '<select id="raGdprRegistry"' + dis + '>';
    html += '<option value="no"' + (gdpr.processingRegistry === 'no' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.gdpr.registryNo','Non')) + '</option>';
    html += '<option value="yes"' + (gdpr.processingRegistry === 'yes' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.gdpr.registryYes','Oui')) + '</option>';
    html += '<option value="planned"' + (gdpr.processingRegistry === 'planned' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.gdpr.registryPlanned','Planifié')) + '</option>';
    html += '</select>';
    html += '<p class="ra-gdpr-explain">' + _esc(_t('riskAnalysis.gdpr.processingRegistryExplain','Le registre des activités de traitement (article 30 RGPD) est obligatoire pour les organismes de plus de 250 salariés.')) + '</p></div>';
    html += '</div>';
    body.innerHTML = html;
  }
  window.raCiaSelect = function(el) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var key = el.getAttribute('data-cia'), val = parseInt(el.getAttribute('data-val'));
    el.parentNode.querySelectorAll('.ra-cia-option').forEach(function(o) { o.classList.remove('ra-cia-option--selected'); });
    el.classList.add('ra-cia-option--selected');
    var a = findAnalysis(wizardState.analysisId);
    if (a) { if (!a.cia) a.cia = {}; a.cia[key] = val; _save(); }
  };

  // Step 2: Continuity
  function renderStepContinuity(body, a, dis) {
    var c = a.continuity || {};
    function rtoLabel(h) { return h <= 4 ? _t('riskAnalysis.continuity.strict','Très strict') : h <= 8 ? _t('riskAnalysis.continuity.moderate','Strict') : h <= 24 ? _t('riskAnalysis.continuity.standard','Standard') : _t('riskAnalysis.continuity.relaxed','Souple'); }
    function rtoClass(h) { return h <= 8 ? 'strict' : h <= 24 ? 'moderate' : 'relaxed'; }
    body.innerHTML = '<h3>' + _esc(_t('riskAnalysis.continuity.title','Continuité')) + '</h3><p class="ra-form-hint">' + _esc(_t('riskAnalysis.continuity.subtitle','')) + '</p><div class="ra-rto-grid">' +
      '<div class="ra-rto-card"><h4>RTO</h4>' + fg(_t('riskAnalysis.continuity.rtoLabel','RTO (heures)'), '<input type="number" id="raRto" min="0" value="' + (c.rtoHours||24) + '" oninput="raUpdateRtoIndicator()"' + dis + '>', false, _t('riskAnalysis.continuity.rtoHint','')) + '<div id="raRtoInd" class="ra-rto-indicator ra-rto-indicator--' + rtoClass(c.rtoHours||24) + '">' + _esc(rtoLabel(c.rtoHours||24)) + '</div></div>' +
      '<div class="ra-rto-card"><h4>RPO</h4>' + fg(_t('riskAnalysis.continuity.rpoLabel','RPO (heures)'), '<input type="number" id="raRpo" min="0" value="' + (c.rpoHours||24) + '" oninput="raUpdateRtoIndicator()"' + dis + '>', false, _t('riskAnalysis.continuity.rpoHint','')) + '<div id="raRpoInd" class="ra-rto-indicator ra-rto-indicator--' + rtoClass(c.rpoHours||24) + '">' + _esc(rtoLabel(c.rpoHours||24)) + '</div></div>' +
      '</div>' +
      '<div class="ra-form-group" style="margin-top:var(--spacing-md)"><label class="ra-form-label">' + _esc(_t('riskAnalysis.continuity.commentLabel','Commentaire continuité')) + '</label>' +
      '<textarea id="raContinuityComment" rows="3" class="ra-form-textarea" placeholder="' + _esc(_t('riskAnalysis.continuity.commentPlaceholder','Justification des objectifs RTO/RPO, contraintes métier...')) + '"' + dis + '>' + _esc(c.comment || '') + '</textarea></div>';
  }
  window.raUpdateRtoIndicator = function() {
    function upd(inputId, indId) {
      var el = document.getElementById(inputId);
      var ind = document.getElementById(indId);
      if (!el || !ind) return;
      var _pv = parseInt(el.value, 10); var v = isNaN(_pv) ? 24 : Math.max(0, _pv); // FIX-AUDIT-RTO : 0 h est une valeur légitime
      var cls = v <= 8 ? 'strict' : v <= 24 ? 'moderate' : 'relaxed';
      var lbl = v <= 4 ? _t('riskAnalysis.continuity.strict','Très strict') : v <= 8 ? _t('riskAnalysis.continuity.moderate','Strict') : v <= 24 ? _t('riskAnalysis.continuity.standard','Standard') : _t('riskAnalysis.continuity.relaxed','Souple');
      ind.className = 'ra-rto-indicator ra-rto-indicator--' + cls;
      ind.textContent = lbl;
    }
    upd('raRto','raRtoInd'); upd('raRpo','raRpoInd');
  };

  // Step 3: Documentation (was Architecture)
  function renderStepArchitecture(body, a, dis) {
    var arch = a.architecture || {};
    var iam = arch.iam || {};
    body.innerHTML = '<h3>' + _esc(_t('riskAnalysis.architecture.title','Documentation')) + '</h3>' +
      '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.architecture.subtitle','Documentation technique et gestion des identités.')) + '</p>' +
      '<h4 class="ra-subsection-title">' + _esc(_t('riskAnalysis.architecture.techArchTitle','Architecture technique')) + '</h4>' +
      fg(_t('riskAnalysis.architecture.hosting','Hébergement'), '<select id="raHosting"' + dis + '><option value="onprem"' + (arch.hosting==='onprem'?' selected':'') + '>' + _esc(_t('riskAnalysis.architecture.hostingOnprem','On-premise')) + '</option><option value="cloud"' + (arch.hosting==='cloud'?' selected':'') + '>' + _esc(_t('riskAnalysis.architecture.hostingCloud','Cloud')) + '</option><option value="hybrid"' + (arch.hosting==='hybrid'?' selected':'') + '>' + _esc(_t('riskAnalysis.architecture.hostingHybrid','Hybride')) + '</option></select>') +
      '<div class="ra-checkbox-group"><input type="checkbox" id="raInternet"' + (arch.internetExposed?' checked':'') + dis + '><label for="raInternet">' + _esc(_t('riskAnalysis.architecture.internetExposed','Exposé Internet')) + '</label></div>' +
      '<h4 class="ra-subsection-title">' + _esc(_t('riskAnalysis.architecture.iamTitle','Gestion des identités (IAM)')) + '</h4>' +
      '<div class="ra-checkbox-group"><input type="checkbox" id="raSso"' + (iam.sso?' checked':'') + dis + '><label for="raSso">' + _esc(_t('riskAnalysis.architecture.sso','SSO')) + '</label></div>' +
      '<div class="ra-checkbox-group"><input type="checkbox" id="raMfa"' + (iam.mfa?' checked':'') + dis + '><label for="raMfa">' + _esc(_t('riskAnalysis.architecture.mfa','MFA')) + '</label></div>' +
      '<div class="ra-checkbox-group"><input type="checkbox" id="raAdminSep"' + (iam.adminSeparation?' checked':'') + dis + '><label for="raAdminSep">' + _esc(_t('riskAnalysis.architecture.adminSeparation','Séparation admin')) + '</label></div>' +
      '<h4 class="ra-subsection-title">' + _esc(_t('riskAnalysis.architecture.architectureDocTitle','Architecture')) + '</h4>' +
      '<div class="ra-checkbox-group"><input type="checkbox" id="raArchDoc"' + (arch.architectureDoc?' checked':'') + dis + '><label for="raArchDoc">' + _esc(_t('riskAnalysis.architecture.architectureDoc','Dossier d\'architecture complété')) + '</label></div>';
  }

  // Step 4: Controls
  function renderStepControls(body, a, dis) {
    if (!a.controls || Object.keys(a.controls).length === 0) {
      a.controls = {};
      RA_CONTROLS_SEED.forEach(function(q) { a.controls[q.id] = { status: 'no', comment: '' }; });
    }
    var html = '<h3>' + _esc(_t('riskAnalysis.controls.title','Posture des contrôles')) + '</h3><p class="ra-form-hint">' + _esc(_t('riskAnalysis.controls.subtitle','')) + '</p>';
    // === RA-UX (A7) === glossaire : posture/maturité ici, efficacité face à un risque
    // dans le traitement — l'ambiguïté « Contrôles = mitigations » revenait en retours clients.
    html += '<p class="ra-form-hint"><i class="fas fa-circle-info"></i> ' + _esc(_t('riskAnalysis.controls.glossaryHint','Ce questionnaire mesure la maturité d\'implémentation des contrôles (posture globale de sécurité). L\'efficacité d\'une mesure face à un risque donné s\'apprécie, elle, dans le traitement de ce risque — ce ne sont pas les mitigations.')) + '</p>';
    RA_DOMAINS.forEach(function(d) {
      var qs = RA_CONTROLS_SEED.filter(q => q.domainId === d.id);
      var answered = qs.filter(q => a.controls[q.id] && a.controls[q.id].status !== 'no').length;
      var progressClass = answered === qs.length ? ' ra-domain-progress--complete' : '';
      html += '<div class="ra-domain-section"><div class="ra-domain-header" onclick="raToggleDomain(this, event)">';
      html += '<div class="ra-domain-header__left"><i class="' + d.icon + '"></i> ' + _esc(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn'))) + '</div>';
      html += '<div><span class="ra-domain-progress' + progressClass + '">' + answered + '/' + qs.length + '</span> <i class="fas fa-chevron-down ra-domain-chevron"></i></div>';
      html += '</div><div class="ra-domain-body">';
      qs.forEach(function(q) {
        var ctrl = a.controls[q.id] || { status: 'no', comment: '' };
        var statuses = [
          { key: 'implemented', label: _t('riskAnalysis.controls.implemented','Implémenté'), cls: 'ra-answer-btn--implemented' },
          { key: 'partial', label: _t('riskAnalysis.controls.partial','Partiel'), cls: 'ra-answer-btn--partial' },
          { key: 'planned', label: _t('riskAnalysis.controls.planned','Planifié'), cls: 'ra-answer-btn--planned' },
          { key: 'toVerify', label: _t('riskAnalysis.controls.toVerify','À vérifier'), cls: 'ra-answer-btn--toVerify' },
          { key: 'no', label: _t('riskAnalysis.controls.no','Non'), cls: 'ra-answer-btn--no' },
          { key: 'na', label: _t('riskAnalysis.controls.na','N/A'), cls: 'ra-answer-btn--na' }
        ];
        // === RA-UX (B4) === objectifs protégés par le contrôle (C/I/A), informatif.
        var _qCia = (q.cia && q.cia.length) ? ' <span class="ra-cia-badge" title="' + _esc(_t('riskAnalysis.controls.ciaProtects', 'Objectifs de sécurité protégés')) + '">' + _esc(q.cia.join('·')) + '</span>' : '';
        html += '<div class="ra-question"><div class="ra-question__text">' + _esc(_bi(q, 'text', 'textEn')) + _qCia + '</div>';
        if (q.hint || q.hintEn) html += '<div class="ra-question__hint">' + _esc(_bi(q, 'hint', 'hintEn')) + '</div>';
        html += '<div class="ra-answer-group">';
        statuses.forEach(function(s) {
          var sel = ctrl.status === s.key ? ' ra-answer-btn--selected' : '';
          html += '<button class="ra-answer-btn ' + s.cls + sel + '" onclick="raSetControl(\'' + q.id + '\',\'' + s.key + '\',this)"' + dis + '>' + _esc(s.label) + '</button>';
        });
        html += '</div>';
        html += '<div class="ra-question__comment"><textarea placeholder="' + _esc(_t('riskAnalysis.controls.comment','Commentaire')) + '" data-ctrl="' + q.id + '" onchange="raSetComment(\'' + q.id + '\',this.value)"' + dis + '>' + _esc(ctrl.comment || '') + '</textarea></div>';
        html += '</div>';
      });
      html += '</div></div>';
    });
    body.innerHTML = html;
  }
  window.raToggleDomain = function(el, e) {
    if (e && e.target && e.target.closest && e.target.closest('.ra-answer-btn, button, textarea, input')) return;
    el.classList.toggle('ra-domain-header--expanded');
    var b = el.nextElementSibling;
    b.classList.toggle('ra-domain-body--visible');
  };
  window.raSetControl = function(qId, status, btn) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    if (!btn) return;
    if (!a.controls[qId]) a.controls[qId] = { status: 'no', comment: '' };
    a.controls[qId].status = status;
    // Auto-comment for planned status
    if (status === 'planned' && !a.controls[qId].comment) {
      var autoComment = _t('riskAnalysis.controls.plannedAutoComment', '[Planifié] Implémentation prévue — justification : ');
      a.controls[qId].comment = autoComment;
      var ta = btn.closest('.ra-question').querySelector('textarea[data-ctrl="' + qId + '"]');
      if (ta) ta.value = autoComment;
    }
    _save();
    btn.parentNode.querySelectorAll('.ra-answer-btn').forEach(function(b) { b.classList.remove('ra-answer-btn--selected'); });
    btn.classList.add('ra-answer-btn--selected');
    // Update progress
    var domId = RA_CONTROLS_SEED.find(q => q.id === qId).domainId;
    var section = btn.closest('.ra-domain-section');
    if (section) {
      var qs = RA_CONTROLS_SEED.filter(q => q.domainId === domId);
      var answered = qs.filter(q => a.controls[q.id] && a.controls[q.id].status !== 'no').length;
      var badge = section.querySelector('.ra-domain-progress');
      if (badge) { badge.textContent = answered + '/' + qs.length; badge.className = 'ra-domain-progress' + (answered === qs.length ? ' ra-domain-progress--complete' : ''); }
    }
  };
  window.raSetComment = function(qId, val) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    if (!a.controls[qId]) a.controls[qId] = { status: 'no', comment: '' };
    a.controls[qId].comment = val;
    _save();
  };

  // Step 5: Risks
  // FIX-RA-SEED-ON-ENTER (audit 2026-08-17, wizard#3/catalogue#1) : la présélection vivait
  // dans le RENDU de l'étape 5 — chaque affichage d'une liste vide ré-injectait des risques
  // (registre impossible à vider, risques fantômes chez un simple lecteur, écriture depuis
  // un chemin de lecture). Elle se joue désormais UNE FOIS, à l'ENTRÉE dans l'étape, en
  // édition seulement, et se mémorise sur l'analyse (_risksSeeded).
  function _raSeedRisksOnce(a) {
    if (!a || a._risksSeeded) return;
    if (!a.risks) a.risks = [];
    if (a.risks.length > 0) { a._risksSeeded = true; return; } // analyse existante déjà peuplée
    if (_ro()) return; // lecteur : jamais de mutation — l'étape montre l'état réel (vide)
    var preselect = ['RISK_RANSOM','RISK_LOG_GAP'];
    if (a.architecture && a.architecture.internetExposed) preselect.push('RISK_EXT_ATTACK','RISK_MISCONFIG','RISK_DDOS');
    if (a.context && (a.context.dataSensitivity === 'high' || a.context.personalData)) preselect.push('RISK_DATA_LEAK','RISK_PRIVACY');
    preselect.forEach(function(cid) {
      // L1 : garde d'existence + snapshot dénormalisé figé à la sélection.
      var pcat = RA_RISK_CATALOG.find(function(c) { return c.id === cid; });
      if (!pcat) return;
      a.risks.push(Object.assign({ id: _id(), catalogId: cid, likelihood: 3, impact: 3, impactBreakdown: null, treatmentDecision: 'reduce', owner: '', notes: '',
        ciaTargets: (pcat.ciaTargets || []).slice() }, _raSnapshotFields(pcat))); // === RA-UX (B4) ===
    });
    a._risksSeeded = true;
    _save();
  }

  function renderStepRisks(body, a, dis) {
    if (!a.risks) a.risks = [];
    _ensureMatrixStore();
    var selected = {};
    // FIX-AUDIT-UX (2026-08-25) : porter l'ÉTAT ('na' ou true), pas un booléen — le 1er clic
    // sur une carte sélectionnée bascule en « non applicable » et le 2e supprime : sans
    // marqueur visuel, l'utilisateur croit avoir désélectionné.
    a.risks.forEach(function(r) { if (r.catalogId) selected[r.catalogId] = (r.treatmentDecision === 'na') ? 'na' : true; });
    // Owner datalist from identity fields + stakeholders (FIX-STAKEHOLDER-CASCADE, audit 2026-07-10)
    var ident = a.identity || {};
    var ownerNames = [ident.encoder, ident.validator, ident.projectManager, ident.beneficiary].filter(Boolean);
    ((window.appData && window.appData.stakeholders) || []).forEach(function(st) {
      if (st && st.name) ownerNames.push(st.name);
    });
    var uniqueOwners = ownerNames.filter(function(v, i, arr) { return arr.indexOf(v) === i; });
    var html = '<datalist id="raOwnerSuggestions">';
    uniqueOwners.forEach(function(n) { html += '<option value="' + _esc(n).replace(/"/g,'&quot;') + '">'; });
    html += '</datalist>';
    html += '<h3>' + _esc(_t('riskAnalysis.risks.title','Risques')) + '</h3><p class="ra-form-hint">' + _esc(_t('riskAnalysis.risks.subtitle','')) + '</p>';
    html += '<h4>' + _esc(_t('riskAnalysis.risks.selectFromCatalog','Catalogue')) + '</h4><div class="ra-risk-catalog">';
    // L2 — le catalogue choisi à l'étape Contexte alimente réellement la grille :
    // entrées du pack d'abord (avec leurs defaults), catalogue générique ensuite.
    var _gridPackId = (a.context || {}).sourceFramework || '';
    var _gridPack = (_gridPackId && window.CSI_ThreatCatalog) ? window.CSI_ThreatCatalog.entriesFor(_gridPackId) : [];
    var _packSel = {};
    a.risks.forEach(function(r) { if (r.packId) _packSel[r.scenarioId || r.threatId] = (r.treatmentDecision === 'na') ? 'na' : true; }); // FIX-AUDIT-UX : idem cartes de pack
    _gridPack.forEach(function(e) {
      var selStateP = _packSel[e.id];
      var sel = selStateP ? (' ra-risk-card--selected' + (selStateP === 'na' ? ' ra-risk-card--na' : '')) : '';
      html += '<div class="ra-risk-card' + sel + '" onclick="raTogglePackRisk(\'' + _esc(_gridPackId) + '\',\'' + _esc(e.id) + '\')"' + _KB_ACT + _roFreeze() + '>';
      html += '<div class="ra-risk-card__title">' + _esc(_bi(e, 'threat', 'threatEn')) + (selStateP === 'na' ? '<span class="ra-risk-card__na-badge">' + _esc(_t('riskAnalysis.risks.naShort', 'N/A')) + '</span>' : '') + '</div>';
      html += '<div class="ra-risk-card__scenario">' + _esc(_bi(e, 'scenario', 'scenarioEn')) + '</div></div>';
    });
    RA_RISK_CATALOG.forEach(function(cat) {
      var selStateC = selected[cat.id];
      var sel = selStateC ? (' ra-risk-card--selected' + (selStateC === 'na' ? ' ra-risk-card--na' : '')) : '';
      html += '<div class="ra-risk-card' + sel + '" onclick="raToggleRisk(\'' + cat.id + '\')"' + _KB_ACT + _roFreeze() + '>';
      html += '<div class="ra-risk-card__title">' + _esc(_bi(cat, 'threat', 'threatEn')) + (selStateC === 'na' ? '<span class="ra-risk-card__na-badge">' + _esc(_t('riskAnalysis.risks.naShort', 'N/A')) + '</span>' : '') + '</div>';
      html += '<div class="ra-risk-card__scenario">' + _esc(_bi(cat, 'scenario', 'scenarioEn')) + '</div></div>';
    });
    html += '</div>';
    // Tuners for selected risks
    // FIX-REVUE RA-UX : libellés C/I/A résolus une fois par rendu (pas par risque).
    var _ciaDefs = [
      { k: 'C', label: _t('riskAnalysis.cia.confidentiality', 'Confidentialité') },
      { k: 'I', label: _t('riskAnalysis.cia.integrity', 'Intégrité') },
      { k: 'A', label: _t('riskAnalysis.cia.availability', 'Disponibilité') }
    ];
    var _ciaTargetsLabel = _esc(_t('riskAnalysis.risks.ciaTargets', 'Objectifs de sécurité menacés'));
    a.risks.forEach(function(r, idx) {
      var cat = _raResolveCat(r);
      var title = cat ? _bi(cat, 'threat', 'threatEn') : (r.customTitle || _t('riskAnalysis.risks.customTitle','Risque personnalisé'));
      var rl = resolveRiskLevel(r.likelihood || 3, r.impact || 3, getThresholdForAnalysis(a)); // FIX-AUDIT-SEUIL
      html += '<div class="ra-risk-tuner"' + (r.treatmentDecision === 'na' ? ' style="opacity:0.62"' : '') + '><div class="ra-risk-tuner__header">';
      if (!cat) {
        html += '<input type="text" class="ra-custom-risk-title" value="' + _esc(title).replace(/"/g,'&quot;') + '"'
             + ' placeholder="' + _esc(_t('riskAnalysis.risks.customTitlePlaceholder','Nom du risque')) + '"'
             + ' onchange="raRenameCustomRisk(' + idx + ',this.value)"'
             + ' onblur="raRenameCustomRisk(' + idx + ',this.value)"' + dis + '>';
        html += '<button type="button" class="ra-custom-risk-delete" onclick="raDeleteCustomRisk(' + idx + ')"'
             + ' title="' + _esc(_t('riskAnalysis.risks.deleteCustom','Supprimer ce risque')) + '"' + dis + '>'
             + '<i class="fas fa-trash"></i></button>';
      } else {
        html += '<h4>' + _esc(title) + '</h4>';
      }
      html += '<div class="ra-inline-score"><span class="ra-light" style="background:' + _safeCssColor(rl.color) + '"></span> ' + _esc(rl.label) + '</div></div>'; // FIX-AUDIT-XSS : couleur de seuil importable
      html += '<div class="ra-risk-tuner__controls">';
      html += '<div>' + fg(_t('riskAnalysis.risks.likelihood','Vraisemblance'), riskSelect('raL_' + idx, r.likelihood || 3, 'likelihood', dis)) + '</div>';
      html += '<div>' + fg(_t('riskAnalysis.risks.impact','Impact'), riskSelect('raI_' + idx, r.impact || 3, 'impact', dis)) + '</div>';
      html += '</div>';
      // === RA-UX (B4) === objectifs de sécurité menacés (C/I/A) — pré-remplis depuis le
      // catalogue, éditables ; purement déclaratif (aucun effet sur le scoring).
      var _ciaSel = Array.isArray(r.ciaTargets) ? r.ciaTargets : [];
      html += '<div class="ra-risk-tuner__row ra-cia-targets"><span class="ra-form-label">' + _ciaTargetsLabel + '</span>';
      _ciaDefs.forEach(function(cd) {
        var _on = _ciaSel.indexOf(cd.k) >= 0;
        html += '<button type="button" class="ra-cia-chip' + (_on ? ' ra-cia-chip--active' : '') + '" onclick="raToggleRiskCia(' + idx + ',\'' + cd.k + '\',this)" title="' + _esc(cd.label) + '"' + dis + '>' + cd.k + '</button>';
      });
      html += '</div>';
      // Evaluate via matrix button (conditional)
      // FIX-RA-MATRIX-SEED : le bouton ne s'affiche QUE si la matrice se résout réellement —
      // sinon il ne menait qu'au toast « Matrice introuvable ».
      if (a.impactMatrixId && window.CSI_ImpactMatrices && window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId)) {
        // FIX-RA-BREAKDOWN-STALE : ne compter que les dimensions de la matrice COURANTE
        // (même règle que l'annexe PDF) — un breakdown d'une ancienne matrice ne gonfle
        // plus le badge alors que le picker rouvert n'affiche rien.
        var _bdMatrix = window.CSI_ImpactMatrices ? window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId) : null;
        var _bdDims = {};
        ((_bdMatrix && _bdMatrix.dimensions) || []).forEach(function(d) { _bdDims[d.id] = true; });
        var bdCount = r.impactBreakdown ? Object.keys(r.impactBreakdown).filter(function(k) { return _bdDims[k]; }).length : 0;
        html += '<div class="ra-risk-tuner__row" style="margin-top:4px">';
        html += '<button class="btn btn--sm" onclick="raOpenImpactPicker(' + idx + ')"' + dis + ' title="' + _esc(_t('riskAnalysis.impactMatrix.evaluateHint','Utilisez la matrice d\'impact pour évaluer ce risque selon plusieurs dimensions')) + '"><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.impactMatrix.evaluateVia','Évaluer via matrice')) + '</button>';
        if (bdCount > 0) {
          html += '<span class="ra-matrix-badge"><i class="fas fa-check-circle"></i> ' + bdCount + ' ' + _esc(_t('riskAnalysis.impactMatrix.evaluated','dim. évaluée(s)')) + '</span>';
        }
        html += '</div>';
      }
      html += '<div class="ra-risk-tuner__row">' + fg(_t('riskAnalysis.risks.treatment','Traitement'), '<select id="raT_' + idx + '" onchange="raUpdateRiskField(' + idx + ',\'treatmentDecision\',this.value)"' + dis + '><option value="reduce"' + (r.treatmentDecision==='reduce'?' selected':'') + '>' + _esc(_t('riskAnalysis.risks.treatReduce','Réduire')) + '</option><option value="accept"' + (r.treatmentDecision==='accept'?' selected':'') + '>' + _esc(_t('riskAnalysis.risks.treatAccept','Accepter')) + '</option><option value="transfer"' + (r.treatmentDecision==='transfer'?' selected':'') + '>' + _esc(_t('riskAnalysis.risks.treatTransfer','Transférer')) + '</option><option value="avoid"' + (r.treatmentDecision==='avoid'?' selected':'') + '>' + _esc(_t('riskAnalysis.risks.treatAvoid','Éviter')) + '</option><option value="na"' + (r.treatmentDecision==='na'?' selected':'') + '>' + _esc(_t('riskAnalysis.risks.treatNa','Non applicable (exclu du scoring)')) + '</option></select>') + '</div>';
      if (r.treatmentDecision === 'na') {
        html += '<div class="ra-risk-tuner__row">' + fg(_t('riskAnalysis.risks.naJustification','Justification de non-applicabilité'),
          '<input type="text" value="' + _esc(r.notes || '').replace(/"/g,'&quot;') + '" onchange="raUpdateRiskField(' + idx + ',\'notes\',this.value)" placeholder="' + _esc(_t('riskAnalysis.risks.naJustifPlaceholder','Pourquoi ce risque ne s\'applique pas (trace ISO 27001, imprimée au rapport)')) + '"' + dis + '>') +
          (r.notes ? '' : '<div style="color:#b45309;font-size:0.82rem"><i class="fas fa-exclamation-triangle"></i> ' + _esc(_t('riskAnalysis.risks.naMissing','Justification manquante — exigée par ISO 27001 §6.1.3.d')) + '</div>') + '</div>';
      }
      html += '<div class="ra-risk-tuner__row">' + fg(_t('riskAnalysis.risks.owner','Propriétaire du risque'), '<input type="text" id="raOwner_' + idx + '" list="raOwnerSuggestions" value="' + _esc(r.owner || '').replace(/"/g,'&quot;') + '" onchange="raUpdateRiskField(' + idx + ',\'owner\',this.value)" placeholder="' + _esc(_t('riskAnalysis.risks.ownerPlaceholder','Personne ou service — saisie libre')) + '"' + dis + '>') + '</div>';
      html += '</div>';
    });
    html += '<button class="btn btn--sm" onclick="raAddCustomRisk()"' + dis + '><i class="fas fa-plus"></i> ' + _esc(_t('riskAnalysis.risks.addCustom','Ajouter un risque personnalisé')) + '</button>';
    body.innerHTML = html;
  }

  function riskSelect(id, val, type, dis) {
    var opts = '';
    for (var i = 1; i <= 5; i++) {
      var lbl = _t('riskAnalysis.risks.' + type + 'Levels.' + i, i.toString());
      opts += '<option value="' + i + '"' + (val === i ? ' selected' : '') + '>' + i + ' - ' + _esc(lbl) + '</option>';
    }
    var field = type === 'likelihood' ? 'likelihood' : 'impact';
    var idx = id.split('_')[1];
    return '<select id="' + id + '" onchange="raUpdateRiskField(' + idx + ',\'' + field + '\',parseInt(this.value))"' + dis + '>' + opts + '</select>';
  }

  window.raToggleRisk = function(catalogId) {
    if (_ro()) return; // FIX-RA-READONLY : <div> cliquable — pointer-events ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks) return;
    var idx = a.risks.findIndex(r => r.catalogId === catalogId);
    if (idx >= 0) {
      // L2 — désélection NON destructive (exigence ISO 27001 6.1.3.d) :
      // 1er clic sur une carte sélectionnée → marque « non applicable »
      // (cotation/propriétaire/notes conservés, trace au registre) ;
      // 2e clic (carte déjà NA) → retrait effectif.
      if (a.risks[idx].treatmentDecision !== 'na') { a.risks[idx].treatmentDecision = 'na'; }
      else { _frWarnIfLinked(a, a.risks[idx].id); a.risks.splice(idx, 1); } // === RA-PROJ === toast si lié
    } else {
      var tcat = RA_RISK_CATALOG.find(function(c) { return c.id === catalogId; });
      var base = { id: _id(), catalogId: catalogId, likelihood: 3, impact: 3, impactBreakdown: null, treatmentDecision: 'reduce', owner: '', notes: '',
        ciaTargets: (tcat && Array.isArray(tcat.ciaTargets)) ? tcat.ciaTargets.slice() : [] }; // === RA-UX (B4) ===
      a.risks.push(tcat ? Object.assign(base, _raSnapshotFields(tcat)) : base);
    }
    _save();
    renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
  };
  window.raTogglePackRisk = function(packId, entryId) {
    if (_ro()) return; // FIX-RA-READONLY : <div> cliquable — pointer-events ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks) return;
    var idx = a.risks.findIndex(function(r) { return r.packId === packId && (r.scenarioId || r.threatId) === entryId; });
    if (idx >= 0) {
      if (a.risks[idx].treatmentDecision !== 'na') { a.risks[idx].treatmentDecision = 'na'; }
      else { _frWarnIfLinked(a, a.risks[idx].id); a.risks.splice(idx, 1); } // === RA-PROJ === toast si lié
    } else if (window.CSI_ThreatCatalog) {
      var e = window.CSI_ThreatCatalog.resolveEntry(packId, null, entryId) || window.CSI_ThreatCatalog.resolveEntry(packId, entryId, null);
      if (!e) return;
      var d = e.defaults || {};
      // customTitle : compat descendante (audit 2026-08-04) — les clients antérieurs
      // au chantier ne lisent ni packs ni snapshots ; sans lui, ce risque s'affiche
      // « ? » dans leur PDF/aperçu et « Risque personnalisé » supprimable dans leur
      // wizard. Inerte ici : la résolution cat prime partout sur customTitle.
      a.risks.push(Object.assign({ id: _id(), catalogId: null, packId: packId, threatId: e.threatId, scenarioId: e.scenarioId,
        customTitle: e.threat || e.threatEn || '',
        likelihood: d.likelihood || 3, impact: d.impact || 3, impactBreakdown: null, treatmentDecision: 'reduce', owner: '', notes: '',
        ciaTargets: (Array.isArray(d.ciaTargets) ? d.ciaTargets : (e.ciaTargets || [])).slice() }, _raSnapshotFields(e))); // === RA-UX (B4) ===
    }
    _save();
    renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
  };
  // FIX-RA-METHOD-LIVE (audit référentiels 2026-08-17, constat 2) : le choix de méthode
  // n'avait AUCUN effet visible au moment où on le fait (bloc couples EBIOS et présélection
  // de matrice conditionnés au rendu suivant). On sauve l'étape puis on la re-rend.
  window.raMethodChanged = function() {
    if (_ro()) return;
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    saveCurrentStep();
    renderStepContent(a);
  };

  window.raUpdateRiskField = function(idx, field, val) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks[idx]) return;
    // FIX-RA-BREAKDOWN-STALE (audit 2026-08-17, échelles#2) : un impact posé À LA MAIN rend
    // le détail matriciel caduc — sans cette purge, le PDF imprimait « Impact 2 » avec un
    // détail de dimensions qui impliquait 5 (trace d'auditabilité auto-contradictoire).
    if (field === 'impact' && a.risks[idx].impactBreakdown) a.risks[idx].impactBreakdown = null;
    a.risks[idx][field] = val;
    _save();
    if (field === 'likelihood' || field === 'impact') {
      renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
    }
  };
  // === RA-UX (B4) === bascule d'un objectif C/I/A sur un risque.
  // FIX-REVUE RA-UX : bascule EN PLACE (classe du bouton cliqué) — le re-rendu complet de
  // l'étape détruisait le chip sous le curseur (clics rapides dans le vide) et payait un
  // save + rebuild de tous les tuners pour un simple tag déclaratif.
  window.raToggleRiskCia = function(idx, key, el) {
    if (_ro()) return;
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks[idx]) return;
    if (!Array.isArray(a.risks[idx].ciaTargets)) a.risks[idx].ciaTargets = [];
    var arr = a.risks[idx].ciaTargets;
    var pos = arr.indexOf(key);
    if (pos >= 0) arr.splice(pos, 1); else arr.push(key);
    // Ordre stable C → I → A pour l'affichage et les exports.
    a.risks[idx].ciaTargets = ['C', 'I', 'A'].filter(function(k) { return arr.indexOf(k) >= 0; });
    _save();
    if (el && el.classList) el.classList.toggle('ra-cia-chip--active', pos < 0);
    else renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
  };
  window.raAddCustomRisk = function() {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    a.risks.push({ id: _id(), catalogId: null, customTitle: _t('riskAnalysis.risks.customTitle','Risque personnalisé'), likelihood: 3, impact: 3, impactBreakdown: null, treatmentDecision: 'reduce', owner: '', notes: '', ciaTargets: [] }); // === RA-UX (B4) ===
    _save();
    renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
  };
  window.raRenameCustomRisk = function(idx, value) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId);
    if (!a || !a.risks[idx]) return;
    var trimmed = (value || '').trim();
    if (!trimmed) trimmed = _t('riskAnalysis.risks.customTitle', 'Risque personnalisé');
    a.risks[idx].customTitle = trimmed;
    _save();
  };
  window.raDeleteCustomRisk = function(idx) {
    if (_ro()) return; // FIX-RA-READONLY (audit 2026-08-05) : garde de handler — le balisage seul ne suffit pas
    var a = findAnalysis(wizardState.analysisId);
    if (!a || !a.risks[idx]) return;
    var riskId = a.risks[idx].id;
    showConfirmModal(
      _t('riskAnalysis.risks.confirmDeleteCustom', 'Supprimer ce risque personnalisé ?'),
      _t('riskAnalysis.risks.confirmDeleteCustomMsg', 'Ce risque sera retiré de l\'analyse.'),
      function() {
        _frWarnIfLinked(a, riskId); // === RA-PROJ === toast si lié
        a.risks = a.risks.filter(function(r) { return r.id !== riskId; });
        _save();
        renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
      },
      { type: 'danger' }
    );
  };

  // === RA-PROJ === rangée de liaison structurée d'une carte de reco : chips des
  // risques liés + select NATIF « + Lier un risque… » (file://, clavier, zéro
  // dépendance). Les ids passent par des attributs data-/value (échappés _esc), jamais
  // par un littéral JS inline — un id forgé ne peut pas s'échapper du contexte HTML.
  function _frRenderRiskLinks(a, item, idx, dis) {
    var ids = Array.isArray(item.coveredRiskIds) ? item.coveredRiskIds : [];
    var proj = computeProjectedPositions(a);
    var tEcho = getThresholdForAnalysis(a); // FIX-REVUE RA-PROJ : le seuil DE L'ANALYSE, jamais l'actif
    // Homonymes : suffixe #n stable, calculé UNE fois dans l'ordre de a.risks — le même
    // risque porte le même libellé dans les chips ET le select (FIX-REVUE RA-PROJ :
    // la double consommation du compteur désalignait les deux contextes).
    var labelCount = {}, labelSeen = {}, riskLabels = {};
    (a.risks || []).forEach(function(r) {
      if (!r || !r.id) return;
      var l = _frLinkedRiskLabel(a, r.id);
      labelCount[l] = (labelCount[l] || 0) + 1;
    });
    (a.risks || []).forEach(function(r) {
      if (!r || !r.id) return;
      var l = _frLinkedRiskLabel(a, r.id);
      labelSeen[l] = (labelSeen[l] || 0) + 1;
      var base = l + ' (' + (r.likelihood || 3) + '×' + (r.impact || 3) + ')';
      riskLabels[r.id] = (labelCount[l] > 1) ? base + ' #' + labelSeen[l] : base;
    });
    var html = '<div class="ra-fr-links">';
    html += '<span class="ra-form-label">' + _esc(_t('riskAnalysis.finalRecs.coveredRisks', 'Risques couverts')) + '</span>';
    // FIX-AUDIT-UX (2026-08-25) : état vide explicite quand l'analyse n'a aucun risque liable.
    var _linkableCount = (a.risks || []).filter(function(r) { return r && r.id && r.treatmentDecision !== 'na'; }).length;
    if (!_linkableCount && !ids.length) {
      return html + '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.projection.noLinkableRisk', 'Aucun risque à lier : ajoutez des risques à l\'étape Risques.')) + '</p></div>';
    }
    html += '<div class="ra-fr-links__chips">';
    ids.forEach(function(rid) {
      var r = (a.risks || []).find(function(x) { return x && x.id === rid; });
      if (!r) return; // id pendant : toléré, filtré au rendu (élagage paresseux à la collecte)
      var na = r.treatmentDecision === 'na';
      html += '<span class="ra-fr-chip' + (na ? ' ra-fr-chip--na' : '') + '">' + _esc(riskLabels[rid]);
      if (na) html += ' <em class="ra-fr-chip__na">' + _esc(_t('riskAnalysis.projection.chipNa', '(non applicable)')) + '</em>';
      // Écho lecture seule de la cible (arbitrage PO n°4) — l'édition vit au tableau.
      var pr = proj[rid];
      if (pr && pr.hasTarget && !na) {
        var _tgtLvl = getRiskLevelForCell(pr.p, pr.g, tEcho);
        html += ' <span class="ra-fr-chip__target">→ ' + _esc(_t('riskAnalysis.projection.chipTarget', 'visé :')) + ' ' + pr.p + '×' + pr.g + (_tgtLvl ? ' (' + _esc(_lvlLabel(_tgtLvl)) + ')' : '') + '</span>';
      }
      html += '<button type="button" class="ra-fr-chip__remove" data-rid="' + _esc(rid) + '" onclick="raToggleRecRisk(' + idx + ', this.getAttribute(\'data-rid\'))" title="' + _esc(_t('common.delete', 'Delete')) + '"' + dis + '>&times;</button></span>';
    });
    html += '</div>';
    var opts = '';
    (a.risks || []).forEach(function(r) {
      if (!r || !r.id) return;
      if (r.treatmentDecision === 'na') return;
      if (ids.indexOf(r.id) >= 0) return;
      opts += '<option value="' + _esc(r.id) + '">' + _esc(riskLabels[r.id]) + '</option>';
    });
    if (opts) {
      html += '<select class="ra-fr-links__select" onchange="if(this.value){raToggleRecRisk(' + idx + ', this.value);}"' + dis + '>';
      html += '<option value="">' + _esc(_t('riskAnalysis.projection.linkRisk', '+ Lier un risque…')) + '</option>' + opts + '</select>';
    }
    html += '</div>';
    return html;
  }

  // === RA-PROJ === tableau central « Projection après mise en œuvre » (étape 6) —
  // LA zone de saisie des cibles (une seule vérité, pas de double rendu à
  // synchroniser). Périmètre : risques couverts ∪ risques porteurs d'une cible
  // (la cible orpheline reste visible et purgeable — revue adverse). La colonne
  // « Résiduel actuel (calculé) » est la baseline anti-survente : sans elle, tout le
  // gain inhérent→cible serait attribué au plan.
  function _renderProjectionBlock(a, dis) {
    var t = getThresholdForAnalysis(a);
    var results = computeAllResults(a);
    var proj = computeProjectedPositions(a);
    var items = ((a.finalRecs || {}).items) || [];
    var html = '<div class="ra-proj-section" id="raProjWrap">';
    html += '<h4 class="ra-section-title"><i class="fas fa-crosshairs"></i> ' + _esc(_t('riskAnalysis.projection.title', 'Projection après mise en œuvre')) + '</h4>';
    var rows = [];
    (a.risks || []).forEach(function(r, ri) {
      if (!r || !r.id) return;
      var pr = proj[r.id];
      if (!pr) {
        // Risque « non applicable » PORTEUR d'une cible : hors moteur de projection
        // (computeProjectedPositions l'exclut à la source), mais la saisie est conservée
        // — ISO 27001 6.1.3.d, et raToggleRisk documente déjà le NA comme une désélection
        // NON destructive. Ligne orpheline : visible, non projetée, purgeable.
        if (r.treatmentDecision !== 'na' || !r.projected) return;
        var _fp = _projClampAxis(r.likelihood) || 3, _fg = _projClampAxis(r.impact) || 3;
        pr = { fromP: _fp, fromG: _fg, p: _fp, g: _fg, covered: false, hasTarget: false, moved: false, clamped: false };
      } else if (!pr.covered && !r.projected) return;
      rows.push({ r: r, ri: ri, pr: pr });
    });
    if (!rows.length) {
      html += '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.projection.hint', 'Liez des risques aux recommandations clefs pour déclarer ici leur position cible en cas de mise en œuvre.')) + '</p></div>';
      return html;
    }
    html += '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.projection.subtitle', 'Position cible déclarée par l\'analyste pour chaque risque couvert — jamais calculée par l\'outil.')) + '</p>';
    html += '<div class="ra-proj-table-wrap"><table class="ra-risk-register ra-proj-table"><thead><tr>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colRisk', 'Risque')) + '</th>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colRecs', 'Recos couvrantes')) + '</th>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colInherent', 'Inhérent')) + '</th>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colResidual', 'Résiduel actuel (calculé)')) + '</th>'
      + '<th>P′</th><th>G′</th>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colTargetLevel', 'Niveau cible')) + '</th>'
      + '<th>' + _esc(_t('riskAnalysis.projection.colNote', 'Note')) + '</th></tr></thead><tbody>';
    rows.forEach(function(row) {
      var r = row.r, ri = row.ri, pr = row.pr;
      var rs = (results.riskScores || []).find(function(s) { return s.id === r.id; }) || {};
      var refs = [];
      var covering = 0, doneCount = 0;
      items.forEach(function(it, i) {
        if (!it || !_recCountsInProjection(it)) return;
        if (!(Array.isArray(it.coveredRiskIds) && it.coveredRiskIds.indexOf(r.id) >= 0)) return;
        covering++;
        if (it.status === 'done') doneCount++;
        refs.push('#' + (i + 1) + ' (' + (it.status === 'done' ? _t('riskAnalysis.finalRecs.statusDone', 'Fait') : _t('riskAnalysis.finalRecs.statusTodo', 'À faire')) + ')');
      });
      // FIX-REVUE RA-PROJ : seuil forgé (levels vides) → niveaux irrésolus tolérés,
      // pastilles/labels neutres, jamais de TypeError (l'étape 6 doit rester utilisable).
      var inhLvl = getRiskLevelForCell(pr.fromP, pr.fromG, t) || null;
      var tgtLvl = (pr.hasTarget ? getRiskLevelForCell(pr.p, pr.g, t) : null) || null;
      var residLvl = rs.residualLevelId ? ((t.levels || []).find(function(l) { return l && l.id === rs.residualLevelId; }) || null) : null;
      html += '<tr' + (pr.covered ? '' : ' class="ra-proj-row--orphan"') + '>';
      html += '<td>' + _esc(_frLinkedRiskLabel(a, r.id)) + '</td>';
      if (pr.covered) {
        html += '<td>' + _esc(refs.join(', ')) + '</td>';
      } else {
        // Cible orpheline : plus aucune reco couvrante — non projetée (arbitrage n°2),
        // donnée conservée, seul moyen propre de la purger : le bouton de retrait.
        html += '<td><span class="ra-proj-orphan-flag"><i class="fas fa-triangle-exclamation"></i> ' + _esc(_t('riskAnalysis.projection.orphanFlag', 'Cible sans recommandation liée')) + '</span> ';
        html += '<button type="button" class="btn btn--sm" onclick="raClearRiskProjection(' + ri + ')"' + dis + '>' + _esc(_t('riskAnalysis.projection.clearTarget', 'Retirer la cible')) + '</button></td>';
      }
      html += '<td>' + pr.fromP + '×' + pr.fromG + ' <span class="ra-level-dot" style="background:' + _safeCssColor(inhLvl && inhLvl.color) + '" title="' + _esc(_lvlLabel(inhLvl)) + '"></span></td>';
      html += '<td>' + (rs.residual != null ? _esc('' + rs.residual) + (residLvl ? ' <span class="ra-level-dot" style="background:' + _safeCssColor(residLvl.color) + '" title="' + _esc(_lvlLabel(residLvl)) + '"></span> ' + _esc(_lvlLabel(residLvl)) : '') : '—') + '</td>';
      // Selects BORNÉS à l'inhérent (invariant structurel, pas un message d'erreur) —
      // pré-remplis à l'inhérent : baisser est un acte volontaire de l'analyste.
      ['likelihood', 'impact'].forEach(function(axis) {
        var cap = axis === 'likelihood' ? pr.fromP : pr.fromG;
        var cur = pr.hasTarget ? (axis === 'likelihood' ? pr.p : pr.g) : cap;
        var selDis = pr.covered ? dis : ' disabled';
        var _axLbl = (axis === 'likelihood')
          ? _t('riskAnalysis.risks.likelihood', 'Vraisemblance')
          : _t('riskAnalysis.risks.impact', 'Impact');
        // FIX-AUDIT-A11Y : nom accessible explicite (le libellé de colonne P′/G′ ne suffit pas).
        html += '<td><select aria-label="' + _esc(_t('riskAnalysis.projection.colTargetLevel', 'Niveau cible') + ' — ' + _axLbl + ' — ' + _frLinkedRiskLabel(a, r.id)) + '" onchange="raSetRiskProjection(' + ri + ',\'' + axis + '\',this.value)"' + selDis + '>';
        for (var v = 1; v <= cap; v++) {
          html += '<option value="' + v + '"' + (v === cur ? ' selected' : '') + '>' + v + '</option>';
        }
        html += '</select></td>';
      });
      html += '<td>';
      if (tgtLvl) {
        html += '<span class="ra-level-dot" style="background:' + _safeCssColor(tgtLvl.color) + '"></span> ' + _esc(_lvlLabel(tgtLvl));
        if (pr.clamped) html += ' <i class="fas fa-triangle-exclamation ra-proj-hint--amber" title="' + _esc(_t('riskAnalysis.projection.clampedHint', 'Cible plafonnée à l\'inhérent courant (l\'inhérent a été abaissé après la déclaration)')) + '"></i>';
        // Garde de cohérence douce (revue adverse) : cible au-dessus du constat actuel.
        if (residLvl && _thLevelIndex(t, tgtLvl.id) > _thLevelIndex(t, residLvl.id)) {
          html += ' <i class="fas fa-circle-exclamation ra-proj-hint--amber" title="' + _esc(_t('riskAnalysis.projection.aboveResidualHint', 'Cible au-dessus du constat actuel (résiduel calculé) — vérifier')) + '"></i>';
        }
        // Indicateur de bouclage (arbitrage n°6) : recos toutes « Fait » → confronter
        // cible déclarée et constat calculé. Pur affichage, aucun recalcul.
        if (pr.covered && covering > 0 && doneCount === covering && residLvl) {
          if (_thLevelIndex(t, residLvl.id) <= _thLevelIndex(t, tgtLvl.id)) {
            html += ' <span class="ra-proj-close ra-proj-close--ok"><i class="fas fa-circle-check"></i> ' + _esc(_t('riskAnalysis.projection.closeOk', 'Objectif atteint')) + '</span>';
          } else {
            html += ' <span class="ra-proj-close ra-proj-close--gap"><i class="fas fa-circle-half-stroke"></i> ' + _esc(_t('riskAnalysis.projection.closeGapPrefix', 'Écart : cible') + ' ' + _lvlLabel(tgtLvl) + ', ' + _t('riskAnalysis.projection.closeGapActual', 'constat') + ' ' + _lvlLabel(residLvl)) + '</span>';
          }
        }
      } else {
        html += '—';
      }
      html += '</td>';
      html += '<td><input type="text" aria-label="' + _esc(_t('riskAnalysis.projection.colNote', 'Note') + ' — ' + _frLinkedRiskLabel(a, r.id)) + '" id="raProjNote_' + ri + '" value="' + _esc((r.projected && r.projected.note) || '').replace(/"/g, '&quot;') + '" placeholder="' + _esc(_t('riskAnalysis.projection.notePlaceholder', 'Justification du jugement (optionnel)')) + '" onchange="raSetRiskProjection(' + ri + ',\'note\',this.value)"' + (pr.covered ? dis : ' disabled') + '></td>';
      html += '</tr>';
    });
    html += '</tbody></table></div></div>';
    return html;
  }

  // === RA-PROJ === matrices de projection : paire inhérent/projeté à l'étape 6
  // (includeInherent), grille projetée seule aux Résultats/aperçu (la grille inhérente
  // y existe déjà). Affichées seulement si ≥1 cible effectivement PROJETABLE déplace
  // un risque — sinon deux grilles identiques sans explication (revue adverse).
  function _renderProjectionMatrices(a, opts) {
    opts = opts || {};
    var results = opts.results || computeAllResults(a);
    var sum = computeProjectionSummary(a, results);
    if (!sum.anyMoved) return '';
    var t = getThresholdForAnalysis(a);
    var proj = computeProjectedPositions(a);
    var inhMap = {}, projMap = {}, uncovMap = {};
    (a.risks || []).forEach(function(rk) {
      var pr = rk && rk.id && proj[rk.id];
      if (!pr) return;
      var ik = pr.fromP + '_' + pr.fromG;
      inhMap[ik] = (inhMap[ik] || 0) + 1;
      if (pr.covered) {
        var pk = pr.p + '_' + pr.g;
        projMap[pk] = (projMap[pk] || 0) + 1;
      } else {
        uncovMap[ik] = (uncovMap[ik] || 0) + 1;
      }
    });
    var html = '<div class="ra-proj-matrices-section" id="raProjMatrices">';
    html += '<div class="ra-proj-matrices">';
    if (opts.includeInherent) {
      html += '<div class="ra-proj-matrices__col"><h5>' + _esc(_t('riskAnalysis.projection.matrixInherentTitle', 'Risques actuels (inhérents)')) + '</h5>' + _renderMatrixGrid(t, inhMap, null) + '</div>';
    }
    html += '<div class="ra-proj-matrices__col"><h5>' + _esc(_t('riskAnalysis.projection.matrixProjectedTitle', 'Projection si recommandations mises en œuvre')) + '</h5>' + _renderMatrixGrid(t, projMap, { projected: true, uncoveredMap: uncovMap, extraClass: 'ra-threshold-grid--projected' }) + '</div>';
    html += '</div>';
    // Légende OBLIGATOIRE (garde-fou méthodologique) + marqueurs.
    html += '<p class="ra-form-hint ra-proj-legend"><i class="fas fa-circle-info"></i> ' + _esc(_t('riskAnalysis.projection.legendDeclarative', 'Projection déclarative : positions cibles saisies par l\'analyste, sous hypothèse de mise en œuvre complète des {n} recommandation(s) clef(s) liée(s) — aucun recalcul : cette vue ne reflète pas la maturité actuelle des contrôles.').replace('{n}', String(sum.linkedRecCount)));
    html += ' <span class="ra-proj-legend__markers"><span class="ra-tg-risk-count ra-tg-risk-count--proj ra-proj-legend__dot">n</span> ' + _esc(_t('riskAnalysis.projection.legendProjectedMarker', 'position cible déclarée'));
    html += ' · <span class="ra-tg-risk-count ra-tg-risk-count--uncov ra-proj-legend__dot">n</span> ' + _esc(_t('riskAnalysis.projection.legendUncoveredMarker', 'Risque non couvert (position inhérente)')) + '</span></p>';
    // Bannière B2 SCINDÉE : couverts restant au-delà ≠ non couverts au-delà.
    if (sum.floorDefined && sum.coveredBeyond > 0) {
      html += '<div class="ra-proj-banner ra-proj-banner--covered"><i class="fas fa-scale-unbalanced"></i> ' + _esc(_t('riskAnalysis.projection.bannerCoveredBeyond', '{n} risque(s) couvert(s) resteraient au-delà des critères d\'acceptation malgré les recommandations clefs — traitement complémentaire ou acceptation formelle requise.').replace('{n}', String(sum.coveredBeyond))) + '</div>';
    }
    if (sum.floorDefined && sum.uncoveredBeyond > 0) {
      html += '<div class="ra-proj-banner ra-proj-banner--uncovered"><i class="fas fa-scale-unbalanced"></i> ' + _esc(_t('riskAnalysis.projection.bannerUncoveredBeyond', '{n} risque(s) au-delà des critères d\'acceptation ne sont couverts par aucune recommandation clef.').replace('{n}', String(sum.uncoveredBeyond))) + '</div>';
    }
    // Liste des déplacements — triple notation Inhérent · Actuel → Cible (le lisible
    // risque par risque ; triée par gain ACTUEL→cible).
    if (sum.moves.length) {
      html += '<div class="ra-proj-moves"><h5>' + _esc(_t('riskAnalysis.projection.movesTitle', 'Déplacements projetés')) + '</h5><ul>';
      sum.moves.forEach(function(mv) {
        var line = mv.label + ' — ' + _t('riskAnalysis.projection.movesInherentWord', 'Inhérent') + ' P' + mv.fromP + '×G' + mv.fromG + ' (' + mv.inherent + ')';
        if (mv.residual != null) line += ' · ' + _t('riskAnalysis.projection.movesActualWord', 'Actuel') + ' ' + mv.residual;
        line += ' → ' + _t('riskAnalysis.projection.movesTargetWord', 'Cible') + ' P' + mv.p + '×G' + mv.g + ' (' + mv.target + ')';
        line += ' — ' + mv.fromLevel + ' → ' + mv.toLevel;
        if (mv.recRefs.length) line += ' — ' + _t('riskAnalysis.projection.colRecs', 'Recos couvrantes') + ' ' + mv.recRefs.join(', ');
        html += '<li>' + _esc(line) + '</li>';
      });
      html += '</ul></div>';
    }
    html += '</div>';
    return html;
  }

  // Step 6: Mesures & Revue
  function renderStepMesures(body, a) {
    // FIX-RA-READONLY : calculé ici (et non reçu en argument) — cette étape est aussi
    // re-rendue par raAddFinalRec/raRemoveFinalRec/raUpdateFinalRecField.
    // `dis` fixe l'état AU RENDU ; `data-readonly-lock` (mécanisme existant du bundle) le
    // rend VIVANT : setEditMode() → refreshReadOnlyLocks() réactive les champs sans
    // re-rendre l'assistant — indispensable en file://, où l'entrée en édition ne
    // repasse pas par loadModuleContent().
    var dis = _roDis() + ' data-readonly-lock="true"' + _permDis(); // FIX-AUDIT-RO : gel permanent si droit de section 'read'
    var html = '<h3>' + _esc(_t('riskAnalysis.steps.mesures','Mesures & Revue')) + '</h3>' +
      '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.mesures.subtitle','Définissez vos recommandations clefs et planifiez la prochaine revue.')) + '</p>';
    // Key Recommendations (finalRecs) — editable section
    var frItems = (a.finalRecs && a.finalRecs.items) || [];
    html += '<div class="ra-finalrecs-section">';
    html += '<h4 class="ra-section-title"><i class="fas fa-star"></i> ' + _esc(_t('riskAnalysis.finalRecs.title','Recommandations clefs')) + ' <span class="ra-finalrecs-counter">(' + frItems.length + '/10)</span></h4>';
    frItems.forEach(function(item, idx) {
      html += '<div class="ra-finalrec-card" data-idx="' + idx + '">';
      html += '<div class="ra-finalrec-card__header"><strong>#' + (idx + 1) + '</strong>';
      html += '<div class="ra-finalrec-card__actions">';
      html += '<select id="raFrStatus_' + idx + '" onchange="raUpdateFinalRecField(' + idx + ',\'status\',this.value)"' + dis + '>';
      html += '<option value="todo"' + (item.status !== 'done' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.finalRecs.statusTodo','À faire')) + '</option>';
      html += '<option value="done"' + (item.status === 'done' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.finalRecs.statusDone','Fait')) + '</option></select>';
      html += '<button class="btn btn--danger btn--sm" onclick="raRemoveFinalRec(' + idx + ')" title="' + _esc(_t('common.delete', 'Delete')) + '"' + dis + '><i class="fas fa-trash"></i></button>';
      html += '</div></div>';
      html += '<input type="text" id="raFrTitle_' + idx + '" value="' + _esc(item.title || '') + '" placeholder="' + _esc(_t('riskAnalysis.finalRecs.titlePlaceholder','Titre de la recommandation')) + '" onchange="raUpdateFinalRecField(' + idx + ',\'title\',this.value)"' + dis + '>';
      html += '<textarea id="raFrDetails_' + idx + '" placeholder="' + _esc(_t('riskAnalysis.finalRecs.detailsPlaceholder','Détails, actions, contexte...')) + '" onchange="raUpdateFinalRecField(' + idx + ',\'details\',this.value)"' + dis + '>' + _esc(item.details || '') + '</textarea>';
      // === RA-PROJ === liaison structurée aux risques de l'analyse (remplace le texte
      // libre A4) ; le texte hérité non vide reste visible et éditable dessous,
      // relabellisé « Note complémentaire » — aucune migration, aucun parsing.
      html += _frRenderRiskLinks(a, item, idx, dis);
      if (item.coveredRisks) {
        html += '<label class="ra-fr-legacy-label">' + _esc(_t('riskAnalysis.finalRecs.legacyNote', 'Note complémentaire (texte hérité)')) + '</label>';
        html += '<input type="text" id="raFrCovered_' + idx + '" value="' + _esc(item.coveredRisks || '').replace(/"/g,'&quot;') + '" onchange="raUpdateFinalRecField(' + idx + ',\'coveredRisks\',this.value)"' + dis + '>';
      }
      html += '<input type="text" id="raFrMitigation_' + idx + '" value="' + _esc(item.expectedMitigation || '').replace(/"/g,'&quot;') + '" placeholder="' + _esc(_t('riskAnalysis.finalRecs.expectedMitigationPlaceholder','Commentaire sur l\'atténuation attendue (optionnel)')) + '" onchange="raUpdateFinalRecField(' + idx + ',\'expectedMitigation\',this.value)"' + dis + '>';
      html += '</div>';
    });
    if (frItems.length < 10) {
      html += '<button class="btn btn--sm" onclick="raAddFinalRec()"' + dis + '><i class="fas fa-plus"></i> ' + _esc(_t('riskAnalysis.finalRecs.add','Ajouter une recommandation clef')) + '</button>';
    }
    html += '</div>';
    // === RA-PROJ === « derrière toutes les recommandations » (demande verbatim) :
    // le tableau de projection puis la paire de matrices inhérent/projeté.
    html += _renderProjectionBlock(a, dis);
    html += _renderProjectionMatrices(a, { includeInherent: true });
    // Review date section
    var reviewEnabled = !!(a.meta && a.meta.nextReviewDate);
    html += '<div class="ra-review-date-section">';
    html += '<h4><i class="fas fa-calendar-check"></i> ' + _esc(_t('riskAnalysis.reviewDate.title','Prochaine revue')) + '</h4>';
    html += '<label class="settings-checkbox"><input type="checkbox" id="raReviewEnabled"' + (reviewEnabled ? ' checked' : '') + ' onchange="raToggleReviewDate(this.checked)"' + dis + '> ' + _esc(_t('riskAnalysis.reviewDate.enable','Activer la date de prochaine revue')) + '</label>';
    html += '<div id="raReviewDateWrap" style="display:' + (reviewEnabled ? 'block' : 'none') + ';margin-top:8px">';
    html += '<input type="date" id="raNextReview" value="' + _esc((a.meta && a.meta.nextReviewDate) || '') + '" onchange="raSetNextReviewDate(this.value)"' + dis + '>';
    html += '</div></div>';
    body.innerHTML = html;
  }

  // Step 7: Results
  function renderStepResults(body, a) {
        // FIX-RESULTS-I18N (audit v2 #24, 2026-08-25) : `a.results` est un cache DÉRIVÉ, figé
    // dans la langue où il a été calculé (18 champs bilingues + 3 libellés — residualLevelLabel,
    // acceptance.floorLabel, spotSummary.avisLabel — qui n'ont AUCUNE variante stockée, donc
    // impossibles à re-résoudre au rendu). Fenêtre réelle : tout re-rendu SANS navigation
    // (retour d'onglet après changement de langue, handler languageChange delta-gaté, pull
    // serveur en stationnant sur l'étape) affichait le registre et le bandeau d'avis en
    // français dans une session EN. On recalcule, comme le font déjà le PDF (:4375) et
    // _renderProjectionMatrices — neutre par construction, raGoStep(7|8) recalcule juste avant.
    var r = a.results ? computeAllResults(a) : null;
    if (!r) { body.innerHTML = '<p>' + _esc(_t('riskAnalysis.results.computing','Calcul...')) + '</p>'; return; }
    // FIX-RA-READONLY-SPOT (audit co-édition 2026-07-17) : les 7 champs d'avis SPOT restaient
    // saisissables hors mode édition — la garde _ro() de saveCurrentStep jetait ensuite la
    // saisie SANS message (perte silencieuse, écran du valideur). Même pattern que
    // renderStepMesures : `dis` fige l'état AU RENDU, `data-readonly-lock` le rend VIVANT
    // (setEditMode → refreshReadOnlyLocks réactive sans re-rendu — indispensable en file://).
    // Les onglets raSetResultTab et les lignes raToggleRec restent actifs (consultation pure).
    var dis = _roDis() + ' data-readonly-lock="true"' + _permDis(); // FIX-AUDIT-RO : gel permanent si droit de section 'read'
    var html = '<h3>' + _esc(_t('riskAnalysis.results.title','Résultats')) + '</h3>' +
      '<p class="ra-form-hint">' + _esc(_t('riskAnalysis.results.subtitle','Synthèse automatique de votre analyse de risques.')) + '</p>';
    // Tab bar
    var tabDefs = [
      { id: 'synthese', label: _t('riskAnalysis.results.tabSynthese', 'Synthèse') },
      { id: 'risques', label: _t('riskAnalysis.results.tabRisques', 'Risques') },
      { id: 'recommandations', label: _t('riskAnalysis.results.tabRecommandations', 'Recommandations') }
    ];
    var activeTab = wizardState.resultTab || 'synthese';
    if (activeTab === 'mesures') activeTab = 'synthese';
    html += '<div class="ra-results-tabs">';
    tabDefs.forEach(function(tb) {
      html += '<button class="ra-results-tab' + (tb.id === activeTab ? ' ra-results-tab--active' : '') + '" data-tab="' + tb.id + '" onclick="raSetResultTab(\'' + tb.id + '\')">' + _esc(tb.label) + '</button>';
    });
    html += '</div>';
    // === Tab: Synthèse ===
    html += '<div class="ra-results-pane" data-tab="synthese" style="display:' + (activeTab === 'synthese' ? 'block' : 'none') + '">';
    // Summary
    var scoreLabel = _t('riskAnalysis.results.scoreLabels.' + r.globalScore, r.globalScore + '/5');
    var avgPercent = 0, domCount = 0;
    RA_DOMAINS.forEach(function(d) { if (r.domainScores[d.id] && !_raDomainNA(r.domainScores[d.id])) { avgPercent += r.domainScores[d.id].percent; domCount++; } });
    avgPercent = domCount > 0 ? Math.round(avgPercent / domCount) : 0;
    html += '<div class="ra-results__summary"><div class="ra-global-light ra-global-light--' + r.globalLight + '">' + r.globalScore + '</div>';
    html += '<div class="ra-summary-details"><h3>' + _esc(scoreLabel) + '</h3>';
    html += '<p>' + _esc(_t('riskAnalysis.results.posture','Posture')) + ': ' + avgPercent + '%</p>';
    html += '</div></div>';
    // Planned controls warning banner
    if (r.totalPlannedControls > 0) {
      html += '<div class="ra-planned-banner"><i class="fas fa-exclamation-triangle"></i> ';
      html += _esc(_t('riskAnalysis.controls.improvedWarning', 'Le résultat d\'analyse des risques repose sur le fait que les mesures et contrôles planifiés seront correctement implémentés — il sera indispensable de réaliser une analyse de risque préalablement au lancement en production du projet.'));
      html += '</div>';
    }
    // === RA-UX (B2) === bannière : risques résiduels au-delà des critères d'acceptation.
    if (r.acceptance && r.acceptance.floorLevelId && r.acceptance.beyondCount > 0) {
      html += '<div class="ra-planned-banner ra-acceptance-banner"><i class="fas fa-scale-unbalanced"></i> ';
      html += _esc(r.acceptance.beyondCount + ' ' + _t('riskAnalysis.thresholds.acceptanceBannerRisks', 'risque(s) résiduel(s) au-delà des critères d\'acceptation du risque') + ' (' + _t('riskAnalysis.thresholds.acceptanceFloorShort', 'plancher') + ' : ' + r.acceptance.floorLabel + _acceptanceRefSuffix(r.acceptance) + ')');
      html += '</div>';
    }
    // Analyse de sécurité — Summary Panel
    if (r.spotSummary) {
      var sp = r.spotSummary;
      var spotData = a.spot || {};
      html += '<div class="ra-spot-panel">';
      html += '<h4 class="ra-section-title"><i class="fas fa-traffic-light"></i> ' + _esc(_t('riskAnalysis.spot.title','Résultats de l\'évaluation (Sécurité)')) + '</h4>';
      // === RA-UX (A6) === l'existence des corrections manuelles n'était pas découvrable.
      html += '<p class="ra-form-hint"><i class="fas fa-circle-info"></i> ' + _esc(_t('riskAnalysis.spot.panelHint','L\'avis et le score résiduel sont calculés automatiquement. Les champs ci-dessous permettent de les corriger manuellement (justification obligatoire) — la correction et sa justification sont tracées dans le rapport. Le résultat se met à jour immédiatement.')) + '</p>';
      html += '<div class="ra-spot-header">';
      html += '<div class="ra-traffic-light-display">';
      html += '<div class="ra-tl-circle ra-tl-circle--red' + (sp.light === 'red' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '<div class="ra-tl-circle ra-tl-circle--yellow' + (sp.light === 'yellow' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '<div class="ra-tl-circle ra-tl-circle--green' + (sp.light === 'green' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '</div>';
      html += '<div class="ra-spot-avis ra-spot-avis--' + sp.light + '">' + _esc(sp.avisLabel) + '</div>';
      html += '</div>';
      html += '<table class="ra-spot-table"><tbody>';
      html += '<tr><td>' + _esc(_t('riskAnalysis.spot.inherentCriticality','Criticité de l\'actif (risque inhérent)')) + '</td><td class="ra-spot-value ra-spot-score--' + (sp.inherentCriticalityScore >= 4 ? 'red' : sp.inherentCriticalityScore === 3 ? 'yellow' : 'green') + '">' + sp.inherentCriticalityScore + '/5</td></tr>';
      var _mNA = !!sp.measuresNotApplicable;
      html += '<tr><td>' + _esc(_t('riskAnalysis.spot.measuresPercent','Niveau d\'implémentation des mesures')) + '</td><td class="ra-spot-value ra-spot-score--' + (_mNA ? 'grey' : sp.measuresImplementationPercent >= 70 ? 'green' : sp.measuresImplementationPercent >= 40 ? 'yellow' : 'red') + '">' + (_mNA ? _esc(_t('riskAnalysis.controls.na','N/A')) : sp.measuresImplementationPercent + '%') + '</td></tr>';
      html += '<tr><td>' + _esc(_t('riskAnalysis.spot.residualScore','Niveau de risque résiduel')) + '</td><td class="ra-spot-value ra-spot-score--' + (sp.residualScore <= 2 ? 'green' : sp.residualScore === 3 ? 'yellow' : 'red') + '">' + sp.residualScore + '/5</td></tr>';
      html += '<tr><td>' + _esc(_t('riskAnalysis.spot.residualPlusOpen','Risque résiduel + risque ouvert')) + '</td><td class="ra-spot-value ra-spot-score--' + (sp.residualPlusOpenScore <= 2 ? 'green' : sp.residualPlusOpenScore === 3 ? 'yellow' : 'red') + '">' + sp.residualPlusOpenScore + '/5</td></tr>';
      html += '</tbody></table>';
      html += '<div class="ra-spot-fields">';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.openRiskAdj','Ajustement risque ouvert')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.openRiskHint','Ajoutez un point si des risques connus ne sont pas encore traités (ex : vulnérabilités identifiées, actions en attente). Cela augmente le score résiduel final.')) + '</div>';
      html += '<select id="raSpotOpenAdj" onchange="raSpotFieldChanged(true)"' + dis + '><option value="0"' + (!spotData.openRiskAdjustment ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.openRiskNone','0 - Aucun risque ouvert')) + '</option>';
      html += '<option value="1"' + (spotData.openRiskAdjustment === 1 ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.openRiskModerate','1 - Quelques risques ouverts')) + '</option>';
      html += '<option value="2"' + (spotData.openRiskAdjustment === 2 ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.openRiskSignificant','2 - Risques ouverts importants')) + '</option></select></div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.avisCommentLabel','Commentaire de l\'évaluateur')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.avisCommentHint','Ajoutez ici vos observations ou nuances sur le résultat de l\'évaluation (ex : contexte particulier, points d\'attention).')) + '</div>';
      html += '<textarea id="raSpotAvisComment" rows="2" onchange="raSpotFieldChanged()"' + dis + '>' + _esc(spotData.avisComment || '') + '</textarea></div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.avisOverrideLabel','Correction manuelle de l\'avis')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.avisOverrideHint','Si l\'avis calculé automatiquement ne reflète pas la réalité (ex : facteurs externes à l\'analyse), vous pouvez le forcer manuellement. Laissez « — » pour conserver le calcul automatique.')) + '</div>';
      html += '<select id="raSpotAvisOverride" onchange="raSpotFieldChanged(true)"' + dis + '>';
      html += '<option value=""' + (!spotData.avisOverrideLight ? ' selected' : '') + '>— (' + _esc(_t('riskAnalysis.spot.residualOverrideAuto','calcul automatique')) + ')</option>';
      html += '<option value="green"' + (spotData.avisOverrideLight === 'green' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.avisLabels.green','Avis favorable')) + '</option>';
      html += '<option value="yellow"' + (spotData.avisOverrideLight === 'yellow' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.avisLabels.yellow','Avis favorable moyennant la mise en œuvre des recommandations clefs')) + '</option>';
      html += '<option value="red"' + (spotData.avisOverrideLight === 'red' ? ' selected' : '') + '>' + _esc(_t('riskAnalysis.spot.avisLabels.red','Avis défavorable')) + '</option>';
      html += '</select>';
      if (sp.avisOverridden) { html += '<div class="ra-form-hint" style="color:var(--color-warning,#b37a30);margin-top:4px"><i class="fas fa-info-circle"></i> ' + _esc(_t('riskAnalysis.spot.avisOverrideActive','L\'avis automatique était : ')) + '<strong>' + _esc(sp.autoAvisLabel) + '</strong></div>'; }
      html += '</div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.avisOverrideJustifLabel','Justification de la correction de l\'avis')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.avisOverrideJustifHint','Expliquez pourquoi vous corrigez l\'avis calculé (obligatoire si une correction est appliquée).')) + '</div>';
      html += '<textarea id="raSpotAvisOverrideJustif" rows="2" onchange="raSpotFieldChanged()"' + dis + '>' + _esc(spotData.avisOverrideJustification || '') + '</textarea></div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.residualOverrideLabel','Correction manuelle du score résiduel')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.residualOverrideHint','Si le score résiduel calculé automatiquement ne reflète pas la réalité, vous pouvez le remplacer manuellement. Laissez « — » pour conserver le calcul automatique.')) + '</div>';
      html += '<select id="raSpotResOverride" onchange="raSpotFieldChanged(true)"' + dis + '><option value=""' + (spotData.residualOverrideScore == null ? ' selected' : '') + '>— (' + _esc(_t('riskAnalysis.spot.residualOverrideAuto','calcul automatique')) + ')</option>';
      for (var ov = 1; ov <= 5; ov++) { html += '<option value="' + ov + '"' + (spotData.residualOverrideScore === ov ? ' selected' : '') + '>' + ov + '/5</option>'; }
      html += '</select></div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.residualOverrideJustifLabel','Justification de la correction')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.residualOverrideJustifHint','Expliquez pourquoi vous corrigez le score calculé (obligatoire si une correction est appliquée).')) + '</div>';
      html += '<textarea id="raSpotResJustif" rows="2" onchange="raSpotFieldChanged()"' + dis + '>' + _esc(spotData.residualOverrideJustification || '') + '</textarea></div>';
      html += '<div><label>' + _esc(_t('riskAnalysis.spot.residualCorrectedLabel','Commentaire sur le risque résiduel')) + '</label>';
      html += '<div class="ra-form-hint">' + _esc(_t('riskAnalysis.spot.residualCorrectedHint','Commentaire libre sur le niveau de risque résiduel après prise en compte des mesures de sécurité.')) + '</div>';
      html += '<textarea id="raSpotResCorrected" rows="2" onchange="raSpotFieldChanged()"' + dis + '>' + _esc(spotData.residualCorrectedComment || '') + '</textarea></div>';
      html += '</div></div>';
    }
    // Domain table
    html += '<h4 class="ra-section-title"><i class="fas fa-chart-bar"></i> ' + _esc(_t('riskAnalysis.results.domainSynthesis','Synthèse')) + '</h4>';
    html += '<table class="ra-domain-table"><thead><tr><th>' + _esc(_t('riskAnalysis.domains.IAM','Domaine')) + '</th><th>' + _esc(_t('riskAnalysis.results.maturity','Maturité')) + '</th><th>%</th><th>' + _esc(_t('riskAnalysis.listColumns.light','Feu')) + '</th></tr></thead><tbody>';
    RA_DOMAINS.forEach(function(d) {
      var ds = r.domainScores[d.id] || { maturity: 0, percent: 0, light: 'grey' };
      var dsNA = _raDomainNA(ds);
      html += '<tr><td><i class="' + d.icon + '"></i> ' + _esc(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn'))) + '</td>';
      html += '<td>' + (dsNA ? _esc(_t('riskAnalysis.controls.na', 'N/A')) : ds.maturity + '/3') + '</td>';
      html += '<td>' + (dsNA ? '—' : '<div class="ra-maturity-bar"><div class="ra-maturity-bar__fill ra-maturity-bar__fill--' + ds.light + '" style="width:' + ds.percent + '%"></div></div> ' + ds.percent + '%');
      if (!dsNA && ds.plannedCount > 0) html += ' <span class="ra-planned-badge" title="' + _esc(_t('riskAnalysis.controls.plannedBadge', 'dont planifié(s)')) + '"><i class="fas fa-clock"></i> ' + ds.plannedCount + '</span>';
      html += '</td>';
      html += '<td class="ra-traffic-cell"><span class="ra-light ra-light--' + ds.light + '"></span></td></tr>';
    });
    html += '</tbody></table>';
    // Radar chart
    html += '<div class="ra-radar-wrapper"><canvas id="raRadarChart" width="400" height="400"></canvas></div>';
    html += '</div>'; // close synthese pane
    // === Tab: Risques ===
    html += '<div class="ra-results-pane" data-tab="risques" style="display:' + (activeTab === 'risques' ? 'block' : 'none') + '">';
    // Risk register
    if (a.risks && a.risks.length > 0) {
      html += '<h4 class="ra-section-title"><i class="fas fa-triangle-exclamation"></i> ' + _esc(_t('riskAnalysis.results.riskRegister','Registre')) + '</h4>';
      html += '<table class="ra-risk-register"><thead><tr><th>' + _esc(_t('riskAnalysis.pdfThreat','Menace')) + '</th><th>' + _esc(_t('riskAnalysis.pdfScenario','Scénario')) + '</th><th title="' + _esc(_t('riskAnalysis.risks.ciaTargets','Objectifs de sécurité menacés')) + '">CIA</th><th>L</th><th>I</th><th>' + _esc(_t('riskAnalysis.pdfInherent','Inh.')) + '</th><th>' + _esc(_t('riskAnalysis.pdfResidual','Rés.')) + '</th><th>' + _esc(_t('riskAnalysis.pdfLight','Feu')) + '</th><th>' + _esc(_t('riskAnalysis.pdfTreatment','Traitement')) + '</th><th>' + _esc(_t('riskAnalysis.risks.owner','Propriétaire')) + '</th></tr></thead><tbody>';
      var _treatLblR = { reduce: _t('riskAnalysis.risks.treatReduce','Réduire'), accept: _t('riskAnalysis.risks.treatAccept','Accepter'), transfer: _t('riskAnalysis.risks.treatTransfer','Transférer'), avoid: _t('riskAnalysis.risks.treatAvoid','Éviter'), na: _t('riskAnalysis.risks.treatNa','Non applicable (exclu du scoring)') };
      a.risks.forEach(function(rk) {
        var cat = _raResolveCat(rk);
        var isNa = rk.treatmentDecision === 'na';
        var title = cat ? _bi(cat, 'threat', 'threatEn') : (rk.customTitle || '?');
        // FIX-AUDIT-REF (2026-08-25) : restituer la référence normative du pack (A01:2025,
        // T1566, E-x…) — elle était fournie par threat-catalog mais n'apparaissait NULLE PART,
        // rompant la traçabilité vers le référentiel choisi (argument produit des packs).
        if (cat && cat.ref) title += ' [' + cat.ref + ']';
        var scen = cat ? _bi(cat, 'scenario', 'scenarioEn') : '—';
        if (isNa && rk.notes) scen += ' — ' + _t('riskAnalysis.risks.naJustification','Justification de non-applicabilité') + ' : ' + rk.notes;
        var rs = r.riskScores.find(s => s.id === rk.id) || {};
        var impactCell = _esc('' + (rk.impact||3));
        if (rk.impactBreakdown && Object.keys(rk.impactBreakdown).length > 0) {
          impactCell += ' <span class="ra-matrix-badge" title="' + _esc(_t('riskAnalysis.impactMatrix.evaluated','dim. évaluée(s)')) + '"><i class="fas fa-table-cells"></i></span>';
        }
        var levelColor = (rs.levelColor || '').replace(/[^a-zA-Z0-9#(),.\s%]/g, '');
        var levelLabel = rs.levelLabel || '';
        // === RA-UX (B2) === marqueur « au-delà des critères d'acceptation » sur le résiduel.
        var _accBadge = rs.beyondAcceptance ? ' <i class="fas fa-scale-unbalanced ra-acceptance-flag" title="' + _esc(_t('riskAnalysis.thresholds.acceptanceBeyondBadge', 'Au-delà des critères d\'acceptation') + (rs.residualLevelLabel ? ' (' + rs.residualLevelLabel + ')' : '')) + '"></i>' : '';
        var _ciaCell = (Array.isArray(rk.ciaTargets) && rk.ciaTargets.length) ? rk.ciaTargets.join('·') : '—'; // === RA-UX (B4) ===
        // FIX-AUDIT-UX (2026-08-25) : colonnes ellipsées (table-layout fixed) — sans title,
        // la justification de non-applicabilité concaténée au scénario est irrécupérable.
        html += '<tr' + (isNa ? ' style="opacity:0.55"' : '') + '><td title="' + _esc(title) + '">' + _esc(title) + '</td><td title="' + _esc(scen) + '">' + _esc(scen) + '</td><td>' + _esc(_ciaCell) + '</td><td>' + _esc('' + (rk.likelihood||3)) + '</td><td>' + impactCell + '</td><td>' + _esc('' + (rs.inherent||'-')) + '</td><td>' + _esc('' + (rs.residual||'-')) + _accBadge + '</td>';
        html += '<td class="ra-traffic-cell">' + (levelColor ? '<span class="ra-level-dot" style="background:' + levelColor + '" title="' + _esc(levelLabel) + '"></span>' : '<span class="ra-light ra-light--' + _esc(rs.light||'grey') + '"></span>') + '</td>';
        html += '<td>' + _esc(_treatLblR[rk.treatmentDecision] || rk.treatmentDecision || '-') + '</td>';
        html += '<td>' + _esc(rk.owner || '') + '</td></tr>';
      });
      html += '</tbody></table>';
    }
    // 5×5 Risk Matrix visualization
    if (a.risks && a.risks.length > 0) {
      html += renderResultsMatrix(a, r);
      html += _renderProjectionMatrices(a, { results: r }); // === RA-PROJ === grille projetée à côté de l'existante
    }
    html += '</div>'; // close risques pane
    // === Tab: Recommandations ===
    html += '<div class="ra-results-pane" data-tab="recommandations" style="display:' + (activeTab === 'recommandations' ? 'block' : 'none') + '">';
    // Recommendations
    if (r.recommendations && r.recommendations.length > 0) {
      html += '<h4 class="ra-section-title"><i class="fas fa-lightbulb"></i> ' + _esc(_t('riskAnalysis.results.recommendations','Recommandations')) + '</h4>';
      html += '<table class="ra-rec-table"><thead><tr><th>#</th><th>' + _esc(_t('riskAnalysis.pdfPriority','Priorité')) + '</th><th>' + _esc(_t('riskAnalysis.pdfRecommendation','Recommandation')) + '</th><th>' + _esc(_t('riskAnalysis.pdfDomain','Domaine')) + '</th><th>' + _esc(_t('riskAnalysis.pdfEffort','Effort')) + '</th></tr></thead><tbody>';
      r.recommendations.forEach(function(rec, idx) {
        html += '<tr class="ra-rec-row" onclick="raToggleRec(' + idx + ')"><td><i class="fas fa-chevron-right ra-rec-chevron"></i>' + (idx+1) + '</td>';
        html += '<td><span class="ra-priority-badge ra-priority-badge--' + rec.priority + '">' + _esc(rec.priority) + '</span></td>';
        html += '<td>' + _esc(rec.title) + (rec.isPlanned ? ' <span class="ra-planned-badge"><i class="fas fa-clock"></i> ' + _esc(_t('riskAnalysis.controls.planned','Planifié')) + '</span>' : '') + '</td>';
        html += '<td>' + _esc(_t('riskAnalysis.domains.' + rec.domainId, rec.domainId)) + '</td>';
        html += '<td>' + _esc(rec.effort) + '</td></tr>';
        html += '<tr class="ra-rec-detail" id="raRecDetail_' + idx + '"><td colspan="5"><div class="ra-rec-detail__inner"><p><strong>' + _esc(_t('riskAnalysis.results.rationale','Rationale')) + ':</strong> ' + _esc(rec.rationale) + '</p>';
        html += '<p><strong>' + _esc(_t('riskAnalysis.results.actions','Actions')) + ':</strong></p><ul>';
        (rec.actions || []).forEach(function(act) { html += '<li>' + _esc(act) + '</li>'; });
        html += '</ul><p><strong>' + _esc(_t('riskAnalysis.results.doneWhen','Critère')) + ':</strong> ' + _esc(rec.doneWhen || '') + '</p></div></td></tr>';
      });
      html += '</tbody></table>';
    }
    html += '</div>'; // close recommandations pane
    body.innerHTML = html;
    // Draw radar
    setTimeout(function() { drawRadar(r.domainScores); }, 100);
  }
  window.raToggleReviewDate = function(checked) {
    if (_ro()) return; // FIX-AUDIT-RO (audit v2) : ce handler ÉCRIT meta.nextReviewDate
    var wrap = document.getElementById('raReviewDateWrap');
    if (wrap) wrap.style.display = checked ? 'block' : 'none';
    // FIX-RA-REVIEW-PERSIST (vidéo POC 2026-08-19) : la case ne faisait qu'afficher/
    // masquer le champ ; l'état ne rejoignait le modèle qu'à la navigation. Tout
    // re-render interne de l'étape (ajout/suppression de recommandation) re-rendait
    // donc la case décochée et jetait la date choisie. On persiste immédiatement.
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('toggle review date')) return;
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    if (!a.meta) a.meta = {};
    if (checked) {
      var el = document.getElementById('raNextReview');
      if (el && el.value && a.meta.nextReviewDate !== el.value) { a.meta.nextReviewDate = el.value; _save(); }
    } else if (a.meta.nextReviewDate) {
      a.meta.nextReviewDate = null; _save();
    }
  };
  window.raSetNextReviewDate = function(val) {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('set review date')) return; // FIX-RA-READONLY
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    if (!a.meta) a.meta = {};
    var v = val || null;
    if (a.meta.nextReviewDate !== v) { a.meta.nextReviewDate = v; _save(); }
  };
  // === RA-UX (A6) === recalcul immédiat à la modification d'un champ SPOT (retour client :
  // l'avis corrigé ne se reflétait qu'après une navigation — « il fallait juste cliquer »).
  // FIX-REVUE RA-UX : seuls les <select> re-rendent l'étape (leur changement modifie le
  // résultat AFFICHÉ). Les textareas (commentaires/justifications) ne changent rien de
  // visible — re-rendre au blur détruisait le champ que l'utilisateur venait de cliquer
  // (mousedown→blur→rebuild→mouseup dans le vide : chaque champ demandait deux clics).
  window.raSpotFieldChanged = function(rerender) {
    if (_ro()) return; // les champs sont gelés en lecture seule ; garde de handler par défense en profondeur
    if (STEP_KEYS[wizardState.step] !== 'results') return;
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    collectCurrentStep(a);
    a.results = computeAllResults(a);
    a.meta.updatedAt = _now();
    _save();
    if (rerender) renderStepContent(a); // l'onglet actif est conservé via wizardState.resultTab
  };
  window.raSetResultTab = function(tabId) {
    wizardState.resultTab = tabId;
    document.querySelectorAll('.ra-results-pane').forEach(function(p) {
      p.style.display = p.getAttribute('data-tab') === tabId ? 'block' : 'none';
    });
    document.querySelectorAll('.ra-results-tab').forEach(function(btn) {
      btn.classList.toggle('ra-results-tab--active', btn.getAttribute('data-tab') === tabId);
    });
    if (tabId === 'synthese') {
      var a = findAnalysis(wizardState.analysisId);
      if (a && a.results) setTimeout(function() { drawRadar(a.results.domainScores); }, 100);
    }
  };
  window.raToggleRec = function(idx) {
    var el = document.getElementById('raRecDetail_' + idx);
    if (el) {
      el.classList.toggle('ra-rec-detail--visible');
      var row = el.previousElementSibling;
      if (row) row.classList.toggle('ra-rec-row--expanded');
    }
  };
  window.raAddFinalRec = function() {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('add a recommendation')) return; // FIX-RA-READONLY
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    // FIX-RA-REVIEW-PERSIST : collecter la saisie DOM de l'étape AVANT le re-render,
    // sinon tout ce qui n'est pas encore commité (date de revue cochée au calendrier,
    // frappe sans blur) est re-rendu à l'état antérieur et silencieusement perdu.
    try { collectCurrentStep(a); } catch (e) { void 0; }
    if (!a.finalRecs) a.finalRecs = { items: [] };
    if (a.finalRecs.items.length >= 10) return;
    a.finalRecs.items.push({ id: _id(), title: '', details: '', coveredRisks: '', expectedMitigation: '', status: 'todo', coveredRiskIds: [] }); // === RA-UX (A4) === + RA-PROJ
    a.results = computeAllResults(a); _save();
    renderStepMesures(document.getElementById('raWizardBody'), a);
  };
  window.raRemoveFinalRec = function(idx) {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('delete a recommendation')) return; // FIX-RA-READONLY
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.finalRecs) return;
    // FIX-RA-REVIEW-PERSIST : résoudre l'id AVANT la collecte — l'auto-clean de
    // collectCurrentStep (filtre des items vides) peut décaler les index DOM.
    var itemId = a.finalRecs.items[idx] ? a.finalRecs.items[idx].id : null;
    if (!itemId) return;
    try { collectCurrentStep(a); } catch (e) { void 0; }
    a.finalRecs.items = a.finalRecs.items.filter(function(r) { return r.id !== itemId; });
    a.results = computeAllResults(a); _save();
    renderStepMesures(document.getElementById('raWizardBody'), a);
  };
  window.raUpdateFinalRecField = function(idx, field, val) {
    if (_ro()) return; // FIX-AUDIT-RO (2026-08-25) : _ro() englobe le droit de section 'read' — ensureEditMode seul laisse passer un compte section-read en mode édition
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('edit a recommendation')) return; // FIX-RA-READONLY
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.finalRecs || !a.finalRecs.items[idx]) return;
    // FIX-RA-REVIEW-PERSIST : résoudre l'ITEM avant toute collecte — l'auto-clean
    // des blocs vides décale les index, et la branche status re-rend l'étape.
    var item = a.finalRecs.items[idx];
    if (field === 'status') {
      try { collectCurrentStep(a); } catch (e) { void 0; }
      if (a.finalRecs.items.indexOf(item) < 0) { _save(); return; } // bloc vide auto-nettoyé : rien à statuer
      item[field] = val;
      _save();
      a.results = computeAllResults(a); renderStepMesures(document.getElementById('raWizardBody'), a);
      return;
    }
    item[field] = val;
    _save();
  };
  // === RA-PROJ === lier/délier un risque à une recommandation. Patron
  // FIX-RA-REVIEW-PERSIST : résoudre l'ITEM par référence AVANT la collecte (l'auto-
  // clean décale les index), collecter le DOM avant le re-render, écrire en RAM puis
  // _save() immédiat (saveCurrentStep ne sauve qu'à la navigation). Défense en
  // profondeur : _ro() en plus d'ensureEditMode (droit de section 'read' inclus).
  // === RA-ACTIFS (2026-09) === toggle idempotent du lien analyse↔actif (pattern
  // raToggleRecRisk). Écrit UNIQUEMENT dans l'analyse (grappe analyse) — jamais dans
  // l'inventaire (grappe cœur) : aucune cascade inter-grappes. La collecte de l'étape
  // courante préserve les champs texte saisis avant le re-rendu.
  window.raToggleAnalysisAsset = function(assetId) {
    if (_ro()) return;
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('link a critical asset to an analysis')) return;
    if (!assetId || typeof assetId !== 'string') return;
    var a = findAnalysis(wizardState.analysisId); if (!a) return;
    if (!Array.isArray(a.criticalAssetIds)) a.criticalAssetIds = [];
    var pos = a.criticalAssetIds.indexOf(assetId);
    if (pos >= 0) a.criticalAssetIds.splice(pos, 1);
    else a.criticalAssetIds.push(assetId);
    try { collectCurrentStep(a); } catch (e) { void 0; }
    _save();
    renderStepContext(document.getElementById('raWizardBody'), a, _stepDis());
  };
  window.raToggleRecRisk = function(idx, riskId) {
    if (_ro()) return;
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('link a risk to a recommendation')) return;
    if (!riskId || typeof riskId !== 'string') return;
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.finalRecs || !a.finalRecs.items[idx]) return;
    var item = a.finalRecs.items[idx];
    // FIX-REVUE RA-PROJ (MAJEUR confirmé, revue adverse 24/08) : appliquer le lien AVANT
    // la collecte — l'auto-clean de l'étape 6 supprimait une reco fraîchement ajoutée
    // (encore vide) avant que le lien ne s'écrive : lien perdu, carte détruite, et
    // l'early-return sautait le re-rendu (DOM figé). Lier EST ce qui donne du contenu au
    // bloc. Symétrie : délier le dernier lien d'un bloc par ailleurs vide → auto-nettoyé
    // à la collecte ci-dessous, AVEC re-rendu.
    if (!Array.isArray(item.coveredRiskIds)) item.coveredRiskIds = [];
    var pos = item.coveredRiskIds.indexOf(riskId);
    if (pos >= 0) item.coveredRiskIds.splice(pos, 1);
    else item.coveredRiskIds.push(riskId);
    try { collectCurrentStep(a); } catch (e) { void 0; }
    a.results = computeAllResults(a); _save(); // _frHasContent étendu → l'avis peut changer
    renderStepMesures(document.getElementById('raWizardBody'), a);
  };
  // === RA-PROJ === déclarer/ajuster la cible d'un risque (tableau de projection).
  // Index dans a.risks : stable entre le rendu et le handler (l'auto-clean de l'étape 6
  // ne touche que finalRecs.items, jamais a.risks). Mêmes gardes que raToggleRecRisk.
  window.raSetRiskProjection = function(idx, axis, val) {
    if (_ro()) return;
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('set a projected risk target')) return;
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks[idx]) return;
    var r = a.risks[idx];
    try { collectCurrentStep(a); } catch (e) { void 0; }
    // FIX-REVUE RA-PROJ : axes inhérents clampés au seed — un .analyse forgé (impact 7)
    // produisait une cible que la normalisation détruisait ensuite en boucle (note incluse).
    if (!r.projected) r.projected = { likelihood: _clamp(parseInt(r.likelihood, 10) || 3, 1, 5), impact: _clamp(parseInt(r.impact, 10) || 3, 1, 5), note: '' };
    if (axis === 'note') {
      r.projected.note = String(val == null ? '' : val);
      _save();
      return; // la note ne déplace rien — pas de re-rendu (pattern des champs texte de reco)
    }
    if (axis !== 'likelihood' && axis !== 'impact') return;
    var cap = _clamp(parseInt(axis === 'likelihood' ? (r.likelihood || 3) : (r.impact || 3), 10) || 3, 1, 5);
    var n = _clamp(parseInt(val, 10) || cap, 1, 5);
    if (n > cap) n = cap; // selects bornés + re-clamp défensif (invariant jamais-pire)
    r.projected[axis] = n;
    _save();
    renderStepMesures(document.getElementById('raWizardBody'), a);
  };
  // Retrait explicite d'une cible (ligne orpheline — arbitrage n°2, revue adverse).
  window.raClearRiskProjection = function(idx) {
    if (_ro()) return;
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('clear a projected risk target')) return;
    var a = findAnalysis(wizardState.analysisId); if (!a || !a.risks[idx]) return;
    try { collectCurrentStep(a); } catch (e) { void 0; }
    a.risks[idx].projected = null;
    _save();
    renderStepMesures(document.getElementById('raWizardBody'), a);
  };

  // === RA-PROJ === traceur de grille 5×5 factorisé depuis renderResultsMatrix. La
  // grille inhérente (opts null) doit produire une sortie STRICTEMENT identique à
  // l'historique (verrou de non-régression) ; le mode projeté n'AJOUTE que des classes
  // (compteurs pointillés) et les compteurs « non couverts » (marqueur distinct — la
  // grille projetée mélange sinon deux sémantiques sans le dire, revue adverse).
  function _renderMatrixGrid(t, riskMap, opts) {
    opts = opts || {};
    var _rmFloorIdx = getAcceptanceFloorIndex(t);
    var uncovMap = opts.uncoveredMap || null;
    var html = '<div class="ra-threshold-grid ra-threshold-grid--results' + (opts.extraClass ? ' ' + opts.extraClass : '') + '">';
    var _rLang = _lang();
    // FIX-REVUE RA-PROJ : un seuil FORGÉ (levels:[], labels absents) importé sur l'entité
    // principale ne doit jamais jeter — sortie neutre, jamais d'exception (doctrine
    // « imports adversariaux tolérés »).
    var _rGLabels = ((_rLang === 'en' && t.gravityLabels) ? t.gravityLabels : (t.gravityLabelsFr || t.gravityLabels)) || [];
    var _rPLabels = ((_rLang === 'en' && t.probabilityLabels) ? t.probabilityLabels : (t.probabilityLabelsFr || t.probabilityLabels)) || [];
    // Header
    html += '<div class="ra-tg-corner"><span class="ra-tg-axis-label ra-tg-axis-g">' + _esc(_t('riskAnalysis.thresholds.gravity','Impact')) + ' →</span><span class="ra-tg-axis-label ra-tg-axis-p">↑ ' + _esc(_t('riskAnalysis.thresholds.probability','Vraisemblance')) + '</span></div>';
    for (var g = 1; g <= 5; g++) {
      html += '<div class="ra-tg-header">' + g + '<br><small>' + _esc(_rGLabels[g-1] || '') + '</small></div>';
    }
    for (var p = 5; p >= 1; p--) {
      html += '<div class="ra-tg-rowlabel">' + p + '<br><small>' + _esc(_rPLabels[p-1] || '') + '</small></div>';
      for (var g2 = 1; g2 <= 5; g2++) {
        var key = p + '_' + g2;
        var levelId = (t.cells || {})[key] || 'minimum';
        var lvl = (t.levels || []).find(function(l) { return l && l.id === levelId; });
        var color = lvl ? lvl.color : '#ccc';
        var count = riskMap[key] || 0;
        var uncov = uncovMap ? (uncovMap[key] || 0) : 0;
        var _rmBeyond = _isLevelBeyondFloor(t, levelId, _rmFloorIdx); // === RA-UX (B2) ===
        html += '<div class="ra-tg-cell' + ((count > 0 || uncov > 0) ? ' ra-tg-cell--has-risk' : '') + (_rmBeyond ? ' ra-tg-cell--beyond' : '') + '" style="background:' + _safeCssColor(color) + '" title="' + _esc((lvl ? _lvlLabel(lvl) : '') + (_rmBeyond ? ' — ' + _t('riskAnalysis.thresholds.acceptanceBeyondBadge', 'Au-delà des critères d\'acceptation') : '')) + '">';
        if (count > 0) {
          html += '<span class="ra-tg-risk-count' + (opts.projected ? ' ra-tg-risk-count--proj' : '') + '">' + count + '</span>';
        }
        if (uncov > 0) {
          html += '<span class="ra-tg-risk-count ra-tg-risk-count--uncov" title="' + _esc(_t('riskAnalysis.projection.legendUncoveredMarker', 'Risque non couvert (position inhérente)')) + '">' + uncov + '</span>';
        }
        html += '</div>';
      }
    }
    html += '</div>';
    return html;
  }

  function renderResultsMatrix(a, r) {
    var t = getThresholdForAnalysis(a); // FIX-REVUE RA-UX : même seuil que computeAllResults
    var html = '<h4 class="ra-section-title"><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.thresholds.matrixTitle','Matrice de risque')) + '</h4>';
    // Build a map: "P_G" → count of risks at that position
    var riskMap = {};
    // FIX-RA-NA-MAPS (audit 2026-08-17) : les « non applicables » sont exclus des scores
    // et du feu global — la matrice qui illustre ces mêmes scores doit les exclure aussi
    // (l'annexe Méthode du PDF l'affirme déjà noir sur blanc).
    (a.risks || []).forEach(function(rk) {
      if (rk.treatmentDecision === 'na') return;
      var pk = (rk.likelihood || 3) + '_' + (rk.impact || 3);
      riskMap[pk] = (riskMap[pk] || 0) + 1;
    });
    html += '<div class="ra-results-matrix">';
    html += _renderMatrixGrid(t, riskMap, null); // === RA-PROJ === traceur factorisé — sortie inchangée
    // Legend
    html += '<div class="ra-threshold-legend ra-threshold-legend--results">';
    t.levels.forEach(function(lv) {
      html += '<span class="ra-threshold-legend-item"><span class="ra-threshold-legend-dot" style="background:' + _safeCssColor(lv.color) + '"></span>' + _esc(_lvlLabel(lv)) + '</span>'; // FIX-AUDIT-XSS : couleur de seuil importable
    });
    html += '</div>';
    // === RA-UX (B2) === rappel du plancher d'acceptation sous la matrice — depuis le
    // MÊME instantané que la bannière et les badges (r.acceptance), jamais une relecture
    // du seuil vivant qui pourrait diverger à l'écran (FIX-REVUE RA-UX).
    if (r && r.acceptance && r.acceptance.floorLevelId) {
      html += '<div class="ra-form-hint"><i class="fas fa-scale-unbalanced"></i> ' + _esc(_t('riskAnalysis.thresholds.acceptanceTitle', 'Critères d\'acceptation du risque') + ' : ' + _t('riskAnalysis.thresholds.acceptanceFrom', 'Inacceptable à partir de') + ' ' + r.acceptance.floorLabel + _acceptanceRefSuffix(r.acceptance)) + '</div>';
    }
    html += '</div>';
    return html;
  }

  var _raChartInstances = {};
  var _raObserver = null;

  function drawRadar(domainScores) {
    var canvas = document.getElementById('raRadarChart');
    if (!canvas || typeof Chart === 'undefined') return;
    if (_raChartInstances.radar) { try { _raChartInstances.radar.destroy(); } catch(e){} _raChartInstances.radar = null; }
    var labels = [], data = [];
    RA_DOMAINS.forEach(function(d) {
      labels.push(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn')));
      data.push(domainScores[d.id] ? domainScores[d.id].maturity : 0);
    });
    _raChartInstances.radar = new Chart(canvas, {
      type: 'radar',
      data: {
        labels: labels,
        datasets: [{ label: _t('riskAnalysis.results.maturity','Maturité'), data: data, backgroundColor: 'rgba(31,184,205,0.2)', borderColor: 'rgba(31,184,205,1)', pointBackgroundColor: 'rgba(31,184,205,1)', borderWidth: 2 }]
      },
      options: { scales: { r: { min: 0, max: 3, ticks: { stepSize: 1 } } }, plugins: { legend: { display: false } }, responsive: true, maintainAspectRatio: true }
    });
  }

  // Step 7: Preview
  function renderStepPreview(body, a) {
        // FIX-RESULTS-I18N (audit v2 #24, 2026-08-25) : `a.results` est un cache DÉRIVÉ, figé
    // dans la langue où il a été calculé (18 champs bilingues + 3 libellés — residualLevelLabel,
    // acceptance.floorLabel, spotSummary.avisLabel — qui n'ont AUCUNE variante stockée, donc
    // impossibles à re-résoudre au rendu). Fenêtre réelle : tout re-rendu SANS navigation
    // (retour d'onglet après changement de langue, handler languageChange delta-gaté, pull
    // serveur en stationnant sur l'étape) affichait le registre et le bandeau d'avis en
    // français dans une session EN. On recalcule, comme le font déjà le PDF (:4375) et
    // _renderProjectionMatrices — neutre par construction, raGoStep(7|8) recalcule juste avant.
    var r = a.results ? computeAllResults(a) : null;
    if (!r) { body.innerHTML = '<p>' + _esc(_t('riskAnalysis.results.computing','Calcul...')) + '</p>'; return; }
    var html = '<div class="ra-preview">';
    // Info box
    html += '<div class="ra-preview-info"><i class="fas fa-eye"></i> ' + _esc(_t('riskAnalysis.preview.infoText','Prévisualisation du rapport. Vérifiez les informations avant l\'export PDF.')) + '</div>';

    // ==========================================================
    // METHODOLOGY SECTION (first, before cover)
    // ==========================================================
    html += '<div class="ra-preview-section ra-preview-methodology">';
    html += '<h3><i class="fas fa-book-open"></i> ' + _esc(_t('riskAnalysis.methodology.title','Cadre méthodologique')) + '</h3>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.objective','Objectif')) + '</h4>';
    html += '<p>' + _esc(_t('riskAnalysis.methodology.objectiveText','Cette analyse vise à identifier, évaluer et traiter les risques de sécurité de l\'information conformément à la norme ISO/IEC 27005:2022.')) + '</p>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.normativeRef','Référence normative')) + '</h4>';
    html += '<p><strong>ISO/IEC 27005:2022</strong> — ' + _esc(_t('riskAnalysis.methodology.normativeRefText','Technologies de l\'information — Gestion des risques liés à la sécurité de l\'information. Cette norme fournit les lignes directrices pour le processus de gestion des risques dans le cadre d\'un SMSI (ISO/IEC 27001).')) + '</p>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.processTitle','Processus d\'analyse en 8 étapes')) + '</h4>';
    html += '<ol class="ra-methodology-steps">';
    var _methSteps = [
      _t('riskAnalysis.methodology.step1','Contexte & périmètre — Définition du périmètre, des parties prenantes et des critères d\'évaluation'),
      _t('riskAnalysis.methodology.step2','Classification CIA & RGPD — Évaluation de la confidentialité, intégrité, disponibilité et impact RGPD'),
      _t('riskAnalysis.methodology.step3','Continuité d\'activité — Objectifs RTO/RPO et contraintes métier'),
      _t('riskAnalysis.methodology.step4','Documentation — Architecture technique, gestion des identités, documentation'),
      _t('riskAnalysis.methodology.step5','Identification des risques — Sélection et évaluation des scénarios de risque'),
      _t('riskAnalysis.methodology.step6','Mesures & Contrôles — Évaluation de 24 mesures de sécurité sur 8 domaines'),
      _t('riskAnalysis.methodology.step7','Résultats & évaluation — Synthèse automatisée, scoring, recommandations'),
      _t('riskAnalysis.methodology.step8','Traitement du risque — Décisions de traitement (réduire, accepter, transférer, éviter)')
    ];
    _methSteps.forEach(function(s) { html += '<li>' + _esc(s) + '</li>'; });
    html += '</ol>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.scoringTitle','Formule de scoring')) + '</h4>';
    html += '<p><code>' + _esc(_t('riskAnalysis.methodology.scoringFormula','Risque résiduel = Vraisemblance × Impact × (1 − (Maturité contrôles ÷ 3) × 0.40)')) + '</code></p>';
    html += '<p style="font-size:0.85rem;color:var(--color-text-light)">' + _esc(_t('riskAnalysis.methodology.scoringAdjust','Ajustements contextuels : +1 si exposition Internet, +1 si CIA élevé, +1 si RTO/RPO strict.')) + '</p>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.scaleTitle','Échelle des risques')) + '</h4>';
    html += '<div class="ra-methodology-scale">';
    // FIX-AUDIT-SCALE (audit 2026-08-25) : l'échelle de l'annexe était CODÉE EN DUR alors que
    // la grille imprimée juste au-dessus lit getThresholdForAnalysis(a) — niveaux et couleurs
    // pouvaient diverger DANS LE MÊME document (seuil personnalisé ou analyse importée).
    var _scaleItems = (getThresholdForAnalysis(a).levels || []).map(function(lv) {
      return { label: _lvlLabel(lv), color: lv.color };
    });
    _scaleItems.forEach(function(si) {
      html += '<div class="ra-methodology-scale-item" style="background:' + _safeCssColor(si.color) + '">' + _esc(si.label) + '</div>'; // FIX-AUDIT-XSS : cohérence (couleurs littérales ici, mais garde uniforme)
    });
    html += '</div>';

    html += '<h4>' + _esc(_t('riskAnalysis.methodology.pdcaTitle','Cycle PDCA')) + '</h4>';
    html += '<div class="ra-methodology-pdca">';
    html += '<div class="ra-pdca-quadrant" style="background:var(--color-info, #3b82f6)"><strong>Plan</strong><br>' + _esc(_t('riskAnalysis.methodology.pdcaPlan','Établir le contexte, identifier les risques')) + '</div>';
    html += '<div class="ra-pdca-quadrant" style="background:var(--color-success, #22c55e)"><strong>Do</strong><br>' + _esc(_t('riskAnalysis.methodology.pdcaDo','Implémenter les mesures de traitement')) + '</div>';
    html += '<div class="ra-pdca-quadrant" style="background:var(--color-danger, #ef4444)"><strong>Act</strong><br>' + _esc(_t('riskAnalysis.methodology.pdcaAct','Améliorer, mettre à jour l\'analyse')) + '</div>';
    html += '<div class="ra-pdca-quadrant" style="background:var(--color-warning, #f97316)"><strong>Check</strong><br>' + _esc(_t('riskAnalysis.methodology.pdcaCheck','Surveiller, contrôler, réévaluer')) + '</div>';
    html += '</div>';

    html += '</div>';

    // Cover + Identity
    var ident = a.identity || {};
    html += '<div class="ra-preview-section ra-preview-cover">';
    html += '<h2>' + _esc(_t('riskAnalysis.preview.reportTitle','Analyse des risques en sécurité de l\'information - Cybersécurité')) + '</h2>';
    html += '<h3>' + _esc(a.meta.title || _t('riskAnalysis.pdfUntitled', 'Sans titre')) + '</h3>';
    if (ident.beneficiary) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.identity.beneficiary','Bénéficiaire/Client')) + ':</strong> ' + _esc(ident.beneficiary) + '</div>';
    if (ident.controlBody) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.identity.controlBody','Organisme de contrôle')) + ':</strong> ' + _esc(ident.controlBody) + '</div>';
    if (ident.projectManager) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.identity.projectManager','Chef de projet')) + ':</strong> ' + _esc(ident.projectManager) + '</div>';
    if (ident.encoder) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.identity.encoder','Encodage')) + ':</strong> ' + _esc(ident.encoder) + '</div>';
    if (ident.validator) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.identity.validator','Validation')) + ':</strong> ' + _esc(ident.validator) + '</div>';
    if (a.meta.reportNumber) html += '<p>' + _esc(_t('riskAnalysis.preview.reportNumber','Numéro du rapport')) + ': ' + _esc(a.meta.reportNumber) + '</p>';
    html += '<p>' + _esc(_t('riskAnalysis.preview.reportDate','Date')) + ': ' + _esc(a.meta.reportDate || new Date().toLocaleDateString()) + '</p>';
    html += '</div>';
    // Analyse de sécurité — Preview
    if (r.spotSummary) {
      var sp = r.spotSummary;
      html += '<div class="ra-preview-section">';
      html += '<h4><i class="fas fa-traffic-light"></i> ' + _esc(_t('riskAnalysis.spot.title','Résultats de l\'évaluation')) + '</h4>';
      html += '<div class="ra-spot-header">';
      html += '<div class="ra-traffic-light-display">';
      html += '<div class="ra-tl-circle ra-tl-circle--red' + (sp.light === 'red' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '<div class="ra-tl-circle ra-tl-circle--yellow' + (sp.light === 'yellow' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '<div class="ra-tl-circle ra-tl-circle--green' + (sp.light === 'green' ? ' ra-tl-circle--active' : '') + '"></div>';
      html += '</div>';
      html += '<div class="ra-spot-avis ra-spot-avis--' + sp.light + '">' + _esc(sp.avisLabel) + '</div>';
      html += '</div>';
      // KPI cards with progress bars and explanations
      var kpis = [
        { label: _t('riskAnalysis.spot.inherentCriticality','Criticité de l\'actif (risque inhérent)'), value: sp.inherentCriticalityScore + '/5', pct: sp.inherentCriticalityScore * 20, color: sp.inherentCriticalityScore >= 4 ? 'red' : sp.inherentCriticalityScore === 3 ? 'yellow' : 'green', hint: _t('riskAnalysis.spot.inherentCriticalityHint','Évalue le niveau d\'exposition intrinsèque de l\'actif.') },
        { label: _t('riskAnalysis.spot.measuresPercent','Niveau d\'implémentation des mesures'), value: sp.measuresNotApplicable ? _t('riskAnalysis.controls.na','N/A') : sp.measuresImplementationPercent + '%', pct: sp.measuresImplementationPercent, color: sp.measuresNotApplicable ? 'grey' : sp.measuresImplementationPercent >= 70 ? 'green' : sp.measuresImplementationPercent >= 40 ? 'yellow' : 'red', hint: _t('riskAnalysis.spot.measuresPercentHint','Pourcentage moyen d\'implémentation des contrôles de sécurité.'), invertBar: true },
        { label: _t('riskAnalysis.spot.residualScore','Niveau de risque résiduel'), value: sp.residualScore + '/5', pct: sp.residualScore * 20, color: sp.residualScore <= 2 ? 'green' : sp.residualScore === 3 ? 'yellow' : 'red', hint: _t('riskAnalysis.spot.residualScoreHint','Niveau de risque après application des mesures de sécurité.') },
        { label: _t('riskAnalysis.spot.residualPlusOpen','Risque résiduel + risque ouvert'), value: sp.residualPlusOpenScore + '/5', pct: sp.residualPlusOpenScore * 20, color: sp.residualPlusOpenScore <= 2 ? 'green' : sp.residualPlusOpenScore === 3 ? 'yellow' : 'red', hint: _t('riskAnalysis.spot.residualPlusOpenHint','Score résiduel augmenté des risques ouverts non encore traités.') }
      ];
      html += '<div class="ra-kpi-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-md,12px);margin-top:var(--spacing-md,12px)">';
      kpis.forEach(function(k) {
        var barColor = k.color === 'green' ? 'var(--color-success,#22c55e)' : k.color === 'yellow' ? 'var(--color-warning,#f97316)' : 'var(--color-danger,#ef4444)';
        html += '<div class="ra-kpi-card" style="border:1px solid var(--color-border,#e9ecef);border-radius:var(--radius-md,8px);padding:var(--spacing-md,12px);background:var(--color-surface,#fff)">';
        html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px"><span style="font-weight:600;font-size:0.85rem">' + _esc(k.label) + '</span>';
        html += '<span class="ra-spot-score--' + k.color + '" style="font-weight:700;font-size:1.1rem">' + _esc(k.value) + '</span></div>';
        html += '<div style="background:var(--color-border,#e9ecef);border-radius:4px;height:8px;overflow:hidden"><div style="height:100%;width:' + k.pct + '%;background:' + barColor + ';border-radius:4px;transition:width 0.3s"></div></div>';
        html += '<div style="font-size:0.75rem;color:var(--color-text-light,#6b7280);margin-top:6px;font-style:italic">' + _esc(k.hint) + '</div>';
        html += '</div>';
      });
      html += '</div>';
      // Comments section
      if ((a.spot || {}).avisComment || sp.avisOverridden || (a.spot || {}).residualCorrectedComment) {
        html += '<table class="ra-spot-table" style="margin-top:var(--spacing-md,12px)"><tbody>';
        if ((a.spot || {}).avisComment) html += '<tr><td>' + _esc(_t('riskAnalysis.spot.avisCommentLabel','Commentaire avis')) + '</td><td>' + _esc(a.spot.avisComment) + '</td></tr>';
        if (sp.avisOverridden) html += '<tr><td>' + _esc(_t('riskAnalysis.spot.avisOverrideJustifLabel','Justification correction avis')) + '</td><td>' + _esc((a.spot || {}).avisOverrideJustification || '') + '</td></tr>';
        if ((a.spot || {}).residualCorrectedComment) html += '<tr><td>' + _esc(_t('riskAnalysis.spot.residualCorrectedLabel','Commentaire résiduel corrigé')) + '</td><td>' + _esc(a.spot.residualCorrectedComment) + '</td></tr>';
        html += '</tbody></table>';
      }
      html += '</div>';
    }
    // Context
    html += '<div class="ra-preview-section">';
    html += '<h4><i class="fas fa-info-circle"></i> ' + _esc(_t('riskAnalysis.steps.context','Contexte')) + '</h4>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.perimeter','Périmètre')) + ':</strong> ' + _esc(a.context.perimeter || 'N/A') + '</div>';
    // === RA-ACTIFS === actifs liés résolus (orphelins exclus) — rien si aucun lien
    (function() {
      var _pvAssets = _raLinkedAssets(a);
      if (_pvAssets.length) {
        html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.linkedAssets','Actifs critiques concernés')) + ':</strong> ' + _esc(_pvAssets.map(_raAssetLabel).join(' ; ')) + '</div>';
      }
    })();
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.description','Description')) + ':</strong> ' + _esc(a.context.description || 'N/A') + '</div>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.stakeholders','Parties prenantes')) + ':</strong> ' + _esc(a.context.stakeholders || 'N/A') + '</div>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.analysisMethod','Méthode d\'analyse')) + ':</strong> ' + _esc(_raMethodLabel(a)) + '</div>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.sourceFramework','Catalogue de risques')) + ':</strong> ' + _esc(_raCatalogLabel(a)) + '</div>';
    if (a.context.analysisMethod === 'ebios-iso27005' && (a.context.riskSources || []).length && window._csiEbiosArchetypes) {
      // Gate sur la méthode : une sélection EBIOS résiduelle ne doit pas
      // s'afficher sous une méthode NIST (audit 2026-08-04, non destructif —
      // la sélection est conservée si l'utilisateur revient à EBIOS).
      html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.ebiosCouplesShort', 'Sources de risque (EBIOS RM)')) + ':</strong> ' + _esc(_raEbiosCoupleLabels(a).join(' · ')) + '</div>';
    }
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.personalData','Données personnelles')) + ':</strong> ' + (a.context.personalData ? _t('common.yes','Oui') : _t('common.no','Non')) + '</div>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.context.dataSensitivity','Sensibilité')) + ':</strong> ' + _esc(_raSensLabel(a.context.dataSensitivity)) + '</div>';
    if (a.impactMatrixId && window.CSI_ImpactMatrices) {
      var prevMatrix = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
      if (prevMatrix) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.impactMatrix.matrixUsed','Matrice d\'impact utilisée')) + ':</strong> ' + _esc(prevMatrix.name) + '</div>';
    }
    if (a.meta && a.meta.nextReviewDate) {
      html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.reviewDate.title','Prochaine revue')) + ':</strong> ' + _esc(a.meta.nextReviewDate) + '</div>';
    }
    // GDPR summary in context
    if (a.gdpr) {
      var _regLbls = { yes: _t('riskAnalysis.gdpr.registryYes','Oui'), no: _t('riskAnalysis.gdpr.registryNo','Non'), planned: _t('riskAnalysis.gdpr.registryPlanned','Planifié') };
      html += '<hr class="ra-section-divider">';
      html += '<h5><i class="fas fa-user-shield"></i> ' + _esc(_t('riskAnalysis.gdpr.title','RGPD — Protection des données')) + '</h5>';
      html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.gdpr.personalData','Données personnelles')) + ':</strong> ' + (a.gdpr.personalData ? _t('common.yes','Oui') : _t('common.no','Non')) + '</div>';
      html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.gdpr.sensitiveData','Données sensibles')) + ':</strong> ' + (a.gdpr.sensitiveData ? _t('common.yes','Oui') : _t('common.no','Non')) + '</div>';
      html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.gdpr.processingRegistry','Registre des traitements')) + ':</strong> ' + _esc(_regLbls[a.gdpr.processingRegistry] || '-') + '</div>';
    }
    html += '</div>';
    // CIA & Continuity — enriched visuals
    html += '<div class="ra-preview-section">';
    html += '<h4><i class="fas fa-shield-halved"></i> ' + _esc(_t('riskAnalysis.cia.title','Classification CIA')) + ' & ' + _esc(_t('riskAnalysis.continuity.title','Continuité')) + '</h4>';
    // CIA Visual Bars
    var _ciaDims = [
      { key: 'confidentiality', label: _t('riskAnalysis.cia.confidentiality','Confidentialité'), icon: 'fas fa-lock' },
      { key: 'integrity', label: _t('riskAnalysis.cia.integrity','Intégrité'), icon: 'fas fa-shield-halved' },
      { key: 'availability', label: _t('riskAnalysis.cia.availability','Disponibilité'), icon: 'fas fa-clock' }
    ];
    // === RA-UX (B1) === barres sur l'échelle courante (matrice liée ou 3 crans historiques).
    var _prvCiaScale = _ciaScaleForValues(a);
    html += '<div class="ra-preview-cia-bars">';
    _ciaDims.forEach(function(dim) {
      var val = _clamp(a.cia[dim.key] || 2, 1, _prvCiaScale.max);
      var cc = _ciaIsHigh(_prvCiaScale, val) ? 'red' : val === 1 ? 'green' : 'yellow';
      html += '<div class="ra-preview-cia-row">';
      html += '<span class="ra-preview-cia-label"><i class="' + dim.icon + '"></i> ' + _esc(dim.label) + '</span>';
      html += '<div class="ra-preview-cia-track">';
      for (var s = 1; s <= _prvCiaScale.max; s++) {
        html += '<div class="ra-preview-cia-seg' + (s <= val ? ' ra-preview-cia-seg--filled ra-preview-cia-seg--' + cc : '') + '"></div>';
      }
      html += '</div>';
      html += '<span class="ra-preview-cia-value">' + val + '/' + _prvCiaScale.max + ' — ' + _esc((_prvCiaScale.levels[val - 1] || {}).label || '') + '</span>';
      html += '</div>';
    });
    html += '</div>';
    // RTO/RPO Visual Gauges
    function _prvRtoZone(h) {
      if (h <= 4) return { cls: 'strict', label: _t('riskAnalysis.continuity.strict','Très strict'), pct: 10 };
      if (h <= 8) return { cls: 'moderate', label: _t('riskAnalysis.continuity.moderate','Strict'), pct: 28 };
      if (h <= 24) return { cls: 'standard', label: _t('riskAnalysis.continuity.standard','Standard'), pct: 55 };
      return { cls: 'relaxed', label: _t('riskAnalysis.continuity.relaxed','Souple'), pct: 85 };
    }
    function _prvRtoGauge(label, hours) {
      var z = _prvRtoZone(hours);
      var o = '<div class="ra-preview-rto-gauge">';
      o += '<span class="ra-preview-rto-label"><strong>' + _esc(label) + ':</strong> ' + hours + 'h</span>';
      o += '<div class="ra-preview-rto-track">';
      o += '<div class="ra-preview-rto-zone ra-preview-rto-zone--strict" style="width:15%"></div>';
      o += '<div class="ra-preview-rto-zone ra-preview-rto-zone--moderate" style="width:25%"></div>';
      o += '<div class="ra-preview-rto-zone ra-preview-rto-zone--standard" style="width:30%"></div>';
      o += '<div class="ra-preview-rto-zone ra-preview-rto-zone--relaxed" style="width:30%"></div>';
      o += '<div class="ra-preview-rto-marker" style="left:' + z.pct + '%"></div>';
      o += '</div>';
      o += '<span class="ra-preview-rto-indicator ra-rto-indicator--' + z.cls + '">' + _esc(z.label) + '</span>';
      o += '</div>';
      return o;
    }
    html += '<div class="ra-preview-rto-section">';
    // FIX-AUDIT-RTO-AFFICHAGE (audit v2) : `|| 24` retransformait en « standard » un 0 h
    // pourtant stocké et légitime — la collecte avait été corrigée, pas l'affichage.
    html += _prvRtoGauge('RTO', _hOr24(a.continuity.rtoHours));
    html += _prvRtoGauge('RPO', _hOr24(a.continuity.rpoHours));
    html += '</div>';
    if (a.continuity.comment) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.continuity.commentLabel','Commentaire continuité')) + ':</strong> ' + _esc(a.continuity.comment) + '</div>';
    if (a.cia.justification) html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.cia.justification','Justification')) + ':</strong> ' + _esc(a.cia.justification) + '</div>';
    html += '</div>';
    // Architecture
    html += '<div class="ra-preview-section">';
    html += '<h4><i class="fas fa-server"></i> ' + _esc(_t('riskAnalysis.architecture.title','Architecture')) + '</h4>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.architecture.hosting','Hébergement')) + ':</strong> ' + _esc(a.architecture.hosting || 'N/A') + '</div>';
    html += '<div class="ra-preview-kv"><strong>' + _esc(_t('riskAnalysis.architecture.internetExposed','Exposé Internet')) + ':</strong> ' + (a.architecture.internetExposed ? _t('common.yes','Oui') : _t('common.no','Non')) + '</div>';
    html += '<div class="ra-preview-kv"><strong>SSO:</strong> ' + (a.architecture.iam.sso ? _t('common.yes','Oui') : _t('common.no','Non')) + ' &nbsp; <strong>MFA:</strong> ' + (a.architecture.iam.mfa ? _t('common.yes','Oui') : _t('common.no','Non')) + ' &nbsp; <strong>' + _esc(_t('riskAnalysis.architecture.adminSeparation','Séparation admin')) + ':</strong> ' + (a.architecture.iam.adminSeparation ? _t('common.yes','Oui') : _t('common.no','Non')) + '</div>';
    html += '</div>';
    // Risk register — enriched with 5-level colors + treatment (=== RA-UX B3 === identification avant les mesures)
    if (a.risks && a.risks.length > 0) {
    html += '<div class="ra-preview-section">';
      html += '<h4><i class="fas fa-triangle-exclamation"></i> ' + _esc(_t('riskAnalysis.results.riskRegister','Registre des risques')) + '</h4>';
      html += '<table><thead><tr><th>' + _esc(_t('riskAnalysis.pdfThreat','Menace')) + '</th><th>' + _esc(_t('riskAnalysis.pdfScenario','Scénario')) + '</th><th>L</th><th>I</th><th>' + _esc(_t('riskAnalysis.pdfInherent','Inh.')) + '</th><th>' + _esc(_t('riskAnalysis.pdfResidual','Rés.')) + '</th><th>' + _esc(_t('riskAnalysis.results.riskLevel','Niveau')) + '</th><th>' + _esc(_t('riskAnalysis.risks.treatment','Traitement')) + '</th><th>' + _esc(_t('riskAnalysis.risks.owner','Propriétaire')) + '</th></tr></thead><tbody>';
      var _treatLabelsP = { reduce: _t('riskAnalysis.risks.treatReduce','Réduire'), accept: _t('riskAnalysis.risks.treatAccept','Accepter'), transfer: _t('riskAnalysis.risks.treatTransfer','Transférer'), avoid: _t('riskAnalysis.risks.treatAvoid','Éviter'), na: _t('riskAnalysis.risks.treatNa','Non applicable (exclu du scoring)') };
      a.risks.forEach(function(rk) {
        var cat = _raResolveCat(rk);
        var isNa = rk.treatmentDecision === 'na';
        var title = cat ? _bi(cat, 'threat', 'threatEn') : (rk.customTitle || '?');
        // FIX-AUDIT-REF (2026-08-25) : restituer la référence normative du pack (A01:2025,
        // T1566, E-x…) — elle était fournie par threat-catalog mais n'apparaissait NULLE PART,
        // rompant la traçabilité vers le référentiel choisi (argument produit des packs).
        if (cat && cat.ref) title += ' [' + cat.ref + ']';
        var scen = cat ? _bi(cat, 'scenario', 'scenarioEn') : '—';
        if (isNa && rk.notes) scen += ' — ' + _t('riskAnalysis.risks.naJustification','Justification de non-applicabilité') + ' : ' + rk.notes;
        var rs = r.riskScores.find(function(s) { return s.id === rk.id; }) || {};
        var safeLevelColor = (rs.levelColor || '').replace(/[^a-zA-Z0-9#(),.\s%]/g, '');
        html += '<tr' + (isNa ? ' style="opacity:0.55"' : '') + '><td>' + _esc(title) + '</td><td>' + _esc(scen) + '</td><td>' + _esc('' + (rk.likelihood || 3)) + '</td><td>' + _esc('' + (rk.impact || 3)) + '</td><td>' + _esc('' + (rs.inherent || '-')) + '</td><td>' + _esc('' + (rs.residual || '-')) + '</td>';
        html += '<td>' + (safeLevelColor ? '<span class="ra-level-dot" style="background:' + safeLevelColor + '"></span><small>' + _esc(rs.levelLabel || '') + '</small>' : '<span class="ra-light ra-light--' + _esc(rs.light || 'grey') + '"></span>') + '</td>';
        html += '<td>' + _esc(_treatLabelsP[rk.treatmentDecision] || rk.treatmentDecision || '-') + '</td>';
        html += '<td>' + _esc(rk.owner || '-') + '</td></tr>';
      });
      html += '</tbody></table>';
      // 5×5 Risk Matrix
      html += renderResultsMatrix(a, r);
      html += _renderProjectionMatrices(a, { results: r }); // === RA-PROJ === aperçu : grille projetée
      // Treatment distribution
      var _tc = { reduce: 0, accept: 0, transfer: 0, avoid: 0 };
      a.risks.forEach(function(rk) { var td = rk.treatmentDecision || 'reduce'; if (_tc[td] !== undefined) _tc[td]++; });
      var _tIcons = { reduce: 'fas fa-screwdriver-wrench', accept: 'fas fa-check-circle', transfer: 'fas fa-share', avoid: 'fas fa-ban' };
      var _tColors = { reduce: 'var(--color-primary)', accept: 'var(--color-success)', transfer: 'var(--color-warning)', avoid: 'var(--color-danger)' };
      html += '<h4 style="margin-top:var(--spacing-lg)"><i class="fas fa-chart-pie"></i> ' + _esc(_t('riskAnalysis.preview.treatmentDistribution','Répartition des traitements')) + '</h4>';
      html += '<div class="ra-preview-treatment-grid">';
      ['reduce','accept','transfer','avoid'].forEach(function(key) {
        html += '<div class="ra-preview-treatment-card"><div class="ra-preview-treatment-count" style="color:' + _tColors[key] + '">' + _tc[key] + '</div>';
        html += '<div class="ra-preview-treatment-label"><i class="' + _tIcons[key] + '"></i> ' + _esc(_treatLabelsP[key]) + '</div></div>';
      });
      html += '</div>';
      html += '</div>';
    }
    // Controls — All controls by domain (=== RA-UX B3 === après le registre)
    html += '<div class="ra-preview-section">';
    html += '<h4><i class="fas fa-clipboard-check"></i> ' + _esc(_t('riskAnalysis.controls.title','Mesures & Contrôles')) + '</h4>';
    if (r.totalPlannedControls > 0) {
      html += '<div class="ra-planned-warning-box">';
      html += '<div class="ra-planned-warning-icon"><i class="fas fa-exclamation-triangle"></i></div>';
      html += '<div class="ra-planned-warning-text">' + _esc(_t('riskAnalysis.controls.improvedWarning', 'Le résultat d\'analyse des risques repose sur le fait que les mesures et contrôles planifiés seront correctement implémentés — il sera indispensable de réaliser une analyse de risque préalablement au lancement en production du projet.')) + '</div>';
      html += '</div>';
    }
    if (r.allControlsList) {
      r.allControlsList.forEach(function(dom) {
        html += '<div class="ra-preview-controls-domain">';
        html += '<h5><i class="' + dom.icon + '"></i> ' + _esc(_t('riskAnalysis.domains.' + dom.domainId, dom.label || '')) + '</h5>';
        html += '<table class="ra-controls-table"><thead><tr>';
        html += '<th style="width:55%">' + _esc(_t('riskAnalysis.controls.pdfControl','Contrôle')) + '</th>';
        html += '<th style="width:15%">' + _esc(_t('riskAnalysis.controls.statusLabel','Statut')) + '</th>';
        html += '<th style="width:30%">' + _esc(_t('riskAnalysis.controls.comment','Commentaire')) + '</th>';
        html += '</tr></thead><tbody>';
        dom.controls.forEach(function(ctrl) {
          var statusLabel = _t('riskAnalysis.controls.' + ctrl.status, ctrl.status);
          html += '<tr>';
          html += '<td>' + _esc(ctrl.text + (ctrl.cia && ctrl.cia.length ? ' [' + ctrl.cia.join('·') + ']' : '')) + '</td>'; // === RA-UX (B4) ===
          html += '<td><span class="ra-status-badge ra-status-badge--' + ctrl.status + '">' + _esc(statusLabel) + '</span></td>';
          html += '<td class="ra-ctrl-comment">' + _esc(ctrl.comment || '-') + '</td>';
          html += '</tr>';
        });
        html += '</tbody></table></div>';
      });
    }
    html += '</div>';
    // Domain synthesis
    html += '<div class="ra-preview-section">';
    html += '<h4><i class="fas fa-chart-bar"></i> ' + _esc(_t('riskAnalysis.results.domainSynthesis','Synthèse des domaines')) + '</h4>';
    html += '<table><thead><tr><th>' + _esc(_t('riskAnalysis.domains.IAM','Domaine')) + '</th><th>' + _esc(_t('riskAnalysis.results.maturity','Maturité')) + '</th><th>%</th><th>' + _esc(_t('riskAnalysis.listColumns.light','Feu')) + '</th></tr></thead><tbody>';
    RA_DOMAINS.forEach(function(d) {
      var ds = r.domainScores[d.id] || { maturity: 0, percent: 0, light: 'grey' };
      var dsNA = _raDomainNA(ds);
      html += '<tr><td>' + _esc(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn'))) + '</td><td>' + (dsNA ? _esc(_t('riskAnalysis.controls.na', 'N/A')) : ds.maturity + '/3') + '</td><td>' + (dsNA ? '—' : ds.percent + '%' + (ds.plannedCount > 0 ? ' <span class="ra-planned-badge"><i class="fas fa-clock"></i> ' + ds.plannedCount + '</span>' : '')) + '</td><td><span class="ra-light ra-light--' + ds.light + '"></span></td></tr>';
    });
    html += '</tbody></table>';
    html += '<div class="ra-radar-wrapper"><canvas id="raPreviewRadar" width="400" height="400"></canvas></div>';
    html += '</div>';
    // Auto Recommendations
    if (r.recommendations && r.recommendations.length > 0) {
      html += '<div class="ra-preview-section">';
      html += '<h4><i class="fas fa-lightbulb"></i> ' + _esc(_t('riskAnalysis.results.recommendations','Recommandations')) + '</h4>';
      html += '<table><thead><tr><th>#</th><th>P</th><th>' + _esc(_t('riskAnalysis.pdfRecommendation','Recommandation')) + '</th><th>' + _esc(_t('riskAnalysis.pdfDomain','Domaine')) + '</th><th>' + _esc(_t('riskAnalysis.pdfEffort','Effort')) + '</th></tr></thead><tbody>';
      r.recommendations.forEach(function(rec, idx) {
        html += '<tr><td>' + (idx + 1) + '</td><td>' + _esc(rec.priority) + '</td><td>' + _esc(rec.title) + (rec.isPlanned ? ' <span class="ra-planned-badge"><i class="fas fa-clock"></i> ' + _esc(_t('riskAnalysis.controls.planned','Planifié')) + '</span>' : '') + '</td><td>' + _esc(_t('riskAnalysis.domains.' + rec.domainId, rec.domainId)) + '</td><td>' + _esc(rec.effort) + '</td></tr>';
      });
      html += '</tbody></table></div>';
    }
    // Assumptions
    html += '<div class="ra-preview-section">';
    html += '<div class="ra-preview-disclaimer">';
    html += _esc(_t('riskAnalysis.preview.disclaimer', 'Cette analyse reflète la posture à la date de réalisation. Les scores sont indicatifs et ne remplacent pas un audit formel. Les recommandations sont générées automatiquement sur la base des réponses fournies.'));
    var _prevLic = _raPackDisclaimer(a); // FIX-AUDIT-LICENCE : attribution du référentiel source
    if (_prevLic) html += '<br>' + _esc(_prevLic);
    html += '</div></div>';
    // Key Recommendations — Priority Mitigation Measures (last section, highlighted)
    var frItems = (a.finalRecs && a.finalRecs.items) ? a.finalRecs.items.filter(_frHasContent) : [];
    if (frItems.length > 0) {
      html += '<div class="ra-preview-section ra-preview-section--highlight">';
      html += '<h4><i class="fas fa-shield-halved"></i> ' + _esc(_t('riskAnalysis.finalRecs.mitigationTitle','Mesures de mitigation prioritaires')) + '</h4>';
      frItems.forEach(function(item, idx) {
        html += '<div class="ra-finalrec-card ra-finalrec-card--highlight"><strong>#' + (idx + 1) + ' — ' + _esc(_frDisplayLabel(a, item)) + '</strong>'; // === RA-PROJ === repli « Couvre : … » pour une reco liens-seulement
        html += '<span style="float:right;font-size:var(--font-size-xs);padding:2px 8px;border-radius:12px;background:' + (item.status === 'done' ? 'var(--color-success)' : 'var(--color-warning)') + ';color:#fff;">' + _esc(item.status === 'done' ? _t('riskAnalysis.finalRecs.statusDone','Fait') : _t('riskAnalysis.finalRecs.statusTodo','À faire')) + '</span>';
        html += '<p style="margin-top:4px;font-size:var(--font-size-sm);">' + _esc(item.details || '') + '</p>';
        // === RA-PROJ === risques LIÉS structurés d'abord ; le texte libre hérité suit,
        // relabellisé « Note complémentaire » (cohérent wizard/PDF — FIX-REVUE RA-PROJ).
        var _prevLinked = (Array.isArray(item.coveredRiskIds) ? item.coveredRiskIds : [])
          .map(function(rid) { return _frLinkedRiskLabel(a, rid); })
          .filter(function(n) { return n && n !== '—'; });
        if (_prevLinked.length) html += '<p style="font-size:var(--font-size-xs);color:var(--color-text-secondary);"><strong>' + _esc(_t('riskAnalysis.finalRecs.coveredRisks','Risques couverts')) + ' :</strong> ' + _esc(_prevLinked.join(', ')) + '</p>';
        if (item.coveredRisks) html += '<p style="font-size:var(--font-size-xs);color:var(--color-text-secondary);"><strong>' + _esc(_t('riskAnalysis.finalRecs.legacyNote','Note complémentaire (texte hérité)')) + ' :</strong> ' + _esc(item.coveredRisks) + '</p>';
        if (item.expectedMitigation) html += '<p style="font-size:var(--font-size-xs);color:var(--color-text-secondary);"><strong>' + _esc(_t('riskAnalysis.finalRecs.expectedMitigation','Atténuation attendue')) + ' :</strong> ' + _esc(item.expectedMitigation) + '</p>';
        html += '</div>';
      });
      html += '</div>';
    }
    html += '</div>';
    body.innerHTML = html;
    // === Build floating TOC ===
    (function() {
      var previewEl = body.querySelector('.ra-preview');
      if (!previewEl) return;
      var iconMap = {
        'fa-book-open': { id: 'ra-prev-methodology', label: _t('riskAnalysis.methodology.title','Méthodologie') },
        'fa-traffic-light': { id: 'ra-prev-spot', label: _t('riskAnalysis.spot.title','Évaluation') },
        'fa-info-circle': { id: 'ra-prev-context', label: _t('riskAnalysis.steps.context','Contexte') },
        'fa-shield-halved': { id: 'ra-prev-cia', label: _t('riskAnalysis.cia.title','CIA & Continuité') },
        'fa-server': { id: 'ra-prev-architecture', label: _t('riskAnalysis.architecture.title','Architecture') },
        'fa-clipboard-check': { id: 'ra-prev-controls', label: _t('riskAnalysis.controls.title','Contrôles') },
        'fa-chart-bar': { id: 'ra-prev-domains', label: _t('riskAnalysis.results.domainSynthesis','Synthèse') },
        'fa-triangle-exclamation': { id: 'ra-prev-risks', label: _t('riskAnalysis.results.riskRegister','Registre') },
        'fa-lightbulb': { id: 'ra-prev-recs', label: _t('riskAnalysis.results.recommendations','Recommandations') }
      };
      var sections = previewEl.querySelectorAll('.ra-preview-section');
      var tocHtml = '<nav class="ra-preview-toc"><strong>' + _esc(_t('riskAnalysis.preview.toc','Sommaire')) + '</strong>';
      sections.forEach(function(sec) {
        var entry = null;
        if (sec.classList.contains('ra-preview-methodology')) {
          entry = iconMap['fa-book-open'];
        } else if (sec.classList.contains('ra-preview-cover')) {
          entry = { id: 'ra-prev-cover', label: _t('riskAnalysis.preview.reportTitle','Couverture') };
        } else if (sec.classList.contains('ra-preview-section--highlight')) {
          entry = { id: 'ra-prev-keyrecs', label: _t('riskAnalysis.finalRecs.mitigationTitle','Mesures clefs') };
        } else if (sec.querySelector('.ra-preview-disclaimer')) {
          entry = { id: 'ra-prev-assumptions', label: _t('riskAnalysis.preview.assumptions','Hypothèses') };
        } else {
          var heading = sec.querySelector('h3, h4');
          var icon = heading ? heading.querySelector('i') : null;
          if (icon) {
            var ic = icon.className;
            Object.keys(iconMap).forEach(function(key) {
              if (ic.indexOf(key) !== -1 && !entry) entry = iconMap[key];
            });
          }
        }
        if (entry) {
          sec.id = entry.id;
          tocHtml += '<a href="#' + entry.id + '" class="ra-preview-toc__link" data-target="' + entry.id + '">' + _esc(entry.label) + '</a>';
        }
      });
      tocHtml += '</nav>';
      var infoBox = previewEl.querySelector('.ra-preview-info');
      if (infoBox) infoBox.insertAdjacentHTML('afterend', tocHtml);
      // Smooth scroll on click
      var tocLinks = previewEl.querySelectorAll('.ra-preview-toc__link');
      tocLinks.forEach(function(link) {
        link.addEventListener('click', function(e) {
          e.preventDefault();
          var target = document.getElementById(link.getAttribute('data-target'));
          if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
        });
      });
      // IntersectionObserver for active highlight
      if (tocLinks.length > 0 && typeof IntersectionObserver !== 'undefined') {
        if (_raObserver) { _raObserver.disconnect(); _raObserver = null; }
        _raObserver = new IntersectionObserver(function(entries) {
          entries.forEach(function(ent) {
            if (ent.isIntersecting) {
              tocLinks.forEach(function(l) { l.classList.remove('ra-preview-toc__link--active'); });
              var active = previewEl.querySelector('.ra-preview-toc__link[data-target="' + ent.target.id + '"]');
              if (active) active.classList.add('ra-preview-toc__link--active');
            }
          });
        }, { rootMargin: '-100px 0px -60% 0px' });
        sections.forEach(function(sec) { if (sec.id) _raObserver.observe(sec); });
      }
    })();
    // Initialize preview radar chart
    setTimeout(function() {
      var prc = document.getElementById('raPreviewRadar');
      if (!prc || typeof Chart === 'undefined') return;
      if (_raChartInstances.preview) { try { _raChartInstances.preview.destroy(); } catch(e){} _raChartInstances.preview = null; }
      var labels = [], data = [];
      RA_DOMAINS.forEach(function(d) {
        labels.push(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn')));
        data.push(r.domainScores[d.id] ? r.domainScores[d.id].maturity : 0);
      });
      _raChartInstances.preview = new Chart(prc, {
        type: 'radar',
        data: { labels: labels, datasets: [{ label: _t('riskAnalysis.results.maturity','Maturité'), data: data, backgroundColor: 'rgba(31,184,205,0.2)', borderColor: 'rgba(31,184,205,1)', pointBackgroundColor: 'rgba(31,184,205,1)', borderWidth: 2 }] },
        options: { scales: { r: { min: 0, max: 3, ticks: { stepSize: 1 } } }, plugins: { legend: { display: false } }, responsive: true, maintainAspectRatio: true }
      });
    }, 100);
  }

  // Step 8: Export
  function renderStepExport(body, a) {
    // === RA-UX (A3/B3) === liste DESCRIPTIVE (cases cochées de l'étape Export) — l'ordre
    // réel vit dans generateRiskAnalysisPDF ; resynchroniser À LA MAIN à tout réordonnancement.
    var secs = ['cover','executive','methodology','spot','context','cia','architecture','risks','controls','domains','recommendations','assumptions','keyRecommendations'];
    var html = '<div class="ra-export-grid">';

    // Card 1 — PDF Export
    html += '<div class="ra-export-card">';
    html += '<div class="ra-export-card__icon"><i class="fas fa-file-pdf"></i></div>';
    html += '<h4>' + _esc(_t('riskAnalysis.exportPdf.title','Export PDF')) + '</h4>';
    html += '<p>' + _esc(_t('riskAnalysis.exportPdf.sections','Le rapport contiendra :')) + '</p>';
    html += '<div class="ra-export-sections">';
    secs.forEach(function(s) {
      html += '<label><input type="checkbox" checked disabled> ' + _esc(_t('riskAnalysis.exportPdf.sectionList.' + s, s)) + '</label>';
    });
    html += '</div>';
    html += '<button class="btn btn--primary" onclick="raExportPdf(\'' + _raSafeId(a.id) + '\')" id="raExportBtn"><i class="fas fa-file-pdf"></i> ' + _esc(_t('riskAnalysis.exportPdf.generatePdf','Générer le PDF')) + '</button>';
    html += '</div>';

    // Card 2 — .analyse Export
    html += '<div class="ra-export-card">';
    html += '<div class="ra-export-card__icon"><i class="fas fa-file-export"></i></div>';
    html += '<h4>' + _esc(_t('riskAnalysis.exportPdf.analyseTitle','Export .analyse')) + '</h4>';
    html += '<p>' + _esc(_t('riskAnalysis.exportPdf.analyseDesc','Exporte l\'analyse complète au format JSON réimportable. Inclut la matrice d\'impacts et les seuils de risque liés.')) + '</p>';
    html += '<button class="btn btn--primary" onclick="raExportAnalyseFile(\'' + _raSafeId(a.id) + '\')"><i class="fas fa-file-export"></i> ' + _esc(_t('riskAnalysis.exportPdf.analyseTitle','Export .analyse')) + '</button>';
    html += '</div>';

    html += '</div>';
    body.innerHTML = html;
  }

  // ============================================================
  // SAVE CURRENT STEP
  // ============================================================
  // FIX-RA-COMMIT-EXITS (vidéo POC 2026-08-18) : la COLLECTE (DOM → analyse) est séparée
  // de la PERSISTANCE pour être rappelable par le commit de sortie ci-dessous.
  function collectCurrentStep(a) {
    switch (wizardState.step) {
      case 0:
        var el;
        el = document.getElementById('raTitle'); if (el) a.meta.title = el.value;
        el = document.getElementById('raPerimeter'); if (el) a.context.perimeter = el.value;
        el = document.getElementById('raDesc'); if (el) a.context.description = el.value;
        el = document.getElementById('raStake'); if (el) a.context.stakeholders = el.value;
        el = document.getElementById('raMethod'); if (el) {
          var _prevMethod = a.context.analysisMethod || '';
          a.context.analysisMethod = el.value;
          // Présélection de la matrice au CHANGEMENT de méthode. FIX-RA-METHOD-REMAP
          // (audit référentiels 2026-08-17, constats 1+3) : l'ancien code forçait le
          // <select> AVANT sa relecture — un choix manuel fait dans la MÊME visite
          // (« Aucune », matrice perso) était écrasé malgré le commentaire qui jurait
          // le contraire. Règle désormais : le remap ne s'applique que si le select est
          // resté sur la valeur STOCKÉE (l'utilisateur n'y a pas touché) ; et une
          // méthode SANS matrice attitrée (pragmatique) DÉTACHE la matrice auto de
          // l'ancienne méthode (le PDF n'imprime plus « pragmatique + matrice EBIOS »).
          var _mMap = { 'ebios-iso27005': 'imx-ebios-rm-v1', 'nist-800-30': 'imx-nist-800-30-v1' };
          if (el.value !== _prevMethod) {
            _ensureMatrixStore(); // FIX-RA-MATRIX-SEED : sans magasin, l'id posé serait mort-né
            var _want = _mMap[el.value] || null;
            var _prevAuto = _mMap[_prevMethod] || null;
            // FIX-RA-MATRIX-SEED : ne jamais pointer une matrice absente.
            if (_want && window.CSI_ImpactMatrices && !window.CSI_ImpactMatrices.getMatrixById(_want)) _want = null;
            var _selEl = document.getElementById('raMatrixSelect');
            var _selVal = _selEl ? (_selEl.value || null) : undefined;
            var _stored = a.impactMatrixId || null;
            var _untouched = (_selVal === undefined) || (_selVal === _stored);
            if (_untouched) {
              if (_want) {
                a.impactMatrixId = _want;
                if (_selEl) _selEl.value = _want;
              } else if (_prevAuto && _stored === _prevAuto) {
                // Rétrogradation vers une méthode sans matrice attitrée : on détache
                // la matrice héritée de l'ancienne méthode (retour à l'échelle 1-5).
                a.impactMatrixId = null;
                if (_selEl) _selEl.value = '';
              }
            }
          }
        }
        el = document.getElementById('raSourceFramework'); if (el) a.context.sourceFramework = el.value;
        var _cpl = document.querySelectorAll('.ra-ebios-couple');
        if (_cpl.length) {
          a.context.riskSources = [];
          _cpl.forEach(function(cb) { if (cb.checked) a.context.riskSources.push(cb.value); });
        }
        el = document.getElementById('raSensitivity'); if (el) a.context.dataSensitivity = el.value;
        el = document.getElementById('raMatrixSelect'); if (el) a.impactMatrixId = el.value || null;
        if (!a.identity) a.identity = {};
        el = document.getElementById('raEncoder'); if (el) a.identity.encoder = el.value;
        el = document.getElementById('raValidator'); if (el) a.identity.validator = el.value;
        el = document.getElementById('raControlBody'); if (el) a.identity.controlBody = el.value;
        el = document.getElementById('raBeneficiary'); if (el) a.identity.beneficiary = el.value;
        el = document.getElementById('raProjectManager'); if (el) a.identity.projectManager = el.value;
        _ciaEnsureScale(a); // === RA-UX (B1) === le changement de méthode/matrice réaligne l'échelle CIA
        break;
      case 1:
        el = document.getElementById('raCiaJustif'); if (el) a.cia.justification = el.value;
        // GDPR fields
        if (!a.gdpr) a.gdpr = { personalData: false, sensitiveData: false, processingRegistry: 'no' };
        el = document.getElementById('raGdprPersonal'); if (el) a.gdpr.personalData = el.checked;
        el = document.getElementById('raGdprSensitive'); if (el) a.gdpr.sensitiveData = el.checked;
        el = document.getElementById('raGdprRegistry'); if (el) a.gdpr.processingRegistry = el.value;
        // Sync back to context for backward compat
        a.context.personalData = a.gdpr.personalData;
        break;
      case 2:
        // FIX-AUDIT-RTO (audit 2026-08-25) : parseInt('0') est falsy — un RTO/RPO saisi à 0 h
        // était stocké 24 h, rendant inertes les majorations de continuité ET la reco
        // « exercer la reprise » précisément pour l'exigence la plus stricte. Parse NaN-aware.
        el = document.getElementById('raRto'); if (el) { var _rto = parseInt(el.value, 10); a.continuity.rtoHours = isNaN(_rto) ? 24 : Math.max(0, _rto); }
        el = document.getElementById('raRpo'); if (el) { var _rpo = parseInt(el.value, 10); a.continuity.rpoHours = isNaN(_rpo) ? 24 : Math.max(0, _rpo); }
        el = document.getElementById('raContinuityComment'); if (el) a.continuity.comment = el.value;
        break;
      case 3:
        el = document.getElementById('raHosting'); if (el) a.architecture.hosting = el.value;
        el = document.getElementById('raInternet'); if (el) a.architecture.internetExposed = el.checked;
        el = document.getElementById('raSso'); if (el) a.architecture.iam.sso = el.checked;
        el = document.getElementById('raMfa'); if (el) a.architecture.iam.mfa = el.checked;
        el = document.getElementById('raAdminSep'); if (el) a.architecture.iam.adminSeparation = el.checked;
        el = document.getElementById('raArchDoc'); if (el) a.architecture.architectureDoc = el.checked;
        break;
      case 4:
        // === RA-UX (B3) === Risks are saved in real-time via raToggleRisk/raUpdateRiskField/raAddCustomRisk handlers
        break;
      case 5:
        // === RA-UX (B3) === Controls are saved in real-time via raSetControl/raSetComment handlers
        break;
      case 6:
        // Save finalRecs inline (Mesures & Revue step)
        if (!a.finalRecs) a.finalRecs = { items: [] };
        a.finalRecs.items.forEach(function(item, idx) {
          el = document.getElementById('raFrTitle_' + idx); if (el) item.title = el.value;
          el = document.getElementById('raFrDetails_' + idx); if (el) item.details = el.value;
          el = document.getElementById('raFrCovered_' + idx); if (el) item.coveredRisks = el.value; // === RA-UX (A4) ===
          el = document.getElementById('raFrMitigation_' + idx); if (el) item.expectedMitigation = el.value; // === RA-UX (A4) ===
        });
        // === RA-PROJ === notes de projection : collectées ici pour hériter de
        // FIX-RA-COMMIT-EXITS (frappe sans blur + F5/bascule = plus de perte silencieuse).
        (a.risks || []).forEach(function(rk, ri) {
          el = document.getElementById('raProjNote_' + ri);
          if (!el || !rk) return;
          if (rk.projected) { rk.projected.note = el.value; }
          else if (el.value) {
            // FIX-REVUE RA-PROJ : note tapée sans blur sur une ligne encore sans cible —
            // matérialiser la cible à l'inhérent (même sémantique que raSetRiskProjection),
            // sinon la frappe est perdue aux sorties malgré FIX-RA-COMMIT-EXITS.
            rk.projected = { likelihood: _clamp(parseInt(rk.likelihood, 10) || 3, 1, 5), impact: _clamp(parseInt(rk.impact, 10) || 3, 1, 5), note: el.value };
          }
        });
        // === RA-PROJ === élagage PARESSEUX des liens pendants (risque supprimé) —
        // uniquement ici, en contexte d'édition (saveCurrentStep/raCommitWizardEntries
        // sont gardés _ro) ; jamais accroché aux sites de suppression de risques.
        var _knownRiskIds = {};
        (a.risks || []).forEach(function(rk) { if (rk && rk.id) _knownRiskIds[rk.id] = true; });
        a.finalRecs.items.forEach(function(item) {
          if (Array.isArray(item.coveredRiskIds)) {
            item.coveredRiskIds = item.coveredRiskIds.filter(function(rid) { return _knownRiskIds[rid]; });
          }
        });
        // Auto-clean empty items
        a.finalRecs.items = a.finalRecs.items.filter(_frHasContent);
        // Review date
        el = document.getElementById('raReviewEnabled');
        if (el) {
          if (el.checked) {
            var el2 = document.getElementById('raNextReview');
            a.meta.nextReviewDate = el2 ? el2.value : null;
          } else {
            a.meta.nextReviewDate = null;
          }
        }
        break;
      case 7:
        // Save Analyse de sécurité editable fields (Results step)
        if (!a.spot) a.spot = {};
        el = document.getElementById('raSpotOpenAdj'); if (el) a.spot.openRiskAdjustment = parseInt(el.value) || 0;
        el = document.getElementById('raSpotAvisComment'); if (el) a.spot.avisComment = el.value;
        el = document.getElementById('raSpotAvisOverride'); if (el) { a.spot.avisOverrideLight = el.value || null; }
        el = document.getElementById('raSpotAvisOverrideJustif'); if (el) a.spot.avisOverrideJustification = el.value;
        el = document.getElementById('raSpotResOverride'); if (el) { a.spot.residualOverrideScore = el.value ? parseInt(el.value, 10) : null; }
        el = document.getElementById('raSpotResJustif'); if (el) a.spot.residualOverrideJustification = el.value;
        el = document.getElementById('raSpotResCorrected'); if (el) a.spot.residualCorrectedComment = el.value;
        break;
    }
  }

  function saveCurrentStep() {
    // FIX-RA-READONLY : hors mode édition il n'y a RIEN de légitime à collecter. Sans ce
    // garde, consulter une analyse recopiait le DOM dans appData — et l'étape 6 y purgeait
    // au passage les recommandations vides, donc une simple consultation pouvait détruire
    // des données en mémoire puis les pousser à la prochaine acquisition.
    if (_ro()) return;
    var a = findAnalysis(wizardState.analysisId);
    if (!a) return;
    collectCurrentStep(a);
      // FIX-AUDIT-STALE (audit 2026-08-25) : raSetControl/raToggleRisk/raUpdateRiskField
      // mutent l'analyse SANS recalculer — la liste, le tri et le miroir projectRisks
      // lisaient un a.results périmé pendant que le PDF, lui, recalcule toujours
      // (FIX-RA-STALE-RESULTS) : liste et rapport pouvaient diverger. La garde
      // `if (a.results)` évite de créer un score pour un brouillon jamais calculé.
      if (a.results) a.results = computeAllResults(a);
    // FIX-MIRROR-DATE-ORDER (audit progressif, 2026-08-26) : le miroir lit
    // meta.updatedAt pour composer completionDate — l'appeler AVANT le bump lui faisait
    // enregistrer l'horodatage de la sauvegarde PRÉCÉDENTE, soit exactement la divergence
    // de date que FIX-AUDIT-MIRROR-STALE revendique fermer. Le bump vient donc d'abord.
    a.meta.updatedAt = _now();
    _refreshExistingProjectMirror(a); // FIX-AUDIT-MIRROR-STALE
    _save();
  }

  // FIX-RA-COMMIT-EXITS (vidéo POC 2026-08-18, wizard#1) : commit de SORTIE. La saisie de
  // l'étape courante n'était lue qu'à la navigation interne (Suivant/Précédent) — toute
  // autre sortie (bouton Mode édition, bascule d'onglet ou de périmètre, changement de
  // langue, F5, logout, auto-quit d'inactivité) la JETAIT pendant que l'indicateur
  // affichait « Sauvegardé sur le serveur » (l'autosave pousse la RAM, jamais le DOM).
  // Appelé par bundle.js à chaque sortie. Sûr par construction :
  //  - jamais en lecture seule (globale, grappe OU droit de section 'read') ;
  //  - jamais si le wizard n'est pas l'écran AFFICHÉ (le DOM d'un onglet quitté peut être
  //    PÉRIMÉ après un re-pull : le committer écraserait le travail frais d'un collègue) ;
  //  - delta-gate : AUCUNE écriture (ni updatedAt ni push) si rien n'a changé — les
  //    accroches multiples d'une même sortie sont no-op au 2e passage.
  window.raCommitWizardEntries = function() {
    if (_ro()) return false;
    if (!wizardState.analysisId) return false;
    if (!document.getElementById('raWizardBody')) return false;
    if (window._currentActiveTab && window._currentActiveTab !== 'risk-analysis') return false;
    var a = findAnalysis(wizardState.analysisId);
    if (!a) return false;
    var before = JSON.stringify(a);
    collectCurrentStep(a);
    // FIX-AUDIT-STALE-GATE (audit v2, 2026-08-25) : le rafraîchissement du cache était placé
    // APRÈS le delta-gate — or les étapes 4 (Risques) et 5 (Posture contrôles) ne collectent
    // RIEN (leurs 9 handlers écrivent en temps réel), donc le gate sortait toujours en amont
    // et le cache restait périmé. Mesuré : 27 contrôles passés à « non » laissaient la LISTE
    // afficher une pastille verte 2/5 pour une analyse rouge, pendant que le PDF recalculait.
    // On fait entrer le cache DANS le delta plutôt que sortir le recalcul du gate : un cache
    // périmé EST un changement, et l'idempotence verrouillée par ra-commit-exits est préservée
    // (la comparaison porte sur _signatureResultats : invariante à l'horodatage ET à la langue).
    var _fresh = a.results ? computeAllResults(a) : null;
    var _stale = !!_fresh && _signatureResultats(_fresh) !== _signatureResultats(a.results);
    if (JSON.stringify(a) === before && !_stale) return false;
    if (_fresh) a.results = _fresh;
    a.meta.updatedAt = _now(); // FIX-MIRROR-DATE-ORDER : bump AVANT le miroir (il lit cette date)
    _refreshExistingProjectMirror(a); // FIX-AUDIT-MIRROR-STALE : couvre toutes les sorties
    _save();
    return true;
  };

  // ============================================================
  // PDF EXPORT
  // ============================================================
  function generateRiskAnalysisPDF(a) {
    if (typeof jspdf === 'undefined' && typeof window.jspdf === 'undefined') {
      if (typeof window.showToast2 === 'function') window.showToast2('jsPDF non disponible.', 'error');
      return;
    }
    // === CSI-CO === (invariant de lecture seule, 2026-08-05) Exporter est une CONSULTATION :
    // l'export reste permis hors mode édition, mais il ne doit alors RIEN écrire. Sinon le
    // numéro de rapport était brûlé en RAM sans pouvoir être persisté (push refusé faute de
    // verrou) : le PDF distribué portait un numéro que le serveur ne connaissait pas, et
    // l'export suivant en attribuait un autre. On calcule donc les résultats dans une
    // variable LOCALE et on imprime un numéro provisoire, sans toucher à l'analyse.
    // FIX-AUDIT-RO-PDF (audit v2, 2026-08-25) : SEUL endroit du module qui recalculait la
    // lecture seule à la main — donc aveugle au droit de SECTION ('read'). Conséquence : un
    // compte en consultation attribuait ET persistait le numéro de rapport OFFICIEL.
    var _roPdf = _ro();
    // FIX-RA-STALE-RESULTS (audit 2026-08-17, résultats#1) : le PDF recalcule TOUJOURS —
    // l'ancien code préférait a.results, figé au dernier passage en étape Résultats : un
    // export lancé depuis la liste après une édition (contrôles repassés à « Non », risque
    // ajouté…) imprimait un rapport officiel avec les scores d'AVANT la modification. En
    // édition, le recalcul est aussi persisté (la liste et projectRisks se réalignent).
    var _results = computeAllResults(a);
    var _reportNo = a.meta.reportNumber;
    var _reportDate = new Date().toISOString().slice(0, 10);
    if (_roPdf) {
      // Numéro provisoire explicite : le lecteur voit que ce tirage n'est pas le rapport
      // officiel tant qu'il n'a pas été généré en mode édition.
      if (!_reportNo) _reportNo = _t('settings.pdfDraftNumber', 'BROUILLON (hors mode édition)');
      // FIX-AUDIT-PDF (2026-08-25) : avec un numero DEJA attribue, le tirage lecture seule
      // sortait avec le numero OFFICIEL sur un contenu recalcule — la promesse « brouillon »
      // n'etait tenue que par le nom de fichier. Marquage explicite, propage couverture+pied.
      else _reportNo = _reportNo + ' — ' + _t('riskAnalysis.pdfReissueUnofficial', 'retirage non officiel');
    } else {
      a.results = _results;
      if (!a.meta.reportNumber) a.meta.reportNumber = _generateReportNumber();
      a.meta.reportDate = _reportDate;
      _reportNo = a.meta.reportNumber;
      _save();
    }
    var jsPDF = (window.jspdf && window.jspdf.jsPDF) || jspdf.jsPDF;
    var doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
    // FIX-AUDIT-PDF-AUTOTABLE (audit v2, 2026-08-25) : sans le plugin autoTable, la génération
    // jetait au 1er tableau — exception muette, aucun PDF produit, et le numéro de rapport
    // officiel déjà attribué et persisté. Échec explicite et SANS effet de bord.
    if (typeof doc.autoTable !== 'function') {
      if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.pdfPluginMissing', 'Export PDF indisponible : extension de tableaux non chargée.'), 'error');
      return;
    }
    var M = { left: 20, right: 20, top: 28, bottom: 18 };
    var PW = 210 - M.left - M.right;
    var PH = 297;
    var y = M.top;
    var r = _results;                       // (lecture seule : jamais stocké dans l'analyse)
    var today = _roPdf ? _reportDate : (a.meta.reportDate || _reportDate);
    var docTitle = _t('riskAnalysis.pdfTitle', 'Analyse des risques en sécurité de l\'information');
    var sectionNum = 0;
    // Theme-aware colors from CSS custom properties
    var themeStyles = getComputedStyle(document.body || document.documentElement);
    function _readCssColor(varName, fallback) { var hex = (themeStyles.getPropertyValue(varName) || '').trim() || fallback; return _hexToRgb(hex); }
    var COLORS = {
      primary: _readCssColor('--color-primary', '#3B82F6'),
      success: _readCssColor('--color-success', '#22C55E'),
      warning: _readCssColor('--color-warning', '#F97316'),
      danger: _readCssColor('--color-danger', '#EF4444'),
      muted: [107, 114, 128],
      headerBg: [241, 245, 249],
      alternateBg: [248, 250, 252]
    };

    // --- PDF metadata ---
    doc.setProperties({
      title: docTitle + ' - ' + (a.meta.title || ''),
      author: 'Cyber-Assistant',
      subject: _t('riskAnalysis.pdfSubject', 'Rapport d\'analyse de risques'),
      creator: 'Cyber-Assistant SMSI'
    });

    // --- Utility functions ---
    // FIX-AUDIT-PDF-LARGEUR (audit v2, 2026-08-25) : plusieurs doc.text directs écrivaient
    // sans borne horizontale — un titre d'analyse long (le champ n'a AUCUN maxlength et le
    // titre survit à l'import) débordait de la page et recouvrait la date, sur TOUTES les
    // pages. On coupe aux VRAIES métriques de la police, pas à un nombre de caractères.
    function _clipLargeur(txt, largeurMax) {
      var t = _pdfSafeLine(txt);
      if (largeurMax <= 0) return '';
      if (doc.getTextWidth(t) <= largeurMax) return t;
      var ell = '...';
      var dispo = largeurMax - doc.getTextWidth(ell);
      if (dispo <= 0) return '';
      var lo = 0, hi = t.length;
      while (lo < hi) {
        var mid = Math.ceil((lo + hi) / 2);
        if (doc.getTextWidth(t.slice(0, mid)) <= dispo) lo = mid; else hi = mid - 1;
      }
      return t.slice(0, lo) + ell;
    }
    function addHeader() {
      var pw = doc.internal.pageSize.getWidth();
      doc.setDrawColor(200);
      doc.setLineWidth(0.3);
      doc.line(M.left, 16, pw - M.right, 16);
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(7);
      doc.setTextColor(120);
      var headerTitle = docTitle;
      if (headerTitle.length > 55) headerTitle = headerTitle.substring(0, 52) + '...';
      // FIX-AUDIT-PDF-CHROME : l'en-tête courant est redessiné sur CHAQUE page — un
      // séparateur invisible dans le titre corrompait donc tout le rapport.
      // FIX-AUDIT-PDF-LARGEUR : et il est borné à l'espace réellement libre avant la date.
      var _dispoEntete = (pw - M.right) - M.left - doc.getTextWidth(today) - 4;
      doc.text(_clipLargeur(headerTitle + ' — ' + (a.meta.title || ''), _dispoEntete), M.left, 12);
      doc.text(today, pw - M.right, 12, { align: 'right' });
      doc.setTextColor(0);
    }
    function addFooter(pageNum) {
      var pw = doc.internal.pageSize.getWidth();
      var ph = doc.internal.pageSize.getHeight();
      doc.setDrawColor(200);
      doc.setLineWidth(0.3);
      doc.line(M.left, ph - 12, pw - M.right, ph - 12);
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(8);
      doc.setTextColor(120);
      var footerLeft = (_reportNo || '') + ' — ' + today;
      doc.text(footerLeft, M.left, ph - 7);
      doc.text('Page ' + pageNum, pw / 2, ph - 7, { align: 'center' });
      doc.text('Cyber-Assistant', pw - M.right, ph - 7, { align: 'right' });
      doc.setTextColor(0);
    }
    // === RA-PROJ === traceur de grille 5×5 PARAMÉTRÉ (utilitaire top-level du PDF —
    // en mode strict une déclaration dans un bloc if ne serait pas visible d'ailleurs).
    // Sert la grille inhérente (comportement historique), la grille projetée et les
    // mini-grilles de la synthèse management.
    function _pdfDrawGrid(tGrid, cellMap, sx, sy, cs, opts) {
      opts = opts || {};
      var headFs = opts.headFs || 6, countFs = opts.countFs || 9;
      doc.setFontSize(headFs); doc.setFont(undefined, 'bold');
      for (var dg = 1; dg <= 5; dg++) {
        doc.text(String(dg), sx + (dg - 1) * cs + cs / 2, sy - 2, { align: 'center' });
      }
      for (var dp = 5; dp >= 1; dp--) {
        var dry = sy + (5 - dp) * cs;
        doc.setFontSize(headFs); doc.setFont(undefined, 'bold');
        doc.text(String(dp), sx - 4, dry + cs / 2 + 1, { align: 'right' });
        for (var dg2 = 1; dg2 <= 5; dg2++) {
          var dkey = dp + '_' + dg2;
          var dlid = (tGrid.cells || {})[dkey] || 'minimum';
          var dlvl = (tGrid.levels || []).find(function(l) { return l && l.id === dlid; });
          var dc = _hexToRgb(dlvl ? dlvl.color : '#cccccc');
          doc.setFillColor(dc[0], dc[1], dc[2]);
          var dcx = sx + (dg2 - 1) * cs;
          doc.rect(dcx, dry, cs, cs, 'F');
          var dcnt = cellMap[dkey] || 0;
          var ducnt = opts.uncoveredMap ? (opts.uncoveredMap[dkey] || 0) : 0;
          if (dcnt > 0) {
            doc.setFontSize(countFs); doc.setFont(undefined, 'bold');
            doc.setTextColor(255, 255, 255);
            doc.text(String(dcnt), dcx + cs / 2 + (ducnt > 0 ? -cs / 5 : 0), dry + cs / 2 + 2, { align: 'center' });
            doc.setTextColor(0);
          }
          if (ducnt > 0) {
            // Marqueur DISTINCT des risques non couverts : « (n) » gris foncé — la
            // grille projetée ne mélange pas deux sémantiques sans le dire.
            doc.setFontSize(Math.max(5, countFs - 2)); doc.setFont(undefined, 'normal');
            doc.setTextColor(60, 60, 60);
            doc.text('(' + ducnt + ')', dcx + cs / 2 + (dcnt > 0 ? cs / 5 : 0), dry + cs / 2 + 2, { align: 'center' });
            doc.setTextColor(0);
          }
        }
      }
      return sy + 5 * cs;
    }
    function addPage() { doc.addPage(); y = M.top; }
    function checkPage(needed) { if (y + needed > PH - M.bottom) addPage(); }
    function sTitle(text, size) {
      sectionNum++;
      checkPage(14);
      var label = sectionNum + '. ' + text;
      doc.setFontSize(size || 14);
      doc.setFont(undefined, 'bold');
      doc.setTextColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
      doc.text(label, M.left, y);
      y += 2;
      doc.setDrawColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
      doc.setLineWidth(0.5);
      doc.line(M.left, y, M.left + PW, y);
      y += (size || 14) * 0.4 + 4;
      doc.setFont(undefined, 'normal');
      doc.setFontSize(10);
      doc.setTextColor(0);
      doc.setDrawColor(0);
    }
    function text(t, indent) {
      if (!t) return;
      // FIX-AUDIT-PDF (2026-08-25) : (1) _pdfSafe generalise — ces helpers impriment des
      // SAISIES LIBRES (contexte, commentaires) et esquivaient l'assainissement, alors que
      // la classe de bug est prouvee (FIX-RA-PDF-BREAKS) ; (2) pagination LIGNE A LIGNE —
      // un seul checkPage(8) puis N lignes imprimees debordait sous le pied de page, jsPDF
      // ne clippant pas : le contenu etait perdu SANS erreur.
      var lines = doc.splitTextToSize(_pdfSafe(t), PW - (indent || 0));
      for (var li = 0; li < lines.length; li++) {
        checkPage(8);
        doc.text(lines[li], M.left + (indent || 0), y);
        y += 4.5;
      }
    }
    function labelValue(label, value) {
      checkPage(8);
      doc.setFont(undefined, 'bold');
      doc.setFontSize(9);
      doc.text(_pdfSafeLine(label) + ': ', M.left, y);
      var lw = doc.getTextWidth(_pdfSafeLine(label) + ': ');
      doc.setFont(undefined, 'normal');
      // FIX-AUDIT-PDF : idem text() — assainissement + pagination ligne a ligne. La 1re
      // ligne reste alignee sur le label ; les suivantes reprennent a gauche apres saut.
      var valLines = doc.splitTextToSize(_pdfSafe(value == null || value === '' ? 'N/A' : value), PW - lw);
      for (var vi = 0; vi < valLines.length; vi++) {
        if (vi > 0) checkPage(8);
        doc.text(valLines[vi], M.left + (vi === 0 ? lw : lw), y);
        y += 4.5;
      }
      doc.setFontSize(10);
    }
    function lightCircle(x, yc, light) {
      var colors = { green: [34,197,94], yellow: [249,115,22], red: [239,68,68], grey: [200,200,200] };
      var c = colors[light] || colors.grey;
      doc.setFillColor(c[0], c[1], c[2]);
      doc.circle(x, yc, 2.5, 'F');
    }
    function drawTrafficLightPDF(x, yStart, activeLight) {
      doc.setFillColor(30, 30, 30);
      doc.roundedRect(x, yStart, 16, 40, 3, 3, 'F');
      var centers = [
        { cy: yStart + 9, light: 'red' },
        { cy: yStart + 20, light: 'yellow' },
        { cy: yStart + 31, light: 'green' }
      ];
      var activeColors = { red: [239, 68, 68], yellow: [249, 195, 80], green: [34, 197, 94] };
      centers.forEach(function(c) {
        if (c.light === activeLight) {
          var clr = activeColors[c.light];
          doc.setFillColor(clr[0], clr[1], clr[2]);
        } else {
          doc.setFillColor(70, 70, 70);
        }
        doc.circle(x + 8, c.cy, 4.5, 'F');
      });
    }
    var tableDefaults = {
      // FIX-AUDIT-PDF-MARGIN (audit v2, 2026-08-25) : sans top/bottom, autoTable complète les
      // côtés absents par 40/scaleFactor = 14,11 mm — sur chaque page de CONTINUATION la table
      // redémarrait sous le filet d'en-tête (tracé à y=16 après coup), qui barrait sa ligne
      // d'en-tête. startY numérique n'étant pas borné par margin.top, aucune première page ne
      // bouge. Le jumeau pdf-report.js pose déjà ces marges : c'était un oubli, pas une règle.
      margin: { left: M.left, right: M.right, top: M.top, bottom: M.bottom },
      tableWidth: PW,
      styles: { fontSize: 8, cellPadding: 2.5, lineColor: [220, 220, 220], lineWidth: 0.2 },
      headStyles: { fillColor: COLORS.primary, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
      alternateRowStyles: { fillColor: COLORS.alternateBg }
    };

    // ==========================================================
    // PAGE DE GARDE PROFESSIONNELLE
    // ==========================================================
    doc.setFillColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
    doc.rect(0, 0, 210, 70, 'F');
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(20);
    doc.setTextColor(255);
    var titleLines = doc.splitTextToSize(docTitle, 170);
    var titleStartY = titleLines.length > 1 ? 28 : 35;
    doc.text(titleLines, 105, titleStartY, { align: 'center' });
    doc.setFontSize(13);
    // FIX-AUDIT-PDF-LARGEUR : le titre de couverture est centré — non borné, il sortait des
    // deux côtés de la page. Repli sur 2 lignes MAXIMUM (la bande colorée s'arrête à y=70),
    // la seconde tronquée aux vraies métriques.
    var _titreCouv = doc.splitTextToSize(_pdfSafeLine(a.meta.title || _t('riskAnalysis.pdfUntitled', 'Sans titre')), 210 - 2 * M.left - 10);
    doc.text(_titreCouv[0], 105, 52, { align: 'center' });
    if (_titreCouv.length > 1) {
      var _reste = _titreCouv.slice(1).join(' ');
      doc.text(_clipLargeur(_reste, 210 - 2 * M.left - 10), 105, 58, { align: 'center' });
    }

    // Optional logo (preserve aspect ratio, max 60×30mm, centered)
    var coverY = 82;
    if (window.appData && window.appData.settings && window.appData.settings.logo) {
      try {
        var logoProps = doc.getImageProperties(window.appData.settings.logo);
        var maxW = 60, maxH = 30;
        var logoRatio = Math.min(maxW / logoProps.width, maxH / logoProps.height);
        var logoW = logoProps.width * logoRatio, logoH = logoProps.height * logoRatio;
        var logoX = (210 - logoW) / 2;
        doc.addImage(window.appData.settings.logo, logoProps.fileType || 'PNG', logoX, coverY, logoW, logoH);
        coverY += logoH + 8;
      } catch(e) { /* skip */ }
    }

    // Identity block
    var ident = a.identity || {};
    doc.setTextColor(50);
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(10);
    var identFields = [
      { label: _t('riskAnalysis.pdfBeneficiary','Bénéficiaire/Client'), value: ident.beneficiary },
      { label: _t('riskAnalysis.pdfProjectManager','Chef de projet'), value: ident.projectManager },
      { label: _t('riskAnalysis.pdfControlBody','Organisme de contrôle'), value: ident.controlBody },
      { label: _t('riskAnalysis.pdfEncoder','Encodage'), value: ident.encoder },
      { label: _t('riskAnalysis.pdfValidator','Validation'), value: ident.validator }
    ];
    identFields.forEach(function(f) {
      if (f.value) {
        doc.setFont('helvetica', 'bold');
        doc.text(_pdfSafeLine(f.label) + ' : ', M.left + 20, coverY);
        var lw = doc.getTextWidth(_pdfSafeLine(f.label) + ' : ');
        doc.setFont('helvetica', 'normal');
        // FIX-AUDIT-PDF-LARGEUR : ces champs d'identité n'ont aucun maxlength et sortaient
        // de la page ; bornés à l'espace restant, aux vraies métriques.
        doc.text(_clipLargeur(f.value, (210 - M.right) - (M.left + 20 + lw)), M.left + 20 + lw, coverY);
        coverY += 7;
      }
    });
    coverY += 6;

    // Report metadata
    doc.setFontSize(10);
    doc.setTextColor(80);
    if (_reportNo) {
      doc.text(_t('riskAnalysis.pdfReport', 'Rapport') + ' : ' + _reportNo, 105, coverY, { align: 'center' });
      coverY += 7;
    }
    doc.text(_t('riskAnalysis.pdfDate', 'Date') + ' : ' + today, 105, coverY, { align: 'center' });
    coverY += 7;
    doc.text(_t('riskAnalysis.pdfStatus', 'Statut') + ' : ' + (a.meta.status === 'complete' ? _t('riskAnalysis.pdfComplete', 'Terminée') : _t('riskAnalysis.pdfDraft', 'Brouillon')), 105, coverY, { align: 'center' });
    coverY += 16;

    // Spot summary on cover (traffic light + avis + summary table)
    if (r.spotSummary) {
      var spCover = r.spotSummary;
      drawTrafficLightPDF(210 - M.right - 18, coverY, spCover.light);
      var avisColorsCover = { red: [239, 68, 68], yellow: [200, 150, 30], green: [34, 197, 94] };
      var acCover = avisColorsCover[spCover.light] || [0, 0, 0];
      doc.setFontSize(11);
      doc.setFont('helvetica', 'bold');
      doc.setTextColor(acCover[0], acCover[1], acCover[2]);
      var coverAvisLines = doc.splitTextToSize(spCover.avisLabel || '', PW - 30);
      doc.text(coverAvisLines, M.left, coverY + 4);
      doc.setTextColor(0);
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(10);
      coverY += coverAvisLines.length * 5 + 6;
      var spotRowsCover = [
        [_t('riskAnalysis.pdfInherentCriticality', 'Criticité de l\'actif (risque inhérent)'), spCover.inherentCriticalityScore + '/5'],
        [_t('riskAnalysis.pdfMeasuresImpl', 'Niveau d\'implémentation des mesures'), spCover.measuresNotApplicable ? _t('riskAnalysis.controls.na', 'N/A') : spCover.measuresImplementationPercent + '%'],
        [_t('riskAnalysis.pdfResidualLevel', 'Niveau de risque résiduel'), spCover.residualScore + '/5'],
        [_t('riskAnalysis.pdfResidualPlusOpen', 'Risque résiduel + risque ouvert'), spCover.residualPlusOpenScore + '/5']
      ];
      if ((a.spot || {}).avisComment) spotRowsCover.push([_t('riskAnalysis.pdfSecurityComment', 'Commentaire avis Sécurité'), _pdfSafe(a.spot.avisComment)]); // FIX-AUDIT-PDF
      // FIX-AUDIT-OVERRIDE (audit 2026-08-25) : la justification n'était imprimée QUE si elle
      // était non vide — un avis ou un résiduel FORCÉ pouvait donc sortir dans le rapport
      // officiel sans aucune trace de la correction. Toute correction est désormais tracée,
      // avec mention explicite quand la justification manque (données antérieures au gate).
      if (spCover.avisOverridden) spotRowsCover.push([_t('riskAnalysis.spot.avisOverrideJustifLabel', 'Justification correction avis'), _pdfSafe(((a.spot || {}).avisOverrideJustification || '').trim() || _t('riskAnalysis.spot.overrideJustifMissing', 'non renseignée'))]);
      if ((a.spot || {}).residualOverrideScore != null) spotRowsCover.push([_t('riskAnalysis.spot.residualOverrideJustifLabel', 'Justification de la correction'), _pdfSafe((a.spot.residualOverrideJustification || '').trim() || _t('riskAnalysis.spot.overrideJustifMissing', 'non renseignée'))]);
      if ((a.spot || {}).residualCorrectedComment) spotRowsCover.push([_t('riskAnalysis.pdfResidualComment', 'Commentaire résiduel corrigé'), _pdfSafe(a.spot.residualCorrectedComment)]); // FIX-AUDIT-PDF
      doc.autoTable(Object.assign({}, tableDefaults, {
        startY: coverY, body: spotRowsCover,
        tableWidth: PW - 25,
        columnStyles: { 0: { cellWidth: 80, fontStyle: 'bold' } },
        didParseCell: function(data) {
          if (data.column.index === 1 && data.section === 'body') {
            var ri = data.row.index;
            if (ri === 0) { var v0 = spCover.inherentCriticalityScore; data.cell.styles.textColor = v0 >= 4 ? COLORS.danger : v0 === 3 ? [200,150,30] : COLORS.success; }
            else if (ri === 1) { var v1 = spCover.measuresImplementationPercent; data.cell.styles.textColor = spCover.measuresNotApplicable ? [120,120,120] : v1 >= 70 ? COLORS.success : v1 >= 40 ? [200,150,30] : COLORS.danger; }
            else if (ri === 2) { var v2 = spCover.residualScore; data.cell.styles.textColor = v2 <= 2 ? COLORS.success : v2 === 3 ? [200,150,30] : COLORS.danger; }
            else if (ri === 3) { var v3 = spCover.residualPlusOpenScore; data.cell.styles.textColor = v3 <= 2 ? COLORS.success : v3 === 3 ? [200,150,30] : COLORS.danger; }
          }
        }
      }));
    }

    // Footer
    doc.setFontSize(9);
    doc.setTextColor(150);
    doc.setFont('helvetica', 'normal');
    doc.text(_t('riskAnalysis.pdfConfidential', 'Document confidentiel'), 105, PH - 25, { align: 'center' });
    doc.setFontSize(8);
    doc.text(_t('riskAnalysis.pdfGeneratedBy', 'Généré par Cyber-Assistant'), 105, PH - 18, { align: 'center' });

    // ==========================================================
    // 1. SYNTHÈSE MANAGEMENT (=== RA-UX A3 === décision 20/08 : l'essentiel pour les
    // profils de décision en page 2 — les recommandations clefs y figurent en résumé
    // (titre + statut + atténuation attendue) ET restent détaillées en fin de rapport)
    // ==========================================================
    addPage();
    sTitle(_t('riskAnalysis.pdfExecSummary', 'Synthèse management'), 14);
    var execLight = r.spotSummary ? r.spotSummary.light : r.globalLight;
    var execScore = r.spotSummary ? r.spotSummary.residualPlusOpenScore : r.globalScore;
    // FIX-PDF-AVIS-LABEL (audit progressif, 2026-08-26) : cette page recomposait le libellé
    // depuis la COULEUR, seule surface de l'application à le faire — l'aperçu, l'écran
    // Résultats et la couverture du PDF lisent tous `spotSummary.avisLabel`. Or trois états
    // distincts partagent le jaune : « moyennant les recommandations », « en attente — aucun
    // risque évalué » et « réservé — risque résiduel élevé ». Tous s'écrasaient donc en
    // « Avis favorable moyennant… », et le rapport officiel se contredisait d'une page à
    // l'autre : couverture « Avis en attente — aucun risque évalué », page 2 « Avis
    // favorable… », annonçant des recommandations qui n'existent pas.
    // Les replis compacts ('Favorable sous réserve'…) étaient morts : les clés existent
    // dans les 3 locales, donc _t résolvait toujours la phrase longue.
    var execAvis = (r.spotSummary && r.spotSummary.avisLabel)
      || ({ green: _t('riskAnalysis.spot.avisLabels.green', 'Avis favorable'),
            yellow: _t('riskAnalysis.spot.avisLabels.yellow', 'Avis favorable moyennant la mise en œuvre des recommandations clefs'),
            red: _t('riskAnalysis.spot.avisLabels.red', 'Avis défavorable') })[execLight]
      || execLight;
    labelValue(_t('riskAnalysis.pdfAvis', 'Avis'), execAvis);
    labelValue(_t('riskAnalysis.pdfGlobalScore', 'Score global'), execScore + '/5');
    if (r.spotSummary) {
      labelValue(_t('riskAnalysis.spot.inherentCriticality', 'Criticité de l\'actif (risque inhérent)'), r.spotSummary.inherentCriticalityScore + '/5');
      labelValue(_t('riskAnalysis.spot.measuresPercent', 'Niveau d\'implémentation des mesures'), r.spotSummary.measuresNotApplicable ? _t('riskAnalysis.controls.na', 'N/A') : r.spotSummary.measuresImplementationPercent + '%');
      labelValue(_t('riskAnalysis.spot.residualScore', 'Niveau de risque résiduel'), r.spotSummary.residualScore + '/5');
    }
    var avgPercent = 0, domCount = 0;
    RA_DOMAINS.forEach(function(d) { if (r.domainScores[d.id] && !_raDomainNA(r.domainScores[d.id])) { avgPercent += r.domainScores[d.id].percent; domCount++; } });
    avgPercent = domCount > 0 ? Math.round(avgPercent / domCount) : 0;
    labelValue(_t('riskAnalysis.pdfControlPosture', 'Posture contrôles moyenne'), avgPercent + '%');
    // === RA-UX (B2) === position du résiduel par rapport aux critères d'acceptation.
    if (r.acceptance && r.acceptance.floorLevelId) {
      var _accVal = (r.acceptance.beyondCount > 0)
        ? r.acceptance.beyondCount + ' ' + _t('riskAnalysis.thresholds.acceptanceBannerRisks', 'risque(s) résiduel(s) au-delà des critères d\'acceptation du risque')
        : _t('riskAnalysis.thresholds.acceptanceConform', 'Conformes aux critères d\'acceptation');
      _accVal += ' (' + _t('riskAnalysis.thresholds.acceptanceFloorShort', 'plancher') + ' : ' + r.acceptance.floorLabel + ')' + _acceptanceRefSuffix(r.acceptance);
      labelValue(_t('riskAnalysis.thresholds.acceptanceTitle', 'Critères d\'acceptation du risque'), _accVal);
    }
    var frItemsExec = (a.finalRecs && a.finalRecs.items) ? a.finalRecs.items.filter(_frHasContent) : [];
    if (frItemsExec.length > 0) {
      y += 4;
      checkPage(12);
      doc.setFont('helvetica', 'bold');
      doc.setFontSize(11);
      doc.setTextColor(0);
      doc.text(_t('riskAnalysis.finalRecs.title', 'Recommandations clefs'), M.left, y);
      y += 6;
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(9);
      frItemsExec.forEach(function(item, i) {
        checkPage(12);
        var stLbl = item.status === 'done' ? _t('riskAnalysis.pdfDone', 'Fait') : _t('riskAnalysis.pdfTodo', 'À faire');
        // Repli : recommandation sans intitulé → détail, puis champs A4 (FIX-REVUE RA-UX :
        // _frHasContent admet un item rempli uniquement via risques couverts/atténuation —
        // sans cette chaîne, la synthèse imprimait une puce numérotée vide).
        // _pdfSafeLine : l'intitulé est une ligne unique — aplatir tout saut/contrôle qui,
        // sinon, déborderait du champ (splitTextToSize ne mesure pas ces caractères).
        var recLine = (i + 1) + '. ' + _pdfSafeLine(_frDisplayLabel(a, item)) + ' — [' + stLbl + ']'; // === RA-PROJ === repli « Couvre : … »
        var recLines = doc.splitTextToSize(recLine, PW - 4);
        doc.text(recLines, M.left + 2, y);
        y += recLines.length * 4.2 + 1;
        if (item.expectedMitigation) {
          var mitLines = doc.splitTextToSize(_t('riskAnalysis.finalRecs.expectedMitigation', 'Atténuation attendue') + ' : ' + _pdfSafe(item.expectedMitigation), PW - 10);
          doc.setTextColor(90);
          doc.text(mitLines, M.left + 6, y);
          doc.setTextColor(0);
          y += mitLines.length * 3.8 + 1.5;
        }
      });
    }
    // === RA-PROJ === paire de MINI-GRILLES avant/après (arbitrage PO n°3 amendé
    // 24/08) — même paire sémantique que l'écran et la section Risques (inhérent →
    // cible déclarée, JAMAIS le résiduel calculé), légende-titre au CONDITIONNEL.
    // Garde A3 « synthèse = 1 page max » : si l'espace restant ne suffit pas, repli
    // automatique en ligne de texte conditionnelle seule.
    var execProjSum = computeProjectionSummary(a, r);
    if (execProjSum.anyMoved) {
      var execCaption = execProjSum.floorDefined
        ? _t('riskAnalysis.projection.pdfCaption', 'Projection si mise en œuvre complète des {n} recommandation(s) clef(s) liée(s) : {x} risque(s) seraient ramenés sous les critères d\'acceptation, {m} resteraient au-delà. Positions cibles déclarées par l\'analyste — aucun recalcul.')
          .replace('{n}', String(execProjSum.linkedRecCount))
          .replace('{x}', String(execProjSum.broughtUnder))
          .replace('{m}', String(execProjSum.coveredBeyond + execProjSum.uncoveredBeyond))
        : _t('riskAnalysis.projection.pdfCaptionNoFloor', 'Projection si mise en œuvre complète des {n} recommandation(s) clef(s) liée(s) — positions cibles déclarées par l\'analyste, aucun recalcul.')
          .replace('{n}', String(execProjSum.linkedRecCount));
      // FIX-AUDIT-PDF (2026-08-25) : la mini-grille imprime des marqueurs « (n) » (risques
      // non couverts) dont la légende ne vivait qu'à la section Risques, pages plus loin —
      // marqueur inexpliqué sur la page la plus lue. On la suffixe ici quand il y en a.
      var _execHasUncov = false;
      var _execProjPeek = computeProjectedPositions(a);
      (a.risks || []).forEach(function(rk) {
        var pk2 = rk && rk.id && _execProjPeek[rk.id];
        if (pk2 && !pk2.covered) _execHasUncov = true;
      });
      if (_execHasUncov) execCaption += ' ' + _t('riskAnalysis.projection.pdfUncovLegend', '(n) = risque non couvert, à sa position inhérente.');
      var execCapLines = doc.splitTextToSize(_pdfSafe(execCaption), PW);
      var miniCs = 6;
      var pairNeeded = 5 + 3 + 5 * miniCs + 5 + execCapLines.length * 3.4 + 4;
      var execPageLimit = PH - M.bottom - 8;
      y += 4;
      if (y + pairNeeded <= execPageLimit) {
        var execT = getThresholdForAnalysis(a);
        var execProjPos = computeProjectedPositions(a);
        var exInh = {}, exTgt = {}, exUnc = {};
        (a.risks || []).forEach(function(rk) {
          var epr = rk && rk.id && execProjPos[rk.id];
          if (!epr) return;
          var eik = epr.fromP + '_' + epr.fromG;
          exInh[eik] = (exInh[eik] || 0) + 1;
          if (epr.covered) { var etk = epr.p + '_' + epr.g; exTgt[etk] = (exTgt[etk] || 0) + 1; }
          else exUnc[eik] = (exUnc[eik] || 0) + 1;
        });
        var exG1 = M.left + 6, exG2 = M.left + 96;
        doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(0);
        doc.text(_pdfSafeLine(_t('riskAnalysis.projection.pdfMiniInherent', 'Risques actuels (inhérents)')), exG1, y);
        doc.text(_pdfSafeLine(_t('riskAnalysis.projection.pdfMiniProjected', 'Projection (déclarée)')), exG2, y);
        y += 6;
        _pdfDrawGrid(execT, exInh, exG1 + 4, y, miniCs, { headFs: 5, countFs: 6 });
        y = _pdfDrawGrid(execT, exTgt, exG2 + 4, y, miniCs, { headFs: 5, countFs: 6, uncoveredMap: exUnc }) + 4;
        doc.setFontSize(7); doc.setFont('helvetica', 'normal'); doc.setTextColor(90);
        doc.text(execCapLines, M.left, y);
        doc.setTextColor(0);
        y += execCapLines.length * 3.4 + 2;
      } else if (y + execCapLines.length * 3.8 + 2 <= execPageLimit) {
        // Repli : ligne de texte conditionnelle seule — elle aussi gardée en hauteur :
        // si même la ligne ne tient pas, on n'imprime RIEN (A3 : jamais de débordement
        // sur le pied de page de la synthèse).
        doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(60);
        doc.text(execCapLines, M.left, y);
        y += execCapLines.length * 3.8 + 2;
      }
      // FIX-REVUE RA-PROJ : restaurer l'état de police — le paragraphe suivant
      // (improvedWarning) héritait sinon du 7pt gris du bloc projection.
      doc.setFontSize(10); doc.setFont('helvetica', 'normal'); doc.setTextColor(0);
    }
    if (r.totalPlannedControls > 0) {
      y += 2;
      text(_t('riskAnalysis.controls.improvedWarning', 'Le résultat d\'analyse des risques repose sur le fait que les mesures et contrôles planifiés seront correctement implémentés — il sera indispensable de réaliser une analyse de risque préalablement au lancement en production du projet.'));
    }

    // ==========================================================
    // PAGE MÉTHODOLOGIQUE (hors-numérotation — suit la synthèse management depuis RA-UX A3)
    // ==========================================================
    addPage();

    // Title
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(16);
    doc.setTextColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
    doc.text(_t('riskAnalysis.methodology.title', 'Cadre méthodologique'), M.left, y);
    y += 2;
    doc.setDrawColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
    doc.setLineWidth(0.5);
    doc.line(M.left, y, M.left + PW, y);
    y += 10;
    doc.setDrawColor(0);
    doc.setLineWidth(0.2);

    // Objective
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.setTextColor(0);
    doc.text(_t('riskAnalysis.methodology.objective', 'Objectif'), M.left, y);
    y += 6;
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(9);
    var methObjLines = doc.splitTextToSize(_t('riskAnalysis.methodology.objectiveText', 'Cette analyse vise à identifier, évaluer et traiter les risques de sécurité de l\'information conformément à la norme ISO/IEC 27005:2022.'), PW);
    doc.text(methObjLines, M.left, y);
    y += methObjLines.length * 4 + 4;

    // Normative Reference (encadré)
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.text(_t('riskAnalysis.methodology.normativeRef', 'Référence normative'), M.left, y);
    y += 6;
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(9);
    var methRefText = 'ISO/IEC 27005:2022 — ' + _t('riskAnalysis.methodology.normativeRefText', 'Technologies de l\'information — Gestion des risques liés à la sécurité de l\'information.');
    var methRefLines = doc.splitTextToSize(methRefText, PW - 8);
    doc.setFillColor(245, 247, 250);
    doc.roundedRect(M.left, y - 4, PW, methRefLines.length * 4 + 8, 2, 2, 'F');
    doc.text(methRefLines, M.left + 4, y);
    y += methRefLines.length * 4 + 8;

    // 8-step process
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.setTextColor(0);
    doc.text(_t('riskAnalysis.methodology.processTitle', 'Processus d\'analyse en 8 étapes'), M.left, y);
    y += 6;
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(8);
    var methProcSteps = [
      _t('riskAnalysis.methodology.step1', 'Contexte & périmètre'),
      _t('riskAnalysis.methodology.step2', 'Classification CIA & RGPD'),
      _t('riskAnalysis.methodology.step3', 'Continuité d\'activité'),
      _t('riskAnalysis.methodology.step4', 'Documentation'),
      _t('riskAnalysis.methodology.step5', 'Identification des risques'),
      _t('riskAnalysis.methodology.step6', 'Mesures & Contrôles'),
      _t('riskAnalysis.methodology.step7', 'Résultats & évaluation'),
      _t('riskAnalysis.methodology.step8', 'Traitement du risque')
    ];
    methProcSteps.forEach(function(step, idx) {
      var stepLines = doc.splitTextToSize((idx + 1) + '. ' + step, PW - 6);
      doc.text(stepLines, M.left + 4, y);
      y += stepLines.length * 3.5 + 1.5;
    });
    y += 4;

    // Scoring formula (encadré coloré)
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.text(_t('riskAnalysis.methodology.scoringTitle', 'Formule de scoring'), M.left, y);
    y += 6;
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(9);
    var methFormula = _t('riskAnalysis.methodology.scoringFormula', 'Risque résiduel = Vraisemblance × Impact × (1 − (Maturité contrôles ÷ 3) × 0.40)').replace(/\u2212/g, '-');
    var methAdjust = _t('riskAnalysis.methodology.scoringAdjust', 'Ajustements contextuels : +1 si exposition Internet, +1 si CIA élevé, +1 si RTO/RPO strict.');
    var formulaBoxH = 18;
    doc.setFillColor(239, 246, 255);
    doc.setDrawColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
    doc.setLineWidth(0.5);
    doc.roundedRect(M.left, y - 4, PW, formulaBoxH, 2, 2, 'FD');
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(9);
    doc.setTextColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
    doc.text(methFormula, M.left + 4, y + 1);
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(8);
    doc.setTextColor(80);
    doc.text(methAdjust, M.left + 4, y + 8);
    doc.setTextColor(0);
    doc.setDrawColor(0);
    doc.setLineWidth(0.2);
    y += formulaBoxH + 6;

    // Risk Scale (5 colored rectangles)
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.setTextColor(0);
    doc.text(_t('riskAnalysis.methodology.scaleTitle', 'Échelle des risques'), M.left, y);
    y += 6;
    var scaleColors = [[34,197,94], [132,204,22], [249,115,22], [239,68,68], [153,27,27]];
    var scaleLabels = [
      _t('riskAnalysis.thresholds.levels.minimum', 'Minimum'),
      _t('riskAnalysis.thresholds.levels.faible', 'Faible'),
      _t('riskAnalysis.thresholds.levels.modere', 'Modéré'),
      _t('riskAnalysis.thresholds.levels.eleve', 'Élevé'),
      _t('riskAnalysis.thresholds.levels.tresEleve', 'Très élevé')
    ];
    var scaleW = (PW - 8) / 5;
    for (var si = 0; si < 5; si++) {
      doc.setFillColor(scaleColors[si][0], scaleColors[si][1], scaleColors[si][2]);
      doc.roundedRect(M.left + si * (scaleW + 2), y, scaleW, 8, 1, 1, 'F');
      doc.setFont('helvetica', 'bold');
      doc.setFontSize(7);
      doc.setTextColor(255);
      doc.text(scaleLabels[si], M.left + si * (scaleW + 2) + scaleW / 2, y + 5.5, { align: 'center' });
    }
    doc.setTextColor(0);
    y += 14;

    // PDCA Cycle (4 quadrants)
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.text(_t('riskAnalysis.methodology.pdcaTitle', 'Cycle PDCA'), M.left, y);
    y += 6;
    var pdcaW = 70, pdcaH = 28;
    var pdcaX = M.left + (PW - pdcaW * 2 - 4) / 2;
    var pdcaItems = [
      { label: 'Plan', text: _t('riskAnalysis.methodology.pdcaPlan', 'Établir le contexte, identifier les risques'), color: [59,130,246], x: pdcaX, y: y },
      { label: 'Do', text: _t('riskAnalysis.methodology.pdcaDo', 'Implémenter les mesures de traitement'), color: [34,197,94], x: pdcaX + pdcaW + 4, y: y },
      { label: 'Act', text: _t('riskAnalysis.methodology.pdcaAct', 'Améliorer, mettre à jour l\'analyse'), color: [239,68,68], x: pdcaX, y: y + pdcaH + 4 },
      { label: 'Check', text: _t('riskAnalysis.methodology.pdcaCheck', 'Surveiller, contrôler, réévaluer'), color: [249,115,22], x: pdcaX + pdcaW + 4, y: y + pdcaH + 4 }
    ];
    pdcaItems.forEach(function(p) {
      doc.setFillColor(p.color[0], p.color[1], p.color[2]);
      doc.roundedRect(p.x, p.y, pdcaW, pdcaH, 2, 2, 'F');
      doc.setFont('helvetica', 'bold');
      doc.setFontSize(10);
      doc.setTextColor(255);
      doc.text(p.label, p.x + pdcaW / 2, p.y + 8, { align: 'center' });
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(7);
      var pLines = doc.splitTextToSize(p.text, pdcaW - 8);
      doc.text(pLines, p.x + pdcaW / 2, p.y + 14, { align: 'center' });
    });
    doc.setTextColor(0);

    // GDPR section in methodology page
    y += pdcaH * 2 + 14;
    if (a.gdpr) {
      doc.setFont('helvetica', 'bold');
      doc.setFontSize(11);
      doc.setTextColor(0);
      doc.text('RGPD / GDPR', M.left, y);
      y += 6;
      doc.setFont('helvetica', 'normal');
      doc.setFontSize(9);
      labelValue(_t('riskAnalysis.gdpr.personalData', 'Données personnelles'), a.gdpr.personalData ? _t('common.yes', 'Oui') : _t('common.no', 'Non'));
      labelValue(_t('riskAnalysis.gdpr.sensitiveData', 'Données sensibles'), a.gdpr.sensitiveData ? _t('common.yes', 'Oui') : _t('common.no', 'Non'));
      var regLabels = { yes: _t('riskAnalysis.gdpr.registryYes', 'Oui'), no: _t('riskAnalysis.gdpr.registryNo', 'Non'), planned: _t('riskAnalysis.gdpr.registryPlanned', 'Planifié') };
      labelValue(_t('riskAnalysis.gdpr.processingRegistry', 'Registre des traitements'), regLabels[a.gdpr.processingRegistry] || a.gdpr.processingRegistry || '-');
    }

    // Next review date
    if (a.meta && a.meta.nextReviewDate) {
      y += 4;
      labelValue(_t('riskAnalysis.reviewDate.title', 'Prochaine revue'), a.meta.nextReviewDate);
    }

    // ==========================================================
    // 2. CONTEXTE & PÉRIMÈTRE
    // ==========================================================
    if (y > M.top + 20) addPage(); else { y += 6; }
    sTitle(_t('riskAnalysis.pdfContext', 'Contexte & Périmètre'), 14);
    labelValue(_t('riskAnalysis.pdfPerimeter', 'Périmètre'), a.context.perimeter);
    // === RA-ACTIFS === actifs liés (résolus, orphelins exclus) — ligne omise si aucun
    (function() {
      var _pdfAssets = _raLinkedAssets(a);
      if (_pdfAssets.length) {
        labelValue(_t('riskAnalysis.context.linkedAssets', 'Actifs critiques concernés'), _pdfAssets.map(_raAssetLabel).join(' ; '));
      }
    })();
    labelValue(_t('riskAnalysis.pdfDescription', 'Description'), a.context.description);
    labelValue(_t('riskAnalysis.pdfStakeholders', 'Parties prenantes'), a.context.stakeholders);
    labelValue(_t('riskAnalysis.context.analysisMethod', 'Méthode d\'analyse'), _raMethodLabel(a));
    labelValue(_t('riskAnalysis.pdfSourceFramework', 'Catalogue de risques'), _raCatalogLabel(a));
    if (a.context.analysisMethod === 'ebios-iso27005' && (a.context.riskSources || []).length && window._csiEbiosArchetypes) {
      labelValue(_t('riskAnalysis.context.ebiosCouplesShort', 'Sources de risque (EBIOS RM)'), _raEbiosCoupleLabels(a).join(' ; '));
    }
    labelValue(_t('riskAnalysis.pdfPersonalData', 'Données personnelles'), a.context.personalData ? _t('common.yes', 'Oui') : _t('common.no', 'Non'));
    labelValue(_t('riskAnalysis.pdfSensitivity', 'Sensibilité'), _raSensLabel(a.context.dataSensitivity));
    if (a.impactMatrixId && window.CSI_ImpactMatrices) {
      var pdfMatrix = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
      if (pdfMatrix) labelValue(_t('riskAnalysis.pdfImpactMatrix', 'Matrice d\'impact'), pdfMatrix.name);
    }
    if (a.meta && a.meta.nextReviewDate) {
      labelValue(_t('riskAnalysis.reviewDate.title', 'Prochaine revue'), a.meta.nextReviewDate);
    }
    y += 5;

    // ==========================================================
    // 3. CIA & CONTINUITÉ
    // ==========================================================
    sTitle(_t('riskAnalysis.pdfCIA', 'Classification CIA & Continuité'), 14);
    // CIA visual bars
    // === RA-UX (B1) === barres sur l'échelle courante (matrice liée ou 3 crans historiques).
    var _pdfCiaScale = _ciaScaleForValues(a);
    var ciaPdfDims = [
      { label: _t('riskAnalysis.pdfConfidentiality', 'Confidentialité'), val: _clamp(a.cia.confidentiality || 2, 1, _pdfCiaScale.max) },
      { label: _t('riskAnalysis.pdfIntegrity', 'Intégrité'), val: _clamp(a.cia.integrity || 2, 1, _pdfCiaScale.max) },
      { label: _t('riskAnalysis.pdfAvailability', 'Disponibilité'), val: _clamp(a.cia.availability || 2, 1, _pdfCiaScale.max) }
    ];
    if (_pdfCiaScale.fromMatrix) {
      checkPage(8); // FIX-AUDIT-B1 : pas de ligne orpheline sous la marge basse
      doc.setFont(undefined, 'italic'); doc.setFontSize(8); doc.setTextColor(90);
      doc.text(_pdfSafe(_t('riskAnalysis.cia.scaleFromMatrix', 'Échelle : niveaux de gravité de la matrice d\'impacts') + ' « ' + _pdfCiaScale.matrixName + ' »'), M.left, y + 3);
      doc.setFont(undefined, 'normal'); doc.setTextColor(0);
      y += 7;
    }
    var _pdfCiaStep = Math.min(22, Math.floor(66 / _pdfCiaScale.max));
    var _pdfCiaSegW = _pdfCiaStep - 2;
    ciaPdfDims.forEach(function(dim) {
      checkPage(12);
      doc.setFont(undefined, 'bold'); doc.setFontSize(9);
      doc.text(dim.label + ':', M.left, y + 4);
      var barX = M.left + 42;
      var dimHigh = _ciaIsHigh(_pdfCiaScale, dim.val);
      var c = dimHigh ? [239, 68, 68] : dim.val === 1 ? [34, 197, 94] : [249, 115, 22];
      for (var s = 1; s <= _pdfCiaScale.max; s++) {
        if (s <= dim.val) { doc.setFillColor(c[0], c[1], c[2]); }
        else { doc.setFillColor(230, 230, 230); }
        doc.rect(barX + (s - 1) * _pdfCiaStep, y, _pdfCiaSegW, 6, 'F');
      }
      doc.setFont(undefined, 'normal'); doc.setFontSize(8);
      // FIX-AUDIT-B1 : libellé de niveau = texte libre non borné — on l'enveloppe dans la
      // largeur restante (jsPDF ne tronque ni ne replie tout seul).
      var _ciaLblX = barX + _pdfCiaScale.max * _pdfCiaStep + 4;
      var _ciaLblLines = doc.splitTextToSize(_pdfSafe(dim.val + '/' + _pdfCiaScale.max + ' — ' + ((_pdfCiaScale.levels[dim.val - 1] || {}).label || '')), Math.max(20, M.left + PW - _ciaLblX));
      doc.text(_ciaLblLines, _ciaLblX, y + 4);
      y += Math.max(10, _ciaLblLines.length * 3.6 + 4);
    });
    y += 2;
    // RTO/RPO visual gauge
    var rtoPdf = [
      { label: 'RTO', hours: _hOr24(a.continuity.rtoHours) }, // FIX-AUDIT-RTO-AFFICHAGE
      { label: 'RPO', hours: _hOr24(a.continuity.rpoHours) }
    ];
    var rtoZoneColors = [COLORS.danger, COLORS.warning, [249,195,80], COLORS.success];
    var rtoZonePcts = [0.15, 0.25, 0.30, 0.30];
    var rtoZoneLabels = [_t('riskAnalysis.continuity.strict','Très strict'), _t('riskAnalysis.continuity.moderate','Strict'), _t('riskAnalysis.continuity.standard','Standard'), _t('riskAnalysis.continuity.relaxed','Souple')];
    rtoPdf.forEach(function(item) {
      checkPage(14);
      doc.setFont(undefined, 'bold'); doc.setFontSize(9);
      doc.text(item.label + ': ' + item.hours + 'h', M.left, y + 4);
      var gx = M.left + 32, gw = PW - 36;
      var cx = gx;
      for (var zi = 0; zi < 4; zi++) {
        var zw = gw * rtoZonePcts[zi];
        doc.setFillColor(rtoZoneColors[zi][0], rtoZoneColors[zi][1], rtoZoneColors[zi][2]);
        doc.rect(cx, y, zw, 5, 'F');
        cx += zw;
      }
      var mPct = item.hours <= 4 ? 0.10 : item.hours <= 8 ? 0.25 : item.hours <= 24 ? 0.50 : 0.80;
      var mx = gx + gw * mPct;
      doc.setFillColor(30, 30, 30);
      doc.rect(mx - 0.5, y - 1, 1, 7, 'F');
      var zLbl = item.hours <= 4 ? rtoZoneLabels[0] : item.hours <= 8 ? rtoZoneLabels[1] : item.hours <= 24 ? rtoZoneLabels[2] : rtoZoneLabels[3];
      doc.setFont(undefined, 'normal'); doc.setFontSize(7);
      doc.text(zLbl, mx - doc.getTextWidth(zLbl) / 2, y + 9);
      y += 14;
    });
    if (a.continuity && a.continuity.comment) { doc.setFontSize(9); labelValue(_t('riskAnalysis.continuity.commentLabel', 'Commentaire continuité'), a.continuity.comment); }
    if (a.cia.justification) { doc.setFontSize(9); labelValue(_t('riskAnalysis.pdfJustification', 'Justification'), a.cia.justification); }
    y += 5;

    // ==========================================================
    // 4. DOCUMENTATION
    // ==========================================================
    sTitle(_t('riskAnalysis.pdfArchitecture', 'Documentation'), 14);
    var docItems = [
      [_t('riskAnalysis.pdfHosting', 'Hébergement'), a.architecture.hosting],
      [_t('riskAnalysis.pdfInternetExposed', 'Exposé Internet'), a.architecture.internetExposed ? _t('common.yes', 'Oui') : _t('common.no', 'Non')],
      ['SSO', a.architecture.iam.sso ? _t('common.yes', 'Oui') : _t('common.no', 'Non')],
      ['MFA', a.architecture.iam.mfa ? _t('common.yes', 'Oui') : _t('common.no', 'Non')],
      [_t('riskAnalysis.pdfAdminSep', 'Séparation admin'), a.architecture.iam.adminSeparation ? _t('common.yes', 'Oui') : _t('common.no', 'Non')],
      [_t('riskAnalysis.pdfArchitectureDoc', 'Dossier d\'architecture'), a.architecture.architectureDoc ? _t('common.yes', 'Oui') : _t('common.no', 'Non')]
    ];
    var colW = PW / 2, docLineH = 5;
    var half = Math.ceil(docItems.length / 2);
    checkPage(half * docLineH + 8); // FIX-AUDIT-PDF-DOC : bloc solidaire, réservé d'un bloc
    var docStartY = y;
    doc.setFontSize(9);
    for (var di = 0; di < docItems.length; di++) {
      var col = di < half ? 0 : 1;
      var row = di < half ? di : di - half;
      var dx = M.left + col * colW;
      var dy = docStartY + row * docLineH;
      doc.setFont(undefined, 'bold');
      // FIX-AUDIT-PDF-DOC (audit v2, 2026-08-25) : dernier bloc à offsets fixes non traité
      // par le lot 7 — ni assainissement, ni bornage. Les deux colonnes étant solidaires sur
      // une grille de 5 mm, on ne peut pas paginer ligne à ligne : on borne à la
      // demi-colonne (les valeurs légitimes sont des énumérations courtes).
      var _dLabel = _pdfSafeLine(docItems[di][0]) + ': ';
      doc.text(_dLabel, dx, dy);
      var dlw = doc.getTextWidth(_dLabel);
      doc.setFont(undefined, 'normal');
      doc.text(_clipLargeur(String(docItems[di][1] || 'N/A'), colW - dlw - 4), dx + dlw, dy);
    }
    y = docStartY + half * docLineH + 2;
    doc.setFontSize(10);

    // ==========================================================
    // 5. REGISTRE DES RISQUES (=== RA-UX B3 === identification avant les mesures — aligné sur la page méthodologique)
    // ==========================================================
    if (a.risks && a.risks.length > 0) {
      checkPage(30);
      sTitle(_t('riskAnalysis.pdfRiskRegister', 'Registre des risques'), 14);
      var riskRows = [];
      var _naRowIdx = {};
      a.risks.forEach(function(rk, _ri) {
        var cat = _raResolveCat(rk);
        var rs = r.riskScores.find(function(s) { return s.id === rk.id; }) || {};
        var _treatLblPdf = { reduce: _t('riskAnalysis.risks.treatReduce','Réduire'), accept: _t('riskAnalysis.risks.treatAccept','Accepter'), transfer: _t('riskAnalysis.risks.treatTransfer','Transférer'), avoid: _t('riskAnalysis.risks.treatAvoid','Éviter'), na: _t('riskAnalysis.risks.treatNa','Non applicable (exclu du scoring)') };
        var _isNa = rk.treatmentDecision === 'na';
        if (_isNa) _naRowIdx[_ri] = true;
        var _scen = cat ? _bi(cat, 'scenario', 'scenarioEn') : '—';
        if (_isNa) _scen += ' — ' + (rk.notes ? _t('riskAnalysis.risks.naJustification','Justification de non-applicabilité') + ' : ' + rk.notes : _t('riskAnalysis.risks.naMissing','Justification manquante — exigée par ISO 27001 §6.1.3.d'));
        // === RA-UX (B2) === astérisque : résiduel au-delà des critères d'acceptation (note sous le tableau).
        var _resCell = (rs.residual || '-') + (rs.beyondAcceptance ? ' *' : '');
        // === RA-UX (B4) === objectifs menacés sous le titre de la menace.
        var _threatCell = (cat ? _bi(cat, 'threat', 'threatEn') : (rk.customTitle || '?')) + ((Array.isArray(rk.ciaTargets) && rk.ciaTargets.length) ? '\n[' + rk.ciaTargets.join('·') + ']' : '');
        riskRows.push([_pdfSafe(_threatCell), _pdfSafe(_scen), rk.likelihood||3, rk.impact||3, rs.inherent||'-', _resCell, rs.light||'grey', _treatLblPdf[rk.treatmentDecision] || rk.treatmentDecision || '-', _pdfSafeLine(rk.owner || '-')]); // FIX-AUDIT-PDF : saisies libres assainies
      });
      doc.autoTable(Object.assign({}, tableDefaults, {
        startY: y,
        head: [[_t('riskAnalysis.pdfThreat', 'Menace'), _t('riskAnalysis.pdfScenario', 'Scénario'), 'L', 'I', _t('riskAnalysis.pdfInherent', 'Inh.'), _t('riskAnalysis.pdfResidual', 'Rés.'), _t('riskAnalysis.pdfLight', 'Feu'), _t('riskAnalysis.pdfTreatment', 'Traitement'), _t('riskAnalysis.risks.owner', 'Propriétaire')]],
        body: riskRows,
        columnStyles: { 0: { cellWidth: 28 }, 1: { cellWidth: 44 }, 2: { cellWidth: 8, halign: 'center' }, 3: { cellWidth: 8, halign: 'center' }, 4: { cellWidth: 11, halign: 'center' }, 5: { cellWidth: 11, halign: 'center' }, 6: { cellWidth: 12, halign: 'center' }, 7: { cellWidth: 24 }, 8: { cellWidth: 24 } },
        didParseCell: function(data) {
          if (data.section === 'body') {
            // Color inherent/residual scores
            if (data.column.index === 4 || data.column.index === 5) {
              var val = parseFloat(data.cell.raw);
              if (!isNaN(val)) {
                data.cell.styles.textColor = val >= 15 ? COLORS.danger : val >= 8 ? COLORS.warning : COLORS.success;
                data.cell.styles.fontStyle = 'bold';
              }
            }
            // L2 — lignes « non applicable » : grisées, justification imprimée (col Scénario)
            if (_naRowIdx[data.row.index]) {
              data.cell.styles.textColor = [150, 150, 150];
              data.cell.styles.fontStyle = 'normal';
            }
            // Hide text in Feu column (replaced by circle in didDrawCell)
            if (data.column.index === 6) {
              data.cell.text = [''];
            }
          }
        },
        didDrawCell: function(data) {
          if (data.section === 'body' && data.column.index === 6) {
            var rk3 = data.row.index >= 0 ? a.risks[data.row.index] : null;
            var rs3 = (rk3 && r.riskScores.find(function(s) { return s.id === rk3.id; })) || {};
            if (rs3.levelColor) {
              var lc = _hexToRgb(rs3.levelColor);
              doc.setFillColor(lc[0], lc[1], lc[2]);
              doc.circle(data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2, 2.5, 'F');
            } else {
              lightCircle(data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2, data.cell.raw);
            }
          }
        }
      }));
      y = doc.lastAutoTable.finalY + 8;
      // L2 — annexe « méthode de cotation » (auditabilité : ISO 27001 6.1.2.a
      // exige des critères d'acceptation visibles ; la formule d'abattement
      // n'était documentée nulle part dans le livrable).
      if (y > 240) { doc.addPage(); y = M.top; }
      doc.setFontSize(9); doc.setFont(undefined, 'bold'); doc.setTextColor(60, 60, 60);
      doc.text(_t('riskAnalysis.pdfMethodTitle', 'Méthode de cotation'), M.left, y); y += 5;
      doc.setFontSize(7.5); doc.setFont(undefined, 'normal'); doc.setTextColor(90, 90, 90);
      var _pdfMethodLines = [
        _t('riskAnalysis.pdfMethodInherent', 'Risque inhérent = vraisemblance (1-5) × impact (1-5), soit 1 à 25.'),
        _t('riskAnalysis.pdfMethodResidual', 'Risque résiduel = inhérent réduit jusqu\'à 40 % selon la maturité moyenne des domaines de contrôle liés au risque, puis majoré (+1) par : exposition Internet (événements adverses uniquement), classification C/I/D élevée, RTO ≤ 8 h ou RPO ≤ 6 h.'),
        _t('riskAnalysis.pdfMethodNa', 'Les risques marqués « non applicable » restent listés avec leur justification mais sont exclus des scores et du feu global ; un domaine de contrôle entièrement N/A est exclu des moyennes.'),
        _t('riskAnalysis.pdfMethodThresholds', 'Les niveaux (feux) proviennent de l\'échelle de risque active, consultable et modifiable dans « Seuils de risque » du module Analyse des risques.')
      ];
      // FIX-AUDIT-LICENCE : le rapport reproduit les intitulés du pack verbatim (CC BY-SA
      // pour OWASP, mention de permission pour MITRE) — l'attribution est une obligation.
      var _pdfLic = _raPackDisclaimer(a);
      if (_pdfLic) _pdfMethodLines.push(_pdfLic);
      // === RA-UX (B2) === explication de l'astérisque + traçabilité de la décision.
      if (r.acceptance && r.acceptance.floorLevelId) {
        _pdfMethodLines.push('* ' + _t('riskAnalysis.thresholds.acceptanceBeyondBadge', 'Au-delà des critères d\'acceptation') + ' (' + _t('riskAnalysis.thresholds.acceptanceFloorShort', 'plancher') + ' : ' + r.acceptance.floorLabel + _acceptanceRefSuffix(r.acceptance) + '). ' + _t('riskAnalysis.thresholds.acceptancePdfMethod', 'Niveau du résiduel dérivé à impact constant (probabilité réduite par les mesures), arrondi au niveau supérieur.'));
      }
      // FIX-AUDIT-PDF-METHODE (audit v2, 2026-08-25) : ces lignes étaient imprimées d'un bloc
      // après un seul checkPage en amont — or la référence de décision d'acceptation n'a AUCUN
      // maxlength : une référence longue produisait une douzaine de lignes qui filaient sous le
      // pied de page et hors de la feuille, définitivement perdues et sans erreur. Même patron
      // de pagination ligne à ligne que les helpers text()/labelValue() (FIX-AUDIT-PDF).
      _pdfMethodLines.forEach(function(line) {
        var wrapped = doc.splitTextToSize(_pdfSafe(line), PW);
        for (var wi = 0; wi < wrapped.length; wi++) {
          checkPage(6);
          doc.text(wrapped[wi], M.left, y);
          y += 3.4;
        }
        y += 1.2;
      });
      y += 4;
      // Impact breakdown footnotes
      if (a.impactMatrixId && window.CSI_ImpactMatrices) {
        var pdfM = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
        if (pdfM) {
          var hasBreakdown = a.risks.some(function(rk) { return rk.impactBreakdown && Object.keys(rk.impactBreakdown).length > 0; });
          if (hasBreakdown) {
            checkPage(20);
            doc.setFontSize(7);
            doc.setFont(undefined, 'italic');
            doc.setTextColor(COLORS.muted[0], COLORS.muted[1], COLORS.muted[2]);
            doc.text(_t('riskAnalysis.pdfImpactDetail', 'Détail évaluation impact') + ' (' + _t('riskAnalysis.pdfMatrix', 'matrice') + ': ' + pdfM.name + '):', M.left, y);
            y += 4;
            a.risks.forEach(function(rk) {
              if (!rk.impactBreakdown) return;
              var cat2 = _raResolveCat(rk);
              var rTitle = cat2 ? _bi(cat2, 'threat', 'threatEn') : (rk.customTitle || '?');
              var parts = [];
              (pdfM.dimensions || []).forEach(function(dim) {
                if (rk.impactBreakdown[dim.id]) parts.push((dim.label || dim.name) + ':' + rk.impactBreakdown[dim.id]);
              });
              if (parts.length > 0) {
                checkPage(5);
                // FIX-RA-PDF-WINANSI (audit 2026-06-10) : '→' (U+2192) n'existe pas dans
                // l'encodage WinAnsi des polices standard jsPDF → glyphe corrompu. Remplacé
                // par '->' (même correctif que le '−' de la formule de scoring).
                doc.text('  • ' + rTitle + ' -> ' + parts.join(', '), M.left, y);
                y += 3.5;
              }
            });
            doc.setFont(undefined, 'normal');
            doc.setFontSize(10);
            doc.setTextColor(0);
            y += 4;
          }
        }
      }
      // 5×5 Risk Matrix in PDF
      checkPage(70);
      doc.setFontSize(10); doc.setFont(undefined, 'bold');
      doc.setTextColor(0); // FIX-AUDIT-PDF : fuite setTextColor(90) du bloc precedent
      doc.text(_t('riskAnalysis.thresholds.matrixTitle','Matrice de risque'), M.left, y);
      y += 6;
      // FIX-RA-THRESHOLD-PDF (revue adverse Groupe D, 2026-08-24) : la grille du PDF
      // lisait le seuil ACTIF — divergence écran/PDF pour une analyse importée liée à
      // son seuil embarqué (l'écran lit getThresholdForAnalysis depuis FIX-REVUE RA-UX).
      // Changement de rendu assumé pour ces analyses : le PDF montre désormais la MÊME
      // grille que l'écran.
      var pdfT = getThresholdForAnalysis(a);
      var pdfRiskMap = {};
      // FIX-RA-NA-MAPS : cohérent avec la matrice de l'étape Résultats et l'annexe Méthode.
      (a.risks || []).forEach(function(rk) { if (rk.treatmentDecision === 'na') return; var pk = (rk.likelihood || 3) + '_' + (rk.impact || 3); pdfRiskMap[pk] = (pdfRiskMap[pk] || 0) + 1; });
      var mcs = 16;
      var msx = M.left + 20;
      var msy = y;
      _pdfDrawGrid(pdfT, pdfRiskMap, msx, msy, mcs);
      doc.setFontSize(7); doc.setFont(undefined, 'normal');
      // FIX-RA-PDF-WINANSI (audit 2026-06-10) : '\u2192' (flèche droite) et '\u2191' (flèche haut)
      // n'existent pas dans l'encodage WinAnsi des polices standard jsPDF (glyphes corrompus
      // dans le PDF final). Remplacés par '->' et '^' (même correctif que le '\u2212' ligne ~2900).
      doc.text(_t('riskAnalysis.thresholds.gravity','Impact') + ' ->', msx + 25, msy - 6);
      doc.text('^ ' + _t('riskAnalysis.thresholds.probability','Vraisemblance'), msx - 18, msy + 35);
      y = msy + 5 * mcs + 4;
      var lgx = msx;
      doc.setFontSize(6);
      pdfT.levels.forEach(function(lv) {
        var lc = _hexToRgb(lv.color);
        doc.setFillColor(lc[0], lc[1], lc[2]);
        doc.rect(lgx, y, 4, 4, 'F');
        var _lvLbl = _lvlLabel(lv);
        doc.text(_lvLbl, lgx + 5, y + 3);
        lgx += doc.getTextWidth(_lvLbl) + 10;
      });
      y += 10;
      // === RA-PROJ === grille PROJETÉE (déclarative) sous la grille inhérente — même
      // traceur, même seuil ; couverts à leur cible, non couverts « (n) » à l'inhérent.
      var pdfProjSum = computeProjectionSummary(a, r);
      if (pdfProjSum.anyMoved) {
        checkPage(112);
        var pdfProjPos = computeProjectedPositions(a);
        var pdfProjMap = {}, pdfUncovMap = {};
        (a.risks || []).forEach(function(rk) {
          var ppr = rk && rk.id && pdfProjPos[rk.id];
          if (!ppr) return;
          if (ppr.covered) { var ppk = ppr.p + '_' + ppr.g; pdfProjMap[ppk] = (pdfProjMap[ppk] || 0) + 1; }
          else { var ipk = ppr.fromP + '_' + ppr.fromG; pdfUncovMap[ipk] = (pdfUncovMap[ipk] || 0) + 1; }
        });
        doc.setFontSize(10); doc.setFont(undefined, 'bold'); doc.setTextColor(0);
        doc.text(_pdfSafeLine(_t('riskAnalysis.projection.matrixProjectedTitle', 'Projection si recommandations mises en œuvre') + ' ' + _t('riskAnalysis.projection.pdfDeclarativeSuffix', '(projection déclarative)')), M.left, y);
        y += 10;
        // FIX-REVUE RA-PROJ : mêmes repères d'axes que la grille inhérente (WinAnsi).
        doc.setFontSize(7); doc.setFont(undefined, 'normal');
        doc.text(_t('riskAnalysis.thresholds.gravity', 'Impact') + ' ->', msx + 25, y - 6);
        doc.text('^ ' + _t('riskAnalysis.thresholds.probability', 'Vraisemblance'), msx - 18, y + 35);
        y = _pdfDrawGrid(pdfT, pdfProjMap, msx, y, mcs, { uncoveredMap: pdfUncovMap }) + 4;
        doc.setFontSize(6.5); doc.setFont(undefined, 'normal'); doc.setTextColor(90);
        var pdfLegendTxt = _pdfSafe(_t('riskAnalysis.projection.legendDeclarative', 'Projection déclarative : positions cibles saisies par l\'analyste, sous hypothèse de mise en œuvre complète des {n} recommandation(s) clef(s) liée(s) — aucun recalcul : cette vue ne reflète pas la maturité actuelle des contrôles.').replace('{n}', String(pdfProjSum.linkedRecCount)) + ' ' + _t('riskAnalysis.projection.pdfUncovLegend', '(n) = risque non couvert, à sa position inhérente.'));
        var pdfLegendLines = doc.splitTextToSize(pdfLegendTxt, PW);
        doc.text(pdfLegendLines, M.left, y);
        doc.setTextColor(0);
        y += pdfLegendLines.length * 3 + 6;
      }
      // Treatment distribution summary
      checkPage(16);
      doc.setFontSize(9); doc.setFont(undefined, 'bold');
      doc.text(_t('riskAnalysis.preview.treatmentDistribution','Répartition des traitements') + ':', M.left, y);
      y += 5;
      var tCpdf = { reduce: 0, accept: 0, transfer: 0, avoid: 0 };
      a.risks.forEach(function(rk) { var td = rk.treatmentDecision || 'reduce'; if (tCpdf[td] !== undefined) tCpdf[td]++; });
      var tLpdf = { reduce: _t('riskAnalysis.risks.treatReduce','Réduire'), accept: _t('riskAnalysis.risks.treatAccept','Accepter'), transfer: _t('riskAnalysis.risks.treatTransfer','Transférer'), avoid: _t('riskAnalysis.risks.treatAvoid','Éviter') };
      var tColPdf = { reduce: COLORS.primary, accept: COLORS.success, transfer: COLORS.warning, avoid: COLORS.danger };
      doc.setFont(undefined, 'normal'); doc.setFontSize(8);
      var totalRisks = a.risks.filter(function(rk) { return rk.treatmentDecision !== 'na'; }).length; // FIX-RA-NA-MAPS : barres sur le périmètre coté
      if (totalRisks > 0) {
        var barStartX = M.left;
        var barFullW = PW;
        var barH = 10;
        ['reduce','accept','transfer','avoid'].forEach(function(key) {
          var count = tCpdf[key];
          if (count === 0) return;
          var w = barFullW * (count / totalRisks);
          var tc = tColPdf[key];
          doc.setFillColor(tc[0], tc[1], tc[2]);
          doc.rect(barStartX, y, w, barH, 'F');
          if (w > 25) {
            doc.setTextColor(255, 255, 255); doc.setFont(undefined, 'bold');
            doc.text(tLpdf[key] + ' (' + count + ')', barStartX + 2, y + barH - 3);
          }
          barStartX += w;
        });
        doc.setTextColor(0); doc.setFont(undefined, 'normal');
      }
      y += 14;
      doc.setFontSize(10); doc.setFont(undefined, 'normal');
    }

    // ==========================================================
    // 6. MESURES & CONTRÔLES
    // ==========================================================
    addPage();
    sTitle(_t('riskAnalysis.controls.pdfControls', 'Mesures & Contrôles'), 14);

    // Planned controls warning box
    if (r.totalPlannedControls > 0) {
      checkPage(25);
      var warnText = _t('riskAnalysis.controls.improvedWarning', 'Le résultat d\'analyse des risques repose sur le fait que les mesures et contrôles planifiés seront correctement implémentés — il sera indispensable de réaliser une analyse de risque préalablement au lancement en production du projet.');
      var warnLines = doc.splitTextToSize(warnText, PW - 16);
      var warnH = warnLines.length * 4.5 + 18;
      doc.setFillColor(255, 243, 205);
      doc.setDrawColor(249, 115, 22);
      doc.setLineWidth(0.5);
      doc.roundedRect(M.left, y, PW, warnH, 2, 2, 'FD');
      doc.setFont(undefined, 'bold');
      doc.setFontSize(9);
      doc.setTextColor(180, 83, 9);
      doc.text(_t('riskAnalysis.controls.warningTitle', 'Attention — Contrôles planifiés'), M.left + 4, y + 6);
      doc.setFont(undefined, 'normal');
      doc.setFontSize(8);
      doc.setTextColor(120, 53, 0);
      doc.text(warnLines, M.left + 4, y + 14);
      doc.setTextColor(0);
      doc.setDrawColor(0);
      doc.setLineWidth(0.2);
      y += warnH + 6;
    }

    // Status color map for PDF
    var ctrlStatusColors = {
      implemented: COLORS.success,
      partial:     COLORS.warning,
      planned:     COLORS.primary,
      toVerify:    [147, 51, 234],
      no:          COLORS.danger,
      na:          COLORS.muted
    };
    var ctrlStatusLabels = {
      implemented: _t('riskAnalysis.controls.implemented', 'Implémenté'),
      partial:     _t('riskAnalysis.controls.partial', 'Partiel'),
      planned:     _t('riskAnalysis.controls.planned', 'Planifié'),
      toVerify:    _t('riskAnalysis.controls.toVerify', 'À vérifier'),
      no:          _t('riskAnalysis.controls.no', 'Non'),
      na:          _t('riskAnalysis.controls.na', 'N/A')
    };

    if (r.allControlsList) {
      r.allControlsList.forEach(function(dom) {
        checkPage(30);
        doc.setFontSize(10); doc.setFont(undefined, 'bold');
        doc.setTextColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
        doc.text(_t('riskAnalysis.domains.' + dom.domainId, dom.label), M.left, y);
        doc.setTextColor(0); doc.setFont(undefined, 'normal');
        y += 5;
        var ctrlRows = [];
        dom.controls.forEach(function(ctrl) {
          ctrlRows.push([ctrl.text + (ctrl.cia && ctrl.cia.length ? ' [' + ctrl.cia.join('·') + ']' : ''), ctrlStatusLabels[ctrl.status] || ctrl.status, ctrl.comment || '-']); // === RA-UX (B4) ===
        });
        doc.autoTable(Object.assign({}, tableDefaults, {
          startY: y,
          head: [[_t('riskAnalysis.controls.pdfControl', 'Contrôle'), _t('riskAnalysis.controls.statusLabel', 'Statut'), _t('riskAnalysis.controls.comment', 'Commentaire')]],
          body: ctrlRows,
          columnStyles: { 0: { cellWidth: 75 }, 1: { cellWidth: 22, halign: 'center' }, 2: { cellWidth: 73 } },
          didParseCell: function(data) {
            if (data.section === 'body' && data.column.index === 1) {
              var sKey = Object.keys(ctrlStatusLabels).find(function(k) { return ctrlStatusLabels[k] === data.cell.raw; });
              if (sKey && ctrlStatusColors[sKey]) {
                data.cell.styles.textColor = ctrlStatusColors[sKey];
                data.cell.styles.fontStyle = 'bold';
              }
            }
          }
        }));
        y = doc.lastAutoTable.finalY + 4;
      });
    }
    y += 4;

    // ==========================================================
    // 7. SYNTHÈSE DES DOMAINES + RADAR CHART
    // ==========================================================
    addPage();
    sTitle(_t('riskAnalysis.pdfDomainSynthesis', 'Synthèse des domaines'), 14);

    // Radar chart export (B4)
    // FIX-RA-PDF-RADAR (audit 2026-06-10) : #raRadarChart n'existe qu'à l'étape 7 du wizard
    // (le DOM du body est remplacé à chaque changement d'étape) → au moment de l'export
    // (étape 9 ou liste, wizard fermé) getElementById renvoyait null et le radar
    // n'apparaissait JAMAIS dans le PDF. On capture le canvas vivant s'il existe encore,
    // sinon on re-rend le radar hors écran avec Chart.js (sans animation → rendu synchrone).
    var radarImg = null;
    var radarCanvas = document.getElementById('raRadarChart');
    if (radarCanvas && radarCanvas.getContext) {
      try { radarImg = radarCanvas.toDataURL('image/png'); } catch(e) { /* canvas tainted — skip */ }
    }
    if (!radarImg && typeof Chart !== 'undefined' && r.domainScores) {
      try {
        var offCanvas = document.createElement('canvas');
        offCanvas.width = 400;
        offCanvas.height = 400;
        var offLabels = [], offData = [];
        RA_DOMAINS.forEach(function(d) {
          offLabels.push(_t('riskAnalysis.domains.' + d.id, _bi(d, 'label', 'labelEn')));
          offData.push(r.domainScores[d.id] ? r.domainScores[d.id].maturity : 0);
        });
        var offChart = new Chart(offCanvas, {
          type: 'radar',
          data: {
            labels: offLabels,
            datasets: [{ label: _t('riskAnalysis.results.maturity','Maturité'), data: offData, backgroundColor: 'rgba(31,184,205,0.2)', borderColor: 'rgba(31,184,205,1)', pointBackgroundColor: 'rgba(31,184,205,1)', borderWidth: 2 }]
          },
          options: { scales: { r: { min: 0, max: 3, ticks: { stepSize: 1 } } }, plugins: { legend: { display: false } }, responsive: false, animation: false, devicePixelRatio: 2 }
        });
        radarImg = offChart.toBase64Image();
        offChart.destroy();
      } catch(e) { radarImg = null; }
    }
    // FIX-RA-PDF-RADAR-GUARD (revue audit 2026-06-11) : addImage hors d'un try faisait
    // échouer TOUT l'export si l'image était dégénérée (canvas de dimension nulle →
    // toDataURL renvoie 'data:,' truthy, ou format refusé par jsPDF). On rejette les
    // data-URL trop courtes et on encadre addImage : en cas d'échec, on saute le radar
    // (comportement d'origine) au lieu de perdre tout le PDF.
    if (radarImg && radarImg.length > 100) {
      try {
        var radarW = 100;
        var radarH = 100;
        var radarX = M.left + (PW - radarW) / 2;
        checkPage(radarH + 10);
        doc.addImage(radarImg, 'PNG', radarX, y, radarW, radarH);
        y += radarH + 8;
      } catch (e) {
        void 0;
      }
    }

    var domRows = [];
    RA_DOMAINS.forEach(function(d) {
      var ds = r.domainScores[d.id] || {};
      var dsNA = _raDomainNA(ds);
      var pctLabel = dsNA ? '—' : (ds.percent || 0) + '%';
      if (!dsNA && ds.plannedCount > 0) pctLabel += ' (' + ds.plannedCount + ' ' + _t('riskAnalysis.controls.planned', 'Planifié') + ')';
      domRows.push([_bi(d, 'label', 'labelEn'), dsNA ? _t('riskAnalysis.controls.na', 'N/A') : (ds.maturity || 0) + '/3', pctLabel, ds.light || 'grey']);
    });
    doc.autoTable(Object.assign({}, tableDefaults, {
      startY: y,
      head: [[_t('riskAnalysis.pdfDomain', 'Domaine'), _t('riskAnalysis.pdfMaturity', 'Maturité'), '%', _t('riskAnalysis.pdfLight', 'Feu')]],
      body: domRows,
      bodyStyles: { minCellHeight: 10 },
      columnStyles: {
        0: { cellWidth: 70 },
        1: { cellWidth: 25, halign: 'center' },
        2: { cellWidth: 55 },
        3: { cellWidth: 20, halign: 'center' }
      },
      didParseCell: function(data) {
        if (data.section === 'body' && data.column.index === 3) {
          data.cell.text = [];
        }
      },
      didDrawCell: function(data) {
        if (data.section === 'body' && data.column.index === 2) {
          // Domaine hors périmètre (100 % N/A, cellule « — ») : pas de barre —
          // une barre vide se lirait « 0 % » alors que l'écran n'en montre aucune.
          if (isNaN(parseInt(data.cell.raw))) return;
          var pct = parseInt(data.cell.raw) || 0;
          var barX = data.cell.x + 2;
          var barY = data.cell.y + data.cell.height - 2.5;
          var barW = data.cell.width - 4;
          var barH = 1.5;
          doc.setFillColor(230, 230, 230);
          doc.rect(barX, barY, barW, barH, 'F');
          var fillColor = pct >= 70 ? COLORS.success : pct >= 40 ? COLORS.warning : COLORS.danger;
          doc.setFillColor(fillColor[0], fillColor[1], fillColor[2]);
          doc.rect(barX, barY, barW * pct / 100, barH, 'F');
        }
        if (data.section === 'body' && data.column.index === 3) {
          lightCircle(data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2, data.cell.raw);
        }
      }
    }));
    y = doc.lastAutoTable.finalY + 8;

    // ==========================================================
    // 8. RECOMMANDATIONS AUTOMATIQUES
    // ==========================================================
    if (r.recommendations && r.recommendations.length > 0) {
      addPage();
      sTitle(_t('riskAnalysis.pdfRecommendations', 'Recommandations'), 14);
      var recRows = [];
      r.recommendations.forEach(function(rec) {
        var recTitle = rec.title;
        if (rec.isPlanned) recTitle += ' [' + _t('riskAnalysis.controls.planned', 'Planifié') + ']';
        recRows.push([rec.priority, recTitle, _t('riskAnalysis.domains.' + rec.domainId, rec.domainId), rec.effort, rec.rationale || '']);
      });
      doc.autoTable(Object.assign({}, tableDefaults, {
        startY: y,
        head: [[_t('riskAnalysis.pdfPriority', 'Priorité'), _t('riskAnalysis.pdfRecommendation', 'Recommandation'), _t('riskAnalysis.pdfDomain', 'Domaine'), _t('riskAnalysis.pdfEffort', 'Effort'), _t('riskAnalysis.pdfRationale', 'Rationale')]],
        body: recRows,
        columnStyles: { 0: { cellWidth: 18, halign: 'center' }, 1: { cellWidth: 55 }, 4: { cellWidth: 45 } },
        didParseCell: function(data) {
          if (data.section === 'body' && data.column.index === 0) {
            var p = String(data.cell.raw).toLowerCase();
            // Les règles produisent 'P1'/'P2' — les libellés historiques restent
            // acceptés par prudence (audit 2026-08-04 : tout sortait en vert).
            if (p === 'p1' || p === 'haute' || p === 'high') data.cell.styles.textColor = COLORS.danger;
            else if (p === 'p2' || p === 'moyenne' || p === 'medium') data.cell.styles.textColor = COLORS.warning;
            else data.cell.styles.textColor = COLORS.success;
            data.cell.styles.fontStyle = 'bold';
          }
        }
      }));
      y = doc.lastAutoTable.finalY + 8;
    }

    // ==========================================================
    // 9. HYPOTHÈSES & LIMITES
    // ==========================================================
    checkPage(30);
    sTitle(_t('riskAnalysis.pdfAssumptions', 'Hypothèses & limites'), 14);
    text(_t('riskAnalysis.pdfAssumptionsText1', 'Cette analyse reflète la posture à la date de réalisation. Les scores sont indicatifs et ne remplacent pas un audit formel.'));
    text(_t('riskAnalysis.pdfAssumptionsText2', 'Les recommandations sont générées automatiquement sur la base des réponses fournies.'));
    if (r.totalPlannedControls > 0) {
      text(_t('riskAnalysis.controls.improvedWarning', 'Le résultat d\'analyse des risques repose sur le fait que les mesures et contrôles planifiés seront correctement implémentés — il sera indispensable de réaliser une analyse de risque préalablement au lancement en production du projet.'));
    }

    // ==========================================================
    // 10. MESURES DE MITIGATION PRIORITAIRES (dernière section)
    // ==========================================================
    var frItemsPdf = (a.finalRecs && a.finalRecs.items) ? a.finalRecs.items.filter(_frHasContent) : [];
    if (frItemsPdf.length > 0) {
      addPage();
      sTitle(_t('riskAnalysis.finalRecs.mitigationTitle', 'Mesures de mitigation prioritaires'), 14);
      // Highlight box
      checkPage(16);
      doc.setFillColor(239, 246, 255);
      doc.setDrawColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
      doc.setLineWidth(0.8);
      doc.roundedRect(M.left, y, PW, 10, 2, 2, 'FD');
      doc.setFont(undefined, 'bold');
      doc.setFontSize(9);
      doc.setTextColor(COLORS.primary[0], COLORS.primary[1], COLORS.primary[2]);
      doc.text(_t('riskAnalysis.finalRecs.mitigationSubtitle', 'Actions prioritaires identifiées pour réduire le niveau de risque'), M.left + 4, y + 6);
      doc.setTextColor(0);
      doc.setFont(undefined, 'normal');
      doc.setDrawColor(0);
      doc.setLineWidth(0.2);
      y += 14;
      var frRows = [];
      var frProjPos = computeProjectedPositions(a); // === RA-PROJ === positions pour « P×G -> P'×G' »
      frItemsPdf.forEach(function(item, i) {
        // === RA-UX (A4) === risques couverts + atténuation attendue dans la cellule détail.
        // FIX-RA-PDF-BREAKS : titre et détails passent aussi par _pdfSafe* (ils l'esquivaient) —
        // un séparateur/contrôle invisible y débordait de la cellule autoTable (largeur fixe).
        var frDetail = _pdfSafe(item.details || '');
        // === RA-PROJ === risques LIÉS (structurés) : « titre (P4×G5 -> P2×G5) » — flèche
        // ASCII (WinAnsi), libellés résolus catalogue/snapshot, liens pendants ignorés.
        var _linkedIds = Array.isArray(item.coveredRiskIds) ? item.coveredRiskIds : [];
        if (_linkedIds.length) {
          var _lparts = [];
          _linkedIds.forEach(function(rid) {
            var lpr = frProjPos[rid];
            var llbl = _frLinkedRiskLabel(a, rid);
            if (llbl === '—') return;
            if (lpr && lpr.hasTarget) _lparts.push(llbl + ' (P' + lpr.fromP + '×G' + lpr.fromG + ' -> P' + lpr.p + '×G' + lpr.g + ')');
            else if (lpr) _lparts.push(llbl + ' (P' + lpr.fromP + '×G' + lpr.fromG + ')');
            else _lparts.push(llbl);
          });
          if (_lparts.length) frDetail += (frDetail ? '\n' : '') + _t('riskAnalysis.finalRecs.coveredRisks', 'Risques couverts') + ' : ' + _pdfSafe(_lparts.join(', '));
        }
        if (item.coveredRisks) frDetail += (frDetail ? '\n' : '') + _t('riskAnalysis.finalRecs.legacyNote', 'Note complémentaire (texte hérité)') + ' : ' + _pdfSafe(item.coveredRisks);
        if (item.expectedMitigation) frDetail += (frDetail ? '\n' : '') + _t('riskAnalysis.finalRecs.expectedMitigation', 'Atténuation attendue') + ' : ' + _pdfSafe(item.expectedMitigation);
        frRows.push([(i + 1).toString(), _pdfSafeLine(item.title || _frDisplayLabel(a, item)), frDetail, item.status === 'done' ? _t('riskAnalysis.pdfDone', 'Fait') : _t('riskAnalysis.pdfTodo', 'À faire')]); // === RA-PROJ === repli
      });
      doc.autoTable(Object.assign({}, tableDefaults, {
        startY: y,
        head: [['#', _t('riskAnalysis.pdfRecTitle', 'Titre'), _t('riskAnalysis.pdfRecDetails', 'Détails'), _t('riskAnalysis.pdfRecStatus', 'Statut')]],
        body: frRows,
        columnStyles: { 0: { cellWidth: 10, halign: 'center' }, 1: { cellWidth: 42 }, 2: { cellWidth: 88 } },
        headStyles: { fillColor: COLORS.primary, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
        didParseCell: function(data) {
          if (data.section === 'body' && data.column.index === 3) {
            data.cell.styles.textColor = data.cell.raw === _t('riskAnalysis.pdfDone', 'Fait') ? COLORS.success : COLORS.warning;
            data.cell.styles.fontStyle = 'bold';
          }
        }
      }));
      y = doc.lastAutoTable.finalY + 8;
      // === RA-PROJ === tableau de projection : les TROIS notions nommées par risque —
      // Inhérent | Résiduel actuel (calculé) | Cible (déclarée) — pour que la
      // distinction constat/hypothèse soit dans le document remis au lecteur.
      var s10Proj = computeProjectedPositions(a);
      var s10T = getThresholdForAnalysis(a);
      var s10Rows = [];
      (a.risks || []).forEach(function(rk) {
        var spr = rk && rk.id && s10Proj[rk.id];
        if (!spr) return;
        if (!spr.covered && !rk.projected) return;
        var srs = (r.riskScores || []).find(function(s) { return s.id === rk.id; }) || {};
        var sRefs = [];
        frItemsPdf.forEach(function(it, i2) {
          if (it && Array.isArray(it.coveredRiskIds) && it.coveredRiskIds.indexOf(rk.id) >= 0) sRefs.push('#' + (i2 + 1));
        });
        var sInhLvl = getRiskLevelForCell(spr.fromP, spr.fromG, s10T);
        var sTgtLbl = spr.hasTarget
          ? 'P' + spr.p + '×G' + spr.g + ' (' + (spr.p * spr.g) + ') — ' + _pdfSafe(_lvlLabel(getRiskLevelForCell(spr.p, spr.g, s10T)))
          : (spr.covered ? '—' : _t('riskAnalysis.projection.orphanFlag', 'Cible sans recommandation liée'));
        s10Rows.push([
          _pdfSafeLine(_frLinkedRiskLabel(a, rk.id)),
          'P' + spr.fromP + '×G' + spr.fromG + ' (' + (spr.fromP * spr.fromG) + ') — ' + _pdfSafe(_lvlLabel(sInhLvl)),
          (srs.residual != null ? srs.residual + (srs.residualLevelLabel ? ' — ' + _pdfSafe(srs.residualLevelLabel) : '') : '—'),
          sTgtLbl,
          sRefs.join(', ') || '—'
        ]);
      });
      if (s10Rows.length) {
        checkPage(30);
        doc.setFont(undefined, 'bold'); doc.setFontSize(10); doc.setTextColor(0);
        doc.text(_pdfSafeLine(_t('riskAnalysis.projection.title', 'Projection après mise en œuvre') + ' ' + _t('riskAnalysis.projection.pdfDeclarativeSuffix', '(projection déclarative)')), M.left, y);
        y += 5;
        doc.autoTable(Object.assign({}, tableDefaults, {
          startY: y,
          head: [[
            _t('riskAnalysis.projection.colRisk', 'Risque'),
            _t('riskAnalysis.projection.colInherent', 'Inhérent'),
            _t('riskAnalysis.projection.colResidual', 'Résiduel actuel (calculé)'),
            _t('riskAnalysis.projection.colTarget', 'Cible (déclarée)'),
            _t('riskAnalysis.projection.colRecs', 'Recos couvrantes')
          ]],
          body: s10Rows,
          headStyles: { fillColor: COLORS.primary, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
          styles: Object.assign({}, tableDefaults.styles, { fontSize: 7.5 }), // FIX-AUDIT-PDF-STYLES : Object.assign est peu profond — sans fusion, filets et padding disparaissaient
          columnStyles: { 0: { cellWidth: 44 }, 1: { cellWidth: 34 }, 2: { cellWidth: 32 }, 3: { cellWidth: 38 } }
        }));
        y = doc.lastAutoTable.finalY + 6;
        // Liste des déplacements en triple notation (triés par gain actuel→cible).
        var s10Sum = computeProjectionSummary(a, r);
        if (s10Sum.moves.length) {
          // FIX-REVUE RA-PROJ : les refs #n de l'agrégat comptent TOUS les items ; le
          // tableau des recos du PDF numérote la liste FILTRÉE — on remappe pour que
          // le même « #2 » désigne la même reco dans les deux.
          var _refMap = {}; var _fj = 0;
          ((a.finalRecs || {}).items || []).forEach(function(it, i2) {
            if (_frHasContent(it)) { _fj++; _refMap['#' + (i2 + 1)] = '#' + _fj; }
          });
          doc.setFont(undefined, 'normal'); doc.setFontSize(7.5); doc.setTextColor(60);
          s10Sum.moves.forEach(function(mv) {
            checkPage(6);
            var mvLine = '• ' + mv.label + ' — ' + _t('riskAnalysis.projection.movesInherentWord', 'Inhérent') + ' P' + mv.fromP + '×G' + mv.fromG + ' (' + mv.inherent + ')'
              + (mv.residual != null ? ' · ' + _t('riskAnalysis.projection.movesActualWord', 'Actuel') + ' ' + mv.residual : '')
              + ' -> ' + _t('riskAnalysis.projection.movesTargetWord', 'Cible') + ' P' + mv.p + '×G' + mv.g + ' (' + mv.target + ')'
              + ' — ' + mv.fromLevel + ' -> ' + mv.toLevel
              + (mv.recRefs.length ? ' — ' + mv.recRefs.map(function(rr) { return _refMap[rr] || rr; }).join(', ') : '');
            var mvLines = doc.splitTextToSize(_pdfSafe(mvLine), PW - 4);
            doc.text(mvLines, M.left + 2, y);
            y += mvLines.length * 3.4 + 1;
          });
          doc.setTextColor(0);
          y += 3;
        }
      }
    }

    // ==========================================================
    // POST-PROCESSING: HEADERS & FOOTERS
    // ==========================================================
    var totalPages = doc.internal.getNumberOfPages();

    // Add headers & footers on all pages except cover (page 1)
    for (var p = 2; p <= totalPages; p++) {
      doc.setPage(p);
      addHeader();
      addFooter(p);
    }

    // ==========================================================
    // SAVE
    // ==========================================================
    doc.setTextColor(0);
    var titleSlug = (a.meta.title || 'export').replace(/[^a-zA-Z0-9]/g, '_').substring(0, 40);
    var filename = (_roPdf ? 'BROUILLON' : (a.meta.reportNumber || 'RA')) + '_' + titleSlug + '_' + today + '.pdf';
    doc.save(filename);
    if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.pdfGenerated', 'PDF généré') + ': ' + filename, 'success');
  }

  // ============================================================
  // IMPACT MATRIX PICKER MODAL
  // ============================================================
  var _pickerRiskIdx = null;
  var _pickerMatrix = null; // L5 : matrice courante du picker (mise à l'échelle N→5 niveaux)

  window.raOpenImpactPicker = function(riskIdx) {
    var a = findAnalysis(wizardState.analysisId);
    if (!a || !a.risks[riskIdx] || !a.impactMatrixId || !window.CSI_ImpactMatrices) return;
    _ensureMatrixStore(); // FIX-RA-MATRIX-SEED : magasin jamais ouvert → « Matrice introuvable » à tort
    var matrix = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
    if (!matrix) {
      if (typeof window.showToast2 === 'function') window.showToast2(_t('riskAnalysis.impactMatrix.matrixNotFound','Matrice introuvable'), 'error');
      return;
    }
    _pickerRiskIdx = riskIdx;
    // L5 : la matrice doit être posée DÈS l'ouverture — sinon « Appliquer » sans
    // reclic recalcule avec la matrice du picker précédent (ou null) et un
    // « Critique » 4/4 d'une matrice 4 niveaux ne monte jamais à 5.
    _pickerMatrix = matrix;
    var r = a.risks[riskIdx];
    var cat = _raResolveCat(r);
    var riskTitle = cat ? _bi(cat, 'threat', 'threatEn') : (r.customTitle || _t('riskAnalysis.risks.customTitle','Risque personnalisé'));
    var bd = r.impactBreakdown || {};

    var html = '<div class="ra-impact-modal-overlay" id="raImpactModal">';
    html += '<div class="ra-impact-modal">';
    // Header
    html += '<div class="ra-impact-modal__header"><h3><i class="fas fa-table-cells"></i> ' + _esc(_t('riskAnalysis.impactMatrix.modalTitle','Évaluation d\'impact')) + '</h3>';
    html += '<button class="ra-impact-modal__close" onclick="raCloseImpactPicker()" title="' + _esc(_t('riskAnalysis.impactMatrix.cancel','Annuler')) + '">&times;</button></div>';
    // Body
    html += '<div class="ra-impact-modal__body">';
    html += '<div class="ra-impact-modal__hint"><strong>' + _esc(riskTitle) + '</strong><br>';
    html += _esc(_t('riskAnalysis.impactMatrix.modalHint','Pour chaque dimension, sélectionnez le niveau d\'impact qui correspond le mieux à ce risque. Le texte descriptif vous aide à choisir le bon niveau. L\'impact global est calculé automatiquement (maximum des niveaux sélectionnés).'));
    html += '</div>';

    // Dimensions
    (matrix.dimensions || []).forEach(function(dim) {
      html += '<div class="ra-impact-dim-row" data-dim="' + _esc(dim.id) + '">';
      html += '<div class="ra-impact-dim-label">' + _esc(dim.label || dim.name) + '</div>';
      html += '<div class="ra-impact-dim-levels">';
      (matrix.levels || []).forEach(function(lv) {
        var sel = bd[dim.id] === lv.level ? ' ra-impact-level-btn--selected' : '';
        // === RA-UX (A2) === la couleur personnalisée du niveau (préréglage TLP inclus)
        // suit jusque dans le picker — sinon l'éditeur et le wizard se contredisaient.
        var _pickIM = window.CSI_ImpactMatrices;
        var _pickColor = (_pickIM && _pickIM.levelColor && lv.color) ? _pickIM.levelColor(lv) : '';
        var _pickStyle = _pickColor ? ' style="background:' + _pickColor + ';border-color:' + _pickColor + ';color:' + (_pickIM.textColorFor ? _pickIM.textColorFor(_pickColor) : '#1f2937') + '"' : '';
        html += '<button type="button" class="ra-impact-level-btn' + sel + '"' + _pickStyle + ' data-dim="' + _esc(dim.id) + '" data-level="' + lv.level + '" onclick="raPickerSelectLevel(this)" title="' + _esc(lv.label) + '">' + lv.level + '</button>';
      });
      html += '</div>';
      // Description area
      var descText = '';
      if (bd[dim.id] && matrix.cells && matrix.cells[dim.id]) {
        descText = matrix.cells[dim.id][String(bd[dim.id])] || '';
      }
      html += '<div class="ra-impact-dim-desc" id="raPickDesc_' + _esc(dim.id) + '">' + _esc(descText) + '</div>';
      html += '</div>';
    });
    html += '</div>';

    // Footer
    var computedImpact = _pickerComputeImpact(bd, _pickerMatrix);
    // FIX-RA-PICKER-AFFINE : sur une matrice N≠5 niveaux, dire la conversion —
    // « max parmi les dimensions » seul est faux à l'écran (max 2/4 → global 2/5
    // aujourd'hui, mais 3/4 → 4/5) et faisait conclure à un bug de calcul.
    var aggLabel;
    var _aggTotal = (_pickerMatrix && Array.isArray(_pickerMatrix.levels) && _pickerMatrix.levels.length) ? _pickerMatrix.levels.length : 5;
    if (_aggTotal === 5) {
      aggLabel = _t('riskAnalysis.impactMatrix.aggregationMax','le niveau maximum parmi les dimensions évaluées');
    } else {
      aggLabel = _t('riskAnalysis.impactMatrix.aggregationMaxScaled','le niveau maximum parmi les dimensions évaluées ({n} niveaux), converti sur l\'échelle de risque à 5 niveaux').replace('{n}', String(_aggTotal));
    }
    html += '<div class="ra-impact-modal__footer">';
    html += '<div class="ra-impact-computed">' + _esc(_t('riskAnalysis.impactMatrix.computedImpact','Impact global')) + ' : <strong id="raPickerScore">' + (computedImpact || '–') + '</strong><br><span style="font-size:0.75rem;font-weight:400;color:var(--color-text-light,#9ca3af)">' + _esc(_t('riskAnalysis.impactMatrix.computedMethod','Méthode de calcul')) + ' : ' + _esc(aggLabel) + '</span></div>';
    html += '<div><button class="btn" onclick="raCloseImpactPicker()">' + _esc(_t('riskAnalysis.impactMatrix.cancel','Annuler')) + '</button> ';
    html += '<button class="btn btn--primary" onclick="raApplyImpactPicker()">' + _esc(_t('riskAnalysis.impactMatrix.apply','Appliquer')) + '</button></div>';
    html += '</div></div></div>';

    // Insert into DOM
    var existing = document.getElementById('raImpactModal');
    if (existing) existing.remove();
    var wrapper = document.createElement('div');
    wrapper.innerHTML = html;
    document.body.appendChild(wrapper.firstElementChild);
  };

  window.raPickerSelectLevel = function(btn) {
    var dimId = btn.getAttribute('data-dim');
    var level = parseInt(btn.getAttribute('data-level'));
    // Toggle: if already selected, deselect
    var wasSelected = btn.classList.contains('ra-impact-level-btn--selected');
    // Remove selection on sibling buttons
    var parent = btn.parentElement;
    var siblings = parent.querySelectorAll('.ra-impact-level-btn');
    for (var i = 0; i < siblings.length; i++) siblings[i].classList.remove('ra-impact-level-btn--selected');
    if (!wasSelected) {
      btn.classList.add('ra-impact-level-btn--selected');
    }
    // Update description
    _pickerUpdateDesc(dimId);
    // Update computed score
    _pickerUpdateScore();
  };

  function _pickerUpdateDesc(dimId) {
    var a = findAnalysis(wizardState.analysisId);
    if (!a || !a.impactMatrixId || !window.CSI_ImpactMatrices) return;
    var matrix = window.CSI_ImpactMatrices.getMatrixById(a.impactMatrixId);
    if (!matrix) return;
    _pickerMatrix = matrix;
    var descEl = document.getElementById('raPickDesc_' + dimId);
    if (!descEl) return;
    var modal = document.getElementById('raImpactModal');
    if (!modal) return;
    var selBtn = null;
    var allSel = modal.querySelectorAll('.ra-impact-level-btn--selected');
    for (var si = 0; si < allSel.length; si++) { if (allSel[si].getAttribute('data-dim') === dimId) { selBtn = allSel[si]; break; } }
    if (selBtn && matrix.cells && matrix.cells[dimId]) {
      descEl.textContent = matrix.cells[dimId][selBtn.getAttribute('data-level')] || '';
    } else {
      descEl.textContent = '';
    }
  }

  function _pickerUpdateScore() {
    var bd = _pickerReadBreakdown();
    var score = _pickerComputeImpact(bd, _pickerMatrix);
    var scoreEl = document.getElementById('raPickerScore');
    if (scoreEl) scoreEl.textContent = score || '–';
  }

  function _pickerReadBreakdown() {
    var modal = document.getElementById('raImpactModal');
    if (!modal) return {};
    var bd = {};
    var selected = modal.querySelectorAll('.ra-impact-level-btn--selected');
    for (var i = 0; i < selected.length; i++) {
      bd[selected[i].getAttribute('data-dim')] = parseInt(selected[i].getAttribute('data-level'));
    }
    return bd;
  }

  function _pickerComputeImpact(breakdown, matrix) {
    var vals = [];
    for (var k in breakdown) { if (breakdown.hasOwnProperty(k)) vals.push(breakdown[k]); }
    if (vals.length === 0) return 0;
    var maxLevel = Math.max.apply(null, vals);
    // L5 (audit 2026-08-04) : une matrice à N niveaux (< 5) plafonnait l'impact
    // à N sur la grille 5×5 — « Critique » EBIOS (4/4) ne pouvait jamais
    // atteindre la colonne 5 (faux négatif systématique au niveau maximal).
    // FIX-RA-PICKER-AFFINE (retour POC 2026-08-19) : le ratio niveau/N×5 gonflait
    // le milieu d'échelle (2/4 → 3/5, au-dessus du médian — vécu comme un faux
    // calcul par l'utilisateur). Normalisation affine préservant LES DEUX
    // extrémités : (niveau−1)/(N−1)×4+1 → 1/4→1, 2/4→2, 3/4→4, 4/4→5.
    var total = (matrix && Array.isArray(matrix.levels) && matrix.levels.length) ? matrix.levels.length : 5;
    if (total === 5 || total < 2) return Math.max(1, Math.min(5, maxLevel));
    return Math.max(1, Math.min(5, Math.round(((maxLevel - 1) / (total - 1)) * 4) + 1));
  }

  window.raApplyImpactPicker = function() {
    if (_ro()) return; // FIX-AUDIT-RO (audit v2) : _ro() englobe le droit de section 'read'
    if (typeof window.ensureEditMode === 'function' && !window.ensureEditMode('apply an impact')) return; // FIX-RA-READONLY
    var a = findAnalysis(wizardState.analysisId);
    if (!a || _pickerRiskIdx === null || !a.risks[_pickerRiskIdx]) return;
    var bd = _pickerReadBreakdown();
    var impact = _pickerComputeImpact(bd, _pickerMatrix);
    if (impact > 0) {
      a.risks[_pickerRiskIdx].impact = impact;
      a.risks[_pickerRiskIdx].impactBreakdown = bd;
    } else {
      a.risks[_pickerRiskIdx].impactBreakdown = null;
    }
    _save();
    raCloseImpactPicker();
    renderStepRisks(document.getElementById('raWizardBody'), a, _stepDis());
  };

  window.raCloseImpactPicker = function() {
    var modal = document.getElementById('raImpactModal');
    if (modal) modal.remove();
    _pickerRiskIdx = null;
    _pickerMatrix = null;
  };

  // === TEST HARNESS (active uniquement si __RA_TEST_MODE__) ===
  if (window.__RA_TEST_MODE__) {
    window._raInternal = {
      computeDomainMaturity: computeDomainMaturity,
      computeAllDomainScores: computeAllDomainScores,
      computeInherentScore: computeInherentScore,
      computeResidualScore: computeResidualScore,
      riskTrafficLight: riskTrafficLight,
      computeGlobalTrafficLight: computeGlobalTrafficLight,
      computeGlobalScore: computeGlobalScore,
      computeAllResults: computeAllResults,
      resolvePath: resolvePath,
      evaluateCondition: evaluateCondition,
      evaluateRule: evaluateRule,
      generateRecommendations: generateRecommendations,
      createBlankAnalysis: createBlankAnalysis,
      _renderProjectionBlock: _renderProjectionBlock, // exposé : la ligne orpheline n est observable que par le rendu
      mergeProjectRecommendations: mergeProjectRecommendations, // exposée : le rapprochement doit rester indépendant de la langue
      _scaleSignature: _scaleSignature, // exposée : l invariant « le marqueur d import n est pas de la politique » protège deux correctifs l un de l autre
      _signatureResultats: _signatureResultats, // exposée : sa complétude est verrouillée par test
      renderStepResults: renderStepResults, // exposés au banc : seuls points où le rendu i18n
      renderStepPreview: renderStepPreview,  // et la posture « tout N/A » sont observables
      createNewAnalysis: createNewAnalysis, // exposé au banc : seul point d'entrée réel de la création (le bouton DOM n'existe pas hors navigateur)
      getAnalyses: getAnalyses,
      RA_DOMAINS: RA_DOMAINS,
      RA_CONTROLS_SEED: RA_CONTROLS_SEED,
      RA_RISK_CATALOG: RA_RISK_CATALOG,
      RA_RECOMMENDATIONS_RULES: RA_RECOMMENDATIONS_RULES,
      _esc: _esc,
      _pdfSafe: _pdfSafe,
      _pdfSafeLine: _pdfSafeLine,
      _clamp: _clamp,
      computeSpotSummary: computeSpotSummary,
      // === RA-UX === helpers du chantier (B2 critères d'acceptation, A4 recommandations)
      getActiveThreshold: getActiveThreshold,
      getThresholdForAnalysis: getThresholdForAnalysis,
      residualLevelForRisk: residualLevelForRisk,
      resolveRiskLevel: resolveRiskLevel, // exposé pour le test « seuil forgé » (audit 2026-08-25)
      isResidualBeyondAcceptance: isResidualBeyondAcceptance,
      getAcceptanceFloorIndex: getAcceptanceFloorIndex,
      isCellBeyondAcceptance: isCellBeyondAcceptance,
      _frHasContent: _frHasContent,
      _raSafeId: _raSafeId, // exposé pour le banc XSS (audit v2)
      _hOr24: _hOr24, // exposé pour le banc fuzz (audit v2)
      // === RA-UX (B1) ===
      _ciaScale: _ciaScale,
      _ciaScaleForValues: _ciaScaleForValues,
      _ciaHighFloor: _ciaHighFloor,
      _ciaAnyHigh: _ciaAnyHigh,
      _ciaRemap: _ciaRemap,
      _ciaEnsureScale: _ciaEnsureScale,
      STEP_KEYS: STEP_KEYS,
      _generateReportNumber: _generateReportNumber,
      _raResolveCat: _raResolveCat,
      _raSnapshotFields: _raSnapshotFields,
      ensureAnalysisSchema: ensureAnalysisSchema,
      // Exposé pour le test d'invariant de lecture seule (ra-readonly-invariant.test.js) :
      // il doit pouvoir pointer l'assistant sur une analyse avant d'appeler les handlers,
      // sinon ils sortent tous en amont et le test passerait à vide.
      wizardState: wizardState,
      // Exports additifs (batterie refonte 2026-08-17) — fonctions PURES ou d'accès,
      // nécessaires aux tests comportementaux (clamp anti-faux-vert, échelle du picker,
      // seed-on-enter, sauvegarde d'étape, i18n niveaux) :
      getRiskLevelForCell: getRiskLevelForCell,
      getActiveThreshold: getActiveThreshold,
      ensureThresholdsInit: ensureThresholdsInit,
      _pickerComputeImpact: _pickerComputeImpact,
      _raSeedRisksOnce: _raSeedRisksOnce,
      saveCurrentStep: saveCurrentStep,
      _lvlLabel: _lvlLabel,
      _raSensLabel: _raSensLabel,
      // === RA-PROJ (Groupe D) === liens recos↔risques + projection déclarative
      computeProjectedPositions: computeProjectedPositions,
      computeProjectionSummary: computeProjectionSummary,
      _recCountsInProjection: _recCountsInProjection,
      _frLinkedRiskLabel: _frLinkedRiskLabel,
      _frDisplayLabel: _frDisplayLabel,
      _renderMatrixGrid: _renderMatrixGrid,
      renderResultsMatrix: renderResultsMatrix,
      _renderProjectionBlock: _renderProjectionBlock,
      _renderProjectionMatrices: _renderProjectionMatrices,
      _riskNormalizeProjection: _riskNormalizeProjection,
      _frNormalizeLinks: _frNormalizeLinks,
      _raNormalizeProjectionFields: _raNormalizeProjectionFields,
      normalizeRiskAnalysis: normalizeRiskAnalysis,
      collectCurrentStep: collectCurrentStep
    };
  }

})();
