/**
 * Color panel — behavioural wiring through the live modal. The pure config
 * transforms are covered in core/theme-controls.test.ts; these confirm the
 * controls read the draft config and write back through `mutateDraftConfig`
 * (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
 * vega-embed is mocked (the gallery is integration-heavy).
 */

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { JsonObject } from '@core/spec-config';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { ThemeBuilderModal } from './ThemeBuilderModal';

vi.mock('../services/chart-renderer', () => ({
  renderSpec: vi.fn(() =>
    Promise.resolve({
      destroy() {},
      resize() {},
      toImageURL: () => Promise.resolve(''),
      inspectData: () => null,
    }),
  ),
}));

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
  vi.useFakeTimers();
  useCustomThemeStore.getState().reset();
  usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
  container = document.createElement('div');
  document.body.appendChild(container);
  act(() => {
    root = createRoot(container);
  });
});

afterEach(() => {
  act(() => root.unmount());
  container.remove();
  vi.useRealTimers();
});

const render = () => act(() => root.render(<ThemeBuilderModal />));

/** Open a draft seeded with `config` (block body so `act` returns void). */
const open = (config: JsonObject) =>
  act(() => {
    useCustomThemeStore.getState().createTheme('Brand', config);
  });

const draftConfig = () => useCustomThemeStore.getState().draftConfig;
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;

/** Drive an input's value through the native setter so React's value tracker
 *  registers the change and fires onChange (a bare `input.value =` doesn't). */
function setNativeValue(el: HTMLInputElement, value: string) {
  // eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
  const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
  setter.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
}

/** A button anywhere in the document (popovers portal to <body>) by exact label. */
const button = (label: string) =>
  [...document.querySelectorAll('button')].find((b) => b.textContent === label);

/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
const picker = (labelPrefix: string) =>
  [...container.querySelectorAll('button')].find((b) =>
    b.getAttribute('aria-label')?.startsWith(labelPrefix),
  );

describe('ColorControls', () => {
  test('materialize expands a named scheme into an editable array', () => {
    render();
    open({ range: { category: 'tableau10' } });

    expect(range().category).toBe('tableau10');
    act(() => button('Materialize to edit')!.click());

    expect(Array.isArray(range().category)).toBe(true);
    expect((range().category as string[]).length).toBe(10);
  });

  test('add and remove palette colors', () => {
    render();
    open({ range: { category: ['#111111', '#222222'] } });

    act(() => button('Add color')!.click());
    expect((range().category as string[]).length).toBe(3);

    const remove = container.querySelector<HTMLButtonElement>(
      '[aria-label="Remove Palette color 1"]',
    )!;
    act(() => remove.click());
    expect(range().category).toEqual(['#222222', '#888888']);
  });

  test('removing the last swatch deletes range.category', () => {
    render();
    open({ range: { category: ['#111111'] } });

    const remove = container.querySelector<HTMLButtonElement>(
      '[aria-label="Remove Palette color 1"]',
    )!;
    act(() => remove.click());
    expect('category' in range()).toBe(false);
  });

  test('setting a default mark color writes mark.color; clear removes it', () => {
    render();
    open({});

    const input = container.querySelector<HTMLInputElement>(
      'input[aria-label="Default mark color"]',
    )!;
    act(() => setNativeValue(input, '#ff0000'));
    expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');

    act(() => button('Clear')!.click());
    expect(draftConfig()?.mark).toBeUndefined();
  });

  test('typing in a swatch hex field updates that color', () => {
    render();
    open({ range: { category: ['#111111', '#222222'] } });

    const hex = container.querySelector<HTMLInputElement>(
      'input[aria-label="Palette color 1 hex value"]',
    )!;
    act(() => setNativeValue(hex, '#abcdef'));
    expect((range().category as string[])[0]).toBe('#abcdef');
  });

  test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
    render();
    // Seed the other families as arrays so the only "Materialize" button is the
    // sequential one (categorical/diverging show "Add color"/"Add stop" instead).
    open({
      range: {
        category: ['#111111'],
        heatmap: 'viridis',
        ramp: 'viridis',
        diverging: ['#aa0000', '#0000aa'],
      },
    });

    act(() => button('Materialize to edit')!.click());
    expect(Array.isArray(range().heatmap)).toBe(true);
    expect(range().ramp).toEqual(range().heatmap);

    const remove = container.querySelector<HTMLButtonElement>(
      '[aria-label="Remove Sequential stop 1"]',
    )!;
    const before = (range().heatmap as string[]).length;
    act(() => remove.click());
    expect((range().heatmap as string[]).length).toBe(before - 1);
    expect(range().ramp).toEqual(range().heatmap);
  });

  test('picking a scheme writes range.category as a Vega scheme object', () => {
    render();
    open({});

    act(() => picker('Categorical color scheme')!.click());
    act(() => button('Category 10')!.click());

    // The `{ scheme }` object — a bare scheme-name string is rejected at render.
    expect(range().category).toEqual({ scheme: 'category10' });
  });

  test('a sequential pick sets both heatmap and ramp to the scheme object', () => {
    render();
    open({});

    act(() => picker('Sequential color scheme')!.click());
    act(() => button('Viridis')!.click());

    expect(range().heatmap).toEqual({ scheme: 'viridis' });
    expect(range().ramp).toEqual({ scheme: 'viridis' });
  });

  test('invalid JSON disables the controls', () => {
    render();
    open({});
    act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));

    expect(container.textContent).toContain('Fix the JSON below to use these controls');
    expect(button('Materialize to edit')).toBeUndefined();
  });

  test('the Type tab exposes the font-apply control', () => {
    render();
    open({});

    const typeTab = [...container.querySelectorAll('button')].find(
      (b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
    )!;
    act(() => typeTab.click());
    expect(picker('Font family')).toBeTruthy();
  });

  test('the raw JSON is collapsed by default and toggles open', () => {
    render();
    open({});

    expect(container.querySelector('#theme-config')).toBeNull();
    const toggle = container.querySelector<HTMLButtonElement>(
      'button[aria-controls="theme-config"]',
    )!;
    expect(toggle.getAttribute('aria-expanded')).toBe('false');

    act(() => toggle.click());
    expect(container.querySelector('#theme-config')).toBeTruthy();
  });

  test('a parse error forces the JSON open', () => {
    render();
    open({});
    expect(container.querySelector('#theme-config')).toBeNull();

    act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
    expect(container.querySelector('#theme-config')).toBeTruthy();
    expect(
      container
        .querySelector('button[aria-controls="theme-config"]')!
        .getAttribute('aria-expanded'),
    ).toBe('true');
  });
});
