/**
 * WordPress dependencies
 */
import { store, privateApis, getConfig } from '@wordpress/interactivity';

/**
 * Internal dependencies
 */
import { preloadStyles, applyStyles, type StyleElement } from './assets/styles';
import {
	preloadScriptModules,
	importScriptModules,
	markScriptModuleAsResolved,
	type ScriptModuleLoad,
} from './assets/script-modules';

const {
	getRegionRootFragment,
	initialVdomPromise,
	toVdom,
	render,
	parseServerData,
	populateServerData,
	batch,
	routerRegions,
	h: createElement,
	navigationSignal,
	sessionId,
	warn,
} = privateApis(
	'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'
);

const regionAttr = `data-wp-router-region`;
const interactiveAttr = `data-wp-interactive`;
const regionsSelector = `[${ interactiveAttr }][${ regionAttr }], [${ interactiveAttr }] [${ interactiveAttr }][${ regionAttr }]`;

export interface NavigateOptions {
	force?: boolean;
	html?: string;
	replace?: boolean;
	timeout?: number;
	loadingAnimation?: boolean;
	screenReaderAnnouncement?: boolean;
}

export interface PrefetchOptions {
	force?: boolean;
	html?: string;
}

interface VdomParams {
	vdom?: WeakMap< Element, any >;
}

interface Page {
	url: string;
	regions: Record< string, any >;
	regionsToAttach: Record< string, string >;
	styles: StyleElement[];
	scriptModules: ScriptModuleLoad[];
	title: string;
	initialData: any;
}

type PreparePage = (
	url: string,
	dom: Document,
	params?: VdomParams
) => Promise< Page >;

// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map< string, Promise< Page | false > >();

// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
const getPagePath = ( url: string ) => {
	const u = new URL( url, window.location.href );
	return u.pathname + u.search;
};

/**
 * Parses the given region's directive.
 *
 * @param region Region element.
 * @return Data contained in the region directive value.
 */
const parseRegionAttribute = ( region: Element ) => {
	const value = region.getAttribute( regionAttr );
	try {
		const { id, attachTo } = JSON.parse( value );
		return { id, attachTo };
	} catch {
		return { id: value };
	}
};

/**
 * Clones the content of the router region vDOM passed as argument.
 *
 * The function creates a new VNode instance removing all priority levels up to
 * the one containing the router-region directive, which should have evaluated
 * in advance.
 *
 * @param vdom A router region's VNode.
 * @return The VNode for the passed router region's content.
 */
const cloneRouterRegionContent = ( vdom: any ) => {
	if ( ! vdom ) {
		return vdom;
	}
	const allPriorityLevels: string[][] = vdom.props.priorityLevels;
	const routerRegionLevel = allPriorityLevels.findIndex( ( level ) =>
		level.includes( 'router-region' )
	);
	const priorityLevels =
		routerRegionLevel !== -1
			? allPriorityLevels.slice( routerRegionLevel + 1 )
			: allPriorityLevels;

	return priorityLevels.length > 0
		? createElement( vdom.type, {
				...vdom.props,
				priorityLevels,
		  } )
		: vdom.props.element;
};

/**
 * IDs of router regions with an `attachTo` property pointing to the same parent
 * element.
 */
const regionsToAttachByParent = new WeakMap< Element, string[] >();

/**
 * Map of root fragments by parent element, used to render router regions with
 * the `attachTo` property. Those elements with the same parent are rendered
 * together in the corresponding root fragment.
 */
const rootFragmentsByParent = new WeakMap< Element, any >();

/**
 * Set of router regions using the `attachTo` property that are present in the
 * initial page.
 *
 * These regions should be treated as regular regions without the `attachTo`
 * attribute as they don't need to be appended; they are already in the HTML.
 */
const initialRegionsToAttach = new Set< string >();

/**
 * Fetches and prepares a page from a given URL.
 *
 * @param url          The URL of the page to fetch.
 * @param options      Options for the fetch operation.
 * @param options.html Optional HTML content. If provided, the function will use
 *                     this instead of fetching from the URL.
 * @return             A Promise that resolves to the prepared page, or false if
 *                     there was an error during fetching or preparation.
 */
const fetchPage = async ( url: string, { html }: { html: string } ) => {
	try {
		if ( ! html ) {
			const res = await window.fetch( url );
			if ( res.status !== 200 ) {
				return false;
			}
			html = await res.text();
		}
		const dom = new window.DOMParser().parseFromString( html, 'text/html' );
		return await preparePage( url, dom );
	} catch {
		return false;
	}
};

