/**
 * Theme Builder structured-control primitives (docs/chart-theming-scope.md §5).
 *
 * Pure core: the read/write transforms the builder's structured controls run on
 * a draft config, plus the named-color-scheme catalog and its resolution. Three
 * concerns:
 *
 *  - **Path get/set** — read a value at a nested config path, and set one back
 *    immutably while preserving every sibling key at each level. That guarantee
 *    is the point: the vega-themes presets carry Vega-_layer_ keys (`symbol`,
 *    `shape`, `path`, `group`) that are absent from the Vega-Lite `Config`
 *    schema but forwarded to Vega unchanged, so they must survive a control's
 *    edit rather than be dropped by a rebuild-from-schema. Setting `undefined`
 *    deletes the leaf and prunes objects the deletion empties, so a control
 *    writes a minimal diff, never a default dump.
 *  - **Scheme catalog** — the named Vega color schemes the color controls offer,
 *    grouped by kind (categorical / sequential / diverging).
 *  - **Scheme resolution** (`schemeColors`) — a scheme name to hex swatches, for
 *    the picker preview and the "materialize to an editable array" action.
 *
 * `vega-scale` (a focused vega sub-package, like core's `vega-expression`) owns
 * the scheme registry; importing it here keeps the umbrella `vega` out of core.
 * The one control transform that predates this module, `applyFontToConfig`,
 * stays in custom-theme.ts with the record entity.
 */

import { scheme } from 'vega-scale';
import { isJsonObject, type JsonObject } from './spec-config';

// ── Path get/set ──────────────────────────────────────────────────────────

/** A nested config location, e.g. `['range', 'category']` or `['mark', 'color']`. */
export type ConfigPath = readonly string[];

/** Read the value at `path`, or undefined if any segment is missing/non-object. */
export function getConfigValue(config: JsonObject, path: ConfigPath): unknown {
  let cur: unknown = config;
  for (const key of path) {
    if (!isJsonObject(cur)) return undefined;
    cur = cur[key];
  }
  return cur;
}

/**
 * Immutably set `value` at `path`. Siblings at every level are preserved (the
 * key-preservation guarantee §5 rests on). `value === undefined` deletes the
 * leaf and prunes any ancestor object the deletion leaves empty. Intermediate
 * objects are created as needed; a non-object value blocking the path is
 * replaced with a fresh object. Returns a new object; input not mutated. An
 * empty path returns the config unchanged.
 */
export function setConfigValue(config: JsonObject, path: ConfigPath, value: unknown): JsonObject {
  if (path.length === 0) return config;
  const [head, ...rest] = path;

  if (rest.length === 0) {
    if (value === undefined) return omitKey(config, head);
    return { ...config, [head]: value };
  }

  const child = isJsonObject(config[head]) ? config[head] : {};
  const nextChild = setConfigValue(child, rest, value);
  // A deletion that empties the child prunes the child too (minimal diff); a
  // child still holding sibling keys (e.g. a preset's Vega-layer keys) stays.
  if (value === undefined && Object.keys(nextChild).length === 0) {
    return omitKey(config, head);
  }
  return { ...config, [head]: nextChild };
}

/** A copy of `obj` without `key` (returns the same object when the key is absent). */
function omitKey(obj: JsonObject, key: string): JsonObject {
  if (!(key in obj)) return obj;
  const { [key]: _omit, ...rest } = obj;
  return rest;
}

// ── Range scheme normalization ────────────────────────────────────────────────

/**
 * Convert any bare scheme-name string under `config.range.*` to Vega's
 * range-scheme object `{ scheme: name }`. A bare string passes vega-lite compile
 * but Vega rejects it at render ("Unrecognized scale range value"), silently
 * blanking the chart; the object form is the one Vega accepts. Idempotent —
 * arrays, existing `{ scheme }` objects, and every other key pass through
 * untouched. Applied where a theme config becomes a render config, so a config
 * authored, imported, or saved by an earlier build with the bare form still
 * renders. Returns the same object when nothing needed changing.
 */
