/**
 * Layout / Axes & grid / Legend / Type panels — behavioural wiring through the
 * live modal. The pure config transforms (path get/set, coercion) are covered in
 * core/theme-controls.test.ts; these confirm each panel reads the draft config
 * and writes the right path back through `mutateDraftConfig`, including the
 * minimal-diff delete (clearing a value removes the key). 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,
      onDataChange: () => () => {},
    }),
  ),
}));

(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`, then switch to a structured-control tab. */
const open = (config: JsonObject, tabLabel: string) => {
  act(() => {
    useCustomThemeStore.getState().createTheme('Brand', config);
  });
  const tab = [...container.querySelectorAll('button')].find(
    (b) => b.getAttribute('role') === 'tab' && b.textContent === tabLabel,
  )!;
  act(() => tab.click());
};

const config = () => useCustomThemeStore.getState().draftConfig as JsonObject;
const at = (...path: string[]): unknown =>
  path.reduce<unknown>(
    (cur, key) =>
      cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[key] : undefined,
    config(),
  );

/** Drive an input's value through the native setter so React's tracker fires onChange. */
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 }));
}

const numberInput = (name: string) =>
  container.querySelector<HTMLInputElement>(`input[aria-label="${name}"]`)!;

/** 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);

/** Open the SelectControl whose accessible name starts with `prefix`, then pick `option`. */
const pick = (prefix: string, option: string) => {
  const trigger = [...container.querySelectorAll('button')].find((b) =>
    b.getAttribute('aria-label')?.startsWith(prefix),
  )!;
  act(() => trigger.click());
  act(() => button(option)!.click());
};

describe('LayoutControls', () => {
  test('background mode "Transparent" writes background: transparent', () => {
    render();
    open({}, 'Layout');
    pick('Background', 'Transparent');
    expect(config().background).toBe('transparent');
  });

  test('corner radius writes view.cornerRadius and clearing prunes the view object', () => {
    render();
    open({}, 'Layout');
    act(() => setNativeValue(numberInput('Corner radius'), '8'));
    expect(at('view', 'cornerRadius')).toBe(8);

    act(() => setNativeValue(numberInput('Corner radius'), ''));
    expect(config().view).toBeUndefined();
  });

  test('object padding shows the JSON hint instead of a number control', () => {
    render();
    open({ padding: { left: 5, top: 5 } }, 'Layout');
    expect(container.textContent).toContain('edit it in the JSON below');
    expect(container.querySelector('input[aria-label="Padding"]')).toBeNull();
  });
});

describe('AxesControls', () => {
  test('grid lines "Hidden" writes axis.grid false', () => {
    render();
    open({}, 'Axes & grid');
    pick('Grid lines', 'Hidden');
    expect(at('axis', 'grid')).toBe(false);
  });

  test('grid style "Dotted" writes a dash array; "Theme default" removes it', () => {
    render();
    open({}, 'Axes & grid');
    pick('Grid style', 'Dotted');
    expect(at('axis', 'gridDash')).toEqual([2, 2]);

    pick('Grid style', 'Theme default');
    expect(at('axis', 'gridDash')).toBeUndefined();
  });

  test('a negative label angle is accepted', () => {
    render();
    open({}, 'Axes & grid');
    act(() => setNativeValue(numberInput('Label angle'), '-45'));
    expect(at('axis', 'labelAngle')).toBe(-45);
  });

  test('ticks "Hidden" writes axis.ticks false', () => {
    render();
    open({}, 'Axes & grid');
    pick('Ticks', 'Hidden');
    expect(at('axis', 'ticks')).toBe(false);
  });
});

describe('LegendControls', () => {
  test('position writes legend.orient', () => {
    render();
    open({}, 'Legend');
    pick('Position', 'Bottom');
    expect(at('legend', 'orient')).toBe('bottom');
  });

  test('symbol size writes legend.symbolSize', () => {
    render();
    open({}, 'Legend');
    act(() => setNativeValue(numberInput('Symbol size'), '120'));
    expect(at('legend', 'symbolSize')).toBe(120);
  });

  test('direction "Horizontal" writes legend.direction', () => {
    render();
    open({}, 'Legend');
    pick('Direction', 'Horizontal');
    expect(at('legend', 'direction')).toBe('horizontal');
  });
});

