/**
 * Cursor-scoped composition editing (docs/architecture/08 → editor augmentation).
 * Powers the composition CodeLens and its palette twins: over the view the cursor
 * sits in, add a sibling before/after it or reorder it among its siblings — the
 * complement to the wrap refactors (which create a composition; this grows and
 * rearranges an existing one).
 *
 * Portable core. `compositionTargetAt` maps a cursor offset to the innermost
 * `layer`/`hconcat`/`vconcat`/`concat` it is inside, the element it is on, and the
 * sibling count — everything a surface needs to place affordances and pick an
 * index. `insertView` / `moveView` are plain immutable edits at that path;
 * `elementOffset` locates an element afterwards so the editor can keep the cursor
 * on it. JSON-text formatting is the editor's job.
 */

import {
  findNodeAtLocation,
  findNodeAtOffset,
  getNodePath,
  parseTree,
  type Node,
} from 'jsonc-parser';
import { isJsonObject, type JsonObject } from './spec-config';
import { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms';

/** A spec path: object keys and array indices, from the root. */
export type SpecPath = (string | number)[];

/** The composition the cursor is inside, and where in it the cursor sits. */
export interface CompositionTarget {
  /** Path to the composition array (`layer`/`hconcat`/…) for the edit. */
  arrayPath: SpecPath;
  /** Start offset of the array node, for the empty-composition fallback anchor. */
  arrayOffset: number;
  /** Number of element views in the array. */
  count: number;
  /** The element the cursor is on, or null between/around elements. */
  element: { index: number; offset: number; length: number } | null;
}

/** Is this array node a composition array (its property key is `layer`/`concat`/…)? */
function isCompositionArray(node: Node): boolean {
  const parent = node.parent;
  if (parent?.type !== 'property') return false;
  const key: unknown = parent.children?.[0]?.value;
  return typeof key === 'string' && ARRAY_COMPOSITIONS.includes(key);
}

/**
 * The composition the cursor at `offset` is inside (the innermost one, so a view
 * nested in a layer-in-concat targets the layer), or null when the cursor is not
 * inside any `layer`/`hconcat`/`vconcat`/`concat`. Error-tolerant, so it works
 * mid-edit. `element` is null when the cursor is on the array's brackets or
 * between views — an empty array then offers a single append; a non-empty one
 * shows nothing until the cursor enters a view.
 */
export function compositionTargetAt(text: string, offset: number): CompositionTarget | null {
  const tree = parseTree(text);
  if (!tree) return null;
  let array: Node | undefined;
  for (let n = findNodeAtOffset(tree, offset, true); n; n = n.parent) {
    if (n.type === 'array' && isCompositionArray(n)) {
      array = n;
      break;
    }
  }
  if (!array) return null;
  const children = array.children ?? [];
  let element: CompositionTarget['element'] = null;
  for (let i = 0; i < children.length; i++) {
    const c = children[i];
    if (offset >= c.offset && offset <= c.offset + c.length) {
      element = { index: i, offset: c.offset, length: c.length };
      break;
    }
  }
  return {
    arrayPath: getNodePath(array),
    arrayOffset: array.offset,
    count: children.length,
    element,
  };
}

/** The array at `path` in `spec`, or null when the path does not lead to one. */
function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null {
  let node: unknown = spec;
  for (const segment of path) {
    if (Array.isArray(node)) node = node[segment as number];
    else if (isJsonObject(node)) node = node[segment as string];
    else return null;
  }
  return Array.isArray(node) ? node : null;
}

/**
 * Insert an empty placeholder view at `index` of the array at `path` (clamped to
 * the array's bounds). Returns a new spec, or null when the path does not lead to
 * an array (the text changed since the path was computed). Input is not mutated.
 */
export function insertView(spec: JsonObject, path: SpecPath, index: number): JsonObject | null {
  const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
  const array = arrayAt(next, path);
  if (!array) return null;
  array.splice(Math.max(0, Math.min(index, array.length)), 0, placeholderView());
  return next;
}

/**
 * Swap the view at `index` of the array at `path` with its neighbor `delta` steps
 * away (±1). Returns a new spec, or null when the path is not an array or either
 * position is out of bounds. Input is not mutated.
 */
export function moveView(
  spec: JsonObject,
  path: SpecPath,
  index: number,
  delta: number,
): JsonObject | null {
  const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
  const array = arrayAt(next, path);
  if (!array) return null;
  const to = index + delta;
  if (index < 0 || index >= array.length || to < 0 || to >= array.length) return null;
  [array[index], array[to]] = [array[to], array[index]];
  return next;
}

/** Start offset of the element at `[...path, index]` in the text, or null. */
export function elementOffset(text: string, path: SpecPath, index: number): number | null {
  const tree = parseTree(text);
  if (!tree) return null;
  return findNodeAtLocation(tree, [...path, index])?.offset ?? null;
}
