'use strict';

var fs = require('node:fs');
var path = require('node:path');

/**
 * @fileoverview defineConfig helper
 * @author Nicholas C. Zakas
 */

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @import * as $eslintcore from "@eslint/core"; */
/** @typedef {$eslintcore.ConfigObject} ConfigObject */
/** @typedef {$eslintcore.LegacyConfigObject} LegacyConfig */
/** @typedef {$eslintcore.Plugin} Plugin */
/** @typedef {$eslintcore.RuleConfig} RuleConfig */
/** @import * as $typests from "./types.ts"; */
/** @typedef {$typests.Config} Config */
/** @typedef {$typests.ExtendsElement} ExtendsElement */
/** @typedef {$typests.ExtensionConfigObject} ExtensionConfigObject */
/** @typedef {$typests.SimpleExtendsElement} SimpleExtendsElement */
/** @typedef {$typests.ConfigWithExtends} ConfigWithExtends */
/** @typedef {$typests.InfiniteArray<ConfigObject>} InfiniteConfigArray */
/** @typedef {$typests.ConfigWithExtendsArray} ConfigWithExtendsArray */

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

const eslintrcKeys = [
	"env",
	"extends",
	"globals",
	"ignorePatterns",
	"noInlineConfig",
	"overrides",
	"parser",
	"parserOptions",
	"reportUnusedDisableDirectives",
	"root",
];

const allowedGlobalIgnoreKeys = new Set(["basePath", "ignores", "name"]);

/**
 * Gets the name of a config object.
 * @param {ConfigObject} config The config object.
 * @param {string} indexPath The index path of the config object.
 * @return {string} The name of the config object.
 */
function getConfigName(config, indexPath) {
	if (config.name) {
		return config.name;
	}

	return `UserConfig${indexPath}`;
}

/**
 * Gets the name of an extension.
 * @param {SimpleExtendsElement} extension The extension.
 * @param {string} indexPath The index of the extension.
 * @return {string} The name of the extension.
 */
function getExtensionName(extension, indexPath) {
	if (typeof extension === "string") {
		return extension;
	}

	if (extension.name) {
		return extension.name;
	}

	return `ExtendedConfig${indexPath}`;
}

/**
 * Determines if a config object is a legacy config.
 * @param {ConfigObject|LegacyConfig} config The config object to check.
 * @return {config is LegacyConfig} `true` if the config object is a legacy config.
 */
function isLegacyConfig(config) {
	// eslintrc's plugins must be an array; while flat config's must be an object.
	if (Array.isArray(config.plugins)) {
		return true;
	}

	for (const key of eslintrcKeys) {
		if (key in config) {
			return true;
		}
	}

	return false;
}

/**
 * Determines if a config object is a global ignores config.
 * @param {ConfigObject} config The config object to check.
 * @return {boolean} `true` if the config object is a global ignores config.
 */
function isGlobalIgnores(config) {
	return Object.keys(config).every(key => allowedGlobalIgnoreKeys.has(key));
}

/**
 * Parses a plugin member ID (rule, processor, etc.) and returns
 * the namespace and member name.
 * @param {string} id The ID to parse.
 * @returns {{namespace:string, name:string}} The namespace and member name.
 */
function getPluginMember(id) {
	const firstSlashIndex = id.indexOf("/");

	if (firstSlashIndex === -1) {
		return { namespace: "", name: id };
	}

	let namespace = id.slice(0, firstSlashIndex);

	/*
	 * Special cases:
	 * 1. The namespace is `@`, that means it's referring to the
	 *    core plugin so `@` is the full namespace.
	 * 2. The namespace starts with `@`, that means it's referring to
	 *    an npm scoped package. That means the namespace is the scope
	 *    and the package name (i.e., `@eslint/core`).
	 */
	if (namespace[0] === "@" && namespace !== "@") {
		const secondSlashIndex = id.indexOf("/", firstSlashIndex + 1);
		if (secondSlashIndex !== -1) {
			namespace = id.slice(0, secondSlashIndex);
			return { namespace, name: id.slice(secondSlashIndex + 1) };
		}
	}

	const name = id.slice(firstSlashIndex + 1);

	return { namespace, name };
}

