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

--- Read-only image viewer for supported raster and SVG files
-- Keeps image viewing isolated from the text and binary document surfaces
-- @module core.image.viewer

local core = require("core")
local config = require("core.config")
local style = require("core.style")
local DocView = require("core.doc.view")
local image_support = require("core.image.support")
local common = require("core.common")
local transparent = require("core.ui.transparent")

local ImageViewer = DocView:extend()

local IMAGE_ZOOM_LEVELS = { 5, 10, 25, 40, 50, 75, 90, 100, 150, 200, 300, 400, 600, 800, 900, 1000, 1500, 2000, 3000, 5000, 7500, 9000, 10000 }
local MIN_ZOOM_MULTIPLIER = 0.01
local FIT_MODE_EPSILON = 0.01
local PAN_CURSOR = "grabbing"
local IMAGE_STEP_SCROLL = 64
local DEBUG_OVERLAY_LINE_HEIGHT = 18
local DEBUG_OVERLAY_PADDING = 8
local DEBUG_OVERLAY_BACKGROUND = { 20, 20, 20, 220 }
local DEBUG_OVERLAY_TEXT = { 255, 255, 255, 255 }
local VIRTUAL_IMAGE_LOADERS = {
	ico = "plugins.icopreview.ico",
	cur = "plugins.icopreview.ico",
	icns = "plugins.icopreview.icns",
}
local get_image_draw_origin

--- Return one supported initial image zoom mode
-- @return string zoom_mode
local function get_initial_zoom_mode()
	local zoom_mode = config.image_viewer_initial_zoom

	if zoom_mode == "fit" or zoom_mode == "fit_width" or zoom_mode == "fit_height" then
		return zoom_mode
	end

	return "fixed"
end

--- Load one indexed image from a supported multi-image file
-- @param filename string physical image filename
-- @param virtual_path string path selecting one embedded image
-- @param extension string normalized physical extension
-- @return userdata|nil image
-- @return string|nil error_message
-- @return table|nil image_info
local function load_virtual_image(filename, virtual_path, extension)
	local loader_module = VIRTUAL_IMAGE_LOADERS[extension]

	if not loader_module then
		return nil, "Virtual images are not supported for this file type"
	end

	local format_name = extension:upper()
	-- Only a plain zero-based decimal index is valid for an embedded frame
	if not virtual_path:match("^%d+$") then
		return nil, string.format("Invalid %s image index: %s", format_name, virtual_path)
	end

	-- Indexed formats remain optional because their readers belong to plugins
	local loaded, loader = pcall(require, loader_module)
	if not loaded or type(loader) ~= "table" then
		return nil, string.format("%s frame support requires the ICOPreview plugin", format_name)
	end
	if type(loader.read_file) ~= "function" or type(loader.load_entry_image) ~= "function" then
		return nil, string.format("%s frame support is unavailable", format_name)
	end

	local image_set, read_error = loader.read_file(filename)

	if not image_set then
		return nil, read_error
	end

	local index = tonumber(virtual_path)
	local entry = index and image_set.entries[index + 1] or nil

	if not entry then
		return nil, string.format("%s image index %s is out of range", format_name, virtual_path)
	end

	local image, load_error = loader.load_entry_image(entry)

	return image, load_error, entry
end

--- Return the configured fit direction for the current image type
-- @param self table image viewer
-- @return string fit_resize
local function get_fit_resize(self)
	local fit_resize = config.image_viewer_fit_resize

	if self.image_extension == "svg" and config.image_viewer_fit_resize_vector ~= nil then
		fit_resize = config.image_viewer_fit_resize_vector
	end

	if fit_resize == "enlarge" or fit_resize == "both" then
		return fit_resize
	end

	return "reduce"
end

--- Apply the configured reduce or enlarge limit to one fit multiplier
-- @param self table image viewer
-- @param zoom_multiplier number raw fit multiplier
-- @return number zoom_multiplier
local function apply_fit_resize(self, zoom_multiplier)
	local fit_resize = get_fit_resize(self)

	if fit_resize == "reduce" then
		return math.min(1, zoom_multiplier)
	elseif fit_resize == "enlarge" then
		return math.max(1, zoom_multiplier)
	end

	return zoom_multiplier
end

--- Return the configured anchor for one mouse zoom operation
-- @param context table|nil input details supplied by the keymap
-- @return number|nil anchor_x
-- @return number|nil anchor_y
local function get_configured_zoom_anchor(context)
	if config.image_viewer_zoom_anchor ~= "mouse" then
		return nil, nil
	end
	if type(context) == "table" and context.mouse_x and context.mouse_y then
		return context.mouse_x, context.mouse_y
	end

	local mouse = core.workspace_view and core.workspace_view.mouse or nil
	return mouse and mouse.x or nil, mouse and mouse.y or nil
end

--- Return the fixed scrollbar lane size for image views
-- Keeping both lanes reserved avoids image shifts when overflow changes
-- @return number scrollbar thickness
local function get_image_scrollbar_size()
	return math.max(1, tonumber(style.scrollbar_size) or 12)
end

