<script lang="ts" module>
	export type ComboboxOption = {
		/** Value of the option */
		value: string;
		/**
		 * Label of the option displayed in the picker and, if the option is
		 * selected, in the main input field. Preview overrides label if provided
		 * and the combobox is open (see usePreview prop for exceptions).
		 */
		label?: string;
		/**
		 * Preview text for the option, displayed in the main input field when
		 * the option is selected. See usePreview prop for controlling when
		 * preview takes precedence over label.
		 */
		preview?: string;
		/**
		 * Additional information text for the option, displayed beside the
		 * label in the picker and, if the option is selected, in the main
		 * input field.
		 */
		infotext?: string;
		/** An optional icon for the option, displayed in the picker and,
		 * if the option is selected, in the main input field.
		 */
		icon?: IconDef;
		/** Whether the option is disabled */
		disabled?: boolean;
	};

	/** returns option label, falling back to value or 'Undefined Option' if no option provided */
	const getLabel = (opt: ComboboxOption | undefined): string =>
		opt ? (opt.label ?? opt.value) : 'Undefined Option';
	/** returns option preview, falling back to getLabel if missing */
	const getPreview = (opt: ComboboxOption | undefined): string => opt?.preview ?? getLabel(opt);
</script>

<script lang="ts">
	import { CaretUpDown, Check } from 'phosphor-svelte';

	import Label from './Label.svelte';
	import StyledRawInput from './StyledRawInput.svelte';
	import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate';
	import type { Action } from 'svelte/action';
	import { onMount, tick, untrack, type Snippet } from 'svelte';
	import { Portal } from '@jsrob/svelte-portal';
	import { scale } from 'svelte/transition';
	import { generateIdentifier, type IconDef } from './util';
	import type { ClassValue, MouseEventHandler } from 'svelte/elements';
	import { matchSorter } from 'match-sorter';
	import Spinner from './Spinner.svelte';
	import type { KeyOption } from 'match-sorter';

	interface Props {
		/**
		 * Name of the input, used for form submission. The currently selected
		 * option's value will be submitted under this name.
		 */
		name?: string;
		/** Optional label for the combobox */
		label?: string;
		/** Optional placeholder for the main input field */
		placeholder?: string;
		/** Whether an option must be selected to continue (default: false) */
		required?: boolean;
		/**
		 * Message displayed below the main input field when required but no
		 * option is selected (default: 'Please select an option'). If set to
		 * null, no message is displayed.
		 */
		invalidMessage?: string | null;
		/**
		 * Allows the user to select an option without updating the value
		 * (events are still triggered).
		 */
		stateless?: boolean;

		/** Bindable value of the combobox, the currently selected option */
		value?: ComboboxOption;
		/** Array of ComboboxOptions for the picker */
		options: ComboboxOption[];
		/**
		 * Overrides label render behaviour for all options. Receives the option
		 * as an argument. If not provided, the option's `label` field is used.
		 * Option `label` field must still be present for search, accessibility,
		 * and visibility in the main input field.
		 */
		labelRender?: Snippet<[opt: ComboboxOption]>;
		/**
		 * Overrides infotext render behaviour for all options. Receives the option
		 * as an argument. If not provided, the option's `infotext` field is used.
		 * Option `infotext` field must still be present for search and accessibility.
		 */
		infotextRender?: Snippet<[opt: ComboboxOption]>;
		/**
		 * Overrides icon render behaviour for all options and the main input. Receives
		 * the option as an argument. If not provided, the option's `icon` field is used,
		 * based on the phosphor `IconDef` structure.
		 */
		iconRender?: Snippet<[opt: ComboboxOption]>;

		/** Bindable open state of the combobox */
		open?: boolean;
		/**
		 * If enabled, matches the picker width to the input width. Otherwise,
		 * the picker width is determined by the average of its content, with
		 * a reasonable minimum (default: false).
		 */
		matchWidth?: boolean;
		/** Uses a compact layout for the search input with less padding and smaller text */
		compact?: boolean;
		/**
		 * Optional icon displayed as long as the selected option does not have
		 * its own icon.
		 */
		icon?: IconDef | Snippet;
		/**
		 * Bindable loading state controls visibility of spinner and fallback
		 * text if no options are found (see notFoundMessage). Only changed
		 * internally if lazy loading is used.
		 */
		loading?: boolean;
		/** Special always-disabled option displayed when loading */
		loadingOption?: ComboboxOption;
		/**
		 * Applies the loading state when the picker is opened, allowing for
		 * asynchronous loading of options.
		 * `always`: applies loading state each time the picker is opened
		 * `true`: applies loading state only the first time the picker is opened
		 * `false` (default): loading state must be controlled externally
		 */
		lazy?: 'always' | boolean;
		/**
		 * Controls when the option preview should take precedence over the
		 * label in the main input field.
		 * `auto` (default): uses preview only when the picker is closed
		 * `true`: always uses preview
		 * `false`: never uses preview
		 */
		usePreview?: boolean | 'auto';
		/** Special always-disabled option displayed when no options match the search */
		notFoundOption?: ComboboxOption;
		/**
		 * Configures which option fields are included in the search
		 * (default: 'label' and 'value'). An empty array triggers fallback
		 * to searching by value only.
		 */
		searchKeys?: ('value' | 'label' | 'preview' | 'infotext')[];

		// TODO: implement searchKeys above.
		/** Additional classes applied to the persistent div container */
		class?: ClassValue | null | undefined;
		/** Optional action applied to the main input */
		use?: () => void;

		/** Callback when the input value changes, triggering validation */
		onvalidate?: (e: InputValidatorEvent) => void;
		/** Callback when the selected option changes */
		onchange?: (opt: ComboboxOption) => void;
		/** Callback when a search is performed */
		onsearch?: (query: string) => boolean | void;
		/** Callback when the picker is scrolled */
		onscroll?: (detail: {
			event: UIEvent;
			top: boolean;
			bottom: boolean;
			searchInput: string;
		}) => void;
		/**
		 * Callback when options should be lazily loaded, see lazy prop.
		 * Expects a promise to be returned, allowing for loading state
		 * to be automatically managed.
		 */
		onlazy?: () => Promise<void>;
		/** Callback when the picker is opened or closed */
		onopenchange?: (open: boolean) => void;
	}

	let {
		name,
		value = $bindable<ComboboxOption | undefined>(undefined),
		open = $bindable(false),
		usePreview = 'auto',
		matchWidth = false,
		options,
		required = false,
		invalidMessage = 'Please select an option',
		label,
		placeholder,
		icon,
		loading = $bindable(false),
		lazy = false,
		compact = false,
		stateless = false,
		loadingOption = { value: 'special-loading', label: 'Loading...' },
		notFoundOption = { value: 'special-not-found', label: 'No options found' },
		searchKeys = ['label', 'value'],
		class: classValue,
		use,
		onvalidate,
		onchange,
		onsearch,
		onscroll,
		onlazy,
		onopenchange,
		labelRender,
		infotextRender,
		iconRender
	}: Props = $props();

	const id = $derived(generateIdentifier('combobox', name));
	const searchKeySet = $derived(new Set(searchKeys));
	const conditionalUse = $derived(use ? use : () => {});

	let valid = $state(true);
	let searchValue = $state('');
	let pickerPosition = $state<'top' | 'bottom'>('bottom');
	let searching = $state(false);
	let iconWidth = $state<number | undefined>(undefined);

	let searchInput = $state<HTMLInputElement | null>(null);
	let searchContainer = $state<HTMLDivElement | null>(null);
	let pickerContainer = $state<HTMLDivElement | null>(null);

	// TODO: there's something weird with dropdown placement on narrow screens
	// TODO: match width to input isn't working properly for vconf selectors

	/** options filtered by search value and searchKeys */
	const filteredItems = $derived.by(() => {
		let keys: KeyOption<ComboboxOption>[] = [];
		if (searchKeySet.has('label')) keys.push((item) => getLabel(item));
		if (searchKeySet.has('preview')) keys.push((item) => getPreview(item));
		if (searchKeySet.has('infotext')) keys.push('infotext');
		if (searchKeySet.has('value') || keys.length === 0) keys.push('value');
		const arr = matchSorter(options, searchValue, { keys });
		return arr;
	});

	/** currently highlighted option, updated by keyboard navigation or defaults to first item */
	let highlighted = $derived.by((): ComboboxOption | undefined => {
		if (!searching) return undefined; // otherwise, the first item is highlighted on first open
		if (filteredItems.length === 0) return undefined;
		if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
		if (!filteredItems[0]?.disabled) return filteredItems[0];
	});

	/**
	 * Whether an icon should be displayed with the main input.
	 * The icon is determined by the following precedence:
	 * highlighted option (only when the picker is open) > selected value > icon prop
	 */
	const iconVisible = $derived(
		(open && highlighted && highlighted.icon) ||
			(value && value.icon && searchValue === '') ||
			loading ||
			icon !== undefined
	);

	/** whether the highlighted option should be used in the selection preview  */
	const useHighlighted = $derived.by(() => {
		return open && highlighted;
	});

	/** validation options build from props */
	const validateOpts: ValidatorOptions = $derived({ required });

	/*** HELPER FUNCTIONS ***/

	/** returns the index of the highlighted item in filteredItems */
	const getHighlightedIndex = () => {
		if (highlighted === undefined) return -1;
		return filteredItems.findIndex((item) => item.value === highlighted?.value);
	};
	/** returns the next or previous available item in filteredItems */
	const getLogicalOption = (startIndex: number, step: number): ComboboxOption | undefined => {
		const constrain = (index: number) => {
			if (index < 0) index = filteredItems.length - 1;
			else if (index >= filteredItems.length) index = 0;
			return index;
		};

		let index = constrain(startIndex + step);
		while (index >= 0 && index < filteredItems.length) {
			const option = filteredItems[index];
			if (option.disabled) {
				index = constrain(index + step);
				continue;
			}
			return option;
		}
		return undefined;
	};
	/** returns the next available item in filteredItems */
	const getNextOption = () => {
		return getLogicalOption(getHighlightedIndex(), 1);
	};
	/** returns the previous available item in filteredItems */
	const getPrevOption = () => {
		return getLogicalOption(getHighlightedIndex(), -1);
	};

	let lazyApplied = false;
	/** opens combobox picker and propagates any necessary events */
	const openPicker = async () => {
		open = true;

		updatePickerRect(); // update picker position
		scrollToHighlighted(); // scroll to highlighted item
		onopenchange?.(true); // trigger onopenchange event if defined

		// handle lazy loading behaviour
		if (lazy === 'always' || (lazy === true && !lazyApplied)) {
			lazyApplied = true;
			loading = true;
			await onlazy?.();
			loading = false;
		}
	};
	/** closes combobox picker and propagates any necessary events */
	const closePicker = () => {
		open = false;
		searchValue = ''; // clear search value for next time picker opens
		searching = false; // reset searching state
		highlighted = value; // reset highlighted item to current value
		onopenchange?.(false); // trigger onopenchange event if defined
	};

	/** updates the value of the combobox and triggers any callbacks, including closing the picker */
	const updateValue = (newValue: ComboboxOption) => {
		if (!stateless) value = newValue;
		closePicker();
		onchange?.(newValue);
	};

	/** sets minimum width of the picker as configured by props */
	const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = (
		container,
		buildOpts
	) => {
		const f = (opts: typeof buildOpts) => {
			if (matchWidth && searchInput) {
				container.style.width = searchInput.scrollWidth + 'px';
				return;
			}

			const options = opts.options;
			if (options.length === 0) return;
			const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length;
			container.style.width = `${avg * 2.5}ch`;
		};
		f(buildOpts);
		return {
			update: (updateOpts: typeof buildOpts) => {
				f(updateOpts);
			}
		};
	};

	/** updates the position of the picker */
	const updatePickerRect = async () => {
		if (!searchContainer || !pickerContainer) {
			await tick();
			if (!searchContainer || !pickerContainer) {
				return;
			}
		}

		const overlay = pickerContainer;
		const target = searchContainer;
		const targetRect = target.getBoundingClientRect();

		if (!open) {
			return;
		}

		// choose whether the overlay should be above or below the target
		const availableSpaceBelow = window.innerHeight - targetRect.bottom;
		const availableSpaceAbove = targetRect.top;
		const outerMargin = 24;

		if (availableSpaceBelow < availableSpaceAbove) {
			// overlay should be above the target
			overlay.style.bottom = `${window.innerHeight - targetRect.top - window.scrollY}px`;
			overlay.style.top = 'auto';
			overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`;
			pickerPosition = 'top';
			overlay.dataset.side = 'top';
		} else {
			// overlay should be below the target
			overlay.style.top = `${targetRect.bottom + window.scrollY}px`;
			overlay.style.bottom = 'auto';
			overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`;
			pickerPosition = 'bottom';
			overlay.dataset.side = 'bottom';
		}

		// set overlay left position
		overlay.style.left = `${targetRect.left}px`;
	};

	/** scrolls the picker to the highlighted item */
	const scrollToHighlighted = () => {
		if (!pickerContainer || !highlighted) return;
		const highlightedElement = pickerContainer.querySelector(`[data-id="${highlighted.value}"]`);
		if (!highlightedElement) return;

		const containerTop = pickerContainer.scrollTop;
		const containerHeight = pickerContainer.clientHeight;
		const elementOffsetTop = (highlightedElement as HTMLElement).offsetTop;
		const elementHeight = (highlightedElement as HTMLElement).offsetHeight;

		const offset = 8; // px, adjust as needed

		if (elementOffsetTop < containerTop + offset) {
			pickerContainer.scrollTop = Math.max(elementOffsetTop - offset, 0);
		} else if (elementOffsetTop + elementHeight > containerTop + containerHeight - offset) {
			pickerContainer.scrollTop = elementOffsetTop + elementHeight - containerHeight + offset;
		}
	};

	/*** EXPORTED API ***/

	/** focuses the combobox search input */
	export const focus = () => {
		searchInput?.focus();
	};

	/** sets the value of the combobox by its underlying value field */
	export const setValueByString = (searchVal: string) => {
		const item = options.find((opt) => opt.value === searchVal);
		if (item) {
			updateValue(item);
		} else {
			console.warn(`Combobox: No option found with value "${searchVal}"`);
		}
	};

	/*** EFFECTS & WINDOW CALLBACKS ***/

	// when the value (or, in some circumstances, highlighted item) changes,
	// update the search input
	//
	// expected triggers include: value, highlighted, useHighlighted, usePreview,
	// searchInput, searching, and open
	$effect(() => {
		if (!searchInput || searching) return;

		if ((!value && !highlighted) || (useHighlighted && !highlighted)) {
			searchInput.value = '';
		} else if (useHighlighted) {
			searchInput.value = getLabel(highlighted);
		} else if (!usePreview || (usePreview === 'auto' && open)) {
			searchInput.value = getLabel(value);
		} else {
			searchInput.value = getPreview(value);
		}
	});

	// when highlighted changes, scroll to it
	$effect(() => {
		if (highlighted) {
			scrollToHighlighted();
		}
	});

	// close picker if clicked outside
	const handleWindowClick: MouseEventHandler<Window> = (e) => {
		if (!searchContainer || !pickerContainer) {
			return;
		}

		if (
			!open ||
			searchContainer.contains(e.target as Node) ||
			pickerContainer.contains(e.target as Node)
		) {
			return;
		}

		closePicker();
	};

	onMount(() => {
		// set initial picker position after load
		setTimeout(() => {
			updatePickerRect();
		}, 500);
	});
