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

const Template = require("../Template");
const WebAssemblyUtils = require("./WebAssemblyUtils");

/** @typedef {import("../Module")} Module */
/** @typedef {import("../MainTemplate")} MainTemplate */

// Get all wasm modules
const getAllWasmModules = chunk => {
	const wasmModules = chunk.getAllAsyncChunks();
	const array = [];
	for (const chunk of wasmModules) {
		for (const m of chunk.modulesIterable) {
			if (m.type.startsWith("webassembly")) {
				array.push(m);
			}
		}
	}

	return array;
};

/**
 * generates the import object function for a module
 * @param {Module} module the module
 * @param {boolean} mangle mangle imports
 * @returns {string} source code
 */
const generateImportObject = (module, mangle) => {
	const waitForInstances = new Map();
	const properties = [];
	const usedWasmDependencies = WebAssemblyUtils.getUsedDependencies(
		module,
		mangle
	);
	for (const usedDep of usedWasmDependencies) {
		const dep = usedDep.dependency;
		const importedModule = dep.module;
		const exportName = dep.name;
		const usedName = importedModule && importedModule.isUsed(exportName);
		const description = dep.description;
		const direct = dep.onlyDirectImport;

		const module = usedDep.module;
		const name = usedDep.name;

		if (direct) {
			const instanceVar = `m${waitForInstances.size}`;
			waitForInstances.set(instanceVar, importedModule.id);
			properties.push({
				module,
				name,
				value: `${instanceVar}[${JSON.stringify(usedName)}]`
			});
		} else {
			const params = description.signature.params.map(
				(param, k) => "p" + k + param.valtype
			);

			const mod = `installedModules[${JSON.stringify(importedModule.id)}]`;
			const func = `${mod}.exports[${JSON.stringify(usedName)}]`;

			properties.push({
				module,
				name,
				value: Template.asString([
					(importedModule.type.startsWith("webassembly")
						? `${mod} ? ${func} : `
						: "") + `function(${params}) {`,
					Template.indent([`return ${func}(${params});`]),
					"}"
				])
			});
		}
	}

	let importObject;
	if (mangle) {
		importObject = [
			"return {",
			Template.indent([
				properties.map(p => `${JSON.stringify(p.name)}: ${p.value}`).join(",\n")
			]),
			"};"
		];
	} else {
		const propertiesByModule = new Map();
		for (const p of properties) {
			let list = propertiesByModule.get(p.module);
			if (list === undefined) {
				propertiesByModule.set(p.module, (list = []));
			}
			list.push(p);
		}
		importObject = [
			"return {",
			Template.indent([
				Array.from(propertiesByModule, ([module, list]) => {
					return Template.asString([
						`${JSON.stringify(module)}: {`,
						Template.indent([
							list.map(p => `${JSON.stringify(p.name)}: ${p.value}`).join(",\n")
						]),
						"}"
					]);
				}).join(",\n")
			]),
			"};"
		];
	}

	if (waitForInstances.size === 1) {
		const moduleId = Array.from(waitForInstances.values())[0];
		const promise = `installedWasmModules[${JSON.stringify(moduleId)}]`;
		const variable = Array.from(waitForInstances.keys())[0];
		return Template.asString([
			`${JSON.stringify(module.id)}: function() {`,
			Template.indent([
				`return promiseResolve().then(function() { return ${promise}; }).then(function(${variable}) {`,
				Template.indent(importObject),
				"});"
			]),
			"},"
		]);
	} else if (waitForInstances.size > 0) {
		const promises = Array.from(
			waitForInstances.values(),
			id => `installedWasmModules[${JSON.stringify(id)}]`
		).join(", ");
		const variables = Array.from(
			waitForInstances.keys(),
			(name, i) => `${name} = array[${i}]`
		).join(", ");
		return Template.asString([
			`${JSON.stringify(module.id)}: function() {`,
			Template.indent([
				`return promiseResolve().then(function() { return Promise.all([${promises}]); }).then(function(array) {`,
				Template.indent([`var ${variables};`, ...importObject]),
				"});"
			]),
			"},"
		]);
	} else {
		return Template.asString([
			`${JSON.stringify(module.id)}: function() {`,
			Template.indent(importObject),
			"},"
		]);
	}
};

class WasmMainTemplatePlugin {
	constructor({ generateLoadBinaryCode, supportsStreaming, mangleImports }) {
		this.generateLoadBinaryCode = generateLoadBinaryCode;
		this.supportsStreaming = supportsStreaming;
		this.mangleImports = mangleImports;
	}

