-- 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.

--- Bounded structured value inspection for LuaPad results
-- @module plugins.luapad.dump

local dump = {}

local DEFAULT_MAX_DEPTH = 5
local DEFAULT_MAX_ITEMS = 100
local DEFAULT_MAX_STRING_LENGTH = 1000
local DEFAULT_MAX_NODES = 1000

local key_type_order = {
	number = 1,
	string = 2,
	boolean = 3,
	table = 4,
	["function"] = 5,
	userdata = 6,
	thread = 7,
}

--- Convert one value to text without trusting its metatable
-- @param value any source value
-- @return string text
function dump.safe_tostring(value)
	local converted, text = pcall(tostring, value)

	if converted then
		return text
	end

	return string.format("<%s: tostring failed>", type(value))
end

--- Return a bounded quoted string for one result value
-- @param value string source string
-- @param maximum_length number maximum retained bytes
-- @return string text
local function format_string(value, maximum_length)
	if #value <= maximum_length then
		return string.format("%q", value)
	end

	return string.format("%q", value:sub(1, maximum_length)) .. "…"
end

--- Return one compact value description for table keys
-- @param value any key value
-- @param options table normalized inspection options
-- @return string text
local function format_key_value(value, options)
	local value_type = type(value)

	if value_type == "string" then
		return format_string(value, options.max_string_length)
	elseif value_type == "number" or value_type == "boolean" or value_type == "nil" then
		return tostring(value)
	end

	return dump.safe_tostring(value)
end

--- Return a readable table-child label for one key
-- @param key any table key
-- @param options table normalized inspection options
-- @return string label
local function format_key_label(key, options)
	if type(key) == "string" and key:match("^[%a_][%w_]*$") then
		return key
	end

	return "[" .. format_key_value(key, options) .. "]"
end

--- Compare table keys by type and then by their stable display text
-- @param left any first key
-- @param right any second key
-- @param options table normalized inspection options
-- @return boolean before
local function compare_keys(left, right, options)
	local left_type = type(left)
	local right_type = type(right)
	local left_order = key_type_order[left_type] or 100
	local right_order = key_type_order[right_type] or 100

	if left_order ~= right_order then
		return left_order < right_order
	end

	if left_type == "number" then
		return left < right
	end

	return format_key_value(left, options) < format_key_value(right, options)
end

--- Reserve one result node from the shared run budget
-- The first value beyond the limit gets one visible marker; later values are omitted
-- @param state table shared inspection state
-- @param node table requested node
-- @return table|nil node
local function reserve_node(state, node)
	if state.budget.remaining > 0 then
		state.budget.remaining = state.budget.remaining - 1

		return node
	end

	if not state.budget.limit_emitted then
		state.budget.limit_emitted = true

		return {
			kind = "limit",
			text = "maximum result size reached",
		}
	end

	return nil
end

--- Return whether a node is the shared result-size marker
-- @param node table|nil inspected node
-- @return boolean limited
local function is_size_limit(node)
	return node
		and node.kind == "limit"
		and node.text == "maximum result size reached"
		or false
end

--- Inspect one value into a bounded tree
-- @param value any source value
-- @param state table shared inspection state
-- @param depth number current table depth
-- @return table|nil node
local function inspect_value(value, state, depth)
	local value_type = type(value)

	if value_type == "nil" then
		return reserve_node(state, { kind = "nil", text = "nil" })
	elseif value_type == "string" then
		return reserve_node(state, {
			kind = "string",
			text = format_string(value, state.options.max_string_length),
		})
	elseif value_type == "number" or value_type == "boolean" then
		return reserve_node(state, { kind = value_type, text = tostring(value) })
	elseif value_type ~= "table" then
		return reserve_node(state, {
			kind = value_type,
			text = dump.safe_tostring(value),
		})
	end

	local existing_node = state.seen[value]

	if existing_node then
		return reserve_node(state, {
			kind = "reference",
			target = existing_node,
		})
	end

	local node = reserve_node(state, {
		kind = "table",
		children = {},
	})

	if not node or is_size_limit(node) then
		return node
	end

	state.seen[value] = node

	if depth >= state.options.max_depth then
		local limit = reserve_node(state, {
			kind = "limit",
			text = "maximum depth reached",
		})

		if not limit then
			return node
		end

		node.children[1] = {
			label = "…",
			node = limit,
		}

		return node
	end

	local keys = {}
	local truncated = false
	local key = next(value)

	while key ~= nil do
		if #keys >= state.options.max_items then
			truncated = true
			break
		end

		keys[#keys + 1] = key
		key = next(value, key)
	end

	table.sort(keys, function(left, right)
		return compare_keys(left, right, state.options)
	end)

	for index = 1, #keys do
		local child_key = keys[index]
		local child_node = inspect_value(rawget(value, child_key), state, depth + 1)

		if not child_node then
			break
		end

		node.children[#node.children + 1] = {
			label = format_key_label(child_key, state.options),
			node = child_node,
		}

		if is_size_limit(child_node) then
			break
		end
	end

	if state.options.include_metatable and not state.budget.limit_emitted then
		local metatable = getmetatable(value)

		if metatable ~= nil then
			local metatable_node = inspect_value(metatable, state, depth + 1)

			if metatable_node then
				node.children[#node.children + 1] = {
					label = "<metatable>",
					node = metatable_node,
				}
			end
		end
	end

	if truncated and not state.budget.limit_emitted then
		local limit = reserve_node(state, {
			kind = "limit",
			text = "more items",
		})

		if limit then
			node.children[#node.children + 1] = {
				label = "…",
				node = limit,
			}
		end
	end

	return node
end

--- Create one node budget shared by all results from a run
-- @param options table|nil inspection limits
-- @return table budget
function dump.new_budget(options)
	options = type(options) == "table" and options or {}

	return {
		remaining = math.max(0, math.floor(tonumber(options.max_nodes) or DEFAULT_MAX_NODES)),
		limit_emitted = false,
	}
end

--- Build one structured, cycle-safe value tree
-- @param value any source value
-- @param options table|nil inspection limits
-- @param budget table|nil shared node budget
-- @return table|nil node
function dump.inspect(value, options, budget)
	options = type(options) == "table" and options or {}
	local normalized_options = {
		max_depth = math.max(0, math.floor(tonumber(options.max_depth) or DEFAULT_MAX_DEPTH)),
		max_items = math.max(0, math.floor(tonumber(options.max_items) or DEFAULT_MAX_ITEMS)),
		max_string_length = math.max(
			0,
			math.floor(tonumber(options.max_string_length) or DEFAULT_MAX_STRING_LENGTH)
		),
		include_metatable = options.include_metatable == true,
	}
	local state = {
		options = normalized_options,
		seen = {},
		budget = budget or dump.new_budget(options),
	}

	return inspect_value(value, state, 0)
end

return dump
