/** @import { RemoteFunctionResponse, RemoteFunctionData, RemoteFunctionDataNode } from 'types' */
/** @import { RemoteQueryUpdate } from '@sveltejs/kit' */
/** @import { CacheEntry } from './cache.svelte.js' */
import * as devalue from 'devalue';
import { app, goto, live_query_map, query_map, query_responses } from '../client.js';
import { HttpError, Redirect } from '@sveltejs/kit/internal';
import { untrack } from 'svelte';
import { create_remote_key, split_remote_key } from '../../shared.js';
import { navigating, page } from '../state.svelte.js';

/** Indicates a query function, as opposed to a query instance */
export const QUERY_FUNCTION_ID = Symbol('sveltekit.query_function_id');
/** Indicates a query override callback, used to release the override */
export const QUERY_OVERRIDE_KEY = Symbol('sveltekit.query_override_key');
/** Indicates a query instance */
export const QUERY_RESOURCE_KEY = Symbol('sveltekit.query_resource_key');

/**
 * If we're inside a reactive context, pin a cache entry for as long as the
 * surrounding effect is alive. Without this, a transiently-referenced
 * `QueryProxy`/`LiveQueryProxy` (e.g. one produced by `{await fn()}` in a
 * template or by `$derived(await fn())`) would be eligible for GC as soon as
 * the awaited value has been read, after which the FinalizationRegistry
 * would evict the cache entry — even though the consuming effect is still
 * alive and may rely on the entry being refreshed (e.g. via `refreshAll()`
 * or a server-initiated single-flight refresh).
 *
 * @template TResource
 * @param {Map<string, Map<string, { resource: TResource }>>} cache_map
 * @param {{ manual_ref: (entry: any, id: string, payload: string) => () => void }} cache
 * @param {string} id
 * @param {string} payload
 */
export function pin_in_effect(cache_map, cache, id, payload) {
	try {
		$effect.pre(() => {
			const entry = cache_map.get(id)?.get(payload);
			if (!entry) return;
			return cache.manual_ref(entry, id, payload);
		});
	} catch {
		// not in an effect context — nothing to pin
	}
}

/**
 * Wrap a proxy's `then`/`catch`/`finally` function so that the underlying
 * cache entry stays pinned for the lifetime of the awaited promise. Without
 * this, a proxy awaited outside any effect (e.g. in an event handler) could
 * be GC'd between the `.then` getter returning the thenable and the
 * underlying promise settling, causing the FinalizationRegistry to evict the
 * cache entry mid-flight and the awaited value to resolve to Svelte's
 * `UNINITIALIZED` sentinel from a torn-down `$derived`.
 *
 * @template TResource
 * @template {(...args: any[]) => Promise<any>} TThen
 * @param {Map<string, Map<string, { resource: TResource }>>} cache_map
 * @param {{ manual_ref: (entry: any, id: string, payload: string) => () => void }} cache
 * @param {string} id
 * @param {string} payload
 * @param {TThen} then
 * @returns {TThen}
 */
export function pin_while_resolving(cache_map, cache, id, payload, then) {
	return /** @type {TThen} */ (
		(...a) => {
			const entry = cache_map.get(id)?.get(payload);
			const release = entry ? cache.manual_ref(entry, id, payload) : undefined;
			const promise = then(...a);
			if (release) {
				promise.then(release, release);
			}
			return promise;
		}
	);
}

/**
 * @returns {{ 'x-sveltekit-pathname': string, 'x-sveltekit-search': string }}
 */
/**
 * Unwraps a `RemoteFunctionDataNode` that was serialized during SSR,
 * rethrowing serialized errors so the consuming resource ends up
 * in the same failed state it had on the server
 * @param {RemoteFunctionDataNode} node
 */
export function unwrap_node(node) {
	if (node.e) {
		throw new HttpError(node.e[0] ?? 500, node.e[1]);
	}

	return node.v;
}

export function get_remote_request_headers() {
	// This will be the correct value of the current or soon-current url,
	// even in forks because it's state-based - therefore not using window.location.
	// Use untrack(...) to Avoid accidental reactive dependency on pathname/search
	return untrack(() => {
		const url = navigating.current?.to?.url ?? page.url;

		return {
			'x-sveltekit-pathname': url.pathname,
			'x-sveltekit-search': url.search
		};
	});
}

/**
 * @param {string} url
 * @param {RequestInit} [init]
 */
