XPathExpr <- R6Class("XPathExpr",
    public = list(
        path = "",
        element = "*",
        condition = "",
        star_prefix = FALSE,
        initialize = function(
            path = "", element = "*", condition = "", star_prefix = FALSE) {
            self$path <- path
            self$element <- element
            self$condition <-
                if (nzchar(condition)) paste0("(", condition, ")")
                else condition
            self$star_prefix <- star_prefix
        },
        str = function() {
            p <- paste0(self$path, self$element)
            if (nzchar(self$condition))
                p <- paste0(p, "[", self$condition, "]")
            p
        },
        repr = function() {
            paste0(first_class_name(self), "[", self$str(), "]")
        },
        add_condition = function(condition, conjunction = "and") {
            self$condition <-
                if (nzchar(self$condition))
                    paste0(self$condition, " ", conjunction, " (", condition, ")")
                else
                    paste0("(", condition, ")")
        },
        add_name_test = function() {
            if (self$element == "*")
                return()
            self$add_condition(paste0("name() = ", xpath_literal(self$element)))
            self$element <- "*"
        },
        join = function(combiner, other) {
            p <- paste0(self$str(), combiner)
            if (other$path != "*/")
                p <- paste0(p, other$path)
            self$path <- p
            self$element <- other$element
            self$condition <- other$condition
            self
        },
        show = function() { # nocov start
            cat(self$repr(), "\n")
        } # nocov end
    ))

is_safe_name <- function(name) {
    grepl("^[a-zA-Z_][a-zA-Z0-9_.-]*$", name)
}

first_class_name <- function(obj) {
    result <- class(obj)[1]

    # HACK!
    # R.oo clashes with our preferred use of 'Class' for the name of the
    # ClassSelector class, even though it is hidden in our package.
    # Because the name of the class is used in places, perform a
    # special case rename from ClassSelector to Class.
    if (result == "ClassSelector") "Class" else result
}

xpath_literal <- function(literal) {
    if (!is.character(literal) || length(literal) != 1) {
        stop("literal must be a single character string")
    }

    lenseq <- seq_len(nchar(literal))
    split_chars <- substring(literal, lenseq, lenseq)

    if (!any(split_chars == "'")) {
        literal <- paste0("'", literal, "'")
    } else if (!any(split_chars == '"')) {
        literal <- paste0('"', literal, '"')
    } else {
        dq_inds <- which(split_chars == "'")
        sq_inds <- which(split_chars != "'")
        split_chars[dq_inds] <- paste0('"', split_chars[dq_inds], '"')
        split_chars[sq_inds] <- paste0("'", split_chars[sq_inds], "'")

        literal <- paste(split_chars, collapse = ",")
        literal <- paste0("concat(", literal, ")")
    }

    literal
}

