/**
 * Copyright Schrodinger, LLC
 *
 * @fileoverview Dropdown picker
 */
import { debounce, first, get, intersection, isEmpty, isObject, noop, reduce, some } from 'lodash'
import React, { PureComponent, SyntheticEvent, KeyboardEvent } from 'react'
import { KeyCodes, EventType } from './enums'
import { SelectOption } from './types'
import { Option } from './Option'
import { Portal } from 'react-portal'
import clsx from 'clsx'

import styles from './styles.module.scss'

export class Dropdown extends PureComponent<Props, State> {
  private dropdownRef = React.createRef<HTMLDivElement>()
  private picklistRef = React.createRef<HTMLDivElement>()
  private debouncedOnTypeaheadChange: Props['onTypeaheadChange']
  private resizePicklist: ResizeObserver | undefined;

  static defaultProps = {
    optional: false,
    options: [],
    placeholder: 'Select…',
    filterable: false,
    multiselect: false,
    defaultValue: '',
    onChange: noop,
    onSelect: noop,
    onDeselect: noop,
    onShowPicker: noop,
    onHidePicker: noop,
    onTypeaheadChange: noop,
  }

  componentDidMount(): void {
    this.resizePicklist = new ResizeObserver(entries => {
      const rect = entries[0].contentRect;

      if (!this.picklistRef.current) {
        return;
      }

      this.picklistRef.current.style.width = rect.width + 2 + 'px';
    });
  }

  componentWillUnmount(): void {
    if (this.resizePicklist) {
      this.resizePicklist.disconnect();
    }
  }

  constructor(props: Props) {
    super(props)

    let selected: State['selected']
    if (props.selected) {
      selected = isObject(props.selected) ? new Set([...props.selected]) : new Set([props.selected])
    } else {
      selected = new Set()
    }

    this.state = {
      selected,
      open: false,
      // allow pre-population of the initial value, which is particularly useful
      // for typeaheads
      search: props.defaultValue,
      options: props.options,
      autofill: '',
    }

    this.debouncedOnTypeaheadChange = debounce(this.props.onTypeaheadChange, 300)
  }

  public render(): JSX.Element {
      const classes = clsx(styles['Dropdown'], this.props.className, {
      [styles.disabled]: this.isDisabled(),
    })
    return (
      <div
        className={classes}
      >
        {this.renderHeader()}
        {this.renderPicklist()}
      </div>
    )
  }

  /**
   *
   * @returns {JSX.Element}
   */
  renderHeader(): JSX.Element {
    if (this.props.header) {
      const selectedValue = this.getSelectedValue()
      return (
        <div ref={this.dropdownRef}>
          {this.props.header({
            selectedValue,
            selected: this.state.selected,
            show: this.showPicker,
            hide: this.hidePicker,
          })}
        </div>
      )
    }

    if (this.props.typeahead) {
      return (
        <div className={styles['Typeahead']} ref={this.dropdownRef}>

          <div className={styles['inputs']}>
            <input
              className={styles['search']}
              type="text"
              onClick={this.showPicker}
              onFocus={this.showPicker}
              onChange={this.setSearchValue}
              onKeyDown={this.onKeyDown}
              value={this.getSelectedValue()}
              placeholder={this.props.placeholder}
              disabled={this.props.disabled}
            />
            <input
              className={clsx(styles['autofill'], { [styles['active']]: this.state.open })}
              type="text"
              value={this.state.autofill}
              tabIndex={-1}
              readOnly
            />
          </div>
          {this.renderClearIcon()}
          {this.renderArrowIcon()}
        </div>
      )
    }

    return (
      <div className={styles['selected-option']} ref={this.dropdownRef}>
        <span onClick={this.showPicker}>{this.getSelectedValue()}</span>
        {this.renderArrowIcon()}
      </div>
    )
  }

  renderPicklist(): JSX.Element|null {
    if (!this.state.open) {
      return null
    }

    if (this.props.typeahead && this.state.options.length === 0) {
      return null
    }

    const classes = clsx(styles['Picklist'], {
      [styles['search-active']]: this.state.search.trim() !== '',
      [styles['multiselect']]: this.props.multiselect,
    })

    if (this.resizePicklist && this.dropdownRef.current) {
      // Observer on dropdown element resize
      this.resizePicklist.observe(this.dropdownRef.current);
    }

    const rect = this.dropdownRef.current?.getBoundingClientRect();
    const style = {
      zIndex: this.props.zIndex,
      top: (rect?.height || 0) + (rect?.top || 0),
      left: (rect?.left || 0),
    }

    if (this.props.picklist) {
      return (
        <Portal>
          <div className={classes} ref={this.picklistRef} style={style}>
            {this.props.picklist({
              onClick: this.onChange,
              options: this.props.options,
              selectedOptions: this.props.selectedOptions,
            })}
          </div>
        </Portal>
      )
    }

    return (
      <Portal>
        <div className={classes} ref={this.picklistRef} style={style}>
          {this.renderSearch()}
          <ul>
            {this.renderDeselectOption()}
            {this.renderPicklistItems()}
          </ul>
        </div>
      </Portal>
    )
  }

