/*
  NOTE: Do not add any dependencies or imports in this file so that it can load quickly in dev.
*/

// eslint-disable-next-line no-console
const debug = import.meta.env.DEV ? console.debug : undefined;
const inBrowser = import.meta.env.SSR === false;
// Track prefetched URLs so we don't prefetch twice
const prefetchedUrls = new Set<string>();
// Track listened anchors so we don't attach duplicated listeners
const listenedAnchors = new WeakSet<HTMLAnchorElement>();

// User-defined config for prefetch. The values are injected by vite-plugin-prefetch
// and can be undefined if not configured. But it will be set a fallback value in `init()`.
// @ts-expect-error injected global
let prefetchAll: boolean = __PREFETCH_PREFETCH_ALL__;
// @ts-expect-error injected global
let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
// @ts-expect-error injected global
let clientPrerender: boolean = __EXPERIMENTAL_CLIENT_PRERENDER__;

interface InitOptions {
	defaultStrategy?: string;
	prefetchAll?: boolean;
}

let inited = false;
/**
 * Initialize the prefetch script, only works once.
 *
 * @param defaultOpts Default options for prefetching if not already set by the user config.
 */
export function init(defaultOpts?: InitOptions) {
	if (!inBrowser) return;

	// Init only once
	if (inited) return;
	inited = true;

	debug?.(`[astro] Initializing prefetch script`);

	// Fallback default values if not set by user config
	prefetchAll ??= defaultOpts?.prefetchAll ?? false;
	defaultStrategy ??= defaultOpts?.defaultStrategy ?? 'hover';

	// In the future, perhaps we can enable treeshaking specific unused strategies
	initTapStrategy();
	initHoverStrategy();
	initViewportStrategy();
	initLoadStrategy();
}

/**
 * Prefetch links with higher priority when the user taps on them
 */
function initTapStrategy() {
	for (const event of ['touchstart', 'mousedown']) {
		document.body.addEventListener(
			event,
			(e) => {
				if (elMatchesStrategy(e.target, 'tap')) {
					prefetch(e.target.href, { with: 'fetch', ignoreSlowConnection: true });
				}
			},
			{ passive: true }
		);
	}
}

/**
 * Prefetch links with higher priority when the user hovers over them
 */
function initHoverStrategy() {
	let timeout: number;

	// Handle focus listeners
	document.body.addEventListener(
		'focusin',
		(e) => {
			if (elMatchesStrategy(e.target, 'hover')) {
				handleHoverIn(e);
			}
		},
		{ passive: true }
	);
	document.body.addEventListener('focusout', handleHoverOut, { passive: true });

	// Handle hover listeners. Re-run each time on page load.
	onPageLoad(() => {
		for (const anchor of document.getElementsByTagName('a')) {
			// Skip if already listening
			if (listenedAnchors.has(anchor)) continue;
			// Add listeners for anchors matching the strategy
			if (elMatchesStrategy(anchor, 'hover')) {
				listenedAnchors.add(anchor);
				anchor.addEventListener('mouseenter', handleHoverIn, { passive: true });
				anchor.addEventListener('mouseleave', handleHoverOut, { passive: true });
			}
		}
	});

	function handleHoverIn(e: Event) {
		const href = (e.target as HTMLAnchorElement).href;

		// Debounce hover prefetches by 80ms
		if (timeout) {
			clearTimeout(timeout);
		}
		timeout = setTimeout(() => {
			prefetch(href, { with: 'fetch' });
		}, 80) as unknown as number;
	}

	// Cancel prefetch if the user hovers away
	function handleHoverOut() {
		if (timeout) {
			clearTimeout(timeout);
			timeout = 0;
		}
	}
}

/**
 * Prefetch links with lower priority as they enter the viewport
 */
function initViewportStrategy() {
	let observer: IntersectionObserver;

	onPageLoad(() => {
		for (const anchor of document.getElementsByTagName('a')) {
			// Skip if already listening
			if (listenedAnchors.has(anchor)) continue;
			// Observe for anchors matching the strategy
			if (elMatchesStrategy(anchor, 'viewport')) {
				listenedAnchors.add(anchor);
				observer ??= createViewportIntersectionObserver();
				observer.observe(anchor);
			}
		}
	});
}

function createViewportIntersectionObserver() {
	const timeouts = new WeakMap<HTMLAnchorElement, number>();

	return new IntersectionObserver((entries, observer) => {
		for (const entry of entries) {
			const anchor = entry.target as HTMLAnchorElement;
			const timeout = timeouts.get(anchor);
			// Prefetch if intersecting
			if (entry.isIntersecting) {
				// Debounce viewport prefetches by 300ms
				if (timeout) {
					clearTimeout(timeout);
				}
				timeouts.set(
					anchor,
					setTimeout(() => {
						observer.unobserve(anchor);
						timeouts.delete(anchor);
						prefetch(anchor.href, { with: 'link' });
					}, 300) as unknown as number
				);
			} else {
				// If exited viewport but haven't prefetched, cancel it
				if (timeout) {
					clearTimeout(timeout);
					timeouts.delete(anchor);
				}
			}
		}
	});
}

