/**
 * Chart renderer (docs/architecture/05 §2).
 *
 * The one place in the app that touches `vega-embed` and the chart DOM.
 * Components ask it to draw a prepared spec into a node and own the returned
 * handle's lifecycle: every successful embed yields a live Vega `View` that must
 * be finalized before the next embed (or it leaks timers, listeners, and DOM).
 */

import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export';
import { inspectViewLabel, inspectableViews } from '@core/inspect-views';
import type { FontAsset } from '@core/font-asset';

/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
interface ImageExportOptions {
  /**
   * Pixel-density multiplier for the **PNG** raster, **relative to the device**.
   * The image is drawn at `scale × devicePixelRatio` logical-pixel density, so
   * `scale: 1` already matches on-screen crispness on a Retina display — the fix
   * for a soft "1×" export (`toImageURL`'s raw `scaleFactor` ignores dpr, so a
   * naive 1× looks half-resolution on a 2× display). Raise for print/zoom.
   * Ignored for the resolution-independent SVG. Default `1`.
   */
  scale?: number;
  /**
   * Opaque colour to paint behind the chart. The chart config renders a
   * **transparent** background (so the on-screen chart shows the pane colour),
   * which makes a naive export transparent; pass a colour to fill it. PNG is
   * composited onto the colour; SVG gets a background `<rect>`. Null/omitted keeps
   * it transparent. Default `null`.
   */
  background?: string | null;
  /**
   * Uploaded font faces the chart references, embedded into the **SVG** as base64
   * `@font-face` rules so it renders the right type off-app (spec §08; scope doc
   * §4). Ignored for PNG (the raster already bakes in the glyphs). Omitted/empty
   * leaves only the family name, which falls back to a system font elsewhere. The
   * caller (which holds the font library) resolves which faces are referenced.
   */
  embedFonts?: ReadonlyArray<FontAsset>;
}

/** One inspectable drawn table — the two ends of its pipeline (spec §04). */
export interface InspectableTable {
  /** Stable selection id — the resolved table's compiled name. */
  id: string;
  /** User-facing label (never a compiler name — `@core/inspect-views`). */
  label: string;
  /** Parsed source rows, before the view's transforms run — the input. */
  input: ReadonlyArray<Record<string, unknown>>;
  /** Post-transform rows the marks draw — the output (equals `input` when the view
   *  has no transforms). */
  resolved: ReadonlyArray<Record<string, unknown>>;
}

/**
 * The chart's inspectable data: one table per distinct table the marks draw, in
 * document order (a multi-view spec yields several). `tables` is empty when the
 * chart draws nothing inspectable, distinct from a `null` handle (no chart).
 */
export interface InspectedData {
  tables: InspectableTable[];
}

/**
 * Debounce for the live data inspector (spec §04; multi-view scope doc M5). A
 * brush drag pulses `addDataListener` continuously; coalescing to one re-read per
 * ~quiet-frame keeps the table feeling live without thrashing the grid on every
 * pixel of the drag.
 */
export const LIVE_INSPECT_DEBOUNCE_MS = 120;

/** The minimal Vega `View` surface the live-inspect watcher needs. */
interface DataChangeView {
  addDataListener(name: string, handler: () => void): unknown;
  removeDataListener(name: string, handler: () => void): unknown;
}

/**
 * Attach debounced listeners to every table the inspector shows so an interactive
 * selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the
 * inspector live — see `RenderHandle.onDataChange`. Watches the union of each
 * drawn table's resolved + input names (deduped); a highlight selection changes no
 * data, so none fire. Returns an unsubscribe that cancels any pending re-read and
 * detaches the listeners — but skips detaching once the view is finalized, since
 * `view.finalize()` has already dropped every listener (and the unmount cleanup
 * order can run this after the destroy). `isFinalized` is a getter, not a boolean,
 * so it reflects the view's state at unsubscribe time, not subscribe time.
 */
