--
-- hud_fs: Render formspecs into HUDs
--
-- Copyright © 2021 by luk3yx
--

local hud_fs = {}
local modname = minetest.get_current_modname()
_G[modname] = hud_fs

local DEBUG = false
local DEFAULT_SCALE = 64
local DEFAULT_Z_INDEX = 0

local floor, type, pairs = math.floor, type, pairs
local max, min = math.max, math.min

-- Attempt to use modlib's parser
local colorstring_to_number
if minetest.global_exists("modlib") and (modlib.version or 0) >= 54 then
    local pcall, from_string = pcall, modlib.minetest.colorspec.from_string
    function colorstring_to_number(col)
        local ok, spec = pcall(from_string, col)
        if not ok or not spec then return end
        return spec:to_number_rgb()
    end
else
    colorstring_to_number = dofile(minetest.get_modpath(modname) ..
        "/colorstring_to_number.lua")
end

hud_fs.colorstring_to_number = colorstring_to_number

-- Hacks to allow colorize() to work to some extent on labels
local function get_label_number(label, style)
    local number, text = label:match("^\027%(c@([^%)]+)%)(.*)$")

    -- Remove trailing escape sequence added by minetest.colorize().
    if text then
        text = text:gsub("\027%(c@[^%)]+%)$", "")
    end

    return text or label, number and colorstring_to_number(number) or
        (style.textcolor and colorstring_to_number(style.textcolor) or 0xFFFFFF)
end

-- Disable the race condition workaround in 5.4.1 singleplayer
-- The bug was fixed in 5.4.1 but there's no (easy) way to detect 5.4.1 clients
-- The workaround is disabled for 5.5.0+ clients (if the server is 5.5.0+)
-- regardless of this setting.
local enable_race_condition_workaround = true
if minetest.is_singleplayer() then
    local version = minetest.get_version()
    if version.project == "Minetest" and version.string == "5.4.1" then
        enable_race_condition_workaround = false
    end
end

local PLUS, TIMES = ("+*"):byte(1, 2)
local function get_font_size(style)
    local size = style.font_size
    if not size then return end

    local first_char = size:byte(1)
    if first_char == TIMES then
        size = tonumber(size:sub(2))
    else
        -- Approximate a multiple size based on the font size
        if first_char == PLUS then
            size = tonumber(size:sub(2))
            if size then
                size = size + 16
            end
        else
            size = tonumber(size)
        end

        if size then
            size = size / 16
        end
    end

    return size and {x = size, y = 0}
end

local empty_table = {}
local function get_style(node, styles_by_name, styles_by_type)
    local style = styles_by_name[node.name]
    if not style then
        return styles_by_type[node.type] or empty_table
    end

    return setmetatable(style, {__index = styles_by_type[node.type]})
end

local function style_is_yes(value, default)
    if value == nil then
        return default
    elseif type(value) == "string" then
        return minetest.is_yes(value)
    else
        return value
    end
end

local font_style_flags = {"bold", "italic", "mono"}
local function get_style_flags(style)
    local flags = 0
    if style.font then
        local options = style.font:split(",")
        for i, flag in ipairs(font_style_flags) do
            if table.indexof(options, flag) > 0 then
                flags = flags + 2 ^ (i - 1)
            end
        end
    end
    return flags
end

local nodes = {}
function nodes.label(node, scale, _, _, _, _, styles_by_type)
    local style = styles_by_type.label or empty_table
    local text, number = get_label_number(node.label, style)
    local elem = {
        hud_elem_type = "text",
        text = text,
        alignment = {x = 1, y = 0},
        number = number,
        size = get_font_size(style),
        style = get_style_flags(style),
    }

    -- Hack for newlines. This will unfortunately break if the font size is
    -- changed from the default.
    if elem.text:find('[\r\n]') then
        elem.alignment.y = 1
        node.y = node.y - 10 / scale
    end
    return elem
end

local log2_div = math.log(2)
function nodes.image(node, scale, _, possibly_using_gles, client_hud_scale)
    -- The provided texture could be any size so this has to scale it first
    local w = floor(node.w * scale * client_hud_scale)
    local h = floor(node.h * scale * client_hud_scale)

    local texture = node.texture_name
    if w > 0 and h > 0 and texture ~= "" then
        texture = "(" .. texture .. ")^[resize:" .. w .. "x" .. h
    else
        -- Minetest throws an error with zero-width HUD images, use blank.png
        -- to keep the image around (for future HUD update calls) while
        -- silencing the error.
        texture = "blank.png"
    end

    -- Hacks to work around textures being aligned to a power of 2 on some
    -- video drivers
    if possibly_using_gles then
        local true_w = 2 ^ math.ceil(math.log(w) / log2_div)
        local true_h = 2 ^ math.ceil(math.log(h) / log2_div)
        if true_w ~= w or true_h ~= h then
            texture = ("[combine:%sx%s:0,0=%s"):format(
                true_w, true_h,
                texture:gsub("\\", "\\\\"):gsub("%^", "\\^"):gsub(":", "\\:")
            )
        end
    end

    return {
        hud_elem_type = "image",
        text = texture,
        alignment = {x = 1, y = 1},
        scale = {x = 1 / client_hud_scale, y = 1 / client_hud_scale},
    }
