-- lx-bundle:true

--- LuaPad execution with captured output and bounded structured values
-- @module plugins.luapad.evaluator

local dump = require("plugins.luapad.dump")

local evaluator = {}
local TIMEOUT_ERROR = {}

--- Build one execution-local environment with access to the live Lx globals
-- Writes remain inside this environment while reads fall through to Lx
-- @param base table|nil fallback environment
-- @return table environment
function evaluator.create_environment(base)
	local environment = {}
	local fallback = base or _G

	setmetatable(environment, {
		-- Scratch variables may start unset even though Lx rejects undefined application globals
		__index = function(_, name)
			return rawget(fallback, name)
		end,
	})
	environment._G = environment

	return environment
end

--- Join captured print arguments with the same tab separator as Lua print
-- @param ... any printed values
-- @return string text
local function format_print_arguments(...)
	local values = table.pack(...)
	local parts = {}

	for index = 1, values.n do
		parts[index] = dump.safe_tostring(values[index])
	end

	return table.concat(parts, "\t")
end

--- Inspect every packed return value without losing nil entries
-- @param values table packed xpcall result
-- @param first_index number first return-value index
-- @param options table|nil dump limits
-- @return table nodes
local function inspect_return_values(values, first_index, options)
	local nodes = {}

	for index = first_index, values.n do
		nodes[#nodes + 1] = dump.inspect(values[index], options)
	end

	return nodes
end

--- Execute one Lua source chunk inside the live Lx ecosystem
-- @param source string Lua source
-- @param options table|nil execution settings
-- @return table result
function evaluator.run(source, options)
	options = type(options) == "table" and options or {}
	local environment = options.environment or evaluator.create_environment(options.base_environment)
	local outputs = {}
	local dump_count = 0
	local chunk_name = tostring(options.chunk_name or "Lua Pad")
	local timeout_seconds = tonumber(options.timeout_seconds)

	if timeout_seconds == nil then
		timeout_seconds = 0.25
	end

	local started_at = system.get_time()

	-- Capture output in run order while leaving the real global print untouched
	environment.print = function(...)
		outputs[#outputs + 1] = {
			kind = "print",
			text = format_print_arguments(...),
		}
	end

	-- Return the original value so dump can stay inside expressions
	environment.dump = function(value, label)
		dump_count = dump_count + 1
		outputs[#outputs + 1] = {
			kind = "dump",
			label = type(label) == "string" and label or "dump" .. dump_count,
			node = dump.inspect(value, options.dump_options),
		}

		return value
	end

	local chunk, compile_error = load(source, "@" .. chunk_name, "t", environment)

	if not chunk then
		return {
			status = "compile_error",
			duration = system.get_time() - started_at,
			environment = environment,
			outputs = {
				{
					kind = "error",
					text = tostring(compile_error),
				},
			},
		}
	end

	local previous_hook
	local previous_hook_mask
	local previous_hook_count
	local hook_installed = false
	local deadline = started_at + math.max(0, timeout_seconds)

	if timeout_seconds > 0 and debug and type(debug.sethook) == "function" then
		previous_hook, previous_hook_mask, previous_hook_count = debug.gethook()

		-- Pure Lua that exceeds its budget is stopped before it can freeze the editor indefinitely
		debug.sethook(function()
			if system.get_time() >= deadline then
				debug.sethook()
				error(TIMEOUT_ERROR, 0)
			end
		end, "", 10000)
		hook_installed = true
	end

	local runtime_status = "ok"

	--- Convert runtime errors into readable tracebacks while recognizing the timeout sentinel
	-- @param runtime_error any raised value
	-- @return string traceback
	local function handle_runtime_error(runtime_error)
		if runtime_error == TIMEOUT_ERROR then
			runtime_status = "timeout"

			return string.format("Execution stopped after %.0f ms", timeout_seconds * 1000)
		end

		runtime_status = "runtime_error"

		return debug.traceback(tostring(runtime_error), 2)
	end

	local returned = table.pack(xpcall(chunk, handle_runtime_error))

	-- Restore any debugger or profiler hook that existed before this run
	if hook_installed then
		debug.sethook(previous_hook, previous_hook_mask, previous_hook_count)
	end

	if returned[1] then
		if returned.n > 1 then
			outputs[#outputs + 1] = {
				kind = "return",
				values = inspect_return_values(returned, 2, options.dump_options),
			}
		end
	else
		outputs[#outputs + 1] = {
			kind = "error",
			text = tostring(returned[2]),
		}
	end

	return {
		status = runtime_status,
		duration = system.get_time() - started_at,
		environment = environment,
		outputs = outputs,
	}
end

return evaluator
