/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {announce} from '@react-aria/live-announcer';
import {AriaButtonProps} from '@react-types/button';
import {AriaComboBoxProps, SelectionMode} from '@react-types/combobox';
import {ariaHideOutside} from '@react-aria/overlays';
import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox';
import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared';
import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useId, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils';
import {ComboBoxState} from '@react-stately/combobox';
import {dispatchVirtualFocus} from '@react-aria/focus';
import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react';
import {getChildNodes, getItemCount} from '@react-stately/collections';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection';
import {privateValidationStateProp} from '@react-stately/form';
import {useInteractOutside} from '@react-aria/interactions';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {useMenuTrigger} from '@react-aria/menu';
import {useTextField} from '@react-aria/textfield';

export interface AriaComboBoxOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaComboBoxProps<T, M>, 'children'> {
  /** The ref for the input element. */
  inputRef: RefObject<HTMLInputElement | null>,
  /** The ref for the list box popover. */
  popoverRef: RefObject<Element | null>,
  /** The ref for the list box. */
  listBoxRef: RefObject<HTMLElement | null>,
  /** The ref for the optional list box popup trigger button.  */
  buttonRef?: RefObject<Element | null>,
  /** An optional keyboard delegate implementation, to override the default. */
  keyboardDelegate?: KeyboardDelegate,
  /**
   * A delegate object that provides layout information for items in the collection.
   * By default this uses the DOM, but this can be overridden to implement things like
   * virtualized scrolling.
   */
  layoutDelegate?: LayoutDelegate
}

export interface ComboBoxAria<T> extends ValidationResult {
  /** Props for the label element. */
  labelProps: DOMAttributes,
  /** Props for the combo box input element. */
  inputProps: InputHTMLAttributes<HTMLInputElement>,
  /** Props for the list box, to be passed to `useListBox`. */
  listBoxProps: AriaListBoxOptions<T>,
  /** Props for the optional trigger button, to be passed to `useButton`. */
  buttonProps: AriaButtonProps,
  /** Props for the element representing the selected value. */
  valueProps: DOMAttributes,
  /** Props for the combo box description element, if any. */
  descriptionProps: DOMAttributes,
  /** Props for the combo box error message element, if any. */
  errorMessageProps: DOMAttributes
}

/**
 * Provides the behavior and accessibility implementation for a combo box component.
 * A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query.
 * @param props - Props for the combo box.
 * @param state - State for the select, as returned by `useComboBoxState`.
 */
