-- This file contains the code for the CSS parser.
--
--  css.lua
--  speedata publisher
--
--  For a list of authors see `git blame'
--  See file COPYING in the root directory for license info.

local explode = string.explode
local string = unicode.utf8

local lua_pattern_escapes = {
    ["^"] = "%^",
    ["$"] = "%$",
    ["("] = "%(",
    [")"] = "%)",
    ["%"] = "%%",
    ["."] = "%.",
    ["["] = "%[",
    ["]"] = "%]",
    ["*"] = "%*",
    ["+"] = "%+",
    ["-"] = "%-",
    ["?"] = "%?",
    ["\0"] = "%z",
}
local function escape_lua_pattern(s)
    return (s:gsub(".", lua_pattern_escapes))
end

---@class CSSRules
---@field rules table<integer, table<string, table<string, string>>> `[priority][selector][property] = value`.
---@field priorities integer[] Known priorities, sorted descending after each parse.
---@field text string[] Concatenation of every parsed CSS source (kept for the Go renderer).
---@field __index? CSSRules Set when the table serves as a metatable in `new`.

-- Constructs a fresh empty CSS rule store.
---@param self CSSRules
---@return CSSRules
local function new(self)
    local c = {
        rules = {},
        priorities = {},
        text = {},
    }
    setmetatable(c, self)
    self.__index = self
    return c
end

-- Sanitizes the selector string and computes its specificity priority
-- using the simple `id=100, class=10, element=1` rule.
---@param selector string
---@return string sanitized
---@return integer priority
local function get_priority(selector)
    local prio = 0
    string.gsub(selector, "[%.#]?[^%s.]+", function(x)
        if string.match(x, "^#") then
            prio = prio + 100
        elseif string.match(x, "^%.") then
            prio = prio + 10
        else
            prio = prio + 1
        end
    end)
    local sel = string.gsub(selector, "^%s*(.-)%s*$", "%1")
    return sel, prio
end

-- Returns the concatenation of every CSS source ever fed into this store.
-- Used to forward the raw CSS to the Go HTML renderer.
---@param self CSSRules
---@return string
local function gettext(self)
    return table.concat(self.text)
end

-- Parses a CSS source string into the rule store. Selectors are split on
-- `,`; rules with the same priority and selector are merged.
---@param self CSSRules
---@param csstext string
---@return nil
local function parsetxt(self, csstext)
    -- save it for later to pass to the Go routine
    self.text[#self.text + 1] = csstext

    csstext = string.gsub(csstext, "%s+", " ")
    -- remove comments:
    csstext = string.gsub(csstext, "/%*.-%*/", " ")
    local stop, selector, selectors, rules, rule, property, expr, rule_stop, rules_text
    stop = 0
    while true do
        _, stop, selector = string.find(csstext, "^%s*([^{]+)", stop + 1)
        if not selector then
            break
        end
        _, stop, rules_text = string.find(csstext, "{([^}]+)}%s*", stop + 1)
        if not rules_text then
            return
        end
        rules = explode(rules_text, ";")
        local rules_t = {}
        for i = 1, #rules do
            rule = rules[i]
            -- if it's not only whitespace
            if not string.match(rule, "^%s*$") then
                _, rule_stop, property = string.find(rule, "%s*([^:]+):")
                -- a rule without a colon has no property and gets skipped
                if property then
                    _, _, expr = string.find(rule, "^%s*(.-)%s*$", rule_stop + 1)
                    rules_t[property] = expr
                end
            end
        end
        selectors = explode(selector, ",")
        local sel, prio
        for i = 1, #selectors do
            sel, prio = get_priority(selectors[i])
            self.rules[prio] = self.rules[prio] or {}
            self.rules[prio][sel] = self.rules[prio][sel] or {}
            for k, v in pairs(rules_t) do
                self.rules[prio][sel][k] = v
            end
        end
    end
    local prio_found
    -- We remember the priority for later use.
    for prio, _ in pairs(self.rules) do
        prio_found = false
        for i = 1, #self.priorities do
            if self.priorities[i] == prio then
                prio_found = true
                break
            end
        end
        if prio_found == false then
            self.priorities[#self.priorities + 1] = prio
        end
    end
    -- now sort the table with the priorities, so we can access the
    -- rules in the order of priorities (that's the whole point)
    table.sort(self.priorities, function(a, b)
        return a > b
    end)
end

-- Loads a CSS file from disk (resolved via `kpse.find_file`) and merges
-- its rules into the store via `parsetxt`.
---@param self CSSRules
---@param filename string
---@return nil
local function parse(self, filename)
    local path = kpse.find_file(filename)
    if not path then
        main.log("error", string.format("CSS: cannot find filename %q.", filename or "--"))
        return
    end
    main.log("info", string.format("Loading CSS %q", path))
    local cssio = io.open(path, "rb")
    if not cssio then
        main.log("error", string.format("CSS: cannot open file %q.", path))
        return
    end
    local csstext = cssio:read("*all")
    cssio:close()
    return parsetxt(self, csstext)
end

-- Returns `true` when `selector` matches the description in `tbl`.
---@param tbl { element?: string, class?: string, id?: string, parent?: string }
---@param selector string
---@return boolean
local function matches_selector(tbl, selector)
    local element, class, id = tbl.element, tbl.class, tbl.id
    local id_found, class_found, element_found = false, false, false
    local id_matches, class_matches, element_matches = false, false, false
    local id_required, class_required, element_required = tbl.id ~= nil, tbl.class ~= nil, tbl.element ~= nil
    -- todo: element_required is probably never false since the publisher always presents an element name

    local return_false = false
    string.gsub(selector, "[%.#]?[^%s.#]+", function(x)
        if string.match(x, "^#") then
            if not id_required then
                return_false = true
            end
            id_found = true
            if id and string.match(id, escape_lua_pattern(string.sub(x, 2))) then
                id_matches = true
            end
        elseif string.match(x, "^%.") then
            if not class_required then
                return_false = true
            end
            class_found = true
            if class and string.match(class, escape_lua_pattern(string.sub(x, 2)) .. "$") then
                class_matches = true
            end
        else
            if not element_required then
                return_false = true
            end
            element_found = true
            if element and string.match(element, "^" .. escape_lua_pattern(x) .. "$") then
                element_matches = true
            end
        end
    end)
    if return_false then
        return false
    end
    -- We return true if we have found something that matches and if these elements, if found, match the requested ones from the tbl
    return element_found == element_matches
        and class_found == class_matches
        and id_found == id_matches
        and (class_found or element_found or id_found)
end

-- Walks the rule store in priority order and returns the first rule body
-- whose selector matches `tbl`. `level` selects which compound-selector
-- segment is tested (1 = rightmost).
---@param self CSSRules
---@param tbl { element?: string, class?: string, id?: string, parent?: string }
---@param level? integer
---@return table<string, string>? rule
local function matches(self, tbl, level)
    level = level or 1
    local interesting_part, parts
    for _, v in ipairs(self.priorities) do
        for selector, rule in pairs(self.rules[v]) do
            parts = explode(selector, " ")
            -- the interesting part depends on the level:
            -- level 1: the last part, level 2, the second last part, ...
            interesting_part = parts[#parts + 1 - level]
            if matches_selector(tbl, interesting_part) == true then
                return rule
            end
        end
    end
    return nil
end

return {
    new = new,
    parse = parse,
    parsetxt = parsetxt,
    matches = matches,
    gettext = gettext,
}
