import type { NodeWithPos } from "@tiptap/core";
import {
  combineTransactionSteps,
  findChildrenInRange,
  getChangedRanges,
  getMarksBetween,
} from "@tiptap/core";
import type { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { LinkMatch } from "./linkDetector.js";
import { tokenizeLink } from "./linkDetector.js";

import {
  UNICODE_WHITESPACE_REGEX,
  UNICODE_WHITESPACE_REGEX_END,
} from "./whitespace.js";

/**
 * Check if the provided tokens form a valid link structure, which can either be a single link token
 * or a link token surrounded by parentheses or square brackets.
 */
function isValidLinkStructure(tokens: LinkMatch[]) {
  if (tokens.length === 1) {
    return tokens[0].isLink;
  }

  if (tokens.length === 3 && tokens[1].isLink) {
    return ["()", "[]"].includes(tokens[0].value + tokens[2].value);
  }

  return false;
}

type AutolinkOptions = {
  type: MarkType;
  defaultProtocol: string;
  validate: (url: string) => boolean;
  shouldAutoLink: (url: string) => boolean;
};

/**
 * Plugin that automatically adds link marks when typing URLs.
 */
export function autolink(options: AutolinkOptions): Plugin {
  return new Plugin({
    key: new PluginKey("autolink"),
    appendTransaction: (transactions, oldState, newState) => {
      const docChanges =
        transactions.some((transaction) => transaction.docChanged) &&
        !oldState.doc.eq(newState.doc);

      const preventAutolink = transactions.some((transaction) =>
        transaction.getMeta("preventAutolink"),
      );

      if (!docChanges || preventAutolink) {
        return;
      }

      const { tr } = newState;
      const transform = combineTransactionSteps(oldState.doc, [
        ...transactions,
      ]);
      const changes = getChangedRanges(transform);

      changes.forEach(({ newRange }) => {
        const nodesInChangedRanges = findChildrenInRange(
          newState.doc,
          newRange,
          (node) => node.isTextblock,
        );

        let textBlock: NodeWithPos | undefined;
        let textBeforeWhitespace: string | undefined;

        if (nodesInChangedRanges.length > 1) {
          textBlock = nodesInChangedRanges[0];
          textBeforeWhitespace = newState.doc.textBetween(
            textBlock.pos,
            textBlock.pos + textBlock.node.nodeSize,
            undefined,
            " ",
          );
        } else if (nodesInChangedRanges.length) {
          const endText = newState.doc.textBetween(
            newRange.from,
            newRange.to,
            " ",
            " ",
          );
          if (!UNICODE_WHITESPACE_REGEX_END.test(endText)) {
            return;
          }
          textBlock = nodesInChangedRanges[0];
          textBeforeWhitespace = newState.doc.textBetween(
            textBlock.pos,
            newRange.to,
            undefined,
            " ",
          );
        }

        if (textBlock && textBeforeWhitespace) {
          const wordsBeforeWhitespace = textBeforeWhitespace
            .split(UNICODE_WHITESPACE_REGEX)
            .filter(Boolean);

          if (wordsBeforeWhitespace.length <= 0) {
            return;
          }

          const lastWordBeforeSpace =
            wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
          const lastWordAndBlockOffset =
            textBlock.pos +
            textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);

          if (!lastWordBeforeSpace) {
            return;
          }

          const linksBeforeSpace = tokenizeLink(
            lastWordBeforeSpace,
            options.defaultProtocol,
          );

          if (!isValidLinkStructure(linksBeforeSpace)) {
            return;
          }

          linksBeforeSpace
            .filter((link) => link.isLink)
            .map((link) => ({
              ...link,
              from: lastWordAndBlockOffset + link.start + 1,
              to: lastWordAndBlockOffset + link.end + 1,
            }))
            // ignore link inside code mark
            .filter((link) => {
              if (!newState.schema.marks.code) {
                return true;
              }

              return !newState.doc.rangeHasMark(
                link.from,
                link.to,
                newState.schema.marks.code,
              );
            })
            .filter((link) => options.validate(link.value))
            .filter((link) => options.shouldAutoLink(link.value))
            .forEach((link) => {
              if (
                getMarksBetween(link.from, link.to, newState.doc).some(
                  (item) => item.mark.type === options.type,
                )
              ) {
                return;
              }

              tr.addMark(
                link.from,
                link.to,
                options.type.create({
                  href: link.href,
                }),
              );
            });
        }
      });

      if (!tr.steps.length) {
        return;
      }

      return tr;
    },
  });
}
