/**
 * Chart Builder — the modal body (spec §06).
 *
 * A two-pane composer. Left: the Data section (filters, calculated fields, row
 * preview), the mark selector, the field shelf (columns as type-glyphed chips), the
 * Marks card (Colour/Size — field or constant), chart-level sort/stacking, guidance,
 * and Create. Right: the on-chart Columns/Rows shelves, the live preview, and the
 * chart-properties strip (title/subtitle/size). All spec logic and defaults/guards
 * come from `@core/chart-builder` via `ChartBuilderStore`; this component is the view.
 *
 * Assignment is field-first: a chip click opens an explicit channel chooser, or
 * assigns directly when a channel is armed (the slot's "Pick a field…" state, made
 * visible at the shelf). A bound channel renders as a pill — a field-type chip that
 * opens a direct type pick, the field name, a remove ✕ — with its per-type
 * transforms (aggregate / bin / granularity) inline beside it. Pickers use
 * `SelectControl`, not native selects. The preview is builder-local (its own
 * debounced render over the shared `chart-renderer` service) rather than a reuse of
 * `LivePreview`, which is bound to the snippet editor's stores.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
  CHANNELS,
  CHART_INTENTS,
  MARK_TYPES,
  TIME_UNITS,
  activeIntent,
  builderWarnings,
  channelAcceptsValue,
  defaultChannelValue,
  defaultFieldType,
  effectiveColumns,
  filterOpArity,
  intentApplicable,
  isBuilderConfigValid,
  isChannelTypeAllowed,
  isColumnAllowedOnChannel,
  isValueMapping,
  supportsAggregate,
  supportsBin,
  supportsSort,
  supportsStack,
  supportsTimeUnit,
  validAggregateOps,
  validFieldTypes,
  validFilterOps,
  type BuilderCalculate,
  type AggregateOp,
  type BuilderColumns,
  type BuilderFilter,
  type BuilderWarningFix,
  type ChannelMapping,
  type ChannelName,
  type ChartIntent,
  type FieldType,
  type FilterOp,
  type MarkType,
  type TimeUnit,
} from '@core/chart-builder';
import { referencedFields, validateExpression } from '@core/expr-validate';
import { tabularRows } from '@core/dataset';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
import { closeModal, openModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import {
  COUNT_FIELD,
  selectBuilderSpecText,
  selectBuilderValid,
  useChartBuilderStore,
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { DataInspectorPanel } from './DataInspector';
import { DataTable } from './DataTable';
import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
import styles from './ChartBuilderModal.module.css';

const RENDER_DEBOUNCE_MS = 300;

/**
 * Render-timing diagnostics for the builder preview. A many-mark chart (e.g. the
 * default one-bar-per-row on a 10k-row dataset) is cheap to compile but expensive
 * for the browser to lay out as **SVG**, and that cost lands *after* `embed()`
 * resolves, in the next paint — the chart appears, then the tab freezes for a moment.
 * Each phase is timed, including that post-embed paint (a double rAF lands just after
 * it), so the numbers attribute the cost to layout rather than chart compilation.
 * Logged in dev always; in prod only when a render is slow.
 */
const SLOW_RENDER_MS = 250;
function logBuilderRenderTiming(t: {
  parse: number;
  prepare: number;
  destroy: number;
  embed: number;
  paint: number;
  total: number;
}): void {
  const total = Math.round(t.total);
  if (!import.meta.env.DEV && total < SLOW_RENDER_MS) return;
  const ms = (n: number) => Math.round(n);
  const { rowCount, config } = useChartBuilderStore.getState();
  console.info(
    `[chart-builder] render ${total}ms — parse ${ms(t.parse)} · prepare ${ms(t.prepare)} · ` +
      `destroy ${ms(t.destroy)} · embed ${ms(t.embed)} · paint ${ms(t.paint)} ` +
      `(mark=${config.mark}, rows=${rowCount ?? 'n/a'})`,
  );
}

/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
function titleCase(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
 *  is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON
 *  audience the builder serves (the spec's mark-set table notes the rect↔heatmap pairing). */
const MARK_LABELS: Record<MarkType, string> = {
  bar: 'Bar',
  line: 'Line',
  point: 'Point',
  area: 'Area',
  circle: 'Circle',
  rect: 'Heatmap',
};

const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
  value: m,
  label: MARK_LABELS[m],
  title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
}));

/** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the
 *  dataset must have for the intent to apply (shown when the chip is disabled). */
const INTENT_LABELS: Record<ChartIntent, string> = {
  compare: 'Compare',
  ranking: 'Ranking',
  time: 'Change over time',
  correlation: 'Correlation',
  distribution: 'Distribution',
  partToWhole: 'Part-to-whole',
  heatmap: 'Heatmap',
};

const INTENT_NEEDS: Record<ChartIntent, string> = {
  compare: 'a category column',
  ranking: 'a category column',
  time: 'a date column',
  correlation: 'two number columns',
  distribution: 'a number column',
  partToWhole: 'two category columns',
  heatmap: 'two category columns',
};

const CHANNEL_LABELS: Record<ChannelName, string> = {
  x: 'X',
  y: 'Y',
  color: 'Color',
  size: 'Size',
};

/** Terse N | O | Q | T abbreviations for a field type (with full-name tooltips). */
const TYPE_ABBR: Record<FieldType, string> = {
  nominal: 'N',
  ordinal: 'O',
  quantitative: 'Q',
  temporal: 'T',
};

/** Readable labels for the non-count aggregate operators (`validAggregateOps`
 *  supplies the per-type menu — e.g. a Nominal field offers only Count distinct). */
const AGGREGATE_LABELS: Record<Exclude<AggregateOp, 'count'>, string> = {
  sum: 'Sum',
  mean: 'Mean',
  median: 'Median',
  min: 'Min',
  max: 'Max',
  distinct: 'Count distinct',
};

/** Friendly labels for each temporal granularity. */
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
  year: 'Year',
  yearquarter: 'Year-Quarter',
  yearmonth: 'Year-Month',
  yearmonthdate: 'Year-Month-Day',
  quarter: 'Quarter',
  month: 'Month',
  week: 'Week',
  date: 'Day of month',
  day: 'Day of week',
  hours: 'Hour',
};

/** Readable labels for the filter operators, phrased to read as "<field> <op> <value>". */
const FILTER_OP_LABELS: Record<FilterOp, string> = {
  equal: 'is',
  notEqual: 'is not',
  lt: '<',
  lte: '≤',
  gt: '>',
  gte: '≥',
  range: 'is between',
  oneOf: 'is one of',
};

/** Rows shown in the builder's data-preview table before truncating (1D, spec §06). */
const PREVIEW_ROW_LIMIT = 50;

