/**
 * @fileoverview Rule to prevent invalid properties in CSS.
 * @author Nicholas C. Zakas
 */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
 * @import { CSSRuleDefinition } from "../types.js"
 * @import { ValuePlain, FunctionNodePlain, CssLocationRange } from "@eslint/css-tree";
 * @typedef {"invalidPropertyValue" | "unknownProperty" | "unknownVar"} NoInvalidPropertiesMessageIds
 * @typedef {[{allowUnknownVariables?: boolean}]} NoInvalidPropertiesOptions
 * @typedef {CSSRuleDefinition<{ RuleOptions: NoInvalidPropertiesOptions, MessageIds: NoInvalidPropertiesMessageIds }>} NoInvalidPropertiesRuleDefinition
 */
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
 * Regex to match var() functional notation with optional fallback.
 */
const varFunctionPattern = /var\(\s*(--[^,\s)]+)\s*(?:,([\s\S]+))?\)/iu;
/**
 * Parses a var() function text and extracts the custom property name and fallback.
 * @param {string} text The text containing a var() function.
 * @returns {{ name: string, fallbackText: string | null } | null} The parsed variable name and optional fallback, or null if not a var().
 */
function parseVarFunction(text) {
    const match = text.match(varFunctionPattern);
    if (!match) {
        return null;
    }
    return {
        name: match[1].trim(),
        fallbackText: match[2]?.trim(),
    };
}
/**
 * Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function.
 * For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`.
 * @param {string} value The fallback value that is used in `var()`.
 * @returns {Array<string>} The list of variable names of fallback value.
 */
