-- 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 reusable Lua-owned Radio Controls with explicit groups
-- @module qui.radio

local qui = require("qui")
local Accelerator = require("qui.accelerator")
local colors = require("qui.colors")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")

local Radio = {}
local groups = {}	-- Text identifiers bind Radio instances without exposing membership tables
local color_flags = setmetatable({}, { __mode = "k" })	-- One reusable flag set follows each live Radio 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

--- Report whether one local point remains inside the arranged Radio
-- @param self table Radio control
-- @param x number local horizontal position
-- @param y number local vertical position
-- @return boolean inside
local function contains_point(self, x, y)
	return x >= 0 and y >= 0 and x < self.arranged_width and y < self.arranged_height
end

--- Tell application code about one completed value change
-- @param self table Radio control
local function notify_change(self)
	if self.on_change then
		self:on_change(self.checked)
	end
end

--- Return the private membership record for one textual group
-- Weak controls let discarded Forms leave the runtime group without explicit Lua ownership work
-- @param name string group identifier
-- @return table members
local function group_members(name)
	local members = groups[name]
	if members == nil then
		members = {
			controls = setmetatable({}, { __mode = "v" }),
			selection = setmetatable({}, { __mode = "v" }),
			tab_stop = setmetatable({}, { __mode = "v" }),
			next_order = 1,
		}
		groups[name] = members
	end

	return members
end

--- Report whether one Radio can currently receive group navigation
-- @param control table|nil Radio candidate
-- @return boolean usable
local function usable_member(control)
	return control ~= nil and control.enabled ~= false and control.visible ~= false
end

--- Return the first usable member in stable declaration order
-- @param members table private group record
-- @return table|nil member
local function first_member(members)
	for index = 1, members.next_order - 1 do
		local candidate = members.controls[index]
		if usable_member(candidate) then
			return candidate
		end
	end

	return nil
end

--- Prefer the selected Radio, falling back to the first usable member
-- @param members table private group record
-- @return table|nil member
local function preferred_tab_stop(members)
	local selected = members.selection[1]

	return usable_member(selected) and selected or first_member(members)
end

--- Move the group's single Tab stop without changing ordinary focus eligibility
-- @param members table private group record
-- @param target table|nil Radio that should receive Tab
-- @return boolean|nil changed
-- @return string|nil error_text
local function move_tab_stop(members, target)
	local previous = members.tab_stop[1]
	if previous == target then
		return true
	end

	if previous then
		local changed, change_error = previous:set_tab_stop(false)
		if not changed then
			return nil, change_error
		end
	end
	if target then
		local changed, change_error = target:set_tab_stop(true)
		if not changed then
			if previous then
				previous:set_tab_stop(true)
			end

			return nil, change_error
		end
	end

	members.tab_stop[1] = target
	return true
end

--- Find the previous or next usable member in stable declaration order
-- @param self table current Radio control
-- @param direction integer negative for previous or positive for next
-- @return table|nil member
local function adjacent_member(self, direction)
	local members = group_members(self.group)
	local controls = members.controls
	local current_index = self.group_order
	if current_index == nil then
		return nil
	end

	local maximum_index = members.next_order - 1
	for offset = 1, maximum_index - 1 do
		local index = (current_index - 1 + direction * offset) % maximum_index + 1
		local candidate = controls[index]
		if usable_member(candidate) then
			return candidate
		end
	end

	return nil
end

--- Change one Radio while keeping its group exclusive
-- Selecting a member clears the previous selection, while direct clearing may leave the group empty
-- @param self table Radio control
-- @param checked boolean requested state
-- @return boolean|nil changed
-- @return string|nil error_text
local function set_checked(self, checked)
	assert(type(checked) == "boolean", "Radio checked state must be a boolean")
	if self.checked == checked then
		return true
	end

	local members = group_members(self.group)
	local previous = members.selection[1]
	if checked and previous and previous ~= self then
		previous.checked = false
		local previous_invalidated, previous_error = previous:invalidate()
		if not previous_invalidated then
			previous.checked = true
			return nil, previous_error
		end
	end

	self.checked = checked
	members.selection[1] = checked and self or (previous == self and nil or previous)
	local invalidated, invalidate_error = self:invalidate()
	if not invalidated then
		self.checked = not checked
		members.selection[1] = previous
		if previous and previous ~= self then
			previous.checked = true
			previous:invalidate()
		end

		return nil, invalidate_error
	end

	local tab_target = preferred_tab_stop(members)
	local tab_changed, tab_error = move_tab_stop(members, tab_target)
	if not tab_changed then
		return nil, tab_error
	end

	if checked and previous and previous ~= self then
		notify_change(previous)
	end
	notify_change(self)

	return true
end

