// Micara bridge — moteur renderer (P1-E, app silencieuse)
//
// Port de l'ancien renderer.js (refonte React + Astryx, t_fa529748) :
// TOUTE la logique WebRTC / mixage / sortie audio est INCHANGÉE. Seule la
// couche UI (DOM) a été remplacée par un adaptateur `ui` consommé par React
// (App.jsx) : log() → ui.onLog, statut → ui.onStatus, update → ui.onUpdate,
// auth silencieuse → ui.onAuthed.
//
// Le signal WS est porté par le processus principal (preload : onSignal /
// signalSend) ; ici on répond aux offres WebRTC des téléphones (une
// RTCPeerConnection par phoneId). Chaque flux entre dans un graphe Web Audio
// (mixer.js) : noise gate + dominance (seul le plus fort parle, les autres
// s'atténuent) → UN seul flux mixé → BlackHole (setSinkId).
// Graphe Web Audio : chaque flux téléphone → source → analyseur (RMS) → gain
// (gate + dominance) → destination commune → UN seul <audio> → BlackHole.
// mixer.cjs (UMD, inchangé depuis P1-E) : calculs purs, testés en Node.
import mixerModule from '../mixer.cjs';
import { t, detectLang } from './i18n.js';
import { speakingTransitions } from './speaking.js';

const { rmsDb, DominanceMixer } = mixerModule;

const LANG = detectLang(); // langue fixée au lancement (langue du Mac)

// --- mixage audio (P1-E) : dominance + noise gate → BlackHole -------------
// Un seul graphe Web Audio : chaque flux téléphone → source → analyseur
// (niveau RMS) → gain (gate + dominance) → destination commune → UN seul
// <audio> → BlackHole (ou haut-parleurs). Le module mixer.js (pur, testé en
// Node) calcule les gains cibles ; ici on les lisse (attack/release) via
// setTargetAtTime et on pilote les GainNodes.

export const MIX = {
  gateOpenDb: -45,  // dBFS : ouverture du gate (parole)
  gateCloseDb: -50, // dBFS : fermeture (hystérésis anti-clapot)
  gateHoldMs: 400,  // maintien avant fermeture (fins de mots)
  duckDb: -18,      // atténuation des flux non-dominants
  dominanceMarginDb: 3,   // P1-E2 : le challenger doit dépasser le dominant de ≥3 dB (anti-clapot 2 locuteurs)
  dominanceDwellMs: 300,  // P1-E2 : délai minimum entre deux bascules de dominant
  attackSec: 0.05,  // montée du gain (nouveau dominant)
  releaseSec: 0.30, // descente (gate / ancien dominant)
  tickMs: 50,       // période de calcul des niveaux
  // Gain d'entrée AVANT l'analyseur : téléphone posé à 1-2 m = RMS -66/-75 dB
  // (18/08) → le gate -45 ne s'ouvrait pas. Boost +10 dB (×3.16) : parole
  // -40 → -30 (gate ouvert), bruit -70 → -60 (fermé). Parole PROche (-15) →
  // -5 : le LIMITEUR de sortie (mixComp, threshold -6) attrape les pics →
  // pas d'écrêtage (le +18 dB initial saturait : « voix déformée, vieux
  // micro » — retours réels 19/08).
  inputGainDb: 10,
  // Veille auto : plus aucun téléphone pendant idleSleepMs → le moteur
  // suspend le graphe audio (zéro CPU) et le main le reflète dans le
  // heartbeat (state:'sleep') + l'icône de barre de menu. Réveil dès qu'un
  // téléphone offre (la négociation WebRTC n'a pas besoin du graphe, le
  // mixage reprend à l'arrivée du flux).
  idleSleepMs: 120_000,
};

