/**
 * Parameter scaffolding (docs/architecture/08 → editor augmentation) — the sibling of
 * `spec-data-transforms`, for a spec's `params` rather than its `transform` pipeline.
 * It offers the Vega-Lite parameters the editor can pre-fill: **variable** parameters
 * (an input widget — slider, dropdown, radio, checkbox — bound via `bind`) and
 * **selection** parameters (point, interval — user interaction on the chart, via
 * `select`). Text/Monaco handling (ranges, insertion, the CodeLens) is the service's
 * job (`app/services/spec-param-scaffold`); this owns the facts the schema can't
 * supply — *where* each family legally attaches and *what* a data-seeded skeleton
 * looks like.
 *
 * **Where a parameter can legally go (grammar-confirmed against the bundled VL
 * schema).** Unlike a data `transform` — which sits on every view node — `params`
 * has two distinct homes, and the two families do *not* share them:
 *
 *   - **top-level `params[]`** (on the root spec, whatever its kind) takes
 *     `TopLevelParameter` = variable *or* selection. This is the only place a
 *     **variable** parameter is legal: a nested unit's `params` accepts selections
 *     only, so a slider must live at the root (where its `bind` widget is a global
 *     input any view's `filter` can read).
 *   - **a unit's `params[]`** (`mark`/`encoding` node) takes `SelectionParameter` —
 *     selections only. A **selection** belongs on the unit whose marks it reads, so
 *     it anchors to the nearest enclosing unit; for a single-view spec the root *is*
 *     that unit, so both families land in the one array.
 *
 * `paramSiteAt` resolves both homes for the CodeLens (root for variable, nearest unit
 * for selection); `paramPlacementAt` classifies a `params[]` slot for the completion,
 * flagging whether it is the root array (both families) or a nested unit's (selections
 * only). Both read `jsonc-parser`'s error-tolerant tree so they hold mid-edit. The
 * catalog builders take the columns in scope (resolved by the service via
 * `active-dataset`) and seed each `${n:default}` tab stop — a slider's bounds from the
 * numeric field's extent, a point selection's field from a categorical column.
 */

import { findNodeAtOffset, getLocation, parseTree, type Node } from 'jsonc-parser';
import { ARRAY_COMPOSITIONS } from './spec-transforms';
import type { ColumnType } from './type-inference';

/** A column in scope at the cursor, with its type and (for numeric columns) its range. */
export interface ParamField {
  name: string;
  type: ColumnType;
  /** Min/max over the column's values, for numeric columns (seeds slider bounds); null otherwise. */
  extent: { min: number; max: number } | null;
}

/** A byte range into the spec text. */
interface ByteRange {
  offset: number;
  length: number;
}

/** A `params[]` array's range and element count, when one exists. */
type ParamArray = (ByteRange & { count: number }) | null;

/**
 * Where one family of parameter attaches: the host object's range (where an
 * `＋ Add parameter` lens sits and a fresh `params: []` is created) plus that object's
 * existing `params[]` (where the per-kind lenses sit and a new entry is spliced in).
 */
export interface ParamHost {
  view: ByteRange;
  array: ParamArray;
}

/**
 * The two homes a parameter can take at the cursor:
 *   - `root` — the top-level spec, where variable parameters live (always present);
 *   - `unit` — the nearest enclosing unit, where selection parameters live, or null
 *     when the cursor is in no unit. When the root is itself a unit (a single-view
 *     spec) `unit` shares the root's `view.offset`, so the service renders one group.
 */
export interface ParamSite {
  root: ParamHost;
  unit: ParamHost | null;
}

/** Where the cursor sits relative to a `params[]` slot:
 *   - `slot` — in a `params` array element position (offer the catalog), tagged with
 *     whether that array is the root's (`atRoot` — both families) or a unit's
 *     (selections only);
 *   - `none` — anywhere else (including on a property key, which the schema owns). */
export type ParamPlacement = { kind: 'slot'; atRoot: boolean } | { kind: 'none' };

/** The property keys of an object node. */
// TODO: duplicated with `spec-data-transforms` (objectKeys byte-identical;
// paramsArrayNode mirrors transformArrayNode modulo the key) — worth a shared
// jsonc-node helper home; see the subtraction cut list in
// docs/exploration/engineering-review-2026-07.md.
function objectKeys(node: Node | null): string[] {
  if (!node?.children) return [];
  const keys: string[] = [];
  for (const prop of node.children) {
    const key = prop.type === 'property' ? prop.children?.[0] : undefined;
    if (key?.type === 'string' && typeof key.value === 'string') keys.push(key.value);
  }
  return keys;
}

/** Keys that make an object a composition container rather than a unit. A container
 *  can carry a shared `encoding` (a layer's, say), but the schema gives it no
 *  `params` — only units and the root take them. */
const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat', 'spec'];

/** A unit view — the leaf a selection attaches to — carries `mark` (or, mid-edit,
 *  `encoding`) and no composition key. */
function isUnitNode(node: Node): boolean {
  const keys = objectKeys(node);
  if (COMPOSITION_KEYS.some((k) => keys.includes(k))) return false;
  return keys.includes('mark') || keys.includes('encoding');
}

/** The `params` array node of an object, or null when it has none. */
function paramsArrayNode(node: Node): Node | null {
  for (const prop of node.children ?? []) {
    if (prop.type === 'property' && prop.children?.[0]?.value === 'params') {
      const value = prop.children[1];
      return value?.type === 'array' ? value : null;
    }
  }
  return null;
}

/** A host descriptor for an object node: its range plus its `params[]` range/count. */
function hostFor(node: Node): ParamHost {
  const array = paramsArrayNode(node);
  return {
    view: { offset: node.offset, length: node.length },
    array: array
      ? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
      : null,
  };
}

