/**
 * Data-transform scaffolding (docs/architecture/08 → editor augmentation) — the
 * pure counterpart of `spec-transforms`. Where `spec-transforms` rewrites a view's
 * *structure* (wrap in a composition), this scaffolds a view's *data pipeline*: the
 * Vega-Lite `transform` steps (filter, calculate, aggregate, …) the editor offers as
 * ready-to-fill, field-typed placeholders. Text/Monaco handling (ranges, insertion,
 * the CodeLens) is the service's job (app/services/spec-transform-scaffold); this
 * owns the facts the schema can't supply — *where* a view's pipeline lives
 * (`transformSiteAt`, for the lens) or a step slot sits (`transformPlacementAt`, for
 * the completion), and *what* a field-typed skeleton for each transform looks like.
 *
 * The **discoverable surface is a CodeLens** — `＋ Add transform` on a view with no
 * pipeline, and `＋ filter`/`＋ aggregate`/… on the array once it exists — mirroring
 * the composition CodeLens (`spec-transform-actions`). The step-slot completion is a
 * type-to-filter accelerator on the same catalog, not the primary way in.
 *
 * **Where a data transform can legally go (grammar-confirmed against the bundled VL
 * schema — the `transform` array appears on 16 spec types, which reduce to one idea:
 * every view node).** Three contexts exist; we scaffold only the one the schema
 * leaves bare, on the same "add only what the schema lacks" rule as `spec-dataset-hints`:
 *
 *   - **step (owned here)** — an element of a view's `transform: [ … ]` array. The
 *     array sits on *every* view node (unit, layer, facet, concat, repeat, and the
 *     root) — the node set `spec-cursor`/`spec-data` already call a "view" — so it
 *     inherits top-down: a step on a composition parent is **shared** (upstream of
 *     all its children); a step on a unit is **local**. The schema never offers a
 *     ready `{ "filter": … }` with your columns, so this is where we add value.
 *   - **inline (deferred to the schema)** — `bin`/`timeUnit`/`aggregate`/`sort` are
 *     also properties on an encoding channel's field def. The JSON schema already
 *     completes those keys *and* their enum values, so scaffolding them here would be
 *     a redundant second source.
 *   - **new pipeline (deferred — composition of two that already work)** — starting a
 *     `transform` on a bare view is the schema completing the `transform` key (→ empty
 *     array) followed by the step completion firing inside it. No separate surface needed.
 *
 * `transformPlacementAt` classifies the cursor from `jsonc-parser`'s error-tolerant
 * tree, so it holds mid-edit. The catalog builders take the columns in scope (resolved
 * by the service via `active-dataset`, exactly as the facet/repeat wraps do) and seed
 * each placeholder with a type-appropriate default.
 */

import {
  findNodeAtLocation,
  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 inferred data type. */
export interface TypedField {
  name: string;
  type: ColumnType;
}

/** A `transform` step belongs to a composition parent (shared) or a unit (local). */
type TransformScope = 'shared' | 'view';

/**
 * Where the cursor sits relative to a data transform:
 *   - `step` — in a `transform: [ … ]` element slot (offer the field-typed catalog),
 *     tagged with whether that array is a composition parent's (shared) or a unit's;
 *   - `none` — anywhere else (including inside an existing step object, where the
 *     schema owns key completion).
 */
export type TransformPlacement = { kind: 'step'; scope: TransformScope } | { kind: 'none' };

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

/**
 * The data-transform site for the view the cursor is in — what the CodeLens needs to
 * anchor and act. `view` is the enclosing view object's range (where an `＋ Add
 * transform` lens sits when there is no pipeline yet); `array` is the `transform[]`
 * range and element count when one exists (where the per-step `＋ filter` … lenses
 * sit, and where a new step is spliced in). `scope` marks a composition parent's
 * pipeline as shared.
 */
export interface TransformSite {
  scope: TransformScope;
  view: ByteRange;
  array: (ByteRange & { count: number }) | null;
}

/** Composition keys whose presence on a view makes its transform *shared* upstream —
 *  the array operators (shared with `spec-cursor`/`spec-insert`) plus the single-child
 *  `facet`/`repeat`, so this stays one spelling of the operator set. */
const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat'];

/** The object node at a jsonc location path, or null if the path isn't an object. */
function objectNodeAt(tree: Node | undefined, path: ReadonlyArray<string | number>): Node | null {
  if (!tree) return null;
  const node = findNodeAtLocation(tree, path as (string | number)[]);
  return node && node.type === 'object' ? node : null;
}

/** The property keys of an object node. */
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;
}

/** A view carrying a composition key feeds all its children, so its transform is shared. */
function scopeFromKeys(keys: readonly string[]): TransformScope {
  return keys.some((k) => COMPOSITION_KEYS.includes(k)) ? 'shared' : 'view';
}

/**
 * Classify the cursor's data-transform context. Read from `jsonc-parser`'s
 * error-tolerant tree so it holds mid-edit — a step slot is a `transform` array
 * element position (`[ …, "transform", <index> ]`) that is *not* a property key,
 * which excludes the cursor being inside a step object editing its own keys (the
 * schema owns that). `scope` reads the owning view's keys, defaulting to `view` when
 * the tree can't resolve them.
 */
