/*
	MIT License http://www.opensource.org/licenses/mit-license.php
	Author Natsu @xiaoxiaojx
*/

"use strict";

const { aliasResolveHandler, compileAliasOptions } = require("./AliasUtils");
const { modulesResolveHandler } = require("./ModulesUtils");
const { readJson } = require("./util/fs");
const { PathType: _PathType, isSubPath, normalize } = require("./util/path");

/** @typedef {import("./Resolver")} Resolver */
/** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
/** @typedef {import("./AliasUtils").AliasOption} AliasOption */
/** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
/** @typedef {import("./Resolver").ResolveContext} ResolveContext */
/** @typedef {import("./Resolver").FileSystem} FileSystem */
/** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */
/** @typedef {import("./Resolver").TsconfigPathsMap} TsconfigPathsMap */
/** @typedef {import("./ResolverFactory").TsconfigOptions} TsconfigOptions */

/**
 * @typedef {object} TsconfigCompilerOptions
 * @property {string=} baseUrl Base URL for resolving paths
 * @property {{ [key: string]: string[] }=} paths TypeScript paths mapping
 */

/**
 * @typedef {object} TsconfigReference
 * @property {string} path Path to the referenced project
 */

/**
 * @typedef {object} Tsconfig
 * @property {TsconfigCompilerOptions=} compilerOptions Compiler options
 * @property {string | string[]=} extends Extended configuration paths
 * @property {TsconfigReference[]=} references Project references
 */

const DEFAULT_CONFIG_FILE = "tsconfig.json";

const READ_JSON_OPTIONS = { stripComments: true };

// Trailing `/*` or `\*` segment of a tsconfig `paths` mapping (e.g.
// `./src/*` → `./src`). Hoisted so we don't allocate a fresh regex per
// path entry on every tsconfig load — and so the same regex object can be
// reused for the matching `test` + `replace` pair below.
const WILDCARD_TAIL_RE = /[/\\]\*$/;

/**
 * @param {string} pattern Path pattern
 * @returns {number} Length of the prefix
 */
function getPrefixLength(pattern) {
	const prefixLength = pattern.indexOf("*");
	if (prefixLength === -1) {
		return pattern.length;
	}
	return prefixLength;
}

/**
 * Sort path patterns.
 * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked.
 * @param {string[]} arr Array of path patterns
 * @returns {string[]} Array of path patterns sorted by longest prefix
 */
function sortByLongestPrefix(arr) {
	return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a));
}

/**
 * Merge two tsconfig objects
 * @param {Tsconfig | null} base base config
 * @param {Tsconfig | null} config config to merge
 * @returns {Tsconfig} merged config
 */
function mergeTsconfigs(base, config) {
	base = base || {};
	config = config || {};

	return {
		...base,
		...config,
		compilerOptions: {
			.../** @type {TsconfigCompilerOptions} */ (base.compilerOptions),
			.../** @type {TsconfigCompilerOptions} */ (config.compilerOptions),
		},
	};
}

/**
 * Substitute ${configDir} template variable in path
 * @param {string} pathValue the path value
 * @param {string} configDir the config directory
 * @returns {string} the path with substituted template
 */
function substituteConfigDir(pathValue, configDir) {
	// eslint-disable-next-line no-template-curly-in-string
	if (!pathValue.includes("${configDir}")) return pathValue;
	return pathValue.replace(/\$\{configDir\}/g, configDir);
}

/**
 * Convert tsconfig paths to resolver options
 * @param {string} configDir Config file directory
 * @param {{ [key: string]: string[] }} paths TypeScript paths mapping
 * @param {Resolver} resolver resolver instance
 * @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
 * @returns {TsconfigPathsData} the resolver options
 */
