/* global React, Button, Icon, StatusBadge, EmptyState, useApiResource, apiFetch, formatDuration, formatPercent, formatNumber, formatRelative, DashboardInventoryStrip, DashHeroStat, RunTimelineChart, FailingWorkflowsLoadingRows, DashboardRecentLoadingRows, ClassificationPill, ScoreChip, DashboardTimePicker, parseDashboardTimeRange, dashboardTimeRangeKey, buildDashboardRangeQuery, formatDashboardTimeRangeLabel, getDashboardRefreshIntervalMs */

// Insights — unified operator surface. Mixes live dashboard signal
// (inventory strip, KPI hero, activity chart, top failing, recent
// runs) with the daily editorial digest "paper" so a Head of Product
// can see "what's happening right now" alongside "what the AI thinks
// is worth fixing today" without switching tabs.
//
// Layout (desktop, full-width):
//   inventory strip:  Servers / Monitors / Workflows counts
//                     (lifted from the standalone Dashboard view)
//   KPI hero:         Runs · Success rate · Failed · Median (48h)
//   calendar:         continuous date axis (one cell per day, dot
//                     per digest, hover/click into a past report)
//   utility bar:      eyebrow + actions (regenerate, email toggle)
//   two-col grid:
//     • left  (paper):  the editorial digest (offset window)
//     • right (rail):   Activity timeline · Top failing · Recent runs
//
// All three right-rail panels read the same /api/dashboard/* endpoints
// the standalone /dashboard tab uses. We lock the window to the
// digest's own 48h analysis window so the live numbers and the
// editorial narrative speak about the same slice of time.

const {
  useEffect: useEffectI,
  useMemo: useMemoI,
  useRef: useRefI,
  useState: useStateI,
} = React;

const SEVERITY_TONES = {
  high: 'bad',
  medium: 'warn',
  low: 'ok',
};

const IMPACT_TONES = {
  high: 'brand',
  medium: 'amber',
  low: 'neutral',
};

// Time the inflight (queued/running) report polls for a final state.
// We back off to 60s after the first 30s to avoid hammering when an LLM
// pass takes >2 min. Capped so a stuck job doesn't keep the page busy.
const REPORT_POLL_MS = 4_000;
const REPORT_POLL_BACKOFF_AFTER_MS = 30_000;
const REPORT_POLL_BACKOFF_MS = 12_000;
const REPORT_POLL_MAX_MS = 5 * 60_000;

