/**
 * Chart Builder — pure Vega-Lite spec assembler (spec §06).
 *
 * Portable core: no browser APIs, no React, no store access. Turns a no-JSON
 * builder configuration (a mark, four optional encoding channels mapped to dataset
 * columns, with per-channel transforms, plus chart-level sort/stack and optional
 * pixel dimensions) into a complete Vega-Lite spec that references the source
 * dataset by name. The UI layer owns the controls; this module owns the spec
 * grammar — what a configuration *means* as Vega-Lite — and the defaults the spec
 * prescribes (pre-population, field-type derivation, smart mark).
 *
 * Beyond the Tier-B floor, a channel may carry a **transform** — an `aggregate`
 * (count/sum/mean/…), a quantitative `bin`, or a temporal `timeUnit` granularity —
 * and the chart may carry a `sort` (rank a categorical axis by its measure) and a
 * `stack` (part-to-whole for bar/area + colour). See
 * docs/chart-builder-research.md §8 for why each exists.
 *
 * The produced spec mirrors what the rest of Astrolabe authors by hand: a `$schema`
 * stamp (shared with the sample template), a named-data reference the renderer
 * resolves at preview time (rendering.ts), a mark with tooltips enabled, the mapped
 * encodings, and any explicit width/height. It is the same string-spec shape the
 * editor and preview consume — `buildSnippetSpecText` serializes it.
 */

import type { ColumnStats } from './profile';
import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
import { escapeVegaField, unescapeVegaField } from './rendering';

/** The six mark types the builder offers, in selector order (spec §06). `rect` is the
 *  heatmap mark — an X×Y grid of cells shaded by a Colour measure. */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle', 'rect'] as const;
export type MarkType = (typeof MARK_TYPES)[number];

/** The four Vega-Lite field types a channel may carry, in override-menu order. */
export type FieldType = 'quantitative' | 'nominal' | 'ordinal' | 'temporal';

/** The four encoding channels the builder offers, in display order (spec §06). */
export const CHANNELS = ['x', 'y', 'color', 'size'] as const;
export type ChannelName = (typeof CHANNELS)[number];

/**
 * Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
 * special — it is **field-less** (counts records), so a `count` mapping carries no
 * `field`. `distinct` counts a field's unique values, so it applies to **any** field
 * type and reads as a quantitative measure. The arithmetic ops (sum/mean/median)
 * reduce a quantitative field; min/max also order a temporal or ordinal one. See
 * `validAggregateOps` for the per-type menu.
 */
export type AggregateOp = 'count' | 'distinct' | 'sum' | 'mean' | 'median' | 'min' | 'max';

/**
 * Temporal granularities (Vega-Lite `timeUnit`), coarse → fine, with the combined
 * units that make a real time axis. Offered when a channel's type is Temporal.
 */
export const TIME_UNITS = [
  'year',
  'yearquarter',
  'yearmonth',
  'yearmonthdate',
  'quarter',
  'month',
  'week',
  'date',
  'day',
  'hours',
] as const;
export type TimeUnit = (typeof TIME_UNITS)[number];

/** Sort the categorical axis by its measure (spec §06 → Ranking). */
export type SortOrder = 'ascending' | 'descending';

/** Part-to-whole stacking for bar/area + a colour series: absolute vs 100%. */
export type StackMode = 'zero' | 'normalize';

/**
 * Comparison operators a guarded filter predicate offers (Vega-Lite field
 * predicates), in menu order. The set a given field admits is narrowed by
 * `validFilterOps` — only a measure/temporal field offers ordering (`lt`…`gt`) and
 * `range`; a category offers membership (`oneOf`). `notEqual` is expressed as a
 * `{ not: { …equal } }` logical wrapper (Vega-Lite has no bare `!=` predicate).
 */
export type FilterOp = 'equal' | 'notEqual' | 'lt' | 'lte' | 'gt' | 'gte' | 'range' | 'oneOf';

/** How a filter expresses its predicate: a guarded shelf, or a raw expression. */
export type FilterMode = 'predicate' | 'expression';

/**
 * One top-level data filter (Vega-Lite `transform: [{ filter }]`), applied to the
 * raw rows **before** encoding aggregation — so "filter rows, then aggregate" is the
 * natural reading. Two modes share one shape (the UI toggles between them on a single
 * row):
 *
 *   - **predicate** — a guarded `field op value` triple (Voyager-style, no expression
 *     to write for the common case). `range` carries a second bound (`value2`);
 *     `oneOf` reads `value` as a comma-separated membership list. Values are coerced
 *     by `fieldType` (a quantitative field compares as a number).
 *   - **expression** — a raw Vega expression string (`datum.x > 0`), the power-form
 *     for what the guarded shelf can't say (validated in the UI via
 *     `expr-validate.ts`).
 *
 * `id` is a stable key for list editing only; it never reaches the produced spec.
 * A partially-filled filter (no value yet, empty expression) is simply skipped by
 * the assembler, so the live preview keeps rendering while the user types.
 */
export interface BuilderFilter {
  /** Stable list key (UI-assigned); not serialized into the spec. */
  id: string;
  /** Which form this filter takes. */
  mode: FilterMode;
  /** Predicate mode: the column being filtered (a dataset or calculated field). */
  field?: string;
  /** Predicate mode: the field's Vega-Lite type — drives the op menu + coercion. */
  fieldType?: FieldType;
  /** Predicate mode: the comparison operator. */
  op?: FilterOp;
  /** Predicate mode: the comparison value (`range` lower bound; `oneOf` CSV list). */
  value?: string;
  /** Predicate mode: the upper bound, for `range` only. */
  value2?: string;
  /** Expression mode: a raw Vega predicate expression. */
  expr?: string;
}

/**
 * One derived field (Vega-Lite `transform: [{ calculate, as }]`): a row-wise Vega
 * expression producing a new column the rest of the builder can encode like any
 * other. Calculates are emitted **before** filters (a row-wise calculate is
 * order-independent — it computes the same per-row value regardless of which rows
 * survive — so running it first is equivalent and lets a filter reference the
 * derived field). `id` is a UI list key only.
 */
export interface BuilderCalculate {
  /** Stable list key (UI-assigned); not serialized into the spec. */
  id: string;
  /** The Vega expression, e.g. `datum.price * datum.quantity`. */
  expr: string;
  /** The new field's name (appears in the channel dropdowns once non-empty). */
  as: string;
}

/**
 * One channel's binding. Two kinds share this shape — the **Property model**: one
 * control that holds either a field or a constant.
 *
 *   - **field** — a dataset column `field` plus its `type`, with optional transforms.
 *     `field` is omitted only for a `count` aggregate (which counts records rather
 *     than reducing a column).
 *   - **value** — a fixed constant (Vega-Lite `{ value }`): a literal colour or size
 *     applied to every mark, with no field/type/transform. `isValueMapping`
 *     discriminates on `value` being present; the channel's `type` is preserved (but
 *     ignored) while a value is set, so toggling back to a field restores it.
 *
 * A channel left on "None" is `null` in the config (omitted from the spec).
 */
export interface ChannelMapping {
  /**
   * A constant value (Vega-Lite `{ value }`) — a fixed colour/size applied to every
   * mark. When set, this channel is a CONSTANT: `field` and the transforms are not
   * emitted (`encodingObject` returns `{ value }`). Only colour/size offer it in the
   * UI (`channelAcceptsValue`), but the assembler handles it on any channel.
   */
  value?: string | number | boolean;
  /** The dataset column. Omitted for a `count` aggregate or when `value` is set. */
  field?: string;
  /** The Vega-Lite field type (see `validFieldTypes`); ignored while `value` is set. */
  type: FieldType;
  /** Aggregation op; `count` is field-less, the rest reduce a quantitative field. */
  aggregate?: AggregateOp;
  /** Bin a quantitative field into ranges (e.g. for a histogram). */
  bin?: boolean;
  /** Temporal granularity for a Temporal field. */
  timeUnit?: TimeUnit;
}

/** The full builder configuration the assembler consumes. */
export interface BuilderConfig {
  /** The dataset the spec references by name (`{ data: { name } }`). */
  datasetName: string;
  /** The active mark type. */
  mark: MarkType;
  /** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
  encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
  /** Optional chart title (Vega-Lite top-level `title`). */
  title?: string;
  /** Optional subtitle; emitted only alongside a title (VL nests it under `title`). */
  subtitle?: string;
  /** Optional explicit chart width in pixels. */
  width?: number;
  /** Optional explicit chart height in pixels. */
  height?: number;
  /** Sort the categorical positional axis by the measure axis (spec §06 → Ranking). */
  sort?: SortOrder;
  /** Stacking for bar/area + a colour series (part-to-whole). */
  stack?: StackMode;
  /** Derived fields (`transform: [{ calculate, as }]`), emitted before `filters`. */
  calculates?: BuilderCalculate[];
  /** Row filters (`transform: [{ filter }]`), applied before encoding aggregation. */
  filters?: BuilderFilter[];
}

/**
 * The field types a column may legitimately carry, given its inferred type — the
 * options the channel's type control offers (spec §06 → Tier B, valid-type
 * locking). A string/boolean is never Quantitative and a non-date is never
 * Temporal (Vega-Lite would mis-encode or error); a number defaults to
 * Quantitative but may be treated as a category. Ordinal is offered wherever the
 * user might reasonably assert an order (numbers, text), a deliberate superset of
 * Voyager's stricter menu. The list head is the default (see `defaultFieldType`).
 *
 * Convergent across the research: Voyager `getValidTypes`
 * (data-pane/field-list.tsx) + Draco `hard.lp` enc_type_valid (a string/boolean
 * can't be quantitative; temporal requires datetime). See
 * docs/chart-builder-research.md §4.
 */
