<script lang="ts" module>
	/**
	 * formatTime returns a human readable 12-hour based string.
	 *
	 * @param time The time to format, can be null or undefined.
	 * @param fallback Optional fallback string to return if time is null or undefined.
	 *                 If not provided, an empty string will be returned.
	 *                 If provided, it will be returned when time is null or undefined.
	 * @returns The time in 12-hour format or the fallback string if undefined.
	 */
	export const formatTime = (time: Time | null | undefined, fallback?: string): string => {
		if (!time) return fallback ?? '';
		return moment(time.toString(), 'HH:mm:ss').format('h:mm A');
	};
</script>

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

	interface Props {
		/**
		 * Name of the input for form integration, form will receive ISO 8601
		 * formatted string.
		 */
		name?: string;
		/** Label for the input */
		label?: string;
		/** Bindable Time value */
		value?: Time | null;
		/** Bindable formatted time 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;
		/** Controls visibility for a confirmation text below the input */
		showConfirm?: boolean;
		/** Whether to use compact styling */
		compact?: boolean;
		/** Class is applied to the root container (div element) */
		class?: ClassValue | null | undefined;
		/** Triggered whenever the time value is changed by the user */
		onchange?: (details: { time: Time | null; formattedTime: string }) => void;
	}

	let {
		name,
		label,
		value = $bindable(null),
		formattedValue = $bindable(''),
		required,
		invalidMessage = 'Please select a time',
		showConfirm = false,
		compact = false,
		class: classValue,
		onchange
	}: Props = $props();

	type ampmKey = 'AM' | 'PM';
	type componentKey = 'hour' | 'minute';

	const id = $derived(generateIdentifier('time-input', name));
	const values: Record<componentKey, string> = $derived.by(() => {
		let hour = '';
		if (value) {
			if (value.hour > 12) {
				hour = (value.hour - 12).toString();
			} else if (value.hour === 0) {
				hour = '12'; // 12 AM case
			} else {
				hour = value.hour.toString();
			}
		}

		return {
			hour,
			minute: !value ? '' : prefixZero(value.minute.toString())
		};
	});
	let ampm: ampmKey = $derived.by(() => {
		if (!value) return 'AM';
		return value.hour >= 12 ? 'PM' : 'AM';
	});
	let valid: boolean = $state(true);

	let hiddenInput: HTMLInputElement;

	const focusList = new FocusManager();
	const hour24Pattern = /^(0?[0-9]|1[0-9]|2[0-3])$/;
	const minutePattern = /^(0?[0-9]|[1-5][0-9])$/;
	const keydownValidatorOpts = { constrain: true };

	/**
	 * toggleAMPM toggles the current AM/PM state
	 */
	const toggleAMPM = () => {
		if (ampm === 'AM') {
			ampm = 'PM';
		} else {
			ampm = 'AM';
		}
	};

	/**
	 * updateValue updates `value` with the current time in 24-hour format.
	 * If any component is invalid or blank, it sets `value` to an empty string.
	 */
	const updateValue = (override?: Partial<Record<componentKey, string>>) => {
		let hourValue = parseInt(override?.hour || values.hour);
		let minuteValue = parseInt(override?.minute || values.minute);

		// if no hour, clear value
		if (isNaN(hourValue)) {
			value = null;
			formattedValue = '';
			updateHiddenInput();
			return;
		}

		// if hour is greater than 12, set to 12-hour format
		if (hourValue > 12) {
			hourValue -= 12;
			ampm = 'PM';
		}

		// convert to 24-hour format
		if (ampm === 'PM' && hourValue < 12) hourValue += 12;
		else if (ampm === 'AM' && hourValue >= 12) hourValue -= 12;
		// default minutes to zero if empty
		if (isNaN(minuteValue)) {
			minuteValue = 0;
		}

		value = new Time(hourValue, minuteValue);
		updateHiddenInput();

		// update formatted value
		formattedValue = formatTime(value);
	};

	/**
	 * updateHiddenInput pushes the current value to the hidden input and triggers
	 * a keyup event to allow validation to detect the change.
	 */
	const updateHiddenInput = () => {
		hiddenInput.value = value?.toString() ?? '';
		hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
		onchange?.({ time: value, formattedTime: formattedValue });
	};

	const components: Record<
		componentKey,
		{
			max: number;
			pattern: RegExp;
			onkeydown?: (e: KeyboardEvent) => void;
			oninput?: (e: Event) => void;
			onblur?: (e: FocusEvent) => void;
			divider?: string;
			placeholder?: string;
		}
	> = {
		/** Hour Input */
		hour: {
			max: 24,
			pattern: hour24Pattern,
			onkeydown: (e: KeyboardEvent) => {
				const target = targetMust<HTMLInputElement>(e);

				if (e.key === ':' && target.value.length !== 0) {
					focusList.focusNext();
					e.preventDefault();
				}

				if (e.key === 'ArrowUp') {
					incrementValue(target, { max: 12, start: 1 });
					if (target.value === '12') toggleAMPM();
				} else if (e.key === 'ArrowDown') {
					decrementValue(target, { max: 12, start: 1 });
					if (target.value === '11') toggleAMPM();
				} else {
					return;
				}

				updateValue({ hour: target.value });
				e.preventDefault();
			},
			oninput: (e) => {
				const target = targetMust<HTMLInputElement>(e);
				if (target.value.length === 2) {
					focusList.focusNext();
				}
			},
			onblur: (e) => {
				const target = targetMust<HTMLInputElement>(e);
				updateValue({ hour: target.value });
			},
			divider: ':'
		},
		/** Minute Input */
		minute: {
			max: 59,
			pattern: minutePattern,
			onkeydown: (e: KeyboardEvent) => {
				const target = targetMust<HTMLInputElement>(e);

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

				updateValue({ minute: target.value });
				e.preventDefault();
			},
			onblur: (e: FocusEvent) => {
				const target = targetMust<HTMLInputElement>(e);
				updateValue({ minute: target.value });
				if (target.value.length === 1) {
					target.value = '0' + target.value;
				}
			}
		}
	};