function InsightsPage({ navigate, reportId, queryString = '' }) {
  const [refreshTick, setRefreshTick] = useStateI(0);
  const [archiving, setArchiving] = useStateI(false);
  const refreshKey = `${reportId || 'latest'}:${refreshTick}`;
  const reportPath = reportId
    ? `/api/insights/${reportId}`
    : `/api/insights/latest`;
  const reportResource = useApiResource(reportPath, [refreshKey]);
  // Calendar walks ~14 days back from today (CALENDAR_DAYS); pulling 30
  // reports leaves headroom for orgs with denser-than-daily cadence
  // (catchup runs after an outage etc.).
  const calendar = useApiResource('/api/insights?limit=30', [refreshKey]);
  const emailPrefs = useApiResource('/api/insights/email-preferences', []);

  // ── Live operational signal ─────────────────────────────────────
  // Live numbers (KPI hero + Activity chart + Top failing + Recent
  // runs) all read /api/dashboard/* against a user-selected time
  // window. Window state is mirrored to the URL query so a
  // bookmark-share captures the slice. Default is 48h, matching
  // the digest's own analysis window so the rail numbers line up
  // with the editorial paper.
  const liveRange = useMemoI(() => parseDashboardTimeRange(queryString), [queryString]);
  const liveRangeKey = dashboardTimeRangeKey(liveRange);
  const liveRangeLabel = formatDashboardTimeRangeLabel(liveRange);
  const liveRangeQuery = buildDashboardRangeQuery(liveRange);
  const liveSummary = useApiResource(
    `/api/dashboard/summary?${liveRangeQuery}`,
    [liveRangeKey, refreshTick],
  );
  const liveTimeline = useApiResource(
    // 12 buckets — the rail panel is ~360px wide, so 24 was packing
    // too many bars to fit; the rightmost would crop. 12 still gives
    // a useful at-a-glance shape and never overflows the box.
    `/api/dashboard/run-timeline?${liveRangeQuery}&buckets=12`,
    [liveRangeKey, refreshTick],
  );
  const liveRecent = useApiResource(
    `/api/dashboard/recent-runs?${liveRangeQuery}&limit=8`,
    [liveRangeKey, refreshTick],
  );
  const liveFailing = useApiResource(
    `/api/dashboard/top-failing-workflows?${liveRangeQuery}`,
    [liveRangeKey, refreshTick],
  );
  const liveServers = useApiResource('/api/mcp-servers');
  const liveMonitors = useApiResource('/api/tool-monitors');
  const liveWorkflows = useApiResource('/api/workflows');
  const [generating, setGenerating] = useStateI(false);
  const [pollDeadlineMs, setPollDeadlineMs] = useStateI(null);
  const pollTimerRef = useRefI(null);
  const report = reportResource.data?.report || null;

  const liveLoading = (liveSummary.loading || liveTimeline.loading
    || liveRecent.loading || liveFailing.loading);
  const hasAnyLiveData = Boolean(liveSummary.data || liveTimeline.data
    || liveRecent.data || liveFailing.data);
  const liveRefreshing = liveLoading && hasAnyLiveData;

  function reloadLive() {
    liveSummary.reload();
    liveTimeline.reload();
    liveRecent.reload();
    liveFailing.reload();
    liveServers.reload();
    liveMonitors.reload();
    liveWorkflows.reload();
  }

  function setLiveRange(nextRange) {
    const params = new URLSearchParams(queryString);
    params.delete('range');
    params.delete('from');
    params.delete('to');
    if (nextRange.type === 'absolute') {
      params.set('from', new Date(nextRange.from).toISOString());
      params.set('to', new Date(nextRange.to).toISOString());
    } else {
      params.set('range', nextRange.range);
    }
    const next = params.toString();
    // Preserve the active /insights/:id when the operator changes
    // window; the window is independent of which report they're
    // reading, but the URL still needs to capture both.
    const base = reportId ? `/insights/${reportId}` : '/insights';
    navigate(`${base}${next ? `?${next}` : ''}`);
  }

  // Auto-refresh the live panels at the cadence the standalone
  // Dashboard uses (faster for shorter windows). The digest itself
  // doesn't refresh — it's a daily artifact addressed by id.
  useEffectI(() => {
    if (liveRange.type !== 'relative') return undefined;
    const intervalMs = getDashboardRefreshIntervalMs(liveRange.range);
    const timer = window.setInterval(() => {
      reloadLive();
    }, intervalMs);
    return () => window.clearInterval(timer);
  }, [liveRangeKey, liveSummary.reload, liveTimeline.reload, liveRecent.reload, liveFailing.reload]);

  useEffectI(() => {
    const status = report?.status;
    if (!status) return undefined;
    if (status === 'completed' || status === 'failed' || status === 'skipped') {
      if (pollTimerRef.current) {
        window.clearTimeout(pollTimerRef.current);
        pollTimerRef.current = null;
      }
      setPollDeadlineMs(null);
      return undefined;
    }
    const startedAt = pollDeadlineMs ?? Date.now() + REPORT_POLL_MAX_MS;
    if (pollDeadlineMs === null) setPollDeadlineMs(startedAt);
    if (Date.now() > startedAt) return undefined;
    const elapsed = REPORT_POLL_MAX_MS - (startedAt - Date.now());
    const wait = elapsed > REPORT_POLL_BACKOFF_AFTER_MS
      ? REPORT_POLL_BACKOFF_MS
      : REPORT_POLL_MS;
    pollTimerRef.current = window.setTimeout(() => {
      reportResource.reload();
    }, wait);
    return () => {
      if (pollTimerRef.current) {
        window.clearTimeout(pollTimerRef.current);
        pollTimerRef.current = null;
      }
    };
  }, [report?.status, report?.updatedAt, pollDeadlineMs, reportResource.reload]);

  async function regenerate() {
    if (generating) return;
    setGenerating(true);
    try {
      const result = await apiFetch('/api/insights', { method: 'POST' });
      if (result?.report?.status) {
        setPollDeadlineMs(Date.now() + REPORT_POLL_MAX_MS);
      }
      setRefreshTick((tick) => tick + 1);
    } catch (error) {
      console.error('Failed to enqueue insights report', error);
    } finally {
      setGenerating(false);
    }
  }

  async function archiveDigest(next) {
    if (archiving || !report?.id) return;
    const willArchive = next !== false;
    if (willArchive && !window.confirm(
      'Archive this digest? It will be hidden from the calendar and the previous-day diff. The page will jump to the most recent live digest.',
    )) return;
    setArchiving(true);
    try {
      await apiFetch(`/api/insights/${report.id}/archive`, {
        method: 'PATCH',
        body: JSON.stringify({ archived: willArchive }),
      });
      // Calendar must reflect the change immediately (the strip filters
      // archived rows server-side). Reload it AND the report. For
      // archive, also navigate back to /insights so /latest picks the
      // next live digest. For unarchive, stay on the report.
      calendar.reload();
      if (willArchive) {
        navigate('/insights');
      } else {
        setRefreshTick((tick) => tick + 1);
      }
    } catch (error) {
      window.alert(`${willArchive ? 'Archive' : 'Unarchive'} failed: ${error.message || error}`);
    } finally {
      setArchiving(false);
    }
  }

  async function toggleEmail(next) {
    try {
      await apiFetch('/api/insights/email-preferences', {
        method: 'PATCH',
        body: JSON.stringify({ enabled: Boolean(next) }),
      });
      emailPrefs.reload();
    } catch (error) {
      console.error('Failed to update email preferences', error);
    }
  }

  const isLoading = reportResource.loading && !report;
  const errorState = reportResource.error;
  const calendarRows = calendar.data?.rows || [];
  const previousReports = reportResource.data?.previousReports || [];
  const emailEnabled = Boolean(emailPrefs.data?.enabled);

  return (
    <div className="page-inner insights-page">
      {/* Page toolbar — title on the left, live-window controls on
          the right. The picker + refresh button govern every "live"
          element on this page (inventory, KPI hero, Activity / Top
          failing / Recent runs) but never the digest paper, which
          lives in its own daily window. */}
      <div className="ui-page-head">
        <h1 className="ui-page-title insights-pagetitle">Insights</h1>
        <div className="ui-page-actions">
          <DashboardTimePicker
            timeRange={liveRange}
            onChange={setLiveRange}
            label="Live" />
          <Button
            size="sm"
            variant="ghost"
            loading={liveRefreshing}
            loadingLabel="Refreshing"
            onClick={reloadLive}>
            <Icon name="refresh" size={13} />Refresh
          </Button>
        </div>
      </div>

      {/* Live operational signal — inventory + KPI hero. Both
          respond to the page-level time picker. These live above
          the Daily Digest section by design: the digest section
          itself contains only the calendar + the editorial paper,
          and the paper carries a small intro + a few KPIs of its
          own (different visual treatment from the hero so they
          read as "operations bar" vs "editorial intro"). */}
      <DashboardInventoryStrip
        servers={liveServers}
        monitors={liveMonitors}
        workflows={liveWorkflows}
        navigate={navigate} />

      <InsightsLiveHero
        summary={liveSummary}
        timeline={liveTimeline}
        rangeLabel={liveRangeLabel}
        navigate={navigate} />

      {/* Main split:
            LEFT  — the Daily digest column (its own header,
                    calendar, and the paper). The "Daily digest"
                    label lives INSIDE this column so it does not
                    visually claim the right rail.
            RIGHT — live charts that respond to the page-level
                    time picker, completely independent of the
                    digest's daily window. */}
      <div className="insights-grid">
        <div className="insights-grid__digest">
          <div className="insights-railhead">
            <div className="insights-railhead__lead">
              <div className="insights-eyebrow">Daily digest</div>
              <p className="insights-railhead__sub">
                Editorial summary generated daily for the last 48 hours
              </p>
            </div>
            <InsightsHeaderActions
              emailEnabled={emailEnabled}
              emailLoading={emailPrefs.loading && !emailPrefs.data}
              onToggleEmail={toggleEmail}
              regenerating={generating}
              onRegenerate={regenerate} />
          </div>
          <InsightsCalendarStrip
            rows={calendarRows}
            loading={calendar.loading && !calendar.data}
            currentReportId={report?.id || null}
            navigate={navigate} />
          <div className="insights-main">
            {errorState && (
              <div className="i-error">
                <div className="i-error__title">Couldn’t load this report</div>
                <p className="i-error__body">
                  {errorState.message || 'The insights API returned an error. Try refreshing in a moment.'}
                </p>
                <Button size="sm" variant="ghost" onClick={() => reportResource.reload()}>
                  Try again
                </Button>
              </div>
            )}
            {!errorState && isLoading && <InsightsSkeleton />}
            {!errorState && !isLoading && !report && (
              <InsightsEmpty regenerating={generating} onRegenerate={regenerate} />
            )}
            {!errorState && !isLoading && report && isReportGenerating(report) && (
              <InsightsGenerating status={report.status} />
            )}
            {!errorState && !isLoading && report && !isReportGenerating(report) && (
              <InsightsReport
                report={report}
                previousReports={previousReports}
                calendarRows={calendarRows}
                navigate={navigate}
                archiving={archiving}
                onArchive={archiveDigest} />
            )}
          </div>
        </div>

        <aside className="insights-grid__rail" aria-label="Live operational signal">
          {/* Two-line header that exactly mirrors the digest header
              on the left, so the column tops align even though the
              digest has a subtitle. */}
          <div className="insights-railhead">
            <div className="insights-railhead__lead">
              <div className="insights-eyebrow">Live signal</div>
              <p className="insights-railhead__sub">{liveRangeLabel} · refreshes automatically</p>
            </div>
          </div>
          <InsightsRailPanel title="Activity">
            <RunTimelineChart
              rows={liveTimeline.data?.rows || []}
              loading={liveTimeline.loading && !liveTimeline.data} />
          </InsightsRailPanel>

          <InsightsRailPanel
            title="Top failing"
            meta={topFailingCountLabel(liveFailing.data?.rows)}
            link={{ label: 'All failures', href: '/runs?status=failed' }}
            navigate={navigate}>
            <InsightsFailingList
              rows={liveFailing.data?.rows || []}
              loading={liveFailing.loading && !liveFailing.data}
              navigate={navigate} />
          </InsightsRailPanel>

          <InsightsRailPanel
            title="Recent runs"
            link={{ label: 'All runs', href: '/runs' }}
            navigate={navigate}>
            <InsightsRecentList
              rows={liveRecent.data?.rows || []}
              loading={liveRecent.loading && !liveRecent.data}
              navigate={navigate} />
          </InsightsRailPanel>
        </aside>
      </div>
    </div>
  );
}

