/**
 * Chart Builder state (spec §06).
 *
 * Backs the Chart Builder modal: a no-JSON composer that turns a dataset + a mark
 * + four channel mappings into a Vega-Lite spec saved as a new snippet. All the
 * spec grammar and the Tier-B defaults/guards live in the portable core
 * (`@core/chart-builder`); this store is the thin app-layer state + actions over
 * that, plus the create-flow side effects (new snippet, toast, activate, close).
 *
 * `init(datasetId)` loads the dataset's columns and pre-populates a smart default
 * config. `init(null)` — the library's Build-Chart door, which opens without a
 * target (spec §06 → Opening) — picks the most recently modified dataset; only
 * with an empty dataset library does the builder land empty (the modal then shows
 * the no-datasets state). The mark is sticky after open (changing a column does
 * not re-derive it) so the user's choice is never overridden mid-edit.
 */

import { create } from 'zustand';
import {
  CHANNELS,
  applyIntent,
  buildSnippetSpecText,
  channelAcceptsValue,
  coerceChannelValue,
  defaultBuilderConfig,
  defaultFieldType,
  effectiveColumns,
  generateChartName,
  isBuilderConfigValid,
  isChannelTypeAllowed,
  isColumnAllowedOnChannel,
  parseChartSpecText,
  pruneEncodings,
  rebaseBuilderConfig,
  supportsBin,
  supportsTimeUnit,
  validAggregateOps,
  validFieldTypes,
  validFilterOps,
  type AggregateOp,
  type BuilderCalculate,
  type BuilderColumns,
  type BuilderConfig,
  type BuilderFilter,
  type ChartIntent,
  type BuilderWarningFix,
  type ChannelMapping,
  type ChannelName,
  type FieldType,
  type FilterMode,
  type MarkType,
  type SortOrder,
  type StackMode,
  type TimeUnit,
} from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference';
import type { Snippet } from '@core/snippet';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';

/** An empty config — no dataset, nothing mapped (the "No dataset loaded" state). */
const EMPTY_CONFIG: BuilderConfig = { datasetName: '', mark: 'bar', encodings: {} };
const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] };

/**
 * Sentinel column value for the "Count of records" dropdown option — a field-less
 * `count` measure (Voyager's `count(*)`). Distinct from any real column name (a NUL
 * byte can't appear in one), so the dropdown can offer it alongside the columns.
 * The NUL is written as a `\u0000` escape so the source stays text, not binary.
 */
export const COUNT_FIELD = '\u0000count';

/**
 * Monotonic id source for filter / calculated-field list rows. Ids are stable React
 * keys and edit handles only — they never reach the produced spec — so a plain
 * session counter is enough (no need for crypto/uuid), and it keeps the rows
 * order-stable as the user adds and removes them.
 */
let transformSeq = 0;
const nextTransformId = (prefix: 'f' | 'c'): string => `${prefix}${++transformSeq}`;

export interface ChartBuilderState {
  /** The dataset being built from, or null when none is loaded. */
  datasetId: number | null;
  /** The dataset's columns + inferred types (drives the dropdowns and defaults). */
  columns: BuilderColumns;
  /** The dataset's row count (null for URL/non-tabular); powers the crowded-axis hint. */
  rowCount: number | null;
  /** The working configuration the preview and the produced spec read from. */
  config: BuilderConfig;
  /**
   * The exact config object `init` produced. Mutations replace `config` via
   * spread, so `config === initialConfig` means "still the untouched opening
   * default" — the dataset switch uses it to choose re-derive vs rebase.
   */
  initialConfig: BuilderConfig;
  /**
   * The channel "armed" to receive the next clicked field (field-first assignment,
   * spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in
   * the shelf then fills it. Null when no slot is armed (a clicked field lands on the
   * first empty channel that accepts it).
   */
  activeChannel: ChannelName | null;
  /**
   * The snippet being **edited in place** (spec §06 → Open in builder), or null in
   * the ordinary create flow. When set, the modal's primary action saves back to
   * this snippet (`saveEdits`) instead of creating a new one, and its header names
   * the snippet (`editingSnippetName`).
   */
  editingSnippetId: string | null;
  /** The edited snippet's name, for the modal header (snapshot at open). */
  editingSnippetName: string | null;

