-- lx-bundle:true

--- Replay-based ANSI screen viewer for static terminal art files
-- Keeps ANSI handling isolated from the normal line-oriented text surface
-- @module core.ansi.viewer

local core = require("core")
local common = require("core.common")
local fontcache = require("core.style.fontcache")
local codepage = require("core.text.codepage")
local style = require("core.style")
local DocView = require("core.doc.view")

local AnsiViewer = DocView:extend()

-- Experimental ANSI viewer settings for local testing only
-- Set to "none", "font_2x", "cell_2x", or "blocky_2x"
local ACTIVE_DOUBLING_MODE = "font_2x" --"font_2x"

-- Experimental ANSI font switch for local testing only
-- Set to true to try the hardcoded ANSI art font path below
local USE_TEST_ANSI_FONT = false

-- Hardcoded test font path for local ANSI viewer experiments
local TEST_ANSI_FONT_PATH = FONTS_DIR .. "ansi.ttf"

-- Hardcoded fallback path for real font scaling when the ANSI test font is off
local DEFAULT_CODE_FONT_PATH = FONTS_DIR .. "monospace.ttf"

-- Experimental ANSI keyboard motion settings for local testing only
-- Set to "balanced", "snappy", or "glide"
local ACTIVE_MOTION_PROFILE = "balanced"

-- Experimental ANSI easing settings for local testing only
-- Set to "linear", "smoothstep", or "cubic_out"
local ACTIVE_MOTION_EASING = "smoothstep"

-- Experimental CP437 low-control glyph switch for local testing only
-- When true, DOS art bytes like 0x04 and 0x0F render as their visible symbols
local USE_CP437_LOW_GLYPHS = true

-- Treat DOS 'SUB' as an end-of-file marker during ANSI replay
-- This hides trailing transfer tags or padding stored after a DOS text EOF
local TREAT_SUB_AS_EOF = true

-- Diagnostic row overdraw for identifying remaining horizontal seam sources
-- When enabled, ANSI row backgrounds draw one extra pixel downward
local DEBUG_ROW_OVERDRAW = false

-- ANSI zoom should stay on locally tested clean steps for each font profile
-- The direct ANSI font path is much seam-heavier in normal text mode, so keep it stricter
local ANSI_SAFE_ZOOM_LEVELS = {
	none = {
		test_ansi_font = { 100 },
		default_code_font = { 100, 200, 400 },
		style_code_font = { 100, 200, 400 },
		default = { 100, 200, 400 },
	},
	font_2x = {
		test_ansi_font = { 100 },
		default_code_font = { 100, 200, 300, 400, 600, 800 },
		default = { 100, 200, 400 },
	},
	cell_2x = {
		default = { 100, 200, 400 },
	},
	blocky_2x = {
		default = { 100, 200, 400 },
	},
}
local ACTIVE_ZOOM_MULTIPLIER = ACTIVE_DOUBLING_MODE == "none" and 1 or 2

local DEFAULT_COLUMNS = 80
local DEFAULT_ROWS = 25
local TAB_WIDTH = 8
local LINE_SCROLL_LERP = 0.05
local PAGE_SCROLL_LERP = 0.01
local JUMP_SCROLL_LERP = 0.9
local KEYBOARD_SCROLL_SETTLE_EPSILON = 0.1

local MOTION_PROFILES = {
	balanced = {
		acceleration = 0.5,
		-- Keep the balanced profile responsive while letting short keyboard scrolls settle sooner
		damping = 0.68,
		max_speed = 30,
		distance_scale = 200,
	},
	snappy = {
		acceleration = 0.32,
		damping = 0.68,
		max_speed = 260,
		distance_scale = 180,
	},
	glide = {
		acceleration = 0.15,
		damping = 0.44,
		max_speed = 20,
		distance_scale = 200,
	},
}

local EASING_FUNCTIONS = {
	linear = function(t)
		return t
	end,
	smoother = function(x)
		return x * (3.0 * x * x - 4.5 * x + 2.5)
	end,
	quart_in_out = function(x)
		return x < 0.5 and (2.0 * (x * x)) or (1.0 - 2.0 * (x - 1.0) * (x - 1.0))
	end,
	smoothstep = function(t)
		return t * t * (3 - 2 * t)
	end,
	cubic_out = function(t)
		local inv = 1 - t
		return 1 - inv * inv * inv
	end,
}

local ANSI_PALETTE = require("core.ansi.ansi_palette")

-- Common CP437 block glyphs rendered as explicit geometry in blocky 2x mode
local BLOCK_GLYPHS = {
	[" "] = "space",
	["\226\150\136"] = "full",
	["\226\150\128"] = "upper_half",
	["\226\150\132"] = "lower_half",
	["\226\150\140"] = "left_half",
	["\226\150\144"] = "right_half",
	["\226\150\145"] = "shade_light",
	["\226\150\146"] = "shade_medium",
	["\226\150\147"] = "shade_dark",
}

-- Common Unicode box-drawing glyphs used after CP437 decode
local BOX_GLYPHS = {
	["\226\148\128"] = "h",
	["\226\148\130"] = "v",
	["\226\148\140"] = "dr",
	["\226\148\144"] = "dl",
	["\226\148\148"] = "ur",
	["\226\148\152"] = "ul",
	["\226\148\156"] = "vr",
	["\226\148\164"] = "vl",
	["\226\148\172"] = "dh",
	["\226\148\180"] = "uh",
	["\226\148\188"] = "cross",
	["\226\149\144"] = "double_h",
	["\226\149\145"] = "double_v",
	["\226\149\148"] = "double_dr",
	["\226\149\151"] = "double_dl",
	["\226\149\154"] = "double_ur",
	["\226\149\157"] = "double_ul",
	["\226\149\160"] = "double_vr",
	["\226\149\163"] = "double_vl",
	["\226\149\166"] = "double_dh",
	["\226\149\169"] = "double_uh",
	["\226\149\172"] = "double_cross",
}

-- CP437 stores visible DOS symbols in part of the low control-byte range
-- Real terminal controls are still handled separately in the replay loop below
local LOW_CP437_GLYPHS = {
	[0x01] = "☺",
	[0x02] = "☻",
	[0x03] = "♥",
	[0x04] = "♦",
	[0x05] = "♣",
	[0x06] = "♠",
	[0x07] = "•",
	[0x08] = "◘",
	[0x09] = "○",
	[0x0A] = "◙",
	[0x0B] = "♂",
	[0x0C] = "♀",
	[0x0D] = "♪",
	[0x0E] = "♫",
	[0x0F] = "☼",
	[0x10] = "►",
	[0x11] = "◄",
	[0x12] = "↕",
	[0x13] = "‼",
	[0x14] = "¶",
	[0x15] = "§",
	[0x16] = "▬",
	[0x17] = "↨",
	[0x18] = "↑",
	[0x19] = "↓",
	[0x1A] = "→",
	[0x1B] = "←",
	[0x1C] = "∟",
	[0x1D] = "↔",
	[0x1E] = "▲",
	[0x1F] = "▼",
}

