// Micara bridge — processus principal Electron (app invisible, t_c73d98b0)
// Pure tuyauterie : login 1× (jeton api), heartbeat de présence (+ état
// 'active'|'sleep'), auto-update au lancement, bouton → dashboard, un seul
// Mac par compte (serveur). Le WebRTC (réponse aux offres des téléphones)
// vit dans le renderer ; ici on porte le signal WS et on relaie les messages.
//
// Invisible au quotidien (décisions Nicolas) :
// - démarrage automatique au LOGIN du Mac (login item, openAsHidden) → jamais
//   de fenêtre au démarrage ;
// - icône discrète en barre de menu : POINT VERT = connecté, ORANGE =
//   problème (le détail est sur l'app web, pas dans le bridge) ;
// - pas de fenêtre par défaut : login (1re fois), deep link micara://open
//   (bouton « Lancer » du dashboard) ou menu de l'icône « Afficher » ;
// - veille auto quand inactif (aucun téléphone pendant IDLE_SLEEP_MS) : le
//   renderer suspend son graphe audio, le heartbeat porte state:'sleep' — le
//   bridge reste en barre de menu et se réveille à la prochaine réunion.
const { app, BrowserWindow, ipcMain, session, shell, safeStorage, Tray, Menu, nativeImage, Notification } = require('electron');
const fs = require('fs');
const path = require('path');
const audio = require('./audio');
const { createConfigStore } = require('./lib/config');
const { createApi } = require('./lib/api');
const { createBridgeClient } = require('./lib/bridge-client');
const { checkForUpdate, appBundlePath, launchInstaller } = require('./lib/updater');
const { parseDeepLinkTicket } = require('./lib/deeplink');
const { execFile } = require('child_process');
const { pickMicaraDevice } = require('./lib/mic-setup');

app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');

const HEARTBEAT_MS = 30_000; // < BRIDGE_ASLEEP_AFTER_MS du serveur (90 s)
const UPDATE_CHECK_MS = 60 * 60 * 1000; // auto-update : vérif au lancement + toutes les 1 h
const DEFAULT_SERVER = 'https://app.getmicara.com';
const DEEP_LINK_SCHEME = 'micara';

function normalizeServerUrl(u) {
  return String(u || '').trim().replace(/\/+$/, '');
}

// Migration silencieuse (décision Nicolas 22/08) : l'ancien dev stockait
// https://roommic.…sslip.io dans config.json. Désormais le bridge se connecte
// TOUJOURS à app.getmicara.com — toute URL stockée ≠ DEFAULT_SERVER est
// remplacée au démarrage (le client ne voit jamais d'URL serveur).
function migrateServerUrl() {
  if (!store) return;
  const stored = normalizeServerUrl(store.load().serverUrl);
  if (stored && stored !== DEFAULT_SERVER) {
    console.log(`[bridge] migration serveur : ${stored} → ${DEFAULT_SERVER}`);
    store.save({ serverUrl: DEFAULT_SERVER });
  }
}

let win = null;
let tray = null;
let store = null;
let api = null;
let client = null;
let heartbeatTimer = null;
let updateInfo = null;
let updateTimer = null;           // check MAJ périodique (1 h — clients qui ne relancent jamais)
let updateBusy = false;           // un seul check/téléchargement à la fois
let installingUpdate = false;     // vrai entre le clic « Mettre à jour » et app.quit()
let notifiedUpdateVersion = null; // notification « disponible » envoyée une fois par version
let bridgeStatus = 'idle';
let spaceCode = null;
let launchedViaDeepLink = false;
// device activation (t_ec4c7c04) : ticket « Lancer » reçu via le deep link,
// en attente de consommation (open-url peut arriver avant app.whenReady)
let pendingLink = null;
// vrai quand la connexion automatique (ticket) a déjà tout fait → le
// did-finish-load saute la re-validation me() (startBridge déjà appelé)
let authedViaTicket = false;
// veille auto : 'active' (réunion/en cours) | 'sleep' (aucun téléphone —
// graphe audio suspendu, le bridge respire toujours, heartbeat state:'sleep')
let sleepState = 'active';

