/**
 * Component pop-up window (Popover)
 *
 * Implemented on the basis of the library Popper.js (см: https://popper.js.org/)
 * and react-wrappers above it - react-popper (см: https://github.com/FezVrasta/react-popper)
 *
 */

import memo from 'memoize-one';
import {Modifier} from '@popperjs/core';
import * as React from 'react';
import {Manager, Popper, PopperChildrenProps, Reference, ReferenceChildrenProps} from 'react-popper';
import {PLACEMENT, POPOVER_BOUNDARIES, POPOVER_THEME, TRIGGER} from '../../constants/constants';
import {capitalize} from '../../utils/capitalize';
import {composeEventHandlers} from '../../utils/composeEventHandlers';
import {filterProps} from '../../utils/filterProps';

import {joinClassNames} from '../../utils/joinClassNames';
import {safeInvoke} from '../../utils/safeInvoke';

import {uniqueId} from '../../utils/uniqueId';

import {Overlay} from '../modal/core/overlay/Overlay';
import {IProps as OverlayProps} from '../modal/core/overlay/Overlay.types';
import * as modifierFn from './modifiers';

import * as styles from './popover.m.scss';
import {
    DefaultProps,
    GetDerivedStateFromProps,
    GetPlacementThemeKey,
    IProps,
    IState,
    PalcementThemeKeys,
    PropsWithDefault
} from './Popover.types';
import {getSizeThemeKey} from '../../utils/getSizeThemeKey';
import {SizeClassNames} from '../../type/SizeTypes';

// Storage for all active Poppers instances
const poppers: {[popperKey: string]: any} = {};

const getPlacementThemeKey: GetPlacementThemeKey = (placement) =>
    `popoverPlacement${placement
        .split('-')
        .map(capitalize)
        .join('')}` as PalcementThemeKeys;

export class Popover extends React.PureComponent<IProps, IState> {
    static defaultProps: DefaultProps = {
        modifiers: [],
        trigger: TRIGGER.CLICK,
        placement: PLACEMENT.AUTO,
        boundaries: POPOVER_BOUNDARIES.CLIPPING_PARENTS,
        mouseLeaveDelay: 0,
        mouseEnterDelay: 0,
        hasBackdrop: false,
        hasArrow: true,
        hasPaddings: true,
        hasShadow: false,
        canFlip: true,
        theme: POPOVER_THEME.LIGHT
    };

    static excludingProps: Array<keyof IProps> = [
        'isOpen',
        'isDefaultOpen',
        'overlayId',
        'target',
        'placement',
        'boundaries',
        'trigger',
        'modifiers',
        'hasBackdrop',
        'hasArrow',
        'hasShadow',
        'hasPaddings',
        'mouseLeaveDelay',
        'mouseEnterDelay',
        'canFlip',
        'theme',
        'onOpen',
        'onClose',
        'onVisibleChange'
    ];

    static getDerivedStateFromProps: GetDerivedStateFromProps = (nextProps, prevState) => {
        const isControlledMode = nextProps.isOpen !== undefined;
        const isOpen = isControlledMode ? (nextProps.isOpen as boolean) : prevState.isOpen;

        return {
            ...prevState,
            isControlledMode,
            isOpen
        };
    };

    override state = Popover.getDerivedStateFromProps(this.props, {
        isOpen: this.props.isDefaultOpen === undefined ? false : this.props.isDefaultOpen,
        popperKey: uniqueId()
    });

    targetElement: HTMLElement | null = null;

    innerRefs = {
        target: (node: HTMLElement | null) => {
            this.targetElement = node;
        }
    };

    scheduleUpdate: (() => void) | null = null;

    /**
     * The cursor is on the popup window?
     */
    isMouseOnPopper: boolean = false;

    /**
     * Is the cursor on the target?
     */
    isMouseOnTarget: boolean = false;

    timer: number = 0;

    get isAnyHoverInteraction () {
        const {trigger} = this.props as PropsWithDefault;

        return [TRIGGER.HOVER, TRIGGER.HOVER_TARGET_ONLY].includes(trigger);
    }

    get isAnyClickInteraction () {
        const {trigger} = this.props as PropsWithDefault;

        return [
            TRIGGER.CLICK,
            TRIGGER.CLICK_TARGET_ONLY,
            TRIGGER.CLICK_TARGET_ONCE,
            TRIGGER.CONTEXT_MENU
        ].includes(trigger);
    }

    handleTargetClick: React.MouseEventHandler<Element> = (e) => {
        if (this.timer) {
            clearTimeout(this.timer);
        }

        if (this.isAnyClickInteraction) {
            this.setState((prevState, prevProps) => {
                const {isControlledMode, isOpen} = prevState;
                const {trigger} = prevProps as PropsWithDefault;

                if (isControlledMode) {
                    return null;
                }

                if (trigger === TRIGGER.CLICK_TARGET_ONCE && isOpen) {
                    return null;
                }

                return {isOpen: !isOpen};
            });

            if (this.state.isOpen) {
                if (this.props.trigger !== TRIGGER.CLICK_TARGET_ONCE) {
                    safeInvoke(this.props.onClose, e);
                    safeInvoke(this.props.onVisibleChange, false);
                }
            } else {
                safeInvoke(this.props.onOpen, e);
                safeInvoke(this.props.onVisibleChange, true);
            }
        }
    };

