import React, {
    forwardRef,
    ForwardedRef,
    ReactNode,
    useState,
    useMemo,
    useEffect,
    useRef,
} from 'react';
import { useCombobox, useMultipleSelection } from 'downshift';
import PopperJS from '@popperjs/core';
import { usePopper } from 'react-popper';
import computeScrollIntoView from 'compute-scroll-into-view';

import { styled } from '../../stitches.config';

import {
    useValue,
    useResizeObserver,
    useIntersectionObserver,
} from '../../hooks';
import {
    BaseFormInputStyles,
    BaseFormInput,
    BaseFormInputProps,
    BaseList,
    BaseListOption,
    BaseListEmptyOption,
    BaseIcon,
} from '../internal';

import { Icon } from '../Icon';
import { Portal } from '../Portal';
import { Pill } from '../Pill';
import { Scroll } from '../Scroll';

import { getSearch as DEFAULT_GET_SEARCH } from '@/utility';
import { getKey as DEFAULT_GET_KEY } from '@/utility';
import { getDisplay as DEFAULT_GET_DISPLAY } from '@/utility';

const StyledFormInput = styled(BaseFormInput, BaseFormInputStyles, {
    position: 'relative',
    variants: {
        size: {
            sm: {
                height: 'auto',
                padding: '0 $1',
                minHeight: '$space$6',
            },
            md: {
                height: 'auto',
                padding: '$1 $2',
                minHeight: '$space$8',
            },
            lg: {
                height: 'auto',
                padding: '$2 $3',
                minHeight: '$space$10',
            },
        },
    },
});

const StyledContainer = styled('div', {
    display: 'flex',
    alignItems: 'center',
    zIndex: '0',
    position: 'relative',
    cursor: 'pointer',
    '&[data-inactive]': {
        curor: 'default',
    },
});

const StyledValueContainer = styled('div', {
    flex: '1',
    display: 'flex',
    alignItems: 'center',
    height: '$space$full',
    outline: 'none',
    zIndex: '1',
    lineHeight: '1rem',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    '&[data-multiple]': {
        flexWrap: 'wrap',
        overflow: 'visible',
        rowGap: '$2',
    },
});

const StyledPlaceholder = styled('div', {
    color: `$grey-3`,
    whiteSpace: 'nowrap',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
});

const StyledText = styled('div', {
    whiteSpace: 'nowrap',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
});

const StyledInput = styled('input', {
    flex: '1',
    backgroundColor: 'transparent',
    outline: 'none',
    cursor: 'pointer',
    width: '100%',
    minWidth: '1px',
    '&[data-inactive]': {
        flex: '0',
    },
});

const StyledPill = styled(Pill, {
    height: `calc($space$6 - 2 * $borderWidths$default)`,
    margin: '0 $1',
    '&:focus': {
        outline: '1px solid $primary-1',
        outlineOffset: '0',
    },
});

const StyledScroll = styled(Scroll, {
    height: '$space$auto',
    maxHeight: '$space$48',
    zIndex: '30',
    borderWidth: '$default',
    borderRadius: '$default',
    borderColor: '$grey-4',
    boxShadow: '$default',
    '&[data-scroll]': {
        height: '$space$full',
    },
    '&[data-closed]': {
        display: 'none',
    },
});

const StyledList = styled(BaseList, {
    display: 'block',
    backgroundColor: `$base`,
    height: '$space$auto',
    border: 'none',
});

const StyledListOption = styled(BaseListOption, {
    '&[data-selected]': {
        color: '$primary-1',
    },
});

const StyledListText = styled('div', {
    whiteSpace: 'nowrap',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    lineHeight: '1rem',
});

const StyledListEmptyOption = styled(BaseListEmptyOption);

// TODO: Need to figure out why @mdi/js isn't tree shaking
const mdiMenuDown = 'M7,10L12,15L17,10H7Z';
const mdiMenuUp = 'M7,15L12,10L17,15H7Z';