/**
 * Normalizes the plugin config by replacing the namespace with the plugin namespace.
 * @param {string} userNamespace The namespace of the plugin.
 * @param {Plugin} plugin The plugin config object.
 * @param {ConfigObject} config The config object to normalize.
 * @return {ConfigObject} The normalized config object.
 */
function normalizePluginConfig(userNamespace, plugin, config) {
	const pluginNamespace = plugin.meta?.namespace;

	// don't do anything if the plugin doesn't have a namespace or rules
	if (
		!pluginNamespace ||
		pluginNamespace === userNamespace ||
		(!config.rules && !config.processor && !config.language)
	) {
		return config;
	}

	const result = { ...config };

	// update the rules
	if (result.rules) {
		const ruleIds = Object.keys(result.rules);

		/** @type {Record<string,RuleConfig|undefined>} */
		const newRules = {};

		for (let i = 0; i < ruleIds.length; i++) {
			const ruleId = ruleIds[i];
			const { namespace: ruleNamespace, name: ruleName } =
				getPluginMember(ruleId);

			if (ruleNamespace === pluginNamespace) {
				newRules[`${userNamespace}/${ruleName}`] = result.rules[ruleId];
			} else {
				newRules[ruleId] = result.rules[ruleId];
			}
		}

		result.rules = newRules;
	}

	// update the processor

	if (typeof result.processor === "string") {
		const { namespace: processorNamespace, name: processorName } =
			getPluginMember(result.processor);

		if (processorNamespace) {
			if (processorNamespace === pluginNamespace) {
				result.processor = `${userNamespace}/${processorName}`;
			}
		}
	}

	// update the language
	if (typeof result.language === "string") {
		const { namespace: languageNamespace, name: languageName } =
			getPluginMember(result.language);

		if (languageNamespace === pluginNamespace) {
			result.language = `${userNamespace}/${languageName}`;
		}
	}

	return result;
}

/**
 * Deeply normalizes a plugin config, traversing recursively into an arrays.
 * @param {string} userPluginNamespace The namespace of the plugin.
 * @param {Plugin} plugin The plugin object.
 * @param {ConfigObject|LegacyConfig|(ConfigObject|LegacyConfig)[]} pluginConfig The plugin config to normalize.
 * @param {string} pluginConfigName The name of the plugin config.
 * @return {InfiniteConfigArray} The normalized plugin config.
 * @throws {TypeError} If the plugin config is a legacy config.
 */
function deepNormalizePluginConfig(
	userPluginNamespace,
	plugin,
	pluginConfig,
	pluginConfigName,
) {
	// if it's an array then it's definitely a new config
	if (Array.isArray(pluginConfig)) {
		return pluginConfig.map(pluginSubConfig =>
			deepNormalizePluginConfig(
				userPluginNamespace,
				plugin,
				pluginSubConfig,
				pluginConfigName,
			),
		);
	}

	// if it's a legacy config, throw an error
	if (isLegacyConfig(pluginConfig)) {
		throw new TypeError(
			`Plugin config "${pluginConfigName}" is an eslintrc config and cannot be used in this context.`,
		);
	}

	return normalizePluginConfig(userPluginNamespace, plugin, pluginConfig);
}

/**
 * Finds a plugin config by name in the given config.
 * @param {ConfigObject} config The config object.
 * @param {string} pluginConfigName The name of the plugin config.
 * @return {InfiniteConfigArray} The plugin config.
 * @throws {TypeError} If the plugin config is not found or is a legacy config.
 */