function tsconfigPathsToResolveOptions(configDir, paths, resolver, baseUrl) {
	// Calculate absolute base URL
	const absoluteBaseUrl = !baseUrl
		? configDir
		: resolver.join(configDir, baseUrl);

	/** @type {string[]} */
	const sortedKeys = sortByLongestPrefix(Object.keys(paths));
	/** @type {AliasOption[]} */
	const alias = [];
	/** @type {string[]} */
	const modules = [];

	for (const pattern of sortedKeys) {
		const mappings = paths[pattern];
		// Substitute ${configDir} in path mappings
		const absolutePaths = mappings.map((mapping) => {
			const substituted = substituteConfigDir(mapping, configDir);
			return resolver.join(absoluteBaseUrl, substituted);
		});

		if (absolutePaths.length > 0) {
			if (pattern === "*") {
				// Pull `dir/*` entries directly into `modules` with their
				// trailing wildcard stripped, skipping anything else. The
				// previous `.map(...).filter(Boolean)` form allocated two
				// throwaway arrays plus a spread iterator per `*` mapping.
				for (let j = 0; j < absolutePaths.length; j++) {
					const dir = absolutePaths[j];
					if (WILDCARD_TAIL_RE.test(dir)) {
						modules.push(dir.replace(WILDCARD_TAIL_RE, ""));
					}
				}
			} else {
				alias.push({ name: pattern, alias: absolutePaths });
			}
		}
	}

	if (absoluteBaseUrl && !modules.includes(absoluteBaseUrl)) {
		modules.push(absoluteBaseUrl);
	}

	return {
		alias: compileAliasOptions(resolver, alias),
		modules,
	};
}

/**
 * Get the base context for the current project
 * @param {string} context the context
 * @param {Resolver} resolver resolver instance
 * @param {string=} baseUrl base URL for resolving paths
 * @returns {string} the base context
 */
function getAbsoluteBaseUrl(context, resolver, baseUrl) {
	return !baseUrl ? context : resolver.join(context, baseUrl);
}

/**
 * @param {TsconfigPathsData} main main paths data
 * @param {string} mainContext main context
 * @param {{ [baseUrl: string]: TsconfigPathsData }} refs references map
 * @param {Set<string>} fileDependencies file dependencies
 * @returns {TsconfigPathsMap} the tsconfig paths map
 */
function buildTsconfigPathsMap(main, mainContext, refs, fileDependencies) {
	const allContexts = /** @type {{ [context: string]: TsconfigPathsData }} */ ({
		[mainContext]: main,
		...refs,
	});
	// Precompute the key list once per tsconfig load. `_selectPathsDataForContext`
	// runs per resolve and otherwise would call `Object.entries(allContexts)`
	// each time, allocating a fresh [key, value][] array.
	const contextList = Object.keys(allContexts);
	return {
		main,
		mainContext,
		refs,
		allContexts,
		contextList,
		fileDependencies,
	};
}

