/**
 * Rendering contract — pure spec preparation (spec §04 → Rendering Contract).
 *
 * Portable core: no browser APIs, no React, no vega-embed. `prepareSpecForRender`
 * is the single transform that sits between "parsed spec the user authored" and
 * "spec the preview actually embeds" (see docs/architecture/05). It performs two
 * deterministic steps, in order, **on a deep copy** so the user's stored spec is
 * never mutated by rendering:
 *
 *   1. Dataset reference resolution  — implemented in M3 (see below).
 *   2. Fit-mode sizing               — implemented in M2.
 *
 * Step 1 (spec §04 → Rendering Contract): every library reference
 * (`{ data: { name } }`) is replaced in-place with the referenced library
 * dataset's actual contents, shaped by source and format. What counts as a library
 * reference is decided by `core/spec-data` (named data, not self-defined), so a
 * `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
 * is left untouched; a name the spec defines for itself via a top-level `datasets`
 * object is left untouched too (Vega-Lite resolves it natively); an unknown library
 * name throws `DatasetNotFoundError`. Resolution recurses into the same nested
 * sub-specs as fit-mode, runs before sizing, and operates only on the copy.
 *
 * The copy-not-mutate invariant and the call site the renderer depends on are
 * fixed.
 */

import type { DataFormat } from './format-detection';
import type { DataSource } from './dataset';
import { libraryRefName, selfDefinedNames } from './spec-data';

/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
export type FitMode = 'default' | 'width' | 'height' | 'full';

/**
 * The minimal structural view of a dataset that reference resolution needs. The
 * DatasetStore's records are structurally compatible, so passing them works
 * without importing the full `Dataset` type (and keeps this free of any cycle).
 */
export interface ResolvableDataset {
  /** The library name a spec references via `{ data: { name } }`. */
  name: string;
  /** The payload — see `Dataset.data` for the per-source/format shape. */
  data: unknown;
  /** One of `json`, `csv`, `tsv`, `topojson`. */
  format: DataFormat;
  /** One of `inline` or `url`. */
  source: DataSource;
  /** For `source = url`: the remote address, used only as the unfetched fallback. */
  url?: string;
}

/** Thrown when a spec references a library dataset name that does not exist. */
export class DatasetNotFoundError extends Error {
  /** The missing dataset's name, so callers can build a tailored, fixable message. */
  readonly datasetName: string;
  constructor(name: string) {
    super(`Dataset not found: "${name}"`);
    this.name = 'DatasetNotFoundError';
    this.datasetName = name;
  }
}

export interface PrepareOptions {
  /** Active fit mode. Defaults to `'default'` (Original — spec sizing untouched). */
  fitMode?: FitMode;
  /** The dataset library used to resolve named-data references (step 1). */
  datasets?: ReadonlyArray<ResolvableDataset>;
}

/** The container/sub-spec keys the rendering contract recurses into (spec §04). */
const CHILD_ARRAY_KEYS = ['layer', 'concat', 'hconcat', 'vconcat'] as const;

/** A spec node we might rewrite sizing on; loose by design (any Vega-Lite spec). */
type SpecNode = Record<string, unknown>;

function isSpecNode(value: unknown): value is SpecNode {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/**
 * Rewrite one node's sizing to the fit mode (spec §04 → Rendering Contract,
 * step 2). `'container'` is Vega-Lite's responsive keyword; the unconstrained
 * dimension is removed so it recomputes naturally.
 */
function applyFitToNode(node: SpecNode, mode: FitMode): void {
  switch (mode) {
    case 'width':
      node.width = 'container';
      delete node.height;
      break;
    case 'height':
      node.height = 'container';
      delete node.width;
      break;
    case 'full':
      node.width = 'container';
      node.height = 'container';
      break;
    // 'default' (Original) leaves sizing untouched and never reaches here.
  }
}

/**
 * Apply the fit mode to a spec and every nested sub-spec it recurses into —
 * layered (`layer`) and concatenated (`concat`/`hconcat`/`vconcat`) children,
 * and a parent spec's single child `spec` (facet/repeat). Mutates in place; the
 * caller (`prepareSpecForRender`) already works on a copy.
 */
function applyFitMode(node: unknown, mode: FitMode): void {
  if (!isSpecNode(node)) return;
  applyFitToNode(node, mode);
  for (const key of CHILD_ARRAY_KEYS) {
    const children = node[key];
    if (Array.isArray(children)) for (const child of children) applyFitMode(child, mode);
  }
  if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
}

/**
 * Build the replacement `data` object for one resolved reference (spec §04 →
 * Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
 * `format` carrying a TopoJSON `feature`); the incoming `name` is dropped and any
 * pre-existing `format` is merged so such keys survive.
 */
function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
  const restFormat = isSpecNode(rest.format) ? rest.format : {};

  // An unfetched URL reference (a legacy record, or one whose snapshot fetch never
  // succeeded) has no local data — fall back to a live Vega-Lite URL fetch so it
  // still renders until a Refresh snapshots it (snapshot model; spec §04 step 1).
  if (dataset.source === 'url' && dataset.data == null) {
    return {
      ...rest,
      url: typeof dataset.url === 'string' ? dataset.url : '',
      format: { ...restFormat, type: dataset.format },
    };
  }

  // Inline data and fetched URL snapshots resolve identically: the payload is in
  // `data`, shaped by format.
  switch (dataset.format) {
    case 'json':
      return { ...rest, values: dataset.data };
    case 'topojson':
      return { ...rest, values: dataset.data, format: { ...restFormat, type: 'topojson' } };
    case 'csv':
    case 'tsv':
      return { ...rest, values: dataset.data, format: { ...restFormat, type: dataset.format } };
  }
}

