import React, {
  ReactElement,
  useRef,
  ReactNode,
  useMemo,
  useState,
  useEffect,
  useCallback,
  CSSProperties,
  FocusEvent,
} from 'react';
import { useMultipleSelection, useCombobox } from 'downshift';
import css from '../../utils/css';

import TagInput from '../TagInput';
import Menu from '../Menu';
import Icon from '../Icon';
import Typography from '../Typography';
import Divider from '../Divider';
import Dropdown from '../Dropdown';
import SuffixIcon from './SuffixIcon';
import {
  SelectWrapper,
  MenuWrapper,
  CategoryWrapper,
  HelpTextWrapper,
  CheckmarkWrapper,
} from './StyledSelect';
import {
  checkAtBottom,
  mapOptions,
  optionPredicate,
  filterGroupedOptions,
  getAccumulatedIndex,
} from './utils';
import { BaseOption, GroupedOption } from './types';
import { invokeWith, noop, pipe } from '../../fp/function';
import { fromUndefinedable, getOrElse, map } from '../../fp/Option';
import { map as arrayMap, flat } from '../../fp/Array';
import { useResizeObserver } from '../../utils/hooks';

import { CommonProps } from '../common';

export interface MultiSelectProps<T extends BaseOption>
  extends Omit<CommonProps, 'id'> {
  /**
   * Specify the [automated assistance](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) in filling out form field values by the browser.
   */
  autoComplete?: string;
  /**
   * Whether the select is disabled.
   */
  disabled?: boolean;
  /**
   * Id of <input> element.
   */
  id?: string;
  /**
   * Whether the input is invalid.
   */
  invalid?: boolean;
  /**
   * Loading state of Select, which will render a spinner at bottom of the option list.
   */
  loading?: boolean;
  /**
   * Name of <input> element, is used to refer to the form data for submission.
   */
  name?: string;
  /**
   * Content to render when filtering items returns zero results.
   */
  noResults?: ReactNode;
  /**
   * Blur event handler.
   */
  onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
  /**
   * onChange event handler.
   */
  onChange: (value: (string | number)[]) => void;
  /**
   * Callback to allow to create new option when option not found based on query (only when the callback is defined).
   */
  onCreateNewOption?: (optionText: string) => void;
  /**
   * Focus event handler.
   */
  onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
  /**
   * Callback invoked when the query string changes.
   */
  onQueryChange?: (query?: string) => void;
  /**
   * Handle scroll event when scrolling to the bottom of the option list.
   */
  onScrollListToBottom?: () => void;
  /**
   * Additonal inline style for option menu dropdown.
   */
  optionMenuStyle?: CSSProperties;
  /**
   * Customise option renderer.
   */
  optionRenderer?: (renderOpts: { index: number; option: T }) => ReactElement;
  /**
   * An array of (grouped) options to be selected.
   *
   * The generic parameter T should extend BaseOption:
   *
   * type BaseOption = {
   *   disabled?: boolean;
   *   helpText?: string;
   *   text: string;
   *   value: string | number;
   * };
   *
   * type GroupedOption<T> = {
   *   category: string;
   *   options: T[];
   * };
   */
  options: Array<GroupedOption<T>> | Array<T>;
  /**
   * Placeholder text in the absence of any value.
   */
  placeholder?: string;
  /**
   * Name of Icon or an Icon element to render on the left side of the input.
   */
  prefix?: string | ReactElement;
  /**
   * Query string to filter options. This value is controlled: its state if defined must be managed externally.
   */
  query?: string;
  /**
   * Customise selected option renderer.
   */
  selectedOptionRenderer?: (renderOpts: { option: T }) => string | ReactElement;
  /**
   * The size of the input box.
   */
  size?: 'small' | 'medium' | 'large';
  /**
   * Current selected value.
   */
  value?: (string | number)[];
}

