<script lang="ts" module>
	/**
	 * formatDuration returns a human readable string for a DateTimeDuration.
	 *
	 * @param duration The duration to format, can be null or undefined.
	 * @param fallback Optional fallback string to return if duration is null or undefined.
	 *                 If not provided, an empty string will be returned.
	 *                 If provided, it will be returned when duration is null or undefined.
	 * @returns The duration in human readable format or the fallback string if undefined.
	 */
	export const formatDuration = (
		duration: DateTimeDuration | null | undefined,
		fallback?: string
	): string => {
		if (!duration) return fallback ?? '';
		const parts: string[] = [];
		if (duration.days) parts.push(`${duration.days}d`);
		if (duration.hours) parts.push(`${duration.hours}h`);
		if (duration.minutes) parts.push(`${duration.minutes}m`);
		if (duration.seconds) parts.push(`${duration.seconds}s`);
		return parts.join(' ');
	};

	/**
	 * durationToISO8601 converts a DateTimeDuration to an ISO 8601 duration string.
	 * @param duration The duration to convert.
	 * @returns The ISO 8601 duration string.
	 */
	export const durationToISO8601 = (duration: DateTimeDuration): string => {
		let str = 'P';
		if (duration.days) str += `${duration.days}D`;
		if (duration.hours || duration.minutes || duration.seconds) str += 'T';
		if (duration.hours) str += `${duration.hours}H`;
		if (duration.minutes) str += `${duration.minutes}M`;
		if (duration.seconds) str += `${duration.seconds}S`;
		return str;
	};

	/**
	 * iso8601ToDuration converts an ISO 8601 duration string to a DateTimeDuration.
	 * @param str The ISO 8601 duration string.
	 * @returns The DateTimeDuration object.
	 */
	export const iso8601ToDuration = (str: string): DateTimeDuration => {
		const regex = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
		const matches = str.match(regex);
		if (!matches) {
			throw new Error('Invalid ISO 8601 duration string');
		}
		return {
			days: matches[1] ? parseInt(matches[1], 10) : 0,
			hours: matches[2] ? parseInt(matches[2], 10) : 0,
			minutes: matches[3] ? parseInt(matches[3], 10) : 0,
			seconds: matches[4] ? parseInt(matches[4], 10) : 0
		};
	};
</script>

