/**
 * Spec ↔ config operations (docs/chart-theming-scope.md §4.3).
 *
 * The two halves of making the injected chart theme portable, mirroring the
 * Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
 *
 * - **Merge** bakes a config into the spec's own `config` block — for
 *   publishing a snippet somewhere the app's theme won't follow it. The spec's
 *   existing `config` wins on conflicts, matching the render-time precedence
 *   (vega-lite layers `spec.config` over the injected config), so baking never
 *   changes how the chart looks.
 * - **Extract** lifts the `config` block out of a spec — for cleaning styling
 *   out of a pasted-in spec (the caller decides where the extracted config
 *   goes: the clipboard, or a saved custom theme).
 *
 * Pure object-in/object-out; JSON text handling (parse, format, undo) is the
 * editor integration's job.
 */

/** A parsed JSON object (the only spec shape these operations accept). */
export type JsonObject = Record<string, unknown>;

/** Is the value a plain JSON object (not an array, not null)? */
export function isJsonObject(value: unknown): value is JsonObject {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
 * Deep-merge `upper` over `lower`: plain objects merge recursively, everything
 * else (arrays, scalars) is replaced by the upper value. The same shape of
 * merge vega-lite applies between the embed-time config and `spec.config`.
 */
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
  const out: JsonObject = { ...lower };
  for (const [key, upperValue] of Object.entries(upper)) {
    const lowerValue = out[key];
    out[key] =
      isJsonObject(lowerValue) && isJsonObject(upperValue)
        ? deepMerge(lowerValue, upperValue)
        : upperValue;
  }
  return out;
}

/**
 * Bake `config` into the spec's `config` block. The spec's existing `config`
 * takes precedence key-by-key (deep), so the rendered result is unchanged —
 * the theme just travels with the spec now. Returns a new object; the input
 * is not mutated. An empty merge result still writes `config: {}` only when
 * the spec already had one; baking an empty config into a config-less spec is
 * a no-op.
 */
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
  const specConfig = isJsonObject(spec.config) ? spec.config : {};
  const merged = deepMerge(config, specConfig);
  if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
  return { ...spec, config: merged };
}

/** Result of `extractConfigFromSpec`. */
export interface ExtractedConfig {
  /** The spec without its `config` block (new object; input not mutated). */
  spec: JsonObject;
  /** The removed `config`, or null when the spec had none worth extracting. */
  config: JsonObject | null;
}

/**
 * Remove the spec's `config` block and hand it back separately. A missing,
 * empty, or non-object `config` extracts as `null` (an empty/junk block is
 * still removed from the spec — there is nothing to keep).
 */
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
  if (!('config' in spec)) return { spec: { ...spec }, config: null };
  const { config, ...rest } = spec;
  const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
  return { spec: rest, config: extracted };
}