// KPI hero — four big stat tiles. Lives above the Daily Digest
// section and reflects the page-level time picker. The digest paper
// renders its own much smaller intro stats line; the hero is meant
// to feel like an "operations bar", while the in-document stats
// feel editorial.
function InsightsLiveHero({ summary, timeline, rangeLabel, navigate }) {
  const stats = summary.data || {};
  const timelineRows = timeline.data?.rows || [];
  const summaryLoading = summary.loading && !summary.data;
  const successRateValue = stats.successRate;
  const successRateTone = typeof successRateValue !== 'number'
    ? 'neutral'
    : successRateValue >= 0.95
      ? 'ok'
      : successRateValue >= 0.8 ? 'warn' : 'bad';
  const failedTone = (stats.failedRuns || 0) > 0 ? 'bad' : 'ok';
  const sparkSeries = useMemoI(() => {
    if (!timelineRows.length) return { runs: [], successRate: [], failed: [] };
    return {
      runs: timelineRows.map((r) => Number(r.totalRuns) || 0),
      successRate: timelineRows.map((r) => {
        const total = Number(r.totalRuns) || 0;
        if (total === 0) return 0;
        const ok = (Number(r.successfulRuns) || 0) + (Number(r.partialRuns) || 0);
        return Math.max(0, Math.min(1, ok / total));
      }),
      failed: timelineRows.map((r) => Number(r.failedRuns) || 0),
    };
  }, [timelineRows]);
  return (
    <div className="dash-hero">
      <DashHeroStat
        label="Runs"
        value={summaryLoading ? null : formatNumber(stats.totalRuns || 0)}
        detail={rangeLabel}
        spark={sparkSeries.runs}
        tone="neutral"
        onClick={() => navigate('/runs')} />
      <DashHeroStat
        label="Success rate"
        value={summaryLoading ? null : formatPercent(stats.successRate || 0)}
        detail="passed or partial"
        spark={sparkSeries.successRate}
        tone={successRateTone}
        onClick={() => navigate('/runs?status=success')} />
      <DashHeroStat
        label="Failed runs"
        value={summaryLoading ? null : formatNumber(stats.failedRuns || 0)}
        detail={(stats.failedRuns || 0) > 0 ? 'needs review' : 'all green'}
        spark={sparkSeries.failed}
        tone={failedTone}
        onClick={() => navigate('/runs?status=failed')} />
      <DashHeroStat
        label="Median run"
        value={summaryLoading ? null : formatDuration(stats.p50DurationMs)}
        detail="P50 duration"
        spark={sparkSeries.runs}
        tone="neutral"
        dim />
    </div>
  );
}

// Reusable right-rail panel wrapper. The right column needs a
// distinctly calmer voice than the digest paper — flat surface, no
// offset shadow — so the eye reads "live context" vs the "report".
function InsightsRailPanel({ title, meta = null, link = null, navigate = null, children }) {
  return (
    <section className="i-rail-panel">
      <header className="i-rail-panel__head">
        <h3 className="i-rail-panel__title">{title}</h3>
        <div className="i-rail-panel__meta">
          {meta && <span className="i-rail-panel__metatext muted">{meta}</span>}
          {link && (
            <a
              className="dash-link"
              href={link.href}
              onClick={(e) => { e.preventDefault(); navigate?.(link.href); }}>
              {link.label}<Icon name="chevronRight" size={12} />
            </a>
          )}
        </div>
      </header>
      <div className="i-rail-panel__body">{children}</div>
    </section>
  );
}

function topFailingCountLabel(rows) {
  const count = Array.isArray(rows) ? rows.length : 0;
  if (count === 0) return null;
  return `${count} workflow${count === 1 ? '' : 's'}`;
}

function InsightsFailingList({ rows, loading, navigate }) {
  const maxFailures = Math.max(...rows.map((r) => r.failure_count), 1);
  if (loading) return <FailingWorkflowsLoadingRows />;
  if (rows.length === 0) {
    return (
      <div className="dash-empty">
        <Icon name="check" size={16} />
        <span>No failing workflows in this range.</span>
      </div>
    );
  }
  return (
    <div className="dash-failing-list">
      {rows.map((row, index) => (
        <button
          type="button"
          className="dash-failing-row"
          key={`${row.workflow_id || 'workflow'}-${row.workflow_name || index}`}
          title={row.workflow_id ? `Open failed runs for ${row.workflow_name}` : row.workflow_name}
          onClick={() => row.workflow_id ? navigate(`/runs?workflowId=${row.workflow_id}&status=failed`) : null}
          disabled={!row.workflow_id}>
          <span className="dash-failing-name">{row.workflow_name}</span>
          <span className="dash-failing-bar">
            <span className="dash-failing-fill" style={{ width: `${(row.failure_count / maxFailures) * 100}%` }} />
          </span>
          <span className="dash-failing-count">{row.failure_count}</span>
        </button>
      ))}
    </div>
  );
}

function InsightsRecentList({ rows, loading, navigate }) {
  if (loading) return <DashboardRecentLoadingRows />;
  if (rows.length === 0) {
    return (
      <div className="dash-empty">
        <Icon name="list" size={16} />
        <span>No runs in this range yet.</span>
      </div>
    );
  }
  return (
    <div className="dash-recent-list">
      {rows.map((run) => (
        <button
          key={run.id}
          type="button"
          className="dash-recent-row"
          onClick={() => navigate(`/runs/${run.id}`)}>
          <StatusBadge status={run.product_status} />
          <span className="dash-recent-name" title={run.workflow_name || 'Untitled workflow'}>
            {run.workflow_name || 'Untitled workflow'}
          </span>
          <ClassificationPill classification={run.evaluation_classification} />
          <ScoreChip score={run.evaluation_score} />
          <span className="dash-recent-meta mono">{formatDuration(run.duration_ms)}</span>
          <span className="dash-recent-time muted text-xs">{formatRelative(run.started_at || run.created_at)}</span>
        </button>
      ))}
    </div>
  );
}

