/*
 * 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 {chain, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils';
import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions';
import {getCollectionId, isNonContiguousSelectionModifier} from './utils';
import {moveVirtualFocus} from '@react-aria/focus';
import {MultipleSelectionManager} from '@react-stately/selection';
import {useEffect, useRef} from 'react';

export interface SelectableItemOptions extends DOMProps {
  /**
   * An interface for reading and updating multiple selection state.
   */
  selectionManager: MultipleSelectionManager,
  /**
   * A unique key for the item.
   */
  key: Key,
  /**
   * Ref to the item.
   */
  ref: RefObject<FocusableElement | null>,
  /**
   * By default, selection occurs on pointer down. This can be strange if selecting an
   * item causes the UI to disappear immediately (e.g. menus).
   */
  shouldSelectOnPressUp?: boolean,
  /**
   * Whether selection requires the pointer/mouse down and up events to occur on the same target or triggers selection on
   * the target of the pointer/mouse up event.
   */
  allowsDifferentPressOrigin?: boolean,
  /**
   * Whether the option is contained in a virtual scroller.
   */
  isVirtualized?: boolean,
  /**
   * Function to focus the item.
   */
  focus?: () => void,
  /**
   * Whether the option should use virtual focus instead of being focused directly.
   */
  shouldUseVirtualFocus?: boolean,
  /** Whether the item is disabled. */
  isDisabled?: boolean,
  /**
   * Handler that is called when a user performs an action on the item. The exact user event depends on
   * the collection's `selectionBehavior` prop and the interaction modality.
   */
  onAction?: () => void,
  /**
   * The behavior of links in the collection.
   * - 'action': link behaves like onAction.
   * - 'selection': link follows selection interactions (e.g. if URL drives selection).
   * - 'override': links override all other interactions (link items are not selectable).
   * - 'none': links are disabled for both selection and actions (e.g. handled elsewhere).
   * @default 'action'
   */
  linkBehavior?: 'action' | 'selection' | 'override' | 'none'
}

export interface SelectableItemStates {
  /** Whether the item is currently in a pressed state. */
  isPressed: boolean,
  /** Whether the item is currently selected. */
  isSelected: boolean,
  /** Whether the item is currently focused. */
  isFocused: boolean,
  /**
   * Whether the item is non-interactive, i.e. both selection and actions are disabled and the item may
   * not be focused. Dependent on `disabledKeys` and `disabledBehavior`.
   */
  isDisabled: boolean,
  /**
   * Whether the item may be selected, dependent on `selectionMode`, `disabledKeys`, and `disabledBehavior`.
   */
  allowsSelection: boolean,
  /**
   * Whether the item has an action, dependent on `onAction`, `disabledKeys`,
   * and `disabledBehavior`. It may also change depending on the current selection state
   * of the list (e.g. when selection is primary). This can be used to enable or disable hover
   * styles or other visual indications of interactivity.
   */
  hasAction: boolean
}

export interface SelectableItemAria extends SelectableItemStates {
  /**
   * Props to be spread on the item root node.
   */
  itemProps: DOMAttributes
}

/**
 * Handles interactions with an item in a selectable collection.
 */
