/**
 * @fileoverview Utility to load config files
 * @author Nicholas C. Zakas
 */

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const path = require("node:path");
const fs = require("node:fs/promises");
const findUp = require("find-up");
const { pathToFileURL } = require("node:url");
const debug = require("debug")("eslint:config-loader");
const { FlatConfigArray } = require("./flat-config-array");
const { WarningService } = require("../services/warning-service");

//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------

/** @typedef {import("../types").Linter.Config} Config */

/**
 * @typedef {Object} ConfigLoaderOptions
 * @property {string|false|undefined} configFile The path to the config file to use.
 * @property {string} cwd The current working directory.
 * @property {boolean} ignoreEnabled Indicates if ignore patterns should be honored.
 * @property {Config|Array<Config>} [baseConfig] The base config to use.
 * @property {Array<Config>} [defaultConfigs] The default configs to use.
 * @property {Array<string>} [ignorePatterns] The ignore patterns to use.
 * @property {Config|Array<Config>} [overrideConfig] The override config to use.
 * @property {boolean} [hasUnstableNativeNodeJsTSConfigFlag] The flag to indicate whether the `unstable_native_nodejs_ts_config` flag is enabled.
 * @property {WarningService} [warningService] The warning service to use.
 */

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

const FLAT_CONFIG_FILENAMES = [
	"eslint.config.js",
	"eslint.config.mjs",
	"eslint.config.cjs",
	"eslint.config.ts",
	"eslint.config.mts",
	"eslint.config.cts",
];

const importedConfigFileModificationTime = new Map();

/**
 * Asserts that the given file path is valid.
 * @param {string} filePath The file path to check.
 * @returns {void}
 * @throws {Error} If `filePath` is not a non-empty string.
 */
function assertValidFilePath(filePath) {
	if (!filePath || typeof filePath !== "string") {
		throw new Error("'filePath' must be a non-empty string");
	}
}

/**
 * Asserts that a configuration exists. A configuration exists if any
 * of the following are true:
 * - `configFilePath` is defined.
 * - `useConfigFile` is `false`.
 * @param {string|undefined} configFilePath The path to the config file.
 * @param {ConfigLoaderOptions} loaderOptions The options to use when loading configuration files.
 * @returns {void}
 * @throws {Error} If no configuration exists.
 */
function assertConfigurationExists(configFilePath, loaderOptions) {
	const { configFile: useConfigFile } = loaderOptions;

	if (!configFilePath && useConfigFile !== false) {
		const error = new Error("Could not find config file.");

		error.messageTemplate = "config-file-missing";
		throw error;
	}
}

/**
 * Check if the file is a TypeScript file.
 * @param {string} filePath The file path to check.
 * @returns {boolean} `true` if the file is a TypeScript file, `false` if it's not.
 */
function isFileTS(filePath) {
	const fileExtension = path.extname(filePath);

	return /^\.[mc]?ts$/u.test(fileExtension);
}

/**
 * Check if ESLint is running in Bun.
 * @returns {boolean} `true` if the ESLint is running Bun, `false` if it's not.
 */
function isRunningInBun() {
	return !!globalThis.Bun;
}

/**
 * Check if ESLint is running in Deno.
 * @returns {boolean} `true` if the ESLint is running in Deno, `false` if it's not.
 */
function isRunningInDeno() {
	return !!globalThis.Deno;
}

/**
 * Checks if native TypeScript support is
 * enabled in the current Node.js process.
 *
 * This function determines if the
 * {@linkcode NodeJS.ProcessFeatures.typescript | typescript}
 * feature is present in the
 * {@linkcode process.features} object
 * and if its value is either "strip" or "transform".
 * @returns {boolean} `true` if native TypeScript support is enabled, otherwise `false`.
 * @since 9.24.0
 */
function isNativeTypeScriptSupportEnabled() {
	return (
		// eslint-disable-next-line n/no-unsupported-features/node-builtins -- it's still an experimental feature.
		["strip", "transform"].includes(process.features.typescript)
	);
}

/**
 * Load the TypeScript configuration file.
 * @param {string} filePath The absolute file path to load.
 * @param {URL} fileURL The file URL to load.
 * @param {number} mtime The last modified timestamp of the file.
 * @returns {Promise<any>} The configuration loaded from the file.
 * @since 9.24.0
 */