function findPluginConfig(config, pluginConfigName) {
	const { namespace: userPluginNamespace, name: configName } =
		getPluginMember(pluginConfigName);
	const plugin = config.plugins?.[userPluginNamespace];

	if (!plugin) {
		throw new TypeError(`Plugin "${userPluginNamespace}" not found.`);
	}

	const directConfig = plugin.configs?.[configName];

	// Prefer direct config, but fall back to flat config if available
	if (directConfig) {
		// Arrays are always flat configs, and non-legacy configs can be used directly
		if (Array.isArray(directConfig) || !isLegacyConfig(directConfig)) {
			return deepNormalizePluginConfig(
				userPluginNamespace,
				plugin,
				directConfig,
				pluginConfigName,
			);
		}
	}

	// If it's a legacy config, or the config does not exist => look for the flat version
	const flatConfig = plugin.configs?.[`flat/${configName}`];
	if (
		flatConfig &&
		(Array.isArray(flatConfig) || !isLegacyConfig(flatConfig))
	) {
		return deepNormalizePluginConfig(
			userPluginNamespace,
			plugin,
			flatConfig,
			pluginConfigName,
		);
	}

	// If we get here, then the config was either not found or is a legacy config
	const message =
		directConfig || flatConfig
			? `Plugin config "${configName}" in plugin "${userPluginNamespace}" is an eslintrc config and cannot be used in this context.`
			: `Plugin config "${configName}" not found in plugin "${userPluginNamespace}".`;
	throw new TypeError(message);
}

/**
 * Flattens an array while keeping track of the index path.
 * @param {any[]} configList The array to traverse.
 * @param {string} indexPath The index path of the value in a multidimensional array.
 * @return {IterableIterator<{indexPath:string, value:any}>} The flattened list of values.
 */
function* flatTraverse(configList, indexPath = "") {
	for (let i = 0; i < configList.length; i++) {
		const newIndexPath = indexPath ? `${indexPath}[${i}]` : `[${i}]`;

		// if it's an array then traverse it as well
		if (Array.isArray(configList[i])) {
			yield* flatTraverse(configList[i], newIndexPath);
			continue;
		}

		yield { indexPath: newIndexPath, value: configList[i] };
	}
}

/**
 * Extends a list of config files by creating every combination of base and extension files.
 * @param {(string|string[])[]} [baseFiles] The base files.
 * @param {(string|string[])[]} [extensionFiles] The extension files.
 * @return {(string|string[])[]} The extended files.
 */
function extendConfigFiles(baseFiles = [], extensionFiles = []) {
	if (!extensionFiles.length) {
		return baseFiles.concat();
	}

	if (!baseFiles.length) {
		return extensionFiles.concat();
	}

	/** @type {(string|string[])[]} */
	const result = [];

	for (const baseFile of baseFiles) {
		for (const extensionFile of extensionFiles) {
			/*
			 * Each entry can be a string or array of strings. The end result
			 * needs to be an array of strings, so we need to be sure to include
			 * all of the items when there's an array.
			 */

			const entry = [];

			if (Array.isArray(baseFile)) {
				entry.push(...baseFile);
			} else {
				entry.push(baseFile);
			}

			if (Array.isArray(extensionFile)) {
				entry.push(...extensionFile);
			} else {
				entry.push(extensionFile);
			}

			result.push(entry);
		}
	}

	return result;
}

/**
 * Extends a config object with another config object.
 * @param {ConfigObject} baseConfig The base config object.
 * @param {string} baseConfigName The name of the base config object.
 * @param {ConfigObject} extension The extension config object.
 * @param {string} extensionName The index of the extension config object.
 * @return {ConfigObject} The extended config object.
 */
function extendConfig(baseConfig, baseConfigName, extension, extensionName) {
	const result = { ...extension };

	// for global ignores there is no further work to be done, we just keep everything
	if (!isGlobalIgnores(extension)) {
		// for files we need to create every combination of base and extension files
		if (baseConfig.files) {
			result.files = extendConfigFiles(baseConfig.files, extension.files);
		}

		// for ignores we just concatenation the extension ignores onto the base ignores
		if (baseConfig.ignores) {
			result.ignores = baseConfig.ignores.concat(extension.ignores ?? []);
		}
	}

	result.name = `${baseConfigName} > ${extensionName}`;

	if (baseConfig.basePath) {
		result.basePath = baseConfig.basePath;
	}

	return result;
}

