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

"use strict";

const asyncLib = require("neo-async");
const { AsyncSeriesWaterfallHook, SyncWaterfallHook } = require("tapable");
const ContextModule = require("./ContextModule");
const ModuleFactory = require("./ModuleFactory");
const ContextElementDependency = require("./dependencies/ContextElementDependency");
const LazySet = require("./util/LazySet");
const { cachedSetProperty } = require("./util/cleverMerge");
const { createFakeHook } = require("./util/deprecation");
const { join } = require("./util/fs");

/** @typedef {import("./ContextModule").ContextModuleOptions} ContextModuleOptions */
/** @typedef {import("./ContextModule").ResolveDependenciesCallback} ResolveDependenciesCallback */
/** @typedef {import("./ModuleFactory").ModuleFactoryCreateData} ModuleFactoryCreateData */
/** @typedef {import("./ModuleFactory").ModuleFactoryCallback} ModuleFactoryCallback */
/** @typedef {import("./ResolverFactory")} ResolverFactory */
/** @typedef {import("./dependencies/ContextDependency")} ContextDependency */
/** @typedef {import("./dependencies/ContextDependency").ContextOptions} ContextOptions */
/** @typedef {import("enhanced-resolve").ResolveRequest} ResolveRequest */
/**
 * @template T
 * @typedef {import("./util/deprecation").FakeHook<T>} FakeHook<T>
 */
/** @typedef {import("./util/fs").IStats} IStats */
/** @typedef {import("./util/fs").InputFileSystem} InputFileSystem */
/** @typedef {{ context: string, request: string }} ContextAlternativeRequest */

/**
 * @typedef {object} ContextResolveData
 * @property {string} context
 * @property {string} request
 * @property {ModuleFactoryCreateData["resolveOptions"]} resolveOptions
 * @property {LazySet<string>} fileDependencies
 * @property {LazySet<string>} missingDependencies
 * @property {LazySet<string>} contextDependencies
 * @property {ContextDependency[]} dependencies
 */

/** @typedef {ContextResolveData & ContextOptions} BeforeContextResolveData */
/** @typedef {BeforeContextResolveData & { resource: string | string[], resourceQuery: string | undefined, resourceFragment: string | undefined, resolveDependencies: ContextModuleFactory["resolveDependencies"] }} AfterContextResolveData */

const EMPTY_RESOLVE_OPTIONS = {};

class ContextModuleFactory extends ModuleFactory {
	/**
	 * @param {ResolverFactory} resolverFactory resolverFactory
	 */
	constructor(resolverFactory) {
		super();
		/** @type {AsyncSeriesWaterfallHook<[ContextAlternativeRequest[], ContextModuleOptions]>} */
		const alternativeRequests = new AsyncSeriesWaterfallHook([
			"modules",
			"options"
		]);
		this.hooks = Object.freeze({
			/** @type {AsyncSeriesWaterfallHook<[BeforeContextResolveData], BeforeContextResolveData | false | void>} */
			beforeResolve: new AsyncSeriesWaterfallHook(["data"]),
			/** @type {AsyncSeriesWaterfallHook<[AfterContextResolveData], AfterContextResolveData | false | void>} */
			afterResolve: new AsyncSeriesWaterfallHook(["data"]),
			/** @type {SyncWaterfallHook<[string[]]>} */
			contextModuleFiles: new SyncWaterfallHook(["files"]),
			/** @type {FakeHook<Pick<AsyncSeriesWaterfallHook<[ContextAlternativeRequest[]]>, "tap" | "tapAsync" | "tapPromise" | "name">>} */
			alternatives: createFakeHook(
				{
					name: "alternatives",
					/** @type {AsyncSeriesWaterfallHook<[ContextAlternativeRequest[]]>["intercept"]} */
					intercept: (interceptor) => {
						throw new Error(
							"Intercepting fake hook ContextModuleFactory.hooks.alternatives is not possible, use ContextModuleFactory.hooks.alternativeRequests instead"
						);
					},
					/** @type {AsyncSeriesWaterfallHook<[ContextAlternativeRequest[]]>["tap"]} */
					tap: (options, fn) => {
						alternativeRequests.tap(options, fn);
					},
					/** @type {AsyncSeriesWaterfallHook<[ContextAlternativeRequest[]]>["tapAsync"]} */
					tapAsync: (options, fn) => {
						alternativeRequests.tapAsync(options, (items, _options, callback) =>
							fn(items, callback)
						);
					},
					/** @type {AsyncSeriesWaterfallHook<[ContextAlternativeRequest[]]>["tapPromise"]} */
					tapPromise: (options, fn) => {
						alternativeRequests.tapPromise(options, fn);
					}
				},
				"ContextModuleFactory.hooks.alternatives has deprecated in favor of ContextModuleFactory.hooks.alternativeRequests with an additional options argument.",
				"DEP_WEBPACK_CONTEXT_MODULE_FACTORY_ALTERNATIVES"
			),
			alternativeRequests
		});
		this.resolverFactory = resolverFactory;
	}

