/**
 * The Vega-Lite data model — the single place that decides what a `data` block
 * *means* (docs/architecture/05 → Rendering Contract; 07 → Relationships). Pure
 * core: no browser APIs, no React.
 *
 * A Vega-Lite `data` block is exactly one of four shapes (mirrored from
 * vega-lite/src/data.ts — `isUrlData`/`isInlineData`/`isNamedData`/`isGenerator`):
 *
 *   - **url**       — has `url`
 *   - **inline**    — has `values` (and MAY also carry a `name` label)
 *   - **generator** — has `sequence` | `sphere` | `graticule`
 *   - **named**     — has a string `name`, and NONE of the above
 *
 * Astrolabe binds its library datasets through *named* data: `{ data: { name } }`
 * resolves to the library dataset's rows at render time (core/rendering). The
 * fidelity rule, taken verbatim from Vega-Lite, is that a `name` ALONE does not
 * make a reference — a `name` riding on inline `values`, a `url`, or a generator
 * is a label Vega-Lite uses to rebind data at runtime, and Astrolabe must leave it
 * untouched. Classifying by this rule (not by "has a string name") is what keeps
 * Astrolabe a strict *extension* of Vega-Lite rather than a divergence: every
 * native data form keeps working, and only true named references are resolved.
 *
 * Reference detection (`extractDatasetRefs`/rename in core/spec-refs) and reference
 * resolution (core/rendering) both run through `libraryRefName` here, so the two
 * can never disagree on what counts as a library dependency.
 */

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

/** The four Vega-Lite data shapes; `null` when the value is not a data block. */
export type DataKind = 'url' | 'inline' | 'generator' | 'named';

/** The keys that mark a Vega-Lite data *generator* (sequence/sphere/graticule). */
const GENERATOR_KEYS = ['sequence', 'sphere', 'graticule'] as const;

/**
 * Classify a Vega-Lite `data` value, matching Vega-Lite's own precedence: `url`
 * and `values` and generator keys win over a bare `name`, so a named-inline or
 * named-url block is inline/url data — not a named reference. Returns `null` for a
 * non-object, `data: null`, or an empty/unrecognized block.
 */
export function classifyData(data: unknown): DataKind | null {
  if (!isJsonObject(data)) return null;
  if ('url' in data) return 'url';
  if ('values' in data) return 'inline';
  if (GENERATOR_KEYS.some((key) => key in data)) return 'generator';
  if (typeof data.name === 'string') return 'named';
  return null;
}

/**
 * The set of dataset names a spec defines for itself via a top-level `datasets`
 * object. These are resolved natively by Vega-Lite, so they are never library
 * dependencies and are excluded from reference detection and resolution.
 */
export function selfDefinedNames(spec: unknown): Set<string> {
  const names = new Set<string>();
  if (isJsonObject(spec) && isJsonObject(spec.datasets)) {
    for (const key of Object.keys(spec.datasets)) names.add(key);
  }
  return names;
}

/**
 * The library dataset name a `data` block references, or `null` when it is not a
 * library reference — i.e. it is named data (`classifyData` → `'named'`) whose
 * name is not one the spec defines for itself. This is the single predicate behind
 * both reference extraction and render-time resolution.
 */
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
  if (classifyData(data) !== 'named') return null;
  const { name } = data as { name: string };
  return selfDefined.has(name) ? null : name;
}

/** A `data` block in scope at a cursor, with the path prefix where it was found. */
export interface DataBinding {
  /** The raw `data` value (classify it with `classifyData` / `libraryRefName`). */
  data: unknown;
  /** The path prefix of the view that declares this `data` — `[]` for the root. */
  anchorPath: ReadonlyArray<string | number>;
}

/**
 * The data binding in scope at a JSON `path` into the spec — the `data` block of
 * the *nearest enclosing view* (docs/architecture/08 → editor augmentation). This
 * implements Vega-Lite's data inheritance: a layer/concat/facet/repeat child
 * inherits its parent's data unless it declares its own, so the binding is the
 * `data` of the deepest object along the path that has one. Returns `null` when no
 * ancestor on the path declares `data`.
 *
 * `path` is a jsonc-parser location path (object keys and array indices, root to
 * cursor). The walk descends it defensively, stopping at any non-object/array.
 */
