/*!
 * V4Fire Client Core
 * https://github.com/V4Fire/Client
 *
 * Released under the MIT license
 * https://github.com/V4Fire/Client/blob/master/LICENSE
 */

/**
 * This package provides a router engined based on the HTML history API with support of dynamic loading of entry points
 * @packageDescription
 */

import symbolGenerator from 'core/symbol';
import { deprecate } from 'core/functools/deprecation';

import { session } from 'core/kv-storage';
import { fromQueryString, toQueryString } from 'core/url';
import { EventEmitter2 as EventEmitter } from 'eventemitter2';

import * as browser from 'core/browser';

import type bRouter from 'base/b-router/b-router';
import type { Router, Route, HistoryClearFilter } from 'core/router/interface';

export const
	$$ = symbolGenerator();

const
	isIFrame = location !== parent.location;

/**
 * This code is needed to fix a bug with the History API router engine when backing to the
 * first history item doesn’t emit a popstate event in Safari if the script is running within an iframe
 * @see https://github.com/V4Fire/Client/issues/717
 */
if (isIFrame && (browser.is.Safari !== false || browser.is.iOS !== false)) {
	history.pushState({}, '', location.href);
}

/**
 * This flag is needed to get rid of a redundant router transition when restoring the page from BFCache in safari
 * @see https://github.com/V4Fire/Client/issues/552
 */
let isOpenedFromBFCache = false;

// The code below is a shim of "clear" logic of the route history:
// it's used the session storage API to clone native history and some hacks to clear th history.
// The way to clear the history is base on the mechanics when we rewind to the previous route of the route we want
// to clear and after the router emit a new transition to erase the all upcoming routes.
// After this, we need to restore some routes, that were unnecessarily dropped from the history,
// that why we need the history clone.

let
	historyLogPointer = 0,
	isHistoryInit = false;

type HistoryLog = Array<{
	route: string;
	params: Route;
}>;

const
	historyLog = <HistoryLog>[],
	historyStorage = session.namespace('[[BROWSER_HISTORY]]');

/**
 * Truncates the history clone log to the real history size
 */
function truncateHistoryLog(): void {
	if (historyLog.length <= history.length) {
		return;
	}

	if (historyLogPointer >= history.length) {
		historyLogPointer = history.length - 1;
		saveHistoryPos();
	}

	historyLog.splice(history.length);
	saveHistoryLog();
}

/**
 * Saves the history log to the session storage
 */
function saveHistoryLog(): void {
	try {
		historyStorage.set('log', historyLog);
	} catch {}
}

/**
 * Saves the active position of a history to the session storage
 */
function saveHistoryPos(): void {
	try {
		historyStorage.set('pos', historyLogPointer);
	} catch {}
}

// Try to load history log from the session storage
try {
	historyLogPointer = historyStorage.get('pos') ?? 0;

	for (let o = <HistoryLog>historyStorage.get('log'), i = 0; i < o.length; i++) {
		const
			el = o[i];

		if (Object.isPlainObject(el)) {
			historyLog.push(el);
		}
	}

	truncateHistoryLog();
} catch {}

/**
 * Creates an engine (browser history api) for `bRouter` component
 * @param component
 */
