/**
 * 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.
 *
 */

import { addClassNamesToElement, isHTMLAnchorElement, $findMatchingParent, $insertNodeToNearestRootAtCaret, mergeRegister, objectKlassEquals } from '@lexical/utils';
import { ElementNode, $copyNode, $isRangeSelection, $applyNodeReplacement, createCommand, $getSelection, $isNodeSelection, $normalizeSelection__EXPERIMENTAL, $setSelection, $getChildCaret, $isElementNode, $rewindSiblingCaret, $caretFromPoint, $normalizeCaret, $isSiblingCaret, $setPointFromCaret, defineExtension, shallowMergeConfig, COMMAND_PRIORITY_EDITOR, PASTE_COMMAND, COMMAND_PRIORITY_LOW, safeCast, isDOMNode, getNearestEditorFromDOMNode, $getNearestNodeFromDOMNode, TextNode, $isTextNode, $isLineBreakNode, $createTextNode } from 'lexical';
import { namedSignals, effect } from '@lexical/extension';

/**
 * 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);
}

const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']);

/** @noInheritDoc */
class LinkNode extends ElementNode {
  /** @internal */
  __url;
  /** @internal */
  __target;
  /** @internal */
  __rel;
  /** @internal */
  __title;
  static getType() {
    return 'link';
  }
  static clone(node) {
    return new LinkNode(node.__url, {
      rel: node.__rel,
      target: node.__target,
      title: node.__title
    }, node.__key);
  }
  constructor(url = '', attributes = {}, key) {
    super(key);
    const {
      target = null,
      rel = null,
      title = null
    } = attributes;
    this.__url = url;
    this.__target = target;
    this.__rel = rel;
    this.__title = title;
  }
  afterCloneFrom(prevNode) {
    super.afterCloneFrom(prevNode);
    this.__url = prevNode.__url;
    this.__rel = prevNode.__rel;
    this.__target = prevNode.__target;
    this.__title = prevNode.__title;
  }
  createDOM(config) {
    const element = document.createElement('a');
    this.updateLinkDOM(null, element, config);
    addClassNamesToElement(element, config.theme.link);
    return element;
  }
  updateLinkDOM(prevNode, anchor, config) {
    if (isHTMLAnchorElement(anchor)) {
      if (!prevNode || prevNode.__url !== this.__url) {
        anchor.href = this.sanitizeUrl(this.__url);
      }
      for (const attr of ['target', 'rel', 'title']) {
        const key = `__${attr}`;
        const value = this[key];
        if (!prevNode || prevNode[key] !== value) {
          if (value) {
            anchor[attr] = value;
          } else {
            anchor.removeAttribute(attr);
          }
        }
      }
    }
  }
  updateDOM(prevNode, anchor, config) {
    this.updateLinkDOM(prevNode, anchor, config);
    return false;
  }
  static importDOM() {
    return {
      a: node => ({
        conversion: $convertAnchorElement,
        priority: 1
      })
    };
  }
  static importJSON(serializedNode) {
    return $createLinkNode().updateFromJSON(serializedNode);
  }
  updateFromJSON(serializedNode) {
    return super.updateFromJSON(serializedNode).setURL(serializedNode.url).setRel(serializedNode.rel || null).setTarget(serializedNode.target || null).setTitle(serializedNode.title || null);
  }
  sanitizeUrl(url) {
    url = formatUrl(url);
    try {
      const parsedUrl = new URL(formatUrl(url));
      if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
        return 'about:blank';
      }
    } catch (_unused) {
      return url;
    }
    return url;
  }
  exportJSON() {
    return {
      ...super.exportJSON(),
      rel: this.getRel(),
      target: this.getTarget(),
      title: this.getTitle(),
      url: this.getURL()
    };
  }
  getURL() {
    return this.getLatest().__url;
  }
  setURL(url) {
    const writable = this.getWritable();
    writable.__url = url;
    return writable;
  }
  getTarget() {
    return this.getLatest().__target;
  }
  setTarget(target) {
    const writable = this.getWritable();
    writable.__target = target;
    return writable;
  }
  getRel() {
    return this.getLatest().__rel;
  }
  setRel(rel) {
    const writable = this.getWritable();
    writable.__rel = rel;
    return writable;
  }
  getTitle() {
    return this.getLatest().__title;
  }
  setTitle(title) {
    const writable = this.getWritable();
    writable.__title = title;
    return writable;
  }
  insertNewAfter(_, restoreSelection = true) {
    const linkNode = $copyNode(this);
    this.insertAfter(linkNode, restoreSelection);
    return linkNode;
  }
  canInsertTextBefore() {
    return false;
  }
  canInsertTextAfter() {
    return false;
  }
  canBeEmpty() {
    return false;
  }
  isInline() {
    return true;
  }
  extractWithChild(child, selection, destination) {
    if (!$isRangeSelection(selection)) {
      return false;
    }
    const anchorNode = selection.anchor.getNode();
    const focusNode = selection.focus.getNode();
    return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && selection.getTextContent().length > 0;
  }
  isEmailURI() {
    return this.__url.startsWith('mailto:');
  }
  isWebSiteURI() {
    return this.__url.startsWith('https://') || this.__url.startsWith('http://');
  }
  shouldMergeAdjacentLink(otherLink) {
    return this.getType() === otherLink.getType() && this.__url === otherLink.__url && this.__target === otherLink.__target && this.__rel === otherLink.__rel && this.__title === otherLink.__title;
  }
}
function $saveCaretPair(point) {
  const next = $caretFromPoint(point, 'next');
  return [next, next.getFlipped()];
}
function $restoreCaretPair(point, pair) {
  for (const caret of pair) {
    if (caret.origin.isAttached()) {
      const normalized = $normalizeCaret(caret);
      $setPointFromCaret(point, normalized);
      return;
    }
  }
}

