<script lang="ts" module>
	/**
	 * Defines the features available in dialog event callbacks.
	 */
	export interface DialogAPI {
		/** shows an error message at the top of the dialog */
		error: (message: RawError | null) => void;
		/** Returns if the dialog is displaying an error */
		hasError: () => boolean;
		/** closes the dialog */
		close: () => void;
		/** opens the dialog */
		open: () => void;
		/** returns true if the dialog is open */
		isOpen: () => boolean;
		/** if default controls are used, sets the submit button loading state */
		loading: () => void;
		/** if default controls are used, sets the submit button loaded state */
		loaded: () => void;
		/** if default controls are used, returns true if the submit button is in loading state */
		isLoading: () => boolean;
		/** if the default controls are used, disabled submission */
		disable: () => void;
		/** if the default controls are used, enables submission */
		enable: () => void;
		/** if the default controls are used, returns true if the dialog is disabled */
		isDisabled: () => boolean;
		/** if default controls are used, disables submission and exiting */
		freeze: () => void;
		/** if default controls are used, enables submission and exiting */
		unfreeze: () => void;
		/** if the default controls are used, returns true if the dialog is frozen */
		isFrozen: () => boolean;
		/**
		 * if the default controls are used, returns true if the continue button is available.
		 * checks if the dialog is in loading, disabled, or frozen state.
		 */
		canContinue: () => boolean;
		/**
		 * title sets the dialog title. WARNING: Title is NOT bindable and
		 * changes made via this API will NOT propagate to consuming components.
		 */
		title: (title: string) => void;
		/** Focuses the dialog */
		focus: () => void;
		/** Returns the stack index for this dialog */
		stackIndex: () => number;
		/**
		 * Returns the z-index of this dialog, where each incremental stack
		 * index increases the z-index by 100 starting from 1000. This can be
		 * used to layer custom elements on top of and within the dialog.
		 */
		zIndex: () => number;
	}

	type DialogControlButton = {
		/** Label for the button */
		label?: string;
		/** Additional classes to apply to the button */
		class?: ClassValue;
		/** Callback when the button is pressed */
		action?: (dialog: DialogAPI) => void;
	};

	/**
	 * Configures the default dialog controls.
	 */
	export type DialogControls = {
		/** Options for the bottom cancel button */
		cancel?: DialogControlButton | null;
		/** Options for the bottom submit button */
		ok?: DialogControlButton | null;
		/** Inverts the order of the buttons */
		flip?: boolean;
	};

	/**
	 * Stores internal state of the dialog, everything necessary to render
	 * internal and override capable snippets.
	 */
	export type DialogState = {
		frozen: boolean;
		loading: boolean;
		disabled: boolean;
		api: DialogAPI;
	};

	const defaultDialogControls: DialogControls = {
		cancel: { label: 'Cancel' },
		ok: { label: 'OK' }
	};

	export { dialogCancelButton, dialogOkButton, dialogCloseButton };

	/** stack of currently open dialogs by identifier */
	let dialogStack: DialogAPI[] = $state([]);
</script>

