import classNames from "classnames";
import * as immer from "immer";
import { Component } from "react";
import Sticky from "react-stickynode";

import { Alert, BusyIcon, Header, Icon, Input, ResizeButton, Spin, TreeNode } from "./components/index.js";
import {
    NonPeristableStorage,
    SUPPORTS_LOCAL_STORAGE,
    Storage,
    buildOrderedParentPairs,
    detectIE,
    getRecursivelyCheckedNodes,
    getTreeItemById,
    getTreeParentById,
    handleSearch,
    handleSearchBlur,
    handleSearchClose,
    handleSearchKeyDown,
    handleSortableTree,
    handleSortableTreeDidUpdate,
    updateTreeItemById,
    uuid,
} from "./util/index.js";

import type { HeaderProps } from "./components/Header.js";
import type { ResizeButtonProps } from "./components/ResizeButton.js";
import type { TreeNodeId, TreeNodeProps } from "./components/TreeNode.js";
import type { IStorage } from "./util/index.js";
import type { onSortParameters } from "./util/injectSortable.js";
import type { CSSProperties, PropsWithChildren } from "react";
import type { Options as SortableOptions } from "sortablejs";
import type Sortable from "sortablejs";

export interface TreeProps extends PropsWithChildren {
    /**
     * The theme, appended as class aiot-theme-{theme} to the rendered <div>
     */
    theme?: string;
    /**
     * The id for the rendered <div>, otherwise an unique id is generated
     */
    id?: string;
    className?: string;
    innerClassName?: string;
    style?: CSSProperties;
    /**
     * Additional attributes for the rendered <div>
     */
    attr?: Record<string, any>;
    isSticky?: boolean;
    isStickyHeader?: boolean;
    isBusyHeader?: boolean;
    treeStickyAttr?: Omit<Sticky.Props, "children">;
    headerStickyAttr?: Omit<Sticky.Props, "children">;
    isResizable?: boolean;
    isFullWidth?: boolean;
    /**
     * The width in px. If there is already a width for this id in the local storage the default width is ignored
     */
    defaultWidth?: ResizeButtonProps["initialWidth"];
    minWidth?: ResizeButtonProps["minWidth"];
    maxWidth?: ResizeButtonProps["maxWidth"];
    opposite?: ResizeButtonProps["opposite"];
    oppositeOffset?: ResizeButtonProps["oppositeOffset"];

    // Toolbar
    isCreatableLinkDisabled?: HeaderProps["isCreatableLinkDisabled"];
    isCreatableLinkCancel?: HeaderProps["isCreatableLinkCancel"];
    isToolbarActive?: HeaderProps["isToolbarActive"];
    isToolbarBusy?: HeaderProps["isToolbarBusy"];
    toolbarActiveButton?: HeaderProps["toolbarActiveButton"];
    headline?: HeaderProps["headline"];
    renameSaveText?: TreeNodeProps["renameSaveText"];
    renameAddText?: TreeNodeProps["renameAddText"];
    /**
     * The create node properties when you want to create a new node on the root tree node
     */
    createRoot?: TreeNodeProps;
    onAddClose?: TreeNodeProps["onAddClose"];
    creatable?: HeaderProps["creatable"];
    toolbar?: HeaderProps["toolbar"];
    multiToolbar?: HeaderProps["multiToolbar"];

