-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

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

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

local evaluator = {}
local TIMEOUT_ERROR = {}
local NIL_VALUE = {}
local environment_values = setmetatable({}, { __mode = "k" })

--- Return one environment's stored value and whether it shadows its base
-- @param environment table execution environment
-- @param name any variable key
-- @return any value
-- @return boolean present
local function get_own_value(environment, name)
	local values = environment_values[environment]

	if values then
		local stored = values[name]

		if stored == NIL_VALUE then
			return nil, true
		end

		return stored, stored ~= nil
	end

	local value = rawget(environment, name)

	return value, value ~= nil
end

--- Restore one environment variable without changing its shadow state
-- @param environment table execution environment
-- @param name any variable key
-- @param value any stored value
-- @param present boolean whether the key shadows its base
local function restore_own_value(environment, name, value, present)
	local values = environment_values[environment]

	if values then
		if present then
			values[name] = value == nil and NIL_VALUE or value
		else
			values[name] = nil
		end
	elseif present then
		rawset(environment, name, value)
	else
		rawset(environment, name, nil)
	end
end

--- Wrap coroutine creation and resume with one shared deadline hook
-- @param base table original coroutine library
-- @param deadline_hook function hook that checks the run deadline
-- @return table proxy run-local coroutine library
-- @return function restore restores hooks on touched threads
local function create_timed_coroutine_proxy(base, deadline_hook)
	local previous_hooks = {}
	local proxy = {}

	local function install_hook(thread)
		if type(thread) ~= "thread" or base.status(thread) == "dead" then
			return
		end
		if previous_hooks[thread] == nil then
			previous_hooks[thread] = table.pack(debug.gethook(thread))
		end
		debug.sethook(thread, deadline_hook, "", 10000)
	end

	function proxy.create(fn)
		local thread = base.create(fn)
		install_hook(thread)

		return thread
	end

	function proxy.resume(thread, ...)
		install_hook(thread)
		local results = table.pack(base.resume(thread, ...))

		if results[1] == false and results[2] == TIMEOUT_ERROR then
			debug.sethook()
			error(TIMEOUT_ERROR, 0)
		end

		return table.unpack(results, 1, results.n)
	end

	function proxy.wrap(fn)
		local thread = proxy.create(fn)

		return function(...)
			local results = table.pack(proxy.resume(thread, ...))

			if results[1] == false then
				error(results[2], 2)
			end

			return table.unpack(results, 2, results.n)
		end
	end

	setmetatable(proxy, { __index = base })

	local function restore()
		for thread, previous in pairs(previous_hooks) do
			-- Dead threads may reject hook changes, while live ones regain their prior hook
			pcall(debug.sethook, thread, previous[1], previous[2], previous[3])
		end
	end

	return proxy, restore
end

--- 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
	local values = {}
	environment_values[environment] = values

	setmetatable(environment, {
		-- Scratch variables may start unset even though Lx rejects undefined application globals
		__index = function(_, name)
			local stored = values[name]

			if stored == NIL_VALUE then
				return nil
			end
			if stored ~= nil then
				return stored
			end
			if environment_values[fallback] then
				return fallback[name]
			end

			return rawget(fallback, name)
		end,
		-- Side storage lets nil remain an explicit shadow instead of deleting the key
		__newindex = function(_, name, value)
			if value == nil then
				values[name] = NIL_VALUE
			else
				values[name] = value
			end
		end,
		-- Iteration keeps showing scratch globals while omitting nil shadows
		__pairs = function()
			local name

			return function()
				local value

				repeat
					name, value = next(values, name)
				until name == nil or value ~= NIL_VALUE

				return name, value
			end
		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
-- @param budget table shared result-node budget
-- @return table nodes
local function inspect_return_values(values, first_index, options, budget)
	local nodes = {}

	for index = first_index, values.n do
		local node = dump.inspect(values[index], options, budget)

		if not node then
			break
		end

		nodes[#nodes + 1] = node
	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 dump_budget = dump.new_budget(options.dump_options)
	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 start_time = 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
		local node = dump.inspect(value, options.dump_options, dump_budget)

		if node then
			outputs[#outputs + 1] = {
				kind = "dump",
				label = type(label) == "string" and label or "dump" .. dump_count,
				node = node,
			}
		end

		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() - start_time,
			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 = start_time + math.max(0, timeout_seconds)
	local previous_coroutine
	local coroutine_proxy
	local restore_coroutine_hooks
	local deadline_hook = function()
		if system.get_time() >= deadline then
			debug.sethook()
			error(TIMEOUT_ERROR, 0)
		end
	end

	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(deadline_hook, "", 10000)
		hook_installed = true

		local base_coroutine = environment.coroutine
		if type(base_coroutine) == "table" then
			local coroutine_was_present
			previous_coroutine, coroutine_was_present = get_own_value(environment, "coroutine")
			coroutine_proxy, restore_coroutine_hooks = create_timed_coroutine_proxy(base_coroutine, deadline_hook)
			environment.coroutine = coroutine_proxy
			previous_coroutine = { value = previous_coroutine, present = coroutine_was_present }
		end
	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))

	if restore_coroutine_hooks then
		restore_coroutine_hooks()
	end
	if coroutine_proxy and environment.coroutine == coroutine_proxy then
		restore_own_value(
			environment,
			"coroutine",
			previous_coroutine.value,
			previous_coroutine.present
		)
	end

	-- 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, dump_budget),
			}
		end
	else
		outputs[#outputs + 1] = {
			kind = "error",
			text = tostring(returned[2]),
		}
	end

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

return evaluator
