import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';

const cb = () => useChartBuilderStore.getState();
const T = new Date('2026-06-01T00:00:00Z');

/** Seed a dataset directly into the store and return its id. */
function seedDataset(name: string, data: unknown): number {
  const ds = createDataset({ name, data, format: 'json', source: 'inline', now: T });
  useDatasetStore.getState().add(ds);
  // `add` is the id authority and reassigns a collision-free id, so read it back
  // rather than trusting the pre-add createDataset default.
  return useDatasetStore.getState().datasets[0].id;
}

beforeEach(() => {
  cb().reset();
  useDatasetStore.getState().reset();
  useSnippetStore.getState().reset();
});

describe('init', () => {
  test('pre-populates a smart default config from the dataset columns', () => {
    const id = seedDataset('Traffic', [
      { day: '2026-01-01', visits: 10 },
      { day: '2026-01-02', visits: 20 },
    ]);
    cb().init(id);

    expect(cb().datasetId).toBe(id);
    expect(cb().config.datasetName).toBe('Traffic');
    // date × number → a Line time series, first col on X, second on Y.
    expect(cb().config.mark).toBe('line');
    expect(cb().config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
    expect(cb().config.encodings.y).toEqual({ field: 'visits', type: 'quantitative' });
  });

  test('lands empty when no dataset is found', () => {
    cb().init(999);
    expect(cb().datasetId).toBeNull();
    expect(cb().config.encodings).toEqual({});
  });
});

describe('setIntent (front door "do it for me")', () => {
  test('reshapes the chart to the intent layout and disarms any armed channel', () => {
    const id = seedDataset('Sales', [
      { region: 'E', segment: 'A', sales: 5 },
      { region: 'W', segment: 'B', sales: 9 },
    ]);
    cb().init(id);
    cb().focusChannel('color'); // arm a channel first
    cb().setIntent('heatmap');
    expect(cb().config.mark).toBe('rect');
    expect(cb().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
    expect(cb().config.encodings.x?.field).toBeDefined();
    expect(cb().config.encodings.y?.field).toBeDefined();
    expect(cb().activeChannel).toBeNull();
  });
});

describe('channel editing', () => {
  test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
    const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
    cb().init(id);
    // A numeric column on Size → Quantitative (allowed); a category cannot be chosen
    // for Size in the UI, and the store keeps the type valid for the channel.
    cb().setChannelColumn('size', 'value');
    expect(cb().config.encodings.size).toEqual({ field: 'value', type: 'quantitative' });
  });

  test('swapXY flips the two axis mappings', () => {
    const id = seedDataset('XY', [{ a: 'x', b: 1 }]);
    cb().init(id);
    const x0 = cb().config.encodings.x;
    const y0 = cb().config.encodings.y;
    cb().swapXY();
    expect(cb().config.encodings.x).toEqual(y0);
    expect(cb().config.encodings.y).toEqual(x0);
  });

  test('clearing a channel to None removes it from the config', () => {
    const id = seedDataset('XY', [{ a: 'x', b: 1 }]);
    cb().init(id);
    cb().setChannelColumn('x', null);
    expect(cb().config.encodings.x).toBeNull();
  });
});