--- Keep the image view's right and bottom scrollbar lanes reserved at all times
-- This keeps the viewport stable whether or not scrolling is currently needed
-- @param self table image viewer
local function sync_scrollbar_lanes(self)
	local scrollbar_size = get_image_scrollbar_size()
	self:set_lane_size("right", self.LaneIDs.VSCROLL, scrollbar_size, self.LaneOrders.VSCROLL)
	self:set_lane_size("bottom", self.LaneIDs.HSCROLL, scrollbar_size, self.LaneOrders.HSCROLL)
end

--- Return one normalized scrollable length for an always-visible scrollbar lane
-- The extra pixel keeps the track drawable even when the content currently fits
-- @param content_size number current content size
-- @param viewport_size number current viewport size
-- @return number scrollable length
local function get_visible_scrollbar_range(content_size, viewport_size)
	return math.max(math.floor(tonumber(content_size) or 0), math.floor(tonumber(viewport_size) or 0) + 1)
end

--- Draw one always-visible scrollbar track plus an optional thumb
-- Tracks stay visible even when the content fits so the image viewport never shifts
-- @param scrollbar table scrollbar instance
-- @param x number track x
-- @param y number track y
-- @param width number track width
-- @param height number track height
-- @param scrollable number normalized scrollable range
local function draw_image_scrollbar(scrollbar, x, y, width, height, scrollable)
	local track_color = style.scrollbar_track or style.background2 or style.background
	renderer.draw_rect(x, y, width, height, track_color)
	scrollbar:set_size(x, y, width, height, scrollable)
	scrollbar:draw_thumb()
end

--- Return one horizontal scrollbar track rect for the image view
-- Image views always reserve the bottom lane so horizontal overflow never shifts the content
-- @param self table image viewer
-- @return number x
-- @return number y
-- @return number width
-- @return number height
local function get_horizontal_scrollbar_rect(self)
	sync_scrollbar_lanes(self)
	self:update_content_size()

	local lane_x, lane_y, lane_width, lane_height = self:get_lane_rect("bottom", self.LaneIDs.HSCROLL)
	local _, _, viewport_width = self:get_content_viewport_rect()
	local scrollable = get_visible_scrollbar_range(self.content_width, viewport_width)
	local max_scroll = math.max(0, self.content_width - viewport_width)
	local percent = (max_scroll > 0) and math.max(0, math.min(1, (self.scroll.x or 0) / max_scroll)) or 0

	self.hscrollbar:set_size(lane_x, lane_y, lane_width, lane_height, scrollable)
	self.hscrollbar:set_percent(percent)
	return self.hscrollbar:get_track_rect()
end

--- Return whether one pointer overlaps the horizontal scrollbar
-- @param self table image viewer
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean overlaps
local function horizontal_scrollbar_overlaps_point(self, x, y)
	get_horizontal_scrollbar_rect(self)
	return self.hscrollbar:overlaps(x, y) ~= nil
end

--- Handle one horizontal scrollbar press and sync the image scroll position immediately
-- @param self table image viewer
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean|nil handled
local function handle_horizontal_scrollbar_mouse_down(self, button, x, y, clicks)
	get_horizontal_scrollbar_rect(self)
	local pressed = self.hscrollbar:on_mouse_down(button, x, y, clicks)
	self.dragging_scrollbar = self.vscrollbar.dragging or self.hscrollbar.dragging or false

	if not pressed then
		return nil
	end

	if type(pressed) == "number" then
		local _, _, viewport_width = self:get_content_viewport_rect()
		local max_scroll = math.max(0, self.content_width - viewport_width)
		local target = common.clamp(pressed * max_scroll, 0, max_scroll)
		self.scroll.to.x = target
		self.scroll.x = target
		self:keep_mouse_interaction_active()
	end

	return true
end

--- Handle one horizontal scrollbar drag and sync the image scroll position immediately
-- @param self table image viewer
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean handled
local function handle_horizontal_scrollbar_mouse_move(self, x, y, dx, dy)
	get_horizontal_scrollbar_rect(self)
	local moved = self.hscrollbar:on_mouse_move(x, y, dx, dy)

	if type(moved) == "number" then
		local _, _, viewport_width = self:get_content_viewport_rect()
		local max_scroll = math.max(0, self.content_width - viewport_width)
		local target = common.clamp(moved * max_scroll, 0, max_scroll)
		self.scroll.to.x = target
		self.scroll.x = target
		self:keep_mouse_interaction_active()
	end

	self.dragging_scrollbar = self.vscrollbar.dragging or self.hscrollbar.dragging or false

	if self.scrollable and horizontal_scrollbar_overlaps_point(self, x, y) then
		self.mouse_cursor = "arrow"
	end

	return moved ~= nil
end

--- Release one horizontal scrollbar drag state
-- @param self table image viewer
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
local function handle_horizontal_scrollbar_mouse_up(self, button, x, y)
	self.hscrollbar:on_mouse_up(button, x, y)
	self.dragging_scrollbar = self.vscrollbar.dragging or self.hscrollbar.dragging or false
end