export function watchInspectableData(
  view: DataChangeView,
  vgSpec: unknown,
  onChange: () => void,
  isFinalized: () => boolean,
): () => void {
  if (isFinalized()) return () => {};
  const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))];
  if (names.length === 0) return () => {};

  let timer: ReturnType<typeof setTimeout> | null = null;
  const handler = (): void => {
    if (timer !== null) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      onChange();
    }, LIVE_INSPECT_DEBOUNCE_MS);
  };
  for (const name of names) view.addDataListener(name, handler);

  return () => {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
    if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler);
  };
}

export interface RenderHandle {
  /** Finalize the underlying Vega view and clear the node. */
  destroy(): void;
  /**
   * Rasterize/serialize the current view to a downloadable URL (spec §08 →
   * Per-chart export). `'png'` resolves to a `blob:` object URL (caller revokes
   * after download); `'svg'` to a `data:` URL. Renderer-agnostic — works from the
   * SVG-backed LivePreview view as well as a canvas one — because Vega draws to its
   * own off-screen surface here, independent of the display backend. Honors
   * `options` (dpr-aware scale, background fill). Rejects if the view was already
   * finalized.
   */
  toImageURL(format: 'png' | 'svg', options?: ImageExportOptions): Promise<string>;
  /**
   * Re-fit the chart to its container's current size (spec §04 Responsiveness).
   *
   * Vega-Lite compiles `"container"` sizing to width/height signals that re-read
   * `containerSize()` ONLY on a `window:resize` event (nothing observes the
   * element, and `view.resize()` alone re-runs layout with the stale size). So a
   * pane drag — which fires no window resize — needs us to synthesize that event.
   * Doing it this way also means only the container-bound dimensions re-fit
   * (fixed ones have no such handler), which is exactly right for Width/Height.
   */
  resize(): void;
  /**
   * The chart's inspectable tables — for the data inspector (spec §04). Enumerates
   * the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
   * and for each reads its input + resolved rows from the live view. A multi-view
   * spec yields several tables; a unit spec yields one. This is the one place
   * besides export that reaches into the view, so the embedding boundary holds
   * (arch 05 §1–§2) — callers get rows, never the `view`.
   *
   * Returns `null` when the view was finalized (no chart). The result's `tables`
   * is empty when a chart draws nothing inspectable, and any table's `input`/
   * `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from
   * "no chart" so the inspector can say which.
   */
  inspectData(): InspectedData | null;
  /**
   * Subscribe to live changes of the inspected tables, for the data inspector's
   * live mode (spec §04; multi-view scope doc M5). An interactive selection that
   * *filters* a downstream view recomputes that view's compiled table in place —
   * no re-embed — so a static inspector would show stale rows until the next full
   * render; this fires (debounced) so the caller can re-read via `inspectData()`.
   * A highlight selection (a `condition` encoding) changes no data, so it never
   * fires. Returns an unsubscribe; a no-op when the view is already finalized.
   */
  onDataChange(listener: () => void): () => void;
}

export interface RenderOptions {
  /**
   * Renderer backend. **Default `'svg'`** — crisp at any zoom, themeable, the
   * contract default for the editor's LivePreview (docs/architecture/05 §2). The
   * Chart Builder preview passes **`'canvas'`**: an SVG chart with thousands of
   * marks (e.g. one bar per row of a 10k-row dataset) costs *seconds* of
   * main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
   * because each mark is a DOM node; canvas is a single node and paints in
   * milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
   * ephemeral preview, and image export (`RenderHandle.toImageURL`) is renderer-agnostic.
   */
  renderer?: 'svg' | 'canvas';
}

/**
 * Maximum canvas side length, in CSS px before the device-pixel-ratio multiplier.
 * Browsers cap a canvas backing store at ~32767px per side (Chrome/Firefox; Safari
 * is lower and area-bound); past that the canvas fails to allocate and draws
 * nothing. SVG has no such cap. `renderSpec` measures a canvas chart's resolved
 * size against this (÷ dpr, since the backing store is dpr× the CSS size) and
 * throws `ChartTooLargeError` rather than handing back a blank canvas.
 */
const MAX_CANVAS_PX = 32767;

