import { describe, expect, it } from 'vitest';
import {
  THEME_SCHEMES,
  asBoolean,
  asNumber,
  asString,
  countSet,
  enumValue,
  getConfigValue,
  normalizeRangeSchemes,
  schemeColors,
  schemesByKind,
  setConfigValue,
} from './theme-controls';

describe('getConfigValue', () => {
  const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } };

  it('reads a nested value', () => {
    expect(getConfigValue(config, ['range', 'category'])).toEqual(['#111', '#222']);
    expect(getConfigValue(config, ['mark', 'color'])).toBe('#333');
  });

  it('returns undefined for a missing key', () => {
    expect(getConfigValue(config, ['legend', 'orient'])).toBeUndefined();
  });

  it('returns undefined when a segment is not an object', () => {
    expect(getConfigValue(config, ['mark', 'color', 'deeper'])).toBeUndefined();
  });
});

describe('setConfigValue', () => {
  it('sets a leaf and preserves siblings', () => {
    const config = { background: 'transparent', font: 'Inter' };
    expect(setConfigValue(config, ['font'], 'Georgia')).toEqual({
      background: 'transparent',
      font: 'Georgia',
    });
  });

  it('creates intermediate objects when setting deep', () => {
    expect(setConfigValue({}, ['range', 'category'], ['#111'])).toEqual({
      range: { category: ['#111'] },
    });
  });

  it('preserves unknown sibling keys at the edited level (Vega-layer keys survive)', () => {
    const config = { range: { category: ['#111'], symbol: { size: 60 } } };
    expect(setConfigValue(config, ['range', 'category'], ['#aaa', '#bbb'])).toEqual({
      range: { category: ['#aaa', '#bbb'], symbol: { size: 60 } },
    });
  });

  it('deletes a leaf when the value is undefined', () => {
    const config = { background: 'white', font: 'Inter' };
    expect(setConfigValue(config, ['font'], undefined)).toEqual({ background: 'white' });
  });

  it('prunes an ancestor object the deletion empties', () => {
    const config = { range: { category: ['#111'] }, font: 'Inter' };
    expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
  });

  it('keeps an ancestor that still holds sibling keys after a deletion', () => {
    const config = { range: { category: ['#111'], symbol: { size: 60 } } };
    expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({
      range: { symbol: { size: 60 } },
    });
  });

  it('is a no-op deleting an absent key', () => {
    const config = { font: 'Inter' };
    expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
  });

  it('does not mutate the input', () => {
    const config = { range: { category: ['#111'] } };
    const snapshot = structuredClone(config);
    setConfigValue(config, ['range', 'category'], ['#999']);
    setConfigValue(config, ['range', 'category'], undefined);
    expect(config).toEqual(snapshot);
  });

  it('returns the config unchanged for an empty path', () => {
    const config = { font: 'Inter' };
    expect(setConfigValue(config, [], 'x')).toBe(config);
  });
});

describe('THEME_SCHEMES catalog', () => {
  it('every catalog scheme resolves to a non-empty palette (guards registry drift)', () => {
    const blank = THEME_SCHEMES.filter((s) => schemeColors(s.name).length === 0);
    expect(blank.map((s) => s.name)).toEqual([]);
  });

  it('has no duplicate names', () => {
    const names = THEME_SCHEMES.map((s) => s.name);
    expect(new Set(names).size).toBe(names.length);
  });

  it('groups by kind in catalog order', () => {
    expect(schemesByKind('categorical').every((s) => s.kind === 'categorical')).toBe(true);
    expect(schemesByKind('sequential').length).toBeGreaterThan(0);
    expect(schemesByKind('diverging').map((s) => s.name)).toContain('spectral');
  });
});

describe('normalizeRangeSchemes', () => {
  it('wraps a bare scheme string in the { scheme } object form', () => {
    expect(normalizeRangeSchemes({ range: { category: 'category20b' } })).toEqual({
      range: { category: { scheme: 'category20b' } },
    });
  });

  it('normalizes every range family and preserves siblings', () => {
    const out = normalizeRangeSchemes({
      background: 'transparent',
      range: { category: ['#111'], heatmap: 'viridis', ramp: 'viridis', diverging: 'redblue' },
    });
    expect(out).toEqual({
      background: 'transparent',
      range: {
        category: ['#111'],
        heatmap: { scheme: 'viridis' },
        ramp: { scheme: 'viridis' },
        diverging: { scheme: 'redblue' },
      },
    });
  });

  it('is idempotent and returns the same object when nothing changes', () => {
    const config = { range: { category: { scheme: 'category10' }, ordinal: ['#000'] } };
    expect(normalizeRangeSchemes(config)).toBe(config);
  });

  it('is a no-op when there is no range object', () => {
    const config = { font: 'Inter' };
    expect(normalizeRangeSchemes(config)).toBe(config);
  });
});

describe('leaf coercion', () => {
  it('asString accepts strings only', () => {
    expect(asString('x')).toBe('x');
    expect(asString(1)).toBeUndefined();
    expect(asString(undefined)).toBeUndefined();
  });

  it('asNumber accepts finite numbers only', () => {
    expect(asNumber(0)).toBe(0);
    expect(asNumber(-45)).toBe(-45);
    expect(asNumber(NaN)).toBeUndefined();
    expect(asNumber(Infinity)).toBeUndefined();
    expect(asNumber('11')).toBeUndefined();
  });

  it('asBoolean accepts booleans only', () => {
    expect(asBoolean(false)).toBe(false);
    expect(asBoolean(true)).toBe(true);
    expect(asBoolean('true')).toBeUndefined();
    expect(asBoolean(undefined)).toBeUndefined();
  });
});

describe('schemeColors', () => {
  it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
    const colors = schemeColors('tableau10', 3);
    expect(colors).toHaveLength(10);
    expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
  });

  it('samples a continuous scheme at count stops, normalized to hex', () => {
    const colors = schemeColors('viridis', 5);
    expect(colors).toHaveLength(5);
    expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
  });

  it('samples the midpoint for a single continuous stop', () => {
    expect(schemeColors('viridis', 1)).toHaveLength(1);
  });

  it('returns [] for an unknown scheme', () => {
    expect(schemeColors('not-a-scheme')).toEqual([]);
  });
});

describe('enumValue', () => {
  const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const;

  it('passes an enumerated value through', () => {
    expect(enumValue('start', options)).toBe('start');
  });

  it('falls back to the "" sentinel for a value the control does not enumerate', () => {
    // A hand-edited config holding a richer value must not blank the trigger.
    expect(enumValue('middle', options)).toBe('');
    expect(enumValue(42, options)).toBe('');
    expect(enumValue(undefined, options)).toBe('');
  });
});

describe('countSet', () => {
  const config = { axis: { grid: false, gridColor: '#fff' }, background: 'transparent' };

  it('counts how many of the given paths are set', () => {
    expect(
      countSet(config, [
        ['axis', 'grid'],
        ['axis', 'gridColor'],
        ['axis', 'gridWidth'],
      ]),
    ).toBe(2);
  });

  it('counts a false/transparent value as set (only undefined is unset)', () => {
    expect(countSet(config, [['axis', 'grid']])).toBe(1); // false is a set value
    expect(countSet(config, [['background']])).toBe(1);
    expect(countSet(config, [['missing']])).toBe(0);
  });
});
