import { mergeAttributes, Node } from '@tiptap/core';
import { TextSelection, Transaction } from '@tiptap/pm/state';
import { ReactNodeViewRenderer } from '@tiptap/react';
import {
  FOOTNOTE_DEFINITION_NAME,
  FOOTNOTE_REFERENCE_NAME,
  FOOTNOTES_LIST_NAME,
  generateFootnoteId,
} from './footnote-util';
import { footnoteNumberingPlugin } from './footnote-numbering';
import { footnoteSyncPlugin, footnotePastePlugin } from './footnote-sync';

export interface FootnoteReferenceOptions {
  HTMLAttributes: Record<string, any>;
  view: any;
  /**
   * Optional predicate identifying remote/collaboration transactions so the
   * sync plugin skips them (orphan cleanup must run only on local changes).
   */
  isRemoteTransaction?: (tr: Transaction) => boolean;
  /**
   * When false, the footnote sync/integrity plugin is fully disabled — it never
   * appends a transaction. Numbering decorations stay active. Set this in
   * read-only / share editors so a viewer's doc is decorated (numbered) but
   * never mutated (e.g. by a programmatic setContent). Defaults to true.
   */
  enableSync?: boolean;
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    footnote: {
      /**
       * Insert a footnote reference at the cursor and create the matching
       * (empty) definition in the bottom footnotes list, in one transaction.
       */
      setFootnote: () => ReturnType;
      /**
       * Remove a footnote reference and cascade-delete its definition (one
       * transaction so a single undo restores both).
       */
      removeFootnote: (id: string) => ReturnType;
      /** Scroll to (and focus) a footnote definition by id. */
      scrollToFootnote: (id: string) => ReturnType;
      /** Scroll to a footnote reference by id. `index` selects WHICH occurrence
       *  to scroll to when the id is referenced more than once (reuse, #166):
       *  0-based, defaults to the first. Used by the definition's multi-backlink
       *  UI (#168). */
      scrollToReference: (id: string, index?: number) => ReturnType;
    };
  }
}

/**
 * Inline atom that marks a footnote reference in the body text. It holds only
 * an `id` linking it to its `footnoteDefinition`; the visible number is NOT
 * stored — it is rendered by the numbering plugin as a decoration (see
 * footnote-numbering.ts). Modeled on mention.ts (inline atom).
 *
 * The reference is forbidden inside code blocks and inside footnote definitions
 * (no nested footnotes); those restrictions are enforced by the `setFootnote`
 * command and the sync plugin rather than by schema content expressions, since
 * an inline group node cannot express "not inside X" declaratively.
 */
