/**
 * Inline data carried by a spec (docs/architecture/08 → editor augmentation).
 *
 * Portable core. Two readers over a view's inline data:
 *
 *   - `rowsForDataBinding` pulls **profileable JSON rows** out so the editor can
 *     profile them (core/profile) and offer the same field hints a library-bound
 *     view gets — a "ghost dataset" derived from the spec, with nothing stored.
 *     URL data has no rows to read statically, and a CSV/TSV string `values`
 *     payload needs format-aware parsing — both out of scope there (they yield null).
 *   - `inlineValuesOf` / `selfDefinedPayloadOf` pull the **raw payload** (values +
 *     format) Extract-to-Dataset lifts whole into a stored dataset — from a view's
 *     inline `values` (kept verbatim, so a CSV/TSV string survives) or from a
 *     self-defined `datasets` entry the view references by name. `specHasExtractableData`
 *     is the gate Extract reads to decide whether any view carries data it can lift.
 */

import type { DataFormat } from './format-detection';
import { isJsonObject } from './spec-config';
import { classifyData, forEachDataBinding, selfDefinedNames } from './spec-data';

/** An array of row objects, or null when the value is not tabular inline data. */
function asRows(value: unknown): Record<string, unknown>[] | null {
  if (Array.isArray(value) && value.length > 0 && value.every(isJsonObject)) {
    return value;
  }
  return null;
}

/**
 * The tabular rows a *specific* `data` binding provides for static profiling, or
 * null when it has none readable here: inline `data.values` (a JSON array of row
 * objects), or a self-defined `{ name }` resolved against the spec's top-level
 * `datasets`. A library `{ name }` reference (resolved from the dataset store), a
 * `url`, a generator, or a CSV/TSV string `values` payload yields null — those
 * carry no statically-readable JSON rows. Used by the view-scoped editor hints to
 * profile the data bound at the cursor (`dataBindingAtPath` → this).
 */
export function rowsForDataBinding(spec: unknown, data: unknown): Record<string, unknown>[] | null {
  const kind = classifyData(data);
  if (kind === 'inline') return asRows((data as { values: unknown }).values);
  if (kind === 'named') {
    const name = (data as { name: string }).name;
    if (selfDefinedNames(spec).has(name) && isJsonObject(spec) && isJsonObject(spec.datasets)) {
      return asRows((spec.datasets as Record<string, unknown>)[name]);
    }
  }
  return null;
}

/** The raw inline payload of a `data` block, or null when it is not inline data. */
export interface InlinePayload {
  /** The block's `values` verbatim — a JSON array OR a raw CSV/TSV/topojson string. */
  values: unknown;
  /** The format declared by `data.format.type`, defaulting to JSON. */
  format: DataFormat;
}

/**
 * The inline payload a `data` block carries — its `values` and declared format —
 * or null when the block is not inline (`classifyData` ≠ `'inline'`). Unlike
 * {@link rowsForDataBinding}, which profiles JSON rows and rejects a CSV/TSV
 * string, this preserves the payload **verbatim**: it is what Extract-to-Dataset
 * lifts into a stored dataset, and a raw CSV/TSV string is already the right
 * runtime shape to store. The format reads an explicit `data.format.type`
 * (csv/tsv/topojson), defaulting to json (a plain array of row objects).
 */
export function inlineValuesOf(data: unknown): InlinePayload | null {
  if (classifyData(data) !== 'inline') return null;
  const block = data as { values: unknown; format?: unknown };
  const type = isJsonObject(block.format) ? block.format.type : undefined;
  const format: DataFormat =
    type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
  return { values: block.values, format };
}

/**
 * The payload of a view's reference to a *self-defined* dataset — a `{ name }`
 * block whose name the spec defines for itself via top-level `datasets`. This is
 * the spec's own embedded data in a different shape (the rows live in the
 * `datasets` map, not in a `data.values` block), so Extract can lift it too. Only a
 * JSON-array `datasets` entry is liftable here; a string payload (rare) yields null
 * and is left for a future pass. Returns null when `data` is not a self-defined
 * reference. The library-reference case (`{ name }` not self-defined) is already a
 * stored dataset — nothing to extract — and also yields null.
 */
export function selfDefinedPayloadOf(spec: unknown, data: unknown): InlinePayload | null {
  if (classifyData(data) !== 'named') return null;
  const { name } = data as { name: string };
  if (!isJsonObject(spec) || !isJsonObject(spec.datasets) || !selfDefinedNames(spec).has(name)) {
    return null;
  }
  const entry = (spec.datasets as Record<string, unknown>)[name];
  return Array.isArray(entry) ? { values: entry, format: 'json' } : null;
}

/**
 * Whether any view binds data Extract can lift — the gate that decides if
 * Extract-to-Dataset is offered. That is inline `values` **or** a reference to a
 * self-defined `datasets` entry (the spec's own embedded data, in either shape). A
 * library reference, a url, or a generator carries nothing to lift. Runs over the
 * shared `forEachDataBinding` walk, stopping at the first liftable binding.
 */
export function specHasExtractableData(spec: unknown): boolean {
  let found = false;
  forEachDataBinding(spec, (node, selfDefined) => {
    const data = node.data;
    const kind = classifyData(data);
    if (
      kind === 'inline' ||
      (kind === 'named' &&
        typeof (data as { name?: unknown }).name === 'string' &&
        selfDefined.has((data as { name: string }).name))
    ) {
      found = true;
      return true; // stop at the first match
    }
  });
  return found;
}