  /**
   * Load a dataset and pre-populate a smart default config (spec §06 → Opening).
   * `null` picks the most recently modified dataset (the un-targeted entry points).
   * Clears any edit-in-place state — a fresh open is a create flow.
   */
  init: (datasetId: number | null) => void;
  /**
   * Hydrate the builder from an existing snippet for **edit-in-place** (spec §06 →
   * Open in builder). Parses the snippet's published spec back to a config (strict —
   * `parseChartSpecText`), loads the referenced dataset's columns, and records the
   * snippet so `saveEdits` writes back to it. Returns false (a no-op) when the spec
   * isn't losslessly representable or its dataset is gone — the caller gates the
   * entry point on the same two conditions, so this is a defensive guard.
   */
  openForEdit: (snippet: Snippet) => boolean;
  /**
   * Save the working config back to the edited snippet (the **Save changes** action,
   * spec §06 → Open in builder): republishes the built spec into both the snippet's
   * versions via `SnippetStore.replaceSnippetSpec`, then resets. Returns false when
   * not in edit mode or the config is invalid. `now` injectable.
   */
  saveEdits: (now?: Date) => boolean;
  /**
   * Re-point the open builder at another dataset (the header picker, spec §06).
   * An untouched default config re-derives fresh smart defaults for the new
   * dataset; a config the user has built on is rebased instead — chart-level
   * intent kept, bindings to columns the new dataset lacks shed (core
   * `rebaseBuilderConfig`).
   */
  switchDataset: (datasetId: number) => void;
  /**
   * Reshape the chart to an intent's recommended layout — the front door's "do it
   * for me" (spec §06 → Intent). Replaces mark + encodings + sort/stack with the
   * intent's; keeps the dataset, data transforms, and chart properties. The active
   * intent is derived from the config (`activeIntent`), not stored, so an edit after
   * a pick reads as "Custom" with no extra state.
   */
  setIntent: (intent: ChartIntent) => void;
  setMark: (mark: MarkType) => void;
  /**
   * Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
   * seeds the channel's default type and clears any prior transforms.
   */
  setChannelColumn: (channel: ChannelName, columnName: string | null) => void;
  /**
   * Arm (or disarm with `null`) a channel slot to receive the next clicked field —
   * the field-first assignment focus model.
   */
  focusChannel: (channel: ChannelName | null) => void;
  /**
   * Place a clicked shelf field on a channel: the armed channel if it accepts the
   * column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
   * channel can take it. Clears the armed slot. An explicit `channel` (the field
   * chip's channel chooser) overrides both — it places the column there even when
   * the channel is occupied (the chooser labels that as a replacement).
   */
  assignField: (columnName: string, channel?: ChannelName) => void;
  /**
   * Set/replace a channel's binding with a **constant value** (the Property model,
   * spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
   * accept one; the raw input is coerced by channel (Size → number). Preserves the
   * prior field type so toggling back to a field restores it.
   */
  setChannelConstant: (channel: ChannelName, raw: string) => void;
  setChannelType: (channel: ChannelName, type: FieldType) => void;
  /** Set/clear a channel's aggregate (sum/mean/…); `undefined` clears it. */
  setChannelAggregate: (channel: ChannelName, aggregate: AggregateOp | undefined) => void;
  /** Toggle binning a quantitative channel into ranges. */
  setChannelBin: (channel: ChannelName, bin: boolean) => void;
  /** Set/clear a temporal channel's granularity (timeUnit). */
  setChannelTimeUnit: (channel: ChannelName, timeUnit: TimeUnit | undefined) => void;
  /** Swap the X and Y mappings (a one-click axis flip). */
  swapXY: () => void;
  /** Apply a guidance hint's one-click remedy (Tier-C actionable hint, §06). */
  applyWarningFix: (fix: BuilderWarningFix) => void;
  /** Sort the categorical axis by its measure (ranking); `undefined` = unsorted. */
  setSort: (sort: SortOrder | undefined) => void;
  /** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */
  setStack: (stack: StackMode | undefined) => void;

  /** Append a new, empty predicate filter (defaults to the first column, equals). */
  addFilter: () => void;
  /** Patch one filter row by id (op/value/value2/expr/mode). */
  updateFilter: (id: string, patch: Partial<Omit<BuilderFilter, 'id'>>) => void;
  /** Re-point a filter to a column: derives its field type and clamps the operator. */
  setFilterField: (id: string, field: string) => void;
  /** Switch a filter between the guarded predicate shelf and a raw expression. */
  setFilterMode: (id: string, mode: FilterMode) => void;
  /** Remove a filter row by id. */
  removeFilter: (id: string) => void;