// --- deep link micara:// (bouton « Lancer » du dashboard web) -----------------
// Enregistre le schéma AVANT whenReady (macOS : l'événement open-url peut
// arriver dès le lancement). En dev (npm start, app non packagée), il faut
// passer l'exécutable + l'argument pour que le lien ouvre CETTE app.
if (!app.isPackaged && process.platform === 'darwin') {
  app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [path.resolve(process.argv[1] || '')]);
} else {
  app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
}

// macOS : le système livre l'URL ici (app déjà lancée OU lancée par le lien)
app.on('open-url', (event, url) => {
  event.preventDefault();
  handleDeepLink(url);
});

// device activation (t_ec4c7c04) : le bouton « Lancer » du dashboard ouvre
// micara://open?link=<ticket> — l'app consomme le ticket et se connecte TOUTE
// SEULE (zéro login manuel). NB sécurité : le ticket est un secret à usage
// unique → jamais logger l'URL complète (le lien n'apparaît dans aucun log).
function handleDeepLink(url) {
  if (!url || !url.startsWith(`${DEEP_LINK_SCHEME}://`)) return;
  console.log('[bridge] deep link reçu (micara://open)');
  launchedViaDeepLink = true;
  const ticket = parseDeepLinkTicket(url);
  if (!ticket) { showWindow(); return; } // lien simple : comportement historique
  pendingLink = ticket;
  if (store) {
    const t = pendingLink;
    pendingLink = null;
    handleLinkTicket(t);
  }
  // sinon (open-url avant app.whenReady) : consommé dans whenReady AVANT la
  // création de la fenêtre → jamais d'écran de login si le ticket est valide.
}

// Consomme le ticket du deep link → échange contre un jeton api longue durée
// → startBridge immédiat. En échec (ticket expiré/déjà utilisé/réseau) :
// fallback — jeton stocké si présent, sinon l'écran de login reste (cas sans
// dashboard). Le feedback visuel (fenêtre) est donné dans tous les cas.
async function handleLinkTicket(ticket) {
  try {
    const res = await createApi({ serverUrl: DEFAULT_SERVER, timeoutMs: 5000 }).consumeLink(ticket);
    store.save({ serverUrl: DEFAULT_SERVER });
    store.saveToken(res.token);
    startBridge(res.token, DEFAULT_SERVER);
    // démarrage auto au login du Mac (comme après un login manuel)
    if (process.platform === 'darwin') {
      app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true });
    }
    authedViaTicket = true;
    send('app:authed', { ok: true });
  } catch (e) {
    console.error('[bridge] ticket « Lancer » refusé :', e.message);
  } finally {
    // feedback : montrer la fenêtre (le bridge est là — ou login si pas de
    // jeton). Au démarrage (win pas encore créé) : no-op, le did-finish-load
    // s'occupe de l'affichage juste après.
    if (win && !win.isDestroyed()) showWindow();
  }
}

// Windows/Linux (portage phase 4) : l'URL arrive en argv de la 2e instance
function argvHasDeepLink(argv) {
  return Array.isArray(argv) && argv.some((a) => String(a).startsWith(`${DEEP_LINK_SCHEME}://`));
}

// --- fenêtre (cachée par défaut — le renderer porte WebRTC + audio) ---------

function send(channel, payload) {
  if (win && !win.isDestroyed()) win.webContents.send(channel, payload);
}

function showWindow() {
  if (!win || win.isDestroyed()) return;
  if (process.platform === 'darwin' && app.dock && app.dock.show) app.dock.show();
  if (win.isMinimized()) win.restore();
  win.show();
  win.focus();
}

