/*
This file is part of the Notesnook project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

import {
  Editor,
  findParentNode,
  NodeWithPos,
  Predicate,
  getChangedRanges
} from "@tiptap/core";
import {
  NodeRange,
  Node as ProsemirrorNode,
  Mark,
  NodeType,
  ResolvedPos,
  Attrs,
  Slice,
  DOMParser,
  Schema,
  Fragment
} from "prosemirror-model";
import { EditorState, Selection, Transaction } from "prosemirror-state";
import TextStyle from "@tiptap/extension-text-style";

export type NodeWithOffset = {
  node?: ProsemirrorNode;
  from: number;
  to: number;
};

export function hasSameAttributes(prev: Attrs, next: Attrs) {
  for (const key in prev) {
    const prevValue = prev[key];
    const nextValue = next[key];
    if (prevValue !== nextValue) return false;
  }
  return true;
}

export function findSelectedDOMNode(
  editor: Editor,
  types: string[]
): HTMLElement | null {
  const { $anchor } = editor.state.selection;

  const selectedNode = editor.state.doc.nodeAt($anchor.pos);
  const pos = types.includes(selectedNode?.type.name || "")
    ? $anchor.pos
    : findParentNode((node) => types.includes(node.type.name))(
        editor.state.selection
      )?.pos;
  if (!pos) return null;

  return (editor.view.nodeDOM(pos) as HTMLElement) || null;
}

export function findSelectedNode(
  editor: Editor,
  type: string
): ProsemirrorNode | null {
  const { $anchor } = editor.state.selection;

  const selectedNode = editor.state.doc.nodeAt($anchor.pos);
  const pos =
    selectedNode?.type.name === type
      ? $anchor.pos
      : findParentNode((node) => node.type.name === type)(
          editor.state.selection
        )?.pos;
  if (pos === undefined) return null;

  return editor.state.doc.nodeAt(pos);
}

export function findMark(
  node: ProsemirrorNode,
  type: string
): Mark | undefined {
  const mark = node.marks.find((m) => m.type.name === type);
  return mark;
}

export function selectionToOffset(
  state: EditorState
): NodeWithOffset | undefined {
  const { from, $from, to, $to } = state.selection;
  const node = state.doc.nodeAt(from);
  return {
    node: node || undefined,
    from: from - $from.textOffset,
    to: node ? from - $from.textOffset + node.nodeSize : to - $to.textOffset
  };
}

export const findChildren = (
  node: ProsemirrorNode,
  predicate: Predicate,
  descend: boolean
) => {
  if (!node) {
    throw new Error('Invalid "node" parameter');
  } else if (!predicate) {
    throw new Error('Invalid "predicate" parameter');
  }
  return walkNode(node, descend).filter((child) => predicate(child.node));
};

export function findChildrenByType(
  node: ProsemirrorNode,
  nodeType: NodeType,
  descend = true
): NodeWithPos[] {
  return findChildren(node, (child) => child.type === nodeType, descend);
}

export const findParentNodeOfTypeClosestToPos = (
  $pos: ResolvedPos,
  nodeType: NodeType
) => {
  return findParentNodeClosestToPos($pos, (node) =>
    equalNodeType(nodeType, node)
  );
};

export type NodeWithPosAndDepth = {
  pos: number;
  start: number;
  depth: number;
  node: ProsemirrorNode;
};
type PredicateWithParent = (
  node: ProsemirrorNode,
  parent?: ProsemirrorNode
) => boolean;
export function findParentNodeClosestToPos(
  $pos: ResolvedPos,
  predicate: PredicateWithParent
): NodeWithPosAndDepth | undefined {
  for (let i = $pos.depth; i > 0; i -= 1) {
    const node = $pos.node(i);

    if (predicate(node, i === 1 ? undefined : $pos.node(i - 1))) {
      return {
        pos: i > 0 ? $pos.before(i) : 0,
        start: $pos.start(i),
        depth: i,
        node
      };
    }
  }
}

export function hasParentNode(predicate: Predicate) {
  return function (selection: Selection) {
    return !!findParentNode(predicate)(selection);
  };
}

export function hasParentNodeOfType(nodeType: NodeType | NodeType[]) {
  return hasParentNode((node) => equalNodeType(nodeType, node));
}

export function findParentNodeOfType(nodeType: NodeType | NodeType[]) {
  return findParentNode((node) => equalNodeType(nodeType, node));
}

export function getParentAttributes(
  editor: Editor,
  keepMarks = false,
  keepAttributes = false
) {
  const { textAlign, textDirection } =
    editor.state.selection.$from.parent?.attrs || {};
  return {
    ...(keepMarks || keepAttributes
      ? editor.getAttributes(TextStyle.name)
      : {}),
    textAlign,
    textDirection
  };
}

const walkNode = (node: ProsemirrorNode, descend = true) => {
  if (!node) {
    throw new Error('Invalid "node" parameter');
  }
  const result: NodeWithPos[] = [];
  node.descendants((child, pos) => {
    result.push({ node: child, pos });
    if (!descend) {
      return false;
    }
  });
  return result;
};

const equalNodeType = (
  nodeType: NodeType | NodeType[],
  node: ProsemirrorNode
) => {
  return (
    (Array.isArray(nodeType) && nodeType.indexOf(node.type) > -1) ||
    node.type === nodeType
  );
};

export function getChangedNodeRanges(tr: Transaction): NodeRange[] {
  // The container of the ranges to be returned from this function.
  const nodeRanges: NodeRange[] = [];
  const ranges = getChangedRanges(tr);

  for (const range of ranges) {
    try {
      const $from = tr.doc.resolve(range.newRange.from);
      const $to = tr.doc.resolve(range.newRange.to);

      // Find the node range for this provided range.
      const nodeRange = $from.blockRange($to);

      // Make sure a valid node is available.
      if (nodeRange) {
        nodeRanges.push(nodeRange);
      }
    } catch {
      // Changed ranged outside the document
    }
  }

  return nodeRanges;
}

interface GetChangedNodesOptions {
  /**
   * Whether to descend into child nodes.
   *
   * @defaultValue false
   */
  descend?: boolean;

  /**
   * A predicate test for node which was found. Return `false` to skip the node.
   *
   * @param node - the node that was found
   * @param pos - the pos of that node
   * @param range - the `NodeRange` which contained this node.
   */
  predicate?: (node: ProsemirrorNode, pos: number, range: NodeRange) => boolean;
}

