<script lang="ts" module>
	export type ComboboxOption = {
		value: string;
		label?: string;
		infotext?: string;
		preview?: string;
		disabled?: boolean;
		icon?: Snippet<[item: ComboboxOption]>;
		render?: Snippet<[item: ComboboxOption]>;
	};

	const getLabel = (item: ComboboxOption | undefined): string => item?.label ?? item?.value ?? '';
	const getPreview = (item: ComboboxOption | undefined): string => item?.preview ?? getLabel(item);
</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 { browser } from '$app/environment';
	import { scale } from 'svelte/transition';
	import { generateIdentifier } from './util';
	import type { ClassValue } from 'svelte/elements';

	interface Props {
		name?: string;
		value?: ComboboxOption;
		open?: boolean;
		usePreview?: boolean | 'auto';
		matchWidth?: boolean;
		options: ComboboxOption[];
		required?: boolean;
		invalidMessage?: string | null;
		label?: string;
		placeholder?: string;
		notFoundMessage?: string;
		class?: ClassValue | null | undefined;
		use?: () => void;
		onvalidate?: (e: InputValidatorEvent) => void;
		onchange?: (item: ComboboxOption) => 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,
		notFoundMessage = 'No results found',
		class: classValue,
		use,
		onvalidate,
		onchange
	}: Props = $props();

	let id = $derived(generateIdentifier('combobox', name));

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

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

	const filteredItems = $derived(
		searchValue === ''
			? options
			: options.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase()))
	);

	let highlighted = $derived.by((): ComboboxOption | undefined => {
		if (filteredItems.length === 0) return undefined;
		if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
		return filteredItems[0];
	});

	let iconVisible = $derived.by(() => {
		return (open && highlighted && highlighted.icon) || (value && value.icon && searchValue === '');
	});
	let useHighlighted = $derived.by(() => {
		return open && highlighted;
	});

	const validateOpts: ValidatorOptions = { required };

	const getHightlightedID = () => {
		if (highlighted === undefined) return -1;
		return filteredItems.findIndex((item) => item.value === highlighted?.value);
	};

	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);
			}
		};
	};

	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`;
	};

	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;
		}
	};

	const conditionalUse = $derived(use ? use : () => {});

	export const focus = () => {
		searchInput?.focus();
	};

	export const setValueByString = (searchVal: string) => {
		const item = options.find((opt) => opt.value === searchVal);
		if (item) {
			value = item;
			searchValue = '';
			open = false;
			onchange?.(item);
		} else {
			console.warn(`Combobox: No option found with value "${searchVal}"`);
		}
	};

	if (browser) {
		// update picker position on window resize
		window.addEventListener('resize', updatePickerRect);

		// add window click listener to close picker
		window.addEventListener('click', (e) => {
			if (!searchContainer || !pickerContainer) {
				return;
			}

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

			open = false;
		});
	}

	// when the picker is opened, update its position
	// when the picker is closed, clear any search value (next time it opens, all options will be shown)
	// when the picker is closed, reset highlighted to the currently selected value (if any)
	$effect(() => {
		if (open) {
			updatePickerRect();
			scrollToHighlighted();
		} else {
			searchValue = '';
			searching = false;
			highlighted = value;
		}
	});

	// when the value (or, in some circumstances, highlighted item) changes, update the search input
	$effect(() => {
		if (!searchInput) return;

		if (useHighlighted && !searching) {
			searchInput.value = getLabel(highlighted);
			return;
		}

		if (untrack(() => searching)) return;

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

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

	onMount(() => {
		setTimeout(() => {
			updatePickerRect();
		}, 500);
	});
</script>

<Portal target="body">
	{#if open}
		<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') {
					open = false;
					searchInput?.focus();
				}
			}}
			tabindex="0"
		>
			{#each filteredItems as item, i (i + item.value)}
				<div
					data-id={item.value}
					aria-selected={value?.value === item.value}
					aria-label={getLabel(item)}
					aria-disabled={item.disabled}
					class={[
						'picker-item options-center mb-0.5 flex min-h-10 flex-wrap py-2.5 pr-1.5 pl-5',
						'rounded-sm text-sm capitalize outline-hidden select-none',
						'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30',
						item.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80'
					]}
					role="option"
					onclick={() => {
						value = item;
						open = false;
						searchInput?.focus();
						onchange?.(item);
					}}
					onkeydown={() => {}}
					tabindex="-1"
				>
					{#if item.icon}
						{@render item.icon(item)}
					{/if}

					<div class={['mr-8', item.icon && 'ml-2']}>
						{#if item.render}
							{@render item.render(item)}
						{:else}
							{getLabel(item)}
						{/if}
					</div>

					{#if item?.infotext}
						<div class="text-sui-text/80 dark:text-sui-background/80 ml-auto text-sm">
							{item.infotext}
						</div>
					{/if}

					{#if value?.value === item.value}
						<div class={[item?.infotext ? 'ml-2' : 'ml-auto']}>
							<Check />
						</div>
					{/if}
				</div>
			{:else}
				<span class="block px-5 py-2 text-sm">{notFoundMessage}</span>
			{/each}
		</div>
	{/if}
</Portal>

<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 searchInputBox(caret: boolean = true)}
	<div class="relative">
		{#if iconVisible}
			<div
				class={[
					'pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 transform select-none'
				]}
				transition:scale
			>
				{#if useHighlighted && highlighted?.icon}
					{@render highlighted.icon(highlighted)}
				{:else if value?.icon}
					{@render value.icon(value)}
				{:else}
					❌
				{/if}
			</div>
		{/if}

		<StyledRawInput
			class={[iconVisible && 'pl-10', caret && 'pr-9', !valid && 'border-red-500!']}
			type="text"
			name={name + '_search'}
			{placeholder}
			autocomplete="off"
			bind:ref={searchInput}
			onclick={() => {
				if (!open) {
					setTimeout(() => {
						searchInput?.select();
					}, 100);
				}

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

				if (e.key === 'Tab' || e.key === 'Enter') {
					if (open && highlighted && highlighted.value !== value?.value) {
						value = highlighted;
						onchange?.(highlighted);
					}
					if (e.key === 'Enter') {
						e.preventDefault();
					}

					open = false;
					return;
				} else if (e.key === 'Escape') {
					open = false;
					return;
				}

				// open the picker
				open = true;

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

				if (e.key === 'ArrowDown') {
					const nextIndex = getHightlightedID() + 1;
					if (nextIndex < filteredItems.length) {
						highlighted = filteredItems[nextIndex];
					}
					return;
				} else if (e.key === 'ArrowUp') {
					const prevIndex = getHightlightedID() - 1;
					if (prevIndex >= 0) {
						highlighted = filteredItems[prevIndex];
					}
					return;
				}
			}}
			oninput={() => {
				if (!searchInput) return;
				searchValue = searchInput.value;
				searching = true;
			}}
			use={() => {
				conditionalUse();
			}}
		/>

		{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext)}
			<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]'
				]}
			>
				{useHighlighted && highlighted?.infotext ? highlighted.infotext : value?.infotext}
			</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}

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

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