/**
 * Processes a DOM document to extract router regions and related resources.
 *
 * This function analyzes the provided DOM document and creates a virtual DOM
 * representation of all HTML regions marked with a `router-region` directive.
 * It also extracts and preloads associated styles and scripts to prepare for
 * rendering the page.
 *
 * @param url             The URL associated with the page, used for asset
 *                        loading and caching.
 * @param dom             The DOM document to process.
 * @param vdomParams      Optional parameters for virtual DOM processing.
 * @param vdomParams.vdom An optional existing virtual DOM cache to check for
 *                        regions. If a region exists in this cache, it will be
 *                        reused instead of creating a new vDOM representation.
 * @return                A Promise that resolves to a {@link Page} object
 *                        containing the virtual DOM for all router regions,
 *                        preloaded styles and scripts, page title, and initial
 *                        server-rendered data.
 */
const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => {
	// Remove all noscript elements as they're irrelevant when request is served via router.
	// This prevents browsers from extracting styles from noscript tags.
	dom.querySelectorAll( 'noscript' ).forEach( ( el ) => el.remove() );

	const regions = {};
	const regionsToAttach = {};
	dom.querySelectorAll( regionsSelector ).forEach( ( region ) => {
		const { id, attachTo } = parseRegionAttribute( region );

		if ( region.parentElement.closest( `[${ regionAttr }]` ) ) {
			regions[ id ] = undefined;
		} else {
			regions[ id ] = vdom?.has( region )
				? vdom.get( region )
				: toVdom( region );
		}

		if ( attachTo && ! initialRegionsToAttach.has( id ) ) {
			regionsToAttach[ id ] = attachTo;
		}
	} );

	const title = dom.querySelector( 'title' )?.innerText;
	const initialData = parseServerData( dom );

	// Wait for styles and modules to be ready.
	const [ styles, scriptModules ] = await Promise.all( [
		Promise.all( preloadStyles( dom ) ),
		Promise.all( preloadScriptModules( dom ) ),
	] );

	return {
		regions,
		regionsToAttach,
		styles,
		scriptModules,
		title,
		initialData,
		url,
	};
};

/**
 * Renders a page by applying styles, populating server data, rendering regions,
 * and updating the document title.
 *
 * @param page The {@link Page} object to render.
 */
const renderPage = ( page: Page ) => {
	applyStyles( page.styles );

	// Clone regionsToAttach.
	const regionsToAttach = { ...page.regionsToAttach };

	batch( () => {
		// Updates the server data.
		populateServerData( page.initialData );

		// Triggers navigation invalidations (`getServerState` and
		// `getServerContext`).
		navigationSignal.value += 1;

		// Resets all router regions before setting the actual values.
		( routerRegions as Map< string, any > ).forEach( ( signal ) => {
			signal.value = null;
		} );

		// Inits regions with attachTo that don't exist yet.
		const parentsToUpdate = new Set< Element >();
		for ( const id in regionsToAttach ) {
			const parent = document.querySelector( regionsToAttach[ id ] );
			if ( ! regionsToAttachByParent.has( parent ) ) {
				regionsToAttachByParent.set( parent, [] );
			}
			const regions = regionsToAttachByParent.get( parent );
			if ( ! regions.includes( id ) ) {
				regions.push( id );
				parentsToUpdate.add( parent );
			}
		}

		// Updates all existing regions.
		for ( const id in page.regions ) {
			if ( routerRegions.has( id ) ) {
				routerRegions.get( id ).value = cloneRouterRegionContent(
					page.regions[ id ]
				);
			}
		}

		// Renders regions attached to the same parent in the same fragment.
		parentsToUpdate.forEach( ( parent ) => {
			const ids = regionsToAttachByParent.get( parent );
			const vdoms = ids.map( ( id ) => page.regions[ id ] );

			if ( ! rootFragmentsByParent.has( parent ) ) {
				const regions = vdoms.map( ( { props, type } ) => {
					const elementType =
						typeof type === 'function' ? props.type : type;

					// Creates an element with the obtained type where the
					// region will be rendered. The type should match the one of
					// the root vnode.
					const region = document.createElement( elementType );
					parent.appendChild( region );
					return region;
				} );
				rootFragmentsByParent.set(
					parent,
					getRegionRootFragment( regions )
				);
			}
			const fragment = rootFragmentsByParent.get( parent );
			render( vdoms, fragment );
		} );
	} );

	if ( page.title ) {
		document.title = page.title;
	}
};

