/* Sidebar — ported from prototype dashboard-parts.jsx.
   Nav items match the Heron platform spec. Items without real
   routes yet show "Coming soon" placeholders until they're built
   in subsequent migration groups. */

/* Per-role nav allow-lists — direct port of the original CMS
   reorderSidebar() function (cms-original L13087–13136). Each role
   gets an explicit list of which nav ids appear, in which section.
   This replaces the previous capability-gate scheme because the
   original was role-list-based, not capability-based, and the two
   models produced different results for site_manager (who should NOT
   see audit) and aquaos_admin (who should NOT see operational org
   surfaces at the top level).
   marketing_admin + designer were never wired in the original CMS;
   we give them sensible defaults based on their authorize() coverage
   in the backend and flag them in the comments below. */
const ROLE_NAV = {
  /* Bug feedback batch (May 2026):
     - 'settings' removed from sidebar — only the topbar gear popup
       (b8c85386).
     - 'templates' removed entirely — feature parked (a0a149cc).
     - 'notifications' removed from sidebar — bell popover in the
       topbar already covers it (30480d6a).
     - 'bulk' + 'audit' moved into Settings popup as tabs (30480d6a).
     Platform admin gets 'users' (ca16fd3a). */
  aquaos_admin: {
    top:      ['dashboard'],
    /* video-backgrounds — TEMP dev surface, aquaos_admin only.
       Remove this id from BOTH this allow-list and NAV_PLATFORM
       below when the surface is productised or retired. */
    platform: ['orgs', 'global-species', 'users', 'analytics', 'video-backgrounds'],
    insights: ['alerts', 'dashboards'],
    tools:    [],
  },
  org_admin: {
    /* Per Oli: org_admin doesn't need Displays/Zones in the top nav
       (those are site_manager day-to-day surfaces; org_admin reaches
       them via dashboard cards or sites detail). Billing moves out of
       the platform section and lives inside Settings instead.
       Analytics is critical for org_admin — they oversee every site in
       the org, so a cross-site rollup belongs in their nav. The
       backend auto-scopes /api/analytics by organisation_id so the
       same page renders different data per role. Alerts (Phase 2)
       added — org_admin can manage alert rules across the whole org. */
    top:      ['dashboard', 'campaigns', 'library'],
    platform: ['sites', 'analytics', 'users'],
    insights: ['approvals', 'alerts', 'dashboards'],
    tools:    ['media'],
  },
  site_manager: {
    /* 'zones' removed — merged into the Displays page per Option A. */
    top:      ['dashboard', 'campaigns', 'library', 'displays'],
    /* Analytics added for site_manager — backend scopes to their
       assigned sites via siteScope() so the cross-site widgets get
       hidden naturally. Page-level cap check uses analytics.view_site.
       Alerts: site_manager owns analytics.manage_alerts so they need
       the nav entry to author + ack rules. */
    platform: ['users', 'analytics'],
    insights: ['approvals', 'alerts', 'dashboards'],
    tools:    ['media'],
  },
  marketing_admin: {
    /* Cross-site campaigns + assets. Backend authorize includes them
       on /api/campaigns, /api/assets, /api/approvals. Analytics
       added — campaign-centric view appropriate for marketing leads.
       Alerts: marketing_admin has view_alerts (read-only) — they want
       to see when campaigns underperform but don't author rules. */
    top:      ['dashboard', 'campaigns', 'library'],
    platform: ['analytics'],
    insights: ['approvals', 'alerts', 'dashboards'],
    tools:    ['media'],
  },
  designer: {
    /* Asset upload + AI copy gen — narrow scope. */
    top:      ['campaigns', 'library'],
    platform: [],
    insights: [],
    tools:    ['media'],
  },
  operator: {
    /* Original L13130-13134 — Screens + Species library only. */
    top:      ['displays', 'library'],
    platform: [],
    insights: [],
    tools:    [],
  },
  /* Legacy 'admin' role from the pre-multi-tenant era — kept in
     lockstep with org_admin. */
  admin: {
    top:      ['dashboard', 'campaigns', 'library'],
    platform: ['analytics', 'users'],
    insights: ['approvals', 'alerts', 'dashboards'],
    tools:    ['media'],
  },
};

const NAV_TOP = [
  { id: 'dashboard', label: 'Dashboard',       icon: 'home' },
  { id: 'campaigns', label: 'Marketing',       icon: 'spark' },
  { id: 'library',   label: 'Species Library', icon: 'library' },
  /* Zones merged into Displays per Option A of the merge UX
     decision — screens now render grouped under collapsible zone
     headers on the Displays page, with a page-level "Add zone"
     button. The standalone Zones entry was redundant. */
  { id: 'displays',  label: 'Displays',        icon: 'monitor' },
];

const NAV_PLATFORM = [
  { id: 'orgs',           label: 'Organisations',      icon: 'building' },
  /* Per-org sites management — visible to org_admin (and aquaos_admin
     if you ever add it to their allow-list). Restores the affordance
     Sarah flagged as missing in bug 85ae5e93: org admins need a way
     to add and manage their aquarium locations. */
  { id: 'sites',          label: 'Sites',              icon: 'building' },
  { id: 'global-species', label: 'Global Species',     icon: 'fish' },
  { id: 'users',          label: 'Users',              icon: 'users' },
  /* Label adjusts per role at render time — aquaos_admin sees
     "Platform Analytics" (cross-org), org_admin sees just "Analytics"
     (their org rollup). The backend scopes the data either way. */
  { id: 'analytics',      label: 'Analytics',          icon: 'chart' },
  /* Billing removed from sidebar — now lives as a tab in the Settings
     popup (see settings.jsx L42, gated by Auth.canSeeBilling). The
     billing.jsx screen still exists and is rendered by SettingsScreen. */
  /* TEMP — Video Backgrounds dev surface (Kling 2.5 etc.). Visible
     to aquaos_admin only via ROLE_NAV. Remove this entry from BOTH
     ROLE_NAV.aquaos_admin.platform AND this NAV_PLATFORM array
     when the feature is productised or retired. */
  { id: 'video-backgrounds', label: 'Video Backgrounds', icon: 'monitor' },
];

const NAV_INSIGHTS = [
  /* Notifications removed — topbar bell popover (sidebar.jsx ~line 780)
     already surfaces them; a dedicated page was redundant. Audit log
     moved into Settings → Audit tab. */
  /* Approvals nav HIDDEN (2026-06-03, Oli): the campaign review workflow
     is paused — campaigns now publish directly without an approval step,
     so the Approvals page is no longer linked. The entry is left in each
     ROLE_NAV[role].insights allow-list (harmless orphan) so re-enabling is
     a one-line uncomment here. The /api/approvals + /:id/submit + /:id/review
     endpoints are untouched. */
  // { id: 'approvals',     label: 'Approvals',     icon: 'check' },
  /* Alerts (Phase 2) — alert rules + recent fires + anomalies. Page-level
     gate uses Auth.canViewAnalyticsAlerts(); manage-actions hidden when
     canManageAnalyticsAlerts() returns false. */
  { id: 'alerts',        label: 'Alerts',        icon: 'alert' },
  /* Custom dashboards (Phase M) — user-authored panel layouts. Same
     view-cap as the rest of analytics; manage actions hidden without
     manage_alerts (close-enough cap for "operator-author tooling"). */
  { id: 'dashboards',    label: 'Dashboards',    icon: 'chart' },
];

const NAV_TOOLS = [
  /* Templates feature parked. Settings now lives only in the topbar gear
     popup. Bulk Operations moved into Settings → Bulk tab. */
  { id: 'media',                 label: 'Media library',        icon: 'library' },
];