/**
 * The Vega expression-language reference — both expression inputs (a filter's
 * expression mode, a calculated field) compile to a raw Vega expression, so this is
 * the precise vocabulary. Surfaced contextually (only when an expression is in play),
 * external so it falls outside offline scope, opened in a new tab.
 */
const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/';

/** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */
function datumRef(name: string): string {
  return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`;
}

/**
 * An example expression seeded as the input placeholder, drawn from the dataset's own
 * columns — discovery for writing Vega expressions without an autocomplete popup. A
 * filter example reads as a predicate; a calculate example as a derived value.
 */
function exprPlaceholder(columns: BuilderColumns, kind: 'filter' | 'calc'): string {
  const numeric = columns.columnTypes.find((c) => c.type === 'number')?.name;
  const anyField = columns.columns[0];
  if (kind === 'filter') {
    if (numeric) return `${datumRef(numeric)} > 0`;
    return anyField ? `${datumRef(anyField)} != null` : 'datum.value > 0';
  }
  if (numeric) return `${datumRef(numeric)} * 2`;
  return anyField ? datumRef(anyField) : 'datum.a + datum.b';
}

/** A compact type indicator for a column option (# / date / bool / text). */
function typeBadge(type: ColumnType): string {
  switch (type) {
    case 'number':
      return '#';
    case 'date':
      return 'date';
    case 'boolean':
      return 'bool';
    default:
      return 'text';
  }
}

/** True when the mapping is the field-less Count-of-records measure. */
function isCount(mapping: ChannelMapping | null): boolean {
  return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined;
}

/** Past this many columns the field shelf splits into Dimensions / Measures groups;
 *  below it (and unless both groups are non-empty) it stays a single flat list. */
const FIELD_SHELF_SPLIT_MIN = 7;

/** The field types a mapping may cycle through on its channel (its column's valid
 *  types, narrowed by the channel — e.g. Size keeps only the measure types). */
function channelTypeOptions(
  channel: ChannelName,
  mapping: ChannelMapping,
  columns: BuilderColumns,
): FieldType[] {
  if (mapping.field === undefined) return [];
  const colType = columns.columnTypes.find((c) => c.name === mapping.field)?.type ?? 'string';
  return validFieldTypes(colType).filter((t) => isChannelTypeAllowed(channel, t));
}

/**
 * A bound channel rendered as a Tableau-style **pill**: a leading type chip, the field
 * (or "Count") label, and a remove (×). The type chip *is the control* — clicking it
 * cycles the field's type within the set valid for this channel (disabled when only one
 * type applies, e.g. a date). Any applicable transforms (aggregate / bin / granularity)
 * sit in a compact row beneath the pill. A **constant** binding (Colour/Size only)
 * shows the value editor instead — a colour picker or a number — emitting `{ value }`.
 */
function ChannelPill({
  channel,
  mapping,
  columns,
}: {
  channel: ChannelName;
  mapping: ChannelMapping;
  columns: BuilderColumns;
}) {
  const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
  const setChannelType = useChartBuilderStore((s) => s.setChannelType);
  const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate);
  const setChannelBin = useChartBuilderStore((s) => s.setChannelBin);
  const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit);
  const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);

  const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`;
  const clear = () => setChannelColumn(channel, null);

  // A constant value (the Property model) — Colour or Size only.
  if (isValueMapping(mapping)) {
    const isColor = channel === 'color';
    return (
      <div className={`${styles.pill} ${styles.pillConst}`}>
        <span className={styles.pillTag}>value</span>
        {isColor ? (
          <ColorField
            size="sm"
            className={styles.constColor}
            label={`${CHANNEL_LABELS[channel]} constant colour`}
            value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
            onChange={(v) => setChannelConstant(channel, v)}
          />
        ) : (
          <input
            type="number"
            className={styles.constNumber}
            aria-label={`${CHANNEL_LABELS[channel]} constant size`}
            value={String(mapping.value ?? '')}
            onChange={(e) => setChannelConstant(channel, e.target.value)}
          />
        )}
        <button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
          <Icon name="close" />
        </button>
      </div>
    );
  }

  const count = isCount(mapping);
  const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
  const canPick = typeOptions.length > 1;
  const currentType: FieldType = count ? 'quantitative' : mapping.type;
  const label = count ? 'Count' : (mapping.field ?? '');

  const hasTransforms =
    !count &&
    (supportsAggregate(mapping.type) ||
      supportsBin(mapping.type) ||
      supportsTimeUnit(mapping.type));

  return (
    <div className={styles.pillWrap}>
      <div className={styles.pill}>
        {/* The type chip opens a direct pick of the valid types (council 2026-06-12:
            a cycling button gave keyboard/SR users no way to jump to a type). */}
        <SelectControl
          id={`cb-type-${channel}`}
          label={`Field type for ${CHANNEL_LABELS[channel]}`}
          heading="Field type"
          options={typeOptions.map((t) => ({ value: t, label: titleCase(t) }))}
          value={currentType}
          onSelect={(t) => setChannelType(channel, t)}
          triggerClassName={styles.pillType}
          triggerContent={TYPE_ABBR[currentType]}
          triggerTitle={titleCase(currentType)}
          disabled={!canPick}
        />
        <span className={styles.pillName} title={label}>
          {label}
        </span>
        <button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
          <Icon name="close" />
        </button>
      </div>

      {hasTransforms && (
        <div className={styles.pillControls}>
          {supportsAggregate(mapping.type) && (
            <div className={styles.transform}>
              <span className={styles.miniLabel} aria-hidden="true">
                Aggregate
              </span>
              <SelectControl
                id={`cb-agg-${channel}`}
                label={`Aggregate for ${CHANNEL_LABELS[channel]}`}
                heading="Aggregate"
                options={[
                  { value: '', label: 'None' },
                  ...validAggregateOps(mapping.type).map((op) => ({
                    value: op,
                    label: AGGREGATE_LABELS[op],
                  })),
                ]}
                value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
                onSelect={(v) => setChannelAggregate(channel, v || undefined)}
              />
            </div>
          )}

          {supportsBin(mapping.type) && (
            <label className={styles.toggle}>
              <input
                type="checkbox"
                checked={!!mapping.bin}
                onChange={(e) => setChannelBin(channel, e.target.checked)}
              />
              Bin
            </label>
          )}

          {supportsTimeUnit(mapping.type) && (
            <div className={styles.transform}>
              <span className={styles.miniLabel} aria-hidden="true">
                Granularity
              </span>
              <SelectControl
                id={`cb-tu-${channel}`}
                label={`Granularity for ${CHANNEL_LABELS[channel]}`}
                heading="Granularity"
                options={[
                  { value: '', label: 'None (raw)' },
                  ...TIME_UNITS.map((u) => ({ value: u, label: TIME_UNIT_LABELS[u] })),
                ]}
                value={mapping.timeUnit ?? ''}
                onSelect={(v) => setChannelTimeUnit(channel, v || undefined)}
              />
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/**
 * One encoding target. When the channel is bound it shows its `ChannelPill`; when empty
 * it is an **assign target** — a button that arms the channel (field-first: click it,
 * then click a field in the shelf to fill it), plus an "or constant" affordance on the
 * channels that take a fixed value (Colour/Size). `hint` is the empty-state prompt.
 */
function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string }) {
  const baseColumns = useChartBuilderStore((s) => s.columns);
  const calculates = useChartBuilderStore((s) => s.config.calculates);
  const columns = useMemo(
    () => effectiveColumns(baseColumns, calculates),
    [baseColumns, calculates],
  );
  const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
  const active = useChartBuilderStore((s) => s.activeChannel === channel);
  const focusChannel = useChartBuilderStore((s) => s.focusChannel);
  const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);

  if (mapping) {
    return <ChannelPill channel={channel} mapping={mapping} columns={columns} />;
  }

  return (
    <div className={`${styles.slot} ${active ? styles.slotActive : ''}`}>
      <button
        type="button"
        className={styles.slotAssign}
        aria-pressed={active}
        aria-label={`${CHANNEL_LABELS[channel]}: ${
          active ? 'pick a field from the list' : 'arm to assign a field'
        }`}
        onClick={() => focusChannel(active ? null : channel)}
      >
        {active ? 'Pick a field…' : (hint ?? 'Add a field')}
      </button>
      {channelAcceptsValue(channel) && (
        // A ghost button, not a link-styled affordance: it acts (binds a constant),
        // and Carbon draws the line at links navigate / buttons act (council
        // 2026-06-12). Verb-first label per the content rules.
        <button
          type="button"
          className={styles.slotConst}
          onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
        >
          Use a constant
        </button>
      )}
    </div>
  );
}

/** What a channel currently shows, for the assignment chooser's "replaces …" hint. */
function occupantLabel(mapping: ChannelMapping | null | undefined): string | undefined {
  if (!mapping) return undefined;
  if (mapping.value !== undefined) return 'replaces the constant';
  if (mapping.aggregate === 'count' && !mapping.field) return 'replaces Count';
  return mapping.field ? `replaces ${mapping.field}` : undefined;
}

/** Channel names as the assignment chooser shows them (the on-chart shelf words). */
const ASSIGN_LABELS: Record<ChannelName, string> = {
  x: 'Columns (X)',
  y: 'Rows (Y)',
  color: 'Color',
  size: 'Size',
};

/**
 * The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
 * calculated fields and a field-less "Count of records") as clickable chips with a type
 * glyph. Clicking a chip opens an explicit channel chooser (the channels that accept the
 * column; an occupied one says what it would replace); with a channel armed, the click
 * assigns straight there instead and Esc disarms. Past `FIELD_SHELF_SPLIT_MIN` columns it
 * groups into Dimensions (categories/dates) and Measures (numerics); a small dataset stays
 * flat. Already-mapped fields are dimmed (a field may still be placed on several channels).
 */
function FieldShelf() {
  const baseColumns = useChartBuilderStore((s) => s.columns);
  const calculates = useChartBuilderStore((s) => s.config.calculates);
  const encodings = useChartBuilderStore((s) => s.config.encodings);
  const assignField = useChartBuilderStore((s) => s.assignField);
  const activeChannel = useChartBuilderStore((s) => s.activeChannel);
  const focusChannel = useChartBuilderStore((s) => s.focusChannel);
  const columns = useMemo(
    () => effectiveColumns(baseColumns, calculates),
    [baseColumns, calculates],
  );

  // Esc disarms the armed channel (captured so the modal itself doesn't close).
  // TODO: with a channel armed AND a SelectControl popover open, one Esc both closes
  // the popover and disarms — both are capture-phase document listeners, so
  // stopPropagation can't serialize them. Rare combination; needs a shared
  // escape-layer stack if it ever matters.
  useEffect(() => {
    if (!activeChannel) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        focusChannel(null);
      }
    };
    document.addEventListener('keydown', onKey, true);
    return () => document.removeEventListener('keydown', onKey, true);
  }, [activeChannel, focusChannel]);

  const assigned = useMemo(() => {
    const set = new Set<string>();
    for (const ch of CHANNELS) {
      const m = encodings[ch];
      if (m?.field) set.add(m.field);
    }
    return set;
  }, [encodings]);

  const colTypeOf = (name: string): ColumnType =>
    columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';

  // A field chip: with a channel armed, clicking assigns straight there (the fast
  // path); unarmed, it opens an explicit channel chooser instead of silently
  // grabbing the first empty seat (council 2026-06-12 — NN/g #3, user choice).
  const fieldChip = (
    name: string,
    display: string,
    domId: string,
    colType: ColumnType,
    glyph: string,
  ) => {
    const choices = CHANNELS.filter((ch) => isColumnAllowedOnChannel(ch, colType));
    return (
      <SelectControl
        key={domId}
        id={`cb-assign-${domId}`}
        label={`Add ${display} to a channel`}
        heading="Add to"
        options={choices.map((ch) => ({
          value: ch,
          label: ASSIGN_LABELS[ch],
          detail: occupantLabel(encodings[ch]),
        }))}
        onSelect={(ch) => assignField(name, ch)}
        beforeOpen={() => {
          if (activeChannel) {
            assignField(name);
            return false;
          }
          return true;
        }}
        triggerClassName={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
        triggerContent={
          <>
            <span className={styles.shelfGlyph} aria-hidden="true">
              {glyph}
            </span>
            <span className={styles.shelfFieldName}>{display}</span>
          </>
        }
      />
    );
  };

  const fieldButton = (name: string) =>
    fieldChip(
      name,
      name,
      `f${columns.columns.indexOf(name)}`,
      colTypeOf(name),
      TYPE_ABBR[defaultFieldType(colTypeOf(name))],
    );

  const countButton = fieldChip(COUNT_FIELD, 'Count of records', 'count', 'number', '∑');

  const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
  const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
  const split =
    columns.columns.length >= FIELD_SHELF_SPLIT_MIN && dimensions.length > 0 && measures.length > 0;

  return (
    <div className={styles.fieldShelf}>
      <span className={styles.fieldLabel}>Fields</span>
      {/* Arming a channel must be visible at the place the next click happens
          (NN/g #1): the shelf gains an accent ring and a status line naming the
          target. The hint is a polite status so AT hears the mode change too. */}
      {activeChannel && (
        <p className={styles.armedHint} role="status">
          Assigning to <strong>{CHANNEL_LABELS[activeChannel]}</strong> — choose a field below. Esc
          cancels.
        </p>
      )}
      <div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
        {split ? (
          <>
            {/* The visible count says "there's more below the fold" — the grouped
                shelf only appears for wide datasets, where the list scrolls. */}
            <span className={styles.shelfGroupHead}>Dimensions · {dimensions.length}</span>
            <div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
            <span className={styles.shelfGroupHead}>Measures · {measures.length + 1}</span>
            <div className={styles.shelfList}>
              {measures.map(fieldButton)}
              {countButton}
            </div>
          </>
        ) : (
          <div className={styles.shelfList}>
            {columns.columns.map(fieldButton)}
            {countButton}
          </div>
        )}
      </div>
    </div>
  );
}

/** The non-positional encodings (Colour, Size) — Tableau's "Marks" card. Each is a
 *  `ChannelSlot`, so it takes a field or a constant. */
function MarksCard() {
  return (
    <div className={styles.marksCard}>
      <span className={styles.fieldLabel}>Marks</span>
      <div className={styles.marksRow}>
        <span className={styles.channelLabel}>{CHANNEL_LABELS.color}</span>
        <ChannelSlot channel="color" />
      </div>
      <div className={styles.marksRow}>
        <span className={styles.channelLabel}>{CHANNEL_LABELS.size}</span>
        <ChannelSlot channel="size" />
      </div>
    </div>
  );
}

/**
 * The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview,
 * Tableau-style — position is a property of the chart, so its controls sit on the
 * chart. A Swap X/Y action flips the two axes. The slot rows are shelf-shaped so
 * Phase 4 faceting can add a second slot per shelf without a layout change, but
 * no placeholder ships before the feature does (an affordance promising unbuilt
 * functionality is a promise, not a feature — NN/g #2/#8).
 */
function OnChartShelves() {
  const swapXY = useChartBuilderStore((s) => s.swapXY);
  return (
    <div className={styles.shelves}>
      <div className={styles.shelvesHead}>
        <span className={styles.fieldLabel}>Axes</span>
        <button type="button" className={styles.swap} onClick={swapXY}>
          ⇄ Swap X/Y
        </button>
      </div>
      <div className={styles.shelfStrip}>
        <span className={styles.shelfName}>Columns</span>
        <div className={styles.shelfSlots}>
          <ChannelSlot channel="x" hint="Add a field for the X axis" />
        </div>
      </div>
      <div className={styles.shelfStrip}>
        <span className={styles.shelfName}>Rows</span>
        <div className={styles.shelfSlots}>
          <ChannelSlot channel="y" hint="Add a field for the Y axis" />
        </div>
      </div>
    </div>
  );
}

/**
 * Inline feedback for an expression input (filter expression / calculated field):
 * a parse error takes priority, else a soft warning for `datum.<field>` references
 * that don't match a known column — a typo guard before the chart renders empty (1E).
 * Nothing renders for a valid, fully-resolved expression. `messageId` lets the owning
 * input point at this node via `aria-describedby`.
 *
 * Both severities are a **polite** live region carrying a **status glyph** (round
 * error / triangle warning), not an assertive alert and never colour alone: the
 * expression validates on every keystroke, so an assertive role would interrupt on
 * each character (APG Alert / WCAG 2.2.4), and severity must read without colour
 * (arch 10 §3; the input also carries `aria-invalid`).
 */
function ExprFeedback({
  expr,
  columns,
  messageId,
}: {
  expr: string;
  columns: BuilderColumns;
  messageId?: string;
}) {
  const feedback = useMemo(() => {
    const validation = validateExpression(expr);
    if (!validation.valid) {
      return { kind: 'error' as const, text: validation.error ?? 'Invalid expression.' };
    }
    const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f));
    if (unknown.length > 0) {
      const plural = unknown.length > 1 ? 's' : '';
      return {
        kind: 'warn' as const,
        text: `Unknown field${plural}: ${unknown.join(', ')} — not a column in this dataset.`,
      };
    }
    return null;
  }, [expr, columns]);

  if (!feedback) return null;
  const isError = feedback.kind === 'error';
  return (
    <p id={messageId} className={isError ? styles.exprError : styles.exprWarn} role="status">
      <Icon
        name={isError ? 'status-error' : 'status-warning'}
        className={styles.exprFeedbackIcon}
      />
      <span>{feedback.text}</span>
    </p>
  );
}

