import { describe, expect, test } from 'vitest';
import {
  classifyData,
  dataBindingAtPath,
  forEachDataBinding,
  libraryRefName,
  mapDataBindings,
  selfDefinedNames,
  setDataBindingAtPath,
} from './spec-data';

describe('classifyData', () => {
  test('classifies the four Vega-Lite data shapes', () => {
    expect(classifyData({ url: 'x.csv' })).toBe('url');
    expect(classifyData({ values: [{ a: 1 }] })).toBe('inline');
    expect(classifyData({ sequence: { start: 0, stop: 5 } })).toBe('generator');
    expect(classifyData({ sphere: true })).toBe('generator');
    expect(classifyData({ graticule: true })).toBe('generator');
    expect(classifyData({ name: 'Sales' })).toBe('named');
  });

  test('url / values / generator win over a bare name (Vega-Lite precedence)', () => {
    // A name on these shapes is a runtime-rebind label, not a named reference.
    expect(classifyData({ name: 'pts', values: [{ a: 1 }] })).toBe('inline');
    expect(classifyData({ name: 'remote', url: 'x.csv' })).toBe('url');
    expect(classifyData({ name: 'seq', sequence: { start: 0, stop: 5 } })).toBe('generator');
  });

  test('returns null for non-data values', () => {
    expect(classifyData(null)).toBeNull();
    expect(classifyData(undefined)).toBeNull();
    expect(classifyData('string')).toBeNull();
    expect(classifyData([])).toBeNull();
    expect(classifyData({})).toBeNull();
    expect(classifyData({ name: 123 })).toBeNull(); // a non-string name is not named data
  });
});

describe('selfDefinedNames', () => {
  test('collects top-level datasets keys', () => {
    expect([...selfDefinedNames({ datasets: { a: [], b: [] } })].sort()).toEqual(['a', 'b']);
  });

  test('is empty when there is no datasets map', () => {
    expect(selfDefinedNames({ data: { name: 'x' } }).size).toBe(0);
    expect(selfDefinedNames(null).size).toBe(0);
  });
});

describe('libraryRefName', () => {
  const none = new Set<string>();

  test('returns the name of a true named reference', () => {
    expect(libraryRefName({ name: 'Sales' }, none)).toBe('Sales');
  });

  test('returns null for inline/url/generator data carrying a name', () => {
    expect(libraryRefName({ name: 'pts', values: [{ a: 1 }] }, none)).toBeNull();
    expect(libraryRefName({ name: 'remote', url: 'x.csv' }, none)).toBeNull();
    expect(libraryRefName({ name: 'seq', sequence: { start: 0, stop: 5 } }, none)).toBeNull();
  });

  test('returns null for a self-defined name', () => {
    expect(libraryRefName({ name: 'local' }, new Set(['local']))).toBeNull();
  });

  test('returns null for a non-data value', () => {
    expect(libraryRefName(null, none)).toBeNull();
    expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
  });
});

describe('dataBindingAtPath', () => {
  test('finds the top-level data when no view overrides it', () => {
    const spec = { data: { name: 'Sales' }, layer: [{ mark: 'bar', encoding: { x: {} } }] };
    // Cursor deep inside the first layer's encoding — inherits the top-level data.
    const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'x']);
    expect(binding).toEqual({ data: { name: 'Sales' }, anchorPath: [] });
  });

  test('the nearest enclosing view wins (Vega-Lite data inheritance)', () => {
    const spec = {
      data: { name: 'Top' },
      layer: [{ data: { name: 'Inner' }, encoding: { y: { field: 'a' } } }],
    };
    const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'y', 'field']);
    expect(binding).toEqual({ data: { name: 'Inner' }, anchorPath: ['layer', 0] });
  });

  test('a sibling view does not leak its data', () => {
    const spec = {
      layer: [
        { data: { name: 'A' }, mark: 'bar' },
        { data: { name: 'B' }, mark: 'line' },
      ],
    };
    expect(dataBindingAtPath(spec, ['layer', 1, 'mark'])).toEqual({
      data: { name: 'B' },
      anchorPath: ['layer', 1],
    });
  });

  test('resolves through a facet/repeat child spec', () => {
    const spec = { facet: { field: 'g' }, spec: { data: { name: 'Child' }, mark: 'point' } };
    expect(dataBindingAtPath(spec, ['spec', 'mark'])).toEqual({
      data: { name: 'Child' },
      anchorPath: ['spec'],
    });
  });

  test('returns null when no ancestor on the path declares data', () => {
    expect(dataBindingAtPath({ mark: 'bar' }, ['mark'])).toBeNull();
  });

  test('descends defensively past a non-object on the path', () => {
    expect(dataBindingAtPath({ mark: 'bar' }, ['mark', 'type', 'nope'])).toBeNull();
  });
});

