import { describe, it, expect } from "vitest";
import { Editor, Extension, getSchema } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Superscript } from "@tiptap/extension-superscript";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as PMNode } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import { FootnoteReference } from "./footnote-reference";
import { FootnotesList } from "./footnotes-list";
import { FootnoteDefinition } from "./footnote-definition";
import { TrailingNode } from "../trailing-node";
import { footnoteSyncPlugin } from "./footnote-sync";
import { getFootnoteNumber } from "./footnote-numbering";
import {
  computeFootnoteNumbers,
  collectReferenceIds,
  FOOTNOTE_REFERENCE_NAME,
  FOOTNOTES_LIST_NAME,
  FOOTNOTE_DEFINITION_NAME,
} from "./footnote-util";

const extensions = [
  Document,
  Paragraph,
  Text,
  FootnoteReference,
  FootnotesList,
  FootnoteDefinition,
];

function makeEditor(content?: any) {
  return new Editor({
    extensions,
    content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
  });
}

function countType(doc: PMNode, name: string): number {
  let n = 0;
  doc.descendants((node) => {
    if (node.type.name === name) n++;
  });
  return n;
}

describe("footnote numbering (pure function)", () => {
  it("numbers references in document order", () => {
    const schema = getSchema(extensions);
    const doc = PMNode.fromJSON(schema, {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "x" },
              content: [{ type: "paragraph" }],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "y" },
              content: [{ type: "paragraph" }],
            },
          ],
        },
      ],
    });

    expect(collectReferenceIds(doc)).toEqual(["x", "y"]);
    const numbers = computeFootnoteNumbers(doc);
    expect(numbers.get("x")).toBe(1);
    expect(numbers.get("y")).toBe(2);
  });
});

describe("setFootnote command", () => {
  it("inserts a reference and a matching definition in the footnotes list", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        { type: "paragraph", content: [{ type: "text", text: "Hello" }] },
      ],
    });
    // Cursor at end of the word.
    editor.commands.setTextSelection(6);
    const ok = editor.commands.setFootnote();
    expect(ok).toBe(true);

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);

    // The reference id and the definition id match.
    let refId: string | null = null;
    let defId: string | null = null;
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id;
      if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id;
    });
    expect(refId).toBeTruthy();
    expect(refId).toBe(defId);
    editor.destroy();
  });

  it("inserts the definition at the correct position matching reference order", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        { type: "paragraph", content: [{ type: "text", text: "AAAA" }] },
        { type: "paragraph", content: [{ type: "text", text: "BBBB" }] },
      ],
    });

    // First footnote: place inside the SECOND paragraph (after "BBBB").
    editor.commands.setTextSelection(11); // end of BBBB
    editor.commands.setFootnote();

    // Second footnote: place inside the FIRST paragraph (after "AAAA"),
    // which is BEFORE the first reference in document order.
    editor.commands.setTextSelection(5); // end of AAAA
    editor.commands.setFootnote();

    const doc = editor.state.doc;
    // Reference order in document.
    const refOrder = collectReferenceIds(doc);
    // Definition order in the list.
    const defOrder: string[] = [];
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
        defOrder.push(node.attrs.id);
      }
    });

    expect(defOrder).toEqual(refOrder);
    expect(defOrder.length).toBe(2);
    editor.destroy();
  });
});

describe("removeFootnote command (cascade)", () => {
  it("removes both the reference and its definition, and drops the empty list", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        { type: "paragraph", content: [{ type: "text", text: "Hello" }] },
      ],
    });
    editor.commands.setTextSelection(6);
    editor.commands.setFootnote();

    let id: string | null = null;
    editor.state.doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id;
    });
    expect(id).toBeTruthy();

    editor.commands.removeFootnote(id!);

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0);
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
    // empty list removed
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
    editor.destroy();
  });
});

