-- 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 Lua-owned non-editable Combobox with a Window overlay Listbox
-- @module qui.combobox

local qui = require("qui")
local colors = require("qui.colors")
local Glyph = require("qui.glyph")
local Listbox = require("qui.listbox")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")

local Combobox = {}
local color_flags = setmetatable({}, { __mode = "k" })	-- One reusable flag set follows each live Combobox 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

--- Return whether one item can become the public selection
-- @param self table Combobox 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

--- Find the first enabled item while walking in one direction
-- @param self table Combobox 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

--- Find one ordinary or page-sized navigation target
-- @param self table Combobox control
-- @param current integer|nil current preview or committed position
-- @param direction integer negative for earlier or positive for later
-- @param distance integer requested item distance
-- @return integer|nil target
local function navigation_target(self, current, direction, distance)
	if #self.items == 0 then
		return nil
	end
	if current == nil then
		return find_enabled(self, direction > 0 and 1 or #self.items, direction)
	end

	local target = math.max(1, math.min(#self.items, current + 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 Combobox instance
-- @param self table Combobox control
-- @param type string requested visual type
-- @param flags table current field conditions
-- @return string|nil color_identifier
function Combobox.default_color(self, type, flags)
	if type == "background" then
		if flags.grayed then
			return colors.combobox_disabled_bg
		elseif flags.down then
			return colors.combobox_down_bg
		elseif flags.pressing then
			return colors.combobox_pressing_bg
		end

		return colors.combobox_bg
	elseif type == "frame" then
		if flags.grayed then
			return colors.combobox_disabled_frame
		elseif flags.down then
			return colors.combobox_down_frame
		elseif flags.pressing then
			return colors.combobox_pressing_frame
		elseif flags.focused then
			return colors.combobox_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.combobox_frame
	elseif type == "inner_frame" then
		if flags.down then
			return colors.combobox_down_inner_frame
		elseif flags.pressing then
			return colors.combobox_pressing_inner_frame
		end
	elseif type == "text" then
		if flags.grayed then
			return colors.combobox_disabled_text
		elseif flags.down then
			return colors.combobox_down_text
		elseif flags.pressing then
			return colors.combobox_pressing_text
		elseif flags.hovering then
			return colors.combobox_hover_text
		end

		return colors.combobox_text
	end

	return nil
end

--- Create one non-editable Combobox for use inside 'qui.pane.controls'
-- Its private Listbox remains Form-owned while C++ places it above the complete native Window
-- @param options table items, selection, sizing, placement, and optional on_change handler
-- @return table control unrestricted Lua Control declaration
function Combobox.new(options)
	assert(type(options) == "table", "Combobox expects a table")
	assert(options.on_change == nil or type(options.on_change) == "function",
		"Combobox on_change must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function",
		"Combobox on_color must be a function")
	assert(options.default_color == nil, "Combobox default_color is fixed; use on_color for dynamic changes")
	assert(options.overlays == nil, "Combobox manages its own overlay")
	assert(options.dropdown == nil, "Combobox dropdown is managed internally")
	assert(not (options.selected ~= nil and options.selected_index ~= nil),
		"Combobox accepts selected or selected_index, not both")

	local control
	local dropdown = Listbox {
		name = type(options.name) == "string" and options.name ~= "" and options.name .. "_dropdown" or nil,
		items = options.items or {},
		selected = options.selected,
		selected_index = options.selected_index,
		focusable = false,
		tab_stop = false,
		on_color = function(_, type)
			-- A popup needs a visible boundary even before the pointer enters it
			if type == "frame" then
				return colors.input_hover_frame
			end
		end,
	}

	control = options
	control.items = dropdown.items
	control.indices_by_value = dropdown.indices_by_value
	control.selected = dropdown.selected
	control.selected_index = dropdown.selected_index
	control.selected_item = dropdown.selected_item
	control.dropdown = dropdown
	control.overlays = { dropdown }
	control.font = control.font or Font.compact
	assert(control.font, "Combobox requires a font")
	control.padding_x = dip_value(
		control.padding_x, theme.resolve_metric(metrics.combobox_padding_x), "Combobox padding_x")
	control.padding_y = dip_value(
		control.padding_y, theme.resolve_metric(metrics.combobox_padding_y), "Combobox padding_y")
	control.arrow_width = dip_value(
		control.arrow_width, theme.resolve_metric(metrics.combobox_arrow_width), "Combobox arrow_width")
	control.overlay_gap = dip_value(
		control.overlay_gap, theme.resolve_metric(metrics.combobox_overlay_gap), "Combobox overlay_gap")
	control.width = control.width and dip_value(control.width, 0, "Combobox width") or nil
	control.height = control.height and dip_value(control.height, 0, "Combobox height") or nil
	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 = Combobox.default_color
	control.hovered = false
	control.focused = false
	control.keyboard_pressed = false
	control.arranged_width = control.width or 0
	control.arranged_height = control.height or 0
	control.dropdown_width = control.width or 1
	control.dropdown_height = 1
	color_flags[control] = {
		grayed = not control.enabled,
		down = false,
		pressing = false,
		hovering = false,
		focused = false,
	}
	--- Keep the private Listbox preview aligned without publishing an application change
	-- @param value string|number|boolean|nil requested preview value
	-- @return boolean|nil changed
	-- @return string|nil error_text
	local function set_dropdown_selection(value)
		return dropdown:set_selected(value)
	end

	--- Apply one committed value to all public fields and the private preview
	-- @param value string|number|boolean|nil requested value
	-- @param invalidate boolean whether the closed field must request drawing
	-- @return boolean|nil changed
	-- @return string|nil error_text
	local function commit_selection(value, invalidate)
		local index = value == nil and nil or control.indices_by_value[value]
		if value ~= nil and index == nil then
			return nil, "Combobox selected value does not exist"
		end
		if index ~= nil and not item_enabled(control, index) then
			return nil, "Combobox selection must name an enabled item"
		end
		if control.selected_index == index then
			return true
		end

		local previous_value = control.selected
		local previous_index = control.selected_index
		local previous_item = control.selected_item
		local previewed, preview_error = set_dropdown_selection(value)
		if not previewed then
			return nil, preview_error
		end

		control.selected = value
		control.selected_index = index
		control.selected_item = index == nil and nil or control.items[index]
		if invalidate then
			local invalidated, invalidate_error = control:invalidate()
			if not invalidated then
				control.selected = previous_value
				control.selected_index = previous_index
				control.selected_item = previous_item
				set_dropdown_selection(previous_value)

				return nil, invalidate_error
			end
		end

		if control.on_change then
			control:on_change(control.selected, control.selected_item)
		end

		return true
	end

	--- Publish a selection which the private Listbox has already completed
	-- This avoids asking the Listbox to select itself again from inside its own change callback
	-- @return boolean|nil changed
	-- @return string|nil error_text
	local function publish_dropdown_selection()
		if control.selected_index == dropdown.selected_index then
			return true
		end

		local previous_value = control.selected
		local previous_index = control.selected_index
		local previous_item = control.selected_item
		control.selected = dropdown.selected
		control.selected_index = dropdown.selected_index
		control.selected_item = dropdown.selected_item
		local invalidated, invalidate_error = control:invalidate()
		if not invalidated then
			control.selected = previous_value
			control.selected_index = previous_index
			control.selected_item = previous_item
			set_dropdown_selection(previous_value)

			return nil, invalidate_error
		end

		if control.on_change then
			control:on_change(control.selected, control.selected_item)
		end

		return true
	end

	--- Open the private Listbox under or above the latest arranged field
	-- @return boolean|nil shown
	-- @return string|nil error_text
	local function open_dropdown()
		if control.enabled == false or control:overlay_visible(dropdown) then
			return true
		end

		local previewed, preview_error = set_dropdown_selection(control.selected)
		if not previewed then
			return nil, preview_error
		end

		return control:show_overlay(dropdown, {
			width = math.max(1, control.arranged_width, control.dropdown_width),
			height = math.max(1, control.dropdown_height),
			gap = control.overlay_gap,
		})
	end

	--- Close the private Listbox without changing the committed value
	-- @return boolean|nil hidden
	-- @return string|nil error_text
	local function close_dropdown()
		if not control:overlay_visible(dropdown) then
			return true
		end

		return control:hide_overlay(dropdown)
	end

	--- Move either the temporary open preview or the committed closed selection
	-- @param direction integer negative for earlier or positive for later
	-- @param distance integer requested number of items
	-- @return boolean|nil changed
	-- @return string|nil error_text
	local function move_selection(direction, distance)
		local open = control:overlay_visible(dropdown)
		local current = open and dropdown.selected_index or control.selected_index
		local target = navigation_target(control, current, direction, distance)
		if target == nil then
			return true
		end

		if open then
			return set_dropdown_selection(control.items[target].value)
		end

		return commit_selection(control.items[target].value, true)
	end

	local listbox_event = dropdown.event

	--- Make the private Listbox behave as a Combobox popup
	-- The wheel scrolls visible rows without changing the temporary choice
	-- A click accepts the row under the pointer and closes the popup
	function dropdown:event(event)
		local result = listbox_event(self, event)
		if event.kind == "pointer_down" and event.button == "left" then
			local index = self:item_index_at(event.x, event.y)
			if item_enabled(control, index) then
				-- A click accepts the pointed row even when wheel preview already made it active
				local changed, change_error = publish_dropdown_selection()
				assert(changed, "Combobox item selection failed: " .. tostring(change_error))
			end

			if item_enabled(control, index) and control:overlay_visible(self) then
				local hidden, hide_error = control:hide_overlay(self)
				assert(hidden, "Combobox dropdown close failed: " .. tostring(hide_error))
			end
		end

		return result
	end

	--- Move to one edge without duplicating the open and closed selection rules
	-- @param from integer first item position to inspect
	-- @param direction integer negative for earlier or positive for later
	-- @return boolean|nil changed
	-- @return string|nil error_text
	local function move_to_edge(from, direction)
		local target = find_enabled(control, from, direction)
		if target == nil then
			return true
		end

		local value = control.items[target].value
		if control:overlay_visible(dropdown) then
			return set_dropdown_selection(value)
		end

		return commit_selection(value, true)
	end

	--- Measure the field and prepare the private Listbox's natural popup size
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local dropdown_width, dropdown_height = dropdown:measure(
			control_canvas,
			0,
			0,
			math.max(1, maximum_width),
			0x3fffffff
		)
		self.dropdown_width = dropdown_width
		self.dropdown_height = dropdown_height
		local text = self.selected_item and self.selected_item.text or ""
		local text_width, text_height = control_canvas:measure_text(self.font, text)
		local natural_width = self.width or math.max(
			120,
			text_width + self.padding_x * 2 + self.arrow_width
		)
		local natural_height = self.height or text_height + self.padding_y * 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 the closed field with the exact stepped fill and frame used by Buttons
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		local open = self:overlay_visible(dropdown)
		local flags = assert(color_flags[self], "Combobox color flags are unavailable")
		flags.grayed = not self.enabled
		flags.down = open
		flags.pressing = self.keyboard_pressed
		flags.hovering = self.hovered
		flags.focused = self.focused
		local background = self:get_color("background", flags)
		local frame = self:get_color("frame", flags)
		local text_color = self:get_color("text", flags)

		control_canvas:fill_frame(0, 0, width, height, background)
		control_canvas:draw_frame(0, 0, width, height, 1, frame)
		if flags.down or flags.pressing then
			local inner_frame = self:get_color("inner_frame", flags)
			control_canvas:draw_frame(1, 1, width - 2, height - 2, 1, inner_frame)
		end

		local text = self.selected_item and self.selected_item.text or ""
		local _, text_height = control_canvas:measure_text(self.font, text)
		control_canvas:draw_text(self.font, text, self.padding_x, math.floor((height - text_height) / 2), text_color)
		control_canvas:draw_glyph(
			Glyph.dropdown,
			math.max(1, width - self.arrow_width - 1),
			1,
			math.min(self.arrow_width, math.max(0, width - 2)),
			math.max(0, height - 2),
			text_color
		)
	end

	--- Handle pointer opening, temporary keyboard navigation, commit, and cancellation
	function control:event(event)
		if event.kind == "pointer_enter" then
			self.hovered = true
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_leave" then
			self.hovered = false
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_down" and event.button == "left" then
			local changed
			local change_error
			if self:overlay_visible(dropdown) then
				changed, change_error = close_dropdown()
			else
				changed, change_error = open_dropdown()
			end
			assert(changed, "Combobox dropdown toggle failed: " .. tostring(change_error))

			return { handled = true, redraw = true, focus = "acquire" }
		elseif event.kind == "focus_gained" then
			self.focused = true
			return { redraw = true }
		elseif event.kind == "focus_lost" or event.kind == "window_deactivated" then
			self.focused = false
			self.keyboard_pressed = false
			local closed, close_error = close_dropdown()
			assert(closed, "Combobox dropdown focus close failed: " .. tostring(close_error))

			return { redraw = true, capture = "release" }
		elseif event.kind == "key_down" and event.alt and event.key == "down" then
			local opened, open_error = open_dropdown()
			assert(opened, "Combobox dropdown open failed: " .. tostring(open_error))

			return { handled = true, redraw = true }
		elseif event.kind == "key_down" and event.key == "escape" and self:overlay_visible(dropdown) then
			local closed, close_error = close_dropdown()
			assert(closed, "Combobox dropdown cancel failed: " .. tostring(close_error))

			return { handled = true, redraw = true }
		elseif event.kind == "key_down" and event.key == "return" and self:overlay_visible(dropdown) then
			local committed, commit_error = commit_selection(dropdown.selected, true)
			assert(committed, "Combobox dropdown commit failed: " .. tostring(commit_error))
			local closed, close_error = close_dropdown()
			assert(closed, "Combobox dropdown close failed: " .. tostring(close_error))

			return { handled = true, redraw = true }
		elseif event.kind == "key_down" and (event.key == "space" or event.key == "return") then
			if not event["repeat"] then
				self.keyboard_pressed = true
			end

			return { handled = true, redraw = true }
		elseif event.kind == "key_up" and (event.key == "space" or event.key == "return") then
			local should_open = self.keyboard_pressed
			self.keyboard_pressed = false
			if should_open then
				local opened, open_error = open_dropdown()
				assert(opened, "Combobox dropdown open failed: " .. tostring(open_error))
			end

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

		local moved
		local move_error
		if event.key == "up" then
			moved, move_error = move_selection(-1, 1)
		elseif event.key == "down" then
			moved, move_error = move_selection(1, 1)
		elseif event.key == "home" then
			moved, move_error = move_to_edge(1, 1)
		elseif event.key == "end" then
			moved, move_error = move_to_edge(#self.items, -1)
		elseif event.key == "pageup" then
			moved, move_error = move_selection(-1, 5)
		elseif event.key == "pagedown" then
			moved, move_error = move_selection(1, 5)
		else
			return nil
		end
		assert(moved, "Combobox keyboard selection failed: " .. tostring(move_error))

		return { handled = true, redraw = true }
	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)
		return commit_selection(value, true)
	end

	local declared = qui.control(control)
	local set_enabled = declared.set_enabled
	local set_visible = declared.set_visible

	--- Close the popup before disabling the anchor Control
	function declared:set_enabled(enabled)
		if not enabled then
			local closed, close_error = close_dropdown()
			if not closed then
				return nil, close_error
			end
		end

		return set_enabled(self, enabled)
	end

	--- Close the popup before removing the anchor Control from layout
	function declared:set_visible(visible)
		if not visible then
			local closed, close_error = close_dropdown()
			if not closed then
				return nil, close_error
			end
		end

		return set_visible(self, visible)
	end

	return declared
end

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

return Combobox