  /** Append a new, empty calculated field. */
  addCalculate: () => void;
  /** Patch one calculated field by id (expr / as); prunes any now-dangling encoding. */
  updateCalculate: (id: string, patch: Partial<Omit<BuilderCalculate, 'id'>>) => void;
  /** Remove a calculated field by id; clears any channel that referenced it. */
  removeCalculate: (id: string) => void;
  setWidth: (width: number | undefined) => void;
  setHeight: (height: number | undefined) => void;
  /** Set/clear the chart title (whitespace-only clears). */
  setTitle: (raw: string) => void;
  /** Set/clear the subtitle; emitted into the spec only alongside a title. */
  setSubtitle: (raw: string) => void;
  /** Build the spec, create + activate a linked snippet, toast, and close. */
  createSnippet: (now?: Date) => boolean;
  reset: () => void;
}

/** The inferred type of a named column, defaulting to `string` if unknown. */
function columnType(columns: BuilderColumns, name: string): ColumnType {
  return columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
}

/** The dataset columns plus the config's calculated fields (what the dropdowns offer). */
function effCols(s: ChartBuilderState): BuilderColumns {
  return effectiveColumns(s.columns, s.config.calculates);
}

/** Replace one channel's mapping, returning the new `{ config }` state slice. */
function updateEncoding(
  s: ChartBuilderState,
  channel: ChannelName,
  mapping: ChannelMapping,
): { config: BuilderConfig } {
  return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
}

/**
 * The fresh mapping for a column placed on a channel: a field-less count for the
 * sentinel, else the column with a channel-appropriate field type (its natural type if
 * allowed here, else the first valid type that is — the Size guard). A fresh mapping
 * carries no transforms. Shared by direct (`setChannelColumn`) and field-first
 * (`assignField`) assignment so both land on the same mapping.
 */
function mappingForColumn(
  s: ChartBuilderState,
  channel: ChannelName,
  columnName: string,
): ChannelMapping {
  if (columnName === COUNT_FIELD) return { type: 'quantitative', aggregate: 'count' };
  const colType = columnType(effCols(s), columnName);
  const valid = validFieldTypes(colType);
  const type = valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
  return { field: columnName, type };
}

/**
 * Which channel a clicked field lands on (field-first assignment): the armed channel
 * if it accepts the column, else the first empty channel that does, in canonical order.
 * Returns null when nothing can take it (so a click is a no-op rather than clobbering a
 * full set). The count sentinel reads as a measure for placement.
 */
function chooseTargetChannel(s: ChartBuilderState, columnName: string): ChannelName | null {
  const colType: ColumnType =
    columnName === COUNT_FIELD ? 'number' : columnType(effCols(s), columnName);
  const accepts = (ch: ChannelName) => isColumnAllowedOnChannel(ch, colType);
  const active = s.activeChannel;
  if (active && accepts(active)) return active;
  for (const ch of CHANNELS) if (!s.config.encodings[ch] && accepts(ch)) return ch;
  return null;
}

