Source: lib/compiler/StyleProcessor.js

const crypto = require('crypto');

/**
 * StyleProcessor is responsible for handling all CSS-related logic during the build process.
 * This includes managing global CSS variables, scoping component-specific styles using hashes,
 * and extracting CSS rules into a global stylesheet.
 */
class StyleProcessor {
    /**
     * Tracks which hashes have already been added to globalStyles to prevent duplicates.
     * @type {Set<string>}
     * @private
     */
    #addedHashes = new Set();

    /**
     * Creates an instance of StyleProcessor.
     */
    constructor() {
        this.reset();
    }

    /**
     * Resets the processor state, clearing all accumulated styles and variables.
     */
    reset() {
        /** 
         * The accumulated global stylesheet content.
         * @type {string} 
         */
        this.globalStyles = "";
        
        /** 
         * A map of CSS variable names to their values.
         * @type {Object<string, string>} 
         */
        this.cssVariables = {};
        
        /**
         * Raw global CSS rules added via addGlobalCSS.
         * @type {Set<string>}
         */
        this.rawGlobalCSS = new Set();
        
        /**
         * Scoped CSS rules.
         * @type {string}
         */
        this.scopedStyles = "";

        this.#addedHashes = new Set();
    }

    /**
     * Adds a global CSS variable to the processor.
     * @param {string} name - The name of the variable (without the @ prefix).
     * @param {string} value - The value of the variable.
     */
    addVariable(name, value) {
        this.cssVariables[name] = value;
    }

    /**
     * Adds raw global CSS rules to the stylesheet.
     * @param {string} css - The raw CSS string.
     */
    addGlobalCSS(css) {
        this.rawGlobalCSS.add(css);
    }

    /**
     * Retrieves the accumulated global styles.
     * @returns {string} The complete CSS string for the application.
     */
    getGlobalStyles() {
        let output = "/* Generated by Avenx-JS */\n";
        
        // 1. Global CSS rules
        for (const css of this.rawGlobalCSS) {
            output += this.applyVariables(css) + "\n";
        }
        
        // 2. Scoped styles
        output += "\n/* Scoped Styles */\n" + this.scopedStyles;
        
        return output;
    }

    /**
     * Processes the CSS within an HTML template. It identifies @css attributes,
     * scopes the rules with a unique hash, replaces variables, and updates the HTML.
     * @param {string} html - The HTML template.
     * @param {Object} [desBlocks={}] - Pre-defined style blocks from a .component.css file.
     * @param {string} [componentName=""] - The name of the component for hash generation.
     * @returns {string} The modified HTML.
     */
    process(html, desBlocks = {}, componentName = "") {
        let modifiedHtml = html;

        // 1. First, handle all @css attributes (new syntax)
        // This replaces '@css name' with 'class="hash"' or merges with existing classes
        modifiedHtml = modifiedHtml.replace(/<([^>]+)\s+@css\s+([\w-]+)([^>]*)>/g, (fullMatch, before, blockName, after) => {
            const cssContent = desBlocks[blockName] || "";
            if (!cssContent) return `<${before}${after}>`;

            const hash = this.getHash(cssContent, componentName);
            this.extractRules(this.applyVariables(cssContent), hash);

            const tagWithClass = this.mergeClassIntoTag(before + after, hash);
            return `<${tagWithClass}>`;
        });

        // 2. Then, handle all <@css /> tags (old/anonymous syntax)
        const tagRegex = /<@css\s+([\w-]+)?\s*\/?>/g;
        let match;
        
        while ((match = tagRegex.exec(modifiedHtml)) !== null) {
            const fullMatch = match[0];
            const blockName = match[1];
            const cssContent = blockName ? (desBlocks[blockName] || "") : "";

            if (!cssContent) {
                modifiedHtml = modifiedHtml.replace(fullMatch, '');
                tagRegex.lastIndex = 0; // Restart because string changed
                continue;
            }

            const hash = this.getHash(cssContent, componentName);
            this.extractRules(this.applyVariables(cssContent), hash);

            const matchIndex = match.index;
            const beforeMatch = modifiedHtml.substring(0, matchIndex);
            const lastTagStart = beforeMatch.lastIndexOf('<');
            const lastTagEnd = beforeMatch.lastIndexOf('>');

            if (lastTagStart !== -1 && lastTagStart > lastTagEnd) {
                // Inside a tag: <div <@css ... /> >
                const tagContent = modifiedHtml.substring(lastTagStart + 1, matchIndex);
                const updatedTag = this.mergeClassIntoTag(tagContent, hash);
                modifiedHtml = modifiedHtml.substring(0, lastTagStart + 1) + 
                               updatedTag + 
                               modifiedHtml.substring(matchIndex + fullMatch.length);
            } else {
                // Outside a tag: <div> <@css ... /> </div>
                // Search for the previous tag to apply the class to
                const prevTagRegex = /<([a-zA-Z0-9-]+)([^>]*)>$/;
                const prevTagMatch = beforeMatch.trimEnd().match(prevTagRegex);
                
                if (prevTagMatch) {
                    const tagStart = beforeMatch.lastIndexOf(prevTagMatch[0]);
                    const tagContent = prevTagMatch[1] + prevTagMatch[2];
                    const updatedTag = this.mergeClassIntoTag(tagContent, hash);
                    
                    modifiedHtml = modifiedHtml.substring(0, tagStart + 1) +
                                   updatedTag +
                                   '>' +
                                   modifiedHtml.substring(tagStart + prevTagMatch[0].length, matchIndex) +
                                   modifiedHtml.substring(matchIndex + fullMatch.length);
                } else {
                    modifiedHtml = modifiedHtml.replace(fullMatch, '');
                }
            }
            tagRegex.lastIndex = 0; // Restart because string changed
        }

        return modifiedHtml;
    }