/**
 * Extracts block-level children from a LinkNode, splitting
 * ancestor nodes as needed to maintain a valid document structure.
 * @param link - The LinkNode to normalize
 */
function $linkNodeTransform(link) {
  const selection = $getSelection();
  let anchorPair = null;
  let focusPair = null;
  if ($isRangeSelection(selection)) {
    anchorPair = $saveCaretPair(selection.anchor);
    focusPair = $saveCaretPair(selection.focus);
  }
  function $restoreSelection() {
    if ($isRangeSelection(selection)) {
      $restoreCaretPair(selection.anchor, anchorPair);
      $restoreCaretPair(selection.focus, focusPair);
      $normalizeSelection__EXPERIMENTAL(selection);
    }
  }
  let transformed = false;
  for (const caret of $getChildCaret(link, 'next')) {
    const node = caret.origin;
    if ($isElementNode(node) && !node.isInline()) {
      const blockChildren = node.getChildren();
      if (blockChildren.length > 0) {
        const innerLink = $copyNode(link);
        innerLink.append(...blockChildren);
        node.append(innerLink);
        transformed = true;
      }
      $insertNodeToNearestRootAtCaret(node, $rewindSiblingCaret(caret), {
        $shouldSplit: () => false
      });
    }
  }
  // Fix a caret pair that points to the end of the absorbing link,
  // which would shift when new children are appended during merge.
  function $fixMergeBoundaryCaret(pair, absorbingLink, mergingLink) {
    const [next, prev] = pair;
    const $isAffected = caret => $isSiblingCaret(caret) && caret.origin.is(absorbingLink);
    if (!$isAffected(next) && !$isAffected(prev)) {
      return pair;
    }
    // Resolve the merge boundary from the merging link's start, since
    // those children survive the move and represent the boundary position.
    const fixed = $normalizeCaret($getChildCaret(mergingLink, 'next'));
    return [fixed, fixed.getFlipped()];
  }
  if (link.isAttached()) {
    const prevSibling = link.getPreviousSibling();
    if ($isLinkNode(prevSibling) && prevSibling.shouldMergeAdjacentLink(link)) {
      if (anchorPair) {
        anchorPair = $fixMergeBoundaryCaret(anchorPair, prevSibling, link);
      }
      if (focusPair) {
        focusPair = $fixMergeBoundaryCaret(focusPair, prevSibling, link);
      }
      prevSibling.append(...link.getChildren());
      link.remove();
      $restoreSelection();
      return;
    }
    const nextSibling = link.getNextSibling();
    if ($isLinkNode(nextSibling) && link.shouldMergeAdjacentLink(nextSibling)) {
      if (anchorPair) {
        anchorPair = $fixMergeBoundaryCaret(anchorPair, link, nextSibling);
      }
      if (focusPair) {
        focusPair = $fixMergeBoundaryCaret(focusPair, link, nextSibling);
      }
      link.append(...nextSibling.getChildren());
      nextSibling.remove();
      transformed = true;
    }
  }
  if (!transformed) {
    return;
  }
  if (!link.canBeEmpty() && link.isEmpty()) {
    const parent = link.getParent();
    link.remove();
    if (parent && parent.isEmpty()) {
      parent.remove();
    }
  }
  $restoreSelection();
}
function $convertAnchorElement(domNode) {
  let node = null;
  if (isHTMLAnchorElement(domNode)) {
    const content = domNode.textContent;
    if (content !== null && content !== '' || domNode.children.length > 0) {
      node = $createLinkNode(domNode.getAttribute('href') || '', {
        rel: domNode.getAttribute('rel'),
        target: domNode.getAttribute('target'),
        title: domNode.getAttribute('title')
      });
    }
  }
  return {
    node
  };
}

/**
 * Takes a URL and creates a LinkNode.
 * @param url - The URL the LinkNode should direct to.
 * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
 * @returns The LinkNode.
 */
function $createLinkNode(url = '', attributes) {
  return $applyNodeReplacement(new LinkNode(url, attributes));
}

/**
 * Determines if node is a LinkNode.
 * @param node - The node to be checked.
 * @returns true if node is a LinkNode, false otherwise.
 */
