import { onMount, tick } from 'svelte';
import { writable } from 'svelte/store';
import { assets, set_paths } from '../paths.js';
import Root from '__GENERATED__/root.svelte';
import { components, dictionary, matchers } from '__GENERATED__/client-manifest.js';
import { init } from './singletons.js';

/**
 * @param {unknown} err
 * @return {Error}
 */
function coalesce_to_error(err) {
	return err instanceof Error ||
		(err && /** @type {any} */ (err).name && /** @type {any} */ (err).message)
		? /** @type {Error} */ (err)
		: new Error(JSON.stringify(err));
}

/**
 * @param {import('types').LoadOutput} loaded
 * @returns {import('types').NormalizedLoadOutput}
 */
function normalize(loaded) {
	// TODO remove for 1.0
	// @ts-expect-error
	if (loaded.fallthrough) {
		throw new Error(
			'fallthrough is no longer supported. Use matchers instead: https://kit.svelte.dev/docs/routing#advanced-routing-matching'
		);
	}

	// TODO remove for 1.0
	if ('maxage' in loaded) {
		throw new Error('maxage should be replaced with cache: { maxage }');
	}

	const has_error_status =
		loaded.status && loaded.status >= 400 && loaded.status <= 599 && !loaded.redirect;
	if (loaded.error || has_error_status) {
		const status = loaded.status;

		if (!loaded.error && has_error_status) {
			return { status: status || 500, error: new Error() };
		}

		const error = typeof loaded.error === 'string' ? new Error(loaded.error) : loaded.error;

		if (!(error instanceof Error)) {
			return {
				status: 500,
				error: new Error(
					`"error" property returned from load() must be a string or instance of Error, received type "${typeof error}"`
				)
			};
		}

		if (!status || status < 400 || status > 599) {
			console.warn('"error" returned from load() without a valid status code — defaulting to 500');
			return { status: 500, error };
		}

		return { status, error };
	}

	if (loaded.redirect) {
		if (!loaded.status || Math.floor(loaded.status / 100) !== 3) {
			return {
				status: 500,
				error: new Error(
					'"redirect" property returned from load() must be accompanied by a 3xx status code'
				)
			};
		}

		if (typeof loaded.redirect !== 'string') {
			return {
				status: 500,
				error: new Error('"redirect" property returned from load() must be a string')
			};
		}
	}

	if (loaded.dependencies) {
		if (
			!Array.isArray(loaded.dependencies) ||
			loaded.dependencies.some((dep) => typeof dep !== 'string')
		) {
			return {
				status: 500,
				error: new Error('"dependencies" property returned from load() must be of type string[]')
			};
		}
	}

	// TODO remove before 1.0
	if (/** @type {any} */ (loaded).context) {
		throw new Error(
			'You are returning "context" from a load function. ' +
				'"context" was renamed to "stuff", please adjust your code accordingly.'
		);
	}

	return /** @type {import('types').NormalizedLoadOutput} */ (loaded);
}

/**
 * @param {string} path
 * @param {import('types').TrailingSlash} trailing_slash
 */
function normalize_path(path, trailing_slash) {
	if (path === '/' || trailing_slash === 'ignore') return path;

	if (trailing_slash === 'never') {
		return path.endsWith('/') ? path.slice(0, -1) : path;
	} else if (trailing_slash === 'always' && !path.endsWith('/')) {
		return path + '/';
	}

	return path;
}

/**
 * Hash using djb2
 * @param {import('types').StrictBody} value
 */
function hash(value) {
	let hash = 5381;
	let i = value.length;

	if (typeof value === 'string') {
		while (i) hash = (hash * 33) ^ value.charCodeAt(--i);
	} else {
		while (i) hash = (hash * 33) ^ value[--i];
	}

	return (hash >>> 0).toString(36);
}

/** @param {HTMLDocument} doc */
function get_base_uri(doc) {
	let baseURI = doc.baseURI;

	if (!baseURI) {
		const baseTags = doc.getElementsByTagName('base');
		baseURI = baseTags.length ? baseTags[0].href : doc.URL;
	}

	return baseURI;
}

function scroll_state() {
	return {
		x: pageXOffset,
		y: pageYOffset
	};
}

/** @param {Event} event */
function find_anchor(event) {
	const node = event
		.composedPath()
		.find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG <a> elements have a lowercase name
	return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node);
}

/** @param {HTMLAnchorElement | SVGAElement} node */
function get_href(node) {
	return node instanceof SVGAElement
		? new URL(node.href.baseVal, document.baseURI)
		: new URL(node.href);
}

/** @param {any} value */
function notifiable_store(value) {
	const store = writable(value);
	let ready = true;

	function notify() {
		ready = true;
		store.update((val) => val);
	}

	/** @param {any} new_value */
	function set(new_value) {
		ready = false;
		store.set(new_value);
	}

	/** @param {(value: any) => void} run */
	function subscribe(run) {
		/** @type {any} */
		let old_value;
		return store.subscribe((new_value) => {
			if (old_value === undefined || (ready && new_value !== old_value)) {
				run((old_value = new_value));
			}
		});
	}

	return { notify, set, subscribe };
}

