-- 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 compact Lua-owned Scrollbar for either vertical or horizontal ranges
-- @module qui.scrollbar

local qui = require("qui")
local colors = require("qui.colors")
local metrics = require("qui.metrics")
local theme = require("qui.theme")

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

--- Return a checked finite number
-- @param value number|nil requested value
-- @param fallback number default value
-- @param name string readable option name
-- @return number value
local function number_value(value, fallback, name)
	value = value == nil and fallback or value
	assert(type(value) == "number" and value == value and math.abs(value) < math.huge,
		name .. " must be a finite number")

	return value
end

--- 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 = number_value(value, fallback, name)
	assert(value >= 0, name .. " must be non-negative")

	return math.floor(value + 0.5)
end

--- Keep one public value inside its configured range
-- @param self table Scrollbar control
-- @param value number requested value
-- @return number constrained
local function constrain_value(self, value)
	return math.max(self.min, math.min(value, self.max))
end

--- Report whether this Scrollbar has somewhere to move
-- @param self table Scrollbar control
-- @return boolean scrollable
local function can_scroll(self)
	return self.enabled and self.max > self.min
end

--- Return the current track length along the scrolling direction
-- @param self table Scrollbar control
-- @return integer length
local function track_length(self)
	return self.horizontal and self.arranged_width or self.arranged_height
end

--- Calculate the proportional thumb length and its leading position
-- Page describes the visible amount while max remains the greatest usable value
-- @param self table Scrollbar control
-- @return integer position
-- @return integer length
local function thumb_geometry(self)
	local length = math.max(0, track_length(self))
	if length == 0 or self.max <= self.min then
		return 0, length
	end

	local range = self.max - self.min
	local total = range + self.page
	local proportional = total > 0 and math.floor(length * self.page / total + 0.5) or 0
	local thumb_length = math.min(length, math.max(self.minimum_thumb, proportional))
	local travel = math.max(0, length - thumb_length)
	local fraction = range > 0 and (self.value - self.min) / range or 0
	local position = math.floor(travel * fraction + 0.5)

	return position, thumb_length
end

--- Return the pointer position along the scrolling direction
-- @param self table Scrollbar control
-- @param event table pointer event
-- @return number position
local function pointer_position(self, event)
	return self.horizontal and event.x - self.arranged_x or event.y - self.arranged_y
end

--- Report whether the pointer is currently over the thumb
-- @param self table Scrollbar control
-- @param event table pointer event
-- @return boolean over_thumb
local function pointer_over_thumb(self, event)
	local position, length = thumb_geometry(self)
	local pointer = pointer_position(self, event)

	return pointer >= position and pointer < position + length
end

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

--- Assign one value and optionally request drawing outside an event callback
-- @param self table Scrollbar control
-- @param value number requested value
-- @param invalidate boolean whether live drawing must be requested here
-- @return boolean|nil changed
-- @return string|nil error_text
local function assign_value(self, value, invalidate)
	value = constrain_value(self, number_value(value, nil, "Scrollbar value"))
	if value == self.value then
		return true
	end

	local previous = self.value
	self.value = value
	if invalidate then
		local invalidated, invalidate_error = self:invalidate()
		if not invalidated then
			self.value = previous

			return nil, invalidate_error
		end
	end

	notify_change(self)
	return true
end

--- Move by one signed amount during input handling
-- @param self table Scrollbar control
-- @param amount number signed change
-- @return boolean changed
local function move_value(self, amount)
	local previous = self.value
	local changed, change_error = assign_value(self, self.value + amount, false)
	assert(changed, "Scrollbar value change failed: " .. tostring(change_error))

	return self.value ~= previous
end

--- Select one central color identifier for every Scrollbar instance
-- @param self table Scrollbar control
-- @param type string requested visual type
-- @param flags table current Scrollbar conditions
-- @return string|nil color_identifier
function Scrollbar.default_color(self, type, flags)
	if type == "background" then
		return flags.grayed and colors.scrollbar_disabled_bg or colors.scrollbar_bg
	elseif type == "thumb" then
		if flags.grayed then
			return colors.scrollbar_disabled_thumb
		elseif flags.pressing then
			return colors.scrollbar_pressing_thumb
		elseif flags.hovering then
			return colors.scrollbar_hover_thumb
		end

		return colors.scrollbar_thumb
	end

	return nil
end