/**
 * Prefetch links with lower priority when page load
 */
function initLoadStrategy() {
	onPageLoad(() => {
		for (const anchor of document.getElementsByTagName('a')) {
			if (elMatchesStrategy(anchor, 'load')) {
				// Prefetch every link in this page
				prefetch(anchor.href, { with: 'link' });
			}
		}
	});
}

export interface PrefetchOptions {
	/**
	 * How the prefetch should prioritize the URL. (default `'link'`)
	 * - `'link'`: use `<link rel="prefetch">`, has lower loading priority.
	 * - `'fetch'`: use `fetch()`, has higher loading priority.
	 */
	with?: 'link' | 'fetch';
	/**
	 * Should prefetch even on data saver mode or slow connection. (default `false`)
	 */
	ignoreSlowConnection?: boolean;
}

/**
 * Prefetch a URL so it's cached when the user navigates to it.
 *
 * @param url A full or partial URL string based on the current `location.href`. They are only fetched if:
 *   - The user is online
 *   - The user is not in data saver mode
 *   - The URL is within the same origin
 *   - The URL is not the current page
 *   - The URL has not already been prefetched
 * @param opts Additional options for prefetching.
 */
export function prefetch(url: string, opts?: PrefetchOptions) {
	const ignoreSlowConnection = opts?.ignoreSlowConnection ?? false;
	if (!canPrefetchUrl(url, ignoreSlowConnection)) return;
	prefetchedUrls.add(url);

	const priority = opts?.with ?? 'link';
	debug?.(`[astro] Prefetching ${url} with ${priority}`);

	if (
		clientPrerender &&
		HTMLScriptElement.supports &&
		HTMLScriptElement.supports('speculationrules')
	) {
		// this code is tree-shaken if unused
		appendSpeculationRules(url);
	} else if (priority === 'link') {
		const link = document.createElement('link');
		link.rel = 'prefetch';
		link.setAttribute('href', url);
		document.head.append(link);
	} else {
		fetch(url).catch((e) => {
			// eslint-disable-next-line no-console
			console.log(`[astro] Failed to prefetch ${url}`);
			// eslint-disable-next-line no-console
			console.error(e);
		});
	}
}

function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) {
	// Skip prefetch if offline
	if (!navigator.onLine) return false;
	// Skip prefetch if using data saver mode or slow connection
	if (!ignoreSlowConnection && isSlowConnection()) return false;
	// Else check if URL is within the same origin, not the current page, and not already prefetched
	try {
		const urlObj = new URL(url, location.href);
		return (
			location.origin === urlObj.origin &&
			(location.pathname !== urlObj.pathname || location.search !== urlObj.search) &&
			!prefetchedUrls.has(url)
		);
	} catch {}
	return false;
}

function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTMLAnchorElement {
	// @ts-expect-error access unknown property this way as it's more performant
	if (el?.tagName !== 'A') return false;
	const attrValue = (el as HTMLElement).dataset.astroPrefetch;

	// Out-out if `prefetchAll` is enabled
	if (attrValue === 'false') {
		return false;
	}

	// Fallback to tap strategy if using data saver mode or slow connection
	if (strategy === 'tap' && (attrValue != null || prefetchAll) && isSlowConnection()) {
		return true;
	}

	// If anchor has no dataset but we want to prefetch all, or has dataset but no value,
	// check against fallback default strategy
	if ((attrValue == null && prefetchAll) || attrValue === '') {
		return strategy === defaultStrategy;
	}
	// Else if dataset is explicitly defined, check against it
	if (attrValue === strategy) {
		return true;
	}
	// Else, no match
	return false;
}

function isSlowConnection() {
	if ('connection' in navigator) {
		// Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
		const conn = navigator.connection as any;
		return conn.saveData || /2g/.test(conn.effectiveType);
	}
	return false;
}

/**
 * Listen to page loads and handle Astro's View Transition specific events
 */
function onPageLoad(cb: () => void) {
	cb();
	// Ignore first call of `astro-page-load` as we already call `cb` above.
	// We have to call `cb` eagerly as View Transitions may not be enabled.
	let firstLoad = false;
	document.addEventListener('astro:page-load', () => {
		if (!firstLoad) {
			firstLoad = true;
			return;
		}
		cb();
	});
}

/**
 * Appends a `<script type="speculationrules">` tag to the head of the
 * document that prerenders the `url` passed in.
 *
 * Modifying the script and appending a new link does not trigger the prerender.
 * A new script must be added for each `url`.
 *
 * @param url The url of the page to prerender.
 */
function appendSpeculationRules(url: string) {
	const script = document.createElement('script');
	script.type = 'speculationrules';
	script.textContent = JSON.stringify({
		prerender: [
			{
				source: 'list',
				urls: [url],
			},
		],
	});
	document.head.append(script);
}