/**
 * A contextual pointer to the Vega expression vocabulary, shown only when an
 * expression input is in play (a calculated field, or a filter in expression mode) —
 * the place the user needs to know what functions/operators exist.
 */
function ExprHelp() {
  return (
    <p className={styles.exprHelp}>
      Filter expressions and calculated fields use the{' '}
      <a
        className={styles.exprHelpLink}
        href={VEGA_EXPRESSION_DOCS_URL}
        target="_blank"
        rel="noreferrer"
      >
        Vega expression language
        <span aria-hidden="true"> ↗</span>
        <span className="visually-hidden"> (opens in a new tab)</span>
      </a>
      .
    </p>
  );
}

/** One filter row — a guarded `field op value` predicate, or a raw expression. */
function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: BuilderColumns }) {
  const setFilterField = useChartBuilderStore((s) => s.setFilterField);
  const updateFilter = useChartBuilderStore((s) => s.updateFilter);
  const setFilterMode = useChartBuilderStore((s) => s.setFilterMode);
  const removeFilter = useChartBuilderStore((s) => s.removeFilter);

  const expressionMode = filter.mode === 'expression';
  const fieldType = filter.fieldType ?? 'nominal';
  const op = filter.op ?? 'equal';
  const arity = filterOpArity(op);
  const hasColumns = columns.columns.length > 0;
  // Links the expression input to its feedback line; harmless when no message renders
  // (aria-describedby to an absent id is ignored — GOV.UK error-message association).
  const exprMsgId = `filter-${filter.id}-expr-msg`;

  return (
    <div className={styles.transformBlock}>
      <div className={styles.transformBlockTop}>
        {expressionMode ? (
          <input
            className={styles.exprInput}
            aria-label="Filter expression"
            placeholder={exprPlaceholder(columns, 'filter')}
            value={filter.expr ?? ''}
            aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
            aria-describedby={exprMsgId}
            onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
          />
        ) : (
          <SelectControl
            id={`cb-ffield-${filter.id}`}
            label="Filter field"
            heading="Filter field"
            options={columns.columns.map((name) => ({ value: name, label: name }))}
            value={filter.field}
            onSelect={(name) => setFilterField(filter.id, name)}
            triggerContent={filter.field ? undefined : 'Choose a field…'}
          />
        )}
        <IconButton size="sm" label="Remove filter" onClick={() => removeFilter(filter.id)}>
          <Icon name="close" />
        </IconButton>
      </div>

      {!expressionMode && (
        <div className={styles.filterPredicate}>
          <SelectControl
            id={`cb-fop-${filter.id}`}
            label="Filter operator"
            heading="Operator"
            options={validFilterOps(fieldType).map((o) => ({
              value: o,
              label: FILTER_OP_LABELS[o],
            }))}
            value={op}
            onSelect={(o) => updateFilter(filter.id, { op: o })}
          />
          {arity === 'range' ? (
            <>
              <input
                className={styles.valueInput}
                aria-label="Lower bound"
                placeholder="min"
                value={filter.value ?? ''}
                onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
              />
              <span className={styles.rangeDash} aria-hidden="true">
                –
              </span>
              <input
                className={styles.valueInput}
                aria-label="Upper bound"
                placeholder="max"
                value={filter.value2 ?? ''}
                onChange={(e) => updateFilter(filter.id, { value2: e.target.value })}
              />
            </>
          ) : (
            <input
              className={styles.valueInput}
              aria-label={arity === 'list' ? 'Values, comma-separated' : 'Filter value'}
              placeholder={arity === 'list' ? 'A, B, C' : 'value'}
              value={filter.value ?? ''}
              onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
            />
          )}
        </div>
      )}

      {expressionMode && (
        <ExprFeedback expr={filter.expr ?? ''} columns={columns} messageId={exprMsgId} />
      )}

      {hasColumns && (
        <button
          type="button"
          className={styles.modeToggle}
          onClick={() => setFilterMode(filter.id, expressionMode ? 'predicate' : 'expression')}
        >
          {expressionMode ? 'Use the field picker' : 'Write an expression'}
        </button>
      )}
    </div>
  );
}