    // Tree
    rootId?: TreeNodeId;
    sortableDelay?: number;
    thresholdPx?: number;
    noFoldersTitle?: any;
    noFoldersDescription?: any;
    noSearchResult?: any;
    searchable?: boolean;
    searchInputBusy?: boolean;
    toggleExpandAll?: boolean;
    isTreeLinkDisabled?: boolean;
    isTreeBusy?: boolean;
    isSortable?: boolean;
    isSortableDisabled?: boolean;
    isSortableBusy?: boolean;
    forceSortableFallback?: boolean;
    /**
     * This function is called when the multi toolbar should be closed.
     */
    onCloseMultiToolbar?: (checked: TreeNodeProps[]) => void;
    onSearchResult?: (result?: TreeNodeProps[]) => void;
    onNodePressF2?: TreeNodeProps["onNodePressF2"];
    onNodeExpand?: TreeNodeProps["onExpand"];
    onRenameClose?: TreeNodeProps["onRenameClose"];
    onSelect?: TreeNodeProps["onSelect"];
    onResize?: ResizeButtonProps["onResize"];
    onResizeFinished?: ResizeButtonProps["onResizeFinished"];
    onResizeOpposite?: ResizeButtonProps["onResizeOpposite"];
    /**
     * This function is called when a node item gets reordered per drag&drop.
     */
    onSort?: (args: onSortParameters) => void;
    onSortStart?: (evt: Sortable.SortableEvent) => void;
    onSortEnd?: TreeProps["onSortStart"];
    onSortMove?: SortableOptions["onMove"];
    staticTree?: TreeNodeProps[];
    tree?: TreeNodeProps[];
    ignoreChildNodes?: boolean;
    allowMultiSelect?: boolean;
    renderItem?: TreeNodeProps["renderItem"];
    renderIcon?: TreeNodeProps["renderIcon"];
    onCheck?: TreeNodeProps["onCheck"];
    autoFocusSearchInput?: boolean;
}

export interface TreeState {
    uuid: string;
    collapsed: boolean;
    stickyTreeCalculatedTop: number | undefined;
    currentlySorting: boolean;
    sortingBusy: boolean; // also available as prop "isSortableBusy"

    // Search results
    searchTerm: string;
    resultSelectedNodeIdx: number | undefined;
    resultTreeBusy: boolean;
    resultTree: TreeNodeProps[] | undefined;
}

/**
 * The ReactJS All-in-One-Tree.
 *
 * @returns
 */
class Tree extends Component<TreeProps, TreeState> {
    protected storage: IStorage;
    protected handleSearch: typeof handleSearch;
    protected handleSearchBlur: typeof handleSearchBlur;
    protected handleSearchClose: typeof handleSearchClose;
    protected handleSearchKeyDown: typeof handleSearchKeyDown;
    protected handleSortableTree: typeof handleSortableTree;
    protected handleSortableTreeDidUpdate: typeof handleSortableTreeDidUpdate;
    protected container: HTMLDivElement;
    protected _sortables: { [s: string]: Sortable };
    protected searchTimeout: ReturnType<typeof setTimeout>;

    public static defaultProps: TreeProps = {
        theme: "default",
        style: {},
        attr: {},
        isSticky: false,
        isStickyHeader: false,
        isBusyHeader: false,
        treeStickyAttr: {},
        headerStickyAttr: {},
        isResizable: true,
        isFullWidth: false,
        defaultWidth: 250,
        minWidth: 250,
        maxWidth: 800,
        oppositeOffset: 16,

        // Toolbar
        isCreatableLinkDisabled: false,
        isCreatableLinkCancel: false,
        isToolbarActive: true,
        isToolbarBusy: false,
        headline: "Folders",
        renameSaveText: "Save",
        renameAddText: "Add",
        creatable: {
            buttons: {
                folder: {
                    icon: '<i class="fa fa-folder-open"></i>',
                },
            },
            backButton: {
                label: "Cancel",
            },
        },
        toolbar: {
            buttons: {
                rename: {
                    content: '<i class="fa fa-pencil"></i>',
                },
            },
            backButton: {
                label: "Cancel",
                save: "Done",
            },
        },
        multiToolbar: {
            buttons: {},
            backButton: {
                label: "Cancel",
            },
        },

        // Tree
        rootId: 0,
        sortableDelay: 100,
        thresholdPx: 5,
        noFoldersTitle: "No folders found",
        noFoldersDescription: "Click the above button to create a new folder.",
        noSearchResult: "No search results found",
        searchable: true,
        searchInputBusy: false,
        toggleExpandAll: true,
        isTreeLinkDisabled: false,
        isTreeBusy: false,
        isSortable: false,
        isSortableDisabled: false,
        isSortableBusy: false,
        forceSortableFallback: false,
        staticTree: [],
        tree: [],
        ignoreChildNodes: false,
        allowMultiSelect: false,
    };