end

-- Hack box[] into image[]
function nodes.box(node, scale)
    local col = node.color
    -- Add default transparency
    if col:byte(1) == 35 then
        if #col == 4 then
            col = col:gsub("[^#]", "%1%1") .. "8C"
        elseif #col == 7 then
            col = col .. "8C"
        end
    end

    -- Since hud_fs_box.png is guaranteed to be 1x1, scale can be used directly
    return {
        hud_elem_type = "image",
        text = 'hud_fs_box.png^[colorize:' .. col,
        alignment = {x = 1, y = 1},
        scale = {x = node.w * scale, y = node.h * scale},
    }
end

function nodes.textarea(node, scale, add_node, _, _, styles_by_name,
        styles_by_type)
    -- Add in separate nodes for the label and background
    if node.label and node.label ~= "" then
        add_node("label", {
            x = node.x,
            y = node.y - 10 / scale,
            label = node.label
        })
    end

    local style = get_style(node, styles_by_name, styles_by_type)
    if node.name and node.name ~= "" and style_is_yes(style.border, true) then
        add_node("box", {
            x = node.x,
            y = node.y,
            w = node.w,
            h = node.h,
            color = "#757575FF"
        })
    end
    node.x = node.x + 5 / scale
    node.y = node.y + 3 / scale
    local lines = (node.default or ""):split("\n", true)
    local max_line_length = node.w * scale / 8
    for i, line in ipairs(lines) do
        lines[i] = minetest.wrap_text(line, max_line_length)
    end
    return {
        hud_elem_type = "text",
        text = table.concat(lines, "\n"),
        alignment = {x = 1, y = 1},
        number = 0xFFFFFF,
        scale = {x = node.w * scale, y = node.h * scale},
        size = get_font_size(style),
        style = get_style_flags(style),
    }
end

local function get_tile_image(tiles, preferred_texture)
    local tile
    for i = preferred_texture, 1, -1 do
        tile = tiles[i]
        if tile then break end
    end
    if type(tile) == "table" then tile = tile.name end
    if type(tile) ~= "string" then tile = "unknown_item.png" end
    return tile
end

function nodes.item_image(node, ...)
    local def = minetest.registered_items[node.item_name]
    if not def then
        node.texture_name = "unknown_item.png"
    elseif def.inventory_image and def.inventory_image ~= "" then
        node.texture_name = def.inventory_image
    elseif def.wield_image and def.wield_image ~= "" then
        node.texture_name = def.wield_image
    elseif def.tiles then
        node.texture_name = minetest.inventorycube(
            get_tile_image(def.tiles, 1),
            get_tile_image(def.tiles, 6),
            get_tile_image(def.tiles, 3)
        )
    else
        node.texture_name = "unknown_node.png"
    end
    return nodes.image(node, ...)
end

function nodes.button(node, _, add_node, _, _, styles_by_name, styles_by_type)
    local style = get_style(node, styles_by_name, styles_by_type)

    -- This function is used by image_button and item_image_button as well
    if node.drawborder or (node.drawborder == nil and
            style_is_yes(style.border, true)) then
        add_node("box", {
            x = node.x,
            y = node.y,
            w = node.w,
            h = node.h,
            color = "#515151FF"
        })
    end

    if style.bgimg then
        add_node("image", {
            x = node.x,
            y = node.y,
            w = node.w,
            h = node.h,
            texture_name = style.bgimg,
        })
    end

    if node.texture_name and node.texture_name ~= "" then
        add_node("image", node)
    elseif node.item_name and node.item_name ~= "" then
        add_node("item_image", node)
    end
    node.x = node.x + node.w / 2
    node.y = node.y + node.h / 2
    local text, number = get_label_number(node.label, style)
    return {
        hud_elem_type = "text",
        text = text,
        alignment = {x = 0, y = 0},
        number = number,
        size = get_font_size(style),
        style = get_style_flags(style),
    }
end
nodes.button_exit = nodes.button
nodes.image_button = nodes.button
nodes.image_button_exit = nodes.button
nodes.item_image_button = nodes.button