	/**
	 * @param {ModuleFactoryCreateData} data data object
	 * @param {ModuleFactoryCallback} callback callback
	 * @returns {void}
	 */
	create(data, callback) {
		const context = data.context;
		const dependencies = /** @type {ContextDependency[]} */ (data.dependencies);
		const resolveOptions = data.resolveOptions;
		const dependency = dependencies[0];
		const fileDependencies = new LazySet();
		const missingDependencies = new LazySet();
		const contextDependencies = new LazySet();
		this.hooks.beforeResolve.callAsync(
			{
				context,
				dependencies,
				layer: data.contextInfo.issuerLayer,
				resolveOptions,
				fileDependencies,
				missingDependencies,
				contextDependencies,
				...dependency.options
			},
			(err, beforeResolveResult) => {
				if (err) {
					return callback(err, {
						fileDependencies,
						missingDependencies,
						contextDependencies
					});
				}

				// Ignored
				if (!beforeResolveResult) {
					return callback(null, {
						fileDependencies,
						missingDependencies,
						contextDependencies
					});
				}

				const context = beforeResolveResult.context;
				const request = beforeResolveResult.request;
				const resolveOptions = beforeResolveResult.resolveOptions;

				/** @type {undefined | string[]} */
				let loaders;
				/** @type {undefined | string} */
				let resource;
				let loadersPrefix = "";
				const idx = request.lastIndexOf("!");
				if (idx >= 0) {
					let loadersRequest = request.slice(0, idx + 1);
					let i;
					for (
						i = 0;
						i < loadersRequest.length && loadersRequest[i] === "!";
						i++
					) {
						loadersPrefix += "!";
					}
					loadersRequest = loadersRequest
						.slice(i)
						.replace(/!+$/, "")
						.replace(/!!+/g, "!");
					loaders = loadersRequest === "" ? [] : loadersRequest.split("!");
					resource = request.slice(idx + 1);
				} else {
					loaders = [];
					resource = request;
				}

				const contextResolver = this.resolverFactory.get(
					"context",
					dependencies.length > 0
						? cachedSetProperty(
								resolveOptions || EMPTY_RESOLVE_OPTIONS,
								"dependencyType",
								dependencies[0].category
							)
						: resolveOptions
				);
				const loaderResolver = this.resolverFactory.get("loader");

				asyncLib.parallel(
					[
						(callback) => {
							const results = /** @type {ResolveRequest[]} */ ([]);
							/**
							 * @param {ResolveRequest} obj obj
							 * @returns {void}
							 */
							const yield_ = (obj) => {
								results.push(obj);
							};

							contextResolver.resolve(
								{},
								context,
								resource,
								{
									fileDependencies,
									missingDependencies,
									contextDependencies,
									yield: yield_
								},
								(err) => {
									if (err) return callback(err);
									callback(null, results);
								}
							);
						},
						(callback) => {
							asyncLib.map(
								loaders,
								(loader, callback) => {
									loaderResolver.resolve(
										{},
										context,
										loader,
										{
											fileDependencies,
											missingDependencies,
											contextDependencies
										},
										(err, result) => {
											if (err) return callback(err);
											callback(null, result);
										}
									);
								},
								callback
							);
						}
					],
					(err, result) => {
						if (err) {
							return callback(err, {
								fileDependencies,
								missingDependencies,
								contextDependencies
							});
						}
						let [contextResult, loaderResult] =
							/** @type {[ResolveRequest[], string[]]} */ (result);
						if (contextResult.length > 1) {
							const first = contextResult[0];
							contextResult = contextResult.filter((r) => r.path);
							if (contextResult.length === 0) contextResult.push(first);
						}
						this.hooks.afterResolve.callAsync(
							{
								addon:
									loadersPrefix +
									loaderResult.join("!") +
									(loaderResult.length > 0 ? "!" : ""),
								resource:
									contextResult.length > 1
										? /** @type {string[]} */ (contextResult.map((r) => r.path))
										: /** @type {string} */ (contextResult[0].path),
								resolveDependencies: this.resolveDependencies.bind(this),
								resourceQuery: contextResult[0].query,
								resourceFragment: contextResult[0].fragment,
								...beforeResolveResult
							},
							(err, result) => {
								if (err) {
									return callback(err, {
										fileDependencies,
										missingDependencies,
										contextDependencies
									});
								}

								// Ignored
								if (!result) {
									return callback(null, {
										fileDependencies,
										missingDependencies,
										contextDependencies
									});
								}

								return callback(null, {
									module: new ContextModule(result.resolveDependencies, result),
									fileDependencies,
									missingDependencies,
									contextDependencies
								});
							}
						);
					}
				);
			}
		);
	}