let actx = null;        // AudioContext unique
let mixDest = null;     // destination commune (stream mixé)
let mixComp = null;     // limiteur de sortie (anti-écrêtage)
let mixer = null;       // DominanceMixer (module pur)
let outputAudio = null; // l'unique lecteur vers BlackHole / haut-parleurs
let outputMode = 'blackhole';
let mixTimer = null;
let dominantId = null;  // phoneId du flux qui parle (pour le log)
// pastille « parle » (t_5b5992c1) : locuteur téléphone signalé au serveur +
// hold ~1 s avant d'annoncer le silence (anti-clignotement entre deux
// prises de parole). Le serveur relaie au dashboard → pastille verte.
let speakingId = null;          // phoneId signalé « parle » au serveur
let speakingStopTimer = null;   // stop différé (hold) en attente
const SPEAKING_HOLD_MS = 1000;  // « délai ~1 s » demandé par Nicolas
let deviceChangeWired = false; // écouteur devicechange branché une seule fois
// veille auto : true = graphe suspendu (aucun téléphone depuis idleSleepMs).
// Le MOTEUR décide (lui seul connaît le graphe) ; le main ne fait que
// refléter l'état dans le heartbeat et l'icône de barre de menu.
let sleepingMode = false;
let idleTimer = null;   // compte à rebours avant mise en veille
const channels = new Map(); // phoneId → { source, analyser, gain, levelBuf, lastTarget }

// --- micro de l'ordi (admin) ------------------------------------------------
// L'admin choisit BlackHole comme micro Teams → il ne peut plus parler dans
// le micro de son Mac. On le capte ICI et on le mixe comme un téléphone.
// echoCancellation: true → l'AEC du Mac annule SES PROPRES haut-parleurs (il
// sait ce qu'il joue) → Larsen minime pour l'admin (demande réelle 19/08).
const LOCAL_ID = '__local__';
const LOCAL_LABEL = 'Ordinateur (admin)';
const label = (id) => (id === LOCAL_ID ? LOCAL_LABEL : id);

async function captureLocalMic() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
    });
    addChannel(LOCAL_ID, stream);
    log(`🎤 Micro de l'ordi ajouté au mix — l'admin est entendu sans téléphone`, 'ok');
  } catch (e) {
    log("Micro de l'ordi indisponible : " + e.message, 'err');
  }
}

// --- adaptateur UI (React) ------------------------------------------------
let ui = null; // { onLog(line, cls), onStatus(s), onUpdate(info), onAuthed(r) }
let wired = false; // listeners window.bridge attachés une seule fois

function log(line, cls = '') {
  if (ui && ui.onLog) ui.onLog(line, cls);
  // copie vers le journal fichier (diagnostic à distance — le bridge est muet)
  if (window.bridge && window.bridge.logFile) window.bridge.logFile(line);
}

// --- veille auto (app invisible, t_c73d98b0) --------------------------------
// Aucun téléphone → le moteur suspend le graphe audio après idleSleepMs
// (zéro CPU, BlackHole relâché) et le signale au main (heartbeat state +
// icône de barre de menu). Réveil immédiat dès qu'un téléphone offre.
function setSleepMode(sleeping) {
  // garde anti-race : un téléphone présent ne doit JAMAIS être endormi
  // (l'ordre offer → ontrack peut être interrompu par le message du main)
  if (sleeping && pcs.size > 0) return;
  if (sleeping === sleepingMode) return;
  sleepingMode = sleeping;
  if (sleeping) {
    if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
    if (actx) { try { actx.suspend(); } catch {} log('Veille — aucun téléphone, graphe audio suspendu. Réveil automatique à la prochaine réunion.', 'info'); }
    if (mixTimer) { clearInterval(mixTimer); mixTimer = null; }
  } else {
    if (actx) {
      if (actx.state === 'suspended') actx.resume();
      if (!mixTimer) mixTimer = setInterval(mixTick, MIX.tickMs);
      routeOutput(); // re-route la sortie (BlackHole peut avoir été relâché)
    }
    log('Réveil — un téléphone arrive, le mixage reprend.', 'info');
  }
  reportActivity();
}

