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

"use strict";

const {
	JAVASCRIPT_MODULE_TYPE_AUTO,
	JAVASCRIPT_MODULE_TYPE_DYNAMIC,
	JAVASCRIPT_MODULE_TYPE_ESM
} = require("./ModuleTypeConstants");
const NodeStuffInWebError = require("./NodeStuffInWebError");
const RuntimeGlobals = require("./RuntimeGlobals");
const CachedConstDependency = require("./dependencies/CachedConstDependency");
const ConstDependency = require("./dependencies/ConstDependency");
const ExternalModuleDependency = require("./dependencies/ExternalModuleDependency");
const ExternalModuleInitFragmentDependency = require("./dependencies/ExternalModuleInitFragmentDependency");
const ImportMetaPlugin = require("./dependencies/ImportMetaPlugin");
const { evaluateToString } = require("./javascript/JavascriptParserHelpers");
const { relative } = require("./util/fs");
const { parseResource } = require("./util/identifier");

/** @typedef {import("../declarations/WebpackOptions").JavascriptParserOptions} JavascriptParserOptions */
/** @typedef {import("../declarations/WebpackOptions").NodeOptions} NodeOptions */
/** @typedef {import("./Compiler")} Compiler */
/** @typedef {import("./Dependency").DependencyLocation} DependencyLocation */
/** @typedef {import("./NormalModule")} NormalModule */
/** @typedef {import("./javascript/JavascriptParser")} JavascriptParser */
/** @typedef {import("./javascript/JavascriptParser").Expression} Expression */
/** @typedef {import("./javascript/JavascriptParser").Range} Range */
/** @typedef {import("./util/fs").InputFileSystem} InputFileSystem */

const PLUGIN_NAME = "NodeStuffPlugin";
const URL_MODULE_CONSTANT_FUNCTION_NAME = "__webpack_fileURLToPath__";

class NodeStuffPlugin {
	/**
	 * @param {NodeOptions} options options
	 */
	constructor(options) {
		this.options = options;
	}