describe("footnote sync plugin (orphans)", () => {
  it("creates an empty definition for a reference pasted without one", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "x" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } },
          ],
        },
      ],
    });
    // Trigger a doc change so appendTransaction runs.
    editor.commands.insertContentAt(1, " ");

    const doc = editor.state.doc;
    let defFound = false;
    doc.descendants((node) => {
      if (
        node.type.name === FOOTNOTE_DEFINITION_NAME &&
        node.attrs.id === "orphan-ref"
      ) {
        defFound = true;
      }
    });
    expect(defFound).toBe(true);
    editor.destroy();
  });

  it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
          ],
        },
        // First (stray) footnotes list, e.g. from a paste/collab merge.
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "x" },
              content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
            },
          ],
        },
        { type: "paragraph", content: [{ type: "text", text: "tail" }] },
        // Second footnotes list (the "real" trailing one).
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "y" },
              content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }],
            },
          ],
        },
      ],
    });
    // Trigger a local doc change so appendTransaction runs.
    editor.commands.insertContentAt(1, " ");

    const doc = editor.state.doc;
    // Converged to exactly ONE list.
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    // Both definitions preserved (no tracking lost).
    const defIds: string[] = [];
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id);
    });
    expect(defIds.sort()).toEqual(["x", "y"]);
    // The single list is the LAST child of the document.
    const lastChild = doc.child(doc.childCount - 1);
    expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
    editor.destroy();
  });

  it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "x" },
              content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
            },
          ],
        },
      ],
    });
    const before = editor.state.doc.toJSON();
    // A change that doesn't touch footnote structure.
    editor.commands.insertContentAt(1, "z");
    const doc = editor.state.doc;
    // Still exactly one list, still last, definition preserved.
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    const lastChild = doc.child(doc.childCount - 1);
    expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
    // The footnotes list subtree is identical to before (no spurious rewrite).
    const beforeList = before.content.find(
      (n: any) => n.type === FOOTNOTES_LIST_NAME,
    );
    const afterList = doc
      .toJSON()
      .content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
    expect(afterList).toEqual(beforeList);
    editor.destroy();
  });

  it("two definitions sharing an id (with two matching references) BOTH survive the first edit (no data loss)", () => {
    // Reproduces the verified data-loss bug: two footnoteDefinition nodes share
    // id "d", and there are two references with id "d". The OLD code built the
    // definitions Map last-wins and emitted exactly one definition for the
    // de-duplicated reference, so the very first keystroke's sync transaction
    // deleted the whole list and rebuilt it from one definition — silently
    // destroying "first" and keeping only "second".
    const editor = makeEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "d" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "first" }] },
              ],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "d" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "second" }] },
              ],
            },
          ],
        },
      ],
    });
    // The first local keystroke fires the sync plugin's appendTransaction.
    editor.commands.insertContentAt(1, " ");

    const doc = editor.state.doc;
    // BOTH definitions survive.
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
    const defTexts: string[] = [];
    const defIds: string[] = [];
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
        defIds.push(node.attrs.id);
        defTexts.push(node.textContent);
      }
    });
    // No content was lost: both "first" and "second" are still present.
    expect(defTexts.sort()).toEqual(["first", "second"]);
    // The colliding ids were made distinct.
    expect(new Set(defIds).size).toBe(2);
    // Each definition's id matches exactly one reference (1:1 pairing).
    const refIds: string[] = [];
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_REFERENCE_NAME) refIds.push(node.attrs.id);
    });
    expect(refIds.sort()).toEqual(defIds.sort());
    editor.destroy();
  });

  it("re-ids colliding duplicates DETERMINISTICALLY (two clients converge to identical ids)", () => {
    // Cross-client determinism guard. Two collaborating clients each see the
    // SAME duplicate-id document and each make a local edit. The sync plugin
    // runs identically on every client, so it MUST mint the SAME new ids on both
    // — otherwise the two clients diverge permanently over Yjs (duplicated
    // footnotes). This is exactly the blocker the previous random-id
    // (generateFootnoteId / Math.random) implementation caused: it would mint
    // DIFFERENT ids on each client and this assertion would fail.
    const duplicateDoc = {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
            { type: "text", text: "c" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "d" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "one" }] },
              ],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "d" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "two" }] },
              ],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "d" },
              content: [
                {
                  type: "paragraph",
                  content: [{ type: "text", text: "three" }],
                },
              ],
            },
          ],
        },
      ],
    };

    const idsAfterLocalEdit = () => {
      // A fresh editor instance = an independent "client" running the same
      // plugin pipeline on the same starting document.
      const editor = makeEditor(structuredClone(duplicateDoc));
      editor.commands.insertContentAt(1, " "); // local keystroke -> sync runs
      const refIds: string[] = [];
      const defIds: string[] = [];
      editor.state.doc.descendants((node) => {
        if (node.type.name === FOOTNOTE_REFERENCE_NAME)
          refIds.push(node.attrs.id);
        if (node.type.name === FOOTNOTE_DEFINITION_NAME)
          defIds.push(node.attrs.id);
      });
      editor.destroy();
      return { refIds, defIds };
    };

    const clientA = idsAfterLocalEdit();
    const clientB = idsAfterLocalEdit();

    // Both clients computed IDENTICAL ids (the property that makes Yjs converge).
    expect(clientA.refIds).toEqual(clientB.refIds);
    expect(clientA.defIds).toEqual(clientB.defIds);

    // And the ids are deterministic-derived (not random uuid-style): the keeper
    // keeps "d", the duplicates become "d__2", "d__3".
    expect(new Set(clientA.refIds)).toEqual(new Set(["d", "d__2", "d__3"]));
    // Every definition survived with a unique id, 1:1 with the references.
    expect(clientA.defIds.length).toBe(3);
    expect(new Set(clientA.defIds).size).toBe(3);
    expect([...clientA.refIds].sort()).toEqual([...clientA.defIds].sort());
  });

  it("removes an orphan definition with no matching reference", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        { type: "paragraph", content: [{ type: "text", text: "x" }] },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "orphan-def" },
              content: [{ type: "paragraph" }],
            },
          ],
        },
      ],
    });
    editor.commands.insertContentAt(1, "y");

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
    editor.destroy();
  });
});