/* Visibility resolver — pulls the current user's role allow-list from
   ROLE_NAV and tests each item's id against it. Direct port of the
   original CMS reorderSidebar() pattern. Roles not in ROLE_NAV (which
   shouldn't happen given backend validation) fall back to the legacy
   admin layout. */
function navAllowed(section) {
  const u = Auth && Auth.getUser ? Auth.getUser() : null;
  const role = (u && u.role) || 'operator';
  const map = ROLE_NAV[role] || ROLE_NAV.admin;
  return new Set(map[section] || []);
}
function visibleNav(section, items) {
  const allowed = navAllowed(section);
  return items.filter((it) => allowed.has(it.id));
}

function NavRow({ item, active, onPick, badgeCount, compact }) {
  return (
    <div
      className={`aq-nav-item ${active ? 'is-active' : ''}`}
      onClick={() => onPick(item.id)}
      role="button"
      tabIndex={0}
      title={compact ? item.label : undefined}
      style={compact ? { justifyContent: 'center', paddingLeft: 0, paddingRight: 0 } : undefined}
    >
      <Icon name={item.icon} />
      {!compact && <span>{item.label}</span>}
      {item.dot && <span className="aq-dot" />}
      {!compact && badgeCount != null && (
        <span className="aq-badge">{badgeCount}</span>
      )}
    </div>
  );
}

function Sidebar({ active, onPick, badges = {}, user }) {
  const isAdmin = user && user.role === 'admin';
  const initials = user && user.name
    ? user.name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase()
    : 'AQ';

  /* Collapse state — persists in localStorage so the user's choice
     survives reloads and role swaps. Compact mode shrinks the sidebar
     to icon-only at ~64px. */
  const [collapsed, setCollapsed] = useState(() => {
    try { return localStorage.getItem('aquaos.sidebar.collapsed') === '1'; }
    catch (_) { return false; }
  });
  function toggleCollapsed() {
    setCollapsed((v) => {
      const next = !v;
      try { localStorage.setItem('aquaos.sidebar.collapsed', next ? '1' : '0'); } catch (_) {}
      document.body.dataset.sidebar = next ? 'compact' : 'full';
      return next;
    });
  }
  useEffect(() => {
    document.body.dataset.sidebar = collapsed ? 'compact' : 'full';
  }, [collapsed]);

  return (
    <aside className={`aq-sidebar ${collapsed ? 'is-compact' : ''}`} style={collapsed ? { width: 64 } : undefined}>
      <div className="aq-sidebar-head" style={{ position: 'relative' }}>
        {/* Brand identity: just the wordmark with a terminator
            square inline. The legacy `.aq-brand-mark` chevron icon
            is gone — when collapsed, we render only the small
            terminator square so the sidebar still carries the
            brand glyph at the narrow width. */}
        {collapsed ? (
          <div className="aq-brand-square-only" aria-label="slate" title="slate" />
        ) : (
          <div className="aq-brand-wordmark">
            <span className="aq-brand-name">slate</span>
            <span className="aq-brand-square" aria-hidden="true" />
          </div>
        )}
        <button
          onClick={toggleCollapsed}
          aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
          title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
          style={{
            position: 'absolute', top: 4, right: 4,
            width: 22, height: 22, borderRadius: 4,
            background: 'transparent', border: 0,
            color: 'var(--aq-text-faint)', cursor: 'pointer',
            display: 'grid', placeItems: 'center',
          }}
        ><Icon name={collapsed ? 'arrow-right' : 'chevron-down'} size={11} /></button>
      </div>

      {!collapsed && (
        <div
          className="aq-sidebar-search"
          onClick={() => { window.location.hash = '#search'; }}
          style={{ cursor: 'pointer' }}
        >
          <Icon name="search" size={13} />
          <span>Search…</span>
          <span className="aq-kbd">⌘K</span>
        </div>
      )}
      {collapsed && (
        <button
          onClick={() => { window.location.hash = '#search'; }}
          aria-label="Search"
          title="Search (⌘K)"
          style={{
            margin: '4px auto', width: 32, height: 32,
            background: 'var(--aq-surface-2)',
            border: '1px solid var(--aq-line)',
            borderRadius: 6, color: 'var(--aq-text-dim)',
            display: 'grid', placeItems: 'center', cursor: 'pointer',
          }}
        ><Icon name="search" size={13} /></button>
      )}

      <nav className="aq-nav">
        <div className="aq-nav-section">
          {visibleNav('top', NAV_TOP).map((item) => (
            <NavRow
              key={item.id}
              item={item}
              active={item.id === active}
              onPick={onPick}
              badgeCount={badges[item.id]}
              compact={collapsed}
            />
          ))}
        </div>

        {visibleNav('platform', NAV_PLATFORM).length > 0 && (
          <div className="aq-nav-section">
            {!collapsed && <div className="aq-nav-title">Platform</div>}
            {visibleNav('platform', NAV_PLATFORM).map((item) => (
              <NavRow
                key={item.id}
                item={item}
                active={item.id === active}
                onPick={onPick}
                badgeCount={badges[item.id]}
                compact={collapsed}
              />
            ))}
          </div>
        )}

        {visibleNav('tools', NAV_TOOLS).length > 0 && (
          <div className="aq-nav-section">
            {!collapsed && <div className="aq-nav-title">Tools</div>}
            {visibleNav('tools', NAV_TOOLS).map((item) => (
              <NavRow
                key={item.id}
                item={item}
                active={item.id === active}
                onPick={onPick}
                badgeCount={badges[item.id]}
                compact={collapsed}
              />
            ))}
          </div>
        )}

        {visibleNav('insights', NAV_INSIGHTS).length > 0 && (
          <div className="aq-nav-section">
            {!collapsed && <div className="aq-nav-title">Insights</div>}
            {visibleNav('insights', NAV_INSIGHTS).map((item) => (
            <NavRow
              key={item.id}
              item={item}
              active={item.id === active}
              onPick={onPick}
            />
          ))}
          </div>
        )}
      </nav>

      {/* User profile moved into the topbar avatar (bug 218fee95) —
          sidebar foot intentionally empty so the rail stays focused on
          navigation. */}
    </aside>
  );
}

/* Bug / feature-request button — small popover anchored to the bug icon
   in the topbar. Captures the current URL + page title alongside the
   text so admins reading the log have context. Submits to /api/feedback
   which writes a row in feedback_items. Esc / outside-click close.
   Popover is portalled to document.body + positioned `fixed` so it
   escapes any stacking context the topbar creates (otherwise it gets
   trapped behind page content).

   Screenshot attachments — the popover accepts up to 4 images via three
   parallel input methods so submitters use whichever is closest to hand:
     1. Click the upload chip → native file picker
     2. Drag a screenshot from Finder/desktop onto the popover
     3. Paste from clipboard (Cmd+V) while the popover has focus
   Files stage client-side as object URLs (no upload happens until Send),
   so a misclick has zero cost and the user can preview before sending. */
const FB_MAX_FILES = 4;
const FB_MAX_BYTES = 5 * 1024 * 1024;
const FB_ACCEPT = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];