local cached_view_font = nil
local cached_view_font_key = nil

--- Copy one RGBA table so palette colors stay immutable
-- @param color table source color
-- @return table copied color
local function copy_color(color)
	return { color[1], color[2], color[3], color[4] or 255 }
end

--- Build the default ANSI cell attribute set
-- @return table attributes
local function new_attr()
	return {
		fg = 7,
		bg = 0,
		bold = false,
		inverse = false,
		hidden = false,
	}
end

--- Copy one cell attribute table for cell-local storage
-- @param attr table source attributes
-- @return table copied attributes
local function copy_attr(attr)
	return {
		fg = attr.fg,
		bg = attr.bg,
		bold = attr.bold,
		inverse = attr.inverse,
		hidden = attr.hidden,
	}
end

--- Convert a 0..255 terminal palette index into RGBA
-- @param index number palette index
-- @return table color
local function palette_color(index)
	index = math.max(0, math.floor(tonumber(index) or 0))

	if index <= 15 then
		return copy_color(ANSI_PALETTE[index] or ANSI_PALETTE[7])
	end

	if index <= 231 then
		local cube = index - 16
		local red = math.floor(cube / 36)
		local green = math.floor((cube % 36) / 6)
		local blue = cube % 6
		local levels = { 0, 95, 135, 175, 215, 255 }

		return { levels[red + 1], levels[green + 1], levels[blue + 1], 255 }
	end

	local gray = 8 + (index - 232) * 10
	return { gray, gray, gray, 255 }
end

--- Resolve one ANSI cell foreground/background pair
-- @param cell table cell state
-- @return table fg
-- @return table bg
local function resolve_cell_colors(cell)
	local fg_index = cell.attr.fg
	local bg_index = cell.attr.bg

	if type(fg_index) == "number" and fg_index >= 0 and fg_index <= 7 and cell.attr.bold then
		fg_index = fg_index + 8
	end

	local fg = type(fg_index) == "table" and copy_color(fg_index) or palette_color(fg_index or 7)
	local bg = type(bg_index) == "table" and copy_color(bg_index) or palette_color(bg_index or 0)

	if cell.attr.inverse then
		fg, bg = bg, fg
	end

	if cell.attr.hidden then
		fg = copy_color(bg)
	end

	return fg, bg
end

--- Return the active ANSI font profile key for local zoom experiments
-- This keeps per-font zoom steps simple without changing shared zoom behavior
-- @return string font profile key
local function get_font_profile_key()
	if ACTIVE_DOUBLING_MODE == "font_2x" then
		return USE_TEST_ANSI_FONT and "test_ansi_font" or "default_code_font"
	end

	if USE_TEST_ANSI_FONT then
		return "test_ansi_font"
	end

	return "style_code_font"
end

--- Return the current safe ANSI zoom step list
-- Local font-dependent zoom tables avoid seam-heavy scales that look obviously wrong
-- @return table zoom levels in percent
local function get_safe_zoom_levels()
	local mode_levels = ANSI_SAFE_ZOOM_LEVELS[ACTIVE_DOUBLING_MODE] or ANSI_SAFE_ZOOM_LEVELS.none
	local font_profile_key = get_font_profile_key()

	return mode_levels[font_profile_key] or mode_levels.default or { 100 }
end

--- Return the fixed font used by ANSI cells
-- @return table font
local function get_view_font()
	local font_path = nil
	local font_size = 15
	local use_font_2x = ACTIVE_DOUBLING_MODE == "font_2x"

	if use_font_2x then
		font_path = USE_TEST_ANSI_FONT and TEST_ANSI_FONT_PATH or DEFAULT_CODE_FONT_PATH
		font_size = math.max(1, math.floor(font_size * ACTIVE_ZOOM_MULTIPLIER + 0.5))
	elseif USE_TEST_ANSI_FONT then
		font_path = TEST_ANSI_FONT_PATH
	end

	if font_path then
		local cache_key = table.concat({ font_path, tostring(font_size) }, "\31")

		if cached_view_font_key == cache_key and cached_view_font then
			-- Scaled-font reloads can free old renderer handles under this local cache
			local ok = pcall(cached_view_font.get_height, cached_view_font)
			if ok then
				return cached_view_font
			end

			cached_view_font = nil
			cached_view_font_key = nil
		end

		local ok, loaded_font = pcall(fontcache.load, font_path, font_size)
		if ok and loaded_font then
			-- ANSI cells should not inherit text-style line gap when testing fixed-cell seams
			if type(loaded_font.set_line_gap) == "function" then
				loaded_font:set_line_gap(false)
			end

			cached_view_font = loaded_font
			cached_view_font_key = cache_key
			return loaded_font
		end
	end

	return style.code_font
end

--- Return the configured ANSI geometric render scale
-- Unsupported values fall back to 1x
-- @return number scale factor
local function get_render_scale()
	if ACTIVE_DOUBLING_MODE ~= "none" then
		return math.max(1, ACTIVE_ZOOM_MULTIPLIER)
	end

	return 1
end

--- Return whether one doubling mode scales font metrics directly
-- @return boolean uses font scaling
local function use_font_scaled_metrics()
	return ACTIVE_DOUBLING_MODE == "font_2x"
end

--- Return the ANSI row height in pixels
-- @return number line height
local function get_line_height()
	-- ANSI cells should use the font cell height directly instead of editor line spacing
	local base_height = math.max(1, math.floor(get_view_font():get_height() + 0.5))
	if use_font_scaled_metrics() then
		return math.max(1, base_height)
	end

	return math.max(1, base_height) * get_render_scale()
end

--- Return the ANSI cell advance in pixels
-- @return number width
local function get_cell_advance()
	-- Snap the font advance to whole pixels before scaling so cell seams do not accumulate
	local base_width = math.max(1, math.floor(get_view_font():get_width("M") + 0.5))

	if use_font_scaled_metrics() then
		return base_width
	end

	return base_width * get_render_scale()
end

--- Return the vertical text offset centered in the line box
-- @return number offset
local function get_line_text_y_offset()
	local line_height = get_line_height()
	local text_height = get_view_font():get_height()
	return math.floor((line_height - text_height) / 2 + 0.5)
end

--- Return the current vertical scroll target
-- @param view table ANSI viewer instance
-- @return number scroll target
local function get_target_scroll_y(view)
	return view.scroll.to.y or view.scroll.y or 0
end

--- Return the current horizontal scroll target
-- @param view table ANSI viewer instance
-- @return number scroll target
local function get_target_scroll_x(view)
	return view.scroll.to.x or view.scroll.x or 0
end

