import { describe, it, expect } from 'vitest';
import {
  MARK_TYPES,
  defaultFieldType,
  validFieldTypes,
  defaultMark,
  isChannelTypeAllowed,
  isColumnAllowedOnChannel,
  supportsAggregate,
  validAggregateOps,
  supportsBin,
  supportsTimeUnit,
  supportsSort,
  supportsStack,
  sortableCategoryChannel,
  builderWarnings,
  defaultBuilderConfig,
  isBuilderConfigValid,
  isValueMapping,
  channelAcceptsValue,
  defaultChannelValue,
  coerceChannelValue,
  CHART_INTENTS,
  intentApplicable,
  intentLayout,
  applyIntent,
  activeIntent,
  buildChartSpec,
  buildSnippetSpecText,
  parseChartSpec,
  parseChartSpecText,
  generateChartName,
  validFilterOps,
  filterOpArity,
  buildTransforms,
  calculatedFieldNames,
  effectiveColumns,
  pruneEncodings,
  rebaseBuilderConfig,
  type BuilderCalculate,
  type BuilderColumns,
  type BuilderConfig,
  type BuilderFilter,
  type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';

const columns: BuilderColumns = {
  columns: ['category', 'value', 'when', 'flag'],
  columnTypes: [
    { name: 'category', type: 'string' },
    { name: 'value', type: 'number' },
    { name: 'when', type: 'date' },
    { name: 'flag', type: 'boolean' },
  ],
};

describe('defaultFieldType', () => {
  it('maps inferred column types to Vega-Lite field types (spec §06)', () => {
    expect(defaultFieldType('number')).toBe('quantitative');
    expect(defaultFieldType('date')).toBe('temporal');
    expect(defaultFieldType('string')).toBe('nominal');
    expect(defaultFieldType('boolean')).toBe('nominal');
  });

  it('is always the head of validFieldTypes (no drift)', () => {
    for (const t of ['number', 'date', 'string', 'boolean'] as const) {
      expect(defaultFieldType(t)).toBe(validFieldTypes(t)[0]);
    }
  });
});

describe('validFieldTypes (Tier B valid-type locking)', () => {
  it('never offers Quantitative for string/boolean, nor Temporal for non-date', () => {
    expect(validFieldTypes('string')).not.toContain('quantitative');
    expect(validFieldTypes('boolean')).not.toContain('quantitative');
    expect(validFieldTypes('number')).not.toContain('temporal');
    expect(validFieldTypes('string')).not.toContain('temporal');
  });

  it('locks date to Temporal only and offers Ordinal where order is plausible', () => {
    expect(validFieldTypes('date')).toEqual(['temporal']);
    expect(validFieldTypes('number')).toContain('ordinal');
    expect(validFieldTypes('string')).toContain('ordinal');
  });
});

describe('defaultMark (Tier B smart default)', () => {
  it('picks Line for time × measure, Point for two measures, Bar for category × measure', () => {
    expect(defaultMark('temporal', 'quantitative')).toBe('line');
    expect(defaultMark('quantitative', 'temporal')).toBe('line');
    expect(defaultMark('quantitative', 'quantitative')).toBe('point');
    expect(defaultMark('nominal', 'quantitative')).toBe('bar');
    expect(defaultMark('quantitative', 'nominal')).toBe('bar');
  });

  it('uses Point when both axes are discrete (Bar/Line/Area need a continuous axis)', () => {
    expect(defaultMark('nominal', 'nominal')).toBe('point');
    expect(defaultMark('nominal', 'ordinal')).toBe('point');
  });

  it('falls back to Bar when an axis is unmapped', () => {
    expect(defaultMark('quantitative', null)).toBe('bar');
    expect(defaultMark(null, null)).toBe('bar');
  });
});

describe('isChannelTypeAllowed (Size discipline)', () => {
  it('forbids Size for Nominal and Temporal, allows it for Quantitative/Ordinal', () => {
    expect(isChannelTypeAllowed('size', 'nominal')).toBe(false);
    expect(isChannelTypeAllowed('size', 'temporal')).toBe(false);
    expect(isChannelTypeAllowed('size', 'quantitative')).toBe(true);
    expect(isChannelTypeAllowed('size', 'ordinal')).toBe(true);
  });

  it('allows any type on X/Y/Color', () => {
    for (const ch of ['x', 'y', 'color'] as const) {
      expect(isChannelTypeAllowed(ch, 'nominal')).toBe(true);
      expect(isChannelTypeAllowed(ch, 'temporal')).toBe(true);
    }
  });
});

describe('isColumnAllowedOnChannel (field-shelf placement by default type)', () => {
  it('lets Size take only a numeric column (its default type is the magnitude)', () => {
    expect(isColumnAllowedOnChannel('size', 'number')).toBe(true);
    expect(isColumnAllowedOnChannel('size', 'string')).toBe(false);
    expect(isColumnAllowedOnChannel('size', 'date')).toBe(false);
    expect(isColumnAllowedOnChannel('size', 'boolean')).toBe(false);
  });

  it('lets X/Y/Color take any column type', () => {
    for (const ch of ['x', 'y', 'color'] as const) {
      for (const t of ['number', 'string', 'date', 'boolean'] as const) {
        expect(isColumnAllowedOnChannel(ch, t)).toBe(true);
      }
    }
  });
});

describe('builderWarnings (Tier B advisories)', () => {
  it('warns when a line/area mark is missing an axis', () => {
    const w = builderWarnings({
      datasetName: 'D',
      mark: 'line',
      encodings: { x: { field: 'a', type: 'temporal' } },
    });
    expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
  });

  it('warns when two measures are drawn on a non-scatter mark, offering [Switch to Point]', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'a', type: 'quantitative' },
        y: { field: 'b', type: 'quantitative' },
      },
    };
    const w = builderWarnings(config);
    const hint = w.find((m) => /scatter/.test(m.message));
    const fix = hint?.fixes?.find((f) => f.label === 'Switch to Point');
    expect(fix).toBeDefined();
    expect(fix!.apply(config).mark).toBe('point');
  });

  it('warns when a bar/line/area has no measure on either axis', () => {
    const w = builderWarnings({
      datasetName: 'D',
      mark: 'bar',
      encodings: { x: { field: 'a', type: 'nominal' }, y: { field: 'b', type: 'nominal' } },
    });
    expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
  });

  it('warns when an area chart is split into colour series, offering [Stack] / [Remove colour]', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'area',
      encodings: {
        x: { field: 't', type: 'temporal' },
        y: { field: 'v', type: 'quantitative' },
        color: { field: 'g', type: 'nominal' },
      },
    };
    const w = builderWarnings(config);
    const hint = w.find((m) => m.channel === 'color');
    expect(hint).toBeDefined();
    const labels = hint?.fixes?.map((f) => f.label) ?? [];
    expect(labels).toEqual(['Stack', 'Remove colour']); // most-recommended first
    // [Remove colour] clears the colour channel, so the hint re-derives away.
    const cleared = hint!.fixes!.find((f) => f.label === 'Remove colour')!.apply(config);
    expect(cleared.encodings.color).toBeNull();
    expect(builderWarnings(cleared).some((m) => m.channel === 'color')).toBe(false);
    // [Stack] turns it into a part-to-whole stack, which is no longer flagged.
    const stacked = hint!.fixes!.find((f) => f.label === 'Stack')!.apply(config);
    expect(stacked.stack).toBe('zero');
    expect(builderWarnings(stacked).some((m) => m.channel === 'color')).toBe(false);
  });

  it('is silent for a clean configuration', () => {
    const w = builderWarnings({
      datasetName: 'D',
      mark: 'line',
      encodings: { x: { field: 't', type: 'temporal' }, y: { field: 'v', type: 'quantitative' } },
    });
    expect(w).toEqual([]);
  });

  describe('heatmap (rect mark)', () => {
    it('warns when a rect mark is missing an axis (a heatmap is an X×Y grid)', () => {
      const w = builderWarnings({
        datasetName: 'D',
        mark: 'rect',
        encodings: { x: { field: 'a', type: 'nominal' } },
      });
      expect(w.some((m) => /Heatmaps need both an X and a Y/.test(m.message))).toBe(true);
    });

    it('nudges a colour measure when both axes are mapped, offering [Colour by count]', () => {
      const config: BuilderConfig = {
        datasetName: 'D',
        mark: 'rect',
        encodings: {
          x: { field: 'region', type: 'nominal' },
          y: { field: 'segment', type: 'nominal' },
        },
      };
      const w = builderWarnings(config);
      const hint = w.find((m) => m.channel === 'color');
      expect(hint?.message).toMatch(/map a measure \(or Count\) to Colour/);
      const fixed = hint!.fixes!.find((f) => f.label === 'Colour by count')!.apply(config);
      expect(fixed.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
      // With a count on Colour it is a complete heatmap — the hint re-derives away.
      expect(builderWarnings(fixed).some((m) => m.channel === 'color')).toBe(false);
    });

    it('does NOT push two-measures-to-scatter on a rect (a binned 2-D histogram is valid)', () => {
      const w = builderWarnings({
        datasetName: 'D',
        mark: 'rect',
        encodings: {
          x: { field: 'a', type: 'quantitative', bin: true },
          y: { field: 'b', type: 'quantitative', bin: true },
          color: { type: 'quantitative', aggregate: 'count' },
        },
      });
      expect(w.some((m) => /scatter/.test(m.message))).toBe(false);
      expect(w).toEqual([]); // a binned 2-D histogram with a count colour is clean
    });

    it('reads a colour-measure heatmap as a clean configuration', () => {
      const w = builderWarnings({
        datasetName: 'D',
        mark: 'rect',
        encodings: {
          x: { field: 'region', type: 'nominal' },
          y: { field: 'segment', type: 'nominal' },
          color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
        },
      });
      expect(w).toEqual([]);
    });
  });

  describe('crowded category axis (one mark per row)', () => {
    const crowded = (overrides: Partial<ChannelMapping> = {}) =>
      builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'name', type: 'nominal' },
            y: { field: 'mpg', type: 'quantitative', ...overrides },
          },
        },
        406,
      );

    it('warns when a raw measure draws one bar per row over a large dataset', () => {
      const w = crowded();
      const hint = w.find((m) => /one mark per row/.test(m.message));
      expect(hint?.channel).toBe('x'); // the category axis
      expect(hint?.message).toContain('406 in this dataset');
      // Remedies are one-click fixes, not prose; most-recommended first.
      const labels = hint?.fixes?.map((f) => f.label) ?? [];
      expect(labels).toEqual(['Aggregate as Sum', 'Swap X/Y']); // aggregate before swap
    });

    it('[Aggregate as Sum] resolves the one-mark-per-row hint', () => {
      const w = crowded();
      const hint = w.find((m) => /one mark per row/.test(m.message));
      const fix = hint?.fixes?.find((f) => f.label === 'Aggregate as Sum');
      expect(fix).toBeDefined();
      const fixed = fix!.apply({
        datasetName: 'D',
        mark: 'bar',
        encodings: {
          x: { field: 'name', type: 'nominal' },
          y: { field: 'mpg', type: 'quantitative' },
        },
      });
      expect(fixed.encodings.y?.aggregate).toBe('sum');
      // and the hint is gone once applied
      expect(builderWarnings(fixed, 406).some((m) => /one mark per row/.test(m.message))).toBe(
        false,
      );
    });

    it('is silent once the measure is aggregated (one bar per category)', () => {
      const w = crowded({ aggregate: 'mean' });
      expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
    });

    it('is silent for a small dataset even with a raw measure', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'name', type: 'nominal' },
            y: { field: 'mpg', type: 'quantitative' },
          },
        },
        12,
      );
      expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
    });

    it('is silent when the row count is unknown (URL/non-tabular)', () => {
      const w = builderWarnings({
        datasetName: 'D',
        mark: 'bar',
        encodings: {
          x: { field: 'name', type: 'nominal' },
          y: { field: 'mpg', type: 'quantitative' },
        },
      });
      expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
    });

    it('uses non-bar wording (no Swap X/Y) for a line mark', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'line',
          encodings: {
            x: { field: 'name', type: 'nominal' },
            y: { field: 'mpg', type: 'quantitative' },
          },
        },
        406,
      );
      const hint = w.find((m) => /one mark per row/.test(m.message));
      expect(hint).toBeDefined();
      const labels = hint?.fixes?.map((f) => f.label) ?? [];
      expect(labels).not.toContain('Swap X/Y'); // a horizontal line makes no sense
      expect(labels).toContain('Aggregate as Sum'); // aggregate still applies
    });
  });

  describe('data-aware hints (from profiled column stats)', () => {
    /** A BuilderColumns carrying stats for the named field. */
    const withStats = (
      name: string,
      stats: {
        distinct?: number;
        distinctCapped?: boolean;
        numericExtent?: { min: number; max: number } | null;
      },
    ): BuilderColumns => ({
      columns: [name],
      columnTypes: [{ name, type: 'string' }],
      columnStats: [
        {
          name,
          distinct: stats.distinct ?? 1,
          distinctCapped: stats.distinctCapped ?? false,
          numericExtent: stats.numericExtent ?? null,
        },
      ],
    });

    it('flags an aggregated category axis that still has too many distinct values', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'sku', type: 'nominal' },
            y: { field: 'qty', type: 'quantitative', aggregate: 'sum' }, // aggregated, so not one-per-row
          },
        },
        500,
        withStats('sku', { distinct: 48 }),
      );
      const hint = w.find((m) => /distinct values/.test(m.message));
      expect(hint?.channel).toBe('x');
      expect(hint?.message).toMatch(/48 distinct values/);
      expect(hint?.fixes?.map((f) => f.label)).toContain('Swap X/Y'); // horizontal-bar remedy
    });

    it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'sku', type: 'nominal' },
            y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
          },
        },
        500,
        withStats('sku', { distinct: 50, distinctCapped: true }),
      );
      expect(w.some((m) => /more than 50 distinct values/.test(m.message))).toBe(true);
    });

    it('is silent for a small-cardinality aggregated category axis', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'sku', type: 'nominal' },
            y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
          },
        },
        500,
        withStats('sku', { distinct: 6 }),
      );
      expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
    });

    it('warns when a discrete colour series has too many categories', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'sku', type: 'nominal' },
            y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
            color: { field: 'tag', type: 'nominal' },
          },
        },
        500,
        {
          columns: ['tag'],
          columnTypes: [{ name: 'tag', type: 'string' }],
          columnStats: [{ name: 'tag', distinct: 20, distinctCapped: false, numericExtent: null }],
        },
      );
      const hint = w.find((m) => m.channel === 'color' && /legend/.test(m.message));
      expect(hint?.message).toMatch(/20 categories/);
    });

    it('does not flag a continuous (quantitative) colour ramp for cardinality', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'point',
          encodings: {
            x: { field: 'a', type: 'quantitative' },
            y: { field: 'b', type: 'quantitative' },
            color: { field: 'score', type: 'quantitative' }, // a ramp, no per-value legend
          },
        },
        500,
        {
          columns: ['score'],
          columnTypes: [{ name: 'score', type: 'number' }],
          columnStats: [
            {
              name: 'score',
              distinct: 50,
              distinctCapped: true,
              numericExtent: { min: 0, max: 9 },
            },
          ],
        },
      );
      expect(w.some((m) => /legend/.test(m.message))).toBe(false);
    });

    it('guards Size against a field whose values go negative', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'point',
          encodings: {
            x: { field: 'a', type: 'quantitative' },
            y: { field: 'b', type: 'quantitative' },
            size: { field: 'delta', type: 'quantitative' },
          },
        },
        500,
        {
          columns: ['delta'],
          columnTypes: [{ name: 'delta', type: 'number' }],
          columnStats: [
            {
              name: 'delta',
              distinct: 30,
              distinctCapped: false,
              numericExtent: { min: -12, max: 40 },
            },
          ],
        },
      );
      const hint = w.find((m) => m.channel === 'size');
      expect(hint?.message).toMatch(/can't show negative values/);
      expect(hint?.message).toMatch(/down to -12/);
    });

    it('does not guard Size when the field is wholly non-negative', () => {
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'point',
          encodings: {
            x: { field: 'a', type: 'quantitative' },
            y: { field: 'b', type: 'quantitative' },
            size: { field: 'amount', type: 'quantitative' },
          },
        },
        500,
        {
          columns: ['amount'],
          columnTypes: [{ name: 'amount', type: 'number' }],
          columnStats: [
            {
              name: 'amount',
              distinct: 30,
              distinctCapped: false,
              numericExtent: { min: 0, max: 40 },
            },
          ],
        },
      );
      expect(w.some((m) => m.channel === 'size')).toBe(false);
    });

    it('skips data-aware hints entirely when no column stats are supplied', () => {
      // Old/URL datasets: same crowded config, but without stats the hints stay quiet.
      const w = builderWarnings(
        {
          datasetName: 'D',
          mark: 'bar',
          encodings: {
            x: { field: 'sku', type: 'nominal' },
            y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
            size: { field: 'qty', type: 'quantitative' },
          },
        },
        500,
      );
      expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
      expect(w.some((m) => m.channel === 'size')).toBe(false);
    });
  });
});