    /**
     * The constructor creates a local storage instance to save
     * the sidebar width and expand states (if supported and enabled).
     */
    public constructor(props: TreeProps) {
        super(props);

        // State
        this.state = {
            uuid: uuid(),
            collapsed: false,
            stickyTreeCalculatedTop: undefined,
            currentlySorting: false,
            sortingBusy: false, // also available as prop "isSortableBusy"

            // Search results
            searchTerm: "",
            resultSelectedNodeIdx: undefined,
            resultTreeBusy: false,
            resultTree: undefined,
        };

        // Localstorage only when id given
        if (this.props.id && SUPPORTS_LOCAL_STORAGE) {
            this.storage = new Storage(this.id());
        } else {
            this.storage = new NonPeristableStorage();
        }

        this.handleSearch = handleSearch.bind(this);
        this.handleSearchBlur = handleSearchBlur.bind(this);
        this.handleSearchClose = handleSearchClose.bind(this);
        this.handleSearchKeyDown = handleSearchKeyDown.bind(this);
        this.handleSortableTree = handleSortableTree.bind(this);
        this.handleSortableTreeDidUpdate = handleSortableTreeDidUpdate.bind(this);
    }

    /**
     * When the component did mount initialize the sticky sidebar and header.
     */
    public componentDidMount() {
        // Calculate offset of tree if tree and header are sticky
        const { isSticky, isStickyHeader, treeStickyAttr, headerStickyAttr } = this.props;
        const obj = document.querySelector(`#${this.id()} .aiot-fixed-header > div`) as HTMLElement;
        let newTreeCalculatedTop = 0;
        if (isSticky && isStickyHeader && typeof treeStickyAttr.top === "undefined" && obj) {
            newTreeCalculatedTop = obj.offsetHeight;

            const headerStickyTop = headerStickyAttr.top;
            if (typeof headerStickyTop === "string") {
                const headerStickyTopObj = document.querySelector(headerStickyTop) as HTMLElement;
                newTreeCalculatedTop += headerStickyTopObj ? headerStickyTopObj.offsetHeight : 0;
            } else if (typeof headerStickyTop === "number") {
                newTreeCalculatedTop += headerStickyTop;
            }
        }

        // Set sticky tree calculated in state
        this.setState({ stickyTreeCalculatedTop: newTreeCalculatedTop });
    }

    /**
     * When the component did update rehandle the sortable tree.
     */
    public componentDidUpdate(prevProps: TreeProps) {
        this.handleSortableTreeDidUpdate(prevProps);
    }

    /**
     * The sidebar gets resized through the ResizeButton and chain it to
     * the onResize method.
     */
    public handleResize = (x: number, collapse: boolean) => {
        this.state.collapsed !== collapse && this.setState({ collapsed: collapse });
        this.props.onResize && this.props.onResize(x, collapse);
    };

    /**
     * The sidebar is resized successfully. Chain it to the onResizeFinished method
     * and save the width to the local storage if supported and enabled.
     */
    public handleResizeFinished = (width: number) => {
        this.storage.setItem("width", width);
        width > 0 && this.storage.setItem("rwidth", width);
        this.props.onResizeFinished && this.props.onResizeFinished(width);
    };

    /**
     * A node gets expanded / collapsed. Chain it to the onNodeExpand method and
     * save the state to the local storage if supported and enabled.
     */
    public handleNodeExpand = (expanded: boolean, node: TreeNodeProps) => {
        const { onNodeExpand } = this.props;
        const { id } = node;
        id && this.storage.setItem(`expandNodes.${node.id}`, expanded);
        onNodeExpand && onNodeExpand(expanded, node);
        this.forceUpdate();
    };

    /**
     * A node gets checked / unchecked so we have to trigger an event (onCheck)
     * and rerender the complete tree so the toolbar gets rerendered, too. The node
     * itself do not rerender because they do not depend on the amount of checked items.
     */
    public handleCheck = (id: TreeNodeId) => {
        const { onCheck } = this.props;
        onCheck && onCheck(id);
        this.forceUpdate();
    };