async function loadTypeScriptConfigFileWithJiti(filePath, fileURL, mtime) {
	const { createJiti, version: jitiVersion } =
		// eslint-disable-next-line no-use-before-define -- `ConfigLoader.loadJiti` can be overwritten for testing
		await ConfigLoader.loadJiti().catch(() => {
			throw new Error(
				"The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it.",
			);
		});

	// Ensure the version is at least 2.2.0.
	const [jitiMajor, jitiMinor] = jitiVersion
		.split(".")
		.map(versionPart => Number(versionPart));
	if (jitiMajor < 2 || (jitiMajor === 2 && jitiMinor < 2)) {
		throw new Error(
			"You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features.",
		);
	}

	/*
	 * Disabling `moduleCache` allows us to reload a
	 * config file when the last modified timestamp changes.
	 */
	const jitiOptions = {
		moduleCache: false,
	};

	const jiti = createJiti(__filename, jitiOptions);
	const config = await jiti.import(fileURL.href);

	importedConfigFileModificationTime.set(filePath, mtime);

	return config?.default ?? config;
}

/**
 * Dynamically imports a module from the given file path.
 * @param {string} filePath The absolute file path of the module to import.
 * @param {URL} fileURL The file URL to load.
 * @param {number} mtime The last modified timestamp of the file.
 * @returns {Promise<any>} - A {@linkcode Promise | promise} that resolves to the imported ESLint config.
 * @since 9.24.0
 */
async function dynamicImportConfig(filePath, fileURL, mtime) {
	const module = await import(fileURL.href);

	importedConfigFileModificationTime.set(filePath, mtime);

	return module.default;
}

/**
 * Load the config array from the given filename.
 * @param {string} filePath The filename to load from.
 * @param {boolean} hasUnstableNativeNodeJsTSConfigFlag The flag to indicate whether the `unstable_native_nodejs_ts_config` flag is enabled.
 * @returns {Promise<any>} The config loaded from the config file.
 */
async function loadConfigFile(filePath, hasUnstableNativeNodeJsTSConfigFlag) {
	debug(`Loading config from ${filePath}`);

	const fileURL = pathToFileURL(filePath);

	debug(`Config file URL is ${fileURL}`);

	const mtime = (await fs.stat(filePath)).mtime.getTime();

	/*
	 * Append a query with the config file's modification time (`mtime`) in order
	 * to import the current version of the config file. Without the query, `import()` would
	 * cache the config file module by the pathname only, and then always return
	 * the same version (the one that was actual when the module was imported for the first time).
	 *
	 * This ensures that the config file module is loaded and executed again
	 * if it has been changed since the last time it was imported.
	 * If it hasn't been changed, `import()` will just return the cached version.
	 *
	 * Note that we should not overuse queries (e.g., by appending the current time
	 * to always reload the config file module) as that could cause memory leaks
	 * because entries are never removed from the import cache.
	 */
	fileURL.searchParams.append("mtime", mtime);

	/*
	 * With queries, we can bypass the import cache. However, when import-ing a CJS module,
	 * Node.js uses the require infrastructure under the hood. That includes the require cache,
	 * which caches the config file module by its file path (queries have no effect).
	 * Therefore, we also need to clear the require cache before importing the config file module.
	 * In order to get the same behavior with ESM and CJS config files, in particular - to reload
	 * the config file only if it has been changed, we track file modification times and clear
	 * the require cache only if the file has been changed.
	 */
	if (importedConfigFileModificationTime.get(filePath) !== mtime) {
		delete require.cache[filePath];
	}

	const isTS = isFileTS(filePath);
	const isBun = isRunningInBun();
	const isDeno = isRunningInDeno();

	/*
	 * If we are dealing with a TypeScript file, then we need to use `jiti` to load it
	 * in Node.js. Deno and Bun both allow native importing of TypeScript files.
	 *
	 * When Node.js supports native TypeScript imports, we can remove this check.
	 */

	if (isTS) {
		if (hasUnstableNativeNodeJsTSConfigFlag) {
			if (isNativeTypeScriptSupportEnabled()) {
				return await dynamicImportConfig(filePath, fileURL, mtime);
			}

			if (!("typescript" in process.features)) {
				throw new Error(
					"The unstable_native_nodejs_ts_config flag is not supported in older versions of Node.js.",
				);
			}

			throw new Error(
				"The unstable_native_nodejs_ts_config flag is enabled, but native TypeScript support is not enabled in the current Node.js process. You need to either enable native TypeScript support by passing --experimental-strip-types or remove the unstable_native_nodejs_ts_config flag.",
			);
		}

		if (!isDeno && !isBun) {
			return await loadTypeScriptConfigFileWithJiti(
				filePath,
				fileURL,
				mtime,
			);
		}
	}

	// fallback to normal runtime behavior

	return await dynamicImportConfig(filePath, fileURL, mtime);
}

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------