/**
 * Loads the given page forcing a full page reload.
 *
 * The function returns a promise that won't resolve, useful to prevent any
 * potential feedback indicating that the navigation has finished while the new
 * page is being loaded.
 *
 * @param href The page href.
 * @return Promise that never resolves.
 */
const forcePageReload = ( href: string ) => {
	window.location.assign( href );
	return new Promise( () => {} );
};

// Listen to the back and forward buttons and restore the page if it's in the
// cache.
window.addEventListener( 'popstate', async () => {
	const pagePath = getPagePath( window.location.href ); // Remove hash.
	const page = pages.has( pagePath ) && ( await pages.get( pagePath ) );
	if ( page ) {
		batch( () => {
			state.url = window.location.href;
			renderPage( page );
		} );
	} else {
		window.location.reload();
	}
} );

// Detect router regions with `attachTo` in the initial page. This step should
// be done before the initial page is processed with `preparePage()` so this
// function treats them as regular router regions.
document.querySelectorAll( regionsSelector ).forEach( ( region ) => {
	const { id, attachTo } = parseRegionAttribute( region );
	if ( attachTo ) {
		initialRegionsToAttach.add( id );
	}
} );

// Initialize the router and cache the initial page using the initial vDOM.
window.document
	.querySelectorAll< HTMLScriptElement >( 'script[type=module][src]' )
	.forEach( ( { src } ) => markScriptModuleAsResolved( src ) );

// Await hydration completion before setting the initial page to ensure initialVdom is populated.
( async () => {
	const initialVdomMap = await initialVdomPromise;
	pages.set(
		getPagePath( window.location.href ),
		Promise.resolve(
			preparePage( getPagePath( window.location.href ), document, {
				vdom: initialVdomMap,
			} )
		)
	);
} )();

// Variable to store the current navigation.
let navigatingTo = '';

let hasLoadedNavigationTextsData = false;
const navigationTexts = {
	loading: 'Loading page, please wait.',
	loaded: 'Page Loaded.',
};

interface Store {
	state: {
		url: string;
		navigation: {
			hasStarted: boolean;
			hasFinished: boolean;
		};
	};
	actions: {
		navigate: (
			href: string,
			options?: NavigateOptions
		) => Promise< void >;
		prefetch: ( url: string, options?: PrefetchOptions ) => Promise< void >;
	};
}

const { state: privateState } = store(
	'core/router/private',
	{
		state: {
			navigation: {
				hasStarted: false,
				hasFinished: false,
			},
		},
	},
	{ lock: true }
);

