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

--- Own horizontal scrollbar range, drawing, and lane input for text surfaces
-- @module core.text.horizontal_scrollbar

local common = require("core.common")
local config = require("core.config")
local style = require("core.style")
local View = require("core.ui.view")

local horizontal_scrollbar = {}
local LANE_ID = View.LaneIDs.HSCROLL
local LANE_ORDER = View.LaneOrders.HSCROLL

--- Add one horizontal scrollbar phase to the optional large-document profile
-- @param dv table document view
-- @param name string phase field name
-- @param start_time number|nil phase start time when profiling is active
local function record_profile_phase(dv, name, start_time)
	if not start_time then
		return
	end

	local profile = dv._horizontal_scrollbar_profile

	if not profile then
		profile = {}
		dv._horizontal_scrollbar_profile = profile
	end

	profile[name] = (profile[name] or 0) + system.get_time() - start_time
end

--- Return the current horizontal width cache for one view
-- Cache exact line widths per document change so width scans stay incremental
-- @param dv table doc view
-- @return table cache
local function get_line_width_cache(dv)
	local doc = dv.doc
	local change_id = (doc.get_change_id and doc:get_change_id()) or 0
	local font = dv:get_font()
	local indent_size = doc:get_indent_size()
	local cache = dv._horizontal_scrollbar_line_width_cache
	if
		cache
		and cache.doc == doc
		and cache.change_id == change_id
		and cache.font == font
		and cache.indent_size == indent_size
	then
		return cache
	end

	cache = {
		doc = doc,
		change_id = change_id,
		font = font,
		indent_size = indent_size,
		lines = {},
	}
	dv._horizontal_scrollbar_line_width_cache = cache

	return cache
end

--- Return cached visual width for one document line
-- @param dv table doc view
-- @param line number line index
-- @return number width
local function line_visual_width(dv, line)
	local cache = get_line_width_cache(dv)
	local cached = cache.lines[line]
	if cached ~= nil then
		return cached
	end

	local text = dv.doc.lines[line] or ""
	local len = #text
	if text:sub(-1) == "\n" then
		len = len - 1
	end

	local width = dv:get_col_x_offset(line, len + 1)
	cache.lines[line] = width

	return width
end