function create_updated_store() {
	const { set, subscribe } = writable(false);

	const interval = +(
		/** @type {string} */ (import.meta.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL)
	);
	const initial = import.meta.env.VITE_SVELTEKIT_APP_VERSION;

	/** @type {NodeJS.Timeout} */
	let timeout;

	async function check() {
		if (import.meta.env.DEV || import.meta.env.SSR) return false;

		clearTimeout(timeout);

		if (interval) timeout = setTimeout(check, interval);

		const file = import.meta.env.VITE_SVELTEKIT_APP_VERSION_FILE;

		const res = await fetch(`${assets}/${file}`, {
			headers: {
				pragma: 'no-cache',
				'cache-control': 'no-cache'
			}
		});

		if (res.ok) {
			const { version } = await res.json();
			const updated = version !== initial;

			if (updated) {
				set(true);
				clearTimeout(timeout);
			}

			return updated;
		} else {
			throw new Error(`Version check failed: ${res.status}`);
		}
	}

	if (interval) timeout = setTimeout(check, interval);

	return {
		subscribe,
		check
	};
}

/**
 * @param {RequestInfo} resource
 * @param {RequestInit} [opts]
 */
function initial_fetch(resource, opts) {
	const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url);

	let selector = `script[sveltekit\\:data-type="data"][sveltekit\\:data-url=${url}]`;

	if (opts && typeof opts.body === 'string') {
		selector += `[sveltekit\\:data-body="${hash(opts.body)}"]`;
	}

	const script = document.querySelector(selector);
	if (script && script.textContent) {
		const { body, ...init } = JSON.parse(script.textContent);
		return Promise.resolve(new Response(body, init));
	}

	return fetch(resource, opts);
}

const param_pattern = /^(\.\.\.)?(\w+)(?:=(\w+))?$/;

/** @param {string} id */
function parse_route_id(id) {
	/** @type {string[]} */
	const names = [];

	/** @type {string[]} */
	const types = [];

	// `/foo` should get an optional trailing slash, `/foo.json` should not
	// const add_trailing_slash = !/\.[a-z]+$/.test(key);
	let add_trailing_slash = true;

	const pattern =
		id === ''
			? /^\/$/
			: new RegExp(
					`^${decodeURIComponent(id)
						.split(/(?:@[a-zA-Z0-9_-]+)?(?:\/|$)/)
						.map((segment, i, segments) => {
							// special case — /[...rest]/ could contain zero segments
							const match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
							if (match) {
								names.push(match[1]);
								types.push(match[2]);
								return '(?:/(.*))?';
							}

							const is_last = i === segments.length - 1;

							return (
								segment &&
								'/' +
									segment
										.split(/\[(.+?)\]/)
										.map((content, i) => {
											if (i % 2) {
												const [, rest, name, type] = /** @type {RegExpMatchArray} */ (
													param_pattern.exec(content)
												);
												names.push(name);
												types.push(type);
												return rest ? '(.*?)' : '([^/]+?)';
											}

											if (is_last && content.includes('.')) add_trailing_slash = false;

											return (
												content // allow users to specify characters on the file system in an encoded manner
													.normalize()
													// We use [ and ] to denote parameters, so users must encode these on the file
													// system to match against them. We don't decode all characters since others
													// can already be epressed and so that '%' can be easily used directly in filenames
													.replace(/%5[Bb]/g, '[')
													.replace(/%5[Dd]/g, ']')
													// '#', '/', and '?' can only appear in URL path segments in an encoded manner.
													// They will not be touched by decodeURI so need to be encoded here, so
													// that we can match against them.
													// We skip '/' since you can't create a file with it on any OS
													.replace(/#/g, '%23')
													.replace(/\?/g, '%3F')
													// escape characters that have special meaning in regex
													.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
											); // TODO handle encoding
										})
										.join('')
							);
						})
						.join('')}${add_trailing_slash ? '/?' : ''}$`
			  );

	return { pattern, names, types };
}

/**
 * @param {RegExpMatchArray} match
 * @param {string[]} names
 * @param {string[]} types
 * @param {Record<string, import('types').ParamMatcher>} matchers
 */
function exec(match, names, types, matchers) {
	/** @type {Record<string, string>} */
	const params = {};

	for (let i = 0; i < names.length; i += 1) {
		const name = names[i];
		const type = types[i];
		const value = match[i + 1] || '';

		if (type) {
			const matcher = matchers[type];
			if (!matcher) throw new Error(`Missing "${type}" param matcher`); // TODO do this ahead of time?

			if (!matcher(value)) return;
		}

		params[name] = value;
	}

	return params;
}

/**
 * @param {import('types').CSRComponentLoader[]} components
 * @param {Record<string, [number[], number[], 1?]>} dictionary
 * @param {Record<string, (param: string) => boolean>} matchers
 * @returns {import('types').CSRRoute[]}
 */