/**
 * Processes a list of extends elements.
 * @param {ConfigWithExtends} config The config object.
 * @param {WeakMap<ConfigObject, string>} configNames The map of config objects to their names.
 * @return {ConfigObject[]} The flattened list of config objects.
 * @throws {TypeError} If the `extends` property is not an array or if nested `extends` is found.
 */
function processExtends(config, configNames) {
	if (!config.extends) {
		return [config];
	}

	if (!Array.isArray(config.extends)) {
		throw new TypeError("The `extends` property must be an array.");
	}

	const {
		/** @type {ConfigObject[]} */
		extends: extendsList,

		/** @type {ConfigObject} */
		...configObject
	} = config;

	const extensionNames = new WeakMap();

	// replace strings with the actual configs
	const objectExtends = extendsList.map(extendsElement => {
		if (typeof extendsElement === "string") {
			const pluginConfig = findPluginConfig(config, extendsElement);

			// assign names
			if (Array.isArray(pluginConfig)) {
				pluginConfig.forEach((pluginConfigElement, index) => {
					extensionNames.set(
						pluginConfigElement,
						`${extendsElement}[${index}]`,
					);
				});
			} else {
				extensionNames.set(pluginConfig, extendsElement);
			}

			return pluginConfig;
		}

		return /** @type {ConfigObject} */ (extendsElement);
	});

	const result = [];

	for (const { indexPath, value: extendsElement } of flatTraverse(
		objectExtends,
	)) {
		const extension = /** @type {ConfigObject} */ (extendsElement);

		if ("basePath" in extension) {
			throw new TypeError("'basePath' in `extends` is not allowed.");
		}

		if ("extends" in extension) {
			throw new TypeError("Nested 'extends' is not allowed.");
		}

		const baseConfigName = /** @type {string} */ (configNames.get(config));
		const extensionName =
			extensionNames.get(extendsElement) ??
			getExtensionName(extendsElement, indexPath);

		result.push(
			extendConfig(
				configObject,
				baseConfigName,
				extension,
				extensionName,
			),
		);
	}

	/*
	 * If the base config object has only `ignores` and `extends`, then
	 * removing `extends` turns it into a global ignores, which is not what
	 * we want. So we need to check if the base config object is a global ignores
	 * and if so, we don't add it to the array.
	 *
	 * (The other option would be to add a `files` entry, but that would result
	 * in a config that didn't actually do anything because there are no
	 * other keys in the config.)
	 */
	if (!isGlobalIgnores(configObject)) {
		result.push(configObject);
	}

	return result.flat();
}

/**
 * Processes a list of config objects and arrays.
 * @param {ConfigWithExtends[]} configList The list of config objects and arrays.
 * @param {WeakMap<ConfigObject, string>} configNames The map of config objects to their names.
 * @return {ConfigObject[]} The flattened list of config objects.
 */
function processConfigList(configList, configNames) {
	return configList.flatMap(config => processExtends(config, configNames));
}

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

/**
 * Helper function to define a config array.
 * @param {ConfigWithExtendsArray} args The arguments to the function.
 * @returns {ConfigObject[]} The config array.
 * @throws {TypeError} If no arguments are provided or if an argument is not an object.
 */
function defineConfig(...args) {
	const configNames = new WeakMap();
	const configs = [];

	if (args.length === 0) {
		throw new TypeError("Expected one or more arguments.");
	}

	// first flatten the list of configs and get the names
	for (const { indexPath, value } of flatTraverse(args)) {
		if (typeof value !== "object" || value === null) {
			throw new TypeError(
				`Expected an object but received ${String(value)}.`,
			);
		}

		const config = /** @type {ConfigWithExtends} */ (value);

		// save config name for easy reference later
		configNames.set(config, getConfigName(config, indexPath));
		configs.push(config);
	}

	return processConfigList(configs, configNames);
}

/**
 * @fileoverview Global ignores helper function.
 * @author Nicholas C. Zakas
 */

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------


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