describe('defaultBuilderConfig', () => {
  it('falls back to first-on-X, second-on-Y when the dataset is unprofiled', () => {
    // `columns` carries no columnStats → no data-aware pick → positional default.
    const config = defaultBuilderConfig('Sales', columns);
    expect(config.mark).toBe('bar');
    expect(config.datasetName).toBe('Sales');
    expect(config.encodings.x).toEqual({ field: 'category', type: 'nominal' });
    expect(config.encodings.y).toEqual({ field: 'value', type: 'quantitative' });
    expect(config.encodings.color).toBeNull();
    expect(config.encodings.size).toBeNull();
  });

  it('opens as a Line when the first two columns are date × number (smart mark)', () => {
    const timeSeries: BuilderColumns = {
      columns: ['day', 'visits'],
      columnTypes: [
        { name: 'day', type: 'date' },
        { name: 'visits', type: 'number' },
      ],
    };
    const config = defaultBuilderConfig('Traffic', timeSeries);
    expect(config.mark).toBe('line');
    expect(config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
    expect(config.encodings.y).toEqual({ field: 'visits', type: 'quantitative' });
  });

  it('leaves Y unmapped when the dataset has a single column', () => {
    const single: BuilderColumns = {
      columns: ['only'],
      columnTypes: [{ name: 'only', type: 'number' }],
    };
    const config = defaultBuilderConfig('One', single);
    expect(config.encodings.x).toEqual({ field: 'only', type: 'quantitative' });
    expect(config.encodings.y).toBeNull();
  });

  it('maps nothing when the dataset has no detected columns', () => {
    const config = defaultBuilderConfig('Empty', { columns: [], columnTypes: [] });
    expect(isBuilderConfigValid(config)).toBe(false);
    expect(config.encodings.x).toBeNull();
  });
});

describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)', () => {
  /** Stats for one column. */
  const stat = (name: string, distinct: number, capped = false) => ({
    name,
    distinct,
    distinctCapped: capped,
    numericExtent: null,
  });

  it('opens on a low-cardinality category vs a count of records, not the first two columns', () => {
    // Superstore-shaped: an id-like number first, a high-cardinality id, then tidy
    // categories — the case a positional first-two-columns default would open as a
    // 9994-bar degenerate chart.
    const wide: BuilderColumns = {
      columns: ['Row ID', 'Order ID', 'Segment', 'Sales'],
      columnTypes: [
        { name: 'Row ID', type: 'number' },
        { name: 'Order ID', type: 'string' },
        { name: 'Segment', type: 'string' },
        { name: 'Sales', type: 'number' },
      ],
      columnStats: [
        stat('Row ID', 50, true),
        stat('Order ID', 50, true), // high cardinality → not a category axis
        stat('Segment', 3), // tidy category → the pick
        stat('Sales', 50, true),
      ],
    };
    const config = defaultBuilderConfig('Superstore', wide);
    expect(config.mark).toBe('bar');
    expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' });
    expect(config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
  });

  it('picks the lowest-cardinality readable category among several', () => {
    const cols: BuilderColumns = {
      columns: ['Region', 'Segment', 'City'],
      columnTypes: [
        { name: 'Region', type: 'string' },
        { name: 'Segment', type: 'string' },
        { name: 'City', type: 'string' },
      ],
      columnStats: [stat('Region', 4), stat('Segment', 3), stat('City', 50, true)],
    };
    const config = defaultBuilderConfig('D', cols);
    expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' }); // 3 < 4
  });

  it('falls through to a time series (date vs count) when no tidy category exists', () => {
    const cols: BuilderColumns = {
      columns: ['Order ID', 'Order Date', 'Sales'],
      columnTypes: [
        { name: 'Order ID', type: 'string' },
        { name: 'Order Date', type: 'date' },
        { name: 'Sales', type: 'number' },
      ],
      columnStats: [
        stat('Order ID', 50, true),
        stat('Order Date', 50, true),
        stat('Sales', 50, true),
      ],
    };
    const config = defaultBuilderConfig('D', cols);
    expect(config.mark).toBe('line');
    expect(config.encodings.x).toEqual({ field: 'Order Date', type: 'temporal' });
    expect(config.encodings.y).toEqual({ field: 'Sales', type: 'quantitative' }); // the measure, raw
  });

  it('falls through to a scatter of two measures when there is no category or date', () => {
    const cols: BuilderColumns = {
      columns: ['Order ID', 'Sales', 'Profit'],
      columnTypes: [
        { name: 'Order ID', type: 'string' },
        { name: 'Sales', type: 'number' },
        { name: 'Profit', type: 'number' },
      ],
      columnStats: [stat('Order ID', 50, true), stat('Sales', 50, true), stat('Profit', 50, true)],
    };
    const config = defaultBuilderConfig('D', cols);
    expect(config.mark).toBe('point');
    expect(config.encodings.x).toEqual({ field: 'Sales', type: 'quantitative' });
    expect(config.encodings.y).toEqual({ field: 'Profit', type: 'quantitative' });
  });
});