/**
 * Replace every library reference in `node` with its dataset's contents, recursing
 * through the spec (arrays and objects) so refs anywhere are resolved — matching
 * `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
 * payload keys so resolution never descends into user data rows. What counts as a
 * library reference is decided by the shared `libraryRefName` (core/spec-data):
 * named data the spec does not define for itself. A `name` riding on inline
 * `values`, a `url`, or a generator is native Vega-Lite data and is left untouched;
 * an unknown library name throws `DatasetNotFoundError`. Matching is
 * case-insensitive, mirroring naming.ts. Mutates in place; the caller already works
 * on a copy.
 */
function resolveDatasetRefs(
  node: unknown,
  byName: Map<string, ResolvableDataset>,
  selfDefined: Set<string>,
): void {
  if (Array.isArray(node)) {
    for (const item of node) resolveDatasetRefs(item, byName, selfDefined);
    return;
  }
  if (!isSpecNode(node)) return;

  const refName = libraryRefName(node.data, selfDefined);
  if (refName !== null) {
    const dataset = byName.get(refName.toLowerCase());
    if (!dataset) throw new DatasetNotFoundError(refName);
    const { name: _drop, ...rest } = node.data as SpecNode;
    node.data = resolvedData(dataset, rest);
  }

  // Recurse into every key except the two that hold data payloads (`data` —
  // resolved/captured above; `datasets` — the spec's own inline data). Pruning
  // them avoids descending into the just-resolved `values` and into user data
  // rows, where a field named `data` holding `{ name: "x" }` would otherwise be
  // spuriously resolved or throw DatasetNotFoundError. extractDatasetRefs prunes
  // the same two keys so resolution and extraction stay in agreement.
  for (const key of Object.keys(node)) {
    if (key === 'data' || key === 'datasets') continue;
    resolveDatasetRefs(node[key], byName, selfDefined);
  }
}

/**
 * Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
 * than a nested-property accessor (docs/architecture/05 §4). Used wherever
 * Astrolabe *constructs* a `field:` from a data-derived column name (chart
 * builder, M4); hand-authored specs are the user's responsibility.
 */
export function escapeVegaField(name: string): string {
  return name.replace(/([.[\]])/g, '\\$1');
}

/**
 * Inverse of {@link escapeVegaField}: drop the backslash escaping `.`/`[`/`]` to
 * recover the literal column name. Used when *reading* a constructed `field:` back
 * (the chart builder's spec→config hydration, spec §06 → Open in builder).
 */
export function unescapeVegaField(field: string): string {
  return field.replace(/\\([.[\]])/g, '$1');
}

/**
 * Transform the authored spec into the spec to embed. Operates on a deep copy
 * and returns it; the input is never mutated.
 */
export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}): T {
  const copy = structuredClone(spec);

  // 1. Dataset reference resolution — runs before sizing, on the same copy.
  const datasets = options.datasets ?? [];
  const byName = new Map<string, ResolvableDataset>();
  for (const d of datasets) byName.set(d.name.toLowerCase(), d);
  resolveDatasetRefs(copy, byName, selfDefinedNames(copy));

  // 2. Fit-mode sizing.
  applyFitMode(copy, options.fitMode ?? 'default');

  return copy;
}
