/**
 * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import {
  chartConfigForSelection,
  chartThemeOptions,
  type ChartThemeSelection,
} from '@core/vega-themes';
import { openModal } from '../modals/ModalCoordinator';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
import { DataInspector } from './DataInspector';
import { InspectorSplitHandle } from './InspectorSplitHandle';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
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>> = [
  // `title` doubles as the accessible name, so it leads with the visible label
  // (WCAG 2.5.3 label-in-name; see SegmentedOption.title).
  { value: 'default', label: 'Original', title: 'Original — the natural size from the spec' },
  { value: 'width', label: 'Width', title: 'Width — fit to the pane width' },
  { value: 'height', label: 'Height', title: 'Height — fit to the pane height' },
  { value: 'full', label: 'Full', title: 'Full — fill the pane' },
];

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

/**
 * Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config
 * charts render (and export) with. A global preference, not per-snippet: a
 * snippet's own `config` still overrides it property by property. Lives in the
 * header, not inside PreviewSettings: SelectControl and SettingsPopover share
 * the one-open-popover registry, so a select nested in the popover would close
 * (and unmount) its own parent on open (the contract: arch 10 §5 — a control
 * needing its own popover sits beside the gear, never inside the panel). The
 * trigger's 16ch label cap keeps long preset/custom names from crowding a
 * narrow header.
 */
/** Sentinel option that opens the Theme Builder instead of selecting a theme. */
const EDIT_THEMES = 'edit-themes';
type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES;

function ChartThemeControl() {
  const chartTheme = useAppStore((s) => s.chartTheme);
  const setChartTheme = useAppStore((s) => s.setChartTheme);
  const customThemes = useCustomThemeStore((s) => s.themes);
  // Fresh-array derivation — memoize so the picker doesn't re-render the world
  // (docs/architecture/01: derive with useMemo, never store).
  const options = useMemo<ReadonlyArray<SelectControlOption<ThemePickerValue>>>(() => {
    const list: SelectControlOption<ThemePickerValue>[] = [...chartThemeOptions(customThemes)];
    // The "manage" entry rides in the value list (the VS Code theme-picker
    // pattern); choosing it opens the builder and leaves the selection alone.
    // It closes the custom-themes block — right after the built-ins, BEFORE the
    // long preset roster — so it is visible without scrolling and sits next to
    // the entries it manages. The roster boundary itself (the divider) is set by
    // chartThemeOptions where the order is decided, so the splice is the only
    // index this component owns. The first preset is the divider-carrying option,
    // so splicing right before it needs no count arithmetic. The row needs no
    // special styling: SelectControl options are real buttons (not listbox
    // options), the "…" is the opens-further-UI convention, and `hasPopup`
    // announces the dialog to AT.
    const firstPreset = list.findIndex((o) => o.dividerBefore);
    list.splice(firstPreset === -1 ? list.length : firstPreset, 0, {
      value: EDIT_THEMES,
      label: 'Edit themes…',
      detail: 'Create and manage custom themes',
      hasPopup: 'dialog',
    });
    return list;
  }, [customThemes]);
  return (
    <SelectControl
      id="preview-chart-theme"
      label="Chart theme"
      options={options}
      value={chartTheme}
      onSelect={(value) => {
        if (value === EDIT_THEMES) openModal('themeBuilder');
        else setChartTheme(value);
      }}
      triggerTitle="Chart theme — how charts are styled when rendered and exported"
    />
  );
}