--- Return one stable intrinsic image size for layout and zoom math
-- SVGs rerender to different bitmap sizes, so the viewer keeps their source size separate
-- @param self table image viewer
-- @return number image width in intrinsic units
-- @return number image height in intrinsic units
local function get_intrinsic_image_size(self)
	if tonumber(self.source_image_width) and tonumber(self.source_image_height) then
		return self.source_image_width, self.source_image_height
	end

	if self.image then
		return self.image:get_size()
	end

	return 0, 0
end

--- Return the current rendered bitmap size in pixels
-- Raster images keep their original bitmap size, while SVGs may rerender per zoom level
-- @param self table image viewer
-- @return number bitmap width in pixels
-- @return number bitmap height in pixels
local function get_rendered_image_size(self)
	if not self.image then
		return 0, 0
	end

	return self.image:get_size()
end

--- Return true when the current doc should rerender through the SVG path
-- @param self table image viewer
-- @return boolean is_svg
local function is_svg_image(self)
	return self.image_extension == "svg"
end

--- Rerender one SVG image to the current zoomed presentation size
-- Keeps vector artwork crisp instead of zooming a stale bitmap
-- @param self table image viewer
-- @return boolean rendered true when the current SVG bitmap is ready
local function ensure_svg_bitmap_matches_content(self)
	if not is_svg_image(self) or not self.doc or type(self.doc.filename) ~= "string" then
		return true
	end

	local target_width = math.max(1, math.floor(self.content_width + 0.5))
	local target_height = math.max(1, math.floor(self.content_height + 0.5))
	local image_width, image_height = get_rendered_image_size(self)
	local same_failed_target = self.failed_svg_width == target_width
		and self.failed_svg_height == target_height

	if same_failed_target then
		return false
	end

	-- A different size is one fresh request and may succeed
	self.failed_svg_width = nil
	self.failed_svg_height = nil

	if image_width == target_width and image_height == target_height then
		self.image_error = nil
		return true
	end

	-- Keep the current image visible if one rerender fails during zooming
	local rerendered_image, rerender_error = canvas.load_svg_image(self.doc.filename, target_width, target_height)

	if not rerendered_image then
		self.image_error = rerender_error or "SVG render failed"
		self.failed_svg_width = target_width
		self.failed_svg_height = target_height
		return false
	end

	self.image = rerendered_image
	self.image_error = nil
	return true
end

--- Return one viewport anchor point for image zoom operations
-- Defaults to the viewport center when no explicit pointer anchor is provided
-- @param self table image viewer
-- @param anchor_x number|nil screen x anchor
-- @param anchor_y number|nil screen y anchor
-- @return number anchor x inside the viewport
-- @return number anchor y inside the viewport
local function resolve_zoom_anchor(self, anchor_x, anchor_y)
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local resolved_x = tonumber(anchor_x) or (viewport_x + viewport_width / 2)
	local resolved_y = tonumber(anchor_y) or (viewport_y + viewport_height / 2)

	resolved_x = math.max(viewport_x, math.min(viewport_x + viewport_width, resolved_x))
	resolved_y = math.max(viewport_y, math.min(viewport_y + viewport_height, resolved_y))

	return resolved_x, resolved_y
end

--- Apply one zoom multiplier around one viewport anchor point
-- Keeps command zoom centered and wheel zoom pinned to the current pointer position
-- @param self table image viewer
-- @param zoom_multiplier number requested visible zoom
-- @param anchor_x number|nil screen x anchor
-- @param anchor_y number|nil screen y anchor
local function set_zoom_with_anchor(self, zoom_multiplier, anchor_x, anchor_y)
	self:update_content_size()
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local resolved_anchor_x, resolved_anchor_y = resolve_zoom_anchor(self, anchor_x, anchor_y)
	local draw_x, draw_y = get_image_draw_origin(self)
	local previous_content_width = self.content_width
	local previous_content_height = self.content_height
	local image_anchor_x = 0
	local image_anchor_y = 0
	local requested_zoom = math.max(MIN_ZOOM_MULTIPLIER, tonumber(zoom_multiplier) or 1)
	local initial_zoom_mode = get_initial_zoom_mode()
	local fit_zoom = self:get_fit_zoom_multiplier(initial_zoom_mode)

	if self.image and previous_content_width > 0 and previous_content_height > 0 then
		image_anchor_x = math.max(0, math.min(previous_content_width, resolved_anchor_x - draw_x))
		image_anchor_y = math.max(0, math.min(previous_content_height, resolved_anchor_y - draw_y))
	end

	if math.abs(requested_zoom - fit_zoom) <= FIT_MODE_EPSILON then
		self.zoom_mode = initial_zoom_mode
		self.zoom_multiplier = fit_zoom
	else
		self.zoom_mode = "fixed"
		self.zoom_multiplier = requested_zoom
	end

	self:update_content_size()
	ensure_svg_bitmap_matches_content(self)

	if self.content_width > viewport_width then
		self.scroll.to.x = image_anchor_x * (self.content_width / math.max(1, previous_content_width)) - (resolved_anchor_x - viewport_x)
	else
		self.scroll.to.x = 0
	end

	if self.content_height > viewport_height then
		self.scroll.to.y = image_anchor_y * (self.content_height / math.max(1, previous_content_height)) - (resolved_anchor_y - viewport_y)
	else
		self.scroll.to.y = 0
	end

	self.scroll.x = self.scroll.to.x
	self.scroll.y = self.scroll.to.y
	self:keep_mouse_interaction_active()
	self:invalidate_presentation()
	self:clamp_scroll_position()
