import React from 'react'
import PropTypes from 'prop-types'
import { Dimensions, Platform, View, StyleSheet } from 'react-native'
import throttle from 'lodash.throttle'
import matchAll from 'string.prototype.matchall'
import { inputSupportsProps } from '../utils/props/inputSupportsProps'
import { selectSystemProps } from '../utils/props/selectSystemProps'
import { textInputProps } from '../utils/props/textInputProps'
import { textInputHandlerProps } from '../utils/props/handlerProps'
import { useCopy } from '../utils/useCopy'
import { htmlAttrs } from '../utils/htmlAttrs'
import { useOverlaidPosition } from '../utils/useOverlaidPosition'
import { useSafeLayoutEffect } from '../utils/useSafeLayoutEffect'
import { getTokensPropType } from '../utils/props/tokens'
import { useThemeTokens } from '../ThemeProvider/useThemeTokens'
import { ListboxOverlay } from '../Listbox/ListboxOverlay'
import { Typography } from '../Typography/Typography'
import { TextInput } from '../TextInput/TextInput'
import { InputSupports } from '../InputSupports/InputSupports'
import { Loading } from './Loading'
import { Suggestions } from './Suggestions'
import {
  DEFAULT_MAX_SUGGESTIONS,
  DEFAULT_MIN_TO_SUGGESTION,
  DEFAULT_MAX_DROPDOWN_HEIGHT,
  INPUT_LEFT_PADDING,
  MIN_LISTBOX_WIDTH
} from './constants'
import { dictionary } from './dictionary'

const staticStyles = StyleSheet.create({
  container: {
    zIndex: 100,
    flexDirection: 'column',
    justifyContent: 'flex-start',
    flexGrow: 0,
    flexShrink: 0
  }
})

const [selectProps, selectedSystemPropTypes] = selectSystemProps([
  htmlAttrs,
  inputSupportsProps,
  textInputHandlerProps,
  textInputProps
])

// Returns JSX to display a bold string `str` with unbolded occurrences of the
// `substring` based in the array of `matchIndexes` provided
const highlightAllMatches = (str, substring = '', matchIndexes = [], resultsTextColor) => (
  // Wrapping all in bold
  <Typography variant={{ bold: false }} tokens={{ color: resultsTextColor }}>
    {matchIndexes
      .reduce((acc, matchIndex, index) => {
        const prefix = index === 0 ? str.slice(0, matchIndex) : null
        const match = str.slice(matchIndex, matchIndex + substring.length)
        const suffix = str.slice(
          matchIndex + substring.length,
          matchIndexes[index + 1] ?? str.length
        )
        return [
          ...acc,
          prefix ? (
            <Typography
              key={`pre-${matchIndex}`}
              variant={{ bold: false }}
              tokens={{ color: resultsTextColor }}
            >
              {prefix}
            </Typography>
          ) : null,
          <Typography
            key={matchIndex}
            variant={{ bold: true }}
            tokens={{ color: resultsTextColor }}
          >
            {match}
          </Typography>,
          suffix ? (
            <Typography
              key={`post-${matchIndex}`}
              variant={{ bold: false }}
              tokens={{ color: resultsTextColor }}
            >
              {suffix}
            </Typography>
          ) : null
        ]
      }, [])
      .filter(Boolean)}
  </Typography>
)
const highlight = (items = [], text = '', color) =>
  items.reduce((acc, item) => {
    const matches = Array.from(
      matchAll(
        item.label.toLowerCase(),
        text.toLowerCase().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
      )
    )?.map(({ index }) => index)

    if (matches?.length) {
      return [...acc, { ...item, label: highlightAllMatches(item.label, text, matches, color) }]
    }

    return [...acc, item]
  }, [])