/** One calculated-field row — a name and a Vega expression producing a new column. */
function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) {
  const updateCalculate = useChartBuilderStore((s) => s.updateCalculate);
  const removeCalculate = useChartBuilderStore((s) => s.removeCalculate);
  const exprMsgId = `calc-${calc.id}-expr-msg`;

  return (
    <div className={styles.transformBlock}>
      <div className={styles.transformBlockTop}>
        <input
          className={styles.calcName}
          aria-label="New field name"
          placeholder="new field"
          value={calc.as}
          onChange={(e) => updateCalculate(calc.id, { as: e.target.value })}
        />
        <span className={styles.calcEquals} aria-hidden="true">
          =
        </span>
        <input
          className={styles.exprInput}
          aria-label="Calculated field expression"
          placeholder={exprPlaceholder(columns, 'calc')}
          value={calc.expr}
          aria-invalid={!validateExpression(calc.expr).valid || undefined}
          aria-describedby={exprMsgId}
          onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
        />
        <IconButton
          size="sm"
          label="Remove calculated field"
          onClick={() => removeCalculate(calc.id)}
        >
          <Icon name="close" />
        </IconButton>
      </div>
      <ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} />
    </div>
  );
}

/**
 * A collapsible, read-only sample of the dataset's actual rows with a per-column type
 * chip in each header (1D) — lets the user sanity-check inferred types before building,
 * exactly when inference is most likely to surprise. Shows the base dataset columns
 * (calculated fields don't exist in the raw rows). Non-tabular payloads have no rows.
 */
