import classNames from 'classnames';
import { deepEqual } from 'fast-equals';
import memoize from 'lodash.memoize';
import PropTypes from 'prop-types';
import React from 'react';

import GlobalActions from '#js/components/GlobalActions.jsx';
import HiddenInput from '#js/components/HiddenInput.jsx';
import TreeNode from '#js/components/TreeNode.jsx';
import defaultLang from '#js/lang/default.js';
import iconsShape from '#js/shapes/iconsShape.js';
import languageShape from '#js/shapes/languageShape.js';
import listShape from '#js/shapes/listShape.js';
import nodeShape from '#js/shapes/nodeShape.js';
import { CHECK_MODEL, KEYS } from '#js/constants.js';
import { IconContext, LanguageContext } from '#js/contexts.js';
import NodeModel from '#js/NodeModel.js';

const combineMemoized = memoize((newValue, defaultValue) => ({ ...defaultValue, ...newValue }));

const defaultIcons = {
    check: <span className="rct-icon rct-icon-check" />,
    uncheck: <span className="rct-icon rct-icon-uncheck" />,
    halfCheck: <span className="rct-icon rct-icon-half-check" />,
    expandClose: <span className="rct-icon rct-icon-expand-close" />,
    expandOpen: <span className="rct-icon rct-icon-expand-open" />,
    expandAll: <span className="rct-icon rct-icon-expand-all" />,
    collapseAll: <span className="rct-icon rct-icon-collapse-all" />,
    parentClose: <span className="rct-icon rct-icon-parent-close" />,
    parentOpen: <span className="rct-icon rct-icon-parent-open" />,
    leaf: <span className="rct-icon rct-icon-leaf" />,
};

class CheckboxTree extends React.Component {
    static propTypes = {
        nodes: PropTypes.arrayOf(nodeShape).isRequired,

        checkKeys: PropTypes.arrayOf(PropTypes.string),
        checkModel: PropTypes.oneOf([CHECK_MODEL.LEAF, CHECK_MODEL.ALL]),
        checked: listShape,
        direction: PropTypes.string,
        disabled: PropTypes.bool,
        expandDisabled: PropTypes.bool,
        expandOnClick: PropTypes.bool,
        expanded: listShape,
        icons: iconsShape,
        iconsClass: PropTypes.string,
        id: PropTypes.string,
        lang: languageShape,
        name: PropTypes.string,
        nameAsArray: PropTypes.bool,
        nativeCheckboxes: PropTypes.bool,
        noCascade: PropTypes.bool,
        onlyLeafCheckboxes: PropTypes.bool,
        optimisticToggle: PropTypes.bool,
        showExpandAll: PropTypes.bool,
        showNodeIcon: PropTypes.bool,
        showNodeTitle: PropTypes.bool,
        onCheck: PropTypes.func,
        onClick: PropTypes.func,
        onContextMenu: PropTypes.func,
        onExpand: PropTypes.func,
    };

    static defaultProps = {
        checkKeys: [KEYS.SPACEBAR, KEYS.ENTER],
        checkModel: CHECK_MODEL.LEAF,
        checked: [],
        direction: 'ltr',
        disabled: false,
        expandDisabled: false,
        expandOnClick: false,
        expanded: [],
        icons: defaultIcons,
        iconsClass: 'fa5',
        id: null,
        lang: defaultLang,
        name: undefined,
        nameAsArray: false,
        nativeCheckboxes: false,
        noCascade: false,
        onlyLeafCheckboxes: false,
        optimisticToggle: true,
        showExpandAll: false,
        showNodeIcon: true,
        showNodeTitle: false,
        onCheck: () => {},
        onClick: null,
        onContextMenu: null,
        onExpand: () => {},
    };

    constructor(props) {
        super(props);

        const model = new NodeModel(props);
        model.flattenNodes(props.nodes);
        model.deserializeLists({
            checked: props.checked,
            expanded: props.expanded,
        });

        this.state = {
            model,
            prevProps: props,
        };

        this.onCheck = this.onCheck.bind(this);
        this.onContextMenu = this.onContextMenu.bind(this);
        this.onExpand = this.onExpand.bind(this);
        this.onNodeClick = this.onNodeClick.bind(this);
        this.onExpandAll = this.onExpandAll.bind(this);
        this.onCollapseAll = this.onCollapseAll.bind(this);
    }

    static getDerivedStateFromProps(newProps, prevState) {
        const { model, prevProps } = prevState;
        const { disabled, nodes } = newProps;
        const newState = { ...prevState, prevProps: newProps };

        // Apply new properties to model
        model.setProps(newProps);

        // Since flattening nodes is an expensive task, only update when there is a node change
        if (!deepEqual(prevProps.nodes, nodes) || prevProps.disabled !== disabled) {
            model.reset();
            model.flattenNodes(nodes);
        }

        model.deserializeLists({
            checked: newProps.checked,
            expanded: newProps.expanded,
        });

        return newState;
    }

    onContextMenu(node) {
        const { onContextMenu } = this.props;

        return (event) => {
            onContextMenu(event, node);
        };
    }

    onCheck(nodeInfo) {
        const { checkModel, noCascade, onCheck } = this.props;
        const { model } = this.state;
        const newModel = model.clone();
        const node = newModel.getNode(nodeInfo.value);

        newModel.toggleChecked(nodeInfo, nodeInfo.checked, checkModel, noCascade);
        onCheck(newModel.serializeList('checked'), { ...node, ...nodeInfo });
    }