/**
 * Live-editor regression tests for the sync-plugin infinite loop (the hard
 * freeze when activating /footnote). These drive a REAL Tiptap editor through
 * the same plugin pipeline the browser uses — including the TrailingNode plugin,
 * which is what turned the "move list to the end" pass into an infinite
 * ping-pong (list moved last -> trailing paragraph appended after it -> list no
 * longer last -> moved again -> ...).
 *
 * If the loop regresses, ProseMirror's appendTransaction round loop never
 * terminates and these tests HANG (the vitest timeout fails them). The
 * transaction counter additionally fails fast with a bounded iteration cap, so
 * a regression surfaces as an explicit error instead of only a slow timeout.
 */
describe("footnote sync plugin (no infinite loop — live editor)", () => {
  // Hard cap on how many doc-changing appendTransaction rounds we tolerate for a
  // single user action. Convergence takes a couple of rounds at most; anything
  // approaching this means the plugins are oscillating.
  const MAX_ROUNDS = 50;

  // The production editor wires FootnoteReference alongside TrailingNode and
  // Superscript; both participate in the loop the bug exhibited, so we mirror
  // that here.
  function makeLiveEditor(content?: any) {
    let rounds = 0;
    // A guard plugin that counts doc-changing appendTransaction rounds and
    // throws if they exceed the cap, converting a would-be infinite loop into a
    // deterministic failure instead of a wall-clock hang.
    const LoopGuard = Extension.create({
      name: "footnoteLoopGuard",
      // Run last so it observes every other plugin's appended transaction.
      priority: -1000,
      addProseMirrorPlugins() {
        return [
          new Plugin({
            key: new PluginKey("footnoteLoopGuard"),
            appendTransaction(transactions) {
              if (transactions.some((t) => t.docChanged)) {
                rounds += 1;
                if (rounds > MAX_ROUNDS) {
                  throw new Error(
                    `footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`,
                  );
                }
              }
              return null;
            },
          }),
        ];
      },
    });

    const editor = new Editor({
      extensions: [
        Document,
        Paragraph,
        Text,
        Superscript,
        TrailingNode,
        LoopGuard,
        FootnoteReference,
        FootnotesList,
        FootnoteDefinition,
      ],
      content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
    });
    return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) };
  }

  function lastFootnotesListIsTrailing(doc: PMNode): boolean {
    // Canonical placement: the list is the last meaningful block — only empty
    // paragraphs (the trailing-node) may follow it.
    let listIndex = -1;
    for (let i = 0; i < doc.childCount; i++) {
      if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i;
    }
    if (listIndex === -1) return false;
    for (let i = listIndex + 1; i < doc.childCount; i++) {
      const child = doc.child(i);
      if (!(child.type.name === "paragraph" && child.content.size === 0)) {
        return false;
      }
    }
    return true;
  }

  it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => {
    const { editor } = makeLiveEditor({
      type: "doc",
      content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
    });
    editor.commands.setTextSelection(3);
    const ok = editor.commands.setFootnote();
    expect(ok).toBe(true);

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
    expect(lastFootnotesListIsTrailing(doc)).toBe(true);
    editor.destroy();
  });

  it("a second setFootnote() does not hang: two refs + two defs in one list", () => {
    const { editor } = makeLiveEditor({
      type: "doc",
      content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
    });
    editor.commands.setTextSelection(3);
    editor.commands.setFootnote();
    editor.commands.setTextSelection(3);
    editor.commands.setFootnote();

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2);
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    expect(lastFootnotesListIsTrailing(doc)).toBe(true);
    editor.destroy();
  });

  it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => {
    const { editor, getRounds, resetRounds } = makeLiveEditor({
      type: "doc",
      content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
    });
    editor.commands.setTextSelection(3);
    editor.commands.setFootnote();

    // Now the doc is canonical. Dispatch an unrelated edit (insert text) and
    // assert the sync plugin converges in a bounded number of rounds and the
    // document is stable (one ref/def/list, list trailing).
    resetRounds();
    editor.commands.insertContentAt(1, "Z");
    const afterFirst = editor.state.doc.toJSON();
    const roundsAfterEdit = getRounds();
    expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS);

    // A follow-up no-op-ish edit must not re-trigger structural rewrites: the
    // footnotes section is identical before and after a further unrelated edit.
    editor.commands.insertContentAt(2, "Y");
    const afterSecond = editor.state.doc.toJSON();

    const listOf = (json: any) =>
      json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
    expect(listOf(afterSecond)).toEqual(listOf(afterFirst));
    expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1);
    editor.destroy();
  });

  it("two footnotesList nodes converge to one (merge) without looping", () => {
    const { editor } = makeLiveEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "x" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "X" }] },
              ],
            },
          ],
        },
        { type: "paragraph", content: [{ type: "text", text: "tail" }] },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "y" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "Y" }] },
              ],
            },
          ],
        },
      ],
    });
    // Trigger a local doc change so appendTransaction runs (must not hang).
    editor.commands.insertContentAt(1, " ");

    const doc = editor.state.doc;
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    const defIds: string[] = [];
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_DEFINITION_NAME)
        defIds.push(node.attrs.id);
    });
    expect(defIds.sort()).toEqual(["x", "y"]);
    expect(lastFootnotesListIsTrailing(doc)).toBe(true);
    editor.destroy();
  });
});

