import { Cross as CrossIcon } from '@transferwise/icons';
import { clsx } from 'clsx';
import { DebouncedFunc } from 'lodash';
import clamp from 'lodash.clamp';
import debounce from 'lodash.debounce';
import React, { Component, ReactNode } from 'react';
import { injectIntl, WrappedComponentProps } from 'react-intl';

import Chip from '../chips/Chip';
import {
  Size,
  Sentiment,
  SizeMedium,
  SizeLarge,
  addClickClassToDocumentOnIos,
  removeClickClassFromDocumentOnIos,
  stopPropagation,
} from '../common';
import { InlinePrompt, InlinePromptProps } from '../prompt';
import { withInputAttributes, WithInputAttributesProps } from '../inputs/contexts';

import TypeaheadInput from './typeaheadInput/TypeaheadInput';
import TypeaheadOption from './typeaheadOption/TypeaheadOption';
import messages from './Typeahead.messages';

const DEFAULT_MIN_QUERY_LENGTH = 3;
const SEARCH_DELAY = 200;

export type TypeaheadOption<T = string> = {
  label: string;
  note?: string;
  secondary?: string;
  value?: T;
  clearQueryOnSelect?: boolean;
  keepFocusOnSelect?: boolean;
};

export interface TypeaheadProps<T> extends Partial<WrappedComponentProps> {
  id: string;
  name: string;
  addon?: ReactNode;
  /**
   * @deprecated Use [`Field`](?path=/docs/forms-field--docs) component and its `message` and `sentiment` props instead.
   * @deprecated `error`, `info` and `success` are deprecated as alert types and will be soon removed.
   */
  alert?: {
    message: InlinePromptProps['children'];
    type?:
      | InlinePromptProps['sentiment']
      | `${Sentiment.ERROR}`
      | `${Sentiment.INFO}`
      | `${Sentiment.SUCCESS}`;
  };
  /** @default false */
  allowNew?: boolean;
  /** @default true */
  autoFillOnBlur?: boolean;
  /** @default false */
  autoFocus?: boolean;
  /** @default [] */
  chipSeparators?: readonly string[];
  /** @default true */
  clearable?: boolean;
  footer?: ReactNode;
  /** @default [] */
  initialValue?: readonly TypeaheadOption<T>[];
  /** @default 'new-password' */
  inputAutoComplete?: string;
  maxHeight?: number;
  /** @default 3 */
  minQueryLength?: number;
  placeholder?: string;
  /** @default false */
  multiple?: boolean;
  options: readonly TypeaheadOption<T>[];
  /** @default 200 */
  searchDelay?: number;
  /** @default true */
  showSuggestions?: boolean;
  /** @default true */
  showNewEntry?: boolean;
  /** @default 'md' */
  size?: SizeMedium | SizeLarge;
  onBlur?: () => void;
  onChange: (options: TypeaheadOption<T>[]) => void;
  onFocus?: () => void;
  onInputChange?: (query: string) => void;
  onSearch?: (query: string) => void;
  validateChip?: (chip: TypeaheadOption<T>) => boolean;
}

type TypeaheadPropsWithInputAttributes<T> = TypeaheadProps<T> &
  Partial<WithInputAttributesProps> &
  WrappedComponentProps;

type TypeaheadState<T> = {
  selected: readonly TypeaheadOption<T>[];
  keyboardFocusedOptionIndex: number | null;
  errorState: boolean;
  query: string;
  optionsShown: boolean;
  isFocused: boolean;
};

class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, TypeaheadState<T>> {
  declare props: TypeaheadPropsWithInputAttributes<T> &
    Required<Pick<TypeaheadPropsWithInputAttributes<T>, keyof typeof Typeahead.defaultProps>>;

  static defaultProps = {
    allowNew: false,
    autoFillOnBlur: true,
    autoFocus: false,
    chipSeparators: [],
    clearable: true,
    initialValue: [],
    inputAutoComplete: 'new-password',
    minQueryLength: DEFAULT_MIN_QUERY_LENGTH,
    multiple: false,
    searchDelay: SEARCH_DELAY,
    showSuggestions: true,
    showNewEntry: true,
    size: Size.MEDIUM,
    validateChip: () => true,
  } satisfies Partial<TypeaheadProps<unknown>>;
  optionRefs: (React.RefObject<HTMLLIElement> | null)[];