    /**
     * Toggle all expanded states.
     *
     * @method
     */
    public handleToggleAll = () => {
        const expanded = this.getExpandedNodes();
        const newExpandedNodes: any = {};
        const newState = !expanded.length;
        const fnChild = ({ id, childNodes = [] }: TreeNodeProps) => {
            if (childNodes.length) {
                newExpandedNodes[id] = newState;
                childNodes.forEach(fnChild);
            }
        };
        this.props.tree.forEach(fnChild);
        this.storage.setItem("expandNodes", newExpandedNodes);
        this.forceUpdate();
    };

    public handleCloseMultiToolbar = () => {
        const { onCloseMultiToolbar } = this.props;
        onCloseMultiToolbar && onCloseMultiToolbar(getRecursivelyCheckedNodes(this.props.tree, null));
        this.forceUpdate();
    };

    /**
     * Get expanded tree nodes.
     *
     * @returns
     */
    public getExpandedNodes(expandedState = this.storage.getItem("expandNodes") || {}) {
        return (this.props.tree || []).filter(({ id, childNodes }) => {
            if (!childNodes || !childNodes.length) {
                return false;
            }
            return expandedState[id] === undefined ? true : expandedState[id];
        });
    }

    /**
     * Render a tree.
     *
     * @param tree The tree
     * @param displayChildren If true the first nodes can have childNodes
     * @param createRoot Shows an additional node after the last one with an input field
     * @param context The tree context
     * @returns
     */
    public renderTree = (
        tree: TreeNodeProps[],
        displayChildren = true,
        createRoot: TreeNodeProps = undefined,
        context: "tree" | "static" | "search" = "tree",
    ) => {
        const {
            renderItem,
            renderIcon,
            onRenameClose,
            onAddClose,
            onSelect,
            onNodePressF2,
            renameSaveText,
            renameAddText,
            ignoreChildNodes,
        } = this.props;
        const nodeAttr = {
            renderItem,
            renderIcon,
            onRenameClose,
            onAddClose,
            onSelect,
            onNodePressF2,
            renameSaveText,
            renameAddText,
            ignoreChildNodes,
        };
        const { isTreeLinkDisabled, rootId, allowMultiSelect } = this.props;
        const { resultSelectedNodeIdx } = this.state;
        const resultTreeLength = typeof resultSelectedNodeIdx === "number" && this.state.resultTree.length;
        const expandedState = this.storage.getItem("expandNodes") || {};
        const $checkable = context !== "static" && allowMultiSelect;
        const className = classNames(
            {
                "aiot-disable-links": isTreeLinkDisabled,
                "aiot-sortable-one": tree.length === 1,
            },
            `aiot-context-${context}`,
        );
        let i = -1;
        const anyNodeHasChildren = tree.some((n) => n.childNodes && n.childNodes.length > 0);

        return [
            // "All" expander
            context === "tree" && anyNodeHasChildren && (
                <div
                    key="all-expander"
                    onClick={this.handleToggleAll}
                    className={classNames("aiot-expander", "aiot-expander-all", {
                        "aiot-open": this.getExpandedNodes(expandedState).length,
                    })}
                />
            ),
            <ul
                key="list-view"
                className={className}
                data-childs-for={rootId}
                ref={displayChildren ? this.handleSortableTree : undefined}
            >
                {tree.map((node) => {
                    i++;
                    const searchSelected =
                        i % resultTreeLength === resultSelectedNodeIdx % resultTreeLength && !displayChildren;
                    const propSearchSelected = context === "search" ? searchSelected : undefined;

                    const createTreeNode = (_node: TreeNodeProps, opts?: TreeNodeProps) => (
                        <TreeNode
                            key={_node.id}
                            searchSelected={propSearchSelected}
                            {..._node}
                            onExpand={this.handleNodeExpand}
                            onCheck={this.handleCheck}
                            $checkable={$checkable}
                            expandedState={expandedState}
                            {...nodeAttr}
                            onUlRef={displayChildren ? this.handleSortableTree : undefined}
                            displayChildren={displayChildren && !ignoreChildNodes}
                            {...opts}
                        />
                    );

                    if (renderItem) {
                        return renderItem(createTreeNode, TreeNode, node);
                    } else {
                        return createTreeNode(node, null);
                    }
                })}
                {!!createRoot && (
                    <TreeNode
                        $_create
                        renderIcon={renderIcon}
                        onRenameClose={onAddClose}
                        renameSaveText={this.props.renameAddText}
                        {...createRoot}
                    />
                )}
            </ul>,
        ];
    };

