import {
    $createParagraphNode,
    $getNearestNodeFromDOMNode,
    $getRoot,
    $isDecoratorNode,
    $isElementNode, $isRootNode,
    $isTextNode,
    ElementNode,
    LexicalEditor,
    LexicalNode, RangeSelection
} from "lexical";
import {LexicalNodeMatcher} from "../nodes";
import {$generateNodesFromDOM} from "@lexical/html";
import {htmlToDom} from "./dom";
import {NodeHasAlignment, NodeHasInset} from "lexical/nodes/common";
import {$findMatchingParent} from "@lexical/utils";
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {$isDiagramNode} from "./diagrams";
import {$isLinkedImageNode} from "./images";
import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";

function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
    return nodes.map(node => {
        if ($isTextNode(node)) {
            const paragraph = $createParagraphNode();
            paragraph.append(node);
            return paragraph;
        }
        return node;
    });
}

export function $htmlToNodes(editor: LexicalEditor, html: string): LexicalNode[] {
    const dom = htmlToDom(html);
    return $generateNodesFromDOM(editor, dom);
}

export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
    return wrapTextNodes($htmlToNodes(editor, html));
}

export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null {
    for (const parent of node.getParents()) {
        if (matcher(parent)) {
            return parent;
        }
    }

    return null;
}

export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNode): LexicalNode[] {
    if (!root) {
        root = $getRoot();
    }

    const matches = [];

    for (const child of root.getChildren()) {
        if (matcher(child)) {
            matches.push(child);
        }

        if ($isElementNode(child)) {
            matches.push(...$getAllNodesOfType(matcher, child));
        }
    }

    return matches;
}

/**
 * Get the node based on the given mouse event.
 */
export function $getNodePositionFromMouseEvent(event: MouseEvent, editor: LexicalEditor): {node: LexicalNode, offset: number}|null {
    const x = event.clientX;
    const y = event.clientY;
    const caretPosition = window.document.caretPositionFromPoint(event.x, event.y);
    if (!caretPosition) {
        const backup = $getNearestBlockNodeForCoords(editor, x, y);
        return backup ? {node: backup, offset: 0} : null;
    }

    const node = $getNearestNodeFromDOMNode(caretPosition.offsetNode);
    if (!node) {
        const backup = $getNearestBlockNodeForCoords(editor, x, y);
        return backup ? {node: backup, offset: 0} : null;
    }

    return {node, offset: caretPosition.offset};
}

/**
 * Get the nearest root/block level node for the given position.
 */
export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode | null {
    // TODO - Take into account x for floated blocks?
    const rootNodes = $getRoot().getChildren();
    for (const node of rootNodes) {
        const nodeDom = editor.getElementByKey(node.__key);
        if (!nodeDom) {
            continue;
        }

        const bounds = nodeDom.getBoundingClientRect();
        if (y <= bounds.bottom) {
            return node;
        }
    }

    return null;
}

export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null {
    const isBlockNode = (node: LexicalNode): boolean => {
        return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline() && !$isRootNode(node);
    };

    if (isBlockNode(node)) {
        return node;
    }

    return $findMatchingParent(node, isBlockNode);
}

/**
 * Sort the given node array by their position in the document.
 * A search point can be provided to limit the search to a specific part of the document, which can
 * avoid having to traverse the entire document.
 */
export function $sortNodes(nodes: LexicalNode[], searchPoint: ElementNode|null = null): LexicalNode[] {
    const idChain: string[] = [];
    const addIds = (n: ElementNode) => {
        for (const child of n.getChildren()) {
            idChain.push(child.getKey())
            if ($isElementNode(child)) {
                addIds(child)
            }
        }
    };

    addIds(searchPoint || $getRoot());

    const sorted = Array.from(nodes);
    sorted.sort((a, b) => {
        const aIndex = idChain.indexOf(a.getKey());
        const bIndex = idChain.indexOf(b.getKey());
        return aIndex - bIndex;
    });

    return sorted;
}

export function $selectOrCreateAdjacent(node: LexicalNode, after: boolean): RangeSelection {
    const nearestBlock = $getNearestNodeBlockParent(node) || node;
    let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling()

    if (!target) {
        target = $createParagraphNode();
        if (after) {
            nearestBlock.insertAfter(target)
        } else {
            nearestBlock.insertBefore(target);
        }
    }

    return after ? target.selectStart() : target.selectEnd();
}

/**
 * Check if the range of nodes represents a single node which is wholly selectable.
 */
export function $isSingleSelectableNode(nodes: LexicalNode[]): boolean {
    if (nodes.length === 1) {
        const node = nodes[0];
        if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) {
            return true;
        }

        if ($isDetailsNode(node)) {
            return true;
        }
    }

    return false;
}

/**
 * Get the single selectable node if the given range represents a single selectable node.
 * Typically called with a selection's node ranges.
 * Normalises the result, like for linked images, for example.
 */
export function $getSingleSelectableNode(nodes: LexicalNode[]): LexicalNode|null {
    if (!$isSingleSelectableNode(nodes)) {
        return null;
    }

    const node = nodes[0];

    if ($isLinkedImageNode(node)) {
        return node.getParent();
    }

    return node;
}

export function nodeHasAlignment(node: object): node is NodeHasAlignment {
    return '__alignment' in node;
}

export function nodeHasInset(node: object): node is NodeHasInset {
    return '__inset' in node;
}