
local fe = minetest.formspec_escape
local hte = minetest.hypertext_escape

local ctx

local player_contexts = {}
local contexts = {}
local inventories = {}

local theme = {}

-- MARK: Helpers

-- Detects percentage units andconverts them into an appropriate number (based on `ref`).
local function resolve_layout_units(value, ref)
    if type(value) == "string" then
        local num = tonumber(value)
        if num then return num end
        
        local percent = value:match "(%d*.?%d+)%%"
        if ref and percent then
            local sign, offset = value:match "%%%s*([+-])%s*(%d*.?%d+)"
            return tonumber(percent) /100 *ref +(tonumber(offset or 0) *(sign == "-" and -1 or 1))
        end
        
        print(string.match("100%", "(%d*.?%d+)%%"), percent, ref)
        
        error("Malformed layout units: "..value)
    else
        return value
    end
end

-- The same as resolve_layout_units, but allows (and notifies of) flex units.
local function resolve_flex_layout_units(value, ref)
    if type(value) == "string" then
        local num = tonumber(value)
        if num then return num end
        
        local percent = value:match "(%d*.?%d+)%%"
        if ref and percent then
            local sign, offset = value:match "%%%s*([+-])%s*(%d*.?%d+)"
            return tonumber(percent) /100 *ref +(tonumber(offset or 0) *(sign == "-" and -1 or 1))
        end
        
        local flex = value:match "(%d*.?%d+)x"
        if flex then
            return tonumber(flex), "flex"
        end

        error("Malformed layout units: "..value)
    else
        return value
    end
end

-- This should be a method on any element classes that support inline styling.
local function add_elem_style(e, state, props)
    -- Allow omitting the state while keeping the properties the last argument.
    if not props then
        props = state
        state = "default"
    end
    
    -- Permit targeting pseudo-elements as well.
    if state:sub(1, 1) == "." then
        e._styles[state] = imfs.style(e.__id..state, props, true)
    else
        e._styles[state] = imfs.style(e.__id..":"..state, props, true)
    end
    
    return e
end

-- Bulk version of `add_elem_style`.
local function add_elem_styles(e, states)
    for state, props in pairs(states) do
        if state:sub(1, 1) == "." then
            e._styles[state] = imfs.style(e.__id..state, props, true)
        else
            e._styles[state] = imfs.style(e.__id..":"..state, props, true)
        end
    end
    return e
end

-- This should be a method on any element classes that support tooltips.
local function add_elem_tooltip(e, text, enabled, bgcolor, txtcolor)
    -- Permit the user to pass a boolean to disable the tooltip (useful for modal dialogs that should occlude tooltips without more bothersome composition boilerplate than necessary).
    if enabled == false then
        return
    elseif enabled ~= true then
        txtcolor = bgcolor
        bgcolor = enabled
    end
    -- We must overload `render()` both to ensure that tooltips don't clutter the element tree and mess with layout containers
    -- and (for area tooltips) in order to ensure the tooltip's position and size match the parent's exactly in all cases.
    
    -- For named elements, prefer name-associated tooltips.
    if e.__id then
        local render = e.render
        e.render = function(e, x, y, w, h)
            local out = render(e, x, y, w, h)
            return out..imfs.tooltip.named(e.__id, text, bgcolor, txtcolor):render()
        end
    -- For other elements, if they have a size, use an area tooltip instead.
    elseif e.w then
        local render = e.render
        e.render = function(e, x, y, w, h)
            local out = render(e, x, y, w, h)
            return out..imfs.tooltip.init(x or e.x, y or e.y, w or e.w, h or e.h, text, bgcolor, txtcolor):render()
        end
    end
    return e
end

local name_scope = {"_"}
local nth_id = {0}

local function scope(name)
    table.insert(name_scope, name)
    table.insert(nth_id, 0)
end

local function scope_end()
    table.remove(name_scope)
    table.remove(nth_id)
end

local function new_id()
    local depth = #nth_id
    nth_id[depth] = nth_id[depth] +1
    return table.concat(name_scope, ".").."_"..nth_id[depth]
end

local function unique_id()
    return "_"..minetest.get_us_time().."_"..math.random(1, 100000)
end

local function string_or(a, b)
    return (a ~= "" and a) or b
end

-- MARK: Data structures

