/*
 * Copyright 2015 Palantir Technologies, Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { waitFor } from "@testing-library/dom";
import { mount, type ReactWrapper } from "enzyme";

import { afterEach, assert, beforeEach, describe, expect, it, vi } from "@blueprintjs/test-commons/vitest";

import { Classes } from "../../common";

import { Tree, type TreeProps } from "./tree";
import { type TreeNodeInfo } from "./treeTypes";

describe("<Tree>", () => {
    let containerElement: HTMLElement;

    beforeEach(() => {
        // this is essentially what TestUtils.renderIntoDocument does
        containerElement = document.createElement("div");
        document.documentElement.appendChild(containerElement);
    });

    afterEach(() => {
        containerElement.remove();
    });

    it("renders its contents", () => {
        const tree = renderTree({ contents: [{ id: 0, label: "Node" }] });
        assert.lengthOf(tree.find(`.${Classes.TREE}`), 1);
    });

    it("handles undefined input well", () => {
        const tree = renderTree({ contents: undefined });
        assert.lengthOf(tree.find(`.${Classes.TREE}`), 1);
    });

    it("handles empty input well", () => {
        const tree = renderTree({ contents: [] });
        assert.lengthOf(tree.find(`.${Classes.TREE}`), 1);
    });

    it("hasCaret forces a caret to be/not be displayed", () => {
        const contents = createDefaultContents();
        contents[0].hasCaret = contents[1].hasCaret = true;
        contents[2].hasCaret = contents[3].hasCaret = false;

        const tree = renderTree({ contents });
        assertNodeHasCaret(tree, "c0", true);
        assertNodeHasCaret(tree, "c1", true);
        assertNodeHasCaret(tree, "c2", false);
        assertNodeHasCaret(tree, "c3", false);
    });

    it("if not specified, caret visibility is determined by the presence of children", () => {
        const tree = renderTree();
        assertNodeHasCaret(tree, "c0", false);
        assertNodeHasCaret(tree, "c1", true);
        assertNodeHasCaret(tree, "c2", false);
        assertNodeHasCaret(tree, "c3", true);
    });

    it("caret direction is determined by node expansion", () => {
        const contents = [
            {
                childNodes: [{ id: 4, label: "" }],
                className: "c0",
                hasCaret: true,
                id: 1,
                isExpanded: false,
                label: "c0",
            },
            { className: "c1", hasCaret: true, id: 0, isExpanded: true, label: "c1" },
            { className: "c2", hasCaret: true, id: 2, isExpanded: false, label: "c2" },
            {
                childNodes: [{ id: 5, label: "c4" }],
                className: "c3",
                hasCaret: true,
                id: 3,
                isExpanded: true,
                label: "c3",
            },
        ];

        const tree = renderTree({ contents });
        assertNodeHasClass(tree, "c0", Classes.TREE_NODE_CARET_CLOSED);
        assertNodeHasClass(tree, "c1", Classes.TREE_NODE_CARET_OPEN);
        assertNodeHasClass(tree, "c2", Classes.TREE_NODE_CARET_CLOSED);
        assertNodeHasClass(tree, "c3", Classes.TREE_NODE_CARET_OPEN);
    });

    it("event callbacks are fired correctly", () => {
        const onNodeClick = vi.fn();
        const onNodeCollapse = vi.fn();
        const onNodeContextMenu = vi.fn();
        const onNodeDoubleClick = vi.fn();
        const onNodeExpand = vi.fn();
        const onNodeMouseEnter = vi.fn();
        const onNodeMouseLeave = vi.fn();

        const contents = createDefaultContents();
        contents[3].isExpanded = true;

        const tree = renderTree({
            contents,
            onNodeClick,
            onNodeCollapse,
            onNodeContextMenu,
            onNodeDoubleClick,
            onNodeExpand,
            onNodeMouseEnter,
            onNodeMouseLeave,
        });

        tree.find(`.c0 > .${Classes.TREE_NODE_CONTENT}`).simulate("click");
        expect(onNodeClick).toHaveBeenCalledOnce();
        expect(onNodeClick.mock.calls[0][1]).toEqual([0]);

        findNodeClass(tree, "c1", Classes.TREE_NODE_CARET).simulate("click");
        expect(onNodeExpand).toHaveBeenCalledOnce();
        expect(onNodeExpand.mock.calls[0][1]).toEqual([1]);
        // make sure that onNodeClick isn't fired again, only onNodeExpand should be
        expect(onNodeClick).toHaveBeenCalledOnce();

        tree.find(`.c6 > .${Classes.TREE_NODE_CONTENT}`).simulate("dblclick");
        expect(onNodeDoubleClick).toHaveBeenCalledOnce();
        expect(onNodeDoubleClick.mock.calls[0][1]).toEqual([3, 0]);

        findNodeClass(tree, "c3", Classes.TREE_NODE_CARET).simulate("click");
        expect(onNodeCollapse).toHaveBeenCalledOnce();
        expect(onNodeCollapse.mock.calls[0][1]).toEqual([3]);

        tree.find(`.c0 > .${Classes.TREE_NODE_CONTENT}`).simulate("contextmenu");
        expect(onNodeContextMenu).toHaveBeenCalledOnce();
        expect(onNodeContextMenu.mock.calls[0][1]).toEqual([0]);

        tree.find(`.c2 > .${Classes.TREE_NODE_CONTENT}`).simulate("mouseenter");
        expect(onNodeMouseEnter).toHaveBeenCalledOnce();
        expect(onNodeMouseEnter.mock.calls[0][1]).toEqual([2]);

        tree.find(`.c2 > .${Classes.TREE_NODE_CONTENT}`).simulate("mouseleave");
        expect(onNodeMouseLeave).toHaveBeenCalledOnce();
        expect(onNodeMouseLeave.mock.calls[0][1]).toEqual([2]);
    });

    it("if disabled, event callbacks are not fired", () => {
        const onNodeClick = vi.fn();
        const onNodeCollapse = vi.fn();
        const onNodeContextMenu = vi.fn();
        const onNodeDoubleClick = vi.fn();
        const onNodeExpand = vi.fn();
        const onNodeMouseEnter = vi.fn();
        const onNodeMouseLeave = vi.fn();

        const contents = createDefaultContents();
        contents[0].disabled = true;
        contents[0].hasCaret = true;
        contents[0].isExpanded = false;

        const tree = renderTree({
            contents,
            onNodeClick,
            onNodeCollapse,
            onNodeContextMenu,
            onNodeDoubleClick,
            onNodeExpand,
            onNodeMouseEnter,
            onNodeMouseLeave,
        });

        const treeNode = tree.find(`.${Classes.TREE_NODE}.c0`);
        const treeNodeContent = treeNode.find(`.${Classes.TREE_NODE_CONTENT}`);
        const treeNodeCaret = treeNodeContent.find(`.${Classes.TREE_NODE_CARET}`).first();

        treeNodeContent.simulate("click");
        expect(onNodeClick).not.toHaveBeenCalled();

        treeNodeContent.simulate("dblclick");
        expect(onNodeDoubleClick).not.toHaveBeenCalled();

        treeNodeContent.simulate("contextmenu");
        expect(onNodeContextMenu).not.toHaveBeenCalled();

        treeNodeContent.simulate("mouseenter");
        expect(onNodeMouseEnter).not.toHaveBeenCalled();

        treeNodeContent.simulate("mouseleave");
        expect(onNodeMouseLeave).not.toHaveBeenCalled();

        treeNodeCaret.simulate("click");
        expect(onNodeExpand).not.toHaveBeenCalled();

        treeNodeCaret.simulate("click");
        expect(onNodeCollapse).not.toHaveBeenCalled();
    });

    it("disabled nodes are rendered correctly", () => {
        const contents = createDefaultContents();
        contents[0].disabled = true;

        const tree = renderTree({ contents });
        const disabledTreeNode = tree.find(`.${Classes.TREE_NODE}.c0.${Classes.DISABLED}`);

        assert.lengthOf(disabledTreeNode, 1);
    });

    it("icons are rendered correctly if present", () => {
        const contents = createDefaultContents();
        contents[1].icon = "document";
        contents[2].icon = "document";

        const tree = renderTree({ contents });
        assertNodeHasClass(tree, "c0", Classes.TREE_NODE_ICON, false);
        assertNodeHasClass(tree, "c1", Classes.TREE_NODE_ICON);
        assertNodeHasClass(tree, "c2", Classes.TREE_NODE_ICON);
    });

    it("isExpanded controls node expansion", () => {
        const contents = createDefaultContents();
        contents[3].isExpanded = false;
        contents[4].isExpanded = true;

        const nodes = renderTree({ contents }).find("li");
        assert.lengthOf(nodes.filter(`.c1.${Classes.TREE_NODE_EXPANDED}`), 0);
        assert.lengthOf(nodes.filter(".c5"), 0);
        assert.lengthOf(nodes.filter(`.c3.${Classes.TREE_NODE_EXPANDED}`), 0);
        assert.lengthOf(nodes.filter(".c6"), 0);
        assert.lengthOf(nodes.filter(`.c4.${Classes.TREE_NODE_EXPANDED}`), 1);
        assert.lengthOf(nodes.filter(".c7"), 1);
    });

    it("isSelected selects nodes", () => {
        const contents = createDefaultContents();
        contents[1].isSelected = false;
        contents[2].isSelected = true;

        const nodes = renderTree({ contents }).find("li");
        assert.lengthOf(nodes.filter(`.c0.${Classes.TREE_NODE_SELECTED}`), 0);
        assert.lengthOf(nodes.filter(`.c1.${Classes.TREE_NODE_SELECTED}`), 0);
        assert.lengthOf(nodes.filter(`.c2.${Classes.TREE_NODE_SELECTED}`), 1);
    });

    it("secondaryLabel renders correctly", () => {
        const contents = createDefaultContents();
        contents[1].secondaryLabel = "Secondary";
        contents[2].secondaryLabel = <p>Paragraph</p>;

        const tree = renderTree({ contents }).find("li");
        assertNodeHasClass(tree, "c0", Classes.TREE_NODE_SECONDARY_LABEL, false);
        assert.strictEqual(findNodeClass(tree, "c1", Classes.TREE_NODE_SECONDARY_LABEL).text(), "Secondary");
        assert.strictEqual(findNodeClass(tree, "c2", Classes.TREE_NODE_SECONDARY_LABEL).text(), "Paragraph");
    });

    it("getNodeContentElement returns references to underlying node elements", async () => {
        const contents = createDefaultContents();
        contents[1].isExpanded = true;

        const wrapper = renderTree({ contents });
        const tree = wrapper.instance() as Tree;

        assert.strictEqual(
            tree.getNodeContentElement(5),
            wrapper.getDOMNode().querySelector<HTMLElement>(`.c5 > .${Classes.TREE_NODE_CONTENT}`),
        );
        assert.isUndefined(tree.getNodeContentElement(100));

        contents[1].isExpanded = false;
        wrapper.setProps({ contents });
        // wait for animation to finish
        await waitFor(() => {
            assert.isUndefined(tree.getNodeContentElement(5));
        });
    });

    it("allows nodes to be removed without throwing", () => {
        const contents = createDefaultContents();
        renderTree({ contents });

        const smallerContents = createDefaultContents().slice(0, -1);
        assert.doesNotThrow(() => renderTree({ contents: smallerContents }));
    });

    function findNodeClass(tree: ReactWrapper, nodeClass: string, childClass: string) {
        return tree.find(`.${nodeClass} > .${Classes.TREE_NODE_CONTENT} .${childClass}`).hostNodes();
    }

    function assertNodeHasClass(tree: ReactWrapper, nodeClass: string, childClass: string, expected = true) {
        assert.equal(findNodeClass(tree, nodeClass, childClass).exists(), expected);
    }

    function assertNodeHasCaret(tree: ReactWrapper, nodeClass: string, hasCaret: boolean) {
        return assertNodeHasClass(tree, nodeClass, hasCaret ? Classes.TREE_NODE_CARET : Classes.TREE_NODE_CARET_NONE);
    }

    function renderTree(props?: Partial<TreeProps>) {
        return mount(<Tree contents={createDefaultContents()} {...props} />);
    }

    /* eslint-disable sort-keys */
    function createDefaultContents(): TreeNodeInfo[] {
        return [
            { id: 0, className: "c0", label: "Item 0" },
            {
                id: 1,
                className: "c1",
                label: "Item 1",
                childNodes: [{ id: 5, className: "c5", label: "Item 5" }],
            },
            { id: 2, className: "c2", label: "Item 2" },
            {
                id: 3,
                className: "c3",
                label: "Item 3",
                childNodes: [{ id: 6, className: "c6", label: "Item 6" }],
            },
            {
                id: 4,
                className: "c4",
                label: "Item 4",
                childNodes: [{ id: 7, className: "c7", label: "Item 7" }],
            },
        ];
    }
});