<script lang="ts">
	import { Portal } from '@jsrob/svelte-portal';
	import { untrack, type Snippet } from 'svelte';
	import type { ClassValue } from 'svelte/elements';
	import { fade } from 'svelte/transition';
	import { flyAndScale } from './transition';
	import Button from './Button.svelte';
	import { X } from 'phosphor-svelte';
	import { ErrorMessage, type RawError } from './error';
	import ErrorBox from './ErrorBox.svelte';
	import { generateIdentifier, mergeOverrideObject } from './util';

	interface Props {
		/** Bindable open state of the dialog */
		open?: boolean;
		/** Title of the dialog */
		title: string | Snippet<[state: DialogState]>;
		/** Description of the dialog, optionally rendered below the title */
		description?: string | Snippet<[state: DialogState]>;
		/** Size of the dialog (default: 'sm') */
		size?: 'sm' | 'md' | 'lg' | 'max';
		/** Additional classes for the dialog */
		class?: ClassValue;
		/** Content of the dialog */
		children?: Snippet;
		/** Bottom controls for the dialog */
		controls?: Snippet<[state: DialogState]> | DialogControls;
		/** Sets bottom alignment of controls (default: end) */
		controlsAlign?: 'start' | 'center' | 'end';
		/** Top-right close control */
		close?: Snippet | Omit<DialogControlButton, 'label'> | null;
		/**
		 * Callback when the dialog is opened
		 * @deprecated use onopenchange instead and check the open parameter
		 */
		onopen?: (dialog: DialogAPI) => void;
		/**
		 * Callback when the dialog is closed
		 * @deprecated use onopenchange instead and check the open parameter
		 */
		onclose?: (dialog: DialogAPI) => void;
		/** Callback when the dialog open state changes */
		onopenchange?: ({ open, dialog }: { open: boolean; dialog: DialogAPI }) => void;
		/** If default controls are used, controls loading state of submit button */
		loading?: boolean;
		/** If default controls are used, freezes all interactions preventing user input */
		frozen?: boolean;
		/** If enabled, automatically freezes dialog when loading (default: true) */
		autoFreeze?: boolean;
		/** If default controls are used, disables submit button */
		disabled?: boolean;
	}

	let {
		open = $bindable(false),
		title,
		description,
		size = 'sm',
		class: classValue,
		children,
		controls: rawControls = defaultDialogControls,
		controlsAlign = 'end',
		close = {},
		onopen,
		onclose,
		onopenchange,
		loading = $bindable(false),
		frozen = $bindable(false),
		autoFreeze = true,
		disabled = $bindable(false)
	}: Props = $props();

	const controls = $derived(
		typeof rawControls === 'function'
			? rawControls
			: mergeOverrideObject(defaultDialogControls, rawControls)
	);

	const identifier = generateIdentifier('dialog');

	let lastOpen = $state(open);
	let dialogPage = $state<HTMLDivElement | null>(null);
	let dialogContainer = $state<HTMLDivElement | null>(null);
	let error = $state<ErrorMessage | null>(null);
	let stackIndex = $state(-1);
	const zIndex = $derived(1000 + stackIndex * 100);

	const reallyFrozen = $derived(autoFreeze ? frozen || loading : frozen);

	/** handles open change */
	const handleOpenChange = (localOpen: boolean) => {
		if (localOpen) {
			document.body.style.overflow = 'hidden'; // prevent scrolling
			// focus the dialog BEFORE callbacks for accessibility & flexibility
			dialogPage?.focus();
			// run callbacks
			onopen?.(dialogAPI);
			onopenchange?.({ open: true, dialog: dialogAPI });
			//update stack
			dialogStack.push(dialogAPI); // add to stack of open dialogs
			stackIndex = dialogStack.length - 1; // track index in stack for this dialog
		} else {
			// update stack
			dialogStack.pop(); // remove from stack of open dialogs
			stackIndex = -1; // reset stack index for this dialog
			// update focus & handle scroll locking for accessibility
			if (dialogStack.length > 0) {
				dialogStack[dialogStack.length - 1].focus();
			} else {
				document.body.style.overflow = ''; // re-enable scrolling since no dialogs are open
			}
			// run callbacks
			onclose?.(dialogAPI);
			onopenchange?.({ open: false, dialog: dialogAPI });
		}
	};

	// deduplicate open changes
	$effect(() => {
		if (open !== untrack(() => lastOpen)) {
			lastOpen = open;
			untrack(() => {
				handleOpenChange(open);
			});
		}
	});

	/** DialogAPI instance to control this dialog */
	export const dialogAPI: DialogAPI = {
		error: (message) => (error = ErrorMessage.from(message)),
		hasError: () => error !== null,
		close: () => (open = false),
		open: () => (open = true),
		isOpen: () => open,
		loading: () => (loading = true),
		loaded: () => (loading = false),
		isLoading: () => loading,
		disable: () => (disabled = true),
		enable: () => (disabled = false),
		isDisabled: () => disabled,
		freeze: () => (frozen = true),
		unfreeze: () => (frozen = false),
		isFrozen: () => frozen,
		canContinue: () => !loading && !disabled && !frozen,
		title: (newTitle) => (title = newTitle),
		focus: () => dialogPage?.focus(),
		stackIndex: () => stackIndex,
		zIndex: () => zIndex
	};

	/** Returns the current state of the dialog */
	export const getState = (): DialogState => {
		return {
			frozen: reallyFrozen,
			loading,
			disabled,
			api: dialogAPI
		};
	};
</script>

