import SelectorItem from './SelectorItem.js';
import SelectorCombinatorEnum from './SelectorCombinatorEnum.js';
import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js';
/**
 * Selector group RegExp.
 *
 * Group 1: Combinator (" ", ",", "+", ">", "̣~")
 * Group 2: Parentheses or brackets.
 */
const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/gm;
/**
 * Selector RegExp.
 *
 * Group 1: All (e.g. "*")
 * Group 2: Tag name (e.g. "div")
 * Group 3: ID (e.g. "#id")
 * Group 4: ID capture characters (e.g. "\:") (should be ignored)
 * Group 5: Class (e.g. ".class")
 * Group 6: Class capture characters (e.g. "r") (should be ignored)
 * Group 7: Attribute name when no value (e.g. "attr1")
 * Group 8: Attribute name capture characters (e.g. "t") (should be ignored)
 * Group 9: Attribute name when there is a value using apostrophe (e.g. "attr1")
 * Group 10: Attribute name capture characters (e.g. "t") (should be ignored)
 * Group 11: Attribute operator when using apostrophe (e.g. "~")
 * Group 12: Attribute value including apostrophes (e.g. "'value1'")
 * Group 13: Attribute value when using double apostrophes (e.g. "value1")
 * Group 14: Attribute value when using single apostrophes (e.g. "value1")
 * Group 15: Attribute modifier when using apostrophe (e.g. "i" or "s")
 * Group 16: Attribute name when there is a value not using apostrophe (e.g. "attr1")
 * Group 17: Attribute name capture characters (e.g. "t") (should be ignored)
 * Group 18: Attribute operator when not using apostrophe (e.g. "~")
 * Group 19: Attribute value when not using apostrophe (e.g. "value1")
 * Group 20: Attribute value capture characters (e.g. "s") (should be ignored)
 * Group 21: Pseudo name when arguments (e.g. "nth-child")
 * Group 22: Pseudo name when no arguments (e.g. "empty")
 * Group 23: Pseudo element (e.g. "::after", "::-webkit-inner-spin-button").
 */
