<script lang="ts" context="module">
	import type { IValue } from '@upnd/upend/types';
	import type { UpEntry } from '@upnd/upend';
	import UpEntryComponent from '../display/UpEntry.svelte';

	export type SELECTOR_TYPE =
		| 'Address'
		| 'LabelledAddress'
		| 'NewAddress'
		| 'Attribute'
		| 'NewAttribute'
		| 'String'
		| 'Number'
		| 'Null';

	export type SelectorValue = {
		t: SELECTOR_TYPE;
	} & (
		| {
				t: 'Address';
				c: Address;
				entry?: UpEntry;
				labels?: string[];
		  }
		| {
				t: 'Attribute';
				name: string;
				labels?: string[];
		  }
		| {
				t: 'String';
				c: string;
		  }
		| {
				t: 'Number';
				c: number;
		  }
		| {
				t: 'Null';
				c: null;
		  }
	);

	export type SelectorOption =
		| SelectorValue
		| { t: 'NewAddress'; c: string }
		| { t: 'NewAttribute'; name: string; label: string };

	export async function selectorValueAsValue(value: SelectorValue): Promise<IValue> {
		switch (value.t) {
			case 'Address':
				return {
					t: 'Address',
					c: value.c
				};
			case 'Attribute':
				return {
					t: 'Address',
					c: await api.componentsToAddress({ t: 'Attribute', c: value.name })
				};
			case 'String':
				return {
					t: 'String',
					c: value.c
				};
			case 'Number':
				return {
					t: 'Number',
					c: value.c
				};
			case 'Null':
				return {
					t: 'Null',
					c: null
				};
		}
	}
</script>