  constructor(props: TypeaheadPropsWithInputAttributes<T>) {
    super(props);
    const { searchDelay, initialValue, multiple } = this.props;
    this.handleSearchDebounced = debounce(this.handleSearch, searchDelay);
    const initialQuery = !multiple && initialValue.length > 0 ? initialValue[0].label : '';
    this.state = {
      selected: initialValue,
      errorState: false,
      query: initialQuery,
      keyboardFocusedOptionIndex: null,
      optionsShown: false,
      isFocused: false,
    };
    this.optionRefs = [] as (React.RefObject<HTMLLIElement> | null)[];
  }

  handleSearchDebounced: DebouncedFunc<Typeahead<T>['handleSearch']>;

  UNSAFE_componentWillReceiveProps(nextProps: TypeaheadPropsWithInputAttributes<T>) {
    if (nextProps.multiple !== this.props.multiple) {
      this.setState((previousState) => {
        const { selected } = previousState;
        if (!nextProps.multiple && selected.length > 0) {
          return {
            query: selected[0].label,
            selected: [selected[0]],
          };
        }
        return {
          selected: previousState.selected,
          query: '',
        };
      });
    }
  }

  componentWillUnmount() {
    this.handleSearchDebounced.cancel();
  }

  handleOnFocus = () => {
    this.showMenu();
    this.props.onFocus?.();
  };

  onOptionSelected = (event: React.MouseEvent, item: TypeaheadOption<T>) => {
    event.preventDefault();
    this.selectItem(item);
  };

  handleOnChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
    const { optionsShown, selected } = this.state;
    const { multiple, onInputChange } = this.props;

    if (!optionsShown) {
      this.showMenu();
    }

    const query = event.target.value;

    if (!multiple && selected.length > 0) {
      this.updateSelectedValue([]);
    }