export function useSelectableItem(options: SelectableItemOptions): SelectableItemAria {
  let {
    id,
    selectionManager: manager,
    key,
    ref,
    shouldSelectOnPressUp,
    shouldUseVirtualFocus,
    focus,
    isDisabled,
    onAction,
    allowsDifferentPressOrigin,
    linkBehavior = 'action'
  } = options;
  let router = useRouter();
  id = useId(id);
  let onSelect = (e: PressEvent | LongPressEvent | PointerEvent) => {
    if (e.pointerType === 'keyboard' && isNonContiguousSelectionModifier(e)) {
      manager.toggleSelection(key);
    } else {
      if (manager.selectionMode === 'none') {
        return;
      }

      if (manager.isLink(key)) {
        if (linkBehavior === 'selection' && ref.current) {
          let itemProps = manager.getItemProps(key);
          router.open(ref.current, e, itemProps.href, itemProps.routerOptions);
          // Always set selected keys back to what they were so that select and combobox close.
          manager.setSelectedKeys(manager.selectedKeys);
          return;
        } else if (linkBehavior === 'override' || linkBehavior === 'none') {
          return;
        }
      }

      if (manager.selectionMode === 'single') {
        if (manager.isSelected(key) && !manager.disallowEmptySelection) {
          manager.toggleSelection(key);
        } else {
          manager.replaceSelection(key);
        }
      } else if (e && e.shiftKey) {
        manager.extendSelection(key);
      } else if (manager.selectionBehavior === 'toggle' || (e && (isCtrlKeyPressed(e) || e.pointerType === 'touch' || e.pointerType === 'virtual'))) {
        // if touch or virtual (VO) then we just want to toggle, otherwise it's impossible to multi select because they don't have modifier keys
        manager.toggleSelection(key);
      } else {
        manager.replaceSelection(key);
      }
    }
  };

  // Focus the associated DOM node when this item becomes the focusedKey
  // TODO: can't make this useLayoutEffect bacause it breaks menus inside dialogs
  // However, if this is a useEffect, it runs twice and dispatches two blur events and immediately sets
  // aria-activeDescendant in useAutocomplete... I've worked around this for now
  useEffect(() => {
    let isFocused = key === manager.focusedKey;
    if (isFocused && manager.isFocused) {
      if (!shouldUseVirtualFocus) {
        if (focus) {
          focus();
        } else if (document.activeElement !== ref.current && ref.current) {
          focusSafely(ref.current);
        }
      } else {
        moveVirtualFocus(ref.current);
      }
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ref, key, manager.focusedKey, manager.childFocusStrategy, manager.isFocused, shouldUseVirtualFocus]);

  isDisabled = isDisabled || manager.isDisabled(key);
  // Set tabIndex to 0 if the element is focused, or -1 otherwise so that only the last focused
  // item is tabbable.  If using virtual focus, don't set a tabIndex at all so that VoiceOver
  // on iOS 14 doesn't try to move real DOM focus to the item anyway.
  let itemProps: SelectableItemAria['itemProps'] = {};
  if (!shouldUseVirtualFocus && !isDisabled) {
    itemProps = {
      tabIndex: key === manager.focusedKey ? 0 : -1,
      onFocus(e) {
        if (e.target === ref.current) {
          manager.setFocusedKey(key);
        }
      }
    };
  } else if (isDisabled) {
    itemProps.onMouseDown = (e) => {
      // Prevent focus going to the body when clicking on a disabled item.
      e.preventDefault();
    };
  }

  useEffect(() => {
    if (isDisabled && manager.focusedKey === key) {
      manager.setFocusedKey(null);
    }
  }, [manager, isDisabled, key]);

  // With checkbox selection, onAction (i.e. navigation) becomes primary, and occurs on a single click of the row.
  // Clicking the checkbox enters selection mode, after which clicking anywhere on any row toggles selection for that row.
  // With highlight selection, onAction is secondary, and occurs on double click. Single click selects the row.
  // With touch, onAction occurs on single tap, and long press enters selection mode.
  let isLinkOverride = manager.isLink(key) && linkBehavior === 'override';
  let isActionOverride = onAction && options['UNSTABLE_itemBehavior'] === 'action';
  let hasLinkAction = manager.isLink(key) && linkBehavior !== 'selection' && linkBehavior !== 'none';
  let allowsSelection = !isDisabled && manager.canSelectItem(key) && !isLinkOverride && !isActionOverride;
  let allowsActions = (onAction || hasLinkAction) && !isDisabled;
  let hasPrimaryAction = allowsActions && (
    manager.selectionBehavior === 'replace'
      ? !allowsSelection
      : !allowsSelection || manager.isEmpty
  );
  let hasSecondaryAction = allowsActions && allowsSelection && manager.selectionBehavior === 'replace';
  let hasAction = hasPrimaryAction || hasSecondaryAction;
  let modality = useRef<PointerType | null>(null);

  let longPressEnabled = hasAction && allowsSelection;
  let longPressEnabledOnPressStart = useRef(false);
  let hadPrimaryActionOnPressStart = useRef(false);
  let collectionItemProps = manager.getItemProps(key);

  let performAction = (e) => {
    if (onAction) {
      onAction();
      ref.current?.dispatchEvent(new CustomEvent('react-aria-item-action', {bubbles: true}));
    }

    if (hasLinkAction && ref.current) {
      router.open(ref.current, e, collectionItemProps.href, collectionItemProps.routerOptions);
    }
  };

  // By default, selection occurs on pointer down. This can be strange if selecting an
  // item causes the UI to disappear immediately (e.g. menus).
  // If shouldSelectOnPressUp is true, we use onPressUp instead of onPressStart.
  // onPress requires a pointer down event on the same element as pointer up. For menus,
  // we want to be able to have the pointer down on the trigger that opens the menu and
  // the pointer up on the menu item rather than requiring a separate press.
  // For keyboard events, selection still occurs on key down.
  let itemPressProps: PressHookProps = {ref};
  if (shouldSelectOnPressUp) {
    itemPressProps.onPressStart = (e) => {
      modality.current = e.pointerType;
      longPressEnabledOnPressStart.current = longPressEnabled;
      if (e.pointerType === 'keyboard' && (!hasAction || isSelectionKey(e.key))) {
        onSelect(e);
      }
    };

    // If allowsDifferentPressOrigin and interacting with mouse, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
    // Otherwise, have selection happen onPress (prevents listview row selection when clicking on interactable elements in the row)
    if (!allowsDifferentPressOrigin) {
      itemPressProps.onPress = (e) => {
        if (hasPrimaryAction || (hasSecondaryAction && e.pointerType !== 'mouse')) {
          if (e.pointerType === 'keyboard' && !isActionKey(e.key)) {
            return;
          }

          performAction(e);
        } else if (e.pointerType !== 'keyboard' && allowsSelection) {
          onSelect(e);
        }
      };
    } else {
      itemPressProps.onPressUp = hasPrimaryAction ? undefined : (e) => {
        if (e.pointerType === 'mouse' && allowsSelection) {
          onSelect(e);
        }
      };

      itemPressProps.onPress = hasPrimaryAction ? performAction : (e) => {
        if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse' && allowsSelection) {
          onSelect(e);
        }
      };
    }
  } else {
    itemPressProps.onPressStart = (e) => {
      modality.current = e.pointerType;
      longPressEnabledOnPressStart.current = longPressEnabled;
      hadPrimaryActionOnPressStart.current = hasPrimaryAction;

      // Select on mouse down unless there is a primary action which will occur on mouse up.
      // For keyboard, select on key down. If there is an action, the Space key selects on key down,
      // and the Enter key performs onAction on key up.
      if (
        allowsSelection && (
          (e.pointerType === 'mouse' && !hasPrimaryAction) ||
          (e.pointerType === 'keyboard' && (!allowsActions || isSelectionKey(e.key)))
        )
      ) {
        onSelect(e);
      }
    };

    itemPressProps.onPress = (e) => {
      // Selection occurs on touch up. Primary actions always occur on pointer up.
      // Both primary and secondary actions occur on Enter key up. The only exception
      // is secondary actions, which occur on double click with a mouse.
      if (
        e.pointerType === 'touch' ||
        e.pointerType === 'pen' ||
        e.pointerType === 'virtual' ||
        (e.pointerType === 'keyboard' && hasAction && isActionKey(e.key)) ||
        (e.pointerType === 'mouse' && hadPrimaryActionOnPressStart.current)
      ) {
        if (hasAction) {
          performAction(e);
        } else if (allowsSelection) {
          onSelect(e);
        }
      }
    };
  }

  itemProps['data-collection'] = getCollectionId(manager.collection);
  itemProps['data-key'] = key;
  itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;

  // When using virtual focus, make sure the focused key gets updated on press.
  if (shouldUseVirtualFocus) {
    itemPressProps = mergeProps(itemPressProps, {
      onPressStart(e) {
        if (e.pointerType !== 'touch') {
          manager.setFocused(true);
          manager.setFocusedKey(key);
        }
      },
      onPress(e) {
        if (e.pointerType === 'touch') {
          manager.setFocused(true);
          manager.setFocusedKey(key);
        }
      }
    });
  }

  if (collectionItemProps) {
    for (let key of ['onPressStart', 'onPressEnd', 'onPressChange', 'onPress', 'onPressUp', 'onClick']) {
      if (collectionItemProps[key]) {
        itemPressProps[key] = chain(itemPressProps[key], collectionItemProps[key]);
      }
    }
  }

  let {pressProps, isPressed} = usePress(itemPressProps);

  // Double clicking with a mouse with selectionBehavior = 'replace' performs an action.
  let onDoubleClick = hasSecondaryAction ? (e) => {
    if (modality.current === 'mouse') {
      e.stopPropagation();
      e.preventDefault();
      performAction(e);
    }
  } : undefined;

  // Long pressing an item with touch when selectionBehavior = 'replace' switches the selection behavior
  // to 'toggle'. This changes the single tap behavior from performing an action (i.e. navigating) to
  // selecting, and may toggle the appearance of a UI affordance like checkboxes on each item.
  let {longPressProps} = useLongPress({
    isDisabled: !longPressEnabled,
    onLongPress(e) {
      if (e.pointerType === 'touch') {
        onSelect(e);
        manager.setSelectionBehavior('toggle');
      }
    }
  });

  // Prevent native drag and drop on long press if we also select on long press.
  // Once the user is in selection mode, they can long press again to drag.
  // Use a capturing listener to ensure this runs before useDrag, regardless of
  // the order the props get merged.
  let onDragStartCapture = e => {
    if (modality.current === 'touch' && longPressEnabledOnPressStart.current) {
      e.preventDefault();
    }
  };

  // Prevent default on link clicks so that we control exactly
  // when they open (to match selection behavior).
  let onClick = linkBehavior !== 'none' && manager.isLink(key) ? e => {
    if (!(openLink as any).isOpening) {
      e.preventDefault();
    }
  } : undefined;

  return {
    itemProps: mergeProps(
      itemProps,
      allowsSelection || hasPrimaryAction || (shouldUseVirtualFocus && !isDisabled) ? pressProps : {},
      longPressEnabled ? longPressProps : {},
      {onDoubleClick, onDragStartCapture, onClick, id},
      // Prevent DOM focus from moving on mouse down when using virtual focus
      shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined
    ),
    isPressed,
    isSelected: manager.isSelected(key),
    isFocused: manager.isFocused && manager.focusedKey === key,
    isDisabled,
    allowsSelection,
    hasAction
  };
}

function isActionKey(key: string | undefined) {
  return key === 'Enter';
}

function isSelectionKey(key: string | undefined) {
  return key === ' ';
}