end

--- Step one image view zoom level up or down without the shared zoom subsystem
-- This keeps wheel zoom and isolated viewer cases deterministic without changing font scale
-- @param self table image viewer
-- @param direction number positive for zoom in, negative for zoom out
-- @param anchor_x number|nil screen x anchor
-- @param anchor_y number|nil screen y anchor
local function step_view_zoom_locally(self, direction, anchor_x, anchor_y)
	local zoom_levels = self:get_zoom_levels()
	local current_zoom_percent = self:get_zoom_multiplier() * 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

	if target_zoom_level then
		set_zoom_with_anchor(self, target_zoom_level / 100, anchor_x, anchor_y)
	end
end

--- Build one read-only image view for a file-backed doc
-- @param doc table image document model
function ImageViewer:new(doc)
	ImageViewer.super.new(self, doc)
	self.scrollable = true
	self.mouse_cursor = "arrow"
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.image = nil
	self.image_error = nil
	self.image_bits_per_pixel = nil
	self.image_extension = nil
	self.failed_svg_width = nil
	self.failed_svg_height = nil
	self.source_image_width = 0
	self.source_image_height = 0
	self.zoom_mode = get_initial_zoom_mode()
	self.zoom_multiplier = 1
	self.content_width = 0
	self.content_height = 0
	self.vscrollbar:set_forced_status("expanded")
	self.hscrollbar:set_forced_status("expanded")
	sync_scrollbar_lanes(self)

	self:reload_image()
end

--- Return key binding contexts for the image viewer surface
-- Keeps image-only navigation local to this viewer
-- @return table contexts ordered from most specific to broadest
function ImageViewer:get_keymap_contexts()
	return { "image_viewer", "doc_view", "global" }
end

--- Return the normalized source image format label
-- @return string|nil format_name
function ImageViewer:get_image_format()
	return image_support.get_format_name(self.image_extension)
end

--- Return whether the source uses vector or pixel graphics
-- @return string graphics_type
function ImageViewer:get_image_graphics_type()
	return self.image_extension == "svg" and "vector" or "pixel"
end

--- Return the decoded raster depth when available
-- @return integer|nil bits_per_pixel
function ImageViewer:get_image_bits_per_pixel()
	return self.image_bits_per_pixel
end

--- Return the intrinsic source image dimensions
-- @return number width
-- @return number height
function ImageViewer:get_image_dimensions()
	return get_intrinsic_image_size(self)
end

--- Return the current displayed image dimensions
-- @return number width
-- @return number height
function ImageViewer:get_displayed_image_dimensions()
	self:update_content_size()

	return self.content_width, self.content_height
end

--- Return image viewer content padding
-- Images should align directly to the pane edges
-- @return table padding with 'x' and 'y'
function ImageViewer:get_content_padding()
	return { x = 0, y = 0 }
end

--- Return deterministic zoom levels for image viewing
-- @return table zoom levels in percent
function ImageViewer:get_zoom_levels()
	return IMAGE_ZOOM_LEVELS
end

--- Return the reset zoom level for the current viewport
-- Fit mode is treated as the neutral zoom state for images
-- @return number zoom level in percent
function ImageViewer:get_default_zoom_level()
	local initial_zoom_mode = get_initial_zoom_mode()
	local initial_zoom = initial_zoom_mode == "fixed" and 1 or self:get_fit_zoom_multiplier(initial_zoom_mode)

	return math.max(1, math.floor(initial_zoom * 100 + 0.5))
end

--- Return the currently visible zoom multiplier
-- Fit mode resolves against the current viewport size
-- @return number zoom multiplier
function ImageViewer:get_zoom_multiplier()
	if self.zoom_mode == "fit" or self.zoom_mode == "fit_width" or self.zoom_mode == "fit_height" then
		return self:get_fit_zoom_multiplier(self.zoom_mode)
	end

	return math.max(MIN_ZOOM_MULTIPLIER, tonumber(self.zoom_multiplier) or 1)
end

--- Return false so image zoom stays intrinsic to the view instead of rescaling document fonts
-- @return boolean apply_absolute_scale
function ImageViewer:should_apply_absolute_scale()
	return false
end

--- Apply one visible zoom multiplier
-- Matching the current fit scale switches back into fit mode
-- @param zoom_multiplier number requested visible zoom
-- @param context table|nil command input details
function ImageViewer:set_zoom_multiplier(zoom_multiplier, context)
	local anchor_x, anchor_y
	-- Keyboard commands stay centered while wheel commands may follow the pointer
	if type(context) == "table" and context.origin == "mouse" then
		anchor_x, anchor_y = get_configured_zoom_anchor(context)
	end
	set_zoom_with_anchor(self, zoom_multiplier, anchor_x, anchor_y)