interface BaseSelectProps<O, V> extends BaseFormInputProps<V> {
    /** Options to render in the list of the Select */
    options: O[];

    /** Placeholder to show when there is no value */
    placeholder?: string;

    /** Props to pass to the input */
    inputProps?: React.InputHTMLAttributes<HTMLInputElement>;

    /** allows for selection of multiple values */
    multiple?: boolean;

    /** Search term */
    search?: string;

    /** Filter the options on search */
    filter?: boolean;

    /** Callback that is triggered when search is updated */
    onSearch?: (value: string) => void;

    /** Callback that is triggered when the end of the list is reached */
    onLoad?: () => void;

    /** Callback that is triggered on search. This returns true if the option should be included. */
    getSearch?: (search: string, option: O) => boolean;

    /** Callback that is triggered to get the unique key of an option */
    getKey?: (option: O) => string;

    /** Callback that is triggered to get the display of an option */
    getDisplay?: (option: O) => ReactNode;

    /** Container to append the list to */
    container?: HTMLElement | null;
}

type SingleSelect<O> = BaseSelectProps<O, O | null>;

type MultipleSelect<O> = BaseSelectProps<O, O[]>;

export type SelectProps<O, multiple extends boolean> = multiple extends true
    ? MultipleSelect<O>
    : SingleSelect<O>;

