/**
 * 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 invariant from '@lexical/internal/invariant';
import {
  $createChildrenArray as $createChildrenArray_,
  $createRangeSelection,
  $getNodeByKey,
  $isElementNode,
  $isTextNode,
  type EditorState,
  type LexicalEditor,
  type NodeKey,
  type NodeMap,
  type RangeSelection,
  type RootNode,
} from 'lexical';

type OffsetElementNode = {
  child: null | OffsetNode;
  end: number;
  key: NodeKey;
  next: null | OffsetNode;
  parent: null | OffsetElementNode;
  prev: null | OffsetNode;
  start: number;
  type: 'element';
};
type OffsetTextNode = {
  child: null;
  end: number;
  key: NodeKey;
  next: null | OffsetNode;
  parent: null | OffsetElementNode;
  prev: null | OffsetNode;
  start: number;
  type: 'text';
};
type OffsetInlineNode = {
  child: null;
  end: number;
  key: NodeKey;
  next: null | OffsetNode;
  parent: null | OffsetElementNode;
  prev: null | OffsetNode;
  start: number;
  type: 'inline';
};
type OffsetNode = OffsetElementNode | OffsetTextNode | OffsetInlineNode;
type OffsetMap = Map<NodeKey, OffsetNode>;

/** @deprecated OffsetView has never worked correctly and will be removed */
export class OffsetView {
  _offsetMap: OffsetMap;
  _firstNode: null | OffsetNode;
  _blockOffsetSize: number;

  constructor(
    offsetMap: OffsetMap,
    firstNode: null | OffsetNode,
    blockOffsetSize = 1,
  ) {
    this._offsetMap = offsetMap;
    this._firstNode = firstNode;
    this._blockOffsetSize = blockOffsetSize;
  }

  createSelectionFromOffsets(
    originalStart: number,
    originalEnd: number,
    diffOffsetView?: OffsetView,
  ): null | RangeSelection {
    const firstNode = this._firstNode;

    if (firstNode === null) {
      return null;
    }

    let start = originalStart;
    let end = originalEnd;
    let startOffsetNode = $searchForNodeWithOffset(
      firstNode,
      start,
      this._blockOffsetSize,
    );
    let endOffsetNode = $searchForNodeWithOffset(
      firstNode,
      end,
      this._blockOffsetSize,
    );

    if (diffOffsetView !== undefined) {
      start = $getAdjustedOffsetFromDiff(
        start,
        startOffsetNode,
        diffOffsetView,
        this,
        this._blockOffsetSize,
      );
      startOffsetNode = $searchForNodeWithOffset(
        firstNode,
        start,
        this._blockOffsetSize,
      );
      end = $getAdjustedOffsetFromDiff(
        end,
        endOffsetNode,
        diffOffsetView,
        this,
        this._blockOffsetSize,
      );
      endOffsetNode = $searchForNodeWithOffset(
        firstNode,
        end,
        this._blockOffsetSize,
      );
    }

    if (startOffsetNode === null || endOffsetNode === null) {
      return null;
    }

    let startKey = startOffsetNode.key;
    let endKey = endOffsetNode.key;
    const startNode = $getNodeByKey(startKey);
    const endNode = $getNodeByKey(endKey);

    if (startNode === null || endNode === null) {
      return null;
    }

    let startOffset = 0;
    let endOffset = 0;
    let startType: 'element' | 'text' = 'element';
    let endType: 'element' | 'text' = 'element';

    if (startOffsetNode.type === 'text') {
      startOffset = start - startOffsetNode.start;
      startType = 'text';
      // If we are at the edge of a text node and we
      // don't have a collapsed selection, then let's
      // try and correct the offset node.
      const sibling = startNode.getNextSibling();

      if (
        start !== end &&
        startOffset === startNode.getTextContentSize() &&
        $isTextNode(sibling)
      ) {
        startOffset = 0;
        startKey = sibling.__key;
      }
    } else if (startOffsetNode.type === 'inline') {
      startKey = startNode.getParentOrThrow().getKey();
      startOffset =
        end > startOffsetNode.start
          ? startOffsetNode.end
          : startOffsetNode.start;
    }

    if (endOffsetNode.type === 'text') {
      endOffset = end - endOffsetNode.start;
      endType = 'text';
    } else if (endOffsetNode.type === 'inline') {
      endKey = endNode.getParentOrThrow().getKey();
      endOffset =
        end > endOffsetNode.start ? endOffsetNode.end : endOffsetNode.start;
    }

    const selection = $createRangeSelection();

    if (selection === null) {
      return null;
    }

    selection.anchor.set(startKey, startOffset, startType);
    selection.focus.set(endKey, endOffset, endType);

    return selection;
  }

