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

import { CommonProps } from '../../common';
import { IconName } from '../../Icon';
import { BaseOption, GroupedOption } from '../types';
import { SelectWrapper } from '../StyledSelect';
import { map as arrayMap, flat } from '../../../fp/Array';
import { mapOptions, optionPredicate, filterGroupedOptions } from '../utils';
import { pipe, noop } from '../../../fp/function';
import { useResizeObserver } from '../../../utils/hooks';
import Dropdown from '../../Dropdown';
import HiddenInput from './HiddenInput';
import OptionList from './OptionList';
import QueryInput from './QueryInput';

export interface SelectProps<T extends BaseOption> extends CommonProps {
  /**
   * 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;
  /**
   * Allow to clear value after selecting an item.
   */
  clearable?: boolean;
  /**
   * Whether the select is disabled.
   */
  disabled?: boolean;
  /**
   * 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?: ({
    option,
    index,
  }: {
    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?: IconName | 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?: ({ option }: { option: T }) => string | ReactElement;
  /**
   * The size of the input box.
   */
  size?: 'small' | 'medium' | 'large';
  /**
   * Current selected value.
   */
  value?: string | number;
}

const Select = <T extends BaseOption>({
  options,
  value,
  onBlur,
  onChange,
  onFocus,
  query,
  onQueryChange,
  optionRenderer,
  selectedOptionRenderer,
  noResults,
  disabled = false,
  size = 'medium',
  invalid,
  placeholder,
  prefix,
  onScrollListToBottom,
  loading,
  name,
  onCreateNewOption,
  id,
  className,
  style,
  sx = {},
  autoComplete,
  optionMenuStyle,
  clearable = false,
  'data-test-id': dataTestId,
}: SelectProps<T>): ReactElement => {
  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 hasResults = mappedOptions.length > 0;

  const selectedItem = useMemo(
    () => flatMappedOptions.find(item => item.value === value),
    [flatMappedOptions, value]
  );

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

  useResizeObserver(resizeCallback, wrapperElement);

  const newOption =
    query !== undefined
      ? { value: query, text: query }
      : { value: '', text: '' };

  const {
    getComboboxProps,
    getInputProps,
    getMenuProps,
    getItemProps,
    isOpen,
    highlightedIndex,
    getToggleButtonProps,
  } = useCombobox<BaseOption>({
    items: hasResults === true ? flatMappedOptions : [newOption],
    itemToString: (item): string => (item !== null ? item.text : ''),
    selectedItem: selectedItem ?? null,
    onSelectedItemChange: ({ selectedItem: newItem }): void => {
      if (newItem != null) {
        if (hasResults === false) {
          onCreateNewOption?.(newItem.text);
        } else {
          onChange(newItem.value);
        }
      }
      if (query !== undefined) {
        onQueryChange?.(undefined);
      }
    },
    onStateChange: state => {
      if (state.type === useCombobox.stateChangeTypes.InputBlur) {
        if (query !== undefined) {
          onQueryChange?.(undefined);
        }
      }
    },
  });

  const selectInput = (
    <QueryInput
      selectedItem={selectedItem}
      getInputProps={getInputProps}
      isOpen={isOpen}
      selectedOptionRenderer={selectedOptionRenderer}
      id={id}
      autoComplete={autoComplete}
      clearable={clearable}
      value={value}
      onChange={onChange}
      query={query}
      onQueryChange={onQueryChange}
      disabled={disabled}
      prefix={prefix}
      loading={loading}
      invalid={invalid}
      placeholder={placeholder}
      size={size}
      onFocus={onFocus}
      onBlur={onBlur}
    />
  );
  const optionMenu = (
    <OptionList<T>
      categories={mappedOptions}
      hasResults={hasResults}
      newOption={newOption}
      selectedItem={selectedItem}
      getItemProps={getItemProps}
      getMenuProps={getMenuProps}
      highlightedIndex={highlightedIndex}
      onScrollListToBottom={onScrollListToBottom}
      onCreateNewOption={onCreateNewOption}
      loading={loading}
      noResults={noResults}
      optionRenderer={optionRenderer}
      style={{ width: optionMenuWidth, ...optionMenuStyle }}
    />
  );

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

export default Select;