describe('intent-first front door', () => {
  const stat = (name: string, distinct: number, capped = false) => ({
    name,
    distinct,
    distinctCapped: capped,
    numericExtent: null,
  });
  // Two categories (Segment 3 < Region 4), one date, two measures → every intent applies.
  const superstore: BuilderColumns = {
    columns: ['Segment', 'Region', 'Order Date', 'Sales', 'Profit'],
    columnTypes: [
      { name: 'Segment', type: 'string' },
      { name: 'Region', type: 'string' },
      { name: 'Order Date', type: 'date' },
      { name: 'Sales', type: 'number' },
      { name: 'Profit', type: 'number' },
    ],
    columnStats: [
      stat('Segment', 3),
      stat('Region', 4),
      stat('Order Date', 50, true),
      stat('Sales', 50, true),
      stat('Profit', 50, true),
    ],
  };
  const count = { type: 'quantitative', aggregate: 'count' };

  describe('intentApplicable (Tableau "Show Me" gating)', () => {
    it('enables every intent on a category+date+measure dataset', () => {
      for (const intent of CHART_INTENTS) {
        expect(intentApplicable(intent, superstore)).toBe(true);
      }
    });

    it('disables intents whose required column roles are missing', () => {
      const oneMeasureOneCat: BuilderColumns = {
        columns: ['Segment', 'Sales'],
        columnTypes: [
          { name: 'Segment', type: 'string' },
          { name: 'Sales', type: 'number' },
        ],
        columnStats: [stat('Segment', 3), stat('Sales', 50, true)],
      };
      expect(intentApplicable('correlation', oneMeasureOneCat)).toBe(false); // needs 2 measures
      expect(intentApplicable('time', oneMeasureOneCat)).toBe(false); // needs a date
      expect(intentApplicable('heatmap', oneMeasureOneCat)).toBe(false); // needs 2 categories
      expect(intentApplicable('partToWhole', oneMeasureOneCat)).toBe(false);
      expect(intentApplicable('compare', oneMeasureOneCat)).toBe(true);
      expect(intentApplicable('distribution', oneMeasureOneCat)).toBe(true);
    });
  });

  describe('intentLayout (intent → mark + channels)', () => {
    it('maps each intent to its recommended layout', () => {
      expect(intentLayout('compare', superstore)).toEqual({
        mark: 'bar',
        encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
      });
      expect(intentLayout('ranking', superstore)).toEqual({
        mark: 'bar',
        sort: 'descending',
        encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
      });
      expect(intentLayout('time', superstore)).toEqual({
        mark: 'line',
        encodings: {
          x: { field: 'Order Date', type: 'temporal' },
          y: { field: 'Sales', type: 'quantitative' },
        },
      });
      expect(intentLayout('correlation', superstore)).toEqual({
        mark: 'point',
        encodings: {
          x: { field: 'Sales', type: 'quantitative' },
          y: { field: 'Profit', type: 'quantitative' },
        },
      });
      expect(intentLayout('distribution', superstore)).toEqual({
        mark: 'bar',
        encodings: { x: { field: 'Sales', type: 'quantitative', bin: true }, y: count },
      });
      expect(intentLayout('partToWhole', superstore)).toEqual({
        mark: 'bar',
        stack: 'zero',
        encodings: {
          x: { field: 'Segment', type: 'nominal' },
          y: count,
          color: { field: 'Region', type: 'nominal' },
        },
      });
      expect(intentLayout('heatmap', superstore)).toEqual({
        mark: 'rect',
        encodings: {
          x: { field: 'Segment', type: 'nominal' },
          y: { field: 'Region', type: 'nominal' },
          color: count,
        },
      });
    });
  });

  describe('applyIntent', () => {
    it('reshapes mark/encodings/sort/stack but keeps dataset, transforms, and title', () => {
      const start: BuilderConfig = {
        datasetName: 'Superstore',
        mark: 'point',
        encodings: { x: { field: 'Sales', type: 'quantitative' } },
        title: 'My chart',
        calculates: [{ id: 'c1', expr: 'datum.Sales * 2', as: 'double' }],
        filters: [{ id: 'f1', mode: 'expression', expr: 'datum.Sales > 0' }],
      };
      const out = applyIntent(start, 'heatmap', superstore);
      expect(out.mark).toBe('rect');
      expect(out.encodings).toEqual({
        x: { field: 'Segment', type: 'nominal' },
        y: { field: 'Region', type: 'nominal' },
        color: count,
        size: null,
      });
      expect(out.datasetName).toBe('Superstore');
      expect(out.title).toBe('My chart');
      expect(out.calculates).toBe(start.calculates);
      expect(out.filters).toBe(start.filters);
    });

    it('clears a stale channel and sort/stack when switching intents', () => {
      const heat = applyIntent(
        { datasetName: 'D', mark: 'bar', encodings: {} },
        'heatmap',
        superstore,
      );
      expect(heat.encodings.color).toEqual(count);
      const ranked = applyIntent(heat, 'ranking', superstore);
      expect(ranked.encodings.color).toBeNull(); // heatmap's colour is dropped
      expect(ranked.sort).toBe('descending');
      const compared = applyIntent(ranked, 'compare', superstore);
      expect(compared.sort).toBeUndefined(); // ranking's sort is dropped
    });
  });

  describe('activeIntent (which chip the live chart lights)', () => {
    it('lights the smart default on open (the coupling guard)', () => {
      // The data-aware default IS an intent layout, so the strip auto-highlights it.
      expect(activeIntent(defaultBuilderConfig('D', superstore), superstore)).toBe('compare');

      const dateShape: BuilderColumns = {
        columns: ['Order ID', 'Order Date', 'Sales'],
        columnTypes: [
          { name: 'Order ID', type: 'string' },
          { name: 'Order Date', type: 'date' },
          { name: 'Sales', type: 'number' },
        ],
        columnStats: [
          stat('Order ID', 50, true),
          stat('Order Date', 50, true),
          stat('Sales', 50, true),
        ],
      };
      expect(activeIntent(defaultBuilderConfig('D', dateShape), dateShape)).toBe('time');

      const scatterShape: BuilderColumns = {
        columns: ['Order ID', 'Sales', 'Profit'],
        columnTypes: [
          { name: 'Order ID', type: 'string' },
          { name: 'Sales', type: 'number' },
          { name: 'Profit', type: 'number' },
        ],
        columnStats: [
          stat('Order ID', 50, true),
          stat('Sales', 50, true),
          stat('Profit', 50, true),
        ],
      };
      expect(activeIntent(defaultBuilderConfig('D', scatterShape), scatterShape)).toBe(
        'correlation',
      );
    });

    it('reflects the picked intent, then reads Custom after a hand edit', () => {
      const base = defaultBuilderConfig('D', superstore);
      expect(activeIntent(applyIntent(base, 'heatmap', superstore), superstore)).toBe('heatmap');
      // An edit that no intent produces (an area mark) → no chip lit.
      const edited: BuilderConfig = { ...base, mark: 'area' };
      expect(activeIntent(edited, superstore)).toBeNull();
    });
  });
});