    /**
     * Render the tree wrapper. In this part the sticky component is ensured and
     * you do not have to worry about it.
     */
    public renderTreeWrapper = () => {
        const {
            isCreatableLinkCancel,
            createRoot,
            searchable,
            searchInputBusy,
            isTreeBusy,
            staticTree,
            tree,
            isSortableBusy,
            children,
            noFoldersTitle,
            noFoldersDescription,
            noSearchResult,
            autoFocusSearchInput,
        } = this.props;
        const { sortingBusy, searchTerm, resultTree, resultTreeBusy } = this.state;

        return (
            <div>
                <div className="aiot-nodes">
                    {children}
                    {staticTree && this.renderTree(staticTree, false, undefined, "static")}
                    {staticTree && <hr />}
                    {searchable && (
                        <div className="aiot-search">
                            <Input
                                autoFocus={autoFocusSearchInput}
                                disabled={!tree.length || isCreatableLinkCancel || sortingBusy || isSortableBusy}
                                size="small"
                                value={searchTerm}
                                onChange={this.handleSearch}
                                onBlur={this.handleSearchBlur}
                                onKeyDown={this.handleSearchKeyDown}
                                suffix={
                                    searchInputBusy || resultTreeBusy ? (
                                        <BusyIcon />
                                    ) : searchTerm.length ? (
                                        <Icon
                                            type="close"
                                            style={{ cursor: "pointer" }}
                                            onClick={this.handleSearchClose}
                                        />
                                    ) : (
                                        <Icon type="search" />
                                    )
                                }
                            />
                        </div>
                    )}
                    {/* @ts-expect-error outdated @types/react */}
                    <Spin
                        spinning={!!isTreeBusy || sortingBusy || isSortableBusy}
                        size="small"
                        style={{ minHeight: 50 }}
                    >
                        {this.renderTree(
                            resultTree || tree,
                            !resultTree,
                            resultTree ? undefined : createRoot,
                            resultTree ? "search" : "tree",
                        )}
                    </Spin>
                    {tree && !tree.filter((node) => node.$visible).length && !isTreeBusy && (
                        <Alert message={noFoldersTitle} description={noFoldersDescription} type="info" showIcon />
                    )}
                    {resultTree && !resultTree.length && <Alert message={noSearchResult} type="warning" showIcon />}
                </div>
            </div>
        );
    };