export async function remote_request(url, init) {
	const response = await fetch(url, init);

	if (!response.ok) {
		const result = await response.json().catch(() => ({
			type: 'error',
			status: response.status,
			error: response.statusText
		}));

		throw new HttpError(result.status ?? response.status ?? 500, result.error);
	}

	const result = /** @type {RemoteFunctionResponse} */ (await response.json());

	if (result.type === 'error') {
		throw new HttpError(result.status ?? 500, result.error);
	}

	const data = /** @type {RemoteFunctionData} */ (
		result.data ? devalue.parse(result.data, app.decoders) : {}
	);

	/**
	 *
	 * @param {string} key
	 * @param {CacheEntry<any> | undefined} entry
	 * @param {any} result
	 */
	function refresh(key, entry, result) {
		if (entry?.resource) {
			if (result.e) {
				entry.resource.fail(new HttpError(result.e[0] ?? 500, result.e[1]));
			} else {
				entry.resource.set(result.v);
			}
		} else if (!result.e) {
			// `query_responses` stores `{ v }`/`{ e }` nodes, not raw values.
			// Errors are deliberately dropped here: they are responses to a specific
			// refresh, not durable state a future resource should initialize with
			query_responses[key] = result;
		}
	}

	// update queries with refreshed data
	if (data.q) {
		for (const key in data.q) {
			const parts = split_remote_key(key);
			const entry = query_map.get(parts.id)?.get(parts.payload);

			refresh(key, entry, data.q[key]);
		}
	}

	// reconnect live queries
	if (data.l) {
		for (const key in data.l) {
			const parts = split_remote_key(key);
			const entry = live_query_map.get(parts.id)?.get(parts.payload);

			refresh(key, entry, data.l[key]);

			// `fail()` is terminal, so only reconnect on the success path —
			// reconnecting after a hard failure would wipe the error state and
			// restart the stream (see commit 63a3e83 regression).
			if (!data.l[key].e) {
				void entry?.resource.reconnect();
			}
		}
	}

	return data;
}

/**
 * @param {RemoteFunctionResponse} response
 * @returns {Promise<Extract<RemoteFunctionResponse, { type: 'result' }>>}
 */
export async function handle_side_channel_response(response) {
	if (response.type === 'redirect') {
		await goto(response.location);
		throw new Redirect(307, response.location);
	}

	if (response.type === 'error') {
		throw new HttpError(response.status ?? 500, response.error);
	}

	return response;
}

/**
 * Given an array of updates, which could be query instances, query functions, or query override release functions,
 * categorize them into overrides (which need to be released after the command completes), refreshes (which
 * just need to be refreshed after the command completes), or both.
 *
 * @param {RemoteQueryUpdate[]} updates
 * @returns {{ overrides: Array<() => void>, refreshes: Set<string> }}
 */
export function categorize_updates(updates) {
	/** @type {Set<string>} */
	const override_keys = new Set();
	/** @type {Array<() => void>} */
	const overrides = [];
	/** @type {Set<string>} */
	const refreshes = new Set();

	for (const update of updates) {
		if (typeof update === 'function') {
			if (Object.hasOwn(update, QUERY_FUNCTION_ID)) {
				// this is a query function (not instance), so we need to find all active instances
				// of this function and request that they be refreshed/reconnected by the command handler
				// @ts-expect-error
				const id = /** @type {string} */ (update[QUERY_FUNCTION_ID]);
				const entries = query_map.get(id) ?? live_query_map.get(id);

				if (entries) {
					for (const payload of entries.keys()) {
						refreshes.add(create_remote_key(id, payload));
					}
				}

				continue;
			}

			if (Object.hasOwn(update, QUERY_OVERRIDE_KEY)) {
				// this is a query override release function, so we need to both request that the query instance
				// be refreshed _and_ stash the release function so we can release the override after the command completes
				// @ts-expect-error
				const key = /** @type {string} */ (update[QUERY_OVERRIDE_KEY]);
				refreshes.add(key);

				if (override_keys.has(key)) {
					throw new Error(
						'Multiple overrides for the same query are not allowed in a single updates() invocation'
					);
				}

				override_keys.add(key);
				overrides.push(/** @type {() => void} */ (update));
				continue;
			}

			// this is just a regular function provided by some user integration, so we can just stash it in the overrides array
			overrides.push(/** @type {() => void} */ (update));
			continue;
		}

		if (
			typeof update === 'object' &&
			update !== null &&
			Object.hasOwn(update, QUERY_RESOURCE_KEY)
		) {
			// this is a query instance, so we just need to request that it be refreshed
			// @ts-expect-error
			refreshes.add(/** @type {string} */ (update[QUERY_RESOURCE_KEY]));
			continue;
		}

		throw new Error(
			'updates() expects a query or live query function, query resource, or query override'
		);
	}

	return { overrides, refreshes };
}
