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

const crypto = require("crypto");
const SortableSet = require("../util/SortableSet");
const GraphHelpers = require("../GraphHelpers");
const { isSubset } = require("../util/SetHelpers");

const hashFilename = name => {
	return crypto
		.createHash("md4")
		.update(name)
		.digest("hex")
		.slice(0, 8);
};

const sortByIdentifier = (a, b) => {
	if (a.identifier() > b.identifier()) return 1;
	if (a.identifier() < b.identifier()) return -1;
	return 0;
};

const getRequests = chunk => {
	let requests = 0;
	for (const chunkGroup of chunk.groupsIterable) {
		requests = Math.max(requests, chunkGroup.chunks.length);
	}
	return requests;
};

const getModulesSize = modules => {
	let sum = 0;
	for (const m of modules) sum += m.size();
	return sum;
};

const isOverlap = (a, b) => {
	for (const item of a.keys()) {
		if (b.has(item)) return true;
	}
	return false;
};

const compareEntries = (a, b) => {
	// 1. by priority
	const diffPriority = a.cacheGroup.priority - b.cacheGroup.priority;
	if (diffPriority) return diffPriority;
	// 2. by number of chunks
	const diffCount = a.chunks.size - b.chunks.size;
	if (diffCount) return diffCount;
	// 3. by size reduction
	const aSizeReduce = a.size * (a.chunks.size - 1);
	const bSizeReduce = b.size * (b.chunks.size - 1);
	const diffSizeReduce = aSizeReduce - bSizeReduce;
	if (diffSizeReduce) return diffSizeReduce;
	// 4. by number of modules (to be able to compare by identifier)
	const modulesA = a.modules;
	const modulesB = b.modules;
	const diff = modulesA.size - modulesB.size;
	if (diff) return diff;
	// 5. by module identifiers
	modulesA.sort();
	modulesB.sort();
	const aI = modulesA[Symbol.iterator]();
	const bI = modulesB[Symbol.iterator]();
	// eslint-disable-next-line no-constant-condition
	while (true) {
		const aItem = aI.next();
		const bItem = bI.next();
		if (aItem.done) return 0;
		const aModuleIdentifier = aItem.value.identifier();
		const bModuleIdentifier = bItem.value.identifier();
		if (aModuleIdentifier > bModuleIdentifier) return -1;
		if (aModuleIdentifier < bModuleIdentifier) return 1;
	}
};