export function validFieldTypes(columnType: ColumnType): FieldType[] {
  switch (columnType) {
    case 'number':
      return ['quantitative', 'ordinal', 'nominal'];
    case 'date':
      return ['temporal'];
    case 'boolean':
      return ['nominal'];
    default: // 'string'
      return ['nominal', 'ordinal'];
  }
}

/**
 * Default Vega-Lite field type for a column from its inferred type (spec §06):
 * numeric → Quantitative, date → Temporal, everything else (text, boolean) →
 * Nominal. The default is the head of `validFieldTypes`, so the two never drift.
 * The user may override afterward, within `validFieldTypes`.
 */
export function defaultFieldType(columnType: ColumnType): FieldType {
  return validFieldTypes(columnType)[0];
}

/** Continuous (measure-like) field types — quantitative and temporal. */
function isContinuous(type: FieldType): boolean {
  return type === 'quantitative' || type === 'temporal';
}

/**
 * Whether a field type may be placed on a channel at all (spec §06 → Tier B, Size
 * discipline). X/Y/Color accept any type; **Size accepts only Quantitative or
 * Ordinal** — encoding a category or a date by symbol size is misleading (size
 * implies ordered magnitude). Draco makes this a hard constraint
 * (`hard.lp:53` size_nominal); we surface it as a UI gate that disables Size for
 * unsuitable columns rather than letting the user produce the bad encoding.
 * (Negative-value exclusion, `hard.lp:56`, needs row data — `builderWarnings`
 * surfaces it as a soft hint from the column's profiled numeric extent; this
 * type-level gate is the part the builder can enforce structurally.)
 */
export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boolean {
  if (channel === 'size') return type === 'quantitative' || type === 'ordinal';
  return true;
}

/**
 * Whether a dataset column may be placed on a channel at all, judged by its **default**
 * field type (spec §06 → Size discipline). X/Y/Color accept any column; Size accepts a
 * column only when its natural type reads as a magnitude (a numeric → Quantitative).
 * The channel's type control narrows further per field. Used to disable unsuitable
 * columns in the field shelf and to pick an auto-assign target when a field is clicked.
 */
export function isColumnAllowedOnChannel(channel: ChannelName, columnType: ColumnType): boolean {
  return isChannelTypeAllowed(channel, defaultFieldType(columnType));
}

/**
 * The non-count aggregates that legitimately apply to a field of this type — the
 * channel's Aggregate menu. Arithmetic reduction (sum/mean/median) needs numbers;
 * min/max need an ordering (numbers, dates, asserted-ordinal values); `distinct`
 * (count of unique values) applies to anything — the natural measure to wring out
 * of a category ("how many unique customers"), which is why the menu is per-type
 * rather than quantitative-only.
 */
export function validAggregateOps(type: FieldType): Exclude<AggregateOp, 'count'>[] {
  switch (type) {
    case 'quantitative':
      return ['sum', 'mean', 'median', 'min', 'max', 'distinct'];
    case 'temporal':
    case 'ordinal':
      return ['min', 'max', 'distinct'];
    case 'nominal':
      return ['distinct'];
  }
}

/** Whether any non-count aggregate (sum/…/distinct) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
  return validAggregateOps(type).length > 0;
}

/** Whether binning into ranges can apply to this field type. */
export function supportsBin(type: FieldType): boolean {
  return type === 'quantitative';
}

/** Whether a temporal granularity (timeUnit) can apply to this field type. */
export function supportsTimeUnit(type: FieldType): boolean {
  return type === 'temporal';
}

/**
 * Whether a channel mapping is a **constant value** (Vega-Lite `{ value }`) rather
 * than a field binding — the discriminator of the Property model. A constant has no
 * field, type, or transform; it colours/sizes every mark the same.
 */
export function isValueMapping(mapping: ChannelMapping): boolean {
  return mapping.value !== undefined;
}

/**
 * Whether a channel offers the constant-value control in the UI. A fixed **colour**
 * or **size** is both common and awkward in JSON (the promotion test), so it earns a
 * control; a constant X/Y position is not useful, so X/Y stay field-only. The
 * assembler emits a value on any channel — this only gates where the toggle appears,
 * and generalizes to channels added later (e.g. opacity).
 */
export function channelAcceptsValue(channel: ChannelName): boolean {
  return channel === 'color' || channel === 'size';
}

/**
 * A sensible starting constant when a channel is first switched to value mode:
 * Vega-Lite's default categorical blue for colour, a clearly-visible 100 for size.
 * The user adjusts it from there.
 */
export function defaultChannelValue(channel: ChannelName): string | number {
  return channel === 'size' ? 100 : '#4c78a8';
}

/**
 * Coerce a constant-value text input to the JS type its channel expects, so the
 * emitted `{ value }` carries the right type: **size** is a magnitude → a number (a
 * blank/non-numeric entry falls back to the raw string rather than NaN); every other
 * channel keeps the string (a colour is a CSS string). Mirrors the filter shelf's
 * `coerceFilterValue` discipline.
 */
export function coerceChannelValue(channel: ChannelName, raw: string): string | number {
  if (channel === 'size') {
    const n = Number(raw);
    return raw.trim() !== '' && Number.isFinite(n) ? n : raw;
  }
  return raw;
}

/**
 * The mark that best fits the X/Y field-type shape (spec §06 → Tier B, smart
 * default mark) — the research's strongest convergence (Draco mark-by-shape soft
 * constraints, Voyager effectiveness, FT, Datawrapper all agree;
 * docs/chart-builder-research.md §4):
 *
 *   - temporal × quantitative → **Line** (a time series)
 *   - quantitative × quantitative → **Point** (a scatter)
 *   - one continuous + one discrete axis → **Bar** (category vs measure)
 *   - both axes discrete → **Point** (Bar/Line/Area need a continuous axis)
 *   - a single mapped axis, or nothing yet → **Bar** (the safe default)
 *
 * `null` means the channel is unmapped. This is the *default*; the user can switch
 * to any of the six marks afterward. **Heatmap (`rect`) is never auto-defaulted** —
 * it reads only with a Colour measure, which the X/Y shape alone can't determine, so
 * it stays a deliberate pick (the heatmap guidance nudges the missing Colour).
 */
export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
  if (xType === null || yType === null) return 'bar';
  const timeVsMeasure =
    (xType === 'temporal' && yType === 'quantitative') ||
    (xType === 'quantitative' && yType === 'temporal');
  if (timeVsMeasure) return 'line';
  const xc = isContinuous(xType);
  const yc = isContinuous(yType);
  if (xc && yc) return 'point'; // two measures → scatter
  if (xc === yc) return 'point'; // both discrete → bar/line/area are invalid here
  return 'bar'; // one continuous axis, one categorical → category-vs-measure bar
}

/** A dataset's columns paired with their inferred types — the builder's input. */
export interface BuilderColumns {
  columns: readonly string[];
  columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
  /**
   * Per-column cardinality + numeric range, when the dataset was profiled with it
   * (datasets created before this profiling, and URL/non-tabular data, omit it).
   * The data-aware `builderWarnings` use it where present and silently skip those
   * hints where absent.
   */
  columnStats?: ReadonlyArray<ColumnStats>;
}

/** The derived field type for a named column, defaulting to Nominal if unknown. */
function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
  const match = columns.columnTypes.find((c) => c.name === name);
  return defaultFieldType(match?.type ?? 'string');
}

/**
 * Above this many distinct values, a column is too high-cardinality to be a good
 * default category axis: its labels overlap into an unreadable axis, and an
 * unaggregated chart that wide can exceed the canvas size limit. Matches the
 * crowded-axis warning threshold.
 */
const CATEGORY_DEFAULT_MAX_DISTINCT = 30;

/**
 * Pick a *sensible, renderable* default X/Y from the data shape, using profiled
 * cardinality (`columnStats`). This avoids opening the builder on a degenerate
 * one-mark-per-row chart — the shape a positional first-two-columns rule yields when
 * the leading columns are an id and a high-cardinality key. Returns `null` when there
 * are no stats to reason about (older / URL datasets), so the caller falls back to the
 * positional default.
 *
 * Preference order (each guaranteed to render and read cleanly):
 *   1. a low-cardinality **category** vs a **count of records** → a tidy bar;
 *   2. else a **temporal** axis vs count → a time series (continuous x, always fits);
 *   3. else two **measures** → a scatter (continuous axes, always fit).
 *
 * The category case pairs with the field-less **count**, not a raw measure: count is
 * always meaningful and avoids summing an id-like numeric (Row ID, Postal Code) into
 * nonsense. This is the builder's *opening* state only; a later intent-first entry
 * point can layer richer recommendations on top.
 */