function createWindow(visible) {
  win = new BrowserWindow({
    width: 420,
    height: 640,
    resizable: false,
    show: visible, // false → app invisible au quotidien (login 1re fois → true)
    title: 'Micara Bridge',
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      // ⚠️ Crucial pour l'app invisible : sans lui, Chromium throttle le
      // renderer caché → WebRTC/audio laguent ou se coupent (salle muette).
      backgroundThrottling: false,
    },
  });
  // UI React + Astryx (refonte t_fa529748) : bundle Vite dans renderer/dist
  // (base relative → fonctionne sous file://). Rebuild : npm run build:renderer.
  win.loadFile(path.join(__dirname, 'renderer', 'dist', 'index.html'));
  win.on('closed', () => { win = null; });

  // au lancement : jeton stocké → validation silencieuse → démarrage du bridge
  win.webContents.on('did-finish-load', () => {
    if (authedViaTicket) {
      // déjà connecté via le ticket « Lancer » (t_ec4c7c04) — consommé AVANT
      // la création de la fenêtre : startBridge est déjà fait, pas de
      // re-validation me() → on confirme juste au renderer et on affiche.
      send('app:authed', { ok: true });
      if (launchedViaDeepLink) showWindow();
      else hideToTray();
      return;
    }
    const token = store.loadToken();
    const serverUrl = DEFAULT_SERVER; // toujours app.getmicara.com (décision 22/08)
    if (!token) return; // renderer → écran de login (fenêtre visible)
    createApi({ serverUrl, token })
      .me()
      .then(() => {
        startBridge(token, serverUrl);
        send('app:authed', { ok: true });
        // validation OK → l'app vit en barre de menu, pas en fenêtre.
        // Sauf si l'utilisateur vient de cliquer « Lancer » (deep link) :
        // montrer la fenêtre = feedback immédiat que le bridge est là.
        if (launchedViaDeepLink) showWindow();
        else hideToTray();
      })
      .catch(() => {
        send('app:authed', { ok: false }); // jeton mort → login
        showWindow();
      });
  });
}

// Range la fenêtre : cachée + dock masqué → seul l'icône de barre de menu reste
function hideToTray() {
  if (win && !win.isDestroyed()) win.hide();
  if (process.platform === 'darwin' && app.dock && app.dock.hide) app.dock.hide();
}

// --- barre de menu (icône discrète, point VERT connecté / ORANGE problème) ---
// Le détail d'un problème est sur l'app web, jamais dans le bridge. L'icône
// ne fait qu'ouvrir le dashboard (clic) et offre Quitter (menu contextuel).

function trayIcon() {
  // vert = connecté (même en veille — le bridge respire) ; orange = problème
  // (signal perdu / connexion en cours) ; gris = déconnecté (pas de compte).
  const name = bridgeStatus === 'connected' ? 'tray-green' : (bridgeStatus === 'idle' ? 'tray-gray' : 'tray-orange');
  const file = path.join(__dirname, 'assets', `${name}.png`);
  return fs.existsSync(file) ? nativeImage.createFromPath(file) : nativeImage.createEmpty();
}

function trayTooltip() {
  if (bridgeStatus === 'connected') {
    return sleepState === 'sleep' ? 'Micara — connecté (en veille, prêt pour la réunion)' : 'Micara — connecté';
  }
  if (bridgeStatus === 'idle') return 'Micara — non connecté (ouvrez le dashboard pour vous connecter)';
  return 'Micara — problème de connexion (voir le dashboard)';
}