end

--- Return total vertical scrollable size for the zoomed image
-- @return number scrollable height
function ImageViewer:get_scrollable_size()
	self:update_content_size()
	return self.content_height
end

--- Return total horizontal scrollable size for the zoomed image
-- @return number scrollable width
function ImageViewer:get_scroll_max_x()
	sync_scrollbar_lanes(self)
	self:update_content_size()
	local _, _, viewport_width = self:get_content_viewport_rect()
	return math.max(0, self.content_width - viewport_width)
end

--- Return one vertical scrollbar track rect for the image view
-- Image views always reserve the vertical lane so the content viewport stays stable
-- @return number x
-- @return number y
-- @return number width
-- @return number height
function ImageViewer:get_scrollbar_rect()
	sync_scrollbar_lanes(self)
	self:update_content_size()

	local lane_x, lane_y, lane_width, lane_height = self:get_lane_rect("right", self.LaneIDs.VSCROLL)
	local _, _, _, viewport_height = self:get_content_viewport_rect()
	local scrollable = get_visible_scrollbar_range(self.content_height, viewport_height)
	local max_scroll = math.max(0, self.content_height - viewport_height)
	local percent = (max_scroll > 0) and math.max(0, math.min(1, (self.scroll.y or 0) / max_scroll)) or 0

	self.vscrollbar:set_size(lane_x, lane_y, lane_width, lane_height, scrollable)
	self.vscrollbar:set_percent(percent)
	return self.vscrollbar:get_track_rect()
end

--- Return one line-height proxy for wheel and keyboard scroll steps
-- @return number line height
function ImageViewer:get_line_height()
	return IMAGE_STEP_SCROLL
end

--- Reload the current doc image into the viewer
function ImageViewer:reload_image()
	local had_loaded_image = self.image ~= nil
	local previous_zoom_mode = self.zoom_mode
	local previous_zoom_multiplier = self.zoom_multiplier
	local filename = self.doc and self.doc.filename or nil
	local parsed_filename = system.parse_file_reference(filename or "", false)
	local physical_filename = parsed_filename.filename
	local virtual_path = parsed_filename.virtual_path
	local supported, extension = image_support.is_supported(physical_filename)
	extension = self.doc and self.doc.image_extension or extension
	supported = supported == true or (self.doc and self.doc.kind == "image")

	self.image = nil
	self.image_error = nil
	self.image_bits_per_pixel = nil
	self.image_extension = extension
	self.failed_svg_width = nil
	self.failed_svg_height = nil
	self.source_image_width = 0
	self.source_image_height = 0
	self.content_width = 0
	self.content_height = 0

	if supported ~= true or type(filename) ~= "string" then
		self.image_error = "Unsupported image file"
		return
	end

	local image_info
	if virtual_path ~= nil then
		self.image, self.image_error, image_info = load_virtual_image(
			physical_filename,
			virtual_path,
			extension
		)
	else
		self.image, self.image_error, image_info = canvas.load_image(physical_filename)
	end

	if not self.image then
		self.image_error = self.image_error or "Image load failed"
		return
	end

	self.source_image_width, self.source_image_height = self.image:get_size()
	if extension ~= "svg" and type(image_info) == "table" then
		local bits_per_pixel = tonumber(image_info.bits_per_pixel)
		if bits_per_pixel and bits_per_pixel > 0 then
			self.image_bits_per_pixel = math.floor(bits_per_pixel)
		end
	end

	if had_loaded_image and config.image_viewer_reset_zoom_on_reload ~= true then
		self.zoom_mode = previous_zoom_mode
		self.zoom_multiplier = previous_zoom_multiplier
	else
		self.zoom_mode = get_initial_zoom_mode()
		self.zoom_multiplier = 1
	end
	self:update_content_size()
	ensure_svg_bitmap_matches_content(self)
	self:invalidate_presentation()
end

--- Return the configured fit zoom multiplier for the current viewport
-- @param zoom_mode string|nil 'fit', 'fit_width', or 'fit_height'
-- @return number zoom multiplier
function ImageViewer:get_fit_zoom_multiplier(zoom_mode)
	local image_width, image_height = get_intrinsic_image_size(self)

	if image_width <= 0 or image_height <= 0 then
		return 1
	end

	local _, _, viewport_width, viewport_height = self:get_content_viewport_rect()

	if viewport_width <= 0 or viewport_height <= 0 then
		return 1
	end

	local resolved_zoom_mode = zoom_mode or "fit"
	local zoom_multiplier

	if resolved_zoom_mode == "fit_width" then
		zoom_multiplier = viewport_width / image_width
	elseif resolved_zoom_mode == "fit_height" then
		zoom_multiplier = viewport_height / image_height
	else
		zoom_multiplier = math.min(viewport_width / image_width, viewport_height / image_height)
	end

	return math.max(MIN_ZOOM_MULTIPLIER, apply_fit_resize(self, zoom_multiplier))
end