function $isLinkNode(node) {
  return node instanceof LinkNode;
}
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
class AutoLinkNode extends LinkNode {
  /** @internal */
  /** Indicates whether the autolink was ever unlinked. **/
  __isUnlinked;
  constructor(url = '', attributes = {}, key) {
    super(url, attributes, key);
    this.__isUnlinked = attributes.isUnlinked !== undefined && attributes.isUnlinked !== null ? attributes.isUnlinked : false;
  }
  afterCloneFrom(prevNode) {
    super.afterCloneFrom(prevNode);
    this.__isUnlinked = prevNode.__isUnlinked;
  }
  static getType() {
    return 'autolink';
  }
  static clone(node) {
    return new AutoLinkNode(node.__url, {
      isUnlinked: node.__isUnlinked,
      rel: node.__rel,
      target: node.__target,
      title: node.__title
    }, node.__key);
  }
  shouldMergeAdjacentLink(_otherLink) {
    return false;
  }
  getIsUnlinked() {
    return this.__isUnlinked;
  }
  setIsUnlinked(value) {
    const self = this.getWritable();
    self.__isUnlinked = value;
    return self;
  }
  createDOM(config) {
    if (this.__isUnlinked) {
      return document.createElement('span');
    } else {
      return super.createDOM(config);
    }
  }
  updateDOM(prevNode, anchor, config) {
    return super.updateDOM(prevNode, anchor, config) || prevNode.__isUnlinked !== this.__isUnlinked;
  }
  static importJSON(serializedNode) {
    return $createAutoLinkNode().updateFromJSON(serializedNode);
  }
  updateFromJSON(serializedNode) {
    return super.updateFromJSON(serializedNode).setIsUnlinked(serializedNode.isUnlinked || false);
  }
  static importDOM() {
    // TODO: Should link node should handle the import over autolink?
    return null;
  }
  exportJSON() {
    return {
      ...super.exportJSON(),
      isUnlinked: this.__isUnlinked
    };
  }
  insertNewAfter(_, restoreSelection = true) {
    const linkNode = $createAutoLinkNode(this.__url, {
      isUnlinked: this.__isUnlinked,
      rel: this.__rel,
      target: this.__target,
      title: this.__title
    });
    this.insertAfter(linkNode, restoreSelection);
    return linkNode;
  }
}

/**
 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
 * @param url - The URL the LinkNode should direct to.
 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
 * @returns The LinkNode.
 */
function $createAutoLinkNode(url = '', attributes) {
  return $applyNodeReplacement(new AutoLinkNode(url, attributes));
}

/**
 * Determines if node is an AutoLinkNode.
 * @param node - The node to be checked.
 * @returns true if node is an AutoLinkNode, false otherwise.
 */
function $isAutoLinkNode(node) {
  return node instanceof AutoLinkNode;
}
const TOGGLE_LINK_COMMAND = createCommand('TOGGLE_LINK_COMMAND');
function $getPointNode(point, offset) {
  if (point.type === 'element') {
    const node = point.getNode();
    if (!$isElementNode(node)) {
      formatDevErrorMessage(`$getPointNode: element point is not an ElementNode`);
    }
    const childNode = node.getChildren()[point.offset + offset];
    return childNode || null;
  }
  return null;
}

/**
 * Preserve the logical start/end of a RangeSelection in situations where
 * the point is an element that may be reparented in the callback.
 *
 * @param $fn The function to run
 * @returns The result of the callback
 */
function $withSelectedNodes($fn) {
  const initialSelection = $getSelection();
  if (!$isRangeSelection(initialSelection)) {
    return $fn();
  }
  const normalized = $normalizeSelection__EXPERIMENTAL(initialSelection);
  const isBackwards = normalized.isBackward();
  const anchorNode = $getPointNode(normalized.anchor, isBackwards ? -1 : 0);
  const focusNode = $getPointNode(normalized.focus, isBackwards ? 0 : -1);
  const rval = $fn();
  if (anchorNode || focusNode) {
    const updatedSelection = $getSelection();
    if ($isRangeSelection(updatedSelection)) {
      const finalSelection = updatedSelection.clone();
      if (anchorNode) {
        const anchorParent = anchorNode.getParent();
        if (anchorParent) {
          finalSelection.anchor.set(anchorParent.getKey(), anchorNode.getIndexWithinParent() + (isBackwards ? 1 : 0), 'element');
        }
      }
      if (focusNode) {
        const focusParent = focusNode.getParent();
        if (focusParent) {
          finalSelection.focus.set(focusParent.getKey(), focusNode.getIndexWithinParent() + (isBackwards ? 0 : 1), 'element');
        }
      }
      $setSelection($normalizeSelection__EXPERIMENTAL(finalSelection));
    }
  }
  return rval;
}

/**
 * Splits a LinkNode by removing selected children from it.
 * Handles three cases: selection at start, end, or middle of the link.
 * @param parentLink - The LinkNode to split
 * @param extractedNodes - The nodes that were extracted from the selection
 */
function $splitLinkAtSelection(parentLink, extractedNodes) {
  const extractedKeys = new Set(extractedNodes.filter(n => parentLink.isParentOf(n)).map(n => n.getKey()));
  const allChildren = parentLink.getChildren();
  // Check if a child is an extracted node OR contains an extracted node
  // This handles nested structures like LinkNode > HeadingNode > TextNode
  const isExtractedChild = child => extractedKeys.has(child.getKey()) || $isElementNode(child) && extractedNodes.some(n => parentLink.isParentOf(n) && child.isParentOf(n));
  const extractedChildren = allChildren.filter(isExtractedChild);
  if (extractedChildren.length === allChildren.length) {
    allChildren.forEach(child => parentLink.insertBefore(child));
    parentLink.remove();
    return;
  }
  const firstExtractedIndex = allChildren.findIndex(isExtractedChild);
  const lastExtractedIndex = allChildren.findLastIndex(isExtractedChild);
  const isAtStart = firstExtractedIndex === 0;
  const isAtEnd = lastExtractedIndex === allChildren.length - 1;
  if (isAtStart) {
    extractedChildren.forEach(child => parentLink.insertBefore(child));
  } else if (isAtEnd) {
    for (let i = extractedChildren.length - 1; i >= 0; i--) {
      parentLink.insertAfter(extractedChildren[i]);
    }
  } else {
    for (let i = extractedChildren.length - 1; i >= 0; i--) {
      parentLink.insertAfter(extractedChildren[i]);
    }
    const trailingChildren = allChildren.slice(lastExtractedIndex + 1);
    if (trailingChildren.length > 0) {
      const newLink = $copyNode(parentLink);
      extractedChildren[extractedChildren.length - 1].insertAfter(newLink);
      trailingChildren.forEach(child => newLink.append(child));
    }
  }
}

