'use strict';

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

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

/** @typedef {import("eslint").Linter.Config} Config */
/** @typedef {import("eslint").Linter.LegacyConfig} LegacyConfig */
/** @typedef {import("eslint").ESLint.Plugin} Plugin */
/** @typedef {import("eslint").Linter.RuleEntry} RuleEntry */
/** @typedef {import("./types.ts").ExtendsElement} ExtendsElement */
/** @typedef {import("./types.ts").SimpleExtendsElement} SimpleExtendsElement */
/** @typedef {import("./types.ts").ConfigWithExtends} ConfigWithExtends */
/** @typedef {import("./types.ts").InfiniteArray<Config>} InfiniteConfigArray */
/** @typedef {import("./types.ts").ConfigWithExtendsArray} ConfigWithExtendsArray */

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

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

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

/**
 * Gets the name of a config object.
 * @param {Config} 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 {Config|LegacyConfig} config The config object to check.
 * @return {config is LegacyConfig} `true` if the config object is a legacy config.
 */
function isLegacyConfig(config) {
	for (const key of eslintrcKeys) {
		if (key in config) {
			return true;
		}
	}

	return false;
}

/**
 * Determines if a config object is a global ignores config.
 * @param {Config} 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 {Config} config The config object to normalize.
 * @return {Config} The normalized config object.
 */
function normalizePluginConfig(userNamespace, plugin, config) {
	// @ts-ignore -- ESLint types aren't updated yet
	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,RuleEntry|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 {Config|LegacyConfig|(Config|LegacyConfig)[]} pluginConfig The plugin config to normalize.
 * @param {string} pluginConfigName The name of the plugin config.
 * @return {InfiniteConfigArray} The normalized plugin 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 {Config} config The config object.
 * @param {string} pluginConfigName The name of the plugin config.
 * @return {InfiniteConfigArray} The plugin 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];
	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, look for the flat version
		const flatConfig = plugin.configs?.[`flat/${configName}`];

		if (
			flatConfig &&
			(Array.isArray(flatConfig) || !isLegacyConfig(flatConfig))
		) {
			return deepNormalizePluginConfig(
				userPluginNamespace,
				plugin,
				flatConfig,
				pluginConfigName,
			);
		}

		throw new TypeError(
			`Plugin config "${configName}" in plugin "${userPluginNamespace}" is an eslintrc config and cannot be used in this context.`,
		);
	}

	throw new TypeError(
		`Plugin config "${configName}" not found in plugin "${userPluginNamespace}".`,
	);
}

/**
 * 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 {Config} baseConfig The base config object.
 * @param {string} baseConfigName The name of the base config object.
 * @param {Config} extension The extension config object.
 * @param {string} extensionName The index of the extension config object.
 * @return {Config} 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}`;

	return result;
}

/**
 * Processes a list of extends elements.
 * @param {ConfigWithExtends} config The config object.
 * @param {WeakMap<Config, string>} configNames The map of config objects to their names.
 * @return {Config[]} The flattened list of config objects.
 */
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 {Config[]} */
		extends: extendsList,

		/** @type {Config} */
		...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 {Config} */ (extendsElement);
	});

	const result = [];

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

		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<Config, string>} configNames The map of config objects to their names.
 * @return {Config[]} 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 {Config[]} The config array.
 */
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 {Config} The global ignores config.
 */
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,
	};
}

exports.defineConfig = defineConfig;
exports.globalIgnores = globalIgnores;