  /**
   * Render an extra option in the picklist, displaying the placeholder value,
   * so users can "deselect" their current selection - ie. this dropdown is optional
   */
  renderDeselectOption(): JSX.Element|null {
    // These options automatically infer that we should not render the extra option
    //   !optional: if this is off, there's nothing to do here
    //   multiselect: this dropdown style inherently supports deselection
    //   state.search: temporarily hide deselect option while actively filtering
    if (!this.props.optional || this.props.multiselect || this.state.search.trim()) {
      return null
    }
    // Only show the deselect option when a value is currently selected
    if (this.state.selected.size === 0 && !this.props.value) {
      return null
    }
    return (
      <li className={styles['clear-selection']} onClick={this.clearSelection}>
        {this.props.placeholder}
      </li>
    )
  }

  /**
   * Reset the users selected choice to null
   */
  clearSelection = (): void => {
    if (!this.props.multiselect) {
      const newState = {
        selected: new Set([]),
        open: false,
      }
      this.setState(newState, () => this.props.onChange(null))

      if (this.props.typeahead) {
        this.setState({ search: '', autofill: '', options: this.props.options })
      }
      return
    }
  }

  /**
   * TODO: This should be handled by a separate component, so we don't need to
   * rerender the button on every change (which inadvertently dismisses open pickers)
   */
  renderPicklistItems(): JSX.Element|JSX.Element[] | null {
    if (this.props.filterable && this.state.options.length === 0) {
      return <div className={styles['no-results']}>0 matches found.</div>
    }

    return Array.from(this.state.options, (option) => this.renderItem(option))
  }

  renderItem({ key, value }: SelectOption): JSX.Element {
    const classes = clsx({
      [styles.selected]: this.state.selected.has(key),
    })

    return (
      <Option
        className={classes}
        onClick={this.onChange}
        optionKey={key}
        value={value}
        search={this.state.search}
        key={key}
        disabled={this.props.selectedOptions?.includes(value)}
      />
    )
  }

  renderSearch(): JSX.Element|null {
    if (!this.props.filterable || this.props.options.length <= 1 || this.props.typeahead) {
      return null
    }

    return (
      <div className={styles['search']}>
        <input
          placeholder="Search…"
          type="text"
          onChange={this.setSearchValue}
          onKeyDown={this.onKeyDown}
          autoFocus
        />
      </div>
    )
  }

  setSearchValue = (event: SyntheticEvent<HTMLInputElement>): void => {
    // We explicitly do not automatically convert this to lowercase during set
    // because the search value is sometimes rendered to the UI (ie: typeahead autofill)
    // and we need to retain / display the casing of the user's original input
    const search = event.currentTarget.value
    const terms = search.toLowerCase()

    const options = reduce(
      this.props.options,
      (list, item) => {
        const match = item.value.toLowerCase().includes(terms)
        return match ? [...list, item] : list
      },
      [] as SelectOption[],
    )

    if (this.props.typeahead) {
      const firstOption = get(first(options), 'value', '')
      let autofill = ''
      if (terms !== '' && firstOption.toLowerCase().startsWith(terms)) {
        autofill = event.currentTarget.value + firstOption.substr(terms.length)
      }
      // In a typeahead, if the user selected a value and then modifies it,
      // we need to re-open the picklist. Additionally, we need to clear any
      // selected value that may be present since they've modified the input
      this.setState({
        open: true,
        selected: new Set([]),
        autofill,
      }, () => this.debouncedOnTypeaheadChange(search))
    }

    this.setState({
      search,
      options,
    })
  }

  onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
    const eventCodes = [event.keyCode, event.charCode, event.which, event.key.toUpperCase()]
    if (intersection(eventCodes, [KeyCodes.ENTER_KEY, KeyCodes.TAB_KEY]).length === 0) {
      return
    }

    const option = first(this.state.options)