	/**
	 * @param {MainTemplate} mainTemplate main template
	 * @returns {void}
	 */
	apply(mainTemplate) {
		mainTemplate.hooks.localVars.tap(
			"WasmMainTemplatePlugin",
			(source, chunk) => {
				const wasmModules = getAllWasmModules(chunk);
				if (wasmModules.length === 0) return source;
				const importObjects = wasmModules.map(module => {
					return generateImportObject(module, this.mangleImports);
				});
				return Template.asString([
					source,
					"",
					"// object to store loaded and loading wasm modules",
					"var installedWasmModules = {};",
					"",
					// This function is used to delay reading the installed wasm module promises
					// by a microtask. Sorting them doesn't help because there are egdecases where
					// sorting is not possible (modules splitted into different chunks).
					// So we not even trying and solve this by a microtask delay.
					"function promiseResolve() { return Promise.resolve(); }",
					"",
					"var wasmImportObjects = {",
					Template.indent(importObjects),
					"};"
				]);
			}
		);
		mainTemplate.hooks.requireEnsure.tap(
			"WasmMainTemplatePlugin",
			(source, chunk, hash) => {
				const webassemblyModuleFilename =
					mainTemplate.outputOptions.webassemblyModuleFilename;

				const chunkModuleMaps = chunk.getChunkModuleMaps(m =>
					m.type.startsWith("webassembly")
				);
				if (Object.keys(chunkModuleMaps.id).length === 0) return source;
				const wasmModuleSrcPath = mainTemplate.getAssetPath(
					JSON.stringify(webassemblyModuleFilename),
					{
						hash: `" + ${mainTemplate.renderCurrentHashCode(hash)} + "`,
						hashWithLength: length =>
							`" + ${mainTemplate.renderCurrentHashCode(hash, length)} + "`,
						module: {
							id: '" + wasmModuleId + "',
							hash: `" + ${JSON.stringify(
								chunkModuleMaps.hash
							)}[wasmModuleId] + "`,
							hashWithLength(length) {
								const shortChunkHashMap = Object.create(null);
								for (const wasmModuleId of Object.keys(chunkModuleMaps.hash)) {
									if (typeof chunkModuleMaps.hash[wasmModuleId] === "string") {
										shortChunkHashMap[wasmModuleId] = chunkModuleMaps.hash[
											wasmModuleId
										].substr(0, length);
									}
								}
								return `" + ${JSON.stringify(
									shortChunkHashMap
								)}[wasmModuleId] + "`;
							}
						}
					}
				);
				const createImportObject = content =>
					this.mangleImports
						? `{ ${JSON.stringify(
								WebAssemblyUtils.MANGLED_MODULE
						  )}: ${content} }`
						: content;
				return Template.asString([
					source,
					"",
					"// Fetch + compile chunk loading for webassembly",
					"",
					`var wasmModules = ${JSON.stringify(
						chunkModuleMaps.id
					)}[chunkId] || [];`,
					"",
					"wasmModules.forEach(function(wasmModuleId) {",
					Template.indent([
						"var installedWasmModuleData = installedWasmModules[wasmModuleId];",
						"",
						'// a Promise means "currently loading" or "already loaded".',
						"if(installedWasmModuleData)",
						Template.indent(["promises.push(installedWasmModuleData);"]),
						"else {",
						Template.indent([
							`var importObject = wasmImportObjects[wasmModuleId]();`,
							`var req = ${this.generateLoadBinaryCode(wasmModuleSrcPath)};`,
							"var promise;",
							this.supportsStreaming
								? Template.asString([
										"if(importObject instanceof Promise && typeof WebAssembly.compileStreaming === 'function') {",
										Template.indent([
											"promise = Promise.all([WebAssembly.compileStreaming(req), importObject]).then(function(items) {",
											Template.indent([
												`return WebAssembly.instantiate(items[0], ${createImportObject(
													"items[1]"
												)});`
											]),
											"});"
										]),
										"} else if(typeof WebAssembly.instantiateStreaming === 'function') {",
										Template.indent([
											`promise = WebAssembly.instantiateStreaming(req, ${createImportObject(
												"importObject"
											)});`
										])
								  ])
								: Template.asString([
										"if(importObject instanceof Promise) {",
										Template.indent([
											"var bytesPromise = req.then(function(x) { return x.arrayBuffer(); });",
											"promise = Promise.all([",
											Template.indent([
												"bytesPromise.then(function(bytes) { return WebAssembly.compile(bytes); }),",
												"importObject"
											]),
											"]).then(function(items) {",
											Template.indent([
												`return WebAssembly.instantiate(items[0], ${createImportObject(
													"items[1]"
												)});`
											]),
											"});"
										])
								  ]),
							"} else {",
							Template.indent([
								"var bytesPromise = req.then(function(x) { return x.arrayBuffer(); });",
								"promise = bytesPromise.then(function(bytes) {",
								Template.indent([
									`return WebAssembly.instantiate(bytes, ${createImportObject(
										"importObject"
									)});`
								]),
								"});"
							]),
							"}",
							"promises.push(installedWasmModules[wasmModuleId] = promise.then(function(res) {",
							Template.indent([
								`return ${mainTemplate.requireFn}.w[wasmModuleId] = (res.instance || res).exports;`
							]),
							"}));"
						]),
						"}"
					]),
					"});"
				]);
			}
		);
		mainTemplate.hooks.requireExtensions.tap(
			"WasmMainTemplatePlugin",
			(source, chunk) => {
				if (!chunk.hasModuleInGraph(m => m.type.startsWith("webassembly"))) {
					return source;
				}
				return Template.asString([
					source,
					"",
					"// object with all WebAssembly.instance exports",
					`${mainTemplate.requireFn}.w = {};`
				]);
			}
		);
		mainTemplate.hooks.hash.tap("WasmMainTemplatePlugin", hash => {
			hash.update("WasmMainTemplatePlugin");
			hash.update("2");
		});
	}
}

module.exports = WasmMainTemplatePlugin;