GenericTranslator <- R6Class("GenericTranslator",
    public = list(
        combinator_mapping = c(" " = "descendant",
                               ">" = "child",
                               "+" = "direct_adjacent",
                               "~" = "indirect_adjacent"),
        # != is not in Selectors Level 3, but included anyway
        attribute_operator_mapping = c("exists" = "exists",
                                       "=" = "equals",
                                       "~=" = "includes",
                                       "|=" = "dashmatch",
                                       "^=" = "prefixmatch",
                                       "$=" = "suffixmatch",
                                       "*=" = "substringmatch",
                                       "!=" = "different"),
        id_attribute = "id",
        lang_attribute = "xml:lang",
        lower_case_element_names = FALSE,
        lower_case_attribute_names = FALSE,
        lower_case_attribute_values = FALSE,
        css_to_xpath = function(css, prefix = "descendant-or-self::") {
            selectors <- parse(css)
            selectors <-
                if (is.null(selectors)) list()
                else if (!is.list(selectors)) list(selectors)
                else selectors

            lapply(selectors, function(selector) {
                if (first_class_name(selector) == "Selector" &&
                    !is.null(selector$pseudo_element))
                    stop("Pseudo-elements are not supported.")
            })

            char_selectors <-
                sapply(selectors,
                       function(selector)
                           self$selector_to_xpath(selector, prefix))

            paste0(char_selectors, collapse = " | ")
        },
        selector_to_xpath = function(selector, prefix = "descendant-or-self::") {
            tree <- selector$parsed_tree
            xpath <- self$xpath(tree)
            if (!inherits(xpath, "XPathExpr"))
                stop("'xpath' is not an instance of 'XPathExpr'")
            paste0(if (!is.null(prefix)) prefix else "", xpath$str())
        },
        xpath = function(parsed_selector) {
            type_name <- first_class_name(parsed_selector)
            method_name <- paste0("xpath_", tolower(type_name))

            if (method_name == "xpath_attrib")
                self$xpath_attrib(parsed_selector)
            else if (method_name == "xpath_class")
                self$xpath_class(parsed_selector)
            else if (method_name == "xpath_combinedselector")
                self$xpath_combinedselector(parsed_selector)
            else if (method_name == "xpath_element")
                self$xpath_element(parsed_selector)
            else if (method_name == "xpath_matching")
                self$xpath_matching(parsed_selector)
            else if (method_name == "xpath_where")
                self$xpath_where(parsed_selector)
            else if (method_name == "xpath_has")
                self$xpath_has(parsed_selector)
            else if (method_name == "xpath_function")
                self$xpath_function(parsed_selector)
            else if (method_name == "xpath_hash")
                self$xpath_hash(parsed_selector)
            else if (method_name == "xpath_negation")
                self$xpath_negation(parsed_selector)
            else if (method_name == "xpath_pseudo")
                self$xpath_pseudo(parsed_selector)
            else
                stop("Unknown method name '", type_name, "'")
        },
        xpath_combinedselector = function(combined) {
            combinator <- paste0(
                "xpath_",
                self$combinator_mapping[combined$combinator],
                "_combinator")

            left_xpath <- self$xpath(combined$selector)
            right_xpath <- self$xpath(combined$subselector)
            if (combinator == "xpath_descendant_combinator")
                self$xpath_descendant_combinator(
                    left = left_xpath, right = right_xpath)
            else if (combinator == "xpath_child_combinator")
                self$xpath_child_combinator(
                    left = left_xpath, right = right_xpath)
            else if (combinator == "xpath_direct_adjacent_combinator")
                self$xpath_direct_adjacent_combinator(
                    left = left_xpath, right = right_xpath)
            else if (combinator == "xpath_indirect_adjacent_combinator")
                self$xpath_indirect_adjacent_combinator(
                    left = left_xpath, right = right_xpath)
            else
                stop("Unknown combinator '",
                     self$combinator_mapping[combined$combinator], "'")
        },
        xpath_negation = function(negation) {
            xpath <- self$xpath(negation$selector)

            # Collect all conditions from the selector list
            conditions <- character(0)
            for (subselector in negation$selector_list) {
                sub_xpath <- self$xpath(subselector)
                sub_xpath$add_name_test()
                if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                    conditions <- c(conditions, sub_xpath$condition)
                }
            }

            # Combine conditions with OR (any match means element is excluded)
            if (length(conditions) > 0) {
                combined_condition <- paste0(conditions, collapse = " or ")
                xpath$add_condition(paste0("not(", combined_condition, ")"))
            } else {
                xpath$add_condition("0")
            }
            xpath
        },
        xpath_matching = function(matching) {
            xpath <- self$xpath(matching$selector)
            exprs <- sapply(matching$selector_list, function(s) self$xpath(s))

            for (e in exprs) {
                e$add_name_test()
                if (nzchar(e$condition)) {
                    xpath$add_condition(e$condition, "or")
                }
            }

            xpath
        },
        xpath_where = function(where) {
            # :where() behaves exactly like :is() in terms of matching,
            # but has zero specificity (handled in the Where class itself)
            xpath <- self$xpath(where$selector)
            exprs <- sapply(where$selector_list, function(s) self$xpath(s))

            for (e in exprs) {
                e$add_name_test()
                if (nzchar(e$condition)) {
                    xpath$add_condition(e$condition, "or")
                }
            }

            xpath
        },
        xpath_has = function(has) {
            # :has() matches elements that have descendants matching the selector list
            xpath <- self$xpath(has$selector)

            # Build conditions that check for the existence of descendants
            conditions <- character(0)
            for (subselector in has$selector_list) {
                sub_xpath <- self$xpath(subselector)
                # Build the full descendant path
                sub_xpath$add_name_test()
                desc_test <- paste0(".//", sub_xpath$element)
                if (nzchar(sub_xpath$condition)) {
                    desc_test <- paste0(desc_test, "[", sub_xpath$condition, "]")
                }
                conditions <- c(conditions, desc_test)
            }

            # Combine conditions with OR (any descendant match means the element matches)
            if (length(conditions) > 0) {
                combined_condition <- paste0(conditions, collapse = " | ")
                xpath$add_condition(combined_condition)
            }

            xpath
        },
        xpath_function = function(fn) {
            method_name <- paste0(
                "xpath_",
                gsub("-", "_", fn$name),
                "_function")
            xp <- self$xpath(fn$selector)

            if (method_name == "xpath_contains_function")
                self$xpath_contains_function(xp, fn)
            else if (method_name == "xpath_lang_function")
                self$xpath_lang_function(xp, fn)
            else if (method_name == "xpath_dir_function")
                self$xpath_dir_function(xp, fn)
            else if (method_name == "xpath_nth_child_function")
                self$xpath_nth_child_function(xp, fn)
            else if (method_name == "xpath_nth_last_child_function")
                self$xpath_nth_last_child_function(xp, fn)
            else if (method_name == "xpath_nth_of_type_function")
                self$xpath_nth_of_type_function(xp, fn)
            else if (method_name == "xpath_nth_last_of_type_function")
                self$xpath_nth_last_of_type_function(xp, fn)
            else
                stop("The pseudo-class :",
                     gsub("-", "_", fn$name),
                     "() is unknown")
        },
        xpath_pseudo = function(pseudo) {
            method_name <- paste0(
                "xpath_",
                gsub("-", "_", pseudo$ident),
                "_pseudo")
            xp <- self$xpath(pseudo$selector)

            if (method_name == "xpath_root_pseudo")
                self$xpath_root_pseudo(xp)
            else if (method_name == "xpath_first_child_pseudo")
                self$xpath_first_child_pseudo(xp)
            else if (method_name == "xpath_last_child_pseudo")
                self$xpath_last_child_pseudo(xp)
            else if (method_name == "xpath_first_of_type_pseudo")
                self$xpath_first_of_type_pseudo(xp)
            else if (method_name == "xpath_last_of_type_pseudo")
                self$xpath_last_of_type_pseudo(xp)
            else if (method_name == "xpath_only_child_pseudo")
                self$xpath_only_child_pseudo(xp)
            else if (method_name == "xpath_only_of_type_pseudo")
                self$xpath_only_of_type_pseudo(xp)
            else if (method_name == "xpath_empty_pseudo")
                self$xpath_empty_pseudo(xp)
            else if (method_name == "xpath_any_link_pseudo")
                self$xpath_any_link_pseudo(xp)
            else if (method_name == "xpath_link_pseudo")
                self$xpath_link_pseudo(xp)
            else if (method_name == "xpath_visited_pseudo")
                self$xpath_visited_pseudo(xp)
            else if (method_name == "xpath_hover_pseudo")
                self$xpath_hover_pseudo(xp)
            else if (method_name == "xpath_active_pseudo")
                self$xpath_active_pseudo(xp)
            else if (method_name == "xpath_focus_pseudo")
                self$xpath_focus_pseudo(xp)
            else if (method_name == "xpath_target_pseudo")
                self$xpath_target_pseudo(xp)
            else if (method_name == "xpath_target_within_pseudo")
                self$xpath_target_within_pseudo(xp)
            else if (method_name == "xpath_local_link_pseudo")
                self$xpath_local_link_pseudo(xp)
            else if (method_name == "xpath_enabled_pseudo")
                self$xpath_enabled_pseudo(xp)
            else if (method_name == "xpath_disabled_pseudo")
                self$xpath_disabled_pseudo(xp)
            else if (method_name == "xpath_checked_pseudo")
                self$xpath_checked_pseudo(xp)
            else
                stop("The pseudo-class :", pseudo$ident, " is unknown")
        },
        xpath_attrib = function(selector) {
            operator <- self$attribute_operator_mapping[selector$operator]
            method_name <- paste0("xpath_attrib_", operator)
            if (self$lower_case_attribute_names) {
                name <- tolower(selector$attrib)
            } else {
                name <- selector$attrib
            }
            safe <- is_safe_name(name)
            if (!is.null(selector$namespace)) {
                name <- paste0(selector$namespace, ":", name)
            }
            if (safe) {
                attrib <- paste0("@", name)
            } else {
                attrib <- paste0(
                    "attribute::*[name() = ", xpath_literal(name), "]")
            }
            if (self$lower_case_attribute_names) {
                value <- tolower(selector$value)
            } else {
                value <- selector$value
            }

            xp <- self$xpath(selector$selector)
            if (method_name == "xpath_attrib_dashmatch")
                self$xpath_attrib_dashmatch(xp, attrib, value)
            else if (method_name == "xpath_attrib_different")
                self$xpath_attrib_different(xp, attrib, value)
            else if (method_name == "xpath_attrib_equals")
                self$xpath_attrib_equals(xp, attrib, value)
            else if (method_name == "xpath_attrib_exists")
                self$xpath_attrib_exists(xp, attrib, value)
            else if (method_name == "xpath_attrib_includes")
                self$xpath_attrib_includes(xp, attrib, value)
            else if (method_name == "xpath_attrib_prefixmatch")
                self$xpath_attrib_prefixmatch(xp, attrib, value)
            else if (method_name == "xpath_attrib_substringmatch")
                self$xpath_attrib_substringmatch(xp, attrib, value)
            else if (method_name == "xpath_attrib_suffixmatch")
                self$xpath_attrib_suffixmatch(xp, attrib, value)
            else
                stop("Unknown attribute operator '", operator, "'")
        },
        # .foo is defined as [class~=foo] in the spec
        xpath_class = function(class_selector) {
            xpath <- self$xpath(class_selector$selector)
            self$xpath_attrib_includes(xpath, "@class",
                                       class_selector$class_name)
            xpath
        },
        xpath_hash = function(id_selector) {
            xpath <- self$xpath(id_selector$selector)
            self$xpath_attrib_equals(xpath, "@id", id_selector$id)
            xpath
        },
        xpath_element = function(selector) {
            element <- selector$element
            if (is.null(element)) {
                element <- "*"
                safe <- TRUE
            } else {
                safe <- is_safe_name(element)
                if (self$lower_case_element_names)
                    element <- tolower(element)
            }
            if (!is.null(selector$namespace)) {
                # Namespace prefixes are case-sensitive.
                # https://www.w3.org/TR/css-namespaces-3/#prefixes
                element <- paste0(selector$namespace, ":", element)
                safe <- safe && is_safe_name(selector$namespace)
            }
            xpath <- XPathExpr$new(element = element)
            if (!safe)
                xpath$add_name_test()
            xpath
        },
        xpath_descendant_combinator = function(left, right) {
            left$join("//", right)
        },
        xpath_child_combinator = function(left, right) {
            left$join("/", right)
        },
        xpath_direct_adjacent_combinator = function(left, right) {
            xpath <- left$join("/following-sibling::", right)
            target_element <- xpath$element
            existing_condition <- xpath$condition
            xpath$add_name_test()

            if (nzchar(existing_condition)) {
                # Has existing conditions from right selector (e.g., attributes)
                # Result: *[1][self::element][existing_condition]
                xpath$condition <- paste0("1][self::", target_element, "][", existing_condition)
            } else {
                # No existing conditions, just position and element test
                xpath$condition <- paste0("1][self::", target_element)
            }

            xpath
        },
        xpath_indirect_adjacent_combinator = function(left, right) {
            left$join("/following-sibling::", right)
        },
        xpath_nth_child_function = function(xpath, fn, last = FALSE,
                                            add_name_test = TRUE) {
            ab <- parse_series(fn$arguments)

            # Validate that parse_series returned valid results
            if (is.null(ab) || length(ab) != 2) {
                stop("Invalid nth-child expression")
            }

            a <- ab[1]
            b <- ab[2]

            # Validate that a and b are valid integers (not NA)
            if (is.na(a) || is.na(b)) {
                stop("Invalid nth-child expression: could not parse as valid integers")
            }

            # From https://www.w3.org/TR/selectors-4/#structural-pseudos:
            #
            # :nth-child(an+b)
            #       an+b-1 siblings before
            #
            # :nth-last-child(an+b)
            #       an+b-1 siblings after
            #
            # :nth-of-type(an+b)
            #       an+b-1 siblings with the same expanded element name before
            #
            # :nth-last-of-type(an+b)
            #       an+b-1 siblings with the same expanded element name after
            #
            # CSS Selectors Level 4 adds optional "of S" selector list:
            # :nth-child(an+b of S) - count only siblings that match selector S
            #
            # So,
            # for :nth-child and :nth-of-type
            #
            #    count(preceding-sibling::<nodetest>) = an+b-1
            #
            # for :nth-last-child and :nth-last-of-type
            #
            #    count(following-sibling::<nodetest>) = an+b-1
            #
            # therefore,
            #    count(...) - (b-1) = 0 (mod a)
            #
            # if a == 0:
            # ~~~~~~~~~~
            #    count(...) = b-1
            #
            # if a < 0:
            # ~~~~~~~~~
            #    count(...) - b +1 <= 0
            # -> count(...) <= b-1
            #
            # if a > 0:
            # ~~~~~~~~~
            #    count(...) - b +1 >= 0
            # -> count(...) >= b-1

            # work with b-1 instead
            b_min_1 <- b - 1

            # early-exit condition 1:
            # ~~~~~~~~~~~~~~~~~~~~~~~
            # for a == 1, nth-*(an+b) means n+b-1 siblings before/after,
            # and since n %in% {0, 1, 2, ...}, if b-1<=0,
            # there is always an "n" matching any number of siblings (maybe none)
            if (a == 1 && b_min_1 <= 0) {
                # CSS Level 4: When selector list is provided, ensure current element matches
                if (!is.null(fn$selector_list) && length(fn$selector_list) > 0) {
                    conditions <- character(0)
                    for (subselector in fn$selector_list) {
                        sub_xpath <- self$xpath(subselector)
                        sub_xpath$add_name_test()
                        if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                            conditions <- c(conditions, sub_xpath$condition)
                        }
                    }

                    if (length(conditions) > 0) {
                        # Current element must match at least one selector (OR)
                        combined_condition <- paste0(conditions, collapse = " or ")
                        xpath$add_condition(combined_condition)
                    }
                }
                return(xpath)
            }
            # early-exit condition 2:
            # ~~~~~~~~~~~~~~~~~~~~~~~
            # an+b-1 siblings with a<0 and (b-1)<0 is not possible
            if (a < 0 && b_min_1 < 0) {
                xpath$add_condition("0")

                # CSS Level 4: When selector list is provided, ensure current element matches
                # Even though the condition is always false, we should still check the selector
                if (!is.null(fn$selector_list) && length(fn$selector_list) > 0) {
                    conditions <- character(0)
                    for (subselector in fn$selector_list) {
                        sub_xpath <- self$xpath(subselector)
                        sub_xpath$add_name_test()
                        if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                            conditions <- c(conditions, sub_xpath$condition)
                        }
                    }

                    if (length(conditions) > 0) {
                        # Current element must match at least one selector (OR)
                        combined_condition <- paste0(conditions, collapse = " or ")
                        xpath$add_condition(combined_condition)
                    }
                }

                return(xpath)
            }

            # `add_name_test` boolean is inverted and somewhat counter-intuitive:
            #
            # nth_of_type() calls nth_child(add_name_test=False)
            if (add_name_test) {
                nodetest <- "*"
            } else {
                nodetest <- xpath$element
            }

            # Build the predicate for selector list filtering (CSS Level 4)
            selector_predicate <- ""
            if (!is.null(fn$selector_list) && length(fn$selector_list) > 0) {
                # Generate XPath conditions for each selector in the list
                conditions <- character(0)
                for (subselector in fn$selector_list) {
                    sub_xpath <- self$xpath(subselector)
                    sub_xpath$add_name_test()
                    if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                        conditions <- c(conditions, sub_xpath$condition)
                    }
                }

                if (length(conditions) > 0) {
                    # Combine conditions with OR (any match counts the sibling)
                    combined_condition <- paste0(conditions, collapse = " or ")
                    selector_predicate <- paste0("[", combined_condition, "]")
                }
            }

            # count siblings before or after the element
            if (!last) {
                siblings_count <- paste0("count(preceding-sibling::",
                                         nodetest, selector_predicate, ")")
            } else {
                siblings_count <- paste0("count(following-sibling::",
                                         nodetest, selector_predicate, ")")
            }

            # special case of fixed position: nth-*(0n+b)
            # if a == 0:
            # ~~~~~~~~~~
            #    count(***-sibling::***) = b-1
            if (a == 0) {
                xpath$add_condition(paste0(siblings_count, " = ", b_min_1))

                # CSS Level 4: When selector list is provided, ensure current element matches
                if (!is.null(fn$selector_list) && length(fn$selector_list) > 0) {
                    conditions <- character(0)
                    for (subselector in fn$selector_list) {
                        sub_xpath <- self$xpath(subselector)
                        sub_xpath$add_name_test()
                        if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                            conditions <- c(conditions, sub_xpath$condition)
                        }
                    }

                    if (length(conditions) > 0) {
                        # Current element must match at least one selector (OR)
                        combined_condition <- paste0(conditions, collapse = " or ")
                        xpath$add_condition(combined_condition)
                    }
                }

                return(xpath)
            }

            expr <- character(0)

            if (a > 0) {
                # siblings count, an+b-1, is always >= 0,
                # so if a>0, and (b-1)<=0, an "n" exists to satisfy this,
                # therefore, the predicate is only interesting if (b-1)>0
                if (b_min_1 > 0) {
                    expr <- c(expr, paste0(siblings_count, " >= ", b_min_1))
                }
            } else {
                # if a<0, and (b-1)<0, no "n" satisfies this,
                # this is tested above as an early exist condition
                # otherwise,
                expr <- c(expr, paste0(siblings_count, " <= ", b_min_1))
            }

            # operations modulo 1 or -1 are simpler, one only needs to verify:
            #
            # - either:
            # count(***-sibling::***) - (b-1) = n = 0, 1, 2, 3, etc.,
            #   i.e. count(***-sibling::***) >= (b-1)
            #
            # - or:
            # count(***-sibling::***) - (b-1) = -n = 0, -1, -2, -3, etc.,
            #   i.e. count(***-sibling::***) <= (b-1)
            # we we just did above.
            #
            if (abs(a) != 1) {
                # count(***-sibling::***) - (b-1) = 0 (mod a)
                left <- siblings_count

                # apply "modulo a" on 2nd term, -(b-1),
                # to simplify things like "(... +6) % -3",
                # and also make it positive with |a|
                b_neg <- (-b_min_1) %% abs(a)

                if (b_neg != 0) {
                    b_neg <- paste0("+", b_neg)
                    left <- paste0("(", left, " ", b_neg, ")")
                }

                expr <- c(expr, paste0(left, " mod ", a, " = 0"))
            }

            if (length(expr)) {
                expr <- paste0(expr, collapse = " and ")
                xpath$add_condition(expr)
            }

            # CSS Level 4: When selector list is provided, ensure current element matches
            if (!is.null(fn$selector_list) && length(fn$selector_list) > 0) {
                conditions <- character(0)
                for (subselector in fn$selector_list) {
                    sub_xpath <- self$xpath(subselector)
                    sub_xpath$add_name_test()
                    if (!is.null(sub_xpath$condition) && nzchar(sub_xpath$condition)) {
                        conditions <- c(conditions, sub_xpath$condition)
                    }
                }

                if (length(conditions) > 0) {
                    # Current element must match at least one selector (OR)
                    combined_condition <- paste0(conditions, collapse = " or ")
                    xpath$add_condition(combined_condition)
                }
            }

            xpath
        },
        xpath_nth_last_child_function = function(xpath, fn) {
            self$xpath_nth_child_function(xpath, fn, last = TRUE)
        },
        xpath_nth_of_type_function = function(xpath, fn) {
            if (xpath$element == "*") {
                stop("*:nth-of-type() is not implemented")
            }
            self$xpath_nth_child_function(xpath, fn, add_name_test = FALSE)
        },
        xpath_nth_last_of_type_function = function(xpath, fn) {
            if (xpath$element == "*") {
                stop("*:nth-last-of-type() is not implemented")
            }
            self$xpath_nth_child_function(xpath, fn, last = TRUE,
                                          add_name_test = FALSE)
        },
        xpath_contains_function = function(xpath, fn) {
            if (!(fn$argument_types() %in% c("STRING", "IDENT"))) {
                stop("Expected a single string or ident for :contains(), got (",
                     paste0(fn$argument_types(), collapse = ", "), ")")
            }
            value <- fn$arguments[[1]]$value
            xpath$add_condition(paste0(
                "contains(., ", xpath_literal(value), ")"))
            xpath
        },
        xpath_lang_function = function(xpath, fn) {
            # Validate all arguments are STRING, IDENT, or * (DELIM)
            arg_types <- fn$argument_types()
            valid_types <- arg_types %in% c("STRING", "IDENT") |
                          (arg_types == "DELIM" & sapply(fn$arguments, function(a) a$value == "*"))
            if (!all(valid_types)) {
                stop("Expected string, ident, or * arguments for :lang(), got ",
                     fn$arguments[[1]]$repr())
            }

            # Extract language values from arguments, combining IDENT-* patterns
            lang_values <- character(0)
            i <- 1
            while (i <= length(fn$arguments)) {
                arg <- fn$arguments[[i]]
                # Check if this is an IDENT ending with '-' followed by a '*' DELIM
                if (arg$type %in% c("IDENT", "STRING") &&
                    grepl("-$", arg$value) &&
                    i < length(fn$arguments) &&
                    fn$arguments[[i + 1]]$type == "DELIM" &&
                    fn$arguments[[i + 1]]$value == "*") {
                    # Combine them: "en-" + "*" = "en-*"
                    lang_values <- c(lang_values, paste0(arg$value, "*"))
                    i <- i + 2  # Skip the next token since we combined it
                } else {
                    lang_values <- c(lang_values, arg$value)
                    i <- i + 1
                }
            }

            # Build conditions for each language value
            conditions <- character(0)
            for (value in lang_values) {
                if (value == "*") {
                    # Wildcard * matches everything - use a condition that's always true
                    conditions <- c(conditions, "true()")
                } else if (grepl("\\*$", value)) {
                    # Wildcard suffix like "en-*" - match any language starting with prefix
                    # Use XPath's lang() function which does prefix matching
                    prefix <- sub("\\*$", "", value)  # Remove trailing *
                    conditions <- c(conditions, paste0("lang(", xpath_literal(prefix), ")"))
                } else {
                    # Regular language tag
                    conditions <- c(conditions, paste0("lang(", xpath_literal(value), ")"))
                }
            }

            # Combine conditions with OR
            if (length(conditions) == 1) {
                xpath$add_condition(conditions[1])
            } else if (length(conditions) > 1) {
                combined <- paste0("(", paste(conditions, collapse = " or "), ")")
                xpath$add_condition(combined)
            }

            xpath
        },
        xpath_dir_function = function(xpath, fn) {
            # validate all arguments are STRING, IDENT, or * (DELIM)
            arg_types <- fn$argument_types()
            valid_types <- arg_types %in% c("STRING", "IDENT") |
                          (arg_types == "DELIM" & sapply(fn$arguments, function(a) a$value == "*"))
            if (!all(valid_types)) {
                stop("Expected string, ident, or * arguments for :dir(), got ",
                     fn$arguments[[1]]$repr())
            }
            # :dir() requires runtime directionality detection based on
            # document language, inherited dir attributes, and text analysis.
            # Not possible in static XPath, so we make it never match.
            xpath$add_condition("0")
            xpath
        },
        xpath_root_pseudo = function(xpath) {
            xpath$add_condition("not(parent::*)")
            xpath
        },
        xpath_first_child_pseudo = function(xpath) {
            xpath$add_condition("count(preceding-sibling::*) = 0")
            xpath
        },
        xpath_last_child_pseudo = function(xpath) {
            xpath$add_condition("count(following-sibling::*) = 0")
            xpath
        },
        xpath_first_of_type_pseudo = function(xpath) {
            if (xpath$element == "*") {
                stop("*:first-of-type is not implemented")
            }
            xpath$add_condition(paste0(
                "count(preceding-sibling::", xpath$element, ") = 0"))
            xpath
        },
        xpath_last_of_type_pseudo = function(xpath) {
            if (xpath$element == "*") {
                stop("*:last-of-type is not implemented")
            }
            xpath$add_condition(paste0(
                "count(following-sibling::", xpath$element, ") = 0"))
            xpath
        },
        xpath_only_child_pseudo = function(xpath) {
            xpath$add_condition("count(parent::*/child::*) = 1")
            xpath
        },
        xpath_only_of_type_pseudo = function(xpath) {
            if (xpath$element == "*") {
                stop("*:only-of-type is not implemented")
            }
            xpath$add_condition(paste0(
                "count(parent::*/child::", xpath$element, ") = 1"))
            xpath
        },
        xpath_empty_pseudo = function(xpath) {
            xpath$add_condition("not(*) and not(string-length())")
            xpath
        },

        #pseudo_never_matches = function(xpath) {
        #    xpath$add_condition("0")
        #    xpath
        #},

        # All are pseudo_never_matches()
        xpath_any_link_pseudo = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_link_pseudo     = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_visited_pseudo  = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_hover_pseudo    = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_active_pseudo   = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_focus_pseudo    = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_target_pseudo   = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_target_within_pseudo = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_local_link_pseudo    = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_enabled_pseudo  = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_disabled_pseudo = function(xpath) { xpath$add_condition("0") ; xpath },
        xpath_checked_pseudo  = function(xpath) { xpath$add_condition("0") ; xpath },

        xpath_attrib_exists = function(xpath, name, value) {
            xpath$add_condition(name)
            xpath
        },
        xpath_attrib_equals = function(xpath, name, value) {
            xpath$add_condition(paste0(name, " = ", xpath_literal(value)))
            xpath
        },
        xpath_attrib_different = function(xpath, name, value) {
            xpath$add_condition(paste0("not(", name, ") or ", name, " != ",
                                       xpath_literal(value)))
            xpath
        },
        xpath_attrib_includes = function(xpath, name, value) {
            if (!is.null(value) && nzchar(value) &&
                grepl("^[^ \t\r\n\f]+$", value)) {
                xpath$add_condition(paste0(
                    name,
                    " and contains(concat(' ', normalize-space(",
                    name,
                    "), ' '), ",
                    xpath_literal(paste0(" ", value, " ")),
                    ")"))
            } else {
                xpath$add_condition("0")
            }
            xpath
        },
        xpath_attrib_dashmatch = function(xpath, name, value) {
            xpath$add_condition(paste0(
                name,
                " and (",
                name,
                " = ",
                xpath_literal(value),
                " or starts-with(",
                name,
                ", ",
                xpath_literal(paste0(value, "-")),
                "))"))
            xpath
        },
        xpath_attrib_prefixmatch = function(xpath, name, value) {
            if (!is.null(value) && nzchar(value)) {
                xpath$add_condition(paste0(
                    name,
                    " and starts-with(",
                    name,
                    ", ",
                    xpath_literal(value),
                    ")"))
            } else {
                xpath$add_condition("0")
            }
            xpath
        },
        # In XPath there is starts-with but not ends-with, hence the oddness
        xpath_attrib_suffixmatch = function(xpath, name, value) {
            if (!is.null(value) && nzchar(value)) {
                xpath$add_condition(paste0(
                    name,
                    " and substring(",
                    name,
                    ", string-length(",
                    name,
                    ")-",
                    nchar(value) - 1,
                    ") = ",
                    xpath_literal(value)))
            } else {
                xpath$add_condition("0")
            }
            xpath
        },
        xpath_attrib_substringmatch = function(xpath, name, value) {
            if (!is.null(value) && nzchar(value)) {
                xpath$add_condition(paste0(
                    name,
                    " and contains(",
                    name,
                    ", ",
                    xpath_literal(value),
                    ")"))
            } else {
                xpath$add_condition("0")
            }
            xpath
        }
    )
)