/** 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);
  // The config the live view was last rendered with — read at SVG export to find
  // which uploaded fonts the chart references (a font may live only in the theme
  // config, not the spec). Tracks `handleRef`: set together, valid whenever a
  // handle is.
  const configRef = useRef<Config | null>(null);
  const generationRef = useRef(0);
  // Serializes node mutations across overlapping renders: each render chains onto
  // the previous one's promise so only one vega-embed ever touches the shared host
  // node at a time (see the render effect for why this matters).
  const renderChainRef = useRef<Promise<void>>(Promise.resolve());
  const shownText = useSnippetStore(selectShownText);
  const fitMode = useAppStore((s) => s.previewFitMode);
  const uiTheme = useAppStore((s) => s.uiTheme);
  const chartTheme = useAppStore((s) => s.chartTheme);
  // Data inspector: open-state gates the divider + table; its height is the divider's.
  const inspectorOpen = useAppStore((s) => s.dataInspectorOpen);
  const inspectorHeight = useAppStore((s) => s.dataInspectorHeight);
  // Custom themes feed `custom:<id>` selection resolution; re-rendering on a
  // change keeps the chart live while a selected theme is edited in the builder.
  const customThemes = useCustomThemeStore((s) => s.themes);
  // 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);
  // Mirrors whether `handleRef` currently holds a live view, so the per-chart
  // export's image actions (which need the view) can enable/disable reactively —
  // a ref change alone wouldn't re-render. Set true on a successful render, false
  // on clear/error/unmount.
  const [chartReady, setChartReady] = useState(false);
  // Bumped whenever a render settles and changes the live view (success, clear,
  // error) so the data inspector re-reads the resolved rows. A monotonic counter,
  // not `chartReady` — consecutive successful renders keep `chartReady` true, but
  // each one is new data the inspector must pick up.
  const [renderEpoch, setRenderEpoch] = useState(0);
  // Bumped (debounced, inside the handle) when an interactive selection changes
  // the inspected data without a re-render — the live data inspector (spec §04;
  // multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse
  // re-reads the table without re-subscribing the listener; their sum is the
  // inspector's single refresh trigger (each event bumps exactly one, so the sum
  // is strictly monotonic — no collisions).
  const [liveEpoch, setLiveEpoch] = useState(0);

  // 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 () => {
        // Every render — including the empty/clear path — advances the generation
        // token, so a slow in-flight render that resolves later knows it's stale.
        // (Clearing must invalidate a pending render too, or it would redraw the
        // chart the user just cleared.)
        const mine = ++generationRef.current;
        const isEmpty = text === '';

        // Parse before touching the node so a syntax error leaves the chart up.
        let parsed: unknown;
        if (!isEmpty) {
          try {
            parsed = JSON.parse(text);
          } catch (e) {
            if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
            return;
          }
        }

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

        // Arm the busy indicator: if a (non-empty) 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);
        if (!isEmpty) {
          busyTimerRef.current = setTimeout(() => {
            // Only arm for the current generation; a superseded render doesn't own busy.
            if (mine === generationRef.current) setBusy(true);
          }, 1000);
        }

        // Serialize node mutations: wait for any in-flight render to settle before
        // we touch the shared host node. Two concurrent vega-embed calls on one node
        // interleave, and a superseded render's destroy() (which clears the node)
        // could blank the chart a newer render is showing. Holding this lock makes
        // both impossible. After the wait, bail if a newer render already superseded
        // us — no wasted embed, and the clear/destroy below only runs while we hold
        // the lock, so it can never wipe a chart another render is currently showing.
        const prior = renderChainRef.current;
        let release!: () => void;
        renderChainRef.current = new Promise<void>((r) => {
          release = r;
        });
        try {
          await prior;
          if (mine !== generationRef.current) {
            clearBusy();
            return;
          }

          // Empty/blank is not an error — clean, empty pane (spec §04).
          if (isEmpty) {
            handleRef.current?.destroy();
            handleRef.current = null;
            setChartReady(false);
            setRenderEpoch((e) => e + 1);
            setError(null);
            clearBusy();
            return;
          }

          try {
            const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
            const config = chartConfigForSelection(chartTheme, uiTheme, customThemes);
            handleRef.current?.destroy();
            handleRef.current = null;
            const handle = await renderSpec(node, prepared as VisualizationSpec, config);
            if (mine !== generationRef.current) {
              // A newer render superseded this one. We still hold the lock, so the
              // newer render hasn't drawn yet (it's queued behind us) — finalizing
              // this handle and clearing the node is safe; the newer render redraws
              // on a clean node when it acquires the lock.
              handle.destroy();
              return;
            }
            handleRef.current = handle;
            configRef.current = config;
            setChartReady(true);
            setRenderEpoch((e) => e + 1);
            setError(null);
            clearBusy();
          } catch (e) {
            if (mine === generationRef.current) {
              setChartReady(false);
              setRenderEpoch((e) => e + 1);
              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.`,
                );
              }
            }
          }
        } finally {
          release();
        }
      })();
    }, delay);

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

  // Rasterize/serialize the live view for the per-chart export (spec §08). Reads
  // `handleRef` (the view LivePreview owns); returns null only when no view is live
  // (the "not ready" case). A rasterize/serialize *failure* is a real error, not a
  // not-ready state, so it propagates for the export UI to report with its detail —
  // burying it here would misreport a tainted canvas or a serialization bug as
  // "not ready yet" (arch 02 fail-loud). Stable identity (no deps).
  const getImageUrl = useCallback(
    async (
      format: 'png' | 'svg',
      options: { scale: number; background: string | null },
    ): Promise<string | null> => {
      const handle = handleRef.current;
      if (!handle) return null;
      // Embed the referenced uploaded faces into an SVG so it renders off-app
      // (spec §08). Read fresh: a font can be uploaded after the last render, and
      // configRef holds the config that render used (a font may be theme-only).
      const embedFonts =
        format === 'svg'
          ? referencedUploadedFonts(
              selectShownText(useSnippetStore.getState()),
              configRef.current,
              useFontStore.getState().fonts,
            )
          : undefined;
      return await handle.toImageURL(format, { ...options, embedFonts });
    },
    [],
  );

  // The input + resolved rows for the data inspector — reads the live view through
  // the handle (null when no chart is up). Stable identity; the inspector re-reads
  // on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
  const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);

  // Live data inspection (spec §04): while the inspector is open, re-read the table
  // when an interactive selection changes the data it shows (a filtering brush). The
  // handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire.
  // Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current
  // handle, and only while the inspector is open so a collapsed one costs nothing.
  // handleRef is a ref (read, not a dep); the cleanup unsubscribes.
  useEffect(() => {
    if (!inspectorOpen) return;
    const handle = handleRef.current;
    if (!handle) return;
    return handle.onDataChange(() => setLiveEpoch((e) => e + 1));
  }, [inspectorOpen, renderEpoch]);

  // 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;
      setChartReady(false);
      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 />
        {/* Right cluster: chart theme, export this chart, then the settings gear. */}
        <div className={styles.headerEnd}>
          <ChartThemeControl />
          <ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
          <PreviewSettings />
        </div>
      </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>
      {/* Data inspector — input vs. resolved rows, stacked below the chart (a
          collapsed disclosure by default); reads the live view through getInspectData.
          When open, a draggable divider sizes it (chart absorbs the change) and the
          stored height fills the table; collapsed, it's just the disclosure bar. */}
      {inspectorOpen && (
        <InspectorSplitHandle label="Resize data inspector" controls="preview-data-inspector" />
      )}
      <DataInspector
        id="preview-data-inspector"
        getData={getInspectData}
        renderEpoch={renderEpoch + liveEpoch}
        heightPx={inspectorOpen ? inspectorHeight : undefined}
      />
    </div>
  );
}
