/**
 * Editor-state integrity: every node must be reachable from ROOT.
 *
 * A node that exists in `SerializedNodes` but appears in no parent's `nodes`
 * or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
 * selection machinery -- but not because it "renders but can't be selected".
 * Craft.js's `<Frame>` only instantiates nodes it can actually walk to via
 * `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is
 * never rendered at all: it doesn't appear on the canvas, it's just inert
 * data sitting in the serialized state. Reachability is computed from the
 * PARENT'S child lists, not from each node's own `parent` pointer: a stale
 * `parent: 'ROOT'` on a node ROOT never lists is precisely the broken case
 * we're looking for.
 *
 * This is a DIFFERENT mechanism from the originally-reported symptom -- a
 * *visible* element on the canvas that can't be selected or deleted. That
 * symptom requires the node to be both rendered AND excluded from Craft's
 * selection/interaction machinery, which is not what an unreachable-from-
 * ROOT node produces (it isn't rendered at all). This repair fixes the
 * "node is present in state but invisible/unrecoverable" case; the
 * originally-reported "visible but unselectable" case is still
 * unreproduced and is presumed to be a different bug.
 *
 * Pure functions over serialized state -- no React, no Craft instance.
 */

const ROOT_ID = 'ROOT';

function childIdsOf(node: any): string[] {
  const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : [];
  const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : [];
  return [...nodes, ...linked];
}

/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */
export function findUnreachableNodeIds(nodes: Record<string, any>): string[] {
  if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];

  const reachable = new Set<string>([ROOT_ID]);
  const queue: string[] = [ROOT_ID];

  while (queue.length > 0) {
    const id = queue.shift()!;
    for (const childId of childIdsOf(nodes[id])) {
      // The `reachable` guard also terminates on a cycle among real nodes.
      if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) {
        reachable.add(childId);
        queue.push(childId);
      }
    }
  }

  return Object.keys(nodes).filter((id) => !reachable.has(id));
}

/** Reattach every unreachable node to the end of ROOT.nodes. Returns the
 *  possibly-rewritten serialized state and the ids that were moved. Never
 *  throws: malformed input comes back unchanged with an empty `repaired`.
 *
 *  Invariant: for any input that parses and has a ROOT, calling
 *  `findUnreachableNodeIds` on the returned `state` (parsed) always yields
 *  `[]` -- there is no orphan configuration this leaves half-repaired. */
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } {
  let nodes: Record<string, any>;
  try {
    nodes = JSON.parse(serialized);
  } catch {
    // A page must never fail to load because the repair pass couldn't parse
    // it -- hand the original string straight back to deserialize().
    return { state: serialized, repaired: [] };
  }

  if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) {
    return { state: serialized, repaired: [] };
  }

  let unreachable = findUnreachableNodeIds(nodes);
  if (unreachable.length === 0) return { state: serialized, repaired: [] };

  if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
  const repaired: string[] = [];

  // Loop because a single pass can leave orphan CYCLES untouched: if every
  // member of a cluster points only at other members of that same cluster,
  // none of them has a parent pointing "out", so nothing qualifies as a top
  // and a one-shot pass would report `repaired: []` while the cluster is
  // still unreachable. Each iteration re-derives `unreachable` from the
  // current (partially repaired) state and terminates once it's empty --
  // this is what makes the invariant hold rather than just being hoped for.
  while (unreachable.length > 0) {
    const orphanSet = new Set(unreachable);

    // Reattach the TOP of each orphan subtree: a node whose `parent` points
    // outside the current orphan set (to something real, to nothing, or is
    // null). Its existing child list carries the rest of its subtree along
    // for free once BFS can reach it again.
    let tops = unreachable.filter((id) => {
      const parent = nodes[id]?.parent;
      return !(typeof parent === 'string' && orphanSet.has(parent));
    });

    // No such node exists only when every remaining orphan's `parent`
    // points at another orphan -- i.e. a closed cycle (2+ nodes referencing
    // only each other). There is no legitimate "outside" anchor to prefer,
    // so break the cycle by force-reattaching one representative member
    // (first in iteration order, for determinism). Because a cycle is
    // strongly connected via the actual nodes/linkedNodes edges, reattaching
    // any single member pulls the rest of that cycle in on the next
    // `findUnreachableNodeIds` pass without touching their `parent` fields.
    if (tops.length === 0) {
      tops = [unreachable[0]];
    }

    for (const id of tops) {
      nodes[id].parent = ROOT_ID;
      nodes[ROOT_ID].nodes.push(id);
      repaired.push(id);
    }

    unreachable = findUnreachableNodeIds(nodes);
  }

  return { state: JSON.stringify(nodes), repaired };
}