function smartDefaultEncodings(
  columns: BuilderColumns,
): { x: ChannelMapping; y: ChannelMapping } | null {
  const stats = columns.columnStats;
  if (!stats || stats.length === 0) return null; // no profiling → positional fallback
  const typeOf = (name: string): ColumnType =>
    columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
  const knownDistinct = (name: string): number | undefined => {
    const s = stats.find((x) => x.name === name);
    return s && !s.distinctCapped ? s.distinct : undefined;
  };
  const count: ChannelMapping = { type: 'quantitative', aggregate: 'count' };
  const numbers = columns.columns.filter((name) => typeOf(name) === 'number');

  // 1. The lowest-cardinality readable category (string/boolean) → a tidy bar. Pair it
  //    with **count** (not a raw measure): a raw measure would draw one bar per row.
  const category = columns.columns
    .filter((name) => {
      const t = typeOf(name);
      if (t !== 'string' && t !== 'boolean') return false;
      const d = knownDistinct(name);
      return d !== undefined && d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
    })
    .sort((a, b) => (knownDistinct(a) ?? 0) - (knownDistinct(b) ?? 0))[0];
  if (category) return { x: { field: category, type: 'nominal' }, y: count };

  // 2. A date → a time series of the first measure (a temporal axis is continuous, so a
  //    line of raw values always fits); fall back to count if there is no measure.
  const temporal = columns.columns.find((name) => typeOf(name) === 'date');
  if (temporal) {
    const y: ChannelMapping = numbers[0] ? { field: numbers[0], type: 'quantitative' } : count;
    return { x: { field: temporal, type: 'temporal' }, y };
  }

  // 3. Two measures → a scatter (continuous axes, always renderable).
  if (numbers.length >= 2) {
    return {
      x: { field: numbers[0], type: 'quantitative' },
      y: { field: numbers[1], type: 'quantitative' },
    };
  }
  return null;
}

/**
 * The builder's opening configuration for a dataset (spec §06 → Default
 * pre-population, Tier B). When the dataset is profiled, X/Y are chosen as a
 * **data-aware "safest bet"** (`smartDefaultEncodings`) — a low-cardinality category
 * vs a count of records (a tidy bar), else a time series, else a scatter — so the
 * builder never opens on a degenerate one-mark-per-row chart that can't render. When
 * there are no stats to reason about, it falls back to the positional rule: first
 * column on X, second (if any) on Y, each with its derived field type. Either way the
 * mark is the **smart default** for the resulting X/Y shape (`defaultMark`), Color
 * and Size start unmapped with no transforms, and a dataset with no detected columns
 * yields an all-unmapped config (the modal then prompts).
 */
export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig {
  const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = {
    x: null,
    y: null,
    color: null,
    size: null,
  };
  // Prefer a data-aware "safest bet" (a renderable, readable chart) when the dataset
  // is profiled; otherwise fall back to the positional first-on-X, second-on-Y rule.
  const smart = smartDefaultEncodings(columns);
  if (smart) {
    encodings.x = smart.x;
    encodings.y = smart.y;
  } else {
    const [first, second] = columns.columns;
    if (first !== undefined) {
      encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
    }
    if (second !== undefined) {
      encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
    }
  }
  const mark = defaultMark(encodings.x?.type ?? null, encodings.y?.type ?? null);
  return { datasetName, mark, encodings };
}

// ── Intent-first front door (spec §06 → Intent) ────────────────────────────────
//
// "What do you want to show?" — a small set of analytic intents (FT Visual
// Vocabulary / Datawrapper taxonomy) that each recommend a mark + channel layout
// from the dataset's column roles. The front door is an *on-ramp*, not a gate: it
// seeds the mark-first builder, which the user can then adjust or ignore, and the
// intent never enters the produced spec — it is builder-local steering only (the
// JSON spec stays the document). Mirrors Tableau "Show Me": intents the data can't
// satisfy are disabled, and the live chart's matching intent stays highlighted.

/** The intents the front door offers, in display order. */
export const CHART_INTENTS = [
  'compare',
  'ranking',
  'time',
  'correlation',
  'distribution',
  'partToWhole',
  'heatmap',
] as const;
export type ChartIntent = (typeof CHART_INTENTS)[number];

/**
 * Dataset columns split by the role they play in a chart. Categories are ordered
 * **most-readable first** — a low-cardinality category (2…`CATEGORY_DEFAULT_MAX_DISTINCT`
 * distinct) ahead of a constant or a high-cardinality one, then by ascending distinct
 * — so `categories[0]` is the same readable axis `smartDefaultEncodings` prefers. That
 * shared preference is what lets `activeIntent` light the smart default on open.
 */
function columnRoles(columns: BuilderColumns): {
  categories: string[];
  measures: string[];
  temporals: string[];
} {
  const typeOf = (name: string): ColumnType =>
    columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
  const distinctOf = (name: string): number => {
    const s = columns.columnStats?.find((x) => x.name === name);
    return s && !s.distinctCapped ? s.distinct : Number.POSITIVE_INFINITY;
  };
  const readable = (name: string): boolean => {
    const d = distinctOf(name);
    return d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
  };
  const measures = columns.columns.filter((n) => typeOf(n) === 'number');
  const temporals = columns.columns.filter((n) => typeOf(n) === 'date');
  const categories = columns.columns
    .filter((n) => typeOf(n) === 'string' || typeOf(n) === 'boolean')
    .sort((a, b) => {
      const ra = readable(a);
      const rb = readable(b);
      if (ra !== rb) return ra ? -1 : 1; // readable categories first
      return distinctOf(a) - distinctOf(b); // then lowest-cardinality
    });
  return { categories, measures, temporals };
}

/** A fresh field-less count measure (never share a reference — configs are immutable). */
function countMapping(): ChannelMapping {
  return { type: 'quantitative', aggregate: 'count' };
}

/**
 * Whether the dataset has the column roles an intent needs to be meaningful. The
 * front door **disables** the intents that don't apply (Tableau "Show Me": an
 * unavailable chart is shown but inert) rather than producing a broken chart.
 */
export function intentApplicable(intent: ChartIntent, columns: BuilderColumns): boolean {
  const { categories, measures, temporals } = columnRoles(columns);
  switch (intent) {
    case 'compare':
    case 'ranking':
      return categories.length >= 1; // a category axis vs a count
    case 'time':
      return temporals.length >= 1;
    case 'correlation':
      return measures.length >= 2; // two measures to cross
    case 'distribution':
      return measures.length >= 1; // a measure to bin
    case 'partToWhole':
    case 'heatmap':
      return categories.length >= 2; // two categorical dimensions
  }
}

/** Keep only the channels an intent actually sets, in canonical order. */
function pruneLayout(
  enc: Partial<Record<ChannelName, ChannelMapping | undefined>>,
): Partial<Record<ChannelName, ChannelMapping>> {
  const out: Partial<Record<ChannelName, ChannelMapping>> = {};
  for (const ch of CHANNELS) {
    const m = enc[ch];
    if (m) out[ch] = m;
  }
  return out;
}

/**
 * The mark + channel layout an intent recommends for these columns (spec §06 →
 * Intent). A *partial* config — only the channels the intent sets, plus the mark and
 * any sort/stack; `applyIntent` merges it onto the working config. Best-effort when a
 * preferred column is absent (the UI disables fully-inapplicable intents via
 * `intentApplicable`, so a returned partial is always at least renderable).
 */
// TODO: exported only for its direct unit test — `applyIntent`/`activeIntent` are its
// sole production callers, both in this module. If no external caller appears, drop the
// export and assert the layout table through `applyIntent` (the file's convention for
// internal helpers like `smartDefaultEncodings`).
export function intentLayout(
  intent: ChartIntent,
  columns: BuilderColumns,
): Pick<BuilderConfig, 'mark' | 'sort' | 'stack'> & {
  encodings: Partial<Record<ChannelName, ChannelMapping>>;
} {
  const { categories, measures, temporals } = columnRoles(columns);
  const cat = (i: number): ChannelMapping | undefined =>
    categories[i] !== undefined ? { field: categories[i], type: 'nominal' } : undefined;
  const measure = (i: number): ChannelMapping | undefined =>
    measures[i] !== undefined ? { field: measures[i], type: 'quantitative' } : undefined;
  const temporal = (i: number): ChannelMapping | undefined =>
    temporals[i] !== undefined ? { field: temporals[i], type: 'temporal' } : undefined;

  switch (intent) {
    case 'compare': // magnitude across categories → a tidy bar of counts
      return { mark: 'bar', encodings: pruneLayout({ x: cat(0), y: countMapping() }) };
    case 'ranking': // the same, ordered by value
      return {
        mark: 'bar',
        sort: 'descending',
        encodings: pruneLayout({ x: cat(0), y: countMapping() }),
      };
    case 'time': // a trend over time → a line of the first measure (or a count)
      return {
        mark: 'line',
        encodings: pruneLayout({ x: temporal(0), y: measure(0) ?? countMapping() }),
      };
    case 'correlation': // two measures crossed → a scatter
      return { mark: 'point', encodings: pruneLayout({ x: measure(0), y: measure(1) }) };
    case 'distribution': {
      // the spread of one measure → a histogram (binned measure × count)
      const m = measure(0);
      return {
        mark: 'bar',
        encodings: pruneLayout({ x: m ? { ...m, bin: true } : undefined, y: countMapping() }),
      };
    }
    case 'partToWhole': // shares of a total → a stacked bar by a second category
      return {
        mark: 'bar',
        stack: 'zero',
        encodings: pruneLayout({ x: cat(0), y: countMapping(), color: cat(1) }),
      };
    case 'heatmap': // a two-category grid shaded by count
      return {
        mark: 'rect',
        encodings: pruneLayout({ x: cat(0), y: cat(1), color: countMapping() }),
      };
  }
}