// Menu de l'icône : « Mettre à jour maintenant » apparaît dès qu'un zip est
// téléchargé (l'app est cachée la plupart du temps — c'est LA surface qui
// couvre les clients qui n'ouvrent jamais la fenêtre).
function updateTrayMenu() {
  if (!tray) return;
  const items = [
    { label: 'Ouvrir le dashboard', click: () => shell.openExternal(DEFAULT_SERVER) },
    { label: 'Afficher la fenêtre', click: showWindow },
  ];
  if (updateInfo && updateInfo.download && !installingUpdate) {
    items.push({ type: 'separator' });
    items.push({
      label: `Mettre à jour maintenant (v${updateInfo.latest})`,
      click: () => installUpdateNow(),
    });
  }
  items.push({ type: 'separator' });
  items.push({ label: 'Quitter Micara', click: () => app.quit() });
  tray.setContextMenu(Menu.buildFromTemplate(items));
}

function createTray() {
  tray = new Tray(trayIcon());
  tray.setToolTip(trayTooltip());
  updateTrayMenu();
  // clic simple → dashboard (le problème s'explique sur l'app web)
  tray.on('click', () => shell.openExternal(DEFAULT_SERVER));
}

function updateTray() {
  if (!tray) return;
  tray.setImage(trayIcon());
  tray.setToolTip(trayTooltip());
}

// --- micro par défaut du système (t_dd15f264, device « Micara Mic ») ---------
// Le driver BlackHole ne peut PAS être renommé : son nom (« BlackHole 16ch »)
// est compilé dans le code C du driver, pas dans son Info.plist (étude — voir
// récap de la tâche). On fait donc le travail côté système :
//   1. lister les devices d'entrée (SwitchAudioSource -a -t input) ;
//   2. choisir le MEILLEUR device Micara (jamais « BlackHole 2ch », caché) ;
//   3. le définir comme micro par défaut du système (-s) → Teams le
//      présélectionne (Réglages → Appareils → Microphone).
// SwitchAudioSource (brew install switchaudio-osx) : outil standard, zéro
// permission spéciale (simple appel CoreAudio). S'il est absent, le bridge
// dégrade proprement : la bannière du renderer explique quoi installer et le
// bouton « Définir le micro par défaut » re-tente quand l'outil est là.
function runSwitchAudio(args) {
  return new Promise((resolve) => {
    execFile('SwitchAudioSource', args, { timeout: 8000 }, (err, stdout, stderr) => {
      if (err && err.code === 'ENOENT') return resolve({ missing: true });
      if (err) return resolve({ ok: false, error: err.message, stderr: String(stderr || '') });
      resolve({ ok: true, stdout: String(stdout || ''), stderr: String(stderr || '') });
    });
  });
}

async function applyDefaultMic() {
  const list = await runSwitchAudio(['-a', '-t', 'input']);
  if (list.missing) return { ok: false, missing: true };
  if (!list.ok) return { ok: false, error: list.error || list.stderr };
  const device = pickMicaraDevice(list.stdout.split('\n'));
  if (!device) return { ok: false, notfound: true };
  const set = await runSwitchAudio(['-s', device, '-t', 'input']);
  if (set.missing) return { ok: false, missing: true };
  if (!set.ok) return { ok: false, error: set.error || set.stderr };
  return { ok: true, deviceName: device };
}

function micSetupState() {
  const s = store ? store.load() : {};
  return {
    done: !!s.micSetupDone,
    ok: !!s.micSetupOk,
    deviceName: s.micSetupDevice || null,
    error: s.micSetupError || null,
    dismissed: !!s.micNoticeDismissed,
  };
}

// Au 1er lancement (et tant que ça n'a pas réussi une fois) : micro par défaut
// du système = device Micara. Le résultat est poussé au renderer (bannière).
function bootstrapMicSetup() {
  (async () => {
    try {
      const st = store.load();
      if (st.micSetupDone && st.micSetupOk) return; // déjà réglé avec succès
      const res = await applyDefaultMic();
      store.save({
        micSetupDone: true,
        micSetupOk: !!res.ok,
        micSetupDevice: res.deviceName || null,
        micSetupError: res.error || (res.missing ? 'missing' : res.notfound ? 'notfound' : null),
      });
      send('app:mic-setup', micSetupState());
    } catch (e) {
      console.error('[bridge] mic-setup:', e.message);
      store.save({ micSetupDone: true, micSetupOk: false, micSetupError: e.message });
      send('app:mic-setup', micSetupState());
    }
  })();
}