function FeedbackButton() {
  const [open, setOpen] = useState(false);
  const [kind, setKind] = useState('bug');
  const [text, setText] = useState('');
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [anchor, setAnchor] = useState(null);  // { top, right } from button rect
  const [files, setFiles] = useState([]);      // [{ id, file, previewUrl }]
  const [dragOver, setDragOver] = useState(false);
  const [error, setError] = useState('');
  const btnRef = useRef(null);
  const popRef = useRef(null);
  const fileInputRef = useRef(null);

  function reposition() {
    if (!btnRef.current) return;
    const r = btnRef.current.getBoundingClientRect();
    setAnchor({
      top:   r.bottom + 6,
      right: window.innerWidth - r.right,
    });
  }

  // Add staged files — handles File objects from picker/drop/paste alike.
  // Validates type + size + count and surfaces a single inline error so the
  // user knows why a file didn't take.
  function addFiles(incoming) {
    if (!incoming || !incoming.length) return;
    setError('');
    setFiles((prev) => {
      const next = prev.slice();
      for (const f of incoming) {
        if (!f) continue;
        if (next.length >= FB_MAX_FILES) {
          setError(`Maximum ${FB_MAX_FILES} screenshots per report.`);
          break;
        }
        if (!FB_ACCEPT.includes(f.type)) {
          setError('Only JPG, PNG, WebP, or GIF screenshots are supported.');
          continue;
        }
        if (f.size > FB_MAX_BYTES) {
          setError('Each screenshot must be 5MB or less.');
          continue;
        }
        next.push({
          id: Math.random().toString(36).slice(2),
          file: f,
          previewUrl: URL.createObjectURL(f),
        });
      }
      return next;
    });
  }

  function removeFile(id) {
    setFiles((prev) => {
      const target = prev.find((x) => x.id === id);
      if (target) { try { URL.revokeObjectURL(target.previewUrl); } catch (_) {} }
      return prev.filter((x) => x.id !== id);
    });
  }

  // Clean up any unsent object URLs when the popover unmounts so we
  // don't leak blob refs from sessions where the user closed without sending.
  useEffect(() => () => {
    files.forEach((f) => { try { URL.revokeObjectURL(f.previewUrl); } catch (_) {} });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!open) return;
    reposition();
    const onClick = (e) => {
      if (popRef.current && popRef.current.contains(e.target)) return;
      if (btnRef.current && btnRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    const onResize = () => reposition();
    // Paste handler — only fires while the popover is open. Captures images
    // pasted from the OS clipboard (e.g. macOS Cmd+Shift+Ctrl+4 → Cmd+V).
    const onPaste = (e) => {
      if (!popRef.current || !popRef.current.contains(document.activeElement)) {
        // Restrict paste to when focus is inside the popover so we don't
        // hijack pastes meant for the page underneath.
        return;
      }
      const items = (e.clipboardData && e.clipboardData.items) || [];
      const imgs = [];
      for (const it of items) {
        if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
          const f = it.getAsFile();
          if (f) imgs.push(f);
        }
      }
      if (imgs.length) {
        e.preventDefault();   // prevent the screenshot path string from also pasting into the textarea
        addFiles(imgs);
      }
    };
    document.addEventListener('mousedown', onClick);
    document.addEventListener('keydown', onKey);
    document.addEventListener('paste', onPaste);
    window.addEventListener('resize', onResize);
    window.addEventListener('scroll', onResize, true);
    return () => {
      document.removeEventListener('mousedown', onClick);
      document.removeEventListener('keydown', onKey);
      document.removeEventListener('paste', onPaste);
      window.removeEventListener('resize', onResize);
      window.removeEventListener('scroll', onResize, true);
    };
  }, [open]);

  // Reset transient "Sent ✓" state shortly after closing.
  // Also clears staged files so reopening the popover starts fresh.
  useEffect(() => {
    if (!open && done) {
      const t = setTimeout(() => {
        setDone(false); setText(''); setError('');
        setFiles((prev) => {
          prev.forEach((f) => { try { URL.revokeObjectURL(f.previewUrl); } catch (_) {} });
          return [];
        });
      }, 300);
      return () => clearTimeout(t);
    }
  }, [open, done]);

  async function submit() {
    if (busy || !text.trim()) return;
    setBusy(true);
    setError('');
    try {
      // Always use FormData so the same code path handles 0-file and N-file
      // submissions; the backend transparently accepts both shapes.
      const fd = new FormData();
      fd.append('text', text.trim());
      fd.append('kind', kind);
      fd.append('page_url', window.location.href);
      fd.append('page_title', document.title);
      files.forEach((f) => fd.append('screenshots', f.file, f.file.name || 'screenshot'));

      const r = await fetch('/api/feedback', {
        method: 'POST',
        credentials: 'include',
        body: fd,
      });
      if (!r.ok) {
        let msg = 'Submit failed';
        try { const d = await r.json(); if (d && d.error) msg = d.error; } catch (_) {}
        throw new Error(msg);
      }
      setDone(true);
      setTimeout(() => setOpen(false), 900);
    } catch (e) {
      setError(e.message || 'Couldn\'t send feedback. Please try again.');
    } finally {
      setBusy(false);
    }
  }

  // Drop-zone handlers — toggle the highlight + accept the dropped files.
  // Wraps the whole popover so users can drop anywhere on it (more forgiving
  // than a tiny 60px target) without losing the textarea drop fallback.
  function onDragEnter(e) { e.preventDefault(); setDragOver(true); }
  function onDragOver(e)  { e.preventDefault(); setDragOver(true); }
  function onDragLeave(e) {
    // Only clear when leaving the popover itself, not its children.
    if (popRef.current && popRef.current.contains(e.relatedTarget)) return;
    setDragOver(false);
  }
  function onDrop(e) {
    e.preventDefault();
    setDragOver(false);
    const dropped = (e.dataTransfer && e.dataTransfer.files) ? Array.from(e.dataTransfer.files) : [];
    addFiles(dropped);
  }

  return (
    <>
      <button
        ref={btnRef}
        className="aq-icon-btn"
        aria-label="Report a bug or suggest a feature"
        title="Report a bug or suggest a feature"
        onClick={() => setOpen((v) => !v)}
      ><Icon name="bug" /></button>
      {open && anchor && ReactDOM.createPortal((
        <div
          ref={popRef}
          onDragEnter={onDragEnter}
          onDragOver={onDragOver}
          onDragLeave={onDragLeave}
          onDrop={onDrop}
          style={{
            position: 'fixed',
            top: anchor.top, right: anchor.right,
            width: 360, padding: 12,
            background: 'var(--aq-surface)',
            border: '1px solid ' + (dragOver ? 'var(--aq-accent, #00c8b4)' : 'var(--aq-line)'),
            borderRadius: 10,
            boxShadow: '0 16px 40px rgba(0,0,0,0.5)' + (dragOver ? ', 0 0 0 3px rgba(0,200,180,0.18)' : ''),
            zIndex: 9999,
            isolation: 'isolate',
            transition: 'border-color 120ms ease, box-shadow 120ms ease',
          }}
        >
          {done ? (
            <div style={{
              display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
              padding: '14px 8px', color: 'var(--aq-success, #4ade80)',
            }}>
              <Icon name="check" />
              <strong style={{ fontSize: 13 }}>Sent — thanks!</strong>
              <span style={{ fontSize: 11, color: 'var(--aq-text-dim)' }}>Logged in Settings → Feedback</span>
            </div>
          ) : (
            <>
              <textarea
                placeholder="What's on your mind? (page is captured automatically)"
                value={text}
                onChange={(e) => setText(e.target.value)}
                onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit(); }}
                rows={4}
                autoFocus
                style={{
                  width: '100%', resize: 'vertical', minHeight: 90,
                  background: 'var(--aq-surface-2, rgba(255,255,255,0.04))',
                  border: '1px solid var(--aq-line)',
                  borderRadius: 6, padding: '8px 10px',
                  color: 'var(--aq-text)', fontFamily: 'inherit', fontSize: 13,
                  outline: 'none', boxSizing: 'border-box',
                }}
              />

              {/* Hidden native file picker — driven by the chip below */}
              <input
                ref={fileInputRef}
                type="file"
                accept={FB_ACCEPT.join(',')}
                multiple
                style={{ display: 'none' }}
                onChange={(e) => {
                  addFiles(Array.from(e.target.files || []));
                  // Reset so picking the same file twice still fires onChange
                  e.target.value = '';
                }}
              />

              {/* Thumbnail strip — only renders when there are staged files */}
              {files.length > 0 && (
                <div style={{
                  display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8,
                }}>
                  {files.map((f) => (
                    <div key={f.id} style={{
                      position: 'relative', width: 56, height: 56,
                      borderRadius: 6, overflow: 'hidden',
                      border: '1px solid var(--aq-line)',
                      background: 'var(--aq-surface-2, rgba(255,255,255,0.04))',
                    }}>
                      <img src={f.previewUrl} alt=""
                        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
                      <button
                        type="button"
                        onClick={() => removeFile(f.id)}
                        aria-label="Remove screenshot"
                        title="Remove"
                        style={{
                          position: 'absolute', top: 2, right: 2,
                          width: 18, height: 18, borderRadius: 9, border: 0,
                          background: 'rgba(0,0,0,0.7)', color: '#fff',
                          fontSize: 11, lineHeight: '18px', padding: 0,
                          cursor: 'pointer',
                        }}
                      >×</button>
                    </div>
                  ))}
                </div>
              )}

              {/* Drop hint + click-to-pick chip — single row.
                  When dragOver is true the textarea + this chip get a brighter
                  accent treatment via the popover's outer border highlight. */}
              <div style={{
                display: 'flex', alignItems: 'center', gap: 8, marginTop: 8,
                fontSize: 11, color: 'var(--aq-text-faint)',
              }}>
                <button
                  type="button"
                  onClick={() => fileInputRef.current && fileInputRef.current.click()}
                  disabled={files.length >= FB_MAX_FILES}
                  style={{
                    display: 'inline-flex', alignItems: 'center', gap: 5,
                    padding: '4px 9px', borderRadius: 999,
                    border: '1px solid var(--aq-line)',
                    background: 'transparent', color: 'var(--aq-text-dim)',
                    fontSize: 11, fontFamily: 'inherit',
                    cursor: files.length >= FB_MAX_FILES ? 'not-allowed' : 'pointer',
                    opacity: files.length >= FB_MAX_FILES ? 0.45 : 1,
                  }}
                >
                  <span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>+</span>
                  <span>Add screenshot</span>
                </button>
                <span style={{ flex: 1 }}>
                  {dragOver ? 'Drop to attach…' : (files.length ? `${files.length}/${FB_MAX_FILES} attached` : 'Drop, paste, or click')}
                </span>
              </div>

              {error && (
                <div role="alert" style={{
                  marginTop: 8, padding: '6px 9px', borderRadius: 6,
                  background: 'rgba(239,68,68,0.14)',
                  color: '#fca5a5', fontSize: 11.5, lineHeight: 1.4,
                }}>{error}</div>
              )}

              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
                <span style={{ fontSize: 10, color: 'var(--aq-text-faint)' }}>
                  ⌘+Return to send
                </span>
                <button
                  onClick={submit}
                  disabled={busy || !text.trim()}
                  style={{
                    padding: '6px 14px', borderRadius: 6, border: 0,
                    background: 'var(--aq-accent, #00c8b4)',
                    color: '#0a1024',
                    fontWeight: 600, fontSize: 12, cursor: busy ? 'wait' : 'pointer',
                    opacity: busy || !text.trim() ? 0.5 : 1,
                  }}
                >{busy ? 'Sending…' : 'Send'}</button>
              </div>
            </>
          )}
        </div>
      ), document.body)}
    </>
  );
}

