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

/**
 * [[include:base/b-dynamic-page/README.md]]
 * @packageDocumentation
 */

import symbolGenerator from 'core/symbol';

import addEmitter from 'core/cache/decorators/helpers/add-emitter';
import { Cache, RestrictedCache, AbstractCache } from 'core/cache';

import SyncPromise from 'core/promise/sync';
import type { EventEmitterLike } from 'core/async';

import iBlock from 'super/i-block/i-block';

import iDynamicPage, {

	component,

	prop,
	system,
	computed,
	watch,

	UnsafeGetter,
	ComponentStatus,
	InitLoadOptions

} from 'super/i-dynamic-page/i-dynamic-page';

import type {

	Include,
	Exclude,

	iDynamicPageEl,
	KeepAliveStrategy,
	UnsafeBDynamicPage

} from 'base/b-dynamic-page/interface';

import { restorePageElementsScroll, saveScrollIntoAttribute } from 'base/b-dynamic-page/helpers';

export * from 'super/i-data/i-data';
export * from 'base/b-dynamic-page/interface';

export const
	$$ = symbolGenerator();

/**
 * Component to dynamically load page components.
 * Basically, it uses with a router.
 */
@component({
	inheritMods: false,
	defaultProps: false
})

export default class bDynamicPage extends iDynamicPage {
	@prop({forceDefault: true})
	override readonly selfDispatching: boolean = true;

	/**
	 * Initial component name to load
	 */
	@prop({type: String, required: false})
	readonly pageProp?: string;

	/**
	 * Active component name to load
	 * @see [[bDynamicPage.pageProp]]
	 */
	@system((o) => o.sync.link())
	page?: string;

	/**
	 * Active page unique key.
	 * It is used to determine whether to reuse current page component or create a new one when switching between routes
	 * with the same page component.
	 */
	@system()
	pageKey?: CanUndef<string>;

	/**
	 * If true, when switching from one page to another, the old page is stored within a cache by its name.
	 * When occur switching back to this page, it will be restored.
	 * It helps to optimize switching between pages but grows memory using.
	 *
	 * Notice, when a page is switching, it will be deactivated by invoking `deactivate`.
	 * When the page is restoring, it will be activated by invoking `activate`.
	 */
	@prop(Boolean)
	readonly keepAlive: boolean = false;

	/**
	 * The maximum number of pages within the global `keepAlive` cache
	 */
	@prop(Number)
	readonly keepAliveSize: number = 10;

	/**
	 * A dictionary of `keepAlive` caches.
	 * The keys represent cache groups (by default uses `global`).
	 */
	@system<bDynamicPage>((o) => o.sync.link('keepAliveSize', (size: number) => ({
		...o.keepAliveCache,
		global: o.addClearListenersToCache(
			size > 0 ?
				new RestrictedCache<iDynamicPageEl>(size) :
				new Cache<iDynamicPageEl>()
		)
	})))

	keepAliveCache!: Dictionary<AbstractCache<iDynamicPageEl>>;

	/**
	 * A predicate to include pages to the `keepAlive` caching: if not specified, will be cached all loaded pages.
	 * It can be defined as:
	 *
	 * 1. a component name (or a list of names);
	 * 2. a regular expression;
	 * 3. a function that takes a component name and returns `true` (include), `false` (does not include),
	 *    a string key to cache (it uses instead of a component name),
	 *    or a special object with information of the used cache strategy.
	 */
	@prop({
		type: [String, Array, RegExp, Function],
		required: false
	})

	readonly include?: Include;

	/**
	 * A predicate to exclude some pages from the `keepAlive` caching.
	 * It can be defined as a component name (or a list of names), regular expression,
	 * or a function that takes a component name and returns `true` (exclude) or `false` (does not exclude).
	 */
	@prop({
		type: [String, Array, RegExp, Function],
		required: false
	})

	readonly exclude?: Exclude;

	/**
	 * Link to an event emitter to listen to events of the page switching
	 */
	@prop({type: Object, required: false})
	readonly emitter?: EventEmitterLike;

	/**
	 * Event name of the page switching
	 */
	@prop({
		type: String,
		required: false,
		forceDefault: true
	})

	readonly event?: string = 'setRoute';

	/**
	 * Function to extract a component name to load from the caught event object.
	 * Also, this function can return a tuple consisting of component name and unique key for the passed routed. The key
	 * will be used to determine whether to reuse current page component or create a new one
	 * when switching between routes with the same page component.
	 */
	@prop({
		type: [Function, Array],
		default: (e) => e != null ? (e.meta.component ?? e.name) : undefined,
		forceDefault: true
	})

	readonly eventConverter!: CanArray<Function>;