export function transformPlacementAt(text: string, offset: number): TransformPlacement {
  const { path, isAtPropertyKey } = getLocation(text, offset);
  if (
    isAtPropertyKey ||
    path.length < 2 ||
    path[path.length - 2] !== 'transform' ||
    typeof path[path.length - 1] !== 'number'
  ) {
    return { kind: 'none' };
  }
  const owner = objectNodeAt(parseTree(text), path.slice(0, -2));
  return { kind: 'step', scope: scopeFromKeys(objectKeys(owner)) };
}

/** Is this object node a view a `transform` can attach to — the root, a composition, or a unit? */
function isViewNode(node: Node): boolean {
  if (!node.parent) return true; // the root spec
  const keys = objectKeys(node);
  if (keys.some((k) => COMPOSITION_KEYS.includes(k))) return true;
  return keys.includes('mark') || keys.includes('encoding');
}

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

/**
 * The transform site for the nearest view enclosing `offset` — climbed from the
 * error-tolerant tree (so it holds mid-edit), or null when the cursor is in no view.
 * The CodeLens reads this to place its lenses and to splice a new step; the byte
 * ranges are turned into Monaco positions by the service.
 */
export function transformSiteAt(text: string, offset: number): TransformSite | null {
  const tree = parseTree(text);
  if (!tree) return null;
  let node: Node | undefined = findNodeAtOffset(tree, offset, true);
  while (node) {
    if (node.type === 'object' && isViewNode(node)) break;
    node = node.parent;
  }
  if (!node) return null;
  const array = transformArrayNode(node);
  return {
    scope: scopeFromKeys(objectKeys(node)),
    view: { offset: node.offset, length: node.length },
    array: array
      ? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
      : null,
  };
}

// ─── Field-typed default picking ────────────────────────────────────────────────

/** What kind of column a placeholder wants, so its default is type-appropriate. */
type FieldNeed = 'any' | 'numeric' | 'temporal' | 'categorical';

/** The first column matching a need, or null when none is in scope. */
function pick(fields: readonly TypedField[], need: FieldNeed): string | null {
  switch (need) {
    case 'numeric':
      return fields.find((f) => f.type === 'number')?.name ?? null;
    case 'temporal':
      return fields.find((f) => f.type === 'date')?.name ?? null;
    case 'categorical':
      return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
    case 'any':
      return fields[0]?.name ?? null;
  }
}

/** A tab-stop's default text: the resolved column when one is in scope, else a name. */
function def(fields: readonly TypedField[], need: FieldNeed, fallback: string): string {
  return pick(fields, need) ?? fallback;
}

// ─── The transform catalog ──────────────────────────────────────────────────────

/** One data transform the editor can scaffold as a `transform[]` step. */
export interface DataTransform {
  id: string;
  /** Completion label. */
  label: string;
  /** One-line description (completion detail). */
  detail: string;
  /** The step snippet: compact JSON with `${n:default}` tab stops. */
  step(fields: readonly TypedField[]): string;
}

/** The scaffoldable transforms, in rough order of how often they're reached for. */
export const DATA_TRANSFORMS: readonly DataTransform[] = [
  {
    id: 'filter',
    label: 'filter',
    detail: 'Keep only rows matching a predicate',
    step: (f) => `{ "filter": "datum.\${1:${def(f, 'any', 'field')}} > \${2:0}" }`,
  },
  {
    id: 'calculate',
    label: 'calculate',
    detail: 'Derive a new field from an expression',
    step: (f) => `{ "calculate": "datum.\${1:${def(f, 'any', 'field')}}", "as": "\${2:newField}" }`,
  },
  {
    id: 'aggregate',
    label: 'aggregate',
    detail: 'Group rows and summarize (mean, sum, count…)',
    step: (f) =>
      `{ "aggregate": [{ "op": "\${1:mean}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_mean}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
  },
  {
    id: 'bin',
    label: 'bin',
    detail: 'Bucket a quantitative field into bins',
    step: (f) =>
      `{ "bin": \${1:true}, "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_binned}" }`,
  },
  {
    id: 'timeUnit',
    label: 'timeUnit',
    detail: 'Derive a calendar unit (year, month…) from a date',
    step: (f) =>
      `{ "timeUnit": "\${1:yearmonth}", "field": "\${2:${def(f, 'temporal', 'date')}}", "as": "\${3:${def(f, 'temporal', 'date')}_unit}" }`,
  },
  {
    id: 'window',
    label: 'window',
    detail: 'Running totals, ranks, row numbers over a sort',
    step: (f) =>
      `{ "window": [{ "op": "\${1:row_number}", "as": "\${2:rank}" }], "sort": [{ "field": "\${3:${def(f, 'any', 'field')}}", "order": "descending" }] }`,
  },
  {
    id: 'joinaggregate',
    label: 'joinaggregate',
    detail: 'Group aggregate that keeps every row (share of total)',
    step: (f) =>
      `{ "joinaggregate": [{ "op": "\${1:sum}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:total}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
  },
  {
    id: 'fold',
    label: 'fold',
    detail: 'Reshape wide columns into key/value rows',
    step: (f) =>
      `{ "fold": ["\${1:${f[0]?.name ?? 'field1'}}", "\${2:${f[1]?.name ?? 'field2'}}"], "as": ["\${3:key}", "\${4:value}"] }`,
  },
  {
    id: 'lookup',
    label: 'lookup',
    detail: 'Join fields from another dataset by a key',
    step: () =>
      `{ "lookup": "\${1:key}", "from": { "data": { "name": "\${2:dataset}" }, "key": "\${3:key}", "fields": ["\${4:field}"] } }`,
  },
];
