/**
 * LivePreview — busy overlay + render serialization (spec §04; arch §10.2).
 *
 * The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
 * `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
 * embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
 * the busy overlay is driven through its real path — a render left in flight past
 * the ~1s timer — not by poking a flag.
 */

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview';

// Controllable renderSpec: each call gets an id and parks a resolver in `H.pending`
// so a test can decide exactly when (and in what order) embeds settle — the only
// way to exercise the overlapping-render serialization deterministically. `destroy`
// records its id and clears the node, mirroring the real handle (which calls
// node.replaceChildren()), so a wrongful blank would be observable.
const H = vi.hoisted(() => ({
  calls: 0,
  pending: [] as Array<() => void>,
  destroyed: [] as number[],
  configs: [] as unknown[],
}));
vi.mock('../services/chart-renderer', () => ({
  renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
    const id = ++H.calls;
    H.configs.push(config);
    return new Promise((resolve) => {
      H.pending.push(() => {
        node.replaceChildren(); // a real embed wipes then rebuilds the host
        const mark = document.createElement('div');
        mark.dataset.render = String(id);
        node.appendChild(mark);
        resolve({
          destroy() {
            node.replaceChildren();
            H.destroyed.push(id);
          },
          resize() {},
          inspectData: () => null,
          onDataChange: () => () => {},
        });
      });
    });
  },
}));
vi.mock('./SettingsPopover', () => ({
  SettingsPopover: () => null,
  SettingRow: () => null,
  RangeControl: () => null,
}));
// Isolate the busy-overlay assertions (which locate the overlay by its
// `aria-hidden="true"`) from the export control's own decorative icon: the
// per-chart export is a sibling header control, mocked out like SettingsPopover.
vi.mock('./ChartExport', () => ({ ChartExport: () => null }));

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

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
  H.calls = 0;
  H.pending.length = 0;
  H.destroyed.length = 0;
  H.configs.length = 0;
  useSnippetStore.getState().reset();
  useDatasetStore.getState().reset();

  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
  act(() => root.render(<LivePreview />));
});

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

describe('LivePreview busy overlay', () => {
  const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));

  // The overlay is the aria-hidden element carrying the "Rendering…" label — a
  // bare [aria-hidden] query would also match decorative bits of the header
  // controls (e.g. the chart-theme select's caret).
  const overlay = () =>
    [...container.querySelectorAll('[aria-hidden="true"]')].find((el) =>
      /rendering/i.test(el.textContent ?? ''),
    ) ?? null;

  // Start a render and leave it parked in `H.pending`; advancing past the debounce
  // and the ~1s busy timer flips `busy` on — the real (and only) path now that it
  // is local state.
  const startSlowRender = async () => {
    act(() => {
      useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
    });
    await tick();
  };

  test('no overlay and no aria-busy before a render is in flight', () => {
    expect(overlay()).toBeNull();
    // aria-busy="false" is technically valid but needlessly verbose; we omit it.
    expect(container.querySelector('[aria-busy]')).toBeNull();
  });

  test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
    vi.useFakeTimers();
    try {
      await startSlowRender();
      expect(overlay()).not.toBeNull();
      expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });

  test('the overlay clears once the render settles', async () => {
    vi.useFakeTimers();
    try {
      await startSlowRender();
      expect(overlay()).not.toBeNull();
      act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
      await tick(0);
      expect(overlay()).toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe('LivePreview render serialization', () => {
  /** Drive the debounced render effect to completion under fake timers. */
  const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));

  test('a second render waits for the in-flight one, which is finalized when superseded', async () => {
    vi.useFakeTimers();
    try {
      // Render A: valid, ref-less spec so prepareSpecForRender passes and we reach
      // renderSpec (which now parks, awaiting our resolver).
      act(() => {
        useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
      });
      await tick();
      expect(H.calls).toBe(1); // A invoked renderSpec and is in flight

      // Render B arrives while A is still embedding. It must NOT start a second
      // embed on the shared node — it queues behind A's lock.
      act(() => {
        useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"bar"}' });
      });
      await tick();
      expect(H.calls).toBe(1); // still only A's embed; B is serialized behind it

      // A settles. It was superseded by B, so its handle is finalized (destroy),
      // and only now does B acquire the lock and start its embed.
      act(() => H.pending[0]());
      await tick(0);
      expect(H.destroyed).toContain(1); // superseded A finalized
      expect(H.calls).toBe(2); // B now embeds

      // B settles and becomes the live chart — never destroyed, and its node
      // content survives (A's finalize ran before B drew, so nothing blanked it).
      act(() => H.pending[1]());
      await tick(0);
      expect(H.destroyed).not.toContain(2);
      expect(container.querySelector('[data-render="2"]')).not.toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });

  test('clearing the editor invalidates a pending render (no stale redraw)', async () => {
    vi.useFakeTimers();
    try {
      act(() => {
        useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
      });
      await tick();
      expect(H.calls).toBe(1); // render A in flight

      // Editor cleared while A embeds. The clear advances the generation token, so
      // when A finally resolves it knows it is stale and finalizes instead of
      // committing — the pane ends up empty, not showing A's chart.
      act(() => {
        useSnippetStore.setState({ draftText: '' });
      });
      await tick();
      act(() => H.pending[0]());
      await tick(0);

      expect(H.destroyed).toContain(1); // stale A finalized, not committed
      expect(container.querySelector('[data-render="1"]')).toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe('LivePreview chart theme', () => {
  const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));

  test('the selected chart theme decides the config passed to renderSpec', async () => {
    vi.useFakeTimers();
    try {
      act(() => {
        useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' });
        useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
      });
      await tick();
      act(() => H.pending[0]());
      await tick(0);
      // Stock = inject nothing; vega-lite's own defaults apply.
      expect(H.configs[0]).toEqual({});

      // Switching the theme re-renders with the new config (no text change needed).
      act(() => useAppStore.setState({ chartTheme: 'astrolabe' }));
      await tick();
      act(() => H.pending[1]());
      await tick(0);
      expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark'));
    } finally {
      vi.useRealTimers();
      act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' }));
    }
  });
});
