/**
 * Chart Builder — the modal body (spec §06).
 *
 * A two-pane composer: left is the configuration (dataset name, mark selector, one
 * block per channel, chart-level sort/stacking, optional dimensions, guidance,
 * Create), right is a live preview of the spec the configuration produces. All spec
 * logic and Tier-B defaults/guards come from `@core/chart-builder` via
 * `ChartBuilderStore`; this component is the view. Each channel is a small block:
 * a column dropdown (with a field-less "Count of records" option), a fixed
 * `N | O | Q | T` field-type segmented control (the column's invalid types are
 * disabled), and the transforms that apply to its type (aggregate + bin for a
 * measure, granularity for a temporal field). 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 { useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
  CHANNELS,
  MARK_TYPES,
  TIME_UNITS,
  builderWarnings,
  channelAcceptsValue,
  defaultChannelValue,
  defaultFieldType,
  effectiveColumns,
  filterOpArity,
  isBuilderConfigValid,
  isChannelTypeAllowed,
  isValueMapping,
  supportsAggregate,
  supportsBin,
  supportsSort,
  supportsStack,
  supportsTimeUnit,
  validFieldTypes,
  validFilterOps,
  type BuilderCalculate,
  type AggregateOp,
  type BuilderColumns,
  type BuilderFilter,
  type BuilderWarningFix,
  type ChannelMapping,
  type ChannelName,
  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 } 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 { 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);
}

const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
  value: m,
  label: titleCase(m),
}));

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',
};

/** Non-count aggregate operators offered for a quantitative field. */
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];

/** 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/';

/** One preview cell's text: blank for empty, the string as-is, else JSON. */
function cellText(value: unknown): string {
  if (value == null) return '';
  if (typeof value === 'string') return value;
  return JSON.stringify(value);
}

/** 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 ? (
          <input
            type="color"
            className={styles.constColor}
            aria-label={`${CHANNEL_LABELS[channel]} constant colour`}
            value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
            onChange={(e) => setChannelConstant(channel, e.target.value)}
          />
        ) : (
          <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 canCycle = typeOptions.length > 1;
  const currentType: FieldType = count ? 'quantitative' : mapping.type;
  const label = count ? 'Count' : (mapping.field ?? '');
  const cycleType = () => {
    if (!canCycle) return;
    const i = typeOptions.indexOf(mapping.type);
    setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]);
  };

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

  return (
    <div className={styles.pillWrap}>
      <div className={styles.pill}>
        {/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
            keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
            review (docs/ux-second-pass.md). */}
        <button
          type="button"
          className={styles.pillType}
          aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
          disabled={!canCycle}
          onClick={cycleType}
        >
          {TYPE_ABBR[currentType]}
        </button>
        <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) && (
            <label className={styles.transform}>
              <span className={styles.miniLabel}>Aggregate</span>
              <select
                className={styles.mini}
                value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
                onChange={(e) =>
                  setChannelAggregate(
                    channel,
                    (e.target.value || undefined) as AggregateOp | undefined,
                  )
                }
              >
                <option value="">None</option>
                {FIELD_AGGREGATES.map((op) => (
                  <option key={op} value={op}>
                    {titleCase(op)}
                  </option>
                ))}
              </select>
            </label>
          )}

          {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) && (
            <label className={styles.transform}>
              <span className={styles.miniLabel}>Granularity</span>
              <select
                className={styles.mini}
                value={mapping.timeUnit ?? ''}
                onChange={(e) =>
                  setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
                }
              >
                <option value="">None (raw)</option>
                {TIME_UNITS.map((u) => (
                  <option key={u} value={u}>
                    {TIME_UNIT_LABELS[u]}
                  </option>
                ))}
              </select>
            </label>
          )}
        </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) && (
        <button
          type="button"
          className={styles.slotConst}
          onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
        >
          or constant
        </button>
      )}
    </div>
  );
}

/**
 * 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 field assigns it to the armed channel, else the first empty channel
 * that accepts it (`assignField`). 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 columns = useMemo(
    () => effectiveColumns(baseColumns, calculates),
    [baseColumns, calculates],
  );

  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';

  const fieldButton = (name: string) => (
    <button
      key={name}
      type="button"
      className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
      onClick={() => assignField(name)}
    >
      <span className={styles.shelfGlyph} aria-hidden="true">
        {TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
      </span>
      <span className={styles.shelfFieldName}>{name}</span>
    </button>
  );

  const countButton = (
    <button
      key="__count"
      type="button"
      className={styles.shelfField}
      onClick={() => assignField(COUNT_FIELD)}
    >
      <span className={styles.shelfGlyph} aria-hidden="true">
        ∑
      </span>
      <span className={styles.shelfFieldName}>Count of records</span>
    </button>
  );

  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>
      {split ? (
        <>
          <span className={styles.shelfGroupHead}>Dimensions</span>
          <div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
          <span className={styles.shelfGroupHead}>Measures</span>
          <div className={styles.shelfList}>
            {measures.map(fieldButton)}
            {countButton}
          </div>
        </>
      ) : (
        <div className={styles.shelfList}>
          {columns.columns.map(fieldButton)}
          {countButton}
        </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>
  );
}

/** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4.
 *  Shown so the layout telegraphs where row/column faceting will live. */
