/**
 * Structural spec transforms (docs/architecture/08 → editor augmentation).
 *
 * Pure object-in/object-out rewrites that wrap a Vega-Lite view in a composition
 * operator — layer, hconcat, vconcat, facet, repeat — or collapse a single-child
 * composition back to a unit. These are the structural edits that are awkward to
 * make by hand in JSON and out of reach of the visual builder (which is
 * single-view); the editor surfaces them as refactor actions
 * (app/services/spec-transform-actions). JSON text handling (parse, format, undo)
 * is the editor integration's job, exactly as for spec-config.
 *
 * Each wrap partitions the source spec's keys: the *shared* top-level keys (data,
 * $schema, title, config, …) stay on the wrapper, and the *view* keys (mark,
 * encoding, …) move into the new child. The partition differs per operator —
 * layered children share the plotting area, so width/height/view stay on top;
 * concat children are independent specs that size themselves, so those move down;
 * facet/repeat lift the data to the wrapper and hold a single child `spec`. The
 * result is the user's compact authored form, never vega-lite's normalized
 * expansion.
 */

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

/** Concat direction: horizontal or vertical. */
type ConcatDir = 'h' | 'v';

/**
 * The array-valued composition operators — the single source of this domain fact,
 * shared with the cursor (`spec-cursor`) and insertion (`spec-insert`) walks.
 */
export const ARRAY_COMPOSITIONS: readonly string[] = ['layer', 'hconcat', 'vconcat', 'concat'];

/**
 * Keys that belong to the wrapper for every operator: pure top-level metadata
 * plus the shared data source and styling. `data` stays on top because layered,
 * concatenated, faceted, and repeated children all inherit the parent's data.
 */
const SHARED_TOP = [
  '$schema',
  'name',
  'description',
  'title',
  'config',
  'usermeta',
  'background',
  'padding',
  'autosize',
  'datasets',
  'data',
  'resolve',
  'params',
];

/** Layers share one plotting area, so the size/view also stay on the wrapper. */
const LAYER_TOP = [...SHARED_TOP, 'width', 'height', 'view'];

/** A fresh placeholder view for the empty slot a wrap (or add-view) opens up. */
export const placeholderView = (): JsonObject => ({ mark: 'point', encoding: {} });

/** Split a spec's keys into those that stay on the wrapper and the rest. */
function partition(
  spec: JsonObject,
  topKeys: readonly string[],
): { top: JsonObject; view: JsonObject } {
  const topSet = new Set(topKeys);
  const top: JsonObject = {};
  const view: JsonObject = {};
  for (const key of Object.keys(spec)) {
    if (topSet.has(key)) top[key] = spec[key];
    else view[key] = spec[key];
  }
  return { top, view };
}

/** Wrap the view in a two-layer composition (the view plus an empty layer). */
export function wrapInLayer(spec: JsonObject): JsonObject {
  const { top, view } = partition(spec, LAYER_TOP);
  return { ...top, layer: [view, placeholderView()] };
}

/** Wrap the view in a horizontal or vertical concat (the view plus an empty view). */
export function wrapInConcat(spec: JsonObject, dir: ConcatDir): JsonObject {
  const key = dir === 'h' ? 'hconcat' : 'vconcat';
  const { top, view } = partition(spec, SHARED_TOP);
  return { ...top, [key]: [view, placeholderView()] };
}

/**
 * Wrap the view in a facet (small multiples across `field`). The caller resolves
 * a sensible field + type from the bound dataset; the field is left editable.
 */
export function wrapInFacet(spec: JsonObject, field: string, type: string): JsonObject {
  const { top, view } = partition(spec, SHARED_TOP);
  return { ...top, facet: { field, type }, spec: view };
}

/**
 * Wrap the view in a repeat (the same chart across `fields`). When `channel` is
 * given and the view encodes it, that channel's field is rewired to
 * `{ repeat: 'repeat' }` so the repeat actually varies the chart; otherwise the
 * view is wrapped as-is for the caller to wire up.
 */
export function wrapInRepeat(
  spec: JsonObject,
  fields: string[],
  channel: string | null,
): JsonObject {
  const { top, view } = partition(spec, SHARED_TOP);
  if (channel && isJsonObject(view.encoding)) {
    const encoding = { ...view.encoding };
    if (isJsonObject(encoding[channel])) {
      encoding[channel] = { ...encoding[channel], field: { repeat: 'repeat' } };
      view.encoding = encoding;
    }
  }
  return { ...top, repeat: fields, spec: view };
}

/**
 * The single array-composition key that holds exactly one child, or null — the
 * gate for `unwrapSingleton`: a `layer`/`concat` whittled down to one member is
 * the redundant structure worth collapsing.
 */
function unwrappableKey(spec: JsonObject): string | null {
  for (const key of ARRAY_COMPOSITIONS) {
    const value = spec[key];
    if (Array.isArray(value) && value.length === 1 && isJsonObject(value[0])) return key;
  }
  return null;
}

/**
 * Collapse a single-child `layer`/`concat` back into a unit: the lone child is
 * merged up onto the wrapper, the child's keys winning on conflict (it is the
 * more specific view). Returns null when there is no single-child composition to
 * collapse — the inverse of the wraps above.
 */
export function unwrapSingleton(spec: JsonObject): JsonObject | null {
  const key = unwrappableKey(spec);
  if (!key) return null;
  const child = (spec[key] as JsonObject[])[0];
  const rest: JsonObject = {};
  for (const k of Object.keys(spec)) if (k !== key) rest[k] = spec[k];
  return { ...rest, ...child };
}