    // This is a faux onBlur handler. We can't actually dismiss the picklist
    // on a real blur event, because those are fired before click events, and
    // would thus interfere with selecting items from the dropdown.
    if (eventCodes.includes(KeyCodes.TAB_KEY) && this.props.typeahead) {
      this.setState({ open: false })
      // SS-35555: We check for an autofill value to differentiate when a user
      // is actively searching (typing) vs just trying to blur the input when a
      // value is already present in the input
      if (this.state.autofill === '') {
        return
      }
    }

    if (!option || this.state.search.trim() === '') {
      return
    }

    if (eventCodes.includes(KeyCodes.ENTER_KEY) && !this.props.typeahead) {
      this.setState({ search: '', options: this.props.options })
      this.onChange(option.key)
      event.currentTarget.value = ''
    } else if (this.props.typeahead) {
      // TODO find a better way to set the input than manually mutating it
      event.currentTarget.value = option.value
      this.setState({ autofill: option.value })
      this.onChange(option.key)
      if (eventCodes.includes(KeyCodes.TAB_KEY)) {
        // Don't automatically highlight the input
        event.currentTarget.focus()
        event.preventDefault()
      }
    }
  }

  renderClearIcon(): JSX.Element | null {
    if (this.getSelectedValue().length === 0) {
      return null
    }
    return (
      <button
        className={styles['clear-btn']}
        onClick={this.clearSelection}
      >
        x
      </button>
    )
  }

  renderArrowIcon(): JSX.Element {
    const classes = clsx(styles['ld-icon'], styles['fa'], {
      [styles['fa-times-circle']]: this.isDisabled(),
      [styles['fa-caret-down']]: !this.isDisabled() && !this.state.open,
      [styles['fa-caret-up']]: !this.isDisabled() && this.state.open,
    })
    return <i className={classes} onClick={this.togglePicker} />
  }

  getSelectedValue(): string {
    if (isEmpty(this.props.options) || (this.state.selected.size === 0 && !this.props.value)) {
      if (!this.props.typeahead) {
        return this.props.placeholder
      }
    }

    // Multiselect displays selected value if there is only one, otherwise a counter
    if (this.state.selected.size > 1) {
      const count = this.state.selected.size
      return `${count} selected`
    }

    // TODO: If `value` is intentionally null but state exists, we can become stale
    const key = this.props.value || this.state.selected.values().next().value

    // TODO: TS does not understand what's going on with find here?
    // It thinks that this line can return (number|SelectOption) for some reason..
    const selectedOption = this.props.options.find((item) => item.key === key)

    if (this.props.typeahead) {
      return get(selectedOption, 'value', this.state.search)
    }

    return get(selectedOption, 'value', '')
  }

  showPicker = (): void => {
    if (this.isDisabled()) {
      return
    }

    // Bind a handler to close the picker on external clicks.
    document.addEventListener(EventType.CLICK, this.handleOutsideClick, { capture: true })
    this.setState({ open: true }, this.props.onShowPicker)
  }

  hidePicker = (): void => {
    // Typeaheads should retain the search value after dismissing the picklist
    // for subsequent opens
    if (this.props.filterable) {
      this.setState({ search: '', options: this.props.options }, this.props.onHidePicker)
    }

    this.setState({ open: false }, this.props.onHidePicker)

    if (this.resizePicklist) {
      this.resizePicklist.disconnect();
    }

    document.removeEventListener(EventType.CLICK, this.handleOutsideClick, { capture: true })
  }

  handleOutsideClick = (event: SyntheticEvent<HTMLElement> | Event): void => {
    const target = event.target as HTMLElement
    if (this.picklistRef.current && this.picklistRef.current.contains(target)) {
      return
    }
    this.hidePicker();
  }

  togglePicker = (): void => {
    if (this.state.open) {
      this.hidePicker()
    } else {
      this.showPicker()
    }
  }

  onChange = (key: SelectOption['key']): void => {
    // If this is not a multiselect, emit an onChange event
    if (!this.props.multiselect) {
      const newState = {
        selected: new Set([key]),
        open: false,
        // If the user was filtering, it's no longer applicable after a selection
        // has been made (this behavior differs from multi-select: TRUE, see below)
        search: '',
        options: this.props.options,
      }
      this.setState(newState, () => {
        this.props.onChange(key)
        this.props.onHidePicker()
      })

      if (this.props.typeahead) {
        const option = this.props.options.find(item => item.key === key)
        // The casing of the user entered text may not match the value from the
        // dropdown, we need to update the autofill now to account for this
        this.setState({
          autofill: get(option, 'value', '')
        })
      }
      return
    }

    // Otherwise, if it is a multiselect list, emit onSelect/onDeselect
    // NOTE: We do not reset the search state for multi-select, to allow the
    // user to make additional selections from the filtered list
    const newState = new Set(this.state.selected)
    if (this.state.selected.has(key)) {
      newState.delete(key)
      this.setState({ selected: newState }, () => this.props.onDeselect(key))
    } else {
      newState.add(key)
      this.setState({ selected: newState }, () => this.props.onSelect(key))
    }
  }


  isDisabled = (): boolean => this.props.disabled || isEmpty(this.props.options)

  static getDerivedStateFromProps(props: Props, state: State): State {
    if(JSON.stringify(props.options) !== JSON.stringify(state.options)) {
      state = {
        ...state,
        options: props.options,
      }
    }
    if (props.selected === undefined) {
      // Undefined indicates that we're just allowing the local state to manage selected values
      return state
    } else if (props.selected === null) {
      // Whereas null indicates that we're intentionally clearing the dropdown
      return { ...state, selected: new Set() }
    }

    const selected = isObject(props.selected) ? new Set([...props.selected]) : new Set([props.selected])

    // If the props contain any selected values, which are not present in state,
    // we treat the props as the source of truth. This is a trade-off between
    // a fully controlled component, and can happen if some external api needs
    // to update the dropdown's selection.
    if (
      selected.size !== state.selected.size ||
      some([...selected], (key: SelectOption['key']) => !state.selected.has(key))
    ) {
      return { ...state, selected }
    }

    return state
  }
}

