local t = require('spec.zync_test')
local sandbox = require('zync.api.configurator.sandbox')

local assert = t.assert

local function get_upvalue(fn, name)
    local i = 1
    while true do
        local up_name, up_value = debug.getupvalue(fn, i)
        if not up_name then
            return nil
        end
        if up_name == name then
            return up_value
        end
        i = i + 1
    end
end

describe('Configurator sandbox', function()
    before_each(function()
        t.helpers.setup()
    end)

    after_each(function()
        t.helpers.teardown()
    end)

    it('loads a valid zconf table', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = 'nucleo_f767zi', runner = 'openocd' }")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(err)
        t.helpers.assert_tables_equal(cfg, {
            board = 'nucleo_f767zi',
            runner = 'openocd',
        })
    end)

    it('rejects missing required fields', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { runner = 'openocd' }")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match("missing required field 'board'"))
    end)

    it('allows profiles without top-level board', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { profiles = { prod = { board = 'nucleo_f767zi' } } }")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(err)
        t.helpers.assert_tables_equal(cfg, {
            profiles = {
                prod = {
                    board = 'nucleo_f767zi',
                },
            },
        })
    end)

    it('blocks forbidden globals in the sandbox', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = os.getenv('ZEPHYR_BASE') }")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match("access to global 'os' is not allowed"))
    end)

    it('validates schema types', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = 123 }")

        local cfg, err = sandbox.load_zconf(path, true)

        assert.is_nil(cfg)
        assert.is_truthy(err:match("field 'board' has to be string"))

        t.helpers.write_file(path, "return { profiles = 'bad' }")

        cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match("field 'profiles' has to be table"))
    end)

    it('rejects non-table config returns', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return 'nope'")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('table expected') or err:match('plain table'))
    end)

    it('rejects metatables in returned config', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return setmetatable({ board = 'nucleo_f767zi' }, {})")

        local cfg, err = sandbox.load_zconf(path, true)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('must return a plain table'))
    end)

    it('rejects nested metatables', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, [[
        local nested = setmetatable({ value = 1 }, {})
        return { board = 'nucleo_f767zi', nested = nested }
        ]])

        local cfg, err = sandbox.load_zconf(path, true)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('metatables are not allowed in config tables'))
    end)

    it('warns on unknown fields', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = 'nucleo_f767zi', extra = 1 }")

        local print_stub = t.stub(_G, 'print')
        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(err)
        assert.is_truthy(cfg)
        assert.stub(print_stub).was.called_with("zconf: unknown field 'extra'")
        print_stub:revert()
    end)

    it('enforces config depth limits', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, [[
        return {
            board = 'nucleo_f767zi',
            nested = { a = { b = { c = { d = { e = { f = { g = { h = {} } } } } } } } },
        }
        ]])

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('config table too deep'))
    end)

    it('errors on empty path', function()
        local ok, cfg_or_err = pcall(function()
            return sandbox.load_zconf('', false)
        end)

        assert.is_false(ok)
        assert.is_truthy(tostring(cfg_or_err):match('Failed to load zync config'))
    end)

    it('propagates loadfile errors for invalid syntax', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = ")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('unexpected') or err:match('syntax'))
    end)

    it('propagates load errors after reading source', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, "return { board = 'nucleo_f767zi' }")

        local load_stub = t.stub(_G, 'load', function()
            return nil, 'forced load failure'
        end)

        local cfg, err = sandbox.load_zconf(path, false)

        load_stub:revert()

        assert.is_nil(cfg)
        assert.is_truthy(err:match('forced load failure'))
    end)

    it('enforces runtime instruction limits', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, [[
        local i = 0
        while true do
            i = i + 1
        end
        return { board = 'nucleo_f767zi' }
        ]])

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('instruction limit exceeded') or err:match('time limit exceeded'))
    end)

    it('enforces runtime time limits', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, [[
        local i = 0
        while true do
            i = i + 1
        end
        return { board = 'nucleo_f767zi' }
        ]])

        local calls = 0
        local clock_stub = t.stub(os, 'clock', function()
            calls = calls + 1
            if calls == 1 then
                return 0
            end
            return 999
        end)

        local cfg, err = sandbox.load_zconf(path, false)

        clock_stub:revert()

        assert.is_nil(cfg)
        assert.is_truthy(err:match('time limit exceeded'))
    end)

    it('enforces config size limits', function()
        local path = t.tmp .. '/zconf.lua'
        local entries = {}
        for i = 1, 2100 do
            table.insert(entries, string.format("k%d = %d", i, i))
        end
        t.helpers.write_file(path, "return { board = 'nucleo_f767zi', " .. table.concat(entries, ', ') .. " }")

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('config too large'))
    end)

    it('blocks defining new globals in sandbox', function()
        local path = t.tmp .. '/zconf.lua'
        t.helpers.write_file(path, [[
        foo = 1
        return { board = 'nucleo_f767zi' }
        ]])

        local cfg, err = sandbox.load_zconf(path, false)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('defining global'))
    end)

    it('exposes sandbox env access guards', function()
        local sandbox_fn = get_upvalue(sandbox.load_zconf, 'sandbox')
        assert.is_truthy(sandbox_fn)

        local env = sandbox_fn({})

        local ok_access, err_access = pcall(function()
            return env.nope
        end)
        assert.is_false(ok_access)
        assert.is_truthy(tostring(err_access):match('access to global'))

        local ok_define, err_define = pcall(function()
            env.nope = 1
        end)
        assert.is_false(ok_define)
        assert.is_truthy(tostring(err_define):match('defining global'))
    end)

    it('rejects non-table schema inputs', function()
        local validate_schema = get_upvalue(sandbox.load_zconf, 'validate_schema')
        local zconf_schema = get_upvalue(sandbox.load_zconf, 'zconf_schema')
        assert.is_truthy(validate_schema)
        assert.is_truthy(zconf_schema)

        local cfg, err = validate_schema('nope', zconf_schema)

        assert.is_nil(cfg)
        assert.is_truthy(err:match('plain table'))
    end)

    it('tracks instruction limit hook execution', function()
        local with_limits = get_upvalue(sandbox.load_zconf, 'with_limits')
        assert.is_truthy(with_limits)

        local hook_err
        local hook_stub = t.stub(debug, 'sethook', function(_, hook)
            if type(hook) == 'function' then
                local ok, err = pcall(hook)
                if not ok then
                    hook_err = err
                end
            end
        end)

        local cfg, err = with_limits(function()
            return { board = 'nucleo_f767zi' }
        end, {
            max_insn = 0,
            hook_every = 1,
            max_sec = 100,
        })

        hook_stub:revert()

        assert.is_truthy(cfg)
        assert.is_nil(err)
        assert.is_truthy(tostring(hook_err):match('instruction limit exceeded'))
    end)

    it('tracks time limit hook execution', function()
        local with_limits = get_upvalue(sandbox.load_zconf, 'with_limits')
        assert.is_truthy(with_limits)

        local calls = 0
        local clock_stub = t.stub(os, 'clock', function()
            calls = calls + 1
            if calls == 1 then
                return 0
            end
            return 999
        end)

        local hook_err
        local hook_stub = t.stub(debug, 'sethook', function(_, hook)
            if type(hook) == 'function' then
                local ok, err = pcall(hook)
                if not ok then
                    hook_err = err
                end
            end
        end)

        local cfg, err = with_limits(function()
            return { board = 'nucleo_f767zi' }
        end, {
            max_insn = 1000000,
            hook_every = 1,
            max_sec = 0,
        })

        hook_stub:revert()
        clock_stub:revert()

        assert.is_truthy(cfg)
        assert.is_nil(err)
        assert.is_truthy(tostring(hook_err):match('time limit exceeded'))
    end)
end)