export const { state, actions } = store< Store >( 'core/router', {
	state: {
		get navigation() {
			if ( globalThis.SCRIPT_DEBUG ) {
				warn(
					`The usage of state.navigation.{hasStarted|hasFinished} from core/router is deprecated and will stop working in WordPress 7.1.`
				);
			}
			return privateState.navigation;
		},
	},
	actions: {
		/**
		 * Navigates to the specified page.
		 *
		 * This function normalizes the passed href, fetches the page HTML if
		 * needed, and updates any interactive regions whose contents have
		 * changed. It also creates a new entry in the browser session history.
		 *
		 * @param href                               The page href.
		 * @param [options]                          Options object.
		 * @param [options.force]                    If true, it forces re-fetching the URL.
		 * @param [options.html]                     HTML string to be used instead of fetching the requested URL.
		 * @param [options.replace]                  If true, it replaces the current entry in the browser session history.
		 * @param [options.timeout]                  Time until the navigation is aborted, in milliseconds. Default is 10000.
		 * @param [options.loadingAnimation]         Whether an animation should be shown while navigating. Default to `true`.
		 * @param [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`.
		 *
		 * @return  Promise that resolves once the navigation is completed or aborted.
		 */
		*navigate( href: string, options: NavigateOptions = {} ) {
			const { clientNavigationDisabled } = getConfig();
			if ( clientNavigationDisabled ) {
				yield forcePageReload( href );
			}

			const pagePath = getPagePath( href );
			const { navigation } = privateState;
			const {
				loadingAnimation = true,
				screenReaderAnnouncement = true,
				timeout = 10000,
			} = options;

			navigatingTo = href;
			actions.prefetch( pagePath, options );

			// Creates a promise that resolves when the specified timeout ends.
			// The timeout value is 10 seconds by default.
			const timeoutPromise = new Promise< void >( ( resolve ) =>
				setTimeout( resolve, timeout )
			);

			// Doesn't update the navigation status immediately, wait 400 ms.
			const loadingTimeout = setTimeout( () => {
				if ( navigatingTo !== href ) {
					return;
				}

				if ( loadingAnimation ) {
					navigation.hasStarted = true;
					navigation.hasFinished = false;
				}
				if ( screenReaderAnnouncement ) {
					a11ySpeak( 'loading' );
				}
			}, 400 );

			const page = yield Promise.race( [
				pages.get( pagePath ),
				timeoutPromise,
			] );

			// Dismisses loading message if it hasn't been added yet.
			clearTimeout( loadingTimeout );

			// Once the page is fetched, the destination URL could have changed
			// (e.g., by clicking another link in the meantime). If so, bail
			// out, and let the newer execution to update the HTML.
			if ( navigatingTo !== href ) {
				return;
			}

			if (
				page &&
				! page.initialData?.config?.[ 'core/router' ]
					?.clientNavigationDisabled
			) {
				yield importScriptModules( page.scriptModules );

				batch( () => {
					// Updates the URL in the state.
					state.url = href;

					// Updates the navigation status once the the new page rendering
					// has been completed.
					if ( loadingAnimation ) {
						navigation.hasStarted = false;
						navigation.hasFinished = true;
					}

					// Renders the new page.
					renderPage( page );
				} );

				window.history[
					options.replace ? 'replaceState' : 'pushState'
				]( { wpInteractivityId: sessionId }, '', href );

				if ( screenReaderAnnouncement ) {
					a11ySpeak( 'loaded' );
				}

				// Scroll to the anchor if exits in the link.
				const { hash } = new URL( href, window.location.href );
				if ( hash ) {
					document.querySelector( hash )?.scrollIntoView();
				}
			} else {
				yield forcePageReload( href );
			}
		},

		/**
		 * Prefetches the page with the passed URL.
		 *
		 * The function normalizes the URL and stores internally the fetch
		 * promise, to avoid triggering a second fetch for an ongoing request.
		 *
		 * @param url             The page URL.
		 * @param [options]       Options object.
		 * @param [options.force] Force fetching the URL again.
		 * @param [options.html]  HTML string to be used instead of fetching the requested URL.
		 *
		 * @return  Promise that resolves once the page has been fetched.
		 */
		*prefetch( url: string, options: PrefetchOptions = {} ) {
			const { clientNavigationDisabled } = getConfig();
			if ( clientNavigationDisabled ) {
				return;
			}

			const pagePath = getPagePath( url );
			if ( options.force || ! pages.has( pagePath ) ) {
				pages.set(
					pagePath,
					fetchPage( pagePath, { html: options.html } )
				);
			}

			yield pages.get( pagePath );
		},
	},
} );

// Initialize the URL in the state if it hasn't been set yet in the server.
state.url = state.url || window.location.href;

/**
 * Announces a message to screen readers.
 *
 * This is a wrapper around the `@wordpress/a11y` package's `speak` function. It handles importing
 * the package on demand and should be used instead of calling `a11y.speak` directly.
 *
 * @param messageKey The message to be announced by assistive technologies.
 */
function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
	if ( ! hasLoadedNavigationTextsData ) {
		hasLoadedNavigationTextsData = true;
		const content = document.getElementById(
			'wp-script-module-data-@wordpress/interactivity-router'
		)?.textContent;
		if ( content ) {
			try {
				const parsed = JSON.parse( content );
				if ( typeof parsed?.i18n?.loading === 'string' ) {
					navigationTexts.loading = parsed.i18n.loading;
				}
				if ( typeof parsed?.i18n?.loaded === 'string' ) {
					navigationTexts.loaded = parsed.i18n.loaded;
				}
			} catch {}
		} else {
			// Fallback to localized strings from Interactivity API state.
			// @todo This block is for Core < 6.7.0. Remove when support is dropped.

			// @ts-expect-error
			if ( state.navigation.texts?.loading ) {
				// @ts-expect-error
				navigationTexts.loading = state.navigation.texts.loading;
			}
			// @ts-expect-error
			if ( state.navigation.texts?.loaded ) {
				// @ts-expect-error
				navigationTexts.loaded = state.navigation.texts.loaded;
			}
		}
	}

	const message = navigationTexts[ messageKey ];

	import( '@wordpress/a11y' ).then(
		( { speak } ) => speak( message ),
		// Ignore failures to load the a11y module.
		() => {}
	);
}
