import {
  combineTransactionSteps,
  Extension,
  findChildrenInRange,
  getChangedRanges,
} from "@tiptap/core";
import { uuidv4 } from "lib0/random";
import { Fragment, Node, Slice } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js";

/**
 * Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id)
 * This extension is licensed under MIT (even though it's part of Tiptap pro).
 *
 * If you're a user of BlockNote, we still recommend to support their awesome work and become a sponsor!
 * https://tiptap.dev/pro
 */

/**
 * Removes duplicated values within an array.
 * Supports numbers, strings and objects.
 */
function removeDuplicates(array: any, by = JSON.stringify) {
  const seen: any = {};
  return array.filter((item: any) => {
    const key = by(item);
    return Object.prototype.hasOwnProperty.call(seen, key)
      ? false
      : (seen[key] = true);
  });
}

/**
 * Returns a list of duplicated items within an array.
 */
function findDuplicates(items: any) {
  const filtered = items.filter(
    (el: any, index: number) => items.indexOf(el) !== index,
  );
  const duplicates = removeDuplicates(filtered);
  return duplicates;
}

/**
 * Whether a node is marked as deleted by a suggestion (carries the
 * `y-attributed-delete` node mark).
 *
 * Under the suggestion/matchNodes binding, changing a block's content type
 * renders the block as a deleted copy (this mark) next to its inserted
 * replacement - and both copies share the same `id`. The deleted copy must be
 * ignored by the uniqueness logic, otherwise its `id` looks like a duplicate
 * and we'd regenerate the `id` on the surviving block.
 */
function isMarkedDeleted(node: Node) {
  return node.marks.some((mark) => mark.type.name === "y-attributed-delete");
}

