/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
/** @import { RemoteFormInput, RemoteForm, RemoteQueryUpdate } from '@sveltejs/kit' */
/** @import { InternalRemoteFormIssue } from 'types' */
import { app_dir, base } from '$app/paths/internal/client';
import { DEV } from 'esm-env';
import { HttpError } from '@sveltejs/kit/internal';
import { query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js';
import { tick } from 'svelte';
import { categorize_updates, remote_request } from './shared.svelte.js';
import { createAttachmentKey } from 'svelte/attachments';
import {
	convert_formdata,
	flatten_issues,
	create_field_proxy,
	deep_set,
	set_nested_value,
	throw_on_old_property_access,
	build_path_string,
	normalize_issue,
	serialize_binary_form,
	DELETE_KEY,
	BINARY_FORM_CONTENT_TYPE
} from '../../form-utils.js';

/**
 * Merge client issues into server issues. Server issues are persisted unless
 * a client-issue exists for the same path, in which case the client-issue overrides it.
 * @param {FormData} form_data
 * @param {InternalRemoteFormIssue[]} current_issues
 * @param {InternalRemoteFormIssue[]} client_issues
 * @returns {InternalRemoteFormIssue[]}
 */
function merge_with_server_issues(form_data, current_issues, client_issues) {
	const merged = [
		...current_issues.filter(
			(issue) => issue.server && !client_issues.some((i) => i.name === issue.name)
		),
		...client_issues
	];

	const keys = Array.from(form_data.keys());

	return merged.sort((a, b) => keys.indexOf(a.name) - keys.indexOf(b.name));
}

/**
 * Client-version of the `form` function from `$app/server`.
 * @template {RemoteFormInput} T
 * @template U
 * @param {string} id
 * @returns {RemoteForm<T, U>}
 */
export function form(id) {
	/** @type {Map<any, { count: number, instance: RemoteForm<T, U> }>} */
	const instances = new Map();

	/** @type {StandardSchemaV1 | null} */
	let shared_preflight_schema = null;

	/** @param {string | number | boolean} [key] */
	function create_instance(key) {
		const action_id_without_key = id;
		const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
		const action = '?/remote=' + encodeURIComponent(action_id);

		// the output of a non-enhanced submission that resulted in this page —
		// consume it so the form's state survives hydration (form outputs are
		// always value nodes; the server never serializes them as errors)
		/** @type {{ input?: Record<string, any>, issues?: InternalRemoteFormIssue[], result?: any } | undefined} */
		const initial = query_responses[action_id]?.v;
		delete query_responses[action_id];

		/**
		 * @type {Record<string, string | string[] | File | File[]>}
		 */
		let input = $state(initial?.input ?? {});

		/** @type {InternalRemoteFormIssue[]} */
		let raw_issues = $state.raw(initial?.issues ?? []);

		const issues = $derived(flatten_issues(raw_issues));

		/** @type {any} */
		let result = $state.raw(initial?.result);

		/** @type {number} */
		let pending_count = $state(0);

		/** @type {StandardSchemaV1 | undefined} */
		let preflight_schema = undefined;

		/**
		 * @param {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} instance
		 */
		let enhance_callback = async (instance) => {
			if (await instance.submit()) {
				await tick();
				// We call reset from the prototype to avoid DOM clobbering
				HTMLFormElement.prototype.reset.call(instance.element);
			}
		};

		/** @type {HTMLFormElement | null} */
		let element = null;

		/** @type {Record<string, boolean>} */
		let touched = {};

		let submitted = $state(false);

		/** @type {InternalRemoteFormIssue[] | null} */
		let unread_issues = null;

		/** @type {string | null} */
		let previous_submitter_name = null;

		/**
		 * In dev, warn if there are validation issues going unread
		 */
		function warn_on_missing_issue_reads() {
			unread_issues = raw_issues;

			setTimeout(() => {
				if (unread_issues === null) {
					return;
				}

				if (unread_issues.length > 0) {
					const message = `Form submission had invalid data, but the validation issues were ignored:`;
					const summary = unread_issues
						.map((issue) =>
							issue.path.length === 0
								? `  - ${issue.message}`
								: `  - ${issue.path.join('.')} (${issue.message})`
						)
						.join('\n');
					const suggestion = `Make sure you provide actionable feedback to users, using e.g. \`myForm.fields.myField.issues()\` or \`myForm.fields.allIssues()\``;

					console.warn(`${message}\n\n${summary}\n\n${suggestion}`);
				}

				unread_issues = null;
			});
		}

		/**
		 * @param {FormData} form_data
		 * @returns {Record<string, any>}
		 */
		function convert(form_data) {
			const data = convert_formdata(form_data);
			if (key !== undefined && !form_data.has('id')) {
				data.id = key;
			}
			return data;
		}

		/**
		 * @param {FormData} form_data
		 * @param {boolean} should_preflight
		 * @returns {Promise<boolean> & { updates: (...args: any[]) => Promise<boolean> }}
		 */
		function submit(form_data, should_preflight) {
			// Store a reference to the current instance and increment the usage count for the duration
			// of the request. This ensures that the instance is not deleted in case of an optimistic update
			// (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards.
			// If the instance would be deleted in the meantime, the error property would be assigned to the old,
			// no-longer-visible instance, so it would never be shown to the user.
			const entry = instances.get(key);
			if (entry) {
				entry.count++;
			}

			let overrides = /** @type {Array<() => void> | null} */ (null);

			/** @type {Set<string> | null} */
			let refreshes = null;

			/** @type {Error | undefined} */
			let updates_error;

			/** @type {Promise<boolean> & { updates: (...args: RemoteQueryUpdate[]) => Promise<boolean> }} */
			const promise = (async () => {
				try {
					await Promise.resolve();

					if (updates_error) {
						throw updates_error;
					}

					if (should_preflight) {
						const valid = await preflight(form_data);
						if (!valid) return false;
					}

					const { blob } = serialize_binary_form(convert(form_data), {
						remote_refreshes: Array.from(refreshes ?? [])
					});

					const response = await remote_request(
						`${base}/${app_dir}/remote/${action_id_without_key}`,
						{
							method: 'POST',
							headers: {
								'Content-Type': BINARY_FORM_CONTENT_TYPE,
								// Forms cannot be called during rendering, so it's save to use location here
								'x-sveltekit-pathname': location.pathname,
								'x-sveltekit-search': location.search
							},
							body: blob
						}
					);

					({ issues: raw_issues = [], result } = response._ ?? {});

					// if the developer took control of updates via `.updates(...)` (even with
					// no arguments), or the server performed explicit refreshes, don't invalidateAll
					const should_invalidate = refreshes === null && !response.r;

					if (response.redirect) {
						// Use internal version to allow redirects to external URLs
						void _goto(
							response.redirect,
							{
								invalidateAll: should_invalidate
							},
							0
						);
						return true;
					}

					const succeeded = raw_issues.length === 0;

					if (succeeded) {
						if (should_invalidate) {
							void invalidateAll();
						}
					} else {
						if (DEV) {
							warn_on_missing_issue_reads();
						}
					}

					return succeeded;
				} catch (e) {
					result = undefined;
					raw_issues = [];
					throw e;
				} finally {
					overrides?.forEach((fn) => fn());

					void tick().then(() => {
						if (entry) {
							entry.count--;
							if (entry.count === 0) {
								instances.delete(key);
							}
						}
					});
				}
			})();

			let updates_called = false;
			promise.updates = (...args) => {
				if (updates_called) {
					console.warn(
						'Updates can only be sent once per form submission. Ignoring additional updates.'
					);
					return promise;
				}
				updates_called = true;

				try {
					({ refreshes, overrides } = categorize_updates(args));
				} catch (error) {
					updates_error = /** @type {Error} */ (error);
				}

				return promise;
			};

			return promise;
		}

		/**
		 * @param {HTMLFormElement} form
		 * @param {FormData} form_data
		 * @returns {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }}
		 */
		function create_enhance_callback_instance(form, form_data) {
			const { enhance: _enhance, ...descriptors } = Object.getOwnPropertyDescriptors(instance);
			void _enhance;

			return /** @type {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} */ (
				Object.defineProperties(
					{},
					{
						...descriptors,
						data: {
							get() {
								// TODO 3.0 remove
								throw new Error(
									`The \`data\` property has been removed from the \`enhance\` callback argument. Use \`instance.fields.value()\` instead.`
								);
							}
						},
						form: {
							get() {
								// TODO 3.0 remove
								throw new Error(
									`The \`form\` property has been removed from the \`enhance\` callback argument. To get the current \`<form>\` element, use \`instance.element\` instead.`
								);
							}
						},
						element: {
							value: form
						},
						submit: {
							value: () => submit(form_data, false)
						}
					}
				)
			);
		}

		/**
		 * @param {FormData} form_data
		 */
		async function preflight(form_data) {
			const data = convert(form_data);
			const schema = preflight_schema ?? shared_preflight_schema;
			const validated = await schema?.['~standard'].validate(data);

			if (validated?.issues) {
				raw_issues = merge_with_server_issues(
					form_data,
					raw_issues,
					validated.issues.map((issue) => normalize_issue(issue, false))
				);

				if (DEV) {
					warn_on_missing_issue_reads();
				}

				return false;
			}

			// Preflight passed - clear stale client-side preflight issues
			if (preflight_schema) {
				raw_issues = raw_issues.filter((issue) => issue.server);
			}

			return true;
		}

		/** @type {RemoteForm<T, U>} */
		const instance = {};

		instance.method = 'POST';
		instance.action = action;

		instance[createAttachmentKey()] = (/** @type {HTMLFormElement} */ form) => {
			if (element) {
				let message = `A form object can only be attached to a single \`<form>\` element`;
				if (DEV && !key) {
					const name = id.split('/').pop();
					message += `. To create multiple instances, use \`${name}.for(key)\``;
				}

				throw new Error(message);
			}

			element = form;

			touched = {};

			/** @param {SubmitEvent} event */
			const handle_submit = async (event) => {
				const form = /** @type {HTMLFormElement} */ (event.target);
				const method = event.submitter?.hasAttribute('formmethod')
					? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod
					: clone(form).method;

				if (method !== 'post') return;

				const action = new URL(
					// We can't do submitter.formAction directly because that property is always set
					event.submitter?.hasAttribute('formaction')
						? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction
						: clone(form).action
				);

				if (action.searchParams.get('/remote') !== action_id) {
					return;
				}

				const target = event.submitter?.hasAttribute('formtarget')
					? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formTarget
					: clone(form).target;

				if (target === '_blank') {
					return;
				}

				event.preventDefault();

				const form_data = new FormData(form, event.submitter);

				if (
					previous_submitter_name !== null &&
					!Array.from(form_data.keys()).map(strip_prefix).includes(previous_submitter_name)
				) {
					// Strip any `n:`/`b:` type prefix before clearing, otherwise
					// `set_nested_value` would coerce `undefined` to `NaN`/`false`
					// instead of clearing the previously-submitted value.
					set_nested_value(input, previous_submitter_name, undefined);
				}

				if (event.submitter) {
					const name = event.submitter.getAttribute('name');
					const value = /** @type {any} */ (event.submitter).value;

					if (name !== null && value !== undefined) {
						set_nested_value(input, name, value);
					}

					previous_submitter_name = strip_prefix(name);
				} else {
					previous_submitter_name = null;
				}

				if (DEV) {
					validate_form_data(form_data, clone(form).enctype);
				}

				submitted = true;

				try {
					// Increment pending count immediately so that `pending` reflects
					// the in-progress state during async preflight validation
					pending_count++;

					const valid = await preflight(form_data);
					if (!valid) return;

					await enhance_callback(create_enhance_callback_instance(form, form_data));
				} catch (e) {
					const error =
						e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
					const status = e instanceof HttpError ? e.status : 500;
					void set_nearest_error_page(error, status);
				} finally {
					pending_count--;
				}
			};

			/** @param {Event} e */
			const handle_input = (e) => {
				// strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement
				// but that makes the types unnecessarily awkward
				const element = /** @type {HTMLInputElement} */ (e.target);

				let name = element.name;
				if (!name) return;

				const is_array = name.endsWith('[]');
				if (is_array) name = name.slice(0, -2);

				const is_file = element.type === 'file';

				touched[name] = true;

				if (is_array) {
					let value;

					if (element.tagName === 'SELECT') {
						value = Array.from(
							element.querySelectorAll('option:checked'),
							(e) => /** @type {HTMLOptionElement} */ (e).value
						);
					} else {
						const elements = /** @type {HTMLInputElement[]} */ (
							Array.from(form.querySelectorAll(`[name="${name}[]"]`))
						);

						if (DEV) {
							for (const e of elements) {
								if ((e.type === 'file') !== is_file) {
									throw new Error(
										`Cannot mix and match file and non-file inputs under the same name ("${element.name}")`
									);
								}
							}
						}

						value = is_file
							? elements.map((input) => Array.from(input.files ?? [])).flat()
							: elements.map((element) => element.value);
						if (element.type === 'checkbox') {
							value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked));
						}
					}

					set_nested_value(input, name, value);
				} else if (is_file) {
					if (DEV && element.multiple) {
						throw new Error(
							`Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"`
						);
					}

					const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];

					if (file) {
						set_nested_value(input, name, file);
					} else {
						set_nested_value(input, name, DELETE_KEY);
					}
				} else {
					set_nested_value(
						input,
						name,
						element.type === 'checkbox' && !element.checked ? null : element.value
					);
				}

				name = strip_prefix(name);

				touched[name] = true;
			};

			const handle_reset = async () => {
				// need to wait a moment, because the `reset` event occurs before
				// the inputs are actually updated (so that it can be cancelled)
				await tick();

				input = convert_formdata(new FormData(form));
				raw_issues = [];
				touched = {};
			};

			form.addEventListener('submit', handle_submit);
			form.addEventListener('input', handle_input);
			form.addEventListener('reset', handle_reset);

			return () => {
				form.removeEventListener('submit', handle_submit);
				form.removeEventListener('input', handle_input);
				form.removeEventListener('reset', handle_reset);
				element = null;
			};
		};

		let validate_id = 0;

		// TODO 3.0 remove
		if (DEV) {
			throw_on_old_property_access(instance);

			Object.defineProperty(instance, 'buttonProps', {
				get() {
					throw new Error(
						'`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
							' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
					);
				}
			});
		}

		Object.defineProperties(instance, {
			element: {
				get: () => element
			},
			submit: {
				value: () => {
					if (!element) {
						throw new Error('Cannot call submit() before the form is attached');
					}

					const default_submitter = /** @type {HTMLElement | undefined} */ (
						element.querySelector('button:not([type]), [type="submit"]')
					);

					const form_data = new FormData(element, default_submitter);

					if (DEV) {
						validate_form_data(form_data, clone(element).enctype);
					}

					submitted = true;
					pending_count++;

					const submission = submit(form_data, true);

					const decrement = () => {
						pending_count--;
					};
					void submission.then(decrement, decrement);

					return submission;
				}
			},
			fields: {
				get: () =>
					create_field_proxy(
						{},
						() => input,
						(path, value) => {
							if (path.length === 0) {
								input = value;
							} else {
								deep_set(input, path.map(String), value);

								const key = build_path_string(path);
								touched[key] = true;
							}
						},
						(path, all) => {
							if (DEV && unread_issues !== null && path !== undefined) {
								unread_issues = unread_issues.filter((issue) => {
									return (
										(all ? issue.path.slice(0, path.length) : issue.path).join('.') !==
										path.join('.')
									);
								});
							}

							return issues;
						}
					)
			},
			result: {
				get: () => result
			},
			pending: {
				get: () => pending_count
			},
			submitted: {
				get: () => submitted
			},
			preflight: {
				/** @type {RemoteForm<T, U>['preflight']} */
				value: (schema) => {
					preflight_schema = schema;

					if (key === undefined) {
						shared_preflight_schema = schema;
					}

					return instance;
				}
			},
			validate: {
				/** @type {RemoteForm<any, any>['validate']} */
				value: async ({ includeUntouched = false, preflightOnly = false } = {}) => {
					if (!element) return;

					const id = ++validate_id;

					// wait a tick in case the user is calling validate() right after set() which takes time to propagate
					await tick();

					const default_submitter = /** @type {HTMLElement | undefined} */ (
						element.querySelector('button:not([type]), [type="submit"]')
					);

					const form_data = new FormData(element, default_submitter);

					/** @type {InternalRemoteFormIssue[]} */
					let array = [];

					const data = convert(form_data);
					const schema = preflight_schema ?? shared_preflight_schema;
					const validated = await schema?.['~standard'].validate(data);

					if (validate_id !== id) {
						return;
					}

					if (validated?.issues) {
						array = validated.issues.map((issue) => normalize_issue(issue, false));
					} else if (!preflightOnly) {
						const result = await remote_request(
							`${base}/${app_dir}/remote/${action_id_without_key}`,
							{
								method: 'POST',
								headers: {
									'Content-Type': BINARY_FORM_CONTENT_TYPE,
									// Validation should not be and will not be called during rendering, so it's save to use location here
									'x-sveltekit-pathname': location.pathname,
									'x-sveltekit-search': location.search
								},
								body: serialize_binary_form(data, {
									validate_only: true
								}).blob
							}
						);

						if (validate_id !== id) {
							return;
						}

						array = /** @type {InternalRemoteFormIssue[]} */ (result._);
					}

					if (!includeUntouched && !submitted) {
						array = array.filter((issue) => touched[issue.name]);
					}

					const is_server_validation = !validated?.issues && !preflightOnly;

					raw_issues = is_server_validation
						? array
						: merge_with_server_issues(form_data, raw_issues, array);
				}
			},
			enhance: {
				/**
				 * @param {(instance: Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }) => any} callback
				 */
				value: (callback) => {
					enhance_callback = callback;
					return instance;
				}
			}
		});

		return instance;
	}

	const instance = create_instance();

	Object.defineProperty(instance, 'for', {
		/** @type {RemoteForm<T, U>['for']} */
		value: (key) => {
			const entry = instances.get(key) ?? { count: 0, instance: create_instance(key) };

			try {
				$effect.pre(() => {
					return () => {
						entry.count--;

						void tick().then(() => {
							if (entry.count === 0) {
								instances.delete(key);
							}
						});
					};
				});

				entry.count += 1;
				instances.set(key, entry);
			} catch {
				// not in an effect context
			}

			return entry.instance;
		}
	});

	return instance;
}

/**
 * Shallow clone an element, so that we can access e.g. `form.action` without worrying
 * that someone has added an `<input name="action">` (https://github.com/sveltejs/kit/issues/7593)
 * @template {HTMLElement} T
 * @param {T} element
 * @returns {T}
 */
function clone(element) {
	return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element));
}

/**
 * @param {FormData} form_data
 * @param {string} enctype
 */
function validate_form_data(form_data, enctype) {
	for (const key of form_data.keys()) {
		if (/^\$[.[]?/.test(key)) {
			throw new Error(
				'`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'
			);
		}
	}

	if (enctype !== 'multipart/form-data') {
		for (const value of form_data.values()) {
			if (value instanceof File) {
				throw new Error(
					'Your form contains <input type="file"> fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.'
				);
			}
		}
	}
}

/**
 * Remove the `n:` or `b:` prefix from a field name
 * @template {string | null} T
 * @param {T} name
 * @returns {T}
 */
function strip_prefix(name) {
	return /** @type {T} */ (name && name.replace(/^[nb]:/, ''));
}