--- Resolve the active ANSI motion profile for keyboard scrolling
-- @return table motion profile
local function get_motion_profile()
	local name = tostring(ACTIVE_MOTION_PROFILE or "balanced")
	return MOTION_PROFILES[name] or MOTION_PROFILES.balanced
end

--- Resolve the active ANSI easing function for keyboard scrolling
-- @return function easing function
-- @return string easing name
local function get_motion_easing()
	local name = tostring(ACTIVE_MOTION_EASING or "smoothstep")
	return EASING_FUNCTIONS[name] or EASING_FUNCTIONS.smoothstep, name
end

--- Return whether one glyph should use explicit doubled geometry
-- @param glyph string rendered glyph
-- @return boolean uses geometry
local function should_draw_geometry(glyph)
	if ACTIVE_DOUBLING_MODE ~= "blocky_2x" then
		return false
	end

	return BLOCK_GLYPHS[glyph] ~= nil or BOX_GLYPHS[glyph] ~= nil
end

--- Return whether one glyph should use explicit geometry in normal ANSI mode
-- Normal text drawing can drift against snapped cell edges for DOS block and box glyphs
-- @param glyph string rendered glyph
-- @return boolean uses normal-mode geometry
local function should_draw_normal_geometry(glyph)
	return BLOCK_GLYPHS[glyph] ~= nil or BOX_GLYPHS[glyph] ~= nil
end

--- Draw one rectangle segment inside a doubled ANSI cell
-- @param x number cell x
-- @param y number cell y
-- @param width number cell width
-- @param height number cell height
-- @param fg table foreground color
-- @param x1 number normalized x start
-- @param y1 number normalized y start
-- @param x2 number normalized x end
-- @param y2 number normalized y end
local function draw_cell_segment(x, y, width, height, fg, x1, y1, x2, y2)
	local segment_x = x + math.floor(width * x1)
	local segment_y = y + math.floor(height * y1)
	local segment_width = math.max(1, math.ceil(width * (x2 - x1)))
	local segment_height = math.max(1, math.ceil(height * (y2 - y1)))
	renderer.draw_rect(segment_x, segment_y, segment_width, segment_height, fg)
end

--- Draw one doubled ANSI cell with explicit geometry
-- @param glyph string rendered glyph
-- @param x number cell x
-- @param y number cell y
-- @param width number cell width
-- @param height number cell height
-- @param fg table foreground color
local function draw_geometry_glyph(glyph, x, y, width, height, fg)
	local block_kind = BLOCK_GLYPHS[glyph]
	local box_kind = BOX_GLYPHS[glyph]
	local line_thickness = math.max(2, math.floor(math.min(width, height) * 0.18))
	local line_left = math.floor((width - line_thickness) / 2)
	local line_top = math.floor((height - line_thickness) / 2)

	if block_kind == "space" then
		return
	elseif block_kind == "full" then
		renderer.draw_rect(x, y, width, height, fg)
		return
	elseif block_kind == "upper_half" then
		renderer.draw_rect(x, y, width, math.ceil(height / 2), fg)
		return
	elseif block_kind == "lower_half" then
		renderer.draw_rect(x, y + math.floor(height / 2), width, math.ceil(height / 2), fg)
		return
	elseif block_kind == "left_half" then
		renderer.draw_rect(x, y, math.ceil(width / 2), height, fg)
		return
	elseif block_kind == "right_half" then
		renderer.draw_rect(x + math.floor(width / 2), y, math.ceil(width / 2), height, fg)
		return
	elseif block_kind == "shade_light" or block_kind == "shade_medium" or block_kind == "shade_dark" then
		local alpha_scale = ({
			shade_light = 0.35,
			shade_medium = 0.55,
			shade_dark = 0.75,
		})[block_kind]
		local shade_color = { fg[1], fg[2], fg[3], math.max(1, math.floor((fg[4] or 255) * alpha_scale)) }
		renderer.draw_rect(x, y, width, height, shade_color)
		return
	end

	if not box_kind then
		return
	end

	-- Keep box drawing explicit so doubled blocks stay seamless
	if box_kind == "h" or box_kind == "double_h" then
		draw_cell_segment(x, y, width, height, fg, 0, 0.5 - line_thickness / (2 * height), 1, 0.5 + line_thickness / (2 * height))
	elseif box_kind == "v" or box_kind == "double_v" then
		draw_cell_segment(x, y, width, height, fg, 0.5 - line_thickness / (2 * width), 0, 0.5 + line_thickness / (2 * width), 1)
	else
		local draw_left = box_kind == "dl" or box_kind == "ul" or box_kind == "vl" or box_kind == "cross" or box_kind == "dh" or box_kind == "uh" or box_kind == "double_dl" or box_kind == "double_ul" or box_kind == "double_vl" or box_kind == "double_cross" or box_kind == "double_dh" or box_kind == "double_uh"
		local draw_right = box_kind == "dr" or box_kind == "ur" or box_kind == "vr" or box_kind == "cross" or box_kind == "dh" or box_kind == "uh" or box_kind == "double_dr" or box_kind == "double_ur" or box_kind == "double_vr" or box_kind == "double_cross" or box_kind == "double_dh" or box_kind == "double_uh"
		local draw_up = box_kind == "ur" or box_kind == "ul" or box_kind == "uh" or box_kind == "cross" or box_kind == "vr" or box_kind == "vl" or box_kind == "double_ur" or box_kind == "double_ul" or box_kind == "double_uh" or box_kind == "double_cross" or box_kind == "double_vr" or box_kind == "double_vl"
		local draw_down = box_kind == "dr" or box_kind == "dl" or box_kind == "dh" or box_kind == "cross" or box_kind == "vr" or box_kind == "vl" or box_kind == "double_dr" or box_kind == "double_dl" or box_kind == "double_dh" or box_kind == "double_cross" or box_kind == "double_vr" or box_kind == "double_vl"

		renderer.draw_rect(x + line_left, y + line_top, line_thickness, line_thickness, fg)
		if draw_left then
			renderer.draw_rect(x, y + line_top, line_left + line_thickness, line_thickness, fg)
		end
		if draw_right then
			renderer.draw_rect(x + line_left, y + line_top, width - line_left, line_thickness, fg)
		end
		if draw_up then
			renderer.draw_rect(x + line_left, y, line_thickness, line_top + line_thickness, fg)
		end
		if draw_down then
			renderer.draw_rect(x + line_left, y + line_top, line_thickness, height - line_top, fg)
		end
	end
end

--- Store a temporary scroll lerp used by the next update tick
-- @param view table ANSI viewer instance
-- @param rate number animation lerp
local function set_scroll_lerp(view, rate)
	view._ansi_scroll_lerp = math.max(0.01, tonumber(rate) or PAGE_SCROLL_LERP)
end

