/**
 * Path-targeted structural restructuring (docs/architecture/08 → editor
 * augmentation) — the cross-container moves behind the composition wireframe's
 * drag-to-restructure, the complement to spec-insert (single-array reorder within
 * one container) and spec-transforms (whole-spec wrap/unwrap).
 *
 * Two operations do the work. `wrapViews(target, source, axis, side)` pairs the
 * dragged `source` view beside the drop `target` *view* in a new `hconcat`/`vconcat`,
 * placed where the target was, and removes the source from where it came.
 * `wrapContainer(container, source, axis, side)` is the complement for a drop on a
 * frame's *margin*: it stacks the source against the whole `container` (the root
 * included) — pulling a view out into a new full-span row/column — rather than
 * beside one sibling. Three correctness rules ride along (the traps that make this
 * the hard part):
 *
 *  - **Flatten** — a bare same-orientation concat nested directly in a concat is
 *    redundant, so wrapping a view beside a sibling already in a matching-axis
 *    concat reads as a plain *insert*, not a nested box.
 *  - **Collapse** — removing the source can leave its old container with one child
 *    (unwrap it) or none (drop it, recursing upward) — `unwrapSingleton`'s job, here
 *    along an arbitrary path.
 *  - **Data-pin** — a source with no own `data` inherits its ancestor's; moved under
 *    a different one it would silently rebind, so its effective data is pinned on.
 *
 * Degenerate drops (onto itself, into its own descendant or its own ancestor, or
 * wrapping the root) return null. Portable core; JSON text/format/undo is the
 * editor's job, as for every transform here.
 */

import { dataBindingAtPath } from './spec-data';
import { isJsonObject, type JsonObject } from './spec-config';
import { arrayAtPath, isPrefixPath, valueAtPath, type SpecPath } from './spec-insert';
import { ARRAY_COMPOSITIONS, concatRootBeside } from './spec-transforms';

/** The orientation of the concat a drop creates. */
export type DropAxis = 'horizontal' | 'vertical';

/** Deep clone via JSON — specs are plain JSON, as everywhere in the transforms. */
const clone = (spec: JsonObject): JsonObject => JSON.parse(JSON.stringify(spec)) as JsonObject;

/** The concat operators a drop flattens through (layers are z-order, never flattened). */
const CONCAT_OPS = ['hconcat', 'vconcat', 'concat'] as const;

/**
 * The data block a sibling of the view at `targetPath` would inherit — the context
 * the new concat sits in, which is the binding in scope at the target's *parent*
 * (so the target's own `data`, if any, doesn't count: a sibling doesn't inherit it).
 */
function contextDataFor(spec: JsonObject, targetPath: SpecPath): unknown {
  return dataBindingAtPath(spec, targetPath.slice(0, -1))?.data;
}

/**
 * Pin the source's effective data onto it before the move, so it keeps the same
 * data in its new home. No-op when the source declares its own `data` (unaffected),
 * has no effective data to preserve, or already inherits the destination's data.
 */
function pinData(
  spec: JsonObject,
  source: JsonObject,
  sourcePath: SpecPath,
  targetPath: SpecPath,
): void {
  if ('data' in source) return;
  const current = dataBindingAtPath(spec, sourcePath);
  if (!current) return;
  const destData = contextDataFor(spec, targetPath);
  if (JSON.stringify(destData) !== JSON.stringify(current.data)) source.data = current.data;
}

/**
 * Remove the array element at `path` (its last segment an index), then collapse the
 * container it left behind: one child remaining → unwrap it onto the wrapper (the
 * child wins on conflict); none remaining → drop the empty composition key and, if
 * that empties its wrapper too, remove the wrapper from its parent array and repeat
 * upward. Mutates `spec`.
 */