--- Refresh cached content bounds from the current image and zoom
function ImageViewer:update_content_size()
	local image_width, image_height = get_intrinsic_image_size(self)

	if image_width <= 0 or image_height <= 0 then
		self.content_width = 0
		self.content_height = 0
		return
	end

	local zoom_multiplier = self:get_zoom_multiplier()
	self.content_width = math.max(1, math.floor(image_width * zoom_multiplier + 0.5))
	self.content_height = math.max(1, math.floor(image_height * zoom_multiplier + 0.5))
end

--- Force one full image-view repaint after a zoom or image load change
function ImageViewer:invalidate_presentation()
	local window_width, window_height = renderer.get_size()
	renderer.invalidate_rect(0, 0, window_width, window_height)
	core.redraw = true
end

--- Return the top-left draw origin for the zoomed image
-- Centers the image when it is smaller than the viewport
-- @return number x draw x
-- @return number y draw y
function get_image_draw_origin(self)
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local draw_x = viewport_x - (self.scroll.x or 0)
	local draw_y = viewport_y - (self.scroll.y or 0)

	if self.content_width < viewport_width then
		draw_x = viewport_x + math.floor((viewport_width - self.content_width) / 2 + 0.5)
	end

	if self.content_height < viewport_height then
		draw_y = viewport_y + math.floor((viewport_height - self.content_height) / 2 + 0.5)
	end

	return draw_x, draw_y
end

--- Return the visible source and destination rectangles for the current zoomed image draw
-- Keeps the renderer work bounded to the viewport instead of scaling the whole image every frame
-- @return number|nil source x
-- @return number|nil source y
-- @return number|nil source width
-- @return number|nil source height
-- @return number|nil dest x
-- @return number|nil dest y
-- @return number|nil dest width
-- @return number|nil dest height
local function get_visible_image_region(self)
	if not self.image then
		return nil
	end

	local image_width, image_height = get_rendered_image_size(self)
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local draw_x, draw_y = get_image_draw_origin(self)
	local visible_left = math.max(viewport_x, draw_x)
	local visible_top = math.max(viewport_y, draw_y)
	local visible_right = math.min(viewport_x + viewport_width, draw_x + self.content_width)
	local visible_bottom = math.min(viewport_y + viewport_height, draw_y + self.content_height)

	if visible_right <= visible_left or visible_bottom <= visible_top then
		return nil
	end

	local source_x = math.max(0, math.floor((visible_left - draw_x) * image_width / self.content_width))
	local source_y = math.max(0, math.floor((visible_top - draw_y) * image_height / self.content_height))
	local source_right = math.min(image_width, math.ceil((visible_right - draw_x) * image_width / self.content_width))
	local source_bottom = math.min(image_height, math.ceil((visible_bottom - draw_y) * image_height / self.content_height))
	local source_width = math.max(1, source_right - source_x)
	local source_height = math.max(1, source_bottom - source_y)
	local dest_x = draw_x + math.floor(source_x * self.content_width / image_width + 0.5)
	local dest_y = draw_y + math.floor(source_y * self.content_height / image_height + 0.5)
	local dest_width = math.max(1, math.floor(source_width * self.content_width / image_width + 0.5))
	local dest_height = math.max(1, math.floor(source_height * self.content_height / image_height + 0.5))

	return source_x, source_y, source_width, source_height, dest_x, dest_y, dest_width, dest_height
end

--- Draw one temporary debug overlay for image zoom diagnostics
-- Shows the source and destination rects the viewer is currently sending to the renderer
-- @param self table image viewer
-- @param source_x number|nil source x
-- @param source_y number|nil source y
-- @param source_width number|nil source width
-- @param source_height number|nil source height
-- @param dest_x number|nil dest x
-- @param dest_y number|nil dest y
-- @param dest_width number|nil dest width
-- @param dest_height number|nil dest height
local function draw_debug_overlay(self, source_x, source_y, source_width, source_height, dest_x, dest_y, dest_width, dest_height)
	if not self.image then
		return
	end

	local image_width, image_height = self.image:get_size()
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local sample_x = source_x and math.min(image_width - 1, source_x + math.floor(math.max(0, source_width - 1) / 2)) or nil
	local sample_y = source_y and math.min(image_height - 1, source_y + math.floor(math.max(0, source_height - 1) / 2)) or nil
	local sample_text = "sample=nil"

	if sample_x and sample_y then
		local red, green, blue, alpha = self.image:get_pixel(sample_x, sample_y)
		sample_text = string.format("sample=%d,%d rgba=%d,%d,%d,%d", sample_x, sample_y, red, green, blue, alpha)
	end

	local lines = {
		string.format("zoom=%.2f%% mode=%s", self:get_zoom_multiplier() * 100, self.zoom_mode),
		string.format("image=%dx%d content=%dx%d", image_width, image_height, self.content_width, self.content_height),
		string.format("viewport=%.0f,%.0f %.0fx%.0f", viewport_x, viewport_y, viewport_width, viewport_height),
		string.format("source=%s,%s %sx%s", tostring(source_x), tostring(source_y), tostring(source_width), tostring(source_height)),
		string.format("dest=%s,%s %sx%s", tostring(dest_x), tostring(dest_y), tostring(dest_width), tostring(dest_height)),
		string.format("scroll=%.2f,%.2f", self.scroll.x or 0, self.scroll.y or 0),
		sample_text,
	}
	local overlay_width = 0

	for index = 1, #lines do
		overlay_width = math.max(overlay_width, renderer.measure_text(style.font, lines[index], 0))
	end

	local overlay_height = #lines * DEBUG_OVERLAY_LINE_HEIGHT + DEBUG_OVERLAY_PADDING * 2
	local draw_x = self.position.x + 12
	local draw_y = self.position.y + 12
	local text_y = draw_y + DEBUG_OVERLAY_PADDING

	renderer.draw_rect(draw_x, draw_y, overlay_width + DEBUG_OVERLAY_PADDING * 2, overlay_height, DEBUG_OVERLAY_BACKGROUND)

	for index = 1, #lines do
		renderer.draw_text(style.font, lines[index], draw_x + DEBUG_OVERLAY_PADDING, text_y, DEBUG_OVERLAY_TEXT)
		text_y = text_y + DEBUG_OVERLAY_LINE_HEIGHT
	end