	/**
	 * Link to the loaded page component
	 */
	@computed({cache: false, dependencies: ['page']})
	get component(): CanPromise<iDynamicPage> {
		const
			c = this.$refs.component;

		const getComponent = () => {
			const
				c = this.$refs.component!;

			if (Object.isArray(c)) {
				return c[0];
			}

			return c;
		};

		return c != null && (!Object.isArray(c) || c.length > 0) ?
			getComponent() :
			this.waitRef('component').then(getComponent);
	}

	override get unsafe(): UnsafeGetter<UnsafeBDynamicPage<this>> {
		return Object.cast(this);
	}

	protected override readonly componentStatusStore: ComponentStatus = 'ready';

	protected override readonly $refs!: {
		component?: iDynamicPage[];
	};

	/**
	 * True if the current page is taken from a cache
	 */
	@system()
	protected pageTakenFromCache: boolean = false;

	/**
	 * Handler: page has been changed
	 */
	@system()
	protected onPageChange?: Function;

	/**
	 * Iterator of the rendering loop (it uses with `asyncRender`)
	 */
	protected get renderIterator(): CanPromise<number> {
		return SyncPromise.resolve(Infinity);
	}

	override initLoad(): Promise<void> {
		return Promise.resolve();
	}

	/**
	 * Reloads the loaded page component
	 */
	override async reload(params?: InitLoadOptions): Promise<void> {
		const component = await this.component;
		return component.reload(params);
	}

	/**
	 * Filter of the rendering loop (it uses with `asyncRender`)
	 */
	protected renderFilter(): CanPromise<boolean> {
		if (this.lfc.isBeforeCreate()) {
			return true;
		}

		const
			{unsafe, route, r} = this;

		return new SyncPromise((r) => {
			this.onPageChange = onPageChange(r, this.route);
		});

		function onPageChange(
			resolve: Function,
			currentRoute: typeof route
		): AnyFunction {
			return (newPage: CanUndef<string>, currentPage: CanUndef<string>) => {
				unsafe.pageTakenFromCache = false;

				const componentRef = unsafe.$refs.component;
				componentRef?.pop();

				const
					currentPageEl = unsafe.block?.element<iDynamicPageEl>('component'),
					currentPageComponent = currentPageEl?.component?.unsafe;

				if (currentPageEl != null) {
					r.emit('beforeSwitchPage', {saveScroll: saveScrollIntoAttribute});

					if (currentPageComponent != null) {
						const
							currentPageStrategy = unsafe.getKeepAliveStrategy(currentPage, currentRoute);

						if (currentPageStrategy.isLoopback) {
							currentPageComponent.$destroy();

						} else {
							currentPageStrategy.add(currentPageEl);
							currentPageComponent.deactivate();
						}
					}

					currentPageEl.remove();
				}

				const
					newPageStrategy = unsafe.getKeepAliveStrategy(newPage),
					pageElFromCache = newPageStrategy.get();

				if (pageElFromCache == null) {
					const handler = () => {
						if (!newPageStrategy.isLoopback) {
							return SyncPromise.resolve(unsafe.component).then((c) => c.activate(true));
						}
					};

					unsafe.localEmitter.once('asyncRenderChunkComplete', handler, {
						label: $$.renderFilter
					});

				} else {
					const
						pageComponentFromCache = pageElFromCache.component;

					if (pageComponentFromCache != null) {
						pageComponentFromCache.activate();

						unsafe.async.requestAnimationFrame(() => {
							restorePageElementsScroll(pageElFromCache);
						}, {label: $$.restorePageElementsScroll});

						unsafe.$el?.append(pageElFromCache);
						pageComponentFromCache.emit('mounted', pageElFromCache);

						componentRef?.push(pageComponentFromCache);
						unsafe.pageTakenFromCache = true;

					} else {
						newPageStrategy.remove();
					}
				}

				resolve(true);
			};
		}
	}