function removeAndCollapse(spec: JsonObject, path: SpecPath): void {
  let arrPath = path.slice(0, -1);
  const idx = path[path.length - 1];
  const arr = arrayAtPath(spec, arrPath);
  if (!arr || typeof idx !== 'number') return;
  arr.splice(idx, 1);

  // Walk up collapsing redundant containers; only the splice above reduces a count,
  // and an unwrap preserves the parent's count, so each step is independent.
  while (arrPath.length >= 1) {
    const current = arrayAtPath(spec, arrPath);
    if (!current) break;
    const opKey = arrPath[arrPath.length - 1] as string;
    const wrapper = valueAtPath(spec, arrPath.slice(0, -1));
    if (!isJsonObject(wrapper)) break;

    if (current.length >= 2) break;
    if (current.length === 1) {
      const child = current[0];
      delete wrapper[opKey];
      if (isJsonObject(child)) Object.assign(wrapper, child); // child wins, like unwrapSingleton
      break; // count preserved upward — nothing more to collapse
    }
    // Empty composition: drop the key; if the wrapper is now an empty array element,
    // remove it from its parent array and continue collapsing that array.
    delete wrapper[opKey];
    const wrapperPath = arrPath.slice(0, -1);
    const wIdx = wrapperPath[wrapperPath.length - 1];
    if (Object.keys(wrapper).length === 0 && typeof wIdx === 'number') {
      const parentArr = arrayAtPath(spec, wrapperPath.slice(0, -1));
      if (!parentArr) break;
      parentArr.splice(wIdx, 1);
      arrPath = wrapperPath.slice(0, -1);
      continue;
    }
    break;
  }
}

/** Flatten any bare same-orientation concat nested directly in a concat, in place. */
function flattenBareConcats(node: unknown): void {
  if (Array.isArray(node)) {
    for (const item of node) flattenBareConcats(item);
    return;
  }
  if (!isJsonObject(node)) return;
  for (const op of CONCAT_OPS) {
    const arr = node[op];
    if (!Array.isArray(arr)) continue;
    const out: unknown[] = [];
    for (const el of arr) {
      flattenBareConcats(el);
      if (
        isJsonObject(el) &&
        Object.keys(el).length === 1 &&
        Array.isArray(el[op]) // a bare concat of the same orientation
      ) {
        out.push(...(el[op] as unknown[]));
      } else {
        out.push(el);
      }
    }
    node[op] = out;
  }
  for (const key of Object.keys(node)) {
    if (!CONCAT_OPS.includes(key as (typeof CONCAT_OPS)[number])) flattenBareConcats(node[key]);
  }
}

/**
 * Pair the `source` view beside the `target` view in a new concat of `axis`, in
 * the slot the target held, removing the source from where it was. `side` places
 * the source `before` or `after` the target. Returns a new spec, or null for a
 * degenerate drop (source is the target, an ancestor or descendant of it, or the
 * target is the root). The input is not mutated.
 *
 * Because a bare same-orientation concat is flattened afterward, dropping a view
 * onto an edge that runs *with* the target's existing concat reads as inserting it
 * into that concat; an edge that runs *across* it nests a new concat — the one
 * operation behind both the wireframe's "move into" and "wrap" drops.
 */
export function wrapViews(
  spec: JsonObject,
  targetPath: SpecPath,
  sourcePath: SpecPath,
  axis: DropAxis,
  side: 'before' | 'after',
): JsonObject | null {
  if (targetPath.length === 0) return null; // can't wrap the root
  // Self, ancestor, or descendant drops are degenerate.
  if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;

  const next = clone(spec);
  const target = valueAtPath(next, targetPath);
  const source = valueAtPath(next, sourcePath);
  if (!isJsonObject(target) || !isJsonObject(source)) return null;

  // Preserve the source's data before it changes context (reads the unmutated tree).
  pinData(next, source, sourcePath, targetPath);

  // Replace the target's slot with the new concat (by reference, so no index shifts).
  const op = axis === 'horizontal' ? 'hconcat' : 'vconcat';
  const wrapper: JsonObject = { [op]: side === 'before' ? [source, target] : [target, source] };
  const tParent = valueAtPath(next, targetPath.slice(0, -1));
  const tKey = targetPath[targetPath.length - 1];
  if (Array.isArray(tParent)) tParent[tKey as number] = wrapper;
  else if (isJsonObject(tParent)) tParent[tKey as string] = wrapper;
  else return null;

  // Remove the source from its old location (+ collapse), then flatten redundant nesting.
  removeAndCollapse(next, sourcePath);
  flattenBareConcats(next);
  return next;
}

/**
 * Collapse every single-child array-composition (`layer`/`concat`) to its lone
 * child (the child wins on key conflict, as `unwrapSingleton`) and drop empty
 * ones — recursively over the whole tree, bottom-up. The complement to
 * `flattenBareConcats`: it both cleans up after `wrapContainer` pulls a view out
 * (removing the source can leave a one- or zero-child composition along its old
 * branch) and powers `simplifyStructure`'s global pass. Whole-tree rather than
 * path-scoped (unlike `removeAndCollapse`) because re-slotting the container shifts
 * the source's old path; the collapse is semantics-preserving, so the wider sweep
 * is safe.
 */
