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

--- Core-owned content zoom subsystem
-- Keeps DPI/UI scale separate from document zoom while reusing the scaled-font cache
-- @module core.ui.zoom

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local fonts = require("core.style.fonts")
local fontcache = require("core.style.fontcache")
local style = require("core.style")

local zoom = {}

-- Keep references to loaded base fonts while they are still valid
local font_cache = setmetatable({}, { __mode = "k" })

-- Scaled-font cache maps a key to a font handle and tracks LRU order + bytes
local scaled_font_cache = {}
local scaled_font_cache_keys = {}
local scaled_font_cache_sizes = {}
local scaled_font_cache_bytes = 0

local startup_scale = SCALE
local current_scale = startup_scale
local pending_scale = nil
local pending_scale_change_time = 0
local pending_scale_first_change_time = 0
local debounce_coroutine_started = false
local renderer_font_load_installed = false
-- Keep one pending text anchor snapshot alongside the debounced scale target
local pending_anchor = nil

--- Return true when one view can receive content zoom changes
-- Command surfaces keep their own document model but should never consume content zoom
-- @param view table|nil candidate view
-- @return boolean can_zoom
local function is_zoom_target_view(view)
	if not (view and view.doc and view ~= core.command_view) then
		return false
	end

	if type(view.get_zoom_multiplier) ~= "function" then
		return false
	end

	if type(view.get_default_zoom_level) ~= "function" then
		return false
	end

	if type(view.get_zoom_levels) ~= "function" then
		return false
	end

	local root_node = core.workspace_view and core.workspace_view.root_node
	if root_node and not root_node:get_node_for_view(view) then
		return false
	end

	return true
end

--- Resolve the visible view that should own content zoom
-- Command palette zoom should still target the last visible document behind the overlay
-- Mouse wheel commands may explicitly target an unfocused view beneath the pointer
-- @param context table|nil optional command context
-- @return table|nil view zoom target
local function get_zoom_target_view(context)
	if type(context) == "table" and context.target_view ~= nil then
		return is_zoom_target_view(context.target_view) and context.target_view or nil
	end

	local active_view = core.active_view
	if is_zoom_target_view(active_view) then
		return active_view
	end

	if active_view == core.command_view and is_zoom_target_view(core.last_active_view) then
		return core.last_active_view
	end

	return nil
end

--- Return true when the targeted view wants shared absolute scale changes
-- Special surfaces such as image viewing keep zoom intrinsic to the view
-- @param context table|nil optional command context
-- @return boolean uses_absolute_scale
local function active_view_uses_absolute_scale(context)
	local active_view = get_zoom_target_view(context)

	if active_view and type(active_view.should_apply_absolute_scale) == "function" then
		return active_view:should_apply_absolute_scale() ~= false
	end

	return true
end

--- Return the current absolute content scale including pending updates
-- @return number scale
local function get_current_absolute_scale()
	if type(pending_scale) == "number" then
		return pending_scale
	end

	return current_scale
end

--- Return the targeted view zoom multiplier
-- Special views can expose an intrinsic multiplier such as ANSI doubled rendering
-- @param context table|nil optional command context
-- @return number multiplier
local function get_active_view_zoom_multiplier(context)
	local active_view = get_zoom_target_view(context)

	if active_view and type(active_view.get_zoom_multiplier) == "function" then
		local multiplier = tonumber(active_view:get_zoom_multiplier())
		if multiplier and multiplier > 0 then
			return multiplier
		end
	end

	return 1
end

--- Return the targeted view zoom reset value in percent
-- @param context table|nil optional command context
-- @return number zoom percent
local function get_active_view_default_zoom_level(context)
	local active_view = get_zoom_target_view(context)

	if active_view and type(active_view.get_default_zoom_level) == "function" then
		local zoom_level = tonumber(active_view:get_default_zoom_level())
		if zoom_level and zoom_level > 0 then
			return zoom_level
		end
	end

	return 100
end