let globalIgnoreCount = 0;

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

/**
 * Creates a global ignores config with the given patterns.
 * @param {string[]} ignorePatterns The ignore patterns.
 * @param {string} [name] The name of the global ignores config.
 * @returns {ConfigObject} The global ignores config.
 * @throws {TypeError} If ignorePatterns is not an array or if it is empty.
 */
function globalIgnores(ignorePatterns, name) {
	if (!Array.isArray(ignorePatterns)) {
		throw new TypeError("ignorePatterns must be an array");
	}

	if (ignorePatterns.length === 0) {
		throw new TypeError("ignorePatterns must contain at least one pattern");
	}

	const id = globalIgnoreCount++;

	return {
		name: name || `globalIgnores ${id}`,
		ignores: ignorePatterns,
	};
}

/**
 * @fileoverview Ignore file utilities for the config-helpers package.
 * This file was forked from the source code for the compat package.
 *
 * @author Nicholas C. Zakas
 * @author Kirk Waiblinger
 */


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


/**
 * @typedef {object} IncludeIgnoreFileOptionsObject
 * @property {boolean} [gitignoreResolution] Whether to interpret the contents of an ignore file relative to the config file or the ignore file.
 * - gitignoreResolution: false (default): Interprets ignore patterns relative to the config file
 * - gitignoreResolution: true: Interprets the ignore patterns in a file relative to the ignore file
 * @property {string} [name] The name to give the output config object(s).
 */

/**
 * Options for `includeIgnoreFile()`. May be provided as an object or, for
 * legacy compatibility with `@eslint/compat`, as a string which is treated as
 * the `name` option.
 * @typedef {IncludeIgnoreFileOptionsObject | string} IncludeIgnoreFileOptions
 */

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

/**
 * Converts an ESLint ignore pattern to a minimatch pattern.
 * @param {string} pattern The .eslintignore or .gitignore pattern to convert.
 * @returns {string} The converted pattern.
 */