/**
 * Encapsulates the loading and caching of configuration files when looking up
 * from the file being linted.
 */
class ConfigLoader {
	/**
	 * Map of config file paths to the config arrays for those directories.
	 * @type {Map<string, FlatConfigArray|Promise<FlatConfigArray>>}
	 */
	#configArrays = new Map();

	/**
	 * Map of absolute directory names to the config file paths for those directories.
	 * @type {Map<string, {configFilePath:string,basePath:string}|Promise<{configFilePath:string,basePath:string}>>}
	 */
	#configFilePaths = new Map();

	/**
	 * The options to use when loading configuration files.
	 * @type {ConfigLoaderOptions}
	 */
	#options;

	/**
	 * Creates a new instance.
	 * @param {ConfigLoaderOptions} options The options to use when loading configuration files.
	 */
	constructor(options) {
		this.#options = options.warningService
			? options
			: { ...options, warningService: new WarningService() };
	}

	/**
	 * Determines which config file to use. This is determined by seeing if an
	 * override config file was specified, and if so, using it; otherwise, as long
	 * as override config file is not explicitly set to `false`, it will search
	 * upwards from `fromDirectory` for a file named `eslint.config.js`.
	 * @param {string} fromDirectory The directory from which to start searching.
	 * @returns {Promise<{configFilePath:string|undefined,basePath:string}>} Location information for
	 *      the config file.
	 */
	async #locateConfigFileToUse(fromDirectory) {
		// check cache first
		if (this.#configFilePaths.has(fromDirectory)) {
			return this.#configFilePaths.get(fromDirectory);
		}

		const resultPromise = ConfigLoader.locateConfigFileToUse({
			useConfigFile: this.#options.configFile,
			cwd: this.#options.cwd,
			fromDirectory,
		});

		// ensure `ConfigLoader.locateConfigFileToUse` is called only once for `fromDirectory`
		this.#configFilePaths.set(fromDirectory, resultPromise);

		// Unwrap the promise. This is primarily for the sync `getCachedConfigArrayForPath` method.
		const result = await resultPromise;

		this.#configFilePaths.set(fromDirectory, result);

