import React, {useCallback, useMemo, useRef} from "react";
import {Suggestible} from "./Suggestible";
import {OptionsSuggestions, useSuggestionsNavigation} from "./OptionsSuggestions";
import {classes, Modify, normalizeString} from "../../Utils";
import {CaretDown, Check, X} from "@phosphor-icons/react";

/**
 * Generic select component.
 */
export function Select<OptionKey extends keyof any, Option>(
	{
		className,
		value, onChange,
		options, renderOption, match,
		selectibleMaxCount, multiple,
		blurOnSelect, blurWhenMaxCountSelected,
		// Properties to pass down.
		onKeyDown,
		// Already set properties.
		type, role,
		children,
		...props
	}: React.PropsWithChildren<Modify<React.InputHTMLAttributes<HTMLInputElement>, {
		/**
		 * The currently selected option(s).
		 */
		value: OptionKey|OptionKey[];

		/**
		 * Called when new options are selected.
		 * @param newValue
		 */
		onChange: (newValue: OptionKey|OptionKey[]) => void;

		/**
		 * Options list or a way to get it from a given search.
		 */
		options: Record<OptionKey, Option>;

		/**
		 * Render an option.
		 * @param option The option to render.
		 */
		renderOption?: (option: Option) => React.ReactNode;

		/**
		 * Option match function. Return true if the given option matches the given search query.
		 * @param search The search query.
		 * @param option The option that should match the search query.
		 */
		match?: (search: string, option: Option) => boolean;

		/**
		 * Max count of options to allow to select.
		 * 1 by default when multiple is false, infinity when multiple is true.
		 */
		selectibleMaxCount?: number;

		/**
		 * True to allow to select an infinite count of options.
		 * false by default.
		 */
		multiple?: boolean;

		/**
		 * When true, the select loses focus when a new option is selected (by clicking or pressing Enter).
		 * false by default.
		 */
		blurOnSelect?: boolean;

		/**
		 * When true, the select loses focus when the max count of selectible options have been reached (by clicking or pressing Enter).
		 * true by default.
		 */
		blurWhenMaxCountSelected?: boolean;

		// Already set properties.
		type?: never;
		role?: never;
	}>>): React.ReactElement
{
	const [search, setSearch] = React.useState("");

	// By default, allow to select only one option.
	// If `multiple` is set and `selectibleMaxCount` is not, allow an infinite count of options to select.
	selectibleMaxCount = selectibleMaxCount ?? ((multiple === undefined ? false : multiple) ? Infinity : 1);

	// true by default.
	blurOnSelect = blurOnSelect === undefined ? false : blurOnSelect;
	blurWhenMaxCountSelected = blurWhenMaxCountSelected === undefined ? true : blurWhenMaxCountSelected;

	// Initialize default option render function.
	const defaultRenderOption = useCallback((option: Option) => (String(option)), []);

	// Initialize default match option function.
	const defaultMatchOption = useCallback((search: string, option: Option) => normalizeString(String(option)).includes(normalizeString(search)), []);

	// An array of the selected options.
	const selectedOptions = useMemo(() => (
		// Normalize value to an array.
		((!Array.isArray(value) ? [value] : value)
			// Try to get the corresponding option for each given value.
			.map((optionKey) => [optionKey, options?.[optionKey]]) as [OptionKey, Option][])
			// Filter non-existing options.
			.filter(([_, option]) => (option !== undefined))
	), [value, options]);

	// A reference to the main search input.
	const inputRef = useRef<HTMLInputElement>();

	// The suggestions' navigator.
	const suggestionsNavigator = useSuggestionsNavigation();

	// Get all available options, filtered using search query.
	const filteredOptions = useMemo(() => (
		!search || (search.length == 0)
			// Nothing is searched, return all options.
			? options
			// Filter options using search query and matching function.
			: Object.fromEntries(
				(Object.entries(options) as [OptionKey, Option][]).filter(([optionKey, option]) => (
					(match ?? defaultMatchOption)(search, option)
				))
			) as Record<OptionKey, Option>
	), [options, search, match, defaultMatchOption]);

	// Called when a new option is selected.
	const handleSelectedOption = useCallback((selectedOption: OptionKey) => {
		// Get an associative object from selected options.
		const currentSelection = Object.fromEntries(selectedOptions) as Record<OptionKey, Option>;

		// Initialize the new selection variable.
		let newSelection: OptionKey|OptionKey[] = Object.keys(currentSelection) as OptionKey[];

		if (selectibleMaxCount == 1)
			// Only one possible selection = the newly selected option is the new selection.
			newSelection = selectedOption;
		else
		{ // Multiple selections possible.
			if (!currentSelection[selectedOption])
			{ // The newly selected option wasn't selected, we should add it in the selection array.
				// Add the newly selected option in the array.
				newSelection = [...newSelection, selectedOption];
				if (newSelection.length > selectibleMaxCount)
					// If the array is now too big, we remove the first options to match the max count of options.
					newSelection.splice(0, newSelection.length - selectibleMaxCount);
			}
			else
			{ // The option was already selected, we should deselect it.
				newSelection = newSelection.filter((key) => key != selectedOption);
			}
		}

		// Call onChange event with the new selection.
		onChange(newSelection);

		// Reset search query.
		setSearch("");

		if (
			// Always blur on selection.
			blurOnSelect
			||
			// Blur when the new selection now reach the max selectible count.
			(blurWhenMaxCountSelected && ((selectibleMaxCount == 1 && !!newSelection) || (selectibleMaxCount == (newSelection as OptionKey[])?.length)))
		) // Try to lose focus, as a new selection has been made and blur conditions are reached.
			window.setTimeout(() => { inputRef?.current?.blur(); }, 0);
	}, [selectedOptions, onChange, setSearch]);

	// Called when an option is deselected.
	const handleDeselectedOption = useCallback((deselectedOption: OptionKey) => {
		// Call onChange event with the new selection.
		onChange(
			selectedOptions
				// Remove deselected option if it was in the selected options array.
				.filter(([optionKey]) => (optionKey != deselectedOption))
				.map(([optionKey]) => optionKey)
		);
	}, [selectedOptions, onChange]);

	return (
		<label className={classes("select", className)}
		       onFocus={useCallback(() => {
			       inputRef.current?.focus();
		       }, [inputRef])}>
			<div>
				<Suggestible suggestions={
					<OptionsSuggestions options={filteredOptions} renderOption={renderOption}
						                  // Get an associative object from selected options.
					                    selectedOptions={Object.fromEntries(selectedOptions) as Record<OptionKey, Option>}
					                    onSelected={handleSelectedOption} navigator={suggestionsNavigator}/>
				}>
					<input ref={inputRef} type={"text"} role={"select"} value={search}
					       onKeyDown={(event) => {
						       if (event.key == "ArrowDown")
							       suggestionsNavigator.current?.next();
						       else if (event.key == "ArrowUp")
							       suggestionsNavigator.current?.previous();
						       else if (event.key == "Enter")
							       suggestionsNavigator.current?.select();

						       return onKeyDown?.(event);
					       }}
					       onChange={(event) => setSearch(event.currentTarget.value)}
					       {...props} />
				</Suggestible>

				<a className={"button"} tabIndex={-1}><CaretDown /></a>
			</div>

			<ul className={"selected"}>
				{ // Showing each selected value.
					selectedOptions.map(([optionKey, option]) => (
						<li key={String(optionKey)}>
							<Check />
							<div className={"option"}>{(renderOption ?? defaultRenderOption)(option)}</div>
							<button className={"remove flat"} type={"button"} onMouseDown={() => handleDeselectedOption(optionKey)}>
								<X />
							</button>
						</li>
					))
				}
			</ul>

			<div className={"label"}>
				{children}
			</div>
		</label>
	);
}
