<script lang="ts">
	import { inputValidator, keydownValidator, liveValidator, validate } from '@repo/validate';
	import Label from './Label.svelte';
	import StyledRawInput from './StyledRawInput.svelte';
	import { onMount } from 'svelte';

	let {
		name,
		label,
		value = $bindable(''),
		required,
		invalidMessage = 'Please select a time'
	}: {
		name: string;
		label?: string;
		value?: string;
		required?: boolean;
		invalidMessage?: string;
	} = $props();

	let ampm: 'AM' | 'PM' = $state('AM');
	let type: HTMLInputElement['type'] = $state('number');
	let valid: boolean = $state(true);

	let hiddenInput: HTMLInputElement;
	let hourInput = $state<HTMLInputElement | null>(null);
	let minuteInput = $state<HTMLInputElement | null>(null);
	let amButton: HTMLButtonElement;
	let pmButton: HTMLButtonElement;

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

	/**
	 * incrementValue increments the value of the input by 1
	 * @param input The input element to increment
	 * @param max The maximum value of the input
	 * @param start The starting value of the input
	 * @returns true if the value was incremented, false if it looped back to 0
	 */
	const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
		const f = () => {
			if (input.value.length === 0) {
				input.value = start.toString();
				return true;
			}

			const value = parseInt(input.value);
			if (value === max) {
				input.value = start.toString();
				return false;
			} else if (value > max) {
				input.value = (value - max).toString();
				return false;
			} else {
				input.value = (value + 1).toString();
				return true;
			}
		};

		const res = f();
		selectEnd(input);
		return res;
	};

	/**
	 * decrementValue decrements the value of the input by 1
	 * @param input The input element to decrement
	 * @param max The maximum value of the input
	 * @param start The starting value of the input
	 * @returns true if the value was decremented, false if it looped back to max
	 */
	const decrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
		const f = () => {
			if (input.value.length === 0) {
				input.value = max.toString();
				return true;
			}

			const value = parseInt(input.value);
			if (value <= start) {
				input.value = max.toString();
				return false;
			} else {
				input.value = (value - 1).toString();
				return true;
			}
		};

		const res = f();
		selectEnd(input);
		return res;
	};

	/**
	 * selectEnd selects the end of the input value
	 * @param input The input element to select
	 */
	const selectEnd = (input: HTMLInputElement) => {
		if (input) {
			setTimeout(() => {
				input.setSelectionRange(input.value.length, input.value.length);
			}, 0);
		}
	};

	/**
	 * prefixZero adds a leading zero to the string if it is less than 10
	 * @param str The string to prefix
	 */
	const prefixZero = (str: string) => {
		if (str.length === 1) {
			return '0' + str;
		}
		return str;
	};

	/**
	 * 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 = () => {
		if (!hourInput || !minuteInput) return;

		let ampmLocal = ampm;
		let hourValue = parseInt(hourInput.value);
		let minuteValue = parseInt(minuteInput.value);

		if (isNaN(hourValue)) {
			value = '';
			updateHiddenInput();
			return;
		}

		// internally convert to 24-hour format
		if (hourValue > 12) {
			hourValue = hourValue - 12;
			ampmLocal = 'PM';
		}
		if (isNaN(minuteValue)) {
			minuteValue = 0;
		}

		value = `${prefixZero((hourValue + (ampmLocal === 'PM' ? 12 : 0)).toString())}:${prefixZero(minuteValue.toString())}`;
		updateHiddenInput();
	};

	/**
	 * 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;
		hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
	};

	onMount(() => {
		if (hourInput) {
			liveValidator(hourInput, keydownValidatorOpts);
		}
		if (minuteInput) {
			liveValidator(minuteInput, keydownValidatorOpts);
		}

		if ('userAgentData' in navigator) {
			if (!(navigator.userAgentData as any).mobile) type = 'text';
		}
	});
</script>

<div>
	{#if label}
		<Label for={name}>{label}</Label>
	{/if}

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

	<div class="flex items-center">
		<!-- Hour input -->
		<StyledRawInput
			placeholder="00"
			name={name + '_hour'}
			{type}
			max={24}
			min={0}
			class="time-input"
			validate={{ pattern: hour24Pattern }}
			onkeydown={(e) => {
				if (!hourInput) return;

				if (e.key === 'ArrowRight' && hourInput.selectionEnd === hourInput.value.length) {
					minuteInput?.focus();
				}

				if (e.key === 'ArrowUp') {
					if (!incrementValue(hourInput, 12, 1)) {
						toggleAMPM();
					}
				}
				if (e.key === 'ArrowDown') {
					if (!decrementValue(hourInput, 12, 1)) {
						toggleAMPM();
					}
				}
				if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
					e.preventDefault();
					return;
				}
			}}
			oninput={() => {
				updateValue();

				if (hourInput?.value.length === 2) {
					minuteInput?.focus();
				}
			}}
			onblur={() => {
				if (!hourInput) return;
				const hourValue = parseInt(hourInput.value);

				if (hourValue > 12) {
					hourInput.value = (hourValue - 12).toString();
					ampm = 'PM';
				}

				updateValue();
			}}
			bind:ref={hourInput}
		/>

		<span class="mx-2 text-3xl">:</span>

		<!-- Minute input -->
		<StyledRawInput
			placeholder="00"
			name={name + '_minute'}
			{type}
			max={59}
			min={0}
			class="time-input mr-3"
			validate={{ pattern: minutePattern }}
			onkeydown={(e) => {
				if (!minuteInput) return;

				if (e.key === 'ArrowLeft' && minuteInput.selectionStart === 0) {
					hourInput?.focus();
				} else if (
					e.key === 'ArrowRight' &&
					minuteInput.selectionEnd === minuteInput.value.length
				) {
					amButton?.focus();
				} else if (e.key === 'Backspace' && minuteInput.value.length === 0) {
					hourInput?.focus();
				}

				if (e.key === 'ArrowUp') {
					incrementValue(minuteInput, 59, 0);
				}
				if (e.key === 'ArrowDown') {
					decrementValue(minuteInput, 59, 0);
				}
				if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
					e.preventDefault();
					return;
				}
			}}
			oninput={() => {
				updateValue();
			}}
			onblur={() => {
				if (minuteInput?.value.length === 1) {
					minuteInput.value = '0' + minuteInput.value;
				}
			}}
			bind:ref={minuteInput}
		/>

		<!-- AM/PM Picker -->
		<div class="flex flex-col">
			<!-- AM Button -->
			<button
				class={['ampm rounded-t-sm border', ampm === 'AM' && 'selected']}
				onclick={() => (ampm = 'AM')}
				onkeydown={(e) => {
					if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') minuteInput?.focus();
					else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') pmButton.focus();
				}}
				bind:this={amButton}
			>
				AM
			</button>

			<!-- PM Button -->
			<button
				class={['ampm rounded-b-sm border-r border-b border-l', ampm === 'PM' && 'selected']}
				onclick={() => (ampm = 'PM')}
				onkeydown={(e) => {
					if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') amButton?.focus();
				}}
				bind:this={pmButton}
			>
				PM
			</button>
		</div>
	</div>

	<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
		<Label for={name} error>
			{invalidMessage}
		</Label>
	</div>
</div>

<style lang="postcss">
	@reference "@repo/tailwindcss-config/app.css";

	:global(.time-input) {
		@apply h-16 w-24 text-center text-xl;

		&:focus::placeholder {
			@apply text-transparent;
		}

		&::-webkit-inner-spin-button,
		&::-webkit-outer-spin-button {
			@apply appearance-none;
		}
	}

	.ampm {
		@apply border-accent dark:border-accent/50 cursor-pointer px-3 py-1 font-medium;

		&.selected {
			@apply bg-accent text-background dark:bg-accent/30 dark:text-background/90;
		}
	}
</style>