/**
 * Reshape the working config to an intent's recommended layout (spec §06 → Intent),
 * the front door's "do it for me". Replaces mark + encodings + sort + stack with the
 * intent's; **keeps** the dataset, the data transforms (filters / calculated fields)
 * and the chart-level title/subtitle/size — all orthogonal to *what kind of chart*.
 * Pure; the store calls it when the user picks an intent.
 */
export function applyIntent(
  config: BuilderConfig,
  intent: ChartIntent,
  columns: BuilderColumns,
): BuilderConfig {
  const layout = intentLayout(intent, columns);
  const next: BuilderConfig = {
    ...config,
    mark: layout.mark,
    encodings: { x: null, y: null, color: null, size: null, ...layout.encodings },
  };
  if (layout.sort) next.sort = layout.sort;
  else delete next.sort;
  if (layout.stack) next.stack = layout.stack;
  else delete next.stack;
  return next;
}

/** Deep-equal two channel mappings (or nulls) on every field the builder emits. */
function sameMapping(a: ChannelMapping | null, b: ChannelMapping | null): boolean {
  if (a === null || b === null) return a === b;
  return (
    a.field === b.field &&
    a.type === b.type &&
    a.aggregate === b.aggregate &&
    !!a.bin === !!b.bin &&
    a.timeUnit === b.timeUnit &&
    a.value === b.value
  );
}

/** Whether the config's mark/encodings/sort/stack match an intent's layout exactly. */
function configMatchesIntent(
  config: BuilderConfig,
  intent: ChartIntent,
  columns: BuilderColumns,
): boolean {
  const layout = intentLayout(intent, columns);
  if (config.mark !== layout.mark) return false;
  if ((config.sort ?? null) !== (layout.sort ?? null)) return false;
  if ((config.stack ?? null) !== (layout.stack ?? null)) return false;
  const want = { x: null, y: null, color: null, size: null, ...layout.encodings };
  for (const ch of CHANNELS) {
    if (!sameMapping(config.encodings[ch] ?? null, want[ch] ?? null)) return false;
  }
  return true;
}

/**
 * The intent whose recommended layout the current config matches, or `null`
 * ("Custom") once the user has edited away from any. Lets the front-door strip
 * highlight the live chart's intent — and auto-light the smart default on open —
 * **without storing the intent** (the config stays the single source of truth, so a
 * dataset switch or a hand-built layout resolves correctly too). A structural match,
 * preferring the first applicable intent on the rare tie.
 */
export function activeIntent(config: BuilderConfig, columns: BuilderColumns): ChartIntent | null {
  return (
    CHART_INTENTS.find(
      (intent) => intentApplicable(intent, columns) && configMatchesIntent(config, intent, columns),
    ) ?? null
  );
}

/** The channels actually mapped (a column field, or a field-less count), in order. */
function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
  return CHANNELS.flatMap((channel) => {
    const mapping = config.encodings[channel];
    return mapping ? [[channel, mapping] as [ChannelName, ChannelMapping]] : [];
  });
}

/** The effective field type a mapping encodes: a count or a distinct-count reads as
 *  a quantitative measure whatever the underlying field; other aggregates (sum, a
 *  temporal min/…) keep the field's own type. */
function effectiveType(mapping: ChannelMapping): FieldType {
  return mapping.aggregate === 'count' || mapping.aggregate === 'distinct'
    ? 'quantitative'
    : mapping.type;
}

/**
 * Whether a mapping reads as a continuous **measure** — the post-transform *role*, not
 * the raw field type. A constant encodes no data; a **binned** field is a discretized
 * *dimension* (the same notion as Vega-Lite's own `isDiscrete(fieldDef)`, which returns
 * true for a binned quantitative); everything else continuous (raw quantitative/temporal,
 * or a count/sum/etc. aggregate) is a measure. Bin and aggregate are mutually exclusive,
 * so the bin guard never hides a real aggregate measure.
 *
 * The builder's guidance must ask this *role* question, never `effectiveType === 'quantitative'`
 * directly — a binned axis is quantitative by type but a dimension by role; conflating the two
 * makes a histogram (binned X + count Y) trip the two-measures→scatter nudge (arch 10 §5).
 */
function isMeasureMapping(mapping: ChannelMapping): boolean {
  if (isValueMapping(mapping)) return false;
  if (mapping.bin) return false; // a binned field is a discretized dimension, not a measure
  return isContinuous(effectiveType(mapping));
}

/**
 * Whether the configuration is renderable / saveable (spec §06 → Validation): at
 * least one channel must be mapped. The modal gates the Create action and the
 * preview prompt on this.
 */
export function isBuilderConfigValid(config: BuilderConfig): boolean {
  return mappedChannels(config).length > 0;
}

/**
 * Whether a mapping is a **reorderable** category axis — one whose order is arbitrary,
 * so ranking it by the measure is meaningful. Nominal/ordinal only, and explicitly
 * **not** a binned or temporal axis: those carry an inherent order (you don't reorder
 * histogram bins or a timeline by frequency). This is a *different* question from
 * `isMeasureMapping` — a binned field is neither a measure nor a reorderable category —
 * which is why sort and the scatter nudge can't share one predicate.
 */
function isReorderableCategory(mapping: ChannelMapping): boolean {
  if (isValueMapping(mapping) || mapping.bin) return false;
  const t = effectiveType(mapping);
  return t === 'nominal' || t === 'ordinal';
}

/**
 * The categorical positional channel to sort, when exactly one of X/Y is a **reorderable
 * category** and the other is a measure (spec §06 → Ranking). Returns the channel to
 * carry `sort`, or undefined when sorting doesn't apply (no clear, reorderable category
 * axis — so a histogram's binned axis is never offered a Sort).
 */
export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
  const x = config.encodings.x ?? null;
  const y = config.encodings.y ?? null;
  if (!x || !y) return undefined;
  if (isReorderableCategory(x) && isMeasureMapping(y)) return 'x';
  if (isReorderableCategory(y) && isMeasureMapping(x)) return 'y';
  return undefined;
}

/** The quantitative positional channel (x or y) that stacking applies to, if any. A
 *  binned axis is a dimension, never the stack measure (so a binned bar with a colour
 *  series stacks the count axis, not the bins). */
function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
  for (const channel of ['x', 'y'] as const) {
    const mapping = config.encodings[channel];
    if (
      mapping &&
      !isValueMapping(mapping) &&
      !mapping.bin &&
      effectiveType(mapping) === 'quantitative'
    )
      return channel;
  }
  return undefined;
}

/**
 * Whether Color is bound to a **field** (a real per-value series + legend) rather
 * than a constant. Stacking and the area-split hint care about a colour *series*; a
 * fixed colour produces neither, so both treat a constant Color as no colour at all.
 */
function colorIsSeries(config: BuilderConfig): boolean {
  const color = config.encodings.color;
  return !!color && !isValueMapping(color);
}

/**
 * Whether sorting can be offered for this config (a clear category-vs-measure axis
 * pair exists). The UI shows the Sort control only when true.
 */
export function supportsSort(config: BuilderConfig): boolean {
  return sortableCategoryChannel(config) !== undefined;
}

/**
 * Whether stacking can be offered: a bar/area mark with a colour series and a
 * quantitative positional axis to stack along (spec §06 → part-to-whole).
 */
export function supportsStack(config: BuilderConfig): boolean {
  return (
    (config.mark === 'bar' || config.mark === 'area') &&
    colorIsSeries(config) &&
    stackMeasureChannel(config) !== undefined
  );
}

/**
 * A one-click remedy a warning can offer. `apply` is a pure config→config transform;
 * the modal renders `label` as a button that runs it. It is an *offer*, never a forced
 * change — once applied, the warning re-derives away. Lives in core so the remedies
 * unit-test alongside the warnings.
 */
export interface BuilderWarningFix {
  /** The button label naming the remedy, e.g. "Aggregate as Sum". */
  label: string;
  /** Produce the corrected configuration from the current one (pure). */
  apply: (config: BuilderConfig) => BuilderConfig;
}

/** A non-blocking advisory about a configuration (spec §06 → Tier B warnings). */
export interface BuilderWarning {
  /** The channel the hint is about, when it's channel-specific. */
  channel?: ChannelName;
  /** A short, plain-language hint the modal shows inline (not an error). */
  message: string;
  /** Optional one-click remedies the modal renders as buttons next to the hint. */
  fixes?: BuilderWarningFix[];
}

/**
 * Above this many rows, a category-vs-measure bar/line/area with a **raw**
 * (un-aggregated) measure draws so many marks — one per row — that the category
 * axis becomes an unreadable picket fence of labels. The threshold is a legibility
 * estimate, not a hard limit (the chart still renders); it's set where vertical bar
 * labels reliably start overlapping. See `builderWarnings`.
 */
const CROWDED_CATEGORY_ROWS = 30;

/**
 * Above this many *distinct* category values, even an aggregated category axis (one
 * mark per category, not per row) has too many labels to read. Matched to
 * `CROWDED_CATEGORY_ROWS`: both flag a category axis that overruns its labels.
 */
const CROWDED_CATEGORY_DISTINCT = 30;