function getVarFallbackList(value) {
    const list = [];
    let currentValue = value;
    while (true) {
        const parsed = parseVarFunction(currentValue);
        if (!parsed) {
            break;
        }
        list.push(parsed.name);
        if (!parsed.fallbackText) {
            break;
        }
        // If fallback is not another var(), we're done
        if (!parsed.fallbackText.toLowerCase().includes("var(")) {
            list.push(parsed.fallbackText);
            break;
        }
        // Continue parsing from fallback
        currentValue = parsed.fallbackText;
    }
    return list;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidPropertiesRuleDefinition} */ ({
    meta: {
        type: "problem",
        docs: {
            description: "Disallow invalid properties",
            recommended: true,
            url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-properties.md",
        },
        schema: [
            {
                type: "object",
                properties: {
                    allowUnknownVariables: {
                        type: "boolean",
                    },
                },
                additionalProperties: false,
            },
        ],
        defaultOptions: [
            {
                allowUnknownVariables: false,
            },
        ],
        messages: {
            invalidPropertyValue: "Invalid value '{{value}}' for property '{{property}}'. Expected {{expected}}.",
            unknownProperty: "Unknown property '{{property}}' found.",
            unknownVar: "Can't validate with unknown variable '{{var}}'.",
        },
    },
    create(context) {
        const sourceCode = context.sourceCode;
        const lexer = sourceCode.lexer;
        /** @type {Map<string,ValuePlain>} */
        const vars = new Map();
        /**
         * @type {Array<{
         *   valueParts: string[],
         *   functionPartsStack: string[][],
         *   valueSegmentLocs: Map<string,CssLocationRange>,
         *   skipValidation: boolean,
         *   hadVarSubstitution: boolean,
         *   resolvedCache:  Map<string,string>
         * }>}
         */
        const declStack = [];
        const [{ allowUnknownVariables }] = context.options;
        /**
         * Iteratively resolves CSS variable references until a value is found.
         * @param {string} variableName The variable name to resolve
         * @param {Map<string, string>} cache Cache for memoization within a single resolution scope
         * @param {Set<string>} [seen] Set of already seen variables to detect cycles
         * @returns {string|null} The resolved value or null if not found
         */
        function resolveVariable(variableName, cache, seen = new Set()) {
            /** @type {Array<string>} */
            const fallbackStack = [];
            let currentVarName = variableName;
            /*
             * Resolves a CSS variable by following its reference chain.
             *
             * Phase 1: Follow var() references
             * - Use `seen` to detect cycles
             * - Use `cache` for memoization
             * - If value is concrete: cache and return
             * - If value is another var(--next, <fallback>):
             *     push fallback to stack and continue with --next
             * - If variable unknown: proceed to Phase 2
             *
             * Phase 2: Try fallback values (if Phase 1 failed)
             * - Process fallbacks in reverse order (LIFO)
             * - Resolve each via resolveFallback()
             * - Return first successful resolution
             */
            while (true) {
                if (seen.has(currentVarName)) {
                    break;
                }
                seen.add(currentVarName);
                if (cache.has(currentVarName)) {
                    return cache.get(currentVarName);
                }
                const valueNode = vars.get(currentVarName);
                if (!valueNode) {
                    break;
                }
                const valueText = sourceCode.getText(valueNode).trim();
                const parsed = parseVarFunction(valueText);
                if (!parsed) {
                    cache.set(currentVarName, valueText);
                    return valueText;
                }
                if (parsed.fallbackText) {
                    fallbackStack.push(parsed.fallbackText);
                }
                currentVarName = parsed.name;
            }
            while (fallbackStack.length > 0) {
                const fallbackText = fallbackStack.pop();
                // eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive
                const resolvedFallback = resolveFallback(fallbackText, cache, seen);
                if (resolvedFallback !== null) {
                    return resolvedFallback;
                }
            }
            return null;
        }
        /**
         * Resolves a fallback text which can contain nested var() calls.
         * Returns the first resolvable value or null if none resolve.
         * @param {string} rawFallbackText The raw fallback text to resolve.
         * @param {Map<string, string>} cache Cache for memoization within a single resolution scope.
         * @param {Set<string>} [seen] Set of already seen variables to detect cycles.
         * @returns {string | null} The resolved fallback value, or null if none can be resolved.
         */
        function resolveFallback(rawFallbackText, cache, seen = new Set()) {
            const fallbackVarList = getVarFallbackList(rawFallbackText);
            if (fallbackVarList.length === 0) {
                return rawFallbackText;
            }
            for (const fallbackCandidate of fallbackVarList) {
                if (fallbackCandidate.startsWith("--")) {
                    const resolved = resolveVariable(fallbackCandidate, cache, seen);
                    if (resolved !== null) {
                        return resolved;
                    }
                    continue;
                }
                return fallbackCandidate.trim();
            }
            return null;
        }
        /**
         * Process a var function node and add its resolved value to the value list
         * @param {Object} varNode The var() function node
         * @param {string[]} valueList Array to collect processed values
         * @param {Map<string,CssLocationRange>} valueSegmentLocs Map of rebuilt value segments to their locations
         * @param {Map<string, string>} resolvedCache Cache for resolved variable values to prevent redundant lookups
         * @returns {boolean} Whether processing was successful
         */
        function processVarFunction(varNode, valueList, valueSegmentLocs, resolvedCache) {
            const varValue = vars.get(varNode.children[0].name);
            if (varValue) {
                const resolvedValue = resolveVariable(varNode.children[0].name, resolvedCache);
                if (resolvedValue) {
                    valueList.push(resolvedValue);
                    valueSegmentLocs.set(resolvedValue, varNode.loc);
                    return true;
                }
            }
            // If the variable is not found and doesn't have a fallback value, report it
            if (varNode.children.length === 1) {
                if (!allowUnknownVariables) {
                    context.report({
                        loc: varNode.children[0].loc,
                        messageId: "unknownVar",
                        data: { var: varNode.children[0].name },
                    });
                    return false;
                }
                return true;
            }
            // Handle fallback values
            if (varNode.children[2].type !== "Raw") {
                return true;
            }
            const fallbackValue = varNode.children[2].value.trim();
            const resolvedFallbackValue = resolveFallback(fallbackValue, resolvedCache);
            if (resolvedFallbackValue) {
                valueList.push(resolvedFallbackValue);
                valueSegmentLocs.set(resolvedFallbackValue, varNode.loc);
                return true;
            }
            // No valid fallback found
            if (!allowUnknownVariables) {
                context.report({
                    loc: varNode.children[0].loc,
                    messageId: "unknownVar",
                    data: { var: varNode.children[0].name },
                });
                return false;
            }
            return true;
        }
        return {
            "Rule > Block Declaration"() {
                declStack.push({
                    valueParts: [],
                    functionPartsStack: [],
                    valueSegmentLocs: new Map(),
                    skipValidation: false,
                    hadVarSubstitution: false,
                    /**
                     * Cache for resolved variable values within this single declaration.
                     * Prevents re-resolving the same variable and re-walking long `var()` chains.
                     */
                    resolvedCache: new Map(),
                });
            },
            "Rule > Block Declaration > Value > *:not(Function)"(node) {
                const state = declStack.at(-1);
                const text = sourceCode.getText(node).trim();
                state.valueParts.push(text);
                state.valueSegmentLocs.set(text, node.loc);
            },
            Function() {
                const state = declStack.at(-1);
                if (!state) {
                    return;
                }
                state.functionPartsStack.push([]);
            },
            "Function > *:not(Function)"(node) {
                const state = declStack.at(-1);
                if (!state) {
                    return;
                }
                const parts = state.functionPartsStack.at(-1);
                const text = sourceCode.getText(node).trim();
                parts.push(text);
                state.valueSegmentLocs.set(text, node.loc);
            },
            "Function:exit"(node) {
                const state = declStack.at(-1);
                if (!state || state.skipValidation) {
                    return;
                }
                const parts = state.functionPartsStack.pop();
                let result;
                if (node.name.toLowerCase() === "var") {
                    const resolvedParts = [];
                    const success = processVarFunction(node, resolvedParts, state.valueSegmentLocs, state.resolvedCache);
                    if (!success) {
                        state.skipValidation = true;
                        return;
                    }
                    if (resolvedParts.length === 0) {
                        return;
                    }
                    state.hadVarSubstitution = true;
                    result = resolvedParts[0];
                }
                else {
                    result = `${node.name}(${parts.join(" ")})`;
                }
                const parentParts = state.functionPartsStack.at(-1);
                if (parentParts) {
                    parentParts.push(result);
                }
                else {
                    state.valueParts.push(result);
                }
            },
            "Rule > Block Declaration:exit"(node) {
                const state = declStack.pop();
                if (node.property.startsWith("--")) {
                    // store the custom property name and value to validate later
                    vars.set(node.property, node.value);
                    // don't validate custom properties
                    return;
                }
                if (state.skipValidation) {
                    return;
                }
                let value = node.value;
                if (state.hadVarSubstitution) {
                    const valueList = state.valueParts;
                    value =
                        valueList.length > 0
                            ? valueList.join(" ")
                            : sourceCode.getText(node.value);
                }
                const { error } = lexer.matchProperty(node.property, value);
                if (error) {
                    // validation failure
                    if (isSyntaxMatchError(error)) {
                        const errorValue = state.hadVarSubstitution &&
                            value.slice(error.mismatchOffset, error.mismatchOffset + error.mismatchLength);
                        context.report({
                            /*
                             * When using variables, check to see if the error
                             * occurred at a location where a variable was replaced.
                             * If so, use that location; otherwise, use the error's
                             * reported location.
                             */
                            loc: state.hadVarSubstitution
                                ? (state.valueSegmentLocs.get(errorValue) ??
                                    node.value.loc)
                                : error.loc,
                            messageId: "invalidPropertyValue",
                            data: {
                                property: node.property,
                                /*
                                 * When using variables, slice the value to
                                 * only include the part that caused the error.
                                 * Otherwise, use the full value from the error.
                                 */
                                value: state.hadVarSubstitution
                                    ? errorValue
                                    : error.css,
                                expected: error.syntax,
                            },
                        });
                        return;
                    }
                    if (!allowUnknownVariables ||
                        isSyntaxReferenceError(error)) {
                        // unknown property
                        context.report({
                            loc: {
                                start: node.loc.start,
                                end: {
                                    line: node.loc.start.line,
                                    column: node.loc.start.column +
                                        node.property.length,
                                },
                            },
                            messageId: "unknownProperty",
                            data: {
                                property: node.property,
                            },
                        });
                    }
                }
            },
        };
    },
});