--- Return the widest cached or measured line in the current viewport
-- @param dv table document view
-- @param viewport_h number visible content height
-- @return number width widest visible line width
local function get_visible_lines_width(dv, viewport_h)
	local lh = dv:get_line_height()
	local y = dv.scroll.y or 0
	local minline = math.max(1, math.floor(y / lh))
	local maxline = math.min(#dv.doc.lines, math.floor((y + viewport_h) / lh) + 1)
	local max_w = 0
	for line = minline, maxline do
		max_w = math.max(max_w, line_visual_width(dv, line))
	end
	return max_w
end

--- Return a valid document-wide width scan state
-- @param dv table document view
-- @return table cache width scan state
local function ensure_width_cache(dv)
	local change_id = dv.doc:get_change_id()
	local line_width_cache = get_line_width_cache(dv)
	local cache = dv._horizontal_scrollbar_width_cache
	if cache and cache.change_id == change_id and cache.line_width_cache == line_width_cache then
		return cache
	end
	cache = {
		change_id = change_id,
		line_width_cache = line_width_cache, -- Font and indentation changes restart the complete scan
		width = 0,
		scan_idx = 1,
		done = false,
	}

	local li = dv.doc.longest_line_index
	local ll = dv.doc.longest_line_length
	if type(li) == "number" and type(ll) == "number" and ll > 0 and li >= 1 and li <= #dv.doc.lines then
		cache.width = math.max(cache.width, line_visual_width(dv, li))
	elseif type(ll) == "number" and ll > 0 then
		cache.width = math.max(cache.width, dv:get_font():get_width(" ") * ll)
	end

	local min_chars = tonumber(config.hscrollbar_estimate_only_min_longest_line_chars) or 0
	local min_lines = tonumber(config.hscrollbar_estimate_only_min_lines) or 0
	local has_very_long_line = min_chars > 0 and type(ll) == "number" and ll >= min_chars
	local has_many_lines = min_lines > 0 and #dv.doc.lines >= min_lines

	-- Either dimension can make a complete width scan too expensive for interactive updates
	if has_very_long_line or has_many_lines then
		cache.done = true
		cache.scan_idx = #dv.doc.lines + 1
	end

	dv._horizontal_scrollbar_width_cache = cache
	return cache
end

--- Advance one bounded part of the document-wide width scan
-- @param dv table document view
-- @param cache table width scan state
local function scan_width_chunk(dv, cache)
	if cache.done then
		return
	end
	local line_count = #dv.doc.lines
	if line_count <= (config.hscrollbar_full_scan_max_lines or 1000) then
		for i = cache.scan_idx, line_count do
			cache.width = math.max(cache.width, line_visual_width(dv, i))
		end
		cache.scan_idx = line_count + 1
		cache.done = true
		return
	end

	local budget_ms = math.max(0.1, tonumber(config.hscrollbar_scan_budget_ms) or 1.0)
	local start_t = system.get_time()
	local i = cache.scan_idx
	while i <= line_count do
		cache.width = math.max(cache.width, line_visual_width(dv, i))
		i = i + 1
		if (system.get_time() - start_t) * 1000 >= budget_ms then
			break
		end
	end
	cache.scan_idx = i
	if i > line_count then
		cache.done = true
	end
end

--- Return widget content width that preserves text-viewport thumb math across a wider lane
-- Draw the track across the full bottom lane while keeping thumb visibility tied to the text viewport width
-- @param lane_w number full bottom lane width
-- @param content_w number full horizontal content width
-- @param content_visible_w number visible text viewport width
-- @return number scrollbar_content_w virtual scrollable width for the widget
local function get_scrollbar_content_width(lane_w, content_w, content_visible_w)
	if lane_w <= 0 or content_visible_w <= 0 or content_w <= content_visible_w then
		return math.max(content_w, lane_w)
	end

	return math.max(content_w, (lane_w * content_w) / content_visible_w)
end

--- Return whether the current visible range has stopped changing
-- Width checks resume on the first stable update after vertical movement ends
-- @param dv table document view
-- @return boolean stable true when visible lines can be measured
local function visible_range_is_stable(dv)
	local scroll_y = dv.scroll.y or 0
	local previous_scroll_y = dv._horizontal_scrollbar_previous_scroll_y
	dv._horizontal_scrollbar_previous_scroll_y = scroll_y

	return previous_scroll_y == nil or math.abs(scroll_y - previous_scroll_y) <= 0.01
end

--- Compute horizontal scrollbar geometry from the best known content width
-- @param dv table document view
-- @param measure_visible_width boolean|nil false while the visible range is moving
-- @return table|nil metrics scrollbar metrics
local function compute_metrics(dv, measure_visible_width)
	if not config.hscrollbar_enabled or not dv.doc or not dv.doc.lines then
		dv:set_lane_size("bottom", LANE_ID, 0, LANE_ORDER)
		return nil
	end

	if dv.is_wrap_enabled and dv:is_wrap_enabled() then
		dv:set_lane_size("bottom", LANE_ID, 0, LANE_ORDER)
		return nil
	end

	local lane_h = math.max(6, math.floor((config.hscrollbar_height or style.scrollbar_size) * SCALE + 0.5))
	local _, _, viewport_w, viewport_h = dv:get_content_viewport_rect()
	viewport_w = math.max(1, viewport_w)
	viewport_h = math.max(1, viewport_h)
	local text_offset_x = dv.get_text_x_offset and dv:get_text_x_offset() or dv:get_gutter_width()
	local content_visible_w = math.max(20, viewport_w - text_offset_x)
	local visible_lines_w = measure_visible_width == false and 0 or get_visible_lines_width(dv, viewport_h)
	local end_pad = math.max(0, math.floor((config.hscrollbar_end_padding or 32) * SCALE + 0.5))
	local core_cache = dv.get_scroll_width_cache and dv:get_scroll_width_cache()
	local known_width

	if core_cache then
		known_width = math.max(core_cache.widest_line, visible_lines_w)
	else
		local cache = ensure_width_cache(dv)
		cache.width = math.max(cache.width, visible_lines_w)
		known_width = cache.width
	end

	-- Let an ordinary virtual cursor extend the horizontal range as it moves
	if dv.doc:is_virtual_space_enabled("enabled") and dv.doc.get_selection then
		local caret_line, caret_col = dv.doc:get_selection()

		if caret_col > dv.doc:get_line_end_col(caret_line) then
			known_width = math.max(known_width, dv:get_col_x_offset(caret_line, caret_col))
		end
	end

	local content_w = math.max(known_width + end_pad, content_visible_w)
	local max_scroll_x = math.max(0, content_w - content_visible_w)

	if max_scroll_x <= 0 then
		dv:set_lane_size("bottom", LANE_ID, 0, LANE_ORDER)
		return { max_scroll_x = 0 }
	end

	dv:set_lane_size("bottom", LANE_ID, lane_h, LANE_ORDER)
	local x, y, w, h = dv:get_lane_rect("bottom", LANE_ID)
	if not x then
		return { max_scroll_x = max_scroll_x }
	end

	local scrollbar_content_w = get_scrollbar_content_width(w, content_w, content_visible_w)
	local sx = common.clamp(dv.scroll.x or 0, 0, max_scroll_x)
	local t = sx / max_scroll_x
	dv.hscrollbar:set_size(x, y, w, h, scrollbar_content_w)
	dv.hscrollbar:set_percent(common.clamp(t, 0, 1))

	return {
		x = x,
		y = y,
		w = w,
		h = h,
		content_w = content_w,
		scrollbar_content_w = scrollbar_content_w,
		max_scroll_x = max_scroll_x,
	}
end

--- Synchronize the shared scrollbar widget with cached text metrics
-- @param dv table document view
-- @param metrics table scrollbar geometry and range
local function sync_hscrollbar(dv, metrics)
	if not (metrics and metrics.x) then
		return
	end
	local t = (metrics.max_scroll_x > 0) and common.clamp((dv.scroll.x or 0) / metrics.max_scroll_x, 0, 1) or 0
	dv.hscrollbar:set_size(
		metrics.x,
		metrics.y,
		metrics.w,
		metrics.h,
		metrics.scrollbar_content_w or metrics.content_w
	)
	dv.hscrollbar:set_percent(t)
end

local lane_handler = {}

--- Handle one horizontal lane press
-- @param dv table document view
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean handled true when the lane consumed the press
function lane_handler.on_mouse_down(dv, button, x, y, clicks)
	if button ~= "left" then
		return false
	end
	local metrics = dv._horizontal_scrollbar_metrics or compute_metrics(dv)
	if not (metrics and metrics.x) then
		return false
	end
	sync_hscrollbar(dv, metrics)
	local pressed = dv.hscrollbar:on_mouse_down(button, x, y, clicks)
	dv.dragging_horizontal_scrollbar = dv.hscrollbar.dragging or false
	if type(pressed) == "number" then
		local sx = common.clamp(pressed * metrics.max_scroll_x, 0, metrics.max_scroll_x)
		dv.scroll.to.x = sx
		dv.scroll.x = sx
	end
	return pressed and true or false
end

--- Handle one horizontal lane drag or hover move
-- @param dv table document view
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer x movement
-- @param dy number pointer y movement
-- @return boolean handled true while dragging or hovering the lane
function lane_handler.on_mouse_move(dv, x, y, dx, dy)
	local metrics = dv._horizontal_scrollbar_metrics or compute_metrics(dv)
	if metrics and metrics.x then
		sync_hscrollbar(dv, metrics)
	end
	local moved = dv.hscrollbar:on_mouse_move(x, y, dx, dy)
	if type(moved) == "number" and metrics and metrics.x then
		local sx = common.clamp(moved * metrics.max_scroll_x, 0, metrics.max_scroll_x)
		dv.scroll.to.x = sx
		dv.scroll.x = sx
	end
	dv.dragging_horizontal_scrollbar = dv.hscrollbar.dragging or false
	if dv.dragging_horizontal_scrollbar then
		return true
	end
	if metrics and metrics.x and dv.hscrollbar:overlaps(x, y) then
		return true
	end
	return false
end

--- Release one horizontal lane interaction
-- @param dv table document view
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled always true for the active lane
function lane_handler.on_mouse_up(dv, button, x, y)
	dv.hscrollbar:on_mouse_up(button, x, y)
	dv.dragging_horizontal_scrollbar = dv.hscrollbar.dragging or false
	return true
end

--- Return the pointer used over the horizontal scrollbar
-- @param dv table document view
-- @param x number pointer x
-- @param y number pointer y
-- @return string|nil cursor arrow while the scrollbar owns the pointer
function lane_handler.get_mouse_cursor(dv, x, y)
	local metrics = dv._horizontal_scrollbar_metrics or compute_metrics(dv)
	if dv.dragging_horizontal_scrollbar then
		return "arrow"
	end
	if metrics and metrics.x and dv.hscrollbar:overlaps(x, y) then
		return "arrow"
	end
	return nil
end

--- Clear horizontal scrollbar hover and drag state
-- @param dv table document view
-- @param _x number pointer x
-- @param _y number pointer y
function lane_handler.on_mouse_left(dv, _x, _y)
	dv.hscrollbar:on_mouse_left()
	dv.dragging_horizontal_scrollbar = false
end

--- Refresh scrollbar metrics and optionally advance the core-owned width scan
-- TextViewer supplies its own incremental scan so it does not duplicate this work
-- @param dv table document view
-- @param scan_widths boolean|nil false when the view owns width scanning
-- @return table|nil metrics scrollbar metrics
function horizontal_scrollbar.update(dv, scan_widths)
	local profiling = config.text_wrap_profile_enabled == true

	if dv.is_wrap_enabled and dv:is_wrap_enabled() then
		local wrapped_start_time = profiling and system.get_time() or nil
		-- Wrapped text owns the available width, so no horizontal scan or lane is needed
		dv._horizontal_scrollbar_metrics = { max_scroll_x = 0 }
		dv:set_lane_size("bottom", LANE_ID, 0, LANE_ORDER)
		dv:set_lane_input_handler("bottom", LANE_ID, nil)
		record_profile_phase(dv, "wrapped", wrapped_start_time)
		local profile = dv._horizontal_scrollbar_profile
		if profile then
			profile.updates = (profile.updates or 0) + 1
		end

		return nil
	end

	local scan_start_time = profiling and system.get_time() or nil
	if scan_widths ~= false then
		local cache = ensure_width_cache(dv)
		scan_width_chunk(dv, cache)
	end
	record_profile_phase(dv, "scan", scan_start_time)

	-- A moving vertical viewport can wait one stable frame before checking newly visible line widths
	local metrics_start_time = profiling and system.get_time() or nil
	local metrics = compute_metrics(dv, visible_range_is_stable(dv))
	dv._horizontal_scrollbar_metrics = metrics
	record_profile_phase(dv, "metrics", metrics_start_time)

	local clamp_start_time = profiling and system.get_time() or nil
	dv:clamp_scroll_position()
	record_profile_phase(dv, "clamp", clamp_start_time)

	local input_start_time = profiling and system.get_time() or nil
	dv:set_lane_input_handler("bottom", LANE_ID, (metrics and metrics.x) and lane_handler or nil)
	record_profile_phase(dv, "input", input_start_time)

	local profile = dv._horizontal_scrollbar_profile
	if profile then
		profile.updates = (profile.updates or 0) + 1
	end

	return metrics
end

--- Return the current horizontal scroll range for one text surface
-- @param dv table document view
-- @return number max_scroll_x horizontal scroll limit
function horizontal_scrollbar.get_scroll_max_x(dv)
	local metrics = dv._horizontal_scrollbar_metrics or compute_metrics(dv)

	return math.max(0, (metrics and metrics.max_scroll_x) or 0)
end

--- Draw the cached horizontal scrollbar after the text surface
-- @param dv table document view
function horizontal_scrollbar.draw(dv)
	local metrics = dv._horizontal_scrollbar_metrics
	if not metrics then
		metrics = compute_metrics(dv)
	end
	dv._horizontal_scrollbar_metrics = metrics
	dv:clamp_scroll_position()
	if not (metrics and metrics.x) then
		return
	end
	sync_hscrollbar(dv, metrics)
	dv.hscrollbar:draw()
end

return horizontal_scrollbar
