/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

'use strict';

var html = require('@lexical/html');
var selection = require('@lexical/selection');
var utils = require('@lexical/utils');
var lexical = require('lexical');

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

// Do not require this module directly! Use normal `invariant` calls.

function formatDevErrorMessage(message) {
  throw new Error(message);
}

/**
 * Returns the *currently selected* Lexical content as an HTML string, relying on the
 * logic defined in the exportDOM methods on the LexicalNode classes. Note that
 * this will not return the HTML content of the entire editor (unless all the content is included
 * in the current selection).
 *
 * @param editor - LexicalEditor instance to get HTML content from
 * @param selection - The selection to use (default is $getSelection())
 * @returns a string of HTML content
 */
function $getHtmlContent(editor, selection = lexical.$getSelection()) {
  if (selection == null) {
    {
      formatDevErrorMessage(`Expected valid LexicalSelection`);
    }
  }

  // If we haven't selected anything
  if (lexical.$isRangeSelection(selection) && selection.isCollapsed() || selection.getNodes().length === 0) {
    return '';
  }
  return html.$generateHtmlFromNodes(editor, selection);
}

/**
 * Returns the *currently selected* Lexical content as a JSON string, relying on the
 * logic defined in the exportJSON methods on the LexicalNode classes. Note that
 * this will not return the JSON content of the entire editor (unless all the content is included
 * in the current selection).
 *
 * @param editor  - LexicalEditor instance to get the JSON content from
 * @param selection - The selection to use (default is $getSelection())
 * @returns
 */
function $getLexicalContent(editor, selection = lexical.$getSelection()) {
  if (selection == null) {
    {
      formatDevErrorMessage(`Expected valid LexicalSelection`);
    }
  }

  // If we haven't selected anything
  if (lexical.$isRangeSelection(selection) && selection.isCollapsed() || selection.getNodes().length === 0) {
    return null;
  }
  return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
}

/**
 * Attempts to insert content of the mime-types text/plain or text/uri-list from
 * the provided DataTransfer object into the editor at the provided selection.
 * text/uri-list is only used if text/plain is not also provided.
 *
 * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
 * @param selection the selection to use as the insertion point for the content in the DataTransfer object
 */
function $insertDataTransferForPlainText(dataTransfer, selection) {
  const text = dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
  if (text != null) {
    selection.insertRawText(text);
  }
}

/**
 * Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
 * text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
 * object into the editor at the provided selection.
 *
 * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
 * @param selection the selection to use as the insertion point for the content in the DataTransfer object
 * @param editor the LexicalEditor the content is being inserted into.
 */
function $insertDataTransferForRichText(dataTransfer, selection, editor) {
  const lexicalString = dataTransfer.getData('application/x-lexical-editor');
  if (lexicalString) {
    try {
      const payload = JSON.parse(lexicalString);
      if (payload.namespace === editor._config.namespace && Array.isArray(payload.nodes)) {
        const nodes = $generateNodesFromSerializedNodes(payload.nodes);
        return $insertGeneratedNodes(editor, nodes, selection);
      }
    } catch (error) {
      console.error(error);
    }
  }
  const htmlString = dataTransfer.getData('text/html');
  const plainString = dataTransfer.getData('text/plain');

  // Skip HTML handling if it matches the plain text representation.
  // This avoids unnecessary processing for plain text strings created by
  // iOS Safari autocorrect, which incorrectly includes a `text/html` type.
  if (htmlString && plainString !== htmlString) {
    try {
      const parser = new DOMParser();
      const dom = parser.parseFromString(trustHTML(htmlString), 'text/html');
      const nodes = html.$generateNodesFromDOM(editor, dom);
      return $insertGeneratedNodes(editor, nodes, selection);
    } catch (error) {
      console.error(error);
    }
  }

  // Multi-line plain text in rich text mode pasted as separate paragraphs
  // instead of single paragraph with linebreaks.
  // Webkit-specific: Supports read 'text/uri-list' in clipboard.
  const text = plainString || dataTransfer.getData('text/uri-list');
  if (text != null) {
    if (lexical.$isRangeSelection(selection)) {
      const parts = text.split(/(\r?\n|\t)/);
      if (parts[parts.length - 1] === '') {
        parts.pop();
      }
      for (let i = 0; i < parts.length; i++) {
        const currentSelection = lexical.$getSelection();
        if (lexical.$isRangeSelection(currentSelection)) {
          const part = parts[i];
          if (part === '\n' || part === '\r\n') {
            currentSelection.insertParagraph();
          } else if (part === '\t') {
            currentSelection.insertNodes([lexical.$createTabNode()]);
          } else {
            currentSelection.insertText(part);
          }
        }
      }
    } else {
      selection.insertRawText(text);
    }
  }
}
function trustHTML(html) {
  if (window.trustedTypes && window.trustedTypes.createPolicy) {
    const policy = window.trustedTypes.createPolicy('lexical', {
      createHTML: input => input
    });
    return policy.createHTML(html);
  }
  return html;
}

