<script module lang="ts">
	import { tv, type VariantProps } from "tailwind-variants";

	// the box, its border and its state colours come from selectField, shared with the native
	// rendering so flipping `rich` on a Select cannot change how the field looks
	export const combobox = tv({
		slots: {
			root: "relative inline-block w-full",
			trigger: "flex min-h-9 cursor-pointer items-center gap-2 px-2.5 py-1",
			value: "min-w-0 flex-1 truncate typography-paragraph-regular text-left",
			panel:
				"combobox-panel fixed z-50 m-0 hidden max-h-[min(24rem,calc(100dvh-1rem))] w-72 max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-sm border border-border-default bg-background-default p-0 text-foreground-default shadow-regular open:flex dark:shadow-none",
			search: "shrink-0 border-b border-border-default p-2",
			list: "min-h-0 flex-1 overflow-y-auto p-1",
			groupHeading:
				"px-2 pt-2 pb-1 font-semibold text-foreground-muted first:pt-1",
			option:
				"flex w-full cursor-pointer items-center gap-2 rounded-sm bg-transparent px-2 py-1.5 text-left aria-disabled:cursor-not-allowed aria-disabled:text-foreground-disabled data-active:bg-background-subtle dark:data-active:bg-background-subtle",
			check: "size-4 shrink-0 text-foreground-default",
			optionLabel: "min-w-0 flex-1 truncate",
			hint: "shrink-0 text-foreground-subtle",
			empty: "px-2 py-6 text-center text-foreground-muted",
			footer: "shrink-0 border-t border-border-default"
		},
		variants: {
			placeholder: {
				true: { value: "text-foreground-placeholder" }
			}
		}
	});

	export type ComboboxVariants = VariantProps<typeof combobox>;
</script>