--- Return the targeted view zoom step list in percent
-- @param context table|nil optional command context
-- @return table zoom levels
local function get_active_view_zoom_levels(context)
	local source_levels = config.zoom_levels or {}
	local active_view = get_zoom_target_view(context)

	if active_view and type(active_view.get_zoom_levels) == "function" then
		local view_zoom_levels = active_view:get_zoom_levels()
		if type(view_zoom_levels) == "table" then
			source_levels = view_zoom_levels
		end
	end

	local parsed_levels = {}
	for index = 1, #source_levels do
		local zoom_level = tonumber(source_levels[index])
		if zoom_level and zoom_level > 0 then
			parsed_levels[#parsed_levels + 1] = zoom_level
		end
	end

	table.sort(parsed_levels)

	local unique_levels = {}
	for index = 1, #parsed_levels do
		if index == 1 or math.abs(parsed_levels[index] - parsed_levels[index - 1]) > 1e-9 then
			unique_levels[#unique_levels + 1] = parsed_levels[index]
		end
	end

	return unique_levels
end

--- Return the targeted view visible zoom bounds in factor form
-- Keep absolute font scaling clamped to visible zoom semantics so startup DPI does not
-- shrink the reachable zoom range such as '600 percent' on a '125 percent' UI scale
-- @return number min_zoom minimum visible zoom factor
-- @return number max_zoom maximum visible zoom factor
local function get_active_view_zoom_bounds(context)
	local zoom_levels = get_active_view_zoom_levels(context)
	if #zoom_levels > 0 then
		return zoom_levels[1] / 100, zoom_levels[#zoom_levels] / 100
	end

	return 0.1, 10
end

--- Return the active scaled-font cache limits
-- @return number cache_limit max entry count
-- @return number cache_bytes_limit max byte count
local function get_scaled_font_cache_limits()
	local cache_limit = tonumber(config.zoom_cache_scaled_fonts_max)
	if not cache_limit then
		cache_limit = 2
	end
	if cache_limit < 0 then
		cache_limit = 0
	end

	local cache_bytes_limit = tonumber(config.zoom_cache_scaled_fonts_max_bytes)
	if not cache_bytes_limit or cache_bytes_limit < 0 then
		cache_bytes_limit = 0
	end

	return math.floor(cache_limit), math.floor(cache_bytes_limit)
end

--- Return scale cache status for debug reporting
-- @return table cache status
function zoom.get_scale_font_cache_status()
	local cache_limit, cache_bytes_limit = get_scaled_font_cache_limits()

	return {
		enabled = config.zoom_cache_scaled_fonts ~= false,
		max_count = cache_limit,
		max_bytes = cache_bytes_limit,
		count = #scaled_font_cache_keys,
		bytes = scaled_font_cache_bytes,
	}
end

--- Normalize one scaled font size to stable quarter-byte precision
-- @param size number raw size
-- @return number normalized size
local function normalize_scaled_size(size)
	local value = tonumber(size) or 0
	return math.floor(value * 256 + 0.5) / 256
end

--- Release one scaled font handle
-- @param font userdata font handle
local function release_scaled_font(font)
	font:release()
end

--- Return active fallback font paths for grouped font loads
-- @return table fallback paths
local function get_fallback_paths()
	if type(fonts.get_active_fallback_paths) == "function" then
		return fonts.get_active_fallback_paths()
	end

	return {}
end

--- Build a stable cache key for one scaled font request
-- @param entry table cached base font entry
-- @param scaled_size number scaled size
-- @param fallback_paths table active fallback path list
-- @return string cache key
local function cache_key_for_scaled_font(entry, scaled_size, fallback_paths)
	local filename = tostring(entry[1])
	local use_fallbacks = entry[3] == true
	local embolden = tonumber(type(entry[4]) == "table" and entry[4].embolden) or 0
	embolden = math.max(0, math.min(2, embolden))

	if not use_fallbacks then
		return string.format("%s|%.4f|0|%.4f", filename, scaled_size, embolden)
	end

	return string.format(
		"%s|%.4f|1|%.4f|%s",
		filename,
		scaled_size,
		embolden,
		table.concat(fallback_paths or {}, "\n")
	)
end

--- Trim old scaled-font cache entries to the configured count limit
-- @param cache_limit number max entry count
local function prune_scaled_font_cache_by_count(cache_limit)
	while #scaled_font_cache_keys > cache_limit do
		local cache_key = table.remove(scaled_font_cache_keys, 1)
		release_scaled_font(scaled_font_cache[cache_key])
		scaled_font_cache[cache_key] = nil
		local entry_bytes = scaled_font_cache_sizes[cache_key] or 0
		scaled_font_cache_bytes = scaled_font_cache_bytes - entry_bytes
		scaled_font_cache_sizes[cache_key] = nil
	end

	collectgarbage("collect")
end

--- Trim old scaled-font cache entries to the configured byte limit
-- @param cache_bytes_limit number max byte count
local function prune_scaled_font_cache_by_bytes(cache_bytes_limit)
	while cache_bytes_limit > 0 and scaled_font_cache_bytes > cache_bytes_limit do
		local cache_key = table.remove(scaled_font_cache_keys, 1)
		release_scaled_font(scaled_font_cache[cache_key])
		scaled_font_cache[cache_key] = nil
		local entry_bytes = scaled_font_cache_sizes[cache_key] or 0
		scaled_font_cache_bytes = scaled_font_cache_bytes - entry_bytes
		scaled_font_cache_sizes[cache_key] = nil
	end

	collectgarbage("collect")
end

--- Fully release the scaled-font cache
local function clear_scaled_font_cache()
	for cache_key, font in pairs(scaled_font_cache) do
		release_scaled_font(font)
		scaled_font_cache[cache_key] = nil
	end

	scaled_font_cache_keys = {}
	scaled_font_cache_sizes = {}
	scaled_font_cache_bytes = 0

	collectgarbage("collect")
end

--- Trim the scaled-font cache using count and byte limits
-- @param cache_limit number max entry count
-- @param cache_bytes_limit number max byte count
local function prune_scaled_font_cache(cache_limit, cache_bytes_limit)
	if cache_limit < 0 then
		cache_limit = 0
	end

	prune_scaled_font_cache_by_count(cache_limit)

	if not cache_bytes_limit then
		cache_bytes_limit = 0
	end

	if cache_bytes_limit > 0 then
		prune_scaled_font_cache_by_bytes(cache_bytes_limit)
	end
end

--- Cache one scaled font and prune old entries
-- @param cache_key string cache key
-- @param font userdata font handle
-- @param cache_limit number max entry count
-- @param cache_bytes_limit number max byte count
local function cache_scaled_font(cache_key, font, cache_limit, cache_bytes_limit)
	if cache_key == nil then
		return
	end

	local entry_bytes = math.max(0, math.floor(font:get_memory_bytes()))
	if cache_limit == 0 or (cache_bytes_limit > 0 and entry_bytes > cache_bytes_limit) then
		return
	end

	for index = 1, #scaled_font_cache_keys do
		if scaled_font_cache_keys[index] == cache_key then
			table.remove(scaled_font_cache_keys, index)
			local old_bytes = scaled_font_cache_sizes[cache_key]
			if old_bytes then
				scaled_font_cache_bytes = math.max(0, scaled_font_cache_bytes - old_bytes)
			end
			break
		end
	end

	scaled_font_cache_keys[#scaled_font_cache_keys + 1] = cache_key
	scaled_font_cache[cache_key] = font
	scaled_font_cache_sizes[cache_key] = entry_bytes
	scaled_font_cache_bytes = scaled_font_cache_bytes + entry_bytes

	prune_scaled_font_cache(cache_limit, cache_bytes_limit)
end

--- Load one scaled font handle from the base font cache entry
-- @param entry table cached base font entry
-- @param scale_multiplier number scale ratio
-- @return userdata font handle
local function load_scaled_font(entry, scale_multiplier)
	local filename = entry[1]
	local base_size = entry[2]
	local use_fallbacks = entry[3] == true
	local font_options = type(entry[4]) == "table" and entry[4] or nil
	local scaled_size = normalize_scaled_size(base_size * scale_multiplier)
	local use_scaled_cache = config.zoom_cache_scaled_fonts ~= false

	if not use_fallbacks then
		return renderer.font.load(filename, scaled_size, font_options)
	end

	local fallback_paths = get_fallback_paths()
	if #fallback_paths == 0 then
		return renderer.font.load(filename, scaled_size, font_options)
	end

	if use_scaled_cache then
		local cache_limit, cache_bytes_limit = get_scaled_font_cache_limits()
		prune_scaled_font_cache(cache_limit, cache_bytes_limit)

		local cache_key = cache_key_for_scaled_font(entry, scaled_size, fallback_paths)
		local cached_font = scaled_font_cache[cache_key]
		if cached_font then
			cache_scaled_font(cache_key, cached_font, cache_limit, cache_bytes_limit)
			return cached_font
		end

		local font = fontcache.load_group_with_fallbacks(filename, scaled_size, fallback_paths, {
			embolden = font_options and font_options.embolden,
			prefer_lazy = true,
			use_cache = false,
		})

		-- A font that cannot fit remains owned by the caller instead of being released
		cache_scaled_font(cache_key, font, cache_limit, cache_bytes_limit)
		return font
	end

	clear_scaled_font_cache()

	return fontcache.load_group_with_fallbacks(filename, scaled_size, fallback_paths, {
		embolden = font_options and font_options.embolden,
		prefer_lazy = true,
		use_cache = false,
	})
end

--- Scale one known font handle by a relative multiplier
-- @param font userdata font handle
-- @param scale_multiplier number relative multiplier
-- @return userdata font handle
local function scale_font(font, scale_multiplier)
	local font_entry = font_cache[font]
	if not font_entry then
		return font
	end

	local scaled_font = load_scaled_font(font_entry, scale_multiplier)
	local scaled_size = normalize_scaled_size(font_entry[2] * scale_multiplier)
	font_cache[scaled_font] = { font_entry[1], scaled_size, font_entry[3] == true, font_entry[4] }

	return scaled_font
end

--- Keep renderer.font.load tracked so later fonts can be zoomed if needed
local function install_renderer_font_load_hook()
	if renderer_font_load_installed then
		return
	end

	renderer_font_load_installed = true

	local original_load_font = renderer.font.load
	function renderer.font.load(filename, size, options)
		local font = original_load_font(filename, size, options)
		font_cache[font] = { filename, size, false, options }
		return font
	end
end

--- Seed tracked base fonts after style initialization
local function seed_base_font_cache()
	font_cache[style.font] = { FONTS_DIR .. "sans.ttf", 14 * startup_scale, true }
	font_cache[style.big_font] = { FONTS_DIR .. "sans.ttf", 42 * startup_scale, true }
	font_cache[style.icon_font] = { FONTS_DIR .. "icons.ttf", 14 * startup_scale, false }
	font_cache[style.code_font] = { FONTS_DIR .. "mono.ttf", 15 * startup_scale, true }
end

--- Return the currently visible content zoom factor
-- @param context table|nil optional command context
-- @return number zoom factor
function zoom.get_zoom(context)
	if not active_view_uses_absolute_scale(context) then
		return get_active_view_zoom_multiplier(context)
	end

	return get_current_absolute_scale() / startup_scale * get_active_view_zoom_multiplier(context)
end

--- Return the targeted view neutral zoom factor
-- @param context table|nil optional command context
-- @return number zoom factor
function zoom.get_default_zoom(context)
	return get_active_view_default_zoom_level(context) / 100
end

--- Convert a visible zoom factor into an absolute font scale
-- @param zoom_factor number visible zoom factor
-- @param context table|nil optional command context
-- @return number absolute scale
local function zoom_factor_to_absolute_scale(zoom_factor, context)
	local multiplier = get_active_view_zoom_multiplier(context)
	return startup_scale * zoom_factor / multiplier
end

--- Clamp one absolute scale using the active view visible zoom limits
-- Zoom limits are defined in visible percent, so clamp through that space first and
-- then convert back to absolute scale to keep '100 percent' neutral across DPI baselines
-- @param absolute_scale number requested absolute scale
-- @param context table|nil optional command context
-- @return number absolute scale
local function clamp_absolute_scale(absolute_scale, context)
	local multiplier = get_active_view_zoom_multiplier(context)
	local min_zoom, max_zoom = get_active_view_zoom_bounds(context)
	local visible_zoom = absolute_scale / startup_scale * multiplier

	visible_zoom = common.clamp(visible_zoom, min_zoom, max_zoom)

	return startup_scale * visible_zoom / multiplier
end

--- Apply one absolute content scale immediately
-- @param scale number absolute content scale
-- @param anchor table|nil captured text anchor snapshot
local function apply_scale(scale, anchor)
	if math.abs(scale - current_scale) < 1e-9 then
		return
	end

	local scale_multiplier = scale / current_scale
	current_scale = scale

	-- Content zoom affects code-oriented surfaces only
	style.code_font = scale_font(style.code_font, scale_multiplier)

	if anchor and anchor.view and anchor.view.doc then
		local view = anchor.view

		-- Let text surfaces keep wrapped and unwrapped zoom restoration in one shared path
		if type(view.restore_content_zoom_anchor) == "function" and view:restore_content_zoom_anchor(anchor) then
			core.redraw = true
			return
		end

		if not (anchor.line and anchor.view.doc.lines[anchor.line]) then
			core.redraw = true
			return
		end

		local viewport_x, viewport_y = view:get_content_viewport_rect()
		local padding = (type(view.get_content_padding) == "function" and view:get_content_padding()) or style.padding
		local line_height = math.max(1, view:get_line_height())
		local line_doc_y = (padding.y or 0) + (anchor.line - 1) * line_height
		local max_scroll_y = view.get_scroll_max_y and view:get_scroll_max_y()
			or math.max(0, view:get_scrollable_size() - view:get_scroll_viewport_height())
		local target_scroll_y = 0

		-- Keyboard zoom in unwrapped text should keep the same visible top line
		-- instead of drifting around the viewport center
		if anchor.anchor_mode == "viewport" then
			target_scroll_y = common.clamp(line_doc_y + (anchor.row_offset or 0), 0, max_scroll_y)
		else
			local doc_anchor_y = line_doc_y + anchor.row_fraction * line_height
			target_scroll_y = common.clamp(doc_anchor_y - anchor.anchor_local_y, 0, max_scroll_y)
		end

		view.scroll.to.y = target_scroll_y
		view.scroll.y = target_scroll_y

		if anchor.keep_x == true then
			local gutter_width = type(view.get_gutter_width) == "function" and view:get_gutter_width() or 0
			local col_x = type(view.get_col_x_offset) == "function" and view:get_col_x_offset(anchor.line, anchor.col) or 0
			local doc_anchor_x = gutter_width + col_x
			local anchor_local_x = anchor.anchor_local_x or 0
			-- Horizontal max-scroll caches may still reflect pre-zoom text widths here
			local target_scroll_x = math.max(0, doc_anchor_x - anchor_local_x)

			view.scroll.to.x = target_scroll_x
			view.scroll.x = target_scroll_x
		end
	end

	core.redraw = true
end

--- Return one clamped anchor point inside the current content viewport
-- @param view table zoom target view
-- @param context table|nil optional command context
-- @return number anchor_x screen x anchor
-- @return number anchor_y screen y anchor
-- @return boolean keep_x true when horizontal anchoring should also be preserved
-- @return boolean snap_to_col1 true when horizontal anchoring should snap to column 1
local function resolve_text_zoom_anchor_point(view, context)
	local viewport_x, viewport_y, viewport_width, viewport_height = view:get_content_viewport_rect()
	local origin = type(context) == "table" and context.origin or nil
	local anchor_x = viewport_x + viewport_width / 2
	local anchor_y = viewport_y + viewport_height / 2
	local keep_x = false
	local snap_to_col1 = type(context) == "table" and context.snap_to_col1 == true

	if origin == "mouse" then
		-- Mouse-wheel zoom should preserve the current pointer row and column
		anchor_x = tonumber(context.mouse_x) or anchor_x
		anchor_y = tonumber(context.mouse_y) or anchor_y
		keep_x = true
	end

	anchor_x = common.clamp(anchor_x, viewport_x, viewport_x + viewport_width)
	anchor_y = common.clamp(anchor_y, viewport_y, viewport_y + viewport_height)
	anchor_x = common.round(anchor_x)
	anchor_y = common.round(anchor_y)

	if not snap_to_col1 and origin == "mouse" and type(view.get_gutter_width) == "function" then
		local gutter_width = view:get_gutter_width()
		local text_area_x = viewport_x + gutter_width
		local text_area_width = math.max(1, viewport_width - gutter_width)

		-- Snap left-side wheel zoom to column 1 so the text origin stays stable near the gutter
		if anchor_x <= text_area_x + text_area_width / 3 then
			snap_to_col1 = true
		end
	end

	return anchor_x, anchor_y, keep_x, snap_to_col1
end

--- Capture one text zoom anchor snapshot before font sizes change
-- The snapshot stores logical document position plus viewport-local pointer offset
-- @param context table|nil optional command context
-- @return table|nil anchor captured text anchor snapshot
local function capture_text_zoom_anchor(context)
	local view = get_zoom_target_view(context)
	if not (view and view.doc) then
		return nil
	end

	if type(view.capture_content_zoom_anchor) == "function"
		and type(view.get_content_viewport_rect) == "function"
	then
		local anchor_x, anchor_y, keep_x, snap_to_col1 = resolve_text_zoom_anchor_point(view, context)
		local anchor = view:capture_content_zoom_anchor(anchor_x, anchor_y)

		if anchor then
			-- Custom document views own their anchor meaning and restoration
			anchor.view = view
			anchor.keep_x = keep_x
			anchor.snap_to_col1 = snap_to_col1

			if snap_to_col1 then
				anchor.col = 1
				anchor.keep_x = true
			end

			return anchor
		end
	end

	if type(view.resolve_screen_position) ~= "function"
		or type(view.get_line_height) ~= "function"
		or type(view.get_content_viewport_rect) ~= "function"
	then
		return nil
	end

	local anchor_x, anchor_y, keep_x, snap_to_col1 = resolve_text_zoom_anchor_point(view, context)
	local viewport_x, viewport_y = view:get_content_viewport_rect()
	local line, col = view:resolve_screen_position(anchor_x, anchor_y)
	local _, line_y = view:get_line_screen_position(line)
	local line_height = math.max(1, view:get_line_height())
	local row_fraction = common.clamp((anchor_y - line_y) / line_height, 0, 1)
	local origin = type(context) == "table" and context.origin or nil
	local scroll_y = view.scroll.to.y or view.scroll.y or 0
	local padding = (type(view.get_content_padding) == "function" and view:get_content_padding()) or style.padding
	local padding_y = math.max(0, tonumber(padding and padding.y) or 0)

	if snap_to_col1 then
		col = 1
		keep_x = true
	end

	-- Keyboard zoom in plain text should keep the top visible line stable while
	-- mouse zoom and reset-to-column-1 continue to preserve their pointed anchor
	if origin ~= "mouse" and snap_to_col1 ~= true then
		local top_line = common.clamp(math.floor((scroll_y - padding_y) / line_height) + 1, 1, #view.doc.lines)

		return {
			view = view,
			line = top_line,
			row_offset = scroll_y - (padding_y + (top_line - 1) * line_height),
			anchor_mode = "viewport",
		}
	end

	return {
		view = view,
		line = line,
		col = col,
		row_fraction = row_fraction,
		anchor_local_x = anchor_x - viewport_x,
		anchor_local_y = anchor_y - viewport_y,
		keep_x = keep_x,
		snap_to_col1 = snap_to_col1,
	}
end

--- Ensure the debounced zoom worker is running
local function ensure_debounce_coroutine()
	if debounce_coroutine_started then
		return
	end

	debounce_coroutine_started = true

	core.add_coroutine(function()
		while true do
			if type(pending_scale) == "number" then
				local debounce_ms = tonumber(config.zoom_debounce_ms) or 40
				local max_wait_ms = tonumber(config.zoom_debounce_max_wait_ms)
				if not max_wait_ms or max_wait_ms <= 0 then
					max_wait_ms = debounce_ms
				end

				if debounce_ms <= 0 then
					local target_scale = pending_scale
					local target_anchor = pending_anchor
					pending_scale = nil
					pending_anchor = nil
					pending_scale_first_change_time = 0
					apply_scale(target_scale, target_anchor)
				else
					local now = system.get_time()
					local idle_elapsed = now - pending_scale_change_time
					local total_elapsed = now - pending_scale_first_change_time
					if idle_elapsed >= debounce_ms / 1000 or total_elapsed >= max_wait_ms / 1000 then
						local target_scale = pending_scale
						local target_anchor = pending_anchor
						pending_scale = nil
						pending_anchor = nil
						pending_scale_first_change_time = 0
						apply_scale(target_scale, target_anchor)
					end
				end
			end

			coroutine.yield(0.016)
		end
	end)
end

--- Schedule one absolute content scale change
-- @param scale number absolute content scale
-- @param context table|nil optional command context
function zoom.set_absolute_scale(scale, context)
	if not get_zoom_target_view(context) then
		return false
	end

	local anchor = capture_text_zoom_anchor(context)
	local debounce_ms = tonumber(config.zoom_debounce_ms) or 40
	scale = clamp_absolute_scale(scale, context)

	if debounce_ms <= 0 then
		pending_scale = nil
		pending_scale_first_change_time = 0
		pending_anchor = nil
		apply_scale(scale, anchor)
		return true
	end

	local now = system.get_time()
	if type(pending_scale) ~= "number" then
		pending_scale_first_change_time = now
	end

	pending_scale = scale
	pending_anchor = anchor
	pending_scale_change_time = now
	ensure_debounce_coroutine()
	return true
end

--- Apply one visible content zoom factor
-- @param zoom_factor number visible zoom factor
-- @param context table|nil optional command context
-- @return boolean changed
function zoom.set_zoom(zoom_factor, context)
	local active_view = get_zoom_target_view(context)
	if not active_view then
		return false
	end

	if active_view and type(active_view.set_zoom_multiplier) == "function" then
		-- Let special views consume visible zoom changes through their own intrinsic scaling
		active_view:set_zoom_multiplier(zoom_factor, context)
	end

	if active_view and type(active_view.should_apply_absolute_scale) == "function" then
		if active_view:should_apply_absolute_scale() == false then
			return true
		end
	end

	return zoom.set_absolute_scale(zoom_factor_to_absolute_scale(zoom_factor, context), context)
end

--- Step visible content zoom through the targeted view zoom levels
-- @param direction number positive for zoom-in, negative for zoom-out
-- @param context table|nil optional command context
function zoom.step(direction, context)
	if not get_zoom_target_view(context) then
		return false
	end

	local zoom_levels = get_active_view_zoom_levels(context)
	if #zoom_levels == 0 then
		local fallback_multiplier = direction > 0 and 1.1 or 0.9
		return zoom.set_zoom(zoom.get_zoom(context) * fallback_multiplier, context)
	end

	local current_zoom_percent = zoom.get_zoom(context) * 100
	local epsilon = 1e-6
	local target_zoom_level = nil

	if direction > 0 then
		for index = 1, #zoom_levels do
			local zoom_level = zoom_levels[index]
			if zoom_level > current_zoom_percent + epsilon then
				target_zoom_level = zoom_level
				break
			end
		end
		target_zoom_level = target_zoom_level or zoom_levels[#zoom_levels]
	else
		for index = #zoom_levels, 1, -1 do
			local zoom_level = zoom_levels[index]
			if zoom_level < current_zoom_percent - epsilon then
				target_zoom_level = zoom_level
				break
			end
		end
		target_zoom_level = target_zoom_level or zoom_levels[1]
	end

	return zoom.set_zoom(target_zoom_level / 100, context)
end

--- Reset visible content zoom to the targeted view neutral level
-- @param context table|nil optional command context
function zoom.reset(context)
	if not get_zoom_target_view(context) then
		return false
	end

	local reset_context = {}

	if type(context) == "table" then
		for key, value in pairs(context) do
			reset_context[key] = value
		end
	end

	-- Reset should always jump back to the first visible text column
	reset_context.snap_to_col1 = true

	return zoom.set_zoom(zoom.get_default_zoom(reset_context), reset_context)
end

--- Install the core zoom subsystem after style fonts exist
function zoom.init()
	install_renderer_font_load_hook()
	seed_base_font_cache()
end

return zoom