/* Persistent topbar badge that mirrors window.AquaOSAddQueue. Visible
   only when the queue has items in flight. Survives page navigation
   because the queue itself does — the curator can leave the species
   library, browse anywhere in the CMS, and still see the import
   progressing in the topbar. Clicking it pops a list of in-flight
   rows and a "View library" shortcut. Bug feedback (bulk-add):
   "should still process even if i leave the site or page". */
function QueueBadge() {
  const [snap, setSnap] = useState(() =>
    (window.AquaOSAddQueue && window.AquaOSAddQueue.getState)
      ? window.AquaOSAddQueue.getState()
      : { items: [], running: 0, capacity: 2 });
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!window.AquaOSAddQueue || typeof window.AquaOSAddQueue.subscribe !== 'function') return;
    return window.AquaOSAddQueue.subscribe(setSnap);
  }, []);

  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  const inFlight = (snap.items || []).filter((it) =>
    it.status === 'waiting' || it.status === 'running' || it.status === 'queued'
  );
  const recent = (snap.items || []).slice(0, 8);
  if (inFlight.length === 0 && recent.length === 0) return null;

  const showSpinner = inFlight.length > 0;
  return (
    <span ref={ref} style={{ position: 'relative' }}>
      <button
        className="aq-icon-btn"
        aria-label={inFlight.length > 0 ? `${inFlight.length} species importing` : 'Species import queue'}
        onClick={() => setOpen((v) => !v)}
        title={showSpinner ? `${inFlight.length} species importing` : 'Species import queue'}
        style={{ position: 'relative' }}
      >
        <Icon name="grid" />
        {showSpinner && (
          <span style={{
            position: 'absolute', top: 4, right: 4,
            minWidth: 14, height: 14, padding: '0 4px',
            borderRadius: 99,
            background: 'var(--aq-accent)', color: 'rgba(0,0,0,0.85)',
            fontSize: 9, fontFamily: 'var(--aq-ff-mono)',
            fontWeight: 700, letterSpacing: '0.04em',
            display: 'grid', placeItems: 'center',
          }}>{inFlight.length > 9 ? '9+' : inFlight.length}</span>
        )}
      </button>
      {open && (
        <div
          className="aq-popover"
          style={{
          position: 'absolute', top: 'calc(100% + 6px)', right: 0,
          width: 340, maxHeight: 420, overflowY: 'auto',
          background: 'var(--aq-surface)',
          border: '1px solid var(--aq-line)',
          borderRadius: 8,
          boxShadow: 'var(--aq-shadow-2, 0 12px 32px rgba(0,0,0,0.45))',
          zIndex: 9999,
        }}>
          <header style={{
            padding: '10px 12px', borderBottom: '1px solid var(--aq-line)',
            display: 'flex', alignItems: 'center', gap: 8,
            background: 'var(--aq-surface-2)',
          }}>
            <div style={{ flex: 1 }}>
              <div style={{
                fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
                letterSpacing: '0.08em', textTransform: 'uppercase',
                color: 'var(--aq-text-faint)',
              }}>Species import</div>
              <div style={{ fontSize: 12, color: 'var(--aq-text)', marginTop: 2 }}>
                {inFlight.length > 0 ? `${inFlight.length} in flight · ${snap.running}/${snap.capacity} active` : 'All caught up'}
              </div>
            </div>
            <a
              href="#library"
              onClick={(e) => { e.preventDefault(); setOpen(false); window.location.hash = '#library'; }}
              style={{ fontSize: 11.5, color: 'var(--aq-accent)', textDecoration: 'none' }}
            >Open library →</a>
          </header>
          {recent.length === 0 && (
            <div style={{ padding: 30, textAlign: 'center', color: 'var(--aq-text-faint)', fontSize: 12 }}>
              No active imports.
            </div>
          )}
          {recent.map((it) => {
            const tone = ({
              waiting:   'var(--aq-text-faint)',
              queued:    'var(--aq-text-faint)',
              running:   'var(--aq-accent)',
              created:   'var(--aq-success)',
              duplicate: 'var(--aq-warn)',
              error:     'var(--aq-danger)',
            }[it.status]) || 'var(--aq-text-faint)';
            return (
              <div key={it.key || it.aphiaId} style={{
                padding: '8px 12px', display: 'flex', gap: 8, alignItems: 'center',
                borderTop: '1px solid var(--aq-line)',
                fontSize: 12,
              }}>
                <span style={{
                  fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
                  letterSpacing: '0.06em', textTransform: 'uppercase',
                  color: tone, width: 60,
                }}>{it.status}</span>
                <span style={{ flex: 1, minWidth: 0, color: 'var(--aq-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {it.commonName || it.scientificName || ('AphiaID ' + it.aphiaId)}
                </span>
                {it.speciesId && (it.status === 'created' || it.status === 'duplicate') && (
                  <a
                    href={`#species/${it.speciesId}`}
                    onClick={(e) => { e.preventDefault(); setOpen(false); window.location.hash = `#species/${it.speciesId}`; }}
                    style={{ color: 'var(--aq-accent)', fontSize: 11, textDecoration: 'none' }}
                  >Open</a>
                )}
              </div>
            );
          })}
          {recent.length > 0 && (
            <footer style={{
              padding: '8px 12px', borderTop: '1px solid var(--aq-line)',
              background: 'var(--aq-surface-2)',
              display: 'flex', justifyContent: 'flex-end',
            }}>
              <button
                onClick={() => {
                  if (window.AquaOSAddQueue && window.AquaOSAddQueue.removeFinished) {
                    window.AquaOSAddQueue.removeFinished();
                  }
                }}
                style={{
                  fontSize: 11, padding: '4px 10px',
                  background: 'transparent', border: '1px solid var(--aq-line)',
                  color: 'var(--aq-text-dim)', borderRadius: 6, cursor: 'pointer',
                }}
              >Clear finished</button>
            </footer>
          )}
        </div>
      )}
    </span>
  );
}

/* Compact circular avatar in the topbar — replaces the sidebar foot's
   user card per bug 218fee95. Click opens a small popover with the
   user's full name, role, email, a quick role-switcher (aquaos_admin
   or whitelisted operator only) and a sign-out button.

   Quick switcher (bug ff23422a "logged in as oli@slate, not seeing
   the account quick switcher … should just link to an org, site
   manager, operator"): three direct buttons, one per role tier,
   inlined here so there's no cross-component event indirection. Each
   button lazy-fetches /api/auth/impersonable-users on dropdown open
   and picks the first user matching the role. The original session
   is stashed in localStorage before assuming so the "Return to
   admin" entry can restore it. */
const ROLE_SWITCHER_KEYS = {
  ORIGINAL: 'aquaos.original_session',
  TOKEN: 'aquaos.token',
  USER: 'aquaos.user',
};
const ALWAYS_VISIBLE_EMAILS = ['oli@slateinteractives.com'];

function TopbarAvatar() {
  const [open, setOpen] = useState(false);
  const [switcherUsers, setSwitcherUsers] = useState(null);   // null = not yet fetched
  const [switcherErr, setSwitcherErr] = useState(null);
  const [switcherBusy, setSwitcherBusy] = useState(null);
  const ref = useRef(null);
  const me = (Auth && Auth.getUser && Auth.getUser()) || null;
  const initials = (me && me.name)
    ? me.name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase()
    : (me && me.email ? me.email[0].toUpperCase() : '?');

  // Re-tightened to aquaos_admin / whitelisted emails per the
  // 2026-05-15 audit (CRIT-5). Any-authenticated-user access was a
  // closed-testing convenience that became a real auth bypass — the
  // backend /assume + /impersonable-users now refuse non-admins (403),
  // so a non-admin clicking the picker would just see dead buttons.
  const myEmailLc = (me && me.email) ? String(me.email).toLowerCase() : '';
  const isAdminOrWhitelisted = !!me && (
    me.role === 'aquaos_admin' ||
    ALWAYS_VISIBLE_EMAILS.includes(myEmailLc)
  );
  const canSwitch = isAdminOrWhitelisted;
  const hasOriginal = typeof window !== 'undefined' && !!localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL);

  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  // Lazy-fetch the impersonable users list when the dropdown opens
  // (and only when the current user is allowed to use the switcher).
  // Re-fetched each open so a freshly added user shows up without a
  // page refresh. Endpoint returns one user per (role, org) bucket so
  // the list stays small.
  //
  // Auth: always attach an Authorization header so the call doesn't
  // depend on the httpOnly cookie reaching the API.
  //   - mid-impersonation (ORIGINAL stash present) → use the stashed
  //     admin token, so a curator who's impersonating a non-admin can
  //     still re-fetch the picker and switch again.
  //   - normal (no stash) → use the user's current token from
  //     localStorage. Bug feedback May 2026 (Oli): the dropdown showed
  //     "Session expired" for a freshly-logged-in aquaos_admin because
  //     the previous version only attached a header when ORIGINAL was
  //     set, so the request fell back to the cookie. On http://
  //     local dev the cookie is `Secure`-blocked by the browser, so
  //     auth quietly failed and the endpoint 401'd. Bearer fixes both.
  useEffect(() => {
    if (!open || !canSwitch) return;
    setSwitcherUsers(null); setSwitcherErr(null);
    // Pick the right Authorization token:
    //   - If the *current* user is already an admin (i.e. not
    //     impersonating), their own token has admin power and that's
    //     what we want. Using the ORIGINAL stash here would be wrong
    //     because the stash can be stale (issued under a previous JWT
    //     secret, or expired after sitting in localStorage for days)
    //     — exactly the May 2026 bug Oli reported where a fresh
    //     login still showed "Session expired".
    //   - Only when the current user is a non-admin who can still see
    //     the switcher (whitelisted email mid-impersonation) do we
    //     fall back to the stashed admin token.
    const headers = {};
    const isCurrentAdmin = !!me && me.role === 'aquaos_admin';
    const currentToken = localStorage.getItem(ROLE_SWITCHER_KEYS.TOKEN);
    if (isCurrentAdmin && currentToken) {
      headers['Authorization'] = 'Bearer ' + currentToken;
    } else {
      try {
        const raw = localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL);
        if (raw) {
          const orig = JSON.parse(raw);
          if (orig && orig.token) headers['Authorization'] = 'Bearer ' + orig.token;
        }
      } catch (_) { /* corrupted stash — fall through to current */ }
      if (!headers['Authorization'] && currentToken) {
        headers['Authorization'] = 'Bearer ' + currentToken;
      }
    }
    fetch('/api/auth/impersonable-users', { credentials: 'include', headers })
      .then((r) => r.ok ? r.json() : r.json().then((j) => Promise.reject(j)))
      .then((d) => setSwitcherUsers(d.users || []))
      .catch((e) => setSwitcherErr((e && e.error) || 'Failed to load users'));
  }, [open, canSwitch]);

  // Pick the first user matching a role. The /impersonable-users
  // endpoint returns one user per (role, org) bucket — for a single
  // org install this means exactly one row per role tier, which is
  // what the three quick-switch buttons want.
  function userForRole(role) {
    if (!Array.isArray(switcherUsers)) return null;
    return switcherUsers.find((u) => u.role === role) || null;
  }

  async function assumeAs(target) {
    if (!target) return;
    setSwitcherBusy(target.id);
    try {
      // Stash the original session BEFORE the call so a refresh
      // mid-assume doesn't strand us. Don't overwrite if we're
      // already impersonating — keep the very first admin session as
      // the anchor.
      if (!localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL)) {
        localStorage.setItem(ROLE_SWITCHER_KEYS.ORIGINAL, JSON.stringify({
          token: localStorage.getItem(ROLE_SWITCHER_KEYS.TOKEN),
          user: localStorage.getItem(ROLE_SWITCHER_KEYS.USER),
        }));
      }
      // When already impersonating, the cookie still carries the
      // current (non-admin) user — and the backend's authenticate
      // middleware prefers cookie over Bearer. Pass the *original*
      // admin's Bearer token so /assume sees an admin caller even
      // mid-impersonation. Without this, switching from site_manager
      // → operator looked like "nothing happens" because the call
      // silently 403'd before the backend gate was relaxed.
      //
      // Normal case (no stash yet) — attach the *current* token. Some
      // dev setups can't deliver the httpOnly cookie back to the API
      // (Secure flag mismatch on plain http://, SameSite restrictions),
      // and without a Bearer the call falls through to "Session
      // expired". This mirrors the picker fetch above.
      // Same priority as the picker fetch: current token wins when the
      // *current* user is an admin (avoids using a stale ORIGINAL stash
      // that survived an earlier logout). Only fall through to the
      // stashed admin token when the current user is non-admin (the
      // whitelisted-email mid-impersonation case).
      const headers = { 'Content-Type': 'application/json' };
      const isCurrentAdmin = !!me && me.role === 'aquaos_admin';
      const currentToken = localStorage.getItem(ROLE_SWITCHER_KEYS.TOKEN);
      if (isCurrentAdmin && currentToken) {
        headers['Authorization'] = 'Bearer ' + currentToken;
      } else {
        const rawOriginal = localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL);
        if (rawOriginal) {
          try {
            const orig = JSON.parse(rawOriginal);
            if (orig && orig.token) headers['Authorization'] = 'Bearer ' + orig.token;
          } catch (_) { /* corrupted stash */ }
        }
        if (!headers['Authorization'] && currentToken) {
          headers['Authorization'] = 'Bearer ' + currentToken;
        }
      }
      const res = await fetch('/api/auth/assume', {
        method: 'POST', credentials: 'include',
        headers,
        body: JSON.stringify({ user_id: target.id }),
      });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || `${res.status} ${res.statusText}`);
      }
      const data = await res.json();
      Auth.setSession(data.token, data.user);
      window.location.reload();
    } catch (err) {
      // Roll back stashed original on failure so we don't strand the
      // UI in an "you're impersonating" visual state.
      try { localStorage.removeItem(ROLE_SWITCHER_KEYS.ORIGINAL); } catch (_) {}
      window.toast && window.toast.danger('Switch role failed: ' + err.message);
    } finally {
      setSwitcherBusy(null);
    }
  }

  async function returnToAdmin() {
    const raw = localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL);
    if (!raw) return;
    setSwitcherBusy('return');
    try {
      const orig = JSON.parse(raw);
      const origUser = orig.user ? JSON.parse(orig.user) : null;
      if (orig.token && origUser) {
        // Hit /assume on the original admin's own user_id — this
        // reissues the cookie cleanly. Fallback to localStorage-only
        // restore if the call fails.
        const r = await fetch('/api/auth/assume', {
          method: 'POST', credentials: 'include',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + orig.token },
          body: JSON.stringify({ user_id: origUser.id }),
        });
        if (r.ok) {
          const data = await r.json();
          Auth.setSession(data.token, data.user);
        } else {
          localStorage.setItem(ROLE_SWITCHER_KEYS.TOKEN, orig.token);
          localStorage.setItem(ROLE_SWITCHER_KEYS.USER, orig.user);
        }
      }
    } catch (_) { /* ignore corrupted stash */ }
    finally {
      localStorage.removeItem(ROLE_SWITCHER_KEYS.ORIGINAL);
      window.location.reload();
    }
  }
  return (
    <span ref={ref} style={{ position: 'relative', display: 'inline-flex' }}>
      <button
        type="button"
        className="aq-topbar-avatar"
        onClick={() => setOpen((v) => !v)}
        aria-label={me ? `${me.name || me.email} — open profile` : 'Sign in'}
        title={me ? `${me.name || me.email} · ${me.role}` : 'Sign in'}
        style={{
          width: 30, height: 30, borderRadius: '50%',
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          /* Muted monochrome disc — translucent fill + muted-white
             initials, matching the org avatars (no chroma gradient). */
          background: 'rgba(255,255,255,0.08)',
          color: 'rgba(255,255,255,0.78)', fontSize: 11, fontWeight: 500, letterSpacing: 0.4,
          border: '1px solid rgba(255,255,255,0.10)',
          cursor: 'pointer', padding: 0, marginLeft: 6,
          fontFamily: 'var(--aq-ff-sans)',
        }}
      >{initials}</button>
      {open && (
        <div
          role="menu"
          className="aq-popover"
          style={{
            position: 'absolute', top: 'calc(100% + 8px)', right: 0,
            minWidth: 240, padding: 12,
            background: 'var(--aq-surface, #15192a)',
            border: '1px solid var(--aq-line)',
            borderRadius: 10,
            boxShadow: '0 12px 40px rgba(0,0,0,0.45)',
            zIndex: 9999,
          }}
        >
          <div style={{ display: 'flex', gap: 10, alignItems: 'center', paddingBottom: 10, borderBottom: '1px solid var(--aq-line)' }}>
            <div style={{
              width: 36, height: 36, borderRadius: '50%',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              background: 'rgba(255,255,255,0.08)',
              color: 'rgba(255,255,255,0.78)', fontSize: 12, fontWeight: 500,
              border: '1px solid rgba(255,255,255,0.10)',
            }}>{initials}</div>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--aq-text)', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {me ? (me.name || me.email) : 'Signed out'}
              </div>
              <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', marginTop: 3, textTransform: 'capitalize' }}>
                {me ? (me.role || 'viewer').replace(/_/g, ' ') : ''}
              </div>
              {me && me.name && me.email && (
                <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {me.email}
                </div>
              )}
            </div>
          </div>
          {/* Quick role switcher — three direct buttons, one per role
              tier (org_admin / site_manager / operator). Each picks
              the first user matching that role from /impersonable-
              users and calls /assume. Inline (no separate widget) so
              the user can switch in one click without hunting for a
              floating panel. Bug feedback ff23422a: "should just link
              to an org, site manager, operator". */}
          {canSwitch && (
            <div style={{
              marginTop: 10, paddingTop: 10,
              borderTop: '1px solid var(--aq-line)',
            }}>
              <div style={{
                fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
                letterSpacing: '0.08em', textTransform: 'uppercase',
                color: 'var(--aq-text-faint)', marginBottom: 6,
              }}>Switch role for testing</div>
              {switcherUsers === null && !switcherErr && (
                <div style={{
                  fontSize: 11.5, color: 'var(--aq-text-faint)',
                  padding: '6px 0',
                }}>Loading…</div>
              )}
              {switcherErr && (
                <div style={{
                  fontSize: 11.5, color: 'var(--aq-warn)',
                  padding: '6px 0',
                }}>{switcherErr}</div>
              )}
              {Array.isArray(switcherUsers) && (() => {
                // Platform Admin first. If we've already stashed an
                // original admin session (i.e. the user impersonated
                // from an admin account), clicking this hops straight
                // back to that anchor — typically oli@slate. If no
                // stash exists yet, assume the first aquaos_admin in
                // the impersonable list.
                const tiers = [
                  { role: 'aquaos_admin', label: 'Platform Admin', icon: 'fa-user-shield' },
                  { role: 'org_admin',    label: 'Org Admin',      icon: 'fa-building' },
                  { role: 'site_manager', label: 'Site Manager',   icon: 'fa-store' },
                  { role: 'operator',     label: 'Operator',       icon: 'fa-user' },
                ];
                return tiers.map((t) => {
                  // For Platform Admin, prefer the stashed original
                  // (it carries the very first admin the user signed
                  // in as — exactly the "go back to oli@slate"
                  // behaviour the user asked for).
                  let originalUser = null;
                  if (t.role === 'aquaos_admin' && hasOriginal) {
                    try {
                      const orig = JSON.parse(localStorage.getItem(ROLE_SWITCHER_KEYS.ORIGINAL));
                      if (orig && orig.user) originalUser = JSON.parse(orig.user);
                    } catch (_) { /* fall through to picker */ }
                  }
                  const target = originalUser || userForRole(t.role);
                  const isMe = !!target && me && me.id === target.id;
                  const disabled = !target || isMe || switcherBusy != null;
                  const subLabel = target
                    ? (target.email || target.org_name || '').slice(0, 38)
                    : 'no user available';
                  const onClick = () => {
                    if (t.role === 'aquaos_admin' && hasOriginal) {
                      // Use the dedicated returnToAdmin flow — it
                      // unstashes the ORIGINAL key so subsequent
                      // switches don't try to re-restore.
                      returnToAdmin();
                    } else {
                      assumeAs(target);
                    }
                  };
                  return (
                    <button
                      key={t.role}
                      type="button"
                      onClick={onClick}
                      disabled={disabled}
                      title={target
                        ? `Sign in as ${target.email || target.name || t.label}${target.org_name ? ' · ' + target.org_name : ''}`
                        : `No ${t.label} user exists yet — seed one or create via the Users page.`}
                      style={{
                        display: 'flex', alignItems: 'center', gap: 8,
                        width: '100%', marginBottom: 4,
                        padding: '7px 10px',
                        background: isMe
                          ? 'color-mix(in srgb, var(--aq-accent) 12%, transparent)'
                          : (t.role === 'aquaos_admin'
                              ? 'color-mix(in srgb, var(--aq-accent) 6%, transparent)'
                              : 'transparent'),
                        color: disabled && !isMe ? 'var(--aq-text-faint)' : 'var(--aq-text)',
                        border: '1px solid var(--aq-line)',
                        borderRadius: 6,
                        fontSize: 12, textAlign: 'left',
                        fontFamily: 'inherit',
                        cursor: disabled ? (isMe ? 'default' : 'not-allowed') : 'pointer',
                        opacity: disabled && !isMe ? 0.6 : 1,
                      }}
                    >
                      <i className={`fas ${t.icon}`} style={{ width: 14, opacity: 0.75 }} />
                      <span style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontWeight: 500 }}>{t.label}</div>
                        <div style={{
                          fontSize: 10.5, color: 'var(--aq-text-faint)',
                          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                        }}>{subLabel}</div>
                      </span>
                      {isMe && (
                        <span style={{
                          fontFamily: 'var(--aq-ff-mono)', fontSize: 9,
                          letterSpacing: '0.06em', color: 'var(--aq-accent)',
                        }}>YOU</span>
                      )}
                      {switcherBusy === (target && target.id) && (
                        <span style={{ fontSize: 11, color: 'var(--aq-text-faint)' }}>…</span>
                      )}
                    </button>
                  );
                });
              })()}
            </div>
          )}
          <button
            type="button"
            onClick={() => {
              // Sign-out has to be bulletproof — the user expects an
              // immediate visual response. Four lessons baked into this:
              //   1. Don't `await` the logout fetch. If the network is
              //      slow / captive-portal'd, awaiting blocks the
              //      redirect for seconds and the user assumes the
              //      button is broken. Fire-and-forget instead.
              //   2. Clear localStorage FIRST so no in-flight component
              //      tries to read a stale token between click and reload.
              //   3. FORCE a full reload — the previous version used
              //      window.location.replace('/cms/#login') which is a
              //      hash-only change. Browsers don't reload for hash-
              //      only navigation, so React's `user` state in App()
              //      stayed populated and the LoginScreen never rendered
              //      (sign-out looked broken — user reported 2026-05-15).
              //   4. The server cookie still gets cleared via the background
              //      fetch (sendBeacon would also work, but POST keepalive
              //      survives the page unload).
              Auth.clear();
              try { fetch('/api/auth/logout', { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); }
              catch (_) {}
              // Hard navigation with a cache-buster query so the URL
              // genuinely differs from the current one — that
              // guarantees a real page reload (some browsers treat
              // hash-only changes as no-ops even via assign()). The
              // App boot path then sees no token + no user and
              // renders LoginScreen.
              window.location.replace('/cms/?_=' + Date.now());
            }}
            style={{
              display: 'block', width: '100%',
              marginTop: me && me.role === 'aquaos_admin' ? 6 : 10, padding: '8px 10px',
              background: 'transparent', color: 'var(--aq-text)',
              border: '1px solid var(--aq-line)', borderRadius: 6,
              fontSize: 12.5, cursor: 'pointer', textAlign: 'left',
              fontFamily: 'inherit',
            }}
          >
            <i className="fas fa-arrow-right-from-bracket" style={{ marginRight: 8, opacity: 0.7 }} />
            Sign out
          </button>
        </div>
      )}
    </span>
  );
}