end

--- Draw one fallback error message when the image failed to load
function ImageViewer:draw_error()
	local message = self.image_error or "Image load failed"
	local text_width = renderer.measure_text(style.font, message, 0)
	local text_height = style.font:get_height()
	local draw_x = self.position.x + math.max(0, math.floor((self.size.x - text_width) / 2))
	local draw_y = self.position.y + math.max(0, math.floor((self.size.y - text_height) / 2))

	renderer.draw_text(style.font, message, draw_x, draw_y, style.accent)
end

--- Scroll the image viewer by one explicit delta pair
-- @param delta_x number horizontal delta
-- @param delta_y number vertical delta
function ImageViewer:scroll_by(delta_x, delta_y)
	self.scroll.to.x = (self.scroll.to.x or self.scroll.x or 0) + (tonumber(delta_x) or 0)
	self.scroll.to.y = (self.scroll.to.y or self.scroll.y or 0) + (tonumber(delta_y) or 0)
	self:clamp_scroll_position()
	self.scroll.x = self.scroll.to.x
	self.scroll.y = self.scroll.to.y
	self:keep_mouse_interaction_active()
end

--- Fast-scroll vertically through a zoomed image
-- @param direction number negative scrolls up and positive scrolls down
-- @return boolean handled true when the view accepted the request
function ImageViewer:fast_scroll_vertical(direction)
	direction = tonumber(direction) or 0

	if direction == 0 then
		return false
	end

	local sign = (direction < 0) and -1 or 1

	self:scroll_by(0, sign * self:get_fast_scroll_step())

	return true
end

--- Scroll the image viewer by one visible page
-- @param direction number negative for up, positive for down
function ImageViewer:scroll_page(direction)
	local _, _, _, viewport_height = self:get_content_viewport_rect()
	local page_step = math.max(IMAGE_STEP_SCROLL, viewport_height - IMAGE_STEP_SCROLL)

	self:scroll_by(0, page_step * direction)
end

--- Jump the image viewer to one horizontal and vertical edge
-- @param to_end boolean|nil true jumps to the bottom-right edge
function ImageViewer:scroll_to_edge(to_end)
	if to_end then
		self.scroll.to.x = self:get_scroll_max_x()
		self.scroll.to.y = self:get_scroll_max_y()
	else
		self.scroll.to.x = 0
		self.scroll.to.y = 0
	end

	self.scroll.x = self.scroll.to.x
	self.scroll.y = self.scroll.to.y
	self:keep_mouse_interaction_active()
end

--- Keep fit-mode image scaling in sync with viewport changes
function ImageViewer:update()
	ImageViewer.super.update(self)

	sync_scrollbar_lanes(self)
	self:update_content_size()
	ensure_svg_bitmap_matches_content(self)
	self.mouse_cursor = self._mouse_pan and PAN_CURSOR or "arrow"

	if not self._mouse_pan then
		self:clamp_scroll_position()
	end
end

--- Draw both image-view scrollbars as always-visible fixed lanes
-- Keeping both tracks on screen prevents the image from shifting when overflow changes
function ImageViewer:draw_scrollbar()
	sync_scrollbar_lanes(self)
	self:update_content_size()

	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local vertical_x, vertical_y, vertical_width, vertical_height = self:get_lane_rect("right", self.LaneIDs.VSCROLL)
	local horizontal_x, horizontal_y, horizontal_width, horizontal_height = self:get_lane_rect("bottom", self.LaneIDs.HSCROLL)
	local vertical_scrollable = get_visible_scrollbar_range(self.content_height, viewport_height)
	local horizontal_scrollable = get_visible_scrollbar_range(self.content_width, viewport_width)
	local max_scroll_y = math.max(0, self.content_height - viewport_height)
	local max_scroll_x = math.max(0, self.content_width - viewport_width)
	local vertical_percent = (max_scroll_y > 0) and math.max(0, math.min(1, (self.scroll.y or 0) / max_scroll_y)) or 0
	local horizontal_percent = (max_scroll_x > 0) and math.max(0, math.min(1, (self.scroll.x or 0) / max_scroll_x)) or 0

	self.vscrollbar:set_percent(vertical_percent)
	draw_image_scrollbar(self.vscrollbar, vertical_x, vertical_y, vertical_width, vertical_height, vertical_scrollable)

	self.hscrollbar:set_percent(horizontal_percent)
	draw_image_scrollbar(self.hscrollbar, horizontal_x, horizontal_y, horizontal_width, horizontal_height, horizontal_scrollable)

	-- Keep the corner stable too so the two lanes read as one permanent frame
	renderer.draw_rect(
		vertical_x,
		horizontal_y,
		vertical_width,
		horizontal_height,
		style.scrollbar_track or style.background2 or style.background
	)