function convertIgnorePatternToMinimatch(pattern) {
	const isNegated = pattern.startsWith("!");
	const negatedPrefix = isNegated ? "!" : "";
	const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd();

	// special cases
	if (["", "**", "/**", "**/"].includes(patternToTest)) {
		return `${negatedPrefix}${patternToTest}`;
	}

	const firstIndexOfSlash = patternToTest.indexOf("/");

	const matchEverywherePrefix =
		firstIndexOfSlash < 0 || firstIndexOfSlash === patternToTest.length - 1
			? "**/"
			: "";

	const patternWithoutLeadingSlash =
		firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest;

	/*
	 * Escape `{` and `(` because in gitignore patterns they are just
	 * literal characters without any specific syntactic meaning,
	 * while in minimatch patterns they can form brace expansion or extglob syntax.
	 *
	 * For example, gitignore pattern `src/{a,b}.js` ignores file `src/{a,b}.js`.
	 * But, the same minimatch pattern `src/{a,b}.js` ignores files `src/a.js` and `src/b.js`.
	 * Minimatch pattern `src/\{a,b}.js` is equivalent to gitignore pattern `src/{a,b}.js`.
	 */
	const escapedPatternWithoutLeadingSlash =
		patternWithoutLeadingSlash.replaceAll(
			// eslint-disable-next-line regexp/no-empty-lookarounds-assertion -- False positive
			/(?=((?:\\.|[^{(])*))\1([{(])/guy,
			"$1\\$2",
		);

	const matchInsideSuffix = patternToTest.endsWith("/**") ? "/*" : "";

	return `${negatedPrefix}${matchEverywherePrefix}${escapedPatternWithoutLeadingSlash}${matchInsideSuffix}`;
}

/**
 * @param {string} ignoreFilePath
 * @returns {string[]}
 */
function ignoreFilePathToPatterns(ignoreFilePath) {
	const ignoreFile = fs.readFileSync(ignoreFilePath, "utf8");
	const lines = ignoreFile.split(/\r?\n/u);

	return lines
		.map(line => line.trim())
		.filter(line => line && !line.startsWith("#"))
		.map(convertIgnorePatternToMinimatch);
}

/**
 * Helper to parse and validate the options to `includeIgnoreFile()`
 *
 * @param {string | { gitignoreResolution?: unknown, name?: unknown } | undefined} options
 * @returns {{ gitignoreResolution: boolean, name: string }}
 */
function parseOptions(options) {
	// legacy compatibility with @eslint/compat's `includeIgnoreFile`
	if (typeof options === "string") {
		return { gitignoreResolution: false, name: options };
	}

	const optionsObject = options ?? {};
	if (typeof optionsObject !== "object" || Array.isArray(optionsObject)) {
		throw new TypeError(
			"The options argument to `includeIgnoreFile()` should be an object or a string.",
		);
	}

	const gitignoreResolution = optionsObject.gitignoreResolution ?? false;
	if (typeof gitignoreResolution !== "boolean") {
		throw new TypeError(
			"The `gitignoreResolution` option must be specified a boolean or omitted",
		);
	}

	const name = optionsObject.name ?? `Imported .gitignore patterns`;
	if (typeof name !== "string") {
		throw new TypeError(
			"The `name` option must be specified as a string or omitted.",
		);
	}

	return { gitignoreResolution, name };
}

/**
 * @overload
 *
 * Reads ignore files and returns objects with the ignore patterns.
 *
 * @param {string[]} ignoreFilePathArg The paths of ignore files to include.
 * @param {IncludeIgnoreFileOptions} [options]
 * @returns {ConfigObject[]}
 */

/**
 * @overload
 *
 * Reads an ignore file and returns an object with the ignore patterns.
 *
 * @param {string} ignoreFilePathArg The path of the ignore file to include.
 * @param {IncludeIgnoreFileOptions} [options]
 * @returns {ConfigObject}
 */

/**
 * @overload
 *
 * Reads an ignore file(s) and returns an object(s) with the ignore patterns.
 *
 * @param {string[] | string} ignoreFilePathArg The path(s) of the ignore file(s) to include.
 * @param {IncludeIgnoreFileOptions} [options]
 * @returns {ConfigObject[] | ConfigObject}
 */

/**
 * Reads an ignore file(s) and returns an object(s) with the ignore patterns.
 *
 * @param {string[] | string} ignoreFilePathArg The path(s) of the ignore file(s) to include.
 * @param {IncludeIgnoreFileOptions} [options]
 * @returns {ConfigObject[] | ConfigObject}
 */
function includeIgnoreFile(ignoreFilePathArg, options) {
	const returnSingleObject = !Array.isArray(ignoreFilePathArg);
	const ignoreFilePaths = Array.isArray(ignoreFilePathArg)
		? ignoreFilePathArg
		: [ignoreFilePathArg];
	for (const ignorePath of ignoreFilePaths) {
		if (typeof ignorePath !== "string") {
			throw new TypeError(
				"The first argument to `includeIgnoreFile()` should be a string or array of strings",
			);
		}
		if (!path.isAbsolute(ignorePath)) {
			throw new Error(
				`The ignore file location must be an absolute path. Received ${ignorePath}`,
			);
		}
	}

	const { gitignoreResolution, name } = parseOptions(options);

	if (returnSingleObject) {
		return {
			name,
			ignores: ignoreFilePathToPatterns(ignoreFilePathArg),
			...(gitignoreResolution
				? { basePath: path.dirname(ignoreFilePathArg) }
				: {}),
		};
	}

	return ignoreFilePaths.map((ignoreFilePath, i) => ({
		name: `${name} (${i})`,
		ignores: ignoreFilePathToPatterns(ignoreFilePath),
		...(gitignoreResolution
			? { basePath: path.dirname(ignoreFilePath) }
			: {}),
	}));
}

exports.convertIgnorePatternToMinimatch = convertIgnorePatternToMinimatch;
exports.defineConfig = defineConfig;
exports.globalIgnores = globalIgnores;
exports.includeIgnoreFile = includeIgnoreFile;