    onExpand(nodeInfo) {
        const { onExpand } = this.props;
        const { model } = this.state;
        const newModel = model.clone();
        const node = newModel.getNode(nodeInfo.value);

        newModel.toggleNode(nodeInfo.value, 'expanded', nodeInfo.expanded);
        onExpand(newModel.serializeList('expanded'), { ...node, ...nodeInfo });
    }

    onNodeClick(nodeInfo) {
        const { onClick } = this.props;
        const { model } = this.state;
        const node = model.getNode(nodeInfo.value);

        onClick({ ...node, ...nodeInfo });
    }

    onExpandAll() {
        this.expandAllNodes();
    }

    onCollapseAll() {
        this.expandAllNodes(false);
    }

    expandAllNodes(expand = true) {
        const { onExpand } = this.props;
        const { model } = this.state;

        onExpand(
            model.clone()
                .expandAllNodes(expand)
                .serializeList('expanded'),
        );
    }

    determineShallowCheckState(node, noCascade) {
        const { model } = this.state;
        const flatNode = model.getNode(node.value);

        if (flatNode.isLeaf || noCascade || node.children.length === 0) {
            // Note that an empty parent node tracks its own state
            return flatNode.checked ? 1 : 0;
        }

        if (this.isEveryChildChecked(node)) {
            return 1;
        }

        if (this.isSomeChildChecked(node)) {
            return 2;
        }

        return 0;
    }

    isEveryChildChecked(node) {
        const { model } = this.state;

        return node.children.every(
            (child) => model.getNode(child.value).checkState === 1,
        );
    }

    isSomeChildChecked(node) {
        const { model } = this.state;

        return node.children.some(
            (child) => model.getNode(child.value).checkState > 0,
        );
    }

    renderTreeNodes(nodes, parent = {}) {
        const {
            checkKeys,
            expandDisabled,
            expandOnClick,
            id,
            noCascade,
            onClick,
            onlyLeafCheckboxes,
            optimisticToggle,
            showNodeTitle,
            showNodeIcon,
        } = this.props;
        const { model } = this.state;

        const treeNodes = nodes.map((node) => {
            const key = node.value;
            const flatNode = model.getNode(node.value);
            const children = flatNode.isParent ? this.renderTreeNodes(node.children, node) : null;

            // Determine the check state after all children check states have been determined
            // This is done during rendering as to avoid an additional loop during the
            // deserialization of the `checked` property
            flatNode.checkState = this.determineShallowCheckState(node, noCascade);

            // Show checkbox only if this is a leaf node or showCheckbox is true
            const showCheckbox = onlyLeafCheckboxes ? flatNode.isLeaf : flatNode.showCheckbox;

            // Render only if parent is expanded or if there is no root parent
            const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;

            if (!parentExpanded) {
                return null;
            }

            // Prepare node information for context menu usage
            const nodeContext = {
                ...node,
                checked: flatNode.checkState,
                expanded: flatNode.expanded,
            };

            return (
                <TreeNode
                    key={key}
                    checkKeys={checkKeys}
                    checked={flatNode.checkState}
                    className={node.className}
                    disabled={flatNode.disabled}
                    expandDisabled={expandDisabled}
                    expandOnClick={expandOnClick}
                    expanded={flatNode.expanded}
                    icon={node.icon}
                    isLeaf={flatNode.isLeaf}
                    isParent={flatNode.isParent}
                    label={node.label}
                    optimisticToggle={optimisticToggle}
                    showCheckbox={showCheckbox}
                    showNodeIcon={showNodeIcon}
                    title={showNodeTitle ? node.title || node.label : node.title}
                    treeId={id}
                    value={node.value}
                    onCheck={this.onCheck}
                    onClick={onClick && this.onNodeClick}
                    onContextMenu={this.onContextMenu(nodeContext)}
                    onExpand={this.onExpand}
                >
                    {children}
                </TreeNode>
            );
        });

        return (
            <ol>
                {treeNodes}
            </ol>
        );
    }

    renderGlobalOptions() {
        const { showExpandAll } = this.props;

        return showExpandAll ? (
            <GlobalActions onCollapseAll={this.onCollapseAll} onExpandAll={this.onExpandAll} />
        ) : null;
    }

    renderHiddenInput() {
        const { checked, name, nameAsArray } = this.props;

        return name !== undefined ? (
            <HiddenInput checked={checked} name={name} nameAsArray={nameAsArray} />
        ) : null;
    }

    render() {
        const {
            direction,
            disabled,
            icons,
            iconsClass,
            id,
            lang,
            nodes,
            nativeCheckboxes,
        } = this.props;
        const mergedLang = combineMemoized(lang, defaultLang);
        const mergedIcons = combineMemoized(icons, defaultIcons);
        const treeNodes = this.renderTreeNodes(nodes);

        const className = classNames({
            'react-checkbox-tree': true,
            'rct-disabled': disabled,
            [`rct-icons-${iconsClass}`]: true,
            'rct-native-display': nativeCheckboxes,
            'rct-direction-rtl': direction === 'rtl',
        });

        return (
            <LanguageContext.Provider value={mergedLang}>
                <IconContext.Provider value={mergedIcons}>
                    <div className={className} id={id}>
                        {this.renderGlobalOptions()}
                        {this.renderHiddenInput()}
                        {treeNodes}
                    </div>
                </IconContext.Provider>
            </LanguageContext.Provider>
        );
    }
}

export default CheckboxTree;
