import { describe, expect, it } from 'vitest';
import {
  CHART_THEME_OPTIONS,
  chartConfigFor,
  chartConfigForSelection,
  chartThemeOptions,
  customThemeIdOf,
  customThemeSelection,
  isChartThemeId,
  isChartThemeSelection,
  darkBaseConfig,
  darkChartConfig,
  darkExpressiveConfig,
  lightBaseConfig,
  lightChartConfig,
  lightExpressiveConfig,
  mergeChartLayers,
} from './vega-themes';
import type { UiTheme } from './theme';

/**
 * Light smoke coverage for the chart theme — the design is mostly visual and
 * verified by eye (docs/IMPLEMENTATION-PLAN.md M1.5). These guard the contract:
 * every theme resolves to a config, charts stay transparent (inherit the
 * surface), and the expressive categorical palette is present per theme.
 */
describe('chartConfigFor', () => {
  const themes: UiTheme[] = ['light', 'dark'];

  it('maps every UiTheme to its config', () => {
    expect(chartConfigFor('light')).toBe(lightChartConfig);
    expect(chartConfigFor('dark')).toBe(darkChartConfig);
  });

  it.each(themes)('keeps the chart background transparent (%s)', (theme) => {
    expect(chartConfigFor(theme).background).toBe('transparent');
  });

  it.each(themes)('uses IBM Plex for the chart font (%s)', (theme) => {
    expect(chartConfigFor(theme).font).toContain('IBM Plex');
  });

  it.each(themes)('ships a multi-color categorical palette (%s)', (theme) => {
    const category = chartConfigFor(theme).range?.category as string[];
    expect(Array.isArray(category)).toBe(true);
    expect(category.length).toBeGreaterThanOrEqual(8);
    // All entries are hex colors and distinct (no accidental duplicate slot).
    expect(category.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
    expect(new Set(category).size).toBe(category.length);
  });

  it('uses distinct palettes per theme', () => {
    expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
  });
});

/**
 * The layer split (docs/chart-theming-scope.md §1): base carries only the
 * legibility minimum (background + guide colors); everything brand-flavored
 * (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
 * A future stock/custom chart style keeps base and swaps expressive.
 */
describe('chart config layers', () => {
  const layers = [
    { base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
    { base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
  ];

  it.each(layers)('base stays free of house style', ({ base }) => {
    expect(base.font).toBeUndefined();
    expect(base.range).toBeUndefined();
    expect(base.view).toBeUndefined();
    expect(base.axis?.gridDash).toBeUndefined();
    expect(base.title).not.toHaveProperty('fontSize');
  });

  it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
    expect(expressive.background).toBeUndefined();
    expect(expressive.axis?.labelColor).toBeUndefined();
    expect(expressive.title).not.toHaveProperty('color');
  });

  it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
    expect(mergeChartLayers(base, expressive)).toEqual(full);
  });

  it('merges the nested title and axis groups instead of replacing them', () => {
    const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
    // One property from each layer survives in the same nested object.
    expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
    expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
  });
});

describe('chartConfigForSelection', () => {
  it('astrolabe follows the UI theme', () => {
    expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
    expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
  });

  it('stock injects nothing (vega-lite defaults apply)', () => {
    expect(chartConfigForSelection('stock', 'light')).toEqual({});
    expect(chartConfigForSelection('stock', 'dark')).toEqual({});
  });

  it('presets resolve to a non-empty config independent of UI theme', () => {
    const light = chartConfigForSelection('fivethirtyeight', 'light');
    expect(Object.keys(light).length).toBeGreaterThan(0);
    expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
  });

  it('every option id resolves to a config', () => {
    for (const { value } of CHART_THEME_OPTIONS) {
      expect(chartConfigForSelection(value, 'light')).toBeTruthy();
    }
  });
});

describe('isChartThemeId', () => {
  it('accepts every option id', () => {
    for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
  });

  it('rejects unknown and non-string values', () => {
    expect(isChartThemeId('comic-sans')).toBe(false);
    expect(isChartThemeId(undefined)).toBe(false);
    expect(isChartThemeId(7)).toBe(false);
  });
});

describe('custom theme selections', () => {
  const themes = [
    { id: 3, name: 'Brand', config: { font: 'Georgia', background: '#fff8f0' } },
    { id: 9, name: 'Mono', config: { font: 'Courier' } },
  ];

  it('round-trips an id through the selection string', () => {
    expect(customThemeSelection(3)).toBe('custom:3');
    expect(customThemeIdOf('custom:3')).toBe(3);
  });

  it('customThemeIdOf rejects non-custom and malformed values', () => {
    expect(customThemeIdOf('astrolabe')).toBeNull();
    expect(customThemeIdOf('custom:')).toBeNull();
    expect(customThemeIdOf('custom:abc')).toBeNull();
    expect(customThemeIdOf('custom:1.5')).toBeNull();
  });

  it('isChartThemeSelection accepts built-ins, presets, and custom ids', () => {
    expect(isChartThemeSelection('astrolabe')).toBe(true);
    expect(isChartThemeSelection('fivethirtyeight')).toBe(true);
    expect(isChartThemeSelection('custom:42')).toBe(true);
    expect(isChartThemeSelection('custom:nope')).toBe(false);
    expect(isChartThemeSelection('comic-sans')).toBe(false);
  });

  it('resolves a custom selection to its saved config', () => {
    expect(chartConfigForSelection('custom:3', 'light', themes)).toBe(themes[0].config);
  });

  it('falls back to the house config when the record is missing (deleted / not hydrated)', () => {
    expect(chartConfigForSelection('custom:404', 'light', themes)).toBe(lightChartConfig);
    expect(chartConfigForSelection('custom:404', 'dark', themes)).toBe(darkChartConfig);
  });

  it('lists custom themes after the built-ins and before the presets', () => {
    const options = chartThemeOptions(themes);
    const values = options.map((o) => o.value);
    expect(values.slice(0, 4)).toEqual(['astrolabe', 'stock', 'custom:3', 'custom:9']);
    expect(values.length).toBe(CHART_THEME_OPTIONS.length + themes.length);
    expect(options[2].label).toBe('Brand');
  });

  it('marks the roster boundary on the first preset, wherever the customs end', () => {
    for (const customs of [[], themes]) {
      const options = chartThemeOptions(customs);
      const dividers = options.filter((o) => o.dividerBefore);
      expect(dividers).toHaveLength(1);
      expect(dividers[0].value).toBe(CHART_THEME_OPTIONS[2].value); // first preset
      expect(options.indexOf(dividers[0])).toBe(2 + customs.length);
    }
  });

  it('lists no custom entries when the library has none', () => {
    const values = chartThemeOptions([]).map((o) => o.value);
    expect(values).toEqual(CHART_THEME_OPTIONS.map((o) => o.value));
  });
});