    /**
     * Render the main wrapper with sticky / resize functionality. It also
     * renders the Header with headline and toolbar.
     *
     * @method
     */
    public renderWrapper = (checkedNodes?: HeaderProps["checkedNodes"]) => {
        // Create resize styles
        const { props } = this;
        const {
            isResizable,
            opposite,
            minWidth,
            maxWidth,
            innerClassName,
            isSticky,
            isStickyHeader,
            isSortableBusy,
            headerStickyAttr,
            oppositeOffset,
            onResizeOpposite,
        } = props;
        const { currentlySorting, sortingBusy, searchTerm, stickyTreeCalculatedTop, collapsed } = this.state;
        // Header
        const {
            headline,
            creatable,
            isCreatableLinkDisabled,
            isCreatableLinkCancel,
            isToolbarActive,
            isToolbarBusy,
            toolbar,
            multiToolbar,
            toolbarActiveButton,
            isBusyHeader,
        } = props;
        const headerAttr = {
            headline,
            creatable,
            isCreatableLinkDisabled,
            isCreatableLinkCancel,
            isToolbarActive,
            isToolbarBusy,
            toolbar,
            multiToolbar,
            toolbarActiveButton,
            isBusyHeader,
        };
        const bodyHeader = (
            <Header
                {...headerAttr}
                isToolbarActive={sortingBusy || isSortableBusy ? false : isToolbarActive}
                checkedNodes={checkedNodes}
                onCloseMultiToolbar={this.handleCloseMultiToolbar}
                isCreatableLinkDisabled={
                    searchTerm || sortingBusy || isSortableBusy || checkedNodes.length
                        ? true
                        : props.isCreatableLinkDisabled
                }
            />
        );
        // Tree
        const bodyTreeWrapper = stickyTreeCalculatedTop !== undefined ? this.renderTreeWrapper() : undefined; // Avoid double rendering for massive tree's while waiting for componentDidMount
        const className = classNames("aiot-pad", innerClassName, {
            "aiot-currently-sorting": currentlySorting,
        });

        // Create sticky attributes
        const treeStickyAttr = Object.assign(
            {},
            {
                top: stickyTreeCalculatedTop,
            },
            props.treeStickyAttr,
        );

        // Create wrapper with toolbar and so on...
        return (
            <div className={className}>
                {isResizable && opposite && (
                    <ResizeButton
                        opposite={opposite}
                        minWidth={minWidth}
                        maxWidth={maxWidth}
                        initialWidth={this.storage.getItem("width")}
                        restoreWidth={this.storage.getItem("rwidth")}
                        containerId={this.id()}
                        onResize={this.handleResize}
                        onResizeOpposite={onResizeOpposite}
                        onResizeFinished={this.handleResizeFinished}
                        oppositeOffset={oppositeOffset}
                    />
                )}
                {!collapsed &&
                    (isStickyHeader ? (
                        <Sticky className="aiot-fixed-header" {...headerStickyAttr}>
                            {bodyHeader}
                        </Sticky>
                    ) : (
                        <div>{bodyHeader}</div>
                    ))}
                {!collapsed &&
                    (isSticky ? <Sticky {...treeStickyAttr}>{bodyTreeWrapper}</Sticky> : <div>{bodyTreeWrapper} </div>)}
            </div>
        );
    };

    /**
     * Get an unique id for this container.
     *
     * @param append Append this string to the id with '--' suffix
     * @returns
     */
    public id(append?: string): string {
        const id = this.props.id || this.state.uuid;
        return append ? `${id}--${append}` : id;
    }

    /**
     * Render.
     */
    public render() {
        const { theme, attr, isFullWidth, toolbarActiveButton } = this.props;
        const checkedNodes = getRecursivelyCheckedNodes(this.props.tree, false);
        const className = classNames("aiot-tree", this.props.className, `aiot-theme-${theme}`, {
            "aiot-wrap-collapse": this.state.collapsed,
            "aiot-full-width": isFullWidth,
            "aiot-has-checked": checkedNodes.length > 0,
            "aiot-toolbar-active-button": toolbarActiveButton,
        });
        const style = Object.assign(
            {},
            this.props.style,
            !isFullWidth && {
                width: `${this.props.defaultWidth}px`,
                minWidth: `${this.props.minWidth}px`,
                maxWidth: `${this.props.maxWidth}px`,
            },
        );

        // Create first wrapper attributes
        const wrapperAttr = {
            id: this.id(),
            style,
            ...attr,
            className,
            ref: (div: HTMLDivElement) => (this.container = div),
        };
        return <div {...wrapperAttr}> {this.renderWrapper(checkedNodes)} </div>;
    }
}

// Export
export { Tree };
export {
    uuid,
    updateTreeItemById,
    getTreeParentById,
    getTreeItemById,
    buildOrderedParentPairs,
    getRecursivelyCheckedNodes,
    detectIE,
    /**
     * @see https://github.com/mweststrate/immer
     */
    immer,
};

export * from "./components/index.js";