--- Scroll vertically by one delta and tune the follow animation
-- @param view table ANSI viewer instance
-- @param delta number signed pixel delta
-- @param lerp number animation lerp
local function scroll_by(view, delta, lerp)
	view.scroll.to.y = common.clamp(get_target_scroll_y(view) + delta, 0, view:get_scroll_max_y())
	set_scroll_lerp(view, lerp)
	view._ansi_keyboard_scroll_active = true
	core.redraw = true
end

--- Scroll horizontally by one delta and tune the follow animation
-- @param view table ANSI viewer instance
-- @param delta number signed pixel delta
-- @param lerp number animation lerp
local function scroll_x_by(view, delta, lerp)
	view.scroll.to.x = common.clamp(get_target_scroll_x(view) + delta, 0, view:get_scroll_max_x())
	set_scroll_lerp(view, lerp)
	core.redraw = true
end

--- Scroll to a normalized vertical ratio
-- @param view table ANSI viewer instance
-- @param ratio number 0..1 target ratio
-- @param lerp number animation lerp
local function scroll_to_ratio(view, ratio, lerp)
	local max_scroll = view:get_scroll_max_y()
	view.scroll.to.y = common.clamp(max_scroll * common.clamp(ratio, 0, 1), 0, max_scroll)
	set_scroll_lerp(view, lerp)
	view._ansi_keyboard_scroll_active = true
	core.redraw = true
end

--- Simulate one keyboard scroll motion step
-- Kept separate so tests can exercise ANSI-only motion behavior directly
-- @param position number current scroll position
-- @param target number target scroll position
-- @param velocity number current velocity
-- @param profile table motion profile
-- @param easing function easing function
-- @return number next position
-- @return number next velocity
local function step_keyboard_scroll_motion(position, target, velocity, profile, easing)
	local delta = target - position
	local normalized = common.clamp(math.abs(delta) / math.max(1, profile.distance_scale), 0, 1)
	local shaped = easing(normalized)
	local drive = shaped * profile.max_speed * (delta < 0 and -1 or 1)
	local next_velocity = velocity * profile.damping + drive * profile.acceleration
	local next_position = position + next_velocity

	if math.abs(delta) <= KEYBOARD_SCROLL_SETTLE_EPSILON and math.abs(next_velocity) <= KEYBOARD_SCROLL_SETTLE_EPSILON then
		return target, 0
	end

	return next_position, next_velocity
end

--- Check whether one doc should open through AnsiViewer
-- @param doc table|nil document
-- @return boolean is ansi doc
local function is_ansi_doc(doc)
	local meta = doc and doc.syntax_detect_meta
	return type(meta) == "table" and meta.ansi_viewer == true
end

--- Build a blank ANSI cell
-- @param attr table current attributes
-- @return table cell
local function new_cell(attr)
	return {
		text = " ",
		attr = copy_attr(attr),
	}
end