describe('isBuilderConfigValid', () => {
  const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };

  it('requires at least one mapped channel', () => {
    expect(isBuilderConfigValid(base)).toBe(false);
    expect(isBuilderConfigValid({ ...base, encodings: { x: null, y: null } })).toBe(false);
    expect(
      isBuilderConfigValid({ ...base, encodings: { color: { field: 'c', type: 'nominal' } } }),
    ).toBe(true);
  });
});

describe('buildChartSpec', () => {
  it('assembles schema, named data, tooltip mark, and mapped encodings', () => {
    const config = defaultBuilderConfig('Sales', columns);
    const spec = buildChartSpec(config);
    expect(spec).toEqual({
      $schema: VEGA_LITE_SCHEMA_URL,
      data: { name: 'Sales' },
      mark: { type: 'bar', tooltip: true },
      encoding: {
        x: { field: 'category', type: 'nominal' },
        y: { field: 'value', type: 'quantitative' },
      },
    });
  });

  it('omits unmapped channels and preserves canonical channel order', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'point',
      encodings: {
        size: { field: 's', type: 'quantitative' },
        x: { field: 'a', type: 'nominal' },
        color: null,
      },
    };
    const spec = buildChartSpec(config);
    expect(Object.keys(spec.encoding as object)).toEqual(['x', 'size']);
  });

  it('omits the encoding block entirely when nothing is mapped', () => {
    const spec = buildChartSpec({ datasetName: 'D', mark: 'bar', encodings: {} });
    expect(spec.encoding).toBeUndefined();
    expect(spec.mark).toEqual({ type: 'bar', tooltip: true });
  });

  it('writes explicit width/height only when provided', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'area',
      encodings: { x: { field: 'a', type: 'temporal' } },
      width: 400,
      height: 300,
    };
    const spec = buildChartSpec(config);
    expect(spec.width).toBe(400);
    expect(spec.height).toBe(300);

    const noDims = buildChartSpec({ ...config, width: undefined, height: undefined });
    expect(noDims.width).toBeUndefined();
    expect(noDims.height).toBeUndefined();
  });

  it('carries every mark type through to the spec', () => {
    for (const mark of MARK_TYPES) {
      const spec = buildChartSpec({
        datasetName: 'D',
        mark,
        encodings: { x: { field: 'a', type: 'nominal' } },
      });
      expect((spec.mark as { type: string }).type).toBe(mark);
    }
  });

  it('escapes `.`/`[`/`]` in encoded field names so they read as literal columns', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'point',
      encodings: {
        x: { field: 'user.age', type: 'quantitative' },
        y: { field: 'cols[0]', type: 'quantitative' },
      },
    });
    const enc = spec.encoding as Record<string, { field: string }>;
    expect(enc.x.field).toBe('user\\.age');
    expect(enc.y.field).toBe('cols\\[0\\]');
  });
});