/**
 * Thrown by `renderSpec` when a **canvas**-backed chart resolves to a height larger
 * than the browser can allocate (see `MAX_CANVAS_PX`). Carries the measured size and
 * the limit so the caller can explain the *actual* cause — the chart is physically
 * too large to draw — rather than guessing at "too many categories". This is a
 * render-backend limit, distinct from the readability cardinality warnings.
 */
export class ChartTooLargeError extends Error {
  /** The chart's resolved height, in CSS px. */
  readonly heightPx: number;
  /** The per-side limit at the current device-pixel-ratio, in CSS px. */
  readonly limitPx: number;
  constructor(heightPx: number, limitPx: number) {
    super(
      `Chart is ${Math.round(heightPx)}px tall — over the ~${Math.round(limitPx)}px canvas limit`,
    );
    this.name = 'ChartTooLargeError';
    this.heightPx = heightPx;
    this.limitPx = limitPx;
  }
}

/** The canvas side limit in CSS px at the current display's device-pixel-ratio. */
function canvasLimitPx(): number {
  const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
  return MAX_CANVAS_PX / dpr;
}

/** Encode an SVG string as a `data:` URL (no blob to revoke). */
function svgDataUrl(svg: string): string {
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

/** Paint a full-bleed background `<rect>` as the first child of the root `<svg>`,
 *  so the exported SVG isn't transparent. Vega emits explicit width/height on the
 *  root, so `100%` resolves to the chart's box. */
function withSvgBackground(svg: string, color: string): string {
  return svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${color}"/>`);
}

/** Composite a (transparent) chart canvas onto an opaque colour, same dimensions. */
function compositeOnColor(chart: HTMLCanvasElement, color: string): HTMLCanvasElement {
  const out = document.createElement('canvas');
  out.width = chart.width;
  out.height = chart.height;
  const ctx = out.getContext('2d');
  if (!ctx) return chart; // no 2d context — fall back to the transparent chart
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, out.width, out.height);
  ctx.drawImage(chart, 0, 0);
  return out;
}

/** A canvas → `blob:` object URL (PNG). The caller revokes it after the download. */
function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (blob) resolve(URL.createObjectURL(blob));
      else reject(new Error('Could not encode the chart as a PNG.'));
    }, 'image/png');
  });
}

/** Weights to preload per family — the roster ships 400/600 (700 for the mono). */
const FONT_LOAD_WEIGHTS = ['400', '600', '700'];
/**
 * Cap on waiting for fonts before rendering anyway. A precached/cached face
 * resolves near-instantly; this only bounds the first fetch of an uncached
 * subset so a slow network never freezes the preview — it renders with fallback
 * metrics and sharpens on a later re-render once the face is cached.
 */
const FONT_LOAD_TIMEOUT_MS = 3000;

/**
 * Load the font faces a spec/config will use before rendering. Vega measures
 * every text label via canvas `measureText` regardless of renderer (even the
 * 'none' probe runs layout), so a face that finishes loading *after* embed
 * leaves the whole chart laid out with fallback metrics.
 *
 * Font loading is a non-critical enhancement, and an individual face failing
 * (offline, a 404, or a system family with no `@font-face`) is *expected* — the
 * correct response is to render with fallback metrics, not to fail the chart. So
 * we wait on `allSettled` (which never rejects; a rejected load is ignored by
 * design) and bound a slow first fetch with the timeout. This is the sanctioned
 * "safe to swallow" case from arch 02's fail-loud rule — the failure mode is
 * graceful fallback, not a buried bug or lost data — kept explicit rather than
 * hidden in a catch-all. No-op where the Font Loading API is absent (tests).
 */
async function ensureFontsLoaded(spec: VisualizationSpec, config: Config): Promise<void> {
  if (typeof document === 'undefined' || !document.fonts?.load) return;
  const families = new Set<string>([...collectFontFamilies(config), ...collectFontFamilies(spec)]);
  if (families.size === 0) return;
  const loads = [...families].flatMap((family) =>
    FONT_LOAD_WEIGHTS.map((weight) => document.fonts.load(`${weight} 16px ${family}`)),
  );
  await Promise.race([
    Promise.allSettled(loads),
    new Promise((resolve) => setTimeout(resolve, FONT_LOAD_TIMEOUT_MS)),
  ]);
}