export function dataBindingAtPath(
  spec: unknown,
  path: ReadonlyArray<string | number>,
): DataBinding | null {
  let node: unknown = spec;
  let binding: DataBinding | null = null;
  if (isJsonObject(node) && 'data' in node) binding = { data: node.data, anchorPath: [] };
  for (let i = 0; i < path.length; i++) {
    if (node === null || typeof node !== 'object') break;
    node = (node as Record<string | number, unknown>)[path[i]];
    if (isJsonObject(node) && 'data' in node) {
      binding = { data: node.data, anchorPath: path.slice(0, i + 1) };
    }
  }
  return binding;
}

/**
 * Set the `data` block of the view at `anchorPath` (an anchor from
 * `dataBindingAtPath`), in place — the write-counterpart of the read above, used by
 * Extract-to-Dataset to swap a view's inline data for a `{ name }` reference.
 * Mutates `spec`, so the caller passes a copy it owns (a fresh parse). Returns false
 * when the path no longer resolves to an object, so the caller can refuse rather
 * than write the wrong node.
 */
export function setDataBindingAtPath(
  spec: unknown,
  anchorPath: ReadonlyArray<string | number>,
  block: unknown,
): boolean {
  let node: unknown = spec;
  for (const key of anchorPath) {
    if (node === null || typeof node !== 'object') return false;
    node = (node as Record<string | number, unknown>)[key];
  }
  if (node === null || typeof node !== 'object') return false;
  (node as Record<string, unknown>).data = block;
  return true;
}

/**
 * The one spec walk every data-binding pass shares (reference detection/resolution,
 * extraction gates). Visits each object node that could carry a `data` binding,
 * with the spec's self-defined `datasets` names, and **prunes two payload keys** —
 * a `data` object's contents and the top-level `datasets` map hold user data, not
 * nested specs, so descending into them would misread a data row carrying a field
 * literally named `data` as a binding. `visit` returns `true` to stop early (for a
 * predicate); any other return continues. Pruning the same two keys here is what
 * keeps every pass in agreement on what counts as a reference — they cannot drift
 * because they share this walk.
 */
export function forEachDataBinding(
  spec: unknown,
  visit: (node: Record<string, unknown>, selfDefined: ReadonlySet<string>) => boolean | void,
): void {
  const selfDefined = selfDefinedNames(spec);
  let stop = false;
  const walk = (node: unknown): void => {
    if (stop) return;
    if (Array.isArray(node)) {
      for (const item of node) walk(item);
      return;
    }
    if (!isJsonObject(node)) return;
    if (visit(node, selfDefined) === true) {
      stop = true;
      return;
    }
    for (const key of Object.keys(node)) {
      if (key === 'data' || key === 'datasets') continue;
      walk(node[key]);
    }
  };
  walk(spec);
}

/**
 * Return a deep copy of `spec` with each data binding's `data` value replaced by
 * `mapData(data, selfDefined)` — the copy-rewrite counterpart of
 * {@link forEachDataBinding}, for rename/promote passes. Prunes the same two keys:
 * the top-level `datasets` map is carried through untouched, and a `data` payload is
 * never descended into (only its block is mapped). The input is not mutated.
 */
export function mapDataBindings(
  spec: unknown,
  mapData: (data: unknown, selfDefined: ReadonlySet<string>) => unknown,
): unknown {
  const selfDefined = selfDefinedNames(spec);
  const rewrite = (node: unknown): unknown => {
    if (Array.isArray(node)) return node.map(rewrite);
    if (node && typeof node === 'object') {
      const out: Record<string, unknown> = {};
      for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
        if (key === 'data') out[key] = mapData(value, selfDefined);
        else if (key === 'datasets') out[key] = value;
        else out[key] = rewrite(value);
      }
      return out;
    }
    return node;
  };
  return rewrite(spec);
}