describe('value channels (constant colour / size — the Property model)', () => {
  it('emits `{ value }` and ignores field/type/transforms when a constant is set', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'point',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        // A constant carries a (preserved-but-ignored) type and even a stale field;
        // the assembler still emits only `{ value }`.
        color: { value: '#c0392b', type: 'nominal', field: 'category', aggregate: 'sum' },
        size: { value: 100, type: 'quantitative' },
      },
    });
    const enc = spec.encoding as Record<string, Record<string, unknown>>;
    expect(enc.color).toEqual({ value: '#c0392b' });
    expect(enc.size).toEqual({ value: 100 });
  });

  it('counts a constant channel as mapped (saveable) and preserves channel order', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'point',
      encodings: { color: { value: 'steelblue', type: 'nominal' } },
    };
    expect(isBuilderConfigValid(config)).toBe(true);
    expect(Object.keys(buildChartSpec(config).encoding as object)).toEqual(['color']);
  });

  it('a constant Colour is not a series: no stacking, no area-split hint', () => {
    const base: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        y: { field: 'value', type: 'quantitative' },
        color: { value: '#4c78a8', type: 'nominal' },
      },
    };
    // A field-bound Colour would enable stacking and (on area) flag the split hint;
    // a constant Colour produces neither — it draws every mark one fixed colour.
    expect(supportsStack(base)).toBe(false);
    const areaWarnings = builderWarnings({ ...base, mark: 'area' });
    expect(areaWarnings.some((w) => w.message.includes('per-series change'))).toBe(false);

    // Sanity: the same config with a *field* Colour does enable stacking.
    const seriesColor: BuilderConfig = {
      ...base,
      encodings: { ...base.encodings, color: { field: 'category', type: 'nominal' } },
    };
    expect(supportsStack(seriesColor)).toBe(true);
  });

  it('describes the Property-model helpers', () => {
    expect(isValueMapping({ value: 10, type: 'quantitative' })).toBe(true);
    expect(isValueMapping({ field: 'value', type: 'quantitative' })).toBe(false);

    expect(channelAcceptsValue('color')).toBe(true);
    expect(channelAcceptsValue('size')).toBe(true);
    expect(channelAcceptsValue('x')).toBe(false);
    expect(channelAcceptsValue('y')).toBe(false);

    expect(typeof defaultChannelValue('color')).toBe('string');
    expect(defaultChannelValue('size')).toBe(100);
  });

  it('coerces a constant value by channel (size → number, others → string)', () => {
    expect(coerceChannelValue('size', '40')).toBe(40);
    expect(coerceChannelValue('size', '')).toBe(''); // blank stays raw, not NaN
    expect(coerceChannelValue('size', 'big')).toBe('big'); // non-numeric stays raw
    expect(coerceChannelValue('color', '#ff0000')).toBe('#ff0000');
    expect(coerceChannelValue('color', 'red')).toBe('red');
  });
});

describe('transforms — aggregate / bin / timeUnit', () => {
  it('emits a field-less count encoding', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    });
    const enc = spec.encoding as Record<string, Record<string, unknown>>;
    expect(enc.y).toEqual({ aggregate: 'count', type: 'quantitative' });
    expect(enc.y.field).toBeUndefined();
  });

  it('emits a non-count aggregate with its field', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
      },
    });
    const enc = spec.encoding as Record<string, Record<string, unknown>>;
    expect(enc.y).toEqual({ field: 'revenue', type: 'quantitative', aggregate: 'sum' });
  });

  it('emits bin on a quantitative field (histogram shape) and timeUnit on a temporal one', () => {
    const hist = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'price', type: 'quantitative', bin: true },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    });
    const henc = hist.encoding as Record<string, Record<string, unknown>>;
    expect(henc.x).toEqual({ field: 'price', type: 'quantitative', bin: true });

    const ts = buildChartSpec({
      datasetName: 'D',
      mark: 'line',
      encodings: {
        x: { field: 'day', type: 'temporal', timeUnit: 'yearmonth' },
        y: { field: 'v', type: 'quantitative' },
      },
    });
    const tenc = ts.encoding as Record<string, Record<string, unknown>>;
    expect(tenc.x).toEqual({ field: 'day', type: 'temporal', timeUnit: 'yearmonth' });
  });

  it('exposes the transform-applicability predicates by field type', () => {
    expect(supportsAggregate('quantitative')).toBe(true);
    // Every type now takes at least one aggregate (`distinct` applies to anything).
    expect(supportsAggregate('nominal')).toBe(true);
    expect(supportsBin('quantitative')).toBe(true);
    expect(supportsBin('temporal')).toBe(false);
    expect(supportsTimeUnit('temporal')).toBe(true);
    expect(supportsTimeUnit('quantitative')).toBe(false);
  });

  it('offers per-type aggregate menus: arithmetic needs numbers, min/max an ordering, distinct anything', () => {
    expect(validAggregateOps('quantitative')).toEqual([
      'sum',
      'mean',
      'median',
      'min',
      'max',
      'distinct',
    ]);
    expect(validAggregateOps('temporal')).toEqual(['min', 'max', 'distinct']);
    expect(validAggregateOps('ordinal')).toEqual(['min', 'max', 'distinct']);
    expect(validAggregateOps('nominal')).toEqual(['distinct']);
  });

  it('emits a distinct-count of a categorical field as a quantitative measure', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
      },
    });
    const enc = spec.encoding as Record<string, Record<string, unknown>>;
    // The carried (nominal) type is for round-tripping; the emitted type is the
    // effective one — a count of unique values reads as a quantitative measure.
    expect(enc.y).toEqual({ field: 'customer', type: 'quantitative', aggregate: 'distinct' });
  });

  it('keeps the field type on order-preserving aggregates (a temporal min is still temporal)', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'point',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { field: 'orderDate', type: 'temporal', aggregate: 'min' },
      },
    });
    const enc = spec.encoding as Record<string, Record<string, unknown>>;
    expect(enc.y).toEqual({ field: 'orderDate', type: 'temporal', aggregate: 'min' });
  });

  it('emits title as a bare string, the object form with a subtitle, nothing without a title', () => {
    const base: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: { x: { field: 'region', type: 'nominal' } },
    };
    expect(buildChartSpec({ ...base, title: 'Sales by region' }).title).toBe('Sales by region');
    expect(buildChartSpec({ ...base, title: 'Sales', subtitle: 'FY26' }).title).toEqual({
      text: 'Sales',
      subtitle: 'FY26',
    });
    // A subtitle alone is not emitted (VL has no standalone subtitle), nor is a
    // whitespace-only title.
    expect(buildChartSpec({ ...base, subtitle: 'orphan' }).title).toBeUndefined();
    expect(buildChartSpec({ ...base, title: '   ' }).title).toBeUndefined();
  });

  it('prefers a user-written title as the generated snippet name', () => {
    expect(
      generateChartName({
        datasetName: 'D',
        mark: 'bar',
        title: 'Quarterly revenue',
        encodings: { x: { field: 'region', type: 'nominal' } },
      }),
    ).toBe('Quarterly revenue');
  });

  it('names a distinct-count chart with a "unique" phrase', () => {
    expect(
      generateChartName({
        datasetName: 'D',
        mark: 'bar',
        encodings: {
          x: { field: 'region', type: 'nominal' },
          y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
        },
      }),
    ).toBe('Bar chart of unique customer by region');
  });

  it('names a heatmap "Heatmap of …" (not "Rect chart of …")', () => {
    expect(
      generateChartName({
        datasetName: 'D',
        mark: 'rect',
        encodings: {
          x: { field: 'region', type: 'nominal' },
          y: { field: 'segment', type: 'nominal' },
          color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
        },
      }),
    ).toBe('Heatmap of segment by region');
  });
});