HTMLTranslator <- R6Class("HTMLTranslator",
    inherit = GenericTranslator,
    public = list(
        xhtml = FALSE,
        initialize = function(xhtml = FALSE, ...) {
            self$xhtml <- xhtml
            if (!xhtml) {
                self$lower_case_element_names <- TRUE
                self$lower_case_attribute_names <- TRUE
            }
            self$lang_attribute <- "lang"
        },
        xpath_checked_pseudo = function(xpath) {
            xpath$add_condition(
                paste0("(@selected and name(.) = 'option') or ",
                       "(@checked ",
                       "and (name(.) = 'input' or name(.) = 'command')",
                       "and (@type = 'checkbox' or @type = 'radio'))"))
            xpath
        },
        xpath_lang_function = function(xpath, fn) {
            # Validate all arguments are STRING, IDENT, or * (DELIM)
            arg_types <- fn$argument_types()
            valid_types <- arg_types %in% c("STRING", "IDENT") |
                          (arg_types == "DELIM" & sapply(fn$arguments, function(a) a$value == "*"))
            if (!all(valid_types)) {
                stop("Expected string, ident, or * arguments for :lang(), got ",
                     fn$arguments[[1]]$repr())
            }

            # Extract language values from arguments, combining IDENT-* patterns
            lang_values <- character(0)
            i <- 1
            while (i <= length(fn$arguments)) {
                arg <- fn$arguments[[i]]
                # Check if this is an IDENT ending with '-' followed by a '*' DELIM
                if (arg$type %in% c("IDENT", "STRING") &&
                    grepl("-$", arg$value) &&
                    i < length(fn$arguments) &&
                    fn$arguments[[i + 1]]$type == "DELIM" &&
                    fn$arguments[[i + 1]]$value == "*") {
                    # Combine them: "en-" + "*" = "en-*"
                    lang_values <- c(lang_values, paste0(arg$value, "*"))
                    i <- i + 2  # Skip the next token since we combined it
                } else {
                    lang_values <- c(lang_values, arg$value)
                    i <- i + 1
                }
            }

            # Build conditions for each language value
            conditions <- character(0)
            for (value in lang_values) {
                if (value == "*") {
                    # Wildcard * matches any element with a lang attribute
                    # Check for any ancestor-or-self with @lang attribute
                    conditions <- c(conditions,
                        paste0("ancestor-or-self::*[@", self$lang_attribute, "]"))
                } else if (grepl("\\*$", value)) {
                    # Wildcard suffix like "en-*" - match any language starting with prefix
                    prefix <- sub("\\*$", "", value)  # Remove trailing *
                    # Don't add '-' if prefix already ends with it
                    search_prefix <- if (grepl("-$", prefix)) tolower(prefix) else paste0(tolower(prefix), "-")
                    conditions <- c(conditions, paste0(
                        "ancestor-or-self::*[@", self$lang_attribute, "][1][starts-with(concat(",
                        "translate(@",
                        self$lang_attribute,
                        ", 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ",
                        "'abcdefghijklmnopqrstuvwxyz'), '-'), ",
                        xpath_literal(search_prefix),
                        ")]"))
                } else {
                    # Regular language tag
                    conditions <- c(conditions, paste0(
                        "ancestor-or-self::*[@", self$lang_attribute, "][1][starts-with(concat(",
                        "translate(@",
                        self$lang_attribute,
                        ", 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ",
                        "'abcdefghijklmnopqrstuvwxyz'), '-'), ",
                        xpath_literal(paste0(tolower(value), "-")),
                        ")]"))
                }
            }

            # Combine conditions with OR
            if (length(conditions) == 1) {
                xpath$add_condition(conditions[1])
            } else if (length(conditions) > 1) {
                combined <- paste0("(", paste(conditions, collapse = " or "), ")")
                xpath$add_condition(combined)
            }

            xpath
        },
        xpath_dir_function = function(xpath, fn) {
            # Validate all arguments are STRING, IDENT, or * (DELIM)
            arg_types <- fn$argument_types()
            valid_types <- arg_types %in% c("STRING", "IDENT") |
                          (arg_types == "DELIM" & sapply(fn$arguments, function(a) a$value == "*"))
            if (!all(valid_types)) {
                stop("Expected string, ident, or * arguments for :dir(), got ",
                     fn$arguments[[1]]$repr())
            }
            # :dir() requires runtime directionality detection based on
            # document language, inherited dir attributes, and text analysis.
            # Not possible in static XPath, so we make it never match.
            xpath$add_condition("0")
            xpath
        },
        xpath_link_pseudo = function(xpath) {
            xpath$add_condition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')")
            xpath
        },
        xpath_disabled_pseudo = function(xpath) {
            xpath$add_condition(
                paste("(",
                      "@disabled and",
                      "(",
                      "(name(.) = 'input' and @type != 'hidden') or",
                      "name(.) = 'button' or",
                      "name(.) = 'select' or",
                      "name(.) = 'textarea' or",
                      "name(.) = 'command' or",
                      "name(.) = 'fieldset' or",
                      "name(.) = 'optgroup' or",
                      "name(.) = 'option'",
                      ")",
                      ") or (",
                      "(",
                      "(name(.) = 'input' and @type != 'hidden') or",
                      "name(.) = 'button' or",
                      "name(.) = 'select' or",
                      "name(.) = 'textarea'",
                      ")",
                      "and ancestor::fieldset[@disabled]",
                      ")"))
            xpath
        },
        xpath_enabled_pseudo = function(xpath) {
            xpath$add_condition(
                paste("(@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area'))",
                      "or",
                      "((name(.) = 'command' or name(.) = 'fieldset' or name(.) = 'optgroup') and not(@disabled))",
                      "or",
                      "(((name(.) = 'input' and @type != 'hidden')",
                      "or name(.) = 'button'",
                      "or name(.) = 'select'",
                      "or name(.) = 'textarea'",
                      "or name(.) = 'keygen')",
                      "and not (@disabled or ancestor::fieldset[@disabled]))",
                      "or (name(.) = 'option' and not(@disabled or ancestor::optgroup[@disabled]))"))
            xpath
        }
    )
)