/**
 * Above this many distinct colour values a discrete legend is too long to scan and
 * the palette starts recycling hues. Qualitative colour scales top out around 8–12
 * across the canon (Datawrapper, FT Visual Vocabulary); 12 is the generous end.
 */
const CROWDED_LEGEND_DISTINCT = 12;

/** Profiled stats for a mapped field, when the dataset carries them. */
function statsFor(field: string | undefined, columns?: BuilderColumns): ColumnStats | undefined {
  if (!field || !columns?.columnStats) return undefined;
  return columns.columnStats.find((s) => s.name === field);
}

/** "N" or "more than 50" — distinct count, honouring the profiler's cap. */
function cardinalityText(stats: ColumnStats): string {
  return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
}

// --- Pure config transforms backing the actionable-hint fixes. They mirror the
// store's setChannelAggregate / swapXY / setStack / setChannelColumn(null) / setMark
// actions, so applying a fix and making the equivalent manual edit land on the same
// config. Kept here (not the store) so the remedies are pure and unit-testable. ---

/** Aggregate one channel's field (clearing any bin — the two are mutually exclusive). */
function withChannelAggregate(
  config: BuilderConfig,
  channel: ChannelName,
  aggregate: AggregateOp,
): BuilderConfig {
  const current = config.encodings[channel];
  if (!current) return config;
  const next: ChannelMapping = { ...current, aggregate };
  delete next.bin;
  return { ...config, encodings: { ...config.encodings, [channel]: next } };
}

/** Exchange the X and Y mappings (the manual Swap X/Y, as a pure transform). */
function withSwappedXY(config: BuilderConfig): BuilderConfig {
  return {
    ...config,
    encodings: {
      ...config.encodings,
      x: config.encodings.y ?? null,
      y: config.encodings.x ?? null,
    },
  };
}

/** Clear one channel back to "None" (drops its mapping from the spec). */
function withChannelCleared(config: BuilderConfig, channel: ChannelName): BuilderConfig {
  return { ...config, encodings: { ...config.encodings, [channel]: null } };
}

/**
 * Non-blocking advisories for the current configuration (spec §06 → Tier B): the
 * encodings that render but read poorly, drawn from the research's soft rules
 * (docs/chart-builder-research.md §4, §7). These never block Create — `isBuilder
 * ConfigValid` is the only gate — they just steer the user toward a better chart.
 * Returned in a stable order so the UI list doesn't jitter as config changes.
 *
 * `rowCount` (the dataset's row count, when known) powers the one-mark-per-row hint;
 * `columns` (with per-column `columnStats`) powers the data-aware hints — crowded
 * legend / category axis (cardinality) and the negative-value Size guard (numeric
 * extent). Either omitted (URL / non-tabular / pre-cardinality datasets) simply
 * skips the hints that need it.
 */
export function builderWarnings(
  config: BuilderConfig,
  rowCount?: number | null,
  columns?: BuilderColumns,
): BuilderWarning[] {
  const warnings: BuilderWarning[] = [];
  const x = config.encodings.x ?? null;
  const y = config.encodings.y ?? null;
  const { mark } = config;

  // Measure/dimension is asked over the post-transform ROLE (`isMeasureMapping`), never
  // raw `effectiveType`, so a binned axis (a discretized dimension) doesn't masquerade as
  // a measure here (arch 10 §5).
  //
  // A config that matches an applicable intent is known-good, so the "taste" heuristics
  // (two-measures→scatter, area-split) could stand down for it via
  // `&& activeIntent(config, columns) === null`. That gate is omitted because no current
  // intent layout (see `intentLayout`) produces a config that trips a taste rule, so it
  // would be a dead branch; add it when a future intent or taste rule would otherwise
  // conflict.

  // Line/area/rect are two-axis marks: a single mapped axis can't draw a meaningful
  // line or band (Draco hard.lp:91 line_area requires both x and y), and a heatmap is
  // an X×Y cell grid by definition.
  if ((mark === 'line' || mark === 'area' || mark === 'rect') && (x === null || y === null)) {
    const noun = mark === 'rect' ? 'Heatmaps' : mark === 'line' ? 'Line charts' : 'Area charts';
    warnings.push({
      message: `${noun} need both an X and a Y axis.`,
    });
  }

  // A heatmap (rect) shades each X×Y cell by a value: without a measure on Colour the
  // cells are uniform (or, with a categorical Colour, overlapping blocks) — not a
  // heatmap. Nudge toward a Colour measure and offer Count as the always-available
  // one (a cross-tab / 2-D-histogram count is the canonical heatmap). Gated on both
  // axes present so it doesn't pile onto the both-axes hint above.
  if (mark === 'rect' && x !== null && y !== null) {
    const heatColor = config.encodings.color ?? null;
    if (!heatColor || !isMeasureMapping(heatColor)) {
      warnings.push({
        channel: 'color',
        message: 'A heatmap shades its cells by a value — map a measure (or Count) to Colour.',
        fixes: [
          {
            label: 'Colour by count',
            apply: (c) => ({
              ...c,
              encodings: { ...c.encodings, color: { type: 'quantitative', aggregate: 'count' } },
            }),
          },
        ],
      });
    }
  }

  // Bar/line/area need a measure on one axis; two categories give nothing to compare
  // (Draco soft.lp:47 only_discrete — the loudest nudge; hard.lp:97/:100 for bar).
  if (
    (mark === 'bar' || mark === 'line' || mark === 'area') &&
    x !== null &&
    y !== null &&
    !isMeasureMapping(x) &&
    !isMeasureMapping(y)
  ) {
    warnings.push({
      message: `${markLabel(mark)} charts need a measure (a value or count) on the X or Y axis.`,
    });
  }

  // Two *quantitative measures* on a bar/line/area: a scatter is the conventional choice
  // (Draco soft.lp c_c weights; Datawrapper). Asked over the post-transform **role**
  // (`isMeasureMapping`), not raw type, so a binned axis — a discretized dimension — never
  // counts: that's what excludes a histogram (binned X + count) and a 2-D-histogram rect.
  // The mark is a positive list (bar/line/area) — point/circle already are scatters, and a
  // rect's right nudge is "bin + colour by count", handled by the heatmap hint above.
  if (
    (mark === 'bar' || mark === 'line' || mark === 'area') &&
    x !== null &&
    y !== null &&
    isMeasureMapping(x) &&
    effectiveType(x) === 'quantitative' &&
    isMeasureMapping(y) &&
    effectiveType(y) === 'quantitative'
  ) {
    warnings.push({
      message: 'Two measures usually read best as a scatter.',
      fixes: [{ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) }],
    });
  }

  // Area split into many series hides per-component change (FT Visual Vocabulary:
  // "seeing change in components can be very difficult").
  // (Stacking turns overlapping series into a cumulative part-to-whole, a valid read,
  // so a stacked area is not flagged — applying the [Stack] fix below clears this.)
  if (mark === 'area' && colorIsSeries(config) && !config.stack) {
    const fixes: BuilderWarningFix[] = [];
    if (supportsStack(config)) {
      fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) });
    }
    fixes.push({ label: 'Remove colour', apply: (c) => withChannelCleared(c, 'color') });
    warnings.push({
      channel: 'color',
      message: 'Area charts make per-series change hard to read.',
      fixes,
    });
  }

  // Crowded category axis: a bar/line/area whose discrete category axis carries too
  // many entries to label legibly. Two ways it happens, mutually exclusive:
  //   1. A *raw* (un-aggregated, un-binned) measure draws one mark per row, so the
  //      label count == rowCount — flagged from rowCount alone (the builder's own
  //      default does this: first column on X, second on Y, no aggregate).
  //   2. Otherwise (aggregated/binned), one mark per *category* — crowded only when
  //      the category column itself has many distinct values, which needs the
  //      profiled cardinality (columnStats) and is skipped without it.
  // The remedy follows the canon: for case 1 aggregate to one mark per category; for
  // either, a bar can flip to horizontal where long labels stay readable (FT Visual
  // Vocabulary: bar is "good when … labels have long category names"; Datawrapper:
  // long category lists belong on a horizontal bar).
  if (mark === 'bar' || mark === 'line' || mark === 'area') {
    const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
    const measureChannel = category ? (category === 'x' ? 'y' : 'x') : null;
    const measure = measureChannel ? (config.encodings[measureChannel] ?? null) : null;
    if (category && measureChannel && measure) {
      const rawMeasure = !measure.aggregate && !measure.bin;
      if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
        // Aggregating the measure collapses one-mark-per-row to one-per-category; a bar
        // can also flip horizontal (Swap X/Y) where long labels stay readable. Offer
        // aggregate only for a quantitative measure (Sum is meaningless on a date).
        const fixes: BuilderWarningFix[] = [];
        if (effectiveType(measure) === 'quantitative') {
          fixes.push({
            label: 'Aggregate as Sum',
            apply: (c) => withChannelAggregate(c, measureChannel, 'sum'),
          });
        }
        if (mark === 'bar') fixes.push({ label: 'Swap X/Y', apply: withSwappedXY });
        warnings.push({
          channel: category,
          message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap.`,
          fixes: fixes.length ? fixes : undefined,
        });
      } else {
        const stats = statsFor(config.encodings[category]?.field, columns);
        if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
          // Aggregated already, so the remedy is fewer categories (filter — not yet a
          // builder control) or, for a bar, a horizontal flip where long lists fit.
          const fixes: BuilderWarningFix[] =
            mark === 'bar' ? [{ label: 'Swap X/Y', apply: withSwappedXY }] : [];
          warnings.push({
            channel: category,
            message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap.`,
            fixes: fixes.length ? fixes : undefined,
          });
        }
      }
    }
  }

  // Crowded colour legend: a discrete colour series with many distinct values makes
  // the legend too long to scan and forces the palette to recycle hues. A continuous
  // colour ramp (quantitative/temporal) has no per-value legend, so this is for
  // discrete colour only (Datawrapper/FT cap categorical colour low).
  const color = config.encodings.color ?? null;
  if (color && !isMeasureMapping(color)) {
    const stats = statsFor(color.field, columns);
    if (stats && stats.distinct > CROWDED_LEGEND_DISTINCT) {
      warnings.push({
        channel: 'color',
        message: `Colour has ${cardinalityText(stats)} categories — a legend that long is hard to scan and the palette will repeat hues. Group rarer categories, or map this field to the X axis instead.`,
      });
    }
  }

  // Negative-value Size guard (Draco hard.lp:56 size_negative): symbol size encodes
  // magnitude, so negatives render as zero/clipped area and mislead. The type gate
  // (isChannelTypeAllowed) can't catch this — it needs the data — so we flag it from
  // the profiled numeric extent.
  const size = config.encodings.size ?? null;
  if (size?.field) {
    const stats = statsFor(size.field, columns);
    if (stats?.numericExtent && stats.numericExtent.min < 0) {
      warnings.push({
        channel: 'size',
        message: `Size can't show negative values, but "${size.field}" goes down to ${stats.numericExtent.min}. Negative magnitudes render as tiny or clipped symbols — encode this field with Colour (a diverging scale) instead, or filter to non-negative values.`,
      });
    }
  }

  return warnings;
}

