import { CrossCircle } from '@transferwise/icons';
import { ListboxOptions } from '@headlessui/react';
import { clsx } from 'clsx';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { Virtualizer, type VirtualizerHandle } from 'virtua';

import { SearchInput } from '../../SearchInput';
import {
  SelectInputItemsCountContext,
  SelectInputItemPositionContext,
} from '../SelectInput.contexts';
import {
  dedupeSelectInputItems,
  filterSelectInputItems,
  MAX_ITEMS_WITHOUT_VIRTUALIZATION,
  searchableString,
  selectInputOptionItemIncludesNeedle,
  sortSelectInputItems,
} from '../SelectInput.utils';
import { SelectInputOptionItem, SelectInputProps, SelectInputItem } from '../SelectInput.types';
import messages from '../SelectInput.messages';

import { SelectInputItemView } from '../ItemView';
import { SelectInputOptionsContainer } from './OptionsContainer';

/**
 * Props for the SelectInputOptions component.
 */
export interface SelectInputOptionsProps<T = string> extends Pick<
  SelectInputProps<T>,
  | 'items'
  | 'renderValue'
  | 'renderFooter'
  | 'filterable'
  | 'filterPlaceholder'
  | 'id'
  | 'parentId'
  | 'compareValues'
  | 'sortFilteredOptions'
> {
  searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
  listboxRef: React.MutableRefObject<HTMLDivElement | null>;
  filterQuery: string;
  onFilterChange: (query: string) => void;
  listBoxLabel?: string;
  listBoxLabelledBy?: string;
  autocomplete?: string;
  name?: string;
  onAutocompleteSelect?: (value: T) => void;
}

/**
 * The main options container component for SelectInput.
 * Manages filtering, virtualisation, and rendering of options.
 */