// Compact actions row used inside the "Daily digest" band on the
// merged Insights view. Title + date live in the band header
// (insights-digestband__lead); here we only render the email
// opt-in toggle and the regenerate button so the band fits on a
// single row at desktop widths.
function InsightsHeaderActions({
  emailEnabled,
  emailLoading,
  onToggleEmail,
  regenerating,
  onRegenerate,
}) {
  return (
    <div className="i-header__actions">
      <label className="i-toggle">
        <input
          type="checkbox"
          checked={emailEnabled}
          disabled={emailLoading}
          onChange={(e) => onToggleEmail(e.target.checked)} />
        <span>Email me daily</span>
      </label>
      <Button
        size="sm"
        variant="ghost"
        loading={regenerating}
        loadingLabel="Queuing"
        onClick={onRegenerate}>
        <Icon name="refresh" size={12} /> Regenerate
      </Button>
    </div>
  );
}

// Number of days the continuous calendar spans. Strict invariant: the
// strip renders exactly one cell per day in this window — never more,
// never less. Today is the rightmost cell; we walk back N-1 days from
// there. Days without a digest are still drawn (empty cell), days with
// digest(s) draw one small square red dot per digest in the cell.
//
// The calendar now lives INSIDE the digest column (~58% of page
// width), so 14 cells fits comfortably while keeping each day cell
// legible without horizontal scrolling on a typical desktop.
const CALENDAR_DAYS = 14;

function InsightsCalendarStrip({ rows, loading, currentReportId, navigate }) {
  const scrollerRef = useRefI(null);
  // hoverInfo describes the dot the cursor (or focus) is on:
  // { report, anchorLeft, anchorTop }. Null when nothing is hovered.
  const [hoverInfo, setHoverInfo] = useStateI(null);

  // Group ALL reports by the viewer's local calendar day (YYYY-MM-DD),
  // keeping every digest sorted oldest → newest within the day so dots
  // line up chronologically inside the cell. Local-day bucketing matches
  // how operators think about "yesterday's" and "today's" digest — a
  // manual rerun fired late in the local evening still belongs to the
  // day it was triggered, even when its UTC timestamp has crossed
  // midnight.
  const reportsByDay = useMemoI(() => {
    const map = new Map();
    for (const row of rows || []) {
      const key = ymdLocal(row.periodEnd);
      if (!key) continue;
      const list = map.get(key) || [];
      list.push(row);
      map.set(key, list);
    }
    for (const list of map.values()) {
      list.sort((a, b) => new Date(a.periodEnd).getTime() - new Date(b.periodEnd).getTime());
    }
    return map;
  }, [rows]);

  // Continuous date axis: today + N-1 prior days in the viewer's local
  // timezone, oldest first so `flex-direction: row` reads chronologically
  // left → right. We step using the local-date constructor instead of
  // subtracting 86,400,000 ms so DST transitions don't drift the axis.
  const cells = useMemoI(() => {
    const out = [];
    const today = startOfDayLocal(new Date());
    for (let i = CALENDAR_DAYS - 1; i >= 0; i -= 1) {
      const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - i);
      const key = ymdLocal(date);
      const reports = reportsByDay.get(key) || [];
      out.push({
        key,
        date,
        reports,
        isToday: i === 0,
      });
    }
    return out;
  }, [reportsByDay]);

  // On mount and whenever the active report changes, scroll the active
  // dot's cell (or today's) into view. One-shot per dependency change
  // so operators can pan freely without being yanked back.
  useEffectI(() => {
    const root = scrollerRef.current;
    if (!root) return;
    const target = root.querySelector('[data-calendar-active="true"]')
      || root.querySelector('[data-calendar-today="true"]');
    if (target && typeof target.scrollIntoView === 'function') {
      target.scrollIntoView({ behavior: 'auto', inline: 'end', block: 'nearest' });
    } else {
      root.scrollLeft = root.scrollWidth;
    }
  }, [currentReportId, cells.length]);

  function panBy(delta) {
    const root = scrollerRef.current;
    if (!root) return;
    root.scrollBy({ left: delta, behavior: 'smooth' });
  }

  function showHover(report, event) {
    const rect = event.currentTarget.getBoundingClientRect();
    const rootRect = scrollerRef.current?.parentElement?.getBoundingClientRect();
    setHoverInfo({
      report,
      anchorLeft: rect.left + rect.width / 2 - (rootRect?.left || 0),
      anchorTop: rect.top - (rootRect?.top || 0),
    });
  }

  function hideHover() {
    setHoverInfo(null);
  }

  function openReport(report) {
    navigate(`/insights/${report.id}`);
  }

  return (
    <div className="insights-calendar" role="navigation" aria-label="Insights calendar">
      <button
        type="button"
        className="insights-calendar__pan"
        aria-label="Scroll calendar left"
        onClick={() => panBy(-280)}>
        <Icon name="chevronLeft" size={14} />
      </button>
      <div
        className="insights-calendar__scroller"
        ref={scrollerRef}
        onMouseLeave={hideHover}>
        {loading && cells.length === 0 && (
          <div className="insights-calendar__loading">Loading recent days…</div>
        )}
        {cells.map((cell) => {
          const hasActive = cell.reports.some((r) => r.id === currentReportId);
          const cellClasses = [
            'insights-calendar__day',
            cell.isToday ? 'insights-calendar__day--today' : '',
            hasActive ? 'insights-calendar__day--active' : '',
            cell.reports.length > 0 ? 'insights-calendar__day--has-report' : 'insights-calendar__day--empty',
          ].filter(Boolean).join(' ');
          return (
            <div
              key={cell.key}
              className={cellClasses}
              data-calendar-today={cell.isToday ? 'true' : undefined}
              data-calendar-active={hasActive ? 'true' : undefined}>
              <span className="insights-calendar__weekday">{formatWeekday(cell.date)}</span>
              <span className="insights-calendar__daynum">{cell.date.getDate()}</span>
              <span className="insights-calendar__dots" aria-hidden={cell.reports.length === 0}>
                {cell.reports.map((report) => {
                  const isActive = report.id === currentReportId;
                  return (
                    <button
                      key={report.id}
                      type="button"
                      className={`insights-calendar__dot${isActive ? ' insights-calendar__dot--active' : ''}`}
                      onClick={() => openReport(report)}
                      onMouseEnter={(event) => showHover(report, event)}
                      onFocus={(event) => showHover(report, event)}
                      onBlur={hideHover}
                      aria-label={report.headline || `Insights for ${formatShortDate(report.periodEnd)}`}
                      title={report.headline || `Insights for ${formatShortDate(report.periodEnd)}`} />
                  );
                })}
              </span>
            </div>
          );
        })}
      </div>
      <button
        type="button"
        className="insights-calendar__pan"
        aria-label="Scroll calendar right"
        onClick={() => panBy(280)}>
        <Icon name="chevronRight" size={14} />
      </button>
      {hoverInfo && (
        <CalendarHoverCard hover={hoverInfo} />
      )}
    </div>
  );
}

