/**
 * Cyber-Assistant — Edit Lock Manager
 *
 * Gère le verrou d'édition global serveur : un seul utilisateur peut éditer
 * à la fois sur tout le serveur. Coordonne avec le backend PHP (sync.php) via :
 *   - POST ?action=edit-lock-acquire   (au passage en mode édition)
 *   - POST ?action=edit-lock-heartbeat (toutes les 20s pour renouveler)
 *   - POST ?action=edit-lock-release   (à la sortie du mode édition)
 *   - POST ?action=edit-lock-takeover  (admin uniquement, pour forcer)
 *
 * État remote propagé via le canal presence-ping (champ `editLock` dans la
 * réponse) — voir lock-manager.js qui appelle setRemoteState() à chaque ping.
 *
 * Auto-quit : si l'utilisateur reste en édition >5 min sans modification,
 * le mode édition est désactivé automatiquement et le verrou libéré.
 *
 * Inerte en mode local (file://).
 *
 * Dépend de : auth-manager.js, storage.js, présence d'un objet i18n optionnel (window.t).
 */
(function () {
  'use strict';

  // ── Configuration ────────────────────────────────────────────────────────
  var HEARTBEAT_INTERVAL_MS = 20000;          // 20s — renouvelle le verrou
  var INACTIVITY_AUTO_QUIT_MS = 5 * 60 * 1000; // 5min — auto-quit si pas d'édition
  var SERVER_TTL_SECONDS = 90;                 // doit rester aligné avec EDIT_LOCK_TTL côté serveur

  // ── État interne ─────────────────────────────────────────────────────────
  var _heartbeatTimer = null;
  var _heldByMe = false;            // True si on détient le verrou (selon le serveur)
  var _lastEditAt = 0;              // Timestamp local (Date.now()) du dernier edit signalé
  var _pendingEditFlag = false;     // True si une édition est survenue depuis le dernier heartbeat
  var _remoteLock = null;           // Dernier état du verrou reçu du serveur (ou null si libre)
  var _listeners = [];              // Callbacks notifiés à chaque changement d'état
  var _unloadListener = null;
  var _isStub = false;              // True en mode local (file://)
  var _staleCheckInFlight = false;  // FIX-STALE-PING : re-vérification autoritaire en cours

  // === CSI-CO === Co-édition par grappes (DOSSIER-ARCHITECTURE-COEDITION.md).
  // Le serveur expose flag + carte via ?action=status ; tant que _coeditEnabled est faux
  // (flag serveur off, status pas encore chargé, vieux serveur), TOUT le comportement
  // historique est inchangé (verrou unique). Détention par grappe = state AUTORITAIRE
  // issu des réponses acquire/heartbeat (jamais déduit du seul canal présence).
  var _coeditEnabled = false;       // Flag serveur COEDIT_CLUSTERS (via status)
  var _sectionClusters = null;      // Carte tabId → grappe (via status)
  var _heldClusters = {};           // grappe → true (détenues par MOI, autoritaire)
  var _remoteLocks = null;          // grappe → lock|null (dernier état serveur, presence/status)
  // === CSI-CO === (libération de courtoisie, dossier §12) grappe → true si une édition
  // RÉELLE y a eu lieu pendant la session (input/change OU push de données). Une grappe
  // touchée n'est JAMAIS relâchée par courtoisie (INV-C9). Conservateur : dans le doute,
  // on marque touché (on ne relâche pas). Vidé à release()/stop().
  var _touchedClusters = {};

  // ── Helpers ──────────────────────────────────────────────────────────────

  function _isServerMode() {
    var S = window.CSI_Storage;
    return S && S.isServerMode && S.isServerMode();
  }

  function _getAuthHeaders() {
    var AM = window.CSI_AuthManager;
    if (!AM || !AM.getToken) return null;
    var token = AM.getToken();
    if (!token) return null;
    return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
  }

  function _getCurrentUserId() {
    var AM = window.CSI_AuthManager;
    if (!AM || !AM.getCurrentUser) return null;
    var u = AM.getCurrentUser();
    return u ? u.id : null;
  }

  function _t(key, fallback) {
    if (typeof window.t === 'function') {
      try {
        var v = window.t(key);
        if (v && v !== key) return v;
      } catch (e) { /* fallthrough */ }
    }
    return fallback || key;
  }

  function _notify(event, payload) {
    for (var i = 0; i < _listeners.length; i++) {
      try { _listeners[i](event, payload); } catch (e) { /* ignore */ }
    }
  }

  // ── Communication serveur ────────────────────────────────────────────────

  // === CSI-ME === écho d'identité + entity= sur les endpoints de verrou. No-op
  // hors mode multi-entités (URL et corps inchangés).
  function _emScope(url) {
    if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
      return window.CSI_EntityManager.appendEntityParam(url);
    }
    return url;
  }
  function _emEcho(body) {
    var b = body || {};
    if (window.CSI_EntityManager && window.CSI_EntityManager.isMultiEntity()) {
      var extra = window.CSI_EntityManager.pushBodyExtra();
      for (var k in extra) { if (Object.prototype.hasOwnProperty.call(extra, k)) b[k] = extra[k]; }
    }
    return b;
  }

  // === CSI-CO === Charge flag + carte des grappes depuis ?action=status (public, best-effort).
  // Échec réseau / vieux serveur sans ces champs → _coeditEnabled reste false (mode legacy sûr).
  function _loadClusterConfig() {
    try {
      fetch('./data/sync.php?action=status', { method: 'GET' }).then(function (r) {
        return r.json();
      }).then(function (data) {
        if (data && data.coeditClusters === true && data.sectionClusters) {
          _sectionClusters = data.sectionClusters;
          _coeditEnabled = true;
        } else {
          _coeditEnabled = false;
        }
      }).catch(function () { /* legacy sûr */ });
    } catch (e) { /* legacy sûr */ }
  }

  // === CSI-CO === Grappe propriétaire d'un onglet. null si co-édition inactive
  // (mode verrou unique). Fail-closed 'coeur' pour une section inconnue (aligné serveur).
  function clusterForTab(tabId) {
    if (!_coeditEnabled || !_sectionClusters) return null;
    return _sectionClusters[tabId] || 'coeur';
  }

  // === CSI-CO === (INV-C11) Onglet NEUTRE pour l'acquisition UI : hors carte des sections
  // (dashboard, settings…) = aucune donnée éditable → n'acquiert AUCUNE grappe, n'affiche
  // AUCUNE lecture-seule de grappe. Le fail-closed 'coeur' serveur en ÉCRITURE est inchangé
  // (une écriture inattendue reste refusée sans verrou coeur) — la neutralité ne concerne
  // que l'interface. Avant ce correctif, naviguer au Dashboard en édition acquérait coeur
  // pour rien (sur-verrouillage R10) et pouvait afficher un bandeau orange absurde.
  function isNeutralTab(tabId) {
    if (!isCoedit()) return false;
    return !(_sectionClusters && Object.prototype.hasOwnProperty.call(_sectionClusters, tabId));
  }

  // === CSI-CO === (courtoisie §12) Marque la grappe d'un onglet comme TOUCHÉE (édition
  // réelle). Appelé par le chemin de push du bundle (signal autoritaire : des données de
  // la grappe partent au serveur) — global-meta exclu (journal, fusion sans verrou).
  function markTabTouched(tabId) {
    if (!isCoedit() || tabId === 'global-meta' || isNeutralTab(tabId)) return;
    var c = clusterForTab(tabId);
    if (c) _touchedClusters[c] = true;
  }

  function isCoedit() { return _coeditEnabled && _isServerMode() && !_isStub; }

  // === CSI-CO === (Lot 4, 2026-07-17) « Demander la main » : signale au détenteur de la
  // grappe qu'on attend le module (demande éphémère côté serveur, TTL 120 s — il la reçoit
  // via le presence-ping). Purement consultatif : ne touche à aucun verrou.
  function requestHand(cluster) {
    if (_isStub || !_isServerMode() || !isCoedit() || !cluster) return Promise.resolve({ ok: false });
    return _post('edit-lock-request', { cluster: cluster }).then(function (resp) {
      return { ok: !!(resp && resp.status === 200 && resp.data && resp.data.ok) };
    }).catch(function () { return { ok: false }; });
  }

  function _currentTabId() {
    return (typeof window._currentActiveTab === 'string' && window._currentActiveTab) ? window._currentActiveTab : 'dashboard';
  }

  // Détenteur (réponse serveur) d'une grappe selon le dernier état connu, ou null.
  function _remoteLockOf(cluster) {
    if (!_remoteLocks || !cluster) return null;
    return _remoteLocks[cluster] || null;
  }

  // === CSI-CO === (P1, 2026-08-05) Identité d'ONGLET jointe à toute requête de verrou.
  // Le verrou serveur ne portait que l'userId : deux onglets du même compte s'y confondaient
  // (l'un libérait les grappes de l'autre). L'identifiant vient de sessionStorage — propre à
  // l'onglet, et il survit à un rechargement, donc un F5 n'est pas pris pour un second onglet.
  function _mySessionId() {
    try {
      var AM = window.CSI_AuthManager;
      if (!AM || !AM.getTabId) return null;
      // Identifiant NON stable (sessionStorage indisponible) : ne rien envoyer. Il serait
      // régénéré à chaque rechargement et le serveur prendrait un simple F5 pour un second
      // onglet — l'utilisateur serait refusé sur son propre écran pendant 45 s. Sans
      // sessionId, le serveur retombe exactement sur le comportement historique.
      if (AM.isTabIdStable && !AM.isTabIdStable()) return null;
      return AM.getTabId();
    } catch (e) { return null; }
  }
  function _withSession(body) {
    var b = body || {};
    var sid = _mySessionId();
    if (sid) b.sessionId = sid;
    return b;
  }

  function _post(action, body) {
    var headers = _getAuthHeaders();
    if (!headers) return Promise.reject(new Error('not_authenticated'));
    return fetch(_emScope('./data/sync.php?action=' + encodeURIComponent(action)), {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(_emEcho(_withSession(body)))
    }).then(function (r) {
      return r.json().catch(function () { return null; }).then(function (data) {
        return { status: r.status, data: data || {} };
      });
    });
  }

  function _get(action) {
    var headers = _getAuthHeaders();
    if (!headers) return Promise.reject(new Error('not_authenticated'));
    return fetch(_emScope('./data/sync.php?action=' + encodeURIComponent(action)), {
      method: 'GET',
      headers: { 'Authorization': headers.Authorization }
    }).then(function (r) {
      return r.json().catch(function () { return null; }).then(function (data) {
        return { status: r.status, data: data || {} };
      });
    });
  }

  // ── Heartbeat & timers ───────────────────────────────────────────────────

  function _startHeartbeat() {
    _stopHeartbeat();
    _heartbeatTimer = setInterval(_tick, HEARTBEAT_INTERVAL_MS);
  }

  function _stopHeartbeat() {
    if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; }
  }

  function _tick() {
    if (!_heldByMe) { _stopHeartbeat(); return; }
    // Auto-quit pour inactivité (5 min sans modification)
    var now = Date.now();
    if (_lastEditAt > 0 && (now - _lastEditAt) > INACTIVITY_AUTO_QUIT_MS) {
      _notify('inactivity-auto-quit', { lastEditAt: _lastEditAt });
      // release() est appelé par le subscriber qui désactive le mode édition
      return;
    }

    var body = {};
    if (_pendingEditFlag) {
      body.edited = true;
      _pendingEditFlag = false;
    }
    _post('edit-lock-heartbeat', body)
      .then(function (resp) {
        if (resp.status === 200 && resp.data.lock) {
          // === CSI-CO === le heartbeat serveur renouvelle CHAQUE grappe détenue et renvoie
          // locks{grappe:lock|null} — null = grappe perdue (takeover ciblé). On resserre
          // l'état autoritaire ; si une grappe a été perdue, notifier (bandeau/lecture seule).
          if (resp.data.locks) {
            var lost = false;
            for (var c in resp.data.locks) {
              if (!Object.prototype.hasOwnProperty.call(resp.data.locks, c)) continue;
              if (resp.data.locks[c]) { _heldClusters[c] = true; }
              else if (_heldClusters[c]) { delete _heldClusters[c]; lost = true; }
            }
            _remoteLocks = _mergeRemoteLocks(resp.data.locks);
            if (lost) _notify('cluster-lost', { heldClusters: _heldClusters });
          }
          _setRemoteLock(resp.data.lock, /*isMe*/ true);
        } else if (resp.status === 410) {
          // Verrou perdu (expiration ou takeover)
          _heldByMe = false;
          _heldClusters = {}; // === CSI-CO ===
          _stopHeartbeat();
          _setRemoteLock(resp.data && resp.data.lock ? resp.data.lock : null, false);
          _notify('lock-lost', { reason: 'gone', remoteLock: _remoteLock });
        } else {
          // Erreur transitoire — on retentera au prochain tick
          _notify('heartbeat-error', { status: resp.status });
        }
      })
      .catch(function () { _notify('heartbeat-error', {}); });
  }

  function _setRemoteLock(lock, isMeHint) {
    var prevHolder = _remoteLock ? _remoteLock.userId : null;
    _remoteLock = lock || null;
    var newHolder = _remoteLock ? _remoteLock.userId : null;

    if (typeof isMeHint === 'boolean') {
      _heldByMe = isMeHint && !!_remoteLock;
    } else {
      var myId = _getCurrentUserId();
      _heldByMe = !!(myId && _remoteLock && _remoteLock.userId === myId);
    }

    if (prevHolder !== newHolder) {
      _notify('holder-changed', { lock: _remoteLock, heldByMe: _heldByMe });
    } else {
      _notify('state-updated', { lock: _remoteLock, heldByMe: _heldByMe });
    }
  }

  // ── API publique ─────────────────────────────────────────────────────────

  // === CSI-CO === $clusters (optionnel) : grappes à acquérir en co-édition. Omis/co-édition
  // inactive → comportement historique EXACT (verrou unique ; le serveur flag-on sans clusters
  // acquiert toutes les grappes — compat E1, plus restrictif jamais moins).
  function acquire(clusters) {
    if (_isStub || !_isServerMode()) {
      // Mode local : toujours autoriser, comportement historique inchangé
      return Promise.resolve({ granted: true, lock: null });
    }
    var body = {};
    if (isCoedit() && clusters && clusters.length) body.clusters = clusters;
    return _post('edit-lock-acquire', body).then(function (resp) {
      if (resp.status === 200 && resp.data.granted) {
        _heldByMe = true;
        _lastEditAt = Date.now();
        _pendingEditFlag = false;
        // === CSI-CO === mémoriser les grappes accordées (state autoritaire).
        if (resp.data.locks) {
          for (var c in resp.data.locks) {
            if (Object.prototype.hasOwnProperty.call(resp.data.locks, c) && resp.data.locks[c]) _heldClusters[c] = true;
          }
          _remoteLocks = _mergeRemoteLocks(resp.data.locks);
        }
        _setRemoteLock(resp.data.lock, true);
        _startHeartbeat();
        return { granted: true, lock: resp.data.lock, locks: resp.data.locks || null };
      }
      // 423 Locked ou autre refus — NE PAS toucher aux grappes déjà détenues (tout-ou-rien
      // serveur : ce refus n'a rien acquis, mais nos acquisitions antérieures restent valides).
      if (!isCoedit()) {
        _heldByMe = false;
        _setRemoteLock(resp.data ? resp.data.lock : null, false);
      } else if (resp.data && resp.data.blockedBy) {
        _remoteLocks = _mergeRemoteLocks(resp.data.blockedBy);
        _notify('state-updated', { lock: _remoteLock, heldByMe: _heldByMe });
      }
      // === CSI-CO === (P1) « c'est MON autre onglet » se lit désormais dans le verrou renvoyé
      // par le serveur (source unique de vérité) : même userId, sessionId différent. Marche
      // entre navigateurs différents, ce qu'un marqueur de stockage local ne pouvait pas faire.
      var _refLock = (resp.data && resp.data.lock) || null;
      var _blocked = (resp.data && resp.data.blockedBy) || null;
      if (!_refLock && _blocked) {
        for (var _bc in _blocked) {
          if (Object.prototype.hasOwnProperty.call(_blocked, _bc) && _blocked[_bc]) { _refLock = _blocked[_bc]; break; }
        }
      }
      var _mySid = _mySessionId();
      var _otherTab = !!(_refLock && _mySid && _refLock.userId === _getCurrentUserId()
                         && _refLock.sessionId && _refLock.sessionId !== _mySid);
      return { granted: false, lock: resp.data ? resp.data.lock : null, status: resp.status,
               blockedBy: _blocked, otherTab: _otherTab };
    });
  }

  // === CSI-CO === Fusionne un fragment {grappe:lock|null} dans l'état _remoteLocks.
  function _mergeRemoteLocks(fragment) {
    var out = _remoteLocks || {};
    for (var c in fragment) {
      if (Object.prototype.hasOwnProperty.call(fragment, c)) out[c] = fragment[c];
    }
    return out;
  }

  // === CSI-CO === Garantit la grappe de l'onglet visé pendant le mode édition : no-op si
  // co-édition inactive ou grappe déjà détenue ; sinon tente l'acquisition (silencieuse si
  // libre). Résout {editable:bool, holder:lock|null}. Ne libère JAMAIS les autres grappes
  // (accumulation pendant la session d'édition ; release() libère tout à la sortie).
  function ensureClusterFor(tabId) {
    if (!isCoedit()) return Promise.resolve({ editable: true, holder: null });
    // === CSI-CO === (INV-C11) onglet neutre : aucune acquisition, jamais de lecture seule.
    if (isNeutralTab(tabId)) return Promise.resolve({ editable: true, holder: null });
    var cluster = clusterForTab(tabId);
    if (!cluster || _heldClusters[cluster]) return Promise.resolve({ editable: true, holder: null });
    return acquire([cluster]).then(function (res) {
      if (res && res.granted) return { editable: true, holder: null };
      var holder = res && res.blockedBy && res.blockedBy[cluster] ? res.blockedBy[cluster] : (res ? res.lock : null);
      return { editable: false, holder: holder || null };
    }).catch(function () {
      // Réseau indisponible : refus prudent (pas d'édition sans verrou confirmé — FIX-LOCK-ACQUIRE-FAIL)
      return { editable: false, holder: null };
    });
  }

  function release() {
    _stopHeartbeat();
    if (!_heldByMe) {
      _heldByMe = false;
      _heldClusters = {}; // === CSI-CO ===
      _touchedClusters = {};
      return Promise.resolve({ released: false });
    }
    // === CSI-CO === (audit co-édition 2026-08-05, MAJEUR) Ne libérer QUE les grappes que
    // CETTE session détient. Le corps vide fait libérer les 4 grappes côté serveur
    // (entNormalizeClusters(null) → CSI_CLUSTERS) : un second onglet du même compte qui sortait
    // d'édition emportait donc les verrous du premier, sans que celui-ci le sache — il se
    // croyait détenteur et ses écritures partaient en 423. Repli sur le comportement historique
    // (tout libérer) si la liste locale est vide : c'est alors le seul moyen de nettoyer.
    var _toRelease = Object.keys(_heldClusters || {});
    _heldByMe = false;
    _heldClusters = {};
    _touchedClusters = {}; // === CSI-CO === (courtoisie) nouvelle session d'édition = ardoise vierge
    if (_isStub || !_isServerMode()) return Promise.resolve({ released: true });
    var _relBody = (isCoedit() && _toRelease.length) ? { clusters: _toRelease } : {};
    return _post('edit-lock-release', _relBody).then(function (resp) {
      if (resp.status === 200) {
        _setRemoteLock(null, false);
        if (_remoteLocks) { for (var c in _remoteLocks) { if (Object.prototype.hasOwnProperty.call(_remoteLocks, c)) _remoteLocks[c] = null; } }
        return { released: !!resp.data.released };
      }
      return { released: false, status: resp.status };
    }).catch(function () { return { released: false }; });
  }

  // === CSI-CO === $clusters (optionnel) : grappes à reprendre (défaut serveur : toutes).
  function takeover(clusters) {
    if (_isStub || !_isServerMode()) {
      return Promise.resolve({ granted: false, error: 'not_in_server_mode' });
    }
    var body = {};
    if (isCoedit() && clusters && clusters.length) body.clusters = clusters;
    return _post('edit-lock-takeover', body).then(function (resp) {
      if (resp.status === 200 && resp.data.granted) {
        _heldByMe = true;
        _lastEditAt = Date.now();
        _pendingEditFlag = false;
        if (resp.data.locks) { // === CSI-CO ===
          for (var c in resp.data.locks) {
            if (Object.prototype.hasOwnProperty.call(resp.data.locks, c) && resp.data.locks[c]) _heldClusters[c] = true;
          }
          _remoteLocks = _mergeRemoteLocks(resp.data.locks);
        }
        _setRemoteLock(resp.data.lock, true);
        _startHeartbeat();
        return { granted: true, lock: resp.data.lock, previousHolder: resp.data.previousHolder || resp.data.previousHolders };
      }
      return { granted: false, status: resp.status, error: resp.data ? resp.data.error : null };
    });
  }

  function notifyEdit() {
    if (!_heldByMe) return;
    _lastEditAt = Date.now();
    _pendingEditFlag = true;
    // FIX-COURTESY-TOUCH (audit co-édition 2026-07-17, N3) : notifyEdit ne marque PLUS la
    // grappe « travaillée ». Il est branché sur les écouteurs document-wide input/change du
    // bundle : un simple FILTRE de tableau (select de toolbar) suffisait à rendre la grappe
    // collante — courtoisie inerte en usage réel, prouvé au banc. Il ne sert désormais que
    // la VIVACITÉ (anti auto-quit). Le marquage « travaillée » (INV-C9) vient exclusivement
    // du chemin de push de données : markTabTouched depuis saveData d'une édition réelle
    // (delta de l'onglet vérifié côté bundle), les cascades, et la bascule d'onglet avec delta.
  }

  // === CSI-CO === (courtoisie §12) Libère UNIQUEMENT les grappes listées (silencieux).
  // Garde anti-course R4 (INV-C10) : re-filtre AU MOMENT DE L'ENVOI — jamais la grappe de
  // l'onglet actuellement affiché (l'utilisateur a pu revenir pendant la décision), jamais
  // une grappe redevenue touchée. Best-effort : un échec réseau laisse la grappe détenue
  // (le TTL/auto-quit la couvrent) — aucun message utilisateur.
  function releaseClusters(clusters) {
    if (!isCoedit() || !clusters || !clusters.length) return Promise.resolve({ released: [] });
    var currentCluster = isNeutralTab(_currentTabId()) ? null : clusterForTab(_currentTabId());
    var toRelease = [];
    for (var i = 0; i < clusters.length; i++) {
      var c = clusters[i];
      if (c && c !== currentCluster && _heldClusters[c] && !_touchedClusters[c]) toRelease.push(c);
    }
    if (!toRelease.length) return Promise.resolve({ released: [] });
    // === CSI-CO === (audit co-édition 2026-08-05, MAJEUR) Libération PESSIMISTE : retirer les
    // grappes de _heldClusters AVANT l'envoi. Elles n'étaient effacées qu'à la RÉCEPTION, donc
    // pendant tout l'aller-retour `ensureClusterFor` les croyait détenues (`if (_heldClusters[c])
    // return editable`) et n'acquérait rien : une navigation concurrente laissait un écran
    // pleinement éditable SANS verrou, avec des 423 permanents ensuite et aucun rattrapage
    // possible (le heartbeat ne voit plus la grappe). En cas d'échec on les restaure.
    var _optimisticallyDropped = [];
    for (var d = 0; d < toRelease.length; d++) {
      if (_heldClusters[toRelease[d]]) { delete _heldClusters[toRelease[d]]; _optimisticallyDropped.push(toRelease[d]); }
    }
    return _post('edit-lock-release', { clusters: toRelease }).then(function (resp) {
      if (resp.status !== 200) {
        // Le serveur n'a rien relâché : rétablir l'état local, sinon on se croirait sans
        // verrou alors qu'on le détient encore (l'inverse du bug, tout aussi faux).
        for (var r = 0; r < _optimisticallyDropped.length; r++) _heldClusters[_optimisticallyDropped[r]] = true;
        return { released: [] };
      }
      var done = (resp.data && resp.data.releasedClusters) || toRelease;
      for (var k = 0; k < done.length; k++) {
        delete _heldClusters[done[k]];
        if (_remoteLocks) _remoteLocks[done[k]] = null;
      }
      // Plus aucune grappe détenue → stopper le heartbeat (aligné release()).
      if (!isHeldByMe()) { _heldByMe = false; _stopHeartbeat(); }
      _notify('state-updated', { lock: _remoteLock, heldByMe: _heldByMe });
      return { released: done };
    }).catch(function () { return { released: [] }; });
  }

  function refreshStatus() {
    if (_isStub || !_isServerMode()) return Promise.resolve(null);
    return _get('edit-lock-status').then(function (resp) {
      if (resp.status === 200) {
        var lock = resp.data.lock || null;
        // === CSI-CO === (FIX-REFRESHSTATUS-COEDIT, audit 2026-07-17) MÊME garde que
        // setRemoteState ci-dessous : le champ `lock` est le 1er détenteur actif TOUTES
        // grappes confondues (sync.php, ordre canonique coeur→pilotage→analyse→config).
        // Sous co-édition il désigne le plus souvent un AUTRE éditeur alors que nous
        // détenons nos propres grappes. En dériver _heldByMe (via _setRemoteLock) était
        // un EMPOISONNEMENT : _tick() s'auto-stoppait au battement suivant → nos grappes
        // expiraient au TTL sans que personne ne les tienne (verrous fantômes 90 s),
        // release() sortait sans prévenir le serveur, beforeunload ne libérait rien —
        // le tout pendant que isHeldByMe() (qui lit _heldClusters) affirmait le contraire
        // à l'UI. Déclencheur : le SEUL appelant est le refus de push 423 (bundle.js), donc
        // la contention fabriquait le bug qui fabriquait plus de contention.
        // La détention reste pilotée par acquire/heartbeat (canal autoritaire, qui détecte
        // déjà les pertes via locks{} et 410) ; ici on ne rafraîchit que l'AFFICHAGE.
        if (isCoedit()) {
          if (resp.data.locks) _remoteLocks = _mergeRemoteLocks(resp.data.locks);
          _notify('state-updated', { lock: _remoteLock, heldByMe: _heldByMe });
          return lock;
        }
        var myId = _getCurrentUserId();
        var isMe = !!(myId && lock && lock.userId === myId);
        _setRemoteLock(lock, isMe);
        return lock;
      }
      return null;
    }).catch(function () { return null; });
  }

  /**
   * Appelé par lock-manager.js (PresenceManager) à chaque presence-ping
   * pour propager l'état du verrou sans appel supplémentaire.
   */
  function setRemoteState(lockFromPresencePing, editLocksFromPresencePing) {
    var myId = _getCurrentUserId();
    var lock = lockFromPresencePing || null;
    var isMe = !!(myId && lock && lock.userId === myId);

    // === CSI-CO === état par grappe (informatif — la détention reste pilotée par
    // acquire/heartbeat, canal autoritaire ; la présence sert l'affichage des détenteurs).
    if (editLocksFromPresencePing && typeof editLocksFromPresencePing === 'object') {
      _remoteLocks = _mergeRemoteLocks(editLocksFromPresencePing);
      // Le champ compat `editLock` = 1er détenteur actif TOUTES grappes confondues — sous
      // co-édition il peut désigner un AUTRE éditeur alors que nous détenons nos grappes.
      // Ne pas passer par le chemin legacy d'éjection : notifier et sortir.
      if (isCoedit()) {
        _notify('state-updated', { lock: _remoteLock, heldByMe: _heldByMe });
        return;
      }
    }

    // Si on pensait détenir le verrou mais le serveur dit non → vérifier puis "lock-lost"
    if (_heldByMe && !isMe) {
      // FIX-STALE-PING (audit 2026-06-10, edit-lock-manager.js:257) : une réponse
      // presence-ping PÉRIMÉE (émise avant notre acquire/takeover, arrivée après) éjectait
      // immédiatement l'éditeur avec un message trompeur. On ne perd plus le verrou sur la
      // foi du canal présence : on re-vérifie d'abord auprès de l'endpoint autoritaire
      // edit-lock-status, et on n'éjecte que si la perte est confirmée.
      if (_staleCheckInFlight) return;
      _staleCheckInFlight = true;
      _get('edit-lock-status').then(function (resp) {
        _staleCheckInFlight = false;
        if (!resp || resp.status !== 200) return; // erreur transitoire : conserver l'état local
        var freshLock = (resp.data && resp.data.lock) || null;
        var freshIsMe = !!(myId && freshLock && freshLock.userId === myId);
        if (_heldByMe && !freshIsMe) {
          _heldByMe = false;
          _stopHeartbeat();
          _setRemoteLock(freshLock, false);
          _notify('lock-lost', { reason: 'remote-state', remoteLock: freshLock });
        } else {
          _setRemoteLock(freshLock, freshIsMe);
        }
      }).catch(function () { _staleCheckInFlight = false; });
      return;
    }
    _setRemoteLock(lock, isMe);
  }

  function getCurrentHolder() {
    // === CSI-CO === sous co-édition, « le » détenteur pertinent = celui de la grappe de
    // l'onglet AFFICHÉ (le bandeau parle toujours de l'écran courant, jamais du système).
    if (isCoedit()) return getHolderFor(_currentTabId());
    return _remoteLock;
  }
  function isHeldByMe() {
    if (isCoedit()) {
      for (var c in _heldClusters) { if (Object.prototype.hasOwnProperty.call(_heldClusters, c)) return true; }
      return false;
    }
    return _heldByMe;
  }
  function isHeldByOther() {
    // === CSI-CO === contextuel à l'onglet affiché sous co-édition.
    if (isCoedit()) return isHeldByOtherFor(_currentTabId());
    if (!_remoteLock) return false;
    var myId = _getCurrentUserId();
    return !!myId && _remoteLock.userId !== myId;
  }

  // === CSI-CO === API par onglet (co-édition inactive → retombe sur l'état legacy).
  function isHeldByMeFor(tabId) {
    if (!isCoedit()) return _heldByMe;
    if (isNeutralTab(tabId)) return true; // (INV-C11) neutre = toujours « éditable » côté UI
    var cluster = clusterForTab(tabId);
    return !!(cluster && _heldClusters[cluster]);
  }
  function isHeldByOtherFor(tabId) {
    if (!isCoedit()) {
      if (!_remoteLock) return false;
      var myId0 = _getCurrentUserId();
      return !!myId0 && _remoteLock.userId !== myId0;
    }
    if (isNeutralTab(tabId)) return false; // (INV-C11) jamais de bandeau orange sur un onglet neutre
    var cluster = clusterForTab(tabId);
    if (!cluster || _heldClusters[cluster]) return false;
    var lk = _remoteLockOf(cluster);
    if (!lk) return false;
    var myId = _getCurrentUserId();
    return !!myId && lk.userId !== myId;
  }
  function getHolderFor(tabId) {
    if (!isCoedit()) return _remoteLock;
    var cluster = clusterForTab(tabId);
    return cluster ? _remoteLockOf(cluster) : null;
  }
  function heldClusters() {
    var out = [];
    for (var c in _heldClusters) { if (Object.prototype.hasOwnProperty.call(_heldClusters, c)) out.push(c); }
    return out;
  }

  function subscribe(cb) {
    if (typeof cb !== 'function') return function () {};
    _listeners.push(cb);
    return function unsubscribe() {
      var idx = _listeners.indexOf(cb);
      if (idx >= 0) _listeners.splice(idx, 1);
    };
  }

  function start() {
    if (_isStub) return;
    _loadClusterConfig(); // === CSI-CO === flag + carte des grappes (best-effort, legacy sûr)
    if (_unloadListener) window.removeEventListener('beforeunload', _unloadListener);
    _unloadListener = function () {
      if (_heldByMe) {
        // Best effort — le serveur libérera de toute façon après TTL si keepalive échoue
        try {
          var headers = _getAuthHeaders();
          if (headers) {
            // === CSI-CO === (audit 2026-08-05) même ciblage que release() : la fermeture de
            // CET onglet ne doit pas emporter les grappes d'un autre onglet du même compte.
            var _uHeld = Object.keys(_heldClusters || {});
            var _uBody = _withSession((isCoedit() && _uHeld.length) ? { clusters: _uHeld } : {});
            fetch(_emScope('./data/sync.php?action=edit-lock-release'), { // === CSI-ME ===
              method: 'POST',
              headers: headers,
              body: JSON.stringify(_emEcho(_uBody)),
              keepalive: true
            });
          }
        } catch (e) { /* ignore */ }
      }
    };
    window.addEventListener('beforeunload', _unloadListener);
  }

  function stop() {
    _stopHeartbeat();
    if (_unloadListener) {
      window.removeEventListener('beforeunload', _unloadListener);
      _unloadListener = null;
    }
    _heldByMe = false;
    _remoteLock = null;
    _lastEditAt = 0;
    _pendingEditFlag = false;
    _heldClusters = {};     // === CSI-CO ===
    _remoteLocks = null;    // === CSI-CO ===
    _touchedClusters = {};  // === CSI-CO === (courtoisie)
  }

  // ── Export ────────────────────────────────────────────────────────────────

  var EditLockManager = {
    start: start,
    stop: stop,
    acquire: acquire,
    release: release,
    takeover: takeover,
    notifyEdit: notifyEdit,
    refreshStatus: refreshStatus,
    setRemoteState: setRemoteState,
    getCurrentHolder: getCurrentHolder,
    isHeldByMe: isHeldByMe,
    isHeldByOther: isHeldByOther,
    subscribe: subscribe,
    // === CSI-CO === API co-édition par grappes
    isCoedit: isCoedit,
    clusterForTab: clusterForTab,
    isNeutralTab: isNeutralTab,
    ensureClusterFor: ensureClusterFor,
    isHeldByMeFor: isHeldByMeFor,
    isHeldByOtherFor: isHeldByOtherFor,
    getHolderFor: getHolderFor,
    heldClusters: heldClusters,
    markTabTouched: markTabTouched,
    releaseClusters: releaseClusters,
    requestHand: requestHand,
    // Constantes exposées (lecture seule)
    HEARTBEAT_INTERVAL_MS: HEARTBEAT_INTERVAL_MS,
    INACTIVITY_AUTO_QUIT_MS: INACTIVITY_AUTO_QUIT_MS,
    SERVER_TTL_SECONDS: SERVER_TTL_SECONDS
  };

  // Stub no-op pour le mode local (file://)
  if (window.location.protocol === 'file:') {
    _isStub = true;
    EditLockManager = {
      start: function () {},
      stop: function () {},
      acquire: function () { return Promise.resolve({ granted: true, lock: null }); },
      release: function () { return Promise.resolve({ released: false }); },
      takeover: function () { return Promise.resolve({ granted: false }); },
      notifyEdit: function () {},
      refreshStatus: function () { return Promise.resolve(null); },
      setRemoteState: function () {},
      getCurrentHolder: function () { return null; },
      isHeldByMe: function () { return true; },          // En mode local, on "détient" toujours implicitement
      isHeldByOther: function () { return false; },
      subscribe: function () { return function () {}; },
      // === CSI-CO === stubs co-édition (file:// : jamais de grappes, tout est éditable)
      isCoedit: function () { return false; },
      clusterForTab: function () { return null; },
      isNeutralTab: function () { return false; },
      ensureClusterFor: function () { return Promise.resolve({ editable: true, holder: null }); },
      isHeldByMeFor: function () { return true; },
      isHeldByOtherFor: function () { return false; },
      getHolderFor: function () { return null; },
      heldClusters: function () { return []; },
      markTabTouched: function () {},
      releaseClusters: function () { return Promise.resolve({ released: [] }); },
      requestHand: function () { return Promise.resolve({ ok: false }); },
      HEARTBEAT_INTERVAL_MS: HEARTBEAT_INTERVAL_MS,
      INACTIVITY_AUTO_QUIT_MS: INACTIVITY_AUTO_QUIT_MS,
      SERVER_TTL_SECONDS: SERVER_TTL_SECONDS
    };
  }

  window.CSI_EditLockManager = EditLockManager;

  void 0;
})();