function DataPreview() {
  const datasetId = useChartBuilderStore((s) => s.datasetId);
  const baseColumns = useChartBuilderStore((s) => s.columns);
  const dataset = useDatasetStore((s) => s.datasets.find((d) => d.id === datasetId) ?? null);
  const [open, setOpen] = useState(false);

  const rows = useMemo(
    () => (dataset ? tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT) : null),
    [dataset],
  );
  if (!dataset) return null;

  const typeOf = (name: string): ColumnType =>
    baseColumns.columnTypes.find((c) => c.name === name)?.type ?? 'string';

  return (
    <div className={styles.dataPreview}>
      <button
        type="button"
        className={styles.previewToggle}
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
      >
        <span className={styles.previewCaret} aria-hidden="true">
          {open ? '▾' : '▸'}
        </span>
        Preview rows
        {dataset.rowCount != null && (
          <span className={styles.previewMeta}>
            {dataset.rowCount.toLocaleString()} rows · {dataset.columnCount} cols
          </span>
        )}
      </button>

      {open &&
        (rows ? (
          <DataTable
            columns={baseColumns.columns}
            rows={rows}
            total={dataset.rowCount ?? undefined}
            ariaLabel="Data preview"
            renderHeader={(col) => (
              <>
                <span className={styles.previewColName}>{col}</span>{' '}
                <span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
              </>
            )}
          />
        ) : (
          <p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
        ))}
    </div>
  );
}

/**
 * The "Data" section at the top of the config pane: row filters and calculated fields
 * (the top-level transforms, 1C) plus a collapsible row preview (1D) — "here are your
 * rows, shape them, then encode them". Calculated fields appear in the channel
 * dropdowns below via the effective-columns derivation.
 */
function DataSection() {
  const baseColumns = useChartBuilderStore((s) => s.columns);
  const calculates = useChartBuilderStore((s) => s.config.calculates);
  const filters = useChartBuilderStore((s) => s.config.filters);
  const addFilter = useChartBuilderStore((s) => s.addFilter);
  const addCalculate = useChartBuilderStore((s) => s.addCalculate);

  const columns = useMemo(
    () => effectiveColumns(baseColumns, calculates),
    [baseColumns, calculates],
  );

  // The expression reference is shown only when an expression input exists — a
  // calculated field, or a filter switched to expression mode.
  const hasExpression =
    (filters ?? []).some((f) => f.mode === 'expression') || (calculates ?? []).length > 0;

  return (
    <section className={styles.dataSection} aria-label="Data">
      <span className={styles.fieldLabel}>Data</span>

      {/* The source rows come first, so they read as the input — distinct from the
          filters/calculated fields below, which shape what the chart actually draws. */}
      <DataPreview />

      <div className={styles.transformGroup}>
        <div className={styles.transformGroupHead}>
          <span className={styles.miniLabel}>Filters</span>
          <button type="button" className={styles.addRow} onClick={addFilter}>
            <Icon name="add" /> Add filter
          </button>
        </div>
        {(filters ?? []).map((f) => (
          <FilterRow key={f.id} filter={f} columns={columns} />
        ))}
      </div>

      <div className={styles.transformGroup}>
        <div className={styles.transformGroupHead}>
          <span className={styles.miniLabel}>Calculated fields</span>
          <button type="button" className={styles.addRow} onClick={addCalculate}>
            <Icon name="add" /> Add field
          </button>
        </div>
        {(calculates ?? []).map((c) => (
          <CalculateRow key={c.id} calc={c} columns={columns} />
        ))}
      </div>

      {hasExpression && <ExprHelp />}
    </section>
  );
}