// Programme la mise en veille si aucun téléphone ne traîne (idempotent).
function scheduleIdle() {
  if (sleepingMode || idleTimer || pcs.size > 0) return;
  idleTimer = setTimeout(() => { idleTimer = null; setSleepMode(true); }, MIX.idleSleepMs);
}

// À appeler après chaque mutation du set de téléphones : réveille si besoin,
// (re)programme la veille, informe le main.
function handleActivityChange() {
  if (pcs.size > 0) {
    if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
    setSleepMode(false);
  } else {
    scheduleIdle();
  }
  reportActivity();
}

// Le main a besoin de connaître l'état pour le heartbeat (state) et l'icône.
function reportActivity() {
  if (window.bridge && window.bridge.activity) {
    window.bridge.activity({ phones: pcs.size, sleeping: sleepingMode });
  }
}

// Crée le graphe au premier flux ; ensuite no-op (relance aussi le tick si
// la veille l'avait arrêté — un flux arrive → le mixage doit tourner).
function ensureMixGraph() {
  if (actx) {
    if (actx.state === 'suspended') actx.resume();
    if (!mixTimer && !sleepingMode) mixTimer = setInterval(mixTick, MIX.tickMs);
    return;
  }
  actx = new AudioContext();
  if (actx.state === 'suspended') actx.resume();
  log('AudioContext : ' + actx.state, 'info'); // diagnostic (autoplay policy)
  mixer = new DominanceMixer(MIX);
  mixDest = actx.createMediaStreamDestination();
  // Limiteur de sortie : attrape les pics au-delà de -6 dB (ratio 20 =
  // limiteur). Sans lui, le boost d'entrée + une parole proche écrêtent à la
  // conversion finale → distorsion (« voix déformée, vieux micro », 19/08).
  mixComp = actx.createDynamicsCompressor();
  mixComp.threshold.value = -6;
  mixComp.knee.value = 6;
  mixComp.ratio.value = 20;
  mixComp.attack.value = 0.002;
  mixComp.release.value = 0.15;
  mixComp.connect(mixDest);
  outputAudio = new Audio();
  outputAudio.srcObject = mixDest.stream;
  outputAudio.autoplay = true;
  // ⚠️ Piège Chromium/Electron : un <audio> HORS DOM peut rester silencieux
  // (surtout avec MediaStreamAudioDestinationNode) — l'attacher au DOM même
  // invisible garantit que le pipeline audio tourne.
  outputAudio.style.display = 'none';
  document.body.appendChild(outputAudio);
  routeOutput(); // setSinkId + play (piège phase 0 : play APRÈS setSinkId)
  mixTimer = setInterval(mixTick, MIX.tickMs);
}

// Sélection du device de SORTIE BlackHole (t_dd15f264) — fonction PURE,
// testable en Node. Préférence stricte, à chaque étape le device PHYSIQUE
// d'abord puis « Default - … » (règle phase 0 : le défaut peut être muet) :
//   1. « Micara Mic » (driver renommé — si le custom build est fait un jour)
//   2. BlackHole NON-2ch (16ch en pratique — celui du mix ; jamais 2ch/Mirror)
//   3. n'importe quel BlackHole (comportement historique, en secours)
export function pickBlackholeDevice(outputs) {
  const list = Array.isArray(outputs) ? outputs : [];
  const byLabel = (re) => (d) => re.test(d.label || '');
  const pickBy = (pred) =>
    list.find((d) => pred(d) && !/^default/i.test(d.label || ''))
    || list.find((d) => pred(d));
  return pickBy(byLabel(/micara/i))
    || pickBy((d) => byLabel(/blackhole/i)(d) && !/2\s?ch|mirror/i.test(d.label || ''))
    || pickBy(byLabel(/blackhole/i));
}