describe('accordion panels', () => {
  // Every panel groups its controls into the single-expand accordion. Verify the
  // structure on the Color panel: each section is a collapsible header, the first
  // is open, the rest collapsed.
  const headers = () =>
    [...container.querySelectorAll('button')].filter((b) =>
      b.hasAttribute('data-accordion-header'),
    );

  test('a converted panel renders collapsible sections, first open', () => {
    render();
    open({}, 'Color');
    const hs = headers();
    expect(hs).toHaveLength(4); // categorical / mark color / sequential / diverging
    expect(hs[0].textContent).toContain('Categorical palette');
    expect(hs[3].textContent).toContain('Diverging gradient');
    expect(hs[0].getAttribute('aria-expanded')).toBe('true');
    expect(hs[1].getAttribute('aria-expanded')).toBe('false');
  });

  test('a section header shows a count badge of the properties set within it', () => {
    render();
    // Two axis-grid keys set → the Grid section badge reads 2.
    open({ axis: { grid: false, gridColor: '#fff' } }, 'Axes & grid');
    const grid = headers().find((h) => h.textContent?.includes('Grid'))!;
    expect(grid.querySelector('[aria-label="2 set"]')?.textContent).toBe('2');
  });
});

describe('MarksControls', () => {
  // The per-type accordion sections start collapsed except the first ("All
  // marks"); open a section by clicking its header before reaching its controls.
  const openSection = (title: string) => {
    const header = [...container.querySelectorAll('button')].find(
      (b) => b.hasAttribute('data-accordion-header') && b.textContent?.includes(title),
    )!;
    act(() => header.click());
  };

  test('bar corner radius writes the bar-only path, not the generic mark', () => {
    render();
    open({}, 'Marks');
    openSection('Bars');
    act(() => setNativeValue(numberInput('Bar corner radius'), '4'));
    expect(at('bar', 'cornerRadiusEnd')).toBe(4);
    expect(at('mark', 'cornerRadius')).toBeUndefined();
  });

  test('clearing a bar control prunes the emptied bar object (minimal diff)', () => {
    render();
    open({ bar: { cornerRadiusEnd: 4 } }, 'Marks');
    openSection('Bars');
    act(() => setNativeValue(numberInput('Bar corner radius'), ''));
    expect(config().bar).toBeUndefined();
  });

  test('line curve writes line.interpolate', () => {
    render();
    open({}, 'Marks');
    openSection('Lines & areas');
    pick('Line curve', 'Monotone');
    expect(at('line', 'interpolate')).toBe('monotone');
  });

  test('arc donut hole writes arc.innerRadius', () => {
    render();
    open({}, 'Marks');
    openSection('Arc');
    act(() => setNativeValue(numberInput('Donut hole radius'), '40'));
    expect(at('arc', 'innerRadius')).toBe(40);
  });

  test('tooltips tri-state "Off" writes mark.tooltip false', () => {
    render();
    open({}, 'Marks');
    pick('Tooltips', 'Off'); // "All marks" section is open by default
    expect(at('mark', 'tooltip')).toBe(false);
  });
});

describe('TypeControls', () => {
  test('axis label size writes axis.labelFontSize', () => {
    render();
    open({}, 'Type');
    act(() => setNativeValue(numberInput('Axis label size'), '9'));
    expect(at('axis', 'labelFontSize')).toBe(9);
  });

  test('axis title weight "Bold" (shared WeightRow) writes 700', () => {
    render();
    open({}, 'Type');
    pick('Axis title weight', 'Bold');
    expect(at('axis', 'titleFontWeight')).toBe(700);
  });
});

describe('TitleControls', () => {
  test('alignment "Left" writes title.anchor start', () => {
    render();
    open({}, 'Title');
    pick('Alignment', 'Left');
    expect(at('title', 'anchor')).toBe('start');
  });

  test('title weight "Bold" writes title.fontWeight 700 (relocated from Type)', () => {
    render();
    open({}, 'Title');
    pick('Title weight', 'Bold');
    expect(at('title', 'fontWeight')).toBe(700);
  });

  test('subtitle size writes title.subtitleFontSize', () => {
    render();
    open({}, 'Title');
    act(() => setNativeValue(numberInput('Subtitle size'), '11'));
    expect(at('title', 'subtitleFontSize')).toBe(11);
  });
});

describe('FormatControls', () => {
  test('a number preset chip fills numberFormat; clearing the field removes it', () => {
    render();
    open({}, 'Formats');
    act(() => button('$1,234.00')!.click());
    expect(config().numberFormat).toBe('$,.2f');

    act(() => setNativeValue(container.querySelector('input[aria-label="Number format"]')!, ''));
    expect(config().numberFormat).toBeUndefined();
  });

  test('date format typed free-text writes timeFormat', () => {
    render();
    open({}, 'Formats');
    act(() => setNativeValue(container.querySelector('input[aria-label="Date format"]')!, '%Y'));
    expect(config().timeFormat).toBe('%Y');
  });
});

describe('HeaderControls', () => {
  test('facet title size writes header.titleFontSize', () => {
    render();
    open({}, 'Headers');
    act(() => setNativeValue(numberInput('Header title size'), '13'));
    expect(at('header', 'titleFontSize')).toBe(13);
  });
});