    handleTargetMouseEnter: React.MouseEventHandler<Element> = (e) => {
        this.isMouseOnTarget = true;

        if (this.timer) {
            clearTimeout(this.timer);
        }

        if (this.isAnyHoverInteraction) {
            this.timer = window.setTimeout(() => {
                this.setState((prevState) => {
                    if (prevState.isControlledMode) {
                        return null;
                    }

                    if (prevState.isOpen === false) {
                        safeInvoke(this.props.onOpen, e);
                        safeInvoke(this.props.onVisibleChange, true);
                    }

                    return {isOpen: true};
                });
            }, this.props.mouseEnterDelay);
        }
    };

    handleTargetMouseLeave: React.MouseEventHandler<Element> = (e) => {
        this.isMouseOnTarget = false;
        const {mouseLeaveDelay} = this.props as PropsWithDefault;

        if (this.timer) {
            clearTimeout(this.timer);
        }

        if (this.isAnyHoverInteraction) {
            // setTimeout required to move the mouse from the target to the popup window without closing it.
            this.timer = window.setTimeout(() => {
                this.setState((prevState) => {
                    if (prevState.isControlledMode) {
                        return null;
                    }

                    return {isOpen: false};
                });

                safeInvoke(this.props.onClose, e);
                safeInvoke(this.props.onVisibleChange, false);
            }, mouseLeaveDelay);
        }
    };

    handlePopperMouseEnter: React.MouseEventHandler<Element> = () => {
        const {trigger} = this.props as PropsWithDefault;

        this.isMouseOnPopper = true;

        if (trigger === TRIGGER.HOVER) {
            if (this.timer) {
                clearTimeout(this.timer);
            }

            this.setState((prevState) => {
                if (prevState.isControlledMode) {
                    return null;
                }

                return {isOpen: true};
            });
        }
    };

    handlePopperMouseLeave: React.MouseEventHandler<Element> = (e) => {
        this.isMouseOnPopper = false;

        if (this.isAnyHoverInteraction) {
            // setTimeout required to move the mouse from the popup window to the target without closing it.
            setTimeout(() => {
                if (!this.isMouseOnTarget) {
                    this.setState((prevState) => {
                        if (prevState.isControlledMode) {
                            return null;
                        }

                        return {isOpen: false};
                    });

                    safeInvoke(this.props.onClose, e);
                    safeInvoke(this.props.onVisibleChange, false);
                }
            }, this.props.mouseLeaveDelay);
        }
    };

    handleClose: OverlayProps['onClose'] = (e) => {
        const {trigger} = this.props as PropsWithDefault;

        const isTargetOrItsChilds = this.targetElement === e.target ||
            (this.targetElement && this.targetElement.contains(e.target as Element));

        const isClickInteraction = trigger === TRIGGER.CLICK || trigger === TRIGGER.CLICK_TARGET_ONCE || trigger === TRIGGER.CONTEXT_MENU;

        if (isClickInteraction && !isTargetOrItsChilds) {
            this.setState((prevState) => {
                if (prevState.isControlledMode) {
                    return null;
                }

                return {isOpen: false};
            });

            safeInvoke(this.props.onClose, e);
            safeInvoke(this.props.onVisibleChange, false);
        }
    };

    composeClickHandlers = memo(composeEventHandlers);

    composeMouseEnterHandlers = memo(composeEventHandlers);

    composeMouseLeaveHandlers = memo(composeEventHandlers);

    override componentDidUpdate (prevProps: IProps, prevState: IState) {
        // Properties that change the modifiers, and therefore need to
        // force remount Popper by changing popperKey
        if (
            prevProps.modifiers !== this.props.modifiers ||
            prevProps.boundaries !== this.props.boundaries ||
            prevProps.canFlip !== this.props.canFlip ||
            prevProps.hasArrow !== this.props.hasArrow
        ) {
            this.setState({popperKey: uniqueId()});
        }

        if (prevState.popperKey !== this.state.popperKey) {
            delete poppers[prevState.popperKey];
        }

        // When you change the margins, the size of the pop-up window changes, and you need to recalculate its position
        // on the purpose. To do this, you must forcibly update the popper instance.js using forceUpdate()
        if (prevProps.hasPaddings !== this.props.hasPaddings && this.scheduleUpdate) {
            this.scheduleUpdate();
        }
    }

    override componentWillUnmount (): void {
        delete poppers[this.state.popperKey];

        if (this.timer) {
            clearTimeout(this.timer);
        }
    }

    hide = () => {
        this.setState({isOpen: false});
    };