/**
 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
 * but saves any children and brings them up to the parent node.
 * @param urlOrAttributes - The URL the link directs to, or an attributes object with an url property
 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
 */
function $toggleLink(urlOrAttributes, attributes = {}) {
  let url;
  if (urlOrAttributes && typeof urlOrAttributes === 'object') {
    const {
      url: urlProp,
      ...rest
    } = urlOrAttributes;
    url = urlProp;
    attributes = {
      ...rest,
      ...attributes
    };
  } else {
    url = urlOrAttributes;
  }
  const {
    target,
    title
  } = attributes;
  const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
  const selection = $getSelection();
  if (selection === null || !$isRangeSelection(selection) && !$isNodeSelection(selection)) {
    return;
  }
  if ($isNodeSelection(selection)) {
    const nodes = selection.getNodes();
    if (nodes.length === 0) {
      return;
    }

    // Handle all selected nodes
    nodes.forEach(node => {
      if (url === null) {
        // Remove link
        const linkParent = $findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
        if (linkParent) {
          linkParent.insertBefore(node);
          if (linkParent.getChildren().length === 0) {
            linkParent.remove();
          }
        }
      } else {
        // Add/Update link
        const existingLink = $findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
        if (existingLink) {
          existingLink.setURL(url);
          if (target !== undefined) {
            existingLink.setTarget(target);
          }
          if (rel !== undefined) {
            existingLink.setRel(rel);
          }
        } else {
          const linkNode = $createLinkNode(url, {
            rel,
            target
          });
          node.insertBefore(linkNode);
          linkNode.append(node);
        }
      }
    });
    return;
  }
  if (selection.isCollapsed() && url === null) {
    for (const node of selection.getNodes()) {
      const parentLink = $findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
      if (parentLink !== null) {
        parentLink.getChildren().forEach(child => {
          parentLink.insertBefore(child);
        });
        parentLink.remove();
      }
      return;
    }
  }

  // Handle RangeSelection
  const nodes = selection.extract();
  if (url === null) {
    const processedLinks = new Set();
    nodes.forEach(node => {
      const parentLink = $findMatchingParent(node, parent => !$isAutoLinkNode(parent) && $isLinkNode(parent));
      if (parentLink !== null) {
        const linkKey = parentLink.getKey();
        if (processedLinks.has(linkKey)) {
          return;
        }
        $splitLinkAtSelection(parentLink, nodes);
        processedLinks.add(linkKey);
      }
    });
    return;
  }
  const updatedNodes = new Set();
  const updateLinkNode = linkNode => {
    if (updatedNodes.has(linkNode.getKey())) {
      return;
    }
    updatedNodes.add(linkNode.getKey());
    linkNode.setURL(url);
    if (target !== undefined) {
      linkNode.setTarget(target);
    }
    if (rel !== undefined) {
      linkNode.setRel(rel);
    }
    if (title !== undefined) {
      linkNode.setTitle(title);
    }
  };
  // Add or merge LinkNodes
  if (nodes.length === 1) {
    const firstNode = nodes[0];
    // if the first node is a LinkNode or if its
    // parent is a LinkNode, we update the URL, target and rel.
    const linkNode = $findMatchingParent(firstNode, $isLinkNode);
    if (linkNode !== null) {
      return updateLinkNode(linkNode);
    }
  }
  $withSelectedNodes(() => {
    let linkNode = null;
    for (const node of nodes) {
      if (!node.isAttached()) {
        continue;
      }
      const parentLinkNode = $findMatchingParent(node, $isLinkNode);
      if (parentLinkNode) {
        updateLinkNode(parentLinkNode);
        continue;
      }
      if ($isElementNode(node)) {
        if (!node.isInline()) {
          // Ignore block nodes, if there are any children we will see them
          // later and wrap in a new LinkNode
          continue;
        }
        if ($isLinkNode(node)) {
          // If it's not an autolink node and we don't already have a LinkNode
          // in this block then we can update it and re-use it
          if (!$isAutoLinkNode(node) && (linkNode === null || !linkNode.getParentOrThrow().isParentOf(node))) {
            updateLinkNode(node);
            linkNode = node;
            continue;
          }
          // Unwrap LinkNode, we already have one or it's an AutoLinkNode
          for (const child of node.getChildren()) {
            node.insertBefore(child);
          }
          node.remove();
          continue;
        }
      }
      const prevLinkNode = node.getPreviousSibling();
      if ($isLinkNode(prevLinkNode) && prevLinkNode.is(linkNode)) {
        prevLinkNode.append(node);
        continue;
      }
      linkNode = $createLinkNode(url, {
        rel,
        target,
        title
      });
      node.insertAfter(linkNode);
      linkNode.append(node);
    }
  });
}
const PHONE_NUMBER_REGEX = /^\+?[0-9\s()-]{5,}$/;