const SELECTOR_REGEXP = /(\*)|([a-zA-Z0-9\u00A0-\uFFFF-]+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\[(([a-zA-Z0-9-_]|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("([^"]*)"|'([^']*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/gm;
/**
 * Selector pseudo RegExp.
 *
 * Group 1: Pseudo name (e.g. "nth-child")
 * Group 2: Parentheses or brackets.
 */
const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/gm;
/**
 * Escaped Character RegExp.
 */
const ESCAPED_CHARACTER_REGEXP = /\\/g;
/**
 * CSS Escape RegExp.
 *
 * Matches CSS escape sequences: hex escapes (e.g. \30 , \0041) and character escapes (e.g. \:, \\).
 * Hex escapes consist of 1-6 hex digits optionally followed by a single whitespace character.
 */
const CSS_UNESCAPE_REGEXP = /\\([0-9a-fA-F]{1,6})\s?|\\(.)/g;
/**
 * Attribute Escape RegExp.
 */
const ATTRIBUTE_ESCAPE_REGEXP = /[.*+?^${}()|[\]\\]/g;
/**
 * Nth Function.
 */
const NTH_FUNCTION = {
    odd: (n) => (n + 1) % 2 === 0,
    even: (n) => (n + 1) % 2 !== 0,
    alwaysFalse: () => false
};
/**
 * Space RegExp.
 */
const SPACE_REGEXP = / /g;
/**
 * Utility for parsing a selection string.
 */
export default class SelectorParser {
    window;
    scope;
    ignoreErrors;
    /**
     *
     * @param options
     * @param options.window
     * @param options.scope
     * @param options.ignoreErrors
     */
    constructor(options) {
        this.window = options.window;
        this.scope = options.scope;
        this.ignoreErrors = options.ignoreErrors ?? false;
    }
    /**
     * Parses a selector string and returns an instance of SelectorItem.
     *
     * @param window Window.
     * @param selector Selector.
     * @param options Options.
     * @param [options.scope] Scope.
     * @param [options.ignoreErrors] Ignores errors.
     * @returns Selector item.
     */
    getSelectorItem(selector) {
        return this.getSelectorGroups(selector)[0][0];
    }
    /**
     * Parses a selector string and returns instances of SelectorItem.
     *
     * @param window Window.
     * @param selector Selector.
     * @param options Options.
     * @param [options.scope] Scope.
     * @param [options.ignoreErrors] Ignores errors.
     * @returns Selector groups.
     */
    getSelectorGroups(selector) {
        selector = selector.trim();
        let currentGroup = [];
        const groups = [currentGroup];
        const regExp = new RegExp(SELECTOR_GROUP_REGEXP);
        const depth = {
            parentheses: 0,
            brackets: 0,
            doubleApostrophe: 0,
            singleApostrophe: 0
        };
        const name = this.scope.nodeType === NodeTypeEnum.documentNode ? 'Document' : 'Element';
        const error = new this.window.DOMException(`Failed to execute 'querySelectorAll' on '${name}': '${selector}' is not a valid selector.`);
        let match = null;
        let lastIndex = 0;
        let selectorItem = null;
        let combinator = SelectorCombinatorEnum.none;
        while ((match = regExp.exec(selector))) {
            if (match[1]) {
                // Matches combinator (" ", ",", "+", ">", "̣~")
                // We should ignore combinators that are inside parentheses, brackets or apostrophes
                if (depth.parentheses === 0 &&
                    depth.brackets === 0 &&
                    depth.singleApostrophe === 0 &&
                    depth.doubleApostrophe === 0) {
                    const childSelector = selector.substring(lastIndex, match.index).trim();
                    switch (match[1].trim()) {
                        case ',':
                            selectorItem = this.getSelectorGroupItem(childSelector, combinator);
                            if (!selectorItem) {
                                if (this.ignoreErrors) {
                                    return [];
                                }
                                throw error;
                            }
                            currentGroup.push(selectorItem);
                            currentGroup = [];
                            groups.push(currentGroup);
                            combinator = SelectorCombinatorEnum.none;
                            break;
                        case '>':
                            selectorItem = this.getSelectorGroupItem(childSelector, combinator);
                            if (!selectorItem) {
                                if (this.ignoreErrors) {
                                    return [];
                                }
                                throw error;
                            }
                            currentGroup.push(selectorItem);
                            combinator = SelectorCombinatorEnum.child;
                            break;
                        case '+':
                            selectorItem = this.getSelectorGroupItem(childSelector, combinator);
                            if (!selectorItem) {
                                if (this.ignoreErrors) {
                                    return [];
                                }
                                throw error;
                            }
                            currentGroup.push(selectorItem);
                            combinator = SelectorCombinatorEnum.adjacentSibling;
                            break;
                        case '~':
                            selectorItem = this.getSelectorGroupItem(childSelector, combinator);
                            if (!selectorItem) {
                                if (this.ignoreErrors) {
                                    return [];
                                }
                                throw error;
                            }
                            currentGroup.push(selectorItem);
                            combinator = SelectorCombinatorEnum.subsequentSibling;
                            break;
                        case '':
                            selectorItem = this.getSelectorGroupItem(childSelector, combinator);
                            if (!selectorItem) {
                                if (this.ignoreErrors) {
                                    return [];
                                }
                                throw error;
                            }
                            currentGroup.push(selectorItem);
                            combinator = SelectorCombinatorEnum.descendant;
                            break;
                    }
                    lastIndex = regExp.lastIndex;
                }
            }
            else {
                // Matches parentheses or brackets.
                switch (match[2]) {
                    case '(':
                        if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
                            depth.parentheses++;
                        }
                        break;
                    case ')':
                        if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
                            depth.parentheses--;
                        }
                        break;
                    case '[':
                        if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
                            depth.brackets++;
                        }
                        break;
                    case ']':
                        if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
                            depth.brackets--;
                        }
                        break;
                    case '"':
                        if (depth.singleApostrophe === 0) {
                            depth.doubleApostrophe = depth.doubleApostrophe === 1 ? 0 : 1;
                        }
                        break;
                    case "'":
                        if (depth.doubleApostrophe === 0) {
                            depth.singleApostrophe = depth.singleApostrophe === 1 ? 0 : 1;
                        }
                        break;
                }
            }
        }
        selectorItem = this.getSelectorGroupItem(selector.substring(lastIndex), combinator);
        if (!selectorItem ||
            depth.parentheses !== 0 ||
            depth.brackets !== 0 ||
            depth.singleApostrophe !== 0 ||
            depth.doubleApostrophe !== 0) {
            if (this.ignoreErrors) {
                return [];
            }
            throw error;
        }
        if (combinator === SelectorCombinatorEnum.none && currentGroup.length > 0) {
            groups.push([selectorItem]);
        }
        else {
            currentGroup.push(selectorItem);
        }
        return groups;
    }
    /**
     * Parses a selector string and returns an SelectorItem.
     *
     * @param selector Selector.
     * @param combinator Combinator.
     * @returns Selector item.
     */
    getSelectorGroupItem(selector, combinator) {
        selector = selector.trim();
        const ignoreErrors = this.ignoreErrors;
        const scope = this.scope;
        if (!selector) {
            return null;
        }
        if (selector === '*') {
            return new SelectorItem({ scope, tagName: '*', combinator, ignoreErrors });
        }
        const regexp = new RegExp(SELECTOR_REGEXP);
        const selectorItem = new SelectorItem({
            scope,
            combinator,
            ignoreErrors
        });
        let match;
        let lastIndex = 0;
        while ((match = regexp.exec(selector))) {
            if (selector.substring(lastIndex, match.index).trim() !== '') {
                return null;
            }
            if (match[1]) {
                // Matches all, e.g. "*"
                selectorItem.tagName = '*';
            }
            else if (match[2]) {
                // Matches tag name, e.g. "div"
                selectorItem.tagName = match[2].toUpperCase();
            }
            else if (match[3]) {
                // Matches ID, e.g. "#id"
                selectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');
            }
            else if (match[5]) {
                // Matches class names, e.g. ".class1"
                selectorItem.classNames = selectorItem.classNames || [];
                selectorItem.classNames.push(match[5].replace(ESCAPED_CHARACTER_REGEXP, ''));
            }
            else if (match[7]) {
                // Matches attributes without value, e.g. [attr]
                selectorItem.attributes = selectorItem.attributes || [];
                selectorItem.attributes.push({
                    name: match[7].replace(ESCAPED_CHARACTER_REGEXP, ''),
                    operator: null,
                    value: null,
                    modifier: null,
                    regExp: null
                });
            }
            else if (match[9] && (match[13] !== undefined || match[14] !== undefined)) {
                // Matches attributes with apostrophes, e.g. [attr='value'] or [attr="value"] or [attr='value' i]
                const value = match[13] ?? match[14];
                const unescapedValue = SelectorParser.cssUnescape(value);
                selectorItem.attributes = selectorItem.attributes || [];
                selectorItem.attributes.push({
                    name: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''),
                    operator: match[11] || null,
                    value: unescapedValue,
                    modifier: match[15] || null,
                    regExp: this.getAttributeRegExp({
                        operator: match[11],
                        value: unescapedValue,
                        modifier: match[15]
                    })
                });
            }
            else if (match[16] && match[19] !== undefined) {
                // Matches attributes without apostrophes, e.g. [attr=value] or [attr=value i]
                const unescapedValue = SelectorParser.cssUnescape(match[19]);
                selectorItem.attributes = selectorItem.attributes || [];
                selectorItem.attributes.push({
                    name: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''),
                    operator: match[18] || null,
                    value: unescapedValue,
                    modifier: null,
                    regExp: this.getAttributeRegExp({ operator: match[18], value: unescapedValue })
                });
            }
            else if (match[21]) {
                // Matches pseudo selectors with arguments, e.g. ":nth-child(2n+1)" or ":not(.class)"
                const pseudoRegExp = new RegExp(SELECTOR_PSEUDO_REGEXP);
                let pseudoMatch = null;
                let name = null;
                let depth = 0;
                let pseudoStartIndex = -1;
                while ((pseudoMatch = pseudoRegExp.exec(match[0]))) {
                    if (pseudoMatch[1]) {
                        if (depth === 0) {
                            name = pseudoMatch[1];
                        }
                    }
                    else if (pseudoMatch[2]) {
                        if (pseudoMatch[2] === '(') {
                            if (depth === 0) {
                                pseudoStartIndex = pseudoRegExp.lastIndex;
                            }
                            depth++;
                        }
                        else if (pseudoMatch[2] === ')') {
                            depth--;
                            if (depth < 0) {
                                // More closing parentheses than opening parentheses, invalid selector
                                return null;
                            }
                            if (depth === 0) {
                                // Missing start parenthesis or name for pseudo selector, invalid selector
                                if (pseudoStartIndex === -1 || !name) {
                                    return null;
                                }
                                selectorItem.pseudos = selectorItem.pseudos || [];
                                selectorItem.pseudos.push(this.getPseudo(name, match[0].substring(pseudoStartIndex, pseudoMatch.index)));
                                name = null;
                                pseudoStartIndex = -1;
                            }
                        }
                    }
                }
            }
            else if (match[22]) {
                // Matches pseudo selectors without arguments, e.g. ":empty" or ":checked"
                selectorItem.pseudos = selectorItem.pseudos || [];
                selectorItem.pseudos.push(this.getPseudo(match[22], null));
            }
            else if (match[23]) {
                // Matches pseudo elements, e.g. "::after" or "::-webkit-inner-spin-button"
                selectorItem.isPseudoElement = true;
            }
            lastIndex = regexp.lastIndex;
        }
        // If there are any characters left in the selector that were not matched, the selector is invalid.
        if (lastIndex < selector.length) {
            return null;
        }
        return selectorItem;
    }
    /**
     * Returns attribute RegExp.
     *
     * @param attribute Attribute.
     * @param attribute.value Attribute value.
     * @param attribute.operator Attribute operator.
     * @param attribute.modifier Attribute modifier.
     * @returns Attribute RegExp.
     */
    getAttributeRegExp(attribute) {
        const modifier = attribute.modifier === 'i' ? 'i' : '';
        if (!attribute.operator || !attribute.value) {
            return null;
        }
        // Escape special regex characters in the value
        const escapedValue = attribute.value.replace(ATTRIBUTE_ESCAPE_REGEXP, '\\$&');
        switch (attribute.operator) {
            // [attribute~="value"] - Contains a specified word.
            case '~':
                return new RegExp(`[- ]${escapedValue}|${escapedValue}[- ]|^${escapedValue}$`, modifier);
            // [attribute|="value"] - Starts with the specified word.
            case '|':
                return new RegExp(`^${escapedValue}[- ]|^${escapedValue}$`, modifier);
            // [attribute^="value"] - Begins with a specified value.
            case '^':
                return new RegExp(`^${escapedValue}`, modifier);
            // [attribute$="value"] - Ends with a specified value.
            case '$':
                return new RegExp(`${escapedValue}$`, modifier);
            // [attribute*="value"] - Contains a specified value.
            case '*':
                return new RegExp(`${escapedValue}`, modifier);
            default:
                return null;
        }
    }
    /**
     * Returns pseudo.
     *
     * @param name Pseudo name.
     * @param args Pseudo arguments.
     * @returns Pseudo.
     */
    getPseudo(name, args) {
        const lowerName = name.toLowerCase();
        if (args) {
            args = args.trim();
        }
        if (!args) {
            return { name: lowerName, arguments: null, selectorItems: null, nthFunction: null };
        }
        switch (lowerName) {
            case 'nth-last-child':
            case 'nth-child':
                const nthOfIndex = args.indexOf(' of ');
                const nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args;
                const selectorItem = nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null;
                return {
                    name: lowerName,
                    arguments: args,
                    selectorItems: selectorItem ? [selectorItem] : null,
                    nthFunction: this.getPseudoNthFunction(nthFunction)
                };
            case 'nth-of-type':
            case 'nth-last-of-type':
                return {
                    name: lowerName,
                    arguments: args,
                    selectorItems: null,
                    nthFunction: this.getPseudoNthFunction(args)
                };
            case 'not':
                const notSelectorItems = [];
                for (const group of this.getSelectorGroups(args)) {
                    if (group[0]) {
                        notSelectorItems.push(group[0]);
                    }
                }
                return {
                    name: lowerName,
                    arguments: args,
                    selectorItems: notSelectorItems,
                    nthFunction: null
                };
            case 'is':
            case 'where':
                const selectorItems = [];
                for (const group of this.getSelectorGroups(args)) {
                    if (group[0]) {
                        selectorItems.push(group[0]);
                    }
                }
                return {
                    name: lowerName,
                    arguments: args,
                    selectorItems,
                    nthFunction: null
                };
            case 'has':
                const hasSelectorItems = [];
                // The ":has()" pseudo selector doesn't allow for it to be nested inside another ":has()" pseudo selector, as it can lead to cyclic querying.
                if (!args.includes(':has(')) {
                    let newArgs = args;
                    if (args[0] === '+') {
                        newArgs = args.replace('+', '');
                    }
                    else if (args[0] === '>') {
                        newArgs = args.replace('>', '');
                    }
                    for (const group of this.getSelectorGroups(newArgs)) {
                        if (group[0]) {
                            hasSelectorItems.push(group[0]);
                        }
                    }
                }
                return {
                    name: lowerName,
                    arguments: args || '',
                    selectorItems: hasSelectorItems,
                    nthFunction: null
                };
            case 'scope':
            case 'root':
            default:
                return { name: lowerName, arguments: args, selectorItems: null, nthFunction: null };
        }
    }
    /**
     * Returns pseudo nth function.
     *
     * Based on:
     * https://github.com/dperini/nwsapi/blob/master/src/nwsapi.js
     *
     * @param args Pseudo arguments.
     * @returns Pseudo nth function.
     */
    getPseudoNthFunction(args) {
        if (!args) {
            return null;
        }
        if (args === 'odd') {
            return NTH_FUNCTION.odd;
        }
        else if (args === 'even') {
            return NTH_FUNCTION.even;
        }
        const parts = args.replace(SPACE_REGEXP, '').split('n');
        let partA = parseInt(parts[0], 10) || 0;
        if (parts[0] == '-') {
            partA = -1;
        }
        if (parts.length === 1) {
            return (n) => n == partA;
        }
        let partB = parseInt(parts[1], 10) || 0;
        if (parts[0] == '+') {
            partB = 1;
        }
        if (partA >= 1 || partA <= -1) {
            if (partA >= 1) {
                if (Math.abs(partA) === 1) {
                    return (n) => n > partB - 1;
                }
                return (n) => n > partB - 1 && (n + -1 * partB) % partA === 0;
            }
            if (Math.abs(partA) === 1) {
                return (n) => n < partB + 1;
            }
            return (n) => n < partB + 1 && (n + -1 * partB) % partA === 0;
        }
        if (parts[0]) {
            return (n) => n === partB;
        }
        return (n) => n > partB - 1;
    }
    /**
     * Unescapes CSS escape sequences in a string value.
     *
     * Handles hex escapes (e.g. "\30 " → "0", "\0041" → "A") and character escapes (e.g. "\:" → ":").
     *
     * @see https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
     * @param value Escaped CSS value.
     * @returns Unescaped value.
     */
    static cssUnescape(value) {
        return value.replace(CSS_UNESCAPE_REGEXP, (_match, hex, char) => {
            if (hex) {
                return String.fromCodePoint(parseInt(hex, 16));
            }
            return char;
        });
    }
}
//# sourceMappingURL=SelectorParser.js.map