// (Re)route la sortie du mix vers BlackHole ou les haut-parleurs.
async function routeOutput() {
  try {
    // getUserMedia factice → les labels des devices deviennent visibles.
    // ⚠️ Si la permission micro est REFUSÉE, les labels restent vides → on ne
    // peut pas identifier le device physique → sortie par défaut (risque : le
    // défaut EST BlackHole → silence). Diagnostic explicite + auto-réparation.
    let micDenied = false;
    try { await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { micDenied = true; }
    const devs = await navigator.mediaDevices.enumerateDevices();
    const outputs = devs.filter((d) => d.kind === 'audiooutput');
    // ⚠️ PRÉFÉRER le device PHYSIQUE : « Default - … » (deviceId 'default') est
    // parfois SILENCIEUX — régression P1-E du fix phase 0 (1e1c009).
    const pick = (re) => outputs.find((d) => re.test(d.label || '') && !/^default/i.test(d.label || ''))
      || outputs.find((d) => re.test(d.label || ''));
    const wanted = outputMode === 'speakers'
      ? pick(/speaker|haut-?parleur|écouteur|headphone/i)
      : pickBlackholeDevice(outputs); // jamais BlackHole 2ch (t_dd15f264)
    if (wanted) {
      await outputAudio.setSinkId(wanted.deviceId);
      await outputAudio.play();
      log(`Sortie audio → ${wanted.label}`, 'ok');
      return;
    }
    // Device physique non trouvé : la cause la plus probable est la permission
    // micro refusée (labels masqués). Le dire explicitement + lister les
    // devices vus (deviceId en secours) pour le diagnostic à distance.
    if (micDenied) {
      log(t(LANG, 'micDenied'), 'err');
      log('Sortie audio : devices vus → ' + outputs.map((d) => d.label || '(label caché, deviceId ' + d.deviceId + ')').join(' | '), 'err');
      // Auto-réparation : dès que la permission est accordée (Réglages Système
      // → Confidentialité → Microphone), devicechange se déclenche → labels
      // visibles → on se reroute sur BlackHole sans relancer l'app.
      if (!deviceChangeWired && navigator.mediaDevices.addEventListener) {
        navigator.mediaDevices.addEventListener('devicechange', () => routeOutput());
        deviceChangeWired = true;
      }
    }
    await outputAudio.play();
    log(outputMode === 'speakers' ? 'Haut-parleurs non détectés — sortie par défaut du Mac' : t(LANG, 'noBlackhole'), 'err');
  } catch (e) {
    log('Erreur sortie audio : ' + e.message, 'err');
  }
}

// --- pastille « parle » (t_5b5992c1) ----------------------------------------
// Envoie un message {type:'speaking'} au serveur (relayé par le main via le
// WS du bridge). Le micro de l'ordi (LOCAL_ID) n'est jamais signalé : il
// n'existe pas dans la liste des participants du dashboard.
function sendSpeaking(phoneId, speaking) {
  if (phoneId === LOCAL_ID) return;
  if (window.bridge && window.bridge.signalSend) {
    window.bridge.signalSend({ type: 'speaking', phoneId, speaking });
  }
}

// Annule un stop différé en attente (nouveau dominant arrivé avant le hold).
function clearSpeakingTimer() {
  if (speakingStopTimer) {
    clearTimeout(speakingStopTimer);
    speakingStopTimer = null;
  }
}

// Boucle de contrôle : niveaux RMS → mixer → gains lissés.
let signalDetected = false;
let teleTicks = 0;
function mixTick() {
  const ids = [...channels.keys()];
  if (!ids.length) return;
  const levels = [];
  for (const id of ids) {
    const ch = channels.get(id);
    ch.analyser.getFloatTimeDomainData(ch.levelBuf);
    levels.push({ id, db: rmsDb(ch.levelBuf) });
  }
  // preuve à distance : le graphe reçoit-il du signal ? (une seule fois par session)
  if (!signalDetected) {
    const maxNow = levels.reduce((m, l) => Math.max(m, l.db), -120);
    if (maxNow > -50) {
      signalDetected = true;
      log(`Signal dans le graphe : RMS ${maxNow.toFixed(1)} dB — le mixage fonctionne`, 'ok');
    }
  }
  const res = mixer.tick(levels, performance.now());
  for (const { id } of levels) {
    const ch = channels.get(id);
    const target = res.gains.get(id);
    const tc = target >= ch.lastTarget ? MIX.attackSec : MIX.releaseSec;
    ch.gain.gain.setTargetAtTime(target, actx.currentTime, tc);
    ch.lastTarget = target;
  }
  if (res.dominant !== dominantId) {
    dominantId = res.dominant;
    log(dominantId === null
      ? 'Mixage : silence — personne ne parle'
      : `Mixage : ${label(dominantId)} parle, les autres s'atténuent`, 'info');
    // pastille « parle » : à chaque bascule du dominant, on envoie au serveur
    // les transitions (nouveau locuteur immédiat, ancien après hold ~1 s).
    clearSpeakingTimer();
    const tr = speakingTransitions({ nextDominant: dominantId, speakingId, localId: LOCAL_ID });
    for (const e of tr.emit) sendSpeaking(e.phoneId, e.speaking);
    speakingId = tr.nextSpeakingId;
    if (tr.stopAfterHold) {
      const stopped = tr.stopAfterHold;
      speakingStopTimer = setTimeout(() => {
        speakingStopTimer = null;
        // le locuteur n'a pas repris la parole pendant le hold → silence
        if (speakingId === stopped) {
          sendSpeaking(stopped, false);
          speakingId = null;
        }
      }, SPEAKING_HOLD_MS);
    }
  }
  // télémétrie fichier ~toutes les 5 s (50 ms × 100) : RMS max, gate, dominant
  if (++teleTicks % 100 === 0) {
    const maxDb = levels.reduce((m, l) => Math.max(m, l.db), -120);
    if (window.bridge && window.bridge.logFile) {
      // duqués = flux ouverts qui ne parlent pas (preuve du ducking 2 locuteurs)
      const ducked = levels.filter((l) => res.gains.get(l.id) > 0 && l.id !== res.dominant).length;
      window.bridge.logFile(`[tele] RMS max ${maxDb.toFixed(1)} dB | gate ${res.openCount}/${levels.length} | dominant ${res.dominant ? label(res.dominant) : '-'} | duqués ${ducked}`);
    }
    statsTick(); // compteurs RTP (diagnostic silence)
  }
}

// Compteurs RTP de TOUS les peers (par phoneId) : prouve si des paquets audio
// ARRIVENT au bridge. 0 octet partout = les émetteurs n'envoient rien (iOS
// gesture ? onglets multiples ?) ; octets > 0 mais RMS -120 = décodage muet.
async function statsTick() {
  if (!pcs.size) return;
  let totalBytes = 0, totalPackets = 0, totalLost = 0;
  const perPc = [];
  for (const [phoneId, pc] of pcs) {
    try {
      const stats = await pc.getStats();
      let bytes = 0, packets = 0, lost = 0, jbMs = 0;
      stats.forEach((r) => {
        if (r.type === 'inbound-rtp' && (r.kind === 'audio' || r.mediaType === 'audio')) {
          bytes += r.bytesReceived || 0;
          packets += r.packetsReceived || 0;
          lost += r.packetsLost || 0;
          // latence du jitter buffer Chromium : delay cumulé (s) / échantillons
          // émis → ms. Si elle atteint 400-500 ms, c'est ELLE la latence.
          if (r.jitterBufferDelay && r.jitterBufferEmittedCount) {
            jbMs = Math.round((r.jitterBufferDelay / r.jitterBufferEmittedCount) * 1000);
          }
        }
      });
      // paire ICE sélectionnée → adresses réelles (LAN = 192.168.x.x, sinon
      // relay/TURN ou Internet) : indispensable pour juger la latence.
      let via = '';
      const pair = [...stats.values()].find((r) => r.type === 'candidate-pair' && r.state === 'succeeded');
      if (pair) {
        const lc = [...stats.values()].find((r) => r.type === 'local-candidate' && r.id === pair.localCandidateId);
        const rc = [...stats.values()].find((r) => r.type === 'remote-candidate' && r.id === pair.remoteCandidateId);
        via = ` (${lc ? lc.address : '?'}→${rc ? rc.address : '?'})`;
      }
      perPc.push(`${phoneId}:${bytes}${via}${jbMs ? ` jb=${jbMs}ms` : ''}`);
      totalBytes += bytes; totalPackets += packets; totalLost += lost;
    } catch { /* un pc mort ne doit pas casser le tick */ }
  }
  if (window.bridge && window.bridge.logFile) {
    window.bridge.logFile(`[stats] ${pcs.size} pc | total: ${totalBytes} octets / ${totalPackets} paquets / ${totalLost} perdus | par pc: ${perPc.join(', ')}`);
  }
}

// Branche un flux téléphone dans le graphe (gate fermé au départ).
function addChannel(phoneId, stream) {
  ensureMixGraph();
  if (channels.has(phoneId)) return;
  const source = actx.createMediaStreamSource(stream);
  const inputGain = actx.createGain();
  inputGain.gain.value = Math.pow(10, MIX.inputGainDb / 20); // boost micro (×8 ≈ +18 dB)
  const analyser = actx.createAnalyser();
  analyser.fftSize = 2048; // ≈ 43 ms @ 48 kHz
  const gain = actx.createGain();
  gain.gain.value = 0; // démarre fermé — le gate décide
  source.connect(inputGain);
  inputGain.connect(analyser);
  analyser.connect(gain);
  gain.connect(mixComp); // bus commun → limiteur → mixDest
  channels.set(phoneId, {
    source, inputGain, analyser, gain, stream, // stream gardé pour le rebranchement à connected
    levelBuf: new Float32Array(analyser.fftSize),
    lastTarget: 0,
  });
  const track = stream.getAudioTracks()[0];
  if (track) track.addEventListener('ended', () => removeChannel(phoneId));
  // Re-play après branchement du flux : si le 1er play() a eu lieu avant
  // l'arrivée du son, Chromium peut rester muet — un play() frais garantit
  // que la lecture est active maintenant qu'il y a des données.
  if (outputAudio) {
    outputAudio.play().catch(() => {});
  }
}

// Retire un flux (téléphone déconnecté / flux fini) — sinon il resterait
// dans le mix et fausserait la dominance.
function removeChannel(phoneId) {
  const ch = channels.get(phoneId);
  if (ch) {
    try { ch.source.disconnect(); } catch {}
    try { ch.inputGain.disconnect(); } catch {}
    try { ch.analyser.disconnect(); } catch {}
    try { ch.gain.disconnect(); } catch {}
    channels.delete(phoneId);
  }
  if (mixer) mixer.forget(phoneId);
  if (dominantId === phoneId) dominantId = null;
  // pastille « parle » : le locuteur est parti → silence IMMÉDIAT (pas de hold)
  if (speakingId === phoneId) {
    clearSpeakingTimer();
    sendSpeaking(phoneId, false);
    speakingId = null;
  }
  log(`${phoneId === LOCAL_ID ? "Micro de l'ordi" : 'Téléphone ' + phoneId} : flux retiré du mixage`, 'info');
}

// --- WebRTC : une connexion par téléphone ----------------------------------
const pcs = new Map(); // phoneId → RTCPeerConnection
let iceServers = [];  // credentials TURN reçues dans le welcome (serveur)

// ⚠️ Sérialisation EXPLICITE avant l'IPC (piège P1-D) : les objets WebIDL
// (RTCSessionDescription, RTCIceCandidate) sont VIDÉS par le structured clone
// d'Electron (contextIsolation) quand le renderer les envoie au main via
// ipcRenderer. Sans ça, l'answer arrive au serveur sans SDP (« [object Object] »,
// 15 chars) et les candidates sans leur string → le téléphone ne peut jamais
// établir ICE → « En attente du bridge » en boucle. Extraire les primitives.
function serializeAnswer(ld) {
  return { type: ld.type, sdp: ld.sdp };
}
function serializeCandidate(c) {
  return { candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex };
}

function setupPc(phoneId) {
  const pc = new RTCPeerConnection(iceServers.length ? { iceServers } : undefined);
  pc.onicecandidate = (e) => {
    if (e.candidate) window.bridge.signalSend({ type: 'ice', phoneId, candidate: serializeCandidate(e.candidate) });
  };
  pc.onconnectionstatechange = () => {
    log(`Téléphone ${phoneId} : WebRTC ${pc.connectionState}`, 'info');
    // ⚠️ Piège Chromium : ontrack précède ICE connected → le flux est branché
    // au graphe AVANT le 1er paquet → le MediaStreamAudioSourceNode peut rester
    // muet (octets reçus > 0 mais RMS -120). Rebrancher à connected.
    if (pc.connectionState === 'connected' && channels.has(phoneId)) {
      const stream = channels.get(phoneId).stream;
      log(`Téléphone ${phoneId} : flux rebranché (connected)`, 'info');
      removeChannel(phoneId);
      addChannel(phoneId, stream);
    }
    if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
      removeChannel(phoneId);
      pcs.delete(phoneId);
      handleActivityChange(); // téléphone parti → reprogramme la veille
    }
  };
  pc.ontrack = (ev) => {
    const stream = ev.streams[0];
    const track = stream ? stream.getAudioTracks()[0] : null;
    log(t(LANG, 'streamStart', phoneId), 'ok');
    if (track) log(`Téléphone ${phoneId} : track muted=${track.muted}`, 'info');
    // ACTIVATEUR (18/08) : un <audio> silencieux (volume 0) consomme le flux —
    // dans Electron, le MediaStreamAudioSourceNode seul sur un flux REÇU reste
    // muet ; il faut qu'un élément média joue le flux pour activer le décodage.
    // Le SON, lui, ne sort QUE par le graphe de mixage (routeOutput) — sinon
    // deux chemins sonores jouent le même flux → doublon → « latence inutilisable ».
    try {
      const act = document.createElement('audio');
      act.srcObject = stream;
      act.autoplay = true;
      act.volume = 0; // inaudible — c'est l'activateur, pas la sortie
      document.body.appendChild(act);
      act.play().catch(() => {});
      log(`Téléphone ${phoneId} : décodage activé (lecture silencieuse)`, 'info');
    } catch (e) { log('Erreur activation décodage : ' + e.message, 'err'); }
    addChannel(phoneId, stream);
    // si le track reste muted après l'arrivée des paquets, le flux est muet →
    // rebrancher au déblocage (double filet de sécurité avec le rebind ci-dessus)
    if (track) {
      track.addEventListener('unmute', () => {
        log(`Téléphone ${phoneId} : track débloqué — rebranchement`, 'info');
        removeChannel(phoneId);
        addChannel(phoneId, stream);
      });
    }
  };
  return pc;
}