// --- Top-level data transforms: filters + calculated fields (spec §06 → Data). ----

/**
 * The comparison operators a field of this type admits (spec §06 → Data filters).
 * A measure or a temporal field can be ordered and ranged (`lt`…`gte`, `range`);
 * a category (nominal/ordinal) offers equality and membership (`oneOf`) only —
 * ordering categories by value isn't meaningful. `equal`/`notEqual` apply to every
 * type. Mirrors Vega-Lite's field-predicate grammar (Voyager's guarded filter).
 */
export function validFilterOps(fieldType: FieldType): FilterOp[] {
  if (fieldType === 'quantitative' || fieldType === 'temporal') {
    return ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
  }
  return ['equal', 'notEqual', 'oneOf'];
}

/** `range` reads two bounds; `oneOf` a membership list; the rest a single value. */
export function filterOpArity(op: FilterOp): 'single' | 'range' | 'list' {
  if (op === 'range') return 'range';
  if (op === 'oneOf') return 'list';
  return 'single';
}

/**
 * Coerce a filter's text value to the type Vega-Lite compares against: a
 * quantitative field compares as a number (so `> 10` orders numerically, not
 * lexically); every other type compares as the raw string. ISO date strings sort
 * lexically == chronologically, so temporal predicates work as strings without a
 * date parse; non-ISO dates are the expression-mode case. A non-numeric string on a
 * quantitative field falls back to the string (Vega-Lite then compares loosely).
 */
function coerceFilterValue(value: string, fieldType: FieldType): string | number {
  if (fieldType === 'quantitative') {
    const n = Number(value);
    return value.trim() !== '' && Number.isFinite(n) ? n : value;
  }
  return value;
}

/**
 * The Vega-Lite field predicate for a guarded filter, or `null` when it is still
 * incomplete (no field/op, or a value the operator needs is blank) — an incomplete
 * filter is skipped so the preview keeps rendering. `notEqual` wraps an `equal`
 * predicate in a `{ not }` (Vega-Lite has no bare inequality predicate).
 */
function predicateObject(filter: BuilderFilter): Record<string, unknown> | null {
  const { op } = filter;
  if (!filter.field || !op) return null;
  // Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
  // field, not a nested-property accessor (same convention as encodingObject and the
  // renderer — docs/architecture/05 §4).
  const field = escapeVegaField(filter.field);
  const type = filter.fieldType ?? 'nominal';
  const value = filter.value ?? '';
  const coerce = (v: string) => coerceFilterValue(v, type);

  switch (op) {
    case 'equal':
      return value === '' ? null : { field, equal: coerce(value) };
    case 'notEqual':
      return value === '' ? null : { not: { field, equal: coerce(value) } };
    case 'lt':
      return value === '' ? null : { field, lt: coerce(value) };
    case 'lte':
      return value === '' ? null : { field, lte: coerce(value) };
    case 'gt':
      return value === '' ? null : { field, gt: coerce(value) };
    case 'gte':
      return value === '' ? null : { field, gte: coerce(value) };
    case 'range': {
      const upper = filter.value2 ?? '';
      if (value === '' || upper === '') return null;
      return { field, range: [coerce(value), coerce(upper)] };
    }
    case 'oneOf': {
      const items = value
        .split(',')
        .map((s) => s.trim())
        .filter((s) => s !== '');
      return items.length === 0 ? null : { field, oneOf: items.map(coerce) };
    }
    default:
      return null;
  }
}

/** One filter's `{ filter }` transform entry, or `null` when incomplete. */
function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
  if (filter.mode === 'expression') {
    const expr = (filter.expr ?? '').trim();
    // A syntactically-invalid expression is dropped like an empty one: a half-typed
    // `datum.x *` must not reach the renderer and blank the preview mid-edit — the
    // inline feedback already flags it. (Same mid-edit resilience as a partial predicate.)
    return expr === '' || !validateExpression(expr).valid ? null : { filter: expr };
  }
  const predicate = predicateObject(filter);
  return predicate ? { filter: predicate } : null;
}

/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete or unparseable. */
function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
  const expr = calc.expr.trim();
  const as = calc.as.trim();
  return expr === '' || as === '' || !validateExpression(expr).valid
    ? null
    : { calculate: expr, as };
}

/**
 * The complete top-level `transform` array for a configuration: every calculated
 * field first (so filters and encodings can reference the derived columns), then
 * every filter, each in the user's list order. Incomplete or unparseable entries (a
 * half-typed predicate, an unnamed calculate, an expression that doesn't parse) are
 * dropped so a configuration mid-edit still produces a renderable spec. An empty
 * result means no `transform` key is emitted.
 */
export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
  const out: Array<Record<string, unknown>> = [];
  for (const calc of config.calculates ?? []) {
    const t = calculateTransformObject(calc);
    if (t) out.push(t);
  }
  for (const filter of config.filters ?? []) {
    const t = filterTransformObject(filter);
    if (t) out.push(t);
  }
  return out;
}

/** The named (`as`) derived fields a config defines, in order, ignoring unnamed ones. */
export function calculatedFieldNames(
  calculates: readonly BuilderCalculate[] | undefined,
): string[] {
  return (calculates ?? []).map((c) => c.as.trim()).filter((as) => as !== '');
}

/**
 * The dataset's columns augmented with the config's calculated fields, so the UI's
 * column dropdowns and field-type logic treat a derived field like any other. A
 * calculated field's inferred type is unknown, so it defaults to **number**
 * (quantitative — the common arithmetic case; the user can retype it within the
 * valid set on the channel). Calculated names that collide with a real column are
 * skipped (the real column wins). No cardinality stats are derived for them.
 */
export function effectiveColumns(
  base: BuilderColumns,
  calculates: readonly BuilderCalculate[] | undefined,
): BuilderColumns {
  const added = calculatedFieldNames(calculates).filter(
    (name, i, all) => !base.columns.includes(name) && all.indexOf(name) === i,
  );
  if (added.length === 0) return base;
  return {
    columns: [...base.columns, ...added],
    columnTypes: [
      ...base.columnTypes,
      ...added.map((name): { name: string; type: ColumnType } => ({ name, type: 'number' })),
    ],
    columnStats: base.columnStats,
  };
}

/**
 * Clear any channel whose mapped column no longer exists among the effective
 * columns — the cleanup after a calculated field is removed or renamed, so the
 * produced spec never encodes a dangling field (which Vega-Lite would render empty).
 * Returns the same config object when nothing changed (stable for React equality).
 */
export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): BuilderConfig {
  const available = new Set(effectiveColumns(base, config.calculates).columns);
  let changed = false;
  const encodings = { ...config.encodings };
  for (const channel of CHANNELS) {
    const mapping = encodings[channel];
    if (
      mapping &&
      !isValueMapping(mapping) &&
      mapping.field !== undefined &&
      !available.has(mapping.field)
    ) {
      encodings[channel] = null;
      changed = true;
    }
  }
  return changed ? { ...config, encodings } : config;
}

/**
 * Re-point an in-progress config at a different dataset (spec §06 → Dataset
 * picker). Chart-level intent survives the switch — mark, title/subtitle,
 * explicit size, sort/stack, calculated fields, and expression filters (their
 * `datum` references are surfaced by the unknown-field feedback, not dropped) —
 * while anything bound to a column the new dataset doesn't have is shed:
 * encodings via `pruneEncodings`, predicate filters by field lookup. With a
 * same-schema dataset (the common switch: a fresher version of the same data)
 * everything survives verbatim.
 */