  getOffsetsFromSelection(selection: RangeSelection): [number, number] {
    const anchor = selection.anchor;
    const focus = selection.focus;
    const offsetMap = this._offsetMap;
    const anchorOffset = anchor.offset;
    const focusOffset = focus.offset;
    let start = -1;
    let end = -1;

    if (anchor.type === 'text') {
      const offsetNode = offsetMap.get(anchor.key);

      if (offsetNode !== undefined) {
        start = offsetNode.start + anchorOffset;
      }
    } else {
      const node = anchor.getNode().getDescendantByIndex(anchorOffset);

      if (node !== null) {
        const offsetNode = offsetMap.get(node.getKey());

        if (offsetNode !== undefined) {
          const isAtEnd = node.getIndexWithinParent() !== anchorOffset;
          start = isAtEnd ? offsetNode.end : offsetNode.start;
        }
      }
    }

    if (focus.type === 'text') {
      const offsetNode = offsetMap.get(focus.key);

      if (offsetNode !== undefined) {
        end = offsetNode.start + focus.offset;
      }
    } else {
      const node = focus.getNode().getDescendantByIndex(focusOffset);

      if (node !== null) {
        const offsetNode = offsetMap.get(node.getKey());

        if (offsetNode !== undefined) {
          const isAtEnd = node.getIndexWithinParent() !== focusOffset;
          end = isAtEnd ? offsetNode.end : offsetNode.start;
        }
      }
    }

    return [start, end];
  }
}

function $getAdjustedOffsetFromDiff(
  offset: number,
  offsetNode: null | OffsetNode,
  prevOffsetView: OffsetView,
  offsetView: OffsetView,
  blockOffsetSize: number,
): number {
  const prevOffsetMap = prevOffsetView._offsetMap;
  const offsetMap = offsetView._offsetMap;
  const visited = new Set();
  let adjustedOffset = offset;
  let currentNode = offsetNode;

  while (currentNode !== null) {
    const key = currentNode.key;
    const prevNode = prevOffsetMap.get(key);
    const diff = currentNode.end - currentNode.start;
    visited.add(key);

    if (prevNode === undefined) {
      adjustedOffset += diff;
    } else {
      const prevDiff = prevNode.end - prevNode.start;

      if (prevDiff !== diff) {
        adjustedOffset += diff - prevDiff;
      }
    }

    const sibling = currentNode.prev;

    if (sibling !== null) {
      currentNode = sibling;
      continue;
    }

    let parent = currentNode.parent;

    while (parent !== null) {
      let parentSibling = parent.prev;

      if (parentSibling !== null) {
        const parentSiblingKey = parentSibling.key;
        const prevParentSibling = prevOffsetMap.get(parentSiblingKey);
        const parentDiff = parentSibling.end - parentSibling.start;
        visited.add(parentSiblingKey);

        if (prevParentSibling === undefined) {
          adjustedOffset += parentDiff;
        } else {
          const prevParentDiff =
            prevParentSibling.end - prevParentSibling.start;

          if (prevParentDiff !== parentDiff) {
            adjustedOffset += parentDiff - prevParentDiff;
          }
        }

        parentSibling = parentSibling.prev;
      }

      parent = parent.parent;
    }

    break;
  }

  // Now traverse through the old offsets nodes and find any nodes we missed
  // above, because they were not in the latest offset node view (they have been
  // deleted).
  const prevFirstNode = prevOffsetView._firstNode;

  if (prevFirstNode !== null) {
    currentNode = $searchForNodeWithOffset(
      prevFirstNode,
      offset,
      blockOffsetSize,
    );
    let alreadyVisitedParentOfCurrentNode = false;

    while (currentNode !== null) {
      if (!visited.has(currentNode.key)) {
        alreadyVisitedParentOfCurrentNode = true;
        break;
      }

      currentNode = currentNode.parent;
    }

    if (!alreadyVisitedParentOfCurrentNode) {
      while (currentNode !== null) {
        const key = currentNode.key;

        if (!visited.has(key)) {
          const node = offsetMap.get(key);
          const prevDiff = currentNode.end - currentNode.start;

          if (node === undefined) {
            adjustedOffset -= prevDiff;
          } else {
            const diff = node.end - node.start;

            if (prevDiff !== diff) {
              adjustedOffset += diff - prevDiff;
            }
          }
        }

        currentNode = currentNode.prev;
      }
    }
  }

  return adjustedOffset;
}