describe('setDataBindingAtPath', () => {
  test('sets the data block of the view at the anchor path', () => {
    const spec = { layer: [{ mark: 'line', data: { values: [{ a: 1 }] } }] };
    expect(setDataBindingAtPath(spec, ['layer', 0], { name: 'X' })).toBe(true);
    expect(spec.layer[0].data).toEqual({ name: 'X' });
  });

  test('sets the root data block for the empty path', () => {
    const spec: { data?: unknown; mark: string } = { mark: 'bar' };
    expect(setDataBindingAtPath(spec, [], { name: 'Root' })).toBe(true);
    expect(spec.data).toEqual({ name: 'Root' });
  });

  test('returns false when the path does not resolve to an object', () => {
    const spec = { layer: [{ mark: 'line' }] };
    expect(setDataBindingAtPath(spec, ['layer', 3], { name: 'X' })).toBe(false);
    expect(setDataBindingAtPath({ mark: 'bar' }, ['mark'], { name: 'X' })).toBe(false);
  });
});

describe('forEachDataBinding', () => {
  test('visits every object node and prunes the data/datasets payloads', () => {
    // A row inside `data.values` carries a field literally named `data`; the prune
    // keeps the walk from visiting it as a binding.
    const spec = {
      datasets: { ds: [{ z: 1 }] },
      layer: [{ data: { name: 'A' } }, { data: { values: [{ data: { name: 'buried' } }] } }],
    };
    const seen: unknown[] = [];
    forEachDataBinding(spec, (node) => {
      if (node.data !== undefined) seen.push(node.data);
    });
    expect(seen).toEqual([{ name: 'A' }, { values: [{ data: { name: 'buried' } }] }]);
  });

  test('passes the spec self-defined names and stops early on a true return', () => {
    const spec = {
      datasets: { ds: [{ a: 1 }] },
      layer: [{ data: { name: 'ds' } }, { data: { name: 'other' } }],
    };
    let visits = 0;
    let sawSelfDefined: ReadonlySet<string> | null = null;
    forEachDataBinding(spec, (node, selfDefined) => {
      visits++;
      sawSelfDefined = selfDefined;
      return node.data !== undefined; // stop at the first node carrying a data block
    });
    expect(sawSelfDefined && [...sawSelfDefined]).toEqual(['ds']);
    // root (no data) → layer[0] (has data, stop). layer[1] never visited.
    expect(visits).toBe(2);
  });
});

describe('mapDataBindings', () => {
  test('maps each data block, carries datasets through, and prunes payloads', () => {
    const spec = {
      datasets: { ds: [{ a: 1 }] },
      layer: [{ data: { name: 'A' } }, { data: { values: [{ data: 'row-field' }] } }],
    };
    const out = mapDataBindings(spec, (data) =>
      typeof (data as { name?: unknown }).name === 'string' ? { name: 'MAPPED' } : data,
    ) as typeof spec;
    expect(out.layer[0].data).toEqual({ name: 'MAPPED' });
    expect(out.layer[1].data).toEqual({ values: [{ data: 'row-field' }] }); // payload untouched
    expect(out.datasets).toEqual({ ds: [{ a: 1 }] }); // carried through
  });

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