-- lx-bundle:true

--- 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 style = require("core.style")
local DocView = require("core.doc.view")
local image_support = require("core.image.support")
local common = require("core.common")

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 = "arrow"
local CHECKER_TILE_SIZE = 16
local CHECKER_LIGHT = { 122, 122, 122, 255 }
local CHECKER_DARK = { 86, 86, 86, 255 }
local IMAGE_STEP_SCROLL = 64
local IMAGE_INTERACTION_REDRAW_SECONDS = 2
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 get_image_draw_origin

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

--- Keep one short redraw grace period after image interaction
-- This avoids the first drag or scroll step waking from the idle redraw cadence
-- @param self table image viewer
local function mark_recent_interaction(self)
	self.keep_redraw_until = system.get_time() + IMAGE_INTERACTION_REDRAW_SECONDS
	core.redraw = true
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
		mark_recent_interaction(self)
	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
		mark_recent_interaction(self)
	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)

	if image_width == target_width and image_height == target_height then
		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"
		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 fit_zoom = self:get_fit_zoom_multiplier()

	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 = "fit"
		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
	mark_recent_interaction(self)
	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_extension = nil
	self.source_image_width = 0
	self.source_image_height = 0
	self.zoom_mode = "fit"
	self.zoom_multiplier = 1
	self.pan_state = nil
	self.content_width = 0
	self.content_height = 0
	self.keep_redraw_until = 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 tab/display name for this image view
-- @return string tab title
function ImageViewer:get_name()
	if self.doc and type(self.doc.get_name) == "function" then
		return self.doc:get_name()
	end

	return "Image"
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()
	return math.max(1, math.floor(self:get_fit_zoom_multiplier() * 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" then
		return self:get_fit_zoom_multiplier()
	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
function ImageViewer:set_zoom_multiplier(zoom_multiplier)
	set_zoom_with_anchor(self, zoom_multiplier)
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 filename = self.doc and self.doc.filename or nil
	local supported, extension = image_support.is_supported(filename)

	self.image = nil
	self.image_error = nil
	self.image_extension = extension
	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

	self.image, self.image_error = canvas.load_image(filename)

	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()
	self.zoom_multiplier = 1
	self.zoom_mode = "fit"
	self:update_content_size()
	ensure_svg_bitmap_matches_content(self)
	self:invalidate_presentation()
end

--- Return the current fit-to-view zoom multiplier
-- Small images keep their natural size instead of scaling up
-- @return number zoom multiplier
function ImageViewer:get_fit_zoom_multiplier()
	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

	if image_width <= viewport_width and image_height <= viewport_height then
		return 1
	end

	return math.min(viewport_width / image_width, viewport_height / image_height)
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 checkerboard background under transparent image regions
-- @param x number image draw x
-- @param y number image draw y
-- @param width number image width
-- @param height number image height
local function draw_checkerboard(x, y, width, height)
	renderer.draw_checkerboard(x, y, width, height, CHECKER_TILE_SIZE, CHECKER_DARK, CHECKER_LIGHT)
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
	mark_recent_interaction(self)
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
	mark_recent_interaction(self)
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:clamp_scroll_position()
	self.mouse_cursor = self.pan_state and PAN_CURSOR or "arrow"

	if (self.keep_redraw_until or 0) > system.get_time() then
		core.redraw = true
	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 = core.workspace_view and core.workspace_view.mouse or nil
		local mouse_x = mouse and mouse.x or nil
		local mouse_y = mouse and mouse.y or nil
		step_view_zoom_locally(self, wheel_y > 0 and 1 or -1, mouse_x, mouse_y)
		mark_recent_interaction(self)
		return true
	end

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

--- Start middle-click panning or defer to base scrollbar 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)
	local handled_horizontal_scrollbar = handle_horizontal_scrollbar_mouse_down(self, button, x, y, clicks)

	if handled_horizontal_scrollbar then
		return true
	end

	if button == "middle" then
		self.pan_state = { x = x, y = y }
		self.mouse_cursor = PAN_CURSOR
		mark_recent_interaction(self)
		return true
	end

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

--- Pan the image while 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 pan_state = self.pan_state

	if pan_state then
		local step_x = x - pan_state.x
		local step_y = y - pan_state.y
		pan_state.x = x
		pan_state.y = y
		self:scroll_by(-step_x, -step_y)
		self.mouse_cursor = PAN_CURSOR
		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

	return ImageViewer.super.on_mouse_move(self, x, y, dx, dy)
end

--- Stop middle-click 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)

	if button == "middle" then
		self.pan_state = nil
	end

	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.pan_state = nil
	self.hscrollbar:on_mouse_left()
	self.mouse_cursor = "arrow"
	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
		renderer.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,
			CHECKER_TILE_SIZE,
			CHECKER_DARK,
			CHECKER_LIGHT
		)
	else
		draw_checkerboard(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 and doc.filename ~= nil
end

return ImageViewer
