/**
 * 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).
 *
 * One operation does the work: `wrapViews(target, source, axis, side)` pairs the
 * dragged `source` view beside the drop `target` in a new `hconcat`/`vconcat`,
 * placed where the target was, and removes the source from where it came. 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';

/** 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;
}