		return result;
	}

	/**
	 * Calculates the config array for this run based on inputs.
	 * @param {string} configFilePath The absolute path to the config file to use if not overridden.
	 * @param {string} basePath The base path to use for relative paths in the config file.
	 * @returns {Promise<FlatConfigArray>} The config array for `eslint`.
	 */
	async #calculateConfigArray(configFilePath, basePath) {
		// check for cached version first
		if (this.#configArrays.has(configFilePath)) {
			return this.#configArrays.get(configFilePath);
		}

		const configsPromise = ConfigLoader.calculateConfigArray(
			configFilePath,
			basePath,
			this.#options,
		);

		// ensure `ConfigLoader.calculateConfigArray` is called only once for `configFilePath`
		this.#configArrays.set(configFilePath, configsPromise);

		// Unwrap the promise. This is primarily for the sync `getCachedConfigArrayForPath` method.
		const configs = await configsPromise;

		this.#configArrays.set(configFilePath, configs);

		return configs;
	}

	/**
	 * Returns the config file path for the given directory or file. This will either use
	 * the override config file that was specified in the constructor options or
	 * search for a config file from the directory.
	 * @param {string} fileOrDirPath The file or directory path to get the config file path for.
	 * @returns {Promise<string|undefined>} The config file path or `undefined` if not found.
	 * @throws {Error} If `fileOrDirPath` is not a non-empty string.
	 * @throws {Error} If `fileOrDirPath` is not an absolute path.
	 */
	async findConfigFileForPath(fileOrDirPath) {
		assertValidFilePath(fileOrDirPath);

		const absoluteDirPath = path.resolve(
			this.#options.cwd,
			path.dirname(fileOrDirPath),
		);
		const { configFilePath } =
			await this.#locateConfigFileToUse(absoluteDirPath);

		return configFilePath;
	}

	/**
	 * Returns a configuration object for the given file based on the CLI options.
	 * This is the same logic used by the ESLint CLI executable to determine
	 * configuration for each file it processes.
	 * @param {string} filePath The path of the file or directory to retrieve config for.
	 * @returns {Promise<FlatConfigArray>} A configuration object for the file.
	 * @throws {Error} If no configuration for `filePath` exists.
	 */
	async loadConfigArrayForFile(filePath) {
		assertValidFilePath(filePath);

		debug(`Calculating config for file ${filePath}`);

		const configFilePath = await this.findConfigFileForPath(filePath);

		assertConfigurationExists(configFilePath, this.#options);

		return this.loadConfigArrayForDirectory(filePath);
	}

	/**
	 * Returns a configuration object for the given directory based on the CLI options.
	 * This is the same logic used by the ESLint CLI executable to determine
	 * configuration for each file it processes.
	 * @param {string} dirPath The path of the directory to retrieve config for.
	 * @returns {Promise<FlatConfigArray>} A configuration object for the directory.
	 */
	async loadConfigArrayForDirectory(dirPath) {
		assertValidFilePath(dirPath);

		debug(`Calculating config for directory ${dirPath}`);

		const absoluteDirPath = path.resolve(
			this.#options.cwd,
			path.dirname(dirPath),
		);
		const { configFilePath, basePath } =
			await this.#locateConfigFileToUse(absoluteDirPath);

		debug(`Using config file ${configFilePath} and base path ${basePath}`);
		return this.#calculateConfigArray(configFilePath, basePath);
	}

	/**
	 * Returns a configuration array for the given file based on the CLI options.
	 * This is a synchronous operation and does not read any files from disk. It's
	 * intended to be used in locations where we know the config file has already
	 * been loaded and we just need to get the configuration for a file.
	 * @param {string} filePath The path of the file to retrieve a config object for.
	 * @returns {FlatConfigArray} A configuration object for the file.
	 * @throws {Error} If `filePath` is not a non-empty string.
	 * @throws {Error} If `filePath` is not an absolute path.
	 * @throws {Error} If the config file was not already loaded.
	 */
	getCachedConfigArrayForFile(filePath) {
		assertValidFilePath(filePath);

		debug(`Looking up cached config for ${filePath}`);

		return this.getCachedConfigArrayForPath(path.dirname(filePath));
	}

	/**
	 * Returns a configuration array for the given directory based on the CLI options.
	 * This is a synchronous operation and does not read any files from disk. It's
	 * intended to be used in locations where we know the config file has already
	 * been loaded and we just need to get the configuration for a file.
	 * @param {string} fileOrDirPath The path of the directory to retrieve a config object for.
	 * @returns {FlatConfigArray} A configuration object for the directory.
	 * @throws {Error} If `dirPath` is not a non-empty string.
	 * @throws {Error} If `dirPath` is not an absolute path.
	 * @throws {Error} If the config file was not already loaded.
	 */
	getCachedConfigArrayForPath(fileOrDirPath) {
		assertValidFilePath(fileOrDirPath);

		debug(`Looking up cached config for ${fileOrDirPath}`);

		const absoluteDirPath = path.resolve(this.#options.cwd, fileOrDirPath);

		if (!this.#configFilePaths.has(absoluteDirPath)) {
			throw new Error(`Could not find config file for ${fileOrDirPath}`);
		}

		const configFilePathInfo = this.#configFilePaths.get(absoluteDirPath);

		if (typeof configFilePathInfo.then === "function") {
			throw new Error(
				`Config file path for ${fileOrDirPath} has not yet been calculated or an error occurred during the calculation`,
			);
		}

		const { configFilePath } = configFilePathInfo;

		const configArray = this.#configArrays.get(configFilePath);

		if (!configArray || typeof configArray.then === "function") {
			throw new Error(
				`Config array for ${fileOrDirPath} has not yet been calculated or an error occurred during the calculation`,
			);
		}

		return configArray;
	}

	/**
	 * Used to import the jiti dependency. This method is exposed internally for testing purposes.
	 * @returns {Promise<{createJiti: Function|undefined, version: string;}>} A promise that fulfills with an object containing the jiti module's createJiti function and version.
	 */
	static async loadJiti() {
		const { createJiti } = await import("jiti");
		const version = require("jiti/package.json").version;
		return { createJiti, version };
	}

	/**
	 * Determines which config file to use. This is determined by seeing if an
	 * override config file was specified, and if so, using it; otherwise, as long
	 * as override config file is not explicitly set to `false`, it will search
	 * upwards from `fromDirectory` for a file named `eslint.config.js`.
	 * This method is exposed internally for testing purposes.
	 * @param {Object} [options] the options object
	 * @param {string|false|undefined} options.useConfigFile The path to the config file to use.
	 * @param {string} options.cwd Path to a directory that should be considered as the current working directory.
	 * @param {string} [options.fromDirectory] The directory from which to start searching. Defaults to `cwd`.
	 * @returns {Promise<{configFilePath:string|undefined,basePath:string}>} Location information for
	 *      the config file.
	 */
	static async locateConfigFileToUse({
		useConfigFile,
		cwd,
		fromDirectory = cwd,
	}) {
		// determine where to load config file from
		let configFilePath;
		let basePath = cwd;

		if (typeof useConfigFile === "string") {
			debug(`Override config file path is ${useConfigFile}`);
			configFilePath = path.resolve(cwd, useConfigFile);
			basePath = cwd;
		} else if (useConfigFile !== false) {
			debug("Searching for eslint.config.js");
			configFilePath = await findUp(FLAT_CONFIG_FILENAMES, {
				cwd: fromDirectory,
			});

			if (configFilePath) {
				basePath = path.dirname(configFilePath);
			}
		}

		return {
			configFilePath,
			basePath,
		};
	}

	/**
	 * Calculates the config array for this run based on inputs.
	 * This method is exposed internally for testing purposes.
	 * @param {string} configFilePath The absolute path to the config file to use if not overridden.
	 * @param {string} basePath The base path to use for relative paths in the config file.
	 * @param {ConfigLoaderOptions} options The options to use when loading configuration files.
	 * @returns {Promise<FlatConfigArray>} The config array for `eslint`.
	 */
	static async calculateConfigArray(configFilePath, basePath, options) {
		const {
			cwd,
			baseConfig,
			ignoreEnabled,
			ignorePatterns,
			overrideConfig,
			hasUnstableNativeNodeJsTSConfigFlag = false,
			defaultConfigs = [],
			warningService,
		} = options;

		debug(
			`Calculating config array from config file ${configFilePath} and base path ${basePath}`,
		);

		const configs = new FlatConfigArray(baseConfig || [], {
			basePath,
			shouldIgnore: ignoreEnabled,
		});

		// load config file
		if (configFilePath) {
			debug(`Loading config file ${configFilePath}`);
			const fileConfig = await loadConfigFile(
				configFilePath,
				hasUnstableNativeNodeJsTSConfigFlag,
			);

			/*
			 * It's possible that a config file could be empty or else
			 * have an empty object or array. In this case, we want to
			 * warn the user that they have an empty config.
			 *
			 * An empty CommonJS file exports an empty object while
			 * an empty ESM file exports undefined.
			 */

			let emptyConfig = typeof fileConfig === "undefined";

			debug(
				`Config file ${configFilePath} is ${emptyConfig ? "empty" : "not empty"}`,
			);

			if (!emptyConfig) {
				if (Array.isArray(fileConfig)) {
					if (fileConfig.length === 0) {
						debug(
							`Config file ${configFilePath} is an empty array`,
						);
						emptyConfig = true;
					} else {
						configs.push(...fileConfig);
					}
				} else {
					if (
						typeof fileConfig === "object" &&
						fileConfig !== null &&
						Object.keys(fileConfig).length === 0
					) {
						debug(
							`Config file ${configFilePath} is an empty object`,
						);
						emptyConfig = true;
					} else {
						configs.push(fileConfig);
					}
				}
			}

			if (emptyConfig) {
				warningService.emitEmptyConfigWarning(configFilePath);
			}
		}

		// add in any configured defaults
		configs.push(...defaultConfigs);

		// append command line ignore patterns
		if (ignorePatterns && ignorePatterns.length > 0) {
			/*
			 * Ignore patterns are added to the end of the config array
			 * so they can override default ignores.
			 */
			configs.push({
				basePath: cwd,
				ignores: ignorePatterns,
			});
		}

		if (overrideConfig) {
			if (Array.isArray(overrideConfig)) {
				configs.push(...overrideConfig);
			} else {
				configs.push(overrideConfig);
			}
		}

		await configs.normalize();

		return configs;
	}
}

module.exports = { ConfigLoader };