function Topbar({ crumbs = ['Slate', 'Dashboard'], showMenu = true }) {
  /* Crumbs accept either:
       - string: rendered as plain text
       - { label, href }: rendered as a hash-route link
     The last crumb is always non-clickable (current page). */
  /* Bell: poll active broadcasts, count unread vs the per-user
     localStorage read-set the notifications page maintains. Click
     opens a small popover anchored to the bell with the most recent
     items + a "View all" link. */
  const [bellOpen, setBellOpen] = useState(false);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [broadcasts, setBroadcasts] = useState([]);

  // ESC closes the settings modal — same affordance every centered modal uses
  useEffect(() => {
    if (!settingsOpen) return;
    const onKey = (e) => { if (e.key === 'Escape') setSettingsOpen(false); };
    document.addEventListener('keydown', onKey);
    // Lock body scroll while modal is open
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      document.removeEventListener('keydown', onKey);
      document.body.style.overflow = prevOverflow;
    };
  }, [settingsOpen]);
  const me = Auth.getUser();
  const readKey = `aquaos.notif.read.${(me && me.id) || 'anon'}`;
  function loadRead() {
    try { return new Set(JSON.parse(localStorage.getItem(readKey) || '[]')); }
    catch (_) { return new Set(); }
  }
  const [readIds, setReadIds] = useState(() => loadRead());

  useEffect(() => {
    if (!me) return;
    let cancelled = false;
    function pull() {
      apiFetch('/api/broadcasts/active')
        .then((r) => { if (!cancelled) setBroadcasts(r.broadcasts || []); })
        .catch(() => {});
    }
    pull();
    /* Light polling — every 60s. The notifications feed itself does
       its own load; this is just for the bell badge. */
    const t = setInterval(pull, 60000);
    return () => { cancelled = true; clearInterval(t); };
    /* Use optional-chain `me?.id` (a stable primitive value) rather
       than the expression `me && me.id` — the latter has new identity
       on every render where `me` is defined, which kills + recreates
       the interval each render and breaks the 60s cadence. */
  }, [me?.id]);

  /* Re-read read-set when the popover opens so it picks up changes
     made on the notifications page. */
  useEffect(() => { if (bellOpen) setReadIds(loadRead()); }, [bellOpen]);

  const bellRef = useRef(null);
  useEffect(() => {
    if (!bellOpen) return;
    const onDoc = (e) => { if (bellRef.current && !bellRef.current.contains(e.target)) setBellOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [bellOpen]);

  const unread = broadcasts.filter((b) => !readIds.has('b-' + b.id)).length;

  return (
    <header className="aq-topbar">
      {/* Mobile-only hamburger — opens the nav drawer (see mobile.css).
          Hidden on desktop and on the campaign-edit route (which has no
          system sidebar, just the editor tools rail). */}
      {showMenu && (
        <button
          className="aq-nav-toggle"
          aria-label="Open navigation"
          onClick={() => document.body.classList.toggle('aq-nav-open')}
        >
          <Icon name="menu" size={18} />
        </button>
      )}
      <div className="aq-crumbs">
        {crumbs.map((c, i) => {
          const isLast = i === crumbs.length - 1;
          const obj = typeof c === 'string' ? { label: c } : (c || {});
          const cls = isLast ? 'aq-current' : '';
          const node = (!isLast && obj.href) ? (
            <a
              href={obj.href}
              className={cls}
              onClick={(e) => { e.preventDefault(); window.location.hash = obj.href; }}
              style={{ color: 'inherit', textDecoration: 'none', cursor: 'pointer' }}
            >{obj.label}</a>
          ) : (
            <span className={cls}>{obj.label}</span>
          );
          return (
            <React.Fragment key={i}>
              {i > 0 && <span className="aq-sep">/</span>}
              {node}
            </React.Fragment>
          );
        })}
      </div>
      {/* Per-page editable title slot (e.g. campaign name) — portalled in
          by editors that need an inline title alongside the breadcrumbs.
          Empty for routes that don't fill it. Sarah feedback (73ae49b1):
          "move the screen name to the right of the page link and put it
          on the apps light button background colour so its clear it's
          editable" — the editor wraps its input in a chip-style button. */}
      <div id="aq-topbar-title-host" style={{
        display: 'flex', alignItems: 'center', gap: 8,
        flex: '1 1 auto', minWidth: 0, marginLeft: 14,
      }}></div>
      <div className="aq-topbar-actions">
        {/* Per-page extra actions (Save / Submit / Preview etc) get portalled
            in here by individual editors. Empty most of the time. */}
        <div id="aq-topbar-extra-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 8 }}></div>
        {/* Bug-report icon — opens a tiny popover for typing a bug or
            feature request. Auto-captures the page URL + title. */}
        <FeedbackButton />
        {/* Persistent species-import queue badge. Hidden when there's
            nothing in flight; appears the moment an import (lookup or
            bulk-add) is queued. Survives page navigation because the
            queue itself lives on window.AquaOSAddQueue. */}
        <QueueBadge />
        <span ref={bellRef} style={{ position: 'relative' }}>
          <button
            className="aq-icon-btn"
            aria-label="Notifications"
            onClick={() => setBellOpen((v) => !v)}
            style={{ position: 'relative' }}
          >
            <Icon name="bell" />
            {unread > 0 && (
              <span style={{
                position: 'absolute', top: 4, right: 4,
                minWidth: 14, height: 14, padding: '0 4px',
                borderRadius: 99,
                background: 'var(--aq-warn)', color: 'rgba(0,0,0,0.85)',
                fontSize: 9, fontFamily: 'var(--aq-ff-mono)',
                fontWeight: 700, letterSpacing: '0.04em',
                display: 'grid', placeItems: 'center',
              }}>{unread > 9 ? '9+' : unread}</span>
            )}
          </button>
          {bellOpen && (
            <div style={{
              position: 'absolute', top: 'calc(100% + 6px)', right: 0,
              width: 320, maxHeight: 420, overflowY: 'auto',
              background: 'var(--aq-surface)',
              border: '1px solid var(--aq-line)',
              borderRadius: 8,
              boxShadow: 'var(--aq-shadow-2)',
              zIndex: 100,
            }}>
              {/* No tray — the header sits on the popover surface; the
                  'NOTIFICATIONS' eyebrow is dropped (you opened this from
                  the bell). */}
              <header style={{
                padding: '12px 14px 8px',
                display: 'flex', alignItems: 'center',
              }}>
                <div style={{ flex: 1, fontSize: 12, color: 'var(--aq-text)' }}>
                  {unread > 0 ? `${unread} unread` : 'All caught up'}
                </div>
                <a
                  href="#notifications"
                  onClick={(e) => { e.preventDefault(); setBellOpen(false); window.location.hash = '#notifications'; }}
                  style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', textDecoration: 'none' }}
                >View all →</a>
              </header>
              {broadcasts.length === 0 && (
                <div style={{ padding: 30, textAlign: 'center', color: 'var(--aq-text-faint)', fontSize: 12 }}>
                  No active broadcasts.
                </div>
              )}
              {broadcasts.slice(0, 8).map((b) => {
                const isUnread = !readIds.has('b-' + b.id);
                return (
                  <div
                    key={b.id}
                    onClick={() => {
                      const next = new Set(readIds); next.add('b-' + b.id);
                      try { localStorage.setItem(readKey, JSON.stringify([...next])); } catch (_) {}
                      setReadIds(next);
                      setBellOpen(false);
                      window.location.hash = '#notifications';
                    }}
                    style={{
                      padding: '10px 12px', margin: '0 4px', borderRadius: 8,
                      cursor: 'pointer',
                      opacity: isUnread ? 1 : 0.65,
                      transition: 'background 120ms ease',
                    }}
                    onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.05)'; }}
                    onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
                  >
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                      <span style={{
                        width: 7, height: 7, borderRadius: '50%',
                        background: b.severity === 'critical' || b.severity === 'urgent' ? 'var(--aq-danger)'
                          : b.severity === 'warning' ? 'var(--aq-warn)'
                          : isUnread ? 'var(--aq-accent)' : 'var(--aq-text-faint)',
                      }} />
                      <div style={{ fontSize: 12.5, color: 'var(--aq-text)', fontWeight: isUnread ? 500 : 400, flex: 1 }}>
                        {b.title || b.message || 'Broadcast'}
                      </div>
                    </div>
                    <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', marginTop: 3 }}>
                      {b.scope || 'Site-wide'}
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </span>
        <button
          className="aq-icon-btn"
          aria-label="Settings"
          onClick={() => setSettingsOpen(true)}
        ><Icon name="settings" /></button>
        {/* User avatar — moved here from the sidebar foot (bug
            218fee95). Click opens a small popover with name + role +
            sign-out. Initials computed from the user name. */}
        <TopbarAvatar />
      </div>
      {settingsOpen && ReactDOM.createPortal((
        <div
          className="aq-settings-modal"
          role="dialog"
          aria-modal="true"
          aria-label="Settings"
          onClick={(e) => { if (e.target.classList.contains('aq-settings-modal')) setSettingsOpen(false); }}
        >
          <div className="aq-settings-card">
            <button
              className="aq-settings-close"
              aria-label="Close settings"
              onClick={() => setSettingsOpen(false)}
            >×</button>
            {/* Reuse the existing SettingsScreen component — its outer
                .st-content wrapper picks up modal-scoped overrides in
                styles.css so the layout fits a centered card. */}
            {window.SettingsScreen && <window.SettingsScreen />}
          </div>
        </div>
      ), document.body)}
    </header>
  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