export function rebaseBuilderConfig(
  config: BuilderConfig,
  datasetName: string,
  columns: BuilderColumns,
): BuilderConfig {
  const available = new Set(effectiveColumns(columns, config.calculates).columns);
  const filters = (config.filters ?? []).filter(
    (f) => f.mode === 'expression' || (f.field !== undefined && available.has(f.field)),
  );
  const rebased: BuilderConfig = { ...config, datasetName };
  if (config.filters !== undefined) rebased.filters = filters;
  return pruneEncodings(rebased, columns);
}

/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;

/** Build one channel's Vega-Lite encoding object from its mapping + transforms. */
function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
  // A constant value: `{ value }` — a fixed colour/size, no field/type/transform.
  if (mapping.value !== undefined) {
    return { value: mapping.value };
  }
  // A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
  if (mapping.aggregate === 'count') {
    return { aggregate: 'count', type: 'quantitative' };
  }
  const enc: Record<string, unknown> = {};
  // Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
  // field, not a nested-property accessor (docs/architecture/05 §4).
  if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
  // The emitted type is the *effective* one: a distinct-count of any field is a
  // quantitative measure (the carried field type is preserved for a later un-aggregate).
  enc.type = effectiveType(mapping);
  if (mapping.aggregate) enc.aggregate = mapping.aggregate;
  if (mapping.bin) enc.bin = true;
  if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
  return enc;
}

/**
 * Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
 * Output). Includes the schema reference, a named data reference to the dataset,
 * any top-level `transform` (calculated fields then row filters), the mark with
 * tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
 * timeUnit transform), chart-level sort (rank a categorical axis by its measure)
 * and stack (part-to-whole), any title/subtitle, and any explicit width/height. Unmapped
 * channels are omitted; if nothing is mapped the `encoding` block is omitted
 * entirely (validation prevents saving that, but the live preview may render a bare
 * mark while the user is still configuring).
 */
export function buildChartSpec(config: BuilderConfig): ChartSpec {
  const spec: ChartSpec = {
    $schema: VEGA_LITE_SCHEMA_URL,
    data: { name: config.datasetName },
  };

  // Top-level transforms (calculated fields, then filters) sit between data and
  // mark — applied to the raw rows before encoding aggregation.
  const transform = buildTransforms(config);
  if (transform.length > 0) spec.transform = transform;

  // Title/subtitle: a bare string for a lone title, the object form when a
  // subtitle rides along. A subtitle without a title is not emitted (VL has no
  // standalone subtitle; the UI disables the input until a title exists).
  const title = config.title?.trim();
  if (title) {
    const subtitle = config.subtitle?.trim();
    spec.title = subtitle ? { text: title, subtitle } : title;
  }

  spec.mark = { type: config.mark, tooltip: true };

  const encoding: Record<string, Record<string, unknown>> = {};
  for (const [channel, mapping] of mappedChannels(config)) {
    encoding[channel] = encodingObject(mapping);
  }

  // Sort: the categorical positional axis sorts by the value of the measure axis
  // ("-y" descending, "y" ascending) — the conventional Vega-Lite ranking idiom.
  if (config.sort) {
    const category = sortableCategoryChannel(config);
    if (category && encoding[category]) {
      const measure = category === 'x' ? 'y' : 'x';
      encoding[category].sort = config.sort === 'descending' ? `-${measure}` : measure;
    }
  }

  // Stack: part-to-whole on the quantitative positional axis of a bar/area + colour.
  if (config.stack && supportsStack(config)) {
    const measure = stackMeasureChannel(config);
    if (measure && encoding[measure]) encoding[measure].stack = config.stack;
  }

  if (Object.keys(encoding).length > 0) spec.encoding = encoding;

  if (config.width !== undefined) spec.width = config.width;
  if (config.height !== undefined) spec.height = config.height;

  return spec;
}

/** The built spec as pretty-printed JSON text, ready for a snippet's `spec`. */
export function buildSnippetSpecText(config: BuilderConfig): string {
  return JSON.stringify(buildChartSpec(config), null, 2);
}

// ── Spec → config hydration (spec §06 → Open in builder) ────────────────────────
//
// The strict inverse of `buildChartSpec`: re-enter the builder populated from an
// existing snippet. Strict means *losslessly representable in the builder's
// dialect* — checked not by enumerating supported features but by parsing a
// candidate config, **re-assembling it, and deep-comparing against the original**
// (ignoring key order, `$schema`, and the injected `mark.tooltip`). That keeps the
// gate exact and self-correcting: every channel/transform the assembler learns to
// emit widens what hydrates for free, with no second list to maintain. A spec the
// builder can't reproduce exactly returns `null` and stays Monaco-only — the GUI
// never overwrites a richer spec (the source of truth stays the JSON).

type JsonObject = Record<string, unknown>;