module.exports = class SplitChunksPlugin {
	constructor(options) {
		this.options = SplitChunksPlugin.normalizeOptions(options);
	}

	static normalizeOptions(options = {}) {
		return {
			chunksFilter: SplitChunksPlugin.normalizeChunksFilter(
				options.chunks || "all"
			),
			minSize: options.minSize || 0,
			minChunks: options.minChunks || 1,
			maxAsyncRequests: options.maxAsyncRequests || 1,
			maxInitialRequests: options.maxInitialRequests || 1,
			getName:
				SplitChunksPlugin.normalizeName({
					name: options.name,
					automaticNameDelimiter: options.automaticNameDelimiter
				}) || (() => {}),
			filename: options.filename || undefined,
			getCacheGroups: SplitChunksPlugin.normalizeCacheGroups({
				cacheGroups: options.cacheGroups,
				automaticNameDelimiter: options.automaticNameDelimiter
			})
		};
	}

	static normalizeName({ name, automaticNameDelimiter }) {
		if (name === true) {
			const fn = (module, chunks, cacheGroup) => {
				const names = chunks.map(c => c.name);
				if (!names.every(Boolean)) return;
				names.sort();
				let name =
					(cacheGroup && cacheGroup !== "default"
						? cacheGroup + automaticNameDelimiter
						: "") + names.join(automaticNameDelimiter);
				// Filenames and paths can't be too long otherwise an
				// ENAMETOOLONG error is raised. If the generated name if too
				// long, it is truncated and a hash is appended. The limit has
				// been set to 100 to prevent `[name].[chunkhash].[ext]` from
				// generating a 256+ character string.
				if (name.length > 100) {
					name =
						name.slice(0, 100) + automaticNameDelimiter + hashFilename(name);
				}
				return name;
			};
			return fn;
		}
		if (typeof name === "string") {
			const fn = () => {
				return name;
			};
			return fn;
		}
		if (typeof name === "function") return name;
	}

	static normalizeChunksFilter(chunks) {
		if (chunks === "initial") {
			return chunk => chunk.canBeInitial();
		}
		if (chunks === "async") {
			return chunk => !chunk.canBeInitial();
		}
		if (chunks === "all") {
			return () => true;
		}
		if (typeof chunks === "function") return chunks;
	}

	static normalizeCacheGroups({ cacheGroups, automaticNameDelimiter }) {
		if (typeof cacheGroups === "function") {
			return cacheGroups;
		}
		if (cacheGroups && typeof cacheGroups === "object") {
			const fn = (module, chunks) => {
				let results;
				for (const key of Object.keys(cacheGroups)) {
					let option = cacheGroups[key];
					if (option === false) continue;
					if (option instanceof RegExp || typeof option === "string") {
						option = {
							test: option
						};
					}
					if (typeof option === "function") {
						let result = option(module);
						if (result) {
							if (results === undefined) results = [];
							for (const r of Array.isArray(result) ? result : [result]) {
								const result = Object.assign(
									{
										key
									},
									r
								);
								if (result.name) result.getName = () => result.name;
								if (result.chunks) {
									result.chunksFilter = SplitChunksPlugin.normalizeChunksFilter(
										result.chunks
									);
								}
								results.push(result);
							}
						}
					} else if (SplitChunksPlugin.checkTest(option.test, module, chunks)) {
						if (results === undefined) results = [];
						results.push({
							key: key,
							priority: option.priority,
							getName: SplitChunksPlugin.normalizeName({
								name: option.name,
								automaticNameDelimiter
							}),
							chunksFilter: SplitChunksPlugin.normalizeChunksFilter(
								option.chunks
							),
							enforce: option.enforce,
							minSize: option.minSize,
							minChunks: option.minChunks,
							maxAsyncRequests: option.maxAsyncRequests,
							maxInitialRequests: option.maxInitialRequests,
							filename: option.filename,
							reuseExistingChunk: option.reuseExistingChunk
						});
					}
				}
				return results;
			};
			return fn;
		}
		const fn = () => {};
		return fn;
	}

	static checkTest(test, module, chunks) {
		if (test === undefined) return true;
		if (typeof test === "function") return test(module, chunks);
		if (typeof test === "boolean") return test;
		const names = chunks
			.map(c => c.name)
			.concat(module.nameForCondition ? [module.nameForCondition()] : [])
			.filter(Boolean);
		if (typeof test === "string") {
			for (const name of names) if (name.startsWith(test)) return true;
			return false;
		}
		if (test instanceof RegExp) {
			for (const name of names) if (test.test(name)) return true;
			return false;
		}
		return false;
	}

	apply(compiler) {
		compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
			let alreadyOptimized = false;
			compilation.hooks.unseal.tap("SplitChunksPlugin", () => {
				alreadyOptimized = false;
			});
			compilation.hooks.optimizeChunksAdvanced.tap(
				"SplitChunksPlugin",
				chunks => {
					if (alreadyOptimized) return;
					alreadyOptimized = true;
					// Give each selected chunk an index (to create strings from chunks)
					const indexMap = new Map();
					let index = 1;
					for (const chunk of chunks) {
						indexMap.set(chunk, index++);
					}
					const getKey = chunks => {
						return Array.from(chunks, c => indexMap.get(c))
							.sort()
							.join();
					};
					// Create a list of possible combinations
					const chunkSetsInGraph = new Map(); // Map<string, Set<Chunk>>
					for (const module of compilation.modules) {
						const chunkIndices = getKey(module.chunksIterable);
						chunkSetsInGraph.set(chunkIndices, new Set(module.chunksIterable));
					}
					const combinations = new Map(); // Map<string, Set<Chunk>[]>
					for (const [key, chunksSet] of chunkSetsInGraph) {
						var array = [];
						for (const set of chunkSetsInGraph.values()) {
							if (isSubset(chunksSet, set)) {
								array.push(set);
							}
						}
						combinations.set(key, array);
					}
					// Map a list of chunks to a list of modules
					// For the key the chunk "index" is used, the value is a SortableSet of modules
					const chunksInfoMap = new Map();
					// Walk through all modules
					for (const module of compilation.modules) {
						// Get array of chunks
						const chunks = module.getChunks();
						// Get cache group
						let cacheGroups = this.options.getCacheGroups(module, chunks);
						if (!Array.isArray(cacheGroups)) continue;
						for (const cacheGroupSource of cacheGroups) {
							const cacheGroup = {
								key: cacheGroupSource.key,
								priority: cacheGroupSource.priority || 0,
								chunksFilter:
									cacheGroupSource.chunksFilter || this.options.chunksFilter,
								minSize:
									cacheGroupSource.minSize !== undefined
										? cacheGroupSource.minSize
										: cacheGroupSource.enforce
											? 0
											: this.options.minSize,
								minChunks:
									cacheGroupSource.minChunks !== undefined
										? cacheGroupSource.minChunks
										: cacheGroupSource.enforce
											? 1
											: this.options.minChunks,
								maxAsyncRequests:
									cacheGroupSource.maxAsyncRequests !== undefined
										? cacheGroupSource.maxAsyncRequests
										: cacheGroupSource.enforce
											? Infinity
											: this.options.maxAsyncRequests,
								maxInitialRequests:
									cacheGroupSource.maxInitialRequests !== undefined
										? cacheGroupSource.maxInitialRequests
										: cacheGroupSource.enforce
											? Infinity
											: this.options.maxInitialRequests,
								getName:
									cacheGroupSource.getName !== undefined
										? cacheGroupSource.getName
										: this.options.getName,
								filename:
									cacheGroupSource.filename !== undefined
										? cacheGroupSource.filename
										: this.options.filename,
								reuseExistingChunk: cacheGroupSource.reuseExistingChunk
							};
							// For all combination of chunk selection
							for (const chunkCombination of combinations.get(getKey(chunks))) {
								// Get indices of chunks in which this module occurs
								const chunkIndices = Array.from(chunkCombination, chunk =>
									indexMap.get(chunk)
								);
								// Break if minimum number of chunks is not reached
								if (chunkIndices.length < cacheGroup.minChunks) continue;
								// Select chunks by configuration
								const selectedChunks = Array.from(chunkCombination).filter(
									cacheGroup.chunksFilter
								);
								// Break if minimum number of chunks is not reached
								if (selectedChunks.length < cacheGroup.minChunks) continue;
								// Determine name for split chunk
								const name = cacheGroup.getName(
									module,
									selectedChunks,
									cacheGroup.key
								);
								// Create key for maps
								// When it has a name we use the name as key
								// Elsewise we create the key from chunks and cache group key
								// This automatically merges equal names
								const chunksKey = getKey(selectedChunks);
								const key =
									(name && `name:${name}`) ||
									`chunks:${chunksKey} key:${cacheGroup.key}`;
								// Add module to maps
								let info = chunksInfoMap.get(key);
								if (info === undefined) {
									chunksInfoMap.set(
										key,
										(info = {
											modules: new SortableSet(undefined, sortByIdentifier),
											cacheGroup,
											name,
											chunks: new Map(),
											reusedableChunks: new Set(),
											chunksKeys: new Set()
										})
									);
								}
								info.modules.add(module);
								if (!info.chunksKeys.has(chunksKey)) {
									info.chunksKeys.add(chunksKey);
									for (const chunk of selectedChunks) {
										info.chunks.set(chunk, chunk.getNumberOfModules());
									}
								}
							}
						}
					}
					for (const [key, info] of chunksInfoMap) {
						// Get size of module lists
						info.size = getModulesSize(info.modules);
						if (info.size < info.cacheGroup.minSize) {
							chunksInfoMap.delete(key);
						}
					}
					let changed = false;
					while (chunksInfoMap.size > 0) {
						// Find best matching entry
						let bestEntryKey;
						let bestEntry;
						for (const pair of chunksInfoMap) {
							const key = pair[0];
							const info = pair[1];
							if (bestEntry === undefined) {
								bestEntry = info;
								bestEntryKey = key;
							} else if (compareEntries(bestEntry, info) < 0) {
								bestEntry = info;
								bestEntryKey = key;
							}
						}

						const item = bestEntry;
						chunksInfoMap.delete(bestEntryKey);

						let chunkName = item.name;
						// Variable for the new chunk (lazy created)
						let newChunk;
						// When no chunk name, check if we can reuse a chunk instead of creating a new one
						let isReused = false;
						if (item.cacheGroup.reuseExistingChunk) {
							for (const pair of item.chunks) {
								if (pair[1] === item.modules.size) {
									const chunk = pair[0];
									if (chunk.hasEntryModule()) continue;
									if (!newChunk || !newChunk.name) newChunk = chunk;
									else if (
										chunk.name &&
										chunk.name.length < newChunk.name.length
									)
										newChunk = chunk;
									else if (
										chunk.name &&
										chunk.name.length === newChunk.name.length &&
										chunk.name < newChunk.name
									)
										newChunk = chunk;
									chunkName = undefined;
									isReused = true;
								}
							}
						}
						// Walk through all chunks
						for (const chunk of item.chunks.keys()) {
							// skip if we address ourself
							if (chunk.name === chunkName || chunk === newChunk) continue;
							// respect max requests when not enforced
							const maxRequests = chunk.isOnlyInitial()
								? item.cacheGroup.maxInitialRequests
								: chunk.canBeInitial()
									? Math.min(
											item.cacheGroup.maxInitialRequests,
											item.cacheGroup.maxAsyncRequests
									  )
									: item.cacheGroup.maxAsyncRequests;
							if (isFinite(maxRequests) && getRequests(chunk) >= maxRequests)
								continue;
							if (newChunk === undefined) {
								// Create the new chunk
								newChunk = compilation.addChunk(chunkName);
							}
							// Add graph connections for splitted chunk
							chunk.split(newChunk);
							// Remove all selected modules from the chunk
							for (const module of item.modules) {
								chunk.removeModule(module);
								module.rewriteChunkInReasons(chunk, [newChunk]);
							}
						}
						// If we successfully created a new chunk or reused one
						if (newChunk) {
							// Add a note to the chunk
							newChunk.chunkReason = isReused
								? "reused as split chunk"
								: "split chunk";
							if (item.cacheGroup.key) {
								newChunk.chunkReason += ` (cache group: ${
									item.cacheGroup.key
								})`;
							}
							if (chunkName) {
								newChunk.chunkReason += ` (name: ${chunkName})`;
								// If the chosen name is already an entry point we remove the entry point
								const entrypoint = compilation.entrypoints.get(chunkName);
								if (entrypoint) {
									compilation.entrypoints.delete(chunkName);
									entrypoint.remove();
									newChunk.entryModule = undefined;
								}
							}
							if (item.cacheGroup.filename) {
								if (!newChunk.isOnlyInitial()) {
									throw new Error(
										"SplitChunksPlugin: You are trying to set a filename for a chunk which is (also) loaded on demand. " +
											"The runtime can only handle loading of chunks which match the chunkFilename schema. " +
											"Using a custom filename would fail at runtime. " +
											`(cache group: ${item.cacheGroup.key})`
									);
								}
								newChunk.filenameTemplate = item.cacheGroup.filename;
							}
							if (!isReused) {
								// Add all modules to the new chunk
								for (const module of item.modules) {
									GraphHelpers.connectChunkAndModule(newChunk, module);
								}
							}
							// remove all modules from other entries and update size
							for (const [key, info] of chunksInfoMap) {
								if (isOverlap(info.chunks, item.chunks)) {
									const oldSize = info.modules.size;
									for (const module of item.modules) {
										info.modules.delete(module);
									}
									if (info.modules.size === 0) {
										chunksInfoMap.delete(key);
										continue;
									}
									if (info.modules.size !== oldSize) {
										info.size = getModulesSize(info.modules);
										if (info.size < info.cacheGroup.minSize)
											chunksInfoMap.delete(key);
									}
								}
							}
							changed = true;
						}
					}
					if (changed) return true;
				}
			);
		});
	}
};