// --- service bridge (signaling + heartbeat + auto-update) ------------------

function startBridge(token, serverUrl) {
  stopBridge();
  api = createApi({ serverUrl, token });
  spaceCode = null;
  sleepState = 'active';

  // présence : toutes les 30 s. Mac endormi / app fermée → plus de heartbeat
  // → le dashboard affiche l'alerte (< 90 s). state = 'active' | 'sleep'
  // (veille auto) → le dashboard distingue « en veille » de « éteint ».
  const beat = () => api.heartbeat({ version: app.getVersion(), state: sleepState }).catch((e) => console.error('[bridge] heartbeat:', e.message));
  beat();
  heartbeatTimer = setInterval(beat, HEARTBEAT_MS);

  const wsUrl = `${serverUrl.replace(/^http/, 'ws')}/ws?token=${encodeURIComponent(token)}&role=bridge`;
  client = createBridgeClient({
    wsUrl,
    onStatus: (s) => {
      bridgeStatus = s;
      updateTray();
      send('app:status', { status: s, spaceCode, sleeping: sleepState === 'sleep' });
    },
    onMessage: (msg) => {
      // l'espace (code secret) arrive dans le welcome → affiché dans la fenêtre
      if (msg.type === 'welcome' && msg.space && msg.space.secretCode) {
        spaceCode = msg.space.secretCode;
        send('app:status', { status: bridgeStatus, spaceCode, sleeping: sleepState === 'sleep' });
      }
      send('app:signal', msg); // offer/answer/ice → renderer (WebRTC)
    },
  });
  client.connect();
  // l'auto-update ne vit plus ici : cycle global (lancement + 1 h), démarré
  // dans whenReady — /api/bridge/latest est PUBLIC (fonctionne sans login).
  // Voir startUpdateCycle().
}

function stopBridge() {
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
  if (client) { client.close(); client = null; }
  api = null;
  // updateInfo n'est PAS remis à zéro : il appartient au cycle global
  // d'auto-update (startUpdateCycle), pas à la session bridge.
  bridgeStatus = 'idle';
  spaceCode = null;
  sleepState = 'active';
  authedViaTicket = false; // prochain login/launch → flux normal (t_ec4c7c04)
  updateTray();
}

// --- auto-update TRANSPARENT (t_2e77f88d) -----------------------------------
// Cycle global, indépendant du login : vérif au LANCEMENT puis toutes les 1 h
// (clients qui ne relancent jamais). Télécharge le zip dans un dossier CACHÉ
// (userData/updates — jamais Téléchargements : le client ne doit rien voir).
// Zip prêt → notification + item tray + bouton fenêtre « Mettre à jour
// maintenant » → app.quit() + script détaché (dézippe, remplace le .app,
// xattr -cr, relance). La nouvelle instance notifie « Micara a été mis à
// jour » (argv --micara-updated passé par le script via `open --args`).
// VOIE PRINCIPALE (futur, app signée) : electron-updater (Squirrel.Mac) —
// inactif tant que la licence Apple 99 €/an n'est pas prise (voir lib/updater.js).

function updatesDir() {
  return path.join(app.getPath('userData'), 'updates');
}

function sendUpdateState(info) {
  updateInfo = info;
  send('app:update', info);
  updateTrayMenu();
}

// ne garde que le zip courant — les anciens (autre version) sont effacés
function cleanupOldZips(keep) {
  try {
    for (const f of fs.readdirSync(updatesDir())) {
      const p = path.join(updatesDir(), f);
      if (p !== keep && f.endsWith('.zip')) fs.unlinkSync(p);
    }
  } catch { /* best effort — le disque reste propre au mieux */ }
}

