/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Tobias Koppers @sokra
*/

"use strict";

const Cache = require("../Cache");
const ProgressPlugin = require("../ProgressPlugin");

/** @typedef {import("../Compiler")} Compiler */
/** @typedef {import("./PackFileCacheStrategy")} PackFileCacheStrategy */

const BUILD_DEPENDENCIES_KEY = Symbol("build dependencies key");
const PLUGIN_NAME = "IdleFileCachePlugin";

class IdleFileCachePlugin {
	/**
	 * @param {PackFileCacheStrategy} strategy cache strategy
	 * @param {number} idleTimeout timeout
	 * @param {number} idleTimeoutForInitialStore initial timeout
	 * @param {number} idleTimeoutAfterLargeChanges timeout after changes
	 */
	constructor(
		strategy,
		idleTimeout,
		idleTimeoutForInitialStore,
		idleTimeoutAfterLargeChanges
	) {
		this.strategy = strategy;
		this.idleTimeout = idleTimeout;
		this.idleTimeoutForInitialStore = idleTimeoutForInitialStore;
		this.idleTimeoutAfterLargeChanges = idleTimeoutAfterLargeChanges;
	}

	/**
	 * Apply the plugin
	 * @param {Compiler} compiler the compiler instance
	 * @returns {void}
	 */
	apply(compiler) {
		const strategy = this.strategy;
		const idleTimeout = this.idleTimeout;
		const idleTimeoutForInitialStore = Math.min(
			idleTimeout,
			this.idleTimeoutForInitialStore
		);
		const idleTimeoutAfterLargeChanges = this.idleTimeoutAfterLargeChanges;
		const resolvedPromise = Promise.resolve();

		let timeSpendInBuild = 0;
		let timeSpendInStore = 0;
		let avgTimeSpendInStore = 0;

		/** @type {Map<string | typeof BUILD_DEPENDENCIES_KEY, () => Promise<void | void[]>>} */
		const pendingIdleTasks = new Map();

		compiler.cache.hooks.store.tap(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			(identifier, etag, data) => {
				pendingIdleTasks.set(identifier, () =>
					strategy.store(identifier, etag, data)
				);
			}
		);

		compiler.cache.hooks.get.tapPromise(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			(identifier, etag, gotHandlers) => {
				const restore = () =>
					strategy.restore(identifier, etag).then((cacheEntry) => {
						if (cacheEntry === undefined) {
							gotHandlers.push((result, callback) => {
								if (result !== undefined) {
									pendingIdleTasks.set(identifier, () =>
										strategy.store(identifier, etag, result)
									);
								}
								callback();
							});
						} else {
							return cacheEntry;
						}
					});
				const pendingTask = pendingIdleTasks.get(identifier);
				if (pendingTask !== undefined) {
					pendingIdleTasks.delete(identifier);
					return pendingTask().then(restore);
				}
				return restore();
			}
		);

		compiler.cache.hooks.storeBuildDependencies.tap(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			(dependencies) => {
				pendingIdleTasks.set(BUILD_DEPENDENCIES_KEY, () =>
					Promise.resolve().then(() =>
						strategy.storeBuildDependencies(dependencies)
					)
				);
			}
		);

		compiler.cache.hooks.shutdown.tapPromise(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			() => {
				if (idleTimer) {
					clearTimeout(idleTimer);
					idleTimer = undefined;
				}
				isIdle = false;
				const reportProgress = ProgressPlugin.getReporter(compiler);
				const jobs = [...pendingIdleTasks.values()];
				if (reportProgress) reportProgress(0, "process pending cache items");
				const promises = jobs.map((fn) => fn());
				pendingIdleTasks.clear();
				promises.push(currentIdlePromise);
				const promise = Promise.all(promises);
				currentIdlePromise = promise.then(() => strategy.afterAllStored());
				if (reportProgress) {
					currentIdlePromise = currentIdlePromise.then(() => {
						reportProgress(1, "stored");
					});
				}
				return currentIdlePromise.then(() => {
					// Reset strategy
					if (strategy.clear) strategy.clear();
				});
			}
		);

		/** @type {Promise<void | void[]>} */
		let currentIdlePromise = resolvedPromise;
		let isIdle = false;
		let isInitialStore = true;
		const processIdleTasks = () => {
			if (isIdle) {
				const startTime = Date.now();
				if (pendingIdleTasks.size > 0) {
					const promises = [currentIdlePromise];
					const maxTime = startTime + 100;
					let maxCount = 100;
					for (const [filename, factory] of pendingIdleTasks) {
						pendingIdleTasks.delete(filename);
						promises.push(factory());
						if (maxCount-- <= 0 || Date.now() > maxTime) break;
					}
					currentIdlePromise = Promise.all(
						/** @type {Promise<void>[]} */
						(promises)
					);
					currentIdlePromise.then(() => {
						timeSpendInStore += Date.now() - startTime;
						// Allow to exit the process between
						idleTimer = setTimeout(processIdleTasks, 0);
						idleTimer.unref();
					});
					return;
				}
				currentIdlePromise = currentIdlePromise
					.then(async () => {
						await strategy.afterAllStored();
						timeSpendInStore += Date.now() - startTime;
						avgTimeSpendInStore =
							Math.max(avgTimeSpendInStore, timeSpendInStore) * 0.9 +
							timeSpendInStore * 0.1;
						timeSpendInStore = 0;
						timeSpendInBuild = 0;
					})
					.catch((err) => {
						const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
						logger.warn(`Background tasks during idle failed: ${err.message}`);
						logger.debug(err.stack);
					});
				isInitialStore = false;
			}
		};
		/** @type {ReturnType<typeof setTimeout> | undefined} */
		let idleTimer;
		compiler.cache.hooks.beginIdle.tap(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			() => {
				const isLargeChange = timeSpendInBuild > avgTimeSpendInStore * 2;
				if (isInitialStore && idleTimeoutForInitialStore < idleTimeout) {
					compiler
						.getInfrastructureLogger(PLUGIN_NAME)
						.log(
							`Initial cache was generated and cache will be persisted in ${
								idleTimeoutForInitialStore / 1000
							}s.`
						);
				} else if (
					isLargeChange &&
					idleTimeoutAfterLargeChanges < idleTimeout
				) {
					compiler
						.getInfrastructureLogger(PLUGIN_NAME)
						.log(
							`Spend ${Math.round(timeSpendInBuild) / 1000}s in build and ${
								Math.round(avgTimeSpendInStore) / 1000
							}s in average in cache store. This is considered as large change and cache will be persisted in ${
								idleTimeoutAfterLargeChanges / 1000
							}s.`
						);
				}
				idleTimer = setTimeout(
					() => {
						idleTimer = undefined;
						isIdle = true;
						resolvedPromise.then(processIdleTasks);
					},
					Math.min(
						isInitialStore ? idleTimeoutForInitialStore : Infinity,
						isLargeChange ? idleTimeoutAfterLargeChanges : Infinity,
						idleTimeout
					)
				);
				idleTimer.unref();
			}
		);
		compiler.cache.hooks.endIdle.tap(
			{ name: PLUGIN_NAME, stage: Cache.STAGE_DISK },
			() => {
				if (idleTimer) {
					clearTimeout(idleTimer);
					idleTimer = undefined;
				}
				isIdle = false;
			}
		);
		compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
			// 10% build overhead is ignored, as it's not cacheable
			timeSpendInBuild *= 0.9;
			timeSpendInBuild +=
				/** @type {number} */ (stats.endTime) -
				/** @type {number} */ (stats.startTime);
		});
	}
}

module.exports = IdleFileCachePlugin;
