/**
 * Composition wireframe — an interactive schematic of the active spec's multi-view
 * structure (docs/architecture/08 → editor augmentation). Nested boxes for
 * `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views;
 * each leaf carries a glyph of its mark type so which-is-which reads at a glance.
 * Clicking a box selects it and reveals that view's source range in the editor
 * (`AppStore.requestRevealView`) — the editor stays the source of truth.
 *
 * A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel
 * is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select,
 * roving tabindex, arrow-key nav in logical (document) order (arch 10 §5).
 *
 * On the editable draft a view can be **restructured by dragging its box**. Intent is
 * read from where the pointer falls against a row/column's children, not one nearest
 * edge (arch 10 §5):
 *   - the interior **central band** reorders within the container;
 *   - the **cross-axis frame margin** (a row's top/bottom, a column's left/right, or
 *     past the block) **pulls the source out** into a new full-span row/column wrapping
 *     the whole container — the root included;
 *   - a view's **far cross edge** pairs the two in a perpendicular `hconcat`/`vconcat`
 *     (`Shift` forces a pair from the centre); a with-axis drop moves/inserts there.
 * Opaque `layer`/`facet`/`repeat`/grid boxes keep the simpler nearest-edge model, and a
 * redundant single-child wrapper is flagged with a one-click **Simplify**. The keyboard
 * equivalent for in-container reorder is Alt+↑/↓ (the APG rearrangeable-listbox pattern);
 * cross-container restructuring stays the editor's wrap actions for keyboard users. Every
 * edit is applied by the editor (which owns the undoable edit) via
 * `AppStore.requestComposeMove`/`requestComposeWrap`/`requestComposeWrapContainer`/
 * `requestComposeSimplify`, focus follows the affected box, and a polite live region
 * announces the result.
 */

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import { ARRAY_COMPOSITIONS } from '@core/spec-transforms';
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
import { usePopover } from '../hooks/usePopover';
import { useAppStore } from '../stores/AppStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { markIconName } from './mark-icon';
import styles from './CompositionWireframe.module.css';

const POPOVER_ID = 'composition-wireframe';
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
/** Pointer travel (px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
/** The concat orientations a drop can descend into (layers/grids are opaque targets). */
const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal', 'vertical']);

type Edge = 'left' | 'right' | 'top' | 'bottom';

/**
 * Fraction of a view's cross-axis size, at each end, that reads as "pair here": drop a
 * view onto another's far edge (a row view's top/bottom, a column view's left/right) to
 * stack the two in a perpendicular split. The central band stays reorder, so an ordinary
 * along-the-axis drag rearranges. (Shift pairs from the central band too.)
 */
const PAIR_BAND = 0.25;

/** A DOM-safe, unique key for a node from its path. */
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');

// TODO: vocabulary diverges from the pair labels — pull-out names a vconcat slot "a row"
// (spatial: a full-span band) and an hconcat slot "a column", while the pair-into labels and
// the commit announcements use the container convention (hconcat = "row", vconcat = "column").
// Both describe the same axis; unify or keep the gesture-specific framing (docs/ux-second-pass.md).
/** The pull-out action label for the margin a drop landed in. */
const pullLabel = (edge: Edge): string =>
  edge === 'top'
    ? 'New row above'
    : edge === 'bottom'
      ? 'New row below'
      : edge === 'left'
        ? 'New column left'
        : 'New column right';

/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
function pathLabel(path: SpecPath): string {
  if (path.length === 0) return 'root';
  let out = '';
  for (const seg of path) out += typeof seg === 'number' ? `[${seg}]` : out ? `.${seg}` : seg;
  return out;
}

const descriptor = (n: ViewNode): string =>
  n.kind === 'unit' ? (n.mark ? `${n.mark} view` : 'view') : `${n.op} · ${n.children.length} views`;

const ariaLabelOf = (n: ViewNode): string =>
  n.kind === 'unit'
    ? n.mark
      ? `${n.mark} view`
      : 'view'
    : `${n.op}, ${n.orientation}, ${n.children.length} views`;