export const Autocomplete = React.forwardRef(
  (
    {
      children,
      copy = 'en',
      fullWidth = true,
      initialItems,
      initialValue,
      isLoading = false,
      items,
      maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
      maxDropdownHeight = DEFAULT_MAX_DROPDOWN_HEIGHT,
      minToSuggestion = DEFAULT_MIN_TO_SUGGESTION,
      noResults,
      onChange,
      onClear,
      onSelect,
      readOnly,
      showOptionsOnFocus = false,
      validation,
      value,
      helpText = '',
      loadingLabel,
      dropdownPosition = 'bottom',
      tokens,
      ...rest
    },
    ref
  ) => {
    const { color: resultsTextColor, ...restOfTokens } = useThemeTokens('Autocomplete', tokens, {
      focus: true
    })

    // The wrapped input is mostly responsible for controlled vs uncontrolled handling,
    // but we also need to adjust suggestions based on the mode:
    // - in controlled mode we rely entirely on the suggestions passed via the `items` prop,
    // - in uncontrolled mode we filter the suggestions ourselves based on the `initialItems`
    //   prop and the text entered
    const isControlled = value !== undefined

    // We need to store current items for uncontrolled usage
    const [currentItems, setCurrentItems] = React.useState(initialItems)

    const [otherItems, setOtherItems] = React.useState(items)

    // We need to store the current value as well to be able to highlight it
    const [currentValue, setCurrentValue] = React.useState(value ?? initialValue)
    const inputTokens = { paddingLeft: INPUT_LEFT_PADDING }

    // Setting up the overlay
    const openOverlayRef = React.useRef()
    const [isExpanded, setIsExpanded] = React.useState(
      (value ?? initialValue)?.length >= minToSuggestion
    )
    const [isFocused, setisFocused] = React.useState(false)
    const [sourceLayout, setSourceLayout] = React.useState(null)

    // When it's nested, selected value
    const [nestedSelectedValue, setNestedSelectedValue] = React.useState(null)

    const [isInputVisible, setIsInputVisible] = React.useState(true)

    const { supportsProps, ...selectedProps } = selectProps(rest)
    const { hint, label: inputLabel } = supportsProps
    const hintExpansionEnabled = isFocused && !!helpText && !currentValue
    const {
      overlaidPosition,
      sourceRef: inputRef,
      // targetRef,
      onTargetLayout,
      isReady
    } = useOverlaidPosition({
      isShown: isExpanded || hintExpansionEnabled,
      offsets: {
        vertical: Platform.OS !== 'web' && (hint || inputLabel) ? 28 : 4
      },
      align:
        dropdownPosition === 'top'
          ? { center: 'center', bottom: 'top' }
          : { center: 'center', top: 'bottom' }
    })
    const targetRef = React.useRef(null)
    // We limit the number of suggestions displayed to avoid huge lists
    // TODO: add a way to make the `Listbox` occupy fixed height and be scrollable
    // within that height, which will unlock similar behaviour for `AutoComplete` as well
    const itemsToSuggest = (data = []) =>
      maxSuggestions ? data.slice(0, maxSuggestions) : [...data]

    const getCopy = useCopy({ dictionary, copy })
    // Tracking input width changes to resize the listbox overlay accordingly
    const [inputWidth, setInputWidth] = React.useState()
    useSafeLayoutEffect(() => {
      if (Platform.OS === 'web') {
        const updateInputWidth = () => {
          setInputWidth(inputRef?.current?.clientWidth + 4) // adding back all the input borders / outlines
          setIsExpanded(false) // close the suggestions while the input is changing
        }

        const throttledUpdateInputWidth = throttle(updateInputWidth, 100, { leading: false })

        throttledUpdateInputWidth()

        Dimensions.addEventListener('change', throttledUpdateInputWidth)

        return () => {
          Dimensions.removeEventListener('change', throttledUpdateInputWidth)
        }
      }
      setInputWidth(sourceLayout?.width)
      return () => {}
    }, [inputRef, sourceLayout])

    const handleMeasure = (event) => {
      onTargetLayout(event)
      if (Platform.OS !== 'web') {
        inputRef?.current?.measureInWindow((x, y, width) => {
          setInputWidth(width)
        })
      }
    }

    const handleChange = (newValue) => {
      onChange?.(newValue)
      setCurrentValue(newValue)
      const shouldExpand =
        newValue?.length >= minToSuggestion ||
        (showOptionsOnFocus && isFocused && newValue?.length === 0)
      setIsExpanded(shouldExpand)
      if (!isControlled && initialItems !== undefined) {
        setCurrentItems(
          initialItems.filter(({ label }) =>
            label?.toLowerCase()?.includes(newValue?.toLowerCase())
          )
        )
      }
    }
    const handleSelect = (selectedId) => {
      onSelect?.(selectedId)
      const selectedItem = (isControlled ? items : currentItems)?.find(
        ({ id }) => id === selectedId
      )
      const { label, nested, title } = selectedItem

      if (title) return
      if (!nested) {
        setNestedSelectedValue(null)
        onChange?.(label)
        setIsExpanded(false)
        setCurrentValue(label)
      }
      if (!isControlled && inputRef?.current) inputRef.current.value = label

      if (nested) {
        setNestedSelectedValue(label)
        setCurrentValue(label)
      }

      inputRef.current.focus()
    }

    /**
     * When an item that is nested equal "true" is selected this useEffect is executed.
     * The nested item is added to the item list at the top, the if validates it doesn't exist
     * within the list, if doesn't exist the nested item is added to the top of the list,
     * the nested item is added with an id equal "0"
     */
    React.useEffect(() => {
      if (nestedSelectedValue && !items.find((item) => item.id === 0)) {
        const tmpItems = [...items]
        tmpItems.unshift({ label: nestedSelectedValue, title: true, id: 0 })
        setOtherItems(
          tmpItems.map((item) => {
            return {
              ...item,
              nestedChild: item.id !== 0
            }
          })
        )
      }
    }, [nestedSelectedValue, items])

    const currentValueRef = React.useRef(currentValue ?? '')
    const handleChangeRef = React.useRef(handleChange)
    useSafeLayoutEffect(() => {
      currentValueRef.current = currentValue ?? ''
      handleChangeRef.current = handleChange
    }, [currentValue, handleChange])

    React.useEffect(() => {
      if (Platform.OS !== 'web') return undefined
      const inputEl = inputRef?.current
      if (!inputEl) return undefined

      let timeoutId
      /** Deferred so React-Native-Web's own onChange fires first. On real keystrokes the value already matches currentValueRef, so the check is a no-op. Only autofill paths — where the native input event fires but RNW never emits onChange — will diverge and route through handleChange. */
      const handleNativeInput = ({ target }) => {
        clearTimeout(timeoutId)
        timeoutId = setTimeout(() => {
          const nativeValue = target?.value ?? ''
          if (nativeValue !== currentValueRef.current) {
            handleChangeRef.current(nativeValue)
          }
        }, 0)
      }

      inputEl.addEventListener('input', handleNativeInput)
      return () => {
        clearTimeout(timeoutId)
        inputEl.removeEventListener('input', handleNativeInput)
      }
    }, [inputRef])

    React.useEffect(() => {
      if (Platform.OS === 'ios' || Platform.OS === 'android') {
        return undefined
      }

      const observer = new IntersectionObserver((entries) => {
        const [entry] = entries
        setIsInputVisible(entry.isIntersecting)
        if (!entry.isIntersecting) {
          setIsExpanded(false)
        }
      })

      const currentInputRef = inputRef.current

      if (currentInputRef) {
        observer.observe(currentInputRef)
      }

      return () => {
        if (currentInputRef) {
          observer.unobserve(currentInputRef)
        }
      }
    }, [inputRef])

    const handleClose = (event) => {
      if (event.type === 'keydown' && (event.key === 'Escape' || event.key === '27')) {
        setIsExpanded(false)
        setNestedSelectedValue(null)
        return
      }

      if (
        event.type === 'keydown' &&
        (event.key === 'ArrowDown' || event.key === 'Tab') &&
        isExpanded &&
        !isLoading &&
        targetRef?.current
      ) {
        event.preventDefault()
        targetRef.current.focus()
        return
      }

      if (event.type === 'click' || event.type === 'touchstart') {
        const clickTarget = event.type === 'click' ? event.target : event.touches[0]?.target
        const isOutsideOverlay =
          openOverlayRef?.current && !openOverlayRef.current.contains(clickTarget)
        const isOutsideInput = inputRef?.current && !inputRef.current.contains(clickTarget)

        if (isOutsideOverlay && isOutsideInput) {
          setIsExpanded(false)
          setNestedSelectedValue(null)
        }
      }
    }
    // Calculate items to show based on current state
    let itemsToShow = []
    if (currentValue?.length > 0) {
      itemsToShow = itemsToSuggest(
        highlight(isControlled ? items : currentItems, currentValue, resultsTextColor)
      )
    } else if (showOptionsOnFocus && isFocused) {
      itemsToShow = itemsToSuggest(isControlled ? items : currentItems || initialItems)
    }
    const helpTextToShow = isFocused && !currentValue ? helpText : noResults ?? getCopy('noResults')

    return (
      <View style={staticStyles.container}>
        <InputSupports
          {...supportsProps}
          accessibilityAutoComplete="list"
          accessibilityControls="autocomplete"
          accessibilityExpanded={isExpanded}
          accessibilityRole="combobox"
          {...selectedProps}
          validation={validation}
          ref={ref}
        >
          {({ inputId, ...props }) => {
            if (typeof children === 'function')
              return children({
                inputId,
                inputRef,
                onChange: handleChange,
                onKeyPress: handleClose,
                readOnly,
                tokens: inputTokens,
                ...selectedProps,
                ...props,
                ...(isControlled ? { value } : { initialValue })
              })

            return (
              <TextInput
                onChange={handleChange}
                onFocus={() => {
                  setisFocused(true)
                  if (
                    (showOptionsOnFocus && (!currentValue || currentValue.length === 0)) ||
                    currentValue?.length >= minToSuggestion
                  ) {
                    setIsExpanded(true)
                  }
                }}
                onBlur={() => {
                  setisFocused(false)
                  if (showOptionsOnFocus && (!currentValue || currentValue.length === 0)) {
                    setIsExpanded(false)
                  }
                }}
                onClear={onClear}
                onKeyPress={handleClose}
                readOnly={readOnly}
                ref={inputRef}
                {...(Platform.OS !== 'web'
                  ? {
                      onLayout: (event) => {
                        setSourceLayout(event.nativeEvent.layout)
                        const { y, height } = event.nativeEvent.layout
                        if (y >= 0 && height > 0) {
                          setIsInputVisible(true)
                        } else {
                          setIsInputVisible(false)
                          setIsExpanded(false)
                        }
                      }
                    }
                  : {})}
                tokens={inputTokens}
                validation={validation}
                {...selectedProps}
                {...props}
                {...(isControlled ? { value } : { initialValue })}
              />
            )
          }}
        </InputSupports>
        {(isExpanded || hintExpansionEnabled) &&
          isInputVisible &&
          (itemsToShow.length > 0 || isExpanded || hintExpansionEnabled) && (
            <>
              <ListboxOverlay
                overlaidPosition={overlaidPosition}
                isReady={isReady}
                minWidth={fullWidth ? inputWidth : MIN_LISTBOX_WIDTH}
                maxWidth={inputWidth}
                maxHeight={maxDropdownHeight}
                onLayout={handleMeasure}
                tokens={restOfTokens}
                ref={openOverlayRef}
              >
                {isLoading ? (
                  <Loading label={loadingLabel ?? getCopy('loading')} />
                ) : (
                  <Suggestions
                    hasResults={getCopy('hasResults')}
                    id="autocomplete"
                    items={
                      nestedSelectedValue
                        ? itemsToSuggest(
                            highlight(otherItems, nestedSelectedValue, resultsTextColor)
                          )
                        : itemsToShow
                    }
                    noResults={helpTextToShow}
                    onClose={handleClose}
                    onSelect={handleSelect}
                    parentRef={inputRef}
                    ref={targetRef}
                  />
                )}
              </ListboxOverlay>
              {targetRef?.current && (
                <View
                  // This catches and shifts focus to other interactive elements.
                  onFocus={() => targetRef?.current?.focus()}
                  tabIndex={0}
                />
              )}
            </>
          )}
      </View>
    )
  }
)
Autocomplete.displayName = 'Autocomplete'