--- Create one Scrollbar for use inside 'qui.pane.controls'
-- Vertical is the default; 'horizontal = true' selects the only other direction
-- @param options table range, value, sizing, placement, and optional on_change handler
-- @return table control unrestricted Lua Control declaration
function Scrollbar.new(options)
	assert(type(options) == "table", "Scrollbar expects a table")
	assert(options.horizontal == nil or type(options.horizontal) == "boolean",
		"Scrollbar horizontal must be a boolean")
	assert(options.on_change == nil or type(options.on_change) == "function",
		"Scrollbar on_change must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function",
		"Scrollbar on_color must be a function")
	assert(options.default_color == nil, "Scrollbar default_color is fixed; use on_color for dynamic changes")

	local control = options
	control.min = number_value(control.min, 0, "Scrollbar min")
	control.max = number_value(control.max, 100, "Scrollbar max")
	assert(control.max >= control.min, "Scrollbar max must be greater than or equal to min")
	control.value = constrain_value(control, number_value(control.value, control.min, "Scrollbar value"))
	control.line = number_value(control.line, 1, "Scrollbar line")
	control.page = number_value(control.page, 10, "Scrollbar page")
	assert(control.line > 0, "Scrollbar line must be greater than zero")
	assert(control.page >= 0, "Scrollbar page must be non-negative")
	control.horizontal = control.horizontal == true
	control.thickness = dip_value(
		control.thickness, theme.resolve_metric(metrics.scrollbar_thickness), "Scrollbar thickness")
	control.minimum_thumb = dip_value(
		control.minimum_thumb, theme.resolve_metric(metrics.scrollbar_minimum_thumb), "Scrollbar minimum_thumb")
	control.natural_length = dip_value(
		control.natural_length, theme.resolve_metric(metrics.scrollbar_natural_length), "Scrollbar natural_length")
	assert(control.thickness > 0, "Scrollbar thickness must be greater than zero")
	control.width = control.width and dip_value(control.width, 0, "Scrollbar width") or nil
	control.height = control.height and dip_value(control.height, 0, "Scrollbar 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 = Scrollbar.default_color
	control.hovered_thumb = false
	control.dragging = false
	control.drag_offset = 0
	control.wheel_remainder = 0
	control.arranged_x = 0
	control.arranged_y = 0
	control.arranged_width = control.width or 0
	control.arranged_height = control.height or 0
	color_flags[control] = {
		grayed = not can_scroll(control),
		hovering = false,
		pressing = false,
	}

	--- Measure a slim cross-axis and a useful default track length
	function control:measure(_, minimum_width, minimum_height, maximum_width, maximum_height)
		local natural_width = self.width or (self.horizontal and self.natural_length or self.thickness)
		local natural_height = self.height or (self.horizontal and self.thickness or self.natural_length)
		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 Scrollbar inside an explicit parent-local rectangle
	-- Standalone drawing and embedded Listbox drawing share this exact visual path
	-- @param control_canvas userdata active Control drawing control_canvas
	-- @param x integer parent-local left edge
	-- @param y integer parent-local top edge
	-- @param width integer available width
	-- @param height integer available height
	-- @param overdraw_right integer|nil extra paint hidden below a surrounding right frame
	-- @param overdraw_bottom integer|nil extra paint hidden below a surrounding bottom frame
	function control:draw_at(control_canvas, x, y, width, height, overdraw_right, overdraw_bottom)
		overdraw_right = overdraw_right or 0
		overdraw_bottom = overdraw_bottom or 0
		assert(overdraw_right >= 0 and overdraw_right % 1 == 0, "Scrollbar right overdraw must be a whole DIP")
		assert(overdraw_bottom >= 0 and overdraw_bottom % 1 == 0, "Scrollbar bottom overdraw must be a whole DIP")
		self.arranged_x = x
		self.arranged_y = y
		self.arranged_width = width
		self.arranged_height = height
		local paint_width = width + overdraw_right
		local paint_height = height + overdraw_bottom
		local flags = assert(color_flags[self], "Scrollbar color flags are unavailable")
		flags.grayed = not can_scroll(self)
		flags.hovering = self.hovered_thumb
		flags.pressing = self.dragging

		control_canvas:fill_rect(x, y, paint_width, paint_height, self:get_color("background", flags))
		local position, length = thumb_geometry(self)
		if self.horizontal then
			control_canvas:fill_rect(x + position, y, length, paint_height, self:get_color("thumb", flags))
		else
			control_canvas:fill_rect(x, y + position, paint_width, length, self:get_color("thumb", flags))
		end
	end

	--- Draw one quiet track and one proportional rectangular thumb
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self:draw_at(control_canvas, 0, 0, width, height)
	end

	--- Route a parent Control event through the same Scrollbar using explicit local bounds
	-- Pointer coordinates stay in the parent Control and are translated by 'pointer_position'
	-- @param event table parent-local input event
	-- @param x integer parent-local left edge
	-- @param y integer parent-local top edge
	-- @param width integer available width
	-- @param height integer available height
	-- @return table|nil result
	function control:event_at(event, x, y, width, height)
		self.arranged_x = x
		self.arranged_y = y
		self.arranged_width = width
		self.arranged_height = height

		return self:event(event)
	end

	--- Handle thumb dragging, page clicks, wheel steps, focus, and standard navigation keys
	function control:event(event)
		if event.kind == "pointer_enter" or event.kind == "pointer_move" then
			if self.dragging then
				local _, thumb_length = thumb_geometry(self)
				local travel = math.max(0, track_length(self) - thumb_length)
				local pointer = pointer_position(self, event) - self.drag_offset
				local fraction = travel > 0 and math.max(0, math.min(1, pointer / travel)) or 0
				local changed, change_error = assign_value(
					self, self.min + (self.max - self.min) * fraction, false)
				assert(changed, "Scrollbar drag failed: " .. tostring(change_error))
				self.hovered_thumb = true

				return { handled = true, redraw = true }
			end

			local hovered = can_scroll(self) and pointer_over_thumb(self, event)
			local changed = hovered ~= self.hovered_thumb
			self.hovered_thumb = hovered

			return { handled = true, redraw = changed }
		elseif event.kind == "pointer_leave" then
			if not self.dragging then
				self.hovered_thumb = false
			end

			return { handled = true, redraw = not self.dragging }
		elseif event.kind == "pointer_down" and event.button == "left" then
			if not can_scroll(self) then
				return { handled = true, focus = "acquire" }
			end

			local pointer = pointer_position(self, event)
			local position = thumb_geometry(self)
			if pointer_over_thumb(self, event) then
				self.dragging = true
				self.drag_offset = pointer - position
				self.hovered_thumb = true

				return { handled = true, redraw = true, focus = "acquire", capture = "acquire" }
			end

			local direction = pointer < position and -1 or 1
			local changed = move_value(self, direction * self.page)
			self.hovered_thumb = pointer_over_thumb(self, event)

			return { handled = true, redraw = changed, focus = "acquire" }
		elseif event.kind == "pointer_up" and event.button == "left" and self.dragging then
			self.dragging = false
			self.hovered_thumb = can_scroll(self) and pointer_over_thumb(self, event)

			return { handled = true, redraw = true, capture = "release" }
		elseif event.kind == "wheel" and can_scroll(self) then
			local wheel_delta = self.horizontal and event.dx or event.dy
			if wheel_delta == 0 and self.horizontal then
				wheel_delta = event.dy	-- Ordinary mouse wheels remain useful on a horizontal Scrollbar
			end
			self.wheel_remainder = self.wheel_remainder + wheel_delta
			local steps = math.modf(self.wheel_remainder)
			self.wheel_remainder = self.wheel_remainder - steps
			local changed = steps ~= 0 and move_value(self, -steps * self.line) or false

			return { handled = true, redraw = changed }
		elseif event.kind == "focus_lost" then
			self.dragging = false
			self.hovered_thumb = false
			return { redraw = true, capture = "release" }
		elseif event.kind ~= "key_down" or not can_scroll(self) then
			return nil
		end

		local amount
		if event.key == "home" then
			amount = self.min - self.value
		elseif event.key == "end" then
			amount = self.max - self.value
		elseif event.key == "pageup" then
			amount = -self.page
		elseif event.key == "pagedown" then
			amount = self.page
		elseif (self.horizontal and event.key == "left") or
			(not self.horizontal and event.key == "up")
		then
			amount = -self.line
		elseif (self.horizontal and event.key == "right") or
			(not self.horizontal and event.key == "down")
		then
			amount = self.line
		else
			return nil
		end

		return { handled = true, redraw = move_value(self, amount) }
	end

	--- Change the current position and redraw a live Scrollbar
	-- @param value number requested value
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_value(value)
		return assign_value(self, value, true)
	end

	--- Change the live range while preserving and constraining the current value
	-- @param minimum number new lowest value
	-- @param maximum number new greatest usable value
	-- @param page number|nil new visible amount, or the existing amount when omitted
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_range(minimum, maximum, page)
		minimum = number_value(minimum, nil, "Scrollbar min")
		maximum = number_value(maximum, nil, "Scrollbar max")
		page = number_value(page, self.page, "Scrollbar page")
		assert(maximum >= minimum, "Scrollbar max must be greater than or equal to min")
		assert(page >= 0, "Scrollbar page must be non-negative")

		local previous_min, previous_max, previous_page, previous_value =
			self.min, self.max, self.page, self.value
		self.min, self.max, self.page = minimum, maximum, page
		self.value = constrain_value(self, self.value)
		local invalidated, invalidate_error = self:invalidate()
		if not invalidated then
			self.min, self.max, self.page, self.value =
				previous_min, previous_max, previous_page, previous_value

			return nil, invalidate_error
		end
		if self.value ~= previous_value then
			notify_change(self)
		end

		return true
	end

	return qui.control(control)
end

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

return Scrollbar