describe('transforms — aggregate / bin / timeUnit / count', () => {
  test('the Count-of-records option maps a field-less count measure', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().setChannelColumn('y', COUNT_FIELD);
    expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
  });

  test('aggregate and bin are mutually exclusive on a channel', () => {
    const id = seedDataset('S', [{ revenue: 5 }]);
    cb().init(id);
    cb().setChannelColumn('y', 'revenue');
    cb().setChannelAggregate('y', 'sum');
    expect(cb().config.encodings.y).toMatchObject({ field: 'revenue', aggregate: 'sum' });
    cb().setChannelBin('y', true);
    expect(cb().config.encodings.y?.aggregate).toBeUndefined();
    expect(cb().config.encodings.y?.bin).toBe(true);
    cb().setChannelAggregate('y', 'mean');
    expect(cb().config.encodings.y?.bin).toBeUndefined();
    expect(cb().config.encodings.y?.aggregate).toBe('mean');
  });

  test('changing field type drops transforms that no longer apply', () => {
    const id = seedDataset('S', [{ price: 5 }]);
    cb().init(id);
    cb().setChannelColumn('x', 'price');
    cb().setChannelBin('x', true);
    cb().setChannelType('x', 'nominal'); // leaving quantitative
    expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
  });

  test('assignField with an explicit channel places the field there, replacing any occupant', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().setChannelColumn('x', 'revenue');
    cb().assignField('region', 'x'); // explicit target from the chip's channel chooser
    expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
    expect(cb().activeChannel).toBeNull();
  });

  test('setTitle / setSubtitle set and clear the chart title fields', () => {
    const id = seedDataset('S', [{ v: 1 }]);
    cb().init(id);
    cb().setTitle('My chart');
    cb().setSubtitle('A closer look');
    expect(cb().config.title).toBe('My chart');
    expect(cb().config.subtitle).toBe('A closer look');
    cb().setTitle('');
    expect(cb().config.title).toBeUndefined();
  });

  test('retyping drops a now-invalid aggregate but keeps a distinct (valid for any type)', () => {
    const id = seedDataset('S', [{ price: 5 }]);
    cb().init(id);
    cb().setChannelColumn('x', 'price');
    cb().setChannelAggregate('x', 'sum');
    cb().setChannelType('x', 'nominal'); // sum can't reduce a nominal field
    expect(cb().config.encodings.x?.aggregate).toBeUndefined();
    cb().setChannelAggregate('x', 'distinct');
    cb().setChannelType('x', 'ordinal'); // distinct counts unique values of anything
    expect(cb().config.encodings.x?.aggregate).toBe('distinct');
  });

  test('setChannelTimeUnit sets and clears granularity', () => {
    const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
    cb().init(id);
    cb().setChannelTimeUnit('x', 'yearmonth');
    expect(cb().config.encodings.x?.timeUnit).toBe('yearmonth');
    cb().setChannelTimeUnit('x', undefined);
    expect(cb().config.encodings.x?.timeUnit).toBeUndefined();
  });
});

describe('sort / stack', () => {
  test('setSort and setStack set and clear the chart-level fields', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().setSort('descending');
    expect(cb().config.sort).toBe('descending');
    cb().setSort(undefined);
    expect(cb().config.sort).toBeUndefined();
    cb().setStack('normalize');
    expect(cb().config.stack).toBe('normalize');
  });
});

describe('applyWarningFix', () => {
  test('applies a hint fix to the working config (actionable hints, §06)', () => {
    const id = seedDataset('Nums', [
      { a: 1, b: 2 },
      { a: 3, b: 4 },
    ]);
    cb().init(id);
    cb().setMark('bar'); // two quantitative axes on a bar → "scatter" hint with a fix
    cb().applyWarningFix({ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) });
    expect(cb().config.mark).toBe('point');
  });
});

describe('filters (1C)', () => {
  test('addFilter seeds a predicate on the first column with its derived type', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().addFilter();
    const f = cb().config.filters?.[0];
    expect(f).toMatchObject({
      mode: 'predicate',
      field: 'region',
      fieldType: 'nominal',
      op: 'equal',
    });
  });

  test('setFilterField re-derives the field type and clamps an out-of-range operator', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().addFilter();
    const fid = cb().config.filters![0].id;
    // Point at the measure, choose an ordering op, then point back at the category:
    cb().setFilterField(fid, 'revenue');
    cb().updateFilter(fid, { op: 'gt', value: '3' });
    expect(cb().config.filters![0]).toMatchObject({ fieldType: 'quantitative', op: 'gt' });
    cb().setFilterField(fid, 'region'); // gt is invalid on a category → resets to equal
    expect(cb().config.filters![0]).toMatchObject({
      field: 'region',
      fieldType: 'nominal',
      op: 'equal',
    });
  });

  test('removeFilter drops the row', () => {
    const id = seedDataset('S', [{ region: 'N' }]);
    cb().init(id);
    cb().addFilter();
    cb().addFilter();
    const first = cb().config.filters![0].id;
    cb().removeFilter(first);
    expect(cb().config.filters).toHaveLength(1);
    expect(cb().config.filters![0].id).not.toBe(first);
  });

  test('setFilterMode toggles to expression and back, seeding a field on return', () => {
    const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
    cb().init(id);
    cb().addFilter();
    const fid = cb().config.filters![0].id;
    cb().setFilterMode(fid, 'expression');
    expect(cb().config.filters![0].mode).toBe('expression');
    cb().setFilterMode(fid, 'predicate');
    expect(cb().config.filters![0]).toMatchObject({ mode: 'predicate', field: 'region' });
  });
});