function notifyUpdateReady(version) {
  if (!Notification.isSupported()) return;
  const n = new Notification({
    title: 'Micara',
    body: `Mise à jour v${version} disponible — cliquez pour installer`,
  });
  n.on('click', () => installUpdateNow());
  n.show();
}

// appelée par la NOUVELLE instance après le remplacement du .app
function notifyUpdated() {
  if (!Notification.isSupported()) return;
  setTimeout(() => {
    new Notification({
      title: 'Micara',
      body: `Micara a été mis à jour vers la version ${app.getVersion()}`,
    }).show();
  }, 2500); // laisse l'app finir de démarrer (fenêtre/icône)
}

async function checkForUpdatesNow() {
  if (updateBusy || installingUpdate) return;
  updateBusy = true;
  try {
    // endpoint public → pas besoin du jeton (fonctionne avant le login)
    const api = createApi({ serverUrl: DEFAULT_SERVER });
    const info = await checkForUpdate({
      api,
      currentVersion: app.getVersion(),
      downloadDir: updatesDir(),
      onProgress: ({ latest, percent, bytes, total }) =>
        send('app:update', { update: true, latest, downloading: true, percent, bytes, total }),
    });
    if (info.download) {
      cleanupOldZips(info.download);
      if (notifiedUpdateVersion !== info.latest) {
        notifiedUpdateVersion = info.latest;
        notifyUpdateReady(info.latest);
      }
    }
    sendUpdateState(info);
  } catch (e) {
    console.error('[bridge] update:', e.message);
  } finally {
    updateBusy = false;
  }
}

function startUpdateCycle() {
  checkForUpdatesNow();
  updateTimer = setInterval(checkForUpdatesNow, UPDATE_CHECK_MS);
}

// Clic « Mettre à jour maintenant » (bouton fenêtre / item tray / notification) :
// l'app lance le script d'installation DÉTACHÉ puis se quitte. Le script
// dézippe, remplace le .app en place, xattr -cr, relance (open --args
// --micara-updated → notification « mis à jour » dans la nouvelle instance).
function installUpdateNow() {
  if (installingUpdate) return { ok: false, error: 'installation déjà en cours' };
  if (!updateInfo || !updateInfo.download) return { ok: false, error: 'aucune mise à jour téléchargée' };
  const zip = updateInfo.download;
  if (!fs.existsSync(zip)) return { ok: false, error: 'zip introuvable (téléchargement supprimé ?)' };
  if (process.platform !== 'darwin') return { ok: false, error: 'installation macOS uniquement' };
  const res = launchInstaller({ zipPath: zip, appPath: appBundlePath(process.execPath) });
  if (!res.ok) return res;
  installingUpdate = true;
  // petit délai : la réponse IPC part avant la fermeture (le renderer peut
  // afficher « installation… »), puis l'app se quitte — le script (détaché)
  // dort 2 s avant de remplacer le .app, le temps que tout se ferme.
  setTimeout(() => app.quit(), 300);
  return { ok: true };
}

// --- IPC -------------------------------------------------------------------

ipcMain.handle('app:state', () => ({
  authed: !!(store && store.loadToken()),
  serverUrl: DEFAULT_SERVER, // toujours app.getmicara.com (décision 22/08)
  version: app.getVersion(),
  bridgeStatus,
  spaceCode,
  updateInfo,
  sleeping: sleepState === 'sleep',
  micSetup: micSetupState(),
}));