<script lang="ts">
	import Check from "$icon/check";
	import ChevronsUpDown from "$icon/chevrons-up-down";
	import Search from "$icon/search";
	import type { Snippet } from "svelte";
	import { tick } from "svelte";
	import Input from "./Input.svelte";
	import { selectField, type SelectAlign, type SelectOption } from "./selectField";

	interface Props {
		options: SelectOption[];
		/** two-way; the selected option's value. Pass without `bind:` when the truth lives
		 *  elsewhere — a ref in the url, say — and let `onSelect` do the navigating. */
		value?: string;
		onSelect?: (value: string) => void;
		/** trigger text while nothing is selected */
		placeholder?: string;
		searchPlaceholder?: string;
		emptyLabel?: string;
		/** names the trigger and the listbox for screen readers */
		label?: string;
		/** take filtering over, e.g. to query a server: `options` is then rendered as given */
		onSearch?: (query: string) => void;
		loading?: boolean;
		error?: boolean;
		disabled?: boolean;
		align?: SelectAlign;
		/** goes on the trigger, so a Field's label can point at it */
		id?: string;
		class?: string;
		panelClass?: string;
		/** pinned below the list, e.g. a "view all branches" link */
		footer?: Snippet;
	}

	let {
		options,
		value = $bindable(""),
		onSelect,
		placeholder = "Select an option",
		searchPlaceholder = "Filter…",
		emptyLabel = "No matches",
		label,
		onSearch,
		loading = false,
		error = false,
		disabled = false,
		align = "left",
		id,
		class: className,
		panelClass,
		footer
	}: Props = $props();

	const panelId = $props.id();
	const listId = `${panelId}-list`;
	const optionId = (index: number) => `${panelId}-option-${index}`;
	// anchor-name has to be a custom ident and unique per instance, so it cannot live in the
	// stylesheet. the invoker is the implicit anchor in principle, but naming it explicitly is
	// what actually holds: with only the implicit one the panel lands in the viewport's corner.
	const anchorName = `--combobox-${panelId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;

	let open = $state(false);
	let search = $state("");
	let activeIndex = $state(-1);
	// only keyboard navigation scrolls the list: doing it on hover yanks the row out from
	// under the pointer
	let scrollActiveIntoView = false;

	let triggerEl = $state<HTMLButtonElement>();
	let panelEl = $state<HTMLElement>();
	let searchEl = $state<HTMLInputElement>();
	let listEl = $state<HTMLElement>();

	const query = $derived(search.trim().toLowerCase());
	const visible = $derived(
		onSearch || query === ""
			? options
			: options.filter(
					(option) =>
						(option.label ?? option.value).toLowerCase().includes(query) ||
						option.value.toLowerCase().includes(query)
				)
	);

	// a heading per run of same-group options, so grouping falls out of the array's order
	// instead of needing a nested shape
	const rows = $derived(
		visible.map((option, index) => ({
			option,
			index,
			heading: option.group !== visible[index - 1]?.group ? option.group : undefined
		}))
	);

	const selectable = $derived(
		visible.reduce<number[]>((indexes, option, index) => {
			if (!option.disabled) indexes.push(index);
			return indexes;
		}, [])
	);

	const selected = $derived(options.find((option) => option.value === value));
	// a value with no matching option still deserves a label: the ref selector can be sitting
	// on a commit sha, or on a branch past the point the list was capped
	const triggerLabel = $derived(selected ? (selected.label ?? selected.value) : value);

	const classes = $derived(combobox({ placeholder: triggerLabel === "" || undefined }));
	const field = $derived(selectField({ error, disabled }));

	const setActive = (index: number, fromKeyboard: boolean) => {
		activeIndex = index;
		scrollActiveIntoView = fromKeyboard;
	};

	const move = (delta: number) => {
		if (selectable.length === 0) return;
		const current = selectable.indexOf(activeIndex);
		// from nowhere, ArrowDown lands on the first option and ArrowUp on the last
		const next =
			current === -1
				? delta > 0
					? 0
					: selectable.length - 1
				: (((current + delta) % selectable.length) + selectable.length) % selectable.length;
		setActive(selectable[next], true);
	};

	const close = () => {
		if (open) panelEl?.hidePopover();
	};

	const select = (option: SelectOption) => {
		if (option.disabled) return;
		value = option.value;
		onSelect?.(option.value);
		close();
		triggerEl?.focus();
	};

	const onToggle = (event: Event) => {
		const { newState } = event as ToggleEvent;
		open = newState === "open";
		if (!open) return;

		// every open starts from a clean slate, with the current selection under the cursor
		search = "";
		onSearch?.("");
		const selectedIndex = options.findIndex((option) => option.value === value);
		setActive(selectable.includes(selectedIndex) ? selectedIndex : (selectable[0] ?? -1), true);
		void tick().then(() => searchEl?.focus());
	};

	const onTriggerKeydown = (event: KeyboardEvent) => {
		if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
			event.preventDefault();
			triggerEl?.click();
		}
	};

	const onSearchInput = (event: Event) => {
		search = (event.currentTarget as HTMLInputElement).value;
		onSearch?.(search.trim());
		// the old active row is gone or has moved: start over at the top of what is left
		setActive(selectable[0] ?? -1, true);
	};

	const onSearchKeydown = (event: KeyboardEvent) => {
		switch (event.key) {
			case "ArrowDown":
				event.preventDefault();
				move(1);
				break;
			case "ArrowUp":
				event.preventDefault();
				move(-1);
				break;
			case "Home":
				event.preventDefault();
				if (selectable.length > 0) setActive(selectable[0], true);
				break;
			case "End":
				event.preventDefault();
				if (selectable.length > 0) setActive(selectable[selectable.length - 1], true);
				break;
			case "Enter": {
				// a bare Enter in a form would submit it, whether or not a row is active
				event.preventDefault();
				const option = visible[activeIndex];
				if (option) select(option);
				break;
			}
			case "Escape":
				event.preventDefault();
				close();
				triggerEl?.focus();
				break;
			case "Tab":
				close();
				break;
		}
	};

	$effect(() => {
		if (!open || !scrollActiveIntoView || activeIndex < 0) return;
		listEl?.querySelector(`#${CSS.escape(optionId(activeIndex))}`)?.scrollIntoView({
			block: "nearest"
		});
	});