/**
 * Data-loss-window regression guard (Fix 1). A pure reference REORDER must not
 * cause the sync plugin to delete-and-recreate any definition subtree — doing so
 * (the previous behaviour) would, through Yjs, replace the CRDT subtree of every
 * definition and could lose a collaborator's in-flight characters on merge.
 *
 * Numbering is decoration-only (footnote-numbering.ts derives numbers from
 * reference order), so the bottom list's PHYSICAL order need not match reference
 * order for the displayed numbers to be correct. We therefore assert: the
 * existing definition NODE INSTANCES are preserved (identity-equal) after the
 * sync pass, AND the derived numbers follow the new reference order.
 */
describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", () => {
  function reorderedDoc() {
    // The "out of order" end-state of a reorder: references occur as [b, a] but
    // the bottom list still physically holds definitions in [a, b] order. This
    // is exactly the situation a reference reorder produces (decoration-only
    // numbering keeps the displayed numbers correct without physically moving
    // the definition subtrees). The sync plugin must leave the definitions
    // ALONE here — no delete/recreate of any definition subtree.
    return {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "p" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } },
            { type: "text", text: "q" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "a" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "A" }] },
              ],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "b" },
              content: [
                { type: "paragraph", content: [{ type: "text", text: "B" }] },
              ],
            },
          ],
        },
      ],
    };
  }

  function getDefNodesById(doc: PMNode): Map<string, PMNode> {
    const m = new Map<string, PMNode>();
    doc.descendants((node) => {
      if (node.type.name === FOOTNOTE_DEFINITION_NAME) m.set(node.attrs.id, node);
    });
    return m;
  }

  it("does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)", () => {
    const editor = makeEditor(reorderedDoc());

    // Capture the exact definition NODE INSTANCES before any sync pass.
    const before = getDefNodesById(editor.state.doc);
    // Sanity: both carry their content right now.
    expect(before.get("a")!.textContent).toBe("A");
    expect(before.get("b")!.textContent).toBe("B");

    // Trigger a local edit elsewhere in the body so the sync plugin runs.
    editor.commands.insertContentAt(1, "z");

    const doc = editor.state.doc;

    // Reference order is [b, a]; the displayed numbers follow reference order
    // (decoration-only numbering): b -> 1, a -> 2 — regardless of physical list
    // order.
    expect(collectReferenceIds(doc)).toEqual(["b", "a"]);
    const numbers = computeFootnoteNumbers(doc);
    expect(numbers.get("b")).toBe(1);
    expect(numbers.get("a")).toBe(2);

    // CRITICAL regression guard: both definitions still exist and are the SAME
    // node instances as before the edit — the plugin did NOT delete/recreate the
    // list (which would replace every definition's CRDT subtree and open the
    // concurrent-edit data-loss window). Identity equality proves the subtree
    // was preserved verbatim.
    const after = getDefNodesById(doc);
    expect(after.get("a")).toBe(before.get("a"));
    expect(after.get("b")).toBe(before.get("b"));
    // Content intact, exactly one list, both definitions present.
    expect(after.get("a")!.textContent).toBe("A");
    expect(after.get("b")!.textContent).toBe("B");
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);

    editor.destroy();
  });
});

