/**
 * Live Preview — the right pane (spec §04).
 *
 * Renders the active snippet's currently-shown spec (draft or published, per the
 * editor view) as a Vega-Lite chart, debounced so typing stays smooth. The
 * pipeline is: shown text → JSON.parse → prepareSpecForRender (copy, pure, fit
 * mode applied) → renderSpec (vega-embed). A render-generation token guards
 * against a slow render resolving after a newer one.
 *
 * The pane header carries the Fit control (4 sizing modes, §04). Render errors
 * are published to the shared PreviewStore so the editor pane mirrors them
 * (§03E); the preview shows the same message in place of the chart.
 *
 * M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
 * (M3) plugs into prepareSpecForRender without changing this component.
 */

import { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';

/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
  { value: 'default', label: 'Original' },
  { value: 'width', label: 'Width' },
  { value: 'height', label: 'Height' },
  { value: 'full', label: 'Full' },
];

/**
 * Sizing class for the chart frame per fit mode. The frame is React-owned, so
 * these classes drive how the host element (which vega-embed brands with its own
 * `display:inline-block`) is sized: the responsive modes give the host a
 * definite width/height for Vega's `"container"` measurement (`containerSize()`
 * reads `host.clientWidth/Height`), while Original lets it shrink to natural size.
 */
const FIT_CLASS: Record<FitMode, string> = {
  default: styles.fitOriginal,
  width: styles.fitWidth,
  height: styles.fitHeight,
  full: styles.fitFull,
};

function FitControl() {
  const fitMode = useAppStore((s) => s.previewFitMode);
  const setFitMode = useAppStore((s) => s.setPreviewFitMode);
  // Single-select set → a radio group, not independent toggles (APG; doc §10.5).
  return (
    <SegmentedControl
      label="Fit chart to pane"
      options={FIT_OPTIONS}
      value={fitMode}
      onChange={setFitMode}
    />
  );
}

/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
function PreviewSettings() {
  const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
  const setPerformance = useUserSettingsStore((s) => s.setPerformance);
  return (
    <SettingsPopover id="preview-settings" label="Preview settings" title="Preview">
      <SettingRow label="Render debounce" htmlFor="set-debounce">
        <RangeControl
          id="set-debounce"
          min={500}
          max={5000}
          step={100}
          value={renderDebounce}
          suffix="ms"
          onChange={(renderDebounce) => setPerformance({ renderDebounce })}
        />
      </SettingRow>
    </SettingsPopover>
  );
}