function parse(components, dictionary, matchers) {
	const routes = Object.entries(dictionary).map(([id, [a, b, has_shadow]]) => {
		const { pattern, names, types } = parse_route_id(id);

		return {
			id,
			/** @param {string} path */
			exec: (path) => {
				const match = pattern.exec(path);
				if (match) return exec(match, names, types, matchers);
			},
			a: a.map((n) => components[n]),
			b: b.map((n) => components[n]),
			has_shadow: !!has_shadow
		};
	});

	return routes;
}

const SCROLL_KEY = 'sveltekit:scroll';
const INDEX_KEY = 'sveltekit:index';

const routes = parse(components, dictionary, matchers);

// we import the root layout/error components eagerly, so that
// connectivity errors after initialisation don't nuke the app
const default_layout = components[0]();
const default_error = components[1]();

const root_stuff = {};

// We track the scroll position associated with each history entry in sessionStorage,
// rather than on history.state itself, because when navigation is driven by
// popstate it's too late to update the scroll position associated with the
// state we're navigating from

/** @typedef {{ x: number, y: number }} ScrollPosition */
/** @type {Record<number, ScrollPosition>} */
let scroll_positions = {};
try {
	scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]);
} catch {
	// do nothing
}

/** @param {number} index */
function update_scroll_positions(index) {
	scroll_positions[index] = scroll_state();
}

/**
 * @param {{
 *   target: Element;
 *   session: App.Session;
 *   base: string;
 *   trailing_slash: import('types').TrailingSlash;
 * }} opts
 * @returns {import('./types').Client}
 */