// Handler des messages du serveur (welcome/offer/ice/phone-left).
async function handleSignal(msg) {
  // le welcome porte les iceServers TURN → on les garde pour setupPc
  if (msg.type === 'welcome' && Array.isArray(msg.iceServers)) {
    iceServers = msg.iceServers;
  }
  // Reconnection du bridge : les anciens pc WebRTC sont morts (le ws a
  // été coupé, les téléphones vont re-offrir) → tout nettoyer pour
  // repartir propre, sinon setRemoteDescription sur un pc failed.
  if (msg.type === 'welcome' && msg.role === 'bridge') {
    for (const [pid, pc] of pcs) { try { pc.close(); } catch {} }
    pcs.clear();
    for (const pid of [...channels.keys()]) removeChannel(pid);
    handleActivityChange(); // 0 téléphone → veille reprogrammée
    captureLocalMic(); // micro de l'ordi (admin) : l'admin est aussi un micro de salle
  }
  if (msg.type === 'phone-left') {
    const pid = msg.phoneId;
    const pc = pcs.get(pid);
    if (pc) { try { pc.close(); } catch {} pcs.delete(pid); }
    if (channels.has(pid)) removeChannel(pid);
    handleActivityChange(); // téléphone parti → veille reprogrammée
    log(`Téléphone ${pid} : parti (nettoyage pc + flux)`, 'info');
  }
  if (msg.type === 'offer') {
    const phoneId = msg.phoneId || 'legacy';
    try {
      if (!pcs.has(phoneId)) pcs.set(phoneId, setupPc(phoneId));
      const pc = pcs.get(phoneId);
      await pc.setRemoteDescription(msg.sdp);
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      window.bridge.signalSend({ type: 'answer', phoneId, sdp: serializeAnswer(pc.localDescription) });
    } catch (e) {
      log('Erreur négociation : ' + e.message, 'err');
    }
  } else if (msg.type === 'ice' && msg.phoneId) {
    const pc = pcs.get(msg.phoneId);
    if (pc) {
      try { await pc.addIceCandidate(msg.candidate); }
      catch (e) { log('ICE : ' + e.message, 'err'); }
    }
  }
  if (['offer', 'ice', 'phone-joined'].includes(msg.type)) {
    handleActivityChange(); // réveil conservateur sur toute activité signal
  }
}