export interface HeaderRenderer {
  /**
   * Display value of the current selection.
   * If multiple selections, this will be the phrase "N selected"
   */
  selectedValue: SelectOption['value']

  /**
   * A Set containing the values of the selected items
   */
  selected: State['selected']

  /**
   * Function to show the picker
   */
  show: (event: SyntheticEvent<HTMLElement>) => void

  /**
   * Function to hide the picker
   */
  hide: (event: SyntheticEvent<HTMLElement> | Event) => void
}

export interface PicklistRenderer {
  /**
   * onClick handler for when users select a picklist option
   */
  onClick: (key: SelectOption['key']) => void

  /**
   * Options for picklist
   */
  options: SelectOption[]

  /**
   * Picklist items that are already selected and need to be disabled
   */
  selectedOptions?: string[]
}

export interface State {
  /**
   * Whether the picklist is visible
   */
  open: boolean

  /**
   * Currently selected value(s)
   */
  selected: Set<SelectOption['key']>

  /**
   * Current search filter
   */
  search: string

  /**
   * Currently visible options
   */
  options: SelectOption[]

  /**
   * Value displayed for typeahead auto-fill
   */
  autofill: string
}

export interface Props {
  /**
   * Custom header to use instead of the default placeholder select box
   */
  header?: (data: HeaderRenderer) => JSX.Element

  /**
   * Custom picklist to use instead of the default picklist
   */
  picklist?: (data: PicklistRenderer) => JSX.Element

  /**
   * Selected value (for use as a controlled component)
   */
  value?: SelectOption['key'] | null

  /**
   * Placeholder text to display when no value is selected
   */
  placeholder: string

  /**
   * Include search functionality in the picklist
   */
  filterable: boolean

  /**
   * Make search the default behavior
   */
  typeahead: boolean

  /**
   * Allow multiple values to be selected
   */
  multiselect: boolean

  /**
   * Options to display
   */
  options: SelectOption[]

  /**
   * Classes for the wrapper div
   */
  className?: string

  /**
   * Whether or not the dropdown is disabled
   */
  disabled?: boolean

  /**
   * Selected values (requires multiselect: true, for use as a controlled component)
   */
  selected?: State['selected'] | Array<SelectOption['key']> | SelectOption['key'] | null

  /**
   * Picklist items that are already selected and need to be disabled
   */
   selectedOptions?: string[]

  /**
   * Allow users to deselect the value, returning to the original placeholder
   */
  optional: boolean

  /**
   * Initial value to be displayed (in typeaheads)
   */
  defaultValue: string

  /**
   * Event fired for single selection dropdowns (multiselect: false)
   */
  onChange: (selected: SelectOption['key'] | null) => void

  /**
   * Event fired for multiselect dropdowns (multiselect: true)
   */
  onSelect: (selected: SelectOption['key'] | null) => void

  /**
   * Event fired when deselecting a multiselect value (multiselect: true)
   */
  onDeselect: (selected: SelectOption['key'] | null) => void

  /**
   * Event fired when showing the picker
   */
  onShowPicker: () => void

  /**
   * Event fired when hiding the picker
   */
  onHidePicker: () => void

  /**
   * Event fired when hiding the picker
   */
  onTypeaheadChange: (value: string) => void

  /**
   * zIndex for picklist
   */
  zIndex?: number
}