--- Ensure one replay row exists and is wide enough
-- @param screen table replay state
-- @param row number row index
-- @param col number last needed column
-- @param attr_override table|nil optional blank-cell attributes
local function ensure_row(screen, row, col, attr_override)
	while #screen.rows < row do
		screen.rows[#screen.rows + 1] = {}
	end

	local cells = screen.rows[row]
	local fill_attr = attr_override or new_attr()

	while #cells < col do
		cells[#cells + 1] = new_cell(fill_attr)
	end
end

--- Apply column-sentinel wrapping before processing the next byte
-- @param screen table replay state
local function advance_wrap_if_needed(screen)
	if screen.wrap_mode == false or screen.cursor_col <= screen.columns then
		return
	end

	screen.cursor_row = math.max(1, screen.cursor_row + 1)
	screen.cursor_col = 1
end

--- Write one visible glyph into the replay grid
-- @param screen table replay state
-- @param text string visible glyph
local function put_char(screen, text)
	advance_wrap_if_needed(screen)

	local row = math.max(1, screen.cursor_row)
	local col = math.max(1, screen.cursor_col)

	ensure_row(screen, row, col)

	screen.rows[row][col] = {
		text = text,
		attr = copy_attr(screen.current_attr),
	}
	screen.cursor_row = row
	screen.max_row = math.max(screen.max_row, row)
	screen.max_col = math.max(screen.max_col, col)
	screen.cursor_col = col + 1
end

--- Clear one inclusive row segment using current blank attributes
-- @param screen table replay state
-- @param row number row index
-- @param from_col number first cleared col
-- @param to_col number last cleared col
local function clear_row_range(screen, row, from_col, to_col)
	from_col = math.max(1, from_col)
	to_col = math.max(from_col, to_col)
	ensure_row(screen, row, to_col, screen.current_attr)

	for col = from_col, to_col do
		screen.rows[row][col] = new_cell(screen.current_attr)
	end
end

--- Erase rows in one inclusive range
-- @param screen table replay state
-- @param from_row number first row
-- @param to_row number last row
local function clear_rows(screen, from_row, to_row)
	from_row = math.max(1, from_row)
	to_row = math.max(from_row, to_row)

	for row = from_row, to_row do
		clear_row_range(screen, row, 1, screen.columns)
	end
end

--- Normalize numeric CSI parameters with a fallback default
-- @param value string|number|nil raw value
-- @param default number fallback
-- @return number normalized value
local function csi_number(value, default)
	local number = tonumber(value)
	if not number or number == 0 then
		return default
	end
	return math.floor(number)
end

--- Move cursor to a bounded screen location
-- @param screen table replay state
-- @param row number target row
-- @param col number target col
local function set_cursor(screen, row, col)
	screen.cursor_row = math.max(1, row)
	screen.cursor_col = common.clamp(col, 1, screen.columns + 1)
	screen.max_row = math.max(screen.max_row, screen.cursor_row)
	screen.max_col = math.max(screen.max_col, screen.cursor_col)
end

--- Split one semicolon-delimited CSI parameter list
-- Keeps empty parameters so defaults follow terminal rules
-- @param text string raw parameter text
-- @return table params
local function split_params(text)
	if text == "" then
		return {}
	end

	local params = {}

	for item in (text .. ";"):gmatch("(.-);") do
		params[#params + 1] = item
	end

	return params
end

--- Parse one CSI body while preserving private-mode markers
-- ANSI art sometimes uses private mode sequences like '?7h' for wrap control
-- @param text string raw parameter text
-- @return table params
-- @return boolean is_private
local function parse_csi_params(text)
	local is_private = text:sub(1, 1) == "?"

	if is_private then
		text = text:sub(2)
	end

	return split_params(text), is_private
end

--- Apply one SGR parameter sequence to the current attributes
-- Supports standard, bright, 256-color, and truecolor paths
-- @param attr table current attributes
-- @param params table parsed SGR params
local function apply_sgr(attr, params)
	if #params == 0 then
		params = { "0" }
	end

	local index = 1

	while index <= #params do
		local code = tonumber(params[index]) or 0

		if code == 0 then
			attr.fg = 7
			attr.bg = 0
			attr.bold = false
			attr.inverse = false
			attr.hidden = false
		elseif code == 1 then
			attr.bold = true
		elseif code == 7 then
			attr.inverse = true
		elseif code == 8 then
			attr.hidden = true
		elseif code == 21 or code == 22 then
			attr.bold = false
		elseif code == 27 then
			attr.inverse = false
		elseif code == 28 then
			attr.hidden = false
		elseif code >= 30 and code <= 37 then
			attr.fg = code - 30
		elseif code == 39 then
			attr.fg = 7
		elseif code >= 40 and code <= 47 then
			attr.bg = code - 40
		elseif code == 49 then
			attr.bg = 0
		elseif code >= 90 and code <= 97 then
			attr.fg = code - 90 + 8
		elseif code >= 100 and code <= 107 then
			attr.bg = code - 100 + 8
		elseif (code == 38 or code == 48) and params[index + 1] == "5" then
			local palette_index = tonumber(params[index + 2])
			if palette_index then
				if code == 38 then
					attr.fg = common.clamp(math.floor(palette_index), 0, 255)
				else
					attr.bg = common.clamp(math.floor(palette_index), 0, 255)
				end
			end
			index = index + 2
		elseif (code == 38 or code == 48) and params[index + 1] == "2" then
			local red = tonumber(params[index + 2]) or 0
			local green = tonumber(params[index + 3]) or 0
			local blue = tonumber(params[index + 4]) or 0
			local color = {
				common.clamp(math.floor(red), 0, 255),
				common.clamp(math.floor(green), 0, 255),
				common.clamp(math.floor(blue), 0, 255),
				255,
			}

			if code == 38 then
				attr.fg = color
			else
				attr.bg = color
			end
			index = index + 4
		end

		index = index + 1
	end
end

--- Apply one parsed CSI control sequence
-- @param screen table replay state
-- @param final string final CSI command byte
-- @param params table parameter list
-- @param is_private boolean whether the sequence uses a private marker
local function apply_csi(screen, final, params, is_private)
	if final == "A" then
		-- 'CUU' moves the cursor up without drawing
		set_cursor(screen, screen.cursor_row - csi_number(params[1], 1), screen.cursor_col)
	elseif final == "B" then
		-- 'CUD' moves the cursor down without drawing
		set_cursor(screen, screen.cursor_row + csi_number(params[1], 1), screen.cursor_col)
	elseif final == "C" then
		-- 'CUF' moves right but should stop on the last visible ANSI column
		-- Only printable writes should advance into the wrap sentinel column
		set_cursor(screen, screen.cursor_row, math.min(screen.columns, screen.cursor_col + csi_number(params[1], 1)))
	elseif final == "D" then
		-- 'CUB' moves left without drawing
		set_cursor(screen, screen.cursor_row, math.max(1, screen.cursor_col - csi_number(params[1], 1)))
	elseif final == "E" then
		-- 'CNL' moves down and returns to column 1
		set_cursor(screen, screen.cursor_row + csi_number(params[1], 1), 1)
	elseif final == "F" then
		-- 'CPL' moves up and returns to column 1
		set_cursor(screen, math.max(1, screen.cursor_row - csi_number(params[1], 1)), 1)
	elseif final == "G" then
		-- 'CHA' jumps to an absolute column on the current row
		set_cursor(screen, screen.cursor_row, csi_number(params[1], 1))
	elseif final == "H" or final == "f" then
		-- 'CUP'/'HVP' jumps to an absolute row and column
		set_cursor(screen, csi_number(params[1], 1), csi_number(params[2], 1))
	elseif final == "J" then
		-- 'ED' erases display cells and paints blanks using current attributes
		local mode = tonumber(params[1]) or 0
		if mode == 0 then
			clear_row_range(screen, screen.cursor_row, screen.cursor_col, screen.columns)
			clear_rows(screen, screen.cursor_row + 1, math.max(screen.max_row, DEFAULT_ROWS))
		elseif mode == 1 then
			clear_rows(screen, 1, screen.cursor_row - 1)
			clear_row_range(screen, screen.cursor_row, 1, screen.cursor_col)
		else
			clear_rows(screen, 1, math.max(screen.max_row, DEFAULT_ROWS))
		end
	elseif final == "K" then
		-- 'EL' erases line cells and paints blanks using current attributes
		local mode = tonumber(params[1]) or 0
		if mode == 1 then
			clear_row_range(screen, screen.cursor_row, 1, screen.cursor_col)
		elseif mode == 2 then
			clear_row_range(screen, screen.cursor_row, 1, screen.columns)
		else
			clear_row_range(screen, screen.cursor_row, screen.cursor_col, screen.columns)
		end
	elseif final == "L" then
		-- 'IL' inserts blank rows at the cursor row
		local count = csi_number(params[1], 1)
		local row = math.max(1, screen.cursor_row)
		ensure_row(screen, row, screen.columns)
		for _ = 1, count do
			table.insert(screen.rows, row, {})
		end
	elseif final == "M" then
		-- 'DL' deletes rows starting at the cursor row
		local count = csi_number(params[1], 1)
		local row = math.max(1, screen.cursor_row)
		for _ = 1, count do
			table.remove(screen.rows, row)
		end
	elseif final == "P" then
		-- 'DCH' deletes characters and shifts the rest of the row left
		local row = math.max(1, screen.cursor_row)
		local count = csi_number(params[1], 1)
		ensure_row(screen, row, screen.columns)
		for _ = 1, count do
			table.remove(screen.rows[row], screen.cursor_col)
				screen.rows[row][screen.columns] = new_cell(screen.current_attr)
			end
	elseif final == "X" then
		-- 'ECH' erases characters in place using current attributes
		local count = csi_number(params[1], 1)
		clear_row_range(screen, screen.cursor_row, screen.cursor_col, screen.cursor_col + count - 1)
	elseif final == "@" then
		-- 'ICH' inserts blank characters and shifts the row right
		local row = math.max(1, screen.cursor_row)
		local count = csi_number(params[1], 1)
		ensure_row(screen, row, screen.columns)
		for _ = 1, count do
			table.insert(screen.rows[row], screen.cursor_col, new_cell(screen.current_attr))
				table.remove(screen.rows[row], screen.columns + 1)
			end
	elseif final == "m" then
		-- 'SGR' updates the active foreground, background, and text attributes
		apply_sgr(screen.current_attr, params)
	elseif is_private and final == "h" then
		if tonumber(params[1]) == 7 then
			-- DEC private mode '?7h' enables wrap-at-edge behavior
			screen.wrap_mode = true
		end
	elseif is_private and final == "l" then
		if tonumber(params[1]) == 7 then
			-- DEC private mode '?7l' disables wrap-at-edge behavior
			screen.wrap_mode = false
			screen.cursor_col = common.clamp(screen.cursor_col, 1, screen.columns)
		end
	elseif final == "s" then
		-- Save the current cursor so later 'u' can restore it
		screen.saved_cursor = { row = screen.cursor_row, col = screen.cursor_col }
	elseif final == "u" then
		-- Restore the last cursor saved by 's' or ESC 7
		local saved = screen.saved_cursor or { row = 1, col = 1 }
		set_cursor(screen, saved.row, saved.col)
	end
end

--- Return the next UTF-8 glyph and following byte index
-- Replay expects the decoded doc text to already contain stable display glyphs
-- @param text string source text
-- @param index number byte index
-- @return string glyph
-- @return number next index
local function next_glyph(text, index)
	local byte = text:byte(index)

	if not byte then
		return nil, index
	end

	if byte < 0x80 then
		return string.char(byte), index + 1
	end

	local next_index = utf8.offset(text, 2, index)
	if not next_index then
		next_index = #text + 1
	end

	local glyph = text:sub(index, next_index - 1)
	if glyph ~= "" then
		return glyph, next_index
	end

	return nil, index + 1
end

--- Map one low CP437 byte to a visible DOS glyph when enabled
-- This is only for bytes the replay loop would otherwise treat as dropped controls
-- @param byte number raw byte value
-- @return string|nil glyph
local function get_low_cp437_glyph(byte)
	if not USE_CP437_LOW_GLYPHS then
		return nil
	end

	return LOW_CP437_GLYPHS[byte]
end

--- Replay one ANSI-like text buffer into a virtual screen
-- @param text string decoded document text
-- @return table replayed screen
local function replay_text(text)
	local screen = {
		rows = {},
		columns = DEFAULT_COLUMNS,
		cursor_row = 1,
		cursor_col = 1,
		wrap_mode = true,
		max_row = DEFAULT_ROWS,
		max_col = DEFAULT_COLUMNS,
		current_attr = new_attr(),
		saved_cursor = { row = 1, col = 1 },
	}

	local index = 1
	local length = #text

	while index <= length do
		advance_wrap_if_needed(screen)

		local byte = text:byte(index)

		if byte == 27 then
			local next_byte = text:byte(index + 1)

			if next_byte == 91 then
				local tail = index + 2
				while tail <= length do
					local final = text:byte(tail)
					if final and final >= 0x40 and final <= 0x7E then
						-- CSI sequences mutate cursor or attributes without drawing glyphs
						local body = text:sub(index + 2, tail - 1)
						local params, is_private = parse_csi_params(body)
						apply_csi(screen, string.char(final), params, is_private)
						index = tail + 1
						break
					end
					tail = tail + 1
				end
				if tail > length then
					break
				end
			elseif next_byte == 55 then
				-- 'ESC 7' saves the cursor in classic terminal syntax
				screen.saved_cursor = { row = screen.cursor_row, col = screen.cursor_col }
				index = index + 2
			elseif next_byte == 56 then
				-- 'ESC 8' restores the cursor saved by 'ESC 7'
				set_cursor(screen, screen.saved_cursor.row or 1, screen.saved_cursor.col or 1)
				index = index + 2
			elseif next_byte == 93 then
				-- Skip OSC sequences fully because they affect terminal metadata, not ANSI art
				local tail = index + 2
				while tail <= length do
					local osc_byte = text:byte(tail)
					if osc_byte == 7 then
						break
					end
					if osc_byte == 27 and text:byte(tail + 1) == 92 then
						tail = tail + 1
						break
					end
					tail = tail + 1
				end
				index = math.min(length + 1, tail + 1)
			else
				-- Unknown escape bytes are ignored so replay can continue
				index = index + 1
			end
		elseif byte == 13 then
			-- 'CR' is ignored in the current ANSI replay model
			index = index + 1
		elseif byte == 10 then
			-- 'LF' moves to the next row and resets to column 1
			set_cursor(screen, screen.cursor_row + 1, 1)
			index = index + 1
		elseif byte == 0 then
			-- Some ANSI files use NUL bytes as real blank cells for horizontal layout
			put_char(screen, " ")
			index = index + 1
		elseif byte == 26 then
			-- DOS text files sometimes store trailing data after a 'SUB' EOF marker
			if TREAT_SUB_AS_EOF then
					break
				end

				index = index + 1
			elseif byte == 8 then
				-- 'BS' moves the cursor one cell left without erasing
				screen.cursor_col = math.max(1, screen.cursor_col - 1)
				index = index + 1
			elseif byte == 9 then
				-- 'TAB' advances the cursor by a fixed 8-column tab width
				screen.cursor_col = math.min(screen.columns + 1, screen.cursor_col + TAB_WIDTH)
				index = index + 1
			elseif byte < 32 then
				-- Remaining low bytes can be visible CP437 symbols in DOS art files
				local glyph = get_low_cp437_glyph(byte)

			if glyph then
				put_char(screen, glyph)
			end

			index = index + 1
		elseif byte >= 32 then
			local glyph, next_index = next_glyph(text, index)
			put_char(screen, glyph or "?")
			index = next_index
		else
			index = index + 1
		end
	end

	screen.max_row = math.max(screen.max_row, #screen.rows)

	for row = 1, screen.max_row do
		ensure_row(screen, row, screen.max_col)
	end

	return screen
end

--- Build one display row cache from a replay row
-- Groups contiguous cells so draw work stays bounded
-- @param row_cells table row cells
-- @return table row cache
local function build_row_cache(row_cells)
	local groups = {}
	local group = nil
	local has_normal_geometry = false

	for col = 1, #row_cells do
		local cell = row_cells[col]
		local fg, bg = resolve_cell_colors(cell)
		local key = table.concat({
			fg[1], fg[2], fg[3], fg[4],
			bg[1], bg[2], bg[3], bg[4],
		}, ",")

		if not group or group.key ~= key then
			group = {
				key = key,
				start_col = col,
				text = {},
				fg = fg,
				bg = bg,
			}
			groups[#groups + 1] = group
		end

		group.text[#group.text + 1] = cell.text or " "

		if should_draw_normal_geometry(cell.text or " ") then
			has_normal_geometry = true
		end
	end

	return {
		groups = groups,
		has_normal_geometry = has_normal_geometry,
	}
end

--- Draw one visible ANSI cell in the active experimental mode
-- @param font table view font
-- @param cell table replay cell
-- @param x number cell x
-- @param y number cell y
-- @param width number cell width
-- @param height number cell height
-- @param text_yoffset number text y offset
local function draw_visible_cell(font, cell, x, y, width, height, text_yoffset)
	local fg, bg = resolve_cell_colors(cell)
	local glyph = cell.text or " "
	local text_x = x

	renderer.draw_rect(x, y, width, height, bg)

	if glyph == " " or glyph == "" then
		return
	end

	if should_draw_geometry(glyph) then
		draw_geometry_glyph(glyph, x, y, width, height, fg)
		return
	end

	if ACTIVE_DOUBLING_MODE == "cell_2x" or ACTIVE_DOUBLING_MODE == "blocky_2x" then
		text_x = x + math.floor((width - math.max(1, font:get_width(glyph))) / 2)
	end

	renderer.draw_text_cells(font, glyph, text_x, y + text_yoffset, fg, width)
end

--- Return one snapped cell boundary on the ANSI grid
-- Snapped boundaries keep adjacent terminal cells on the same pixel edges
-- @param base_position number scroll-adjusted axis origin
-- @param step_size number cell width or line height
-- @param index number 1-based cell or row index
-- @return number snapped boundary
local function get_snapped_grid_boundary(base_position, step_size, index)
	return math.floor(base_position + (index - 1) * step_size + 0.5)
end

--- Rebuild replay text with the document's original newline style
-- ANSI files often rely on CRLF semantics, not normalized LF-only lines
-- @param doc table document model
-- @return string replay text
local function get_doc_replay_text(doc)
	if doc and type(doc.filename) == "string" and doc.filename ~= "" then
		local handle = io.open(doc.filename, "rb")

		if handle then
			local raw_data = handle:read("*a") or ""
			handle:close()

			-- Replay ANSI docs from original bytes so CP437 art bypasses text-doc UTF decode damage
			local decoded, _, decode_error = codepage.decode(raw_data, "cp437", "?")
			if decoded and not decode_error then
				-- Many static ANSI art files use bare LF as a full visual newline
				-- Normalize LF-only files to CRLF for replay so rows do not keep the old cursor column
				if raw_data:find("\n", 1, true) and not raw_data:find("\r", 1, true) then
					decoded = decoded:gsub("\n", "\r\n")
				end

				return decoded
			end
		end
	end

	local lines = doc and doc.lines or {}

	if doc and doc.crlf == true then
		local out = {}

		for index = 1, #lines do
			local line = lines[index]

			if type(line) == "string" and line:sub(-1) == "\n" then
				out[index] = line:sub(1, -2) .. "\r\n"
			else
				out[index] = line or ""
			end
		end

		return table.concat(out)
	end

	return table.concat(lines)
end

--- Rebuild the replay cache when the bound doc changes
function AnsiViewer:rebuild_cache()
	local text = get_doc_replay_text(self.doc)
	local screen = replay_text(text)
	local row_groups = {}

	for row = 1, screen.max_row do
		row_groups[row] = build_row_cache(screen.rows[row] or {})
	end

	self._ansi_screen = screen
	self._ansi_row_groups = row_groups
	self._ansi_change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
end

--- Build one ANSI viewer pane
-- @param doc table document model
function AnsiViewer:new(doc)
	AnsiViewer.super.new(self, doc)
	self.mouse_cursor = "arrow"
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.scrollable = true
	self._ansi_screen = nil
	self._ansi_row_groups = {}
	self._ansi_change_id = -1
	self._ansi_scroll_lerp = PAGE_SCROLL_LERP
	self._ansi_pan = nil
	self._ansi_keyboard_scroll_active = false
	self._ansi_scroll_velocity_y = 0
end

--- Return key binding contexts for the ANSI viewer surface
-- ANSI bindings stay isolated from the shared text and binary surfaces
-- @return table contexts ordered from most specific to broadest
function AnsiViewer:get_keymap_contexts()
	return { "ansi_viewer", "doc_view", "global" }
end

--- Return a display name for ANSI tabs
-- @return string tab title
function AnsiViewer:get_name()
	if self.doc and type(self.doc.get_name) == "function" then
		return self.doc:get_name()
	end
	return "ANSI"
end

--- Return the full vertical content size
-- @return number scrollable height
function AnsiViewer:get_scrollable_size()
	local padding = self:get_content_padding()
	local screen = self._ansi_screen or { max_row = DEFAULT_ROWS }
	return padding.y * 2 + screen.max_row * get_line_height()
end

--- Return the horizontal scroll limit for wide ANSI art
-- @return number max scroll x
function AnsiViewer:get_scroll_max_x()
	local padding = self:get_content_padding()
	local screen = self._ansi_screen or { max_col = DEFAULT_COLUMNS }
	local content_width = padding.x * 2 + screen.max_col * get_cell_advance()
	return math.max(0, content_width - self.size.x)
end

--- Return ANSI viewer content padding
-- ANSI art should align directly to the pane edge by default
-- @return table padding with 'x' and 'y'
function AnsiViewer:get_content_padding()
	return { x = 0, y = 0 }
end

--- Return ANSI-specific zoom steps
-- Keep ANSI art on locally tested zoom levels that stay cleaner for the active font profile
-- @return table zoom levels in percent
function AnsiViewer:get_zoom_levels()
	return get_safe_zoom_levels()
end

--- Return ANSI-specific reset zoom level
-- Reset to the neutral tested level for the current font profile
-- @return number zoom level in percent
function AnsiViewer:get_default_zoom_level()
	local safe_zoom_levels = get_safe_zoom_levels()

	for index = 1, #safe_zoom_levels do
		if safe_zoom_levels[index] == 100 then
			return 100
		end
	end

	return safe_zoom_levels[1] or 100
end

--- Return ANSI-specific intrinsic zoom multiplier
-- Doubled render modes enlarge the content without changing the shared baseline scale
-- @return number zoom multiplier
function AnsiViewer:get_zoom_multiplier()
	if ACTIVE_DOUBLING_MODE == "none" then
		return 1
	end

	return ACTIVE_ZOOM_MULTIPLIER
end

--- Apply one ANSI intrinsic zoom multiplier
-- Doubled test modes switch between coarse render scales instead of relying on shared text zoom
-- @param zoom_multiplier number requested visible zoom multiplier
function AnsiViewer:set_zoom_multiplier(zoom_multiplier)
	if ACTIVE_DOUBLING_MODE == "none" then
		ACTIVE_ZOOM_MULTIPLIER = 1
		return
	end

	local requested_multiplier = tonumber(zoom_multiplier) or 1
	local safe_zoom_levels = get_safe_zoom_levels()
	local chosen_multiplier = (safe_zoom_levels[1] or 100) / 100

	for index = 1, #safe_zoom_levels do
		local candidate_multiplier = safe_zoom_levels[index] / 100
		if math.abs(candidate_multiplier - requested_multiplier) < math.abs(chosen_multiplier - requested_multiplier) then
			chosen_multiplier = candidate_multiplier
		end
	end

	ACTIVE_ZOOM_MULTIPLIER = chosen_multiplier
end

--- Refresh replay cache and clamp scroll bounds
function AnsiViewer:update()
	local change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
	if self._ansi_change_id ~= change_id or not self._ansi_screen then
		self:rebuild_cache()
	end

	self:clamp_scroll_position()
	self:move_towards(self.scroll, "x", self.scroll.to.x, self._ansi_scroll_lerp or PAGE_SCROLL_LERP)

	if self._ansi_keyboard_scroll_active then
		local profile = get_motion_profile()
		local easing = get_motion_easing()
		local next_y, next_velocity = step_keyboard_scroll_motion(
			self.scroll.y or 0,
			self.scroll.to.y or 0,
			self._ansi_scroll_velocity_y or 0,
			profile,
			easing
		)
		self.scroll.y = common.clamp(next_y, 0, self:get_scroll_max_y())
		self._ansi_scroll_velocity_y = next_velocity

		if self.scroll.y == (self.scroll.to.y or 0) and self._ansi_scroll_velocity_y == 0 then
			self._ansi_keyboard_scroll_active = false
		else
			core.redraw = true
		end
	else
		self:move_towards(self.scroll, "y", self.scroll.to.y, self._ansi_scroll_lerp or PAGE_SCROLL_LERP)
	end

	self:clamp_scroll_position()
	self._ansi_scroll_lerp = PAGE_SCROLL_LERP
	self.mouse_cursor = "arrow"
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 AnsiViewer:on_mouse_down(button, x, y, clicks)
	if self:try_middle_click_zoom_reset(button) then
		return true
	end

	if button == "middle" then
		self._ansi_pan = { x = x, y = y }
		self.mouse_cursor = "arrow"
		return true
	end

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

--- Pan the ANSI view 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
function AnsiViewer:on_mouse_move(x, y, dx, dy)
	local pan = self._ansi_pan
	if pan then
		local step_x = x - pan.x
		local step_y = y - pan.y
		pan.x = x
		pan.y = y
		self.scroll.to.x = common.clamp(get_target_scroll_x(self) - step_x, 0, self:get_scroll_max_x())
		self.scroll.to.y = common.clamp(get_target_scroll_y(self) - step_y, 0, self:get_scroll_max_y())
		self.scroll.x = self.scroll.to.x
		self.scroll.y = self.scroll.to.y
		self._ansi_scroll_velocity_y = 0
		self._ansi_keyboard_scroll_active = false
		self.mouse_cursor = "arrow"
		core.redraw = true
		return
	end

	return AnsiViewer.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 AnsiViewer:on_mouse_up(button, x, y)
	if button == "middle" then
		self._ansi_pan = nil
	end

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

--- Clear transient pan state when the pointer leaves the view
function AnsiViewer:on_mouse_left(...)
	self._ansi_pan = nil
	self.mouse_cursor = "arrow"
	return AnsiViewer.super.on_mouse_left(self, ...)
end

--- Draw the replayed ANSI grid
function AnsiViewer:draw()
	self:draw_background(style.background)

	local screen = self._ansi_screen
	if not screen then
		self:draw_scrollbar()
		return
	end

	local line_height = get_line_height()
	local cell_advance = get_cell_advance()
	local text_yoffset = get_line_text_y_offset()
	local font = get_view_font()
	local padding = self:get_content_padding()
	local base_x = self.position.x + padding.x - common.round(self.scroll.x)
	local base_y = self.position.y + padding.y - common.round(self.scroll.y)
	local min_row = math.max(1, math.floor((self.scroll.y or 0) / line_height) - 1)
	local max_row = math.min(screen.max_row, math.ceil(((self.scroll.y or 0) + self.size.y) / line_height) + 2)

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

	for row = min_row, max_row do
		local row_y = get_snapped_grid_boundary(base_y, line_height, row)
		local next_row_y = get_snapped_grid_boundary(base_y, line_height, row + 1)
		local row_height = math.max(1, next_row_y - row_y)
		local row_text_yoffset = math.floor((row_height - font:get_height()) / 2 + 0.5)
		local row_cache = self._ansi_row_groups[row] or { groups = {}, has_normal_geometry = false }
		local groups = row_cache.groups or {}
		local row_cells = screen.rows[row] or {}
		local use_per_cell_draw = ACTIVE_DOUBLING_MODE ~= "none" or row_cache.has_normal_geometry == true

		for index = 1, #groups do
			local group = groups[index]
			local group_x = get_snapped_grid_boundary(base_x, cell_advance, group.start_col)
			local next_group_x = get_snapped_grid_boundary(base_x, cell_advance, group.start_col + #group.text)
			local group_width = math.max(1, next_group_x - group_x)
			local background_row_height = row_height + (DEBUG_ROW_OVERDRAW and 1 or 0)

			renderer.draw_rect(group_x, row_y, group_width, background_row_height, group.bg)

			if not use_per_cell_draw then
				renderer.draw_text_cells(font, table.concat(group.text), group_x, row_y + row_text_yoffset, group.fg, cell_advance)
			end
		end

		if use_per_cell_draw then
			for col = 1, #row_cells do
				local cell_x = get_snapped_grid_boundary(base_x, cell_advance, col)
				local next_cell_x = get_snapped_grid_boundary(base_x, cell_advance, col + 1)
				local cell_width = math.max(1, next_cell_x - cell_x)
				draw_visible_cell(font, row_cells[col], cell_x, row_y, cell_width, row_height, row_text_yoffset)
			end
		end
	end

	core.pop_clip_rect()
	self:draw_scrollbar()
end

AnsiViewer.is_active = function(view)
	local active_view = view or core.active_view
	return active_view and active_view.is and active_view:is(AnsiViewer) and is_ansi_doc(active_view.doc)
end

AnsiViewer.get_line_height = get_line_height
AnsiViewer.get_page_scroll_delta = function(view)
	return math.max(1, view.size.y - get_line_height() * 2)
end
AnsiViewer.scroll_by = function(view, delta)
	scroll_by(view, delta, LINE_SCROLL_LERP)
end
AnsiViewer.scroll_page_by = function(view, delta)
	scroll_by(view, delta, PAGE_SCROLL_LERP)
end
AnsiViewer.scroll_to_ratio = function(view, ratio)
	scroll_to_ratio(view, ratio, JUMP_SCROLL_LERP)
end
AnsiViewer.scroll_x_by = function(view, delta)
	scroll_x_by(view, delta, LINE_SCROLL_LERP)
end
AnsiViewer.debug_step_keyboard_scroll_motion = function(position, target, velocity, profile_name, easing_name)
	local profile = MOTION_PROFILES[profile_name] or MOTION_PROFILES.balanced
	local easing = EASING_FUNCTIONS[easing_name] or EASING_FUNCTIONS.smoothstep
	return step_keyboard_scroll_motion(position, target, velocity, profile, easing)
end
AnsiViewer.debug_get_metrics = function()
	return {
		mode = ACTIVE_DOUBLING_MODE,
		motion_profile = ACTIVE_MOTION_PROFILE,
		motion_easing = ACTIVE_MOTION_EASING,
		scale = get_render_scale(),
		line_height = get_line_height(),
		cell_advance = get_cell_advance(),
	}
end

AnsiViewer.debug_replay_doc = function(doc)
	local text = get_doc_replay_text(doc)
	return replay_text(text)
end

return AnsiViewer