// --- API publique (consommée par App.jsx) -----------------------------------

// Branche les événements window.bridge vers l'adaptateur UI. Idempotent :
// React StrictMode (dev) monte/démonte deux fois → les listeners ne doivent
// pas être attachés deux fois (le preload n'expose pas de off()).
export function initBridgeEngine(adapter = {}) {
  ui = adapter;
  if (!wired && typeof window !== 'undefined' && window.bridge) {
    if (typeof window.bridge.onSignal === 'function') window.bridge.onSignal(handleSignal);
    if (typeof window.bridge.onStatus === 'function') {
      window.bridge.onStatus((s) => ui && ui.onStatus && ui.onStatus(s));
    }
    if (typeof window.bridge.onUpdate === 'function') {
      window.bridge.onUpdate((u) => ui && ui.onUpdate && ui.onUpdate(u));
    }
    if (typeof window.bridge.onAuthed === 'function') {
      window.bridge.onAuthed((r) => ui && ui.onAuthed && ui.onAuthed(r));
    }
    if (typeof window.bridge.onMicSetup === 'function') {
      window.bridge.onMicSetup((s) => ui && ui.onMicSetup && ui.onMicSetup(s));
    }
    wired = true;
  }
  return api;
}

export async function submitLogin({ email, password }) {
  // le serveur est TOUJOURS app.getmicara.com (côté main) — l'UI ne
  // transmet plus d'URL (décision 22/08 : login épuré, zéro détail technique)
  return window.bridge.login({ email, password }); // { ok } | { ok:false, error }
}