/**
 * Formats a URL string by adding appropriate protocol if missing
 *
 * @param url - URL to format
 * @returns Formatted URL with appropriate protocol
 */
function formatUrl(url) {
  // Check if URL already has a protocol
  if (url.match(/^[a-z][a-z0-9+.-]*:/i)) {
    // URL already has a protocol, leave it as is
    return url;
  }
  // Check if it's a relative path (starting with '/', '.', or '#')
  else if (url.match(/^[/#.]/)) {
    // Relative path, leave it as is
    return url;
  }

  // Check for email address
  else if (url.includes('@')) {
    return `mailto:${url}`;
  }

  // Check for phone number
  else if (PHONE_NUMBER_REGEX.test(url)) {
    return `tel:${url}`;
  }

  // For everything else, return with https:// prefix
  return `https://${url}`;
}

/**
 * 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.
 *
 */

const defaultProps = {
  attributes: undefined,
  validateUrl: undefined
};

/** @internal */
function registerLink(editor, stores) {
  return mergeRegister(editor.registerNodeTransform(LinkNode, $linkNodeTransform), editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
    const validateUrl = stores.validateUrl.peek();
    const attributes = stores.attributes.peek();
    if (payload === null) {
      $toggleLink(null);
      return true;
    } else if (typeof payload === 'string') {
      if (validateUrl === undefined || validateUrl(payload)) {
        $toggleLink(payload, attributes);
        return true;
      }
      return false;
    } else {
      const {
        url,
        target,
        rel,
        title
      } = payload;
      $toggleLink(url, {
        ...attributes,
        rel,
        target,
        title
      });
      return true;
    }
  }, COMMAND_PRIORITY_EDITOR), effect(() => {
    const validateUrl = stores.validateUrl.value;
    if (!validateUrl) {
      return;
    }
    const attributes = stores.attributes.value;
    return editor.registerCommand(PASTE_COMMAND, event => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection) || selection.isCollapsed() || !objectKlassEquals(event, ClipboardEvent)) {
        return false;
      }
      if (event.clipboardData === null) {
        return false;
      }
      const clipboardText = event.clipboardData.getData('text');
      if (!validateUrl(clipboardText)) {
        return false;
      }
      // If we select nodes that are elements then avoid applying the link.
      if (!selection.getNodes().some(node => $isElementNode(node))) {
        editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
          ...attributes,
          url: clipboardText
        });
        event.preventDefault();
        return true;
      }
      return false;
    }, COMMAND_PRIORITY_LOW);
  }));
}

/**
 * Provides {@link LinkNode}, an implementation of
 * {@link TOGGLE_LINK_COMMAND}, and a {@link PASTE_COMMAND}
 * listener to wrap selected nodes in a link when a
 * URL is pasted and `validateUrl` is defined.
 */
const LinkExtension = defineExtension({
  build(editor, config, state) {
    return namedSignals(config);
  },
  config: defaultProps,
  mergeConfig(config, overrides) {
    const merged = shallowMergeConfig(config, overrides);
    if (config.attributes) {
      merged.attributes = shallowMergeConfig(config.attributes, merged.attributes);
    }
    return merged;
  },
  name: '@lexical/link/Link',
  nodes: () => [LinkNode],
  register(editor, config, state) {
    return registerLink(editor, state.getOutput());
  }
});

/**
 * 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.
 *
 */

function findMatchingDOM(startNode, predicate) {
  let node = startNode;
  while (node != null) {
    if (predicate(node)) {
      return node;
    }
    node = node.parentNode;
  }
  return null;
}
function registerClickableLink(editor, stores, eventOptions = {}) {
  const onClick = event => {
    const target = event.target;
    if (!isDOMNode(target)) {
      return;
    }
    const nearestEditor = getNearestEditorFromDOMNode(target);
    if (nearestEditor === null) {
      return;
    }
    let url = null;
    let urlTarget = null;
    nearestEditor.update(() => {
      const clickedNode = $getNearestNodeFromDOMNode(target);
      if (clickedNode !== null) {
        const maybeLinkNode = $findMatchingParent(clickedNode, $isElementNode);
        if (!stores.disabled.peek()) {
          if ($isLinkNode(maybeLinkNode)) {
            url = maybeLinkNode.sanitizeUrl(maybeLinkNode.getURL());
            urlTarget = maybeLinkNode.getTarget();
          } else {
            const a = findMatchingDOM(target, isHTMLAnchorElement);
            if (a !== null) {
              url = a.href;
              urlTarget = a.target;
            }
          }
        }
      }
    });
    if (url === null || url === '') {
      return;
    }

    // Allow user to select link text without following url
    const selection = editor.getEditorState().read($getSelection);
    if ($isRangeSelection(selection) && !selection.isCollapsed()) {
      event.preventDefault();
      return;
    }
    const isMiddle = event.type === 'auxclick' && event.button === 1;
    window.open(url, stores.newTab.peek() || isMiddle || event.metaKey || event.ctrlKey || urlTarget === '_blank' ? '_blank' : '_self');
    event.preventDefault();
  };
  const onMouseUp = event => {
    if (event.button === 1) {
      onClick(event);
    }
  };
  return editor.registerRootListener(rootElement => {
    if (rootElement) {
      rootElement.addEventListener('click', onClick, eventOptions);
      rootElement.addEventListener('mouseup', onMouseUp, eventOptions);
      return () => {
        rootElement.removeEventListener('click', onClick);
        rootElement.removeEventListener('mouseup', onMouseUp);
      };
    }
  });
}