export function normalizeRangeSchemes(config: JsonObject): JsonObject {
  const range = config.range;
  if (!isJsonObject(range)) return config;
  let changed = false;
  const out: JsonObject = { ...range };
  for (const [family, value] of Object.entries(range)) {
    if (typeof value === 'string') {
      out[family] = { scheme: value };
      changed = true;
    }
  }
  return changed ? { ...config, range: out } : config;
}

// ── Leaf coercion ─────────────────────────────────────────────────────────────
// Read a config leaf back into the type a structured control binds to, or
// `undefined` when it holds something else (a hand-edit, a preset's richer
// form) — the control then shows its default rather than misrendering.

export const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
export const asNumber = (v: unknown): number | undefined =>
  typeof v === 'number' && Number.isFinite(v) ? v : undefined;
export const asBoolean = (v: unknown): boolean | undefined =>
  typeof v === 'boolean' ? v : undefined;

/**
 * Read an enum config leaf back to one of `options`' values, or `''` (the
 * conventional "Theme default" sentinel a structured-control option set carries)
 * when it holds something the control doesn't enumerate — a hand-edit, a preset's
 * richer form. Keeps a select from showing a blank trigger for an unrecognized
 * value. `options` is matched structurally, so the UI's `SelectControlOption`
 * satisfies it without core depending on the component.
 */
export function enumValue<V extends string>(v: unknown, options: ReadonlyArray<{ value: V }>): V {
  return typeof v === 'string' && options.some((o) => o.value === v) ? (v as V) : ('' as V);
}

/**
 * How many of `paths` are set (non-undefined) in `config` — a control section's
 * "modified" count, shown as its accordion badge so overrides are scannable
 * while a section is collapsed (NN/g recognition). Counts only the listed
 * control paths, so it reads as "fields you've set here", not "every key".
 */
export function countSet(config: JsonObject, paths: ReadonlyArray<ConfigPath>): number {
  return paths.reduce((n, p) => (getConfigValue(config, p) !== undefined ? n + 1 : n), 0);
}

// ── Named color schemes ─────────────────────────────────────────────────────

type SchemeKind = 'categorical' | 'sequential' | 'diverging';

interface ThemeScheme {
  /** The Vega scheme id — written verbatim into `range.*` and resolved by Vega. */
  name: string;
  /** Human label for the picker. */
  label: string;
  kind: SchemeKind;
}

/**
 * The named Vega schemes the color controls offer, in display order within each
 * kind. Categorical feeds `range.category`; sequential feeds `range.heatmap` /
 * `ramp` / `ordinal`; diverging feeds `range.diverging`. Every name is verified
 * present in the installed vega-scale registry by the catalog test, so a
 * registry change surfaces as a test failure rather than a blank swatch.
 */
