import { describe, expect, test } from 'vitest';
import {
  extractDatasetRefs,
  promoteSelfDefinedDataset,
  recomputeDatasetRefs,
  renameDatasetInSpec,
} from './spec-refs';

describe('extractDatasetRefs', () => {
  test('collects a top-level named-data reference', () => {
    expect(extractDatasetRefs({ data: { name: 'Sales' }, mark: 'bar' })).toEqual(['Sales']);
  });

  test('collects per-layer references', () => {
    const spec = {
      layer: [
        { data: { name: 'A' }, mark: 'bar' },
        { data: { name: 'B' }, mark: 'line' },
      ],
    };
    expect(extractDatasetRefs(spec).sort()).toEqual(['A', 'B']);
  });

  test('collects references inside concat and a child spec (facet/repeat)', () => {
    const spec = {
      facet: { field: 'g' },
      spec: { hconcat: [{ data: { name: 'C' } }, { vconcat: [{ data: { name: 'D' } }] }] },
    };
    expect(extractDatasetRefs(spec).sort()).toEqual(['C', 'D']);
  });

  test('accepts a JSON-text spec', () => {
    const spec = JSON.stringify({ data: { name: 'FromString' } });
    expect(extractDatasetRefs(spec)).toEqual(['FromString']);
  });

  test('unparseable string yields no refs', () => {
    expect(extractDatasetRefs('{ not valid json')).toEqual([]);
  });

  test('inline-data and url-data (no name) are not references', () => {
    expect(extractDatasetRefs({ data: { values: [{ a: 1 }] } })).toEqual([]);
    expect(extractDatasetRefs({ data: { url: 'http://x/y.csv' } })).toEqual([]);
  });

  test('a name on inline/url data is a Vega-Lite label, not a library reference', () => {
    // `{ name, values }` is inline data and `{ name, url }` is url data — the name
    // is Vega-Lite's runtime-rebind label, not a dependency (vega-lite/src/data.ts).
    expect(extractDatasetRefs({ data: { name: 'pts', values: [{ a: 1 }] } })).toEqual([]);
    expect(extractDatasetRefs({ data: { name: 'remote', url: 'http://x/y.csv' } })).toEqual([]);
  });

  test('generator data is not a reference', () => {
    expect(extractDatasetRefs({ data: { sequence: { start: 0, stop: 10 } } })).toEqual([]);
    expect(extractDatasetRefs({ data: { name: 'globe', sphere: true } })).toEqual([]);
    expect(extractDatasetRefs({ data: { graticule: true } })).toEqual([]);
  });

  test('excludes names the spec defines for itself via top-level datasets', () => {
    const spec = {
      datasets: { foo: [{ a: 1 }] },
      data: { name: 'foo' },
      layer: [{ data: { name: 'Library' } }],
    };
    // "foo" is self-defined and not a library dependency; "Library" is.
    expect(extractDatasetRefs(spec)).toEqual(['Library']);
  });

  test('a data row carrying a field literally named "data" is not a reference', () => {
    // The inline rows happen to have a column called `data` whose value looks
    // like a reference object — it is payload, not a library dependency.
    const spec = {
      data: { values: [{ data: { name: 'NotARef' } }, { data: { name: 'AlsoNot' } }] },
      mark: 'bar',
    };
    expect(extractDatasetRefs(spec)).toEqual([]);
  });

  test('does not descend into a self-defined datasets payload', () => {
    const spec = { datasets: { local: [{ data: { name: 'Buried' } }] }, mark: 'bar' };
    expect(extractDatasetRefs(spec)).toEqual([]);
  });

  test('collects a reference from a lookup transform (from.data)', () => {
    const spec = {
      transform: [{ lookup: 'id', from: { data: { name: 'Lookup' }, key: 'id', fields: ['x'] } }],
    };
    expect(extractDatasetRefs(spec)).toEqual(['Lookup']);
  });
});

describe('recomputeDatasetRefs', () => {
  test('returns sorted, de-duped names', () => {
    const spec = {
      layer: [{ data: { name: 'B' } }, { data: { name: 'A' } }, { data: { name: 'B' } }],
    };
    expect(recomputeDatasetRefs(spec)).toEqual(['A', 'B']);
  });
});