local observers = {}
local state = {
    observers = observers,
    get = function(e)
        local observer = observers[#observers]
        if observer then
            observer[e] = true
        end
        return e._val
    end,
    set = function(e, val)
        if val == nil then error "!" end
        e._old_val = e._val
        e._val = val
        for _, x in pairs(e._getters) do
            x:update()
        end
    end,
    __call = function(e, val)
        if val == nil then -- Getter
            return e:get()
        else -- Setter
            return e:set(val)
        end
    end
}
state.__index = state
setmetatable(state, {
    __call = function(_, val)
        local e = {_getters = {}, _val = val}
        setmetatable(e, state)
        
        return e
    end
})

local DerivedState = {
    get = function(e)
        if e._stale then
            e:update()
        end
        return e._val
    end,
    update = function(e)
        for dep in pairs(e._deps) do
            dep._getters[e.__id] = nil
            e._deps[dep] = nil
        end
        
        local tracker = e._deps
        table.insert(observers, tracker)
        
        local val = e._fn()
        
        table.remove(observers)
        
        e._val = val
        
        for dep in pairs(e._deps) do
            dep._getters[e.__id] = e
        end
        
        -- Don't propagate updates on initial computation to avoid stack overflow.
        if not e._stale then
            for _, x in pairs(e._getters) do
                x:update()
            end
        end
        e._stale = false
    end,
    __call = function(e)
        return e:get()
    end
}
DerivedState.__index = DerivedState

function derive(fn)
    return setmetatable({_fn = fn, _stale = true, _getters = {}, _deps = {}, __id = minetest.get_us_time()}, DerivedState)
end

local function get(e, x)
    local item = e[x]
    local mt = getmetatable(item)
    if mt == state or mt == DerivedState then
        return fe(tostring(item() or ""))
    end
    return fe(tostring(item or ""))
end

local function get_raw(e, x)
    local item = e[x]
    local mt = getmetatable(item)
    if mt == state or mt == DerivedState then
        return item()
    end
    return item
end

-- MARK: Elements

local fs_style = {
    render = function(e)
        local props = {}
        for k, v in pairs(e.props) do
            props[#props +1] = ";"
            props[#props +1] = k
            props[#props +1] = "="
            props[#props +1] = tostring(v)
        end
        return string.format("style[%s%s]", e.name, table.concat(props))
    end
}
fs_style.__index = fs_style
setmetatable(fs_style, {
    __call = function(_, name, props, internal)
        local e = {name = name, props = props}
        setmetatable(e, fs_style)
        if not internal then table.insert(ctx, e) end
        return e
    end
})

local fs_tooltip = {
    _no_layout = true,
    init = function(x, y, w, h, text, bgcolor, txtcolor)
        local e = {x = x, y = y, w = w, h = h, text = text, bgcolor = bgcolor or imfs.theme.tooltip_bg or "#444", txtcolor = txtcolor or imfs.theme.tooltip_text or "#aaa"}
        setmetatable(e, imfs.tooltip)
        return e
    end,
    named = function(name, text, bgcolor, txtcolor)
        local e = {_name = name, text = text, bgcolor = bgcolor or imfs.theme.tooltip_bg or "#444", txtcolor = txtcolor or imfs.theme.tooltip_text or "#aaa"}
        setmetatable(e, imfs.tooltip)
        return e
    end,
    render = function(e, x, y, w, h)
        if e._name then
            return string.format("tooltip[%s;%s;%s;%s]", e._name, get(e, "text"), get(e, "bgcolor"), get(e, "txtcolor"))
        else
            return string.format(
                "tooltip[%f,%f;%f,%f;%s;%s;%s]",
                x or get(e, "x"), y or get(e, "y"),
                w or get(e, "w"), h or get(e, "h"),
                get(e, "text"),
                get(e, "bgcolor"),
                get(e, "txtcolor")
            )
        end
    end
}
fs_tooltip.__index = fs_tooltip
setmetatable(fs_tooltip, {
    __call = function(_, x, y, w, h, text, bgcolor, txtcolor)
        local e = {x = x, y = y, w = w, h = h, text = text, bgcolor = bgcolor or imfs.theme.tooltip_bg or "#444", txtcolor = txtcolor or imfs.theme.tooltip_text or "#aaa"}
        setmetatable(e, fs_tooltip)
        table.insert(ctx, e)
        return e
    end
})

local fs_label = {
    render = function(e, x, y)
        return string.format("label[%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), get(e, "txt"))
    end
}
fs_label.__index = fs_label
setmetatable(fs_label, {
    __call = function(_, x, y, txt)
        local e = {x = x, y = y, txt = txt}
        setmetatable(e, fs_label)
        table.insert(ctx, e)
        return e
    end
})

local fs_arealabel = {
    render = function(e, x, y, w, h)
        x = x or get(e, "x")
        y = y or get(e, "y")
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        if e._scrollable then
            return string.format("textarea[%f,%f;%f,%f;;;%s]", x, y, w, h, get(e, "txt"))
        else
            return string.format("label[%f,%f;%f,%f;%s]", x, y, w, h, get(e, "txt"))
        end
    end,
    scrollable = function(e)
        e._scrollable = true
        return e
    end,
    tooltip = add_elem_tooltip,
}
fs_arealabel.__index = fs_arealabel
setmetatable(fs_arealabel, {
    __call = function(_, x, y, w, h, txt)
        local e = {x = x, y = y, w = w, h = h, txt = txt}
        setmetatable(e, fs_arealabel)
        table.insert(ctx, e)
        return e
    end
})

local fs_hypertext = {
    render = function(e, x, y, w, h)
        local out = {}
        for _, x in pairs(e._styles) do
            out[#out +1] = x:render()
        end
        
        out[#out +1] = string.format("hypertext[%f,%f;%f,%f;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), e.__id, get(e, "txt"))
        return table.concat(out)
    end,
    onaction = function(e, fn)
        ctx._events.on_click[e.__id] = fn
        return e
    end,
    named = function(e, name)
        e.__id = name
        return e
    end,
    styles = add_elem_styles,
    style = add_elem_style,
    tooltip = add_elem_tooltip,
}
fs_hypertext.__index = fs_hypertext
setmetatable(fs_hypertext, {
    __call = function(_, x, y, w, h, txt)
        local e = {x = x, y = y, w = w, h = h, txt = txt, _styles = {}}
        e.__id = new_id()
        setmetatable(e, fs_hypertext)
        table.insert(ctx, e)
        return e
    end
})

local fs_box = {
    render = function(e, x, y, w, h)
        return string.format("box[%f,%f;%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "bg"))
    end,
    tooltip = add_elem_tooltip,
}
fs_box.__index = fs_box
setmetatable(fs_box, {
    __call = function(_, x, y, w, h, bg)
        local e = {x = x, y = y, w = w, h = h, bg = bg}
        setmetatable(e, fs_box)
        table.insert(ctx, e)
        return e
    end
})

local fs_image = {
    render = function(e, x, y, w, h)
        if e._anim_frames then
            return string.format(
                "animated_image[%f,%f;%f,%f;%s;%s;%f;%f;%f;%s]",
                x or get(e, "x"), y or get(e, "y"),
                w or get(e, "w"), h or get(e, "h"),
                e.__id,
                get(e, "texture"),
                get(e, "_anim_frames"),
                get(e, "_anim_duration"),
                get(e, "_anim_start"),
                get(e, "middle")
            )
        else
            return string.format("image[%f,%f;%f,%f;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "texture"), get(e, "middle"))
        end
    end,
    animated = function(e, frames, duration, start)
        -- We only need an ID if the image is animated and must persist its state.
        e.__id = e.__id or new_id()
        e._anim_frames = frames
        e._anim_duration = duration or 50
        e._anim_start = start or 1
        return e
    end,
    tooltip = add_elem_tooltip,
}
fs_image.__index = fs_image
setmetatable(fs_image, {
    __call = function(_, x, y, w, h, texture, middle)
        local e = {x = x, y = y, w = w, h = h, texture = texture, middle = middle or ""}
        setmetatable(e, fs_image)
        table.insert(ctx, e)
        return e
    end
})

local fs_item_image = {
    render = function(e, x, y, w, h)
        return string.format("item_image[%f,%f;%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "item"))
    end,
    tooltip = add_elem_tooltip,
}
fs_item_image.__index = fs_item_image
setmetatable(fs_item_image, {
    __call = function(_, x, y, w, h, item)
        local e = {x = x, y = y, w = w, h = h, item = item}
        setmetatable(e, fs_item_image)
        table.insert(ctx, e)
        return e
    end
})

local fs_model = {
    render = function(e, x, y, w, h)
        local out = {}
        for _, x in pairs(e._styles) do
            out[#out +1] = x:render()
        end
        
        local textures = get_raw(e, "textures")
        if type(textures) ~= "string" then
            textures = table.concat(textures, ",")
        end
        
        local rx = get_raw(e, "_rotation_x")
        local ry = get_raw(e, "_rotation_y")
        local rotation = ""
        if rx and ry then
            rotation = string.format("%s,%s", fe(rx), fe(ry))
        end
        
        local as = get_raw(e, "_animation_start")
        local ae = get_raw(e, "_animation_end")
        local animation = ""
        if as and ae then
            animation = string.format("%s,%s", fe(as), fe(ae))
        end
        
        out[#out +1] = string.format(
            "model[%f,%f;%f,%f;%s;%s;%s;%s;%s;%s;%s;%s]",
            x or get(e, "x"), y or get(e, "y"),
            w or get(e, "w"), h or get(e, "h"),
            e.__id,
            get(e, "mesh"), textures,
            rotation, get(e, "_continuous"),
            get(e, "_mouse_control"),
            animation, get(e, "_animation_speed")
        )
        return table.concat(out)
    end,
    rotation = function(e, x, y, continuous)
        e._rotation_x = x
        e._rotation_y = y or 0
        e._continuous = continuous
        return e
    end,
    mouse_control = function(e, mouse_control)
        e._mouse_control = mouse_control ~= false and true or false
        return e
    end,
    animated = function(e, start, end_, speed)
        e._animation_start = start
        e._animation_end = end_
        e._animation_speed = speed or 1
        return e
    end,
    style = add_elem_style,
    styles = add_elem_styles,
    tooltip = add_elem_tooltip,
}
fs_model.__index = fs_model
setmetatable(fs_model, {
    __call = function(_, x, y, w, h, mesh, textures)
        local e = {x = x, y = y, w = w, h = h, mesh = mesh, textures = textures, _mouse_control = true, _styles = {}}
        e.__id = new_id()
        setmetatable(e, fs_model)
        table.insert(ctx, e)
        return e
    end
})

local fs_button = {
    render = function(e, x, y, w, h)
        local out = {}
        for _, x in pairs(e._styles) do
            out[#out +1] = x:render()
        end
        if e._item then
            out[#out +1] = string.format("item_image_button[%f,%f;%f,%f;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_item"), e.__id, get(e, "label"))
        elseif e._image then
            if e._image_pressed then
                -- We never specify noclip or border here. That's a job for styles.
                out[#out +1] = string.format("image_button[%f,%f;%f,%f;%s;%s;%s;;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label"), get(e, "_image_pressed"))
            else
                out[#out +1] = string.format("image_button%s[%f,%f;%f,%f;%s;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label"))
            end
        else
            out[#out +1] = string.format("button%s[%f,%f;%f,%f;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), e.__id, get(e, "label"))
        end
        return table.concat(out)
    end,
    image = function(e, img, pressed_img)
        e._image = img
        e._image_pressed = pressed_img
        return e
    end,
    item_image = function(e, item)
        e._item = item
        return e
    end,
    exit = function(e)
        e._exit = true
        return e
    end,
    onclick = function(e, fn)
        ctx._events.on_click[e.__id] = fn
        return e
    end,
    style = add_elem_style,
    styles = add_elem_styles,
    tooltip = add_elem_tooltip,
}
fs_button.__index = fs_button
setmetatable(fs_button, {
    __call = function(_, x, y, w, h, label)
        local e = {x = x, y = y, w = w, h = h, label = label, _styles = {}}
        e.__id = new_id()
        setmetatable(e, fs_button)
        table.insert(ctx, e)
        return e
    end
})

local fs_checkbox = {
    render = function(e, x, y)
        return string.format("checkbox[%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), e.__id, get(e, "label"), get(e, "checked") and "true" or "false")
    end,
    onchange = function(fn)
        ctx._events.on_change[e.__id] = fn
        return e
    end,
    tooltip = add_elem_tooltip,
}
fs_checkbox.__index = fs_checkbox
setmetatable(fs_checkbox, {
    __call = function(_, x, y, label, checked)
        local e = {x = x, y = y, label = label or "", checked = checked or label or false}
        e.__id = new_id()
        setmetatable(e, fs_checkbox)
        table.insert(ctx, e)
        return e
    end
})

local fs_list = {
    render = function(e, x, y, w, h)
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        return string.format("list[%s;%s;%f,%f;%d,%d;%s]", get(e, "location"), get(e, "list"), x or get(e, "x"), y or get(e, "y"), w, h, get(e, "start"))
    end
}
fs_list.__index = fs_list
setmetatable(fs_list, {
    __call = function(_, x, y, w, h, location, list, start)
        local e = {x = x, y = y, w = type(w == "string") and w or w +((w -1) /4), h = type(h) == "string" and h or h +((h -1) /4), location = location or "current_player", list = list or "main", start = start or ""}
        e.__id = new_id()
        setmetatable(e, fs_list)
        table.insert(ctx, e)
        return e
    end
})

local fs_inventory = fs_list

local fs_listring = {
    render = function(e)
        if e.location then
            return string.format("listring[%s;%s]", e.location, e.list)
        else
            return "listring[]"
        end
    end,
}
fs_listring.__index = fs_listring
setmetatable(fs_listring, {
    __call = function(_, location, list)
        local e = location and list and {location = location, list = list} or {}
        setmetatable(e, fs_listring)
        table.insert(ctx, e)
        return e
    end
})

local fs_field = {
    render = function(e, x, y, w, h)
        local out = {}
        for _, x in pairs(e._styles) do
            out[#out +1] = x:render()
        end
        
        x = x or get(e, "x")
        y = y or get(e, "y")
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        if e._password then
            out[#out +1] = string.format("pwdfield[%f,%f;%f,%f;%s;%s]", x, y, w, h, e.__id, get(e, "label"))
            
            if not e._close_on_enter then
                out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id)
            end
        else
            out[#out +1] = string.format("%s[%f,%f;%f,%f;%s;%s;%s]", e._textarea and "textarea" or "field", x, y, w, h, e.__id, get(e, "label"), get(e, "value"))
            
            if not e._textarea and not e._close_on_enter then
                out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id)
            end
        end
        
        return table.concat(out)
    end,
    onenter = function(e, fn)
        ctx._events.on_enter[e.__id] = fn
        return e
    end,
    onchange = function(e, fn)
        ctx._events.on_change[e.__id] = fn
        return e
    end,
    close_on_enter = function(e)
        e._close_on_enter = true
        return e
    end,
    multiline = function(e)
        e._textarea = true
        return e
    end,
    password = function(e)
        e._password = true
        return e
    end,
    style = add_elem_style,
    styles = add_elem_styles,
    tooltip = add_elem_tooltip,
}
fs_field.__index = fs_field
setmetatable(fs_field, {
    __call = function(_, x, y, w, h, label, value)
        local e = {x = x, y = y, w = w, h = h, label = value and label or "", value = value or label or "", _styles = {}}
        e.__id = new_id()
        setmetatable(e, fs_field)
        table.insert(ctx, e)
        return e
    end
})

local function fs_textarea(...)
    return fs_field(...)
               :multiline()
end

local fs_scrollbar = {
    render = function(e, x, y, w, h)
        local out = {}
        for _, x in pairs(e._styles) do
            out[#out +1] = x:render()
        end
        
        if e._options then
            out[#out +1] = "scrollbaroptions["
            local first = true
            for k, v in pairs(e._options) do
                if not first then
                    out[#out +1] = ";"
                end
                out[#out +1] = k
                out[#out +1] = "="
                out[#out +1] = v
                first = nil
            end
            out[#out +1] = "]"
        end
        
        out[#out +1] = string.format("scrollbar[%f,%f;%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "orientation"), e.__id, get(e, "value"))
        
        -- If we were given custom options, reset them all so they don't interfere with future scrollbars (particularly those automatically configured by scroll containers).
        if e._options then
            out[#out +1] = "scrollbaroptions[min=-1;max=-1;smallstep=-1;largestep=-1;thumbsize=-1;arrows=default]"
        end
        
        return table.concat(out)
    end,
    options = function(e, props)
        e._options = props
        return e
    end,
    onchange = function(e, fn)
        ctx._events.on_scrollbar_event[e.__id] = fn
        return e
    end,
    style = add_elem_style,
    styles = add_elem_styles,
    tooltip = add_elem_tooltip,
}
fs_scrollbar.__index = fs_scrollbar
setmetatable(fs_scrollbar, {
    __call = function(_, x, y, w, h, orientation, value)
        local e = {x = x, y = y, w = w, h = h, orientation = orientation or "vertical", value = value or "", _styles = {}}
        e.__id = new_id()
        setmetatable(e, fs_scrollbar)
        ctx[#ctx +1] = e
        return e
    end
})

local fs_scroll_container = {
    render = function(e, x, y, w, h, orient, fac, pad)
        x = x or get(e, "x")
        y = y or get(e, "y")
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        local out = {
            string.format("scroll_container[%f,%f;%f,%f;%s;%s;%s;%s]",
                x, y,
                w, h,
                e.__id,
                orient or get(e, "orientation"),
                fac or get(e, "factor"),
                pad or get(e, "padding")
            )
        }
        for i = 1, #e do
            local c = e[i]
            local cx, cy
            if c.x then
                cx = resolve_layout_units(get_raw(c, "x"), w)
                cy = resolve_layout_units(get_raw(c, "y"), h)
            end
            
            local cw, ch
            if c.w then
                cw = resolve_layout_units(get(c, "w"), w)
                ch = resolve_layout_units(get(c, "h"), h)
            end
            
            out[#out +1] = c:render(cx, cy, cw, ch)
        end
        out[#out +1] = "scroll_container_end[]"
        
        if not e._scrollbar then
            local v = e.__fs._ctx.fields[e.__id]
            out[#out +1] = string.format("scrollbar[-800,-800;0,0;%s;%s;%s]", get(e, "orientation"), e.__id, v and v:sub(5) or "")
        end
        
        return table.concat(out)
    end,
    scrollbar = function(e, fn, ...)
        -- Make a fake ctx to put the scrollbar in.
        ctx = setmetatable({
            __parent = ctx,
            _events = setmetatable({}, {
                __index = ctx._events,
                __newindex = ctx._events
            }),
        }, {
            -- Assignment trap to ensure that the scrollbar gets our ID immediately on construction.
            __newindex = function(tbl, key, value)
                value.__id = e.__id
                return rawset(tbl, key, value)
            end
        })
        if type(fn) == "function" then
            fn()
        else
            fs_scrollbar(fn, ...)
        end
        e._scrollbar = ctx[1]
        ctx = ctx.__parent
        e._scrollbar.__id = e.__id
        return e
    end,
    onscroll = function(e, fn)
        if e._scrollbar then
            e._scrollbar:onchange(fn)
            return e
        end
        e._scrollbar = fs_scrollbar(-800, -800, 0, 0, e._orientation, e.__id):onchange(fn)
        return e
    end,
    -- A non-transient name is required in order to preserve scroll position upon a rebuild.
    named = function(e, name)
        e.__id = name
        return e
    end
}
fs_scroll_container.__index = fs_scroll_container
setmetatable(fs_scroll_container, {
    __call = function(_, x, y, w, h, orientation, factor, padding)
        local e = {
            x = x,
            y = y,
            w = w,
            h = h,
            orientation = orientation or "vertical",
            factor = factor or "",
            padding = padding or "0",
            _styles = {},
            __parent = ctx,
            _events = setmetatable({}, {
                __index = ctx._events,
                __newindex = ctx._events
            }),
            __fs = ctx.__fs or ctx
        }
        e.__id = new_id()
        setmetatable(e, fs_scroll_container)
        table.insert(ctx, e)
        ctx = e
        return e
    end
})

local function fs_scroll_container_end()
    if getmetatable(ctx) ~= fs_scroll_container then
        minetest.log("warn", "`scroll_container_end` has no scroll container to end; it will be ignored.")
        return
    end
    -- If the container has a custom scrollbar, we add it _after_ the container's contents.
    if ctx._scrollbar then
        table.insert(ctx.__parent, ctx._scrollbar)
    end
    ctx = ctx.__parent
end


-- MARK: Builtin layouting helpers

local fs_group = {
    render = function(e, x, y, w, h)
        x = x or get(e, "x")
        y = y or get(e, "y")
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        local out = {}
        for i = 1, #e do
            local c = e[i]
            local cx, cy
            if c.x then
                cx = resolve_layout_units(get_raw(c, "x"), w) +x
                cy = resolve_layout_units(get_raw(c, "y"), h) +y
            end
            
            local cw, ch
            if c.w then
                cw = resolve_layout_units(get(c, "w"), w)
                ch = resolve_layout_units(get(c, "h"), h)
            end
            
            out[#out +1] = c:render(cx, cy, cw, ch)
        end
        
        return table.concat(out)
    end,
}
fs_group.__index = fs_group
setmetatable(fs_group, {
    __call = function(_, x, y, w, h)
        local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
        setmetatable(e, fs_group)
        table.insert(ctx, e)
        ctx = e
        return e
    end
})

local function fs_group_end()
    if getmetatable(ctx) ~= fs_group then
        minetest.log("warn", "`group_end` has no group to end; it will be ignored.")
        return
    end
    ctx = ctx.__parent
end


local fs_row = {
    render = function(e, x, y, w, h)
        x = x or get(e, "x")
        y = y or get(e, "y")
        w = w or get(e, "w")
        h = h or get(e, "h")
        
        local axis = e._direction == "column" and y or x
        local axis_size = e._direction == "column" and h or w
        
        local out = {}
        
        local total_grow = 0
        local used_space = 0
        local flex_found
        
        -- Pass 1: Collect total sizing information to allow layout computation.
        for i = 1, #e do
            local c = e[i]
            if c.w and not c._no_layout then
                local ca, ca_flex = resolve_flex_layout_units(get(c, e._direction == "column" and "h" or "w"), axis_size)
                if ca_flex then
                    flex_found = true
                    c.__flex = ca
                    total_grow = total_grow +ca
                else
                    used_space = used_space +(ca or 0)
                end
            end
        end
        
        used_space = used_space +(e._gap *math.max(0, #e -1))
        local grow_basis = total_grow > 0 and (math.max(0, axis_size -used_space) /total_grow) or 0

        -- If any flex element exists, it will take up all unused space, so the total width is guaranteed to be 100%.
        -- Otherwise, we can simply use the size we already calculated for fixed elements.
        local total_width = flex_found and axis_size or used_space
        
        -- Pass 2: Assign element positions based on flex ratios.
        local current = 0
        for i = 1, #e do
            local c = e[i]
            if not c._no_layout then
                c.__flex_offset = current
                if c.__flex then
                    c.__flex = c.__flex *grow_basis
                    current = current +c.__flex +e._gap
                elseif c.w then
                    current = current +resolve_layout_units(get(c, e._direction == "column" and "h" or "w"), axis_size) +e._gap
                else
                    current = current +e._gap
                end
            end
        end
        
        -- Pass 3: Justify and render.
        local base = axis
        if e._align == "center" then
            base = axis +((axis_size -total_width) /2)
        elseif e._align == "right" then
            base = axis +axis_size -total_width
        end
        
        for i = 1, #e do
            local c = e[i]
            if c._no_layout then
                local cx, cy
                if c.x then
                    cx = resolve_layout_units(get_raw(c, "x"), w) +x
                    cy = resolve_layout_units(get_raw(c, "y"), h) +y
                end
                
                local cw, ch
                if c.w then
                    cw = resolve_layout_units(get(c, "w"), w)
                    ch = resolve_layout_units(get(c, "h"), h)
                end
                
                out[#out +1] = c:render(cx, cy, cw, ch)
            elseif c.w then
                if e._direction == "column" then
                    out[#out +1] = c:render(resolve_layout_units(get(c, "x"), w) +x, base +c.__flex_offset, resolve_layout_units(get(c, "w"), w), c.__flex)
                else
                    out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"), h) +y, c.__flex, resolve_layout_units(get(c, "h"), h))
                end
            elseif c.x then
                if e._direction == "column" then
                    out[#out +1] = c:render(resolve_layout_units(get(c, "x"), w) +x, base +c.__flex_offset)
                else
                    out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"), h) +y)
                end
            end
        end
        
        return table.concat(out)
    end,
    direction = function(e, dir)
        e._direction = dir
        return e
    end,
    gap = function(e, gap)
        e._gap = gap
        return e
    end,
    align = function(e, align)
        e._align = align
        return e
    end,
}
fs_row.__index = fs_row
setmetatable(fs_row, {
    __call = function(_, x, y, w, h)
        local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
        setmetatable(e, fs_row)
        table.insert(ctx, e)
        ctx = e
        return e
    end
})


local function fs_row_end()
    if getmetatable(ctx) ~= fs_row then
        minetest.log("warn", "`row_end` has no row to end; it will be ignored.")
        return
    end
    local axis = ctx._direction == "column" and "w" or "h"
    if ctx[axis] == "fit" then
        local size = 0
        for i = 1, #ctx do
            local child_size = tonumber(ctx[i][axis]) or 0
            if child_size > size then
                size = child_size
            end
        end
        ctx[axis] = size
    end
    ctx = ctx.__parent
end

local function fs_column(...)
    return fs_row(...)
        :direction "column"
end

local fs_column_end = fs_row_end

-- MARK: Building

local Window = {
    render = function(e)
        local out = {
            "formspec_version[10]",
            string.format("size[%f,%f]", e.width, e.height)
        }

        if e._no_prepend then
            out[#out +1] = "no_prepend[]"
        end
        
        if e._position then
            out[#out +1] = string.format("position[%f,%f]", e._position.x, e._position.y)
        end

        if e._anchor then
            out[#out +1] = string.format("anchor[%f,%f]", e._anchor.x, e._anchor.y)
        end

        if e._padding then
            out[#out +1] = string.format("padding[%f,%f]", e._padding.x, e._padding.y)
        end
        
        if e._modal then
            out[#out +1] = "allow_close[false]"
        end
        
        if e._fullscreen then
            out[#out +1] = string.format("bgcolor[%s;%s;%s]", get(e, "_fgcolor"), get(e, "_fullscreen"), get(e, "_bgcolor"))
        end
        
        for i = 1, #e do
            local c = e[i]
            local cx, cy
            if c.x then
                cx = resolve_layout_units(get(c, "x"), e.width)
                cy = resolve_layout_units(get(c, "y"), e.height)
            end
            
            local cw, ch
            if c.w then
                cw = resolve_layout_units(get(c, "w"), e.width)
                ch = resolve_layout_units(get(c, "h"), e.height)
            end
            
            out[#out +1] = c:render(cx, cy, cw, ch)
        end
        return table.concat(out)
    end,
    no_prepend = function(e)
        e._no_prepend = true
        return e
    end,
    position = function(e, x, y)
        e._position = {x = x, y = y}
        return e
    end,
    anchor = function(e, x, y)
        e._anchor = {x = x, y = y}
        return e
    end,
    padding = function(e, x, y)
        e._padding = {x = x, y = y}
        return e
    end,
    modal = function(e, modal)
        e._modal = modal ~= false and true or false
        return e
    end,
    onclose = function(e, fn)
        e._onclose = fn
        return e
    end,
    bgcolor = function(e, foreground, fullscreen)
        if fullscreen then
            if fullscreen == true then
                e._fullscreen = "true"
                e._bgcolor = foreground
            else
                e._fullscreen = "both"
                e._fgcolor = foreground
                e._bgcolor = fullscreen
            end
        elseif not foreground then
            e._fullscreen = "neither"
        else
            e._fullscreen = "false"
            e._fgcolor = foreground
        end
        return e
    end
}
Window.__index = Window

local function fs_begin(w, h)
    ctx = {_events = {on_click = {}, on_change = {}, on_scrollbar_event = {}, on_enter = {}}, width = w or 12, height = h or 10}
    setmetatable(ctx, Window)
    name_scope = {"_"}
    nth_id = {0}
    return ctx
end

local function fs_end()
    local _ctx = ctx
    ctx = nil
    name_scope = nil
    nth_id = nil
    return _ctx
end

local Context = {
    update = function(e)
        -- This is used to prevent changes in state from inside the builder to trigger another rebuild.
        if e._ignore then return end
        -- `_inert` should be set when many state updates may take place at once, to avoid a rapid succession of rebuilds.
        -- After these updates have taken place, the user should call `rebuild()` manually if `_dirty` is true.
        if e._inert then
            e._dirty = true
        else
            e:rebuild()
        end
    end,
    rebuild = function(e)
        e._dirty = nil
        e._ignore = true
        e:clear_state_bindings()
        
        local tracker = e._linked_states
        table.insert(observers, tracker)
        
        local fs = type(e.formspec) == "function" and e.formspec(e.state, function() e:close() end) or e.formspec
        
        e._window = fs
        
        table.remove(observers)
        
        e._ignore = nil
        
        for x in pairs(e._linked_states) do
            x._getters[e.id] = e
        end
        
        fs._ctx = e
        
        e:collect_state_depends()
        
        e._events = fs._events
        
        local str = fs:render()
        if e._mainmenu then
            minetest.update_formspec(str)
        elseif e._is_inventory then
            e._player:set_inventory_formspec(str)
        else
            minetest.show_formspec(e.target, e.id, str)
        end
    end,
    get_states_from_elem = function(e, el)
        for k, x in pairs(el) do
            local mt = getmetatable(x)
            if mt == state or mt == DerivedState then
                x._getters[e.id] = e
                e._linked_states[x] = true
            elseif tonumber(k) then
                e:get_states_from_elem(x)
            end
        end
    end,
    collect_state_depends = function(e)
        for _, el in ipairs(e._window) do
            e:get_states_from_elem(el)
        end
    end,
    clear_state_bindings = function(e)
        for x in pairs(e._linked_states) do
            x._getters[e.id] = nil
        end
    end,
    deinit = function(e)
        if contexts[e.id] then
            contexts[e.id] = nil
        end
        if e._window._onclose then
            e._window:_onclose()
        end
        e:clear_state_bindings()
        -- Kill our rebuild capability in case any callbacks fire on the way out.
        e.rebuild = function() end
    end,
    close = function(e)
        -- Inventories cannot be 'closed', only replaced.
        if e._is_inventory then return end
        minetest.close_formspec(e.target, e.id)
        e:deinit()
    end
}
Context.__index = Context

local function fs_show(target, fs, state)
    local id = "form"..unique_id()
    local ctx = setmetatable({
        formspec = fs,
        fields = {},
        target = type(target) == "string" and target or target:get_player_name(),
        id = id,
        _linked_states = {},
        state = state or {}
    }, Context)
    
    if player_contexts[ctx.target] then
        player_contexts[ctx.target]:deinit()
    end
    
    contexts[id] = ctx
    player_contexts[ctx.target] = ctx
    
    ctx:rebuild()
    
    return ctx
end

local function fs_set_inventory(p, fs, state)
    local id = "form"..unique_id()
    local name = type(p) == "string" and p or p:get_player_name()
    local ctx = setmetatable({
        _is_inventory = true,
        _player = type(p) == "string" and minetest.get_player_by_name(p) or p,
        formspec = fs,
        fields = {},
        target = name,
        id = id,
        _linked_states = {},
        state = state or {}
    }, Context)
    
    inventories[name] = ctx
    
    ctx:rebuild()
    
    return ctx
end

local function fs_remove_inventory(p)
    local name = p:get_player_name()
    local inv = inventories[name]
    if inv then
        inv:deinit()
        inventories[name] = nil
    end
end

-- MARK: Callback handling

local function handler(ctx, fields)
    ctx._inert = true
    
    -- We split event handling into two passes here in order to guarantee that action events take precedence over state change events.
    -- Otherwise, it would be possible for a field's state synchronization to accidentally overwrite state changed by an action, which is almost certainly not what the user intended to happen.
    
    -- First pass: Stateful element updates.
    for k, v in pairs(fields) do
        if ctx._events.on_scrollbar_event[k] then
            local ev = minetest.explode_scrollbar_event(v)
            ctx._events.on_scrollbar_event[k](ev.type, ev.value)
        elseif (not ctx.fields[k] or ctx.fields[k] ~= v) and ctx._events.on_change[k] then
            ctx._events.on_change[k](v)
        end
    end

    -- Handle pressing Enter in a field. (This is somewhere between a state event and an action, so we process between passes.)
    if fields.key_enter_field and ctx._events.on_enter[fields.key_enter_field] then
        ctx._events.on_enter[fields.key_enter_field](fields[fields.key_enter_field])
    end
    
    -- Second pass: Actions.
    for k, v in pairs(fields) do
        if ctx._events.on_click[k] then
            ctx._events.on_click[k](v)
        end
    end
    
    ctx.fields = fields
    
    ctx._inert = nil
    
    if fields.quit then
        ctx:deinit()
    else
        if ctx._dirty then
            ctx:rebuild()
            ctx._dirty = nil
        end
    end
end

-- Compatibility with main menu usage.
if minetest.update_formspec then
    local mainmenu_context
    function fs_show(fs, state)
        if mainmenu_context then
            mainmenu_context:deinit()
        end
        
        local id = "form"..unique_id()
        local ctx = setmetatable({
            _mainmenu = true,
            formspec = fs,
            fields = {},
            target = "menu",
            id = id,
            _linked_states = {},
            state = state or {}
        }, Context)
        
        mainmenu_context = ctx
        
        ctx:rebuild()
        
        return ctx
    end
    
    fs_set_inventory = nil
    
    minetest.button_handler = function(fields)
        handler(mainmenu_context, fields)
    end
-- Normal in-game usage.
else
    minetest.register_on_player_receive_fields(function(p, form, fields)
        local name = p:get_player_name()
        local ctx = contexts[form]
        -- If this is an event from a player inventory, check if we have a context managing that inventory.
        -- No other special handling is needed, because the context already knows that it's an inventory and will react appropriately.
        if form == "" then
            ctx = inventories[name]
        end
        
        -- Ensure that a) players can only trigger effects of formspecs that were opened legitimately, and b) only the player who opened it may interact with a given formspec instance.
        if ctx and ctx.target == name then
            handler(ctx, fields)
        end
    end)
    
    -- Remove all contexts tied to a leaving player.
    minetest.register_on_leaveplayer(function(p)
        fs_remove_inventory(p)
    end)
end
    

-- MARK: API exposure

imfs = {
    _contexts = contexts,
    _inventories = inventories,
    
    _new_id = new_id,
    _unique_id = unique_id,
    
    state = state,
    derive = derive,
    get_field = get,
    begin = fs_begin,
    end_ = fs_end,
    scope = scope,
    scope_end = scope_end,
    show = fs_show,
    set_inventory = fs_set_inventory,
    remove_inventory = fs_remove_inventory,
    resolve_layout_units = resolve_layout_units,
    resolve_flex_layout_units = resolve_flex_layout_units,
    container_start = function()
        local container = {__parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
        table.insert(ctx, container)
        ctx = container
    end,
    container_end = function()
        ctx = ctx.__parent
    end,
    add_to_context = function(container)
        table.insert(ctx, container)
    end,
    
    theme = theme,
    
    style = fs_style,
    tooltip = fs_tooltip,
    label = fs_label,
    arealabel = fs_arealabel,
    hypertext = fs_hypertext,
    box = fs_box,
    image = fs_image,
    item_image = fs_item_image,
    model = fs_model,
    button = fs_button,
    list = fs_list,
    inventory = fs_inventory,
    listring = fs_listring,
    field = fs_field,
    textarea = fs_textarea,
    checkbox = fs_checkbox,
    scrollbar = fs_scrollbar,
    scroll_container = fs_scroll_container,
    scroll_container_end = fs_scroll_container_end,
    group = fs_group,
    group_end = fs_group_end,
    row = fs_row,
    row_end = fs_row_end,
    column = fs_column,
    column_end = fs_column_end,
    
    -- Can be called in a game's base mod to globalize imfs for brevity.
    export = function()
        for k, v in pairs(imfs) do
            local key
            if k == "state" or k == "derive" then
                key = k
            elseif k == "end_" then
                key = "fs_end"
            else
                key = "fs_"..k
            end
            _G[key] = v
        end
    end
}