/** Children layout class per orientation (layered is a row of marks in one frame). */
const LAYOUT: Record<Orientation, string> = {
  horizontal: styles.row,
  vertical: styles.col,
  grid: styles.grid,
  layered: styles.layered,
};

interface Flat {
  node: ViewNode;
  key: string;
  parentKey: string | null;
  /** The composition that holds this node, or null for the root. */
  parent: ViewNode | null;
}

/** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */
function flatten(root: ViewNode): Flat[] {
  const out: Flat[] = [];
  const walk = (n: ViewNode, parentKey: string | null, parent: ViewNode | null) => {
    const key = keyOf(n.path);
    out.push({ node: n, key, parentKey, parent });
    for (const c of n.children) walk(c, key, n);
  };
  walk(root, null, null);
  return out;
}

/** The nearest edge of rect `r` to the point — drives the drop axis and side. */
function edgeOf(r: DOMRect, x: number, y: number): Edge {
  const fx = (x - r.left) / r.width;
  const fy = (y - r.top) / r.height;
  const dist: Record<Edge, number> = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
  return (Object.keys(dist) as Edge[]).reduce((a, b) => (dist[a] <= dist[b] ? a : b));
}

/** The resolved drop the wireframe will commit, and how to draw it on the target. */
interface DropResolution {
  targetKey: string;
  edge: Edge;
  mode: 'move' | 'wrap' | 'pull';
  /** A short human label for what the drop will do — shown in the drag chip. */
  label: string;
  commit:
    | { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
    | {
        kind: 'wrap';
        targetPath: SpecPath;
        sourcePath: SpecPath;
        axis: DropAxis;
        side: 'before' | 'after';
      }
    | {
        kind: 'wrap-container';
        containerPath: SpecPath;
        sourcePath: SpecPath;
        axis: DropAxis;
        side: 'before' | 'after';
      };
}

interface DragState {
  sourceKey: string;
  sourceNode: ViewNode;
  resolution: DropResolution | null;
  /** Live pointer position, for the drag chip that follows the cursor. */
  pointer: { x: number; y: number };
}

function WireframeTree({ tree }: { tree: ViewNode }) {
  const requestRevealView = useAppStore((s) => s.requestRevealView);
  const requestComposeMove = useAppStore((s) => s.requestComposeMove);
  const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
  const requestComposeWrapContainer = useAppStore((s) => s.requestComposeWrapContainer);
  const requestComposeSimplify = useAppStore((s) => s.requestComposeSimplify);
  // Restructure only on the editable draft — the published view is read-only.
  const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null);
  const flat = useMemo(() => flatten(tree), [tree]);
  const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]);
  const rootKey = keyOf(tree.path);

  // Redundant single-child wrappers — a `{hconcat: [oneView]}` is just that view (a
  // layer/concat of one is the same). Flagged on the boxes and offered up for a one-click
  // Simplify. (facet/repeat hold one child by design, so they're never array-compositions.)
  const degenerateKeys = useMemo(() => {
    const keys = new Set<string>();
    for (const { node, key } of flat) {
      if (node.op && ARRAY_COMPOSITIONS.includes(node.op) && node.children.length === 1)
        keys.add(key);
    }
    return keys;
  }, [flat]);

  const [selectedKey, setSelectedKey] = useState<string | null>(null);
  const [focusedKey, setFocusedKey] = useState<string | null>(null);
  const [hoverKey, setHoverKey] = useState<string | null>(null);
  const [drag, setDrag] = useState<DragState | null>(null);
  const [announcement, setAnnouncement] = useState('');
  // The box to focus once the tree rebuilds after a restructure (a drag leaves focus
  // on the body; keyboard needs focus to follow the view to its new place — APG).
  const [pendingFocusKey, setPendingFocusKey] = useState<string | null>(null);

  const treeRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<DragState | null>(null);
  const justDraggedRef = useRef(false);
  const setDragState = (d: DragState | null) => {
    dragRef.current = d;
    setDrag(d);
  };

  const effectiveFocus =
    (focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;

  const select = useCallback(
    (n: ViewNode) => {
      setSelectedKey(keyOf(n.path));
      setFocusedKey(keyOf(n.path));
      requestRevealView(n.offset, n.length);
    },
    [requestRevealView],
  );

  // Select, focus-after-rebuild, and announce the box at `key` once a restructure
  // has been requested (the tree rebuilds from the edited draft text).
  const settleOn = useCallback((key: string, message: string) => {
    setSelectedKey(key);
    setFocusedKey(key);
    setPendingFocusKey(key);
    setAnnouncement(message);
  }, []);

  const reorder = useCallback(
    (arrayPath: SpecPath, from: number, to: number, node: ViewNode, count: number) => {
      requestComposeMove(arrayPath, from, to);
      settleOn(
        keyOf([...arrayPath, to]),
        `Moved ${descriptor(node)} to position ${to + 1} of ${count}`,
      );
    },
    [requestComposeMove, settleOn],
  );

  // Restore focus to the affected box once it re-renders at its new position.
  useEffect(() => {
    if (!pendingFocusKey) return;
    const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${pendingFocusKey}"]`);
    if (el) {
      el.focus();
      setPendingFocusKey(null);
    }
  }, [flat, pendingFocusKey]);

  // Rect of the box for `key`, read live from the DOM during a drag.
  const rectOf = useCallback((key: string): DOMRect | null => {
    const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${key}"]`);
    return el ? el.getBoundingClientRect() : null;
  }, []);

  // The drop target: descend through concats (row/column) into the child under the
  // pointer; stop at a leaf or an opaque container (layer/facet/repeat/grid). The
  // dragged box is skipped so the pointer never targets it or its subtree.
  const dropTargetNode = useCallback(
    (x: number, y: number, sourceKey: string): ViewNode | null => {
      let node = tree;
      while (node.kind === 'composition' && node.orientation && DESCENDABLE.has(node.orientation)) {
        const child = node.children.find((c) => {
          if (keyOf(c.path) === sourceKey) return false;
          const r = rectOf(keyOf(c.path));
          return r ? x >= r.left && x <= r.right && y >= r.top && y <= r.bottom : false;
        });
        if (!child) break;
        node = child;
      }
      return keyOf(node.path) === sourceKey ? null : node;
    },
    [tree, rectOf],
  );

  // TODO: the drag-math cluster (edgeOf + dropTargetNode + this zone classifier) is pure
  // decision logic — given pointer, child rects, orientation and paths it returns a commit or
  // null — but lives in the component, tested only through happy-dom rect stubs. Lift it into a
  // core function tested without a DOM when a second draggable surface appears (eng-council).
  const resolveDrop = useCallback(
    (x: number, y: number, source: ViewNode, pair: boolean): DropResolution | null => {
      const sourcePath = source.path;
      const target = dropTargetNode(x, y, keyOf(sourcePath));
      if (!target) return null;

      // The row/column container we're acting within, and the child under the pointer
      // (null when the pointer fell in the container's margin or an inter-child gap).
      const targetIsRowCol =
        target.kind === 'composition' &&
        !!target.orientation &&
        DESCENDABLE.has(target.orientation);
      const container = targetIsRowCol ? target : (parentByKey.get(keyOf(target.path)) ?? null);
      const childUnder = targetIsRowCol ? null : target;

      // Inside a row/column, intent is read against the children's bounding box: reorder
      // is the default for the interior central band (so a drag along the block just
      // rearranges it); a pull-out needs the pointer past the children on the *cross* axis
      // (the frame margin or beyond the block); dropping onto a view's *far cross edge*
      // pairs the two into a nested split. (Opaque layer/grid contexts keep the
      // nearest-edge model further down.)
      if (container?.orientation && DESCENDABLE.has(container.orientation) && container.op) {
        const horizontal = container.orientation === 'horizontal';
        const arrayPath: SpecPath = [...container.path, container.op];
        const kids = container.children
          .map((c) => ({ node: c, rect: rectOf(keyOf(c.path)) }))
          .filter((k): k is { node: ViewNode; rect: DOMRect } => k.rect != null);
        if (!kids.length) return null;
        const bbox = {
          left: Math.min(...kids.map((k) => k.rect.left)),
          top: Math.min(...kids.map((k) => k.rect.top)),
          right: Math.max(...kids.map((k) => k.rect.right)),
          bottom: Math.max(...kids.map((k) => k.rect.bottom)),
        };

        // 1) Pull-out — pointer past the children on the cross axis. Pulling a node into
        //    its own descendant is the one degenerate case.
        const before = horizontal ? y < bbox.top : x < bbox.left;
        const after = horizontal ? y > bbox.bottom : x > bbox.right;
        if (before || after) {
          if (isPrefixPath(sourcePath, container.path)) return null;
          const edge: Edge = horizontal ? (before ? 'top' : 'bottom') : before ? 'left' : 'right';
          return {
            targetKey: keyOf(container.path),
            edge,
            mode: 'pull',
            label: pullLabel(edge),
            commit: {
              kind: 'wrap-container',
              containerPath: container.path,
              sourcePath,
              axis: horizontal ? 'vertical' : 'horizontal',
              side: before ? 'before' : 'after',
            },
          };
        }

        // 2) Pair — drop onto a view's far cross edge (a row view's top/bottom, a column
        //    view's left/right) to nest the two in a perpendicular split; Shift pairs from
        //    the central band too. The cross half picks the order.
        if (childUnder) {
          const cr = rectOf(keyOf(childUnder.path));
          const related =
            isPrefixPath(sourcePath, childUnder.path) || isPrefixPath(childUnder.path, sourcePath);
          if (cr && !related) {
            const cf = horizontal ? (y - cr.top) / cr.height : (x - cr.left) / cr.width;
            if (pair || cf < PAIR_BAND || cf > 1 - PAIR_BAND) {
              const lead = cf < 0.5;
              const axis: DropAxis = horizontal ? 'vertical' : 'horizontal';
              const edge: Edge = horizontal ? (lead ? 'top' : 'bottom') : lead ? 'left' : 'right';
              return {
                targetKey: keyOf(childUnder.path),
                edge,
                mode: 'wrap',
                label: `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
                commit: {
                  kind: 'wrap',
                  targetPath: childUnder.path,
                  sourcePath,
                  axis,
                  side: lead ? 'before' : 'after',
                },
              };
            }
          }
        }

        // 3) Reorder (default) — the insertion slot from the pointer's main-axis position,
        //    anchored on the child whose edge marks the gap.
        const mainPos = horizontal ? x : y;
        let index = 0;
        for (const k of kids) {
          const center = horizontal
            ? (k.rect.left + k.rect.right) / 2
            : (k.rect.top + k.rect.bottom) / 2;
          if (mainPos > center) index += 1;
        }
        const atEnd = index >= kids.length;
        const anchor = atEnd ? kids[kids.length - 1].node : kids[index].node;
        const edge: Edge = horizontal ? (atEnd ? 'right' : 'left') : atEnd ? 'bottom' : 'top';

        if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
          const from = sourcePath[sourcePath.length - 1] as number;
          let to = index > from ? index - 1 : index;
          to = Math.max(0, Math.min(to, container.children.length - 1));
          if (to === from) return null; // no-op
          return {
            targetKey: keyOf(anchor.path),
            edge,
            mode: 'move',
            label: 'Reorder',
            commit: { kind: 'move', arrayPath, from, to },
          };
        }
        // From another container → insert here, as a with-axis wrap that flattens in.
        if (isPrefixPath(sourcePath, anchor.path) || isPrefixPath(anchor.path, sourcePath))
          return null;
        return {
          targetKey: keyOf(anchor.path),
          edge,
          mode: 'move',
          label: 'Move here',
          commit: {
            kind: 'wrap',
            targetPath: anchor.path,
            sourcePath,
            axis: horizontal ? 'horizontal' : 'vertical',
            side: atEnd ? 'after' : 'before',
          },
        };
      }

      // Opaque container (layer/grid) or root: the simpler nearest-edge model.
      const targetPath = target.path;
      if (targetPath.length === 0) return null;
      if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
      const r = rectOf(keyOf(targetPath));
      if (!r) return null;
      const edge = edgeOf(r, x, y);
      const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical';
      const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after';
      const parent = parentByKey.get(keyOf(targetPath));
      const tIdx = targetPath[targetPath.length - 1];
      const along =
        (axis === 'horizontal' && parent?.orientation === 'horizontal') ||
        (axis === 'vertical' && parent?.orientation === 'vertical');
      if (along && parent && typeof tIdx === 'number') {
        const arrayPath = targetPath.slice(0, -1);
        if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
          const from = sourcePath[sourcePath.length - 1] as number;
          const gap = side === 'before' ? tIdx : tIdx + 1;
          let to = gap > from ? gap - 1 : gap;
          to = Math.max(0, Math.min(to, parent.children.length - 1));
          if (to === from) return null; // no-op
          return {
            targetKey: keyOf(targetPath),
            edge,
            mode: 'move',
            label: 'Reorder',
            commit: { kind: 'move', arrayPath, from, to },
          };
        }
      }
      return {
        targetKey: keyOf(targetPath),
        edge,
        mode: 'wrap',
        label: along ? 'Move here' : `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
        commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
      };
    },
    [dropTargetNode, parentByKey, rectOf],
  );

  const commitDrop = useCallback(
    (res: DropResolution, source: ViewNode) => {
      if (res.commit.kind === 'move') {
        const { arrayPath, from, to } = res.commit;
        const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
        reorder(arrayPath, from, to, source, count);
      } else if (res.commit.kind === 'wrap-container') {
        const { containerPath, sourcePath, axis, side } = res.commit;
        requestComposeWrapContainer(containerPath, sourcePath, axis, side);
        // Focus/selection lands on the container's slot — now the new split holding it.
        settleOn(
          keyOf(containerPath),
          `Pulled ${descriptor(source)} into a new ${axis === 'vertical' ? 'row' : 'column'}`,
        );
      } else {
        const { targetPath, sourcePath, axis, side } = res.commit;
        requestComposeWrap(targetPath, sourcePath, axis, side);
        // Focus/selection lands on the new split at the target's slot. When the drop
        // flattens to a plain insert (a with-axis cross-container drop), the target's
        // path shifts, so this key no longer resolves and the focus-restore no-ops — a
        // tolerated gap while cross-container wrap is pointer-only (focus matters for the
        // keyboard path, which is in-container `Alt+↑/↓` reorder, where the key is exact).
        settleOn(
          keyOf(targetPath),
          `Paired ${descriptor(source)} into a ${axis === 'horizontal' ? 'row' : 'column'}`,
        );
      }
    },
    [parentByKey, reorder, requestComposeWrap, requestComposeWrapContainer, settleOn],
  );

  const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
    if (e.button !== 0 || !editable) return;
    // Only the innermost view under the pointer starts the drag: every nested frame is
    // itself draggable, and without this the pointerdown bubbles to each ancestor, whose
    // beginDrag runs *after* (bubble order) and overwrites the source — so grabbing a
    // child would drag its outer block instead.
    e.stopPropagation();
    const startX = e.clientX;
    const startY = e.clientY;
    let started = false;
    // The last pointer + Shift state, so a Shift press/release re-resolves in place
    // (Shift forces a pair when the pointer is over a view's central band).
    let last = { x: startX, y: startY, pair: e.shiftKey };

    const resolveAt = () => {
      setDragState({
        sourceKey: keyOf(source.path),
        sourceNode: source,
        resolution: resolveDrop(last.x, last.y, source, last.pair),
        pointer: { x: last.x, y: last.y },
      });
    };
    const onMove = (ev: PointerEvent) => {
      if (!started) {
        if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
        started = true;
        document.body.style.cursor = 'grabbing';
        document.body.style.userSelect = 'none';
      }
      last = { x: ev.clientX, y: ev.clientY, pair: ev.shiftKey };
      resolveAt();
    };
    const onShift = (ev: WindowEventMap['keydown']) => {
      if (started && ev.key === 'Shift') {
        last = { ...last, pair: ev.type === 'keydown' };
        resolveAt();
      }
    };
    const cleanup = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onCancel);
      window.removeEventListener('keydown', onShift);
      window.removeEventListener('keyup', onShift);
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
    };
    const onUp = () => {
      const d = dragRef.current;
      cleanup();
      setDragState(null);
      if (started) {
        justDraggedRef.current = true; // swallow the click that follows a real drag
        if (d?.resolution) commitDrop(d.resolution, d.sourceNode);
      }
    };
    // A touch the browser reclaims for scrolling fires pointercancel — abort cleanly.
    const onCancel = () => {
      cleanup();
      setDragState(null);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onCancel);
    window.addEventListener('keydown', onShift);
    window.addEventListener('keyup', onShift);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const i = flat.findIndex((f) => f.key === effectiveFocus);
    if (i < 0) return;

    // Alt+↑/↓ reorders the focused view among its siblings (APG rearrangeable list).
    if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      e.preventDefault();
      const f = flat[i];
      const idx = f.node.path.at(-1);
      if (!editable || !f.parent || typeof idx !== 'number') return;
      const count = f.parent.children.length;
      const to = e.key === 'ArrowUp' ? idx - 1 : idx + 1;
      if (to < 0 || to >= count) {
        setAnnouncement(e.key === 'ArrowUp' ? 'Already at the start' : 'Already at the end');
        return;
      }
      reorder(f.node.path.slice(0, -1), idx, to, f.node, count);
      return;
    }

    const moveTo = (j: number) => {
      const target = flat[j];
      if (!target) return;
      e.preventDefault();
      setFocusedKey(target.key);
      treeRef.current?.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
    };
    switch (e.key) {
      case 'ArrowDown':
        return moveTo(i + 1);
      case 'ArrowUp':
        return moveTo(i - 1);
      case 'Home':
        return moveTo(0);
      case 'End':
        return moveTo(flat.length - 1);
      case 'ArrowRight': // first child is the next node in pre-order
        return flat[i].node.children.length ? moveTo(i + 1) : undefined;
      case 'ArrowLeft': {
        const pk = flat[i].parentKey;
        return pk ? moveTo(flat.findIndex((f) => f.key === pk)) : undefined;
      }
      case 'Enter':
      case ' ':
        e.preventDefault();
        return select(flat[i].node);
    }
  };

  const renderNode = (n: ViewNode, parent: ViewNode | null): ReactNode => {
    const key = keyOf(n.path);
    const container = n.kind === 'composition';
    const generated = n.op === 'facet' || n.op === 'repeat';
    const idx = n.path.at(-1);
    const draggable = editable && parent != null && typeof idx === 'number';
    const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null;
    // A unit view inside a layer is a bare mark glyph in the layer's shared frame,
    // not its own box — a layer is one plotting space with several marks stacked.
    const compactMark = parent?.op === 'layer' && !container;

    const cls = [
      styles.node,
      compactMark ? styles.layerMark : container ? styles.container : styles.leaf,
      n.op === 'layer' ? styles.layerBox : '',
      key === selectedKey ? styles.selected : '',
      generated ? styles.generated : '',
    ]
      .filter(Boolean)
      .join(' ');
    return (
      <div
        key={key}
        data-key={key}
        role="treeitem"
        aria-label={ariaLabelOf(n)}
        aria-selected={key === selectedKey}
        aria-expanded={container ? true : undefined}
        aria-keyshortcuts={draggable ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
        tabIndex={key === effectiveFocus ? 0 : -1}
        className={cls}
        data-draggable={draggable || undefined}
        data-dragging={drag?.sourceKey === key || undefined}
        data-orientation={n.orientation}
        data-degenerate={degenerateKeys.has(key) || undefined}
        data-drop-edge={onTarget && onTarget.mode !== 'pull' ? onTarget.edge : undefined}
        data-drop-mode={onTarget && onTarget.mode !== 'pull' ? onTarget.mode : undefined}
        data-drop-pull={onTarget?.mode === 'pull' ? onTarget.edge : undefined}
        onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
        onClick={(e) => {
          e.stopPropagation();
          if (justDraggedRef.current) {
            justDraggedRef.current = false;
            return;
          }
          select(n);
        }}
        onMouseEnter={(e) => {
          e.stopPropagation();
          setHoverKey(key);
        }}
        onMouseLeave={() => setHoverKey(null)}
        onFocus={(e) => {
          e.stopPropagation();
          setFocusedKey(key);
        }}
      >
        {container ? (
          <>
            {n.op === 'layer' && <Icon name="layers" className={styles.layerBadge} />}
            <div
              role="group"
              data-orientation={n.orientation}
              className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
            >
              {n.children.map((c) => renderNode(c, n))}
            </div>
          </>
        ) : (
          <Icon name={markIconName(n.mark)} size="md" className={styles.markIcon} />
        )}
      </div>
    );
  };

  const captionNode =
    (hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null;

  return (
    <>
      <div
        ref={treeRef}
        role="tree"
        aria-label="View composition"
        className={styles.tree}
        data-drag-active={drag ? '' : undefined}
        onKeyDown={onKeyDown}
      >
        {renderNode(tree, null)}
      </div>
      {editable && degenerateKeys.size > 0 && (
        <div className={styles.warning}>
          <span>
            {degenerateKeys.size === 1
              ? 'A single-view wrapper adds no structure.'
              : `${degenerateKeys.size} single-view wrappers add no structure.`}
          </span>
          <button
            type="button"
            className={styles.simplify}
            onClick={() => requestComposeSimplify()}
          >
            Simplify
          </button>
        </div>
      )}
      <p className={styles.caption} aria-hidden="true">
        {captionNode ? (
          <>
            <code>{pathLabel(captionNode.path)}</code> — {descriptor(captionNode)}
          </>
        ) : (
          <span className={styles.muted}>
            {editable
              ? 'Drag to reorder · onto a view’s edge to stack · into a frame’s margin to pull out · click to reveal'
              : 'Hover a block to identify it · click to reveal it'}
          </span>
        )}
      </p>
      <div className="visually-hidden" role="status" aria-live="polite">
        {announcement}
      </div>
      {/* A chip following the cursor names what the drop will do — the live "what
          happens" hint (pointer-only affordance, so aria-hidden; SR users get the
          commit announcement above). Portaled out of the clipped popover. */}
      {drag &&
        createPortal(
          <div
            className={styles.dragChip}
            data-empty={drag.resolution ? undefined : ''}
            style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 16 }}
            aria-hidden="true"
          >
            {drag.resolution?.label ?? 'No change here'}
          </div>,
          document.body,
        )}
    </>
  );
}