export default function createRouter(component: bRouter): Router {
	const
		{async: $a} = component;

	const
		engineGroup = {group: 'routerEngine'},
		popstateLabel = {...engineGroup, label: $$.popstate},
		pageshowLabel = {...engineGroup, label: $$.pageshow},
		modHistoryLabel = {...engineGroup, label: $$.modHistory};

	$a
		.clearAll(engineGroup);

	function load(route: string, params?: Route, method: string = 'pushState'): Promise<void> {
		if (!Object.isTruly(route)) {
			throw new ReferenceError('A page to load is not specified');
		}

		// Remove some redundant characters
		route = route.replace(/[#?]\s*$/, '');

		return new Promise((resolve) => {
			let
				syncMethod = method;

			if (params == null) {
				location.href = route;
				return;
			}

			// The route identifier is needed to support the feature of the history clearing
			if (params._id == null) {
				params._id = Math.random().toString().slice(2);
			}

			if (method !== 'replaceState') {
				isHistoryInit = true;

			} else if (!isHistoryInit) {
				isHistoryInit = true;

				// Prevent pushing of one route more than one times:
				// this situation take a place when we reload the browser page
				if (historyLog.length > 0 && !Object.fastCompare(
					Object.reject(historyLog[historyLog.length - 1]?.params, '_id'),
					Object.reject(params, '_id')
				)) {
					syncMethod = 'pushState';
				}
			}

			if (historyLog.length === 0 || syncMethod === 'pushState') {
				historyLog.push({route, params});
				historyLogPointer = historyLog.length - 1;
				saveHistoryPos();

			} else {
				historyLog[historyLog.length - 1] = {route, params};
			}

			saveHistoryLog();

			const
				qsRgxp = /\?.*?(?=#|$)/;

			/**
			 * Parses parameters from the query string
			 *
			 * @param qs
			 * @param test
			 */
			const parseQuery = (qs: string, test?: boolean) => {
				if (test && !RegExp.test(qsRgxp, qs)) {
					return {};
				}

				return fromQueryString(qs);
			};

			params.query = Object.assign(parseQuery(route, true), params.query);

			let
				qs = toQueryString(params.query);

			if (qs !== '') {
				qs = `?${qs}`;

				if (RegExp.test(qsRgxp, route)) {
					route = route.replace(qsRgxp, qs);

				} else {
					route += qs;
				}
			}

			if (location.href !== route) {
				params.url = route;

				// "params" can contain proxy objects,
				// to avoid DataCloneError we should clone it by using Object.mixin({deep: true})
				const filteredParams = Object.mixin({deep: true, filter: (el) => !Object.isFunction(el)}, {}, params);
				history[method](filteredParams, params.name, route);
			}

			const
				// eslint-disable-next-line @typescript-eslint/unbound-method
				{load} = params.meta;

			if (load == null) {
				resolve();
				return;
			}

			load().then(() => resolve()).catch(stderr);
		});
	}

	const emitter = new EventEmitter({
		maxListeners: 1e3,
		newListener: false
	});

	const router = Object.mixin({withAccessors: true}, Object.create(emitter), <Router>{
		get route(): CanUndef<Route> {
			const
				url = this.id(location.href);

			return {
				name: url,
				/** @deprecated */
				page: url,
				query: fromQueryString(location.search),
				...history.state,
				url
			};
		},

		get page(): CanUndef<Route> {
			deprecate({name: 'page', type: 'accessor', renamedTo: 'route'});
			return this.route;
		},

		get history(): Route[] {
			const
				list = <Route[]>[];

			for (let i = 0; i < historyLog.length; i++) {
				list.push(historyLog[i].params);
			}

			return list;
		},

		id(route: string): string {
			try {
				return new URL(route).pathname;

			} catch {
				return route;
			}
		},

		push(route: string, params?: Route): Promise<void> {
			return load(route, params);
		},

		replace(route: string, params?: Route): Promise<void> {
			return load(route, params, 'replaceState');
		},

		go(pos: number): void {
			history.go(pos);
		},

		forward(): void {
			history.forward();
		},

		back(): void {
			history.back();
		},

		async clear(filter?: HistoryClearFilter): Promise<void> {
			$a.muteEventListener(popstateLabel);
			truncateHistoryLog();

			const
				cutIntervals = <number[][]>[[]];

			let
				lastEnd = 0;

			for (let i = 0; i < historyLog.length; i++) {
				const
					interval = cutIntervals[cutIntervals.length - 1];

				if (i > 0 && (!filter || Object.isTruly(filter(historyLog[i].params)))) {
					if (interval.length === 0) {
						interval.push(i > 0 ? i : 1);
					}

				} else {
					if (lastEnd === 0) {
						lastEnd = i;
					}

					if (interval.length > 0) {
						interval.push(i);
						cutIntervals.push([]);
					}
				}
			}

			const
				last = cutIntervals[cutIntervals.length - 1];

			switch (last.length) {
				case 0:
					cutIntervals.pop();
					break;

				case 1:
					last.push(lastEnd);
					break;

				default:
					// Loopback
			}

			if (cutIntervals.length === 0) {
				return;
			}

			for (let i = cutIntervals.length; i-- > 0;) {
				const
					el = cutIntervals[i];

				const
					from = el[0],
					to = historyLog[el[1]];

				if (from <= historyLogPointer) {
					history.go(from - historyLogPointer - 1);
				}

				await $a.promisifyOnce(globalThis, 'popstate', modHistoryLabel);

				historyLog.splice(from);
				historyLog.push(to);

				history.pushState(
					to.params,
					to.params.name,
					to.route
				);

				saveHistoryLog();

				// eslint-disable-next-line require-atomic-updates
				historyLogPointer = historyLog.length - 1;
				saveHistoryPos();

				await $a.nextTick();
			}

			$a.unmuteEventListener(popstateLabel);
			truncateHistoryLog();

			const
				lastPos = historyLogPointer - cutIntervals[0][0];

			if (lastPos > 0) {
				history.go(lastPos);
				await $a.promisifyOnce(globalThis, 'popstate', modHistoryLabel);

				// eslint-disable-next-line require-atomic-updates
				historyLogPointer = lastPos;
				saveHistoryPos();
			}
		},

		clearTmp(): Promise<void> {
			return this.clear((el) => {
				if (!Object.isPlainObject(el)) {
					return false;
				}

				return Object.isTruly(el.params?.tmp) || Object.isTruly(el.query?.tmp) || Object.isTruly(el.meta?.tmp);
			});
		}
	});

	$a.on(globalThis, 'popstate', async () => {
		if (browser.is.iOS !== false && isOpenedFromBFCache) {
			isOpenedFromBFCache = false;
			return;
		}

		truncateHistoryLog();

		const
			routeId = Object.get(history, 'state._id');

		if (routeId != null) {
			try {
				for (let i = 0; i < historyLog.length; i++) {
					if (Object.get(historyLog[i], 'params._id') === routeId) {
						historyLogPointer = i;
						saveHistoryPos();
						break;
					}
				}

			} catch (err) {
				stderr(err);
			}
		}

		await component.emitTransition(location.href, history.state, 'event');
	}, popstateLabel);

	$a.on(globalThis, 'pageshow', (event: PageTransitionEvent) => {
		if (event.persisted) {
			isOpenedFromBFCache = true;
		}
	}, pageshowLabel);

	return router;
}