const MultiSelect = <T extends BaseOption>({
  options,
  value,
  onBlur,
  onChange,
  onFocus,
  optionRenderer,
  selectedOptionRenderer,
  disabled = false,
  size = 'medium',
  invalid,
  placeholder,
  prefix,
  onScrollListToBottom,
  loading,
  query,
  onQueryChange,
  noResults,
  name,
  onCreateNewOption,
  id,
  className,
  style,
  sx = {},
  autoComplete,
  optionMenuStyle,
  'data-test-id': dataTestId,
}: MultiSelectProps<T>): ReactElement => {
  const [newOption, setNewOption] = useState<BaseOption>({
    value: '',
    text: '',
  });
  const [wrapperElement, setWrapperElement] = useState<HTMLDivElement | null>(
    null
  );
  const [optionMenuWidth, setOptionMenuWidth] = useState<number>();
  const mappedOptions = useMemo(() => {
    return filterGroupedOptions(optionPredicate(query), mapOptions<T>(options));
  }, [options, query]);

  const flatMappedOptions = useMemo(
    () =>
      pipe(
        mappedOptions,
        arrayMap(opt => opt.options),
        flat
      ),
    [mappedOptions]
  );

  const flatOptions = useMemo(
    () =>
      pipe(
        options,
        opts => mapOptions<T>(opts),
        arrayMap(opt => opt.options),
        flat
      ),
    [options]
  );

  const hasResults = flatMappedOptions.length > 0;

  const selectedItemsFromValue =
    value !== undefined
      ? value.flatMap(optionVal => {
          const foundItem = flatOptions.find(opt => opt.value === optionVal);
          return foundItem !== undefined ? [foundItem] : [];
        })
      : [];

  const { getDropdownProps, selectedItems } = useMultipleSelection({
    itemToString: (item): string => (item !== null ? item.text : ''),
    selectedItems: selectedItemsFromValue,
  });

  useEffect(() => {
    if (hasResults === false && query !== undefined) {
      setNewOption({ value: query, text: query });
    }
  }, [hasResults, query, setNewOption]);

  const resizeCallback = useCallback(
    ({ width }) => {
      setOptionMenuWidth(width);
    },
    [setOptionMenuWidth]
  );

  useResizeObserver(resizeCallback, wrapperElement);

  const {
    isOpen,
    getComboboxProps,
    getToggleButtonProps,
    getMenuProps,
    getInputProps,
    getItemProps,
    highlightedIndex,
    toggleMenu,
  } = useCombobox({
    items: hasResults === true ? flatMappedOptions : [newOption],
    stateReducer: (_state, actionAndChanges) => {
      const { changes, type } = actionAndChanges;
      const { selectedItem } = changes;
      const callOnChange = (): void => {
        if (selectedItem !== undefined && selectedItem !== null) {
          if (value?.includes(selectedItem.value) === true) {
            // Remove item
            onChange(
              selectedItems
                .filter(item => item.value !== selectedItem.value)
                .map(item => item.value)
            );
          } else if (hasResults === false && onCreateNewOption !== undefined) {
            onCreateNewOption(selectedItem.text);
          } else {
            // Add item
            onChange([
              ...selectedItems.map(item => item.value),
              selectedItem.value,
            ]);
          }
        }
        if (
          onQueryChange !== undefined &&
          query !== undefined &&
          query !== ''
        ) {
          onQueryChange(undefined);
        }
      };

      switch (type) {
        case useCombobox.stateChangeTypes.InputKeyDownEnter:
          callOnChange();
          return {
            ...changes,
            isOpen: true,
          };
        case useCombobox.stateChangeTypes.ItemClick:
          callOnChange();
          return changes;
        default:
          return changes;
      }
    },
  });

  const onItemRemove = ({
    value: removedItemValue,
  }: {
    value: string | number;
  }): void => {
    onChange(
      selectedItems
        .filter(item => item.value !== removedItemValue)
        .map(item => item.value)
    );
    toggleMenu();
  };

  const menuRef = useRef(null);
  const onScrollToBottom = (): void => {
    const isAtBottom = checkAtBottom(menuRef.current);

    if (
      isAtBottom === true &&
      loading !== true &&
      onScrollListToBottom !== undefined
    ) {
      onScrollListToBottom();
    }
  };

  const readonly = onQueryChange === undefined;
  const tagInputValue = query !== undefined ? query : '';
  const tagInputOnChange = React.useCallback(
    e => onQueryChange !== undefined && onQueryChange(e.target.value),
    [onQueryChange]
  );

  const tags = React.useMemo(
    () =>
      selectedItems.map(item => ({
        value: item.value,
        text: pipe(
          selectedOptionRenderer,
          fromUndefinedable,
          map(invokeWith({ option: item })),
          getOrElse(() => item.text)
        ),
      })),
    [selectedItems, selectedOptionRenderer]
  );

  useEffect(() => {
    if (isOpen === false && query !== undefined) toggleMenu();
  }, [query]);

  const selectInput = (
    <TagInput
      prefix={prefix}
      suffix={<SuffixIcon loading={loading} open={isOpen} />}
      size={size}
      placeholder={placeholder}
      onRemove={onItemRemove}
      tags={tags}
      {...getInputProps(
        getDropdownProps({
          value: undefined, // not yet support input value
          disabled,
        })
      )}
      invalid={invalid}
      name={name}
      readonly={readonly}
      value={tagInputValue}
      onChange={tagInputOnChange}
      id={id}
      autoComplete={autoComplete}
      onFocus={onFocus}
      onBlur={onBlur}
    />
  );

  const optionMenu = (
    <MenuWrapper
      ref={menuRef}
      onScroll={onScrollToBottom}
      style={{ width: optionMenuWidth, ...optionMenuStyle }}
    >
      <Menu {...getMenuProps()}>
        {mappedOptions.map((opt, catIndex) => {
          const accumulatedIndex = getAccumulatedIndex(mappedOptions, catIndex);
          return (
            <React.Fragment key={opt.category}>
              {opt.category !== '' && (
                <CategoryWrapper>
                  <Typography.Text
                    tagName="span"
                    fontSize={12}
                    fontWeight="semi-bold"
                    intent="subdued"
                  >
                    {opt.category}
                  </Typography.Text>
                </CategoryWrapper>
              )}
              {opt.options.map((item, index) => {
                const actualIndex = accumulatedIndex + index;
                const isActiveItem = value?.includes(item.value) ?? false;
                const helpTextElement = (
                  <HelpTextWrapper>
                    {item.helpText !== undefined && (
                      <Typography.Text
                        tagName="span"
                        intent={isActiveItem ? 'main' : 'subdued'}
                      >
                        {item.helpText}
                      </Typography.Text>
                    )}
                    <CheckmarkWrapper shown={isActiveItem}>
                      <Icon
                        intent={
                          item.disabled === true ? 'subdued-text' : 'primary'
                        }
                        icon="checkmark"
                      />
                    </CheckmarkWrapper>
                  </HelpTextWrapper>
                );

                return (
                  <Menu.Item
                    key={item.value}
                    text={
                      optionRenderer !== undefined
                        ? optionRenderer({ option: item, index })
                        : item.text
                    }
                    focused={actualIndex === highlightedIndex}
                    active={isActiveItem}
                    textElement={helpTextElement}
                    {...getItemProps({
                      item,
                      index: actualIndex,
                      disabled: item.disabled,
                    })}
                  />
                );
              })}
              {catIndex < mappedOptions.length - 1 && <Divider />}
            </React.Fragment>
          );
        })}
        {hasResults === false &&
          loading !== true &&
          onCreateNewOption !== undefined &&
          query !== undefined && (
            <Menu.Item
              icon="add"
              text={query}
              focused={highlightedIndex === 0}
              {...getItemProps({
                item: newOption,
                index: 0,
              })}
            />
          )}
      </Menu>
      {hasResults === false && onCreateNewOption === undefined && noResults}
    </MenuWrapper>
  );

  return (
    <SelectWrapper
      {...getComboboxProps({
        ...getToggleButtonProps({ disabled }),
        ref: setWrapperElement,
      })}
      className={className}
      style={{ ...style, ...css(sx) }}
      data-test-id={dataTestId}
    >
      <Dropdown
        target={selectInput}
        content={optionMenu}
        open={isOpen}
        onClose={noop}
        keepContentMounted
      />
    </SelectWrapper>
  );
};

export default MultiSelect;