</script>

<svelte:window onresize={updatePickerRect} onclick={handleWindowClick} />

<!-- Combobox picker -->
<Portal target="body">
	{#if open}
		<!-- Picker container -->
		<div
			class={[
				'picker absolute top-0 left-0 z-50 overflow-y-auto px-2 py-3',
				'rounded-sm border shadow-lg shadow-black/25 outline-hidden',
				'border-sui-accent dark:border-sui-accent/50 dark:bg-sui-text-800 bg-white dark:sm:bg-slate-800',
				'text-sui-text dark:text-sui-background',
				open && pickerPosition === 'top' && 'mb-[var(--outer-gap)]',
				open && pickerPosition === 'bottom' && 'mt-[var(--outer-gap)]'
			]}
			use:minWidth={{ options: options }}
			bind:this={pickerContainer}
			transition:scale={{ duration: 200 }}
			role="listbox"
			onkeydown={(e) => {
				if (e.key === 'Escape') {
					closePicker();
					searchInput?.focus();
				}
			}}
			onscroll={(e) => {
				if (!onscroll) return;
				const target = e.target as HTMLDivElement;
				if (!target) return;

				const margin = 10; // 10px margin for top & bottom
				const atTop = target.scrollTop < margin;
				const atBottom = target.scrollTop + target.clientHeight > target.scrollHeight - margin;
				onscroll({ event: e, top: atTop, bottom: atBottom, searchInput: searchInput?.value ?? '' });
			}}
			tabindex="0"
		>
			{#each filteredItems as opt (opt.value)}
				{@render option(opt)}
			{:else}
				<!-- Display loading state or not found if no options available -->
				{#if loading}
					{@render option(loadingOption, true)}
				{:else}
					{@render option(notFoundOption, true)}
				{/if}
			{/each}
		</div>
	{/if}
</Portal>

<!-- Combobox main input container -->
<div class={classValue}>
	<!-- Combobox Label -->
	{#if label}
		<Label for={id}>{label}</Label>
	{/if}

	<!-- Hidden input stores currently selected value -->
	<input
		{name}
		{id}
		value={value?.value ?? ''}
		type="hidden"
		use:validate={validateOpts}
		onvalidate={(e) => {
			valid = e.detail.valid;
			onvalidate?.(e);
		}}
	/>

	<!-- Search input -->
	<div bind:this={searchContainer}>
		{@render searchInputBox(true)}
	</div>

	<!-- Error message if invalid -->
	{#if invalidMessage !== null}
		<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
			<Label for={id} error>{invalidMessage}</Label>
		</div>
	{/if}
</div>

{#snippet optionIcon(opt: ComboboxOption)}
	{#if iconRender}
		{@render iconRender(opt)}
	{:else if opt.icon}
		<opt.icon.component {...opt.icon.props} />
	{/if}
{/snippet}

<!-- Combobox option -->
{#snippet option(opt: ComboboxOption, forceDisabled?: boolean)}
	{@const optDisabled = opt.disabled || forceDisabled}
	<!-- Option container -->
	<div
		data-id={opt.value}
		aria-selected={value?.value === opt.value}
		aria-label={getLabel(opt)}
		aria-disabled={optDisabled}
		class={[
			!compact ? 'mb-0.5 min-h-10 py-2.5 pr-1.5 pl-5' : 'mb-0.25 min-h-8 py-1.5 pr-1.5 pl-2.5',
			'flex flex-wrap items-center',
			'rounded-sm text-sm capitalize outline-hidden select-none',
			'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30',
			opt.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80',
			optDisabled && 'cursor-not-allowed opacity-50'
		]}
		role="option"
		onclick={() => {
			if (optDisabled) return;
			updateValue(opt);
			searchInput?.focus();
		}}
		onkeydown={() => {}}
		tabindex="-1"
	>
		<!-- Option icon -->
		{@render optionIcon(opt)}

		<!-- Option label -->
		<div class={['mr-8', opt.icon && 'ml-2']}>
			{@render snippetOrString(opt, labelRender || getLabel(opt))}
		</div>

		<!-- Option infotext (always right-aligned) -->
		{#if opt.infotext || infotextRender}
			<div class="text-sui-text/80 dark:text-sui-background/80 ml-auto text-sm">
				{@render snippetOrString(opt, infotextRender || opt.infotext)}
			</div>
		{/if}

		<!-- Option checkmark, visible if selected -->
		{#if value?.value === opt.value}
			<div class={[opt?.infotext ? 'ml-2' : 'ml-auto']}>
				<Check />
			</div>
		{/if}
	</div>
{/snippet}

<!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)}
	<div class="relative">
		<!-- Persistant OR selected option icon, if visible -->
		{#if iconVisible}
			<div
				class={[
					(iconWidth === undefined || iconWidth === 0) && 'opacity-0',
					'pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 transform select-none'
				]}
				transition:scale
				bind:clientWidth={iconWidth}
			>
				{#if loading}
					<Spinner class="stroke-sui-accent! -mt-0.5" size="1em" />
				{:else if useHighlighted && highlighted}
					{@render optionIcon(highlighted)}
				{:else if value}
					{@render optionIcon(value)}
				{:else if icon}
					{#if typeof icon === 'function'}
						{@render icon()}
					{:else}
						<icon.component {...icon.props} />
					{/if}
				{:else}
					❌
				{/if}
			</div>
		{/if}

		<!-- Combobox input box -->
		<StyledRawInput
			style={iconWidth && iconVisible ? `padding-left: ${iconWidth + 14 + 10}px` : undefined}
			class={[caret && 'pr-9', !valid && 'border-red-500!']}
			{compact}
			type="text"
			name={name + '_search'}
			{placeholder}
			autocomplete="off"
			bind:ref={searchInput}
			onclick={() => {
				if (!open) {
					setTimeout(() => {
						searchInput?.select();
					}, 100);
				}

				openPicker();
			}}
			onkeydown={(e) => {
				if (!searchInput) return;

				if (e.key === 'Tab' || e.key === 'Enter') {
					if (open && highlighted && !highlighted.disabled && highlighted.value !== value?.value) {
						updateValue(highlighted);
					}
					if (e.key === 'Enter') {
						closePicker();
						e.preventDefault();
					}
					return;
				} else if (e.key === 'Escape') {
					closePicker();
					return;
				}

				// open the picker
				openPicker();

				if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
					searching = false;
					e.preventDefault();
				}

				if (e.key === 'ArrowDown') {
					highlighted = getNextOption();
					return;
				} else if (e.key === 'ArrowUp') {
					highlighted = getPrevOption();
					return;
				}
			}}
			oninput={() => {
				if (!searchInput) return;
				searchValue = searchInput.value;
				searching = true;
				onsearch?.(searchValue);
			}}
			use={() => {
				conditionalUse();
			}}
		/>

		<!-- Right-aligned infotext (overlay) -->
		{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext) || infotextRender}
			<div
				class={[
					'pointer-events-none absolute top-1/2 -translate-y-1/2 transform text-sm select-none',
					'text-sui-text/80 dark:text-sui-background/80',
					caret ? 'end-10' : 'end-[1.125rem]'
				]}
			>
				{#if useHighlighted && highlighted}
					{@render snippetOrString(highlighted, infotextRender || highlighted.infotext)}
				{:else if value}
					{@render snippetOrString(value, infotextRender || value.infotext)}
				{/if}
			</div>
		{/if}

		{#if caret}
			<CaretUpDown
				class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
				onclick={() => {
					open = !open;
					if (open) searchInput?.focus();
				}}
			/>
		{/if}
	</div>
{/snippet}

{#snippet snippetOrString(
	opt: ComboboxOption,
	value: string | Snippet<[item: ComboboxOption]> | undefined
)}
	{#if typeof value === 'function'}
		{@render value(opt)}
	{:else}
		{value}
	{/if}
{/snippet}

<style lang="postcss">
	@reference "./styles/reference.css";

	.picker {
		--outer-gap: 0.5rem;
		@apply max-h-[calc(100vh-var(--outer-gap))];
	}
</style>