/**
 * Sync-plugin guard paths that are awkward to exercise through a live editor:
 * the remote-transaction skip and the enableSync:false (read-only) mode.
 */
describe("footnote sync plugin (guards)", () => {
  // Build a non-canonical document (an orphan reference with no definition) so a
  // sync pass would normally append a transaction.
  function nonCanonicalState() {
    const schema = getSchema(extensions);
    const doc = PMNode.fromJSON(schema, {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "x" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
          ],
        },
      ],
    });
    return EditorState.create({ schema, doc });
  }

  it("isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)", () => {
    // The sync plugin must SKIP remote/collab transactions so orphan cleanup and
    // structural rewrites only ever run on local edits.
    const plugin = footnoteSyncPlugin(() => true);
    const state = nonCanonicalState();

    // Produce a doc-changing transaction (insert a space) and feed it to the
    // plugin's appendTransaction exactly as ProseMirror would.
    const tr = state.tr.insertText(" ", 1);
    const newState = state.apply(tr);
    const result = plugin.spec.appendTransaction!(
      [tr],
      state,
      newState,
    );
    expect(result).toBeNull();
  });

  it("isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)", () => {
    // Control: with a local (non-remote) transaction the same non-canonical doc
    // triggers a sync transaction, proving the null above is the remote guard
    // and not a no-op everywhere.
    const plugin = footnoteSyncPlugin(() => false);
    const state = nonCanonicalState();
    const tr = state.tr.insertText(" ", 1);
    const newState = state.apply(tr);
    const result = plugin.spec.appendTransaction!([tr], state, newState);
    expect(result).not.toBeNull();
    expect(result!.docChanged).toBe(true);
  });

  it("enableSync:false: the plugin never mutates the doc (read-only viewer)", () => {
    // Build an editor with sync disabled. An orphan reference (no definition)
    // must NOT trigger a definition insertion — the document is left untouched.
    const editor = new Editor({
      extensions: [
        Document,
        Paragraph,
        Text,
        FootnoteReference.configure({ enableSync: false }),
        FootnotesList,
        FootnoteDefinition,
      ],
      content: {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              { type: "text", text: "x" },
              { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
            ],
          },
        ],
      },
    });
    // A local edit that would normally trigger orphan-definition synthesis.
    editor.commands.insertContentAt(1, "y");

    const doc = editor.state.doc;
    // No definition (and no list) was ever created — sync is disabled.
    expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
    expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
    // Numbering decorations still work: the reference is numbered 1.
    expect(getFootnoteNumber(editor.state, "orphan")).toBe(1);
    editor.destroy();
  });
});

/**
 * Numbering cache (Fix 2). NodeViews must read footnote numbers from the
 * numbering plugin's cached map (updated once per doc change) rather than
 * recomputing the whole map per render. We assert the cache exists, is correct,
 * and stays current across edits.
 */
describe("footnote numbering cache", () => {
  it("exposes correct numbers via getFootnoteNumber and updates on edits", () => {
    const editor = makeEditor({
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            { type: "text", text: "a" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
            { type: "text", text: "b" },
            { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
          ],
        },
        {
          type: FOOTNOTES_LIST_NAME,
          content: [
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "x" },
              content: [{ type: "paragraph" }],
            },
            {
              type: FOOTNOTE_DEFINITION_NAME,
              attrs: { id: "y" },
              content: [{ type: "paragraph" }],
            },
          ],
        },
      ],
    });

    // The cache mirrors computeFootnoteNumbers — but is read in O(1) per id.
    expect(getFootnoteNumber(editor.state, "x")).toBe(1);
    expect(getFootnoteNumber(editor.state, "y")).toBe(2);
    // The cached map is the SAME values a fresh full computation would yield.
    const fresh = computeFootnoteNumbers(editor.state.doc);
    expect(getFootnoteNumber(editor.state, "x")).toBe(fresh.get("x"));
    expect(getFootnoteNumber(editor.state, "y")).toBe(fresh.get("y"));

    // After inserting a new earlier reference, the cache updates so the numbers
    // shift (decoration-only numbering follows reference order).
    editor.commands.insertContentAt(1, {
      type: FOOTNOTE_REFERENCE_NAME,
      attrs: { id: "z" },
    });
    expect(getFootnoteNumber(editor.state, "z")).toBe(1);
    expect(getFootnoteNumber(editor.state, "x")).toBe(2);
    expect(getFootnoteNumber(editor.state, "y")).toBe(3);
    editor.destroy();
  });
});