export const THEME_SCHEMES: ReadonlyArray<ThemeScheme> = [
  // Categorical
  { name: 'tableau10', label: 'Tableau 10', kind: 'categorical' },
  { name: 'tableau20', label: 'Tableau 20', kind: 'categorical' },
  { name: 'category10', label: 'Category 10', kind: 'categorical' },
  { name: 'category20', label: 'Category 20', kind: 'categorical' },
  { name: 'category20b', label: 'Category 20b', kind: 'categorical' },
  { name: 'category20c', label: 'Category 20c', kind: 'categorical' },
  { name: 'observable10', label: 'Observable 10', kind: 'categorical' },
  { name: 'accent', label: 'Accent', kind: 'categorical' },
  { name: 'dark2', label: 'Dark 2', kind: 'categorical' },
  { name: 'paired', label: 'Paired', kind: 'categorical' },
  { name: 'set1', label: 'Set 1', kind: 'categorical' },
  { name: 'set2', label: 'Set 2', kind: 'categorical' },
  { name: 'set3', label: 'Set 3', kind: 'categorical' },
  { name: 'pastel1', label: 'Pastel 1', kind: 'categorical' },
  { name: 'pastel2', label: 'Pastel 2', kind: 'categorical' },
  // Sequential (single- and multi-hue)
  { name: 'viridis', label: 'Viridis', kind: 'sequential' },
  { name: 'magma', label: 'Magma', kind: 'sequential' },
  { name: 'inferno', label: 'Inferno', kind: 'sequential' },
  { name: 'plasma', label: 'Plasma', kind: 'sequential' },
  { name: 'cividis', label: 'Cividis', kind: 'sequential' },
  { name: 'turbo', label: 'Turbo', kind: 'sequential' },
  { name: 'blues', label: 'Blues', kind: 'sequential' },
  { name: 'greens', label: 'Greens', kind: 'sequential' },
  { name: 'greys', label: 'Greys', kind: 'sequential' },
  { name: 'oranges', label: 'Oranges', kind: 'sequential' },
  { name: 'purples', label: 'Purples', kind: 'sequential' },
  { name: 'reds', label: 'Reds', kind: 'sequential' },
  { name: 'bluegreen', label: 'Blue-Green', kind: 'sequential' },
  { name: 'bluepurple', label: 'Blue-Purple', kind: 'sequential' },
  { name: 'greenblue', label: 'Green-Blue', kind: 'sequential' },
  { name: 'orangered', label: 'Orange-Red', kind: 'sequential' },
  { name: 'purpleblue', label: 'Purple-Blue', kind: 'sequential' },
  { name: 'purplebluegreen', label: 'Purple-Blue-Green', kind: 'sequential' },
  { name: 'purplered', label: 'Purple-Red', kind: 'sequential' },
  { name: 'redpurple', label: 'Red-Purple', kind: 'sequential' },
  { name: 'yellowgreen', label: 'Yellow-Green', kind: 'sequential' },
  { name: 'yellowgreenblue', label: 'Yellow-Green-Blue', kind: 'sequential' },
  { name: 'yelloworangebrown', label: 'Yellow-Orange-Brown', kind: 'sequential' },
  { name: 'yelloworangered', label: 'Yellow-Orange-Red', kind: 'sequential' },
  // Diverging
  { name: 'blueorange', label: 'Blue-Orange', kind: 'diverging' },
  { name: 'brownbluegreen', label: 'Brown-Blue-Green', kind: 'diverging' },
  { name: 'purplegreen', label: 'Purple-Green', kind: 'diverging' },
  { name: 'pinkyellowgreen', label: 'Pink-Yellow-Green', kind: 'diverging' },
  { name: 'purpleorange', label: 'Purple-Orange', kind: 'diverging' },
  { name: 'redblue', label: 'Red-Blue', kind: 'diverging' },
  { name: 'redgrey', label: 'Red-Grey', kind: 'diverging' },
  { name: 'redyellowblue', label: 'Red-Yellow-Blue', kind: 'diverging' },
  { name: 'redyellowgreen', label: 'Red-Yellow-Green', kind: 'diverging' },
  { name: 'spectral', label: 'Spectral', kind: 'diverging' },
];

/** The schemes of one kind, in catalog order. */
export function schemesByKind(kind: SchemeKind): ThemeScheme[] {
  return THEME_SCHEMES.filter((s) => s.kind === kind);
}

// ── Scheme resolution ───────────────────────────────────────────────────────

/**
 * Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
 * fixed palette in full; a continuous scheme (sequential/diverging) is sampled
 * at `count` evenly-spaced stops (`count` applies to continuous schemes only).
 * Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
 * name returns `[]` — the picker shows that scheme without a preview rather than
 * throwing. Used for the swatch/gradient preview and "materialize to swatches".
 */
export function schemeColors(name: string, count = 9): string[] {
  const resolved: unknown = scheme(name);
  if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
  if (typeof resolved === 'function' && count > 0) {
    const interp = resolved as (t: number) => string;
    if (count === 1) return [toHex(interp(0.5))];
    return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
  }
  return [];
}

/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
function toHex(color: string): string {
  if (color.startsWith('#')) return color;
  const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
  if (!m) return color;
  const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
  return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
}
