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

--- Lua editor with permanent tab-local results lane
-- @module plugins.luapad.view

local common = require("core.common")
local config = require("core.config")
local core = require("core")
local command = require("core.command")
local style = require("core.style")
local TextEditor = require("core.text.editor")
local evaluator = require("plugins.luapad.evaluator")

local LuaPadView = TextEditor:extend()

local PANEL_LANE_ID = "lua_pad_results"
local PANEL_LANE_ORDER = 100
local PANEL_DIVIDER_HEIGHT = 2
local PANEL_DIVIDER_HIT_HEIGHT = 6
local PANEL_HEADER_PADDING = 6

--- Fill one LuaPad environment with the small set used by most Lx scripts
-- @param environment table LuaPad execution environment
local function prepare_environment(environment)
	-- Native APIs already exist globally; these commonly used Lua modules do not
	environment.core = core
	environment.config = config
	environment.command = command
	environment.common = common
	-- Refresh this before every run so it follows the view used immediately before LuaPad
	environment.previous_view = core.last_active_view
end

local lua_keywords = {
	["and"] = true,
	["break"] = true,
	["do"] = true,
	["else"] = true,
	["elseif"] = true,
	["end"] = true,
	["false"] = true,
	["for"] = true,
	["function"] = true,
	["goto"] = true,
	["if"] = true,
	["in"] = true,
	["local"] = true,
	["nil"] = true,
	["not"] = true,
	["or"] = true,
	["repeat"] = true,
	["return"] = true,
	["then"] = true,
	["true"] = true,
	["until"] = true,
	["while"] = true,
}

--- Return whether one point is inside a rectangle
-- @param point_x number pointer x
-- @param point_y number pointer y
-- @param x number rectangle x
-- @param y number rectangle y
-- @param width number rectangle width
-- @param height number rectangle height
-- @return boolean inside
local function point_in_rect(point_x, point_y, x, y, width, height)
	return point_x >= x
		and point_y >= y
		and point_x < x + width
		and point_y < y + height
end

--- Return whether text can serve as an unquoted Lua variable name
-- @param text string candidate name
-- @return boolean valid
local function is_lua_name(text)
	-- Pattern breakdown:
	-- '^[%a_][%w_]*$' accepts one Lua-style identifier and rejects punctuation or spaces
	return text:match("^[%a_][%w_]*$") ~= nil and not lua_keywords[text]
end

--- Return one child's visible key and full reference path
-- @param parent_path string owning table path
-- @param label string inspected child label
-- @return string display_label
-- @return string child_path
local function format_child_name(parent_path, label)
	if is_lua_name(label) then
		return "[" .. label .. "]", parent_path .. "." .. label
	elseif label:sub(1, 1) == "[" then
		return label, parent_path .. label
	end

	return "[" .. label .. "]", parent_path .. "[" .. string.format("%q", label) .. "]"
end

--- Append one structured value and its children as tab-indented rows
-- @param rows table target rows
-- @param label string value label
-- @param node table inspected value
-- @param depth number indentation depth
-- @param path string full display path
-- @param table_paths table first path for each inspected table
local function append_value_rows(rows, label, node, depth, path, table_paths)
	local prefix = string.rep("\t", depth)
	local text = prefix .. label

	if node.kind == "table" then
		table_paths[node] = path

		if #node.children == 0 then
			text = text .. " <empty table>"
		end
	elseif node.kind == "reference" then
		text = text .. " <reference to " .. (table_paths[node.target] or "<unknown>") .. ">"
	else
		text = text .. " " .. node.text
	end

	rows[#rows + 1] = {
		depth = depth,
		kind = node.kind,
		text = text,
	}

	for child_index = 1, #(node.children or {}) do
		local child = node.children[child_index]
		local child_label, child_path

		if child.node.kind == "limit" then
			child_label, child_path = child.label, path
		else
			child_label, child_path = format_child_name(path, child.label)
		end

		append_value_rows(rows, child_label, child.node, depth + 1, child_path, table_paths)
	end
end