describe('role-aware guidance (bin is a dimension, not a measure)', () => {
  // A 1-D histogram: bar, binned quantitative X, count Y. Both axes are quantitative by
  // *type*, but the binned X is a discretized dimension by *role* — the false-positive
  // class the role fix closes (eng-council 2026-06-13).
  const histogram: BuilderConfig = {
    datasetName: 'D',
    mark: 'bar',
    encodings: {
      x: { field: 'amount', type: 'quantitative', bin: true },
      y: { type: 'quantitative', aggregate: 'count' },
    },
  };

  it('does not nudge a histogram toward a scatter (the binned axis is a dimension)', () => {
    expect(builderWarnings(histogram).some((m) => /scatter/.test(m.message))).toBe(false);
    expect(builderWarnings(histogram)).toEqual([]); // a clean histogram has no hints
  });

  it('does not offer Sort on a histogram (binned bins carry an inherent order)', () => {
    expect(sortableCategoryChannel(histogram)).toBeUndefined();
    expect(supportsSort(histogram)).toBe(false);
  });

  it('still offers Sort on a real category-vs-measure bar', () => {
    const bar: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    };
    expect(sortableCategoryChannel(bar)).toBe('x');
    expect(supportsSort(bar)).toBe(true);
  });

  it('still nudges two RAW quantitative measures on a bar toward a scatter', () => {
    const twoRaw: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'sales', type: 'quantitative' },
        y: { field: 'profit', type: 'quantitative' },
      },
    };
    expect(builderWarnings(twoRaw).some((m) => /scatter/.test(m.message))).toBe(true);
  });
});

describe('sort (ranking)', () => {
  const ranking: BuilderConfig = {
    datasetName: 'D',
    mark: 'bar',
    encodings: {
      x: { field: 'region', type: 'nominal' },
      y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
    },
  };

  it('sorts the category axis by the measure axis (descending → "-y")', () => {
    expect(sortableCategoryChannel(ranking)).toBe('x');
    expect(supportsSort(ranking)).toBe(true);
    const enc = buildChartSpec({ ...ranking, sort: 'descending' }).encoding as Record<
      string,
      Record<string, unknown>
    >;
    expect(enc.x.sort).toBe('-y');
    const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record<
      string,
      Record<string, unknown>
    >;
    expect(asc.x.sort).toBe('y');
  });

  it('does not offer sort when both axes are measures', () => {
    const scatter: BuilderConfig = {
      datasetName: 'D',
      mark: 'point',
      encodings: {
        x: { field: 'a', type: 'quantitative' },
        y: { field: 'b', type: 'quantitative' },
      },
    };
    expect(supportsSort(scatter)).toBe(false);
    expect(buildChartSpec({ ...scatter, sort: 'descending' }).encoding).toBeDefined();
    const enc = buildChartSpec({ ...scatter, sort: 'descending' }).encoding as Record<
      string,
      Record<string, unknown>
    >;
    expect(enc.x.sort).toBeUndefined();
  });
});

describe('stack (part-to-whole)', () => {
  const stacked: BuilderConfig = {
    datasetName: 'D',
    mark: 'bar',
    encodings: {
      x: { field: 'month', type: 'ordinal' },
      y: { field: 'sales', type: 'quantitative', aggregate: 'sum' },
      color: { field: 'product', type: 'nominal' },
    },
  };

  it('stacks the quantitative axis for a bar/area + colour series', () => {
    expect(supportsStack(stacked)).toBe(true);
    const enc = buildChartSpec({ ...stacked, stack: 'normalize' }).encoding as Record<
      string,
      Record<string, unknown>
    >;
    expect(enc.y.stack).toBe('normalize');
  });

  it('does not stack without a colour series or on a point mark', () => {
    expect(supportsStack({ ...stacked, encodings: { ...stacked.encodings, color: null } })).toBe(
      false,
    );
    expect(supportsStack({ ...stacked, mark: 'point' })).toBe(false);
  });
});

describe('buildSnippetSpecText', () => {
  it('produces pretty-printed JSON that parses back to the spec', () => {
    const config = defaultBuilderConfig('Sales', columns);
    const text = buildSnippetSpecText(config);
    expect(text).toContain('\n  ');
    expect(JSON.parse(text)).toEqual(buildChartSpec(config));
  });
});

describe('generateChartName', () => {
  it('reads "<Mark> chart of <y> by <x>" when both axes are mapped', () => {
    const config = defaultBuilderConfig('Sales', columns);
    expect(generateChartName(config)).toBe('Bar chart of value by category');
  });

  it('names the single mapped field when only one channel is set', () => {
    const config: BuilderConfig = {
      datasetName: 'Sales',
      mark: 'line',
      encodings: { color: { field: 'region', type: 'nominal' } },
    };
    expect(generateChartName(config)).toBe('Line chart of region');
  });

  it('falls back to the dataset name when nothing is mapped', () => {
    const config: BuilderConfig = { datasetName: 'Sales', mark: 'circle', encodings: {} };
    expect(generateChartName(config)).toBe('Circle chart of Sales');
  });
});

// --- Data transforms: filters + calculated fields (spec §06 → Data) -------------

const filter = (over: Partial<BuilderFilter>): BuilderFilter => ({
  id: 'f1',
  mode: 'predicate',
  ...over,
});
const calc = (over: Partial<BuilderCalculate>): BuilderCalculate => ({
  id: 'c1',
  expr: '',
  as: '',
  ...over,
});
const withFilters = (...filters: BuilderFilter[]): BuilderConfig => ({
  datasetName: 'D',
  mark: 'bar',
  encodings: {},
  filters,
});

describe('validFilterOps (guarded operators per field type)', () => {
  it('offers ordering + range for measures and temporal fields', () => {
    const ordered = ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
    expect(validFilterOps('quantitative')).toEqual(ordered);
    expect(validFilterOps('temporal')).toEqual(ordered);
  });

  it('offers only equality + membership for categories (no ordering)', () => {
    expect(validFilterOps('nominal')).toEqual(['equal', 'notEqual', 'oneOf']);
    expect(validFilterOps('ordinal')).toEqual(['equal', 'notEqual', 'oneOf']);
  });
});

describe('filterOpArity', () => {
  it('classifies single / range / list operators', () => {
    expect(filterOpArity('equal')).toBe('single');
    expect(filterOpArity('gte')).toBe('single');
    expect(filterOpArity('range')).toBe('range');
    expect(filterOpArity('oneOf')).toBe('list');
  });
});

describe('buildTransforms (predicate coercion + shape)', () => {
  it('coerces a quantitative predicate value to a number', () => {
    const t = buildTransforms(
      withFilters(filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '10' })),
    );
    expect(t).toEqual([{ filter: { field: 'value', gt: 10 } }]);
  });

  it('keeps a categorical value a string', () => {
    const t = buildTransforms(
      withFilters(filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'East' })),
    );
    expect(t).toEqual([{ filter: { field: 'category', equal: 'East' } }]);
  });

  it('expresses notEqual as a {not} wrapper (no bare inequality predicate)', () => {
    const t = buildTransforms(
      withFilters(
        filter({ field: 'category', fieldType: 'nominal', op: 'notEqual', value: 'East' }),
      ),
    );
    expect(t).toEqual([{ filter: { not: { field: 'category', equal: 'East' } } }]);
  });

  it('builds a two-bound range with coerced numbers', () => {
    const t = buildTransforms(
      withFilters(
        filter({
          field: 'value',
          fieldType: 'quantitative',
          op: 'range',
          value: '0',
          value2: '100',
        }),
      ),
    );
    expect(t).toEqual([{ filter: { field: 'value', range: [0, 100] } }]);
  });

  it('splits and trims a oneOf membership list', () => {
    const t = buildTransforms(
      withFilters(
        filter({
          field: 'category',
          fieldType: 'nominal',
          op: 'oneOf',
          value: 'East, West ,North',
        }),
      ),
    );
    expect(t).toEqual([{ filter: { field: 'category', oneOf: ['East', 'West', 'North'] } }]);
  });

  it('passes an expression-mode filter through verbatim', () => {
    const t = buildTransforms(withFilters(filter({ mode: 'expression', expr: 'datum.value > 0' })));
    expect(t).toEqual([{ filter: 'datum.value > 0' }]);
  });

  it('escapes `.`/`[`/`]` in a predicate field so it reads as a literal column', () => {
    const t = buildTransforms(
      withFilters(filter({ field: 'user.age', fieldType: 'quantitative', op: 'gt', value: '10' })),
    );
    expect(t).toEqual([{ filter: { field: 'user\\.age', gt: 10 } }]);
  });

  it('skips incomplete entries (blank value, blank range bound, blank expression)', () => {
    const t = buildTransforms(
      withFilters(
        filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' }),
        filter({ id: 'f2', field: 'value', fieldType: 'quantitative', op: 'range', value: '1' }),
        filter({ id: 'f3', mode: 'expression', expr: '   ' }),
      ),
    );
    expect(t).toEqual([]);
  });

  it('emits calculated fields before filters, in list order', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {},
      calculates: [calc({ as: 'total', expr: 'datum.a + datum.b' })],
      filters: [filter({ field: 'total', fieldType: 'quantitative', op: 'gt', value: '5' })],
    };
    expect(buildTransforms(config)).toEqual([
      { calculate: 'datum.a + datum.b', as: 'total' },
      { filter: { field: 'total', gt: 5 } },
    ]);
  });

  it('skips an unnamed or empty calculate', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {},
      calculates: [calc({ as: '', expr: 'datum.a' }), calc({ id: 'c2', as: 'x', expr: '' })],
    };
    expect(buildTransforms(config)).toEqual([]);
  });

  it('drops a syntactically-invalid expression filter (mid-edit preview resilience)', () => {
    const t = buildTransforms(
      withFilters(
        filter({ mode: 'expression', expr: 'datum.value *' }), // half-typed → unparseable
        filter({ id: 'f2', mode: 'expression', expr: 'datum.value > 0' }), // valid stays
      ),
    );
    expect(t).toEqual([{ filter: 'datum.value > 0' }]);
  });

  it('drops a calculated field whose expression does not parse', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {},
      calculates: [
        calc({ as: 'bad', expr: 'datum.a +' }),
        calc({ id: 'c2', as: 'ok', expr: 'datum.a' }),
      ],
    };
    expect(buildTransforms(config)).toEqual([{ calculate: 'datum.a', as: 'ok' }]);
  });
});