    /**
     * Merges a CSS class hash into an existing tag string, handling existing class attributes.
     * @param {string} tagContent - The content of the tag (e.g., "div id='foo'").
     * @param {string} hash - The CSS class hash to merge.
     * @returns {string} The updated tag content.
     * @private
     */
    mergeClassIntoTag(tagContent, hash) {
        const classRegex = /class="([^"]*)"|class='([^']*)'/;
        const match = tagContent.match(classRegex);

        if (match) {
            const isSingleQuote = match[2] !== undefined;
            const existingClasses = isSingleQuote ? match[2] : match[1];
            const quote = isSingleQuote ? "'" : '"';
            
            if (existingClasses.includes(hash)) return tagContent;
            
            const newClassAttr = `class=${quote}${hash} ${existingClasses}${quote}`;
            return tagContent.replace(match[0], newClassAttr);
        } else {
            // Check if it's a self-closing tag or has other attributes
            if (tagContent.trim().endsWith('/')) {
                return tagContent.replace(/\s*\/$/, ` class="${hash}" /`);
            }
            return tagContent.trimEnd() + ` class="${hash}"`;
        }
    }

    /**
     * Replaces CSS variables (e.g., @primary) with their values.
     * @param {string} cssContent - The CSS content to process.
     * @returns {string} The CSS content with variables replaced.
     */
    applyVariables(cssContent) {
        let content = cssContent;
        // Sort variables by length descending to prevent partial replacement (e.g., @primary vs @primary-hover)
        const sortedVars = Object.entries(this.cssVariables).sort((a, b) => b[0].length - a[0].length);
        
        for (const [varName, varValue] of sortedVars) {
            // Use a negative lookahead to ensure we don't match a partial variable name
            // that is actually followed by a hyphen or word characters.
            const varRegex = new RegExp(`@${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'g');
            content = content.replace(varRegex, varValue);
        }
        return content;
    }

    /**
     * Generates a unique hash for a CSS block.
     * @param {string} cssContent - The CSS content.
     * @param {string} componentName - The name of the component.
     * @returns {string} The generated hash.
     */
    getHash(cssContent, componentName) {
        return "avenx-" + crypto.createHash('md5').update(cssContent + componentName).digest('hex').substring(0, 8);
    }

    /**
     * Extracts CSS rules from a content string, scopes them using the provided hash,
     * and appends them to the global stylesheet.
     * @param {string} cssContent - The CSS content to extract rules from.
     * @param {string} hash - The hash to use for scoping.
     * @private
     */
    extractRules(cssContent, hash) {
        if (this.#addedHashes.has(hash)) return;
        this.#addedHashes.add(hash);

        const scopeRules = (content, isNestedContext = false) => {
            let baseRules = "";
            let nestedRules = "";
            let current = "";
            let depth = 0;

            const processPart = (part) => {
                let rule = part.trim();
                if (!rule) return;

                if (rule.includes('{')) {
                    // It has a body block (nested rule or at-rule)
                    const openBraceIdx = rule.indexOf('{');
                    const closeBraceIdx = rule.lastIndexOf('}');
                    if (openBraceIdx !== -1 && closeBraceIdx !== -1) {
                        let selector = rule.substring(0, openBraceIdx).trim();
                        const body = rule.substring(openBraceIdx + 1, closeBraceIdx).trim();

                        if (selector.startsWith('@')) {
                            // Check if it's a nesting at-rule (like @media, @supports, @container, or @document)
                            if (selector.startsWith('@media') || selector.startsWith('@supports') || selector.startsWith('@document') || selector.startsWith('@container')) {
                                // Recursively process the rules inside
                                const scopedBody = scopeRules(body, true);
                                nestedRules += `${selector} {\n${scopedBody}}\n`;
                            } else {
                                // Non-nesting at-rule (like @keyframes, @font-face) - keep body unchanged
                                nestedRules += `${selector} {\n${body}\n}\n`;
                            }
                        } else {
                            // Regular selector rule
                            if (selector.includes('&')) {
                                selector = selector.replace(/&/g, `.${hash}`);
                            } else {
                                selector = `.${hash}${selector}`;
                            }
                            nestedRules += `${selector} { ${body} }\n`;
                        }
                    }
                } else {
                    // It's one or more base properties (e.g. "color: red; margin: 0;")
                    const props = rule.split(';').map(p => p.trim()).filter(p => p.length > 0);
                    props.forEach(prop => {
                        baseRules += prop + "; ";
                    });
                }
            };

            for (let i = 0; i < content.length; i++) {
                const char = content[i];
                current += char;
                if (char === '{') depth++;
                else if (char === '}') depth--;

                if (depth === 0 && (char === ';' || char === '}')) {
                    processPart(current);
                    current = "";
                }
            }

            if (current.trim()) processPart(current);

            let result = "";
            if (baseRules.trim()) {
                result += `.${hash} { ${baseRules.trim()} }\n`;
            }
            if (nestedRules.trim()) {
                result += nestedRules;
            }
            return result;
        };

        const scoped = scopeRules(cssContent, false);
        this.scopedStyles += scoped;
    }
}

module.exports = StyleProcessor;