--- Allocate a unique valid Lua-style root name
-- @param preferred_name string|nil requested name
-- @param prefix string fallback prefix
-- @param used_names table names already used by this result
-- @return string name
local function allocate_root_name(preferred_name, prefix, used_names)
	if preferred_name and is_lua_name(preferred_name) and not used_names[preferred_name] then
		used_names[preferred_name] = true

		return preferred_name
	end

	local suffix = 1
	local name = prefix .. suffix

	while used_names[name] do
		suffix = suffix + 1
		name = prefix .. suffix
	end

	used_names[name] = true

	return name
end

--- Convert one evaluator result into visible panel rows
-- @param result table evaluator result
-- @return table rows
local function build_result_rows(result)
	local rows = {}
	local used_names = {}

	for output_index = 1, #result.outputs do
		local output = result.outputs[output_index]

		if output.kind == "print" then
			rows[#rows + 1] = {
				kind = "print",
				text = "print: " .. output.text,
			}
		elseif output.kind == "dump" then
			local root_name = allocate_root_name(output.label, "dump", used_names)
			append_value_rows(rows, root_name, output.node, 0, root_name, {})
		elseif output.kind == "return" then
			for value_index = 1, #output.values do
				local root_name = allocate_root_name(nil, "return", used_names)
				append_value_rows(rows, root_name, output.values[value_index], 0, root_name, {})
			end
		elseif output.kind == "error" then
			local first_line = true

			-- Pattern breakdown:
			-- '[^\n]+' returns each non-empty traceback line for separate panel rows
			for line in output.text:gmatch("[^\n]+") do
				rows[#rows + 1] = {
					kind = "error",
					text = (first_line and "Error: " or "  ") .. line,
				}
				first_line = false
			end
		end
	end

	if #rows == 0 then
		rows[1] = {
			kind = "notice",
			text = "No output",
		}
	end

	return rows
end

--- Return the theme color for one result row kind
-- @param kind string row kind
-- @return table color
local function get_row_color(kind)
	local color

	if kind == "error" then
		color = style.error
	elseif kind == "heading" then
		color = style.accent
	elseif kind == "reference" then
		color = style.notice
	elseif kind == "limit" then
		color = style.warning
	elseif kind == "print" then
		color = style.text
	else
		color = style.syntax.normal or style.text
	end

	return style.as_plain_color(color) or style.text
end

local panel_input_handler = {}

--- Begin divider dragging or consume ordinary result-panel clicks
-- @param view table LuaPad view
-- @param button string pointer button
-- @param _x number pointer x
-- @param y number pointer y
-- @return boolean handled
function panel_input_handler.on_mouse_down(view, button, _x, y)
	local _, panel_y = view:get_lane_rect("bottom", PANEL_LANE_ID)

	if button == "left" and panel_y and y <= panel_y + PANEL_DIVIDER_HIT_HEIGHT * (SCALE or 1) then
		view.lua_pad_panel_dragging = true
	end

	-- Result clicks must never place the editor caret behind the reserved lane
	return true
end

--- Resize the results lane while its divider is held
-- @param view table LuaPad view
-- @param _x number pointer x
-- @param y number pointer y
function panel_input_handler.on_mouse_move(view, _x, y)
	if not view.lua_pad_panel_dragging or view.size.y <= 0 then
		return
	end

	local requested_height = view.position.y + view.size.y - y
	view.lua_pad_panel_ratio = requested_height / view.size.y
	view:refresh_result_panel_lane()
	core.redraw = true
end

--- Finish results divider dragging
-- @param view table LuaPad view
function panel_input_handler.on_mouse_up(view)
	view.lua_pad_panel_dragging = false
end

--- Cancel results divider dragging when the pointer leaves
-- @param view table LuaPad view
function panel_input_handler.on_mouse_left(view)
	view.lua_pad_panel_dragging = false
end

