// Micara bridge — renderer (P1-E, app silencieuse)
// Login 1× + écran de statut minimal. 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).
// NOTE : fonctions au top-level (pas d'IIFE) pour le test sandbox vm.

'use strict';

// --- i18n FR/EN (décision produit : EN+FR dès le départ) ------------------
const I18N = {
  fr: {
    version: 'v0.3.1',
    loginHint: 'Entrez le compte admin Micara une seule fois. Le jeton est enregistré sur ce Mac.',
    server: 'Serveur',
    email: 'Email',
    password: 'Mot de passe',
    login: 'Se connecter',
    loginError: 'Connexion impossible : %s',
    sessionExpired: 'Session expirée — reconnectez-vous.',
    connecting: 'Connexion au signal…',
    connected: 'Connecté',
    lost: 'Signal perdu — reconnexion…',
    space: 'Espace',
    dashboard: 'Ouvrir le dashboard',
    logout: 'Se déconnecter',
    output: 'Sortie audio',
    outputBlackhole: 'BlackHole (micro virtuel — pour Teams)',
    outputSpeakers: 'Haut-parleurs (test latence)',
    updateReady: 'Mise à jour v%s téléchargée — cliquez pour installer',
    updateError: 'Mise à jour v%s disponible, mais téléchargement impossible',
    streamStart: 'Flux reçu (téléphone %s) — lecture…',
    noBlackhole: '⚠️ BlackHole introuvable — sortie par défaut (surveillez vos haut-parleurs !)',
    micDenied: '⚠️ Permission micro refusée — labels audio invisibles : BlackHole non identifiable. Autorisez Micara dans Réglages Système → Confidentialité et sécurité → Microphone (l\'app se corrige toute seule ensuite).',
  },
  en: {
    version: 'v0.3.1',
    loginHint: 'Enter your Micara admin account once. The token is saved on this Mac.',
    server: 'Server',
    email: 'Email',
    password: 'Password',
    login: 'Sign in',
    loginError: 'Cannot connect: %s',
    sessionExpired: 'Session expired — please sign in again.',
    connecting: 'Connecting to signal…',
    connected: 'Connected',
    lost: 'Signal lost — reconnecting…',
    space: 'Space',
    dashboard: 'Open dashboard',
    logout: 'Sign out',
    output: 'Audio output',
    outputBlackhole: 'BlackHole (virtual mic — for Teams)',
    outputSpeakers: 'Speakers (latency test)',
    updateReady: 'Update v%s downloaded — click to install',
    updateError: 'Update v%s available, but download failed',
    streamStart: 'Stream received (phone %s) — playing…',
    noBlackhole: '⚠️ BlackHole not found — using default output (watch your speakers!)',
    micDenied: '⚠️ Microphone permission denied — audio labels hidden: cannot identify BlackHole. Allow Micara in System Settings → Privacy & Security → Microphone (the app will fix itself after).',
  },
};
let L = I18N.fr;

// --- helpers DOM ----------------------------------------------------------
function $(id) { return document.getElementById(id); }

function log(line, cls = '') {
  const el = $('log');
  if (!el) return;
  const div = document.createElement('div');
  div.className = cls;
  div.textContent = line;
  el.appendChild(div);
  el.scrollTop = el.scrollHeight;
  // copie vers le journal fichier (diagnostic à distance — le bridge est muet)
  if (window.bridge && window.bridge.logFile) window.bridge.logFile(line);
}

function applyI18n() {
  const els = document.querySelectorAll('[data-i18n]');
  for (let i = 0; i < els.length; i++) {
    const key = els[i].getAttribute('data-i18n');
    if (L[key]) els[i].textContent = L[key];
  }
  const v = $('version');
  if (v) v.textContent = L.version;
}

function fmt(s, arg) { return s.replace('%s', arg); }

// --- vues ------------------------------------------------------------------
function showLogin(message) {
  $('view-login').style.display = 'block';
  $('view-status').style.display = 'none';
  if (message) $('login-error').textContent = fmt(L.loginError, message);
}