--- Select one central color identifier for every Radio instance
-- @param self table Radio control
-- @param type string requested visual type
-- @param flags table current Radio conditions
-- @return string color_identifier
function Radio.default_color(self, type, flags)
	if type == "background" then
		if flags.grayed then
			return colors.radio_disabled_bg
		elseif flags.pressing then
			return colors.radio_pressing_bg
		elseif flags.down then
			return colors.radio_down_bg
		elseif flags.hovering then
			return colors.radio_hover_bg
		end

		return colors.radio_bg
	elseif type == "frame" then
		-- Focus is the stronger state, so moving the pointer over it must not hide the active frame
		if flags.focused then
			return colors.radio_focused_frame
		elseif flags.hovering then
			return colors.radio_hover_frame
		end

		return colors.radio_frame
	elseif type == "checkmark" then
		return flags.grayed and colors.radio_disabled_checkmark or colors.radio_checkmark
	elseif type == "text" then
		return flags.grayed and colors.radio_disabled_text or colors.radio_text
	end

	return nil
end

--- Create one Radio for use inside 'qui.pane.controls'
-- @param options table group, text, state, sizing, placement, and optional on_change handler
-- @return table control unrestricted Lua Control declaration
function Radio.new(options)
	assert(type(options) == "table", "Radio expects a table")
	assert(type(options.group) == "string" and options.group ~= "", "Radio group must be a non-empty string")
	assert(options.text == nil or type(options.text) == "string", "Radio text must be a string")
	assert(options.accelerator == nil, "Radio must mark its accelerator with & inside text")
	assert(options.show_accelerators == nil or type(options.show_accelerators) == "boolean",
		"Radio show_accelerators must be a boolean")
	assert(options.checked == nil or type(options.checked) == "boolean", "Radio checked must be a boolean")
	assert(options.compact == nil or type(options.compact) == "boolean", "Radio compact must be a boolean")
	assert(options.tab_stop == nil, "Radio tab_stop is managed by its group")
	assert(options.on_change == nil or type(options.on_change) == "function", "Radio on_change must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function", "Radio on_color must be a function")
	assert(options.default_color == nil, "Radio default_color is fixed; use on_color for dynamic changes")

	local control = options
	local members = group_members(control.group)
	control.text, control.accelerator, control.accelerator_column =
		Accelerator.parse(control.text or "", "Radio text")
	control.show_accelerators = control.show_accelerators ~= false
	control.checked = control.checked == true
	assert(not control.checked or members.selection[1] == nil, "Radio group already has a checked member")
	control.compact = control.compact == true
	control.font = control.font or (control.compact and Font.compact or Font.default)
	assert(control.font, "Radio requires a font")
	control.indicator_size = dip_value(
		control.indicator_size,
		theme.resolve_metric(control.compact and
			metrics.radio_compact_indicator_size or metrics.radio_indicator_size),
		"Radio indicator_size"
	)
	-- Every Radio reserves the normal indicator area so compact controls keep their captions aligned
	control.indicator_slot_size = math.max(
		control.indicator_size, theme.resolve_metric(metrics.radio_indicator_size))
	control.indicator_gap = dip_value(
		control.indicator_gap, theme.resolve_metric(metrics.radio_indicator_gap), "Radio indicator_gap")
	control.checkmark_padding = dip_value(
		control.checkmark_padding,
		theme.resolve_metric(metrics.radio_checkmark_padding),
		"Radio checkmark_padding")
	assert(control.checkmark_padding * 2 < control.indicator_size,
		"Radio checkmark_padding must leave room for the selected mark")
	control.text_indent = control.text_indent and dip_value(control.text_indent, 0, "Radio text_indent") or nil
	control.width = control.width and dip_value(control.width, 0, "Radio width") or nil
	control.height = control.height and dip_value(control.height, 0, "Radio height") or nil
	control.enabled = control.enabled ~= false
	control.focusable = control.focusable ~= false
	control.accepts_pointer = control.accepts_pointer ~= false
	control.default_color = Radio.default_color
	control.hovered = false
	control.focused = false
	control.pointer_pressed = false
	control.keyboard_pressed = false
	control.arranged_width = control.width or 0
	control.arranged_height = control.height or 0
	control.group_order = members.next_order
	members.controls[control.group_order] = control
	members.next_order = members.next_order + 1
	if control.checked then
		members.selection[1] = control
	end
	local previous_tab_stop = members.tab_stop[1]
	local tab_target = preferred_tab_stop(members)
	if previous_tab_stop and previous_tab_stop ~= tab_target then
		previous_tab_stop.tab_stop = false
	end
	control.tab_stop = tab_target == control
	members.tab_stop[1] = tab_target
	color_flags[control] = {
		grayed = not control.enabled,
		down = control.checked,
		pressing = false,
		hovering = false,
		focused = false,
	}

	--- Measure the indicator, optional caption indent, and shaped text
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local text_width, text_height = control_canvas:measure_text(self.font, self.text)
		local text_x = self.text_indent or self.indicator_slot_size + (self.text == "" and 0 or self.indicator_gap)
		local natural_width = self.width or math.max(self.indicator_slot_size, text_x + text_width)
		local natural_height = self.height or math.max(self.indicator_slot_size, text_height)
		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 one direct Radio without allocating visual child Controls
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		local visibly_pressed = (self.pointer_pressed and self.hovered) or self.keyboard_pressed
		local flags = assert(color_flags[self], "Radio color flags are unavailable")
		flags.grayed = not self.enabled
		flags.down = self.checked
		flags.pressing = visibly_pressed
		flags.hovering = self.hovered
		flags.focused = self.focused
		local slot_y = math.floor((height - self.indicator_slot_size) / 2)
		local indicator_x = math.floor((self.indicator_slot_size - self.indicator_size) / 2)
		local indicator_y = slot_y + math.floor((self.indicator_slot_size - self.indicator_size) / 2)
		local mark_size = self.indicator_size - self.checkmark_padding * 2

		-- The complete Control background remains independent from the transparent outlined indicator
		control_canvas:fill_rect(0, 0, width, height, self:get_color("background", flags))
		control_canvas:draw_circle(
			indicator_x,
			indicator_y,
			self.indicator_size,
			1,
			self:get_color("frame", flags))
		if self.checked then
			control_canvas:fill_circle(
				indicator_x + self.checkmark_padding,
				indicator_y + self.checkmark_padding,
				mark_size,
				self:get_color("checkmark", flags))
		end
		if self.text ~= "" then
			local _, text_height = control_canvas:measure_text(self.font, self.text)
			local text_x = self.text_indent or self.indicator_slot_size + self.indicator_gap
			local text_y = math.floor((height - text_height) / 2)
			local text_color = self:get_color("text", flags)

			control_canvas:draw_text(self.font, self.text, text_x, text_y, text_color)
			if self.show_accelerators then
				Accelerator.draw(
					control_canvas,
					self.font,
					self.text,
					self.accelerator_column,
					text_x,
					text_y,
					text_height,
					text_color
				)
			end
		end
	end

	--- Handle pointer, focus, and Space without taking Return from the containing Form
	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
			self.pointer_pressed = true
			return { handled = true, redraw = true, focus = "acquire", capture = "acquire" }
		elseif event.kind == "pointer_up" and event.button == "left" and self.pointer_pressed then
			self.pointer_pressed = false
			if contains_point(self, event.x, event.y) and not self.checked then
				local changed, change_error = set_checked(self, true)
				assert(changed, "Radio pointer selection failed: " .. tostring(change_error))
			end

			return { handled = true, redraw = true, capture = "release" }
		elseif event.kind == "focus_gained" then
			self.focused = true
			return { redraw = true }
		elseif event.kind == "focus_lost" then
			self.focused = false
			self.pointer_pressed = false
			self.keyboard_pressed = false
			return { redraw = true, capture = "release" }
		elseif event.kind == "key_down" and event.key == "space" 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" then
			local should_select = self.keyboard_pressed and not self.checked
			self.keyboard_pressed = false
			if should_select then
				local changed, change_error = set_checked(self, true)
				assert(changed, "Radio keyboard selection failed: " .. tostring(change_error))
			end

			return { handled = true, redraw = true }
		elseif event.kind == "key_down" and not event["repeat"] and
			(event.key == "left" or event.key == "up" or event.key == "right" or event.key == "down")
		then
			local direction = (event.key == "left" or event.key == "up") and -1 or 1
			local adjacent = adjacent_member(self, direction)
			if adjacent then
				local changed, change_error = set_checked(adjacent, true)
				assert(changed, "Radio arrow selection failed: " .. tostring(change_error))
				local focused, focus_error = adjacent:focus()
				assert(focused, "Radio arrow focus failed: " .. tostring(focus_error))
			end

			return { handled = true, redraw = true }
		end

		return nil
	end

	--- Change checked state only when the requested value differs
	-- @param checked boolean requested value
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_checked(checked)
		return set_checked(self, checked)
	end

	--- Select this Radio through the same public path used by Form accelerators
	-- @return boolean|nil activated
	-- @return string|nil error_text
	function control:activate()
		if not self.enabled then
			return false
		end

		return set_checked(self, true)
	end

	control = qui.control(control)
	local base_set_enabled = control.set_enabled
	local base_set_visible = control.set_visible

	--- Change enabled state and keep one usable group Tab stop
	-- @param enabled boolean requested enabled state
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_enabled(enabled)
		local changed, change_error = base_set_enabled(self, enabled)
		if not changed then
			return nil, change_error
		end

		return move_tab_stop(members, preferred_tab_stop(members))
	end

	--- Change visibility and keep one usable group Tab stop
	-- @param visible boolean requested visible state
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_visible(visible)
		local changed, change_error = base_set_visible(self, visible)
		if not changed then
			return nil, change_error
		end

		return move_tab_stop(members, preferred_tab_stop(members))
	end

	return control
end

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

return Radio