export function SelectInputOptions<T = string>({
  id,
  parentId,
  items,
  compareValues: compareValuesProp,
  renderValue = String,
  renderFooter,
  filterable = false,
  filterPlaceholder,
  sortFilteredOptions,
  searchInputRef,
  listboxRef,
  filterQuery,
  onFilterChange,
  listBoxLabel,
  listBoxLabelledBy,
  autocomplete,
  name,
  onAutocompleteSelect,
}: SelectInputOptionsProps<T>) {
  const intl = useIntl();
  const virtualiserHandlerRef = useRef<VirtualizerHandle>(null);
  const controllerRef = filterable ? searchInputRef : listboxRef;
  const [initialRender, setInitialRender] = useState(true);

  const needle = useMemo(() => {
    if (filterable) {
      return filterQuery ? searchableString(filterQuery) : null;
    }
    return undefined;
  }, [filterQuery, filterable]);
  useEffect(() => {
    if (needle) {
      // Ensure having an active option while filtering.
      // Without `requestAnimationFrame` upon which React depends for scheduling
      // updates, the active status would only show for a split second and then
      // disappear inadvertently.
      requestAnimationFrame(() => {
        if (
          controllerRef.current != null &&
          !controllerRef.current.hasAttribute('aria-activedescendant')
        ) {
          // Activate first option via synthetic key press
          controllerRef.current.dispatchEvent(
            new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
          );
        }
      });
    }
  }, [controllerRef, needle]);

  const compareValues = useMemo(() => {
    if (!compareValuesProp) {
      return undefined;
    }

    if (typeof compareValuesProp === 'function') {
      return (a: NonNullable<T>, b: NonNullable<T>) => compareValuesProp(a, b);
    }

    const key = compareValuesProp;
    return (a: NonNullable<T>, b: NonNullable<T>) => {
      if (typeof a === 'object' && a != null && typeof b === 'object' && b != null) {
        return (a as Record<string, unknown>)[key] === (b as Record<string, unknown>)[key];
      }
      return a === b;
    };
  }, [compareValuesProp]);

  const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] = useMemo(() => {
    if (needle == null) {
      return items;
    }

    const dedupedItems = dedupeSelectInputItems(items, compareValues);

    if (sortFilteredOptions) {
      // When sorting, filter out non-matching items completely to avoid ghost items
      const filtered = dedupedItems.map((item) => {
        if (item.type === 'option') {
          return selectInputOptionItemIncludesNeedle(item, needle)
            ? item
            : { ...item, value: undefined };
        }
        if (item.type === 'group') {
          return {
            ...item,
            options: item.options.map((option) =>
              selectInputOptionItemIncludesNeedle(option, needle)
                ? option
                : { ...option, value: undefined },
            ),
          };
        }
        return item;
      });

      return sortSelectInputItems(filtered, sortFilteredOptions, filterQuery);
    }

    return filterSelectInputItems(dedupedItems, (item) =>
      selectInputOptionItemIncludesNeedle(item, needle),
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [needle, items, compareValues]);
  const resultsEmpty = needle != null && filteredItems.length === 0;

  const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;

  // Items shown once shall be kept mounted until the needle changes, otherwise
  // the scroll position may jump around inadvertently. Pattern adopted from:
  // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
  const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
  const prevNeedleRef = useRef(needle);

  useEffect(() => {
    const needleChanged = prevNeedleRef.current !== needle;
    prevNeedleRef.current = needle;

    if (needleChanged) {
      // Reset mounted indexes when search changes to avoid stale scroll positions
      setMountedIndexes([]);
      return;
    }

    // Ensure the 'End' key works as intended by keeping the last item mounted.
    // Skipped on needle change to prevent auto-scrolling on search.
    if (filteredItems.length > 0) {
      setMountedIndexes((prevMountedIndexes) => {
        // Create a new array with existing indexes plus the last item index
        return [...new Set([...prevMountedIndexes, filteredItems.length - 1])]; // Sorting is redundant by nature here
      });
    }
  }, [needle, filteredItems.length]);

  const listboxContainerRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (listboxContainerRef.current != null) {
      listboxContainerRef.current.style.setProperty(
        '--initial-height',
        `${listboxContainerRef.current.offsetHeight}px`,
      );
    }
  }, []);

  useEffect(() => {
    setInitialRender(false);
  }, []);

  const showStatus = resultsEmpty;
  const statusId = useId();
  const listboxId = useId();

  const getItemNode = (index: number) => {
    const item = filteredItems[index];
    return (
      <SelectInputItemView key={index} item={item} renderValue={renderValue} needle={needle} />
    );
  };

  const findMatchingItem = (autocompleteValue: string): T | null => {
    const flatOptions = items
      .flatMap((item) =>
        item.type === 'group' ? item.options : item.type === 'option' ? [item] : [],
      )
      .filter(
        (item): item is SelectInputOptionItem<NonNullable<T>> =>
          item.type === 'option' && item.value != null,
      );

    const exactMatch = flatOptions.find(
      (option) =>
        String(option.value) === autocompleteValue ||
        option.filterMatchers?.some((matcher) => matcher === autocompleteValue),
    );

    if (exactMatch) {
      return exactMatch.value;
    }

    const fuzzyMatch = flatOptions.find((option) =>
      option.filterMatchers?.some((matcher) =>
        matcher.toLowerCase().includes(autocompleteValue.toLowerCase()),
      ),
    );

    return fuzzyMatch ? fuzzyMatch.value : null;
  };

  return (
    <ListboxOptions
      modal
      as={SelectInputOptionsContainer}
      static
      className="np-select-input-options-container"
      onAriaActiveDescendantChange={(value: React.AriaAttributes['aria-activedescendant']) => {
        if (controllerRef.current != null) {
          if (!initialRender && value != null) {
            controllerRef.current.setAttribute('aria-activedescendant', value);
          } else {
            controllerRef.current.removeAttribute('aria-activedescendant');
          }
        }
      }}
    >
      {filterable ? (
        <div className="np-select-input-query-container">
          <SearchInput
            ref={searchInputRef}
            id={id}
            name={name}
            autoComplete={autocomplete}
            role="combobox"
            shape="rectangle"
            placeholder={filterPlaceholder}
            aria-label={filterPlaceholder}
            defaultValue={filterQuery}
            aria-autocomplete="list"
            aria-expanded
            aria-controls={listboxId}
            aria-describedby={showStatus ? statusId : undefined}
            onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
              // Prevent interfering with the matcher of Headless UI
              // https://mathiasbynens.be/notes/javascript-unicode#regex
              if (/^.$/u.test(event.key)) {
                event.stopPropagation();
              }
            }}
            onChange={(event) => {
              // Free up resources and ensure not to go out of bounds when the
              // resulting item count is less than before
              const inputValue = event.currentTarget.value;

              // Free up resources and ensure not to go out of bounds
              setMountedIndexes([]);
              onFilterChange(inputValue);
            }}
            onInput={(event) => {
              const inputValue = event.currentTarget.value;
              const inputElement = event.currentTarget;

              if (autocomplete && onAutocompleteSelect && inputValue) {
                setTimeout(() => {
                  if (inputElement.value === inputValue && inputValue.length > 2) {
                    const matchedValue = findMatchingItem(inputValue);
                    if (matchedValue !== null) {
                      onAutocompleteSelect(matchedValue);
                    }
                  }
                }, 50);
              }
            }}
          />
        </div>
      ) : null}

      <section
        ref={listboxContainerRef}
        tabIndex={-1}
        className={clsx(
          'np-select-input-listbox-container',
          virtualized && 'np-select-input-listbox-container--virtualized',
          needle == null && // Groups aren't shown when filtering
            items.some((item) => item.type === 'group') &&
            'np-select-input-listbox-container--has-group',
        )}
        data-wds-parent={parentId ?? undefined}
      >
        {resultsEmpty ? (
          <div id={statusId} className="np-select-input-options-status">
            <CrossCircle size={16} className="np-select-input-options-status-icon" />
            {intl.formatMessage(messages.noResultsFound)}
          </div>
        ) : null}

        <div
          ref={listboxRef}
          id={listboxId}
          role="listbox"
          aria-orientation="vertical"
          aria-label={listBoxLabel}
          aria-labelledby={listBoxLabelledBy}
          tabIndex={0}
          className="np-select-input-listbox"
        >
          {!virtualized ? (
            filteredItems.map((_, index) => getItemNode(index))
          ) : (
            <Virtualizer
              ref={virtualiserHandlerRef}
              data={filteredItems}
              keepMounted={mountedIndexes}
              scrollRef={listboxRef} // `VList` doesn't expose this
              onScroll={async () => {
                if (!virtualiserHandlerRef.current) return;

                const startIndex = virtualiserHandlerRef.current.findItemIndex(
                  virtualiserHandlerRef.current.scrollOffset,
                );
                const endIndex = virtualiserHandlerRef.current.findItemIndex(
                  virtualiserHandlerRef.current.scrollOffset +
                    virtualiserHandlerRef.current.viewportSize,
                );

                setMountedIndexes((prevMountedIndexes) => {
                  // Create an array of all indexes that should be visible

                  const visibleIndexes = [];
                  for (let index = startIndex; index <= endIndex; index += 1) {
                    // eslint-disable-next-line functional/immutable-data
                    visibleIndexes.push(index);
                  }

                  // Combine with previous indexes and sort
                  return [...new Set([...prevMountedIndexes, ...visibleIndexes])].sort(
                    (a, b) => a - b,
                  );
                });
              }}
            >
              {(item, index) => (
                // The position of each item can't be inferred by browsers when
                // virtualizing, as some of the items may not be in the DOM
                <SelectInputItemsCountContext.Provider value={filteredItems.length}>
                  <SelectInputItemPositionContext.Provider value={index + 1}>
                    {getItemNode(index)}
                  </SelectInputItemPositionContext.Provider>
                </SelectInputItemsCountContext.Provider>
              )}
            </Virtualizer>
          )}
        </div>

        {renderFooter != null ? (
          <footer className="np-select-input-footer">
            <div
              role="none"
              onKeyDown={(event) => {
                // Prevent interfering with Headless UI
                if (event.key !== 'Escape') {
                  event.stopPropagation();
                }
              }}
            >
              {renderFooter({
                resultsEmpty,
                queryNormalized: needle,
              })}
            </div>
          </footer>
        ) : null}
      </section>
    </ListboxOptions>
  );
}
