/* Slate — Analytics (Variant A layout)
   ===================================================
   Dense operator dashboard. Capability-gated widgets so the same
   component renders correctly for aquaos_admin, org_admin,
   site_manager, and marketing_admin (designer/operator never reach
   it; sidebar gating filters them out).

   Wired to:
     /api/analytics/metrics    — 6 KPI tiles (one round-trip)
     /api/analytics/campaigns  — top campaigns table
     /api/analytics/species    — top species table
     /api/analytics/events     — live event log seed
     /api/analytics/stream     — SSE for live updates

   Saved views + alerts + PDF export wire in Phase 1B / 1C; this is
   the foundation. */

const ANALYTICS_TIME_RANGES = [
  { id: '24h', label: '24h', hours: 24 },
  { id: '7d',  label: '7d',  hours: 24 * 7 },
  { id: '30d', label: '30d', hours: 24 * 30 },
  { id: '90d', label: '90d', hours: 24 * 90 },
];

/* Compute from/to ISO strings for a given range id. Used for both
   data-fetch query strings and for showing the picked period in the
   header. */
function rangeToDates(rangeId) {
  const range = ANALYTICS_TIME_RANGES.find((r) => r.id === rangeId) || ANALYTICS_TIME_RANGES[1];
  const to = new Date();
  const from = new Date(to.getTime() - range.hours * 3600 * 1000);
  return { from: from.toISOString(), to: to.toISOString(), label: range.label };
}

/* Compact-number formatter so 147382 reads as "147,382" (CMS default
   British formatting). For the KPI tiles where space is tight, we
   keep them un-abbreviated — operators want exact counts. */
function fmtN(n) {
  const v = Number(n) || 0;
  return v.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
function fmtPct(n, digits = 1) {
  const v = Number(n) || 0;
  return v.toFixed(digits) + '%';
}
function fmtCurrency(cents, currency = 'GBP') {
  const v = (Number(cents) || 0) / 100;
  try {
    return v.toLocaleString(undefined, { style: 'currency', currency, maximumFractionDigits: 0 });
  } catch (_) { return '£' + v.toFixed(0); }
}
function fmtDelta(d, suffix = '%') {
  const v = Number(d) || 0;
  if (v === 0) return '— flat';
  const arrow = v > 0 ? '▲' : '▼';
  return `${arrow} ${Math.abs(v).toFixed(1)}${suffix}`;
}
function deltaTone(d) {
  if (!d) return 'flat';
  return d > 0 ? 'up' : 'dn';
}

function tryJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }

/* Generic multi-select chip — one chip in the filter row that pops a
   checkbox dropdown. Selected ids array is owned by the parent.
   Used for the site filter (with the same component ready to drop in
   for zones / campaigns once those backend filters land). */
function FilterChip({ label, allLabel, selected, options, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, []);
  const isAll = !selected || selected.length === 0;
  const chipLabel = isAll
    ? allLabel
    : (selected.length === 1
        ? (options.find((o) => o.id === selected[0]) || {}).label || (label + ' · 1')
        : `${label} · ${selected.length}`);
  function toggle(id) {
    if (selected.includes(id)) onChange(selected.filter((x) => x !== id));
    else onChange([...selected, id]);
  }
  return (
    <span ref={ref} style={{ position: 'relative' }}>
      <span className={'x-analytics-chip' + (isAll ? ' is-active' : '')}
            onClick={() => setOpen((o) => !o)} style={{ cursor: 'pointer' }}>
        {chipLabel}
      </span>
      {open && (
        <div style={{
          position: 'absolute', top: 'calc(100% + 6px)', left: 0, minWidth: 220, zIndex: 50,
          background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
          borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 4,
          maxHeight: 320, overflowY: 'auto',
        }}>
          <a onClick={() => { onChange([]); setOpen(false); }}
             style={menuRowStyle(isAll)}>{allLabel}</a>
          <div style={{ height: 1, background: 'var(--aqos-border)', margin: '4px 0' }} />
          {options.length === 0 && (
            <div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--aqos-text-faint)' }}>
              No options yet.
            </div>
          )}
          {options.map((o) => (
            <label key={o.id} style={{
              display: 'flex', alignItems: 'center', gap: 8,
              padding: '6px 10px', fontSize: 12.5, cursor: 'pointer',
              borderRadius: 4,
            }}>
              <input type="checkbox" checked={selected.includes(o.id)}
                     onChange={() => toggle(o.id)} />
              <span>{o.label}</span>
            </label>
          ))}
        </div>
      )}
    </span>
  );
}

/* Comparison label — what the toolbar chip shows. The "kind" is
   what gets sent to the backend; the label is human-readable. */
function compareLabel(value, sites) {
  if (!value) return 'vs previous period';
  if (value === 'last_year') return 'vs same period last year';
  if (value.startsWith('site:')) {
    const id = value.slice(5);
    const s = (sites || []).find((x) => x.id === id);
    return 'vs ' + (s ? s.name : 'site');
  }
  return 'vs previous period';
}

/* Comparison picker dropdown. Shows the three modes: previous period
   (no comparison; default), same period last year, or any single site
   in the user's org. Click outside closes. */
function CompareMenu({ value, sites, onPick, onClose }) {
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) onClose(); }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [onClose]);
  return (
    <div ref={ref} style={{
      position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 240, zIndex: 50,
      background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
      borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 4,
    }}>
      <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.4 }}>
        Compare deltas to
      </div>
      <a onClick={() => onPick(null)}        style={menuRowStyle(value === null)}>Previous period (default)</a>
      <a onClick={() => onPick('last_year')} style={menuRowStyle(value === 'last_year')}>Same period last year</a>
      {sites && sites.length > 0 && (
        <Fragment>
          <div style={{ height: 1, background: 'var(--aqos-border)', margin: '4px 0' }} />
          <div style={{ padding: '6px 10px', fontSize: 10.5, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.4 }}>
            vs site
          </div>
          {sites.map((s) => (
            <a key={s.id} onClick={() => onPick('site:' + s.id)} style={menuRowStyle(value === 'site:' + s.id)}>{s.name}</a>
          ))}
        </Fragment>
      )}
    </div>
  );
}
function menuRowStyle(active) {
  return {
    display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer',
    background: active ? 'var(--aqos-surface-2)' : 'transparent',
    color: active ? 'var(--aqos-text)' : 'var(--aqos-text-dim)',
    fontWeight: active ? 600 : 400,
    borderRadius: 4,
  };
}

/* SVG sparkline — kept inline here so the component file is fully
   self-contained. ~30 lines. Accepts an array of numbers, fits to
   a fixed viewBox, applies a stroke colour. */
