--- @module "zync.api.configurator.sandbox"
---
--- Sandbox environment to safely load config
---
local Sandbox = {}

--- @class zync.api.configurator.sandbox.schema_rule
---
--- Config entry rule
---
--- @field type string      Config entry type
--- @field required boolean | fun(cfg: table<any>): boolean Config entry required?
---

--- @alias zync.api.configurator.sandbox.zconf_schema table<string, zync.api.configurator.sandbox.schema_rule>
---
--- Zync config schema
---

--- Constructs zync config schema rule
---
--- @param type string  Config entry type
--- @param req boolean | function  Requirement
---
--- @return zync.api.configurator.sandbox.schema_rule
---
local function entry(type, req)
    return { type = type, required = req }
end

--- @type zync.api.configurator.sandbox.zconf_schema
---
--- Zync config schema
---
local zconf_schema = {
    -- stylua: ignore start
    board           = entry('string', function(cfg) return cfg.profiles == nil end),
    runner          = entry('string', false),
    profiles        = entry('table',  false),
    default_profile = entry('string', false),
    args            = entry('table',  false),
    zephyr_ws       = entry('string', false),
    zephyr_base     = entry('string', false),
    -- stylua: ignore end
}
-- NOTE: zconf.lua only allows the fields above at top-level. ${key} interpolation
-- is intended for values within supported fields (e.g., args or profiles), not
-- for introducing new top-level keys.

--- @class zync.api.configurator.sandbox.runtime_limits
---
--- Runtime limits for zconf execution
---
--- @field max_insn number      Max instructions
--- @field hook_every number    Debug hook period (bytecode instructions)
--- @field max_sec number       Max runtime in s
---

--- @type zync.api.configurator.sandbox.runtime_limits
---
--- Zync config execution runtime limits
---
local runtime_limits = {
    max_insn = 200000,
    hook_every = 1000,
    max_sec = 3,
}

--- @class zync.api.configurator.sandbox.size_limits
---
--- Zync config size limits
---
--- @field max_nodes number     Maximum table nodes
--- @field max_depth number     Maximum table depth

--- @type zync.api.configurator.sandbox.size_limits
---
--- Zync config size limits
local size_limits = {
    max_nodes = 2000,
    max_depth = 8,
}

--- @class zync.api.configurator.sandbox.env
---
--- Safe sandbox environment
---
--- Used to safely execute lua code
---

--- @type table<string, any>
---
--- Sandbox env base whitelisting selected _ENV functionality
---
local env_base = {
    -- basic
    assert = assert,
    error = error,
    ipairs = ipairs,
    pairs = pairs,
    next = next,
    tonumber = tonumber,
    tostring = tostring,
    type = type,
    -- data helpers
    math = math,
    string = string,
    table = {
        insert = table.insert,
        remove = table.remove,
        sort = table.sort,
        concat = table.concat,
    },
}

--- Creates a sandbox environment from a provided base
---
--- @param base table<string, any> Sandbox env base
---
--- @return zync.api.configurator.sandbox.env Constructed safe sandbox environment
---
local function sandbox(base)
    return setmetatable(base, {
        __index = function(_, k)
            error(("zconf: access to global '%s' is not allowed"):format(tostring(k)), 2)
        end,
        __newindex = function(_, k, _)
            error(("zconf: defining global '%s' is not allowed"):format(tostring(k)), 2)
        end,
    })
end

--- Checks whether a provided table is a plain table
---
--- @param t table<any> Table to check
---
--- @return boolean True if plain, false otherwise
---
local function is_plain_table(t)
    if type(t) ~= 'table' then
        return false
    end
    return getmetatable(t) == nil
end