/**
 * Normally in a Lexical editor the `CLICK_COMMAND` on a LinkNode will cause the
 * selection to change instead of opening a link. This extension can be used to
 * restore the default behavior, e.g. when the editor is not editable.
 */
const ClickableLinkExtension = defineExtension({
  build(editor, config, state) {
    return namedSignals(config);
  },
  config: safeCast({
    disabled: false,
    newTab: false
  }),
  dependencies: [LinkExtension],
  name: '@lexical/link/ClickableLink',
  register(editor, config, state) {
    return registerClickableLink(editor, state.getOutput());
  }
});

/**
 * 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.
 *
 */

function createLinkMatcherWithRegExp(regExp, urlTransformer = text => text) {
  return text => {
    const match = regExp.exec(text);
    if (match === null) {
      return null;
    }
    return {
      index: match.index,
      length: match[0].length,
      text: match[0],
      url: urlTransformer(match[0])
    };
  };
}
function findFirstMatch(text, matchers) {
  for (let i = 0; i < matchers.length; i++) {
    const match = matchers[i](text);
    if (match) {
      return match;
    }
  }
  return null;
}
const PUNCTUATION_OR_SPACE = /[.,;\s]/;
function isSeparator(char) {
  return PUNCTUATION_OR_SPACE.test(char);
}
function endsWithSeparator(textContent) {
  return isSeparator(textContent[textContent.length - 1]);
}
function startsWithSeparator(textContent) {
  return isSeparator(textContent[0]);
}

/**
 * Check if the text content starts with a fullstop followed by a top-level domain.
 * Meaning if the text content can be a beginning of a top level domain.
 * @param textContent
 * @param isEmail
 * @returns boolean
 */