function Sparkline({ data, color = 'oklch(0.66 0.16 270)', height = 22 }) {
  const values = (data && data.length) ? data : [0, 0];
  const max = Math.max(...values, 1);
  const min = Math.min(...values, 0);
  const range = max - min || 1;
  const stepX = 100 / Math.max(1, values.length - 1);
  const points = values.map((v, i) => {
    const x = i * stepX;
    const y = 22 - ((v - min) / range) * 18 - 2;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  return (
    <svg viewBox="0 0 100 22" preserveAspectRatio="none" style={{ width: '100%', height, opacity: 0.85 }}>
      <polyline fill="none" stroke={color} strokeWidth="1.4" points={points} />
    </svg>
  );
}

/* One KPI tile. Hidden if `gated` is false. `compareValue` + `compareDelta`
   are optional — when present, the tile renders a tiny secondary line
   under the primary delta showing what the user is comparing against
   (e.g. "vs site B: 4,200 (▲ 12.5%)"). */
function AnalyticsKpi({ label, value, delta, deltaSuffix, sparkline, sparkColor, gated = true, hint, compareValue, compareDelta, compareLabel: cmpLabel }) {
  if (!gated) return null;
  const tone = deltaTone(delta);
  const cmpTone = deltaTone(compareDelta);
  return (
    <div className="x-analytics-kpi">
      <div className="x-analytics-kpi-label">{label}</div>
      <div className="x-analytics-kpi-value">{value}</div>
      <div className={`x-analytics-kpi-delta x-analytics-kpi-delta--${tone}`}>{hint || fmtDelta(delta, deltaSuffix || '%')}</div>
      {compareValue != null && (
        <div style={{ fontSize: 10.5, color: 'var(--aqos-text-faint)', marginTop: 2 }}>
          {cmpLabel}: <strong style={{ color: 'var(--aqos-text-dim)' }}>{compareValue}</strong>
          <span style={{ marginLeft: 6, color: cmpTone === 'up' ? 'oklch(0.78 0.13 160)' : (cmpTone === 'dn' ? 'var(--aqos-danger)' : 'var(--aqos-text-faint)') }}>
            {fmtDelta(compareDelta, deltaSuffix || '%')}
          </span>
        </div>
      )}
      <Sparkline data={sparkline || []} color={sparkColor} />
    </div>
  );
}

/* Top-N table — campaigns or species. Same shape; columns differ.
   onRowClick is optional — when provided rows become drill-down links
   (used to jump to #analytics-campaign/:id and friends). */
function AnalyticsTable({ title, sortHint, columns, rows, exportName, canExport, onExport, emptyHint, onRowClick }) {
  const total = rows.reduce((acc, r) => acc + (r._sortValue || 0), 0);
  return (
    <div className="x-analytics-panel">
      <div className="x-analytics-panel-head">
        <span className="x-analytics-panel-title">{title}</span>
        <div className="x-analytics-panel-actions">
          <span style={{ color: 'var(--aqos-text-faint)' }}>Sort: {sortHint}</span>
          {canExport && <a onClick={() => onExport(exportName)} title="Download as CSV">↓ CSV</a>}
        </div>
      </div>
      <table className="x-analytics-table">
        <thead>
          <tr>
            {columns.map((c) => <th key={c.key} style={{ textAlign: c.align || 'left', width: c.width }}>{c.label}</th>)}
          </tr>
        </thead>
        <tbody>
          {rows.length === 0 && (
            <tr><td colSpan={columns.length} style={{ padding: '24px 14px', color: 'var(--aqos-text-faint)', textAlign: 'center', fontSize: 12 }}>
              {emptyHint || 'No data in this range yet.'}
            </td></tr>
          )}
          {rows.map((r, i) => (
            <tr key={r.id || i}
                onClick={onRowClick ? () => onRowClick(r) : undefined}
                style={onRowClick ? { cursor: 'pointer' } : null}>
              {columns.map((c) => (
                <td key={c.key} className={c.numeric ? 'x-analytics-table-num' : ''} style={{ textAlign: c.align || 'left' }}>
                  {c.render ? c.render(r) : r[c.key]}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

/* Live event log row — colour-coded by type. */
function EventRow({ ev }) {
  const t = ev.type || '';
  let colorClass = 'x-analytics-ev-other';
  if (t === 'qr.scan') colorClass = 'x-analytics-ev-scan';
  else if (t === 'sale.attributed') colorClass = 'x-analytics-ev-sale';
  else if (t === 'alert.fired') colorClass = 'x-analytics-ev-alert';
  return (
    <div className="x-analytics-log-row">
      <span className="x-analytics-log-t">{(ev.ts || '').slice(11, 19)}</span>
      <span className={'x-analytics-log-ev ' + colorClass}>{t}</span>
      <span className="x-analytics-log-scope">{ev.scope || '—'}</span>
      <span className="x-analytics-log-who">{ev.label}</span>
      <span className="x-analytics-log-val">{ev.value}</span>
    </div>
  );
}

/* Tiny utility — convert array of objects to CSV string, then trigger
   a browser download. Inline to avoid pulling in a CSV library. */
function downloadCsv(filename, rows) {
  if (!rows || rows.length === 0) return;
  const cols = Object.keys(rows[0]);
  const escape = (v) => {
    const s = (v == null ? '' : String(v));
    if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
    return s;
  };
  const csv = [cols.join(',')]
    .concat(rows.map((r) => cols.map((c) => escape(r[c])).join(',')))
    .join('\n');
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename + '.csv';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

/* ─── Saved-views UI ────────────────────────────────────────────── */

/* Dropdown listing all reachable views for the current tab. Personal
   views show a 🧍 glyph; team-shared views show 👥 so users can tell
   them apart at a glance. Owner-only delete (the API enforces it
   too). Click outside closes the menu. */
function SavedViewsMenu({ views, activeId, onSelect, onClear, onDelete, onCopy, onClose, meId }) {
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) onClose(); }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [onClose]);
  return (
    <div ref={ref} className="x-analytics-views-menu" style={{
      position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 280, zIndex: 50,
      background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
      borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 4,
    }}>
      <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.4 }}>
        Saved views
      </div>
      {views.length === 0 && (
        <div style={{ padding: '12px 10px', fontSize: 12, color: 'var(--aqos-text-dim)' }}>
          No saved views yet. Click "Save view" to capture this snapshot.
        </div>
      )}
      {views.map((v) => {
        const isActive = v.id === activeId;
        const isMine = v.user_id === meId;
        return (
          <div key={v.id} className={'x-analytics-views-row' + (isActive ? ' is-active' : '')} style={{
            display: 'flex', alignItems: 'center', gap: 6,
            padding: '6px 8px', borderRadius: 6, cursor: 'pointer',
            background: isActive ? 'var(--aqos-surface-2)' : 'transparent',
          }}>
            <span style={{ flex: 1 }} onClick={() => onSelect(v.id)}>
              <span style={{ marginRight: 6 }}>{v.is_shared ? '👥' : '🧍'}</span>
              <strong style={{ fontSize: 12.5 }}>{v.name}</strong>
              {(v.config && v.config.range) && <span style={{ marginLeft: 6, color: 'var(--aqos-text-faint)', fontSize: 11 }}>· {v.config.range}</span>}
            </span>
            <a onClick={(e) => { e.stopPropagation(); onCopy(v.id); }} title="Copy share link" style={{ fontSize: 11, color: 'var(--aqos-text-dim)' }}>↗</a>
            {isMine && (
              <a onClick={(e) => { e.stopPropagation(); onDelete(v.id); }} title="Delete" style={{ fontSize: 11, color: 'var(--aqos-danger)' }}>✕</a>
            )}
          </div>
        );
      })}
      {activeId && (
        <div style={{ borderTop: '1px solid var(--aqos-border)', marginTop: 4 }}>
          <a onClick={onClear} style={{ display: 'block', padding: '8px 10px', fontSize: 12, color: 'var(--aqos-text-dim)', cursor: 'pointer' }}>
            Clear active view
          </a>
        </div>
      )}
    </div>
  );
}

/* Centred modal — same pattern other CMS modals use. Single text
   input + a "share with team" toggle that's hidden when the user
   doesn't have analytics.share_views. */
function SaveViewModal({ defaultName, canShare, onSave, onCancel }) {
  const [name, setName] = useState(defaultName || '');
  const [shared, setShared] = useState(false);
  const [busy, setBusy] = useState(false);
  function submit(e) {
    if (e) e.preventDefault();
    if (!name.trim() || busy) return;
    setBusy(true);
    Promise.resolve(onSave(name.trim(), shared)).finally(() => setBusy(false));
  }
  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 200,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={onCancel}>
      <form onSubmit={submit} onClick={(e) => e.stopPropagation()} style={{
        width: 380, background: 'var(--aqos-surface)', borderRadius: 10,
        border: '1px solid var(--aqos-border)', padding: 18,
        boxShadow: '0 20px 50px rgba(0,0,0,0.35)',
      }}>
        <h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Save analytics view</h2>
        <label style={{ display: 'block', fontSize: 11, color: 'var(--aqos-text-dim)', marginBottom: 4 }}>Name</label>
        <input
          autoFocus
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="e.g. Weekly ops review"
          style={{
            width: '100%', padding: '8px 10px', fontSize: 13,
            background: 'var(--aqos-surface-2)', color: 'var(--aqos-text)',
            border: '1px solid var(--aqos-border)', borderRadius: 6,
          }}
        />
        <p style={{ fontSize: 11, color: 'var(--aqos-text-faint)', marginTop: 8 }}>
          Captures the current time range and filters. Open it later from the Views menu.
        </p>
        {canShare && (
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5, cursor: 'pointer' }}>
            <input type="checkbox" checked={shared} onChange={(e) => setShared(e.target.checked)} />
            Share with my team (visible to everyone in this org)
          </label>
        )}
        <div style={{ marginTop: 14, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
          <button type="button" className="x-btn ghost" onClick={onCancel}>Cancel</button>
          <button type="submit" className="x-btn" disabled={busy || !name.trim()}>
            {busy ? 'Saving…' : 'Save view'}
          </button>
        </div>
      </form>
    </div>
  );
}

/* Multi-format export menu — drops a small dropdown next to the chip
   with CSV / XLSX / JSON options. Each option exports a bundle of
   (metrics + campaigns + species + events) for the current range. */
function ExportMenu({ range, metrics, campaigns, species, events }) {
  const [open, setOpen] = useState(false);
  const [showSchedule, setShowSchedule] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, []);
  const bundle = {
    range, exported_at: new Date().toISOString(),
    metrics, campaigns, species, events,
  };
  function exportJson() {
    const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
    triggerDownload(blob, `analytics-overview-${range}.json`);
    setOpen(false);
  }
  function exportXlsx() {
    if (!window.XLSX) {
      window.toast && window.toast('Spreadsheet library still loading — try again in a moment.', 'warn');
      return;
    }
    const wb = window.XLSX.utils.book_new();
    /* Metrics sheet — flatten the nested KPI object into a single row. */
    const flat = {};
    Object.entries(metrics || {}).forEach(([k, v]) => {
      if (v && typeof v === 'object') {
        Object.entries(v).forEach(([kk, vv]) => {
          if (Array.isArray(vv)) return; // skip sparkline arrays
          flat[`${k}.${kk}`] = vv;
        });
      } else {
        flat[k] = v;
      }
    });
    window.XLSX.utils.book_append_sheet(wb, window.XLSX.utils.json_to_sheet([flat]), 'Metrics');
    window.XLSX.utils.book_append_sheet(wb, window.XLSX.utils.json_to_sheet(campaigns || []), 'Campaigns');
    window.XLSX.utils.book_append_sheet(wb, window.XLSX.utils.json_to_sheet(species || []), 'Species');
    window.XLSX.utils.book_append_sheet(wb, window.XLSX.utils.json_to_sheet(events || []), 'Events');
    window.XLSX.writeFile(wb, `analytics-overview-${range}.xlsx`);
    setOpen(false);
  }
  function exportCsv() {
    /* "All CSV" of the metrics row only — same as the old chip behaviour. */
    downloadCsv('analytics-overview-' + range, [{ range, ...flatMetrics(metrics) }]);
    setOpen(false);
  }
  /* PDF report — opens the server-rendered HTML report in a new tab with
     ?print=1 so the browser's print dialog auto-fires. The user picks
     "Save as PDF". Auth uses the httpOnly cookie which is same-origin
     and automatic on window.open() — the ?token= query fallback was
     removed (audit CRIT-4) because tokens in URLs end up in access
     logs and browser history. */
  function exportPdf() {
    /* Cookies are httpOnly + same-origin, so window.open carries auth
       without us having to embed the JWT in the URL (which would leak
       to access logs and browser history). */
    const { from, to } = rangeToDates(range);
    const sp = new URLSearchParams({ from, to, print: '1' });
    window.open('/api/analytics/report.html?' + sp.toString(), '_blank');
    setOpen(false);
  }
  return (
    <span ref={ref} style={{ position: 'relative' }}>
      <span className="x-analytics-chip" onClick={() => setOpen((o) => !o)} style={{ cursor: 'pointer' }}>
        ↓ Export
      </span>
      {open && (
        <div style={{
          position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 180, zIndex: 50,
          background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
          borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 4,
        }}>
          <a onClick={exportCsv}  style={{ display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer' }}>↓ CSV (overview row)</a>
          <a onClick={exportXlsx} style={{ display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer' }}>↓ Excel (.xlsx, full)</a>
          <a onClick={exportJson} style={{ display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer' }}>↓ JSON (full)</a>
          <div style={{ height: 1, background: 'var(--aqos-border)', margin: '4px 0' }} />
          <a onClick={exportPdf}  style={{ display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer' }}>↓ PDF report (editorial)</a>
          {window.ScheduledReportModal && (
            <a onClick={() => { setShowSchedule(true); setOpen(false); }}
               style={{ display: 'block', padding: '8px 10px', fontSize: 12, cursor: 'pointer' }}>
              ⏱ Schedule report…
            </a>
          )}
        </div>
      )}
      {showSchedule && window.ScheduledReportModal && (
        <window.ScheduledReportModal
          initial={{ range_id: range, name: 'Recurring report', cadence: 'weekly' }}
          onCancel={() => setShowSchedule(false)}
          onSaved={() => {
            setShowSchedule(false);
            window.toast && window.toast('Scheduled report saved — manage it under Alerts → Scheduled reports.');
          }}
        />
      )}
    </span>
  );
}
function flatMetrics(metrics) {
  const out = {};
  Object.entries(metrics || {}).forEach(([k, v]) => {
    if (v && typeof v === 'object' && 'value' in v) out[k] = v.value;
    else if (v != null && typeof v !== 'object') out[k] = v;
  });
  return out;
}
function triggerDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

/* ─── Engagement panel ────────────────────────────────────────────
   Shows passive-viewing signals — average dwell per species, hold rate,
   total holds. Sources from /api/analytics/engagement which itself reads
   the kiosk-emitted species.exposure + species.hold events. Renders
   nothing if there's no data yet (so the panel doesn't shout at
   operators of orgs that haven't shipped the kiosk update). */
function EngagementPanel({ data, loading, range }) {
  if (loading) return null;
  if (!data || !data.totals) return null;
  const totals = data.totals;
  const species = data.species || [];
  /* Hide the whole panel until we have at least one exposure recorded.
     New deployments need 24h or so before this becomes useful. */
  if (totals.exposures === 0) return null;
  function fmtMs(ms) {
    const v = Number(ms) || 0;
    if (v < 1000) return v + ' ms';
    if (v < 60000) return (v / 1000).toFixed(1) + ' s';
    return Math.floor(v / 60000) + 'm ' + Math.round((v % 60000) / 1000) + 's';
  }
  return (
    <div className="x-analytics-panel" style={{ marginTop: 16 }}>
      <div className="x-analytics-panel-head">
        <span className="x-analytics-panel-title">
          🤚 Passive engagement
          <span style={{ marginLeft: 8, color: 'var(--aqos-text-faint)', fontWeight: 400, fontSize: 11 }}>
            What visitors actually watch (no tap required)
          </span>
        </span>
        <div className="x-analytics-panel-actions">
          <span style={{ color: 'var(--aqos-text-dim)' }}>
            {fmtN(totals.exposures)} exposures · {fmtN(totals.holds)} holds ({fmtPct(totals.hold_rate_pct, 1)} hold rate)
          </span>
        </div>
      </div>
      {/* Top species by hold rate */}
      <table className="x-analytics-table">
        <thead>
          <tr>
            <th>Species</th>
            <th style={{ textAlign: 'right' }}>Avg time on screen</th>
            <th style={{ textAlign: 'right' }}>Holds</th>
            <th style={{ textAlign: 'right' }}>Hold rate</th>
            <th style={{ textAlign: 'right' }}>Avg hold</th>
          </tr>
        </thead>
        <tbody>
          {species.length === 0 && (
            <tr><td colSpan={5} style={{ padding: '24px 14px', color: 'var(--aqos-text-faint)', textAlign: 'center', fontSize: 12 }}>
              Not enough engagement data yet — check back after a few hours of visitor activity.
            </td></tr>
          )}
          {species.map((s) => {
            const isHigh = s.hold_rate_pct >= totals.hold_rate_pct * 1.5;
            const isLow  = s.hold_rate_pct < totals.hold_rate_pct * 0.5;
            return (
              <tr key={s.species_id}
                  onClick={() => { window.location.hash = `#analytics-engagement/${s.species_id}?range=${range}`; }}
                  style={{ cursor: 'pointer' }}
                  title="Open engagement detail">
                <td>
                  {s.emoji && <span style={{ marginRight: 6 }}>{s.emoji}</span>}
                  <strong>{s.common_name || s.scientific_name || '—'}</strong>
                </td>
                <td className="x-analytics-table-num">{fmtMs(s.avg_exposure_ms)}</td>
                <td className="x-analytics-table-num">{fmtN(s.hold_count)}</td>
                <td className="x-analytics-table-num">
                  <span style={{
                    color: isHigh ? 'oklch(0.78 0.13 160)' : (isLow ? 'var(--aqos-text-faint)' : 'var(--aqos-text-dim)'),
                    fontWeight: isHigh ? 600 : 400,
                  }}>{fmtPct(s.hold_rate_pct, 1)}</span>
                </td>
                <td className="x-analytics-table-num">{fmtMs(s.avg_hold_ms)}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

/* ─── AR Live usage panel ──────────────────────────────────────── */
/* Shows visitor engagement with the AR Live mobile feature: sessions
 * today / this week, median session duration, accuracy from visitor
 * thumbs-up/down, and the top species spotted in the last 7 days.
 * Data comes from /api/analytics/ar/summary which aggregates over
 * ar_sessions / ar_frame_metrics / ar_feedback (see migration v59).
 */
function ARLivePanel() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    apiFetch('/api/analytics/ar/summary')
      .then((r) => { if (!cancelled) { setData(r); setErr(null); } })
      .catch((e) => { if (!cancelled) setErr(e.message || 'failed'); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, []);

  // Hide entirely if there's been no AR usage at all — venues that
  // haven't installed visitor mobile QR yet shouldn't see an empty card.
  if (!loading && data && data.sessions_this_week === 0 && data.sessions_today === 0) {
    return null;
  }

  const fmtDur = (ms) => {
    if (ms == null) return '—';
    if (ms < 60000) return Math.round(ms / 1000) + 's';
    return (ms / 60000).toFixed(1) + 'm';
  };
  const accPct = data?.accuracy?.ratio != null
    ? Math.round(data.accuracy.ratio * 100) + '%'
    : '—';
  const accHint = data?.accuracy
    ? `${data.accuracy.correct} ✓ · ${data.accuracy.incorrect} ✗`
    : 'No feedback yet';

  return (
    <div className="x-analytics-panel" style={{ marginTop: 16 }}>
      <div className="x-analytics-panel-head">
        <span className="x-analytics-panel-title">
          AR Live usage
          <span style={{
            marginLeft: 8, fontSize: 9, fontWeight: 700, letterSpacing: '0.1em',
            background: 'linear-gradient(135deg,#ff5050,#ff8060)', color: 'white',
            padding: '2px 6px', borderRadius: 4, verticalAlign: 'middle',
          }}>NEW</span>
        </span>
        <div className="x-analytics-panel-actions">
          <span style={{ color: 'var(--aqos-text-faint)', fontSize: 11 }}>Last 7 days</span>
        </div>
      </div>
      {loading && (
        <div style={{ padding: '24px 14px', textAlign: 'center', color: 'var(--aqos-text-faint)', fontSize: 12 }}>
          Loading…
        </div>
      )}
      {err && !loading && (
        <div style={{ padding: '14px', color: 'var(--aqos-danger)', fontSize: 12.5 }}>
          Couldn't load AR usage: {err}
        </div>
      )}
      {data && !loading && !err && (
        <div style={{ padding: '8px 4px 12px' }}>
          {/* KPI strip — 4 tiles, same shape as the main dashboard */}
          <div style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(4, 1fr)', gap: 10,
            padding: '0 10px 10px',
          }}>
            <div className="x-analytics-kpi">
              <div className="x-analytics-kpi-label">Sessions today</div>
              <div className="x-analytics-kpi-value">{data.sessions_today || 0}</div>
              <div className="x-analytics-kpi-delta x-analytics-kpi-delta--zero">
                {data.sessions_this_week || 0} this week
              </div>
            </div>
            <div className="x-analytics-kpi">
              <div className="x-analytics-kpi-label">Median session</div>
              <div className="x-analytics-kpi-value">{fmtDur(data.median_duration_ms)}</div>
              <div className="x-analytics-kpi-delta x-analytics-kpi-delta--zero">duration</div>
            </div>
            <div className="x-analytics-kpi">
              <div className="x-analytics-kpi-label">Species spotted</div>
              <div className="x-analytics-kpi-value">{data.total_species_spotted || 0}</div>
              <div className="x-analytics-kpi-delta x-analytics-kpi-delta--zero">
                across all sessions
              </div>
            </div>
            <div className="x-analytics-kpi">
              <div className="x-analytics-kpi-label">Accuracy</div>
              <div className="x-analytics-kpi-value">{accPct}</div>
              <div className="x-analytics-kpi-delta x-analytics-kpi-delta--zero">{accHint}</div>
            </div>
          </div>

          {/* Top species spotted — horizontal pill rail */}
          {data.top_species && data.top_species.length > 0 && (
            <div style={{ padding: '10px 14px 4px' }}>
              <div style={{
                fontSize: 11, fontWeight: 500, letterSpacing: '0.6px',
                textTransform: 'uppercase', color: 'var(--aqos-text-faint)',
                marginBottom: 8,
              }}>
                Top spotted
              </div>
              <div style={{
                display: 'flex', flexWrap: 'wrap', gap: 6,
              }}>
                {data.top_species.map((s) => (
                  <span key={s.species_id} style={{
                    display: 'inline-flex', alignItems: 'center', gap: 6,
                    padding: '4px 10px', borderRadius: 999,
                    background: 'color-mix(in srgb, ' + (s.accent_color || '#6c8cff') + ' 14%, transparent)',
                    border: '1px solid color-mix(in srgb, ' + (s.accent_color || '#6c8cff') + ' 35%, transparent)',
                    fontSize: 12, color: 'var(--aqos-text)',
                  }}>
                    <span style={{
                      width: 6, height: 6, borderRadius: '50%',
                      background: s.accent_color || '#6c8cff',
                    }} />
                    {s.common_name}
                    <span style={{ color: 'var(--aqos-text-dim)', marginLeft: 2 }}>· {s.count}</span>
                  </span>
                ))}
              </div>
            </div>
          )}

          {/* Error rate footnote — only shows if non-trivial */}
          {data.error_rate != null && data.error_rate > 0.05 && (
            <div style={{
              padding: '8px 14px', fontSize: 11.5,
              color: 'var(--aqos-warn, #ef9f27)',
            }}>
              ⚠ {Math.round(data.error_rate * 100)}% of sessions hit errors — investigate.
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ─── Insights card ────────────────────────────────────────────── */

/* AI-generated narrative briefing — sits above the KPI strip and
   summarises what the operator should pay attention to. Posts the
   current dashboard snapshot to /api/analytics/insights and renders
   the response. Dismissable per session via the X button. */
function InsightsCard({ range, metrics, campaigns, species }) {
  const [insight, setInsight] = useState(null);
  const [loading, setLoading] = useState(false);
  const [err, setErr] = useState(null);
  const [dismissed, setDismissed] = useState(() => {
    /* Per-session dismissal — remember within the tab via sessionStorage
       (NOT localStorage, so a fresh load shows insights again). */
    try { return sessionStorage.getItem('aquaos.analytics.insights.dismissed') === '1'; } catch (_) { return false; }
  });

  /* Recompute the insight when the dashboard data settles. We debounce
     by waiting until metrics is non-null and refetching whenever
     range/metrics change. */
  useEffect(() => {
    if (dismissed) return;
    if (!metrics) return;
    let cancelled = false;
    setLoading(true); setErr(null);
    apiFetch('/api/analytics/insights', {
      method: 'POST',
      body: JSON.stringify({
        range,
        dashboardSnapshot: {
          range, periodLabel: range,
          metrics, campaigns, species,
        },
      }),
    })
      .then((r) => { if (!cancelled) { setInsight(r); setLoading(false); } })
      .catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
    return () => { cancelled = true; };
  }, [range, dismissed, metrics && metrics.impressions && metrics.impressions.value]);

  if (dismissed) return null;
  if (err && err.includes('ANTHROPIC_API_KEY')) return null; // silent skip when AI not configured

  function regen() {
    setLoading(true); setErr(null);
    apiFetch('/api/analytics/insights?refresh=1', {
      method: 'POST',
      body: JSON.stringify({
        range,
        dashboardSnapshot: { range, periodLabel: range, metrics, campaigns, species },
      }),
    })
      .then((r) => { setInsight(r); setLoading(false); })
      .catch((e) => { setErr(e.message); setLoading(false); });
  }
  function dismiss() {
    setDismissed(true);
    try { sessionStorage.setItem('aquaos.analytics.insights.dismissed', '1'); } catch (_) {}
  }

  return (
    <div style={{
      marginBottom: 16,
      padding: '14px 16px',
      background: 'linear-gradient(135deg, color-mix(in srgb, oklch(0.66 0.16 270) 8%, var(--aqos-surface)) 0%, var(--aqos-surface) 100%)',
      border: '1px solid var(--aqos-border)',
      borderLeft: '3px solid oklch(0.66 0.16 270)',
      borderRadius: 8,
      display: 'grid', gridTemplateColumns: '1fr auto', gap: 12,
    }}>
      <div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
          <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.5, color: 'oklch(0.66 0.16 270)' }}>
            ✨ AI BRIEFING
          </span>
          {insight && <strong style={{ fontSize: 13 }}>{insight.headline}</strong>}
          {loading && <span style={{ fontSize: 11, color: 'var(--aqos-text-faint)' }}>generating…</span>}
        </div>
        {insight && !loading && (
          <div style={{ color: 'var(--aqos-text-dim)', fontSize: 12.5, lineHeight: 1.55 }}>
            {insight.body}
          </div>
        )}
        {err && !loading && (
          <div style={{ color: 'var(--aqos-text-faint)', fontSize: 11.5 }}>
            Couldn't generate insights: {err}
          </div>
        )}
        {insight && (
          <div style={{ marginTop: 6, fontSize: 10.5, color: 'var(--aqos-text-faint)' }}>
            {insight.cached ? 'cached' : 'fresh'} · {insight.model} · generated {insight.generated_at ? new Date(insight.generated_at).toLocaleTimeString() : '—'}
          </div>
        )}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
        <a onClick={regen} style={{ fontSize: 11, color: 'var(--aqos-text-dim)', cursor: 'pointer' }} title="Regenerate (uses a fresh prompt)">↻ Refresh</a>
        <a onClick={dismiss} style={{ fontSize: 11, color: 'var(--aqos-text-faint)', cursor: 'pointer' }} title="Hide for this session">✕ Hide</a>
      </div>
    </div>
  );
}

/* ─── Annotation modal ─────────────────────────────────────────── */

const ANNOTATION_KINDS = [
  { id: 'deploy',   label: 'Deploy',         color: '#7aa2f7' },
  { id: 'campaign', label: 'Campaign launch', color: '#4cc9c0' },
  { id: 'incident', label: 'Incident',        color: '#ec7479' },
  { id: 'other',    label: 'Other',           color: '#9ca3af' },
];

function AnnotationModal({ onCancel, onSaved, initial }) {
  const isEdit = !!(initial && initial.id);
  /* Default to "now" in the user's local timezone — easier than UTC
     for the human authoring this. We convert to ISO on submit.
     For edit: parse the existing UTC timestamp into a local-time string
     for the datetime-local input. */
  const pad2 = (n) => String(n).padStart(2, '0');
  function isoToLocalInput(iso) {
    const d = new Date(iso);
    return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
  }
  const defaultDt = isEdit ? isoToLocalInput(initial.occurred_at) : isoToLocalInput(new Date().toISOString());

  const [occurredAt, setOccurredAt] = useState(defaultDt);
  const [label, setLabel] = useState((initial && initial.label) || '');
  const [kind, setKind] = useState((initial && initial.kind) || 'deploy');
  const [description, setDescription] = useState((initial && initial.description) || '');
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  function submit(e) {
    if (e) e.preventDefault();
    if (!label.trim()) { setErr('Label required'); return; }
    setBusy(true); setErr(null);
    const k = ANNOTATION_KINDS.find((x) => x.id === kind) || ANNOTATION_KINDS[0];
    const body = JSON.stringify({
      occurred_at: new Date(occurredAt).toISOString(),
      label: label.trim(),
      kind, color: k.color,
      description: description.trim() || null,
    });
    const req = isEdit
      ? apiFetch('/api/analytics/annotations/' + encodeURIComponent(initial.id), { method: 'PATCH', body })
      : apiFetch('/api/analytics/annotations', { method: 'POST', body });
    req.then(() => { setBusy(false); onSaved(); })
       .catch((e) => { setBusy(false); setErr(e.message); });
  }
  function deleteAnno() {
    if (!isEdit) return;
    if (!window.confirm(`Delete annotation "${initial.label}"?`)) return;
    apiFetch('/api/analytics/annotations/' + encodeURIComponent(initial.id), { method: 'DELETE' })
      .then(() => onSaved())
      .catch((e) => setErr(e.message));
  }

  const inputStyleLocal = {
    width: '100%', padding: '8px 10px', fontSize: 13,
    background: 'var(--aqos-surface-2)', color: 'var(--aqos-text)',
    border: '1px solid var(--aqos-border)', borderRadius: 6,
    boxSizing: 'border-box',
  };

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 200,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={onCancel}>
      <form onSubmit={submit} onClick={(e) => e.stopPropagation()} style={{
        width: 460, background: 'var(--aqos-surface)', borderRadius: 10,
        border: '1px solid var(--aqos-border)', padding: 22,
        boxShadow: '0 20px 50px rgba(0,0,0,0.35)',
      }}>
        <h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>{isEdit ? 'Edit annotation' : 'Add annotation'}</h2>
        <p style={{ fontSize: 12, color: 'var(--aqos-text-faint)', marginBottom: 14 }}>
          Marks a moment on every timeline chart in this org's analytics — useful for explaining sudden changes.
        </p>

        <label style={{ display: 'block', marginBottom: 12 }}>
          <div style={{ fontSize: 11, color: 'var(--aqos-text-dim)', marginBottom: 4 }}>Label</div>
          <input value={label} onChange={(e) => setLabel(e.target.value)}
                 placeholder="e.g. Lobby tank cleaning · Summer kids campaign launch" style={inputStyleLocal} autoFocus />
        </label>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          <label style={{ display: 'block', marginBottom: 12 }}>
            <div style={{ fontSize: 11, color: 'var(--aqos-text-dim)', marginBottom: 4 }}>When</div>
            <input type="datetime-local" value={occurredAt} onChange={(e) => setOccurredAt(e.target.value)} style={inputStyleLocal} />
          </label>
          <label style={{ display: 'block', marginBottom: 12 }}>
            <div style={{ fontSize: 11, color: 'var(--aqos-text-dim)', marginBottom: 4 }}>Kind</div>
            <select value={kind} onChange={(e) => setKind(e.target.value)} style={inputStyleLocal}>
              {ANNOTATION_KINDS.map((k) => <option key={k.id} value={k.id}>{k.label}</option>)}
            </select>
          </label>
        </div>

        <label style={{ display: 'block', marginBottom: 8 }}>
          <div style={{ fontSize: 11, color: 'var(--aqos-text-dim)', marginBottom: 4 }}>Description (optional)</div>
          <textarea value={description} onChange={(e) => setDescription(e.target.value)}
                    placeholder="What happened? What should viewers know?"
                    style={{ ...inputStyleLocal, minHeight: 60, resize: 'vertical' }} />
        </label>

        {err && (
          <div style={{
            marginTop: 8, padding: '8px 12px', fontSize: 12.5,
            background: 'color-mix(in srgb, var(--aqos-danger) 12%, transparent)',
            color: 'var(--aqos-danger)', borderRadius: 6,
          }}>{err}</div>
        )}

        <div style={{ marginTop: 14, display: 'flex', justifyContent: isEdit ? 'space-between' : 'flex-end', gap: 8 }}>
          {isEdit && <button type="button" className="x-btn" onClick={deleteAnno} style={{ background: 'var(--aqos-danger)' }}>Delete</button>}
          <div style={{ display: 'flex', gap: 8 }}>
            <button type="button" className="x-btn ghost" onClick={onCancel}>Cancel</button>
            <button type="submit" className="x-btn" disabled={busy || !label.trim()}>
              {busy ? 'Saving…' : (isEdit ? 'Save changes' : 'Add annotation')}
            </button>
          </div>
        </div>
      </form>
    </div>
  );
}

/* ─── Annotation list modal ───────────────────────────────────────
   One-stop view for managing all annotations chronologically. Pulls
   a wide window (90 days back through now) so most are visible. Each
   row is clickable to edit (re-uses the AnnotationModal in edit mode
   via the same global event the timeline pins use). */
function AnnotationListModal({ onClose }) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [editing, setEditing] = useState(null);
  const canManage = !!(Auth.canManageAnalyticsAlerts && Auth.canManageAnalyticsAlerts());

  function load() {
    setLoading(true);
    /* Generous default window so old context-setting annotations don't
       drop off. If the org has > a year of usage this'll need
       pagination — flag for follow-up. */
    const now = new Date();
    const yearAgo = new Date(now.getTime() - 365 * 24 * 3600 * 1000);
    apiFetch('/api/analytics/annotations?from=' + encodeURIComponent(yearAgo.toISOString()) + '&to=' + encodeURIComponent(now.toISOString()))
      .then((r) => { setItems((r && r.annotations) || []); setLoading(false); })
      .catch(() => setLoading(false));
  }
  useEffect(load, []);

  function deleteItem(a) {
    if (!window.confirm(`Delete annotation "${a.label}"?`)) return;
    apiFetch('/api/analytics/annotations/' + encodeURIComponent(a.id), { method: 'DELETE' })
      .then(load)
      .catch((e) => window.toast && window.toast(e.message, 'error'));
  }

  return (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 200,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }} onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: 640, maxHeight: '80vh', display: 'flex', flexDirection: 'column',
        background: 'var(--aqos-surface)', borderRadius: 10,
        border: '1px solid var(--aqos-border)', padding: 18,
        boxShadow: '0 20px 50px rgba(0,0,0,0.35)',
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
          <h2 style={{ fontSize: 16, fontWeight: 600 }}>All annotations</h2>
          <span style={{ fontSize: 12, color: 'var(--aqos-text-faint)' }}>
            {loading ? 'Loading…' : `${items.length} in last 365 days`}
          </span>
        </div>

        <div style={{ overflowY: 'auto', flex: 1 }}>
          {!loading && items.length === 0 && (
            <div style={{ padding: 20, color: 'var(--aqos-text-faint)', textAlign: 'center', fontSize: 12.5 }}>
              No annotations yet. Use the "⏷ Annotate" chip to mark moments on the timeline.
            </div>
          )}
          {items.map((a) => (
            <div key={a.id} style={{
              padding: '8px 10px', borderBottom: '1px solid var(--aqos-border)',
              display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10,
            }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
                <span style={{
                  width: 8, height: 8, borderRadius: 4, flexShrink: 0,
                  background: a.color || '#9ca3af',
                }} />
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{ fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                    {a.label}
                    <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.4 }}>
                      {a.kind}
                    </span>
                  </div>
                  <div style={{ fontSize: 11, color: 'var(--aqos-text-faint)' }}>
                    {new Date(a.occurred_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
                    {a.site_name && <span> · {a.site_name}</span>}
                  </div>
                </div>
              </div>
              {canManage && (
                <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
                  <a onClick={() => setEditing(a)} style={{ fontSize: 11, cursor: 'pointer' }}>Edit</a>
                  <a onClick={() => deleteItem(a)} style={{ fontSize: 11, color: 'var(--aqos-danger)', cursor: 'pointer' }}>Delete</a>
                </div>
              )}
            </div>
          ))}
        </div>

        <div style={{ marginTop: 14, display: 'flex', justifyContent: 'flex-end' }}>
          <button type="button" className="x-btn" onClick={onClose}>Close</button>
        </div>

        {editing && (
          <AnnotationModal
            initial={editing}
            onCancel={() => setEditing(null)}
            onSaved={() => { setEditing(null); load(); }}
          />
        )}
      </div>
    </div>
  );
}

/* ─── Main component ─────────────────────────────────────────────── */

function AnalyticsScreen() {
  /* Capability check happens inside the layout shell so the user gets
     a friendly "no access" panel rather than a blank canvas if
     somehow they reached the URL. */
  const canView = Auth.canViewAnalytics && Auth.canViewAnalytics();
  const canRevenue = Auth.canViewAnalyticsRevenue && Auth.canViewAnalyticsRevenue();
  const canAlerts = Auth.canViewAnalyticsAlerts && Auth.canViewAnalyticsAlerts();
  const canExport = Auth.canExportAnalytics && Auth.canExportAnalytics();
  const canShare = Auth.canShareAnalyticsViews && Auth.canShareAnalyticsViews();
  const canPlatform = Auth.canViewPlatformAnalytics && Auth.canViewPlatformAnalytics();

  const [range, setRange] = useState('7d');
  const [metrics, setMetrics] = useState(null);
  const [campaigns, setCampaigns] = useState([]);
  const [species, setSpecies] = useState([]);
  const [events, setEvents] = useState([]);
  const [engagement, setEngagement] = useState(null);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState(null);
  const [sseLive, setSseLive] = useState(false);
  const [lastSyncAt, setLastSyncAt] = useState(null);

  /* ── Saved views state ────────────────────────────────────────────
     We store the list of all reachable views (own + team-shared from
     this org) plus the currently-active id (if any). The "tab" we
     scope to is 'overview' since that's what this dashboard is. */
  const [savedViews, setSavedViews] = useState([]);
  const [activeViewId, setActiveViewId] = useState(null);
  const [showSaveModal, setShowSaveModal] = useState(false);
  const [showViewsMenu, setShowViewsMenu] = useState(false);
  const [showAnnotation, setShowAnnotation] = useState(false);
  const [showAnnotationList, setShowAnnotationList] = useState(false);

  /* Comparison state — null means "no comparison" (KPI tiles show the
     usual prev-period delta). 'last_year' or 'site:<id>' overlays a
     second value on each tile. We persist this via setSitesAvailable
     for the picker too. */
  const [compareWith, setCompareWith] = useState(null);
  const [showCompareMenu, setShowCompareMenu] = useState(false);
  // Secondary toolbar actions (views, save, annotate, funnel, compare)
  // collapse behind one "Tools" dropdown so only Export + the time range
  // stay always-visible.
  const [showToolsMenu, setShowToolsMenu] = useState(false);
  const [sites, setSites] = useState([]);
  const [zones, setZones] = useState([]);
  const [screens, setScreens] = useState([]);
  const [campaignsList, setCampaignsList] = useState([]);
  const [selectedSites,     setSelectedSites]     = useState([]);
  const [selectedZones,     setSelectedZones]     = useState([]);
  const [selectedScreens,   setSelectedScreens]   = useState([]);
  const [selectedCampaigns, setSelectedCampaigns] = useState([]);

  useEffect(() => {
    /* Fetch the org's sites/zones/screens/campaigns once so the
       comparison + filter pickers can list them. /api/auth/sites is
       the canonical sites endpoint (mounted by routes/auth.js); the
       others mount under their own routers. All authenticated roles
       can read; we silently swallow 403s for restricted ones. */
    apiFetch('/api/auth/sites').then((r) => setSites((r && r.sites) || (Array.isArray(r) ? r : []))).catch(() => {});
    apiFetch('/api/zones').then((r) => setZones((r && r.zones) || [])).catch(() => {});
    apiFetch('/api/screens').then((r) => setScreens((r && r.screens) || [])).catch(() => {});
    apiFetch('/api/campaigns').then((r) => setCampaignsList((r && r.campaigns) || (Array.isArray(r) ? r : []))).catch(() => {});
  }, []);

  /* Read the initial range + saved-view from the URL hash on mount.
     A view-id wins over any explicit range — opening a saved view
     should always reproduce the same picture. */
  useEffect(() => {
    const raw = (window.location.hash || '').replace(/^#/, '').replace(/^\//, '');
    const [, qs] = raw.split('?');
    if (!qs) return;
    const sp = new URLSearchParams(qs);
    const r = sp.get('range');
    const v = sp.get('view');
    const cw = sp.get('compare_with');
    if (r && ANALYTICS_TIME_RANGES.some((x) => x.id === r)) setRange(r);
    if (v) setActiveViewId(v);
    if (cw) setCompareWith(cw);
    const sIds = sp.get('site_ids');     if (sIds) setSelectedSites(sIds.split(',').filter(Boolean));
    const zIds = sp.get('zone_ids');     if (zIds) setSelectedZones(zIds.split(',').filter(Boolean));
    const scIds = sp.get('screen_ids');  if (scIds) setSelectedScreens(scIds.split(',').filter(Boolean));
    const cIds = sp.get('campaign_ids'); if (cIds) setSelectedCampaigns(cIds.split(',').filter(Boolean));
  }, []);

  /* Load saved views once. */
  useEffect(() => {
    if (!canView) return;
    apiFetch('/api/analytics/saved-views?tab=overview')
      .then((r) => setSavedViews((r && r.views) || []))
      .catch(() => {});
  }, [canView]);

  /* When the activeViewId changes, apply the view's range (and any
     other saved knobs in `config`). The view is loaded from
     `savedViews` if already in memory, otherwise we'd need a fetch
     by id (not exposed yet). */
  useEffect(() => {
    if (!activeViewId) return;
    const v = savedViews.find((x) => x.id === activeViewId);
    if (!v) return;
    const cfg = v.config || tryJSON(v.config_json) || {};
    if (cfg.range && ANALYTICS_TIME_RANGES.some((x) => x.id === cfg.range)) {
      setRange(cfg.range);
    }
    if ('compareWith' in cfg) setCompareWith(cfg.compareWith || null);
    if (cfg.filters) {
      if (Array.isArray(cfg.filters.site_ids))     setSelectedSites(cfg.filters.site_ids);
      if (Array.isArray(cfg.filters.zone_ids))     setSelectedZones(cfg.filters.zone_ids);
      if (Array.isArray(cfg.filters.screen_ids))   setSelectedScreens(cfg.filters.screen_ids);
      if (Array.isArray(cfg.filters.campaign_ids)) setSelectedCampaigns(cfg.filters.campaign_ids);
    }
  }, [activeViewId, savedViews]);

  /* Keep the URL hash in sync whenever range or active view changes,
     so links to this dashboard reproduce the picture. We only touch
     the query-string portion — leave the hash head ("analytics") alone
     to avoid retriggering app-level routing. */
  useEffect(() => {
    const head = 'analytics';
    const sp = new URLSearchParams();
    if (activeViewId) sp.set('view', activeViewId);
    if (range && range !== '7d') sp.set('range', range);
    if (selectedSites.length > 0)     sp.set('site_ids',     selectedSites.join(','));
    if (selectedZones.length > 0)     sp.set('zone_ids',     selectedZones.join(','));
    if (selectedScreens.length > 0)   sp.set('screen_ids',   selectedScreens.join(','));
    if (selectedCampaigns.length > 0) sp.set('campaign_ids', selectedCampaigns.join(','));
    if (compareWith) sp.set('compare_with', compareWith);
    const qs = sp.toString();
    const next = '#' + head + (qs ? '?' + qs : '');
    if (window.location.hash !== next) {
      // Use replaceState so we don't pollute browser history with chip clicks.
      try { window.history.replaceState(null, '', next); } catch (_) { window.location.hash = next; }
    }
  }, [range, activeViewId]);

  function saveCurrentView(name, isShared) {
    if (!name) return;
    /* Snapshot every knob the user might want to recover when loading
       this view later: range chip, comparison mode, and all 4 filter
       dimensions. */
    const config = {
      range,
      compareWith,
      filters: {
        site_ids:     selectedSites,
        zone_ids:     selectedZones,
        screen_ids:   selectedScreens,
        campaign_ids: selectedCampaigns,
      },
    };
    return apiFetch('/api/analytics/saved-views', {
      method: 'POST',
      body: JSON.stringify({ name, tab: 'overview', config, is_shared: isShared && canShare ? 1 : 0 }),
    }).then((r) => {
      // Re-fetch list and select the new one.
      return apiFetch('/api/analytics/saved-views?tab=overview').then((list) => {
        setSavedViews((list && list.views) || []);
        if (r && r.id) setActiveViewId(r.id);
        setShowSaveModal(false);
      });
    }).catch((e) => {
      window.toast && window.toast('Couldn\'t save view: ' + e.message, 'error');
    });
  }
  function deleteView(id) {
    if (!id) return;
    if (!window.confirm('Delete this saved view?')) return;
    apiFetch('/api/analytics/saved-views/' + encodeURIComponent(id), { method: 'DELETE' })
      .then(() => {
        setSavedViews((prev) => prev.filter((v) => v.id !== id));
        if (activeViewId === id) setActiveViewId(null);
      })
      .catch((e) => window.toast && window.toast('Couldn\'t delete view: ' + e.message, 'error'));
  }
  function copyShareLink(id) {
    const sp = new URLSearchParams();
    sp.set('view', id);
    const url = window.location.origin + window.location.pathname + '#analytics?' + sp.toString();
    if (navigator.clipboard) {
      navigator.clipboard.writeText(url).then(
        () => window.toast && window.toast('Share link copied to clipboard'),
        () => window.prompt('Copy this link:', url)
      );
    } else {
      window.prompt('Copy this link:', url);
    }
  }

  /* Refresh non-stream data whenever the range changes. The SSE
     subscription handles live event-log updates separately. */
  useEffect(() => {
    if (!canView) return;
    let cancelled = false;
    setLoading(true);
    const { from, to } = rangeToDates(range);
    /* All four filter dimensions get appended as &<name>_ids=a,b,c.
       The backend siteScope() respects site_ids; parseFilters() in
       analytics-sql.js produces fragments for the others (used
       wherever a query needs them). */
    function listQs(name, ids) { return ids.length > 0 ? `&${name}_ids=${encodeURIComponent(ids.join(','))}` : ''; }
    const filterQs =
      listQs('site',     selectedSites) +
      listQs('zone',     selectedZones) +
      listQs('screen',   selectedScreens) +
      listQs('campaign', selectedCampaigns);
    const qs = `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` + filterQs;
    const metricsQs = qs + (compareWith ? `&compare_with=${encodeURIComponent(compareWith)}` : '');
    Promise.all([
      apiFetch('/api/analytics/metrics' + metricsQs).catch((e) => { setErr(e.message); return null; }),
      apiFetch('/api/analytics/campaigns' + qs + '&limit=5').catch(() => ({ campaigns: [] })),
      apiFetch('/api/analytics/species' + qs + '&limit=5').catch(() => ({ ranking: [] })),
      apiFetch('/api/analytics/events?limit=12' + filterQs).catch(() => ({ events: [] })),
      apiFetch('/api/analytics/engagement' + qs + '&limit=8').catch(() => null),
    ]).then(([m, cp, sp, ev, eng]) => {
      if (cancelled) return;
      setMetrics(m);
      setCampaigns((cp && cp.campaigns) || []);
      setSpecies((sp && sp.ranking) || []);
      setEvents((ev && ev.events) || []);
      setEngagement(eng);
      setLastSyncAt(new Date());
      setLoading(false);
    });
    return () => { cancelled = true; };
  }, [range, canView, compareWith,
      selectedSites.join(','), selectedZones.join(','),
      selectedScreens.join(','), selectedCampaigns.join(',')]);

  /* Server-Sent Events subscription — live event log. Reconnects
     automatically when the browser drops the connection (EventSource
     handles that). Auth uses the httpOnly cookie (withCredentials:
     true) — same-origin EventSource carries cookies automatically.
     The ?token= URL fallback was removed (audit CRIT-4) because
     tokens in URLs leak to access logs. */
  useEffect(() => {
    if (!canView) return;
    if (typeof window.EventSource === 'undefined') return; // No SSE? Skip; polling above keeps log fresh.
    let es;
    try {
      es = new EventSource('/api/analytics/stream', { withCredentials: true });
    } catch (_) { return; }
    es.onopen = () => setSseLive(true);
    es.onerror = () => setSseLive(false);
    es.onmessage = (msg) => {
      try {
        const data = JSON.parse(msg.data);
        if (data && data.events && data.events.length) {
          /* Merge: prepend new events, dedupe on ts+type+scope, cap. */
          setEvents((prev) => {
            const seen = new Set(prev.map((e) => e.ts + '|' + e.type + '|' + e.scope));
            const fresh = data.events
              .map((e) => ({
                ts: e.ts,
                type: e.type,
                scope: e.screen_code || '—',
                label: e.species_name || e.event_type || e.type,
                value: '',
              }))
              .filter((e) => !seen.has(e.ts + '|' + e.type + '|' + e.scope));
            return fresh.concat(prev).slice(0, 30);
          });
          setLastSyncAt(new Date());
        }
      } catch (_) {}
    };
    return () => { try { es.close(); } catch (_) {} setSseLive(false); };
  }, [canView]);

  /* Build the KPI tile config — derived from `metrics` and gated by
     capability flags. Order matches the Variant A mockup. The optional
     `compare` block produced by /metrics drops a secondary value into
     each tile when the user picked "vs same period last year" or
     "vs site X". */
  const m = metrics || {};
  const cmp = m.compare || null;
  const cmpLabelShort = cmp ? (cmp.kind === 'last_year' ? 'YoY' : 'site') : null;
  const kpis = [
    {
      label: 'Impressions',
      value: fmtN(m.impressions && m.impressions.value),
      delta: m.impressions && m.impressions.delta_pct,
      sparkline: m.impressions && m.impressions.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: true,
      compareValue: cmp ? fmtN(cmp.impressions && cmp.impressions.value) : null,
      compareDelta: cmp ? cmp.impressions && cmp.impressions.delta_pct : null,
      compareLabel: cmpLabelShort,
    },
    {
      label: 'Scans',
      value: fmtN(m.scans && m.scans.value),
      delta: m.scans && m.scans.delta_pct,
      sparkline: m.scans && m.scans.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: true,
      compareValue: cmp ? fmtN(cmp.scans && cmp.scans.value) : null,
      compareDelta: cmp ? cmp.scans && cmp.scans.delta_pct : null,
      compareLabel: cmpLabelShort,
    },
    {
      label: 'Scan rate',
      value: fmtPct(m.scan_rate && m.scan_rate.value, 2),
      delta: m.scan_rate && m.scan_rate.delta_pp,
      deltaSuffix: 'pp',
      sparkline: m.scan_rate && m.scan_rate.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: true,
      compareValue: cmp ? fmtPct(cmp.scan_rate && cmp.scan_rate.value, 2) : null,
      compareDelta: cmp ? cmp.scan_rate && cmp.scan_rate.delta_pp : null,
      compareLabel: cmpLabelShort,
    },
    {
      label: 'Screens online',
      value: m.screens_online ? `${m.screens_online.online} / ${m.screens_online.total}` : '— / —',
      hint: m.screens_online ? `${fmtPct(m.screens_online.uptime_pct, 1)} uptime` : '',
      sparkline: m.screens_online && m.screens_online.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: true,
    },
    {
      label: 'Active alerts',
      value: m.active_alerts ? String(m.active_alerts.count) : '0',
      hint: m.active_alerts ? `${m.active_alerts.fired_24h} fired in 24h` : '',
      sparkline: m.active_alerts && m.active_alerts.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: canAlerts,
    },
    {
      label: 'Attrib. revenue',
      value: fmtCurrency(m.revenue && m.revenue.cents),
      delta: m.revenue && m.revenue.delta_pct,
      sparkline: m.revenue && m.revenue.sparkline,
      sparkColor: 'rgba(255,255,255,0.22)',
      gated: canRevenue,
      compareValue: cmp ? fmtCurrency(cmp.revenue && cmp.revenue.cents) : null,
      compareDelta: cmp ? cmp.revenue && cmp.revenue.delta_pct : null,
      compareLabel: cmpLabelShort,
    },
  ];
  const visibleKpiCount = kpis.filter((k) => k.gated).length;

  function exportCampaigns() {
    downloadCsv('analytics-campaigns-' + range, campaigns.map((c) => ({
      campaign_id: c.campaign_id,
      campaign_name: c.campaign_name,
      impressions: c.impressions,
      cta_clicks: c.cta_clicks,
      qr_scans: c.qr_scans,
      ctr_pct: c.ctr_pct,
      share_pct: c.share_pct,
    })));
  }
  function exportSpecies() {
    downloadCsv('analytics-species-' + range, species.map((s) => ({
      species_id: s.id,
      common_name: s.common_name,
      scientific_name: s.scientific_name,
      views: s.views || s.view_count || 0,
      avg_dwell_seconds: s.avg_dwell_seconds || 0,
    })));
  }
  function exportEvents() {
    downloadCsv('analytics-events-' + range, events.map((e) => ({
      timestamp: e.ts,
      event_type: e.type,
      scope: e.scope,
      label: e.label,
      value: e.value,
    })));
  }

  if (!canView) {
    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 720, padding: '60px 0' }}>
          <h1 style={{ fontSize: 22, fontWeight: 600, marginBottom: 8 }}>Analytics</h1>
          <p style={{ color: 'var(--aqos-text-dim)' }}>You don't have permission to view analytics. Ask your org admin to grant the <code>analytics.view_org</code> or <code>analytics.view_site</code> capability.</p>
        </div>
      </div>
    );
  }

  return (
    <div className="aq-content">
      <div className="aq-content-inner" style={{ maxWidth: 1400 }}>
        <header className="x-analytics-head">
          <div>
            <h1>Analytics</h1>
          </div>
          <div className="x-analytics-livebar">
            {sseLive ? <span className="x-analytics-livebar-on"><span className="x-analytics-livedot" />Live · SSE</span> : <span style={{ color: 'var(--aqos-text-faint)' }}>Polling · {range === '24h' ? '30s' : 'on range change'}</span>}
            {lastSyncAt && <span className="x-analytics-meta">Last sync {Math.max(0, Math.round((Date.now() - lastSyncAt.getTime()) / 1000))}s ago</span>}
          </div>
        </header>

        <div className="x-analytics-filters">
          <FilterChip
            label="Sites"
            allLabel="All sites"
            selected={selectedSites}
            options={sites.map((s) => ({ id: s.id, label: s.name }))}
            onChange={setSelectedSites}
          />
          <FilterChip
            label="Zones"
            allLabel="All zones"
            selected={selectedZones}
            options={zones
              .filter((z) => selectedSites.length === 0 || selectedSites.includes(z.site_id))
              .map((z) => ({ id: z.id, label: z.site_name ? `${z.site_name} · ${z.name}` : z.name }))}
            onChange={setSelectedZones}
          />
          <FilterChip
            label="Screens"
            allLabel="All screens"
            selected={selectedScreens}
            options={screens
              .filter((s) => selectedSites.length === 0 || selectedSites.includes(s.site_id))
              .map((s) => ({ id: s.id, label: s.screen_code + (s.name ? ' · ' + s.name : '') }))}
            onChange={setSelectedScreens}
          />
          <FilterChip
            label="Campaigns"
            allLabel="All campaigns"
            selected={selectedCampaigns}
            options={campaignsList.map((c) => ({ id: c.id, label: c.name }))}
            onChange={setSelectedCampaigns}
          />
          <span style={{ flex: 1 }} />
          <div className="x-analytics-rangeseg">
            {ANALYTICS_TIME_RANGES.map((r) => (
              <span
                key={r.id}
                className={'x-analytics-chip' + (range === r.id ? ' is-active' : '')}
                onClick={() => setRange(r.id)}
              >{r.label}</span>
            ))}
          </div>
          {canExport && <ExportMenu range={range} metrics={m} campaigns={campaigns} species={species} events={events} />}
          {/* Tools — one dropdown for all the secondary actions
              (views, save, annotate, funnel, compare). Keeps Export +
              time range as the only always-visible controls. The Views
              and Compare sub-pickers still render their own popovers,
              anchored to this same relative span. */}
          <span style={{ position: 'relative' }}>
            <span
              className={'x-analytics-chip' + (showToolsMenu ? ' is-active' : '')}
              onClick={() => setShowToolsMenu((v) => !v)}
              style={{ cursor: 'pointer' }}
              title="Saved views, annotations, funnel, comparison"
            >⋯ Tools</span>
            {showToolsMenu && (
              <div
                role="menu"
                onMouseLeave={() => setShowToolsMenu(false)}
                style={{
                  position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 60,
                  minWidth: 220, padding: 6, borderRadius: 10,
                  background: 'var(--aqos-surface)', border: '1px solid var(--aqos-border)',
                  boxShadow: '0 16px 40px rgba(0,0,0,0.45)',
                  display: 'flex', flexDirection: 'column', gap: 1,
                  fontFamily: 'var(--aqos-ff-sans)',
                }}
              >
                {[
                  { key: 'views', label: savedViews.length ? `Saved views (${savedViews.length})` : 'Saved views', sub: activeViewId ? (savedViews.find((x) => x.id === activeViewId) || {}).name : null, onClick: () => { setShowToolsMenu(false); setShowViewsMenu(true); } },
                  { key: 'save', label: 'Save current view', onClick: () => { setShowToolsMenu(false); setShowSaveModal(true); } },
                  ...(Auth.canManageAnalyticsAlerts && Auth.canManageAnalyticsAlerts() ? [{ key: 'annotate', label: 'Add annotation', onClick: () => { setShowToolsMenu(false); setShowAnnotation(true); } }] : []),
                  { key: 'annotations', label: 'View annotations', onClick: () => { setShowToolsMenu(false); setShowAnnotationList(true); } },
                  { key: 'funnel', label: 'Conversion funnel', onClick: () => { setShowToolsMenu(false); window.location.hash = '#analytics-funnel/x?range=' + range; } },
                  { key: 'compare', label: 'Compare', sub: compareLabel(compareWith, sites), onClick: () => { setShowToolsMenu(false); setShowCompareMenu(true); } },
                ].map((it) => (
                  <button
                    key={it.key}
                    role="menuitem"
                    onClick={it.onClick}
                    style={{
                      display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10,
                      width: '100%', textAlign: 'left', cursor: 'pointer',
                      padding: '8px 10px', borderRadius: 6, border: 0,
                      background: 'transparent', color: 'var(--aqos-text)',
                      font: 'inherit', fontSize: 12.5,
                    }}
                    onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.05)'; }}
                    onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
                  >
                    <span>{it.label}</span>
                    {it.sub && <span style={{ color: 'var(--aqos-text-faint)', fontSize: 11 }}>{it.sub}</span>}
                  </button>
                ))}
              </div>
            )}
            {showViewsMenu && (
              <SavedViewsMenu
                views={savedViews}
                activeId={activeViewId}
                onSelect={(id) => { setActiveViewId(id); setShowViewsMenu(false); }}
                onClear={() => { setActiveViewId(null); setShowViewsMenu(false); }}
                onDelete={deleteView}
                onCopy={copyShareLink}
                onClose={() => setShowViewsMenu(false)}
                meId={Auth.getUser() && Auth.getUser().id}
              />
            )}
            {showCompareMenu && (
              <CompareMenu
                value={compareWith}
                sites={sites}
                onPick={(v) => { setCompareWith(v); setShowCompareMenu(false); }}
                onClose={() => setShowCompareMenu(false)}
              />
            )}
          </span>
        </div>

        {showSaveModal && (
          <SaveViewModal
            defaultName={`Snapshot · ${range}`}
            canShare={canShare}
            onSave={(name, isShared) => saveCurrentView(name, isShared)}
            onCancel={() => setShowSaveModal(false)}
          />
        )}
        {showAnnotation && (
          <AnnotationModal
            onCancel={() => setShowAnnotation(false)}
            onSaved={() => { setShowAnnotation(false); window.toast && window.toast('Annotation added — visible on detail timelines.'); }}
          />
        )}
        {showAnnotationList && (
          <AnnotationListModal onClose={() => setShowAnnotationList(false)} />
        )}

        <InsightsCard
          range={range}
          metrics={metrics}
          campaigns={campaigns}
          species={species}
        />

        <div className="x-analytics-kpis" style={{ gridTemplateColumns: `repeat(${visibleKpiCount}, 1fr)` }}>
          {kpis.map((k, i) => <AnalyticsKpi key={i} {...k} />)}
        </div>

        <div className="x-analytics-row2">
          <AnalyticsTable
            title="Top campaigns"
            sortHint="Impressions ▾"
            canExport={canExport}
            exportName="campaigns"
            onExport={exportCampaigns}
            columns={[
              { key: 'name', label: 'Campaign', render: (r) => r.campaign_name || '—' },
              { key: 'impressions', label: 'Impr.', align: 'right', numeric: true, render: (r) => { r._sortValue = r.impressions; return fmtN(r.impressions); } },
              { key: 'ctr', label: 'CTR', align: 'right', numeric: true, render: (r) => fmtPct(r.ctr_pct, 1) },
              { key: 'share', label: 'Share', align: 'right', numeric: true, render: (r) => fmtPct(r.share_pct, 1) },
            ]}
            rows={campaigns}
            emptyHint={loading ? 'Loading…' : 'No campaign impressions in this range yet.'}
            onRowClick={(r) => { if (r.campaign_id) window.location.hash = `#analytics-campaign/${r.campaign_id}?range=${range}`; }}
          />
          <AnalyticsTable
            title="Top species"
            sortHint="Views ▾"
            canExport={canExport}
            exportName="species"
            onExport={exportSpecies}
            columns={[
              { key: 'name', label: 'Species', render: (r) => r.common_name || r.scientific_name || '—' },
              { key: 'views', label: 'Views', align: 'right', numeric: true, render: (r) => { const v = r.views || r.view_count || 0; r._sortValue = v; return fmtN(v); } },
              { key: 'dwell', label: 'Avg dwell', align: 'right', numeric: true, render: (r) => r.avg_dwell_seconds ? r.avg_dwell_seconds + 's' : '—' },
              { key: 'last', label: 'Last seen', align: 'right', numeric: true, render: (r) => r.last_seen_at ? r.last_seen_at.slice(11, 16) : '—' },
            ]}
            rows={species}
            emptyHint={loading ? 'Loading…' : 'No species views in this range yet.'}
            onRowClick={(r) => { if (r.id) window.location.hash = `#analytics-species/${r.id}?range=${range}`; }}
          />
        </div>

        <EngagementPanel data={engagement} loading={loading} range={range} />

        {/* AR Live mobile-visitor usage. Self-fetches its own data;
            hidden if no AR sessions exist yet for this org. */}
        <ARLivePanel />

        <div className="x-analytics-panel">
          <div className="x-analytics-panel-head">
            <span className="x-analytics-panel-title">
              {sseLive && <span className="x-analytics-livedot" style={{ marginRight: 6 }} />}
              Live event stream
            </span>
            <div className="x-analytics-panel-actions">
              {canExport && <a onClick={exportEvents}>↓ CSV</a>}
            </div>
          </div>
          <div>
            {events.length === 0 && (
              <div style={{ padding: '24px 14px', textAlign: 'center', color: 'var(--aqos-text-faint)', fontSize: 12 }}>
                {loading ? 'Loading…' : 'No events yet — they appear here as kiosks emit them.'}
              </div>
            )}
            {events.map((ev, i) => <EventRow key={ev.ts + '_' + i} ev={ev} />)}
          </div>
        </div>

        {err && (
          <div style={{ marginTop: 24, padding: '10px 14px', background: 'color-mix(in srgb, var(--aqos-danger) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--aqos-danger) 32%, transparent)', borderRadius: 8, fontSize: 12.5 }}>
            <strong>Couldn't load some metrics:</strong> {err}
          </div>
        )}
      </div>
    </div>
  );
}

/* Expose AnnotationModal so the detail screens can open it for editing
   when a user clicks an existing annotation pin. */
window.AnnotationModal = AnnotationModal;
window.AnalyticsScreen = AnalyticsScreen;