/**
 * Get all the changed nodes from the provided transaction.
 *
 * The following example will give us all the text nodes in the provided
 * transaction.
 *
 * ```ts
 * import { getChangedNodes } from 'remirror/core';
 *
 * const changedTextNodes = getChangeNodes(tr, { descend: true, predicate: (node) => node.isText });
 * ```
 */
export function getChangedNodes(
  tr: Transaction,
  options: GetChangedNodesOptions = {}
): NodeWithPos[] {
  const { descend = false, predicate } = options;
  const nodeRange = getChangedNodeRanges(tr);

  // The container for the nodes which have been added..
  const nodes: NodeWithPos[] = [];
  for (const range of nodeRange) {
    const { start, end } = range;

    // Find all the nodes between the provided node range.
    tr.doc.nodesBetween(start, end, (node, pos) => {
      // Check wether this is a node that should be added.
      const shouldAdd = !predicate || predicate(node, start, range);

      if (shouldAdd && nodes.every((n) => n.pos !== pos)) {
        nodes.push({ node, pos });
      }

      return descend;
    });
  }

  return nodes;
}

export function getExactChangedNodes(
  tr: Transaction,
  predicate?: (node: ProsemirrorNode, pos: number, range: NodeRange) => boolean
): NodeWithPos[] {
  const nodeRange = getChangedNodeRanges(tr);

  // The container for the nodes which have been added..
  const nodes: NodeWithPos[] = [];
  for (const range of nodeRange) {
    const { start } = range;

    if (nodeRange && nodes.every((n) => n.pos !== start)) {
      const node = tr.doc.nodeAt(start);
      if (node && (!predicate || predicate(node, start, range)))
        nodes.push({ node, pos: start });
    }
  }

  return nodes;
}