</script>

<div class={classes.root({ class: className })}>
	<button
		bind:this={triggerEl}
		{id}
		type="button"
		{disabled}
		class={field.box({ class: classes.trigger() })}
		popovertarget={panelId}
		popovertargetaction="toggle"
		onkeydown={onTriggerKeydown}
		aria-haspopup="listbox"
		aria-expanded={open}
		aria-controls={panelId}
		aria-label={label}
		style="anchor-name: {anchorName}"
	>
		<span class={classes.value()}>{triggerLabel === "" ? placeholder : triggerLabel}</span>
		<ChevronsUpDown class={field.chevron({ class: "size-3.5" })} aria-hidden="true" />
	</button>

	<div
		bind:this={panelEl}
		id={panelId}
		data-align={align}
		popover="auto"
		ontoggle={onToggle}
		style="position-anchor: {anchorName}"
		class={classes.panel({ class: panelClass })}
	>
		<div class={classes.search()}>
			<Input
				bind:element={searchEl}
				type="text"
				value={search}
				oninput={onSearchInput}
				onkeydown={onSearchKeydown}
				placeholder={searchPlaceholder}
				iconLeft={Search}
				{loading}
				autocomplete="off"
				spellcheck="false"
				role="combobox"
				aria-expanded="true"
				aria-controls={listId}
				aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
				aria-label={label ? `${label} filter` : "Filter options"}
			/>
		</div>

		<div bind:this={listEl} id={listId} role="listbox" aria-label={label} class={classes.list()}>
			{#each rows as { option, index, heading } (option.value)}
				{#if heading}
					<div class={classes.groupHeading()}>{heading}</div>
				{/if}
				<button
					id={optionId(index)}
					type="button"
					role="option"
					tabindex={-1}
					class={classes.option()}
					data-active={index === activeIndex || undefined}
					aria-selected={option.value === value}
					aria-disabled={option.disabled}
					onclick={() => select(option)}
					onpointermove={() => {
						if (!option.disabled && index !== activeIndex) setActive(index, false);
					}}
				>
					{#if option.value === value}
						<Check class={classes.check()} aria-hidden="true" />
					{:else}
						<span class="size-4 shrink-0" aria-hidden="true"></span>
					{/if}
					<span class={classes.optionLabel()}>{option.label ?? option.value}</span>
					{#if option.hint}
						<span class={classes.hint()}>{option.hint}</span>
					{/if}
				</button>
			{:else}
				<p class={classes.empty()}>{emptyLabel}</p>
			{/each}
		</div>

		{#if footer}
			<div class={classes.footer()}>{@render footer()}</div>
		{/if}
	</div>
</div>

<style>
	/* anchor positioning went Baseline in early 2026; without it the popover falls back to the
	   UA's centred position, the same trade-off Dropdown already takes. the trigger is the
	   implicit anchor because it invoked the popover through popovertarget. */
	@supports (position-area: bottom) and (position-try: flip-block) {
		.combobox-panel {
			/* the gap to the trigger — margin, because position-area owns inset */
			margin: 0.25rem;
			position-try: flip-block;
		}

		.combobox-panel[data-align="left"] {
			position-area: block-end span-inline-end;
		}

		.combobox-panel[data-align="right"] {
			position-area: block-end span-inline-start;
		}
	}

	.combobox-panel {
		opacity: 0;
		scale: 0.98;
		transition:
			opacity 120ms ease-out,
			scale 120ms ease-out,
			overlay 120ms allow-discrete,
			display 120ms allow-discrete;
	}

	.combobox-panel:popover-open {
		opacity: 1;
		scale: 1;
	}

	@starting-style {
		.combobox-panel:popover-open {
			opacity: 0;
			scale: 0.98;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.combobox-panel {
			transition: none;
		}
	}
</style>