function collapseSingletons(node: unknown): unknown {
  if (Array.isArray(node)) {
    return node
      .map(collapseSingletons)
      .filter((c) => !(isJsonObject(c) && Object.keys(c).length === 0));
  }
  if (!isJsonObject(node)) return node;
  const obj: Record<string, unknown> = {};
  for (const k of Object.keys(node)) obj[k] = collapseSingletons(node[k]);
  for (const op of ARRAY_COMPOSITIONS) {
    const arr = obj[op];
    if (!Array.isArray(arr)) continue;
    if (arr.length === 1 && isJsonObject(arr[0])) {
      const child = arr[0];
      delete obj[op];
      return { ...obj, ...child }; // child wins, like unwrapSingleton
    }
    if (arr.length === 0) delete obj[op];
  }
  return obj;
}

/**
 * Stack the `source` view against the *whole* container at `containerPath` in a new
 * concat of `axis` — the frame-margin pull-out. The container (the root included)
 * keeps its place; the source becomes a full-span sibling on `side`, removed from
 * where it was. The complement to `wrapViews`: there the source pairs with a single
 * sibling view, here with the container's entire contents.
 *
 * The source may sit *inside* the container (the motivating "pull a view out of its
 * row into a new row above") or in a sibling subtree. Returns null for a degenerate
 * drop (no source/container, or the source is the container or an ancestor of it).
 * The root case lifts the spec-level metadata via core/spec-transforms; the input is
 * not mutated.
 */
export function wrapContainer(
  spec: JsonObject,
  containerPath: SpecPath,
  sourcePath: SpecPath,
  axis: DropAxis,
  side: 'before' | 'after',
): JsonObject | null {
  // The source can be inside the container or in a sibling subtree, but never the
  // container itself or an ancestor of it.
  if (isPrefixPath(sourcePath, containerPath)) return null;

  const next = clone(spec);
  const source = valueAtPath(next, sourcePath);
  const container = valueAtPath(next, containerPath);
  if (!isJsonObject(source) || !isJsonObject(container)) return null;

  // The array holding the source today — captured before re-slotting shifts its path.
  const srcArr = arrayAtPath(next, sourcePath.slice(0, -1));
  if (!srcArr) return null;

  // Preserve the source's data before its context changes (reads the unmutated tree).
  pinData(next, source, sourcePath, containerPath);

  const op = axis === 'horizontal' ? 'hconcat' : 'vconcat';
  let root: JsonObject;
  if (containerPath.length === 0) {
    // Wrapping the whole spec: shared metadata stays on top, only the view is wrapped.
    root = concatRootBeside(container, source, axis === 'horizontal' ? 'h' : 'v', side);
  } else {
    const wrapper: JsonObject = {
      [op]: side === 'before' ? [source, container] : [container, source],
    };
    const cParent = valueAtPath(next, containerPath.slice(0, -1));
    const cKey = containerPath[containerPath.length - 1];
    if (Array.isArray(cParent)) cParent[cKey as number] = wrapper;
    else if (isJsonObject(cParent)) cParent[cKey as string] = wrapper;
    else return null;
    root = next;
  }

  // Remove the source from its old home (by reference — its path moved under the new
  // wrapper), then collapse any container left one-/zero-child and flatten redundant
  // same-orientation nesting the wrap produced (e.g. a new row inside its own column).
  const si = srcArr.indexOf(source);
  if (si >= 0) srcArr.splice(si, 1);

  // `collapseSingletons` runs whole-tree (the re-slot above shifts the source's path, so
  // a path-scoped walk can't find it). A deliberate side effect: a pull-out also tidies
  // any pre-existing redundant single-child wrapper elsewhere — the same cleanup the
  // Simplify action offers. Semantics-preserving, so it's safe to fold into the one edit.
  const collapsed = collapseSingletons(root) as JsonObject;
  flattenBareConcats(collapsed);
  return collapsed;
}

/**
 * Collapse every redundant single-child composition in the spec to its lone child —
 * a `{hconcat: [oneView]}` is just that view. Recursive and semantics-preserving: the
 * "simplify" the wireframe offers once it spots such wrappers. Returns the cleaned
 * spec, or null when there is nothing to simplify. (`facet`/`repeat` hold a single
 * child by design and aren't array-compositions, so they're left alone.)
 */
export function simplifyStructure(spec: JsonObject): JsonObject | null {
  const next = collapseSingletons(spec) as JsonObject;
  return JSON.stringify(next) === JSON.stringify(spec) ? null : next;
}