function startsWithTLD(textContent, isEmail) {
  if (isEmail) {
    return /^\.[a-zA-Z]{2,}/.test(textContent);
  } else {
    return /^\.[a-zA-Z0-9]{1,}/.test(textContent);
  }
}
function isPreviousNodeValid(node) {
  let previousNode = node.getPreviousSibling();
  if ($isElementNode(previousNode)) {
    previousNode = previousNode.getLastDescendant();
  }
  return previousNode === null || $isLineBreakNode(previousNode) || $isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent());
}
function isNextNodeValid(node) {
  let nextNode = node.getNextSibling();
  if ($isElementNode(nextNode)) {
    nextNode = nextNode.getFirstDescendant();
  }
  return nextNode === null || $isLineBreakNode(nextNode) || $isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent());
}
function isContentAroundIsValid(matchStart, matchEnd, text, nodes) {
  const contentBeforeIsValid = matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0]);
  if (!contentBeforeIsValid) {
    return false;
  }
  const contentAfterIsValid = matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1]);
  return contentAfterIsValid;
}
function extractMatchingNodes(nodes, startIndex, endIndex) {
  const unmodifiedBeforeNodes = [];
  const matchingNodes = [];
  const unmodifiedAfterNodes = [];
  let matchingOffset = 0;
  let currentOffset = 0;
  const currentNodes = [...nodes];
  while (currentNodes.length > 0) {
    const currentNode = currentNodes[0];
    const currentNodeText = currentNode.getTextContent();
    const currentNodeLength = currentNodeText.length;
    const currentNodeStart = currentOffset;
    const currentNodeEnd = currentOffset + currentNodeLength;
    if (currentNodeEnd <= startIndex) {
      unmodifiedBeforeNodes.push(currentNode);
      matchingOffset += currentNodeLength;
    } else if (currentNodeStart >= endIndex) {
      unmodifiedAfterNodes.push(currentNode);
    } else {
      matchingNodes.push(currentNode);
    }
    currentOffset += currentNodeLength;
    currentNodes.shift();
  }
  return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes];
}
function $createAutoLinkNode_(nodes, startIndex, endIndex, match) {
  const linkNode = $createAutoLinkNode(match.url, match.attributes);
  if (nodes.length === 1) {
    let remainingTextNode = nodes[0];
    let linkTextNode;
    if (startIndex === 0) {
      [linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex);
    } else {
      [, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex);
    }
    const textNode = $createTextNode(match.text);
    textNode.setFormat(linkTextNode.getFormat());
    textNode.setDetail(linkTextNode.getDetail());
    textNode.setStyle(linkTextNode.getStyle());
    linkNode.append(textNode);
    linkTextNode.replace(linkNode);
    return remainingTextNode;
  } else if (nodes.length > 1) {
    const firstTextNode = nodes[0];
    let offset = firstTextNode.getTextContent().length;
    let firstLinkTextNode;
    if (startIndex === 0) {
      firstLinkTextNode = firstTextNode;
    } else {
      [, firstLinkTextNode] = firstTextNode.splitText(startIndex);
    }
    const linkNodes = [];
    let remainingTextNode;
    for (let i = 1; i < nodes.length; i++) {
      const currentNode = nodes[i];
      const currentNodeText = currentNode.getTextContent();
      const currentNodeLength = currentNodeText.length;
      const currentNodeStart = offset;
      const currentNodeEnd = offset + currentNodeLength;
      if (currentNodeStart < endIndex) {
        if (currentNodeEnd <= endIndex) {
          linkNodes.push(currentNode);
        } else {
          const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart);
          linkNodes.push(linkTextNode);
          remainingTextNode = endNode;
        }
      }
      offset += currentNodeLength;
    }
    const selection = $getSelection();
    const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined;
    const textNode = $createTextNode(firstLinkTextNode.getTextContent());
    textNode.setFormat(firstLinkTextNode.getFormat());
    textNode.setDetail(firstLinkTextNode.getDetail());
    textNode.setStyle(firstLinkTextNode.getStyle());
    linkNode.append(textNode, ...linkNodes);
    // it does not preserve caret position if caret was at the first text node
    // so we need to restore caret position
    if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
      if ($isRangeSelection(selection)) {
        textNode.select(selection.anchor.offset, selection.focus.offset);
      } else if ($isNodeSelection(selection)) {
        textNode.select(0, textNode.getTextContent().length);
      }
    }
    firstLinkTextNode.replace(linkNode);
    return remainingTextNode;
  }
  return undefined;
}
function $handleLinkCreation(nodes, matchers, onChange) {
  // Early return if any node is already part of an AutoLinkNode (idempotency check)
  for (const node of nodes) {
    const parent = node.getParent();
    if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
      return;
    }
  }
  let currentNodes = [...nodes];
  const initialText = currentNodes.map(node => node.getTextContent()).join('');
  let text = initialText;
  let match;
  let invalidMatchEnd = 0;
  while ((match = findFirstMatch(text, matchers)) && match !== null) {
    const matchStart = match.index;
    const matchLength = match.length;
    const matchEnd = matchStart + matchLength;
    const isValid = isContentAroundIsValid(invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd, initialText, currentNodes);
    if (isValid) {
      const [matchingOffset,, matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes(currentNodes, invalidMatchEnd + matchStart, invalidMatchEnd + matchEnd);

      // Skip if matching nodes are already part of an AutoLinkNode
      let alreadyLinked = false;
      for (const node of matchingNodes) {
        const parent = node.getParent();
        if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
          alreadyLinked = true;
          break;
        }
      }
      if (alreadyLinked) {
        invalidMatchEnd += matchEnd;
        text = text.substring(matchEnd);
        continue;
      }
      const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset;
      const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset;
      const remainingTextNode = $createAutoLinkNode_(matchingNodes, actualMatchStart, actualMatchEnd, match);
      currentNodes = remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes;
      onChange(match.url, null);
      invalidMatchEnd = 0;
    } else {
      invalidMatchEnd += matchEnd;
    }
    text = text.substring(matchEnd);
  }
}
function handleLinkEdit(linkNode, matchers, onChange) {
  // Check children are simple text
  const children = linkNode.getChildren();
  const childrenLength = children.length;
  for (let i = 0; i < childrenLength; i++) {
    const child = children[i];
    if (!$isTextNode(child) || !child.isSimpleText()) {
      replaceWithChildren(linkNode);
      onChange(null, linkNode.getURL());
      return;
    }
  }

  // Check text content fully matches
  const text = linkNode.getTextContent();
  const match = findFirstMatch(text, matchers);
  if (match === null || match.text !== text) {
    replaceWithChildren(linkNode);
    onChange(null, linkNode.getURL());
    return;
  }

  // Check neighbors
  if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) {
    replaceWithChildren(linkNode);
    onChange(null, linkNode.getURL());
    return;
  }
  const url = linkNode.getURL();
  if (url !== match.url) {
    linkNode.setURL(match.url);
    onChange(match.url, url);
  }
  if (match.attributes) {
    const rel = linkNode.getRel();
    if (rel !== match.attributes.rel) {
      linkNode.setRel(match.attributes.rel || null);
      onChange(match.attributes.rel || null, rel);
    }
    const target = linkNode.getTarget();
    if (target !== match.attributes.target) {
      linkNode.setTarget(match.attributes.target || null);
      onChange(match.attributes.target || null, target);
    }
  }
}

// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible.
// Given the creation preconditions, these can only be simple text nodes.
function handleBadNeighbors(textNode, matchers, onChange) {
  const parent = textNode.getParent();
  const previousSibling = textNode.getPreviousSibling();
  const nextSibling = textNode.getNextSibling();
  const text = textNode.getTextContent();

  // Skip if textNode is already part of an AutoLinkNode (idempotency check)
  // The handleLinkEdit on the parent will handle unwrapping if needed
  if ($isAutoLinkNode(parent) && !parent.getIsUnlinked()) {
    return;
  }

  // Handle case: textNode added AFTER a link, making link invalid
  // Check if previousSibling is a link and adding this textNode makes it invalid
  if ($isAutoLinkNode(previousSibling) && !previousSibling.getIsUnlinked()) {
    // Check if the textNode is still a sibling (hasn't been moved) to prevent loops
    if (previousSibling.is(textNode.getPreviousSibling()) && textNode.getParent() === previousSibling.getParent()) {
      // If text doesn't start with separator, link should be unwrapped
      // because non-separator after link makes the boundary invalid
      if (!startsWithSeparator(text)) {
        // Non-separator after link - unwrap the link
        replaceWithChildren(previousSibling);
        onChange(null, previousSibling.getURL());
        return; // Early return after unwrapping to avoid further processing
      }

      // If text starts with separator, check if it's valid TLD continuation
      if (startsWithTLD(text, previousSibling.isEmailURI())) {
        // Valid TLD continuation - try to append
        const combinedText = previousSibling.getTextContent() + text;
        const match = findFirstMatch(combinedText, matchers);
        if (match !== null && match.text === combinedText) {
          previousSibling.append(textNode);
          handleLinkEdit(previousSibling, matchers, onChange);
          onChange(null, previousSibling.getURL());
        }
      }
      // If starts with separator but not valid TLD, do nothing (link stays valid)
    }
  }

  // Handle case: textNode added BEFORE a link, making link invalid
  if ($isAutoLinkNode(nextSibling) && !nextSibling.getIsUnlinked() && !endsWithSeparator(text)) {
    // Check if the nextSibling is still a sibling (hasn't been moved) to prevent loops
    if (nextSibling.is(textNode.getNextSibling()) && textNode.getParent() === nextSibling.getParent()) {
      replaceWithChildren(nextSibling);
      onChange(null, nextSibling.getURL());
    }
  }
}
function replaceWithChildren(node) {
  const children = node.getChildren();
  const childrenLength = children.length;
  for (let j = childrenLength - 1; j >= 0; j--) {
    node.insertAfter(children[j]);
  }
  node.remove();
  return children.map(child => child.getLatest());
}
function getTextNodesToMatch(textNode) {
  // check if next siblings are simple text nodes till a node contains a space separator
  const textNodesToMatch = [textNode];
  let nextSibling = textNode.getNextSibling();
  while (nextSibling !== null && $isTextNode(nextSibling) && nextSibling.isSimpleText()) {
    textNodesToMatch.push(nextSibling);
    if (/[\s]/.test(nextSibling.getTextContent())) {
      break;
    }
    nextSibling = nextSibling.getNextSibling();
  }
  return textNodesToMatch;
}
const defaultConfig = {
  changeHandlers: [],
  excludeParents: [],
  matchers: []
};
function registerAutoLink(editor, config = defaultConfig) {
  const {
    matchers,
    changeHandlers,
    excludeParents
  } = config;
  const onChange = (url, prevUrl) => {
    for (const handler of changeHandlers) {
      handler(url, prevUrl);
    }
  };
  return mergeRegister(editor.registerNodeTransform(TextNode, textNode => {
    const parent = textNode.getParentOrThrow();
    const previous = textNode.getPreviousSibling();
    if ($isAutoLinkNode(parent)) {
      handleLinkEdit(parent, matchers, onChange);
    } else if (!$isLinkNode(parent) && !excludeParents.some(pred => pred(parent))) {
      if (textNode.isSimpleText() && (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))) {
        const textNodesToMatch = getTextNodesToMatch(textNode);
        $handleLinkCreation(textNodesToMatch, matchers, onChange);
      }
      handleBadNeighbors(textNode, matchers, onChange);
    }
  }), editor.registerCommand(TOGGLE_LINK_COMMAND, payload => {
    const selection = $getSelection();
    if (payload !== null || !$isRangeSelection(selection)) {
      return false;
    }
    const nodes = selection.extract();
    nodes.forEach(node => {
      const parent = node.getParent();
      if ($isAutoLinkNode(parent)) {
        // invert the value
        parent.setIsUnlinked(!parent.getIsUnlinked());
        parent.markDirty();
      }
    });
    return false;
  },
  // Has to be higher than TOGGLE_LINK_COMMAND in LinkExtension
  COMMAND_PRIORITY_LOW));
}

/**
 * An extension to automatically create AutoLinkNode from text
 * that matches the configured matchers. No default implementation
 * is provided for any matcher, see {@link createLinkMatcherWithRegExp}
 * for a helper function to create a matcher from a RegExp, and the
 * Playground's [AutoLinkPlugin](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutoLinkPlugin/index.tsx)
 * for some example RegExps that could be used.
 *
 * The given `matchers` and `changeHandlers` will be merged by
 * concatenating the configured arrays.
 */
const AutoLinkExtension = defineExtension({
  config: defaultConfig,
  dependencies: [LinkExtension],
  mergeConfig(config, overrides) {
    const merged = shallowMergeConfig(config, overrides);
    for (const k of ['matchers', 'changeHandlers', 'excludeParents']) {
      const v = overrides[k];
      if (Array.isArray(v)) {
        merged[k] = [...config[k], ...v];
      }
    }
    return merged;
  },
  name: '@lexical/link/AutoLink',
  nodes: [AutoLinkNode],
  register: registerAutoLink
});

/**
 * 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.
 *
 */


/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
const toggleLink = $toggleLink;

export { $createAutoLinkNode, $createLinkNode, $isAutoLinkNode, $isLinkNode, $toggleLink, AutoLinkExtension, AutoLinkNode, ClickableLinkExtension, LinkExtension, LinkNode, TOGGLE_LINK_COMMAND, createLinkMatcherWithRegExp, formatUrl, registerAutoLink, registerClickableLink, registerLink, toggleLink };