<script lang="ts">
	import { debounce } from 'lodash';
	import { createEventDispatcher } from 'svelte';
	import type { UpListing } from '@upnd/upend';
	import type { Address } from '@upnd/upend/types';
	import { baseSearchOnce, createLabelled } from '$lib/util/search';
	import UpObject from '../display/UpObject.svelte';
	import IconButton from './IconButton.svelte';
	import Input from './Input.svelte';
	import { matchSorter } from 'match-sorter';
	import api from '$lib/api';
	import { ATTR_LABEL } from '@upnd/upend/constants';
	import { i18n } from '$lib/i18n';
	import debug from 'debug';
	import Spinner from './Spinner.svelte';

	const dispatch = createEventDispatcher();
	const dbg = debug('kestrel:Selector');
	let selectorEl: HTMLElement;

	export let MAX_OPTIONS = 25;

	export let types: SELECTOR_TYPE[] = ['Address', 'NewAddress', 'Attribute', 'String', 'Number'];
	export let attributeOptions: string[] | undefined = undefined;
	export let emptyOptions: SelectorOption[] | undefined = undefined;
	export let placeholder = '';
	export let disabled = false;
	export let keepFocusOnSet = false;

	export let initial: SelectorValue | undefined = undefined;
	let inputValue = '';
	let updating = false;

	$: setInitial(initial);
	function setInitial(initial: SelectorValue | undefined) {
		if (initial) {
			switch (initial.t) {
				case 'Address':
				case 'String':
					inputValue = initial.c;
					break;
				case 'Attribute':
					inputValue = initial.name;
					break;
				case 'Number':
					inputValue = String(initial.c);
					break;
			}
		}
	}

	let current: (SelectorOption & { t: 'Address' | 'Attribute' | 'String' | 'Number' }) | undefined =
		undefined;

	export function reset() {
		inputValue = '';
		current = undefined;
		dispatch('input', current);
	}

	let options: SelectorOption[] = [];
	let searchResult: UpListing | undefined = undefined;
	let optionsAbortController: AbortController | undefined = undefined;
	const updateOptions = debounce(async (query: string, doSearch: boolean) => {
		updating = true;
		let result: SelectorOption[] = [];

		optionsAbortController?.abort();
		const abortController = new AbortController();
		optionsAbortController = abortController;

		if (query.length === 0 && emptyOptions !== undefined) {
			options = emptyOptions;
			updating = false;
			return;
		}

		try {
			if (types.includes('Number')) {
				const number = parseFloat(query);
				if (!Number.isNaN(number)) {
					result.push({
						t: 'Number',
						c: number
					});
				}
			}

			if (types.includes('String') && query.length) {
				result.push({
					t: 'String',
					c: query
				});
			}

			options = result;

			if (types.includes('Address') || types.includes('LabelledAddress')) {
				if (doSearch) {
					if (emptyOptions === undefined || query.length > 0) {
						searchResult = await baseSearchOnce(query, { abortController });
					} else {
						searchResult = undefined;
					}
				}

				let exactHits = Object.entries(addressToLabels)
					.filter(([_, labels]) => labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()))
					.map(([addr, _]) => addr);

				if (exactHits.length) {
					exactHits.forEach((addr) =>
						result.push({
							t: 'Address',
							c: addr,
							labels: addressToLabels[addr],
							entry: undefined
						})
					);
				} else if (query.length && types.includes('NewAddress')) {
					result.push({
						t: 'NewAddress',
						c: query
					});
				}

				let validOptions = (searchResult?.entries || []).filter(
					(e) => !exactHits.includes(e.entity)
				);
				// only includes LabelledAddress
				if (!types.includes('Address')) {
					validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
				}

				const sortedOptions = matchSorter(validOptions, inputValue, {
					keys: ['value.c', (i) => addressToLabels[i.entity]?.join(' ')]
				});

				for (const entry of sortedOptions) {
					const common = {
						t: 'Address' as const,
						c: entry.entity
					};
					if (entry.attribute == ATTR_LABEL && entry.value.c) {
						result.push({
							...common,
							labels: [entry.value.c.toString()]
						});
					} else {
						result.push({ ...common, entry });
					}
				}
			}

			if (types.includes('Attribute')) {
				let selectorOptions: SelectorOption[] = [];
				const allAttributes = await api.fetchAllAttributes({ abortController });
				const attributes = attributeOptions
					? allAttributes.filter((attr) => attributeOptions!.includes(attr.name))
					: allAttributes;
				if (emptyOptions === undefined || query.length > 0) {
					selectorOptions.push(
						...attributes
							.filter(
								(attr) =>
									attr.name.toLowerCase().includes(query.toLowerCase()) ||
									attr.labels.some((label) => label.toLowerCase().includes(query.toLowerCase()))
							)
							.map(
								(attribute) =>
									({
										t: 'Attribute',
										...attribute
									}) as SelectorOption
							)
					);
				}

				selectorOptions = matchSorter(selectorOptions, inputValue, {
					keys: [(i) => (i.t === 'Attribute' && i.labels) || [], 'name']
				});

				const attributeToCreate = query.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_');
				if (
					!attributeOptions &&
					query &&
					!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
					types.includes('NewAttribute')
				) {
					selectorOptions.push({
						t: 'NewAttribute',
						name: attributeToCreate,
						label: query
					});
				}

				result.push(...selectorOptions);
			}
		} catch (e: unknown) {
			if (e && (e as Error).name !== 'AbortError') {
				throw e;
			}
		}

		if (abortController.signal.aborted) {
			dbg('%o Aborted search', selectorEl);
			return;
		}

		options = result;
		updating = false;
	}, 200);

	$: dbg('%o Options: %O', selectorEl, options);

	$: {
		if (inputFocused) {
			updateOptions(inputValue, true);
			addressToLabels = {};
		}
	}

	let addressToLabels: { [key: string]: string[] } = {};
	function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
		addressToLabels[address] = ev.detail;
		updateOptions(inputValue, false);
	}

	async function set(option: SelectorOption) {
		dbg('%o Setting option %O', selectorEl, option);

		switch (option.t) {
			case 'Address':
				inputValue = option.c;
				current = option;
				break;
			case 'NewAddress':
				{
					const addr = await createLabelled(option.c);
					inputValue = addr;
					current = {
						t: 'Address',
						c: addr,
						labels: [option.c]
					};
				}

				break;
			case 'Attribute':
				inputValue = option.name;
				current = option;
				break;
			case 'NewAttribute':
				inputValue = option.name;

				{
					const address = await api.componentsToAddress({
						t: 'Attribute',
						c: option.name
					});
					await api.putEntityAttribute(address, ATTR_LABEL, {
						t: 'String',
						c: option.label
					});

					current = {
						t: 'Attribute',
						name: option.name,
						labels: [option.label]
					};
				}
				break;
			case 'String':
				inputValue = option.c;
				current = option;
				break;
			case 'Number':
				inputValue = String(option.c);
				current = option;
				break;
		}

		dbg('%o Result set value: %O', selectorEl, current);
		dispatch('input', current);

		options = [];
		optionFocusIndex = -1;
		hover = false;
		if (keepFocusOnSet) {
			focus();
		}
	}

	let listEl: HTMLUListElement;
	let optionFocusIndex = -1;
	function handleArrowKeys(ev: KeyboardEvent) {
		if (!options.length) {
			return;
		}

		const optionEls = Array.from(listEl.children) as HTMLLIElement[];
		let targetIndex = optionEls.findIndex((el) => document.activeElement === el);
		switch (ev.key) {
			case 'ArrowDown':
				targetIndex += 1;

				// pressed down on last
				if (targetIndex >= optionEls.length) {
					targetIndex = 0;
				}
				break;
			case 'ArrowUp':
				targetIndex -= 1;

				// pressed up on input
				if (targetIndex == -2) {
					targetIndex = optionEls.length - 1;
				}

				// pressed up on first
				if (targetIndex == -1) {
					focus();
					return;
				}
				break;
			default:
				return; // early return, stop processing
		}

		if (optionEls[targetIndex]) {
			optionEls[targetIndex].focus();
		}
	}

	let input: Input;
	export function focus() {
		// dbg("%o Focusing input", selectorEl);
		input.focus();
	}

	let inputFocused = false;
	let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
	$: visible =
		(inputFocused || hover || optionFocusIndex > -1) && Boolean(options.length || updating);
	$: dispatch('focus', inputFocused || hover || optionFocusIndex > -1);

	$: dbg('%o focus = %s, hover = %s, visible = %s', selectorEl, inputFocused, hover, visible);