/**
 * Inserts Lexical nodes into the editor using different strategies depending on
 * some simple selection-based heuristics. If you're looking for a generic way to
 * to insert nodes into the editor at a specific selection point, you probably want
 * {@link lexical.$insertNodes}
 *
 * @param editor LexicalEditor instance to insert the nodes into.
 * @param nodes The nodes to insert.
 * @param selection The selection to insert the nodes into.
 */
function $insertGeneratedNodes(editor, nodes, selection) {
  if (!editor.dispatchCommand(lexical.SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
    nodes,
    selection
  })) {
    selection.insertNodes(nodes);
    $updateSelectionOnInsert(selection);
  }
  return;
}
function $updateSelectionOnInsert(selection) {
  if (lexical.$isRangeSelection(selection) && selection.isCollapsed()) {
    const anchor = selection.anchor;
    let nodeToInspect = null;
    const anchorCaret = lexical.$caretFromPoint(anchor, 'previous');
    if (anchorCaret) {
      if (lexical.$isTextPointCaret(anchorCaret)) {
        nodeToInspect = anchorCaret.origin;
      } else {
        const range = lexical.$getCaretRange(anchorCaret, lexical.$getChildCaret(lexical.$getRoot(), 'next').getFlipped());
        for (const caret of range) {
          if (lexical.$isTextNode(caret.origin)) {
            nodeToInspect = caret.origin;
            break;
          } else if (lexical.$isElementNode(caret.origin) && !caret.origin.isInline()) {
            break;
          }
        }
      }
    }
    if (nodeToInspect && lexical.$isTextNode(nodeToInspect)) {
      const newFormat = nodeToInspect.getFormat();
      const newStyle = nodeToInspect.getStyle();
      if (selection.format !== newFormat || selection.style !== newStyle) {
        selection.format = newFormat;
        selection.style = newStyle;
        selection.dirty = true;
      }
    }
  }
}
function exportNodeToJSON(node) {
  const serializedNode = node.exportJSON();
  const nodeClass = node.constructor;
  if (serializedNode.type !== nodeClass.getType()) {
    {
      formatDevErrorMessage(`LexicalNode: Node ${nodeClass.name} does not implement .exportJSON().`);
    }
  }
  if (lexical.$isElementNode(node)) {
    const serializedChildren = serializedNode.children;
    if (!Array.isArray(serializedChildren)) {
      {
        formatDevErrorMessage(`LexicalNode: Node ${nodeClass.name} is an element but .exportJSON() does not have a children array.`);
      }
    }
  }
  return serializedNode;
}
function $appendNodesToJSON(editor, selection$1, currentNode, targetArray = []) {
  let shouldInclude = selection$1 !== null ? currentNode.isSelected(selection$1) : true;
  const shouldExclude = lexical.$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
  let target = currentNode;
  if (selection$1 !== null && lexical.$isTextNode(target)) {
    target = selection.$sliceSelectedTextNodeContent(selection$1, target, 'clone');
  }
  const children = lexical.$isElementNode(target) ? target.getChildren() : [];
  const serializedNode = exportNodeToJSON(target);
  if (lexical.$isTextNode(target) && target.getTextContentSize() === 0) {
    // If an uncollapsed selection ends or starts at the end of a line of specialized,
    // TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
    // with text of length 0. We don't want this, it makes a confusing mess. Reset!
    shouldInclude = false;
  }
  for (let i = 0; i < children.length; i++) {
    const childNode = children[i];
    const shouldIncludeChild = $appendNodesToJSON(editor, selection$1, childNode, serializedNode.children);
    if (!shouldInclude && lexical.$isElementNode(currentNode) && shouldIncludeChild && currentNode.extractWithChild(childNode, selection$1, 'clone')) {
      shouldInclude = true;
    }
  }
  if (shouldInclude && !shouldExclude) {
    targetArray.push(serializedNode);
  } else if (Array.isArray(serializedNode.children)) {
    for (let i = 0; i < serializedNode.children.length; i++) {
      const serializedChildNode = serializedNode.children[i];
      targetArray.push(serializedChildNode);
    }
  }
  return shouldInclude;
}