describe('buildChartSpec — transform integration', () => {
  it('places transform between data and mark, only when something resolves', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: { x: { field: 'category', type: 'nominal' } },
      filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '0' })],
    });
    expect(spec.transform).toEqual([{ filter: { field: 'value', gte: 0 } }]);
    expect(Object.keys(spec)).toEqual(['$schema', 'data', 'transform', 'mark', 'encoding']);
  });

  it('omits the transform key when no filter/calculate resolves', () => {
    const spec = buildChartSpec({
      datasetName: 'D',
      mark: 'bar',
      encodings: { x: { field: 'category', type: 'nominal' } },
      filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' })],
    });
    expect(spec).not.toHaveProperty('transform');
  });
});

describe('calculatedFieldNames', () => {
  it('returns named (as) fields in order, trimmed, dropping empties', () => {
    expect(
      calculatedFieldNames([
        calc({ as: ' total ', expr: '1' }),
        calc({ id: 'c2', as: '', expr: '2' }),
      ]),
    ).toEqual(['total']);
  });

  it('handles an absent list', () => {
    expect(calculatedFieldNames(undefined)).toEqual([]);
  });
});

describe('effectiveColumns', () => {
  it('appends calculated fields as numeric columns the channels can use', () => {
    const eff = effectiveColumns(columns, [calc({ as: 'ratio', expr: 'datum.value / 2' })]);
    expect(eff.columns).toContain('ratio');
    expect(eff.columnTypes).toContainEqual({ name: 'ratio', type: 'number' });
  });

  it('does not shadow a real column with a same-named calculate', () => {
    const eff = effectiveColumns(columns, [calc({ as: 'value', expr: '1' })]);
    expect(eff.columns.filter((c) => c === 'value')).toHaveLength(1);
  });

  it('returns the base columns unchanged (same ref) when there are no calculates', () => {
    expect(effectiveColumns(columns, undefined)).toBe(columns);
    expect(effectiveColumns(columns, [])).toBe(columns);
  });
});

describe('pruneEncodings', () => {
  it('clears a channel mapped to a now-missing field', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: { y: { field: 'gone', type: 'quantitative' } },
      calculates: [],
    };
    expect(pruneEncodings(config, columns).encodings.y).toBeNull();
  });

  it('keeps channels on real or still-present calculated fields (same ref)', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        y: { field: 'ratio', type: 'quantitative' },
      },
      calculates: [calc({ as: 'ratio', expr: '1' })],
    };
    expect(pruneEncodings(config, columns)).toBe(config);
  });

  it('leaves a field-less count mapping alone', () => {
    const config: BuilderConfig = {
      datasetName: 'D',
      mark: 'bar',
      encodings: { y: { type: 'quantitative', aggregate: 'count' } },
    };
    expect(pruneEncodings(config, columns)).toBe(config);
  });
});