	/**
	 * Apply the plugin
	 * @param {Compiler} compiler the compiler instance
	 * @returns {void}
	 */
	apply(compiler) {
		const { options } = this;

		compiler.hooks.compilation.tap(
			PLUGIN_NAME,
			(compilation, { normalModuleFactory }) => {
				compilation.dependencyTemplates.set(
					ExternalModuleDependency,
					new ExternalModuleDependency.Template()
				);
				compilation.dependencyTemplates.set(
					ExternalModuleInitFragmentDependency,
					new ExternalModuleInitFragmentDependency.Template()
				);

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {NodeOptions} nodeOptions options
				 * @returns {void}
				 */
				const globalHandler = (parser, nodeOptions) => {
					/**
					 * @param {Expression} expr expression
					 * @returns {ConstDependency} const dependency
					 */
					const getGlobalDep = (expr) => {
						if (compilation.outputOptions.environment.globalThis) {
							return new ConstDependency(
								"globalThis",
								/** @type {Range} */ (expr.range)
							);
						}

						return new ConstDependency(
							RuntimeGlobals.global,
							/** @type {Range} */ (expr.range),
							[RuntimeGlobals.global]
						);
					};

					const withWarning = nodeOptions.global === "warn";

					parser.hooks.expression.for("global").tap(PLUGIN_NAME, (expr) => {
						const dep = getGlobalDep(expr);
						dep.loc = /** @type {DependencyLocation} */ (expr.loc);
						parser.state.module.addPresentationalDependency(dep);

						if (withWarning) {
							parser.state.module.addWarning(
								new NodeStuffInWebError(
									dep.loc,
									"global",
									"The global namespace object is a Node.js feature and isn't available in browsers."
								)
							);
						}
					});

					parser.hooks.rename.for("global").tap(PLUGIN_NAME, (expr) => {
						const dep = getGlobalDep(expr);
						dep.loc = /** @type {DependencyLocation} */ (expr.loc);
						parser.state.module.addPresentationalDependency(dep);
						return false;
					});
				};

				const hooks = ImportMetaPlugin.getCompilationHooks(compilation);

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {"__filename" | "__dirname" | "import.meta.filename" | "import.meta.dirname"} expressionName expression name
				 * @param {(module: NormalModule) => string} fn function
				 * @param {"filename" | "dirname"} property a property
				 * @returns {void}
				 */
				const setModuleConstant = (parser, expressionName, fn, property) => {
					parser.hooks.expression
						.for(expressionName)
						.tap(PLUGIN_NAME, (expr) => {
							const dep = new ConstDependency(
								fn(parser.state.module),
								/** @type {Range} */
								(expr.range)
							);
							dep.loc = /** @type {DependencyLocation} */ (expr.loc);
							parser.state.module.addPresentationalDependency(dep);
							return true;
						});

					if (
						expressionName === "import.meta.filename" ||
						expressionName === "import.meta.dirname"
					) {
						hooks.propertyInDestructuring.tap(PLUGIN_NAME, (usingProperty) => {
							if (usingProperty.id === property) {
								return `${property}: ${fn(parser.state.module)},`;
							}
						});
					}
				};

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {"__filename" | "__dirname" | "import.meta.filename" | "import.meta.dirname"} expressionName expression name
				 * @param {(module: NormalModule) => string} fn function
				 * @param {"filename" | "dirname"} property a property
				 * @param {string=} warning warning
				 * @returns {void}
				 */
				const setCachedModuleConstant = (
					parser,
					expressionName,
					fn,
					property,
					warning
				) => {
					parser.hooks.expression
						.for(expressionName)
						.tap(PLUGIN_NAME, (expr) => {
							const dep = new CachedConstDependency(
								JSON.stringify(fn(parser.state.module)),
								/** @type {Range} */
								(expr.range),
								`__webpack_${property}__`
							);
							dep.loc = /** @type {DependencyLocation} */ (expr.loc);
							parser.state.module.addPresentationalDependency(dep);

							if (warning) {
								parser.state.module.addWarning(
									new NodeStuffInWebError(dep.loc, expressionName, warning)
								);
							}

							return true;
						});

					if (
						expressionName === "import.meta.filename" ||
						expressionName === "import.meta.dirname"
					) {
						hooks.propertyInDestructuring.tap(PLUGIN_NAME, (usingProperty) => {
							if (property === usingProperty.id) {
								if (warning) {
									parser.state.module.addWarning(
										new NodeStuffInWebError(
											usingProperty.loc,
											expressionName,
											warning
										)
									);
								}

								return `${property}: ${JSON.stringify(fn(parser.state.module))},`;
							}
						});
					}
				};

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {"__filename" | "__dirname" | "import.meta.filename" | "import.meta.dirname"} expressionName expression name
				 * @param {string} value value
				 * @param {"filename" | "dirname"} property a property
				 * @param {string=} warning warning
				 * @returns {void}
				 */
				const setConstant = (
					parser,
					expressionName,
					value,
					property,
					warning
				) =>
					setCachedModuleConstant(
						parser,
						expressionName,
						() => value,
						property,
						warning
					);

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {"__filename" | "__dirname" | "import.meta.filename" | "import.meta.dirname"} expressionName expression name
				 * @param {"dirname" | "filename"} property property
				 * @param {() => string} value function to get value
				 * @returns {void}
				 */
				const setUrlModuleConstant = (
					parser,
					expressionName,
					property,
					value
				) => {
					parser.hooks.expression
						.for(expressionName)
						.tap(PLUGIN_NAME, (expr) => {
							const { importMetaName, environment, module } =
								compilation.outputOptions;

							if (
								module &&
								importMetaName === "import.meta" &&
								(expressionName === "import.meta.filename" ||
									expressionName === "import.meta.dirname") &&
								environment.importMetaDirnameAndFilename
							) {
								return true;
							}

							// Generate `import.meta.dirname` and `import.meta.filename` when:
							// - they are supported by the environment
							// - it is a universal target, because we can't use `import mod from "node:url"; ` at the top file
							const dep =
								environment.importMetaDirnameAndFilename ||
								(compiler.platform.web === null &&
									compiler.platform.node === null &&
									module)
									? new ConstDependency(
											`${importMetaName}.${property}`,
											/** @type {Range} */
											(expr.range)
										)
									: new ExternalModuleDependency(
											"url",
											[
												{
													name: "fileURLToPath",
													value: URL_MODULE_CONSTANT_FUNCTION_NAME
												}
											],
											undefined,
											`${URL_MODULE_CONSTANT_FUNCTION_NAME}(${value()})`,
											/** @type {Range} */ (expr.range),
											`__webpack_${property}__`
										);
							dep.loc = /** @type {DependencyLocation} */ (expr.loc);
							parser.state.module.addPresentationalDependency(dep);

							return true;
						});

					if (
						expressionName === "import.meta.filename" ||
						expressionName === "import.meta.dirname"
					) {
						hooks.propertyInDestructuring.tap(PLUGIN_NAME, (usingProperty) => {
							if (property === usingProperty.id) {
								const { importMetaName, environment, module } =
									compilation.outputOptions;

								if (
									module &&
									importMetaName === "import.meta" &&
									(expressionName === "import.meta.filename" ||
										expressionName === "import.meta.dirname") &&
									environment.importMetaDirnameAndFilename
								) {
									return `${property}: ${importMetaName}.${property},`;
								}

								if (environment.importMetaDirnameAndFilename) {
									return `${property}: ${importMetaName}.${property},`;
								}

								const dep = new ExternalModuleInitFragmentDependency(
									"url",
									[
										{
											name: "fileURLToPath",
											value: URL_MODULE_CONSTANT_FUNCTION_NAME
										}
									],
									undefined
								);

								dep.loc = /** @type {DependencyLocation} */ (usingProperty.loc);
								parser.state.module.addPresentationalDependency(dep);

								return `${property}: ${URL_MODULE_CONSTANT_FUNCTION_NAME}(${value()}),`;
							}
						});
					}
				};

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {NodeOptions} nodeOptions options
				 * @param {{ dirname: "__dirname" | "import.meta.dirname", filename: "__filename" | "import.meta.filename" }} identifiers options
				 * @returns {void}
				 */
				const dirnameAndFilenameHandler = (
					parser,
					nodeOptions,
					{ dirname, filename }
				) => {
					// Keep `import.meta.filename` in code
					if (
						nodeOptions.__filename === false &&
						filename === "import.meta.filename"
					) {
						setModuleConstant(parser, filename, () => filename, "filename");
					}

					if (nodeOptions.__filename) {
						switch (nodeOptions.__filename) {
							case "mock":
								setConstant(parser, filename, "/index.js", "filename");
								break;
							case "warn-mock":
								setConstant(
									parser,
									filename,
									"/index.js",
									"filename",
									"__filename is a Node.js feature and isn't available in browsers."
								);
								break;
							case "node-module": {
								const importMetaName = compilation.outputOptions.importMetaName;

								setUrlModuleConstant(
									parser,
									filename,
									"filename",
									() => `${importMetaName}.url`
								);
								break;
							}
							case "eval-only":
								// Keep `import.meta.filename` in the source code for the ES module output, or create a fallback using `import.meta.url` if possible
								if (compilation.outputOptions.module) {
									const { importMetaName } = compilation.outputOptions;

									setUrlModuleConstant(
										parser,
										filename,
										"filename",
										() => `${importMetaName}.url`
									);
								}
								// Replace `import.meta.filename` with `__filename` for the non-ES module output
								else if (filename === "import.meta.filename") {
									setModuleConstant(
										parser,
										filename,
										() => "__filename",
										"filename"
									);
								}
								break;
							case true:
								setCachedModuleConstant(
									parser,
									filename,
									(module) =>
										relative(
											/** @type {InputFileSystem} */ (compiler.inputFileSystem),
											compiler.context,
											module.resource
										),
									"filename"
								);
								break;
						}

						parser.hooks.evaluateIdentifier
							.for("__filename")
							.tap(PLUGIN_NAME, (expr) => {
								if (!parser.state.module) return;
								const resource = parseResource(parser.state.module.resource);
								return evaluateToString(resource.path)(expr);
							});
					}

					// Keep `import.meta.dirname` in code
					if (
						nodeOptions.__dirname === false &&
						dirname === "import.meta.dirname"
					) {
						setModuleConstant(parser, dirname, () => dirname, "dirname");
					}

					if (nodeOptions.__dirname) {
						switch (nodeOptions.__dirname) {
							case "mock":
								setConstant(parser, dirname, "/", "dirname");
								break;
							case "warn-mock":
								setConstant(
									parser,
									dirname,
									"/",
									"dirname",
									"__dirname is a Node.js feature and isn't available in browsers."
								);
								break;
							case "node-module": {
								const importMetaName = compilation.outputOptions.importMetaName;

								setUrlModuleConstant(
									parser,
									dirname,
									"dirname",
									() => `${importMetaName}.url.replace(/\\/(?:[^\\/]*)$/, "")`
								);
								break;
							}
							case "eval-only":
								// Keep `import.meta.dirname` in the source code for the ES module output and replace `__dirname` on `import.meta.dirname`
								if (compilation.outputOptions.module) {
									const { importMetaName } = compilation.outputOptions;

									setUrlModuleConstant(
										parser,
										dirname,
										"dirname",
										() => `${importMetaName}.url.replace(/\\/(?:[^\\/]*)$/, "")`
									);
								}
								// Replace `import.meta.dirname` with `__dirname` for the non-ES module output
								else if (dirname === "import.meta.dirname") {
									setModuleConstant(
										parser,
										dirname,
										() => "__dirname",
										"dirname"
									);
								}
								break;
							case true:
								setCachedModuleConstant(
									parser,
									dirname,
									(module) =>
										relative(
											/** @type {InputFileSystem} */ (compiler.inputFileSystem),
											compiler.context,
											/** @type {string} */ (module.context)
										),
									"dirname"
								);
								break;
						}

						parser.hooks.evaluateIdentifier
							.for(dirname)
							.tap(PLUGIN_NAME, (expr) => {
								if (!parser.state.module) return;
								return evaluateToString(
									/** @type {string} */
									(parser.state.module.context)
								)(expr);
							});
					}
				};

				/**
				 * @param {JavascriptParser} parser the parser
				 * @param {JavascriptParserOptions} parserOptions the javascript parser options
				 * @param {boolean} a true when we need to handle `__filename` and `__dirname`, otherwise false
				 * @param {boolean} b true when we need to handle `import.meta.filename` and `import.meta.dirname`, otherwise false
				 */
				const handler = (parser, parserOptions, a, b) => {
					if (b && parserOptions.node === false) {
						// Keep `import.meta.dirname` and `import.meta.filename` in code
						setModuleConstant(
							parser,
							"import.meta.dirname",
							() => "import.meta.dirname",
							"dirname"
						);
						setModuleConstant(
							parser,
							"import.meta.filename",
							() => "import.meta.filename",
							"filename"
						);
						return;
					}

					let localOptions = options;

					if (parserOptions.node) {
						localOptions = { ...localOptions, ...parserOptions.node };
					}

					if (localOptions.global !== false) {
						globalHandler(parser, localOptions);
					}

					if (a) {
						dirnameAndFilenameHandler(parser, localOptions, {
							dirname: "__dirname",
							filename: "__filename"
						});
					}

					if (b && parserOptions.importMeta !== false) {
						dirnameAndFilenameHandler(parser, localOptions, {
							dirname: "import.meta.dirname",
							filename: "import.meta.filename"
						});
					}
				};

				normalModuleFactory.hooks.parser
					.for(JAVASCRIPT_MODULE_TYPE_AUTO)
					.tap(PLUGIN_NAME, (parser, parserOptions) => {
						handler(parser, parserOptions, true, true);
					});
				normalModuleFactory.hooks.parser
					.for(JAVASCRIPT_MODULE_TYPE_DYNAMIC)
					.tap(PLUGIN_NAME, (parser, parserOptions) => {
						handler(parser, parserOptions, true, false);
					});
				normalModuleFactory.hooks.parser
					.for(JAVASCRIPT_MODULE_TYPE_ESM)
					.tap(PLUGIN_NAME, (parser, parserOptions) => {
						handler(parser, parserOptions, false, true);
					});
			}
		);
	}
}

module.exports = NodeStuffPlugin;