/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
export async function renderSpec(
  node: HTMLElement,
  spec: VisualizationSpec,
  config: Config,
  options: RenderOptions = {},
): Promise<RenderHandle> {
  const renderer = options.renderer ?? 'svg';

  // Load the chart's fonts before any layout pass measures text (see the helper).
  await ensureFontsLoaded(spec, config);

  // Canvas can't allocate past the browser's max dimension, and an oversized canvas
  // fails *silently* (a blank/broken surface, sometimes a null 2d context). So for
  // canvas we first run a headless ('none') layout pass — no canvas allocated — read
  // the chart's resolved height, and throw with the real numbers if it won't fit.
  // SVG renders any size (just slowly), so it skips this. The probe uses a detached
  // node and is finalized immediately; only its computed `height` signal is read.
  if (renderer === 'canvas') {
    const probeHost = document.createElement('div');
    const probe = await vegaEmbed(probeHost, spec, { actions: false, renderer: 'none', config });
    const height = probe.view.height();
    probe.view.finalize();
    const limit = canvasLimitPx();
    if (typeof height === 'number' && height > limit) throw new ChartTooLargeError(height, limit);
  }

  const result: EmbedResult = await vegaEmbed(node, spec, {
    actions: false, // Astrolabe owns its own export/copy affordances
    renderer,
    config,
    // Suppress vega-tooltip's bundled light/dark stylesheet so the app owns the
    // tooltip's look. With the default style gone, the single `#vg-tooltip-element`
    // it appends to <body> is styled entirely from our design tokens in base.css —
    // surface, type, and the key/value table — and follows `[data-theme]` for free
    // (the element inherits the cascade from <html>). vega-tooltip still positions
    // the element (inline top/left) and toggles `.visible`; our CSS supplies the
    // structural rules (position/visibility/z-index) the default sheet used to.
    tooltip: { disableDefaultStyle: true },
  });

  // Reads of a finalized view throw; `inspectData` checks this to no-op safely
  // after destroy() (the inspector may read on a render that resolved late).
  let finalized = false;

  return {
    destroy() {
      finalized = true;
      result.view.finalize();
      node.replaceChildren();
    },
    async toImageURL(format, options = {}) {
      const { scale = 1, background = null, embedFonts = [] } = options;
      if (format === 'svg') {
        // Vector — resolution-independent, so dpr/scale don't apply. The view
        // writes only family names, so referenced uploaded faces are embedded as
        // @font-face data-URIs; a background is added as a full-bleed rect. Both
        // are injected into the serialized string, not the live view.
        let svg = embedFontsInSvg(await result.view.toSVG(), embedFonts);
        if (background) svg = withSvgBackground(svg, background);
        return svgDataUrl(svg);
      }
      // Multiply the requested scale by the device pixel ratio so a "1×" export is
      // as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
      const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
      const chart = await result.view.toCanvas(scale * dpr);
      const out = background ? compositeOnColor(chart, background) : chart;
      return canvasObjectUrl(out);
    },
    resize() {
      // Synthesize the window:resize the container signals listen for (see the
      // interface doc). The view re-reads containerSize() and re-renders itself;
      // a finalized view has already removed its listener, so this is a safe
      // no-op after destroy().
      if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
    },
    inspectData() {
      if (finalized) return null;
      // The tables the marks draw + their input lineage come from the compiled Vega
      // spec (a byproduct of the embed, not recompiled); the rows come from
      // view.data(name), which hands back the live array (no copy).
      const views = inspectableViews(result.vgSpec);
      const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
        (result.view.data(name) ?? []) as Record<string, unknown>[];
      const tables = views.map((v, i) => ({
        id: v.resolved,
        label: inspectViewLabel(v.input, i),
        input: rows(v.input),
        resolved: rows(v.resolved),
      }));
      return { tables };
    },
    onDataChange(listener) {
      return watchInspectableData(result.view, result.vgSpec, listener, () => finalized);
    },
  };
}