export function LivePreview() {
  const hostRef = useRef<HTMLDivElement>(null);
  const handleRef = useRef<RenderHandle | null>(null);
  const generationRef = useRef(0);
  const shownText = useSnippetStore(selectShownText);
  const fitMode = useAppStore((s) => s.previewFitMode);
  const uiTheme = useAppStore((s) => s.uiTheme);
  // Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
  // dataset change keeps a referencing chart live as its data is edited.
  const datasets = useDatasetStore(useShallow((s) => s.datasets));
  // A programmatic buffer load (select/create/revert/hydrate — `bufferEpoch`) or a
  // Draft/Published switch (`editorView`) must render *immediately*, not after the
  // typing debounce (spec §03C). Keystrokes change only `shownText`, so when these
  // two are unchanged the change is typing and the debounce applies.
  const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
  const editorView = useSnippetStore((s) => s.editorView);
  // User-tunable render debounce (spec §07 → Performance).
  const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
  // Seed with a sentinel epoch so the very first paint counts as a load (immediate).
  const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
  const error = usePreviewStore((s) => s.error);
  const setError = usePreviewStore((s) => s.setError);
  const busy = usePreviewStore((s) => s.busy);
  const setBusy = usePreviewStore((s) => s.setBusy);

  // Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
  // overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
  // avoid flicker on typical fast renders).
  const busyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    const node = hostRef.current;
    if (!node) return;
    const text = shownText.trim();

    // Immediate on snippet load / view switch (spec §03C), debounced while typing.
    const prevLoad = lastLoadRef.current;
    const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
    lastLoadRef.current = { bufferEpoch, editorView };
    const delay = immediate ? 0 : renderDebounce;

    // The debounced body is async; wrap in a void IIFE so the timer callback
    // returns void (it handles its own errors internally — nothing awaits it).
    const timer = setTimeout(() => {
      void (async () => {
        // Empty/blank is not an error — clean, empty pane (spec §04).
        if (text === '') {
          handleRef.current?.destroy();
          handleRef.current = null;
          setError(null);
          return;
        }

        let parsed: unknown;
        try {
          parsed = JSON.parse(text);
        } catch (e) {
          setError(`Invalid JSON: ${(e as Error).message}`);
          return;
        }

        const mine = ++generationRef.current;

        // Arm the busy indicator: if the render hasn't settled within ~1s, flip the
        // flag. Sub-1s renders (the common case) never show the overlay — no flicker
        // (arch §10.2; NN/g ≤1s = uninterrupted thought, >1s = noticeably waiting).
        if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
        busyTimerRef.current = setTimeout(() => {
          // Only arm for the current generation; a superseded render doesn't own busy.
          if (mine === generationRef.current) setBusy(true);
        }, 1000);

        /** Clear busy and the timer unconditionally — called on settle or error. */
        const clearBusy = () => {
          if (busyTimerRef.current !== null) {
            clearTimeout(busyTimerRef.current);
            busyTimerRef.current = null;
          }
          setBusy(false);
        };

        try {
          const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
          const config = chartConfigFor(uiTheme);
          handleRef.current?.destroy();
          handleRef.current = null;
          const handle = await renderSpec(node, prepared as VisualizationSpec, config);
          if (mine !== generationRef.current) {
            // TODO: a superseded render's destroy() calls node.replaceChildren(),
            // which can blank the live chart if two embeds on the same node are
            // ever in flight at once (heavy spec whose embed outlasts the 300ms
            // debounce). The debounce makes this rare; when dataset work (M3)
            // lands, serialize renders or finalize the stale view without
            // clearing the shared node.
            handle.destroy(); // a newer render superseded this one
            return;
          }
          handleRef.current = handle;
          setError(null);
          clearBusy();
        } catch (e) {
          if (mine === generationRef.current) {
            clearBusy();
            // A missing dataset reference is not a JSON/spec problem, so it gets a
            // tailored, fixable message instead of the generic syntax hint (council:
            // GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
            if (e instanceof DatasetNotFoundError) {
              setError(
                `Dataset "${e.datasetName}" not found. Create it from Datasets ` +
                  `(⌘/Ctrl+K), or check the dataset name in your spec.`,
              );
            } else {
              setError(
                `Rendering error: ${(e as Error).message}. ` +
                  `Check your JSON syntax and that the spec is valid Vega-Lite.`,
              );
            }
          }
        }
      })();
    }, delay);

    return () => clearTimeout(timer);
  }, [
    shownText,
    fitMode,
    uiTheme,
    datasets,
    setError,
    setBusy,
    bufferEpoch,
    editorView,
    renderDebounce,
  ]);

  // Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
  // observe the element, so we do: one observer on the stable host node for the
  // component's life. Only responsive fit modes depend on container size;
  // Original is fixed natural size and the pane just scrolls. ResizeObserver
  // callbacks are frame-batched, so this tracks the drag without thrashing.
  useEffect(() => {
    const node = hostRef.current;
    if (!node || typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(() => {
      if (useAppStore.getState().previewFitMode === 'default') return;
      handleRef.current?.resize();
    });
    ro.observe(node);
    return () => ro.disconnect();
  }, []);

  // Finalize the live view on unmount, and clear the shared error + busy state so
  // stale transient state never outlives this pane.
  useEffect(
    () => () => {
      handleRef.current?.destroy();
      handleRef.current = null;
      if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
      usePreviewStore.getState().setError(null);
      usePreviewStore.getState().setBusy(false);
    },
    [],
  );

  return (
    <div className={styles.preview}>
      <div className={styles.header}>
        <FitControl />
        <PreviewSettings />
      </div>
      {/*
       * aria-busy on the chart region tells AT the area is being updated (arch §10.2;
       * spec §04). The overlay is a non-blocking sibling inside the relative-positioned
       * body; it never covers the header or editor, so editing stays fully interactive.
       */}
      <div className={styles.body} aria-busy={busy || undefined}>
        {/* Frame is React-owned and carries the fit-sizing class; the inner host
            is owned by vega-embed (it brands it `.vega-embed` and mutates its
            classList at runtime), so its className stays static and React never
            clobbers Vega's own classes. */}
        <div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
          <div className={styles.host} ref={hostRef} />
        </div>
        {/* Visual only — no live region. The same error is announced once by the
            editor pane's role="alert" (one producer, two subscribers; doc §10.1),
            so adding one here would double-announce it. */}
        {error !== null && <pre className={styles.error}>{error}</pre>}
        {/*
         * Busy overlay: non-blocking, overlays only the chart body, never the header
         * or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
         * so sub-1s renders produce no flicker. The spinner animation is suppressed
         * under prefers-reduced-motion (base.css *{animation-duration:0.01ms}).
         */}
        {busy && (
          <div className={styles.busyOverlay} aria-hidden="true">
            <span className={styles.busySpinner} />
            <span className={styles.busyLabel}>Rendering…</span>
          </div>
        )}
      </div>
    </div>
  );
}