	/**
	 * Returns a `keepAlive` cache strategy for the specified page
	 *
	 * @param page
	 * @param [route] - link to an application route object
	 */
	protected getKeepAliveStrategy(page: CanUndef<string>, route: this['route'] = this.route): KeepAliveStrategy {
		const loopbackStrategy = {
			isLoopback: true,
			has: () => false,
			get: () => undefined,
			add: (page) => page,
			remove: () => undefined
		};

		if (!this.keepAlive || page == null) {
			return loopbackStrategy;
		}

		const
			{exclude, include} = this;

		if (exclude != null) {
			if (Object.isFunction(exclude)) {
				if (Object.isTruly(exclude(page, route, this))) {
					return loopbackStrategy;
				}

			} else if (Object.isRegExp(exclude) ? exclude.test(page) : Array.concat([], exclude).includes(page)) {
				return loopbackStrategy;
			}
		}

		let
			cacheKey = page;

		const
			globalCache = this.keepAliveCache.global!;

		const globalStrategy = {
			isLoopback: false,
			has: () => globalCache.has(cacheKey),
			get: () => globalCache.get(cacheKey),
			add: (page) => globalCache.set(cacheKey, page),
			remove: () => globalCache.remove(cacheKey)
		};

		if (include != null) {
			if (Object.isFunction(include)) {
				const
					res = include(page, route, this);

				// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
				if (res == null || res === false) {
					return loopbackStrategy;
				}

				if (Object.isString(res) || res === true) {
					cacheKey = res === true ? page : res;
					return globalStrategy;
				}

				const cache = this.keepAliveCache[res.cacheGroup] ?? this.addClearListenersToCache(res.createCache());
				this.keepAliveCache[res.cacheGroup] = cache;

				return {
					isLoopback: false,
					has: () => cache.has(res.cacheKey),
					get: () => cache.get(res.cacheKey),
					add: (page) => cache.set(res.cacheKey, page),
					remove: () => cache.remove(res.cacheKey)
				};
			}

			if (Object.isRegExp(include) ? !include.test(page) : !Array.concat([], include).includes(page)) {
				return loopbackStrategy;
			}
		}

		return globalStrategy;
	}

	protected override initBaseAPI(): void {
		super.initBaseAPI();
		this.addClearListenersToCache = this.instance.addClearListenersToCache.bind(this);
	}

	/**
	 * Wraps the specified cache object and returns a wrapper.
	 * The method adds listeners to destroy unused pages from the cache.
	 *
	 * @param cache
	 */
	protected addClearListenersToCache<T extends AbstractCache<iDynamicPageEl>>(cache: T): T {
		const
			wrappedCache = addEmitter<AbstractCache<iDynamicPageEl>>(cache);

		let
			instanceCache: WeakMap<iDynamicPageEl, number> = new WeakMap();

		wrappedCache.subscribe('set', cache, changeCountInMap(0, 1));
		wrappedCache.subscribe('remove', cache, changeCountInMap(1, -1));

		wrappedCache.subscribe('remove', cache, ({result}) => {
			if (result == null || (instanceCache.get(result) ?? 0) > 0) {
				return;
			}

			result.component?.unsafe.$destroy();
		});

		wrappedCache.subscribe('clear', cache, ({result}) => {
			result.forEach((el) => el.component?.unsafe.$destroy());
			instanceCache = new WeakMap();
		});

		return cache;

		function changeCountInMap(def: number, delta: number): AnyFunction {
			return ({result}: {result: CanUndef<iDynamicPageEl>}) => {
				if (result == null) {
					return;
				}

				const count = instanceCache.get(result) ?? def;
				instanceCache.set(result, count + delta);
			};
		}
	}

	/**
	 * Synchronization for the `emitter` prop
	 */
	@watch('emitter')
	@watch({path: 'event', immediate: true})
	protected syncEmitterWatcher(): void {
		const
			{async: $a} = this;

		const
			group = {group: 'emitter'};

		$a
			.clearAll(group);

		if (this.event != null) {
			$a.on(this.emitter ?? this.$root, this.event, (component, e) => {
				if (component != null && !((<Dictionary>component).instance instanceof iBlock)) {
					e = component;
				}

				let
					newPageInfo = e;

				if (Object.isTruly(this.eventConverter)) {
					newPageInfo = Array
						.concat([], this.eventConverter)
						.reduce((res, fn) => fn.call(this, res, this.page), newPageInfo);
				}

				const
					[newPageComponentName, newPageKey] = Object.isString(newPageInfo) ? [newPageInfo] : (newPageInfo ?? []);

				const
					pageChanged = newPageComponentName !== this.page,
					oldPageKey = this.pageKey;

				if (newPageComponentName == null || Object.isString(newPageComponentName)) {
					this.page = newPageComponentName;
					this.pageKey = newPageKey;

					if (!pageChanged && newPageKey !== oldPageKey) {
						this.syncPageWatcher(newPageComponentName, this.page);
					}
				}

			}, group);
		}
	}

	/**
	 * Synchronization for the `page` field
	 */
	@watch({path: 'page', immediate: true})
	protected syncPageWatcher(page: CanUndef<string>, oldPage: CanUndef<string>): void {
		if (this.onPageChange == null) {
			const
				label = {label: $$.syncPageWatcher};

			this.watch('onPageChange', {...label, immediate: true}, () => {
				if (this.onPageChange == null) {
					return;
				}

				this.onPageChange(page, oldPage);
				this.async.terminateWorker(label);
			});

		} else {
			this.onPageChange(page, oldPage);
		}
	}

	protected override initModEvents(): void {
		super.initModEvents();
		this.sync.mod('hidden', 'page', (v) => !Object.isTruly(v));
	}
}