/** Sort control values: 'none' maps to an unsorted config. */
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
  { value: 'none', label: 'None' },
  { value: 'ascending', label: 'Asc' },
  { value: 'descending', label: 'Desc' },
];

const STACK_OPTIONS: ReadonlyArray<SegmentedOption<'zero' | 'normalize'>> = [
  { value: 'zero', label: 'Stacked' },
  { value: 'normalize', label: '100%' },
];

function BuilderPreview() {
  const hostRef = useRef<HTMLDivElement>(null);
  const handleRef = useRef<RenderHandle | null>(null);
  const generationRef = useRef(0);
  // The error contract (docs/architecture/10): a plain headline that names the
  // problem and points at the next step; any raw Vega diagnostic goes in `detail`,
  // shown behind a disclosure rather than in the headline.
  const [error, setError] = useState<{ message: string; detail?: string } | null>(null);
  // Set when the chart resolves larger than the canvas backend can draw — a
  // physical render-size limit, distinct from the readability cardinality warnings.
  const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
  // Resolved-data disclosure: open state (modal-local, not persisted) + an epoch
  // bumped on each settled render so the open table re-reads the post-transform rows
  // the builder's filters/calculated fields produce (the output, beside the source
  // rows in the config pane's preview — see DataInspector).
  const [dataOpen, setDataOpen] = useState(false);
  const [renderEpoch, setRenderEpoch] = useState(0);

  const specText = useChartBuilderStore(selectBuilderSpecText);
  const valid = useChartBuilderStore(selectBuilderValid);
  // An explicit Chart size must show up in the preview — the 'width' fit mode
  // overwrites width AND drops height, so it only applies while sizing is auto.
  const explicitSize = useChartBuilderStore(
    (s) => s.config.width !== undefined || s.config.height !== undefined,
  );
  const uiTheme = useAppStore((s) => s.uiTheme);
  const datasets = useDatasetStore(useShallow((s) => s.datasets));

  useEffect(() => {
    const node = hostRef.current;
    const timer = setTimeout(() => {
      void (async () => {
        const mine = ++generationRef.current;
        if (!valid) {
          handleRef.current?.destroy();
          handleRef.current = null;
          setError(null);
          setTooLarge(null);
          setRenderEpoch((e) => e + 1);
          return;
        }
        if (!node) return;
        try {
          const t0 = performance.now();
          const parsed: unknown = JSON.parse(specText);
          const t1 = performance.now();
          const prepared = prepareSpecForRender(parsed, {
            fitMode: explicitSize ? 'default' : 'width',
            datasets,
          });
          const t2 = performance.now();
          handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
          handleRef.current = null;
          const t3 = performance.now();
          const handle = await renderSpec(
            node,
            prepared as VisualizationSpec,
            chartConfigFor(uiTheme),
            // Canvas, not SVG: a many-mark preview (one bar per row of a big dataset)
            // costs seconds of SVG layout/paint; canvas paints in ms (see renderer).
            { renderer: 'canvas' },
          );
          if (mine !== generationRef.current) {
            handle.destroy();
            return;
          }
          handleRef.current = handle;
          setError(null);
          setTooLarge(null);
          setRenderEpoch((e) => e + 1);
          const t4 = performance.now();
          // The browser lays out/paints the (possibly huge) SVG after embed resolves;
          // a double rAF lands just after that paint, capturing the freeze the user
          // feels. Skipped if a newer render has already superseded this one.
          requestAnimationFrame(() =>
            requestAnimationFrame(() => {
              if (mine !== generationRef.current) return;
              const t5 = performance.now();
              logBuilderRenderTiming({
                parse: t1 - t0,
                prepare: t2 - t1,
                destroy: t3 - t2,
                embed: t4 - t3,
                paint: t5 - t4,
                total: t5 - t0,
              });
            }),
          );
        } catch (e) {
          if (mine !== generationRef.current) return;
          setRenderEpoch((epoch) => epoch + 1);
          if (e instanceof ChartTooLargeError) {
            // A physical render-size limit (canvas max dimension), not a data error.
            setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
            setError(null);
          } else if (e instanceof DatasetNotFoundError) {
            // Near-unreachable (the builder opens from an existing dataset), but if the
            // backing dataset is deleted mid-session the contract still wants the next step.
            setError({
              message: `Dataset "${e.datasetName}" not found — recreate it from Datasets, then reopen the builder.`,
            });
            setTooLarge(null);
          } else {
            // Plain headline; the raw Vega message folds into the disclosure below.
            setError({
              message: "Couldn't render this chart.",
              detail: (e as Error).message,
            });
            setTooLarge(null);
          }
        }
      })();
    }, RENDER_DEBOUNCE_MS);

    return () => clearTimeout(timer);
  }, [specText, valid, explicitSize, uiTheme, datasets]);

  useEffect(
    () => () => {
      handleRef.current?.destroy();
      handleRef.current = null;
    },
    [],
  );

  // The input + resolved rows the chart drew (the latter after the builder's
  // filters/calculated fields). Reads the live view through the handle; null when
  // no chart is up.
  const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);

  return (
    <div className={styles.previewPane}>
      {!valid && (
        <p className={styles.previewHint}>Map at least one channel to a column to see a chart.</p>
      )}
      {valid && tooLarge && (
        <p className={styles.previewHint} role="status">
          This chart would be about {Math.round(tooLarge.heightPx).toLocaleString()} px tall —
          larger than the browser can draw on a canvas (
          {Math.round(tooLarge.limitPx).toLocaleString()} px max here). Aggregate the measure or
          filter to fewer rows so it fits.
        </p>
      )}
      <div className={styles.previewFrame} hidden={!valid || tooLarge !== null || error !== null}>
        <div className={styles.previewHost} ref={hostRef} />
      </div>
      {valid && tooLarge === null && error !== null && (
        <div className={styles.previewError} role="alert">
          <p className={styles.previewErrorHeadline}>{error.message}</p>
          {error.detail !== undefined && (
            <details className={styles.previewErrorDetails}>
              <summary className={styles.previewErrorSummary}>Technical details</summary>
              <pre className={styles.previewErrorDetail}>{error.detail}</pre>
            </details>
          )}
        </div>
      )}
      {/* Data inspector — input vs. resolved rows the chart drew (the latter after
          the builder's transforms). Only with a live chart, so it never duplicates
          the "map a channel" / error hints above. */}
      {valid && tooLarge === null && error === null && (
        <DataInspectorPanel
          open={dataOpen}
          onToggle={setDataOpen}
          getData={getInspectData}
          renderEpoch={renderEpoch}
        />
      )}
    </div>
  );
}