function CalendarHoverCard({ hover }) {
  const { report, anchorLeft, anchorTop } = hover;
  // Container is `position: relative`; we measured (anchorLeft, anchorTop)
  // relative to it in showHover(). The card is centered horizontally on
  // the dot and pulled up by its full height via translate-Y so it
  // floats just above the calendar row.
  const style = {
    left: Math.max(8, anchorLeft),
    top: Math.max(0, anchorTop),
  };
  const heat = successHeat(report.successRate);
  return (
    <div className={`insights-calendar__tooltip insights-calendar__tooltip--${heat}`} style={style} role="tooltip">
      <div className="insights-calendar__tooltip-meta">
        <span className="insights-calendar__tooltip-date">{formatLongDate(new Date(report.periodEnd))}</span>
        <span className={`insights-calendar__tooltip-status insights-calendar__tooltip-status--${report.status}`}>
          {statusLabel(report.status) || report.status}
        </span>
      </div>
      <div className="insights-calendar__tooltip-headline">
        {report.headline || 'Editorial digest'}
      </div>
      <div className="insights-calendar__tooltip-stats">
        <span><strong>{report.successRate !== null ? formatPercent(report.successRate) : '—'}</strong> success</span>
        <span><strong>{formatNumber(report.totalRuns)}</strong> runs</span>
        <span><strong>{formatNumber(report.failedRuns)}</strong> failed</span>
      </div>
      <div className="insights-calendar__tooltip-cta">Click to open</div>
    </div>
  );
}

// Normalizer used to fingerprint editorial findings across days.
// Two findings are considered "the same issue" when their normalized
// title (alphanumerics only, lowercased, first 48 chars) and category
// match. Tool overlap isn't required because the LLM may rephrase the
// same root issue differently while keeping the topic intact.
function findingFingerprint(finding) {
  const title = String(finding?.title || '')
    .toLowerCase()
    .replace(/[^a-z0-9 ]+/g, '')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 48);
  const category = String(finding?.category || 'other');
  return `${category}::${title}`;
}

// Walks the prior-day reports back from today and returns a Map of
// fingerprint → consecutive-day-count for findings that show up in
// today AND in an unbroken chain of prior reports. The count includes
// today (so a finding present today + yesterday => 2). Findings that
// don't appear in yesterday's report aren't tracked here even if they
// returned earlier — we want adjacency, not historical reappearance.
function computePersistence(currentFindings, previousReports) {
  const persistence = new Map();
  if (!Array.isArray(currentFindings) || currentFindings.length === 0) return persistence;
  const todayPrints = new Set(currentFindings.map(findingFingerprint));
  for (const print of todayPrints) {
    let streak = 1;
    for (const prior of previousReports || []) {
      const priorPrints = new Set((prior.findings || []).map(findingFingerprint));
      if (priorPrints.has(print)) {
        streak += 1;
      } else {
        break;
      }
    }
    if (streak > 1) persistence.set(print, streak);
  }
  return persistence;
}

function InsightsReport({ report, previousReports, calendarRows, navigate, archiving, onArchive }) {
  const metrics = report.metrics || {};
  const narrative = report.narrative || {};
  const totals = metrics.totals || {};
  const focalMcp = metrics.focal_mcp || null;
  const otherMcps = metrics.account_other_mcps || [];
  const benchmarks = metrics.benchmark_competitor || [];

  const productFindings = narrative.product_findings || [];
  const setupFindings = narrative.setup_findings || [];
  const competitorFindings = narrative.competitor_findings || [];
  const diffPills = narrative.diff_pills || [];
  const headlineCopy = (narrative.headline || '').trim();
  const focalName = (narrative.focal_mcp_name || focalMcp?.name || '').trim();

  const yesterday = previousReports?.[0];
  const successDelta = (totals.success_rate != null && yesterday?.successRate != null)
    ? Math.round((totals.success_rate - yesterday.successRate) * 1000) / 10
    : null;

  return (
    <>
      {/* On-brand offset "window" container — same hard-ink card +
          offset second sheet used on /runs/:id and on the landing
          page hero. The window-head strip carries the mono eyebrow
          on the left and the report URL on the right; the editorial
          content lives inside `.i-page__inner` underneath.
          NOTE: The large KPI pulse used to live above this paper;
          it now lives on the page above the Daily Digest section
          (see InsightsLiveHero in the parent). Inside the paper we
          only keep a small editorial intro stats line below the
          headline so the document carries "a few KPIs" without
          duplicating the hero's heft. */}
      <article className="i-page window" aria-label="Daily digest report">
        <div className="window-head i-page__head">
          <span>
            DAILY DIGEST
            {report.archivedAt && (
              <span className="i-archived-pill" title="This digest is archived. It is hidden from the calendar and the previous-day diff but reachable by direct URL.">
                ARCHIVED
              </span>
            )}
          </span>
          <span className="window-head-url">
            {`/insights${report.id ? `/${report.id.slice(0, 8)}` : ''}`}
            {onArchive && (
              <button
                type="button"
                className="btn btn-sm btn-ghost i-archive-btn"
                disabled={archiving}
                title={report.archivedAt ? 'Unarchive this digest' : 'Archive this digest'}
                onClick={() => onArchive(!report.archivedAt)}>
                {archiving
                  ? <Icon name="refresh" size={12} />
                  : <Icon name="archive" size={12} />}
                {report.archivedAt ? 'Unarchive' : 'Archive'}
              </button>
            )}
          </span>
        </div>
        <div className="i-page__inner">
          {(headlineCopy || focalName) && (
            <InsightsHeadline
              headline={headlineCopy}
              lede={(narrative.lede || '').trim()}
              focalName={focalName} />
          )}

          <InsightsIntroStats
            totals={totals}
            successDelta={successDelta}
            yesterday={yesterday} />

          {productFindings.length > 0 && (
            <InsightsFindingSection
              tone="product"
              title="Improve your MCP"
              subtitle="What agents struggle to do — and what to change in the product to fix it."
              findings={productFindings} />
          )}

          <InsightsCompetitorSection findings={competitorFindings} hasBenchmarks={benchmarks.length > 0} />

          {setupFindings.length > 0 && (
            <InsightsFindingSection
              tone="setup"
              title="Improve how you test this MCP"
              subtitle="Measurement gaps in the Armature setup. Fixing these tightens future digests."
              findings={setupFindings} />
          )}

          {diffPills.length > 0 && (
            <InsightsDiffPills pills={diffPills} />
          )}

          <InsightsAccountFooter
            otherMcps={otherMcps}
            focalName={focalName}
            navigate={navigate} />
        </div>
      </article>
    </>
  );
}

