-- lx-bundle:true

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

--- Provide a reusable Lua-owned single-selection Listbox
-- @module qui.listbox

local qui = require("qui")
local colors = require("qui.colors")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")
local Scrollbar = require("qui.scrollbar")

local Listbox = {}
local color_flags = setmetatable({}, { __mode = "k" })	-- One reusable flag set follows each live Listbox table

--- Return a checked non-negative whole DIP value
-- @param value number|nil requested value
-- @param fallback number default value
-- @param name string readable option name
-- @return integer value
local function dip_value(value, fallback, name)
	value = value == nil and fallback or value
	assert(type(value) == "number" and value >= 0 and value < math.huge, name .. " must be a non-negative number")

	return math.floor(value + 0.5)
end

--- Copy and validate items so later caller-table changes cannot silently damage selection
-- A string is shorthand for an enabled item whose text and value are the same
-- @param source table caller-supplied item list
-- @return table items normalized private item records
-- @return table indices_by_value item lookup by public value
local function prepare_items(source)
	assert(type(source) == "table", "Listbox items must be a table")

	local items = {}
	local indices_by_value = {}
	local item_count = 0
	local highest_index = 0
	for key in pairs(source) do
		assert(type(key) == "number" and key >= 1 and key % 1 == 0,
			"Listbox items must use positive whole-number positions")
		item_count = item_count + 1
		highest_index = math.max(highest_index, key)
	end
	assert(highest_index == item_count, "Listbox items must be a gapless list")

	for index, source_item in ipairs(source) do
		local item
		if type(source_item) == "string" then
			item = {
				text = source_item,
				value = source_item,
				enabled = true,
				data = nil,
			}
		else
			assert(type(source_item) == "table", "Listbox item " .. index .. " must be a string or table")
			assert(type(source_item.text) == "string", "Listbox item " .. index .. " text must be a string")
			local value = source_item.value == nil and source_item.text or source_item.value
			assert(
				type(value) == "string" or type(value) == "number" or type(value) == "boolean",
				"Listbox item " .. index .. " value must be a string, number, or boolean"
			)
			assert(type(value) ~= "number" or value == value and math.abs(value) < math.huge,
				"Listbox item " .. index .. " value number must be finite")
			assert(source_item.enabled == nil or type(source_item.enabled) == "boolean",
				"Listbox item " .. index .. " enabled must be a boolean")
			item = {
				text = source_item.text,
				value = value,
				enabled = source_item.enabled ~= false,
				data = source_item.data,	-- Application data stays untouched and is never interpreted by QUI
			}
		end

		assert(indices_by_value[item.value] == nil, "Listbox item values must be unique")
		items[index] = item
		indices_by_value[item.value] = index
	end

	return items, indices_by_value
end

--- Return whether an item can become the active selection
-- @param self table Listbox control
-- @param index integer|nil item position
-- @return boolean enabled
local function item_enabled(self, index)
	local item = index and self.items[index]

	return item ~= nil and item.enabled
end

--- Return the first enabled item at or beyond one position
-- @param self table Listbox control
-- @param start integer first position to inspect
-- @param direction integer negative for earlier or positive for later
-- @return integer|nil index
local function find_enabled(self, start, direction)
	local index = start
	while index >= 1 and index <= #self.items do
		if item_enabled(self, index) then
			return index
		end
		index = index + direction
	end

	return nil
end

--- Return how many complete item rows fit inside the current frame
-- @param self table Listbox control
-- @return integer count
local function visible_row_count(self)
	local inner_height = math.max(0, self.arranged_height - 2)

	return math.max(1, math.floor(inner_height / math.max(1, self.row_height)))
end

--- Report whether all item rows fit without a vertical Scrollbar
-- @param self table Listbox control
-- @return boolean visible
local function scrollbar_visible(self)
	return #self.items > visible_row_count(self)
end