// TODO why $ function with Editor instance?
/**
 * Gets the Lexical JSON of the nodes inside the provided Selection.
 *
 * @param editor LexicalEditor to get the JSON content from.
 * @param selection Selection to get the JSON content from.
 * @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
 */
function $generateJSONFromSelectedNodes(editor, selection) {
  const nodes = [];
  const root = lexical.$getRoot();
  const topLevelChildren = root.getChildren();
  for (let i = 0; i < topLevelChildren.length; i++) {
    const topLevelNode = topLevelChildren[i];
    $appendNodesToJSON(editor, selection, topLevelNode, nodes);
  }
  return {
    namespace: editor._config.namespace,
    nodes
  };
}

/**
 * This method takes an array of objects conforming to the BaseSerializedNode interface and returns
 * an Array containing instances of the corresponding LexicalNode classes registered on the editor.
 * Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
 *
 * @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
 * @returns an Array of Lexical Node objects.
 */
function $generateNodesFromSerializedNodes(serializedNodes) {
  const nodes = [];
  for (let i = 0; i < serializedNodes.length; i++) {
    const serializedNode = serializedNodes[i];
    const node = lexical.$parseSerializedNode(serializedNode);
    if (lexical.$isTextNode(node)) {
      selection.$addNodeStyle(node);
    }
    nodes.push(node);
  }
  return nodes;
}
const EVENT_LATENCY = 50;
let clipboardEventTimeout = null;

// TODO custom selection
// TODO potentially have a node customizable version for plain text
/**
 * Copies the content of the current selection to the clipboard in
 * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
 * formats.
 *
 * @param editor the LexicalEditor instance to copy content from
 * @param event the native browser ClipboardEvent to add the content to.
 * @returns
 */