// Slim editorial KPI strip that lives inside the digest paper
// directly under the headline. Replaces the older big-tile
// `<InsightsPulse>` — same numbers (Pass rate / Runs / Failures /
// Median run + the focal MCP scope line) but a much smaller
// visual treatment, so the page-level KPI hero above the section
// stays the "operations bar" and this one stays the "editorial
// intro". Numbers are derived from the digest's own analysis
// window (locked to 48h server-side), independent from the live
// hero's time picker.
function InsightsIntroStats({ totals, successDelta, yesterday }) {
  const successRate = totals.success_rate;
  const successTone = typeof successRate !== 'number'
    ? 'flat'
    : successRate >= 0.95 ? 'good' : successRate >= 0.8 ? 'warn' : 'bad';
  const failedCount = Number(totals.failed_runs || 0);
  const failedTone = failedCount > 0 ? 'bad' : 'good';
  const cells = [
    {
      key: 'pass',
      label: 'Pass rate',
      value: successRate == null ? '—' : formatPercent(successRate),
      tone: successTone,
      delta: successDelta,
    },
    {
      key: 'runs',
      label: 'Runs',
      value: formatNumber(totals.total_runs || 0),
    },
    {
      key: 'fail',
      label: 'Failures',
      value: formatNumber(failedCount),
      tone: failedTone,
    },
    {
      key: 'p50',
      label: 'Median run',
      value: totals.p50_duration_ms == null ? '—' : formatDuration(totals.p50_duration_ms),
    },
  ];
  return (
    <section className="i-intro" aria-label="Window snapshot">
      <dl className="i-intro__stats">
        {cells.map((cell) => (
          <div key={cell.key} className={`i-intro__stat is-${cell.tone || 'flat'}`}>
            <dt className="i-intro__label">{cell.label}</dt>
            <dd className="i-intro__value">{cell.value}</dd>
            {cell.delta != null && (
              <div className={`i-intro__delta is-${cell.delta > 0 ? 'good' : cell.delta < 0 ? 'bad' : 'flat'}`}>
                {cell.delta > 0 ? '+' : ''}{cell.delta} pts vs yesterday
              </div>
            )}
            {cell.delta == null && yesterday && cell.key === 'pass' && (
              <div className="i-intro__delta is-flat">No prior digest</div>
            )}
          </div>
        ))}
      </dl>
    </section>
  );
}

// Big-bold-sentence headline. Designed to deliver the message
// before any chart so a Head of Product reading on phone gets the
// answer in 5 seconds. `lede` is an optional ONE-sentence intro
// shown beneath the headline when the headline cannot carry the
// news on its own. The KPI strip below carries every other number
// the reader needs.
function InsightsHeadline({ headline, lede = '', focalName }) {
  return (
    <section className="i-headline">
      {focalName && (
        <div className="eyebrow i-headline__eyebrow">{focalName}</div>
      )}
      {headline && <h1 className="i-headline__copy">{headline}</h1>}
      {lede && <p className="i-headline__lede">{lede}</p>}
    </section>
  );
}

// One bucket (product / setup / competitor). Renders a section
// header followed by a vertical column of finding cards.
function InsightsFindingSection({ tone, title, subtitle, findings }) {
  return (
    <section className={`i-findings is-${tone}`}>
      <header className="i-findings__head">
        <h2 className="i-findings__title">{title}</h2>
        {subtitle && <p className="i-findings__sub">{subtitle}</p>}
      </header>
      <div className="i-findings__list">
        {findings.map((finding, idx) => (
          <InsightsFindingCard key={finding.tag || idx} finding={finding} />
        ))}
      </div>
    </section>
  );
}

// A finding: tag + bold title at top (clickable header), and a
// collapsible body containing analysis → chart → fix. Default
// collapsed so the report stays scannable; readers expand only the
// findings they want to deep-dive on instead of scrolling through
// every chart. The "always-open" override is reserved for the
// no-data competitor placeholder where there's no body to hide.
function InsightsFindingCard({ finding, defaultOpen = false, forceOpen = false }) {
  const tone = finding.tone || 'warning';
  const [open, setOpen] = useStateI(defaultOpen);
  const isOpen = forceOpen || open;
  const hasBody = Boolean(finding.analysis || finding.chart || finding.fix);
  const toggle = () => {
    if (forceOpen || !hasBody) return;
    setOpen((v) => !v);
  };
  return (
    <article className={`i-finding is-${tone}${isOpen ? ' is-open' : ''}`}>
      <button
        type="button"
        className="i-finding__head"
        onClick={toggle}
        aria-expanded={isOpen}
        aria-controls={`finding-${finding.tag || ''}-body`}
        disabled={forceOpen || !hasBody}>
        <span className={`i-finding__tag is-${tone}`}>{finding.tag}</span>
        <h3 className="i-finding__title">{finding.title}</h3>
        {hasBody && !forceOpen && (
          <span className="i-finding__chev" aria-hidden="true">
            {/* One chevron icon, CSS-rotated when the card opens —
                avoids adding a chevronUp variant to the shared icon set. */}
            <Icon name="chevronDown" size={14} />
          </span>
        )}
      </button>
      {isOpen && hasBody && (
        <div className="i-finding__body" id={`finding-${finding.tag || ''}-body`}>
          {finding.analysis && (
            <p className="i-finding__analysis">{finding.analysis}</p>
          )}
          {finding.chart && (
            <div className="i-finding__chart">
              <InsightsChart chart={finding.chart} />
            </div>
          )}
          {finding.fix && (
            <div className="i-finding__fix">
              <span className="i-finding__fix-label">Recommendation</span>
              {finding.fix}
            </div>
          )}
        </div>
      )}
    </article>
  );
}

// Competitor section: when benchmark data is missing we still render
// a card explaining what would unlock this row. Honesty about gaps is
// part of the product — it's the Head of Product's most important
// missing signal.
function InsightsCompetitorSection({ findings, hasBenchmarks }) {
  if (findings.length === 0 && !hasBenchmarks) {
    // Empty-state card explains the missing signal. Collapsing it
    // would defeat the purpose, so we render it always-open with no
    // disclosure chrome.
    const placeholder = {
      tag: 'C0',
      tone: 'info',
      title: 'No benchmark batches finished in this window',
      analysis:
        "Competitor comparison is the most important signal a digest can carry. Without it we can't say whether your shell-fallback rate, token cost, or pass rate are normal for MCPs in your category.",
      fix:
        'Schedule a benchmark batch comparing this MCP against 2 or 3 competitors on your top workflows. Once it finishes, this section will surface findings like "Supabase agents finish Customer Notes in 1.2 min vs your 5.8 min".',
    };
    return (
      <section className="i-findings is-competitor">
        <header className="i-findings__head">
          <h2 className="i-findings__title">How you compare to competitors</h2>
        </header>
        <div className="i-findings__list">
          <InsightsFindingCard finding={placeholder} forceOpen />
        </div>
      </section>
    );
  }
  if (findings.length === 0) return null;
  return (
    <CollapsibleFindingSection
      tone="competitor"
      title="How you compare to competitors"
      subtitle="From completed benchmark batches. Borrow the patterns that win."
      findings={findings} />
  );
}

// Same shape as `InsightsFindingSection` but the header is a button
// that toggles the list. Used by the benchmark/competitor block so
// the digest stays scannable — competitor findings are the longest
// section and most readers want to glance at the title before they
// decide to expand.
function CollapsibleFindingSection({ tone, title, subtitle, findings }) {
  const [open, setOpen] = useStateI(false);
  return (
    <section className={`i-findings is-${tone}${open ? ' is-open' : ''}`}>
      <button
        type="button"
        className="i-findings__head i-findings__head--toggle"
        aria-expanded={open}
        onClick={() => setOpen((v) => !v)}>
        <div className="i-findings__head-text">
          <h2 className="i-findings__title">{title}</h2>
          {subtitle && <p className="i-findings__sub">{subtitle}</p>}
          <span className="i-findings__count">{findings.length} finding{findings.length === 1 ? '' : 's'}</span>
        </div>
        <span className="i-findings__chev" aria-hidden="true">
          <Icon name="chevronDown" size={14} />
        </span>
      </button>
      {open && (
        <div className="i-findings__list">
          {findings.map((finding, idx) => (
            <InsightsFindingCard key={finding.tag || idx} finding={finding} />
          ))}
        </div>
      )}
    </section>
  );
}

