import { VirtualItem, useVirtualizer } from '@tanstack/react-virtual';
import findLastIndex from 'lodash/findLastIndex';
import {
  ChangeEventHandler,
  KeyboardEventHandler,
  MouseEvent,
  ReactNode,
  Ref,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { useTranslation } from '../../../../core/hooks/useTranslation';
import { useId } from '../../../../hooks/useId';
import { useTestIdAttribute } from '../../../../hooks/useTestIdAttribute';
import { FullPropsOptions, TestIdProps } from '../../../../types';
import { assertEmptyObject } from '../../../../utils/assertEmptyObject';
import { makeTestId } from '../../../../utils/makeTestId';
import { SkeletonOption } from '../../constants';
import { SelectOption } from '../../types';

import {
  StyledHiddenSearchContainer,
  StyledNoOptionsMessage,
  StyledSelectFilterInput,
  StyledSelectList,
  StyledSelectMenu,
  StyledSelectOption,
  TunedScrollable,
  TunedSkeleton,
} from './styled';

export type MenuProps<Option extends SelectOption> = {
  /** If `true` that search input in dropdown will be hidden */
  hideSearch: boolean | undefined;
  /** Text that will be shown in search input when they is empy */
  searchPlaceholder: string | undefined;
  /** Message that will be shown in dropdown when no options should be visible */
  noOptionsMessage: string | undefined;
  /** Options of a custom option component */
  optionComponent: {
    /** Custom component to render {@link SelectField} item */
    renderer: (props: { option: Option; selected: boolean; highlighted: boolean }) => JSX.Element;
    /** Height of a custom option element in pixels. */
    height: number;
  };
  /** Function that should be invoked when user close menu */
  onClose: () => void;
  /** Ref for menu container */
  menuRef?: Ref<HTMLDivElement>;
} & TestIdProps &
  FullPropsOptions<
    'multiple',
    {
      selectedValues: Option['value'][];
      onChange: (values: Option['value'][]) => void;
    },
    {
      selectedValue: Option['value'] | undefined | null;
      onChange: (values: Option['value']) => void;
    }
  > &
  FullPropsOptions<
    'async',
    {
      totalOptions: number;
      optionsPrevPage: Option[];
      optionsCurrentPage: Option[];
      optionsNextPage: Option[];
      onOptionsPageChanged: (page: number, filter: string) => void;
      optionsPerPage: number;
    },
    {
      options: Option[];
      /** Function that convert option to string */
      optionToSearchString?: (option: Option) => string;
    }
  >;

type OptionWithSkeleton<Option extends SelectOption> = Option | typeof SkeletonOption;

function BlankComponent({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

/**
 * Internal component of {@link SelectField} that should be used to display
 * dropdown.
 */
export function Menu<Option extends SelectOption>(props: MenuProps<Option>) {
  const {
    searchPlaceholder,
    noOptionsMessage,
    multiple,
    selectedValue,
    selectedValues,
    hideSearch,
    optionComponent,
    onClose,
    onChange,
    menuRef,
    async,
    options,
    optionToSearchString,
    totalOptions,
    optionsPrevPage,
    optionsCurrentPage,
    optionsNextPage,
    onOptionsPageChanged,
    optionsPerPage,
    testId,
    ...rest
  } = props;
  assertEmptyObject(rest);

  const { t } = useTranslation();
  const testIdAttribute = useTestIdAttribute();

  const listId = useId();
  const [searchValue, setSearchValueValue] = useState<string>('');
  const [currentAsyncPage, setCurrentAsyncPage] = useState(0);
  const [highlightedIndex, setHighlightedIndex] = useState<null | number>(null);

  const filteredOptions = useMemo(() => {
    if (async) {
      return [] as Option[];
    }

    if (!searchValue) {
      return options;
    }

    const normalSearchValue = searchValue.toLowerCase();

    return options.filter((option) => {
      const filterString = optionToSearchString
        ? optionToSearchString(option)
        : `${option.label} ${option.value}`;

      return filterString.toLowerCase().includes(normalSearchValue);
    });
  }, [async, options, optionToSearchString, searchValue]);

  const usedTotalOptions = async ? totalOptions : filteredOptions.length;

  const availableOptions = useMemo(() => {
    if (!async) {
      return filteredOptions;
    }
    const allOptions: Option[] = [...optionsPrevPage, ...optionsCurrentPage, ...optionsNextPage];
    const ids = allOptions.map(({ value }) => value);
    const uniqueOptions = allOptions.filter(({ value }, index) => !ids.includes(value, index + 1));
    return uniqueOptions;
  }, [async, optionsPrevPage, optionsCurrentPage, optionsNextPage, filteredOptions]);

  const availableOptionsStartIndex = async ? Math.max((currentAsyncPage - 1) * optionsPerPage, 0) : 0;

  const getOptionByIndex = useCallback(
    (index: number): OptionWithSkeleton<Option> => {
      return availableOptions[index - availableOptionsStartIndex] ?? SkeletonOption;
    },
    [availableOptions, availableOptionsStartIndex],
  );

  const menuListRef = useRef(null);
  const { getVirtualItems, scrollToIndex, getTotalSize } = useVirtualizer({
    count: usedTotalOptions,
    getScrollElement: () => menuListRef.current,
    estimateSize: useCallback(() => optionComponent.height, [optionComponent.height]),
    enableSmoothScroll: false,
    overscan: 2,
    getItemKey: useCallback(
      (index: number) => availableOptions[index - availableOptionsStartIndex]?.value ?? `skeleton-${index}`,
      [availableOptions, availableOptionsStartIndex],
    ),
  });

  const virtualItems = getVirtualItems() as Array<
    Omit<VirtualItem<HTMLLIElement>, 'key'> & { key: Option['value'] }
  >;

  // When user scroll options calculate middle option index in visible area,
  // them by this index calculate current async page and if this page changed
  // set this page as current async page
  useEffect(() => {
    if (!async) {
      return;
    }

    let middle = 0;

    if (virtualItems.length === 1) {
      middle = virtualItems[0].index;
    } else if (virtualItems.length > 1) {
      const start = virtualItems[0].index;
      const end = virtualItems[virtualItems.length - 1].index;
      middle = start + Math.round((end - start) / 2);
    }
    const page = Math.floor((middle + 1) / optionsPerPage);

    setCurrentAsyncPage(page);
  }, [async, optionsPerPage, virtualItems]);

  // When search value or current async page changed notify parent component about it
  useEffect(() => {
    if (onOptionsPageChanged) {
      onOptionsPageChanged(currentAsyncPage, searchValue);
    }
  }, [currentAsyncPage, onOptionsPageChanged, searchValue]);

  const findNextIndex = useCallback(
    (index: number) => {
      const predicate = (minIndex: number, maxIndex: number) => (option: Option, i: number) => {
        return i >= minIndex && i <= maxIndex && !option.disabled;
      };
      let result = availableOptions.findIndex(predicate(index + 1, availableOptions.length - 1));
      if (result !== -1) {
        return result;
      }
      result = availableOptions.findIndex(predicate(0, index - 1));
      if (result !== -1) {
        return result;
      }
      return null;
    },
    [availableOptions],
  );

  const findPrevIndex = useCallback(
    (index: number) => {
      const predicate = (minIndex: number, maxIndex: number) => (option: Option, i: number) => {
        return i >= minIndex && i <= maxIndex && !option.disabled;
      };
      let result = findLastIndex(availableOptions, predicate(0, index - 1));
      if (result !== -1) {
        return result;
      }
      result = findLastIndex(availableOptions, predicate(index + 1, availableOptions.length - 1));
      if (result !== -1) {
        return result;
      }
      return null;
    },
    [availableOptions],
  );

  // Scroll to selected by keyboard item
  useEffect(() => {
    if (highlightedIndex !== null) {
      scrollToIndex(highlightedIndex);
    }
  }, [scrollToIndex, highlightedIndex]);

  // When user change filter make current highlited item the fist item
  useEffect(() => {
    setHighlightedIndex(findNextIndex(-1));
  }, [searchValue, findNextIndex]);

  // When menu shown at the first time scroll to selected item and set it as highlighted
  // Don't move to the selected option if it's selector with multiple feature
  //
  // This effect should be executed after all other effects that change highlighted index!
  useEffect(() => {
    if (!multiple && selectedValue) {
      const selectedItemIndex = filteredOptions.findIndex((option) => option.value === selectedValue);
      if (selectedItemIndex === -1) {
        // We can be here if is async select, and we don't load current value
        return;
      }

      setHighlightedIndex(selectedItemIndex);
      scrollToIndex(selectedItemIndex, { align: 'center' });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const SearchContainer = hideSearch ? StyledHiddenSearchContainer : BlankComponent;

  const handleFilterChange: ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
    setSearchValueValue(event.target.value);
  }, []);

  const setSelectedOption = useCallback(
    (option: OptionWithSkeleton<Option>) => {
      if (option === SkeletonOption) {
        onClose();
        return;
      }

      if (!multiple) {
        onChange(option.value);
        onClose();
      }

      if (multiple) {
        const selected = selectedValues.includes(option.value);
        const newValue = selected
          ? selectedValues.filter((value) => value !== option.value)
          : [...selectedValues, option.value];
        onChange(newValue);
      }
    },
    [multiple, onChange, onClose, selectedValues],
  );

  const handleFilterKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
    (event) => {
      if (highlightedIndex === null) {
        return;
      }
      switch (event.key) {
        case 'ArrowDown':
          setHighlightedIndex(findNextIndex(highlightedIndex) ?? highlightedIndex);
          break;
        case 'ArrowUp':
          setHighlightedIndex(findPrevIndex(highlightedIndex) ?? highlightedIndex);
          break;
        case 'Enter':
          setSelectedOption(getOptionByIndex(highlightedIndex));
          break;
        default:
      }
    },
    [
      getOptionByIndex,
      highlightedIndex,
      setSelectedOption,
      setHighlightedIndex,
      findNextIndex,
      findPrevIndex,
    ],
  );

  const handleOptionClick = (option: OptionWithSkeleton<Option>) => (event: MouseEvent<HTMLLIElement>) => {
    event.stopPropagation();
    setSelectedOption(option);
  };

  return (
    <StyledSelectMenu ref={menuRef} {...{ [testIdAttribute]: testId }}>
      <SearchContainer>
        <StyledSelectFilterInput
          aria-autocomplete="list"
          aria-controls={listId}
          aria-hidden={hideSearch}
          aria-label={searchPlaceholder ?? t('ui.select.search')}
          autoComplete="off"
          autoFocus
          onChange={handleFilterChange}
          onKeyDown={handleFilterKeyDown}
          placeholder={searchPlaceholder ?? t('ui.select.search')}
          value={searchValue}
          {...{ [testIdAttribute]: makeTestId(testId, 'search-input') }}
        />
      </SearchContainer>

      <TunedScrollable ref={menuListRef}>
        <StyledSelectList
          aria-activedescendant={highlightedIndex !== null ? `${listId}-item-${highlightedIndex}` : undefined}
          id={listId}
          role="listbox"
          style={{
            height: getTotalSize(),
          }}
          {...{ [testIdAttribute]: makeTestId(testId, 'list') }}
        >
          {virtualItems.map((virtualRow) => {
            const index = virtualRow.index;
            const option = getOptionByIndex(index);
            const selected = (() => {
              if (option === SkeletonOption) {
                return false;
              }

              if (!multiple) {
                return option.value === selectedValue;
              } else {
                return selectedValues.includes(option.value);
              }
            })();
            const disabled = option !== SkeletonOption ? option.disabled : undefined;
            const highlighted = highlightedIndex === index;

            return (
              <StyledSelectOption
                key={virtualRow.key}
                $highlighted={highlighted}
                aria-disabled={disabled}
                aria-selected={selected}
                id={`${listId}-item-${index}`}
                onClick={disabled ? undefined : handleOptionClick(option)}
                role="option"
                style={{
                  height: virtualRow.size,
                  transform: `translateY(${virtualRow.start}px)`,
                }}
                {...{ [testIdAttribute]: makeTestId(testId, 'option') }}
              >
                {option === SkeletonOption ? (
                  <TunedSkeleton $index={index} />
                ) : (
                  <optionComponent.renderer highlighted={highlighted} option={option} selected={selected} />
                )}
              </StyledSelectOption>
            );
          })}
        </StyledSelectList>
      </TunedScrollable>

      {(async && totalOptions === 0) || (!async && filteredOptions.length === 0) ? (
        <StyledNoOptionsMessage {...{ [testIdAttribute]: makeTestId(testId, 'no-option') }}>
          {noOptionsMessage ?? t('ui.select.noneOptions')}
        </StyledNoOptionsMessage>
      ) : null}
    </StyledSelectMenu>
  );
}
