/**
 * @fileoverview Flat Config Array
 * @author Nicholas C. Zakas
 */

"use strict";

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

const { ConfigArray, ConfigArraySymbol } = require("@eslint/config-array");
const { flatConfigSchema } = require("./flat-config-schema");
const { defaultConfig } = require("./default-config");
const { Config } = require("./config");

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

/**
 * Fields that are considered metadata and not part of the config object.
 */
const META_FIELDS = new Set(["name"]);

/**
 * Wraps a config error with details about where the error occurred.
 * @param {Error} error The original error.
 * @param {number} originalLength The original length of the config array.
 * @param {number} baseLength The length of the base config.
 * @returns {TypeError} The new error with details.
 */
function wrapConfigErrorWithDetails(error, originalLength, baseLength) {
	let location = "user-defined";
	let configIndex = error.index;

	/*
	 * A config array is set up in this order:
	 * 1. Base config
	 * 2. Original configs
	 * 3. User-defined configs
	 * 4. CLI-defined configs
	 *
	 * So we need to adjust the index to account for the base config.
	 *
	 * - If the index is less than the base length, it's in the base config
	 *   (as specified by `baseConfig` argument to `FlatConfigArray` constructor).
	 * - If the index is greater than the base length but less than the original
	 *   length + base length, it's in the original config. The original config
	 *   is passed to the `FlatConfigArray` constructor as the first argument.
	 * - Otherwise, it's in the user-defined config, which is loaded from the
	 *   config file and merged with any command-line options.
	 */
	if (error.index < baseLength) {
		location = "base";
	} else if (error.index < originalLength + baseLength) {
		location = "original";
		configIndex = error.index - baseLength;
	} else {
		configIndex = error.index - originalLength - baseLength;
	}

	return new TypeError(
		`${error.message.slice(0, -1)} at ${location} index ${configIndex}.`,
		{ cause: error },
	);
}

const originalBaseConfig = Symbol("originalBaseConfig");
const originalLength = Symbol("originalLength");
const baseLength = Symbol("baseLength");

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

/**
 * Represents an array containing configuration information for ESLint.
 */
class FlatConfigArray extends ConfigArray {
	/**
	 * Creates a new instance.
	 * @param {*[]} configs An array of configuration information.
	 * @param {{basePath: string, shouldIgnore: boolean, baseConfig: FlatConfig}} options The options
	 *      to use for the config array instance.
	 */
	constructor(
		configs,
		{ basePath, shouldIgnore = true, baseConfig = defaultConfig } = {},
	) {
		super(configs, {
			basePath,
			schema: flatConfigSchema,
		});

		/**
		 * The original length of the array before any modifications.
		 * @type {number}
		 */
		this[originalLength] = this.length;

		if (baseConfig[Symbol.iterator]) {
			this.unshift(...baseConfig);
		} else {
			this.unshift(baseConfig);
		}

		/**
		 * The length of the array after applying the base config.
		 * @type {number}
		 */
		this[baseLength] = this.length - this[originalLength];

		/**
		 * The base config used to build the config array.
		 * @type {Array<FlatConfig>}
		 */
		this[originalBaseConfig] = baseConfig;
		Object.defineProperty(this, originalBaseConfig, { writable: false });

		/**
		 * Determines if `ignores` fields should be honored.
		 * If true, then all `ignores` fields are honored.
		 * if false, then only `ignores` fields in the baseConfig are honored.
		 * @type {boolean}
		 */
		this.shouldIgnore = shouldIgnore;
		Object.defineProperty(this, "shouldIgnore", { writable: false });
	}

	/**
	 * Normalizes the array by calling the superclass method and catching/rethrowing
	 * any ConfigError exceptions with additional details.
	 * @param {any} [context] The context to use to normalize the array.
	 * @returns {Promise<FlatConfigArray>} A promise that resolves when the array is normalized.
	 */
	normalize(context) {
		return super.normalize(context).catch(error => {
			if (error.name === "ConfigError") {
				throw wrapConfigErrorWithDetails(
					error,
					this[originalLength],
					this[baseLength],
				);
			}

			throw error;
		});
	}

	/**
	 * Normalizes the array by calling the superclass method and catching/rethrowing
	 * any ConfigError exceptions with additional details.
	 * @param {any} [context] The context to use to normalize the array.
	 * @returns {FlatConfigArray} The current instance.
	 * @throws {TypeError} If the config is invalid.
	 */
	normalizeSync(context) {
		try {
			return super.normalizeSync(context);
		} catch (error) {
			if (error.name === "ConfigError") {
				throw wrapConfigErrorWithDetails(
					error,
					this[originalLength],
					this[baseLength],
				);
			}

			throw error;
		}
	}

	/* eslint-disable class-methods-use-this -- Desired as instance method */
	/**
	 * Replaces a config with another config to allow us to put strings
	 * in the config array that will be replaced by objects before
	 * normalization.
	 * @param {Object} config The config to preprocess.
	 * @returns {Object} The preprocessed config.
	 */
	[ConfigArraySymbol.preprocessConfig](config) {
		/*
		 * If a config object has `ignores` and no other non-meta fields, then it's an object
		 * for global ignores. If `shouldIgnore` is false, that object shouldn't apply,
		 * so we'll remove its `ignores`.
		 */
		if (
			!this.shouldIgnore &&
			!this[originalBaseConfig].includes(config) &&
			config.ignores &&
			Object.keys(config).filter(key => !META_FIELDS.has(key)).length ===
				1
		) {
			/* eslint-disable-next-line no-unused-vars -- need to strip off other keys */
			const { ignores, ...otherKeys } = config;

			return otherKeys;
		}

		return config;
	}

	/**
	 * Finalizes the config by replacing plugin references with their objects
	 * and validating rule option schemas.
	 * @param {Object} config The config to finalize.
	 * @returns {Object} The finalized config.
	 * @throws {TypeError} If the config is invalid.
	 */
	[ConfigArraySymbol.finalizeConfig](config) {
		return new Config(config);
	}
	/* eslint-enable class-methods-use-this -- Desired as instance method */
}

exports.FlatConfigArray = FlatConfigArray;
