/**
 * Snippet → dataset reference extraction and rename propagation
 * (spec §09F → Cross-entity relationships; docs/architecture/07 §3.1, §6).
 *
 * Portable core: no browser APIs, no React, no store access. A Vega-Lite spec
 * references a library dataset through *named* data — `{ "data": { "name": "X" } }`
 * — which can appear at the top level, per-layer, inside `spec`/`facet`/concat
 * children, or in a lookup transform's `from.data`. Rather than enumerate the
 * grammar, we walk the spec recursively and collect every library reference we
 * find — the single source of truth for "what does this spec reference", which the
 * renderer's resolution must agree with.
 *
 * What counts as a *library reference* is decided by `libraryRefName`
 * (core/spec-data): named data (a string `name` with no `values`/`url`/generator)
 * whose name the spec does not define for itself via a top-level `datasets` object.
 * A `name` riding on inline `values` or a `url` is a Vega-Lite label, not a
 * dependency, so it is left alone — keeping Astrolabe a strict extension of
 * Vega-Lite. The renderer resolves through the same predicate, so the two cannot
 * disagree.
 *
 * The walk recurses into every key EXCEPT two, which hold user data payloads
 * rather than nested specs: a `data` object (classified at the parent site; its
 * `values`/`format` are payload, never a nested ref) and a top-level `datasets`
 * map (the spec's own inline data). Pruning those is what keeps a data *row* that
 * happens to carry a field literally named `data: { name: "x" }` from being misread
 * as a library reference. The renderer's resolution prunes the same two keys.
 *
 * A spec may be stored as an **object** or as **JSON text** (see spec §09A); we
 * normalize once at the boundary (unparseable text → no refs / unchanged spec) so
 * the recursive walk never has to care.
 */

import { isJsonObject } from './spec-config';
import { classifyData, forEachDataBinding, libraryRefName, mapDataBindings } from './spec-data';

type Json = unknown;

/** Parse a string spec; an unparseable draft simply has no resolvable refs. */
function safeParse(s: string): Json {
  try {
    return JSON.parse(s);
  } catch {
    return null;
  }
}

/**
 * Collects every **library** dataset name referenced anywhere in the spec
 * (`libraryRefName`), excluding names the spec defines for itself via a top-level
 * `datasets` object. Accepts an object or JSON text.
 */
export function extractDatasetRefs(spec: Json): string[] {
  const root = typeof spec === 'string' ? safeParse(spec) : spec;
  const names = new Set<string>();
  forEachDataBinding(root, (node, selfDefined) => {
    const refName = libraryRefName(node.data, selfDefined);
    if (refName !== null) names.add(refName);
  });
  return [...names];
}

/** The list stored on `snippet.datasetRefs` — sorted + de-duped for stable diffs. */
export function recomputeDatasetRefs(spec: Json): string[] {
  return [...new Set(extractDatasetRefs(spec))].sort();
}

/**
 * Returns a copy of `spec` with every library reference to `oldName` (per
 * `libraryRefName`) renamed to `newName`, recursing through arrays and objects.
 * Native Vega-Lite data that merely carries the name as a label (inline `values`,
 * a `url`, a generator) and a self-defined top-level `datasets` key are left
 * untouched. The input's stored shape is preserved: a string spec is parsed,
 * rewritten, and re-serialized as pretty JSON text; an object spec returns an
 * object. The generic `<T>` reflects this shape preservation; the input is never
 * mutated.
 */
export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string): T {
  const isString = typeof spec === 'string';
  const root = isString ? safeParse(spec) : spec;
  // Unparseable text → return it unchanged (nothing resolvable to rewrite).
  if (isString && root === null) return spec;

  // Rewrite only a `data` block that is a library reference to `oldName`; native
  // data carrying the name as a label and a self-defined `datasets` key are left
  // alone (the shared walk prunes the payload keys).
  const rewritten = mapDataBindings(root, (data, selfDefined) =>
    libraryRefName(data, selfDefined) === oldName
      ? { ...(data as Record<string, Json>), name: newName }
      : data,
  );
  return (isString ? JSON.stringify(rewritten, null, 2) : rewritten) as T;
}

/**
 * Promote a self-defined dataset to a stored library reference — the rewrite behind
 * Extract-to-Dataset for the `datasets.<name>` case (docs/architecture/07 §3.2).
 *
 * A view that binds `{ "data": { "name": X } }` where the spec defines `X` for
 * itself via top-level `datasets` is referencing the spec's *own* embedded data.
 * Once that data is saved to the library, the `datasets` entry must go so the same
 * `{ name }` reference resolves to the stored dataset instead. This returns a copy
 * of `spec` with:
 *   - every self-defined `{ name: oldName }` data block renamed to `newName` (a
 *     no-op when the name is unchanged, in which case the reference simply
 *     un-shadows onto the new library dataset of the same name); and
 *   - the `datasets[oldName]` entry removed (and the whole `datasets` map removed
 *     when it empties).
 *
 * Returns `null` when `oldName` is not actually a self-defined dataset, so the
 * caller extracts nothing. The input is never mutated. (Parallel to
 * `renameDatasetInSpec`, but it targets *named* references by name rather than
 * *library* references via `libraryRefName` — self-defined names are exactly the
 * ones the latter excludes.)
 */
export function promoteSelfDefinedDataset(
  spec: unknown,
  oldName: string,
  newName: string,
): Record<string, Json> | null {
  if (!isJsonObject(spec) || !isJsonObject(spec.datasets) || !(oldName in spec.datasets)) {
    return null;
  }

  // Rename every named reference to oldName (a `data` block carrying `values`/`url`
  // is its own data, not a `datasets` reference, so classify first). A same-name
  // promotion needs no rewrite — the reference un-shadows onto the new library
  // dataset once the entry below is gone.
  const renamed = (
    newName === oldName
      ? { ...spec }
      : mapDataBindings(spec, (data) =>
          classifyData(data) === 'named' && (data as { name: string }).name === oldName
            ? { ...(data as Record<string, Json>), name: newName }
            : data,
        )
  ) as Record<string, Json>;

  const datasets = { ...(renamed.datasets as Record<string, Json>) };
  delete datasets[oldName];
  if (Object.keys(datasets).length === 0) delete renamed.datasets;
  else renamed.datasets = datasets;
  return renamed;
}