ipcMain.handle('app:login', async (_ev, { email, password }) => {
  try {
    // Plus de champ serveur dans l'UI (décision 22/08) : le bridge se
    // connecte TOUJOURS à app.getmicara.com. L'ancienne URL stockée
    // (roommic.sslip.io) a été migrée silencieusement au démarrage.
    const res = await createApi({ serverUrl: DEFAULT_SERVER }).login(String(email || '').trim(), String(password || ''));
    store.save({ serverUrl: DEFAULT_SERVER });
    store.saveToken(res.token);
    startBridge(res.token, DEFAULT_SERVER);
    // démarrage automatique au login du Mac : réglé UNE FOIS à la connexion.
    // openAsHidden → l'app démarre sans fenêtre (elle vit en barre de menu).
    if (process.platform === 'darwin') {
      app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true });
    }
    hideToTray(); // login réussi → l'app disparaît, l'icône reste
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e.message };
  }
});

ipcMain.handle('app:logout', () => {
  stopBridge();
  store.saveToken(null); // oublie le jeton → prochain lancement = login
  if (process.platform === 'darwin') {
    app.setLoginItemSettings({ openAtLogin: false }); // plus d'auto-start
  }
  return { ok: true };
});

ipcMain.handle('app:open-dashboard', () => {
  shell.openExternal(DEFAULT_SERVER);
  return { ok: true };
});

// Micro par défaut : re-tente (bouton « Définir le micro par défaut » de la
// bannière) ou masque définitivement l'instruction (« Compris »).
ipcMain.handle('app:mic-apply', async () => {
  const res = await applyDefaultMic();
  store.save({
    micSetupDone: true,
    micSetupOk: !!res.ok,
    micSetupDevice: res.deviceName || null,
    micSetupError: res.error || (res.missing ? 'missing' : res.notfound ? 'notfound' : null),
  });
  send('app:mic-setup', micSetupState());
  return res;
});

ipcMain.handle('app:mic-dismiss', () => {
  store.save({ micNoticeDismissed: true });
  return { ok: true };
});

// Veille auto : le renderer rapporte son activité (nombre de téléphones +
// état de veille décidé par le MOTEUR — lui seul connaît le graphe audio).
// Ici on ne fait que refléter l'état dans le heartbeat (state:'sleep') et la
// barre de menu. Plus de téléphone → le renderer dort après IDLE_SLEEP_MS.
ipcMain.on('app:activity', (_ev, payload) => {
  const sleeping = !!(payload && payload.sleeping);
  if (sleeping === (sleepState === 'sleep')) return;
  sleepState = sleeping ? 'sleep' : 'active';
  updateTray();
  send('app:status', { status: bridgeStatus, spaceCode, sleeping: sleepState === 'sleep' });
  // heartbeat immédiat : le dashboard voit la bascule sans attendre le prochain tick
  if (api) api.heartbeat({ version: app.getVersion(), state: sleepState }).catch(() => {});
});

// Thème actif du serveur (design system multi-thèmes, lot J) : le renderer est
// chargé en file:// → un fetch relatif ne peut pas marcher et les CORS ne sont
// pas configurés sur le serveur. Le main (aucune restriction CORS) récupère
// GET {serverUrl}/api/theme et passe les tokens au renderer ; hors-ligne ou
// erreur → { ok:false } → le renderer retombe sur le thème par défaut (iOS).
ipcMain.handle('app:theme', async () => {
  const serverUrl = DEFAULT_SERVER; // toujours app.getmicara.com (décision 22/08)
  try {
    const res = await fetch(`${serverUrl}/api/theme`);
    if (!res.ok) return { ok: false };
    const data = await res.json();
    return { ok: true, tokens: (data && data.tokens) || {}, name: (data && data.name) || null };
  } catch (e) {
    console.error('[bridge] theme:', e.message);
    return { ok: false };
  }
});

ipcMain.handle('app:install-update', () => installUpdateNow());

// relais renderer → serveur (réponses WebRTC : answer, ice)
ipcMain.on('app:signal-send', (_ev, msg) => {
  if (client) client.send(msg);
});

