import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createDataset } from '@core/dataset';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { ChartBuilderModal } from './ChartBuilderModal';

// The builder preview embeds a real Vega chart in an effect; stub the renderer so
// these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make
// it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the
// same class the component imports, so its `instanceof` check matches. The class is
// declared inside the factory because vi.mock is hoisted above module-scope code.
vi.mock('../services/chart-renderer', () => {
  class ChartTooLargeError extends Error {
    heightPx: number;
    limitPx: number;
    constructor(heightPx: number, limitPx: number) {
      super('too large');
      this.name = 'ChartTooLargeError';
      this.heightPx = heightPx;
      this.limitPx = limitPx;
    }
  }
  return {
    renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
    ChartTooLargeError,
  };
});

// React 19 wants this flag set for act() to drive effects without warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const T = new Date('2026-06-01T00:00:00Z');
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
  useChartBuilderStore.getState().reset();
  useDatasetStore.getState().reset();
  useSnippetStore.getState().reset();
  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
});

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

describe('ChartBuilderModal', () => {
  test('renders without an infinite update loop when the config has warnings (regression)', async () => {
    // Two numeric columns → default mark Point (clean). Switching to Bar makes it
    // "two measures on a non-scatter" → a NON-EMPTY warnings array — the exact
    // condition that previously looped because the warnings selector returned a
    // fresh array of objects on every render. The fix derives warnings via useMemo
    // over the stable `config` reference instead.
    const ds = createDataset({
      name: 'Nums',
      data: [
        { a: 1, b: 2 },
        { a: 3, b: 4 },
      ],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    // `add` reassigns a collision-free id (it is the id authority), so read it back.
    const id = useDatasetStore.getState().datasets[0].id;
    useChartBuilderStore.getState().init(id);
    useChartBuilderStore.getState().setMark('bar');
    expect(useChartBuilderStore.getState().config.mark).toBe('bar');

    // If the component looped, this act() would throw "Maximum update depth exceeded".
    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });

    expect(container.textContent).toContain('Building from');
    expect(container.textContent).toContain('scatter'); // the guidance hint rendered
  });

  test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => {
    const ds = createDataset({
      name: 'Nums',
      data: [
        { a: 1, b: 2 },
        { a: 3, b: 4 },
      ],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    useChartBuilderStore.getState().init(id);
    useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });

    // The hint renders a [Switch to Point] button (not just prose).
    const fixButton = Array.from(container.querySelectorAll('button')).find(
      (b) => b.textContent === 'Switch to Point',
    );
    expect(fixButton).toBeDefined();
    expect(container.textContent).toContain('scatter');

    await act(async () => {
      fixButton!.click();
      await Promise.resolve();
    });

    // Applying it switches the mark and the hint re-derives away.
    expect(useChartBuilderStore.getState().config.mark).toBe('point');
    expect(container.textContent).not.toContain('scatter');
  });

  test('shows the canvas-limit message when the chart resolves too large to render', async () => {
    vi.useFakeTimers();
    const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer');
    vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383));

    const ds = createDataset({
      name: 'Big',
      data: [
        { a: 1, b: 'x' },
        { a: 2, b: 'y' },
      ],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    useChartBuilderStore.getState().init(id);

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });
    // Drive the debounced render so renderSpec runs and rejects with the limit error.
    await act(async () => {
      await vi.advanceTimersByTimeAsync(400);
    });

    expect(container.textContent).toContain('larger than the browser can draw on a canvas');
    expect(container.textContent).toContain('200,000'); // the measured height
    vi.useRealTimers();
  });

  test('shows the empty state when no dataset is loaded', async () => {
    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });
    expect(container.textContent).toContain('No dataset loaded');
  });

  test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
    vi.useFakeTimers();
    const { renderSpec } = await import('../services/chart-renderer');
    vi.mocked(renderSpec).mockClear();
    const ds = createDataset({
      name: 'Sales',
      data: [
        { region: 'N', revenue: 100 },
        { region: 'S', revenue: 50 },
      ],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    const store = useChartBuilderStore.getState();
    store.init(id);
    store.addFilter();
    const fid = useChartBuilderStore.getState().config.filters![0].id;
    store.setFilterField(fid, 'revenue');
    store.updateFilter(fid, { op: 'gt', value: '60' });

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });
    await act(async () => {
      await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
    });

    expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
    const calls = vi.mocked(renderSpec).mock.calls;
    const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
    expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
    vi.useRealTimers();
  });

  test('the data preview discloses the dataset rows on demand (1D)', async () => {
    const ds = createDataset({
      name: 'Sales',
      data: [{ region: 'North', revenue: 100 }],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    useChartBuilderStore.getState().init(id);

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });

    const toggle = Array.from(container.querySelectorAll('button')).find((b) =>
      b.textContent?.includes('Preview rows'),
    );
    expect(toggle).toBeTruthy();
    expect(container.querySelector('table')).toBeNull(); // collapsed by default

    await act(async () => {
      toggle!.click();
      await Promise.resolve();
    });

    expect(container.querySelector('table')).toBeTruthy();
    expect(container.textContent).toContain('region');
    expect(container.textContent).toContain('North');
  });

  test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => {
    const ds = createDataset({
      name: 'S',
      data: [{ a: 1 }],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    const store = useChartBuilderStore.getState();
    store.init(id);
    store.addFilter();
    const fid = useChartBuilderStore.getState().config.filters![0].id;
    store.setFilterMode(fid, 'expression');
    store.updateFilter(fid, { expr: 'datum.a *' });

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });

    // Polite, not assertive: live per-keystroke validation uses role="status" with a
    // status glyph, never an assertive alert (council: APG Alert / WCAG 2.2.4).
    const messages = Array.from(container.querySelectorAll('[role="status"]'));
    const errorMsg = messages.find((n) => /nexpected|Invalid/.test(n.textContent ?? ''));
    expect(errorMsg).toBeTruthy();
    // The expression input is linked to its message and flagged invalid.
    const exprInput = container.querySelector('input[aria-label="Filter expression"]');
    expect(exprInput?.getAttribute('aria-invalid')).toBe('true');
    expect(exprInput?.getAttribute('aria-describedby')).toBe(errorMsg?.id);
  });

  test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
    const ds = createDataset({
      name: 'S',
      data: [{ a: 1 }],
      format: 'json',
      source: 'inline',
      now: T,
    });
    useDatasetStore.getState().add(ds);
    const id = useDatasetStore.getState().datasets[0].id;
    useChartBuilderStore.getState().init(id);

    await act(async () => {
      root.render(<ChartBuilderModal />);
      await Promise.resolve();
    });
    const refLink = () =>
      Array.from(container.querySelectorAll('a')).find((a) =>
        a.textContent?.includes('Vega expression'),
      );
    expect(refLink()).toBeUndefined(); // no expression yet → no reference link

    await act(async () => {
      useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression
      await Promise.resolve();
    });
    expect(refLink()).toBeDefined();
    expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
  });
});