/** A positive integer from a dimension input; blank/garbage reads as "auto". */
function parseDim(raw: string): number | undefined {
  if (raw.trim() === '') return undefined;
  const n = Number(raw);
  return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
}

/**
 * Chart properties — title/subtitle and explicit width/height, as a slim strip
 * pinned under the preview: these describe *the chart*, so they live on the chart
 * side, not in the encoding pane (council 2026-06-12 — NN/g #4, the Tableau/Lyra
 * convention). The subtitle is disabled until a title exists because Vega-Lite has
 * no standalone subtitle (it nests under `title`).
 */
function ChartProps() {
  const title = useChartBuilderStore((s) => s.config.title ?? '');
  const subtitle = useChartBuilderStore((s) => s.config.subtitle ?? '');
  const width = useChartBuilderStore((s) => s.config.width);
  const height = useChartBuilderStore((s) => s.config.height);
  const setTitle = useChartBuilderStore((s) => s.setTitle);
  const setSubtitle = useChartBuilderStore((s) => s.setSubtitle);
  const setWidth = useChartBuilderStore((s) => s.setWidth);
  const setHeight = useChartBuilderStore((s) => s.setHeight);
  const hasTitle = title.trim() !== '';

  return (
    <div className={styles.chartProps}>
      <label className={styles.propField}>
        <span>Title</span>
        <input
          className={styles.propInput}
          value={title}
          placeholder="None"
          onChange={(e) => setTitle(e.target.value)}
        />
      </label>
      <label className={styles.propField}>
        <span>Subtitle</span>
        <input
          className={styles.propInput}
          value={subtitle}
          placeholder={hasTitle ? 'None' : 'Add a title first'}
          disabled={!hasTitle}
          onChange={(e) => setSubtitle(e.target.value)}
        />
      </label>
      <label className={styles.propField}>
        <span>Width</span>
        <input
          type="number"
          min={1}
          className={styles.dimInput}
          value={width ?? ''}
          placeholder="auto"
          onChange={(e) => setWidth(parseDim(e.target.value))}
        />
      </label>
      <label className={styles.propField}>
        <span>Height</span>
        <input
          type="number"
          min={1}
          className={styles.dimInput}
          value={height ?? ''}
          placeholder="auto"
          onChange={(e) => setHeight(parseDim(e.target.value))}
        />
      </label>
    </div>
  );
}

/**
 * The dataset picker at the top of the config pane (spec §06 → Dataset picker):
 * which data the chart builds from is itself a builder choice, so the builder can
 * open without a preselected dataset and the data can be switched without leaving.
 * Switching re-derives smart defaults while the config is untouched, and rebases
 * (keeps chart-level intent, sheds bindings to missing columns) once it isn't.
 */
function DatasetPicker({ datasetId }: { datasetId: number | null }) {
  const datasets = useDatasetStore(useShallow((s) => s.datasets));
  const switchDataset = useChartBuilderStore((s) => s.switchDataset);
  return (
    <div className={styles.datasetRow}>
      <span className={styles.fieldLabel}>Dataset</span>
      <SelectControl
        id="cb-dataset"
        label="Dataset to build from"
        heading="Dataset"
        options={datasets.map((d) => ({ value: String(d.id), label: d.name }))}
        value={datasetId !== null ? String(datasetId) : undefined}
        onSelect={(v) => switchDataset(Number(v))}
        triggerContent={datasetId === null ? 'Choose a dataset…' : undefined}
      />
    </div>
  );
}

/**
 * The intent-first front door (spec §06 → Intent): a persistent "what do you want to
 * show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
 * channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't
 * satisfy are disabled (with the reason in their name), and the chip whose layout the
 * live chart matches stays highlighted; once the user edits away from any, none is — a
 * "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
 * drive the channels directly). The active intent is derived from the config, so no
 * intent state is stored.
 */
function IntentStrip() {
  const columns = useChartBuilderStore((s) => s.columns);
  const config = useChartBuilderStore((s) => s.config);
  const setIntent = useChartBuilderStore((s) => s.setIntent);

  const active = useMemo(() => activeIntent(config, columns), [config, columns]);
  const applicable = useMemo(
    () => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
    [columns],
  );

  // APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
  // NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
  // focus only — Enter/Space activates — because applying an
  // intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
  // every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
  // discoverable (APG: focusable disabled controls where discoverability is crucial).
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const tabStop = useMemo(() => {
    if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
    const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
    return firstEnabled >= 0 ? firstEnabled : 0;
  }, [active, applicable]);

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    const n = CHART_INTENTS.length;
    let next: number;
    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        next = (index + 1) % n;
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        next = (index - 1 + n) % n;
        break;
      case 'Home':
        next = 0;
        break;
      case 'End':
        next = n - 1;
        break;
      default:
        return; // not ours — let it bubble (Enter/Space activate the native button)
    }
    e.preventDefault();
    btnRefs.current[next]?.focus();
  };

  return (
    <div className={styles.intentStrip}>
      <span className={styles.intentQ} id="cb-intent-q">
        What do you want to show?
      </span>
      <p className={styles.intentSub}>
        {active ? (
          <>
            Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
            controls below.
          </>
        ) : (
          <>Pick a starting point and we will set the chart up — or build it yourself below.</>
        )}
      </p>
      <div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
        {CHART_INTENTS.map((intent, i) => {
          const enabled = applicable.has(intent);
          return (
            <button
              key={intent}
              ref={(el) => {
                btnRefs.current[i] = el;
              }}
              type="button"
              className={styles.intentChip}
              aria-pressed={active === intent}
              aria-disabled={!enabled || undefined}
              aria-label={
                enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
              }
              title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
              tabIndex={i === tabStop ? 0 : -1}
              onKeyDown={(e) => onKeyDown(e, i)}
              onClick={() => {
                if (enabled) setIntent(intent);
              }}
            >
              {INTENT_LABELS[intent]}
            </button>
          );
        })}
      </div>
    </div>
  );
}

/**
 * The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
 * what the builder does and offers the one next step — never a dead end. The
 * action swaps this modal for the Datasets manager opened on its create form.
 */
function NoDatasets() {
  return (
    <div className={styles.emptyState}>
      <h3 className={styles.emptyTitle}>No datasets yet</h3>
      <p className={styles.emptyBody}>
        The Chart Builder turns a saved dataset into a chart — pick columns, watch the chart take
        shape, and save it as a snippet. Add a dataset to start building.
      </p>
      <Button
        variant="primary"
        size="lg"
        onClick={() => {
          openModal('datasets');
          useDatasetStore.getState().startCreate();
        }}
      >
        Add a dataset
      </Button>
    </div>
  );
}