module.exports = class TsconfigPathsPlugin {
	/**
	 * @param {true | string | TsconfigOptions} configFileOrOptions tsconfig file path or options object
	 */
	constructor(configFileOrOptions) {
		if (
			typeof configFileOrOptions === "object" &&
			configFileOrOptions !== null
		) {
			// Options object format
			const { configFile } = configFileOrOptions;
			/** @type {boolean} */
			this.isAutoConfigFile = typeof configFile !== "string";
			/** @type {string} */
			this.configFile = this.isAutoConfigFile
				? DEFAULT_CONFIG_FILE
				: /** @type {string} */ (configFile);
			/** @type {string[] | "auto"} */
			if (Array.isArray(configFileOrOptions.references)) {
				/** @type {TsconfigReference[] | "auto"} */
				this.references = configFileOrOptions.references.map((ref) => ({
					path: ref,
				}));
			} else if (configFileOrOptions.references === "auto") {
				this.references = "auto";
			} else {
				this.references = [];
			}
			/** @type {string | undefined} */
			this.baseUrl = configFileOrOptions.baseUrl;
		} else {
			/** @type {boolean} */
			this.isAutoConfigFile = configFileOrOptions === true;
			/** @type {string} */
			this.configFile = this.isAutoConfigFile
				? DEFAULT_CONFIG_FILE
				: /** @type {string} */ (configFileOrOptions);
			/** @type {TsconfigReference[] | "auto"} */
			this.references = [];
			/** @type {string | undefined} */
			this.baseUrl = undefined;
		}
	}

	/**
	 * @param {Resolver} resolver the resolver
	 * @returns {void}
	 */
	apply(resolver) {
		const aliasTarget = resolver.ensureHook("internal-resolve");
		const moduleTarget = resolver.ensureHook("module");

		resolver
			.getHook("raw-resolve")
			.tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
				this._getTsconfigPathsMap(
					resolver,
					request,
					resolveContext,
					(err, tsconfigPathsMap) => {
						if (err) return callback(err);
						if (!tsconfigPathsMap) return callback();

						const selectedData = this._selectPathsDataForContext(
							request.path,
							tsconfigPathsMap,
						);

						if (!selectedData) return callback();

						aliasResolveHandler(
							resolver,
							selectedData.alias,
							aliasTarget,
							request,
							resolveContext,
							(err, result) => {
								if (err) return callback(err);
								if (result) return callback(null, result);
								// https://github.com/webpack/webpack/issues/20944
								// Unlike resolve.alias, tsconfig paths should fall through
								// when a pattern matches but the mapped path does not exist
								// (matching TypeScript's native resolution behavior).
								return callback();
							},
						);
					},
				);
			});

		resolver
			.getHook("raw-module")
			.tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
				this._getTsconfigPathsMap(
					resolver,
					request,
					resolveContext,
					(err, tsconfigPathsMap) => {
						if (err) return callback(err);
						if (!tsconfigPathsMap) return callback();

						const selectedData = this._selectPathsDataForContext(
							request.path,
							tsconfigPathsMap,
						);

						if (!selectedData) return callback();

						modulesResolveHandler(
							resolver,
							selectedData.modules,
							moduleTarget,
							request,
							resolveContext,
							callback,
						);
					},
				);
			});
	}

	/**
	 * Get TsconfigPathsMap for the request (with caching)
	 * @param {Resolver} resolver the resolver
	 * @param {ResolveRequest} request the request
	 * @param {ResolveContext} resolveContext the resolve context
	 * @param {(err: Error | null, result?: TsconfigPathsMap | null) => void} callback the callback
	 * @returns {void}
	 */
	_getTsconfigPathsMap(resolver, request, resolveContext, callback) {
		if (typeof request.tsconfigPathsMap !== "undefined") {
			const cached = request.tsconfigPathsMap;
			if (!cached) return callback(null, null);
			if (resolveContext.fileDependencies) {
				for (const fileDependency of cached.fileDependencies) {
					resolveContext.fileDependencies.add(fileDependency);
				}
			}
			return callback(null, cached);
		}

		const absTsconfigPath = resolver.join(
			request.path || process.cwd(),
			this.configFile,
		);
		this._loadTsconfigPathsMap(resolver, absTsconfigPath, (err, result) => {
			if (err) {
				request.tsconfigPathsMap = null;
				if (
					this.isAutoConfigFile &&
					/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
				) {
					return callback(null, null);
				}
				return callback(err);
			}

			const map = /** @type {TsconfigPathsMap} */ (result);
			request.tsconfigPathsMap = map;
			if (resolveContext.fileDependencies) {
				for (const fileDependency of map.fileDependencies) {
					resolveContext.fileDependencies.add(fileDependency);
				}
			}
			callback(null, map);
		});
	}

	/**
	 * Load tsconfig.json and build complete TsconfigPathsMap
	 * Includes main project paths and all referenced projects
	 * @param {Resolver} resolver the resolver
	 * @param {string} absTsconfigPath absolute path to tsconfig.json
	 * @param {(err: Error | null, result?: TsconfigPathsMap) => void} callback the callback
	 * @returns {void}
	 */
	_loadTsconfigPathsMap(resolver, absTsconfigPath, callback) {
		/** @type {Set<string>} */
		const fileDependencies = new Set();

		this._loadTsconfig(
			resolver,
			absTsconfigPath,
			fileDependencies,
			undefined,
			(err, config) => {
				if (err) return callback(err);

				const cfg = /** @type {Tsconfig} */ (config);
				const compilerOptions = cfg.compilerOptions || {};
				const mainContext = resolver.dirname(absTsconfigPath);

				const baseUrl =
					this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;

				const main = tsconfigPathsToResolveOptions(
					mainContext,
					compilerOptions.paths || {},
					resolver,
					baseUrl,
				);
				/** @type {{ [baseUrl: string]: TsconfigPathsData }} */
				const refs = {};

				let referencesToUse = null;
				if (this.references === "auto") {
					referencesToUse = cfg.references;
				} else if (Array.isArray(this.references)) {
					referencesToUse = this.references;
				}

				if (!Array.isArray(referencesToUse)) {
					return callback(
						null,
						buildTsconfigPathsMap(main, mainContext, refs, fileDependencies),
					);
				}

				this._loadTsconfigReferences(
					resolver,
					mainContext,
					referencesToUse,
					fileDependencies,
					refs,
					(refErr) => {
						if (refErr) return callback(refErr);
						callback(
							null,
							buildTsconfigPathsMap(main, mainContext, refs, fileDependencies),
						);
					},
				);
			},
		);
	}

	/**
	 * Select the correct TsconfigPathsData based on request.path (context-aware)
	 * Matches the behavior of tsconfig-paths-webpack-plugin
	 * @param {string | false} requestPath the request path
	 * @param {TsconfigPathsMap} tsconfigPathsMap the tsconfig paths map
	 * @returns {TsconfigPathsData | null} the selected paths data
	 */
	_selectPathsDataForContext(requestPath, tsconfigPathsMap) {
		const { main, allContexts, contextList } = tsconfigPathsMap;
		if (!requestPath) {
			return main;
		}
		let longestMatchContext = null;
		let longestMatchLength = 0;
		// Iterate the pre-computed key list (the previous
		// `Object.entries(allContexts)` form allocated a fresh
		// `[key, value][]` per resolve). Defer the `allContexts[context]`
		// lookup to after we know the context actually matches — non-matches
		// are the common case and don't need the property access.
		for (let i = 0; i < contextList.length; i++) {
			const context = contextList[i];
			if (context === requestPath) {
				return allContexts[context];
			}
			// Cheap integer-compare gate first: a context can only beat the
			// current longest match if its own length is strictly greater.
			// Skipping `isSubPath` (a `startsWith` + char-code probe) when the
			// length already disqualifies the candidate avoids the per-resolve
			// scan over every shorter context.
			if (
				context.length > longestMatchLength &&
				isSubPath(context, requestPath)
			) {
				longestMatchContext = context;
				longestMatchLength = context.length;
			}
		}
		return longestMatchContext === null
			? null
			: allContexts[longestMatchContext];
	}

	/**
	 * Load tsconfig from extends path
	 * @param {Resolver} resolver the resolver
	 * @param {string} configFilePath current config file path
	 * @param {string} extendedConfigValue extends value
	 * @param {Set<string>} fileDependencies the file dependencies
	 * @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
	 * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
	 * @returns {void}
	 */
	_loadTsconfigFromExtends(
		resolver,
		configFilePath,
		extendedConfigValue,
		fileDependencies,
		visitedConfigPaths,
		callback,
	) {
		const { fileSystem } = resolver;
		const currentDir = resolver.dirname(configFilePath);

		// Substitute ${configDir} in extends path
		extendedConfigValue = substituteConfigDir(extendedConfigValue, currentDir);

		// Remember the original value before potentially appending .json
		const originalExtendedConfigValue = extendedConfigValue;

		if (
			typeof extendedConfigValue === "string" &&
			!extendedConfigValue.includes(".json")
		) {
			extendedConfigValue += ".json";
		}

		const initialExtendedConfigPath = resolver.join(
			currentDir,
			extendedConfigValue,
		);

		fileSystem.stat(initialExtendedConfigPath, (existsErr) => {
			let extendedConfigPath = initialExtendedConfigPath;
			if (existsErr) {
				// Handle scoped package extends like "@scope/name" (no sub-path):
				// "@scope/name" should resolve to node_modules/@scope/name/tsconfig.json,
				// not node_modules/@scope/name.json
				// See: test/fixtures/tsconfig-paths/extends-pkg-entry/
				if (
					typeof originalExtendedConfigValue === "string" &&
					originalExtendedConfigValue.startsWith("@") &&
					originalExtendedConfigValue.split("/").length === 2
				) {
					extendedConfigPath = resolver.join(
						currentDir,
						normalize(
							`node_modules/${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`,
						),
					);
				} else if (extendedConfigValue.includes("/")) {
					// Handle package sub-path extends like "react/tsconfig":
					// "react/tsconfig" resolves to node_modules/react/tsconfig.json
					// See: test/fixtures/tsconfig-paths/extends-npm/
					extendedConfigPath = resolver.join(
						currentDir,
						normalize(`node_modules/${extendedConfigValue}`),
					);
				} else if (
					!originalExtendedConfigValue.startsWith(".") &&
					!originalExtendedConfigValue.startsWith("/")
				) {
					// Handle unscoped package extends like "my-base-config" (no sub-path):
					// "my-base-config" should resolve to node_modules/my-base-config/tsconfig.json
					extendedConfigPath = resolver.join(
						currentDir,
						normalize(
							`node_modules/${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`,
						),
					);
				}
			}

			this._loadTsconfig(
				resolver,
				extendedConfigPath,
				fileDependencies,
				visitedConfigPaths,
				(err, config) => {
					if (err) return callback(err);

					const cfg = /** @type {Tsconfig} */ (config);
					const compilerOptions = cfg.compilerOptions || {
						baseUrl: undefined,
					};

					if (compilerOptions.baseUrl) {
						const extendedConfigDir = resolver.dirname(extendedConfigPath);
						compilerOptions.baseUrl = getAbsoluteBaseUrl(
							extendedConfigDir,
							resolver,
							compilerOptions.baseUrl,
						);
					}

					delete cfg.references;

					callback(null, cfg);
				},
			);
		});
	}

	/**
	 * Load referenced tsconfig projects and store in referenceMatchMap
	 * Simple implementation matching tsconfig-paths-webpack-plugin:
	 * Just load each reference and store independently
	 * @param {Resolver} resolver the resolver
	 * @param {string} context the context
	 * @param {TsconfigReference[]} references array of references
	 * @param {Set<string>} fileDependencies the file dependencies
	 * @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
	 * @param {(err: Error | null) => void} callback callback
	 * @param {Set<string>=} visitedRefPaths visited reference config paths (for circular reference detection)
	 * @returns {void}
	 */
	_loadTsconfigReferences(
		resolver,
		context,
		references,
		fileDependencies,
		referenceMatchMap,
		callback,
		visitedRefPaths,
	) {
		if (references.length === 0) return callback(null);

		const visited = visitedRefPaths || new Set();
		let pending = references.length;
		const finishOne = () => {
			if (--pending === 0) callback(null);
		};

		for (const ref of references) {
			const refPath = substituteConfigDir(ref.path, context);
			const refConfigPath = resolver.join(
				resolver.join(context, refPath),
				DEFAULT_CONFIG_FILE,
			);

			if (visited.has(refConfigPath)) {
				finishOne();
				continue;
			}
			visited.add(refConfigPath);

			this._loadTsconfig(
				resolver,
				refConfigPath,
				fileDependencies,
				undefined,
				(err, refConfig) => {
					// Failures are swallowed to match tsconfig-paths-webpack-plugin:
					// a broken reference must not abort the main project's resolution.
					if (err) return finishOne();

					const cfg = /** @type {Tsconfig} */ (refConfig);
					if (cfg.compilerOptions && cfg.compilerOptions.paths) {
						const refContext = resolver.dirname(refConfigPath);

						referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
							refContext,
							cfg.compilerOptions.paths || {},
							resolver,
							cfg.compilerOptions.baseUrl,
						);
					}

					if (this.references === "auto" && Array.isArray(cfg.references)) {
						this._loadTsconfigReferences(
							resolver,
							resolver.dirname(refConfigPath),
							cfg.references,
							fileDependencies,
							referenceMatchMap,
							finishOne,
							visited,
						);
					} else {
						finishOne();
					}
				},
			);
		}
	}

	/**
	 * Load tsconfig.json with extends support
	 * @param {Resolver} resolver the resolver
	 * @param {string} configFilePath absolute path to tsconfig.json
	 * @param {Set<string>} fileDependencies the file dependencies
	 * @param {Set<string> | undefined} visitedConfigPaths config paths being loaded (for circular extends detection)
	 * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
	 * @returns {void}
	 */
	_loadTsconfig(
		resolver,
		configFilePath,
		fileDependencies,
		visitedConfigPaths,
		callback,
	) {
		const visited = visitedConfigPaths || new Set();

		if (visited.has(configFilePath)) {
			return callback(null, /** @type {Tsconfig} */ ({}));
		}
		visited.add(configFilePath);

		readJson(
			resolver.fileSystem,
			configFilePath,
			READ_JSON_OPTIONS,
			(err, parsed) => {
				if (err) return callback(/** @type {Error} */ (err));

				const config = /** @type {Tsconfig} */ (parsed);
				fileDependencies.add(configFilePath);

				const extendedConfig = config.extends;
				if (!extendedConfig) return callback(null, config);

				if (!Array.isArray(extendedConfig)) {
					this._loadTsconfigFromExtends(
						resolver,
						configFilePath,
						extendedConfig,
						fileDependencies,
						visited,
						(extErr, extendedTsconfig) => {
							if (extErr) return callback(extErr);
							callback(
								null,
								mergeTsconfigs(
									/** @type {Tsconfig} */ (extendedTsconfig),
									config,
								),
							);
						},
					);
					return;
				}

				/** @type {Tsconfig} */
				let base = {};
				let i = 0;
				const next = () => {
					if (i >= extendedConfig.length) {
						return callback(null, mergeTsconfigs(base, config));
					}
					this._loadTsconfigFromExtends(
						resolver,
						configFilePath,
						extendedConfig[i++],
						fileDependencies,
						visited,
						(extErr, extendedTsconfig) => {
							if (extErr) return callback(extErr);
							base = mergeTsconfigs(
								base,
								/** @type {Tsconfig} */ (extendedTsconfig),
							);
							next();
						},
					);
				};
				next();
			},
		);
	}
};