describe('rebaseBuilderConfig (dataset switch)', () => {
  // The new dataset shares `category` but lacks `value`/`when`/`flag`.
  const newColumns: BuilderColumns = {
    columns: ['category', 'profit'],
    columnTypes: [
      { name: 'category', type: 'string' },
      { name: 'profit', type: 'number' },
    ],
  };

  it('keeps chart-level intent and same-name bindings, re-pointing the dataset', () => {
    const config: BuilderConfig = {
      datasetName: 'Old',
      mark: 'bar',
      title: 'Revenue by region',
      subtitle: 'FY26',
      width: 400,
      height: 200,
      sort: 'descending',
      stack: 'normalize',
      encodings: { x: { field: 'category', type: 'nominal' } },
    };
    const out = rebaseBuilderConfig(config, 'New', newColumns);
    expect(out.datasetName).toBe('New');
    expect(out.mark).toBe('bar');
    expect(out.title).toBe('Revenue by region');
    expect(out.subtitle).toBe('FY26');
    expect(out.width).toBe(400);
    expect(out.height).toBe(200);
    expect(out.sort).toBe('descending');
    expect(out.stack).toBe('normalize');
    expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
  });

  it('sheds encodings and predicate filters bound to columns the new dataset lacks', () => {
    const config: BuilderConfig = {
      datasetName: 'Old',
      mark: 'bar',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        y: { field: 'value', type: 'quantitative' },
      },
      filters: [
        filter({ id: 'f1', field: 'value', fieldType: 'quantitative', op: 'gt', value: '0' }),
        filter({ id: 'f2', field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' }),
      ],
    };
    const out = rebaseBuilderConfig(config, 'New', newColumns);
    expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
    expect(out.encodings.y).toBeNull();
    expect(out.filters?.map((f) => f.id)).toEqual(['f2']);
  });

  it('keeps expression filters, constants, count mappings, and calculated-field bindings', () => {
    const config: BuilderConfig = {
      datasetName: 'Old',
      mark: 'bar',
      encodings: {
        x: { field: 'ratio', type: 'quantitative' }, // a calculated field travels along
        y: { type: 'quantitative', aggregate: 'count' }, // field-less count
        color: { value: '#ff0000', type: 'nominal' }, // constant — no column binding
      },
      calculates: [calc({ as: 'ratio', expr: 'datum.profit * 2' })],
      filters: [filter({ id: 'fx', mode: 'expression', expr: 'datum.value > 0' })],
    };
    const out = rebaseBuilderConfig(config, 'New', newColumns);
    expect(out.encodings.x).toEqual({ field: 'ratio', type: 'quantitative' });
    expect(out.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
    expect(out.encodings.color).toEqual({ value: '#ff0000', type: 'nominal' });
    expect(out.calculates).toEqual(config.calculates);
    expect(out.filters).toEqual(config.filters);
  });

  it('a same-schema dataset keeps the whole config (only the name changes)', () => {
    const config: BuilderConfig = {
      datasetName: 'Old',
      mark: 'line',
      encodings: {
        x: { field: 'when', type: 'temporal' },
        y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
      },
      filters: [filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' })],
    };
    const out = rebaseBuilderConfig(config, 'New', columns);
    expect(out).toEqual({ ...config, datasetName: 'New' });
  });
});

describe('parseChartSpec (strict spec → config hydration, spec §06 → Open in builder)', () => {
  // The master invariant: parse is the exact inverse of build *at the spec level*.
  // Transform ids and an ignored value-type may legitimately differ on the config but
  // never reach the spec, so the round-trip is asserted on the rebuilt spec, not the
  // config: build ∘ parse ∘ build === build.
  const roundTrips = (config: BuilderConfig) => {
    const spec = buildChartSpec(config);
    const parsed = parseChartSpec(spec);
    expect(parsed).not.toBeNull();
    expect(buildChartSpec(parsed!)).toEqual(spec);
  };

  it('round-trips the full range of builder configs', () => {
    roundTrips({
      datasetName: 'Sales',
      mark: 'bar',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        y: { field: 'value', type: 'quantitative' },
      },
    });
    // count + distinct + bin + timeUnit + aggregate
    roundTrips({
      datasetName: 'Sales',
      mark: 'bar',
      encodings: {
        x: { field: 'value', type: 'quantitative', bin: true },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    });
    roundTrips({
      datasetName: 'Sales',
      mark: 'line',
      encodings: {
        x: { field: 'when', type: 'temporal', timeUnit: 'yearmonth' },
        y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
        color: { field: 'category', type: 'nominal' },
      },
    });
    roundTrips({
      datasetName: 'Sales',
      mark: 'rect',
      encodings: {
        x: { field: 'region', type: 'nominal' },
        y: { field: 'segment', type: 'ordinal' },
        color: { field: 'value', type: 'quantitative', aggregate: 'distinct' },
      },
    });
    // constant colour + size, explicit dimensions, title + subtitle
    roundTrips({
      datasetName: 'Sales',
      mark: 'point',
      encodings: {
        x: { field: 'a', type: 'quantitative' },
        y: { field: 'b', type: 'quantitative' },
        color: { value: '#ff0000', type: 'nominal' },
        size: { value: 100, type: 'quantitative' },
      },
      width: 480,
      height: 320,
      title: 'Profit by region',
      subtitle: 'FY26',
    });
    // sort (ranking) + a field whose name needs escaping
    roundTrips({
      datasetName: 'Sales',
      mark: 'bar',
      sort: 'descending',
      encodings: {
        x: { field: 'user.region', type: 'nominal' },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    });
    // stack (part-to-whole)
    roundTrips({
      datasetName: 'Sales',
      mark: 'bar',
      stack: 'normalize',
      encodings: {
        x: { field: 'when', type: 'temporal' },
        y: { field: 'value', type: 'quantitative' },
        color: { field: 'series', type: 'nominal' },
      },
    });
    // every guarded filter operator + a calculated field
    roundTrips({
      datasetName: 'Sales',
      mark: 'point',
      encodings: {
        x: { field: 'value', type: 'quantitative' },
        y: { field: 'ratio', type: 'quantitative' },
      },
      calculates: [calc({ as: 'ratio', expr: 'datum.profit / datum.value' })],
      filters: [
        filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '10' }),
        filter({
          id: 'f2',
          field: 'value',
          fieldType: 'quantitative',
          op: 'range',
          value: '0',
          value2: '100',
        }),
        filter({
          id: 'f3',
          field: 'region',
          fieldType: 'nominal',
          op: 'oneOf',
          value: 'North,South',
        }),
        filter({ id: 'f4', field: 'tier', fieldType: 'nominal', op: 'notEqual', value: 'Z' }),
        filter({ id: 'f5', mode: 'expression', expr: 'datum.value > 0' }),
      ],
    });
  });

  it('parses a clean bar chart to the exact config', () => {
    const spec = {
      $schema: VEGA_LITE_SCHEMA_URL,
      data: { name: 'Sales' },
      mark: { type: 'bar', tooltip: true },
      encoding: {
        x: { field: 'category', type: 'nominal' },
        y: { aggregate: 'count', type: 'quantitative' },
      },
    };
    expect(parseChartSpec(spec)).toEqual({
      datasetName: 'Sales',
      mark: 'bar',
      encodings: {
        x: { field: 'category', type: 'nominal' },
        y: { type: 'quantitative', aggregate: 'count' },
      },
    });
  });

  it('accepts a bare-string mark and a missing tooltip (injected tooltip is ignored)', () => {
    expect(
      parseChartSpec({
        data: { name: 'D' },
        mark: 'point',
        encoding: {
          x: { field: 'a', type: 'quantitative' },
          y: { field: 'b', type: 'quantitative' },
        },
      }),
    ).not.toBeNull();
  });

  it('rejects specs whose data is not a bare name reference', () => {
    const enc = { x: { field: 'a', type: 'quantitative' } };
    expect(parseChartSpec({ data: { values: [{ a: 1 }] }, mark: 'bar', encoding: enc })).toBeNull();
    expect(parseChartSpec({ data: { url: 'x.csv' }, mark: 'bar', encoding: enc })).toBeNull();
    expect(parseChartSpec({ mark: 'bar', encoding: enc })).toBeNull();
  });

  it('rejects content the builder cannot represent', () => {
    const data = { name: 'D' };
    // a mark outside the builder's set
    expect(
      parseChartSpec({
        data,
        mark: 'arc',
        encoding: { theta: { field: 'v', type: 'quantitative' } },
      }),
    ).toBeNull();
    // a mark carrying an extra property
    expect(
      parseChartSpec({
        data,
        mark: { type: 'bar', filled: true },
        encoding: { x: { field: 'a', type: 'nominal' } },
      }),
    ).toBeNull();
    // a channel the builder doesn't own
    expect(
      parseChartSpec({
        data,
        mark: 'arc',
        encoding: { theta: { field: 'v', type: 'quantitative' } },
      }),
    ).toBeNull();
    expect(
      parseChartSpec({
        data,
        mark: 'point',
        encoding: {
          x: { field: 'a', type: 'quantitative' },
          tooltip: { field: 'b', type: 'nominal' },
        },
      }),
    ).toBeNull();
    // an encoding field carrying an extra key (scale/axis/legend/title)
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        encoding: {
          x: { field: 'a', type: 'nominal', axis: { title: 'X' } },
          y: { field: 'b', type: 'quantitative' },
        },
      }),
    ).toBeNull();
    // a non-bare bin
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        encoding: {
          x: { field: 'a', type: 'quantitative', bin: { maxbins: 10 } },
          y: { aggregate: 'count', type: 'quantitative' },
        },
      }),
    ).toBeNull();
    // a foreign transform
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        transform: [{ fold: ['a', 'b'] }],
        encoding: { x: { field: 'a', type: 'nominal' } },
      }),
    ).toBeNull();
    // a calculated field with an extra key
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        transform: [{ calculate: 'datum.a', as: 'c', extra: 1 }],
        encoding: { x: { field: 'a', type: 'nominal' } },
      }),
    ).toBeNull();
    // an unknown top-level key (params/selection/config/…)
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        params: [{ name: 'sel' }],
        encoding: { x: { field: 'a', type: 'nominal' } },
      }),
    ).toBeNull();
    // a title object with an extra key
    expect(
      parseChartSpec({
        data,
        mark: 'bar',
        title: { text: 'T', anchor: 'start' },
        encoding: { x: { field: 'a', type: 'nominal' } },
      }),
    ).toBeNull();
  });

  it('rejects a transform order the builder would not emit (filter before calculate)', () => {
    // The builder always emits calculated fields before filters; the reverse order
    // re-assembles differently, so the round-trip gate rejects it.
    expect(
      parseChartSpec({
        data: { name: 'D' },
        mark: 'point',
        transform: [{ filter: 'datum.x > 0' }, { calculate: 'datum.a * 2', as: 'b' }],
        encoding: {
          x: { field: 'b', type: 'quantitative' },
          y: { field: 'x', type: 'quantitative' },
        },
      }),
    ).toBeNull();
  });

  it('parseChartSpecText parses valid JSON and rejects the rest', () => {
    const text = buildSnippetSpecText({
      datasetName: 'D',
      mark: 'bar',
      encodings: {
        x: { field: 'a', type: 'nominal' },
        y: { aggregate: 'count', type: 'quantitative' },
      },
    });
    expect(parseChartSpecText(text)).not.toBeNull();
    expect(parseChartSpecText('{ not valid json')).toBeNull();
    expect(parseChartSpecText('42')).toBeNull();
  });
});