--- Return the inside-right Scrollbar rectangle
-- The outer Listbox frame remains visible around both the rows and Scrollbar
-- @param self table Listbox control
-- @return integer x
-- @return integer y
-- @return integer width
-- @return integer height
local function scrollbar_bounds(self)
	local width = math.min(self.scrollbar.thickness, math.max(0, self.arranged_width - 2))
	local x = math.max(1, self.arranged_width - 1 - width)

	return x, 1, width, math.max(0, self.arranged_height - 2)
end

--- Return the exclusive right edge available to item rows
-- @param self table Listbox control
-- @return integer right
local function item_right(self)
	if scrollbar_visible(self) then
		local x = scrollbar_bounds(self)
		return x
	end

	return math.max(1, self.arranged_width - 1)
end

--- Keep the scroll position valid and optionally reveal one selected item
-- @param self table Listbox control
-- @param reveal_index integer|nil item that must remain visible
local function constrain_scroll(self, reveal_index)
	local visible_rows = visible_row_count(self)
	local maximum_first = math.max(1, #self.items - visible_rows + 1)
	self.first_visible = math.max(1, math.min(self.first_visible, maximum_first))

	if reveal_index then
		if reveal_index < self.first_visible then
			self.first_visible = reveal_index
		elseif reveal_index >= self.first_visible + visible_rows then
			self.first_visible = reveal_index - visible_rows + 1
		end
		self.first_visible = math.max(1, math.min(self.first_visible, maximum_first))
	end
end

--- Keep the embedded Scrollbar range synchronized with whole visible rows
-- @param self table Listbox control
local function sync_scrollbar(self)
	local visible_rows = visible_row_count(self)
	local maximum_first = math.max(1, #self.items - visible_rows + 1)
	local scrollbar = self.scrollbar
	scrollbar.min = 1
	scrollbar.max = maximum_first
	scrollbar.value = self.first_visible
	scrollbar.line = 1
	scrollbar.page = visible_rows
	scrollbar.enabled = self.enabled
end

--- Tell application code about one completed selection change
-- @param self table Listbox control
local function notify_change(self)
	if self.on_change then
		self:on_change(self.selected, self.selected_item)
	end
end

--- Keep the public selection fields together, including false boolean values
-- @param self table Listbox control
-- @param index integer|nil selected item position
local function assign_selection(self, index)
	self.selected_index = index
	self.selected_item = index == nil and nil or self.items[index]
	if self.selected_item == nil then
		self.selected = nil
	else
		self.selected = self.selected_item.value
	end
end

--- Change selection by item position and optionally request a redraw
-- @param self table Listbox control
-- @param index integer|nil requested position or nil to clear
-- @param invalidate boolean whether live drawing must be requested here
-- @return boolean|nil changed
-- @return string|nil error_text
local function select_index(self, index, invalidate)
	assert(index == nil or type(index) == "number" and index % 1 == 0,
		"Listbox selected_index must be a whole number or nil")
	if index ~= nil and not item_enabled(self, index) then
		return nil, "Listbox selection must name an enabled item"
	end
	if self.selected_index == index then
		return true
	end

	local previous_index = self.selected_index
	local previous_value = self.selected
	local previous_item = self.selected_item
	assign_selection(self, index)
	constrain_scroll(self, index)

	if invalidate then
		local invalidated, invalidate_error = self:invalidate()
		if not invalidated then
			self.selected_index = previous_index
			self.selected = previous_value
			self.selected_item = previous_item
			constrain_scroll(self, previous_index)

			return nil, invalidate_error
		end
	end

	notify_change(self)
	return true
end

--- Return the item under one local pointer position
-- @param self table Listbox control
-- @param x number local horizontal position
-- @param y number local vertical position
-- @return integer|nil index
local function item_at_position(self, x, y)
	if x < 1 or x >= item_right(self) or y < 1 or y >= self.arranged_height - 1 then
		return nil
	end

	local row = math.floor((y - 1) / math.max(1, self.row_height))
	local index = self.first_visible + row

	return index <= #self.items and index or nil
end

--- Report whether one pointer event belongs to the embedded Scrollbar strip
-- @param self table Listbox control
-- @param event table local pointer event
-- @return boolean inside
local function pointer_in_scrollbar(self, event)
	if not scrollbar_visible(self) then
		return false
	end
	if type(event.x) ~= "number" or type(event.y) ~= "number" then
		return false	-- Keyboard, focus, and wheel events do not carry a pointer position
	end

	local x, y, width, height = scrollbar_bounds(self)

	return event.x >= x and event.x < x + width and event.y >= y and event.y < y + height
end

--- Move selection by an ordinary or page-sized keyboard step
-- @param self table Listbox control
-- @param direction integer negative for earlier or positive for later
-- @param distance integer requested number of rows
-- @return integer|nil target
local function navigation_target(self, direction, distance)
	if #self.items == 0 then
		return nil
	end
	if self.selected_index == nil then
		return find_enabled(self, direction > 0 and 1 or #self.items, direction)
	end

	local target = math.max(1, math.min(#self.items, self.selected_index + direction * distance))
	local found = find_enabled(self, target, direction)
	if found == nil then
		found = find_enabled(self, target, -direction)
	end

	return found
end

--- Select one central color identifier for every Listbox instance
-- @param self table Listbox control
-- @param type string requested visual type
-- @param flags table current Listbox or item conditions
-- @return string color_identifier
function Listbox.default_color(self, type, flags)
	if type == "background" then
		if flags.down then
			return colors.listbox_selection_bg
		elseif flags.hovering then
			return colors.listbox_hover_bg
		end

		return colors.listbox_bg
	elseif type == "frame" then
		if flags.focused then
			return colors.listbox_focused_frame
		elseif flags.hovering then
			-- Every input uses one shared hover frame so fields feel related
			return colors.input_hover_frame
		end

		return colors.listbox_frame
	elseif type == "text" then
		if flags.grayed then
			return colors.listbox_disabled_text
		elseif flags.down then
			return colors.listbox_selection_text
		end

		return colors.listbox_text
	end

	return nil
end

--- Create one Listbox for use inside 'qui.pane.controls'
-- @param options table items, selection, sizing, placement, and optional on_change handler
-- @return table control unrestricted Lua Control declaration
function Listbox.new(options)
	assert(type(options) == "table", "Listbox expects a table")
	assert(options.on_change == nil or type(options.on_change) == "function", "Listbox on_change must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function", "Listbox on_color must be a function")
	assert(options.default_color == nil, "Listbox default_color is fixed; use on_color for dynamic changes")
	assert(not (options.selected ~= nil and options.selected_index ~= nil),
		"Listbox accepts selected or selected_index, not both")

	local control = options
	control.items, control.indices_by_value = prepare_items(control.items or {})
	control.font = control.font or Font.default
	assert(control.font, "Listbox requires a font")
	control.padding_x = dip_value(
		control.padding_x, theme.resolve_metric(metrics.listbox_padding_x), "Listbox padding_x")
	control.padding_y = dip_value(
		control.padding_y, theme.resolve_metric(metrics.listbox_padding_y), "Listbox padding_y")
	control.width = control.width and dip_value(control.width, 0, "Listbox width") or nil
	control.height = control.height and dip_value(control.height, 0, "Listbox height") or nil
	control.row_height = control.row_height and dip_value(control.row_height, 0, "Listbox row_height") or 1
	assert(control.row_height > 0, "Listbox row_height must be greater than zero")
	control.enabled = control.enabled ~= false
	control.focusable = control.focusable ~= false
	control.tab_stop = control.tab_stop ~= false
	control.accepts_pointer = control.accepts_pointer ~= false
	control.default_color = Listbox.default_color
	control.hovered = false
	control.hovered_index = nil
	control.focused = false
	control.first_visible = 1
	control.arranged_width = control.width or 0
	control.arranged_height = control.height or 0
	-- The embedded Scrollbar is Lua state and drawing behavior, not another Form-owned Control
	control.scrollbar = Scrollbar {
		focusable = false,
		tab_stop = false,
		on_change = function(_, value)
			control.first_visible = math.floor(value + 0.5)
			constrain_scroll(control)
		end,
	}

	local initial_index = control.selected_index
	if initial_index == nil and control.selected ~= nil then
		initial_index = control.indices_by_value[control.selected]
		assert(initial_index ~= nil, "Listbox selected value does not exist")
	end
	if initial_index ~= nil then
		assert(type(initial_index) == "number" and initial_index % 1 == 0,
			"Listbox selected_index must be a whole number")
		assert(item_enabled(control, initial_index), "Listbox initial selection must name an enabled item")
	end
	assign_selection(control, initial_index)
	color_flags[control] = {
		grayed = not control.enabled,
		down = false,
		hovering = false,
		focused = false,
	}

	--- Measure item text and derive a compact natural list height
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local widest_text = 0
		local _, font_height = control_canvas:measure_text(self.font, "Mg")
		self.row_height = math.max(self.row_height, font_height + self.padding_y * 2)
		for _, item in ipairs(self.items) do
			local text_width = control_canvas:measure_text(self.font, item.text)
			widest_text = math.max(widest_text, text_width)
		end

		local natural_width = self.width or math.max(120, widest_text + self.padding_x * 2 + 2)
		local natural_rows = math.max(1, math.min(#self.items, 6))
		if self.width == nil and #self.items > natural_rows then
			-- The automatic Scrollbar must not take width away from naturally measured captions
			natural_width = natural_width + self.scrollbar.thickness
		end
		local natural_height = self.height or self.row_height * natural_rows + 2
		local width = math.max(minimum_width, math.min(natural_width, maximum_width))
		local height = math.max(minimum_height, math.min(natural_height, maximum_height))

		return width, height
	end

	--- Draw visible rows directly inside one clipped Control
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		-- Wheel scrolling may intentionally move the selection off-screen until navigation selects again
		constrain_scroll(self)
		sync_scrollbar(self)
		local flags = assert(color_flags[self], "Listbox color flags are unavailable")
		flags.grayed = not self.enabled
		flags.down = false
		-- The Listbox surface stays normal; only the exact row under the pointer receives hover color
		flags.hovering = false
		flags.focused = self.focused

		control_canvas:fill_rect(0, 0, width, height, self:get_color("background", flags))
		local visible_rows = visible_row_count(self)
		local final_index = math.min(#self.items, self.first_visible + visible_rows - 1)
		local row_right = item_right(self)
		for index = self.first_visible, final_index do
			local item = self.items[index]
			local row_y = 1 + (index - self.first_visible) * self.row_height
			flags.grayed = not self.enabled or not item.enabled
			flags.down = index == self.selected_index
			flags.hovering = index == self.hovered_index
			flags.focused = false
			if flags.down or flags.hovering then
				control_canvas:fill_rect(1, row_y, math.max(0, row_right - 1), self.row_height,
					self:get_color("background", flags))
			end

			local _, text_height = control_canvas:measure_text(self.font, item.text)
			local text_y = row_y + math.floor((self.row_height - text_height) / 2)
			control_canvas:draw_text(self.font, item.text, self.padding_x + 1, text_y, self:get_color("text", flags))
		end

		if scrollbar_visible(self) then
			local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
			-- Paint one DIP under the right and bottom frame so fractional scaling cannot expose a seam
			self.scrollbar:draw_at(control_canvas, x, y, scrollbar_width, scrollbar_height, 1, 1)
		end

		flags.grayed = not self.enabled
		flags.down = false
		flags.hovering = self.hovered
		flags.focused = self.focused
		control_canvas:draw_rect(0, 0, width, height, 1, self:get_color("frame", flags))
	end

	--- Handle direct selection, scrolling, focus, and standard list navigation
	function control:event(event)
		sync_scrollbar(self)
		local scrollbar_was_hovered = self.scrollbar.hovered_thumb
		local targets_scrollbar = self.scrollbar.dragging or pointer_in_scrollbar(self, event)
		if targets_scrollbar and
			(event.kind == "pointer_enter" or event.kind == "pointer_move" or
				event.kind == "pointer_down" or event.kind == "pointer_up")
		then
			local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
			self.hovered = true
			self.hovered_index = nil

			return self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
		elseif not self.scrollbar.dragging then
			self.scrollbar.hovered_thumb = false
		end

		if event.kind == "pointer_enter" then
			self.hovered = true
			self.hovered_index = item_at_position(self, event.x, event.y)
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_move" then
			local previous_index = self.hovered_index
			self.hovered_index = item_at_position(self, event.x, event.y)

			return {
				handled = true,
				redraw = previous_index ~= self.hovered_index or scrollbar_was_hovered,
			}
		elseif event.kind == "pointer_leave" then
			local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
			self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
			self.hovered = false
			self.hovered_index = nil
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_down" and event.button == "left" then
			local index = item_at_position(self, event.x, event.y)
			if item_enabled(self, index) then
				local changed, change_error = select_index(self, index, false)
				assert(changed, "Listbox pointer selection failed: " .. tostring(change_error))
			end

			return { handled = true, redraw = true, focus = "acquire" }
		elseif event.kind == "wheel" and event.dy ~= 0 then
			local direction = event.dy > 0 and -1 or 1
			local notches = math.max(1, math.floor(math.abs(event.dy) + 0.5))
			local previous_first = self.first_visible
			self.first_visible = self.first_visible + direction * notches * 3
			constrain_scroll(self)

			return { handled = true, redraw = previous_first ~= self.first_visible }
		elseif event.kind == "focus_gained" then
			self.focused = true
			return { redraw = true }
		elseif event.kind == "focus_lost" then
			self.focused = false
			if self.scrollbar.dragging then
				local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
				return self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
			end

			return { redraw = true }
		elseif event.kind ~= "key_down" then
			return nil
		end

		local target
		if event.key == "up" then
			target = navigation_target(self, -1, 1)
		elseif event.key == "down" then
			target = navigation_target(self, 1, 1)
		elseif event.key == "home" then
			target = find_enabled(self, 1, 1)
		elseif event.key == "end" then
			target = find_enabled(self, #self.items, -1)
		elseif event.key == "pageup" then
			target = navigation_target(self, -1, math.max(1, visible_row_count(self) - 1))
		elseif event.key == "pagedown" then
			target = navigation_target(self, 1, math.max(1, visible_row_count(self) - 1))
		else
			return nil
		end

		if target then
			local changed, change_error = select_index(self, target, false)
			assert(changed, "Listbox keyboard selection failed: " .. tostring(change_error))
		end

		return { handled = true, redraw = true }
	end

	--- Return the item under one local pointer position
	-- Combobox uses this same hit test so its Scrollbar strip never accepts an item
	-- @param x number local horizontal position
	-- @param y number local vertical position
	-- @return integer|nil index
	function control:item_index_at(x, y)
		return item_at_position(self, x, y)
	end

	--- Select one item by its public value, or clear selection with nil
	-- @param value string|number|boolean|nil requested value
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_selected(value)
		local index = value == nil and nil or self.indices_by_value[value]
		if value ~= nil and index == nil then
			return nil, "Listbox selected value does not exist"
		end

		return select_index(self, index, true)
	end

	return qui.control(control)
end

-- Callable module syntax keeps declarations readable as 'Listbox { ... }'
setmetatable(Listbox, {
	__call = function(_, options)
		return Listbox.new(options)
	end,
})

return Listbox