function $searchForNodeWithOffset(
  firstNode: OffsetNode,
  offset: number,
  blockOffsetSize: number,
): OffsetNode | null {
  let currentNode = firstNode;

  while (currentNode !== null) {
    const end =
      currentNode.end +
      (currentNode.type !== 'element' || blockOffsetSize === 0 ? 1 : 0);

    if (offset < end) {
      const child = currentNode.child;

      if (child !== null) {
        currentNode = child;
        continue;
      }

      return currentNode;
    }

    const sibling = currentNode.next;

    if (sibling === null) {
      break;
    }

    currentNode = sibling;
  }

  return null;
}

function $createInternalOffsetNode(
  child: null | OffsetNode,
  type: 'element' | 'text' | 'inline',
  start: number,
  end: number,
  key: NodeKey,
  parent: null | OffsetElementNode,
): {
  child: null | OffsetNode;
  type: 'element' | 'text' | 'inline';
  start: number;
  end: number;
  key: NodeKey;
  parent: null | OffsetElementNode;
  next: null;
  prev: null;
} {
  return {
    child,
    end,
    key,
    next: null,
    parent,
    prev: null,
    start,
    type,
  };
}

function $createOffsetNode(
  state: {
    offset: number;
    prevIsBlock: boolean;
  },
  key: NodeKey,
  parent: null | OffsetElementNode,
  nodeMap: NodeMap,
  offsetMap: OffsetMap,
  blockOffsetSize: number,
): OffsetNode {
  const node = nodeMap.get(key);

  if (node === undefined) {
    invariant(false, 'createOffsetModel: could not find node by key');
  }

  const start = state.offset;

  if ($isElementNode(node)) {
    const childKeys = $createChildrenArray(node, nodeMap);
    const blockIsEmpty = childKeys.length === 0;
    const child = blockIsEmpty
      ? null
      : $createOffsetChild(
          state,
          childKeys,
          null,
          nodeMap,
          offsetMap,
          blockOffsetSize,
        );

    // If the prev node was not a block or the block is empty, we should
    // account for the user being able to selection the block (due to the \n).
    if (!state.prevIsBlock || blockIsEmpty) {
      state.prevIsBlock = true;
      state.offset += blockOffsetSize;
    }

    const offsetNode = $createInternalOffsetNode(
      child,
      'element',
      start,
      start,
      key,
      parent,
    ) as OffsetElementNode;

    if (child !== null) {
      child.parent = offsetNode;
    }

    const end = state.offset;
    offsetNode.end = end;
    offsetMap.set(key, offsetNode);
    return offsetNode;
  }

  state.prevIsBlock = false;

  const isText = $isTextNode(node);
  const length = isText ? node.__text.length : 1;
  const end = (state.offset += length);

  const offsetNode = $createInternalOffsetNode(
    null,
    isText ? 'text' : 'inline',
    start,
    end,
    key,
    parent,
  );

  offsetMap.set(key, offsetNode as OffsetNode);

  return offsetNode as OffsetNode;
}

function $createOffsetChild(
  state: {
    offset: number;
    prevIsBlock: boolean;
  },
  children: NodeKey[],
  parent: null | OffsetElementNode,
  nodeMap: NodeMap,
  offsetMap: OffsetMap,
  blockOffsetSize: number,
): OffsetNode | null {
  let firstNode = null;
  let currentNode = null;

  const childrenLength = children.length;

  for (let i = 0; i < childrenLength; i++) {
    const childKey = children[i];

    const offsetNode = $createOffsetNode(
      state,
      childKey,
      parent,
      nodeMap,
      offsetMap,
      blockOffsetSize,
    );

    if (currentNode === null) {
      firstNode = offsetNode;
    } else {
      offsetNode.prev = currentNode;
      currentNode.next = offsetNode;
    }

    currentNode = offsetNode;
  }

  return firstNode;
}

/** @deprecated moved to `lexical` */
export const $createChildrenArray = $createChildrenArray_;

/** @deprecated OffsetView has never worked correctly and will be removed */
export function $createOffsetView(
  editor: LexicalEditor,
  blockOffsetSize = 1,
  editorState?: EditorState | null,
): OffsetView {
  const targetEditorState =
    editorState || editor._pendingEditorState || editor._editorState;
  const nodeMap = targetEditorState._nodeMap;

  const root = nodeMap.get('root') as RootNode;

  const offsetMap = new Map();
  const state = {
    offset: 0,
    prevIsBlock: false,
  };

  const node = $createOffsetChild(
    state,
    $createChildrenArray(root, nodeMap),
    null,
    nodeMap,
    offsetMap,
    blockOffsetSize,
  );

  return new OffsetView(offsetMap, node, blockOffsetSize);
}