export function ChartBuilderModal() {
  const datasetId = useChartBuilderStore((s) => s.datasetId);
  const datasetCount = useDatasetStore((s) => s.datasets.length);
  const mark = useChartBuilderStore((s) => s.config.mark);
  const sort = useChartBuilderStore((s) => s.config.sort);
  const stack = useChartBuilderStore((s) => s.config.stack);
  const setMark = useChartBuilderStore((s) => s.setMark);
  const setSort = useChartBuilderStore((s) => s.setSort);
  const setStack = useChartBuilderStore((s) => s.setStack);
  const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
  const runCreate = useChartBuilderStore((s) => s.createSnippet);
  const runSave = useChartBuilderStore((s) => s.saveEdits);
  // Edit-in-place (spec §06 → Open in builder): when a snippet was opened in the
  // builder, the primary action *saves back* to it instead of creating a new snippet.
  const editingSnippetId = useChartBuilderStore((s) => s.editingSnippetId);
  const editingSnippetName = useChartBuilderStore((s) => s.editingSnippetName);
  const editing = editingSnippetId !== null;

  // Validity + guidance + which chart-level controls apply are derived from the
  // stable `config` reference via useMemo, NOT a store selector that would build a
  // fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
  const config = useChartBuilderStore((s) => s.config);
  const rowCount = useChartBuilderStore((s) => s.rowCount);
  // `columns` is a stable reference set once at init (not rebuilt per render), so
  // subscribing to it won't loop useSyncExternalStore.
  const columns = useChartBuilderStore((s) => s.columns);
  const valid = useMemo(() => isBuilderConfigValid(config), [config]);
  const warnings = useMemo(
    () => builderWarnings(config, rowCount, columns),
    [config, rowCount, columns],
  );
  const canSort = useMemo(() => supportsSort(config), [config]);
  const canStack = useMemo(() => supportsStack(config), [config]);

  // Applying a hint's fix removes that hint's list item, so focus would otherwise fall
  // to <body>. The change is announced politely (the chart updates silently for sighted
  // users) and focus moves to the guidance region, or the config pane if the last hint
  // just cleared — the pattern for a control that removes its own container (arch 10 §5).
  const configPaneRef = useRef<HTMLDivElement>(null);
  const warningsRef = useRef<HTMLUListElement>(null);
  const pendingFixFocus = useRef(false);
  const [fixAnnouncement, setFixAnnouncement] = useState('');

  const handleFix = (fix: BuilderWarningFix) => {
    applyWarningFix(fix); // re-derives `warnings`, firing the focus effect below
    setFixAnnouncement(`Applied: ${fix.label}.`);
    pendingFixFocus.current = true;
  };

  // After a fix re-derives the warnings, move focus off the (now-removed) button:
  // to the guidance region if hints remain, else the config pane. Ref-flag, not
  // state, so we never setState inside the effect (react-hooks/set-state-in-effect).
  useEffect(() => {
    if (!pendingFixFocus.current) return;
    pendingFixFocus.current = false;
    (warningsRef.current ?? configPaneRef.current)?.focus();
  }, [warnings]);

  if (datasetId === null) {
    if (datasetCount === 0) return <NoDatasets />;
    // Datasets exist but none is loaded (init auto-picks, so this is a fallback
    // for a builder restored into an odd state): offer the choice directly.
    return (
      <div className={styles.emptyState}>
        <h3 className={styles.emptyTitle}>Choose a dataset</h3>
        <p className={styles.emptyBody}>Pick the dataset to build a chart from.</p>
        <DatasetPicker datasetId={null} />
      </div>
    );
  }

  return (
    <div className={styles.builder}>
      <div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
        <div className="visually-hidden" role="status" aria-live="polite">
          {fixAnnouncement}
        </div>
        {editing && (
          <p className={styles.editingBanner}>
            Editing <strong>{editingSnippetName}</strong>
          </p>
        )}
        <DatasetPicker datasetId={datasetId} />

        <IntentStrip />

        <DataSection />

        <div className={styles.field}>
          <span className={styles.fieldLabel}>Mark</span>
          <SegmentedControl
            label="Mark type"
            options={MARK_OPTIONS}
            value={mark}
            onChange={setMark}
            // Six marks don't fit one 320–360px row: wrap to two, each segment kept at
            // the control height (the base look is fixed-height, single-row).
            className={styles.markPicker}
            optionClassName={styles.markPickerOption}
          />
        </div>

        <FieldShelf />
        <MarksCard />

        {(canSort || canStack) && (
          <div className={styles.chartControls}>
            {canSort && (
              <div className={styles.field}>
                <span className={styles.fieldLabel}>Sort</span>
                <SegmentedControl
                  label="Sort the category axis by its measure"
                  options={SORT_OPTIONS}
                  value={sort ?? 'none'}
                  onChange={(v) => setSort(v === 'none' ? undefined : v)}
                />
              </div>
            )}
            {canStack && (
              <div className={styles.field}>
                <span className={styles.fieldLabel}>Stacking</span>
                <SegmentedControl
                  label="Stacking mode"
                  options={STACK_OPTIONS}
                  value={stack ?? 'zero'}
                  onChange={setStack}
                />
              </div>
            )}
          </div>
        )}

        {warnings.length > 0 && (
          <ul
            className={styles.warnings}
            ref={warningsRef}
            tabIndex={-1}
            aria-label="Chart guidance"
          >
            {warnings.map((w) => (
              <li key={w.message} className={styles.warning}>
                <Icon name="status-warning" className={styles.warningIcon} />
                <div className={styles.warningBody}>
                  <span>{w.message}</span>
                  {w.fixes && w.fixes.length > 0 && (
                    <div className={styles.warningFixes}>
                      {w.fixes.map((fix) => (
                        <button
                          key={fix.label}
                          type="button"
                          className={styles.warningFix}
                          onClick={() => handleFix(fix)}
                        >
                          {fix.label}
                        </button>
                      ))}
                    </div>
                  )}
                </div>
              </li>
            ))}
          </ul>
        )}

        {!valid && (
          <p id="cb-create-hint" className={styles.createHint}>
            Map at least one channel to a column to {editing ? 'save changes' : 'create a snippet'}.
          </p>
        )}
        <div className={styles.actions}>
          <Button size="lg" onClick={() => void closeModal()}>
            Cancel
          </Button>
          <Button
            variant="primary"
            size="lg"
            disabled={!valid}
            aria-describedby={!valid ? 'cb-create-hint' : undefined}
            // The commit is the user's confirmation — close with no discard prompt.
            onClick={() => {
              if (editing ? runSave() : runCreate()) void closeModal(true);
            }}
          >
            {editing ? 'Save changes' : 'Create Snippet'}
          </Button>
        </div>
      </div>

      <div className={styles.previewSide}>
        <OnChartShelves />
        <BuilderPreview />
        <ChartProps />
      </div>
    </div>
  );
}