const _Select = <O, multiple extends boolean>(
    props: SelectProps<O, multiple>,
    ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
    const {
        id,
        value,
        defaultValue,
        onChange = () => null,
        options,
        placeholder,
        disabled = false,
        valid,
        size,
        inputProps = {},
        multiple = false,
        search = '',
        filter = true,
        onSearch = () => null,
        onLoad = () => null,
        getSearch = DEFAULT_GET_SEARCH,
        getKey = DEFAULT_GET_KEY,
        getDisplay = DEFAULT_GET_DISPLAY,
        container,
        ...otherProps
    } = props;

    // store the input in a ref
    const inputRef = useRef<HTMLInputElement | null>(null);
    const listRef = useRef<HTMLElement | null>(null);
    const lastRef = useRef<HTMLElement | null>(null);

    const [scroll, setScroll] = useState<boolean>(false);

    // manage she searchValue
    const [searchValue, setSearchValue] = useState<string>(search);

    // update the search value whenever it changes
    useEffect(() => {
        setSearchValue(search);
    }, [search]);

    // manage value for the-select
    const [internalValue, setInternalValue] = useValue<
        SingleSelect<O>['value'] | MultipleSelect<O>['value']
    >({
        initialValue: multiple ? [] : null,
        value: value,
        defaultValue: defaultValue,
        onChange: (value) => {
            if (multiple) {
                (onChange as NonNullable<MultipleSelect<O>['onChange']>)(
                    value as NonNullable<MultipleSelect<O>['value']>,
                );
            } else {
                (onChange as NonNullable<SingleSelect<O>['onChange']>)(
                    value as NonNullable<SingleSelect<O>['value']>,
                );
            }
        },
    });

    // store the selected options in an array
    const selectedOptions: O[] = useMemo(() => {
        if (multiple) {
            return internalValue as NonNullable<MultipleSelect<O>['value']>;
        } else {
            if (internalValue === null) {
                return [];
            }

            return [internalValue as NonNullable<SingleSelect<O>['value']>];
        }
    }, [multiple, internalValue]);

    // Stores keys for selected options
    const selectedKeys = useMemo(() => {
        // this assumes that there are no duplicate keys
        // we will replace with the first one

        const selected: {
            [key: string]: number;
        } = {};
        for (let optIdx = selectedOptions.length - 1; optIdx >= 0; optIdx--) {
            const k = getKey(selectedOptions[optIdx]);

            selected[k] = optIdx;
        }

        return selected;
    }, [selectedOptions, getKey]);

    // filtered options that are returned after search (or don't filter )
    const renderedOptions: O[] = useMemo(() => {
        if (!filter || !searchValue) {
            return options;
        }

        // make it case insensitive
        const cleaned = searchValue.toLowerCase();

        return options.filter((o) => getSearch(cleaned, o));
    }, [searchValue, filter, options, getSearch]);

    // create the downshift instance for accessibility
    const { getDropdownProps, getSelectedItemProps } = useMultipleSelection({
        selectedItems: selectedOptions,
        onStateChange({ selectedItems, type }) {
            switch (type) {
                case useMultipleSelection.stateChangeTypes
                    .SelectedItemKeyDownBackspace:
                case useMultipleSelection.stateChangeTypes
                    .SelectedItemKeyDownDelete:
                case useMultipleSelection.stateChangeTypes
                    .DropdownKeyDownBackspace:
                case useMultipleSelection.stateChangeTypes
                    .FunctionRemoveSelectedItem:
                    setOptions(selectedItems || []);
                    break;
                default:
                    break;
            }
        },
    });

    const {
        isOpen,
        getMenuProps,
        getToggleButtonProps,
        getInputProps,
        getComboboxProps,
        highlightedIndex,
        getItemProps,
    } = useCombobox<O>({
        id: id,
        labelId: otherProps['aria-labelledby'],
        items: renderedOptions,
        itemToString: (item) => {
            return item ? getKey(item) : '';
        },
        selectedItem: null,
        onSelectedItemChange: ({ selectedItem }) => {
            handleChange(selectedItem === undefined ? null : selectedItem);
        },
        stateReducer: (state, actionAndChanges) => {
            const { changes, type } = actionAndChanges;

            switch (type) {
                case useCombobox.stateChangeTypes.InputKeyDownArrowDown: {
                    const updated = {
                        ...changes,
                    };

                    if (!state.isOpen) {
                        // don't change the index if it isn't open
                        updated.highlightedIndex = state.highlightedIndex;
                    }

                    return updated;
                }
                case useCombobox.stateChangeTypes.InputKeyDownEnter:
                case useCombobox.stateChangeTypes.ItemClick: {
                    const updated = {
                        ...changes,
                    };

                    // keep open if multiple
                    if (multiple) {
                        updated.isOpen = true;
                    }

                    return {
                        ...updated,
                        highlightedIndex: state.highlightedIndex, // don't move the highlight.
                        inputValue: '', // clear the inputValue
                    };
                }
                case useCombobox.stateChangeTypes
                    .ControlledPropUpdatedSelectedItem:
                case useCombobox.stateChangeTypes.InputBlur: {
                    return {
                        ...changes,
                        inputValue: '', // clear the inputValue
                    };
                }
                default:
                    return changes;
            }
        },
        inputValue: searchValue,
        onInputValueChange: ({ inputValue }) => {
            const updated = inputValue || '';

            // set the internal search value to update the text
            setSearchValue(updated);

            // call the callback
            if (onSearch) {
                onSearch(updated);
            }
        },
        circularNavigation: false,
        scrollIntoView: (node) => {
            if (!node) {
                return;
            }

            const actions = computeScrollIntoView(node, {
                boundary: popperElement,
                block: 'nearest',
                scrollMode: 'if-needed',
            });
            actions.forEach(({ el, top, left }) => {
                el.scrollTop = top;
                el.scrollLeft = left;
            });
        },
    });

    // create the elements (usePopper takes in the DOM Node);
    const [referenceElement, setReferenceElement] =
        useState<HTMLDivElement | null>(null);
    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
        null,
    );

    // create the popper + modifiers
    const modifiers = useMemo(
        () => [
            {
                name: 'flip',
                options: {
                    fallbackPlacements: ['top'],
                },
            },
            {
                name: 'offset',
                options: { offset: [0, 4] },
            },
            {
                name: 'sameWidth',
                enabled: true,
                phase: 'beforeWrite',
                requires: ['computeStyles'],
                fn: ({
                    state,
                }: PopperJS.ModifierArguments<Record<string, unknown>>) => {
                    state.styles.popper.width = `${state.rects.reference.width}px`;
                },
                effect: ({
                    state,
                }: PopperJS.ModifierArguments<Record<string, unknown>>) => {
                    state.elements.popper.style.width = `${
                        (state.elements.reference as HTMLElement).offsetWidth
                    }px`;
                },
            },
            {
                name: 'eventListeners',
                options: {
                    scroll: isOpen,
                    resize: isOpen,
                },
            },
        ],
        [isOpen],
    );

    const { styles, attributes, forceUpdate } = usePopper(
        referenceElement,
        popperElement,
        {
            strategy: 'absolute',
            placement: 'bottom-start',
            modifiers: modifiers as PopperJS.Modifier<unknown, unknown>[],
        },
    );

    // resize
    useResizeObserver(listRef, (rect) => {
        setScroll(
            popperElement?.offsetHeight !== undefined
                ? rect.height >= popperElement?.offsetHeight
                : false,
        );
    });

    // infinite load
    useIntersectionObserver(lastRef, () => {
        // only load if it is open and the list is bigger than the scroll area
        if (
            !disabled &&
            isOpen &&
            listRef.current &&
            popperElement &&
            listRef.current.offsetHeight > popperElement.offsetHeight
        ) {
            onLoad();
        }
    });

    // update the position on open or when the options change
    useEffect(() => {
        if (isOpen && forceUpdate) {
            forceUpdate();
        }
    }, [isOpen, forceUpdate, selectedOptions]);

    /**
     * When an option is selected, handle the change (adding or removing as necessary)
     * @param option - option that was selected
     */
    const handleChange = (option: O | null) => {
        if (multiple) {
            if (!option) {
                return;
            }

            const key = getKey(option);

            // get the index of the key (if it exists)
            const optionIdx = Object.prototype.hasOwnProperty.call(
                selectedKeys,
                key,
            )
                ? selectedKeys[key]
                : -1;

            if (optionIdx === -1) {
                addOption(option);
            } else {
                removeOption(optionIdx);
            }
        } else {
            setInternalValue(option);
        }
    };

    /**
     * Set the options
     * @param options - options to set
     */
    const setOptions = (options: O[]) => {
        if (multiple) {
            setInternalValue(options);
        } else if (!multiple && options.length > 0) {
            setInternalValue(options[0]);
        } else if (!multiple && options.length === 0) {
            setInternalValue(null);
        }
    };

    /**
     * Add an option to the selected options
     * @param option - option to add
     */
    const addOption = (option: O) => {
        if (!multiple) {
            return;
        }

        const updated = [...selectedOptions, option];

        setInternalValue(updated);
    };

    /**
     * Remove an option from the selected options
     * @param optionIdx - Index of option to remove
     */
    const removeOption = (optionIdx: number) => {
        if (!multiple) {
            return;
        }

        const updated = selectedOptions.filter((o, idx) => {
            return idx !== optionIdx;
        });

        setInternalValue(updated);
    };

    return (
        <>
            <StyledFormInput
                focusRef={inputRef}
                valid={valid}
                size={size}
                disabled={disabled}
                {...getComboboxProps({
                    disabled: disabled,
                    ref: (node) => {
                        setReferenceElement(node);
                        if (typeof ref === 'function') {
                            ref(node);
                        } else if (ref) {
                            ref.current = node;
                        }
                    },
                    ...otherProps,
                })}
            >
                <StyledContainer
                    data-inactive={disabled}
                    {...getToggleButtonProps({
                        disabled: disabled,
                    })}
                >
                    <StyledValueContainer
                        data-multiple={multiple || undefined}
                        onKeyDown={(event) => {
                            // stop the propagation if its open and the user hits escaple. If we do not, modals will close.
                            if (isOpen && event.key === 'Escape') {
                                event.stopPropagation();
                            }
                        }}
                    >
                        {multiple && selectedOptions.length > 0 ? (
                            <>
                                {selectedOptions.map((option, index) => {
                                    const key = getKey(option);
                                    return (
                                        <StyledPill
                                            key={`${key}${index}`}
                                            onClose={(event) => {
                                                // prevent the change of focus + other event listeners
                                                event.preventDefault();
                                                event.stopPropagation();

                                                removeOption(index);
                                            }}
                                            {...getSelectedItemProps({
                                                selectedItem: option,
                                                index,
                                            })}
                                            closeProps={{
                                                tabIndex: -1,
                                            }}
                                        >
                                            {getDisplay(option)}
                                        </StyledPill>
                                    );
                                })}
                            </>
                        ) : (
                            <></>
                        )}

                        <StyledInput
                            data-inactive={!isOpen || undefined}
                            {...getInputProps(
                                getDropdownProps({
                                    preventKeyAction: isOpen,
                                    id: id,
                                    ref: (node: HTMLInputElement) => {
                                        if (inputRef) {
                                            inputRef.current = node;
                                        }
                                    },
                                    disabled: disabled,
                                    ...inputProps,
                                }),
                            )}
                        />

                        {!isOpen && !multiple && selectedOptions.length > 0 ? (
                            <StyledText>
                                {getDisplay(selectedOptions[0])}
                            </StyledText>
                        ) : (
                            <></>
                        )}

                        {!isOpen && selectedOptions.length === 0 ? (
                            <StyledPlaceholder>
                                {placeholder || <>&nbsp;</>}
                            </StyledPlaceholder>
                        ) : (
                            <></>
                        )}
                    </StyledValueContainer>
                    <BaseIcon
                        valid={valid}
                        data-expanded={isOpen || undefined}
                        aria-hidden={true}
                    >
                        <Icon path={isOpen ? mdiMenuUp : mdiMenuDown} />
                    </BaseIcon>
                </StyledContainer>
            </StyledFormInput>
            <Portal container={container}>
                <StyledScroll
                    ref={setPopperElement}
                    horizontal={false}
                    data-scroll={scroll || undefined}
                    data-closed={!isOpen || undefined}
                    style={{ ...styles.popper }}
                    {...attributes.poppper}
                >
                    <StyledList
                        as={'ul'}
                        {...getMenuProps(
                            {
                                ref: (node) => {
                                    listRef.current = node;
                                },
                            },
                            { suppressRefError: true },
                        )}
                    >
                        {isOpen && (
                            <>
                                {renderedOptions.map((option, index) => {
                                    const key = getKey(option);
                                    const selected =
                                        Object.prototype.hasOwnProperty.call(
                                            selectedKeys,
                                            key,
                                        );

                                    return (
                                        <StyledListOption
                                            key={`${key}${index}`}
                                            data-selected={
                                                selected || undefined
                                            }
                                            data-focused={
                                                highlightedIndex === index ||
                                                undefined
                                            }
                                            {...getItemProps({
                                                item: option,
                                                index: index,
                                                title: key,
                                            })}
                                        >
                                            <StyledListText>
                                                {getDisplay(option)}
                                            </StyledListText>
                                        </StyledListOption>
                                    );
                                })}
                                <span ref={lastRef}></span>
                            </>
                        )}

                        {isOpen && renderedOptions.length === 0 && (
                            <StyledListEmptyOption>
                                No Options
                            </StyledListEmptyOption>
                        )}
                    </StyledList>
                </StyledScroll>
            </Portal>
        </>
    );
};

export const Select = forwardRef(_Select) as <O, multiple extends boolean>(
    props: SelectProps<O, multiple> & {
        ref?: ForwardedRef<HTMLDivElement>;
    },
) => ReturnType<typeof _Select>;