export function getDeletedNodes(
  tr: Transaction,
  predicate: (node: ProsemirrorNode, parent?: ProsemirrorNode) => boolean
) {
  const nodes: NodeWithPos[] = [];
  for (const step of tr.steps) {
    if (
      "slice" in step &&
      step.slice instanceof Slice &&
      "to" in step &&
      typeof step.to === "number" &&
      "from" in step &&
      typeof step.from === "number" &&
      step.from < tr.doc.nodeSize - 1 &&
      step.slice === Slice.empty
    ) {
      const $from = tr.doc.resolve(step.from);
      const node = findParentNodeClosestToPos($from, predicate);
      if (node) nodes.push(node);
    }
  }
  return nodes;
}

export function isClickWithinBounds(
  e: MouseEvent | TouchEvent,
  pos: ResolvedPos,
  hitPosition: "left" | "right",
  hitArea: { width: number; height: number } = { width: 40, height: 40 }
) {
  const { target } = e;
  if (!(target instanceof HTMLElement)) return false;

  const { x, y, right, width } = target.getBoundingClientRect();
  const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
  const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
  const isRtl =
    target.dir === "rtl" ||
    findParentNodeClosestToPos(pos, (node) => !!node.attrs.textDirection)?.node
      .attrs.textDirection === "rtl";

  switch (hitPosition) {
    case "left": {
      let xStart = clientX >= x - hitArea.width;
      let xEnd = clientX <= x;
      const yStart = clientY >= y;
      const yEnd = clientY <= y + hitArea.height;

      if (isRtl) {
        xEnd = clientX <= right + hitArea.width;
        xStart = clientX >= right;
      }

      return xStart && xEnd && yStart && yEnd;
    }
    case "right": {
      let xEnd = clientX <= x + width;
      let xStart = clientX >= x + width - hitArea.width;
      const yStart = clientY >= y;
      const yEnd = clientY <= y + hitArea.height;

      if (isRtl) {
        xStart = clientX >= x;
        xEnd = clientX <= x + hitArea.width;
      }

      return xStart && xEnd && yStart && yEnd;
    }
    default:
      return false;
  }
}

export function ensureLeadingParagraph(node: Node, schema: Schema): Fragment {
  const parser = DOMParser.fromSchema(schema);
  const fragment = parser.parse(node).content;
  const firstNode = fragment.firstChild;

  if (firstNode && firstNode.type.name !== "paragraph") {
    const emptyParagraph = schema.nodes.paragraph.create();
    return fragment.addToStart(emptyParagraph);
  }

  return fragment;
}

/**
 * Helper for iterating through the nodes in a document that changed
 * compared to the given previous document. Useful for avoiding
 * duplicate work on each transaction.
 *
 * @public
 */
export function changedDescendants(
  old: ProsemirrorNode,
  cur: ProsemirrorNode,
  offset: number,
  f: (newNode: ProsemirrorNode, pos: number, oldNode?: ProsemirrorNode) => void
): void {
  const oldSize = old.childCount,
    curSize = cur.childCount;
  outer: for (let i = 0, j = 0; i < curSize; i++) {
    const child = cur.child(i);
    for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) {
      if (old.child(scan) == child) {
        j = scan + 1;
        offset += child.nodeSize;
        continue outer;
      }
    }
    f(child, offset, i < oldSize ? old.child(i) : undefined);
    if (j < oldSize && old.child(j).sameMarkup(child)) {
      changedDescendants(old.child(j), child, offset + 1, f);
    } else {
      child.nodesBetween(
        0,
        child.content.size,
        f as (node: ProsemirrorNode, pos: number) => void,
        offset + 1
      );
    }
    offset += child.nodeSize;
  }
}