describe('calculated fields (1C)', () => {
  test('a calculated field becomes selectable on a channel, defaulting to quantitative', () => {
    const id = seedDataset('S', [{ a: 1, b: 2 }]);
    cb().init(id);
    cb().addCalculate();
    const cid = cb().config.calculates![0].id;
    cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
    cb().setChannelColumn('y', 'total');
    expect(cb().config.encodings.y).toEqual({ field: 'total', type: 'quantitative' });
  });

  test('removing a calculated field clears any channel that referenced it', () => {
    const id = seedDataset('S', [{ a: 1, b: 2 }]);
    cb().init(id);
    cb().addCalculate();
    const cid = cb().config.calculates![0].id;
    cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
    cb().setChannelColumn('y', 'total');
    cb().removeCalculate(cid);
    expect(cb().config.calculates).toHaveLength(0);
    expect(cb().config.encodings.y).toBeNull(); // pruned — its field no longer exists
  });

  test('renaming a referenced calculated field prunes the now-dangling channel', () => {
    const id = seedDataset('S', [{ a: 1 }]);
    cb().init(id);
    cb().addCalculate();
    const cid = cb().config.calculates![0].id;
    cb().updateCalculate(cid, { as: 'doubled', expr: 'datum.a * 2' });
    cb().setChannelColumn('x', 'doubled');
    cb().updateCalculate(cid, { as: 'tripled' }); // the channel still points at "doubled"
    expect(cb().config.encodings.x).toBeNull();
  });
});

describe('createSnippet', () => {
  test('builds a linked snippet, activates it, and resets the builder', () => {
    const id = seedDataset('Sales', [
      { region: 'N', revenue: 100 },
      { region: 'S', revenue: 80 },
    ]);
    cb().init(id);

    expect(cb().createSnippet(T)).toBe(true);

    const snippets = useSnippetStore.getState().snippets;
    expect(snippets).toHaveLength(1);
    const made = snippets[0];
    // Linked to its dataset by name (§09F), and the spec references it.
    expect(made.datasetRefs).toEqual(['Sales']);
    expect(made.spec).toContain('"name": "Sales"');
    expect(made.meta.createdWith).toBe('chart-builder');
    expect(useSnippetStore.getState().activeSnippetId).toBe(made.id);
    // Builder state is reset for a fresh next open.
    expect(cb().datasetId).toBeNull();
  });

  test('refuses to create when no channel is mapped', () => {
    const id = seedDataset('Empty', [{ a: 1 }]);
    cb().init(id);
    cb().setChannelColumn('x', null);
    cb().setChannelColumn('y', null);
    expect(cb().createSnippet(T)).toBe(false);
    expect(useSnippetStore.getState().snippets).toHaveLength(0);
  });
});