end

--- Route wheel input into pointer-anchored image zoom
-- Plain wheel zoom keeps image viewing direct without the text-view modifier requirement
-- @param a number first wheel arg
-- @param b number|nil second wheel arg
-- @param c number|nil third wheel arg
-- @param d number|nil fourth wheel arg
-- @return boolean|nil handled
function ImageViewer:on_mouse_wheel(a, b, c, d)
	local wheel_y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a

	if wheel_y and wheel_y ~= 0 then
		local mouse_x, mouse_y = get_configured_zoom_anchor()
		step_view_zoom_locally(self, wheel_y > 0 and 1 or -1, mouse_x, mouse_y)
		self:keep_mouse_interaction_active()
		return true
	end

	return ImageViewer.super.on_mouse_wheel(self, a, b, c, d)
end

--- Defer ordinary mouse presses to scrollbars and shared document handling
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean|nil handled
function ImageViewer:on_mouse_down(button, x, y, clicks)
	if self:try_middle_click_zoom_reset(button) then
		return true
	end

	local handled_horizontal_scrollbar = handle_horizontal_scrollbar_mouse_down(self, button, x, y, clicks)

	if handled_horizontal_scrollbar then
		return true
	end

	return ImageViewer.super.on_mouse_down(self, button, x, y, clicks)
end

--- Pan the image while left or middle mouse is held
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number delta x
-- @param dy number delta y
-- @return boolean|nil handled
function ImageViewer:on_mouse_move(x, y, dx, dy)
	local handled_pan = ImageViewer.super.on_mouse_move(self, x, y, dx, dy)

	if handled_pan then
		return true
	end

	if self.hscrollbar.dragging or horizontal_scrollbar_overlaps_point(self, x, y) then
		local handled_horizontal_scrollbar = handle_horizontal_scrollbar_mouse_move(self, x, y, dx, dy)

		if handled_horizontal_scrollbar then
			return true
		end
	end

	-- Prewarm rendering before a possible image drag begins
	self:keep_mouse_interaction_active()

	return handled_pan
end

--- Stop active mouse panning
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean|nil handled
function ImageViewer:on_mouse_up(button, x, y)
	handle_horizontal_scrollbar_mouse_up(self, button, x, y)

	return ImageViewer.super.on_mouse_up(self, button, x, y)
end

--- Clear transient pan state when the pointer leaves the view
-- @return boolean|nil handled
function ImageViewer:on_mouse_left(...)
	self.hscrollbar:on_mouse_left()

	return ImageViewer.super.on_mouse_left(self, ...)
end

--- Draw the current image or its fallback error state
function ImageViewer:draw()
	self:draw_background(style.background)

	if not self.image then
		self:draw_error()
		self:draw_scrollbar()
		return
	end

	self:update_content_size()
	ensure_svg_bitmap_matches_content(self)

	local draw_x, draw_y = get_image_draw_origin(self)
	local source_x, source_y, source_width, source_height, dest_x, dest_y, dest_width, dest_height = get_visible_image_region(self)

	core.push_clip_rect(self.position.x, self.position.y, self.size.x, self.size.y)

	if source_x then
		-- Queue one tiled GPU image draw and keep overlay text and scrollbars on the software layer above it
		---@diagnostic disable: param-type-mismatch
		transparent.draw_canvas_tiled(
			self.image,
			source_x,
			source_y,
			source_width,
			source_height,
			dest_x,
			dest_y,
			dest_width,
			dest_height,
			self.position.x,
			self.position.y,
			self.size.x,
			self.size.y
		)
		---@diagnostic enable: param-type-mismatch
	else
		transparent.draw(draw_x, draw_y, self.content_width, self.content_height)
	end

	-- The temporary zoom diagnostics are disabled again now that the tiled image path is stable enough to debug without them
	-- draw_debug_overlay(self, source_x, source_y, source_width, source_height, dest_x, dest_y, dest_width, dest_height)

	core.pop_clip_rect()

	self:draw_scrollbar()
end

--- Return true when one doc should use the image viewer surface
-- @param doc table|nil candidate doc
-- @return boolean supported
function ImageViewer.supports_doc(doc)
	return doc and (doc.is_image == true or doc.kind == "image") and doc.filename ~= nil
end

return ImageViewer