	/**
	 * @param {InputFileSystem} fs file system
	 * @param {ContextModuleOptions} options options
	 * @param {ResolveDependenciesCallback} callback callback function
	 * @returns {void}
	 */
	resolveDependencies(fs, options, callback) {
		const cmf = this;
		const {
			resource,
			resourceQuery,
			resourceFragment,
			recursive,
			regExp,
			include,
			exclude,
			referencedExports,
			category,
			typePrefix,
			attributes
		} = options;
		if (!regExp || !resource) return callback(null, []);

		/**
		 * @param {string} ctx context
		 * @param {string} directory directory
		 * @param {Set<string>} visited visited
		 * @param {ResolveDependenciesCallback} callback callback
		 */
		const addDirectoryChecked = (ctx, directory, visited, callback) => {
			/** @type {NonNullable<InputFileSystem["realpath"]>} */
			(fs.realpath)(directory, (err, _realPath) => {
				if (err) return callback(err);
				const realPath = /** @type {string} */ (_realPath);
				if (visited.has(realPath)) return callback(null, []);
				/** @type {Set<string> | undefined} */
				let recursionStack;
				addDirectory(
					ctx,
					directory,
					(_, dir, callback) => {
						if (recursionStack === undefined) {
							recursionStack = new Set(visited);
							recursionStack.add(realPath);
						}
						addDirectoryChecked(ctx, dir, recursionStack, callback);
					},
					callback
				);
			});
		};

		/**
		 * @param {string} ctx context
		 * @param {string} directory directory
		 * @param {(context: string, subResource: string, callback: () => void) => void} addSubDirectory addSubDirectoryFn
		 * @param {ResolveDependenciesCallback} callback callback
		 * @returns {void}
		 */
		const addDirectory = (ctx, directory, addSubDirectory, callback) => {
			fs.readdir(directory, (err, files) => {
				if (err) return callback(err);
				const processedFiles = cmf.hooks.contextModuleFiles.call(
					/** @type {string[]} */ (files).map((file) => file.normalize("NFC"))
				);
				if (!processedFiles || processedFiles.length === 0) {
					return callback(null, []);
				}
				asyncLib.map(
					processedFiles.filter((p) => p.indexOf(".") !== 0),
					(segment, callback) => {
						const subResource = join(fs, directory, segment);

						if (!exclude || !exclude.test(subResource)) {
							fs.stat(subResource, (err, _stat) => {
								if (err) {
									if (err.code === "ENOENT") {
										// ENOENT is ok here because the file may have been deleted between
										// the readdir and stat calls.
										return callback();
									}
									return callback(err);
								}

								const stat = /** @type {IStats} */ (_stat);

								if (stat.isDirectory()) {
									if (!recursive) return callback();
									addSubDirectory(ctx, subResource, callback);
								} else if (
									stat.isFile() &&
									(!include || include.test(subResource))
								) {
									/** @type {{ context: string, request: string }} */
									const obj = {
										context: ctx,
										request: `.${subResource.slice(ctx.length).replace(/\\/g, "/")}`
									};

									this.hooks.alternativeRequests.callAsync(
										[obj],
										options,
										(err, alternatives) => {
											if (err) return callback(err);
											callback(
												null,
												/** @type {ContextAlternativeRequest[]} */
												(alternatives)
													.filter((obj) =>
														regExp.test(/** @type {string} */ (obj.request))
													)
													.map((obj) => {
														const dep = new ContextElementDependency(
															`${obj.request}${resourceQuery}${resourceFragment}`,
															obj.request,
															typePrefix,
															/** @type {string} */
															(category),
															referencedExports,
															obj.context,
															attributes
														);
														dep.optional = true;
														return dep;
													})
											);
										}
									);
								} else {
									callback();
								}
							});
						} else {
							callback();
						}
					},
					(err, result) => {
						if (err) return callback(err);

						if (!result) return callback(null, []);

						const flattenedResult = [];

						for (const item of result) {
							if (item) flattenedResult.push(...item);
						}

						callback(null, flattenedResult);
					}
				);
			});
		};

		/**
		 * @param {string} ctx context
		 * @param {string} dir dir
		 * @param {ResolveDependenciesCallback} callback callback
		 * @returns {void}
		 */
		const addSubDirectory = (ctx, dir, callback) =>
			addDirectory(ctx, dir, addSubDirectory, callback);

		/**
		 * @param {string} resource resource
		 * @param {ResolveDependenciesCallback} callback callback
		 */
		const visitResource = (resource, callback) => {
			if (typeof fs.realpath === "function") {
				addDirectoryChecked(resource, resource, new Set(), callback);
			} else {
				addDirectory(resource, resource, addSubDirectory, callback);
			}
		};

		if (typeof resource === "string") {
			visitResource(resource, callback);
		} else {
			asyncLib.map(resource, visitResource, (err, _result) => {
				if (err) return callback(err);
				const result = /** @type {ContextElementDependency[][]} */ (_result);

				// result dependencies should have unique userRequest
				// ordered by resolve result
				/** @type {Set<string>} */
				const temp = new Set();
				/** @type {ContextElementDependency[]} */
				const res = [];
				for (let i = 0; i < result.length; i++) {
					const inner = result[i];
					for (const el of inner) {
						if (temp.has(el.userRequest)) continue;
						res.push(el);
						temp.add(el.userRequest);
					}
				}
				callback(null, res);
			});
		}
	}
}

module.exports = ContextModuleFactory;