describe('field-first assignment (assignField / focusChannel)', () => {
  test('a clicked field lands on the first empty channel that accepts it', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5, qty: 2 }]);
    cb().init(id); // region→X, count→Y (smart default for one category + measures)
    cb().setChannelColumn('x', null);
    cb().setChannelColumn('y', null);

    cb().assignField('region'); // first empty channel is X
    expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
    cb().assignField('sales'); // X taken → Y
    expect(cb().config.encodings.y).toEqual({ field: 'sales', type: 'quantitative' });
  });

  test('the armed channel takes the field, then disarms', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().focusChannel('color');
    expect(cb().activeChannel).toBe('color');
    cb().assignField('region');
    expect(cb().config.encodings.color).toEqual({ field: 'region', type: 'nominal' });
    expect(cb().activeChannel).toBeNull(); // armed slot cleared after assignment
  });

  test('skips a channel that cannot take the column (Size needs a measure)', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().focusChannel('size');
    cb().assignField('region'); // a category can't go on Size → falls through to an empty axis
    expect(cb().config.encodings.size ?? null).toBeNull();
    // It landed on the first axis that accepts it instead of being dropped.
    const onAxis =
      cb().config.encodings.x?.field === 'region' || cb().config.encodings.y?.field === 'region';
    expect(onAxis).toBe(true);
  });

  test('Count of records assigns as a field-less count measure', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().setChannelColumn('x', null);
    cb().setChannelColumn('y', null);
    cb().focusChannel('y');
    cb().assignField(COUNT_FIELD);
    expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
  });
});

describe('constant values (setChannelConstant — the Property model)', () => {
  test('sets a fixed colour on Color, emitted as a constant', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().setChannelConstant('color', '#c0392b');
    expect(cb().config.encodings.color).toMatchObject({ value: '#c0392b' });
  });

  test('coerces a Size constant to a number', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().setChannelConstant('size', '120');
    expect(cb().config.encodings.size?.value).toBe(120);
  });

  test('preserves the prior field type so toggling back to a field restores it', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    cb().setChannelColumn('color', 'region'); // nominal field
    cb().setChannelConstant('color', '#000000');
    expect(cb().config.encodings.color).toEqual({ value: '#000000', type: 'nominal' });
  });

  test('refuses a constant on a positional channel (X/Y stay field-only)', () => {
    const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
    cb().init(id);
    const before = cb().config.encodings.x;
    cb().setChannelConstant('x', '50');
    expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
  });
});

describe('init(null) — the un-targeted doors (library / onboarding)', () => {
  test('picks the most recently modified dataset', () => {
    const dsOld = createDataset({
      name: 'Older',
      data: [{ a: 'x', b: 1 }],
      format: 'json',
      source: 'inline',
      now: new Date('2026-05-01T00:00:00Z'),
    });
    const dsNew = createDataset({
      name: 'Fresher',
      data: [{ c: 'y', d: 2 }],
      format: 'json',
      source: 'inline',
      now: new Date('2026-06-01T00:00:00Z'),
    });
    useDatasetStore.getState().add(dsOld);
    useDatasetStore.getState().add(dsNew);

    cb().init(null);
    expect(cb().config.datasetName).toBe('Fresher');
    expect(cb().datasetId).not.toBeNull();
  });

  test('lands empty when the dataset library is empty', () => {
    cb().init(null);
    expect(cb().datasetId).toBeNull();
    expect(cb().config.encodings).toEqual({});
  });
});