/** A plain (non-array) object guard. */
function isObject(v: unknown): v is JsonObject {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/** A canonical string for a JSON value with object keys sorted — order-independent equality. */
function canonicalJson(v: unknown): string {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
  if (isObject(v)) {
    return `{${Object.keys(v)
      .sort()
      .map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`)
      .join(',')}}`;
  }
  return JSON.stringify(v) ?? 'null';
}

/** Reduce a spec to the form the round-trip compares: drop `$schema`, and treat a
 *  bare-string mark and an `{ type, tooltip: true }` mark as one (the builder always
 *  injects `tooltip: true`, so it must not count as a difference). */
function normalizeForRoundTrip(spec: JsonObject): JsonObject {
  const { $schema: _schema, ...rest } = spec;
  void _schema;
  if (rest.mark !== undefined) {
    if (typeof rest.mark === 'string') {
      rest.mark = { type: rest.mark };
    } else if (isObject(rest.mark)) {
      const mark = { ...rest.mark };
      if (mark.tooltip === true) delete mark.tooltip;
      rest.mark = mark;
    }
  }
  return rest;
}

/** A `FieldType` if the value is one, else `null`. */
function asFieldType(v: unknown): FieldType | null {
  return v === 'quantitative' || v === 'nominal' || v === 'ordinal' || v === 'temporal' ? v : null;
}

/** A constant value back-coerced to its `BuilderFilter` string shape (for the predicate forms). */
function filterValueToString(v: unknown): string | null {
  if (typeof v === 'string') return v;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  return null;
}

/** The `fieldType` whose `coerceFilterValue` reproduces this JSON value: a number
 *  came from a quantitative compare, anything else from a string compare. */
function filterTypeFor(v: unknown): FieldType {
  return typeof v === 'number' ? 'quantitative' : 'nominal';
}

/** Parse one `{ filter }` transform entry into a `BuilderFilter`, or `null` if it is
 *  not a shape the guarded shelf / expression mode can represent. */
function parseFilter(entry: unknown, id: string): BuilderFilter | null {
  if (!isObject(entry) || !('filter' in entry)) return null;
  const f = entry.filter;
  if (typeof f === 'string') return { id, mode: 'expression', expr: f };
  if (!isObject(f)) return null;

  // `notEqual` is the only wrapped predicate the assembler emits: `{ not: { field, equal } }`.
  if (isObject(f.not) && Object.keys(f).length === 1) {
    const inner = f.not;
    if (typeof inner.field === 'string' && 'equal' in inner && Object.keys(inner).length === 2) {
      const value = filterValueToString(inner.equal);
      if (value === null) return null;
      return {
        id,
        mode: 'predicate',
        field: unescapeVegaField(inner.field),
        fieldType: filterTypeFor(inner.equal),
        op: 'notEqual',
        value,
      };
    }
    return null;
  }

  if (typeof f.field !== 'string') return null;
  const field = unescapeVegaField(f.field);
  const keys = Object.keys(f).filter((k) => k !== 'field');
  if (keys.length !== 1) return null; // exactly one predicate operator besides `field`
  const op = keys[0];
  const base = { id, mode: 'predicate' as const, field };

  switch (op) {
    case 'equal':
    case 'lt':
    case 'lte':
    case 'gt':
    case 'gte': {
      const value = filterValueToString(f[op]);
      if (value === null) return null;
      return { ...base, fieldType: filterTypeFor(f[op]), op, value };
    }
    case 'range': {
      const r = f.range;
      if (!Array.isArray(r) || r.length !== 2) return null;
      const lo = filterValueToString(r[0]);
      const hi = filterValueToString(r[1]);
      if (lo === null || hi === null) return null;
      return { ...base, fieldType: filterTypeFor(r[0]), op: 'range', value: lo, value2: hi };
    }
    case 'oneOf': {
      const list = f.oneOf;
      if (!Array.isArray(list) || list.length === 0) return null;
      const items = list.map(filterValueToString);
      if (items.some((s) => s === null)) return null;
      // The shelf rebuilds the list by splitting on "," and trimming — so a member
      // that itself contains a comma or edge whitespace can't round-trip; the gate
      // (re-assemble + compare) rejects those, keeping this strict.
      return {
        ...base,
        fieldType: filterTypeFor(list[0]),
        op: 'oneOf',
        value: (items as string[]).join(','),
      };
    }
    default:
      return null;
  }
}

/** Parse the top-level `transform` array into the config's calculates + filters
 *  (the two kinds the builder emits), or `null` if any entry is foreign. */
function parseTransforms(
  transform: unknown,
): { calculates: BuilderCalculate[]; filters: BuilderFilter[] } | null {
  const calculates: BuilderCalculate[] = [];
  const filters: BuilderFilter[] = [];
  if (transform === undefined) return { calculates, filters };
  if (!Array.isArray(transform)) return null;
  const entries: unknown[] = transform;
  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i];
    if (isObject(entry) && 'calculate' in entry) {
      // A calculated field is exactly `{ calculate, as }` — any other key is foreign.
      if (
        typeof entry.calculate !== 'string' ||
        typeof entry.as !== 'string' ||
        Object.keys(entry).length !== 2
      ) {
        return null;
      }
      calculates.push({ id: `c${i}`, expr: entry.calculate, as: entry.as });
      continue;
    }
    const filter = parseFilter(entry, `f${i}`);
    if (!filter) return null; // a transform the builder can't represent ⇒ reject
    filters.push(filter);
  }
  return { calculates, filters };
}

/** Parse one encoding channel object into a `ChannelMapping`, plus any channel-level
 *  `sort`/`stack` the builder lifts to config level. `null` ⇒ not builder dialect. */
function parseChannel(
  channel: ChannelName,
  enc: unknown,
): { mapping: ChannelMapping; sort?: SortOrder; stack?: StackMode } | null {
  if (!isObject(enc)) return null;
  const rest = { ...enc };

  // Lift channel-level sort/stack out before reading the field mapping.
  let sort: SortOrder | undefined;
  let stack: StackMode | undefined;
  if ('sort' in rest) {
    const s = rest.sort;
    if (s === 'x' || s === 'y') sort = 'ascending';
    else if (s === '-x' || s === '-y') sort = 'descending';
    else return null; // a richer sort spec (object/array) isn't builder dialect
    delete rest.sort;
  }
  if ('stack' in rest) {
    if (rest.stack === 'zero' || rest.stack === 'normalize') stack = rest.stack;
    else return null;
    delete rest.stack;
  }

  // A constant value: `{ value }` only.
  if ('value' in rest) {
    if (Object.keys(rest).length !== 1) return null;
    const value = rest.value;
    if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
      return null;
    }
    const type: FieldType = channel === 'size' ? 'quantitative' : 'nominal';
    return { mapping: { value, type }, sort, stack };
  }

  // A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
  if (rest.aggregate === 'count') {
    if (rest.type !== 'quantitative' || 'field' in rest || Object.keys(rest).length !== 2) {
      return null;
    }
    return { mapping: { type: 'quantitative', aggregate: 'count' }, sort, stack };
  }

  // A field mapping: field + type (+ optional aggregate/bin/timeUnit), nothing else.
  if (typeof rest.field !== 'string') return null;
  const type = asFieldType(rest.type);
  if (type === null) return null;
  const mapping: ChannelMapping = { field: unescapeVegaField(rest.field), type };
  const consumed = new Set(['field', 'type']);
  if ('aggregate' in rest) {
    const agg = rest.aggregate;
    const valid: AggregateOp[] = ['distinct', 'sum', 'mean', 'median', 'min', 'max'];
    if (typeof agg !== 'string' || !valid.includes(agg as AggregateOp)) return null;
    mapping.aggregate = agg as AggregateOp;
    consumed.add('aggregate');
  }
  if ('bin' in rest) {
    if (rest.bin !== true) return null; // only the bare `bin: true` form
    mapping.bin = true;
    consumed.add('bin');
  }
  if ('timeUnit' in rest) {
    if (!TIME_UNITS.includes(rest.timeUnit as TimeUnit)) return null;
    mapping.timeUnit = rest.timeUnit as TimeUnit;
    consumed.add('timeUnit');
  }
  if (Object.keys(rest).some((k) => !consumed.has(k))) return null; // a foreign key (axis/scale/…)
  return { mapping, sort, stack };
}

/**
 * The strict inverse of {@link buildChartSpec} (spec §06 → Open in builder): a
 * Vega-Lite spec object → the `BuilderConfig` that produces it, or `null` when the
 * spec is not **losslessly** representable in the builder's dialect. Losslessness is
 * the authority — a candidate config is parsed leniently, then re-assembled and
 * deep-compared against the input (key order, `$schema`, and the injected
 * `mark.tooltip` ignored). Only a spec the builder can reproduce *exactly* hydrates;
 * everything else stays Monaco-only, so the builder never silently drops a richer
 * spec's content.
 *
 * The dataset reference is read by name only (`{ data: { name } }`) — the builder's
 * sole data model; an inline-`values`/`url` spec has no builder representation and
 * returns `null`. The caller still confirms the named dataset exists before opening.
 */
export function parseChartSpec(spec: unknown): BuilderConfig | null {
  if (!isObject(spec)) return null;

  // Data must be a bare name reference — the only data shape the builder emits.
  if (!isObject(spec.data) || typeof spec.data.name !== 'string') return null;
  const datasetName = spec.data.name;

  // Mark: a known type, as a bare string or `{ type, tooltip? }`.
  const markValue = isObject(spec.mark) ? spec.mark.type : spec.mark;
  if (typeof markValue !== 'string' || !MARK_TYPES.includes(markValue as MarkType)) return null;
  const mark = markValue as MarkType;

  const config: BuilderConfig = { datasetName, mark, encodings: {} };

  // Title: a bare string, or `{ text, subtitle? }`.
  if ('title' in spec) {
    if (typeof spec.title === 'string') {
      config.title = spec.title;
    } else if (isObject(spec.title) && typeof spec.title.text === 'string') {
      config.title = spec.title.text;
      if ('subtitle' in spec.title) {
        if (typeof spec.title.subtitle !== 'string') return null;
        config.subtitle = spec.title.subtitle;
      }
    } else {
      return null;
    }
  }

  // Transforms → calculated fields + filters.
  const transforms = parseTransforms(spec.transform);
  if (transforms === null) return null;
  if (transforms.calculates.length > 0) config.calculates = transforms.calculates;
  if (transforms.filters.length > 0) config.filters = transforms.filters;

  // Encodings → channel mappings, lifting channel-level sort/stack to config level.
  if ('encoding' in spec) {
    if (!isObject(spec.encoding)) return null;
    for (const key of Object.keys(spec.encoding)) {
      if (!CHANNELS.includes(key as ChannelName)) return null; // a channel the builder doesn't own
      const parsed = parseChannel(key as ChannelName, spec.encoding[key]);
      if (parsed === null) return null;
      config.encodings[key as ChannelName] = parsed.mapping;
      if (parsed.sort) config.sort = parsed.sort;
      if (parsed.stack) config.stack = parsed.stack;
    }
  }

  // Explicit dimensions.
  if ('width' in spec) {
    if (typeof spec.width !== 'number') return null;
    config.width = spec.width;
  }
  if ('height' in spec) {
    if (typeof spec.height !== 'number') return null;
    config.height = spec.height;
  }

  // Any top-level key the assembler doesn't emit ⇒ not builder dialect.
  const allowedTop = new Set([
    '$schema',
    'data',
    'mark',
    'encoding',
    'transform',
    'title',
    'width',
    'height',
  ]);
  if (Object.keys(spec).some((k) => !allowedTop.has(k))) return null;

  // The authority: the parsed config must re-assemble to the original spec exactly.
  if (
    canonicalJson(normalizeForRoundTrip(buildChartSpec(config))) !==
    canonicalJson(normalizeForRoundTrip(spec))
  ) {
    return null;
  }
  return config;
}

/**
 * Parse a snippet's spec **text** into a `BuilderConfig`, or `null` when the text
 * isn't valid JSON or isn't losslessly representable in the builder (see
 * {@link parseChartSpec}). The convenience the library/store use — snippets store
 * their spec as text.
 */
export function parseChartSpecText(specText: string): BuilderConfig | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(specText);
  } catch {
    return null;
  }
  return parseChartSpec(parsed);
}

/** Title-case a single mark type for display/naming (e.g. `bar` → `Bar`). */
function markLabel(mark: MarkType): string {
  return mark.charAt(0).toUpperCase() + mark.slice(1);
}

/** The chart-type noun for a generated name: "Heatmap" for `rect` (its `markLabel`
 *  "Rect" is jargon), "<Mark> chart" for the rest. */
function markNoun(mark: MarkType): string {
  return mark === 'rect' ? 'Heatmap' : `${markLabel(mark)} chart`;
}

/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
function describeMapping(mapping: ChannelMapping): string {
  if (mapping.value !== undefined) return 'a constant';
  if (mapping.aggregate === 'count') return 'count';
  const field = mapping.field ?? '';
  if (mapping.aggregate === 'distinct') return `unique ${field}`;
  if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
  return field;
}

/**
 * An auto-generated, descriptive name for the created snippet (spec §06 → Output:
 * "an auto-generated descriptive name"). When both X and Y are mapped it reads
 * "Bar chart of <y> by <x>" (using each channel's measure phrase, e.g. "count" or
 * "sum of revenue"); otherwise it falls back to naming the dataset. Deterministic —
 * no timestamp — so the name describes the chart, not when it was made.
 */
export function generateChartName(config: BuilderConfig): string {
  // A user-written chart title is the best possible name — prefer it verbatim.
  const title = config.title?.trim();
  if (title) return title;
  const noun = markNoun(config.mark);
  const x = config.encodings.x;
  const y = config.encodings.y;
  if (x && y) return `${noun} of ${describeMapping(y)} by ${describeMapping(x)}`;
  const only = mappedChannels(config)[0];
  if (only) return `${noun} of ${describeMapping(only[1])}`;
  return `${noun} of ${config.datasetName}`;
}