    this.setState({ query }, () => {
      this.handleSearchDebounced(query);
      if (onInputChange) {
        onInputChange(query);
      }
    });
  };

  handleOnPaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
    const { allowNew, multiple, chipSeparators } = this.props;
    const { selected } = this.state;

    if (allowNew && multiple && chipSeparators.length > 0) {
      event.preventDefault();
      const value = event.clipboardData.getData('text');
      if (value) {
        const regex = new RegExp(chipSeparators.join('|'));
        const pastedChips = value
          .split(regex)
          .map((label) => ({ label: label.trim() }))
          .filter((chip) => chip.label);

        this.updateSelectedValue([...selected, ...pastedChips]);
      }
    }
  };

  handleOnKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
    const { showSuggestions, allowNew, multiple, chipSeparators, options } = this.props;
    const { keyboardFocusedOptionIndex, query, selected } = this.state;
    const chipsMode = !showSuggestions && allowNew && multiple;

    if (chipsMode && ['Enter', 'Tab', ...chipSeparators].includes(event.key) && query.trim()) {
      event.preventDefault();
      this.selectItem({ label: query });
    } else {
      switch (event.key) {
        case 'ArrowDown':
          event.preventDefault();
          this.moveFocusedOption(1);
          break;
        case 'ArrowUp':
          event.preventDefault();
          this.moveFocusedOption(-1);
          break;
        case 'Enter':
          event.preventDefault();
          if (keyboardFocusedOptionIndex != null && options[keyboardFocusedOptionIndex]) {
            this.selectItem(options[keyboardFocusedOptionIndex]);
          } else if (allowNew && query.trim()) {
            this.selectItem({ label: query });
          }
          break;
        case 'Backspace':
          if (multiple && !query && selected.length > 0) {
            this.updateSelectedValue(selected.slice(0, -1));
          }
          break;
        default:
          break;
      }
    }
  };
  moveFocusedOption(offset: number) {
    this.setState((previousState) => {
      const { keyboardFocusedOptionIndex } = previousState;
      const { options } = this.props;
      let index = 0;
      if (keyboardFocusedOptionIndex !== null) {
        index = clamp(keyboardFocusedOptionIndex + offset, 0, options.length - 1);
      }
      const optionRef = this.optionRefs[index];
      if (optionRef?.current) {
        optionRef.current.focus(); // Set focus on the option element
      }
      return {
        keyboardFocusedOptionIndex: index,
      };
    });
  }

  selectItem = (item: TypeaheadOption<T>) => {
    const { multiple } = this.props;
    let selected = [...this.state.selected];
    let query;
    if (multiple) {
      selected.push(item);
      query = '';
    } else {
      selected = [item];
      query = item.label;
    }

    this.updateSelectedValue(selected);

    if (!item.keepFocusOnSelect) {
      this.hideMenu();
    }

    if (item.clearQueryOnSelect) {
      query = '';
    }

    this.setState({
      query,
    });
  };

  handleSearch = (query: string) => {
    const { onSearch } = this.props;
    if (onSearch) {
      onSearch(query);
    }

    this.setState((previousState) => ({
      keyboardFocusedOptionIndex: previousState.keyboardFocusedOptionIndex === null ? null : 0,
    }));
  };

  handleDocumentClick = () => {
    if (this.state.optionsShown) {
      this.hideMenu();
      const { allowNew, onBlur, autoFillOnBlur } = this.props;
      const { query } = this.state;
      this.setState({
        isFocused: false,
      });
      if (allowNew && autoFillOnBlur && query.trim()) {
        this.selectItem({ label: query });
      }

      if (onBlur) {
        onBlur();
      }
    }
  };

  showMenu = () => {
    this.setState(
      {
        isFocused: true,
        optionsShown: true,
      },
      () => {
        addClickClassToDocumentOnIos();
        document.addEventListener('click', this.handleDocumentClick, false);
      },
    );
  };

  hideMenu = () => {
    this.setState(
      {
        optionsShown: false,
        keyboardFocusedOptionIndex: null,
      },
      () => {
        removeClickClassFromDocumentOnIos();
        document.removeEventListener('click', this.handleDocumentClick, false);
      },
    );
  };

  updateSelectedValue = (selected: readonly TypeaheadOption<T>[]) => {
    const { onChange, validateChip } = this.props;

    const errorState = selected.some((chip) => !validateChip(chip));
    this.setState({ selected, errorState }, () => {
      onChange([...selected]);
    });
  };

  clear = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    if (this.state.selected.length > 0) {
      this.updateSelectedValue([]);
    }

    this.setState({
      query: '',
    });
  };

  removeChip = (option: TypeaheadOption<T>) => {
    const { selected } = this.state;

    if (selected.length > 0) {
      this.updateSelectedValue([...selected.filter((selectedOption) => selectedOption !== option)]);
    }
  };

  renderChip = (option: TypeaheadOption<T>, idx: number): ReactNode => {
    const valid = this.props.validateChip?.(option);

    return (
      <Chip
        key={idx}
        label={option.label}
        className={clsx('m-t-1', {
          'has-error': !valid,
          'np-chip--valid': valid,
        })}
        onRemove={() => this.removeChip(option)}
      />
    );
  };

  renderMenu = ({
    footer,
    options,
    id,
    keyboardFocusedOptionIndex,
    query,
    allowNew,
    showNewEntry,
    dropdownOpen,
  }: Pick<
    TypeaheadPropsWithInputAttributes<T>,
    'footer' | 'options' | 'id' | 'allowNew' | 'showNewEntry'
  > &
    Pick<TypeaheadState<T>, 'keyboardFocusedOptionIndex' | 'query'> & {
      dropdownOpen: boolean;
    }) => {
    const optionsToRender = [...options];
    if (
      allowNew &&
      query.trim() &&
      options.every((option) => option.label.trim().toUpperCase() !== query.trim().toUpperCase()) &&
      showNewEntry
    ) {
      optionsToRender.push({
        label: query,
      });
    }
    return (
      <div
        className={clsx('dropdown btn-group btn-block', { open: dropdownOpen })}
        id={`menu-${id}`}
      >
        {(!!optionsToRender.length || footer) && (
          <ul className="dropdown-menu" role="menu">
            {optionsToRender.map((option, idx) => {
              const ref = React.createRef<HTMLLIElement>();
              this.optionRefs[idx] = ref;
              return (
                <TypeaheadOption
                  key={`${option.label}${idx.toString()}`}
                  ref={ref}
                  query={query}
                  option={option}
                  selected={keyboardFocusedOptionIndex === idx}
                  id={String(option.label.replace(/\s+/g, '-'))}
                  onClick={(event) => {
                    this.onOptionSelected(event, option);
                  }}
                />
              );
            })}
            {footer}
          </ul>
        )}
      </div>
    );
  };

  render() {
    const {
      inputAttributes,
      id: idProp,
      placeholder,
      multiple,
      size,
      addon,
      name,
      clearable,
      allowNew,
      footer,
      showSuggestions,
      showNewEntry,
      options,
      minQueryLength,
      autoFocus,
      maxHeight,
      alert,
      inputAutoComplete,
    } = this.props;
    const id = idProp ?? inputAttributes?.id;

    const { errorState, query, selected, optionsShown, keyboardFocusedOptionIndex } = this.state;

    const clearButton = clearable && (query || selected.length > 0);

    const dropdownOpen = optionsShown && showSuggestions && query.length >= minQueryLength;

    const menu = this.renderMenu({
      footer,
      options,
      id,
      keyboardFocusedOptionIndex,
      query,
      allowNew,
      showNewEntry,
      dropdownOpen,
    });

    const alertType = (() => {
      if (!alert?.type || alert.type === Sentiment.INFO) {
        return Sentiment.NEUTRAL;
      }
      if (alert.type === Sentiment.ERROR) {
        return Sentiment.NEGATIVE;
      }
      if (alert.type === Sentiment.SUCCESS) {
        return Sentiment.POSITIVE;
      }
      return alert.type;
    })();
    const hasError = errorState || (alert && alertType === Sentiment.NEGATIVE);
    const displayAlert = (!errorState && alert) || (alert && alertType === Sentiment.NEGATIVE);
    const hasWarning = displayAlert && alertType === Sentiment.WARNING;
    const hasInfo = displayAlert && alertType === Sentiment.NEUTRAL;
    return (
      /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */
      <div
        role="group"
        {...inputAttributes}
        id={id}
        className={clsx('typeahead', `typeahead-${size}`, {
          'typeahead--has-value': selected.length > 0,
          'typeahead--empty': selected.length === 0,
          'typeahead--multiple': multiple,
          open: dropdownOpen,
        })}
        onClick={stopPropagation}
      >
        <div
          className={clsx('form-group', 'form-group--typeahead', {
            'has-error': hasError,
            'has-warning': hasWarning,
            'has-info': hasInfo,
          })}
        >
          <div
            className={clsx(`input-group input-group-${size}`, {
              'input-group--has-error': hasError,
            })}
          >
            {addon && <span className="input-group-addon input-group-addon--search">{addon}</span>}

            <TypeaheadInput
              {...{
                autoFocus,
                multiple,
                dropdownOpen,
                placeholder,
                selected,
                maxHeight,
              }}
              id={id}
              name={name}
              value={query}
              typeaheadId={id}
              renderChip={this.renderChip}
              autoComplete={inputAutoComplete}
              ariaActivedescendant={
                keyboardFocusedOptionIndex !== null && options[keyboardFocusedOptionIndex]
                  ? `option-${options[keyboardFocusedOptionIndex].label.replace(/\s+/g, '-')}`
                  : undefined
              }
              onChange={this.handleOnChange}
              onKeyDown={this.handleOnKeyDown}
              onFocus={this.handleOnFocus}
              onPaste={this.handleOnPaste}
            />

            {clearButton && (
              <div className="input-group-addon">
                <button
                  type="button"
                  className="btn-unstyled"
                  aria-label={this.props?.intl?.formatMessage(messages.clearLabel)}
                  onClick={this.clear}
                >
                  <CrossIcon />
                </button>
              </div>
            )}
          </div>
          {displayAlert ? (
            <InlinePrompt sentiment={alertType} className="typeahead--prompt" width="full">
              {alert.message}
            </InlinePrompt>
          ) : (
            menu
          )}
        </div>
      </div>
    );
  }
}

export default injectIntl(withInputAttributes(Typeahead, { nonLabelable: true })) as <T>(
  props: TypeaheadProps<T>,
) => React.ReactElement;