describe('switchDataset (the builder dataset picker)', () => {
  test('an untouched default config re-derives smart defaults for the new dataset', () => {
    const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
    const bId = seedDataset('B', [{ day: '2026-01-01', visits: 10 }]);
    cb().init(aId);

    cb().switchDataset(bId);
    expect(cb().datasetId).toBe(bId);
    expect(cb().config.datasetName).toBe('B');
    // Fresh defaults for B's columns, not a pruned remnant of A's.
    expect(cb().config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
  });

  test('a built-on config is rebased: chart intent kept, missing-column bindings shed', () => {
    const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
    const bId = seedDataset('B', [{ region: 'S', profit: 2 }]);
    cb().init(aId);
    cb().setChannelColumn('x', 'region');
    cb().setChannelColumn('y', 'revenue');
    cb().setTitle('Revenue by region');
    cb().addFilter();
    const fid = useChartBuilderStore.getState().config.filters![0].id;
    cb().setFilterField(fid, 'revenue');

    cb().switchDataset(bId);
    const config = cb().config;
    expect(config.datasetName).toBe('B');
    expect(config.title).toBe('Revenue by region');
    expect(config.encodings.x).toEqual({ field: 'region', type: 'nominal' }); // shared column survives
    expect(config.encodings.y).toBeNull(); // `revenue` doesn't exist on B
    expect(config.filters).toEqual([]); // the revenue filter is shed too
  });

  test('no-op for an unknown id or the already-loaded dataset', () => {
    const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
    cb().init(aId);
    cb().setTitle('Kept');

    const before = cb().config;
    cb().switchDataset(999);
    expect(cb().config).toBe(before);
    cb().switchDataset(aId);
    expect(cb().config).toBe(before);
  });
});

describe('openForEdit / saveEdits (edit-in-place, spec §06 → Open in builder)', () => {
  /** Seed a dataset, build a snippet from it, and return that linked snippet. */
  function buildSnippetFrom(name: string, data: unknown) {
    const id = seedDataset(name, data);
    cb().init(id);
    cb().createSnippet(T); // creates a linked, builder-dialect snippet; resets the builder
    return { id, snippet: useSnippetStore.getState().snippets[0] };
  }

  test('hydrates the builder from a builder-made snippet and records the edit target', () => {
    const { id, snippet } = buildSnippetFrom('Sales', [
      { region: 'E', sales: 5 },
      { region: 'W', sales: 9 },
    ]);

    expect(cb().openForEdit(snippet)).toBe(true);
    expect(cb().editingSnippetId).toBe(snippet.id);
    expect(cb().editingSnippetName).toBe(snippet.name);
    expect(cb().datasetId).toBe(id);
    expect(cb().config.datasetName).toBe('Sales');
    // A loaded chart is built-on work: a dataset switch must rebase, not re-derive,
    // so config must not be reference-equal to initialConfig (see switchDataset).
    expect(cb().config).not.toBe(cb().initialConfig);
  });

  test('saveEdits republishes into the same snippet and clears edit state', () => {
    const { snippet } = buildSnippetFrom('Sales', [
      { region: 'E', sales: 5 },
      { region: 'W', sales: 9 },
    ]);
    const originalSpec = snippet.spec;

    cb().openForEdit(snippet);
    cb().setMark('point');
    expect(cb().saveEdits(new Date('2026-06-02T00:00:00Z'))).toBe(true);

    const after = useSnippetStore.getState().snippets.find((s) => s.id === snippet.id)!;
    expect(after.spec).not.toBe(originalSpec);
    expect(after.spec).toContain('"point"');
    expect(after.spec).toBe(after.draftSpec); // republished — no pending draft
    expect(useSnippetStore.getState().activeSnippetId).toBe(snippet.id);
    // The builder reset out of edit mode.
    expect(cb().editingSnippetId).toBeNull();
    expect(cb().datasetId).toBeNull();
  });

  test('openForEdit refuses a spec the builder cannot represent (inline data)', () => {
    seedDataset('Sales', [{ region: 'E', sales: 5 }]);
    // The default sample template is an inline-data bar chart — no `{ data: { name } }`,
    // so it is not builder-representable.
    useSnippetStore.getState().createSnippet();
    const inline = useSnippetStore.getState().snippets[0];
    expect(cb().openForEdit(inline)).toBe(false);
    expect(cb().editingSnippetId).toBeNull();
  });

  test('openForEdit refuses when the referenced dataset is gone', () => {
    const { snippet } = buildSnippetFrom('Sales', [{ region: 'E', sales: 5 }]);
    useDatasetStore.getState().reset(); // the dataset no longer exists
    expect(cb().openForEdit(snippet)).toBe(false);
  });

  test('saveEdits is a no-op outside an edit session', () => {
    expect(cb().saveEdits(T)).toBe(false);
  });
});