// journalisation fichier (diagnostic à distance — le bridge est silencieux) :
// ~/Library/Application Support/micara-bridge/bridge.log, même contenu que
// l'UI + télémétrie du mixer. Lisible par Nicolas sur demande.
ipcMain.on('app:log-file', (_ev, line) => {
  try {
    const dir = app.getPath('userData');
    fs.mkdirSync(dir, { recursive: true });
    fs.appendFileSync(path.join(dir, 'bridge.log'), `${new Date().toISOString()} ${line}\n`);
  } catch { /* le journal ne doit jamais faire planter l'app */ }
});

// --- audio (sortie BlackHole / haut-parleurs) -------------------------------

ipcMain.handle('audio:devices', () => audio.listBlackHoleDevices());

ipcMain.on('audio:start', (ev, { deviceName, sampleRate }) => {
  try {
    audio.openOutput(deviceName, sampleRate);
    ev.sender.send('audio:status', `sortie audio ouverte → ${audio.DEFAULT_DEVICE} @ ${sampleRate} Hz`);
  } catch (e) {
    ev.sender.send('audio:status', `ERREUR audio : ${e.message}`);
  }
});

ipcMain.on('audio:chunk', (_ev, chunk) => audio.writeChunk(chunk));
ipcMain.on('audio:stop', () => audio.close());

// Fenêtre fermée ≠ app quittée : le bridge vit en barre de menu (veille auto).
// Quitter = menu de l'icône (ou Cmd+Q).
app.on('window-all-closed', () => { /* l'app continue (tray) */ });

app.on('before-quit', () => {
  audio.close();
  if (heartbeatTimer) clearInterval(heartbeatTimer);
});

// --- cycle de vie -----------------------------------------------------------

if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  app.on('second-instance', (_ev, argv) => {
    // cas limite : le script a relancé l'app alors qu'une instance vivait
    // encore (remplacement pendant un redémarrage) → notifier ici quand même
    if (argv.includes('--micara-updated')) notifyUpdated();
    if (argvHasDeepLink(argv)) handleDeepLink(argv.find((a) => String(a).startsWith(`${DEEP_LINK_SCHEME}://`)));
    else showWindow(); // relance de l'app → ramener la fenêtre existante
  });

  app.whenReady().then(async () => {
    // spike : auto-accorde les permissions média (micro) pour les labels devices
    session.defaultSession.setPermissionRequestHandler((_wc, _permission, cb) => cb(true));
    store = createConfigStore({ userDataDir: app.getPath('userData'), safeStorage });
    migrateServerUrl(); // ancienne URL roommic.sslip.io → app.getmicara.com (silencieux)
    bootstrapMicSetup(); // micro par défaut du système au 1er lancement (t_dd15f264)
    createTray();
    // auto-update TRANSPARENT : vérif au lancement + toutes les 1 h (t_2e77f88d)
    startUpdateCycle();
    // relancée par le script d'installation après remplacement du .app →
    // notifier « Micara a été mis à jour vers la version X »
    if (process.argv.includes('--micara-updated')) notifyUpdated();

    // device activation (t_ec4c7c04) : ticket « Lancer » reçu au lancement
    // (open-url avant ready) → consommé AVANT la création de la fenêtre →
    // jamais d'écran de login si le ticket est valide (zéro flash).
    if (pendingLink) {
      const t = pendingLink;
      pendingLink = null;
      await handleLinkTicket(t);
    }

    const token = store.loadToken();
    // jeton présent → fenêtre CACHÉE (app invisible, le renderer tourne pour
    // le WebRTC) ; pas de jeton → fenêtre VISIBLE (écran de login, 1re fois).
    // NB: !token (pas !!token) — bug corrigé 22/08: la 1re fois, la fenêtre
    // de login était créée cachée → « rien ne s'ouvre ».
    createWindow(!token || launchedViaDeepLink);
    if (!token) {
      // premier lancement : l'app doit se montrer pour le login
      if (process.platform === 'darwin' && app.dock && app.dock.show) app.dock.show();
    } else {
      hideToTray();
    }
  });
}