function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
  return (
    <div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
      <span>+ {kind} facet</span>
      <span className={styles.facetTag}>later</span>
    </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. Each shelf holds the axis slot plus a reserved faceting placeholder. A Swap
 * X/Y action flips the two axes.
 */
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" />
          <FacetSlot kind="column" />
        </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" />
          <FacetSlot kind="row" />
        </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 })}
          />
        ) : (
          <select
            className={styles.filterField}
            aria-label="Filter field"
            value={filter.field ?? ''}
            onChange={(e) => setFilterField(filter.id, e.target.value)}
          >
            {!filter.field && <option value="">Choose a field…</option>}
            {columns.columns.map((name) => (
              <option key={name} value={name}>
                {name}
              </option>
            ))}
          </select>
        )}
        <button
          type="button"
          className={styles.removeRow}
          aria-label="Remove filter"
          onClick={() => removeFilter(filter.id)}
        >
          <Icon name="close" />
        </button>
      </div>

      {!expressionMode && (
        <div className={styles.filterPredicate}>
          <select
            className={styles.mini}
            aria-label="Filter operator"
            value={op}
            onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
          >
            {validFilterOps(fieldType).map((o) => (
              <option key={o} value={o}>
                {FILTER_OP_LABELS[o]}
              </option>
            ))}
          </select>
          {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 })}
        />
        <button
          type="button"
          className={styles.removeRow}
          aria-label="Remove calculated field"
          onClick={() => removeCalculate(calc.id)}
        >
          <Icon name="close" />
        </button>
      </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 ? (
          <div
            className={styles.previewTableWrap}
            tabIndex={0}
            role="group"
            aria-label="Data preview"
          >
            <table className={styles.previewTable}>
              <thead>
                <tr>
                  {baseColumns.columns.map((col) => (
                    <th key={col} scope="col">
                      <span className={styles.previewColName}>{col}</span>{' '}
                      <span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((row, ri) => (
                  <tr key={ri}>
                    {baseColumns.columns.map((col) => (
                      <td key={col}>{cellText(row[col])}</td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        ) : (
          <p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
        ))}

      {open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (
        <p className={styles.previewEmptyNote}>
          Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.
        </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);

  const specText = useChartBuilderStore(selectBuilderSpecText);
  const valid = useChartBuilderStore(selectBuilderValid);
  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);
          return;
        }
        if (!node) return;
        try {
          const t0 = performance.now();
          const parsed: unknown = JSON.parse(specText);
          const t1 = performance.now();
          const prepared = prepareSpecForRender(parsed, { fitMode: '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);
          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;
          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, uiTheme, datasets]);

  useEffect(
    () => () => {
      handleRef.current?.destroy();
      handleRef.current = 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>
      )}
    </div>
  );
}

export function ChartBuilderModal() {
  const datasetId = useChartBuilderStore((s) => s.datasetId);
  const datasetName = useChartBuilderStore((s) => s.config.datasetName);
  const mark = useChartBuilderStore((s) => s.config.mark);
  const width = useChartBuilderStore((s) => s.config.width);
  const height = useChartBuilderStore((s) => s.config.height);
  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 setWidth = useChartBuilderStore((s) => s.setWidth);
  const setHeight = useChartBuilderStore((s) => s.setHeight);
  const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
  const runCreate = useChartBuilderStore((s) => s.createSnippet);

  // 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) {
    return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
  }

  const parseDim = (raw: string): number | undefined => {
    if (raw.trim() === '') return undefined;
    const n = Number(raw);
    return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
  };

  return (
    <div className={styles.builder}>
      <div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
        <div className="visually-hidden" role="status" aria-live="polite">
          {fixAnnouncement}
        </div>
        <p className={styles.datasetName}>
          Building from <strong>{datasetName}</strong>
        </p>

        <DataSection />

        <div className={styles.field}>
          <span className={styles.fieldLabel}>Mark</span>
          <SegmentedControl
            label="Mark type"
            options={MARK_OPTIONS}
            value={mark}
            onChange={setMark}
          />
        </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>
        )}

        <div className={styles.dimensions}>
          {/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
              channel; this is the rendered chart's width/height. */}
          <span className={styles.fieldLabel}>Chart size (optional)</span>
          <div className={styles.dimInputs}>
            <label className={styles.dimField}>
              <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.dimField}>
              <span>Height</span>
              <input
                type="number"
                min={1}
                className={styles.dimInput}
                value={height ?? ''}
                placeholder="auto"
                onChange={(e) => setHeight(parseDim(e.target.value))}
              />
            </label>
          </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 create a snippet.
          </p>
        )}
        <div className={styles.actions}>
          <button type="button" className={styles.action} onClick={() => void closeModal()}>
            Cancel
          </button>
          <button
            type="button"
            className={`${styles.action} ${styles.primary}`}
            disabled={!valid}
            aria-describedby={!valid ? 'cb-create-hint' : undefined}
            onClick={() => runCreate()}
          >
            Create Snippet
          </button>
        </div>
      </div>

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