    override render () {
        const {isOpen, popperKey} = this.state;
        const {placement, hasBackdrop, overlayId} = this.props as PropsWithDefault;
        const modifiers = this.getModifiers();

        return (
            <Manager>
                <Reference innerRef={this.innerRefs.target}>{this.renderTarget}</Reference>
                <Overlay
                    id={overlayId}
                    isOpen={isOpen}
                    hasBackdrop={hasBackdrop}
                    shouldCloseOnClickOutside={true}
                    onClose={this.handleClose}
                >
                    <Popper key={popperKey} placement={placement} modifiers={modifiers}>
                        {this.renderPopper}
                    </Popper>
                </Overlay>
            </Manager>
        );
    }

    renderTarget = (referenceProps: ReferenceChildrenProps) => {
        const {target} = this.props as PropsWithDefault;

        if (typeof target === 'function') {
            const props = {
                forwardedRef: referenceProps.ref,
                onClick: this.handleTargetClick,
                onMouseEnter: this.handleTargetMouseEnter,
                onMouseLeave: this.handleTargetMouseLeave
            };

            return target(props);
        }

        const commonProps = {
            onClick: this.composeClickHandlers(target.props.onClick, this.handleTargetClick),
            onMouseEnter: this.composeMouseEnterHandlers(target.props.onMouseEnter, this.handleTargetMouseEnter),
            onMouseLeave: this.composeMouseLeaveHandlers(target.props.onMouseLeave, this.handleTargetMouseLeave)
        };

        const props = typeof target.type === 'function' ? {
            ...commonProps,
            forwardedRef: referenceProps.ref
            } : {
            ...commonProps,
            ref: referenceProps.ref
        };

        return React.cloneElement(target, props);
    };

    renderPopper = (popperProps: PopperChildrenProps) => {
        this.scheduleUpdate = popperProps.forceUpdate;
        const {hasPaddings, theme, size} = this.props as PropsWithDefault;
        const placement = (popperProps.placement as PLACEMENT | undefined) || this.props.placement;

        let placementClassName;
        let sizeClassName;

        /* istanbul ignore next line */
        if (placement) {
            const placementThemeKey = getPlacementThemeKey(placement);

            placementClassName = styles[placementThemeKey];
        }

        if (size) {
            const sizeThemeKey = getSizeThemeKey<SizeClassNames>('', size);

            sizeClassName = styles[sizeThemeKey];
        }

        const className = joinClassNames(
            styles.popover,
            placementClassName,
            sizeClassName,
            [styles.popoverHasPaddings, hasPaddings],
            [styles.lightTheme, theme === POPOVER_THEME.LIGHT],
            [styles.darkTheme, theme === POPOVER_THEME.DARK],
            [styles.transparentTheme, theme === POPOVER_THEME.TRANSPARENT]
        );

        return (
            <div
                ref={popperProps.ref}
                style={{...popperProps.style}}
                className={className}
                onMouseEnter={this.handlePopperMouseEnter}
                onMouseLeave={this.handlePopperMouseLeave}
            >
                {this.renderBlock()}
                {this.renderArrow(popperProps)}
            </div>
        );
    };

    renderBlock () {
        const className = joinClassNames(
            styles.block,
            [styles.hasShadow, Boolean(this.props.hasShadow)],
        );

        const htmlProps = {
            ...filterProps<IProps, React.HTMLAttributes<HTMLDivElement>>(this.props, Popover.excludingProps),
            className
        };

        return <div {...htmlProps} />;
    }

    renderArrow (popperProps: PopperChildrenProps) {
        const {hasArrow} = this.props as PropsWithDefault;
        const arrowContainerStyle = popperProps.arrowProps.style;

        return hasArrow ? (
            <div ref={popperProps.arrowProps.ref} style={arrowContainerStyle} className={styles.arrowContainer}>
                <div className={styles.arrowTransform}>
                    <div className={styles.arrow} />
                </div>
            </div>
        ) : null;
    }

    /**
     * Get modifiers
     *
     * Merge the default modifiers the modifiers of the properties.
     */
    getModifiers (): Array<Partial<Modifier<any, any>>> {
        const {
            modifiers,
            hasArrow,
            canFlip
        } = this.props as PropsWithDefault;

        const {popperKey} = this.state;

        return [
            {
                name: 'preventOverflow',
                enabled: true,
                options: {
                    altBoundary: true,
                    padding: 4,
                    boundary: POPOVER_BOUNDARIES.VIEWPORT
                }
            },
            {
                name: 'arrow',
                enabled: hasArrow,
                options: {
                    padding: 8
                }
            },
            {
                name: 'flip',
                enabled: canFlip
            },
            {
                name: 'offset',
                options: {
                    offset: hasArrow ? [0, 8] : [0, 2]
                }
            },
            ...modifiers,
            {
                name: 'updateChildPoppers',
                enabled: true,
                phase: 'main',
                fn: modifierFn.createUpdateChildPoppersModifier(poppers, popperKey)
            },
            {
                name: 'hideWhenReferenceHidden',
                enabled: true,
                phase: 'write',
                fn: modifierFn.hideWhenReferenceHidden
            }
        ];
    }
}