// If a language dictionary entry is provided, it must contain every key
const dictionaryContentShape = PropTypes.shape({
  hasResults: PropTypes.string.isRequired,
  loading: PropTypes.string.isRequired,
  noResults: PropTypes.string.isRequired
})

Autocomplete.propTypes = {
  ...selectedSystemPropTypes,
  /**
   * Matches the width of the suggestions list to the width of the input. When false, the list
   * falls back to its minimum width. Defaults to `true`.
   */
  fullWidth: PropTypes.bool,
  /**
   * Can be used to provide a function that renders a custom input.
   *
   * @example
   * ```jsx
   * <Autocomplete items={items} value={currentValue}>
   *   {({ inputId, inputRef, onChange, onKeyPress, readOnly, tokens, value }) => (
   *     <Search
   *       nativeID={inputId}
   *       ref={inputRef}
   *       onChange={onChange}
   *       onKeyPress={onKeyPress}
   *       readOnly={readOnly}
   *       tokens={tokens}
   *       value={value}
   *     />
   *   )}
   * </Autocomplete>
   * ```
   */
  children: PropTypes.func,
  tokens: getTokensPropType('Autocomplete'),
  /**
   * Copy language identifier
   */
  copy: PropTypes.oneOfType([PropTypes.oneOf(['en', 'fr']), dictionaryContentShape]),
  /**
   * Set to true in order to display the loading indicator instead of results
   */
  isLoading: PropTypes.bool,
  /**
   * List of items to display as suggestions
   */
  items: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, label: PropTypes.string })),
  /**
   * Label to display alongside the spinner when in a loading state
   */
  loadingLabel: PropTypes.string,
  /**
   * Minimum number of characters typed for a list of suggestions to appear
   */
  minToSuggestion: PropTypes.number,
  /**
   * Maximum number of suggestions provided at the same time
   */
  maxSuggestions: PropTypes.number,
  /**
   * Maximum height (in pixels) of the dropdown before scrolling is enabled
   */
  maxDropdownHeight: PropTypes.number,
  /**
   * Text or JSX to render when no results are available
   */
  noResults: PropTypes.node,
  /**
   * Help text to display when the input is focused and empty
   */
  helpText: PropTypes.string,
  /**
   * Handler function to be called when the input value changes
   */
  onChange: PropTypes.func,
  /**
   * Handler function to be called when the clear button (appears if the handler is passed) is pressed
   */
  onClear: PropTypes.func,
  /**
   * Callback function to be called when an item is selected from the list
   */
  onSelect: PropTypes.func,
  /**
   * When true, displays all available options when the input receives focus (even without typing)
   */
  showOptionsOnFocus: PropTypes.bool,
  /**
   * Input value for controlled usage
   */
  value: PropTypes.string,
  /**
   * Controls whether the dropdown renders above or below the input. Defaults to 'bottom'.
   */
  dropdownPosition: PropTypes.oneOf(['top', 'bottom'])
}