export function CompositionWireframe() {
  const { open, toggle, close, triggerRef, setPopNode } = usePopover({
    id: POPOVER_ID,
    align: 'right',
    flip: true,
    initialFocus: INITIAL_FOCUS,
  });
  const shownText = useSnippetStore(selectShownText);
  const tree = useMemo(() => viewTree(shownText), [shownText]);
  const hasComposition = !!tree && tree.kind === 'composition';

  // The wireframe is meaningless for a single-view spec — hide the affordance, and
  // close it if it was open when the composition is unwrapped away.
  useEffect(() => {
    if (!hasComposition && open) close();
  }, [hasComposition, open, close]);

  if (!tree || tree.kind !== 'composition') return null;

  return (
    <div className={styles.wrap}>
      <IconButton
        ref={triggerRef}
        label="View composition structure"
        aria-expanded={open}
        aria-controls={POPOVER_ID}
        onClick={toggle}
      >
        <Icon name="structure" />
      </IconButton>
      {open &&
        createPortal(
          <div
            ref={setPopNode}
            id={POPOVER_ID}
            className={styles.pop}
            role="group"
            aria-label="Composition structure"
          >
            <h4 className={styles.title}>Structure</h4>
            <WireframeTree tree={tree} />
          </div>,
          document.body,
        )}
    </div>
  );
}
