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

"use strict";

const asyncLib = require("neo-async");
const Queue = require("./util/Queue");

/** @typedef {import("./Compiler")} Compiler */
/** @typedef {import("./DependenciesBlock")} DependenciesBlock */
/** @typedef {import("./Dependency")} Dependency */
/** @typedef {import("./Dependency").ExportSpec} ExportSpec */
/** @typedef {import("./Dependency").ExportsSpec} ExportsSpec */
/** @typedef {import("./ExportsInfo")} ExportsInfo */
/** @typedef {import("./Module")} Module */

class FlagDependencyExportsPlugin {
	/**
	 * Apply the plugin
	 * @param {Compiler} compiler the compiler instance
	 * @returns {void}
	 */
	apply(compiler) {
		compiler.hooks.compilation.tap(
			"FlagDependencyExportsPlugin",
			compilation => {
				const moduleGraph = compilation.moduleGraph;
				const cache = compilation.getCache("FlagDependencyExportsPlugin");
				compilation.hooks.finishModules.tapAsync(
					"FlagDependencyExportsPlugin",
					(modules, callback) => {
						const logger = compilation.getLogger(
							"webpack.FlagDependencyExportsPlugin"
						);
						let statRestoredFromMemCache = 0;
						let statRestoredFromCache = 0;
						let statNoExports = 0;
						let statFlaggedUncached = 0;
						let statNotCached = 0;
						let statQueueItemsProcessed = 0;

						const { moduleMemCaches } = compilation;

						/** @type {Queue<Module>} */
						const queue = new Queue();

						// Step 1: Try to restore cached provided export info from cache
						logger.time("restore cached provided exports");
						asyncLib.each(
							modules,
							(module, callback) => {
								const exportsInfo = moduleGraph.getExportsInfo(module);
								if (!module.buildMeta || !module.buildMeta.exportsType) {
									if (exportsInfo.otherExportsInfo.provided !== null) {
										// It's a module without declared exports
										statNoExports++;
										exportsInfo.setHasProvideInfo();
										exportsInfo.setUnknownExportsProvided();
										return callback();
									}
								}
								if (typeof module.buildInfo.hash !== "string") {
									statFlaggedUncached++;
									// Enqueue uncacheable module for determining the exports
									queue.enqueue(module);
									exportsInfo.setHasProvideInfo();
									return callback();
								}
								const memCache = moduleMemCaches && moduleMemCaches.get(module);
								const memCacheValue = memCache && memCache.get(this);
								if (memCacheValue !== undefined) {
									statRestoredFromMemCache++;
									exportsInfo.restoreProvided(memCacheValue);
									return callback();
								}
								cache.get(
									module.identifier(),
									module.buildInfo.hash,
									(err, result) => {
										if (err) return callback(err);

										if (result !== undefined) {
											statRestoredFromCache++;
											exportsInfo.restoreProvided(result);
										} else {
											statNotCached++;
											// Without cached info enqueue module for determining the exports
											queue.enqueue(module);
											exportsInfo.setHasProvideInfo();
										}
										callback();
									}
								);
							},
							err => {
								logger.timeEnd("restore cached provided exports");
								if (err) return callback(err);

								/** @type {Set<Module>} */
								const modulesToStore = new Set();

								/** @type {Map<Module, Set<Module>>} */
								const dependencies = new Map();

								/** @type {Module} */
								let module;

								/** @type {ExportsInfo} */
								let exportsInfo;

								/** @type {Map<Dependency, ExportsSpec>} */
								const exportsSpecsFromDependencies = new Map();

								let cacheable = true;
								let changed = false;

								/**
								 * @param {DependenciesBlock} depBlock the dependencies block
								 * @returns {void}
								 */
								const processDependenciesBlock = depBlock => {
									for (const dep of depBlock.dependencies) {
										processDependency(dep);
									}
									for (const block of depBlock.blocks) {
										processDependenciesBlock(block);
									}
								};

								/**
								 * @param {Dependency} dep the dependency
								 * @returns {void}
								 */
								const processDependency = dep => {
									const exportDesc = dep.getExports(moduleGraph);
									if (!exportDesc) return;
									exportsSpecsFromDependencies.set(dep, exportDesc);
								};

								/**
								 * @param {Dependency} dep dependency
								 * @param {ExportsSpec} exportDesc info
								 * @returns {void}
								 */
								const processExportsSpec = (dep, exportDesc) => {
									const exports = exportDesc.exports;
									const globalCanMangle = exportDesc.canMangle;
									const globalFrom = exportDesc.from;
									const globalPriority = exportDesc.priority;
									const globalTerminalBinding =
										exportDesc.terminalBinding || false;
									const exportDeps = exportDesc.dependencies;
									if (exportDesc.hideExports) {
										for (const name of exportDesc.hideExports) {
											const exportInfo = exportsInfo.getExportInfo(name);
											exportInfo.unsetTarget(dep);
										}
									}
									if (exports === true) {
										// unknown exports
										if (
											exportsInfo.setUnknownExportsProvided(
												globalCanMangle,
												exportDesc.excludeExports,
												globalFrom && dep,
												globalFrom,
												globalPriority
											)
										) {
											changed = true;
										}
									} else if (Array.isArray(exports)) {
										/**
										 * merge in new exports
										 * @param {ExportsInfo} exportsInfo own exports info
										 * @param {(ExportSpec | string)[]} exports list of exports
										 */
										const mergeExports = (exportsInfo, exports) => {
											for (const exportNameOrSpec of exports) {
												let name;
												let canMangle = globalCanMangle;
												let terminalBinding = globalTerminalBinding;
												let exports = undefined;
												let from = globalFrom;
												let fromExport = undefined;
												let priority = globalPriority;
												let hidden = false;
												if (typeof exportNameOrSpec === "string") {
													name = exportNameOrSpec;
												} else {
													name = exportNameOrSpec.name;
													if (exportNameOrSpec.canMangle !== undefined)
														canMangle = exportNameOrSpec.canMangle;
													if (exportNameOrSpec.export !== undefined)
														fromExport = exportNameOrSpec.export;
													if (exportNameOrSpec.exports !== undefined)
														exports = exportNameOrSpec.exports;
													if (exportNameOrSpec.from !== undefined)
														from = exportNameOrSpec.from;
													if (exportNameOrSpec.priority !== undefined)
														priority = exportNameOrSpec.priority;
													if (exportNameOrSpec.terminalBinding !== undefined)
														terminalBinding = exportNameOrSpec.terminalBinding;
													if (exportNameOrSpec.hidden !== undefined)
														hidden = exportNameOrSpec.hidden;
												}
												const exportInfo = exportsInfo.getExportInfo(name);

												if (
													exportInfo.provided === false ||
													exportInfo.provided === null
												) {
													exportInfo.provided = true;
													changed = true;
												}

												if (
													exportInfo.canMangleProvide !== false &&
													canMangle === false
												) {
													exportInfo.canMangleProvide = false;
													changed = true;
												}

												if (terminalBinding && !exportInfo.terminalBinding) {
													exportInfo.terminalBinding = true;
													changed = true;
												}

												if (exports) {
													const nestedExportsInfo =
														exportInfo.createNestedExportsInfo();
													mergeExports(nestedExportsInfo, exports);
												}

												if (
													from &&
													(hidden
														? exportInfo.unsetTarget(dep)
														: exportInfo.setTarget(
																dep,
																from,
																fromExport === undefined ? [name] : fromExport,
																priority
														  ))
												) {
													changed = true;
												}

												// Recalculate target exportsInfo
												const target = exportInfo.getTarget(moduleGraph);
												let targetExportsInfo = undefined;
												if (target) {
													const targetModuleExportsInfo =
														moduleGraph.getExportsInfo(target.module);
													targetExportsInfo =
														targetModuleExportsInfo.getNestedExportsInfo(
															target.export
														);
													// add dependency for this module
													const set = dependencies.get(target.module);
													if (set === undefined) {
														dependencies.set(target.module, new Set([module]));
													} else {
														set.add(module);
													}
												}

												if (exportInfo.exportsInfoOwned) {
													if (
														exportInfo.exportsInfo.setRedirectNamedTo(
															targetExportsInfo
														)
													) {
														changed = true;
													}
												} else if (
													exportInfo.exportsInfo !== targetExportsInfo
												) {
													exportInfo.exportsInfo = targetExportsInfo;
													changed = true;
												}
											}
										};
										mergeExports(exportsInfo, exports);
									}
									// store dependencies
									if (exportDeps) {
										cacheable = false;
										for (const exportDependency of exportDeps) {
											// add dependency for this module
											const set = dependencies.get(exportDependency);
											if (set === undefined) {
												dependencies.set(exportDependency, new Set([module]));
											} else {
												set.add(module);
											}
										}
									}
								};

								const notifyDependencies = () => {
									const deps = dependencies.get(module);
									if (deps !== undefined) {
										for (const dep of deps) {
											queue.enqueue(dep);
										}
									}
								};

								logger.time("figure out provided exports");
								while (queue.length > 0) {
									module = queue.dequeue();

									statQueueItemsProcessed++;

									exportsInfo = moduleGraph.getExportsInfo(module);

									cacheable = true;
									changed = false;

									exportsSpecsFromDependencies.clear();
									moduleGraph.freeze();
									processDependenciesBlock(module);
									moduleGraph.unfreeze();
									for (const [
										dep,
										exportsSpec
									] of exportsSpecsFromDependencies) {
										processExportsSpec(dep, exportsSpec);
									}

									if (cacheable) {
										modulesToStore.add(module);
									}

									if (changed) {
										notifyDependencies();
									}
								}
								logger.timeEnd("figure out provided exports");

								logger.log(
									`${Math.round(
										(100 * (statFlaggedUncached + statNotCached)) /
											(statRestoredFromMemCache +
												statRestoredFromCache +
												statNotCached +
												statFlaggedUncached +
												statNoExports)
									)}% of exports of modules have been determined (${statNoExports} no declared exports, ${statNotCached} not cached, ${statFlaggedUncached} flagged uncacheable, ${statRestoredFromCache} from cache, ${statRestoredFromMemCache} from mem cache, ${
										statQueueItemsProcessed -
										statNotCached -
										statFlaggedUncached
									} additional calculations due to dependencies)`
								);

								logger.time("store provided exports into cache");
								asyncLib.each(
									modulesToStore,
									(module, callback) => {
										if (typeof module.buildInfo.hash !== "string") {
											// not cacheable
											return callback();
										}
										const cachedData = moduleGraph
											.getExportsInfo(module)
											.getRestoreProvidedData();
										const memCache =
											moduleMemCaches && moduleMemCaches.get(module);
										if (memCache) {
											memCache.set(this, cachedData);
										}
										cache.store(
											module.identifier(),
											module.buildInfo.hash,
											cachedData,
											callback
										);
									},
									err => {
										logger.timeEnd("store provided exports into cache");
										callback(err);
									}
								);
							}
						);
					}
				);

				/** @type {WeakMap<Module, any>} */
				const providedExportsCache = new WeakMap();
				compilation.hooks.rebuildModule.tap(
					"FlagDependencyExportsPlugin",
					module => {
						providedExportsCache.set(
							module,
							moduleGraph.getExportsInfo(module).getRestoreProvidedData()
						);
					}
				);
				compilation.hooks.finishRebuildingModule.tap(
					"FlagDependencyExportsPlugin",
					module => {
						moduleGraph
							.getExportsInfo(module)
							.restoreProvided(providedExportsCache.get(module));
					}
				);
			}
		);
	}
}

module.exports = FlagDependencyExportsPlugin;