--- Return the divider cursor only near the resizable panel edge
-- @param view table LuaPad view
-- @param _x number pointer x
-- @param y number pointer y
-- @return string cursor
function panel_input_handler.get_mouse_cursor(view, _x, y)
	local _, panel_y = view:get_lane_rect("bottom", PANEL_LANE_ID)

	if panel_y and y <= panel_y + PANEL_DIVIDER_HIT_HEIGHT * (SCALE or 1) then
		return "sizev"
	end

	return "arrow"
end

--- Build one LuaPad editor and its permanent lower result lane
-- @param doc table Lua source document
-- @param opts table|nil editor options
function LuaPadView:new(doc, opts)
	LuaPadView.super.new(self, doc, opts)
	self.is_lua_pad = true
	self.lua_pad_environment = evaluator.create_environment()
	prepare_environment(self.lua_pad_environment)
	self.lua_pad_panel_ratio = tonumber(config.plugins.lua_pad.panel_height_ratio) or 0.32
	self.lua_pad_result_scroll = 0
	self.lua_pad_status = "Ready"
	self.lua_pad_result_rows = {
		{
			kind = "notice",
			text = "Ctrl+Enter runs this Lua script",
		},
	}
	self:set_lane_input_handler("bottom", PANEL_LANE_ID, panel_input_handler)
	self:refresh_result_panel_lane()
end

--- Return the LuaPad-specific key context before ordinary editor bindings
-- @return table contexts
function LuaPadView:get_keymap_contexts()
	return { "lua_pad", "text_editor", "doc_view", "global" }
end

--- Return a stable scratch title until the pad is saved to a real file
-- @return string name
function LuaPadView:get_name()
	if self.doc.filename then
		return LuaPadView.super.get_name(self)
	end

	return "Luapad" .. (self.doc:is_dirty() and "*" or "")
end

--- Reserve a bounded fraction of this tab for its results
function LuaPadView:refresh_result_panel_lane()
	local scale = SCALE or 1
	local minimum_editor_height = (tonumber(config.plugins.lua_pad.minimum_editor_height) or 120) * scale
	local maximum_panel_height = math.max(0, self.size.y - minimum_editor_height)
	local minimum_panel_height = math.min(
		(tonumber(config.plugins.lua_pad.minimum_panel_height) or 120) * scale,
		maximum_panel_height
	)
	local requested_height = self.size.y * common.clamp(self.lua_pad_panel_ratio, 0.05, 0.9)
	local panel_height = maximum_panel_height > 0
		and common.clamp(requested_height, minimum_panel_height, maximum_panel_height)
		or 0

	self.lua_pad_panel_ratio = self.size.y > 0 and panel_height / self.size.y
		or self.lua_pad_panel_ratio
	self:set_lane_size("bottom", PANEL_LANE_ID, panel_height, PANEL_LANE_ORDER)
	-- Panel, row, font, and scale changes must keep visible rows in range
	self.lua_pad_result_scroll = common.clamp(
		self.lua_pad_result_scroll,
		0,
		self:get_result_scroll_max()
	)
end