export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaComboBoxOptions<T, M>, state: ComboBoxState<T, M>): ComboBoxAria<T> {
  let {
    buttonRef,
    popoverRef,
    inputRef,
    listBoxRef,
    keyboardDelegate,
    layoutDelegate,
    // completionMode = 'suggest',
    shouldFocusWrap,
    isReadOnly,
    isDisabled
  } = props;
  let backupBtnRef = useRef(null);
  buttonRef = buttonRef ?? backupBtnRef;

  let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/combobox');
  let {menuTriggerProps, menuProps} = useMenuTrigger<T>(
    {
      type: 'listbox',
      isDisabled: isDisabled || isReadOnly
    },
    state,
    buttonRef
  );

  // Set listbox id so it can be used when calling getItemId later
  listData.set(state, {id: menuProps.id});

  // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down).
  // When virtualized, the layout object will be passed in as a prop and override this.
  let {collection} = state;
  let {disabledKeys} = state.selectionManager;
  let delegate = useMemo(() => (
    keyboardDelegate || new ListKeyboardDelegate({
      collection,
      disabledKeys,
      ref: listBoxRef,
      layoutDelegate
    })
  ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]);

  // Use useSelectableCollection to get the keyboard handlers to apply to the textfield
  let {collectionProps} = useSelectableCollection({
    selectionManager: state.selectionManager,
    keyboardDelegate: delegate,
    disallowTypeAhead: true,
    disallowEmptySelection: true,
    shouldFocusWrap,
    ref: inputRef,
    // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component
    isVirtualized: true
  });

  let router = useRouter();

  // For textfield specific keydown operations
  let onKeyDown = (e: BaseEvent<KeyboardEvent<any>>) => {
    if (e.nativeEvent.isComposing) {
      return;
    }
    switch (e.key) {
      case 'Enter':
      case 'Tab':
        // Prevent form submission if menu is open since we may be selecting a option
        if (state.isOpen && e.key === 'Enter') {
          e.preventDefault();
        }

        // If the focused item is a link, trigger opening it. Items that are links are not selectable.
        if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) {
          let collectionItem = state.collection.getItem(state.selectionManager.focusedKey);
          if (collectionItem?.props.href) {
            let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`);
            if (e.key === 'Enter' && item instanceof HTMLAnchorElement) {
              router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions);
            }
            state.close();
            break;
          } else if (collectionItem?.props.onAction) {
            collectionItem.props.onAction();
            state.close();
            break;
          }
        }
        state.commit();
        break;
      case 'Escape':
        if (
          !state.selectionManager.isEmpty ||
          state.inputValue === '' ||
          props.allowsCustomValue
        ) {
          e.continuePropagation();
        }
        state.revert();
        break;
      case 'ArrowDown':
        state.open('first', 'manual');
        break;
      case 'ArrowUp':
        state.open('last', 'manual');
        break;
      case 'ArrowLeft':
      case 'ArrowRight':
        state.selectionManager.setFocusedKey(null);
        break;
    }
  };

  let onBlur = (e: FocusEvent<HTMLInputElement>) => {
    let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget;
    let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget);
    // Ignore blur if focused moved to the button(if exists) or into the popover.
    if (blurFromButton || blurIntoPopover) {
      return;
    }

    if (props.onBlur) {
      props.onBlur(e);
    }

    state.setFocused(false);
  };

  let onFocus = (e: FocusEvent<HTMLInputElement>) => {
    if (state.isFocused) {
      return;
    }

    if (props.onFocus) {
      props.onFocus(e);
    }

    state.setFocused(true);
  };

  let valueId = useValueId([state.selectedItems, state.selectionManager.selectionMode]);
  let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
  let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({
    ...props,
    onChange: state.setInputValue,
    onKeyDown: !isReadOnly ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown,
    onBlur,
    value: state.inputValue,
    defaultValue: state.defaultInputValue,
    onFocus,
    autoComplete: 'off',
    validate: undefined,
    [privateValidationStateProp]: state,
    'aria-describedby': [valueId, props['aria-describedby']].filter(Boolean).join(' ') || undefined
  }, inputRef);

  useFormReset(inputRef, state.defaultValue, state.setValue);

  // Press handlers for the ComboBox button
  let onPress = (e: PressEvent) => {
    if (e.pointerType === 'touch') {
      // Focus the input field in case it isn't focused yet
      inputRef.current?.focus();
      state.toggle(null, 'manual');
    }
  };

  let onPressStart = (e: PressEvent) => {
    if (e.pointerType !== 'touch') {
      inputRef.current?.focus();
      state.toggle((e.pointerType === 'keyboard' || e.pointerType === 'virtual') ? 'first' : null, 'manual');
    }
  };

  let triggerLabelProps = useLabels({
    id: menuTriggerProps.id,
    'aria-label': stringFormatter.format('buttonLabel'),
    'aria-labelledby': props['aria-labelledby'] || labelProps.id
  });

  let listBoxProps = useLabels({
    id: menuProps.id,
    'aria-label': stringFormatter.format('listboxLabel'),
    'aria-labelledby': props['aria-labelledby'] || labelProps.id
  });

  // If a touch happens on direct center of ComboBox input, might be virtual click from iPad so open ComboBox menu
  let lastEventTime = useRef(0);
  let onTouchEnd = (e: TouchEvent) => {
    if (isDisabled || isReadOnly) {
      return;
    }

    // Sometimes VoiceOver on iOS fires two touchend events in quick succession. Ignore the second one.
    if (e.timeStamp - lastEventTime.current < 500) {
      e.preventDefault();
      inputRef.current?.focus();
      return;
    }

    let rect = (getEventTarget(e) as Element).getBoundingClientRect();
    let touch = e.changedTouches[0];

    let centerX = Math.ceil(rect.left + .5 * rect.width);
    let centerY = Math.ceil(rect.top + .5 * rect.height);

    if (touch.clientX === centerX && touch.clientY === centerY) {
      e.preventDefault();
      inputRef.current?.focus();
      state.toggle(null, 'manual');

      lastEventTime.current = e.timeStamp;
    }
  };

  // VoiceOver has issues with announcing aria-activedescendant properly on change
  // (especially on iOS). We use a live region announcer to announce focus changes
  // manually. In addition, section titles are announced when navigating into a new section.
  let focusedItem = state.selectionManager.focusedKey != null && state.isOpen
    ? state.collection.getItem(state.selectionManager.focusedKey)
    : undefined;
  let sectionKey = focusedItem?.parentKey ?? null;
  let itemKey = state.selectionManager.focusedKey ?? null;
  let lastSection = useRef(sectionKey);
  let lastItem = useRef(itemKey);
  useEffect(() => {
    if (isAppleDevice() && focusedItem != null && itemKey != null && itemKey !== lastItem.current) {
      let isSelected = state.selectionManager.isSelected(itemKey);
      let section = sectionKey != null ? state.collection.getItem(sectionKey) : null;
      let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || '';

      let announcement = stringFormatter.format('focusAnnouncement', {
        isGroupChange: (section && sectionKey !== lastSection.current) ?? false,
        groupTitle: sectionTitle,
        groupCount: section ? [...getChildNodes(section, state.collection)].length : 0,
        optionText: focusedItem['aria-label'] || focusedItem.textValue || '',
        isSelected
      });

      announce(announcement);
    }

    lastSection.current = sectionKey;
    lastItem.current = itemKey;
  });

  // Announce the number of available suggestions when it changes
  let optionCount = getItemCount(state.collection);
  let lastSize = useRef(optionCount);
  let lastOpen = useRef(state.isOpen);
  useEffect(() => {
    // Only announce the number of options available when the menu opens if there is no
    // focused item, otherwise screen readers will typically read e.g. "1 of 6".
    // The exception is VoiceOver since this isn't included in the message above.
    let didOpenWithoutFocusedItem =
      state.isOpen !== lastOpen.current &&
      (state.selectionManager.focusedKey == null || isAppleDevice());

    if (state.isOpen && (didOpenWithoutFocusedItem || optionCount !== lastSize.current)) {
      let announcement = stringFormatter.format('countAnnouncement', {optionCount});
      announce(announcement);
    }

    lastSize.current = optionCount;
    lastOpen.current = state.isOpen;
  });

  // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically.
  // TODO: do we need to do this for multi-select?
  let lastSelectedKey = useRef(state.selectedKey);
  useEffect(() => {
    if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) {
      let optionText = state.selectedItem['aria-label'] || state.selectedItem.textValue || '';
      let announcement = stringFormatter.format('selectedAnnouncement', {optionText});
      announce(announcement);
    }

    lastSelectedKey.current = state.selectedKey;
  });

  useEffect(() => {
    if (state.isOpen) {
      return ariaHideOutside([inputRef.current, popoverRef.current].filter(element => element != null));
    }
  }, [state.isOpen, inputRef, popoverRef]);

  useUpdateEffect(() => {
    // Re-show focus ring when there is no virtually focused item.
    if (!focusedItem && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) === inputRef.current) {
      dispatchVirtualFocus(inputRef.current, null);
    }
  }, [focusedItem]);

  useEvent(listBoxRef, 'react-aria-item-action', state.isOpen ? () => {
    state.close();
  } : undefined);

  // usePopover -> useOverlay calls useInteractOutside, but ComboBox is non-modal, so `isDismissable` is false
  // Because of this, onInteractOutside is not passed to useInteractOutside, so we need to call it here.
  useInteractOutside({
    ref: popoverRef,
    onInteractOutside: (e) => {
      let target = getEventTarget(e) as Element;
      if (nodeContains(buttonRef?.current, target) || nodeContains(inputRef.current, target)) {
        return;
      }
      state.close();
    },
    isDisabled: !state.isOpen
  });

  return {
    labelProps,
    buttonProps: {
      ...menuTriggerProps,
      ...triggerLabelProps,
      excludeFromTabOrder: true,
      preventFocusOnPress: true,
      onPress,
      onPressStart,
      isDisabled: isDisabled || isReadOnly
    },
    inputProps: mergeProps(inputProps, {
      role: 'combobox',
      'aria-expanded': menuTriggerProps['aria-expanded'],
      'aria-controls': state.isOpen ? menuProps.id : undefined,
      // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both)
      'aria-autocomplete': 'list',
      'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined,
      onTouchEnd,
      // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions.
      autoCorrect: 'off',
      // This disable's the macOS Safari spell check auto corrections.
      spellCheck: 'false'
    }),
    listBoxProps: mergeProps(menuProps, listBoxProps, {
      autoFocus: state.focusStrategy || true,
      shouldUseVirtualFocus: true,
      shouldSelectOnPressUp: true,
      shouldFocusOnHover: true,
      linkBehavior: 'selection' as const,
      ['UNSTABLE_itemBehavior']: 'action'
    }),
    valueProps: {
      id: valueId
    },
    descriptionProps,
    errorMessageProps,
    isInvalid,
    validationErrors,
    validationDetails
  };
}

// This is a modified version of useSlotId that uses useEffect instead of useLayoutEffect.
// Triggering re-renders from useLayoutEffect breaks useComboBoxState's useEffect logic in React 18.
// These re-renders preempt async state updates in the useEffect, which ends up running multiple times
// prior to the state being updated. This results in onSelectionChange being called multiple times.
// TODO: refactor useComboBoxState to avoid this.
function useValueId(depArray: ReadonlyArray<any> = []): string | undefined {
  let id = useId();
  let [exists, setExists] = useState(true);
  let [lastDeps, setLastDeps] = useState(depArray);

  // If the deps changed, set exists to true so we can test whether the element exists.
  if (lastDeps.some((v, i) => !Object.is(v, depArray[i]))) {
    setExists(true);
    setLastDeps(depArray);
  }

  useEffect(() => {
    if (exists && !document.getElementById(id)) {
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setExists(false);
    }
  }, [id, exists, lastDeps]);

  return exists ? id : undefined;
}