<script lang="ts">
	import type { DateTimeDuration } from '@internationalized/date';
	import type { ClassValue } from 'svelte/elements';
	import { FocusManager } from './focus';
	import { generateIdentifier, prefixZero, targetMust } from './util';
	import { decrementValue, incrementValue } from './numeric-utils';
	import Label from './Label.svelte';
	import { liveValidator, validate } from '@svelte-toolkit/validate';
	import StyledRawInput from './StyledRawInput.svelte';

	interface Props {
		/**
		 * Name of the input for form integration, form will receive ISO 8601
		 * formatted string.
		 */
		name?: string;
		/** Label for the input */
		label?: string;
		/** Precision for duration input */
		precision?: { min: componentKey; max: componentKey };
		/** Bindable DateTimeDuration value */
		value?: DateTimeDuration | null;
		/** Bindable formatted duration string, always matches current value and cannot be set */
		formattedValue?: string;
		/** Whether the input is required */
		required?: boolean;
		/** Message to show when the input is invalid */
		invalidMessage?: string;
		/** Whether to use compact styling */
		compact?: boolean;
		/** Additional classes are applied to the root container (div element) */
		class?: ClassValue | null;
		/** Triggered whenever the duration value is changed by the user */
		onchange?: (details: { duration: DateTimeDuration | null; formattedDuration: string }) => void;
	}

	let {
		name,
		label,
		precision = { max: 'minutes', min: 'hours' },
		value = $bindable(null),
		formattedValue = $bindable(''),
		required = false,
		invalidMessage = 'Please enter a duration',
		compact,
		class: classValue,
		onchange
	}: Props = $props();

	const COMPONENT_KEYS = ['days', 'hours', 'minutes', 'seconds'] as const;
	type componentKey = (typeof COMPONENT_KEYS)[number];
	/** selected components are controlled by min and max precision */
	let selectedComponents: componentKey[] = $derived.by(() => {
		const minIndex = COMPONENT_KEYS.indexOf(precision.min);
		const maxIndex = COMPONENT_KEYS.indexOf(precision.max);
		if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
			throw new Error('Invalid precision settings for DurationInput');
		}
		return COMPONENT_KEYS.slice(minIndex, maxIndex + 1);
	});

	const focusList = new FocusManager();
	const numericPattern = /^\d*$/;
	const keydownValidatorOpts = { constrain: true };

	const id = $derived(generateIdentifier('duration-input', name));
	const values: Record<componentKey, string> = $derived.by(() => {
		return COMPONENT_KEYS.reduce(
			(acc, key) => {
				if (!value || value[key] === 0) {
					acc[key] = '';
				} else {
					acc[key] = prefixZero(value[key]?.toString() || '0');
				}
				return acc;
			},
			{} as Record<componentKey, string>
		);
	});
	let valid: boolean = $state(true);
	let hiddenInput: HTMLInputElement;

	/** updateValue updates `value` with the current values from the input fields */
	const updateValue = (override?: Partial<Record<componentKey, string>>) => {
		const newValues: Record<componentKey, number> = {
			days: parseInt(override?.days ?? values.days) || 0,
			hours: parseInt(override?.hours ?? values.hours) || 0,
			minutes: parseInt(override?.minutes ?? values.minutes) || 0,
			seconds: parseInt(override?.seconds ?? values.seconds) || 0
		};

		let zero = true;
		for (const key of selectedComponents) {
			if (newValues[key] !== 0) {
				zero = false;
				break;
			}
		}

		if (zero) {
			value = null;
		} else if (!value) {
			// Create a new value object if we don't have one yet
			value = newValues;
		} else {
			// Otherwise apply all new values to existing value object
			for (const [k, v] of Object.entries(newValues)) {
				value[k as componentKey] = v;
			}
		}

		updateHiddenInput();
		// update formatted value
		formattedValue = formatDuration(value);
	};

	/**
	 * updateHiddenInput pushes the current value to the hidden input in ISO
	 * 8601 duration format and triggers a keyup event to allow validation to
	 * detect the change.
	 */
	const updateHiddenInput = () => {
		// TODO: Make sure this formats correctly
		hiddenInput.value = value ? durationToISO8601(value) : '';
		hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
		onchange?.({ duration: value, formattedDuration: formattedValue });
		console.log('updated hidden input', hiddenInput.value);
	};

	/**
	 * buildEventHandlers generates a fairly generic onkeydown and onblur
	 * handler pair for a given component key. Start is always 0, max is
	 * unset for all keys. No special handling per key.
	 */
	const buildEventHandlers = (key: componentKey) => {
		return {
			onkeydown: (e: KeyboardEvent) => {
				const target = targetMust<HTMLInputElement>(e);

				if (e.key === 'ArrowUp') {
					incrementValue(target, { start: 0 });
				} else if (e.key === 'ArrowDown') {
					decrementValue(target, { start: 0 });
				} else {
					return;
				}

				updateValue({ [key]: target.value });
				e.preventDefault();
			},
			onblur: (e: FocusEvent) => {
				const target = targetMust<HTMLInputElement>(e);
				updateValue({ [key]: target.value });
			}
		};
	};

	/** component definitions */
	const components: Record<
		componentKey,
		{
			pattern: RegExp;
			onkeydown?: (e: KeyboardEvent) => void;
			oninput?: (e: Event) => void;
			onblur?: (e: FocusEvent) => void;
			divider?: string;
			placeholder?: string;
		}
	> = {
		days: {
			pattern: numericPattern,
			...buildEventHandlers('days')
		},
		hours: {
			pattern: numericPattern,
			divider: ':',
			placeholder: '0',
			...buildEventHandlers('hours')
		},
		minutes: {
			pattern: numericPattern,
			divider: ':',
			placeholder: '0',
			...buildEventHandlers('minutes')
		},
		seconds: {
			pattern: numericPattern,
			placeholder: '0',
			...buildEventHandlers('seconds')
		}
	};
</script>

<div class={classValue}>
	{#if label}
		<Label for={id}>{label}</Label>
	{/if}

	<!-- Hidden input stores the selected duration in ISO 8601 format -->
	<input
		type="hidden"
		{id}
		{name}
		use:validate={{ required, autovalOnInvalid: true }}
		onvalidate={(e) => (valid = e.detail.valid)}
		bind:this={hiddenInput}
	/>

	<div class="flex items-start">
		{#each selectedComponents as componentKey[] as key, index}
			{@const opts = components[key]}
			{@const partID = generateIdentifier('duration-input-part', key)}
			<div class="flex flex-col items-center">
				<StyledRawInput
					id={partID}
					class={[
						'text-center text-xl focus:placeholder:text-transparent',
						compact ? 'h-9 w-16! placeholder:text-base' : 'h-16 w-24!'
					]}
					value={values[key]}
					inputmode="numeric"
					pattern="[0-9]*"
					placeholder={opts.placeholder}
					validate={{ pattern: opts.pattern }}
					use={(n) => liveValidator(n, keydownValidatorOpts)}
					onkeydown={opts.onkeydown}
					oninput={opts.oninput}
					onblur={opts.onblur}
					{@attach focusList.input({ selectAll: true })}
				/>

				<Label for={partID} class={['capitalize', compact && '-mt-0.5']}>{key}</Label>
			</div>

			{#if opts.divider && index < selectedComponents.length - 1}
				<span class={[compact ? 'mx-1 text-2xl' : 'mx-2 mt-3 text-3xl']}>
					{opts.divider}
				</span>
			{/if}
		{/each}
	</div>
</div>