<Portal target="body">
	{#if open}
		{@render dialog()}
	{/if}
</Portal>

{#snippet dialog()}
	<div
		bind:this={dialogPage}
		class={[
			'fixed inset-0 flex items-center-safe justify-center bg-black/50 backdrop-blur-sm',
			'overflow-auto p-8',
			classValue
		]}
		style={// increase z-index and decrease opacity for each nested dialog
		`z-index: ${zIndex}`}
		transition:fade={{ duration: 150 }}
		onclick={(e) => {
			const target = e.target as HTMLElement;
			if (open && !reallyFrozen && !dialogContainer?.contains(target) && target !== dialogContainer)
				open = false;
		}}
		onkeydown={(e) => {
			if (e.key === 'Escape' && !reallyFrozen) {
				if (stackIndex === dialogStack.length - 1) {
					// only close if this dialog is the topmost dialog
					open = false;
				}
			}
		}}
		role="dialog"
		aria-labelledby="{identifier}-title"
		aria-describedby="{identifier}-description"
		tabindex="-1"
	>
		<div
			bind:this={dialogContainer}
			class={[
				'relative w-[90vw] rounded-xl bg-white p-6 shadow-lg',
				size === 'sm' && 'max-w-[450px]',
				size === 'md' && 'max-w-[650px]',
				size === 'lg' && 'max-w-[850px]',
				size === 'max' && 'max-w-[95vw]'
			]}
			transition:flyAndScale={{
				duration: 150,
				y: -8,
				start: 0.96
			}}
		>
			<div class="flex items-center justify-between">
				<!-- Dialog title -->
				<h2
					class="pointer-events-none mb-2 text-lg font-medium text-black select-none"
					id="{identifier}-title"
				>
					{@render stringOrSnippet(title)}
				</h2>

				<!-- Close Button -->
				{#if close !== null}
					{#if typeof close === 'function'}
						{@render close()}
					{:else}
						{@render dialogCloseButton(getState(), close)}
					{/if}
				{/if}
			</div>

			{#if error}
				<ErrorBox {error} />
			{/if}

			{#if description}
				<p class="mb-3 leading-normal text-zinc-600" id="{identifier}-description">
					{@render stringOrSnippet(description)}
				</p>
			{/if}

			{#if children}{@render children()}{:else}Dialog is empty{/if}

			<!-- Dialog Controls -->
			<div
				class={[
					'mt-6 flex gap-4',
					controlsAlign === 'start' && 'justify-start',
					controlsAlign === 'center' && 'justify-center',
					controlsAlign === 'end' && 'justify-end'
				]}
			>
				{#if controls && typeof controls === 'function'}{@render controls(
						getState()
					)}{:else if controls?.flip}
					{#if controls.ok !== null}
						{@render dialogOkButton(getState(), controls.ok)}
					{/if}
					{#if controls.cancel !== null}
						{@render dialogCancelButton(getState(), controls.cancel)}
					{/if}
				{:else}
					{#if controls.cancel !== null}
						{@render dialogCancelButton(getState(), controls.cancel)}
					{/if}
					{#if controls.ok !== null}
						{@render dialogOkButton(getState(), controls.ok)}
					{/if}
				{/if}
			</div>
		</div>
	</div>
{/snippet}

{#snippet dialogCancelButton(state: DialogState, opts?: DialogControls['cancel'])}
	<Button
		class={opts?.class}
		onclick={() => {
			if (opts?.action) {
				opts.action(state.api);
			} else if (!state.frozen) {
				state.api.close();
			}
		}}
		disabled={state.frozen || state.disabled}
	>
		{opts?.label || 'Cancel'}
	</Button>
{/snippet}

{#snippet dialogOkButton(state: DialogState, opts?: DialogControls['ok'])}
	<Button
		class={opts?.class}
		onclick={() => {
			if (opts?.action) {
				opts.action(state.api);
			} else if (!state.frozen && !state.loading && !state.disabled) {
				state.api.close();
			}
		}}
		disabled={state.frozen || state.loading || state.disabled}
		loading={state.loading}
	>
		{opts?.label || 'OK'}
	</Button>
{/snippet}

{#snippet dialogCloseButton(state: DialogState, opts?: Omit<DialogControlButton, 'label'> | null)}
	<button
		type="button"
		aria-label="close"
		class={[
			'inline-flex cursor-pointer items-center justify-center',
			'disabled:cursor-not-allowed disabled:opacity-50',
			'rounded-full p-2 transition-colors hover:bg-zinc-200/50'
		]}
		onclick={() => {
			if (opts?.action) {
				opts.action(state.api);
			} else if (!state.frozen) {
				state.api.close();
			}
		}}
		disabled={state.frozen}
	>
		<X size="1.25em" weight="bold" />
	</button>
{/snippet}

{#snippet stringOrSnippet(val: string | Snippet<[state: DialogState]>)}
	{#if typeof val === 'string'}
		{val}
	{:else}
		{@render val(getState())}
	{/if}
{/snippet}
