/* Slate — Analytics drill-down detail screens
   ===================================================
   Phase 1B.1 — three detail components routed under
     /cms/#analytics-campaign/:id
     /cms/#analytics-species/:id
     /cms/#analytics-screen/:id

   We use distinct hash heads (not nested /analytics/...) because the
   readHashRoute() router in app.jsx splits on the first '/'. Adding
   nested segments would mean a router rewrite; sibling routes keep
   the patch surface tiny and let ROUTE_ACTIVE_NAV light up the
   Analytics nav for all three.

   Each detail screen:
     • Reads :id from `param` (passed by App)
     • Honours the ?range=24h|7d|30d|90d query string (range chips)
     • Headline KPI strip + day timeline + related-entity table
     • CSV export of the table per page (gated by analytics.export)
     • "← Back to analytics" link returns to #analytics

   We deliberately reuse Sparkline / AnalyticsKpi / AnalyticsTable /
   downloadCsv etc. from analytics.jsx — they're attached to the
   Babel-shared scope so we can call them by name. If analytics.jsx
   hasn't loaded yet (script ordering), we fall back to inline
   shims. */

(function () {
  /* Defensive helpers — use the ones from analytics.jsx if present,
     otherwise minimal shims so the file can stand on its own during
     hot-reload edge cases. */
  const _fmtN  = typeof fmtN  === 'function' ? fmtN  : (n) => String(Math.round(Number(n) || 0));
  const _fmtPct = typeof fmtPct === 'function' ? fmtPct : (n, d = 1) => (Number(n) || 0).toFixed(d) + '%';
  const _fmtCurrency = typeof fmtCurrency === 'function' ? fmtCurrency : (c) => '£' + ((Number(c) || 0) / 100).toFixed(0);
  const _Sparkline = typeof Sparkline === 'function' ? Sparkline : null;
  const _downloadCsv = typeof downloadCsv === 'function' ? downloadCsv : null;
  const _ANALYTICS_TIME_RANGES = typeof ANALYTICS_TIME_RANGES !== 'undefined'
    ? 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 },
      ];
  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 };
  }

  /* Read the entity id + initial range. app.jsx already strips the
     query string from `param` (it parses `#route/param?qs=…` and
     passes qs separately as the `query` prop). The old code split
     `param` on `?` to recover the qs, but by the time it ran the qs
     was always missing — so every `?range=` deep-link silently
     defaulted to '7d' across all 5 drill-down pages.

     Per the 2026-05-15 audit (HIGH-31): read `range` from the
     `query` object instead. */
  function parseDetailParam(param, query) {
    if (!param) return { id: null, range: '7d' };
    // Defensive: strip a stray "?suffix" if some caller still passes
    // a combined param (older deep-links / bookmarks).
    const id = param.split('?')[0];
    let range = '7d';
    const r = query && query.range;
    if (r && _ANALYTICS_TIME_RANGES.some((x) => x.id === r)) range = r;
    return { id, range };
  }

  /* Tiny shared header strip used by all three detail pages. The range
     chip set re-uses the same .x-analytics-chip styling so the page
     feels continuous with the parent dashboard. */
  function DetailHead({ kicker, title, subtitle, range, onRangeChange, onBack, canExport, onExportAll }) {
    return (
      <header className="x-analytics-head" style={{ marginBottom: 14 }}>
        <div>
          <a onClick={onBack} className="x-analytics-meta" style={{ cursor: 'pointer', display: 'inline-block', marginBottom: 4 }}>
            ← Back to analytics
          </a>
          <h1>{title}</h1>
          <div className="x-analytics-meta">
            <span style={{ color: 'var(--aqos-text-faint)' }}>{kicker}</span>
            {subtitle && <span style={{ marginLeft: 10 }}>· {subtitle}</span>}
          </div>
        </div>
        <div className="x-analytics-filters" style={{ marginBottom: 0, padding: 0, border: 0 }}>
          {_ANALYTICS_TIME_RANGES.map((r) => (
            <span
              key={r.id}
              className={'x-analytics-chip' + (range === r.id ? ' is-active' : '')}
              onClick={() => onRangeChange(r.id)}
              style={{ cursor: 'pointer' }}
            >{r.label}</span>
          ))}
          {canExport && onExportAll && (
            <span className="x-analytics-chip" onClick={onExportAll} style={{ cursor: 'pointer' }}>↓ CSV</span>
          )}
        </div>
      </header>
    );
  }

  /* Slim KPI tile — same visual as AnalyticsKpi from analytics.jsx but
     hand-rolled here so detail pages don't depend on the precise prop
     shape evolving. */
  function DetailKpi({ label, value, hint, sparkline, sparkColor }) {
    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--flat">{hint || ' '}</div>
        {_Sparkline && <_Sparkline data={sparkline || []} color={sparkColor} />}
      </div>
    );
  }

  /* Day-by-day timeline as a stacked-bar histogram. The /campaigns/:id
     timeline shape is { day, impressions, cta_clicks, qr_scans }; the
     /screens/:id and /species/:id endpoints both return { day, count }.
     This component accepts an array of bars `series` so it works for
     all three. */
  /* Simple anomaly detector: flag any day whose total is more than
     1.5× the median absolute deviation away from the day-set median.
     Returns a Set of day strings to highlight. Cheap to compute in JS;
     keeps the timeline self-contained without a server roundtrip. */
  function detectDayAnomalies(rows, series) {
    if (rows.length < 5) return new Set();
    const totals = rows.map((r) => series.reduce((a, s) => a + (Number(r[s.key]) || 0), 0));
    const sorted = [...totals].sort((a, b) => a - b);
    const median = sorted[Math.floor(sorted.length / 2)];
    const deviations = totals.map((v) => Math.abs(v - median)).sort((a, b) => a - b);
    const mad = deviations[Math.floor(deviations.length / 2)] || 1;
    const out = new Set();
    rows.forEach((r, i) => {
      if (Math.abs(totals[i] - median) >= 2.5 * mad) out.add(r.day);
    });
    return out;
  }

  /* Linear-regression forecast — fit y = mx + b on the last K days,
     project forward N more days. Returns array of {day, projected}.
     Only meaningful with ≥7 data points; we noop otherwise. */
  function forecastBars(rows, series, lookahead = 3) {
    if (rows.length < 7) return [];
    const totals = rows.map((r) => series.reduce((a, s) => a + (Number(r[s.key]) || 0), 0));
    const n = totals.length;
    const xs = totals.map((_, i) => i);
    const sumX = xs.reduce((a, b) => a + b, 0);
    const sumY = totals.reduce((a, b) => a + b, 0);
    const sumXY = xs.reduce((acc, x, i) => acc + x * totals[i], 0);
    const sumX2 = xs.reduce((acc, x) => acc + x * x, 0);
    const denom = (n * sumX2 - sumX * sumX) || 1;
    const slope = (n * sumXY - sumX * sumY) / denom;
    const intercept = (sumY - slope * sumX) / n;
    const lastDay = rows[rows.length - 1].day;
    const lastDate = new Date(lastDay + 'T00:00:00Z');
    const out = [];
    for (let i = 1; i <= lookahead; i++) {
      const proj = Math.max(0, Math.round(intercept + slope * (n - 1 + i)));
      const d = new Date(lastDate.getTime() + i * 24 * 3600 * 1000);
      out.push({ day: d.toISOString().slice(0, 10), projected: proj });
    }
    return out;
  }

  /* Synced-cursor hook — when a user hovers a day on ANY DetailTimeline
     on the page, every other DetailTimeline highlights the same day.
     Implemented via a global event ('aquaos-timeline-hover') so we
     don't need a context provider. Each timeline both dispatches
     (on its own mouse moves) and subscribes (to highlight when
     others fire). */
  function useTimelineHover() {
    const [hoverDay, setHoverDay] = useState(null);
    useEffect(() => {
      function onHover(e) { setHoverDay(e && e.detail ? e.detail.day : null); }
      window.addEventListener('aquaos-timeline-hover', onHover);
      return () => window.removeEventListener('aquaos-timeline-hover', onHover);
    }, []);
    return hoverDay;
  }
  function broadcastTimelineHover(day) {
    window.dispatchEvent(new CustomEvent('aquaos-timeline-hover', { detail: { day } }));
  }

  /* Drag-to-zoom — when the user drags horizontally across the timeline
     we capture the start + end day-indices and dispatch an
     'aquaos-timeline-zoom' event with {from, to} ISO dates. The host
     page subscribes via window.addEventListener and re-fetches with
     the tighter window. We intentionally fire as a window event (not
     a context callback) so the same DetailTimeline component can
     drop into any host without prop wiring. */
  function broadcastTimelineZoom(fromIso, toIso) {
    window.dispatchEvent(new CustomEvent('aquaos-timeline-zoom', { detail: { from: fromIso, to: toIso } }));
  }

  function DetailTimeline({ rows, series, annotations }) {
    const hoverDay = useTimelineHover();
    /* Drag selection state — null when not dragging, else {startIdx, endIdx}.
       Indices refer to safeRows (excludes projected bars). */
    const [drag, setDrag] = useState(null);
    const safeRows = Array.isArray(rows) ? rows : [];
    if (safeRows.length === 0) {
      return (
        <div className="x-analytics-panel">
          <div className="x-analytics-panel-head">
            <span className="x-analytics-panel-title">Daily activity</span>
          </div>
          <div style={{ padding: '24px 14px', color: 'var(--aqos-text-faint)', textAlign: 'center', fontSize: 12 }}>
            No activity in this range.
          </div>
        </div>
      );
    }
    const anomalyDays = detectDayAnomalies(safeRows, series);
    const projection = forecastBars(safeRows, series, 3);
    const allBars = safeRows.concat(projection.map((p) => ({ day: p.day, _projected: p.projected })));
    const maxBar = Math.max(
      1,
      ...allBars.map((r) => r._projected != null ? r._projected : series.reduce((acc, s) => acc + (Number(r[s.key]) || 0), 0))
    );
    /* Map annotations to "which bar index do they fall on" so we can
       overlay a vertical pin without a separate timeline scale. We
       match on r.day === YYYY-MM-DD against the annotation's UTC date. */
    const dayIndex = {};
    safeRows.forEach((r, i) => { if (r.day) dayIndex[r.day] = i; });
    /* Use UTC to derive YYYY-MM-DD because the timeline buckets come
       from server-side day grouping (UTC). Slicing the raw ISO string
       breaks for users west of UTC where local Tuesday 23:00 looks
       like Wednesday in the bucket. */
    const pins = (annotations || [])
      .map((a) => {
        const d = new Date(a.occurred_at);
        if (isNaN(d.getTime())) return null;
        const day = d.toISOString().slice(0, 10);
        const idx = dayIndex[day];
        if (idx == null) return null;
        return { ...a, _idx: idx };
      })
      .filter(Boolean);
    const anomalyCount = anomalyDays.size;
    return (
      <div className="x-analytics-panel">
        <div className="x-analytics-panel-head">
          <span className="x-analytics-panel-title">Daily activity</span>
          <div className="x-analytics-panel-actions">
            {series.map((s) => (
              <span key={s.key} style={{ color: 'var(--aqos-text-dim)', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                <span style={{ width: 8, height: 8, background: s.color, borderRadius: 2, display: 'inline-block' }} />
                {s.label}
              </span>
            ))}
            {anomalyCount > 0 && (
              <span style={{ color: 'oklch(0.78 0.14 75)', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                <span style={{ width: 8, height: 8, background: 'oklch(0.78 0.14 75)', borderRadius: 4, display: 'inline-block' }} />
                {anomalyCount} anomaly{anomalyCount === 1 ? '' : 's'}
              </span>
            )}
            {projection.length > 0 && (
              <span style={{ color: 'var(--aqos-text-faint)', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                <span style={{ width: 8, height: 8, background: 'var(--aqos-text-faint)', borderRadius: 2, opacity: 0.4, display: 'inline-block' }} />
                {projection.length}d projection
              </span>
            )}
            {pins.length > 0 && (
              <span style={{ color: 'var(--aqos-text-faint)' }}>· {pins.length} annotation{pins.length === 1 ? '' : 's'}</span>
            )}
          </div>
        </div>
        <div style={{ position: 'relative', padding: '14px 14px 6px' }}>
          <div
            style={{ display: 'flex', alignItems: 'flex-end', gap: 4, height: 140, userSelect: 'none', cursor: drag ? 'col-resize' : 'crosshair' }}
            onMouseLeave={() => { broadcastTimelineHover(null); /* keep drag state — release will commit */ }}
            onMouseUp={() => {
              if (!drag) return;
              const a = Math.min(drag.startIdx, drag.endIdx);
              const b = Math.max(drag.startIdx, drag.endIdx);
              const fromDay = safeRows[a] && safeRows[a].day;
              const toDay = safeRows[b] && safeRows[b].day;
              if (fromDay && toDay && (a !== b || drag.startIdx !== drag.endIdx)) {
                /* Convert YYYY-MM-DD day strings back to ISO timestamps
                   spanning the whole range. The host page treats these
                   exactly like any other from/to. */
                const fromIso = new Date(fromDay + 'T00:00:00Z').toISOString();
                const toIso = new Date(toDay + 'T23:59:59Z').toISOString();
                broadcastTimelineZoom(fromIso, toIso);
              }
              setDrag(null);
            }}
          >
            {allBars.map((r, i) => {
              const isProjected = r._projected != null;
              const total = isProjected ? r._projected : series.reduce((acc, s) => acc + (Number(r[s.key]) || 0), 0);
              const totalH = (total / maxBar) * 110;
              const isAnomaly = anomalyDays.has(r.day);
              const isHovered = hoverDay === r.day;
              const tooltip = (isProjected ? '↗ Forecast · ' : '')
                + r.day + ' — ' + total
                + (isAnomaly ? ' (anomaly)' : '');
              const inDragRange = drag && !isProjected
                ? i >= Math.min(drag.startIdx, drag.endIdx) && i <= Math.max(drag.startIdx, drag.endIdx)
                : false;
              return (
                <div key={r.day + (isProjected ? '_p' : '')}
                     onMouseEnter={() => {
                       broadcastTimelineHover(r.day);
                       if (drag && !isProjected) setDrag({ ...drag, endIdx: i });
                     }}
                     onMouseDown={(e) => {
                       /* Only start a drag on real (non-projected) bars and
                          on the LEFT button. Projected bars are not part of
                          the data range so zooming into them is meaningless. */
                       if (isProjected || e.button !== 0) return;
                       e.preventDefault();
                       setDrag({ startIdx: i, endIdx: i });
                     }}
                     style={{
                       flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch',
                       gap: 2, position: 'relative',
                       /* Hover halo — subtle background tint + outline. The
                          guide line is a separate full-height overlay so it's
                          visible even on bars with very low values. Drag
                          selection wins over hover when both apply. */
                       background: inDragRange
                         ? 'color-mix(in srgb, oklch(0.66 0.16 270) 22%, transparent)'
                         : (isHovered ? 'color-mix(in srgb, oklch(0.66 0.16 270) 12%, transparent)' : 'transparent'),
                       borderRadius: (isHovered || inDragRange) ? 2 : 0,
                       transition: 'background 80ms ease',
                     }}
                     title={tooltip}>
                  <div style={{
                    flex: 1, display: 'flex', flexDirection: 'column-reverse',
                    height: totalH,
                    border: isAnomaly ? '1px solid oklch(0.78 0.14 75)' : 'none',
                    borderRadius: isAnomaly ? 2 : 0,
                  }}>
                    {isProjected ? (
                      <div style={{
                        height: totalH, minHeight: 2,
                        background: 'var(--aqos-text-faint)',
                        opacity: 0.35,
                        backgroundImage: 'repeating-linear-gradient(45deg, transparent 0 4px, rgba(0,0,0,0.1) 4px 6px)',
                      }} />
                    ) : (
                      series.map((s) => {
                        const v = Number(r[s.key]) || 0;
                        if (!v) return null;
                        const h = (v / total) * totalH;
                        return <div key={s.key} style={{ height: h, background: s.color, minHeight: 2 }} />;
                      })
                    )}
                  </div>
                </div>
              );
            })}
          </div>
          {/* Annotation overlay — absolute positioned vertical line per pin
              using the bar index to compute the % left offset. The pin
              dot is clickable when AnnotationModal is loaded — opens
              the modal in edit mode for that annotation. Falls back to
              non-interactive when modal isn't available. */}
          {pins.length > 0 && (
            <div style={{ position: 'absolute', inset: '14px 14px 6px 14px', pointerEvents: 'none' }}>
              {pins.map((a) => {
                const left = ((a._idx + 0.5) / safeRows.length) * 100;
                const canEdit = !!window.AnnotationModal && Auth.canManageAnalyticsAlerts && Auth.canManageAnalyticsAlerts();
                return (
                  <div key={a.id} style={{
                    position: 'absolute', top: 0, bottom: 0,
                    left: `${left}%`, width: 0,
                    borderLeft: `1px dashed ${a.color || '#9ca3af'}`,
                    pointerEvents: 'auto',
                  }} title={`${String(a.occurred_at).slice(0, 10)} · ${a.label}${a.description ? ' — ' + a.description : ''}${canEdit ? ' (click to edit)' : ''}`}>
                    <div onClick={canEdit ? (e) => { e.stopPropagation(); window.dispatchEvent(new CustomEvent('aquaos-annotation-edit', { detail: a })); } : undefined}
                         style={{
                      position: 'absolute', top: -6, left: -4,
                      width: 9, height: 9, borderRadius: 5,
                      background: a.color || '#9ca3af',
                      border: '1px solid var(--aqos-page, #0c0d10)',
                      cursor: canEdit ? 'pointer' : 'default',
                    }} />
                  </div>
                );
              })}
            </div>
          )}
        </div>
        <div style={{ display: 'flex', gap: 4, padding: '0 14px 12px', fontSize: 10, color: 'var(--aqos-text-faint)' }}>
          {safeRows.map((r) => {
            // Show the day-of-month for first/last/middle bars only; saves clutter at 30d+.
            return (
              <div key={r.day} style={{ flex: 1, textAlign: 'center', overflow: 'hidden', whiteSpace: 'nowrap' }}>
                {r.day && r.day.slice(-2)}
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  /* A simple table (re-implementation of AnalyticsTable) for the
     by-screen / by-species / by-creative breakdowns. Lightweight —
     no built-in sorting, the API delivers them sorted. */
  function DetailTable({ title, columns, rows, emptyHint, onRowClick }) {
    return (
      <div className="x-analytics-panel">
        <div className="x-analytics-panel-head">
          <span className="x-analytics-panel-title">{title}</span>
        </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 || 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 || r.slide_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>
    );
  }

  /* useZoomListener — hook that subscribes to the global
     'aquaos-timeline-zoom' event from DetailTimeline drag-to-zoom and
     calls the supplied handler with {from, to} ISO strings. The host
     page typically responds by re-fetching its data with the tighter
     window. Subscribers also expose a "zoomed?" boolean + a reset() so
     the page can render a "← Reset zoom" affordance. */
  function useZoomListener(onZoom) {
    useEffect(() => {
      function handler(e) { if (e && e.detail && onZoom) onZoom(e.detail.from, e.detail.to); }
      window.addEventListener('aquaos-timeline-zoom', handler);
      return () => window.removeEventListener('aquaos-timeline-zoom', handler);
    }, [onZoom]);
  }

  /* ZoomBanner — drop-in component that renders the "🔍 Zoomed in"
     callout + "← Reset zoom" link when a custom window is active.
     Each detail screen passes its own `zoom` state + `onReset`. */
  function ZoomBanner({ zoom, onReset }) {
    if (!zoom) return null;
    return (
      <div style={{
        marginBottom: 12, padding: '8px 12px',
        background: 'color-mix(in srgb, oklch(0.66 0.16 270) 12%, var(--aqos-surface))',
        border: '1px solid color-mix(in srgb, oklch(0.66 0.16 270) 30%, var(--aqos-border))',
        borderRadius: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12,
      }}>
        <span style={{ color: 'var(--aqos-text-dim)' }}>
          🔍 Zoomed in: <strong style={{ color: 'var(--aqos-text)' }}>{String(zoom.from).slice(0, 10)}</strong>
          {' → '}
          <strong style={{ color: 'var(--aqos-text)' }}>{String(zoom.to).slice(0, 10)}</strong>
        </span>
        <a onClick={onReset} style={{ cursor: 'pointer', color: 'oklch(0.66 0.16 270)', fontSize: 11.5 }}>← Reset zoom</a>
      </div>
    );
  }

  /* AnnotationEditPortal — drop-in component that listens for the
     global 'aquaos-annotation-edit' event (dispatched when a user
     clicks an annotation pin in DetailTimeline) and renders the
     AnnotationModal in edit mode. Self-contained: just render
     <AnnotationEditPortal /> once anywhere on the page; no props
     needed. The optional `onSaved` callback fires after a successful
     save/delete so the host page can re-fetch its annotation list. */
  function AnnotationEditPortal({ onSaved }) {
    const [editing, setEditing] = useState(null);
    useEffect(() => {
      function handler(e) { if (e && e.detail) setEditing(e.detail); }
      window.addEventListener('aquaos-annotation-edit', handler);
      return () => window.removeEventListener('aquaos-annotation-edit', handler);
    }, []);
    if (!editing || !window.AnnotationModal) return null;
    const Modal = window.AnnotationModal;
    return <Modal
      initial={editing}
      onCancel={() => setEditing(null)}
      onSaved={() => { setEditing(null); if (onSaved) onSaved(); }}
    />;
  }

  /* "No permission" view — same copy as the parent screen so the
     drill-down looks consistent if a user lands on a deep-link they
     can't view. */
  function NoAccess() {
    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 this analytics drill-down.
            Ask your org admin to grant the <code>analytics.view_org</code> or
            <code> analytics.view_site</code> capability.
          </p>
        </div>
      </div>
    );
  }

  /* ─────────────────────────────────────────────────────────────────
     Campaign detail
     /api/analytics/campaigns/:id → { campaign, totals, timeline,
                                       by_screen, by_creative }
     ───────────────────────────────────────────────────────────────── */
  function AnalyticsCampaignDetail({ param, query }) {
    const canView = !!(Auth.canViewAnalytics && Auth.canViewAnalytics());
    const canExport = !!(Auth.canExportAnalytics && Auth.canExportAnalytics());
    const { id, range: initialRange } = parseDetailParam(param, query);
    const [range, setRange] = useState(initialRange);
    const [zoom, setZoom] = useState(null);
    const [data, setData] = useState(null);
    const [annotations, setAnnotations] = useState([]);
    const [err, setErr] = useState(null);
    const [loading, setLoading] = useState(true);

    useZoomListener((from, to) => setZoom({ from, to }));

    useEffect(() => {
      if (!canView || !id) return;
      let cancelled = false;
      setLoading(true); setErr(null);
      const win = zoom || _rangeToDates(range);
      const qs = `?from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}`;
      Promise.all([
        apiFetch(`/api/analytics/campaigns/${encodeURIComponent(id)}${qs}`),
        apiFetch('/api/analytics/annotations' + qs).catch(() => ({ annotations: [] })),
      ]).then(([d, a]) => {
        if (cancelled) return;
        setData(d); setAnnotations((a && a.annotations) || []); setLoading(false);
      }).catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
      return () => { cancelled = true; };
    }, [id, range, canView, zoom && zoom.from, zoom && zoom.to]);

    /* Keep the URL in sync with the range chip so deep-links survive.
       Switching range chips also clears any active drag-zoom. */
    function changeRange(r) {
      setRange(r);
      setZoom(null);
      window.location.hash = `#analytics-campaign/${id}?range=${r}`;
    }
    function back() { window.location.hash = '#analytics'; }
    function exportAll() {
      if (!_downloadCsv || !data) return;
      _downloadCsv(`campaign-${id}-by-screen-${range}`, (data.by_screen || []).map((r) => ({
        screen_code: r.screen_code,
        screen_name: r.screen_name,
        zone: r.zone_name,
        impressions: r.impressions,
        cta_clicks: r.cta_clicks,
        qr_scans: r.qr_scans,
      })));
    }

    if (!canView) return <NoAccess />;
    if (!id) {
      return (
        <div className="aq-content"><div className="aq-content-inner" style={{ padding: 60 }}>
          <p>Campaign id missing from URL.</p>
        </div></div>
      );
    }

    const campaign = data && data.campaign;
    const totals = (data && data.totals) || {};
    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 1400 }}>
          <DetailHead
            kicker="Campaign"
            title={loading ? 'Loading…' : (campaign ? campaign.name : 'Campaign')}
            subtitle={campaign && campaign.status ? `Status: ${campaign.status}` : null}
            range={range}
            onRangeChange={changeRange}
            onBack={back}
            canExport={canExport}
            onExportAll={exportAll}
          />

          <ZoomBanner zoom={zoom} onReset={() => setZoom(null)} />

          <div className="x-analytics-kpis" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
            <DetailKpi label="Impressions"      value={_fmtN(totals.impressions)} sparkColor="oklch(0.78 0.13 160)" />
            <DetailKpi label="CTA clicks"       value={_fmtN(totals.cta_clicks)}  sparkColor="oklch(0.66 0.16 270)" />
            <DetailKpi label="QR scans"         value={_fmtN(totals.qr_scans)}    sparkColor="oklch(0.70 0.16 25)" />
            <DetailKpi label="Combined CTR"     value={_fmtPct(totals.ctr_pct, 2)} sparkColor="oklch(0.78 0.14 75)" />
            <DetailKpi label="Screens reached"  value={_fmtN(totals.screens_reached)} sparkColor="oklch(0.78 0.13 160)" />
          </div>

          <DetailTimeline
            rows={(data && data.timeline) || []}
            annotations={annotations}
            series={[
              { key: 'impressions', label: 'Impressions', color: 'oklch(0.78 0.13 160 / 0.35)' },
              { key: 'cta_clicks',  label: 'Clicks',      color: 'oklch(0.66 0.16 270)' },
              { key: 'qr_scans',    label: 'Scans',       color: 'oklch(0.70 0.16 25)' },
            ]}
          />

          <div className="x-analytics-row2">
            <DetailTable
              title="By screen"
              columns={[
                { key: 'screen', label: 'Screen', render: (r) => (
                  <span><strong>{r.screen_code}</strong> <span style={{ color: 'var(--aqos-text-faint)' }}>· {r.screen_name}</span></span>
                ) },
                { key: 'zone', label: 'Zone', render: (r) => r.zone_name || '—' },
                { key: 'impressions', label: 'Impr.', align: 'right', numeric: true, render: (r) => _fmtN(r.impressions) },
                { key: 'ctr', label: 'CTR', align: 'right', numeric: true,
                  render: (r) => _fmtPct(r.impressions ? ((Number(r.cta_clicks) + Number(r.qr_scans)) / Number(r.impressions)) * 100 : 0, 1) },
              ]}
              rows={(data && data.by_screen) || []}
              emptyHint={loading ? 'Loading…' : 'No screen impressions yet in this range.'}
              onRowClick={(r) => { window.location.hash = `#analytics-screen/${r.id}?range=${range}`; }}
            />
            <DetailTable
              title="By creative slide"
              columns={[
                { key: 'slide', label: 'Slide', render: (r) => r.slide_name || `Slide ${r.position + 1}` },
                { key: 'impressions', label: 'Impr.', align: 'right', numeric: true, render: (r) => _fmtN(r.impressions) },
                { key: 'cta', label: 'Clicks', align: 'right', numeric: true, render: (r) => _fmtN(r.cta_clicks) },
                { key: 'qr',  label: 'Scans',  align: 'right', numeric: true, render: (r) => _fmtN(r.qr_scans) },
              ]}
              rows={(data && data.by_creative) || []}
              emptyHint={loading ? 'Loading…' : 'No per-slide breakdown available.'}
            />
          </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 campaign analytics:</strong> {err}
            </div>
          )}
          <AnnotationEditPortal onSaved={() => {
            const { from, to } = _rangeToDates(range);
            apiFetch('/api/analytics/annotations?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
              .then((a) => setAnnotations((a && a.annotations) || []));
          }} />
        </div>
      </div>
    );
  }

  /* ─────────────────────────────────────────────────────────────────
     Species detail
     /api/analytics/species/:id → { species, totals, timeline,
                                     by_screen }
     ───────────────────────────────────────────────────────────────── */
  function AnalyticsSpeciesDetail({ param, query }) {
    const canView = !!(Auth.canViewAnalytics && Auth.canViewAnalytics());
    const canRevenue = !!(Auth.canViewAnalyticsRevenue && Auth.canViewAnalyticsRevenue());
    const canExport = !!(Auth.canExportAnalytics && Auth.canExportAnalytics());
    const { id, range: initialRange } = parseDetailParam(param, query);
    const [range, setRange] = useState(initialRange);
    const [zoom, setZoom] = useState(null);
    const [data, setData] = useState(null);
    const [annotations, setAnnotations] = useState([]);
    const [err, setErr] = useState(null);
    const [loading, setLoading] = useState(true);

    useZoomListener((from, to) => setZoom({ from, to }));

    useEffect(() => {
      if (!canView || !id) return;
      let cancelled = false;
      setLoading(true); setErr(null);
      const win = zoom || _rangeToDates(range);
      const qs = `?from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}`;
      Promise.all([
        apiFetch(`/api/analytics/species/${encodeURIComponent(id)}${qs}`),
        apiFetch('/api/analytics/annotations' + qs).catch(() => ({ annotations: [] })),
      ]).then(([d, a]) => {
        if (cancelled) return;
        setData(d); setAnnotations((a && a.annotations) || []); setLoading(false);
      }).catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
      return () => { cancelled = true; };
    }, [id, range, canView, zoom && zoom.from, zoom && zoom.to]);

    function changeRange(r) {
      setRange(r);
      setZoom(null);
      window.location.hash = `#analytics-species/${id}?range=${r}`;
    }
    function back() { window.location.hash = '#analytics'; }
    function exportAll() {
      if (!_downloadCsv || !data) return;
      _downloadCsv(`species-${id}-by-screen-${range}`, (data.by_screen || []).map((r) => ({
        screen_code: r.screen_code,
        screen_name: r.screen_name,
        zone: r.zone_name,
        views: r.views,
      })));
    }

    if (!canView) return <NoAccess />;
    if (!id) {
      return (
        <div className="aq-content"><div className="aq-content-inner" style={{ padding: 60 }}>
          <p>Species id missing from URL.</p>
        </div></div>
      );
    }

    const sp = data && data.species;
    const totals = (data && data.totals) || {};
    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 1400 }}>
          <DetailHead
            kicker="Species"
            title={loading ? 'Loading…' : (sp ? `${sp.emoji || ''} ${sp.common_name || sp.scientific_name || 'Species'}`.trim() : 'Species')}
            subtitle={sp && sp.scientific_name ? sp.scientific_name : null}
            range={range}
            onRangeChange={changeRange}
            onBack={back}
            canExport={canExport}
            onExportAll={exportAll}
          />

          <ZoomBanner zoom={zoom} onReset={() => setZoom(null)} />

          <div className="x-analytics-kpis" style={{ gridTemplateColumns: `repeat(${canRevenue ? 4 : 3}, 1fr)` }}>
            <DetailKpi label="Views"        value={_fmtN(totals.views)} sparkColor="oklch(0.78 0.13 160)" />
            <DetailKpi label="Screens seen" value={_fmtN(totals.screens_seen)} sparkColor="oklch(0.66 0.16 270)" />
            <DetailKpi
              label="Last seen"
              value={totals.last_seen_at ? new Date(totals.last_seen_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
              sparkColor="oklch(0.70 0.16 25)"
            />
            {canRevenue && (
              <DetailKpi
                label="Attrib. revenue"
                value={_fmtCurrency(totals.revenue_cents)}
                hint={totals.orders ? `${_fmtN(totals.orders)} orders` : ''}
                sparkColor="oklch(0.78 0.13 160)"
              />
            )}
          </div>

          <DetailTimeline
            rows={(data && data.timeline) || []}
            annotations={annotations}
            series={[{ key: 'count', label: 'Views', color: 'oklch(0.66 0.16 270)' }]}
          />

          <DetailTable
            title="By screen"
            columns={[
              { key: 'screen', label: 'Screen', render: (r) => (
                <span><strong>{r.screen_code}</strong> <span style={{ color: 'var(--aqos-text-faint)' }}>· {r.screen_name}</span></span>
              ) },
              { key: 'zone', label: 'Zone', render: (r) => r.zone_name || '—' },
              { key: 'views', label: 'Views', align: 'right', numeric: true, render: (r) => _fmtN(r.views) },
            ]}
            rows={(data && data.by_screen) || []}
            emptyHint={loading ? 'Loading…' : 'No screen views yet in this range.'}
            onRowClick={(r) => { window.location.hash = `#analytics-screen/${r.id}?range=${range}`; }}
          />

          {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 species analytics:</strong> {err}
            </div>
          )}
          <AnnotationEditPortal onSaved={() => {
            const { from, to } = _rangeToDates(range);
            apiFetch('/api/analytics/annotations?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
              .then((a) => setAnnotations((a && a.annotations) || []));
          }} />
        </div>
      </div>
    );
  }

  /* ─────────────────────────────────────────────────────────────────
     Screen detail
     /api/analytics/screens/:id → { screen, totals, timeline,
                                     by_event, top_species }
     ───────────────────────────────────────────────────────────────── */
  function AnalyticsScreenDetail({ param, query }) {
    const canView = !!(Auth.canViewAnalytics && Auth.canViewAnalytics());
    const canExport = !!(Auth.canExportAnalytics && Auth.canExportAnalytics());
    const { id, range: initialRange } = parseDetailParam(param, query);
    const [range, setRange] = useState(initialRange);
    const [zoom, setZoom] = useState(null);
    const [data, setData] = useState(null);
    const [annotations, setAnnotations] = useState([]);
    const [err, setErr] = useState(null);
    const [loading, setLoading] = useState(true);

    useZoomListener((from, to) => setZoom({ from, to }));

    useEffect(() => {
      if (!canView || !id) return;
      let cancelled = false;
      setLoading(true); setErr(null);
      const win = zoom || _rangeToDates(range);
      const qs = `?from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}`;
      Promise.all([
        apiFetch(`/api/analytics/screens/${encodeURIComponent(id)}${qs}`),
        apiFetch('/api/analytics/annotations' + qs).catch(() => ({ annotations: [] })),
      ]).then(([d, a]) => {
        if (cancelled) return;
        setData(d); setAnnotations((a && a.annotations) || []); setLoading(false);
      }).catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
      return () => { cancelled = true; };
    }, [id, range, canView, zoom && zoom.from, zoom && zoom.to]);

    function changeRange(r) {
      setRange(r);
      setZoom(null);
      window.location.hash = `#analytics-screen/${id}?range=${r}`;
    }
    function back() { window.location.hash = '#analytics'; }
    function exportAll() {
      if (!_downloadCsv || !data) return;
      _downloadCsv(`screen-${id}-top-species-${range}`, (data.top_species || []).map((r) => ({
        species_id: r.id,
        common_name: r.common_name,
        views: r.views,
      })));
    }

    if (!canView) return <NoAccess />;
    if (!id) {
      return (
        <div className="aq-content"><div className="aq-content-inner" style={{ padding: 60 }}>
          <p>Screen id missing from URL.</p>
        </div></div>
      );
    }

    const screen = data && data.screen;
    const totals = (data && data.totals) || {};
    const isOnline = screen && screen.is_online;

    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 1400 }}>
          <DetailHead
            kicker="Screen"
            title={loading ? 'Loading…' : (screen ? `${screen.screen_code} · ${screen.screen_name || ''}`.trim() : 'Screen')}
            subtitle={screen ? `${screen.zone_name || ''} · ${screen.site_name || ''}` : null}
            range={range}
            onRangeChange={changeRange}
            onBack={back}
            canExport={canExport}
            onExportAll={exportAll}
          />

          <ZoomBanner zoom={zoom} onReset={() => setZoom(null)} />

          {screen && (
            <div style={{ marginBottom: 14 }}>
              <span className="x-analytics-chip" style={{
                background: isOnline ? 'color-mix(in srgb, oklch(0.78 0.13 160) 18%, transparent)' : 'color-mix(in srgb, var(--aqos-danger) 18%, transparent)',
                color: isOnline ? 'oklch(0.78 0.13 160)' : 'var(--aqos-danger)',
              }}>
                {isOnline ? '● Online' : '● Offline'}
              </span>
              {screen.last_heartbeat && (
                <span style={{ marginLeft: 10, color: 'var(--aqos-text-faint)', fontSize: 11.5 }}>
                  Last heartbeat: {new Date(screen.last_heartbeat).toLocaleString()}
                </span>
              )}
            </div>
          )}

          <div className="x-analytics-kpis" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
            <DetailKpi label="Events"      value={_fmtN(totals.events)}      sparkColor="oklch(0.78 0.13 160)" />
            <DetailKpi label="Impressions" value={_fmtN(totals.impressions)} sparkColor="oklch(0.66 0.16 270)" />
            <DetailKpi label="CTA clicks"  value={_fmtN(totals.cta_clicks)}  sparkColor="oklch(0.70 0.16 25)" />
            <DetailKpi label="QR scans"    value={_fmtN(totals.qr_scans)}    sparkColor="oklch(0.78 0.14 75)" />
            <DetailKpi label="Species seen"  value={_fmtN(totals.species_seen)} sparkColor="oklch(0.78 0.13 160)" />
          </div>

          <DetailTimeline
            rows={(data && data.timeline) || []}
            annotations={annotations}
            series={[{ key: 'count', label: 'Events', color: 'oklch(0.78 0.13 160 / 0.65)' }]}
          />

          <div className="x-analytics-row2">
            <DetailTable
              title="Top species"
              columns={[
                { key: 'species', label: 'Species', render: (r) => `${r.emoji || ''} ${r.common_name || ''}`.trim() },
                { key: 'views', label: 'Views', align: 'right', numeric: true, render: (r) => _fmtN(r.views) },
              ]}
              rows={(data && data.top_species) || []}
              emptyHint={loading ? 'Loading…' : 'No species views recorded for this screen yet.'}
              onRowClick={(r) => { window.location.hash = `#analytics-species/${r.id}?range=${range}`; }}
            />
            <DetailTable
              title="By event type"
              columns={[
                { key: 'event_type', label: 'Type' },
                { key: 'count', label: 'Count', align: 'right', numeric: true, render: (r) => _fmtN(r.count) },
              ]}
              rows={(data && data.by_event) || []}
              emptyHint={loading ? 'Loading…' : 'No events recorded for this screen yet.'}
            />
          </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 screen analytics:</strong> {err}
            </div>
          )}
          <AnnotationEditPortal onSaved={() => {
            const { from, to } = _rangeToDates(range);
            apiFetch('/api/analytics/annotations?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
              .then((a) => setAnnotations((a && a.annotations) || []));
          }} />
        </div>
      </div>
    );
  }

  /* ─────────────────────────────────────────────────────────────────
     Funnel screen — visit → species_select → CTA/QR → sales
     /api/analytics/funnel → { steps: [{key,label,count,pct}], period }
     ───────────────────────────────────────────────────────────────── */
  function AnalyticsFunnelScreen({ param }) {
    const canView = !!(Auth.canViewAnalytics && Auth.canViewAnalytics());
    const { range: initialRange } = parseDetailParam(param || '');
    const [range, setRange] = useState(initialRange);
    const [data, setData] = useState(null);
    const [err, setErr] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      if (!canView) return;
      let cancelled = false;
      setLoading(true); setErr(null);
      const { from, to } = _rangeToDates(range);
      const qs = `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
      apiFetch('/api/analytics/funnel' + qs)
        .then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
        .catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
      return () => { cancelled = true; };
    }, [range, canView]);

    function changeRange(r) {
      setRange(r);
      window.location.hash = `#analytics-funnel/x?range=${r}`;
    }
    function back() { window.location.hash = '#analytics'; }

    if (!canView) return <NoAccess />;

    const steps = (data && data.steps) || [];
    /* Top-of-funnel count is what every drop-off is measured against —
       always the first step. */
    const top = steps[0] ? Number(steps[0].count) || 0 : 0;

    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 1100 }}>
          <DetailHead
            kicker="Conversion"
            title="Funnel"
            subtitle="Visits → species engagement → CTA/QR → sales"
            range={range}
            onRangeChange={changeRange}
            onBack={back}
            canExport={false}
            onExportAll={null}
          />

          {loading && <div style={{ padding: 40, color: 'var(--aqos-text-faint)' }}>Calculating…</div>}

          {!loading && steps.length > 0 && (
            <div className="x-analytics-panel" style={{ padding: '20px 24px 26px' }}>
              {/* Stacked horizontal bars — width is % of top-of-funnel.
                  Each step also shows the drop-off from the previous one. */}
              {steps.map((s, i) => {
                const widthPct = top > 0 ? (Number(s.count) / top) * 100 : 0;
                const prev = i > 0 ? Number(steps[i - 1].count) || 0 : null;
                const dropPct = prev != null && prev > 0
                  ? Math.round((1 - (Number(s.count) / prev)) * 1000) / 10
                  : null;
                const palette = ['oklch(0.78 0.13 160)', 'oklch(0.66 0.16 270)', 'oklch(0.70 0.16 25)', 'oklch(0.78 0.14 75)'];
                const color = palette[i % palette.length];
                return (
                  <div key={s.key} style={{ marginBottom: i === steps.length - 1 ? 0 : 18 }}>
                    <div style={{
                      display: 'flex', justifyContent: 'space-between',
                      alignItems: 'baseline', marginBottom: 6, fontSize: 12.5,
                    }}>
                      <strong>{s.label}</strong>
                      <span style={{ color: 'var(--aqos-text-dim)' }}>
                        <strong style={{ color: 'var(--aqos-text)' }}>{_fmtN(s.count)}</strong>
                        <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--aqos-text-faint)' }}>
                          {_fmtPct(s.pct, 1)} of top
                        </span>
                        {dropPct != null && dropPct > 0 && (
                          <span style={{ marginLeft: 10, fontSize: 11, color: 'var(--aqos-danger)' }}>
                            ▼ {_fmtPct(dropPct, 1)} from previous
                          </span>
                        )}
                      </span>
                    </div>
                    <div style={{
                      height: 28, borderRadius: 4,
                      background: 'var(--aqos-surface-2)', position: 'relative', overflow: 'hidden',
                    }}>
                      <div style={{
                        position: 'absolute', inset: 0, width: `${widthPct.toFixed(2)}%`,
                        background: color, opacity: 0.85,
                        transition: 'width 200ms ease',
                      }} />
                    </div>
                  </div>
                );
              })}

              {/* Conversion summary line */}
              {steps.length >= 2 && top > 0 && (
                <div style={{
                  marginTop: 24, padding: '12px 14px', borderRadius: 6,
                  background: 'var(--aqos-surface-2)', fontSize: 12.5, color: 'var(--aqos-text-dim)',
                }}>
                  <strong style={{ color: 'var(--aqos-text)' }}>End-to-end conversion:</strong>{' '}
                  {_fmtPct((Number(steps[steps.length - 1].count) / top) * 100, 2)}
                  {' '}({_fmtN(steps[steps.length - 1].count)} of {_fmtN(top)}).
                </div>
              )}
            </div>
          )}

          {!loading && steps.length === 0 && (
            <div className="x-analytics-panel" style={{ padding: 40, textAlign: 'center', color: 'var(--aqos-text-faint)' }}>
              No funnel data in this range yet.
            </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 funnel:</strong> {err}
            </div>
          )}
        </div>
      </div>
    );
  }

  /* ─────────────────────────────────────────────────────────────────
     Engagement detail screen — per-species deep dive
     /api/analytics/engagement/:id → { species, totals, heatmap,
                                        distribution, comparison, by_screen }
     ───────────────────────────────────────────────────────────────── */
  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';
  }
  const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

  function HoldHeatmap({ heatmap }) {
    if (!heatmap || !heatmap.grid) return null;
    const max = Math.max(1, heatmap.max);
    return (
      <div className="x-hod-wrap" style={{ padding: '14px 16px 18px' }}>
        <div style={{ fontSize: 11, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.6, fontWeight: 600, marginBottom: 10 }}>
          Hold heatmap (day-of-week × hour-of-day)
        </div>
        <div className="x-hod-heatmap" style={{ display: 'grid', gridTemplateColumns: '32px repeat(24, 1fr)', gap: 2, fontSize: 9, color: 'var(--aqos-text-faint)' }}>
          {/* header row: blank corner + hour labels */}
          <div></div>
          {Array.from({ length: 24 }, (_, h) => (
            <div key={'h' + h} style={{ textAlign: 'center' }}>
              {h % 3 === 0 ? String(h).padStart(2, '0') : ''}
            </div>
          ))}
          {/* body rows */}
          {heatmap.grid.map((row, d) => (
            <Fragment key={'r' + d}>
              <div style={{ paddingRight: 4, textAlign: 'right', alignSelf: 'center' }}>{DOW_LABELS[d]}</div>
              {row.map((c, h) => {
                const intensity = c / max;
                /* Lerp from surface-2 (cold) to indigo (hot). */
                const bg = c === 0 ? 'var(--aqos-surface-2)' : `oklch(${0.4 + intensity * 0.4} ${0.05 + intensity * 0.15} 270 / ${0.3 + intensity * 0.7})`;
                return (
                  <div key={'c' + d + '_' + h}
                       title={`${DOW_LABELS[d]} ${String(h).padStart(2, '0')}:00 — ${c} hold${c === 1 ? '' : 's'}`}
                       style={{ aspectRatio: '1', background: bg, borderRadius: 2 }} />
                );
              })}
            </Fragment>
          ))}
        </div>
      </div>
    );
  }

  function ExposureHistogram({ distribution }) {
    if (!distribution || distribution.length === 0) return null;
    const max = Math.max(1, ...distribution.map((b) => b.count));
    const total = distribution.reduce((a, b) => a + b.count, 0);
    return (
      <div style={{ padding: '14px 16px 18px' }}>
        <div style={{ fontSize: 11, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.6, fontWeight: 600, marginBottom: 10 }}>
          Exposure-duration distribution
        </div>
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 120 }}>
          {distribution.map((b) => {
            const h = (b.count / max) * 100;
            const pct = total > 0 ? (b.count / total) * 100 : 0;
            return (
              <div key={b.label} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }} title={`${b.label}: ${b.count} (${pct.toFixed(1)}%)`}>
                <div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end' }}>
                  <div style={{ width: '100%', height: h + '%', background: 'oklch(0.66 0.16 270)', opacity: 0.8, borderRadius: 2, minHeight: b.count > 0 ? 2 : 0 }} />
                </div>
                <div style={{ fontSize: 10, color: 'var(--aqos-text-faint)' }}>{b.label}</div>
                <div style={{ fontSize: 10.5, color: 'var(--aqos-text-dim)', fontWeight: 600 }}>{_fmtN(b.count)}</div>
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  function AnalyticsEngagementDetail({ param, query }) {
    const canView = !!(Auth.canViewAnalytics && Auth.canViewAnalytics());
    const { id, range: initialRange } = parseDetailParam(param, query);
    const [range, setRange] = useState(initialRange);
    const [zoom, setZoom] = useState(null); // {from, to} when drag-zoomed
    const [data, setData] = useState(null);
    const [cohort, setCohort] = useState(null);
    const [err, setErr] = useState(null);
    const [loading, setLoading] = useState(true);

    /* Drag-to-zoom: when fired we override the range chips with an
       explicit window. Re-clicking a range chip resets back to the
       chip-derived window. */
    useZoomListener((from, to) => setZoom({ from, to }));

    useEffect(() => {
      if (!canView || !id) return;
      let cancelled = false;
      setLoading(true); setErr(null);
      const window = zoom || _rangeToDates(range);
      const qs = `?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`;
      Promise.all([
        apiFetch(`/api/analytics/engagement/${encodeURIComponent(id)}${qs}`),
        apiFetch(`/api/analytics/cohort/${encodeURIComponent(id)}${qs}&window_min=30`).catch(() => null),
      ]).then(([d, c]) => {
        if (cancelled) return;
        setData(d); setCohort(c); setLoading(false);
      }).catch((e) => { if (!cancelled) { setErr(e.message); setLoading(false); } });
      return () => { cancelled = true; };
    }, [id, range, canView, zoom && zoom.from, zoom && zoom.to]);

    function changeRange(r) {
      setRange(r);
      setZoom(null); // clear drag-zoom when user picks a chip
      window.location.hash = `#analytics-engagement/${id}?range=${r}`;
    }
    function back() { window.location.hash = '#analytics'; }
    function resetZoom() { setZoom(null); }

    if (!canView) return <NoAccess />;
    if (!id) return <div className="aq-content"><div className="aq-content-inner" style={{ padding: 60 }}>Species id missing.</div></div>;

    const sp = data && data.species;
    const totals = (data && data.totals) || {};
    const cmp = (data && data.comparison) || {};
    const vs = totals.hold_rate_pct - (cmp.org_hold_rate_pct || 0);

    return (
      <div className="aq-content">
        <div className="aq-content-inner" style={{ maxWidth: 1200 }}>
          <DetailHead
            kicker="Passive engagement"
            title={loading ? 'Loading…' : (sp ? `${sp.emoji || ''} ${sp.common_name || sp.scientific_name || 'Species'}`.trim() : 'Species')}
            subtitle={sp && sp.scientific_name ? sp.scientific_name : null}
            range={range}
            onRangeChange={changeRange}
            onBack={back}
            canExport={false}
            onExportAll={null}
          />

          <ZoomBanner zoom={zoom} onReset={resetZoom} />

          {/* KPIs */}
          <div className="x-analytics-kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
            <DetailKpi label="Exposures"   value={_fmtN(totals.exposures)}                  sparkColor="oklch(0.78 0.13 160)" />
            <DetailKpi label="Holds"       value={_fmtN(totals.holds)}                      sparkColor="oklch(0.66 0.16 270)" />
            <DetailKpi
              label="Hold rate"
              value={_fmtPct(totals.hold_rate_pct, 1)}
              hint={vs === 0 ? 'matches org avg' : (vs > 0 ? `+${vs.toFixed(1)}pp vs org` : `${vs.toFixed(1)}pp vs org`)}
              sparkColor={vs >= 0 ? 'oklch(0.78 0.13 160)' : 'oklch(0.78 0.14 75)'}
            />
            <DetailKpi
              label="Avg dwell"
              value={fmtMs(totals.avg_exposure_ms)}
              hint={`org avg ${fmtMs(cmp.org_avg_exposure_ms)}`}
              sparkColor="oklch(0.70 0.16 25)"
            />
          </div>

          <div className="x-analytics-row2">
            <div className="x-analytics-panel">
              {data && <HoldHeatmap heatmap={data.heatmap} />}
            </div>
            <div className="x-analytics-panel">
              {data && <ExposureHistogram distribution={data.distribution} />}
            </div>
          </div>

          {/* Cohort follow-through — what visitors did AFTER they engaged
              with this species. Powered by /api/analytics/cohort/:id. */}
          {cohort && cohort.engagement_count > 0 && (
            <div className="x-analytics-panel" style={{ marginBottom: 16, padding: '14px 18px 18px' }}>
              <div style={{ fontSize: 11, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.6, fontWeight: 600, marginBottom: 6 }}>
                Cohort follow-through · {cohort.window_min}m window after engagement
              </div>
              <div style={{ marginBottom: 10, fontSize: 12, color: 'var(--aqos-text-dim)' }}>
                Of <strong style={{ color: 'var(--aqos-text)' }}>{_fmtN(cohort.engagement_count)}</strong> visitors
                who held on this species, what did they do next on the same screen?
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
                {[
                  { label: 'QR scans',     count: cohort.followups.qr_scans,    pct: cohort.rates.qr_scan_pct,    color: 'oklch(0.66 0.16 270)' },
                  { label: 'CTA clicks',   count: cohort.followups.cta_clicks,  pct: cohort.rates.cta_click_pct,  color: 'oklch(0.70 0.16 25)' },
                  { label: 'Sales',        count: cohort.followups.sales,       pct: cohort.rates.sales_pct,      color: 'oklch(0.78 0.13 160)' },
                  { label: 'Other holds',  count: cohort.followups.other_holds, pct: cohort.rates.other_hold_pct, color: 'oklch(0.78 0.14 75)' },
                ].map((b) => (
                  <div key={b.label} style={{ padding: '10px 12px', background: 'var(--aqos-surface-2)', borderRadius: 6 }}>
                    <div style={{ fontSize: 10.5, color: 'var(--aqos-text-faint)', textTransform: 'uppercase', letterSpacing: 0.4, fontWeight: 600 }}>{b.label}</div>
                    <div style={{ fontSize: 18, fontWeight: 600, marginTop: 2 }}>{_fmtN(b.count)}</div>
                    <div style={{ fontSize: 11, color: b.color, marginTop: 2 }}>{_fmtPct(b.pct, 1)} of holds</div>
                  </div>
                ))}
              </div>
              {cohort.sample_followups && cohort.sample_followups.length > 0 && (
                <details style={{ marginTop: 12, fontSize: 11.5 }}>
                  <summary style={{ cursor: 'pointer', color: 'var(--aqos-text-dim)' }}>
                    Sample follow-through chain ({cohort.sample_followups.length} of {cohort.engagement_count})
                  </summary>
                  <div style={{ marginTop: 8, fontFamily: 'var(--aq-ff-mono, ui-monospace, monospace)', fontSize: 11, color: 'var(--aqos-text-faint)' }}>
                    {cohort.sample_followups.map((s, i) => (
                      <div key={i} style={{ padding: '2px 0' }}>
                        hold @ {String(s.engagement_at).slice(11, 19)} → {s.followup_type} (+{s.delta_seconds}s)
                      </div>
                    ))}
                  </div>
                </details>
              )}
            </div>
          )}

          <DetailTable
            title="By screen"
            columns={[
              { key: 'screen', label: 'Screen', render: (r) => (
                <span><strong>{r.screen_code}</strong> <span style={{ color: 'var(--aqos-text-faint)' }}>· {r.screen_name}</span></span>
              ) },
              { key: 'zone', label: 'Zone', render: (r) => r.zone_name || '—' },
              { key: 'exposures', label: 'Exposures', align: 'right', numeric: true, render: (r) => _fmtN(r.exposures) },
              { key: 'holds', label: 'Holds', align: 'right', numeric: true, render: (r) => _fmtN(r.holds) },
              { key: 'hold_rate', label: 'Hold rate', align: 'right', numeric: true, render: (r) => (
                <strong style={{ color: r.hold_rate_pct >= (cmp.org_hold_rate_pct || 0) * 1.5 ? 'oklch(0.78 0.13 160)' : 'var(--aqos-text-dim)' }}>
                  {_fmtPct(r.hold_rate_pct, 1)}
                </strong>
              ) },
              { key: 'avg_dwell', label: 'Avg dwell', align: 'right', numeric: true, render: (r) => fmtMs(r.avg_exposure_ms) },
            ]}
            rows={(data && data.by_screen) || []}
            emptyHint={loading ? 'Loading…' : 'No screens have shown this species yet in this range.'}
            onRowClick={(r) => { window.location.hash = `#analytics-screen/${r.id}?range=${range}`; }}
          />

          {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 engagement data:</strong> {err}
            </div>
          )}
        </div>
      </div>
    );
  }

  /* Expose on window so app.jsx can pick them up. */
  window.AnalyticsCampaignDetail   = AnalyticsCampaignDetail;
  window.AnalyticsSpeciesDetail    = AnalyticsSpeciesDetail;
  window.AnalyticsScreenDetail     = AnalyticsScreenDetail;
  window.AnalyticsFunnelScreen     = AnalyticsFunnelScreen;
  window.AnalyticsEngagementDetail = AnalyticsEngagementDetail;
})();
