/**
 * @fileoverview Flat config schema
 * @author Nicholas C. Zakas
 */

"use strict";

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

/**
 * @typedef ObjectPropertySchema
 * @property {Function|string} merge The function or name of the function to call
 *      to merge multiple objects with this property.
 * @property {Function|string} validate The function or name of the function to call
 *      to validate the value of this property.
 */

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

const ruleSeverities = new Map([
    [0, 0], ["off", 0],
    [1, 1], ["warn", 1],
    [2, 2], ["error", 2]
]);

const globalVariablesValues = new Set([
    true, "true", "writable", "writeable",
    false, "false", "readonly", "readable", null,
    "off"
]);

/**
 * Check if a value is a non-null object.
 * @param {any} value The value to check.
 * @returns {boolean} `true` if the value is a non-null object.
 */
function isNonNullObject(value) {
    return typeof value === "object" && value !== null;
}

/**
 * Check if a value is undefined.
 * @param {any} value The value to check.
 * @returns {boolean} `true` if the value is undefined.
 */
function isUndefined(value) {
    return typeof value === "undefined";
}

/**
 * Deeply merges two objects.
 * @param {Object} first The base object.
 * @param {Object} second The overrides object.
 * @returns {Object} An object with properties from both first and second.
 */
function deepMerge(first = {}, second = {}) {

    /*
     * If the second value is an array, just return it. We don't merge
     * arrays because order matters and we can't know the correct order.
     */
    if (Array.isArray(second)) {
        return second;
    }

    /*
     * First create a result object where properties from the second object
     * overwrite properties from the first. This sets up a baseline to use
     * later rather than needing to inspect and change every property
     * individually.
     */
    const result = {
        ...first,
        ...second
    };

    for (const key of Object.keys(second)) {

        // avoid hairy edge case
        if (key === "__proto__") {
            continue;
        }

        const firstValue = first[key];
        const secondValue = second[key];

        if (isNonNullObject(firstValue)) {
            result[key] = deepMerge(firstValue, secondValue);
        } else if (isUndefined(firstValue)) {
            if (isNonNullObject(secondValue)) {
                result[key] = deepMerge(
                    Array.isArray(secondValue) ? [] : {},
                    secondValue
                );
            } else if (!isUndefined(secondValue)) {
                result[key] = secondValue;
            }
        }
    }

    return result;

}

/**
 * Normalizes the rule options config for a given rule by ensuring that
 * it is an array and that the first item is 0, 1, or 2.
 * @param {Array|string|number} ruleOptions The rule options config.
 * @returns {Array} An array of rule options.
 */
function normalizeRuleOptions(ruleOptions) {

    const finalOptions = Array.isArray(ruleOptions)
        ? ruleOptions.slice(0)
        : [ruleOptions];

    finalOptions[0] = ruleSeverities.get(finalOptions[0]);
    return finalOptions;
}

//-----------------------------------------------------------------------------
// Assertions
//-----------------------------------------------------------------------------

/**
 * The error type when a rule's options are configured with an invalid type.
 */
class InvalidRuleOptionsError extends Error {

    /**
     * @param {string} ruleId Rule name being configured.
     * @param {any} value The invalid value.
     */
    constructor(ruleId, value) {
        super(`Key "${ruleId}": Expected severity of "off", 0, "warn", 1, "error", or 2.`);
        this.messageTemplate = "invalid-rule-options";
        this.messageData = { ruleId, value };
    }
}

/**
 * Validates that a value is a valid rule options entry.
 * @param {string} ruleId Rule name being configured.
 * @param {any} value The value to check.
 * @returns {void}
 * @throws {InvalidRuleOptionsError} If the value isn't a valid rule options.
 */
function assertIsRuleOptions(ruleId, value) {
    if (typeof value !== "string" && typeof value !== "number" && !Array.isArray(value)) {
        throw new InvalidRuleOptionsError(ruleId, value);
    }
}

/**
 * The error type when a rule's severity is invalid.
 */
class InvalidRuleSeverityError extends Error {

    /**
     * @param {string} ruleId Rule name being configured.
     * @param {any} value The invalid value.
     */
    constructor(ruleId, value) {
        super(`Key "${ruleId}": Expected severity of "off", 0, "warn", 1, "error", or 2.`);
        this.messageTemplate = "invalid-rule-severity";
        this.messageData = { ruleId, value };
    }
}