describe('renameDatasetInSpec', () => {
  test('rewrites every occurrence of the old name', () => {
    const spec = {
      data: { name: 'Old' },
      layer: [{ data: { name: 'Old' } }, { data: { name: 'Other' } }],
    };
    const out = renameDatasetInSpec(spec, 'Old', 'New');
    expect(out.data.name).toBe('New');
    expect(out.layer[0].data.name).toBe('New');
    expect(out.layer[1].data.name).toBe('Other');
  });

  test('preserves other keys on the data object', () => {
    const spec = { data: { name: 'Old', format: { type: 'csv' } } };
    const out = renameDatasetInSpec(spec, 'Old', 'New');
    expect(out.data).toEqual({ name: 'New', format: { type: 'csv' } });
  });

  test('does not mutate the input', () => {
    const spec = { data: { name: 'Old' } };
    const before = structuredClone(spec);
    renameDatasetInSpec(spec, 'Old', 'New');
    expect(spec).toEqual(before);
  });

  test('preserves object shape for an object spec', () => {
    const out = renameDatasetInSpec({ data: { name: 'Old' } }, 'Old', 'New');
    expect(typeof out).toBe('object');
  });

  test('preserves string shape for a string spec (returns pretty JSON text)', () => {
    const out = renameDatasetInSpec(JSON.stringify({ data: { name: 'Old' } }), 'Old', 'New');
    expect(typeof out).toBe('string');
    expect(JSON.parse(out)).toEqual({ data: { name: 'New' } });
  });

  test('returns an unparseable string spec unchanged', () => {
    const bad = '{ not json';
    expect(renameDatasetInSpec(bad, 'Old', 'New')).toBe(bad);
  });

  test("does not rename a name defined by the spec's own top-level datasets", () => {
    const spec = { datasets: { Old: [{ a: 1 }] }, data: { name: 'Old' } };
    const out = renameDatasetInSpec(spec, 'Old', 'New');
    expect(out.data.name).toBe('Old'); // self-defined — left untouched
    expect(Object.keys(out.datasets)).toEqual(['Old']);
  });

  test('does not rewrite a named-inline block or a "data" field buried in its rows', () => {
    // `{ name, values }` is inline data — the name is a Vega-Lite label, not a
    // library reference — so neither it nor the row payload is renamed.
    const spec = {
      data: { name: 'Old', values: [{ data: { name: 'Old' } }] },
      mark: 'bar',
    };
    const out = renameDatasetInSpec(spec, 'Old', 'New');
    expect(out.data.name).toBe('Old');
    expect(out.data.values[0].data.name).toBe('Old');
  });

  test('renames a reference inside a lookup transform (from.data)', () => {
    const spec = {
      transform: [{ lookup: 'id', from: { data: { name: 'Old' }, key: 'id' } }],
    };
    const out = renameDatasetInSpec(spec, 'Old', 'New');
    expect(out.transform[0].from.data.name).toBe('New');
  });
});

describe('promoteSelfDefinedDataset', () => {
  test('keeping the name drops the datasets entry and leaves references intact', () => {
    const spec = { datasets: { sales: [{ a: 1 }] }, data: { name: 'sales' }, mark: 'bar' };
    const out = promoteSelfDefinedDataset(spec, 'sales', 'sales')!;
    expect(out.datasets).toBeUndefined(); // map emptied → removed
    expect(out.data).toEqual({ name: 'sales' }); // reference un-shadows onto the library
  });

  test('renaming rewrites every reference and keeps sibling datasets entries', () => {
    const spec = {
      datasets: { sales: [{ a: 1 }], other: [{ b: 2 }] },
      layer: [
        { data: { name: 'sales' } },
        { data: { name: 'sales' } },
        { data: { name: 'other' } },
      ],
    };
    const out = promoteSelfDefinedDataset(spec, 'sales', 'Sales 2024')!;
    expect(out.layer).toEqual([
      { data: { name: 'Sales 2024' } },
      { data: { name: 'Sales 2024' } },
      { data: { name: 'other' } },
    ]);
    expect(out.datasets).toEqual({ other: [{ b: 2 }] });
  });

  test('does not touch a named-inline block that happens to share the name', () => {
    // `{ name, values }` is its own inline data, not a datasets reference.
    const spec = {
      datasets: { sales: [{ a: 1 }] },
      layer: [{ data: { name: 'sales' } }, { data: { name: 'sales', values: [{ z: 9 }] } }],
    };
    const out = promoteSelfDefinedDataset(spec, 'sales', 'New')!;
    expect(out.layer).toEqual([
      { data: { name: 'New' } }, // the reference
      { data: { name: 'sales', values: [{ z: 9 }] } }, // inline — left alone
    ]);
  });

  test('returns null when the name is not a self-defined dataset', () => {
    expect(promoteSelfDefinedDataset({ data: { name: 'sales' } }, 'sales', 'New')).toBeNull();
    expect(promoteSelfDefinedDataset({ datasets: { a: [{}] } }, 'missing', 'New')).toBeNull();
  });

  test('does not mutate the input', () => {
    const spec = { datasets: { sales: [{ a: 1 }] }, data: { name: 'sales' } };
    const before = structuredClone(spec);
    promoteSelfDefinedDataset(spec, 'sales', 'New');
    expect(spec).toEqual(before);
  });
});