--- Return the largest valid vertical result scroll
-- @return number maximum scroll
function LuaPadView:get_result_scroll_max()
	local _, _, _, panel_height = self:get_lane_rect("bottom", PANEL_LANE_ID)

	if not panel_height then
		return 0
	end

	local scale = SCALE or 1
	local font = style.code_font or style.font
	local line_height = font:get_height() + 2 * scale
	local header_height = style.font:get_height() + PANEL_HEADER_PADDING * 2 * scale
	local body_height = math.max(0, panel_height - header_height)

	return math.max(0, #self.lua_pad_result_rows * line_height - body_height)
end

--- Execute the complete LuaPad document and replace its visible results
-- @return boolean executed
function LuaPadView:execute()
	self.lua_pad_status = "Running"
	core.redraw = true
	-- The user may switch between LuaPad and different views between executions
	prepare_environment(self.lua_pad_environment)

	local result = evaluator.run(table.concat(self.doc.lines), {
		chunk_name = self.doc.filename or "Luapad",
		environment = self.lua_pad_environment,
		timeout_seconds = config.plugins.lua_pad.timeout_seconds,
		dump_options = {
			max_depth = config.plugins.lua_pad.dump_max_depth,
			max_items = config.plugins.lua_pad.dump_max_items,
			max_string_length = config.plugins.lua_pad.dump_max_string_length,
			max_nodes = config.plugins.lua_pad.dump_max_nodes,
			include_metatable = config.plugins.lua_pad.dump_include_metatable,
		},
	})

	self.lua_pad_environment = result.environment
	local status_labels = {
		ok = "Complete",
		compile_error = "Compile error",
		runtime_error = "Runtime error",
		timeout = "Timed out",
	}
	self.lua_pad_status = string.format(
		"%s · %.2f ms",
		status_labels[result.status] or "Stopped",
		result.duration * 1000
	)
	self.lua_pad_result_rows = build_result_rows(result)
	self.lua_pad_result_scroll = 0
	core.redraw = true

	return true
end

--- Keep the result lane sized before editor viewport calculations
function LuaPadView:update()
	self:refresh_result_panel_lane()
	LuaPadView.super.update(self)
end

--- Scroll results when the pointer is over the lower panel
-- @param a number wheel value
-- @param b number|nil wheel value
-- @param c number|nil wheel value
-- @param d number|nil wheel value
-- @return boolean|nil handled
function LuaPadView:on_mouse_wheel(a, b, c, d)
	local mouse = core.workspace_view and core.workspace_view.mouse
	local x, y, width, height = self:get_lane_rect("bottom", PANEL_LANE_ID)

	if mouse and x and point_in_rect(mouse.x, mouse.y, x, y, width, height) then
		local delta = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a
		local line_height = (style.code_font or style.font):get_height()
		self.lua_pad_result_scroll = common.clamp(
			self.lua_pad_result_scroll - delta * line_height * 3,
			0,
			self:get_result_scroll_max()
		)
		core.redraw = true

		return true
	end

	return LuaPadView.super.on_mouse_wheel(self, a, b, c, d)
end

--- Draw the lower result panel after the ordinary editor surface
function LuaPadView:draw_result_panel()
	local x, y, width, height = self:get_lane_rect("bottom", PANEL_LANE_ID)

	if not x or height <= 0 then
		return
	end

	local scale = SCALE or 1
	local divider_height = math.max(1, PANEL_DIVIDER_HEIGHT * scale)
	local header_height = style.font:get_height() + PANEL_HEADER_PADDING * 2 * scale
	local body_top = y + header_height
	local body_height = math.max(0, height - header_height)
	local font = style.code_font or style.font
	local line_height = font:get_height() + 2 * scale
	local first_row = math.max(1, math.floor(self.lua_pad_result_scroll / line_height) + 1)
	local row_y = body_top
		- (self.lua_pad_result_scroll % line_height)
	local last_row = math.min(
		#self.lua_pad_result_rows,
		first_row + math.ceil(body_height / line_height)
	)

	core.push_clip_rect(x, y, width, height)
	renderer.draw_rect(x, y, width, height, style.background2)
	renderer.draw_rect(x, y, width, divider_height, style.divider)
	renderer.draw_rect(x, y + divider_height, width, header_height - divider_height, style.background3)
	renderer.draw_text(
		style.font,
		"Results · " .. self.lua_pad_status,
		x + style.padding.x,
		y + PANEL_HEADER_PADDING * scale,
		style.accent
	)

	core.push_clip_rect(x, body_top, width, body_height)

	for row_index = first_row, last_row do
		local row = self.lua_pad_result_rows[row_index]
		local depth = row.depth or 0
		local tab_width = font:get_width(" ") * self.doc:get_indent_size()
		local visible_text = depth > 0 and row.text:sub(depth + 1) or row.text

		renderer.draw_text(
			font,
			visible_text,
			x + style.padding.x + depth * tab_width,
			row_y,
			get_row_color(row.kind)
		)
		row_y = row_y + line_height
	end

	core.pop_clip_rect()
	core.pop_clip_rect()
end

--- Draw the normal editor and its tab-local results
function LuaPadView:draw()
	LuaPadView.super.draw(self)
	self:draw_result_panel()
end

return LuaPadView