local function render_error(err)
    minetest.log("error", "[hud_fs] Error rendering HUD: " .. tostring(err))
    return {}
end

local function render(tree, possibly_using_gles, scale, z_index, window)
    if type(tree) == "string" then
        local err
        tree, err = formspec_ast.parse(tree)
        if not tree then
            return render_error(err)
        end
    end

    tree = formspec_ast.flatten(tree)
    -- if (tree.formspec_version or 1) < 2 then
    --     return render_error("Formspec version 1 is not supported!")
    -- end

    local hud_elems = {}
    local size_w, size_h = 0, 0
    local pos = {x = 0.5, y = 0.5}
    local offset_x, offset_y = 0, 0
    scale = scale or DEFAULT_SCALE
    z_index = z_index or DEFAULT_Z_INDEX

    local styles_by_name = {}
    local styles_by_type = {}
    local client_hud_scale = window and window.real_hud_scaling or 1
    local function add_node(node_type, node)
        local elem = nodes[node_type](node, scale, add_node,
            possibly_using_gles, client_hud_scale, styles_by_name,
            styles_by_type)
        elem.position = pos
        elem.z_index = z_index
        elem.offset = {
            x = (node.x + offset_x) * scale,
            y = (node.y + offset_y) * scale
        }
        hud_elems[#hud_elems + 1] = elem
        z_index = z_index + 1
    end

    for _, node in ipairs(tree) do
        local node_type = node.type
        if node_type == "size" then
            size_w, size_h = node.w, node.h
            offset_x, offset_y = -size_w / 2, -size_h / 2
        elseif node_type == "position" then
            pos = {x = node.x, y = node.y}
        elseif node_type == "anchor" then
            if size_w == 0 or size_h == 0 then
                return render_error("anchor[] without size[]")
            end
            offset_x = -node.x * size_w
            offset_y = -node.y * size_h
        elseif node_type == "padding" and window then
            if size_w == 0 or size_h == 0 then
                return render_error("padding[] without size[]")
            elseif #hud_elems > 0 then
                return render_error("padding[] after other elements")
            end

            -- If padding[] is present, override the specified scale on new
            -- clients
            scale = min(
                -- The client's size
                window.size.x / (window.max_formspec_size.x * 1.1),
                window.size.y / (window.max_formspec_size.y * 1.1),

                -- Maximum size based on the padding
                window.size.x * (1 - node.x * 2) / size_w,
                window.size.y * (1 - node.y * 2) / size_h
            ) / client_hud_scale
        elseif node_type == "style" or node_type == "style_type" then
            local styles = node_type == "style" and styles_by_name or
                styles_by_type
            for _, name in ipairs(node.selectors or {node.name}) do
                local props = styles[name]
                if not props then
                    props = {}
                    styles[name] = props
                end
                for k, v in pairs(node.props) do
                    if v == "" then
                        props[k] = nil
                    else
                        props[k] = v
                    end
                end
            end
        elseif nodes[node_type] then
            add_node(node_type, node)
        elseif node_type == nil and node.hud_elem_type then
            -- Pass through plain HUD elements
            hud_elems[#hud_elems + 1] = node
        end
    end

    return hud_elems
end

local hud_elems = {}
--[[
hud_elems[player_name][formname] = {
    {List of HUD IDs},
    {List of HUD definitions}
}
]]

-- Returns needs_replacing, differences
local function compare_elems(old_elem, new_elem)
    local differences = {}
    for k, v in pairs(old_elem) do
        local v2 = new_elem[k]
        if type(v) == "table" and type(v2) == "table" then
            -- Tables are guaranteed to be vectors at the moment, don't bother
            -- checking anything else to improve performance.
            if v.x ~= v2.x or v.y ~= v2.y or v.z ~= v2.z then
                differences[#differences + 1] = k
            end
        elseif v ~= v2 then
            -- Sometimes the HUD element will need to be deleted/re-added.
            if k == "hud_elem_type" or v2 == nil then return true, nil end
            differences[#differences + 1] = k
        end
    end

    -- Check for missing keys in old_elem
    for k in pairs(new_elem) do
        if old_elem[k] == nil then
            differences[#differences + 1] = k
        end
    end

    return false, differences
end

local function reshow_hud(name, formname, data)
    if not hud_elems[name] or hud_elems[name][formname] ~= data then
        return
    end

    data[3] = nil
    local fs = data[4]
    if fs then
        data[4] = nil
        hud_fs.show_hud(name, formname, fs)
    end
end

local scales = {}
local z_indexes = {}
function hud_fs.show_hud(player, formname, formspec)
    if type(player) == "string" then
        player = minetest.get_player_by_name(player)
        if not player then return end
    end

    local name = player:get_player_name()
    if not hud_elems[name] then
        hud_elems[name] = {}
    end

    -- Work around Minetest bug (should be fixed in 5.4)
    local info = minetest.get_player_information(name)
    if not info then return end

    local data = hud_elems[name][formname]
    if not data then
        data = {{}, {}}
        hud_elems[name][formname] = data
    end
    local proto_ver = info.protocol_version

    -- Work around client-side race conditions in MT <= 5.4.0
    if proto_ver < 40 and data[3] then
        data[4] = formspec
        return
    end

    -- MultiCraft-specific detection of clients which may be using OpenGL ES 1
    local possibly_using_gles = (info.platform == "Android" or
        info.platform == "iOS")

    local ids, elems = data[1], data[2]
    local window = minetest.get_player_window_information and
        minetest.get_player_window_information(name)
    local new_elems = render(formspec, possibly_using_gles, scales[formname],
        z_indexes[formname], window)

    -- Z-index was added to MT 5.2.0 (protocol version 39) and is ignored by
    -- older clients. Because of the way HUDs work, sometimes it's safest to
    -- just delete and re-add every single element for these older clients.
    if proto_ver < 39 then
        -- Maybe we can get away with just doing hud_change() and not resending
        local diff_cache = {}
        local can_update = true
        local update_packets = max(#elems - #new_elems, 0)
        for i, new_elem in ipairs(new_elems) do
            local old_elem = elems[i]
            if old_elem then
                local needs_replacing, diff = compare_elems(old_elem, new_elem)
                if needs_replacing then
                    can_update = false
                    break
                end
                if #diff > 0 then
                    diff_cache[i] = diff
                    update_packets = update_packets + 1
                end
            else
                -- If elements are added the whole HUD needs to be resent.
                can_update = false
                break
            end
        end

        local resend_packets = #elems + #new_elems
        if can_update and resend_packets >= update_packets then
            -- Send lots of hud_change packets
            for i, new_elem in ipairs(new_elems) do
                local diff = diff_cache[i]
                if diff then
                    for _, stat in ipairs(diff) do
                        player:hud_change(ids[i], stat, new_elem[stat])
                    end
                    elems[i] = new_elem
                end
            end

            -- Remove any extra elements
            for i = #new_elems + 1, #ids do
                player:hud_remove(ids[i])
                ids[i] = nil
                elems[i] = nil
            end

            if DEBUG then
                minetest.chat_send_player(name, "[DEBUG] Sent " ..
                    update_packets .. " packet(s) using hud_change() and " ..
                    "hud_remove() to update HUD")
            end
        else
            -- Or resend every single HUD element
            for i = 1, #ids do
                player:hud_remove(ids[i])
                ids[i] = nil
                elems[i] = nil
            end
            for i, elem in ipairs(new_elems) do
                ids[i] = player:hud_add(elem)
                elems[i] = elem
            end

            -- Block future HUD modifications if the new HUD isn't empty
            if new_elems[1] and enable_race_condition_workaround then
                data[3] = true
                minetest.after(0.05, reshow_hud, name, formname, data)
            end

            if DEBUG then
                minetest.chat_send_player(name, "[DEBUG] Sent " ..
                    resend_packets .. " packet(s) resending entire HUD")
            end
        end
        return
    end

    -- As MT 5.2.0+ clients support z_index, the HUD IDs don't need to be
    -- sequential and the update packets can therefore be more efficient.
    local replaced, modified, modify_packets, added = 0, 0, 0, 0
    for i, new_elem in ipairs(new_elems) do
        local old_elem = elems[i]
        if old_elem then
            local needs_replacing, diff = compare_elems(old_elem, new_elem)
            if needs_replacing or #diff > 2 then
                -- Resend the entire element if there are more than two
                -- differences as this only sends two packets to the client.
                player:hud_remove(ids[i])
                ids[i] = player:hud_add(new_elem)
                replaced = replaced + 1
            else
                -- Otherwise it's more efficient to use multiple hud_change()
                -- calls (this is a no-op if #diff == 0).
                for _, stat in ipairs(diff) do
                    player:hud_change(ids[i], stat, new_elem[stat])
                    modify_packets = modify_packets + 1
                end
                if #diff > 0 then modified = modified + 1 end
            end
        else
            ids[i] = player:hud_add(new_elem)
            added = added + 1
        end
        elems[i] = new_elem
    end

    -- Remove any extra elements
    local removed = 0
    for i = #new_elems + 1, #ids do
        player:hud_remove(ids[i])
        ids[i] = nil
        elems[i] = nil
        removed = removed + 1
    end

    -- Only block future HUD modifications if any elements have been added
    if proto_ver < 40 and added > 0 and enable_race_condition_workaround then
        data[3] = true
        minetest.after(0.05, reshow_hud, name, formname, data)
    end

    if DEBUG then
        local packets = modify_packets + replaced * 2 + added + removed
        minetest.chat_send_player(name, "[DEBUG] Sent " .. packets ..
            " network packet(s): Modified " .. modified ..
            " elements in-place (for " .. modify_packets ..
            " packet(s)), replaced " .. replaced .. ", added " .. added ..
            " and removed " .. removed .. " element(s).")
    end
end

function hud_fs.close_hud(player, formname)
    hud_fs.show_hud(player, formname, {})
end

minetest.register_on_leaveplayer(function(player)
    hud_elems[player:get_player_name()] = nil
end)

-- Sets the base z-index for formname. Should not be done when the formspec is
-- open. Note that this is not used for all elements, if the formspec contains
-- 10 HUD elements it will use a z-index ranging from z_index to z_index + 9.
-- This has no effect for clients older than 5.2.0.
function hud_fs.set_z_index(formname, z_index)
    if z_index == DEFAULT_Z_INDEX then z_index = nil end
    z_indexes[formname] = z_index
end

-- Sets the scale for formname. This can be done when the HUD is open, however
-- the scale won't be changed for existing formspecs.
function hud_fs.set_scale(formname, scale)
    if scale == DEFAULT_SCALE then scale = nil end
    scales[formname] = scale
end

-- Testing
--[=[
if not DEBUG then return end

local using_fs = {}
local function poll()
    for _, player in ipairs(minetest.get_connected_players()) do
        local name = player:get_player_name()
        local fs = "formspec_version[3]" ..
            "size[4,4.5] position[1,0] anchor[1,0] no_prepend[]" ..
            "bgcolor[#00000022;true]" ..
            "box[0,0;5,2;#380C2AFF]" ..
            "label[0.25,0.5;This is a test HUD]" ..
            "label[0.25,1;" .. minetest.colorize("#00ffff", "Server uptime: " ..
                floor(minetest.get_server_uptime()) .. " seconds") ..
                "]" ..
            "label[0.25,1.5;Run /hud to interact!]"
        if math.random(0, 1) == 0 then
            fs = fs .. "image[3,1.5;1,1;default_dirt.png]"
        end
        -- if math.random(1, 5) > 1 then
        --     fs = fs .. "label[0,1.5;Second label (test)\nNewline test\rabc]"
        -- end
        fs = fs ..
            "box[0,2;5,2;#380C2ABF]" ..
            "container[0,2.5]" ..
                "item_image[0,0;1,1;carts:rail]" ..
                "item_image[1,0;1,1;default:dirt_with_grass]" ..
                "container[2,-0.5]" ..
                    "item_image[0,0.5;1,1;default:cactus]" ..
                    "item_image[1,0.5;1,1;default:tree]" ..
                "container_end[]" ..
            "container_end[]" ..
            "button[0,4;2,0.5;mrkr;Waypoints]" ..
            "button[2,4;2,0.5;close;Close]"
        if using_fs[name] then
            minetest.show_formspec(name, "hud_fs:uptime", fs)
            hud_fs.close_hud(name, "hud_fs:uptime")
        else
            hud_fs.show_hud(name, "hud_fs:uptime", fs)
        end
    end
end

local function poll_outer()
    poll()
    minetest.after(1, poll_outer)
end
minetest.register_on_mods_loaded(poll_outer)

minetest.register_chatcommand("hud", {
    func = function(name, _)
        using_fs[name] = true
        poll()
    end,
})

minetest.register_chatcommand("hud2", {
    func = function(name, _)
        minetest.show_formspec(name, "hud_fs:uptime", "formspec_version[3]" ..
            "size[4,4.5] position[1,0] anchor[1,0] no_prepend[]" ..
            "bgcolor[#ff000000;neither;#00ff0000]" ..
            "button[0,4;2,0.5;mrkr;Waypoints]" ..
            "button[2,4;2,0.5;close;Close]")
    end,
})

minetest.register_on_player_receive_fields(function(player, formname, fields)
    if formname ~= "hud_fs:uptime" then return end
    local name = player:get_player_name()
    if fields.mrkr then
        minetest.registered_chatcommands["mrkr"].func(name, "")
    elseif fields.close then
        minetest.close_formspec(name, "hud_fs:uptime")
    elseif not fields.quit then
        return
    end

    using_fs[name] = nil
    poll()
end)
]=]