export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
  name: FOOTNOTE_REFERENCE_NAME,

  // Higher than the default (100) so its parse rule is considered before the
  // Superscript mark's <sup> rule.
  priority: 101,

  group: 'inline',
  inline: true,
  atom: true,
  selectable: true,
  draggable: false,

  addOptions() {
    return {
      HTMLAttributes: {},
      view: null,
      isRemoteTransaction: undefined,
      enableSync: true,
    };
  },

  addProseMirrorPlugins() {
    const plugins = [footnoteNumberingPlugin()];
    // Numbering always runs (decoration-only). The sync/integrity plugin is
    // skipped entirely when sync is disabled (read-only / share) so the viewer's
    // doc is never mutated.
    if (this.options.enableSync !== false) {
      plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction));
      // Regenerate colliding footnote ids on paste so a pasted reference+
      // definition pair never clobbers/merges with an existing footnote.
      plugins.push(footnotePastePlugin());
    }
    return plugins;
  },

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-id'),
        renderHTML: (attributes) => {
          if (!attributes.id) return {};
          return { 'data-id': attributes.id };
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        // High priority so the Superscript mark (which also matches <sup>) does
        // not claim a footnote reference and drop it as empty content.
        tag: 'sup[data-footnote-ref]',
        priority: 100,
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'sup',
      mergeAttributes(
        { 'data-footnote-ref': '', class: 'footnote-ref' },
        this.options.HTMLAttributes,
        HTMLAttributes,
      ),
    ];
  },

  // Plain-text representation (used by generateText / markdown text fallbacks).
  renderText({ node }) {
    return `[^${node.attrs.id ?? ''}]`;
  },

  addNodeView() {
    if (!this.options.view) return null;
    // Force the react node view to render immediately using flush sync.
    this.editor.isInitialized = true;
    return ReactNodeViewRenderer(this.options.view);
  },

  addCommands() {
    return {
      setFootnote:
        () =>
        ({ state, tr, dispatch, editor }) => {
          const { schema, selection } = state;
          const refType = schema.nodes[FOOTNOTE_REFERENCE_NAME];
          const listType = schema.nodes[FOOTNOTES_LIST_NAME];
          const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME];
          if (!refType || !listType || !defType) return false;

          const { $from } = selection;

          // Forbid references inside code blocks and inside footnote definitions
          // (no nested footnotes).
          for (let depth = $from.depth; depth > 0; depth--) {
            const node = $from.node(depth);
            if (
              node.type.spec.code ||
              node.type.name === FOOTNOTE_DEFINITION_NAME ||
              node.type.name === FOOTNOTES_LIST_NAME
            ) {
              return false;
            }
          }

          // Make sure the parent accepts an inline atom here.
          const insertPos = selection.from;
          if (
            !$from.parent.type.spec.content?.includes('inline') &&
            !$from.parent.isTextblock
          ) {
            return false;
          }

          const id = generateFootnoteId();

          // 1) Count references that occur strictly before the insertion point;
          //    the new definition goes at that index in the bottom list so the
          //    list order matches reference order.
          let refsBefore = 0;
          state.doc.nodesBetween(0, insertPos, (node) => {
            if (node.type.name === FOOTNOTE_REFERENCE_NAME) refsBefore++;
          });

          // 2) Insert the reference at the cursor.
          tr.insert(insertPos, refType.create({ id }));

          // 3) Locate (or create) the footnotes list, then insert the new
          //    definition at index `refsBefore`.
          const emptyParagraph = schema.nodes.paragraph.create();
          const definition = defType.create({ id }, emptyParagraph);

          // Find existing list (always the last top-level child if present).
          let listPos: number | null = null;
          let listNode: any = null;
          tr.doc.forEach((child, offset) => {
            if (child.type.name === FOOTNOTES_LIST_NAME) {
              listPos = offset;
              listNode = child;
            }
          });

          let defInsidePos: number | null = null;
          if (listNode == null) {
            // Create a new list at the very end of the document.
            const list = listType.create(null, definition);
            const end = tr.doc.content.size;
            tr.insert(end, list);
            // Cursor target: inside the new definition's first paragraph.
            // end -> list open, +1 definition open, +1 paragraph open.
            defInsidePos = end + 3;
          } else {
            // Insert at the right index within the existing list.
            const listStart = listPos! + 1; // position of the first definition
            let pos = listStart;
            let index = 0;
            listNode.forEach((defChild: any, defOffset: number) => {
              if (index < refsBefore) {
                pos = listStart + defOffset + defChild.nodeSize;
                index++;
              }
            });
            tr.insert(pos, definition);
            defInsidePos = pos + 2; // +1 enter definition, +1 enter paragraph
          }

          if (dispatch) {
            // Move the cursor into the new definition's paragraph so the user
            // can immediately type the footnote text.
            try {
              const resolved = tr.doc.resolve(
                Math.min(defInsidePos!, tr.doc.content.size),
              );
              tr.setSelection(TextSelection.near(resolved));
            } catch {
              // Selection placement is best-effort; ignore failures.
            }
            tr.scrollIntoView();
            dispatch(tr);
          }

          return true;
        },

      removeFootnote:
        (id: string) =>
        ({ state, tr, dispatch }) => {
          if (!id) return false;

          // Collect: reference range(s), the definition range, and the list.
          const refRanges: Array<{ from: number; to: number }> = [];
          let defRange: { from: number; to: number } | null = null;
          let listInfo: { pos: number; size: number; count: number } | null =
            null;

          state.doc.descendants((node, pos) => {
            if (
              node.type.name === FOOTNOTE_REFERENCE_NAME &&
              node.attrs.id === id
            ) {
              refRanges.push({ from: pos, to: pos + node.nodeSize });
            }
            if (
              node.type.name === FOOTNOTE_DEFINITION_NAME &&
              node.attrs.id === id
            ) {
              defRange = { from: pos, to: pos + node.nodeSize };
            }
            if (node.type.name === FOOTNOTES_LIST_NAME) {
              listInfo = {
                pos,
                size: node.nodeSize,
                count: node.childCount,
              };
            }
          });

          if (refRanges.length === 0 && !defRange) return false;

          // Build the list of ranges to delete. If removing this definition
          // would empty the list (it is the list's only child), delete the
          // entire list instead — an empty footnotesList is invalid schema and
          // a leftover empty list would be ugly.
          const ranges: Array<{ from: number; to: number }> = [...refRanges];
          if (defRange) {
            if (listInfo && (listInfo as any).count <= 1) {
              const li = listInfo as { pos: number; size: number };
              ranges.push({ from: li.pos, to: li.pos + li.size });
            } else {
              ranges.push(defRange);
            }
          }

          // Delete from the end so earlier positions stay valid.
          ranges
            .sort((a, b) => b.from - a.from)
            .forEach(({ from, to }) => tr.delete(from, to));

          if (dispatch) dispatch(tr);
          return true;
        },

      scrollToFootnote:
        (id: string) =>
        ({ editor }) => {
          if (!id) return false;
          const dom = editor.view.dom.querySelector(
            `[data-footnote-def][data-id="${id}"]`,
          ) as HTMLElement | null;
          if (!dom) return false;
          dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
          return true;
        },

      scrollToReference:
        (id: string, index = 0) =>
        ({ editor }) => {
          if (!id) return false;
          // querySelectorAll returns the occurrences in document order, so the
          // index maps 1:1 to the definition's a/b/c backlink (#168). Fall back
          // to the first match for an out-of-range index.
          const matches = editor.view.dom.querySelectorAll(
            `sup[data-footnote-ref][data-id="${id}"]`,
          );
          const dom = (matches[index] ?? matches[0]) as HTMLElement | undefined;
          if (!dom) return false;
          dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
          return true;
        },
    };
  },
});