async function copyToClipboard(editor, event, data) {
  if (clipboardEventTimeout !== null) {
    // Prevent weird race conditions that can happen when this function is run multiple times
    // synchronously. In the future, we can do better, we can cancel/override the previously running job.
    return false;
  }
  if (event !== null) {
    return new Promise((resolve, reject) => {
      editor.update(() => {
        resolve($copyToClipboardEvent(editor, event, data));
      });
    });
  }
  const rootElement = editor.getRootElement();
  const editorWindow = editor._window || window;
  const windowDocument = editorWindow.document;
  const domSelection = lexical.getDOMSelection(editorWindow);
  if (rootElement === null || domSelection === null) {
    return false;
  }
  const element = windowDocument.createElement('span');
  element.style.cssText = 'position: fixed; top: -1000px;';
  element.append(windowDocument.createTextNode('#'));
  rootElement.append(element);
  const range = new Range();
  range.setStart(element, 0);
  range.setEnd(element, 1);
  domSelection.removeAllRanges();
  domSelection.addRange(range);
  return new Promise((resolve, reject) => {
    const removeListener = editor.registerCommand(lexical.COPY_COMMAND, secondEvent => {
      if (utils.objectKlassEquals(secondEvent, ClipboardEvent)) {
        removeListener();
        if (clipboardEventTimeout !== null) {
          editorWindow.clearTimeout(clipboardEventTimeout);
          clipboardEventTimeout = null;
        }
        resolve($copyToClipboardEvent(editor, secondEvent, data));
      }
      // Block the entire copy flow while we wait for the next ClipboardEvent
      return true;
    }, lexical.COMMAND_PRIORITY_CRITICAL);
    // If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
    // the listener will be quickly freed so that the user can reuse it again
    clipboardEventTimeout = editorWindow.setTimeout(() => {
      removeListener();
      clipboardEventTimeout = null;
      resolve(false);
    }, EVENT_LATENCY);
    windowDocument.execCommand('copy');
    element.remove();
  });
}

// TODO shouldn't pass editor (pass namespace directly)
function $copyToClipboardEvent(editor, event, data) {
  if (data === undefined) {
    const domSelection = lexical.getDOMSelection(editor._window);
    const selection = lexical.$getSelection();
    if (!selection || selection.isCollapsed()) {
      return false;
    }
    if (!domSelection) {
      return false;
    }
    const anchorDOM = domSelection.anchorNode;
    const focusDOM = domSelection.focusNode;
    if (anchorDOM !== null && focusDOM !== null && !lexical.isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
      return false;
    }
    data = $getClipboardDataFromSelection(selection);
  }
  event.preventDefault();
  const clipboardData = event.clipboardData;
  if (clipboardData === null) {
    return false;
  }
  setLexicalClipboardDataTransfer(clipboardData, data);
  return true;
}
const clipboardDataFunctions = [['text/html', $getHtmlContent], ['application/x-lexical-editor', $getLexicalContent]];

/**
 * Serialize the content of the current selection to strings in
 * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
 * formats (as available).
 *
 * @param selection the selection to serialize (defaults to $getSelection())
 * @returns LexicalClipboardData
 */
function $getClipboardDataFromSelection(selection = lexical.$getSelection()) {
  const clipboardData = {
    'text/plain': selection ? selection.getTextContent() : ''
  };
  if (selection) {
    const editor = lexical.$getEditor();
    for (const [mimeType, $editorFn] of clipboardDataFunctions) {
      const v = $editorFn(editor, selection);
      if (v !== null) {
        clipboardData[mimeType] = v;
      }
    }
  }
  return clipboardData;
}

/**
 * Call setData on the given clipboardData for each MIME type present
 * in the given data (from {@link $getClipboardDataFromSelection})
 *
 * @param clipboardData the event.clipboardData to populate from data
 * @param data The lexical data
 */
function setLexicalClipboardDataTransfer(clipboardData, data) {
  for (const [k] of clipboardDataFunctions) {
    if (data[k] === undefined) {
      clipboardData.setData(k, '');
    }
  }
  for (const k in data) {
    const v = data[k];
    if (v !== undefined) {
      clipboardData.setData(k, v);
    }
  }
}

exports.$generateJSONFromSelectedNodes = $generateJSONFromSelectedNodes;
exports.$generateNodesFromSerializedNodes = $generateNodesFromSerializedNodes;
exports.$getClipboardDataFromSelection = $getClipboardDataFromSelection;
exports.$getHtmlContent = $getHtmlContent;
exports.$getLexicalContent = $getLexicalContent;
exports.$insertDataTransferForPlainText = $insertDataTransferForPlainText;
exports.$insertDataTransferForRichText = $insertDataTransferForRichText;
exports.$insertGeneratedNodes = $insertGeneratedNodes;
exports.copyToClipboard = copyToClipboard;
exports.setLexicalClipboardDataTransfer = setLexicalClipboardDataTransfer;