function InsightsDiffPills({ pills }) {
  return (
    <section className="i-diffpills">
      <div className="i-diffpills__label">What changed since yesterday</div>
      <div className="i-diffpills__row">
        {pills.map((pill, idx) => (
          <span key={idx} className={`i-diffpill is-${pill.kind}`}>
            <span className="i-diffpill__kind">{pill.kind.toUpperCase()}</span>
            <span className="i-diffpill__text">{pill.text}</span>
          </span>
        ))}
      </div>
    </section>
  );
}

function InsightsAccountFooter({ otherMcps, focalName, navigate }) {
  if (otherMcps.length === 0) return null;
  return (
    <section className="i-acct">
      <div className="i-acct__label">Account-wide notes · about measurement, not your MCP</div>
      <p className="i-acct__body">
        {focalName ? (
          <>This digest analyzed <strong>{focalName}</strong> in detail. </>
        ) : null}
        Other MCPs in this account also ran in the window:{' '}
        {otherMcps.map((mcp, idx) => (
          <React.Fragment key={mcp.mcp_name + idx}>
            {idx > 0 ? ', ' : ''}
            <button
              type="button"
              className="i-acct__chip"
              onClick={() => navigate(`/runs?mcp=${encodeURIComponent(mcp.mcp_name)}`)}>
              {mcp.mcp_name} · {mcp.pass_rate != null ? `${Math.round(mcp.pass_rate * 100)}%` : '—'} · {mcp.runs} runs
            </button>
          </React.Fragment>
        ))}
        . Low pass rates on auxiliary MCPs are most often auth-profile expiry, not MCP quality.
      </p>
    </section>
  );
}

// Inline SVG chart renderer. Supports the four kinds the LLM is
// allowed to emit: bar_horizontal, bar_horizontal_stacked,
// bar_vertical, donut. Kept dependency-free so the digest renders
// fine in any embed context (email screenshots, screenshots, etc.).
function InsightsChart({ chart }) {
  if (!chart) return null;
  if (chart.kind === 'donut') return <DonutChart chart={chart} />;
  if (chart.kind === 'bar_vertical') return <VerticalBarChart chart={chart} />;
  if (chart.kind === 'bar_horizontal_stacked') {
    return <HorizontalBarChart chart={chart} stacked />;
  }
  return <HorizontalBarChart chart={chart} />;
}

function chartToneColor(tone) {
  switch (tone) {
    case 'danger': return 'var(--accent-danger, #d04949)';
    case 'warning': return 'var(--accent-warning, #c98316)';
    case 'success': return 'var(--accent-success, #2f8c5d)';
    case 'info': return 'var(--accent-info, #2f6fc8)';
    case 'neutral': return 'var(--text-tertiary, #8a93a6)';
    default: return 'var(--accent-info, #2f6fc8)';
  }
}

const FALLBACK_SERIES_TONES = ['info', 'success', 'danger', 'warning', 'neutral'];

// Donut slices must each get a distinct color. The series tone (when the
// LLM provides one) describes the overall chart, not a single slice, so
// using it for every slice — as a naive `series[0].tone || palette[i]`
// would — collapses the whole donut to one color. Instead, anchor the
// series tone to slice 0 and walk the fallback palette for the rest,
// skipping the anchored tone so two slices never collide.
//
// Defensive against unknown tone strings: `chartToneColor` maps anything
// outside the five known tones to the same color as `info`, so anchoring
// an unrecognized tone would recreate the same-color collapse against
// the `info` palette entry. Treat unknown tones as if no tone was given.
function donutSliceTones(seriesTone, sliceCount) {
  const anchor = FALLBACK_SERIES_TONES.includes(seriesTone) ? seriesTone : null;
  const palette = anchor
    ? [anchor, ...FALLBACK_SERIES_TONES.filter((t) => t !== anchor)]
    : FALLBACK_SERIES_TONES;
  const tones = [];
  for (let i = 0; i < sliceCount; i++) {
    tones.push(palette[i % palette.length]);
  }
  return tones;
}