</script>

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

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

	<div class="flex items-center">
		<!-- Hour, Minute Inputs -->
		{#each ['hour', 'minute'] as componentKey[] as key}
			{@const opts = components[key]}
			<StyledRawInput
				class={[
					'text-center text-xl focus:placeholder:text-transparent',
					compact ? 'h-9 w-16!' : 'h-16 w-24!'
				]}
				value={values[key]}
				inputmode="numeric"
				pattern="[0-9]*"
				placeholder={opts.placeholder ?? '00'}
				max={opts.max}
				min={0}
				validate={{ pattern: opts.pattern }}
				use={(n) => liveValidator(n, keydownValidatorOpts)}
				forceInvalid={!valid}
				onkeydown={opts.onkeydown}
				oninput={opts.oninput}
				onblur={opts.onblur}
				{@attach focusList.input({ selectAll: true })}
			/>

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

		<!-- AM/PM Picker -->
		<div class={['flex', compact ? 'ml-1.5' : 'ml-3 flex-col']}>
			{#each ['AM', 'PM'] as (typeof ampm)[] as shade, index}
				<button
					type="button"
					class={[
						compact ? 'px-2 py-0.5' : 'px-3 py-1',
						'border-sui-accent dark:border-sui-accent/50 cursor-pointer border font-medium',
						ampm === shade
							? [
									'bg-sui-accent text-sui-background dark:bg-sui-accent/30 dark:text-sui-background/90',
									'ring-sui-text z-10 ring-1 dark:ring-blue-300'
								]
							: 'bg-white',
						index === 0
							? compact
								? 'rounded-l-sm'
								: 'rounded-t-sm'
							: compact
								? 'rounded-r-sm'
								: 'rounded-b-sm'
					]}
					onclick={() => {
						ampm = shade;
						updateValue();
					}}
					{@attach focusList.button()}
				>
					{shade}
				</button>
			{/each}
		</div>
	</div>

	<div class={['opacity-0 transition-opacity', (!valid || showConfirm) && 'opacity-100']}>
		<Label for={id} error={!valid}>
			{#if !valid}
				{invalidMessage}
			{:else if showConfirm}
				{formattedValue !== '' ? `See you at ${formattedValue}!` : ''}
			{/if}
		</Label>
	</div>
</div>