const UniqueID = Extension.create({
  name: "uniqueID",
  // we’ll set a very high priority to make sure this runs first
  // and is compatible with `appendTransaction` hooks of other extensions
  priority: 10000,
  addOptions() {
    return {
      types: [] as string[],
      setIdAttribute: false,
      isWithinEditor: undefined as ((element: Element) => boolean) | undefined,
      generateID: () => {
        // Use mock ID if tests are running.
        if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) {
          const testOptions = (window as any).__TEST_OPTIONS;
          if (testOptions.mockID === undefined) {
            testOptions.mockID = 0;
          } else {
            testOptions.mockID++;
          }

          return testOptions.mockID.toString() as string;
        }

        return uuidv4();
      },
    };
  },
  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          id: {
            default: null,
            parseHTML: (element) => element.getAttribute(`data-id`),
            renderHTML: (attributes) => {
              const defaultIdAttributes = {
                [`data-id`]: attributes.id,
              };
              if (this.options.setIdAttribute) {
                return {
                  ...defaultIdAttributes,
                  id: attributes.id,
                };
              } else {
                return defaultIdAttributes;
              }
            },
          },
        },
      },
    ];
  },
  // check initial content for missing ids
  // onCreate() {
  //   // Don’t do this when the collaboration extension is active
  //   // because this may update the content, so Y.js tries to merge these changes.
  //   // This leads to empty block nodes.
  //   // See: https://github.com/ueberdosis/tiptap/issues/2400
  //   if (
  //     this.editor.extensionManager.extensions.find(
  //       (extension) => extension.name === "collaboration"
  //     )
  //   ) {
  //     return;
  //   }
  //   const { view, state } = this.editor;
  //   const { tr, doc } = state;
  //   const { types, attributeName, generateID } = this.options;
  //   const nodesWithoutId = findChildren(doc, (node) => {
  //     return (
  //       types.includes(node.type.name) && node.attrs[attributeName] === null
  //     );
  //   });
  //   nodesWithoutId.forEach(({ node, pos }) => {
  //     tr.setNodeMarkup(pos, undefined, {
  //       ...node.attrs,
  //       [attributeName]: generateID(),
  //     });
  //   });
  //   tr.setMeta("addToHistory", false);
  //   view.dispatch(tr);
  // },
  addProseMirrorPlugins() {
    const { isWithinEditor } = this.options;
    let dragSourceElement: any = null;
    let transformPasted = false;
    return [
      new Plugin({
        key: new PluginKey("uniqueID"),
        appendTransaction: (transactions, oldState, newState) => {
          const docChanges =
            transactions.some((transaction) => transaction.docChanged) &&
            !oldState.doc.eq(newState.doc);
          if (!docChanges) {
            return;
          }
          const { tr } = newState;
          const { types, generateID } = this.options;
          const transform = combineTransactionSteps(
            oldState.doc,
            transactions as any,
          );
          const { mapping } = transform;
          // get changed ranges based on the old state
          const changes = getChangedRanges(transform);

          changes.forEach(({ newRange }) => {
            const newNodes = findChildrenInRange(
              newState.doc,
              newRange,
              (node) => {
                return types.includes(node.type.name);
              },
            );
            const newIds = newNodes
              .map(({ node }) => node.attrs.id)
              .filter((id) => id !== null);
            const duplicatedNewIds = findDuplicates(newIds);

            newNodes.forEach(({ node, pos }) => {
              // ignore ids on blocks marked as deleted (see above).
              if (isMarkedDeleted(node)) {
                return;
              }
              // instead of checking `node.attrs[attributeName]` directly
              // we look at the current state of the node within `tr.doc`.
              // this helps to prevent adding new ids to the same node
              // if the node changed multiple times within one transaction
              const id = tr.doc.nodeAt(pos)?.attrs.id;

              if (id === null) {
                // edge case, when using collaboration, yjs will set the id to null in `_forceRerender`
                // when loading the editor
                // this checks for this case and keeps it at initialBlockId so there will be no change
                const initialDoc = oldState.doc.type.createAndFill()!.content;
                const wasInitial =
                  oldState.doc.content.findDiffStart(initialDoc) === null;

                if (wasInitial) {
                  // the old state was the "initial content"
                  const jsonNode = JSON.parse(
                    JSON.stringify(newState.doc.toJSON()),
                  );
                  jsonNode.content[0].content[0].attrs.id = "initialBlockId";
                  // would the new state with the fix also be the "initial content"?
                  if (
                    JSON.stringify(jsonNode.content) ===
                    JSON.stringify(initialDoc.toJSON())
                  ) {
                    // yes, apply the fix
                    tr.setNodeMarkup(pos, undefined, {
                      ...node.attrs,
                      id: "initialBlockId",
                    });
                    return;
                  }
                }

                tr.setNodeMarkup(pos, undefined, {
                  ...node.attrs,
                  id: generateID(),
                });
                return;
              }
              // check if the node doesn’t exist in the old state
              const { deleted } = mapping.invert().mapResult(pos);
              const newNode = deleted && duplicatedNewIds.includes(id);
              // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them
              if (newNode && !isSuggestedDeletionNode(node)) {
                tr.setNodeMarkup(pos, undefined, {
                  ...node.attrs,
                  id: generateID(),
                });
              }
            });
          });
          if (!tr.steps.length) {
            return;
          }
          // mark the transaction as having been processed by the uniqueID plugin
          tr.setMeta("uniqueID", true);
          return tr;
        },
        // we register a global drag handler to track the current drag source element
        view(view) {
          const handleDragstart = (event: any) => {
            const editorParent = view.dom.parentElement;
            const isFromEditor =
              editorParent?.contains(event.target) ||
              isWithinEditor?.(event.target);
            dragSourceElement = isFromEditor ? editorParent : null;
          };
          window.addEventListener("dragstart", handleDragstart);
          return {
            destroy() {
              window.removeEventListener("dragstart", handleDragstart);
            },
          };
        },
        props: {
          // `handleDOMEvents` is called before `transformPasted` so we can do
          // some checks before. However, `transformPasted` only runs when
          // editor content is pasted - not external content.
          handleDOMEvents: {
            // only create new ids for dropped content while holding `alt`
            // or content is dragged from another editor
            drop: (view, event: any) => {
              let _a;
              if (
                dragSourceElement !== view.dom.parentElement ||
                ((_a = event.dataTransfer) === null || _a === void 0
                  ? void 0
                  : _a.effectAllowed) === "copy"
              ) {
                transformPasted = true;
              } else {
                transformPasted = false;
              }

              dragSourceElement = null;

              return false;
            },
            // always create new ids on pasted content
            paste: () => {
              transformPasted = true;
              return false;
            },
          },
          // we’ll remove ids for every pasted node
          // so we can create a new one within `appendTransaction`
          transformPasted: (slice) => {
            if (!transformPasted) {
              return slice;
            }
            const { types } = this.options;
            const removeId = (fragment: any) => {
              const list: any[] = [];
              fragment.forEach((node: any) => {
                // don’t touch text nodes
                if (node.isText) {
                  list.push(node);
                  return;
                }
                // check for any other child nodes
                if (!types.includes(node.type.name)) {
                  list.push(node.copy(removeId(node.content)));
                  return;
                }
                // remove id
                const nodeWithoutId = node.type.create(
                  {
                    ...node.attrs,
                    id: null,
                  },
                  removeId(node.content),
                  node.marks,
                );
                list.push(nodeWithoutId);
              });
              return Fragment.from(list);
            };
            // reset check
            transformPasted = false;
            return new Slice(
              removeId(slice.content),
              slice.openStart,
              slice.openEnd,
            );
          },
        },
      }),
    ];
  },
});

export { UniqueID as default, UniqueID };