/**
 * Resolve both parameter homes at `offset`: the root spec (for variable parameters)
 * and the nearest enclosing unit (for selections), or null when the text has no
 * object root. The CodeLens reads this to place its lenses and to splice a new entry.
 */
export function paramSiteAt(text: string, offset: number): ParamSite | null {
  const tree = parseTree(text);
  if (!tree || tree.type !== 'object') return null;

  let unit: ParamHost | null = null;
  let node: Node | undefined = findNodeAtOffset(tree, offset, true);
  while (node) {
    if (node.type === 'object' && isUnitNode(node)) {
      unit = hostFor(node);
      break;
    }
    node = node.parent;
  }
  return { root: hostFor(tree), unit };
}

/**
 * Classify the cursor's `params` context from the error-tolerant tree (so it holds
 * mid-edit). A slot is a `params` array element position (`[ …, "params", <index> ]`)
 * that is not a property key; `atRoot` when that array sits directly on the root
 * (path is exactly `["params", n]`) — the only place variable parameters are legal.
 */
export function paramPlacementAt(text: string, offset: number): ParamPlacement {
  const { path, isAtPropertyKey } = getLocation(text, offset);
  if (
    isAtPropertyKey ||
    path.length < 2 ||
    path[path.length - 2] !== 'params' ||
    typeof path[path.length - 1] !== 'number'
  ) {
    return { kind: 'none' };
  }
  return { kind: 'slot', atRoot: path.length === 2 };
}

// ─── Field-seeded defaults ────────────────────────────────────────────────────────

/** The first numeric column carrying a known extent (for slider bounds), or null. */
function numericField(fields: readonly ParamField[]): ParamField | null {
  return fields.find((f) => f.type === 'number' && f.extent) ?? null;
}

/** The first categorical column (for a point selection's field), or null. */
function categoricalField(fields: readonly ParamField[]): string | null {
  return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
}

/** Trim binary-float noise from a computed number for clean snippet text. */
function fmt(n: number): string {
  return String(Math.round(n * 1e6) / 1e6);
}

/** A "nice" step (1/2/5 × 10ⁿ) near 1/100 of the range, so a slider has ~50–100 stops. */
function niceStep(range: number): number {
  if (!(range > 0)) return 1;
  const raw = range / 100;
  const mag = Math.pow(10, Math.floor(Math.log10(raw)));
  const norm = raw / mag; // 1 ≤ norm < 10
  const nice = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;
  return nice * mag;
}

/** Slider bounds seeded from the numeric field's extent, or a plain 0–100 default. */
function sliderBounds(fields: readonly ParamField[]): {
  min: string;
  max: string;
  value: string;
  step: string;
} {
  const f = numericField(fields);
  if (!f?.extent) return { min: '0', max: '100', value: '50', step: '1' };
  const { min, max } = f.extent;
  const step = niceStep(max - min);
  const value = Math.round((min + max) / 2 / step) * step;
  return { min: fmt(min), max: fmt(max), value: fmt(value), step: fmt(step) };
}

// ─── The parameter catalog ──────────────────────────────────────────────────────

/** The two kinds of parameter: a bound input widget, or a chart-interaction selection. */
export type ParamFamily = 'variable' | 'selection';

/** One parameter the editor can scaffold as a `params[]` entry. */
export interface ParamKind {
  id: string;
  /** Completion label. */
  label: string;
  /** One-line description (completion detail). */
  detail: string;
  /** Variable parameters are root-only; selections attach to a unit. */
  family: ParamFamily;
  /** The entry snippet: compact JSON with `${n:default}` tab stops. */
  param(fields: readonly ParamField[]): string;
}

/** The scaffoldable parameters, variable widgets first, then selections. */
export const PARAMS: readonly ParamKind[] = [
  {
    id: 'slider',
    label: 'slider',
    detail: 'A numeric range input bound to a variable',
    family: 'variable',
    param: (f) => {
      const b = sliderBounds(f);
      return `{ "name": "\${1:threshold}", "value": \${2:${b.value}}, "bind": { "input": "range", "min": \${3:${b.min}}, "max": \${4:${b.max}}, "step": \${5:${b.step}} } }`;
    },
  },
  {
    id: 'dropdown',
    label: 'dropdown',
    detail: 'A select menu bound to a variable',
    family: 'variable',
    param: () =>
      `{ "name": "\${1:choice}", "value": "\${2:A}", "bind": { "input": "select", "options": ["\${2:A}", "\${3:B}", "\${4:C}"] } }`,
  },
  {
    id: 'radio',
    label: 'radio',
    detail: 'A radio-button group bound to a variable',
    family: 'variable',
    param: () =>
      `{ "name": "\${1:choice}", "value": "\${2:A}", "bind": { "input": "radio", "options": ["\${2:A}", "\${3:B}", "\${4:C}"] } }`,
  },
  {
    id: 'checkbox',
    label: 'checkbox',
    detail: 'A boolean toggle bound to a variable',
    family: 'variable',
    param: () => `{ "name": "\${1:toggle}", "value": \${2:true}, "bind": { "input": "checkbox" } }`,
  },
  {
    id: 'point',
    label: 'point',
    detail: 'Click to select marks sharing a field value',
    family: 'selection',
    param: (f) =>
      `{ "name": "\${1:highlight}", "select": { "type": "point", "fields": ["\${2:${categoricalField(f) ?? 'category'}}"] } }`,
  },
  {
    id: 'interval',
    label: 'interval',
    detail: 'Drag a brush over the chart to select a range',
    family: 'selection',
    param: () =>
      `{ "name": "\${1:brush}", "select": { "type": "interval", "encodings": ["\${2:x}", "\${3:y}"] } }`,
  },
];
