/* Uleez — App shell: sidebar, header, nav config */
(function () {
  const { useState, useEffect } = React;
  const Icon = window.Icon;
  const { Avatar, IconButton } = window;

  // ── Theme (light/dark) ─────────────────────────────────────────────────────
  // The initial theme is applied by an inline script in index.html (avoids a flash).
  // Persisted in localStorage; toggled from the header.
  function getTheme() {
    return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
  }
  function applyTheme(theme) {
    const t = theme === 'dark' ? 'dark' : 'light';
    document.documentElement.setAttribute('data-theme', t);
    try { localStorage.setItem('kerya-theme', t); } catch (e) {}
  }

  // Header button that switches between light and dark themes.
  function ThemeToggle() {
    const [theme, setTheme] = useState(getTheme());
    const toggle = () => {
      const next = theme === 'dark' ? 'light' : 'dark';
      applyTheme(next);
      setTheme(next);
    };
    return React.createElement('button', {
      className: 'icon-btn', onClick: toggle,
      title: window.I18N ? I18N.t(theme === 'dark' ? 'shell.themeToLight' : 'shell.themeToDark')
        : (theme === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'),
      'aria-label': window.I18N ? I18N.t('shell.toggleTheme') : 'Changer de thème',
    }, React.createElement(Icon, { name: theme === 'dark' ? 'sun' : 'moon', size: 18 }));
  }
  window.ThemeToggle = ThemeToggle;

  const NAV_GROUPS = {
    // `labelKey` (quand elle existe) l'emporte sur `label` au rendu — voir `navLabel` plus bas.
    // NAV_GROUPS est une constante de module, évaluée une fois : y figer des chaînes traduites
    // les gèlerait à la langue du chargement de la page.
    //
    // Seul l'espace LOUEUR est traduit dans cette version ; le back-office admin reste en
    // français et garde donc ses `label` littéraux.
    lessor: [
      { label: null, items: [
        { id: 'dashboard', label: 'Tableau de bord', labelKey: 'nav.dashboard', icon: 'dashboard' },
        { id: 'bookings',  label: 'Réservations',   labelKey: 'nav.bookings', icon: 'clipboard', alertKey: 'pendingBookings' },
        { id: 'calendar',  label: 'Planning',        labelKey: 'nav.calendar', icon: 'calendar' },
        { id: 'vehicles',  label: 'Ma flotte',       labelKey: 'nav.fleet', icon: 'car' },
        { id: 'promotions', label: 'Promotions', labelKey: 'promo.nav', icon: 'gift' },
      ]},
      { label: 'Finance', items: [
        { id: 'payments', label: 'Paiements', labelKey: 'nav.payments', icon: 'wallet' },
        { id: 'subscription', label: 'Mon abonnement', labelKey: 'nav.subscription', icon: 'building' },
        { id: 'profile',  label: 'Mon profil', labelKey: 'nav.profile', icon: 'user' },
      ]},
    ],
    admin: [
      { label: null, items: [
        { id: 'dashboard', label: 'Tableau de bord', icon: 'dashboard' },
        { id: 'lessors',   label: 'Loueurs',         icon: 'building', alertKey: 'pendingLessors' },
        { id: 'clients',   label: 'Clients',         icon: 'user' },
        { id: 'vehicles',  label: 'Véhicules',       icon: 'car',      alertKey: 'pendingVehicles' },
        { id: 'bookings',  label: 'Réservations',    icon: 'clipboard' },
        { id: 'activity',  label: 'Activité globale', icon: 'banknote' },
        { id: 'billing',   label: 'Facturation',      icon: 'wallet' },
      ]},
      { label: 'Plateforme', items: [
        { id: 'applications', label: 'Candidatures', icon: 'clipboard' },
        { id: 'bugs', label: 'Signalements', icon: 'alert' },
        { id: 'vehicle-promos', label: 'Promotions', icon: 'gift' },
        { id: 'promos',   label: 'Codes promo', icon: 'gift' },
        { id: 'support',  label: 'Support',     icon: 'lifebuoy' },
        { id: 'settings', label: 'Paramètres',  icon: 'settings' },
      ]},
    ],
    // Restricted support agents: support desk only.
    support_agent: [
      { label: null, items: [
        { id: 'support', label: 'Support', icon: 'lifebuoy' },
        // Les agents support lisent AUSSI les signalements de bug : le backend les y autorise
        // (`@Roles('admin','support_agent')`), et c'est souvent eux qui reçoivent la plainte.
        { id: 'bugs', label: 'Signalements', icon: 'alert' },
      ]},
    ],
  };

  function getUserDisplayName(currentUser, role) {
    if (!currentUser) return role === 'admin' ? 'Administrateur' : (role === 'support_agent' ? 'Agent support' : 'Loueur');
    return currentUser.firstName
      ? `${currentUser.firstName} ${currentUser.lastName || ''}`.trim()
      : (currentUser.email || (window.I18N ? I18N.t('shell.user') : 'Utilisateur'));
  }

  function getUserEmail(currentUser) {
    return currentUser?.email || '';
  }

  function getUserColor(currentUser) {
    if (!currentUser) return 'av-slate';
    return KD.avColor(currentUser.firstName || currentUser.email || '');
  }

  function BrandMark() {
    return React.createElement('img', { src: 'icons/Icon-192.png', className: 'brand-mark', style: { objectFit: 'cover', background: 'transparent' } });
  }

  function Sidebar({ role, page, onNavigate, collapsed, open, onLogout, currentUser }) {
    // Repli sur l'identité : sans I18N chargé, `t(k)` rend la clé, ce qui reste lisible et
    // évite un `t` nul qu'il faudrait tester à chaque appel.
    const t = window.I18N ? I18N.useT() : ((k) => k);
    const groups = NAV_GROUPS[role] || NAV_GROUPS.lessor;
    // Une entrée sans `labelKey` (tout l'espace admin) garde son libellé littéral : le repli
    // est le français, pas une chaîne vide.
    const navLabel = (it) => (it.labelKey ? t(it.labelKey) : it.label);
    const displayName = getUserDisplayName(currentUser, role);
    const email = getUserEmail(currentUser);
    const color = getUserColor(currentUser);
    // Seul le libellé LOUEUR est traduit : l'espace admin reste en français (cf. plus haut).
    const brandRole = role === 'admin' ? 'Administration'
      : (role === 'support_agent' ? 'Support' : t('shell.lessorSpace'));
    const [lessorLogoUrl, setLessorLogoUrl] = useState(null);

    useEffect(() => {
      if (role === 'lessor') {
        API.lessor.getProfile().then(p => { if (p.logoUrl) setLessorLogoUrl(API.resolveUrl(p.logoUrl)); }).catch(() => {});
      }
    }, [role]);

    return React.createElement('aside', { className: `sidebar ${collapsed ? 'collapsed' : ''} ${open ? 'open' : ''}` },
      React.createElement('div', { className: 'side-brand' },
        React.createElement(BrandMark),
        React.createElement('div', { className: 'brand-meta' },
          React.createElement('span', { className: 'name' }, 'Ul', React.createElement('span', { style: { color: 'var(--brand-300)' } }, 'eez')),
          React.createElement('span', { className: 'role' }, brandRole))),

      React.createElement('div', { style: { flex: 1, overflowY: 'auto', overflowX: 'hidden' } },
        groups.map((g, gi) =>
          React.createElement('div', { key: gi, className: 'side-section' },
            g.label
              ? React.createElement('div', { className: 'side-section-label' }, g.label)
              : (gi > 0 ? React.createElement('div', { style: { height: 8 } }) : null),
            React.createElement('div', { className: 'nav' },
              g.items.map((it) =>
                React.createElement('button', {
                  key: it.id,
                  className: `nav-item ${page === it.id ? 'active' : ''}`,
                  onClick: () => onNavigate(it.id),
                  title: collapsed ? navLabel(it) : undefined,
                },
                  React.createElement('span', { className: 'ico' }, React.createElement(Icon, { name: it.icon, size: 20 })),
                  React.createElement('span', { className: 'label' }, navLabel(it)),
                )
              )
            )
          )
        )
      ),

      React.createElement('div', { className: 'side-foot' },
        React.createElement('div', { className: 'side-user' },
          React.createElement(Avatar, { name: displayName, color, size: 38, src: lessorLogoUrl || undefined, square: true }),
          React.createElement('div', { className: 'su-meta' },
            React.createElement('div', { className: 'n' }, displayName),
            React.createElement('div', { className: 'e' }, email)),
          React.createElement('button', { className: 'side-logout', onClick: onLogout, title: t('shell.logout') },
            React.createElement(Icon, { name: 'logout', size: 17 }))
        ),
        // Public legal pages. Real links (not hash routes) so they open outside the SPA's
        // auth gate and stay valid as the store-facing URLs.
        collapsed ? null : React.createElement('div', {
          style: { marginTop: 10, fontSize: 11.5, opacity: 0.75, display: 'flex', gap: 6, flexWrap: 'wrap' },
        },
          React.createElement('a', { href: '/conditions', target: '_blank', rel: 'noopener', style: { color: 'inherit' } }, t('shell.terms')),
          React.createElement('span', null, '·'),
          React.createElement('a', { href: '/confidentialite', target: '_blank', rel: 'noopener', style: { color: 'inherit' } }, t('shell.privacy')))
      )
    );
  }

  function Header({ title, sub, role, collapsed, onCollapse, onMenu, onRoleSwitch, onLogout, onNavigate, currentUser }) {
    const tHeader = window.I18N ? I18N.useT() : ((k) => k);
    const displayName = getUserDisplayName(currentUser, role);
    const color = getUserColor(currentUser);

    return React.createElement('header', { className: 'header' },
      React.createElement('button', { className: 'collapse-btn', onClick: onCollapse, title: tHeader('shell.collapseMenu') },
        React.createElement(Icon, { name: 'panelLeft', size: 18 })),
      React.createElement('button', { className: 'menu-btn', onClick: onMenu },
        React.createElement(Icon, { name: 'menu', size: 20 })),

      React.createElement('div', { className: 'page-title' },
        React.createElement('h1', null, title),
        sub ? React.createElement('span', { className: 'sub' }, sub) : null),

      React.createElement('div', { className: 'spacer' }),

      // Role switch (only shown to admin for quick preview)
      role === 'admin'
        ? React.createElement('div', { className: 'role-switch', title: 'Aperçu prototype' },
            React.createElement('button', { className: role === 'lessor' ? 'on' : '', onClick: () => onRoleSwitch('lessor') },
              React.createElement(Icon, { name: 'building', size: 15 }), 'Loueur'),
            React.createElement('button', { className: role === 'admin' ? 'on' : '', onClick: () => onRoleSwitch('admin') },
              React.createElement(Icon, { name: 'shield', size: 15 }), 'Admin'))
        : null,

      React.createElement('div', { className: 'header-search' },
        React.createElement(Icon, { name: 'search', size: 17 }),
        React.createElement('input', { placeholder: tHeader('shell.search') })),

      React.createElement(ThemeToggle),

      React.createElement(NotificationBell, { onNavigate }),

      React.createElement(AccountMenu, { role, displayName, color, onNavigate, onLogout }),
    );
  }

  // Bloc agence en haut à droite : affichait déjà nom + rôle + chevron, mais `onClick: () => {}`
  // — un no-op, aucune action derrière une apparence pourtant cliquable (hover, chevron). Même
  // structure que NotificationBell juste au-dessus (état `open` + panneau ancré + fond plein
  // écran qui ferme au clic à l'extérieur).
  function AccountMenu({ role, displayName, color, onNavigate, onLogout }) {
    const tMenu = window.I18N ? I18N.useT() : ((k) => k);
    const [open, setOpen] = useState(false);
    const go = (page) => { setOpen(false); onNavigate(page, {}); };
    const logout = () => { setOpen(false); onLogout(); };

    const panelStyle = {
      position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 220,
      background: 'var(--card)', border: '1px solid var(--line)', borderRadius: 14,
      boxShadow: '0 12px 32px rgba(0,0,0,.14)', zIndex: 50, overflow: 'hidden', padding: '6px 0',
    };
    const itemStyle = {
      display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left',
      border: 0, background: 'none', cursor: 'pointer', padding: '10px 14px',
      fontSize: 13.5, color: 'var(--ink, #111)',
    };

    return React.createElement('div', { style: { position: 'relative' } },
      React.createElement('button', { className: 'header-avatar', onClick: () => setOpen((o) => !o) },
        React.createElement(Avatar, { name: displayName, color, size: 32 }),
        React.createElement('div', { className: 'ha-meta' },
          React.createElement('div', { className: 'n' }, displayName),
          React.createElement('div', { className: 'r' }, role === 'admin' ? 'Administrateur' : (role === 'support_agent' ? 'Agent support' : tMenu('shell.roleLessor')))),
        React.createElement(Icon, { name: 'chevDown', size: 15, style: { color: 'var(--slate-400)' } })),
      open
        ? React.createElement(React.Fragment, null,
            React.createElement('div', { onClick: () => setOpen(false), style: { position: 'fixed', inset: 0, zIndex: 40 } }),
            React.createElement('div', { style: panelStyle },
              role === 'lessor'
                ? React.createElement(React.Fragment, null,
                    React.createElement('button', { style: itemStyle, onClick: () => go('profile') },
                      React.createElement(Icon, { name: 'user', size: 16 }), tMenu('shell.myProfile')),
                    React.createElement('button', { style: itemStyle, onClick: () => go('subscription') },
                      React.createElement(Icon, { name: 'building', size: 16 }), tMenu('shell.mySubscription')))
                : null,
              role === 'admin'
                ? React.createElement('button', { style: itemStyle, onClick: () => go('settings') },
                    React.createElement(Icon, { name: 'settings', size: 16 }), tMenu('shell.settings'))
                : null,
              React.createElement('button', { style: { ...itemStyle, color: 'var(--rose-500, #ef4444)' }, onClick: logout },
                React.createElement(Icon, { name: 'logout', size: 16 }), tMenu('shell.logout')))
          )
        : null);
  }

  // Fonction pure, hors composant : elle lit la langue au moment de l'appel. C'est suffisant
  // ici, car elle n'est appelée que pendant le rendu de la cloche, elle-même abonnée aux
  // changements de langue via `useT`.
  function notifTimeAgo(iso) {
    if (!iso) return '';
    const tr = window.I18N ? I18N.t : ((k, p) => k);
    const s = Math.round((Date.now() - new Date(iso)) / 1000);
    if (s < 60) return tr('notif.justNow');
    const m = Math.round(s / 60); if (m < 60) return tr('notif.minutesAgo', { n: m });
    const h = Math.round(m / 60); if (h < 24) return tr('notif.hoursAgo', { n: h });
    const d = Math.round(h / 24); return tr('notif.daysAgo', { n: d });
  }

  // Functional notification bell: unread badge, live feed (socket), contextual navigation.
  function NotificationBell({ onNavigate }) {
    const tBell = window.I18N ? I18N.useT() : ((k) => k);
    const [open, setOpen] = useState(false);
    const [items, setItems] = useState([]);
    const [unread, setUnread] = useState(0);
    const [loading, setLoading] = useState(false);

    const load = () => {
      setLoading(true);
      API.notifications.list(1, 20)
        .then((res) => setItems(Array.isArray(res) ? res : (res.items || [])))
        .catch(() => {})
        .finally(() => setLoading(false));
    };

    // Re-read the authoritative count from the API. The socket delivers deltas, and deltas
    // are lost while disconnected — so anything that means "we may have missed some" ends here.
    const syncUnread = () => {
      API.notifications.unreadCount().then((r) => setUnread(r.count || 0)).catch(() => {});
    };

    useEffect(() => {
      syncUnread();
      API.Realtime.connect();
      // Live updates: prepend new notifications and bump the badge.
      const unsub = API.Realtime.on('notification:new', (n) => {
        setItems((prev) => [n, ...prev.filter((x) => x.id !== n.id)].slice(0, 40));
        setUnread((u) => u + 1);
      });

      // THREE recovery paths, because "the bell only updates after I reload the page" had
      // nothing to do with the server (it always emits `notification:new`) and everything to
      // do with a socket that had quietly died:
      //  1. after every (re)connect, re-read the count — the events we missed are not replayed;
      const unsubReconnect = API.Realtime.onReconnect(() => {
        syncUnread();
        setOpen((isOpen) => { if (isOpen) load(); return isOpen; });
      });
      //  2. when the tab becomes visible again (background tabs get throttled/suspended);
      const onVisible = () => { if (!document.hidden) { API.Realtime.connect(); syncUnread(); } };
      document.addEventListener('visibilitychange', onVisible);
      //  3. a slow safety poll while the tab is visible, so even a wedged socket cannot make
      //     the badge permanently wrong. 60 s is cheap (one tiny GET) and invisible to the user.
      const poll = setInterval(() => { if (!document.hidden) syncUnread(); }, 60000);

      return () => {
        unsub();
        unsubReconnect();
        document.removeEventListener('visibilitychange', onVisible);
        clearInterval(poll);
      };
    }, []);

    const toggle = () => { const next = !open; setOpen(next); if (next) load(); };

    const openItem = (n) => {
      if (!n.readAt) {
        API.notifications.markRead(n.id).catch(() => {});
        setUnread((u) => Math.max(0, u - 1));
        setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, readAt: new Date().toISOString() } : x)));
      }
      setOpen(false);
      if (!onNavigate) return;

      // Route to where the action actually is.
      //
      // This used to be a single line mapping every `screen: 'conversation'` to the messages
      // page and everything else with a bookingId to the bookings LIST — and the params were
      // dropped on the floor, so the messages page always opened its first conversation. Result:
      // every notification felt like it went to the same place, and kyc/loyalty ones did
      // nothing at all.
      //
      // ORDER MATTERS, and emergencies come FIRST.
      //
      // SOS tickets carry a bookingId like any booking conversation, so testing bookingId
      // first — as this did — sent every emergency to the booking detail page. That page
      // loads the ORDINARY thread (the server excludes category 'sos' from
      // getTicketByBooking), so the emergency conversation was unreachable from the bell
      // entirely. The server now stamps `category` and keeps `screen: 'sos'` for the whole
      // life of an SOS thread; both are checked here.
      //
      // Then a booking id wins: BookingDetail already embeds that booking's conversation, so
      // a message about a rental opens the thread AND its context in one go.
      const d = n.data || {};
      const isUuid = (v) => typeof v === 'string' && /^[0-9a-f-]{36}$/i.test(v);

      if (d.screen === 'sos' || d.category === 'sos') {
        onNavigate('assistance', isUuid(d.ticketId) ? { ticketId: d.ticketId } : {});
        return;
      }
      if (isUuid(d.bookingId)) {
        onNavigate('booking-detail', { id: d.bookingId });
        return;
      }
      if (d.screen === 'conversation' && isUuid(d.ticketId)) {
        onNavigate('assistance', { ticketId: d.ticketId });
        return;
      }
      if (d.screen === 'conversation') { onNavigate('assistance', {}); return; }
      if (d.screen === 'kyc') { onNavigate('profile', {}); return; }
      onNavigate('bookings', {});
    };

    const markAll = () => {
      API.notifications.markAllRead().catch(() => {});
      setUnread(0);
      setItems((prev) => prev.map((x) => ({ ...x, readAt: x.readAt || new Date().toISOString() })));
    };

    const panelStyle = {
      position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 340, maxWidth: '90vw',
      background: 'var(--card)', border: '1px solid var(--line)', borderRadius: 14,
      boxShadow: '0 12px 32px rgba(0,0,0,.14)', zIndex: 50, overflow: 'hidden',
    };

    return React.createElement('div', { style: { position: 'relative' } },
      React.createElement('button', { className: 'icon-btn', onClick: toggle, 'aria-label': tBell('notif.title'), style: { position: 'relative' } },
        React.createElement(Icon, { name: 'bell', size: 19 }),
        unread > 0
          ? React.createElement('span', {
              style: {
                position: 'absolute', top: 2, right: 2, minWidth: 16, height: 16, padding: '0 4px',
                borderRadius: 8, background: 'var(--rose-500, #ef4444)', color: '#fff', fontSize: 10,
                fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
              },
            }, unread > 9 ? '9+' : String(unread))
          : null),
      open
        ? React.createElement(React.Fragment, null,
            React.createElement('div', { onClick: () => setOpen(false), style: { position: 'fixed', inset: 0, zIndex: 40 } }),
            React.createElement('div', { style: panelStyle },
              React.createElement('div', {
                style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderBottom: '1px solid var(--line)' },
              },
                React.createElement('strong', null, tBell('notif.title')),
                unread > 0
                  ? React.createElement('button', { onClick: markAll, style: { border: 0, background: 'none', color: 'var(--brand, #4f46e5)', fontSize: 12, cursor: 'pointer', fontWeight: 600 } }, tBell('notif.markAllRead'))
                  : null),
              React.createElement('div', { style: { maxHeight: 380, overflowY: 'auto' } },
                loading
                  ? React.createElement('div', { style: { padding: 18, textAlign: 'center', color: 'var(--slate-400)', fontSize: 13 } }, tBell('notif.loading'))
                  : items.length === 0
                    ? React.createElement('div', { style: { padding: 24, textAlign: 'center', color: 'var(--slate-400)', fontSize: 13 } }, tBell('notif.empty'))
                    : items.map((n) => React.createElement('button', {
                        key: n.id, onClick: () => openItem(n),
                        style: {
                          display: 'block', width: '100%', textAlign: 'left', border: 0, cursor: 'pointer',
                          padding: '11px 14px', borderBottom: '1px solid var(--line)',
                          background: n.readAt ? 'transparent' : 'var(--brand-50, #eef2ff)',
                        },
                      },
                      React.createElement('div', { style: { fontWeight: 600, fontSize: 13.5, color: 'var(--ink, #111)' } }, n.title),
                      n.body ? React.createElement('div', { style: { fontSize: 12.5, color: 'var(--slate-500, #64748b)', marginTop: 2 } }, n.body) : null,
                      React.createElement('div', { style: { fontSize: 11, color: 'var(--slate-400, #94a3b8)', marginTop: 4 } }, notifTimeAgo(n.createdAt))
                    ))
              )
            )
          )
        : null);
  }

  // Downsample a decoded AudioBuffer into `count` normalized peak heights [0..1] for a waveform.
  function computePeaks(audioBuffer, count) {
    const data = audioBuffer.getChannelData(0);
    const block = Math.floor(data.length / count) || 1;
    const peaks = [];
    let max = 0.0001;
    for (let i = 0; i < count; i++) {
      let sum = 0;
      const start = i * block;
      for (let j = 0; j < block; j++) sum += Math.abs(data[start + j] || 0);
      const v = sum / block;
      peaks.push(v);
      if (v > max) max = v;
    }
    return peaks.map((v) => Math.min(1, v / max));
  }
  const FALLBACK_PEAKS = Array.from({ length: 40 }, (_, i) => 0.35 + 0.5 * Math.abs(Math.sin(i * 0.7)));

  // ONE AudioContext for the whole page.
  //
  // This is the "voice notes have no sound" bug: VoicePlayer used to do `new AudioContext()`
  // per message, at mount, for every voice note in the thread. Browsers cap concurrent
  // contexts per document (~6 on Chrome); past that the constructor throws, and the empty
  // `.catch(() => {})` swallowed it — the player rendered normally (fallback waveform, 0:00)
  // with a permanently disabled play button. So it worked on short threads and stopped
  // working as voice notes piled up.
  let sharedCtx = null;
  function getAudioContext() {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return null;
    if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AC();
    return sharedCtx;
  }
  window.getSharedAudioContext = getAudioContext;

  // WhatsApp-style voice player.
  //
  // PLAYBACK GOES THROUGH AN <audio> ELEMENT, deliberately — it used to go through a Web
  // Audio AudioBufferSourceNode, and that was the second "voice notes have no sound" bug:
  // `getAudioContext()` was only reached INSIDE `load()`, i.e. after two awaits (the
  // authenticated fetch, then `blob.arrayBuffer()`), so the click's user activation had
  // already expired. `playFrom` then did `await ctx.resume()` on a context the browser was
  // still blocking — a promise that never settles — so `src.start()` was never reached.
  // And because the context is shared page-wide, the first voice note to leave it suspended
  // killed every other one: "it played once, then nothing ever worked again".
  //
  // A media element has none of that failure mode: a user-initiated play() either plays or
  // REJECTS (so we can surface it), and the browser's own pipeline decodes AAC/m4a, which
  // `decodeAudioData` does not handle everywhere (Firefox in particular).
  //
  // Web Audio is still used, but ONLY to compute the waveform — off the critical path, with
  // a fallback shape if it fails. It never calls resume(), because decodeAudioData works
  // perfectly well on a suspended context.
  //
  // This requires `media-src 'self' blob:` in `_headers`; without it the CSP falls back to
  // default-src 'self' and blocks the blob: source.

  // Decode a blob into waveform peaks. Best effort — a failure just means generic bars.
  async function peaksFromBlob(blob, count) {
    const ctx = getAudioContext();
    if (!ctx) return null;
    const ab = await blob.arrayBuffer();
    const decoded = await ctx.decodeAudioData(ab.slice(0));
    return { peaks: computePeaks(decoded, count), duration: decoded.duration || 0 };
  }

  function VoicePlayer({ downloadPath, ticketId }) {
    const [dur, setDur] = useState(0);
    const [peaks, setPeaks] = useState(null);
    const [loading, setLoading] = useState(false);
    const [failed, setFailed] = useState(false);
    const [playing, setPlaying] = useState(false);
    const [pos, setPos] = useState(0);
    const elRef = React.useRef(null);
    const urlRef = React.useRef(null);
    const loadedRef = React.useRef(false);
    const cancelledRef = React.useRef(false);

    useEffect(() => {
      cancelledRef.current = false;
      return () => {
        cancelledRef.current = true;
        const el = elRef.current;
        if (el) { try { el.pause(); el.removeAttribute('src'); el.load(); } catch (e) {} }
        // Object URLs are a document-lifetime leak until revoked.
        if (urlRef.current) { try { URL.revokeObjectURL(urlRef.current); } catch (e) {} urlRef.current = null; }
        loadedRef.current = false;
        // NOTE: never close the shared AudioContext here — every player on the page uses it.
      };
    }, [downloadPath]);

    // Fetch once (authenticated), hand the blob to the media element, and kick off the
    // waveform decode separately so it can never hold playback back.
    const load = async () => {
      if (loadedRef.current) return true;
      setLoading(true);
      setFailed(false);
      try {
        const blob = await API.support.attachmentBlob(downloadPath, ticketId);
        if (cancelledRef.current) return false;
        const el = elRef.current;
        if (!el) throw new Error(window.I18N ? I18N.t('voice.unavailable') : 'Lecteur indisponible');
        const url = URL.createObjectURL(blob);
        urlRef.current = url;
        el.src = url;
        loadedRef.current = true;
        peaksFromBlob(blob, 40).then((res) => {
          if (cancelledRef.current || !res) return;
          setPeaks(res.peaks);
          // Only trust it if the element could not report one itself.
          setDur((d) => (d > 0 ? d : res.duration));
        }).catch(() => { /* generic bars; playback is unaffected */ });
        return true;
      } catch (e) {
        // Surfaced, not swallowed: a 401, a decode failure and a genuinely silent recording
        // used to be indistinguishable — all of them "the button does nothing".
        if (!cancelledRef.current) setFailed(true);
        return false;
      } finally {
        if (!cancelledRef.current) setLoading(false);
      }
    };

    const toggle = async () => {
      const el = elRef.current;
      if (!el) return;
      if (playing) { el.pause(); return; }
      if (!loadedRef.current) {
        const ok = await load();
        if (!ok) return;
      }
      try {
        await el.play();
        setFailed(false);
      } catch (e) {
        // Rejects (rather than hanging) when the browser refuses — so it stays retryable.
        setFailed(true);
      }
    };

    const seek = (frac) => {
      const el = elRef.current;
      if (!el || !loadedRef.current || !dur) return;
      const target = Math.max(0, Math.min(0.999, frac)) * dur;
      try { el.currentTime = target; setPos(target); } catch (e) {}
    };

    const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
    const frac = dur ? Math.min(1, pos / dur) : 0;
    const bars = peaks || FALLBACK_PEAKS;

    return React.createElement('div', { className: 'voice-player' + (failed ? ' failed' : '') },
      React.createElement('audio', {
        ref: elRef,
        preload: 'none',
        // Kept in the layout but invisible: `display:none` media is refused by some
        // versions of Safari, and a zero-sized element is the portable way to hide it.
        style: { position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' },
        onPlay: () => setPlaying(true),
        onPause: () => setPlaying(false),
        onEnded: () => { setPlaying(false); setPos(0); },
        onTimeUpdate: (ev) => setPos(ev.currentTarget.currentTime || 0),
        onLoadedMetadata: (ev) => {
          const d = ev.currentTarget.duration;
          if (isFinite(d) && d > 0) setDur(d);
        },
        onError: () => { setPlaying(false); setFailed(true); },
      }),
      // Never disabled any more: the button IS the load trigger, and a failure has to remain
      // retryable instead of leaving a dead control on screen.
      React.createElement('button', {
        className: 'voice-play', onClick: toggle, disabled: loading, type: 'button',
        title: window.I18N
          ? I18N.t(failed ? 'voice.playFailed' : (playing ? 'voice.pause' : 'voice.play'))
          : (failed ? 'Lecture impossible — cliquez pour réessayer' : (playing ? 'Pause' : 'Écouter')),
      }, React.createElement(Icon, { name: loading ? 'clock' : (failed ? 'refresh' : (playing ? 'pause' : 'play')), size: 15 })),
      React.createElement('div', {
        className: 'voice-wave',
        onClick: (ev) => { const r = ev.currentTarget.getBoundingClientRect(); seek((ev.clientX - r.left) / r.width); },
      }, bars.map((h, i) => React.createElement('span', {
        key: i, className: 'voice-bar' + (((i + 0.5) / bars.length) <= frac ? ' on' : ''),
        style: { height: Math.max(10, Math.round(h * 100)) + '%' },
      }))),
      React.createElement('span', { className: 'voice-time' }, fmt(playing || pos ? pos : dur)));
  }
  window.VoicePlayer = VoicePlayer;

  // Renders a chat attachment: image inline, voice message via VoicePlayer, or a download
  // button for PDFs. The raw file URL is never exposed — everything goes through the
  // authenticated /support/attachments/:id endpoint.
  // Resolve an attachment's bytes into an object URL, and revoke it on unmount.
  //
  // Extracted so the revoke discipline lives in ONE place: the album mosaic and the lightbox
  // each hold several of these at once, and a leaked object URL pins the decrypted photo in
  // memory for the life of the tab.
  function useAttachmentUrl(attachment, enabled = true) {
    const [blobUrl, setBlobUrl] = useState(null);
    const path = attachment && attachment.downloadPath;
    useEffect(() => {
      setBlobUrl(null);
      if (!enabled || !attachment || !attachment.isImage) return;
      let url = null; let cancelled = false;
      // attachment.ticketId is stamped by E2E.decryptIncoming: the bytes are sealed under
      // the CONVERSATION key, and the download path alone does not say which one.
      API.support.attachmentObjectUrl(attachment.downloadPath, attachment.ticketId)
        .then((u) => { if (cancelled) { URL.revokeObjectURL(u); } else { url = u; setBlobUrl(u); } })
        .catch(() => {});
      return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
    }, [path, enabled]);
    return blobUrl;
  }
  window.useAttachmentUrl = useAttachmentUrl;

  function ChatAttachment({ attachment }) {
    const isAudio = !!(attachment && (attachment.isAudio || (attachment.mime || '').indexOf('audio/') === 0));
    const blobUrl = useAttachmentUrl(attachment);

    if (!attachment) return null;

    if (attachment.isImage) {
      return React.createElement('div', { style: { marginTop: 6 } },
        blobUrl
          ? React.createElement('img', {
              src: blobUrl, alt: attachment.name || 'image',
              title: window.I18N ? I18N.t('media.download') : 'Télécharger',
              onClick: () => API.support.downloadAttachment(attachment.downloadPath, attachment.name, attachment.ticketId),
              style: { maxWidth: 220, maxHeight: 220, borderRadius: 10, cursor: 'pointer', display: 'block' },
            })
          : React.createElement('div', { style: { width: 180, height: 120, borderRadius: 10, background: 'var(--slate-100)' } }));
    }

    if (isAudio) {
      return React.createElement('div', { style: { marginTop: 6 } },
        React.createElement(VoicePlayer, { downloadPath: attachment.downloadPath, ticketId: attachment.ticketId }));
    }

    return React.createElement('button', {
      onClick: () => API.support.downloadAttachment(attachment.downloadPath, attachment.name, attachment.ticketId),
      style: {
        marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
        borderRadius: 10, border: '1px solid var(--line)', background: 'var(--surface)', color: 'var(--ink)', cursor: 'pointer', maxWidth: 240,
      },
    },
      React.createElement(Icon, { name: 'clipboard', size: 18 }),
      React.createElement('span', { style: { fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, attachment.name || 'Document'),
      React.createElement(Icon, { name: 'download', size: 15 }));
  }

  // ── Albums: several photos in ONE message ────────────────────────────────
  // The server stores an album as one row per photo sharing an attachment.groupId, and
  // `groupAlbums` (ui.jsx) merges those rows into a single bubble carrying `attachments[]`.
  // The layout rules below mirror AlbumView on mobile — a mosaic that splits differently on
  // the two clients is worse than no mosaic at all.

  function AlbumTile({ attachment, onOpen, overlay, style }) {
    const blobUrl = useAttachmentUrl(attachment);
    return React.createElement('div', {
      onClick: onOpen,
      style: Object.assign({ position: 'relative', cursor: 'pointer', overflow: 'hidden', background: 'var(--slate-100)' }, style),
    },
      blobUrl
        ? React.createElement('img', {
            src: blobUrl, alt: attachment.name || 'photo',
            style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
          })
        : null,
      overlay
        ? React.createElement('div', {
            style: {
              position: 'absolute', inset: 0, background: 'rgba(0,0,0,.45)', color: '#fff',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              fontSize: 20, fontWeight: 800,
            },
          }, overlay)
        : null);
  }

  function ChatAlbum({ attachments }) {
    const [lightbox, setLightbox] = useState(-1);
    const list = attachments || [];
    const n = list.length;
    if (n === 0) return null;
    // `groupCount` is the server's hint: it lets the "+N" be correct before every row of the
    // album has arrived over the socket.
    const total = Math.max(n, (list[0] && list[0].groupCount) || 0);
    const W = 240, GAP = 2;
    const open = (i) => setLightbox(i);

    let grid;
    if (n === 2) {
      const t = (W - GAP) / 2;
      grid = [
        React.createElement(AlbumTile, { key: 0, attachment: list[0], onOpen: () => open(0), style: { width: t, height: 160 } }),
        React.createElement(AlbumTile, { key: 1, attachment: list[1], onOpen: () => open(1), style: { width: t, height: 160 } }),
      ];
      grid = React.createElement('div', { style: { display: 'flex', gap: GAP } }, grid);
    } else if (n === 3) {
      const big = (W - GAP) * 2 / 3, small = (W - GAP) / 3, smallH = (W - GAP) / 2;
      grid = React.createElement('div', { style: { display: 'flex', gap: GAP } },
        React.createElement(AlbumTile, { key: 0, attachment: list[0], onOpen: () => open(0), style: { width: big, height: W } }),
        React.createElement('div', { key: 'col', style: { display: 'flex', flexDirection: 'column', gap: GAP } },
          React.createElement(AlbumTile, { key: 1, attachment: list[1], onOpen: () => open(1), style: { width: small, height: smallH } }),
          React.createElement(AlbumTile, { key: 2, attachment: list[2], onOpen: () => open(2), style: { width: small, height: smallH } })));
    } else {
      const t = (W - GAP) / 2;
      grid = React.createElement('div', {
        style: { display: 'grid', gridTemplateColumns: `${t}px ${t}px`, gap: GAP },
      }, [0, 1, 2, 3].map((i) => React.createElement(AlbumTile, {
        key: i, attachment: list[i], onOpen: () => open(i),
        style: { width: t, height: t },
        // The fourth visible tile carries the "+N"; the click still opens the lightbox
        // there, from which the user pages through the rest.
        overlay: i === 3 && total > 4 ? `+${total - 4}` : null,
      })));
    }

    return React.createElement(React.Fragment, null,
      React.createElement('div', { style: { marginTop: 6, borderRadius: 10, overflow: 'hidden', width: 'fit-content' } }, grid),
      lightbox >= 0
        ? React.createElement(ChatLightbox, {
            attachments: list, index: lightbox,
            onIndex: setLightbox, onClose: () => setLightbox(-1),
          })
        : null);
  }

  // Full-screen photo viewer. Hand-rolled (~80 lines) because there is no build step here
  // and pulling a lightbox library would mean a fourth external script under CSP + SRI.
  function ChatLightbox({ attachments, index, onIndex, onClose }) {
    const total = attachments.length;
    const current = attachments[index];
    const blobUrl = useAttachmentUrl(current);

    useEffect(() => {
      const onKey = (e) => {
        if (e.key === 'Escape') onClose();
        else if (e.key === 'ArrowRight' && index < total - 1) onIndex(index + 1);
        else if (e.key === 'ArrowLeft' && index > 0) onIndex(index - 1);
      };
      window.addEventListener('keydown', onKey);
      // The page behind must not scroll while the overlay is up.
      const prev = document.body.style.overflow;
      document.body.style.overflow = 'hidden';
      return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
    }, [index, total]);

    const navBtn = (dir, label) => React.createElement('button', {
      onClick: (e) => { e.stopPropagation(); onIndex(index + dir); },
      title: label,
      style: {
        position: 'absolute', top: '50%', transform: 'translateY(-50%)',
        [dir < 0 ? 'left' : 'right']: 16, width: 44, height: 44, borderRadius: '50%',
        border: 0, background: 'rgba(0,0,0,.5)', color: '#fff', cursor: 'pointer', fontSize: 22,
      },
    }, dir < 0 ? '‹' : '›');

    return ReactDOM.createPortal(
      React.createElement('div', {
        onClick: onClose,
        style: {
          position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,.92)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        },
      },
        blobUrl
          ? React.createElement('img', {
              src: blobUrl, alt: current.name || 'photo',
              onClick: (e) => e.stopPropagation(),
              style: { maxWidth: '92vw', maxHeight: '86vh', objectFit: 'contain', borderRadius: 6 },
            })
          : React.createElement('div', { style: { color: '#fff', fontSize: 13 } }, 'Chargement…'),
        index > 0 ? navBtn(-1, window.I18N ? I18N.t('media.previousPhoto') : 'Photo précédente') : null,
        index < total - 1 ? navBtn(1, window.I18N ? I18N.t('media.nextPhoto') : 'Photo suivante') : null,
        React.createElement('div', {
          style: { position: 'absolute', top: 16, left: 0, right: 0, textAlign: 'center', color: '#fff', fontSize: 13, fontWeight: 600 },
        }, `${index + 1} / ${total}`),
        React.createElement('button', {
          onClick: (e) => { e.stopPropagation(); API.support.downloadAttachment(current.downloadPath, current.name, current.ticketId); },
          title: window.I18N ? I18N.t('media.download') : 'Télécharger',
          style: {
            position: 'absolute', top: 12, right: 60, width: 40, height: 40, borderRadius: '50%',
            border: 0, background: 'rgba(0,0,0,.5)', color: '#fff', cursor: 'pointer',
          },
        }, React.createElement(Icon, { name: 'download', size: 17 })),
        React.createElement('button', {
          onClick: onClose, title: window.I18N ? I18N.t('media.close') : 'Fermer (Échap)',
          style: {
            position: 'absolute', top: 12, right: 12, width: 40, height: 40, borderRadius: '50%',
            border: 0, background: 'rgba(0,0,0,.5)', color: '#fff', cursor: 'pointer',
          },
        }, React.createElement(Icon, { name: 'x', size: 17 }))),
      document.body);
  }

  window.Sidebar = Sidebar;
  window.Header = Header;
  window.NotificationBell = NotificationBell;
  window.ChatAttachment = ChatAttachment;
  window.ChatAlbum = ChatAlbum;
  window.ChatLightbox = ChatLightbox;
})();