--- Validates zync config against a schema of config rules
---
--- @param cfg table<any> Config to validate
--- @param schema zync.api.configurator.sandbox.zconf_schema Config schema
---
--- @return zync.api.config | nil Zync config if successfully loaded, nil if failed
--- @return string? Error string if errs out during load
---
local function validate_schema(cfg, schema)
    -- plain table?
    if not is_plain_table(cfg) then
        return nil, 'zconf: must return a plain table (no metatable)'
    end

    -- unknown keys
    for k, _ in pairs(cfg) do
        if not schema[k] then
            print(("zconf: unknown field '%s'"):format(tostring(k)))
        end
    end

    -- required keys
    for k, rule in pairs(schema) do
        local v = cfg[k]
        if not v then
            local required = rule.required
            if type(required) == 'function' then
                required = required(cfg)
            end
            if required then
                return nil, ("zconf: missing required field '%s'"):format(k)
            end
        else
            if rule.type and type(v) ~= rule.type then
                return nil, ("zconf: field '%s' has to be %s (got %s)"):format(k, rule.type, type(v))
            end
        end
    end

    return cfg
end

--- Executes zync config with enforced limits
---
--- @param chunk function Chunk zync config is loaded into
--- @param limits zync.api.configurator.sandbox.runtime_limits  Zync config execution runtime limits
---
--- @return zync.api.config | nil Zync config if successfully loaded, nil if failed
--- @return string? Error string if errs out during load
---
local function with_limits(chunk, limits)
    local max_insn = limits.max_insn
    local hook_every = limits.hook_every
    local max_sec = limits.max_sec
    local start = os.clock()

    -- create debug coroutine
    local co = coroutine.create(chunk)
    local insn = 0

    -- hook up debug coroutine to run every `hook_every` instructions
    debug.sethook(co, function()
        -- check bytecode instruction count
        insn = insn + hook_every
        if insn > max_insn then
            error('zconf: instruction limit exceeded (possible infinite loop)', 2)
        end

        -- check elapsed time
        if (os.clock() - start) > max_sec then
            error('zconf: time limit exceeded (possible busy loop)', 2)
        end
    end, '', hook_every)

    -- get hook status
    local ok, res = coroutine.resume(co)
    -- clear hook
    debug.sethook(co)

    if not ok then
        return nil, res
    end
    return res
end

--- Enforces zync config size limits
---
--- @param root table Zync config table root
--- @param limits zync.ap.configurator.sandbox.size_limits Config table size limits
---
local function enforce_size_limits(root, limits)
    local max_nodes = limits.max_nodes
    local max_depth = limits.max_depth

    local nodes = 0
    local function walk(t, depth)
        if depth > max_depth then
            error('zconf: config table too deep', 2)
        end
        for _, v in pairs(t) do
            nodes = nodes + 1
            if nodes > max_nodes then
                error('zconf: config too large', 2)
            end

            if type(v) == 'table' then
                if getmetatable(v) ~= nil then
                    error('zconf: metatables are not allowed in config tables')
                end
                walk(v, depth + 1)
            end
        end
    end

    walk(root, 1)
end

--- Loads zync config in a safe sandbox environment
---
--- @param path string      Path to zync config
--- @param unsafe? boolean  Force unsafe execution
---
--- @return zync.api.config | nil Zync config if successfully loaded, nil if failed
--- @return string? Error string if errs out during load
---
function Sandbox.load_zconf(path, unsafe)
    if not path or (path and path == '') then
        return nil, error('Failed to load zync config.', 2)
    end

    -- Load as *text* (Lua 5.2+) rejecting bytecode chunks
    local chunk, err = loadfile(path, 't')
    if not chunk then
        return nil, err
    end

    -- Construct sandbox env (or just plain env if unsafe forced)
    local env = (not unsafe or (unsafe and unsafe == false)) and sandbox(env_base) or _ENV

    -- Lua 5.2+ / 5.3 / 5.4
    -- loadfile already returned a function; set its _ENV upvalue:
    -- easiest is to re-load through load(), but we can also use debug (don't).
    -- So: just re-load with env using load().
    local src = assert(io.open(path, 'rb')):read('*a')
    -- NOTE: using io here is in zync, not exposed to zconf env.
    local c2, e2 = load(src, '@' .. path, 't', env)
    if not c2 then
        return nil, e2
    end
    chunk = c2

    -- Execute loaded zync config module with runtime limits
    local res, run_err = with_limits(chunk, runtime_limits)
    if not res then
        return nil, run_err
    end

    --- Enforce size limits
    local ok
    ok, err = pcall(enforce_size_limits, res, size_limits)
    if not ok then
        return nil, err
    end

    --- Validate loaded config
    return validate_schema(res, zconf_schema)
end

return Sandbox