export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
  datasetId: null,
  columns: EMPTY_COLUMNS,
  rowCount: null,
  config: EMPTY_CONFIG,
  initialConfig: EMPTY_CONFIG,
  activeChannel: null,
  editingSnippetId: null,
  editingSnippetName: null,

  init: (datasetId) => {
    const all = useDatasetStore.getState().datasets;
    // An un-targeted open (the library / onboarding doors) lands on the most
    // recently modified dataset — the freshest data is the likeliest subject.
    const dataset =
      datasetId === null
        ? all.reduce<(typeof all)[number] | undefined>(
            (best, d) => (best === undefined || d.modified > best.modified ? d : best),
            undefined,
          )
        : all.find((d) => d.id === datasetId);
    if (!dataset) {
      set({
        datasetId: null,
        columns: EMPTY_COLUMNS,
        rowCount: null,
        config: EMPTY_CONFIG,
        initialConfig: EMPTY_CONFIG,
        activeChannel: null,
        editingSnippetId: null,
        editingSnippetName: null,
      });
      return;
    }
    const columns: BuilderColumns = {
      columns: dataset.columns,
      columnTypes: dataset.columnTypes,
      columnStats: dataset.columnStats,
    };
    const config = defaultBuilderConfig(dataset.name, columns);
    set({
      datasetId: dataset.id,
      columns,
      rowCount: dataset.rowCount,
      config,
      initialConfig: config,
      activeChannel: null,
      editingSnippetId: null,
      editingSnippetName: null,
    });
  },

  openForEdit: (snippet) => {
    const config = parseChartSpecText(snippet.spec);
    if (!config) return false; // not losslessly representable (caller gates on this)
    const dataset = useDatasetStore.getState().datasets.find((d) => d.name === config.datasetName);
    if (!dataset) return false; // the referenced dataset is gone (caller gates too)
    const columns: BuilderColumns = {
      columns: dataset.columns,
      columnTypes: dataset.columnTypes,
      columnStats: dataset.columnStats,
    };
    // Re-stamp the parsed transform ids through this store's own sequence so a later
    // addFilter/addCalculate can't collide with one (parse numbers them by position).
    const hydrated: BuilderConfig = { ...config };
    if (config.calculates) {
      hydrated.calculates = config.calculates.map((c) => ({ ...c, id: nextTransformId('c') }));
    }
    if (config.filters) {
      hydrated.filters = config.filters.map((f) => ({ ...f, id: nextTransformId('f') }));
    }
    set({
      datasetId: dataset.id,
      columns,
      rowCount: dataset.rowCount,
      config: hydrated,
      // A loaded chart has no re-derivable "opening default"; keep initialConfig
      // distinct from config so a dataset switch rebases (preserving the loaded
      // chart), never re-derives fresh defaults (see switchDataset).
      initialConfig: EMPTY_CONFIG,
      activeChannel: null,
      editingSnippetId: snippet.id,
      editingSnippetName: snippet.name,
    });
    return true;
  },

  saveEdits: (now) => {
    const { config, editingSnippetId } = get();
    if (editingSnippetId === null) return false; // not an edit session
    if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
    const specText = buildSnippetSpecText(config);
    const ok = useSnippetStore.getState().replaceSnippetSpec(editingSnippetId, specText, now);
    if (ok) get().reset();
    return ok;
  },

  switchDataset: (datasetId) => {
    const s = get();
    if (datasetId === s.datasetId) return;
    const dataset = useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
    if (!dataset) return;
    // An untouched opening config (every mutation replaces `config` via spread, so
    // reference identity with the init-produced object means nothing changed) — or
    // no dataset at all — re-derives fresh defaults for the new one; anything the
    // user has built on is rebased so their work survives the switch.
    if (s.datasetId === null || s.config === s.initialConfig) {
      get().init(datasetId);
      return;
    }
    const columns: BuilderColumns = {
      columns: dataset.columns,
      columnTypes: dataset.columnTypes,
      columnStats: dataset.columnStats,
    };
    // `initialConfig` is deliberately left stale: a rebased config is still the
    // user's built-on work, so a later switch must rebase again, never re-derive.
    set({
      datasetId: dataset.id,
      columns,
      rowCount: dataset.rowCount,
      config: rebaseBuilderConfig(s.config, dataset.name, columns),
      activeChannel: null,
    });
  },

  setIntent: (intent) =>
    set((s) => ({ activeChannel: null, config: applyIntent(s.config, intent, s.columns) })),

  setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),

  setChannelColumn: (channel, columnName) =>
    set((s) => {
      // null = "None"; otherwise a fresh, transform-free mapping (count or typed field,
      // its type clamped to one the channel allows). Effective columns include the
      // config's calculated fields.
      const mapping = columnName === null ? null : mappingForColumn(s, channel, columnName);
      return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
    }),

  focusChannel: (channel) => set({ activeChannel: channel }),

  assignField: (columnName, channel) =>
    set((s) => {
      const target = channel ?? chooseTargetChannel(s, columnName);
      if (!target) return { activeChannel: null }; // nothing can take it → just disarm
      const mapping = mappingForColumn(s, target, columnName);
      return {
        activeChannel: null,
        config: { ...s.config, encodings: { ...s.config.encodings, [target]: mapping } },
      };
    }),

  setChannelConstant: (channel, raw) =>
    set((s) => {
      if (!channelAcceptsValue(channel)) return s; // only Color/Size hold a constant
      const current = s.config.encodings[channel];
      // Preserve the prior field type so a later toggle back to a field restores it;
      // default by channel when there was none. A constant carries no field/transforms.
      const type: FieldType = current?.type ?? (channel === 'size' ? 'quantitative' : 'nominal');
      const mapping: ChannelMapping = { value: coerceChannelValue(channel, raw), type };
      return {
        activeChannel: null,
        config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } },
      };
    }),

  setChannelType: (channel, type) =>
    set((s) => {
      const current = s.config.encodings[channel];
      if (!current) return s; // no field on this channel → nothing to retype
      // Drop transforms that no longer apply to the new type (e.g. a sum or bin when
      // leaving Quantitative, a granularity when leaving Temporal). A `distinct`
      // survives any retype (it applies to every field type); `count` is field-less
      // and never reaches here (a count pill offers no type cycle).
      const next: ChannelMapping = { ...current, type };
      if (
        next.aggregate &&
        next.aggregate !== 'count' &&
        !validAggregateOps(type).includes(next.aggregate)
      ) {
        delete next.aggregate;
      }
      if (!supportsBin(type)) delete next.bin;
      if (!supportsTimeUnit(type)) delete next.timeUnit;
      return updateEncoding(s, channel, next);
    }),

  setChannelAggregate: (channel, aggregate) =>
    set((s) => {
      const current = s.config.encodings[channel];
      if (!current) return s;
      const next: ChannelMapping = { ...current };
      if (aggregate) {
        next.aggregate = aggregate;
        delete next.bin; // a field can't be both aggregated and binned (Draco hard:28)
      } else delete next.aggregate;
      return updateEncoding(s, channel, next);
    }),

  setChannelBin: (channel, bin) =>
    set((s) => {
      const current = s.config.encodings[channel];
      if (!current) return s;
      const next: ChannelMapping = { ...current };
      if (bin) {
        next.bin = true;
        delete next.aggregate; // mutually exclusive with aggregate (Draco hard:28)
      } else delete next.bin;
      return updateEncoding(s, channel, next);
    }),

  setChannelTimeUnit: (channel, timeUnit) =>
    set((s) => {
      const current = s.config.encodings[channel];
      if (!current) return s;
      const next: ChannelMapping = { ...current };
      if (timeUnit) next.timeUnit = timeUnit;
      else delete next.timeUnit;
      return updateEncoding(s, channel, next);
    }),

  setSort: (sort) => set((s) => ({ config: { ...s.config, sort } })),
  setStack: (stack) => set((s) => ({ config: { ...s.config, stack } })),

  addFilter: () =>
    set((s) => {
      // Seed the new row on the first available column so it is immediately usable;
      // an unmapped dataset (no columns) yields an expression-mode row instead.
      const first = effCols(s).columns[0];
      const filter: BuilderFilter = first
        ? {
            id: nextTransformId('f'),
            mode: 'predicate',
            field: first,
            fieldType: defaultFieldType(columnType(effCols(s), first)),
            op: 'equal',
            value: '',
          }
        : { id: nextTransformId('f'), mode: 'expression', expr: '' };
      return { config: { ...s.config, filters: [...(s.config.filters ?? []), filter] } };
    }),

  updateFilter: (id, patch) =>
    set((s) => ({
      config: {
        ...s.config,
        filters: (s.config.filters ?? []).map((f) => (f.id === id ? { ...f, ...patch } : f)),
      },
    })),

  setFilterField: (id, field) =>
    set((s) => {
      const fieldType = defaultFieldType(columnType(effCols(s), field));
      return {
        config: {
          ...s.config,
          filters: (s.config.filters ?? []).map((f) => {
            if (f.id !== id) return f;
            // Re-point the field and its type; keep the operator only if it is still
            // valid for the new type (a measure op on a category resets to equals).
            const op = f.op && validFilterOps(fieldType).includes(f.op) ? f.op : 'equal';
            return { ...f, field, fieldType, op };
          }),
        },
      };
    }),

  setFilterMode: (id, mode) =>
    set((s) => {
      const first = effCols(s).columns[0];
      return {
        config: {
          ...s.config,
          filters: (s.config.filters ?? []).map((f) => {
            if (f.id !== id) return f;
            // Switching to the predicate shelf without a field yet (e.g. the row was
            // born in expression mode) seeds the first column so it's usable at once.
            if (mode === 'predicate' && !f.field && first) {
              return {
                ...f,
                mode,
                field: first,
                fieldType: defaultFieldType(columnType(effCols(s), first)),
                op: f.op ?? 'equal',
              };
            }
            return { ...f, mode };
          }),
        },
      };
    }),

  removeFilter: (id) =>
    set((s) => ({
      config: { ...s.config, filters: (s.config.filters ?? []).filter((f) => f.id !== id) },
    })),

  addCalculate: () =>
    set((s) => ({
      config: {
        ...s.config,
        calculates: [
          ...(s.config.calculates ?? []),
          { id: nextTransformId('c'), expr: '', as: '' },
        ],
      },
    })),

  updateCalculate: (id, patch) =>
    set((s) => {
      const calculates = (s.config.calculates ?? []).map((c) =>
        c.id === id ? { ...c, ...patch } : c,
      );
      // A rename (changed `as`) can orphan a channel that mapped the old name; prune
      // any encoding whose field no longer exists among the effective columns.
      return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
    }),

  removeCalculate: (id) =>
    set((s) => {
      const calculates = (s.config.calculates ?? []).filter((c) => c.id !== id);
      return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
    }),

  swapXY: () =>
    set((s) => ({
      config: {
        ...s.config,
        encodings: {
          ...s.config.encodings,
          x: s.config.encodings.y ?? null,
          y: s.config.encodings.x ?? null,
        },
      },
    })),

  applyWarningFix: (fix) => set((s) => ({ config: fix.apply(s.config) })),

  setWidth: (width) => set((s) => ({ config: { ...s.config, width } })),
  setHeight: (height) => set((s) => ({ config: { ...s.config, height } })),

  // The raw input value is kept (mid-edit spaces and all) so typing feels normal;
  // the assembler trims and skips a blank/whitespace-only title at emission.
  setTitle: (raw) => set((s) => ({ config: { ...s.config, title: raw || undefined } })),
  setSubtitle: (raw) => set((s) => ({ config: { ...s.config, subtitle: raw || undefined } })),

  createSnippet: (now) => {
    const { config } = get();
    if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too

    const name = generateChartName(config);
    const specText = buildSnippetSpecText(config);
    // createSnippet mirrors datasetRefs from the spec, so the new snippet is linked
    // to its dataset (§09F) without extra wiring. Provenance kept in meta (§06).
    useSnippetStore.getState().createSnippet({
      name,
      // Generated, not chosen: stays in the auto naming tier, so publish keeps
      // the name tracking the spec until the user renames (spec §02 → Naming).
      nameSource: 'auto',
      spec: specText,
      now,
      meta: { createdWith: 'chart-builder', builtFromDataset: config.datasetName },
    });

    // No success toast: the new snippet immediately becomes active and opens in the
    // editor, so the result is visible — toasting it would be noise (contract 10 §1,
    // "toast only what the user can't already see"). Closing the modal is the
    // component's choreography (it owns the modal lifecycle; the store stays
    // coordinator-free) — it closes on this returning true.
    get().reset();
    return true;
  },

  reset: () =>
    set({
      datasetId: null,
      columns: EMPTY_COLUMNS,
      rowCount: null,
      config: EMPTY_CONFIG,
      initialConfig: EMPTY_CONFIG,
      activeChannel: null,
      editingSnippetId: null,
      editingSnippetName: null,
    }),
}));

/**
 * Selector: whether the config can be saved (≥1 channel mapped, spec §06 →
 * Validation). Returns a boolean (stable under Object.is), so it is safe to
 * subscribe to directly. Non-blocking *guidance* (`builderWarnings`) deliberately
 * has NO selector here — it builds a fresh array of objects each call, which no
 * subscription equality can stabilize; the component derives it via `useMemo` over
 * the stable `config` reference instead (see ChartBuilderModal).
 */
export const selectBuilderValid = (s: ChartBuilderState) => isBuilderConfigValid(s.config);

/** Selector: the built spec as JSON text, for the live preview. */
export const selectBuilderSpecText = (s: ChartBuilderState) => buildSnippetSpecText(s.config);