export async function logout() {
  return window.bridge.logout();
}

export function openDashboard() {
  return window.bridge.openDashboard();
}

export function installUpdate() {
  return window.bridge.installUpdate();
}

// Micro par défaut du système (t_dd15f264) : re-tente ou masque la bannière.
export async function applyMicSetup() {
  return window.bridge.applyDefaultMic(); // { ok, deviceName } | { ok:false, missing|notfound|error }
}

export function dismissMicNotice() {
  return window.bridge.dismissMicNotice();
}

// Change la sortie du mix en cours de réunion (sélecteur UI).
export function setOutputMode(mode) {
  outputMode = mode;
  if (actx && actx.state === 'suspended') actx.resume(); // débloque l'audio
  if (outputAudio) routeOutput(); // re-route le mix en cours de réunion
}

// Diagnostic (tests sandbox + support) : état interne du moteur.
export function debugState() {
  return {
    pcs: pcs.size,
    channels: channels.size,
    iceServers: iceServers.length,
    dominant: dominantId,
    outputMode,
    mixGraph: !!actx,
    sleeping: sleepingMode,
  };
}

export const api = {
  init: initBridgeEngine,
  submitLogin,
  logout,
  openDashboard,
  installUpdate,
  applyMicSetup,
  dismissMicNotice,
  setOutputMode,
  debugState,
};