function showStatus() {
  $('view-login').style.display = 'none';
  $('view-status').style.display = 'block';
  $('status-text').textContent = L.connecting;
  setDot('connecting');
}

function setDot(state) {
  const dot = $('dot');
  dot.className = 'dot';
  if (state === 'connected') dot.classList.add('connected');
  else if (state === 'connecting') dot.classList.add('connecting');
  else if (state === 'disconnected') dot.classList.add('disconnected');
}

function applyStatus({ status, spaceCode }) {
  const st = $('status-text');
  if (status === 'connected') {
    st.textContent = L.connected;
    setDot('connected');
  } else if (status === 'disconnected') {
    st.textContent = L.lost;
    setDot('disconnected');
  } else {
    st.textContent = L.connecting;
    setDot('connecting');
  }
  if (spaceCode) $('space-code').textContent = spaceCode;
}

function applyUpdate(info) {
  const line = $('update-line');
  if (!info || !info.update) { line.style.display = 'none'; return; }
  line.style.display = 'block';
  line.className = 'update' + (info.download ? '' : ' err');
  line.textContent = info.download
    ? fmt(L.updateReady, info.latest)
    : fmt(L.updateError, info.latest);
}

// --- login -----------------------------------------------------------------
async function submitLogin() {
  const btn = $('login-btn');
  btn.disabled = true;
  const res = await window.bridge.login({
    serverUrl: $('server').value,
    email: $('email').value,
    password: $('password').value,
  });
  btn.disabled = false;
  if (res.ok) showStatus();
  else $('login-error').textContent = fmt(L.loginError, res.error);
}

// --- 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.

// mixer.js (chargé avant dans index.html) expose ses fonctions sur
// window.MicaraMixer — ni require() ni import dans le renderer.
const { rmsDb, DominanceMixer } = (typeof window !== 'undefined' && window.MicaraMixer) || {};

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,
};

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)
let deviceChangeWired = false; // écouteur devicechange branché une seule fois
const channels = new Map(); // phoneId → { source, analyser, gain, levelBuf, lastTarget }

// Crée le graphe au premier flux ; ensuite no-op.
function ensureMixGraph() {
  if (actx) 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);
}

// (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)
      : pick(/blackhole/i);
    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(L.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' : L.noBlackhole, 'err');
  } catch (e) {
    log('Erreur sortie audio : ' + e.message, 'err');
  }
}

// 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 : ${dominantId} parle, les autres s'atténuent`, 'info');
  }
  // 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 || '-'} | 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;
  log(`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);
    }
  };
  pc.ontrack = (ev) => {
    const stream = ev.streams[0];
    const track = stream ? stream.getAudioTracks()[0] : null;
    log(fmt(L.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;
}

window.bridge.onSignal(async (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);
  }
  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);
    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'); }
    }
  }
});

// --- démarrage -------------------------------------------------------------
window.addEventListener('DOMContentLoaded', async () => {
  const lang = String(navigator.language || 'fr').toLowerCase();
  L = lang.startsWith('fr') ? I18N.fr : I18N.en;
  applyI18n();

  $('login-btn').addEventListener('click', submitLogin);
  $('dashboard-btn').addEventListener('click', () => window.bridge.openDashboard());
  $('logout-btn').addEventListener('click', async () => {
    await window.bridge.logout();
    showLogin();
  });
  $('update-line').addEventListener('click', () => window.bridge.installUpdate());
  outputMode = $('output').value;
  $('output').addEventListener('change', (e) => {
    outputMode = e.target.value;
    if (actx && actx.state === 'suspended') actx.resume(); // débloque l'audio
    if (outputAudio) routeOutput(); // re-route le mix en cours de réunion
  });

  window.bridge.onStatus(applyStatus);
  window.bridge.onUpdate(applyUpdate);
  window.bridge.onAuthed((r) => {
    if (r.ok) showStatus();
    else showLogin(L.sessionExpired);
  });
  window.bridge.onAudioStatus((s) => log(s, s.startsWith('ERREUR') ? 'err' : 'ok'));

  const st = await window.bridge.state();
  if (st.authed) showStatus();
  else showLogin();
});