function HorizontalBarChart({ chart, stacked = false }) {
  const categories = chart.categories || [];
  const series = chart.series || [];
  const suffix = chart.value_suffix || '';
  const seriesMax = stacked
    ? Math.max(0, ...categories.map((_, i) => series.reduce((s, ser) => s + (Number(ser.data[i]) || 0), 0)))
    : Math.max(0, ...series.flatMap((s) => s.data.map((v) => Number(v) || 0)));
  const max = seriesMax > 0 ? seriesMax : 1;
  return (
    <div className="i-chart i-chart--hbar">
      {categories.map((cat, i) => {
        const seriesValues = series.map((s) => Number(s.data[i]) || 0);
        const total = seriesValues.reduce((a, b) => a + b, 0);
        return (
          <div key={cat + i} className="i-chart__row">
            <div className="i-chart__label" title={cat}>{cat}</div>
            <div className="i-chart__track">
              {stacked ? (
                <div className="i-chart__stack" style={{ width: `${Math.max(0, (total / max) * 100)}%` }}>
                  {series.map((ser, sIdx) => {
                    const v = seriesValues[sIdx];
                    if (v <= 0) return null;
                    return (
                      <div
                        key={ser.name + sIdx}
                        className="i-chart__seg"
                        style={{
                          width: `${(v / total) * 100}%`,
                          background: chartToneColor(ser.tone || FALLBACK_SERIES_TONES[sIdx % FALLBACK_SERIES_TONES.length]),
                        }} />
                    );
                  })}
                </div>
              ) : (
                <div
                  className="i-chart__bar"
                  style={{
                    width: `${(seriesValues[0] / max) * 100}%`,
                    background: chartToneColor(series[0]?.tone || FALLBACK_SERIES_TONES[0]),
                  }} />
              )}
            </div>
            <div className="i-chart__value">
              {stacked
                ? series.map((ser, sIdx) => `${formatChartNumber(seriesValues[sIdx])}${suffix}`).join(' / ')
                : `${formatChartNumber(seriesValues[0])}${suffix}`}
            </div>
          </div>
        );
      })}
      {series.length > 1 && (
        <div className="i-chart__legend">
          {series.map((ser, sIdx) => (
            <span key={ser.name + sIdx} className="i-chart__legend-item">
              <span
                className="i-chart__swatch"
                style={{ background: chartToneColor(ser.tone || FALLBACK_SERIES_TONES[sIdx % FALLBACK_SERIES_TONES.length]) }} />
              {ser.name}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

function VerticalBarChart({ chart }) {
  const categories = chart.categories || [];
  const series = chart.series || [];
  const suffix = chart.value_suffix || '';
  const max = Math.max(1, ...series.flatMap((s) => s.data.map((v) => Number(v) || 0)));
  return (
    <div className="i-chart i-chart--vbar">
      <div className="i-chart__cols">
        {categories.map((cat, i) => (
          <div key={cat + i} className="i-chart__col">
            <div className="i-chart__col-bars">
              {series.map((ser, sIdx) => {
                const v = Number(ser.data[i]) || 0;
                return (
                  <div
                    key={ser.name + sIdx}
                    className="i-chart__col-bar"
                    style={{
                      height: `${(v / max) * 100}%`,
                      background: chartToneColor(ser.tone || FALLBACK_SERIES_TONES[sIdx % FALLBACK_SERIES_TONES.length]),
                    }}
                    title={`${ser.name}: ${v}${suffix}`} />
                );
              })}
            </div>
            <div className="i-chart__col-label">{cat}</div>
          </div>
        ))}
      </div>
      {series.length > 1 && (
        <div className="i-chart__legend">
          {series.map((ser, sIdx) => (
            <span key={ser.name + sIdx} className="i-chart__legend-item">
              <span
                className="i-chart__swatch"
                style={{ background: chartToneColor(ser.tone || FALLBACK_SERIES_TONES[sIdx % FALLBACK_SERIES_TONES.length]) }} />
              {ser.name}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

function DonutChart({ chart }) {
  const series = chart.series || [];
  const categories = chart.categories || [];
  const allValues = series[0]?.data || [];
  const total = allValues.reduce((a, b) => a + (Number(b) || 0), 0);
  if (total <= 0) return null;
  const sliceCount = Math.max(allValues.length, categories.length);
  const sliceTones = donutSliceTones(series[0]?.tone || null, sliceCount);
  const r = 36;
  const c = 2 * Math.PI * r;
  let acc = 0;
  return (
    <div className="i-chart i-chart--donut">
      <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
        <circle cx="48" cy="48" r={r} fill="none" stroke="var(--stroke-tertiary, #e6e8ee)" strokeWidth="14" />
        {allValues.map((rawV, i) => {
          const v = Number(rawV) || 0;
          if (v <= 0) return null;
          const dash = (v / total) * c;
          const offset = -acc;
          acc += dash;
          return (
            <circle
              key={i}
              cx="48"
              cy="48"
              r={r}
              fill="none"
              stroke={chartToneColor(sliceTones[i])}
              strokeWidth="14"
              strokeDasharray={`${dash} ${c - dash}`}
              strokeDashoffset={offset}
              transform="rotate(-90 48 48)" />
          );
        })}
      </svg>
      <div className="i-chart__donut-legend">
        {categories.map((cat, i) => (
          <div key={cat + i} className="i-chart__donut-item">
            <span
              className="i-chart__swatch"
              style={{ background: chartToneColor(sliceTones[i]) }} />
            <span className="i-chart__donut-label">{cat}</span>
            <span className="i-chart__donut-value">{formatChartNumber(allValues[i])}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function formatChartNumber(value) {
  const n = Number(value);
  if (!Number.isFinite(n)) return '';
  if (Math.abs(n) >= 1000) return formatNumber(Math.round(n));
  if (Number.isInteger(n)) return String(n);
  return n.toFixed(1).replace(/\.0$/, '');
}

// (Legacy renderer and its companions removed — `InsightsReport` is
// the single rendering path. Pre-two-bucket digests (one historic
// row) were deleted from `org_insight_reports`; every digest the
// scheduler produces from here on conforms to the modern schema:
// { headline, lede, focal_mcp_name, product_findings[],
//   setup_findings[], competitor_findings[], diff_pills[] }.)

function InsightsSkeleton() {
  return (
    <div className="i-skeleton">
      <div className="i-skeleton__row" style={{ height: 96 }} />
      <div className="i-skeleton__row" style={{ height: 140 }} />
      <div className="i-skeleton__cols">
        <div className="i-skeleton__row" style={{ height: 220 }} />
        <div className="i-skeleton__row" style={{ height: 220 }} />
        <div className="i-skeleton__row" style={{ height: 220 }} />
      </div>
    </div>
  );
}

function InsightsEmpty({ regenerating, onRegenerate }) {
  return (
    <EmptyState
      icon="newspaper"
      title="No insights report yet"
      body="Insights generates a daily editorial digest of your MCP runs. Trigger your first one now."
      action={(
        <Button
          size="sm"
          variant="primary"
          loading={regenerating}
          loadingLabel="Queuing"
          onClick={onRegenerate}>
          <Icon name="sparkles" size={12} /> Generate first report
        </Button>
      )} />
  );
}

// Returns true when the selected report exists but has not finished
// generating yet. Rendering the report shell in this state would show
// an empty headline, no findings, and a misleading "0%" pass-rate
// card — `InsightsGenerating` is the placeholder we show instead.
function isReportGenerating(report) {
  return report?.status === 'queued' || report?.status === 'running';
}

function InsightsGenerating({ status }) {
  const label = status === 'queued' ? 'Queued' : 'Working on it';
  return (
    <section className="i-generating" aria-live="polite">
      <div className="i-generating__spin" aria-hidden="true">
        <Icon name="refresh" size={16} />
      </div>
      <div className="i-generating__body">
        <div className="i-generating__label">{label}</div>
        <p className="i-generating__copy">
          Today's digest is still being written. Check back in a minute.
        </p>
      </div>
    </section>
  );
}

function statusLabel(status) {
  switch (status) {
    case 'queued': return 'Queued — generating soon';
    case 'running': return 'Generating now';
    case 'failed': return 'Last run failed';
    case 'skipped': return 'Skipped';
    default: return status;
  }
}

function formatPeriod(report) {
  if (!report) return '';
  const start = report.periodStart ? new Date(report.periodStart) : null;
  const end = report.periodEnd ? new Date(report.periodEnd) : null;
  if (!start || !end) return '';
  const sameDay = start.toDateString() === end.toDateString();
  const fmt = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  return sameDay ? fmt(start) : `${fmt(start)} – ${fmt(end)}`;
}

function formatShortDate(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '';
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

function formatWeekday(date) {
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
  return date.toLocaleDateString(undefined, { weekday: 'short' }).toUpperCase();
}

function formatLongDate(date) {
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
  return date.toLocaleDateString(undefined, {
    weekday: 'long',
    month: 'short',
    day: 'numeric',
  });
}

// Truncates a Date to start-of-day in the viewer's local timezone.
// The calendar strip is local-anchored so its labels and groupings
// match how the viewer thinks about "today" and "yesterday"; the
// scheduler still dedupes on the UTC calendar day server-side, but
// that contract is orthogonal to how the digest is displayed.
function startOfDayLocal(date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

// YYYY-MM-DD in the viewer's local timezone. Stable key the calendar
// uses to match a report's period_end against the cell axis. Strings
// (ISO) are accepted as-is.
function ymdLocal(input) {
  if (!input) return '';
  const d = input instanceof Date ? input : new Date(input);
  if (Number.isNaN(d.getTime())) return '';
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

function successTone(rate) {
  if (rate === null || rate === undefined) return 'neutral';
  if (rate >= 0.95) return 'ok';
  if (rate >= 0.85) return 'warn';
  return 'bad';
}

function successHeat(rate) {
  if (rate === null || rate === undefined) return 'neutral';
  if (rate >= 0.9) return 'ok';
  if (rate >= 0.7) return 'warn';
  return 'bad';
}

window.InsightsPage = InsightsPage;