</script>

<div class="selector" bind:this={selectorEl}>
	{#if current?.t === 'Address' && inputValue.length > 0}
		<div class="input">
			<div class="label">
				<UpObject link address={String(current.c)} />
			</div>
			<IconButton name="x" on:click={() => (inputValue = '')} />
		</div>
	{:else}
		<Input
			bind:this={input}
			bind:value={inputValue}
			on:focusChange={(ev) => (inputFocused = ev.detail)}
			on:keydown={handleArrowKeys}
			{disabled}
			{placeholder}
		>
			<slot name="prefix" slot="prefix" />
		</Input>
	{/if}
	<ul
		class="options"
		class:visible
		on:mouseenter={() => (hover = true)}
		on:mouseleave={() => (hover = false)}
		bind:this={listEl}
	>
		{#if updating}
			<li><Spinner centered /></li>
		{/if}
		{#each options.slice(0, MAX_OPTIONS) as option, idx}
			<!-- svelte-ignore a11y-no-noninteractive-tabindex a11y-no-noninteractive-element-interactions -->
			<li
				tabindex="0"
				on:click={() => set(option)}
				on:mousemove={() => focus()}
				on:focus={() => (optionFocusIndex = idx)}
				on:blur={() => (optionFocusIndex = -1)}
				on:keydown={(ev) => {
					if (ev.key === 'Enter') {
						set(option);
					} else {
						handleArrowKeys(ev);
					}
				}}
			>
				{#if option.t === 'Address'}
					{@const address = option.c}
					{#if option.entry}
						<UpEntryComponent entry={option.entry} />
					{:else}
						<UpObject
							{address}
							labels={option.labels}
							on:resolved={(ev) => onAddressResolved(address, ev)}
						/>{/if}
				{:else if option.t === 'NewAddress'}
					<div class="content new">{option.c}</div>
					<div class="type">{$i18n.t('Create object')}</div>
				{:else if option.t === 'Attribute'}
					{#if option.labels?.length}
						<div class="content">
							{#each option.labels as label}
								<div class="label">{label}</div>
							{/each}
						</div>
						<div class="type">{option.name}</div>
					{:else}
						<div class="content">
							{option.name}
						</div>
					{/if}
				{:else if option.t === 'NewAttribute'}
					<div class="content">{option.label}</div>
					<div class="type">{$i18n.t('Create attribute')} ({option.name})</div>
				{:else}
					<div class="type">{option.t}</div>
					<div class="content">{option.c}</div>
				{/if}
			</li>
		{/each}
	</ul>
</div>

<style lang="scss">
	.selector {
		position: relative;
	}

	.input {
		display: flex;
		min-width: 0;
		.label {
			flex: 1;
			min-width: 0;
		}
	}

	.options {
		position: absolute;
		list-style: none;
		margin: 2px 0 0;
		padding: 0;
		border: 1px solid var(--foreground-lighter);
		width: 100%;
		border-radius: 4px;
		background: var(--background);

		visibility: hidden;
		opacity: 0;

		transition: opacity 0.2s;

		z-index: 99;

		&.visible {
			visibility: visible;
			opacity: 1;
		}

		li {
			cursor: pointer;
			padding: 0.25em;

			transition: background-color 0.1s;
			&:hover {
				background-color: var(--background-lighter);
			}

			&:focus {
				background-color: var(--background-lighter);
				outline: none;
			}

			.type,
			.content {
				display: inline-block;
			}

			.type {
				opacity: 0.8;
				font-size: smaller;
			}

			.label {
				display: inline-block;
			}
		}

		.content.new {
			padding: 0.25em;
		}
	}
</style>