/**
 * Validates that a value is valid rule severity.
 * @param {string} ruleId Rule name being configured.
 * @param {any} value The value to check.
 * @returns {void}
 * @throws {InvalidRuleSeverityError} If the value isn't a valid rule severity.
 */
function assertIsRuleSeverity(ruleId, value) {
    const severity = typeof value === "string"
        ? ruleSeverities.get(value.toLowerCase())
        : ruleSeverities.get(value);

    if (typeof severity === "undefined") {
        throw new InvalidRuleSeverityError(ruleId, value);
    }
}

/**
 * Validates that a given string is the form pluginName/objectName.
 * @param {string} value The string to check.
 * @returns {void}
 * @throws {TypeError} If the string isn't in the correct format.
 */
function assertIsPluginMemberName(value) {
    if (!/[@a-z0-9-_$]+(?:\/(?:[a-z0-9-_$]+))+$/iu.test(value)) {
        throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`);
    }
}

/**
 * Validates that a value is an object.
 * @param {any} value The value to check.
 * @returns {void}
 * @throws {TypeError} If the value isn't an object.
 */
function assertIsObject(value) {
    if (!isNonNullObject(value)) {
        throw new TypeError("Expected an object.");
    }
}

/**
 * The error type when there's an eslintrc-style options in a flat config.
 */
class IncompatibleKeyError extends Error {

    /**
     * @param {string} key The invalid key.
     */
    constructor(key) {
        super("This appears to be in eslintrc format rather than flat config format.");
        this.messageTemplate = "eslintrc-incompat";
        this.messageData = { key };
    }
}

/**
 * The error type when there's an eslintrc-style plugins array found.
 */
class IncompatiblePluginsError extends Error {

    /**
     * Creates a new instance.
     * @param {Array<string>} plugins The plugins array.
     */
    constructor(plugins) {
        super("This appears to be in eslintrc format (array of strings) rather than flat config format (object).");
        this.messageTemplate = "eslintrc-plugins";
        this.messageData = { plugins };
    }
}


//-----------------------------------------------------------------------------
// Low-Level Schemas
//-----------------------------------------------------------------------------

/** @type {ObjectPropertySchema} */
const booleanSchema = {
    merge: "replace",
    validate: "boolean"
};

/** @type {ObjectPropertySchema} */
const deepObjectAssignSchema = {
    merge(first = {}, second = {}) {
        return deepMerge(first, second);
    },
    validate: "object"
};

//-----------------------------------------------------------------------------
// High-Level Schemas
//-----------------------------------------------------------------------------

/** @type {ObjectPropertySchema} */
const globalsSchema = {
    merge: "assign",
    validate(value) {

        assertIsObject(value);

        for (const key of Object.keys(value)) {

            // avoid hairy edge case
            if (key === "__proto__") {
                continue;
            }

            if (key !== key.trim()) {
                throw new TypeError(`Global "${key}" has leading or trailing whitespace.`);
            }

            if (!globalVariablesValues.has(value[key])) {
                throw new TypeError(`Key "${key}": Expected "readonly", "writable", or "off".`);
            }
        }
    }
};

/** @type {ObjectPropertySchema} */
const parserSchema = {
    merge: "replace",
    validate(value) {

        if (!value || typeof value !== "object" ||
            (typeof value.parse !== "function" && typeof value.parseForESLint !== "function")
        ) {
            throw new TypeError("Expected object with parse() or parseForESLint() method.");
        }

    }
};

/** @type {ObjectPropertySchema} */
const pluginsSchema = {
    merge(first = {}, second = {}) {
        const keys = new Set([...Object.keys(first), ...Object.keys(second)]);
        const result = {};

        // manually validate that plugins are not redefined
        for (const key of keys) {

            // avoid hairy edge case
            if (key === "__proto__") {
                continue;
            }

            if (key in first && key in second && first[key] !== second[key]) {
                throw new TypeError(`Cannot redefine plugin "${key}".`);
            }

            result[key] = second[key] || first[key];
        }

        return result;
    },
    validate(value) {

        // first check the value to be sure it's an object
        if (value === null || typeof value !== "object") {
            throw new TypeError("Expected an object.");
        }

        // make sure it's not an array, which would mean eslintrc-style is used
        if (Array.isArray(value)) {
            throw new IncompatiblePluginsError(value);
        }

        // second check the keys to make sure they are objects
        for (const key of Object.keys(value)) {

            // avoid hairy edge case
            if (key === "__proto__") {
                continue;
            }

            if (value[key] === null || typeof value[key] !== "object") {
                throw new TypeError(`Key "${key}": Expected an object.`);
            }
        }
    }
};

/** @type {ObjectPropertySchema} */
const processorSchema = {
    merge: "replace",
    validate(value) {
        if (typeof value === "string") {
            assertIsPluginMemberName(value);
        } else if (value && typeof value === "object") {
            if (typeof value.preprocess !== "function" || typeof value.postprocess !== "function") {
                throw new TypeError("Object must have a preprocess() and a postprocess() method.");
            }
        } else {
            throw new TypeError("Expected an object or a string.");
        }
    }
};

/** @type {ObjectPropertySchema} */
const rulesSchema = {
    merge(first = {}, second = {}) {

        const result = {
            ...first,
            ...second
        };

        for (const ruleId of Object.keys(result)) {

            // avoid hairy edge case
            if (ruleId === "__proto__") {

                /* eslint-disable-next-line no-proto -- Though deprecated, may still be present */
                delete result.__proto__;
                continue;
            }

            result[ruleId] = normalizeRuleOptions(result[ruleId]);

            /*
             * If either rule config is missing, then the correct
             * config is already present and we just need to normalize
             * the severity.
             */
            if (!(ruleId in first) || !(ruleId in second)) {
                continue;
            }

            const firstRuleOptions = normalizeRuleOptions(first[ruleId]);
            const secondRuleOptions = normalizeRuleOptions(second[ruleId]);

            /*
             * If the second rule config only has a severity (length of 1),
             * then use that severity and keep the rest of the options from
             * the first rule config.
             */
            if (secondRuleOptions.length === 1) {
                result[ruleId] = [secondRuleOptions[0], ...firstRuleOptions.slice(1)];
                continue;
            }

            /*
             * In any other situation, then the second rule config takes
             * precedence. That means the value at `result[ruleId]` is
             * already correct and no further work is necessary.
             */
        }

        return result;
    },

    validate(value) {
        assertIsObject(value);

        /*
         * We are not checking the rule schema here because there is no
         * guarantee that the rule definition is present at this point. Instead
         * we wait and check the rule schema during the finalization step
         * of calculating a config.
         */
        for (const ruleId of Object.keys(value)) {

            // avoid hairy edge case
            if (ruleId === "__proto__") {
                continue;
            }

            const ruleOptions = value[ruleId];

            assertIsRuleOptions(ruleId, ruleOptions);

            if (Array.isArray(ruleOptions)) {
                assertIsRuleSeverity(ruleId, ruleOptions[0]);
            } else {
                assertIsRuleSeverity(ruleId, ruleOptions);
            }
        }
    }
};

/** @type {ObjectPropertySchema} */
const ecmaVersionSchema = {
    merge: "replace",
    validate(value) {
        if (typeof value === "number" || value === "latest") {
            return;
        }

        throw new TypeError("Expected a number or \"latest\".");
    }
};

/** @type {ObjectPropertySchema} */
const sourceTypeSchema = {
    merge: "replace",
    validate(value) {
        if (typeof value !== "string" || !/^(?:script|module|commonjs)$/u.test(value)) {
            throw new TypeError("Expected \"script\", \"module\", or \"commonjs\".");
        }
    }
};

/**
 * Creates a schema that always throws an error. Useful for warning
 * about eslintrc-style keys.
 * @param {string} key The eslintrc key to create a schema for.
 * @returns {ObjectPropertySchema} The schema.
 */
function createEslintrcErrorSchema(key) {
    return {
        merge: "replace",
        validate() {
            throw new IncompatibleKeyError(key);
        }
    };
}

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

//-----------------------------------------------------------------------------
// Full schema
//-----------------------------------------------------------------------------

const flatConfigSchema = {

    // eslintrc-style keys that should always error
    ...Object.fromEntries(eslintrcKeys.map(key => [key, createEslintrcErrorSchema(key)])),

    // flat config keys
    settings: deepObjectAssignSchema,
    linterOptions: {
        schema: {
            noInlineConfig: booleanSchema,
            reportUnusedDisableDirectives: booleanSchema
        }
    },
    languageOptions: {
        schema: {
            ecmaVersion: ecmaVersionSchema,
            sourceType: sourceTypeSchema,
            globals: globalsSchema,
            parser: parserSchema,
            parserOptions: deepObjectAssignSchema
        }
    },
    processor: processorSchema,
    plugins: pluginsSchema,
    rules: rulesSchema
};

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

module.exports = {
    flatConfigSchema,
    assertIsRuleSeverity,
    assertIsRuleOptions
};