function create_client({ target, session, base, trailing_slash }) {
	/** @type {Map<string, import('./types').NavigationResult>} */
	const cache = new Map();

	/** @type {Array<((href: string) => boolean)>} */
	const invalidated = [];

	const stores = {
		url: notifiable_store({}),
		page: notifiable_store({}),
		navigating: writable(/** @type {import('types').Navigation | null} */ (null)),
		session: writable(session),
		updated: create_updated_store()
	};

	/** @type {{id: string | null, promise: Promise<import('./types').NavigationResult | undefined> | null}} */
	const load_cache = {
		id: null,
		promise: null
	};

	const callbacks = {
		/** @type {Array<(opts: { from: URL, to: URL | null, cancel: () => void }) => void>} */
		before_navigate: [],

		/** @type {Array<(opts: { from: URL | null, to: URL }) => void>} */
		after_navigate: []
	};

	/** @type {import('./types').NavigationState} */
	let current = {
		branch: [],
		error: null,
		session_id: 0,
		stuff: root_stuff,
		// @ts-ignore - we need the initial value to be null
		url: null
	};

	let started = false;
	let autoscroll = true;
	let updating = false;
	let session_id = 1;

	/** @type {Promise<void> | null} */
	let invalidating = null;

	/** @type {import('svelte').SvelteComponent} */
	let root;

	/** @type {App.Session} */
	let $session;

	let ready = false;
	stores.session.subscribe(async (value) => {
		$session = value;

		if (!ready) return;
		session_id += 1;

		update(new URL(location.href), [], true);
	});
	ready = true;

	let router_enabled = true;

	// keeping track of the history index in order to prevent popstate navigation events if needed
	let current_history_index = history.state?.[INDEX_KEY];

	if (!current_history_index) {
		// we use Date.now() as an offset so that cross-document navigations
		// within the app don't result in data loss
		current_history_index = Date.now();

		// create initial history entry, so we can return here
		history.replaceState(
			{ ...history.state, [INDEX_KEY]: current_history_index },
			'',
			location.href
		);
	}

	// if we reload the page, or Cmd-Shift-T back to it,
	// recover scroll position
	const scroll = scroll_positions[current_history_index];
	if (scroll) {
		history.scrollRestoration = 'manual';
		scrollTo(scroll.x, scroll.y);
	}

	let hash_navigating = false;

	/** @type {import('types').Page} */
	let page;

	/** @type {{}} */
	let token;

	/**
	 * @param {string} href
	 * @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts
	 * @param {string[]} redirect_chain
	 */
	async function goto(
		href,
		{ noscroll = false, replaceState = false, keepfocus = false, state = {} },
		redirect_chain
	) {
		const url = new URL(href, get_base_uri(document));

		if (router_enabled) {
			return navigate({
				url,
				scroll: noscroll ? scroll_state() : null,
				keepfocus,
				redirect_chain,
				details: {
					state,
					replaceState
				},
				accepted: () => {},
				blocked: () => {}
			});
		}

		await native_navigation(url);
	}

	/** @param {URL} url */
	async function prefetch(url) {
		const intent = get_navigation_intent(url);

		if (!intent) {
			throw new Error('Attempted to prefetch a URL that does not belong to this app');
		}

		load_cache.promise = load_route(intent, false);
		load_cache.id = intent.id;

		return load_cache.promise;
	}

	/**
	 * Returns `true` if update completes, `false` if it is aborted
	 * @param {URL} url
	 * @param {string[]} redirect_chain
	 * @param {boolean} no_cache
	 * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean, details: { replaceState: boolean, state: any } | null}} [opts]
	 */
	async function update(url, redirect_chain, no_cache, opts) {
		const intent = get_navigation_intent(url);

		const current_token = (token = {});
		let navigation_result = intent && (await load_route(intent, no_cache));

		if (
			!navigation_result &&
			url.origin === location.origin &&
			url.pathname === location.pathname
		) {
			// this could happen in SPA fallback mode if the user navigated to
			// `/non-existent-page`. if we fall back to reloading the page, it
			// will create an infinite loop. so whereas we normally handle
			// unknown routes by going to the server, in this special case
			// we render a client-side error page instead
			navigation_result = await load_root_error_page({
				status: 404,
				error: new Error(`Not found: ${url.pathname}`),
				url,
				routeId: null
			});
		}

		if (!navigation_result) {
			await native_navigation(url);
			return false; // unnecessary, but TypeScript prefers it this way
		}

		// abort if user navigated during update
		if (token !== current_token) return false;

		invalidated.length = 0;

		if (navigation_result.redirect) {
			if (redirect_chain.length > 10 || redirect_chain.includes(url.pathname)) {
				navigation_result = await load_root_error_page({
					status: 500,
					error: new Error('Redirect loop'),
					url,
					routeId: null
				});
			} else {
				if (router_enabled) {
					goto(new URL(navigation_result.redirect, url).href, {}, [
						...redirect_chain,
						url.pathname
					]);
				} else {
					await native_navigation(new URL(navigation_result.redirect, location.href));
				}

				return false;
			}
		} else if (navigation_result.props?.page?.status >= 400) {
			const updated = await stores.updated.check();
			if (updated) {
				await native_navigation(url);
			}
		}

		updating = true;

		if (opts && opts.details) {
			const { details } = opts;
			const change = details.replaceState ? 0 : 1;
			details.state[INDEX_KEY] = current_history_index += change;
			history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url);
		}

		if (started) {
			current = navigation_result.state;

			root.$set(navigation_result.props);
		} else {
			initialize(navigation_result);
		}

		// opts must be passed if we're navigating
		if (opts) {
			const { scroll, keepfocus } = opts;

			if (!keepfocus) {
				// Reset page selection and focus
				// We try to mimic browsers' behaviour as closely as possible by targeting the
				// first scrollable region, but unfortunately it's not a perfect match — e.g.
				// shift-tabbing won't immediately cycle up from the end of the page on Chromium
				// See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area
				const root = document.body;
				const tabindex = root.getAttribute('tabindex');

				getSelection()?.removeAllRanges();
				root.tabIndex = -1;
				root.focus({ preventScroll: true });

				// restore `tabindex` as to prevent `root` from stealing input from elements
				if (tabindex !== null) {
					root.setAttribute('tabindex', tabindex);
				} else {
					root.removeAttribute('tabindex');
				}
			}

			// need to render the DOM before we can scroll to the rendered elements
			await tick();

			if (autoscroll) {
				const deep_linked = url.hash && document.getElementById(url.hash.slice(1));
				if (scroll) {
					scrollTo(scroll.x, scroll.y);
				} else if (deep_linked) {
					// Here we use `scrollIntoView` on the element instead of `scrollTo`
					// because it natively supports the `scroll-margin` and `scroll-behavior`
					// CSS properties.
					deep_linked.scrollIntoView();
				} else {
					scrollTo(0, 0);
				}
			}
		} else {
			// in this case we're simply invalidating
			await tick();
		}

		load_cache.promise = null;
		load_cache.id = null;
		autoscroll = true;
		updating = false;

		if (navigation_result.props.page) {
			page = navigation_result.props.page;
		}

		const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1];
		router_enabled = leaf_node?.module.router !== false;

		return true;
	}

	/** @param {import('./types').NavigationResult} result */
	function initialize(result) {
		current = result.state;

		const style = document.querySelector('style[data-sveltekit]');
		if (style) style.remove();

		page = result.props.page;

		root = new Root({
			target,
			props: { ...result.props, stores },
			hydrate: true
		});

		started = true;

		if (router_enabled) {
			const navigation = { from: null, to: new URL(location.href) };
			callbacks.after_navigate.forEach((fn) => fn(navigation));
		}
	}

	/**
	 *
	 * @param {{
	 *   url: URL;
	 *   params: Record<string, string>;
	 *   stuff: Record<string, any>;
	 *   branch: Array<import('./types').BranchNode | undefined>;
	 *   status: number;
	 *   error: Error | null;
	 *   routeId: string | null;
	 * }} opts
	 */
	async function get_navigation_result_from_branch({
		url,
		params,
		stuff,
		branch,
		status,
		error,
		routeId
	}) {
		const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean));
		const redirect = filtered.find((f) => f.loaded?.redirect);

		/** @type {import('./types').NavigationResult} */
		const result = {
			redirect: redirect?.loaded?.redirect,
			state: {
				url,
				params,
				branch,
				error,
				stuff,
				session_id
			},
			props: {
				components: filtered.map((node) => node.module.default)
			}
		};

		for (let i = 0; i < filtered.length; i += 1) {
			const loaded = filtered[i].loaded;
			result.props[`props_${i}`] = loaded ? await loaded.props : null;
		}

		const page_changed =
			!current.url ||
			url.href !== current.url.href ||
			current.error !== error ||
			current.stuff !== stuff;

		if (page_changed) {
			result.props.page = { error, params, routeId, status, stuff, url };

			// TODO remove this for 1.0
			/**
			 * @param {string} property
			 * @param {string} replacement
			 */
			const print_error = (property, replacement) => {
				Object.defineProperty(result.props.page, property, {
					get: () => {
						throw new Error(`$page.${property} has been replaced by $page.url.${replacement}`);
					}
				});
			};

			print_error('origin', 'origin');
			print_error('path', 'pathname');
			print_error('query', 'searchParams');
		}

		const leaf = filtered[filtered.length - 1];
		const load_cache = leaf?.loaded?.cache;

		if (load_cache) {
			const key = url.pathname + url.search; // omit hash
			let ready = false;

			const clear = () => {
				if (cache.get(key) === result) {
					cache.delete(key);
				}

				unsubscribe();
				clearTimeout(timeout);
			};

			const timeout = setTimeout(clear, load_cache.maxage * 1000);

			const unsubscribe = stores.session.subscribe(() => {
				if (ready) clear();
			});

			ready = true;

			cache.set(key, result);
		}

		return result;
	}

	/**
	 * @param {{
	 *   status?: number;
	 *   error?: Error;
	 *   module: import('types').CSRComponent;
	 *   url: URL;
	 *   params: Record<string, string>;
	 *   stuff: Record<string, any>;
	 *   props?: Record<string, any>;
	 *   routeId: string | null;
	 * }} options
	 */
	async function load_node({ status, error, module, url, params, stuff, props, routeId }) {
		/** @type {import('./types').BranchNode} */
		const node = {
			module,
			uses: {
				params: new Set(),
				url: false,
				session: false,
				stuff: false,
				dependencies: new Set()
			},
			loaded: null,
			stuff
		};

		/** @param dep {string} */
		function add_dependency(dep) {
			const { href } = new URL(dep, url);
			node.uses.dependencies.add(href);
		}

		if (props) {
			// shadow endpoint props means we need to mark this URL as a dependency of itself
			node.uses.dependencies.add(url.href);
		}

		/** @type {Record<string, string>} */
		const uses_params = {};
		for (const key in params) {
			Object.defineProperty(uses_params, key, {
				get() {
					node.uses.params.add(key);
					return params[key];
				},
				enumerable: true
			});
		}

		const session = $session;

		if (module.load) {
			/** @type {import('types').LoadInput} */
			const load_input = {
				routeId,
				params: uses_params,
				props: props || {},
				get url() {
					node.uses.url = true;
					return url;
				},
				get session() {
					node.uses.session = true;
					return session;
				},
				get stuff() {
					node.uses.stuff = true;
					return { ...stuff };
				},
				fetch(resource, info) {
					const requested = typeof resource === 'string' ? resource : resource.url;
					add_dependency(requested);

					return started ? fetch(resource, info) : initial_fetch(resource, info);
				},
				status: status ?? null,
				error: error ?? null
			};

			if (import.meta.env.DEV) {
				// TODO remove this for 1.0
				Object.defineProperty(load_input, 'page', {
					get: () => {
						throw new Error('`page` in `load` functions has been replaced by `url` and `params`');
					}
				});
			}

			const loaded = await module.load.call(null, load_input);

			if (!loaded) {
				throw new Error('load function must return a value');
			}

			node.loaded = normalize(loaded);
			if (node.loaded.stuff) node.stuff = node.loaded.stuff;
			if (node.loaded.dependencies) {
				node.loaded.dependencies.forEach(add_dependency);
			}
		} else if (props) {
			node.loaded = normalize({ props });
		}

		return node;
	}

	/**
	 * @param {import('./types').NavigationIntent} intent
	 * @param {boolean} no_cache
	 */
	async function load_route({ id, url, params, route }, no_cache) {
		if (load_cache.id === id && load_cache.promise) {
			return load_cache.promise;
		}

		if (!no_cache) {
			const cached = cache.get(id);
			if (cached) return cached;
		}

		const { a, b, has_shadow } = route;

		const changed = current.url && {
			url: id !== current.url.pathname + current.url.search,
			params: Object.keys(params).filter((key) => current.params[key] !== params[key]),
			session: session_id !== current.session_id
		};

		/** @type {Array<import('./types').BranchNode | undefined>} */
		let branch = [];

		/** @type {Record<string, any>} */
		let stuff = root_stuff;
		let stuff_changed = false;

		/** @type {number | undefined} */
		let status = 200;

		/** @type {Error | null} */
		let error = null;

		// preload modules to avoid waterfall, but handle rejections
		// so they don't get reported to Sentry et al (we don't need
		// to act on the failures at this point)
		a.forEach((loader) => loader().catch(() => {}));

		load: for (let i = 0; i < a.length; i += 1) {
			/** @type {import('./types').BranchNode | undefined} */
			let node;

			try {
				if (!a[i]) continue;

				const module = await a[i]();
				const previous = current.branch[i];

				const changed_since_last_render =
					!previous ||
					module !== previous.module ||
					(changed.url && previous.uses.url) ||
					changed.params.some((param) => previous.uses.params.has(param)) ||
					(changed.session && previous.uses.session) ||
					Array.from(previous.uses.dependencies).some((dep) => invalidated.some((fn) => fn(dep))) ||
					(stuff_changed && previous.uses.stuff);

				if (changed_since_last_render) {
					/** @type {Record<string, any>} */
					let props = {};

					const is_shadow_page = has_shadow && i === a.length - 1;

					if (is_shadow_page) {
						const res = await fetch(
							`${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`,
							{
								headers: {
									'x-sveltekit-load': 'true'
								}
							}
						);

						if (res.ok) {
							const redirect = res.headers.get('x-sveltekit-location');

							if (redirect) {
								return {
									redirect,
									props: {},
									state: current
								};
							}

							props = res.status === 204 ? {} : await res.json();
						} else {
							status = res.status;
							error = new Error('Failed to load data');
						}
					}

					if (!error) {
						node = await load_node({
							module,
							url,
							params,
							props,
							stuff,
							routeId: route.id
						});
					}

					if (node) {
						if (is_shadow_page) {
							node.uses.url = true;
						}

						if (node.loaded) {
							if (node.loaded.error) {
								status = node.loaded.status;
								error = node.loaded.error;
							}

							if (node.loaded.redirect) {
								return {
									redirect: node.loaded.redirect,
									props: {},
									state: current
								};
							}

							if (node.loaded.stuff) {
								stuff_changed = true;
							}
						}
					}
				} else {
					node = previous;
				}
			} catch (e) {
				status = 500;
				error = coalesce_to_error(e);
			}

			if (error) {
				while (i--) {
					if (b[i]) {
						let error_loaded;

						/** @type {import('./types').BranchNode | undefined} */
						let node_loaded;
						let j = i;
						while (!(node_loaded = branch[j])) {
							j -= 1;
						}

						try {
							error_loaded = await load_node({
								status,
								error,
								module: await b[i](),
								url,
								params,
								stuff: node_loaded.stuff,
								routeId: route.id
							});

							if (error_loaded?.loaded?.error) {
								continue;
							}

							if (error_loaded?.loaded?.stuff) {
								stuff = {
									...stuff,
									...error_loaded.loaded.stuff
								};
							}

							branch = branch.slice(0, j + 1).concat(error_loaded);
							break load;
						} catch (e) {
							continue;
						}
					}
				}

				return await load_root_error_page({
					status,
					error,
					url,
					routeId: route.id
				});
			} else {
				if (node?.loaded?.stuff) {
					stuff = {
						...stuff,
						...node.loaded.stuff
					};
				}

				branch.push(node);
			}
		}

		return await get_navigation_result_from_branch({
			url,
			params,
			stuff,
			branch,
			status,
			error,
			routeId: route.id
		});
	}

	/**
	 * @param {{
	 *   status: number;
	 *   error: Error;
	 *   url: URL;
	 *   routeId: string | null
	 * }} opts
	 */
	async function load_root_error_page({ status, error, url, routeId }) {
		/** @type {Record<string, string>} */
		const params = {}; // error page does not have params

		const root_layout = await load_node({
			module: await default_layout,
			url,
			params,
			stuff: {},
			routeId
		});

		const root_error = await load_node({
			status,
			error,
			module: await default_error,
			url,
			params,
			stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {},
			routeId
		});

		return await get_navigation_result_from_branch({
			url,
			params,
			stuff: {
				...root_layout?.loaded?.stuff,
				...root_error?.loaded?.stuff
			},
			branch: [root_layout, root_error],
			status,
			error,
			routeId
		});
	}

	/** @param {URL} url */
	function get_navigation_intent(url) {
		if (url.origin !== location.origin || !url.pathname.startsWith(base)) return;

		const path = decodeURI(url.pathname.slice(base.length) || '/');

		for (const route of routes) {
			const params = route.exec(path);

			if (params) {
				/** @type {import('./types').NavigationIntent} */
				const intent = {
					id: url.pathname + url.search,
					route,
					params,
					url
				};

				return intent;
			}
		}
	}

	/**
	 * @param {{
	 *   url: URL;
	 *   scroll: { x: number, y: number } | null;
	 *   keepfocus: boolean;
	 *   redirect_chain: string[];
	 *   details: {
	 *     replaceState: boolean;
	 *     state: any;
	 *   } | null;
	 *   accepted: () => void;
	 *   blocked: () => void;
	 * }} opts
	 */
	async function navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) {
		const from = current.url;
		let should_block = false;

		const navigation = {
			from,
			to: url,
			cancel: () => (should_block = true)
		};

		callbacks.before_navigate.forEach((fn) => fn(navigation));

		if (should_block) {
			blocked();
			return;
		}

		const pathname = normalize_path(url.pathname, trailing_slash);
		const normalized = new URL(url.origin + pathname + url.search + url.hash);

		update_scroll_positions(current_history_index);

		accepted();

		if (started) {
			stores.navigating.set({
				from: current.url,
				to: normalized
			});
		}

		const completed = await update(normalized, redirect_chain, false, {
			scroll,
			keepfocus,
			details
		});

		if (completed) {
			const navigation = { from, to: normalized };
			callbacks.after_navigate.forEach((fn) => fn(navigation));

			stores.navigating.set(null);
		}
	}

	/**
	 * Loads `href` the old-fashioned way, with a full page reload.
	 * Returns a `Promise` that never resolves (to prevent any
	 * subsequent work, e.g. history manipulation, from happening)
	 * @param {URL} url
	 */
	function native_navigation(url) {
		location.href = url.href;
		return new Promise(() => {});
	}

	return {
		after_navigate: (fn) => {
			onMount(() => {
				callbacks.after_navigate.push(fn);

				return () => {
					const i = callbacks.after_navigate.indexOf(fn);
					callbacks.after_navigate.splice(i, 1);
				};
			});
		},

		before_navigate: (fn) => {
			onMount(() => {
				callbacks.before_navigate.push(fn);

				return () => {
					const i = callbacks.before_navigate.indexOf(fn);
					callbacks.before_navigate.splice(i, 1);
				};
			});
		},

		disable_scroll_handling: () => {
			if (import.meta.env.DEV && started && !updating) {
				throw new Error('Can only disable scroll handling during navigation');
			}

			if (updating || !started) {
				autoscroll = false;
			}
		},

		goto: (href, opts = {}) => goto(href, opts, []),

		invalidate: (resource) => {
			if (typeof resource === 'function') {
				invalidated.push(resource);
			} else {
				const { href } = new URL(resource, location.href);
				invalidated.push((dep) => dep === href);
			}

			if (!invalidating) {
				invalidating = Promise.resolve().then(async () => {
					await update(new URL(location.href), [], true);

					invalidating = null;
				});
			}

			return invalidating;
		},

		prefetch: async (href) => {
			const url = new URL(href, get_base_uri(document));
			await prefetch(url);
		},

		// TODO rethink this API
		prefetch_routes: async (pathnames) => {
			const matching = pathnames
				? routes.filter((route) => pathnames.some((pathname) => route.exec(pathname)))
				: routes;

			const promises = matching.map((r) => Promise.all(r.a.map((load) => load())));

			await Promise.all(promises);
		},

		_start_router: () => {
			history.scrollRestoration = 'manual';

			// Adopted from Nuxt.js
			// Reset scrollRestoration to auto when leaving page, allowing page reload
			// and back-navigation from other pages to use the browser to restore the
			// scrolling position.
			addEventListener('beforeunload', (e) => {
				let should_block = false;

				const navigation = {
					from: current.url,
					to: null,
					cancel: () => (should_block = true)
				};

				callbacks.before_navigate.forEach((fn) => fn(navigation));

				if (should_block) {
					e.preventDefault();
					e.returnValue = '';
				} else {
					history.scrollRestoration = 'auto';
				}
			});

			addEventListener('visibilitychange', () => {
				if (document.visibilityState === 'hidden') {
					update_scroll_positions(current_history_index);

					try {
						sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions);
					} catch {
						// do nothing
					}
				}
			});

			/** @param {Event} event */
			const trigger_prefetch = (event) => {
				const a = find_anchor(event);
				if (a && a.href && a.hasAttribute('sveltekit:prefetch')) {
					prefetch(get_href(a));
				}
			};

			/** @type {NodeJS.Timeout} */
			let mousemove_timeout;

			/** @param {MouseEvent|TouchEvent} event */
			const handle_mousemove = (event) => {
				clearTimeout(mousemove_timeout);
				mousemove_timeout = setTimeout(() => {
					// event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout
					// add a layer of indirection to address that
					event.target?.dispatchEvent(
						new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true })
					);
				}, 20);
			};

			addEventListener('touchstart', trigger_prefetch);
			addEventListener('mousemove', handle_mousemove);
			addEventListener('sveltekit:trigger_prefetch', trigger_prefetch);

			/** @param {MouseEvent} event */
			addEventListener('click', (event) => {
				if (!router_enabled) return;

				// Adapted from https://github.com/visionmedia/page.js
				// MIT license https://github.com/visionmedia/page.js#license
				if (event.button || event.which !== 1) return;
				if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
				if (event.defaultPrevented) return;

				const a = find_anchor(event);
				if (!a) return;

				if (!a.href) return;

				const is_svg_a_element = a instanceof SVGAElement;
				const url = get_href(a);

				// Ignore if url does not have origin (e.g. `mailto:`, `tel:`.)
				// MEMO: Without this condition, firefox will open mailer twice.
				// See: https://github.com/sveltejs/kit/issues/4045
				if (!is_svg_a_element && url.origin === 'null') return;

				// Ignore if tag has
				// 1. 'download' attribute
				// 2. 'rel' attribute includes external
				const rel = (a.getAttribute('rel') || '').split(/\s+/);

				if (
					a.hasAttribute('download') ||
					rel.includes('external') ||
					a.hasAttribute('sveltekit:reload')
				) {
					return;
				}

				// Ignore if <a> has a target
				if (is_svg_a_element ? a.target.baseVal : a.target) return;

				// Check if new url only differs by hash and use the browser default behavior in that case
				// This will ensure the `hashchange` event is fired
				// Removing the hash does a full page navigation in the browser, so make sure a hash is present
				const [base, hash] = url.href.split('#');
				if (hash !== undefined && base === location.href.split('#')[0]) {
					// set this flag to distinguish between navigations triggered by
					// clicking a hash link and those triggered by popstate
					hash_navigating = true;

					update_scroll_positions(current_history_index);

					stores.page.set({ ...page, url });
					stores.page.notify();

					return;
				}

				navigate({
					url,
					scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null,
					keepfocus: false,
					redirect_chain: [],
					details: {
						state: {},
						replaceState: url.href === location.href
					},
					accepted: () => event.preventDefault(),
					blocked: () => event.preventDefault()
				});
			});

			addEventListener('popstate', (event) => {
				if (event.state && router_enabled) {
					// if a popstate-driven navigation is cancelled, we need to counteract it
					// with history.go, which means we end up back here, hence this check
					if (event.state[INDEX_KEY] === current_history_index) return;

					navigate({
						url: new URL(location.href),
						scroll: scroll_positions[event.state[INDEX_KEY]],
						keepfocus: false,
						redirect_chain: [],
						details: null,
						accepted: () => {
							current_history_index = event.state[INDEX_KEY];
						},
						blocked: () => {
							const delta = current_history_index - event.state[INDEX_KEY];
							history.go(delta);
						}
					});
				}
			});

			addEventListener('hashchange', () => {
				// if the hashchange happened as a result of clicking on a link,
				// we need to update history, otherwise we have to leave it alone
				if (hash_navigating) {
					hash_navigating = false;
					history.replaceState(
						{ ...history.state, [INDEX_KEY]: ++current_history_index },
						'',
						location.href
					);
				}
			});
		},

		_hydrate: async ({ status, error, nodes, params, routeId }) => {
			const url = new URL(location.href);

			/** @type {Array<import('./types').BranchNode | undefined>} */
			const branch = [];

			/** @type {Record<string, any>} */
			let stuff = {};

			/** @type {import('./types').NavigationResult | undefined} */
			let result;

			let error_args;

			try {
				for (let i = 0; i < nodes.length; i += 1) {
					const is_leaf = i === nodes.length - 1;

					let props;

					if (is_leaf) {
						const serialized = document.querySelector('script[sveltekit\\:data-type="props"]');
						if (serialized) {
							props = JSON.parse(/** @type {string} */ (serialized.textContent));
						}
					}

					const node = await load_node({
						module: await nodes[i],
						url,
						params,
						stuff,
						status: is_leaf ? status : undefined,
						error: is_leaf ? error : undefined,
						props,
						routeId
					});

					if (props) {
						node.uses.dependencies.add(url.href);
						node.uses.url = true;
					}

					branch.push(node);

					if (node && node.loaded) {
						if (node.loaded.error) {
							if (error) throw node.loaded.error;
							error_args = {
								status: node.loaded.status,
								error: node.loaded.error,
								url,
								routeId
							};
						} else if (node.loaded.stuff) {
							stuff = {
								...stuff,
								...node.loaded.stuff
							};
						}
					}
				}

				result = error_args
					? await load_root_error_page(error_args)
					: await get_navigation_result_from_branch({
							url,
							params,
							stuff,
							branch,
							status,
							error,
							routeId
					  });
			} catch (e) {
				if (error) throw e;

				result = await load_root_error_page({
					status: 500,
					error: coalesce_to_error(e),
					url,
					routeId
				});
			}

			if (result.redirect) {
				// this is a real edge case — `load` would need to return
				// a redirect but only in the browser
				await native_navigation(new URL(result.redirect, location.href));
			}

			initialize(result);
		}
	};
}

/**
 * @param {{
 *   paths: {
 *     assets: string;
 *     base: string;
 *   },
 *   target: Element;
 *   session: any;
 *   route: boolean;
 *   spa: boolean;
 *   trailing_slash: import('types').TrailingSlash;
 *   hydrate: {
 *     status: number;
 *     error: Error;
 *     nodes: Array<Promise<import('types').CSRComponent>>;
 *     params: Record<string, string>;
 *     routeId: string | null;
 *   };
 * }} opts
 */
async function start({ paths, target, session, route, spa, trailing_slash, hydrate }) {
	const client = create_client({
		target,
		session,
		base: paths.base,
		trailing_slash
	});

	init({ client });
	set_paths(paths);

	if (hydrate) {
		await client._hydrate(hydrate);
	}

	if (route) {
		if (spa) client.goto(location.href, { replaceState: true });
		client._start_router();
	}

	dispatchEvent(new CustomEvent('sveltekit:start'));
}

export { start };
