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

--- Replay-based preview for static ANSi art files
-- Keeps handling out of normal text view
-- @module plugins.ansipreview.view

local core = require("core")
local common = require("core.common")
local codepage = require("core.text.codepage")
local fontcache = require("core.style.fontcache")
local keymap = require("core.keymap")
local style = require("core.style")
local motion = require("plugins.ansipreview.motion")
local replay = require("plugins.ansipreview.replay")
local DocView = require("core.doc.view")

local AnsiPreview = DocView:extend()

-- Commands can recognize this view without loading its class in advance
AnsiPreview.is_ansi_preview = true

-- Experimental ANSi preview 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 preview 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 .. "mono.ttf"

-- Choose one complete keyboard movement preset for local testing
local ACTIVE_MOTION_PRESET = "normal"

-- Each movement phase may use any curve listed in 'motion.easing_names'
local MOTION_PRESETS = {
	normal = {
		line = {
			start_speed = 160,
			max_speed = 900,
			acceleration_time = 0.28,
			acceleration_easing = "cubic_out",
			release_time = 0.24,
			release_easing = "smootherstep",
		},
		page = {
			start_speed = 180,
			max_speed = 2000,
			acceleration_time = 0.08,
			acceleration_easing = "cubic_out",
			brake_ratio = 0.58,
			minimum_brake_distance = 140,
			brake_easing = "smoothstep",
		},
		jump = {
			start_speed = 240,
			max_speed = 3200,
			distance_speed_multiplier = 6,
			acceleration_time = 0.06,
			acceleration_easing = "cubic_out",
			brake_ratio = 0.18,
			minimum_brake_distance = 180,
			brake_time = 0.32,
			brake_easing = "cubic_out",
		},
	},
	soft = {
		line = {
			start_speed = 100,
			max_speed = 650,
			acceleration_time = 0.38,
			acceleration_easing = "sine_out",
			release_time = 0.34,
			release_easing = "sine_in_out",
		},
		page = {
			start_speed = 120,
			max_speed = 1400,
			acceleration_time = 0.12,
			acceleration_easing = "sine_out",
			brake_ratio = 0.7,
			minimum_brake_distance = 180,
			brake_easing = "sine_in_out",
		},
		jump = {
			start_speed = 160,
			max_speed = 2400,
			distance_speed_multiplier = 4.5,
			acceleration_time = 0.1,
			acceleration_easing = "sine_out",
			brake_ratio = 0.2,
			minimum_brake_distance = 200,
			brake_time = 0.42,
			brake_easing = "sine_out",
		},
	},
	quick = {
		line = {
			start_speed = 220,
			max_speed = 1200,
			acceleration_time = 0.16,
			acceleration_easing = "quart_out",
			release_time = 0.14,
			release_easing = "quad_out",
		},
		page = {
			start_speed = 260,
			max_speed = 2600,
			acceleration_time = 0.05,
			acceleration_easing = "quart_out",
			brake_ratio = 0.42,
			minimum_brake_distance = 100,
			brake_easing = "quad_in",
		},
		jump = {
			start_speed = 320,
			max_speed = 4200,
			distance_speed_multiplier = 8,
			acceleration_time = 0.04,
			acceleration_easing = "quart_out",
			brake_ratio = 0.12,
			minimum_brake_distance = 120,
			brake_time = 0.22,
			brake_easing = "quart_out",
		},
	},
}

-- This order is also the order used by the live preset commands
local MOTION_PRESET_NAMES = { "normal", "soft", "quick" }

-- 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 = replay.DEFAULT_COLUMNS
local DEFAULT_ROWS = replay.DEFAULT_ROWS
local WHEEL_SCROLL_LERP = 0.06
local HORIZONTAL_SCROLL_LERP = 0.05
local AUTO_SCROLL_SPEED = 150
local AUTO_SCROLL_ACCELERATION = 80
local AUTO_SCROLL_EDGE_WAIT = 1.5

local PALETTE = require("plugins.ansipreview.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",
}

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

--- 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(PALETTE[index] or 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 preview 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 preview instance
-- @return number scroll target
local function get_target_scroll_x(view)
	return view.scroll.to.x or view.scroll.x or 0
end

--- Return the selected set of line, page, and jump settings
-- @return table preset
local function get_motion_preset()
	return MOTION_PRESETS[ACTIVE_MOTION_PRESET] or MOTION_PRESETS.normal
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

--- Scroll horizontally by one delta and tune the follow animation
-- @param view table ANSi preview instance
-- @param delta number signed pixel delta
local function scroll_x_by(view, delta)
	view.scroll.to.x = common.clamp(get_target_scroll_x(view) + delta, 0, view:get_scroll_max_x())
	core.redraw = true
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 files 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 AnsiPreview:rebuild_cache()
	local text = get_doc_replay_text(self.doc)
	local screen = replay.run(text)
	local row_groups = {}

	-- Untouched blank rows stay absent instead of allocating one cache per visual line
	for row, cells in pairs(screen.rows) do
		row_groups[row] = build_row_cache(cells)
	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 preview pane
-- @param doc table document model
function AnsiPreview:new(doc)
	AnsiPreview.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_pan = nil
	self._ansi_motion = motion.new_state()
	self._ansi_motion_last_time = system.get_time()
	self._ansi_held_line_keys = {}
	self._ansi_auto_scroll = nil
	self._ansi_auto_direction = 1
end

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

--- Return the short name shown in the status bar
-- @return string format_name
function AnsiPreview:get_format_status_name()
	return "ANSi art"
end

--- Return the full vertical content size
-- @return number scrollable height
function AnsiPreview: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 AnsiPreview: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 preview content padding
-- ANSi art should align directly to the pane edge by default
-- @return table padding with 'x' and 'y'
function AnsiPreview:get_content_padding()
	return { x = 0, y = 0 }
end

--- Return the centered horizontal drawing origin for ANSi content
-- Wide art keeps its normal horizontal scrolling
-- @return number origin_x
function AnsiPreview:get_content_origin_x()
	local screen = self._ansi_screen or { max_col = DEFAULT_COLUMNS }
	local content_width = screen.max_col * get_cell_advance()
	local centered_offset = math.max(0, math.floor((self.size.x - content_width) / 2))

	return self.position.x + centered_offset - common.round(self.scroll.x or 0)
end

--- Move the automatic scroller by one frame
-- @param now number current time
-- @param elapsed number elapsed seconds
-- @param maximum number largest scroll position
-- @return boolean active true while moving, waiting, or slowing down
function AnsiPreview:update_auto_scroll(now, elapsed, maximum)
	local auto_scroll = self._ansi_auto_scroll
	if not auto_scroll then
		return false
	end

	if auto_scroll.wait_until then
		if now < auto_scroll.wait_until then
			core.redraw = true
			return true
		end

		auto_scroll.direction = -auto_scroll.direction
		self._ansi_auto_direction = auto_scroll.direction
		auto_scroll.wait_until = nil
	end

	local speed_target = auto_scroll.running and AUTO_SCROLL_SPEED or 0
	if auto_scroll.running then
		-- Begin slowing soon enough to arrive gently at the current edge
		local edge = auto_scroll.direction > 0 and maximum or 0
		local distance = math.abs(edge - (self.scroll.y or 0))
		local edge_speed = math.sqrt(2 * AUTO_SCROLL_ACCELERATION * distance)
		speed_target = math.min(speed_target, edge_speed)
	end

	local speed_step = AUTO_SCROLL_ACCELERATION * elapsed
	if auto_scroll.speed < speed_target then
		auto_scroll.speed = math.min(speed_target, auto_scroll.speed + speed_step)
	else
		auto_scroll.speed = math.max(speed_target, auto_scroll.speed - speed_step)
	end

	if not auto_scroll.running and auto_scroll.speed == 0 then
		self._ansi_auto_scroll = nil
		return false
	end

	local edge = auto_scroll.direction > 0 and maximum or 0
	local next_y = common.clamp(
		(self.scroll.y or 0) + auto_scroll.direction * auto_scroll.speed * elapsed,
		0,
		maximum
	)

	if auto_scroll.running and math.abs(edge - next_y) < 0.5 and auto_scroll.speed < 1 then
		next_y = edge
		auto_scroll.speed = 0
		auto_scroll.wait_until = now + AUTO_SCROLL_EDGE_WAIT
	end

	self.scroll.y = next_y
	self.scroll.to.y = next_y
	core.redraw = true

	return true
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 AnsiPreview: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 AnsiPreview: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 AnsiPreview: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 AnsiPreview: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 AnsiPreview:update()
	-- Keep the custom motion loop while still using shared viewer reloads
	self:reload_stale_doc_if_needed()

	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

	local now = system.get_time()
	local elapsed = common.clamp(now - (self._ansi_motion_last_time or now), 0, 0.05)
	local preset = get_motion_preset()
	local maximum = self:get_scroll_max_y()
	local keyboard_motion = self._ansi_motion
	self._ansi_motion_last_time = now

	if keyboard_motion.line_direction ~= 0 and not system.window_has_focus() then
		-- A lost window may not deliver the matching key release
		self._ansi_held_line_keys = {}
		motion.set_line_direction(keyboard_motion, 0, preset.line)
	end

	self:clamp_scroll_position()
	self:move_towards(self.scroll, "x", self.scroll.to.x, HORIZONTAL_SCROLL_LERP)

	local auto_scroll_active = self:update_auto_scroll(now, elapsed, maximum)
	local motion_target = keyboard_motion.target
	if auto_scroll_active then
		-- Automatic scrolling owns the vertical position until it fully stops
	elseif motion_target then
		-- The motion module creates both target fields together
		---@diagnostic disable-next-line: undefined-field
		local target_position = motion_target.position
		---@diagnostic disable-next-line: undefined-field
		local target_kind = motion_target.kind
		local next_y, active = motion.step_target(
			keyboard_motion,
			self.scroll.y or 0,
			maximum,
			elapsed,
			preset[target_kind] or preset.page
		)
		self.scroll.y = next_y
		self.scroll.to.y = active and target_position or next_y
		core.redraw = true
	elseif motion.is_active(keyboard_motion) then
		local next_y = motion.step_line(
			keyboard_motion,
			self.scroll.y or 0,
			maximum,
			elapsed,
			preset.line
		)
		self.scroll.y = next_y
		self.scroll.to.y = next_y
		core.redraw = true
	else
		-- Wheel movement keeps the existing smooth target following
		self:move_towards(self.scroll, "y", self.scroll.to.y, WHEEL_SCROLL_LERP)
	end

	self:clamp_scroll_position()
	self.mouse_cursor = self._ansi_pan and "grabbing" or "arrow"
end

--- Fast-scroll ANSi content without retaining keyboard-scroll momentum
-- @param direction number negative scrolls up and positive scrolls down
-- @return boolean handled true when the view accepted the request
function AnsiPreview:fast_scroll_vertical(direction)
	self:cancel_keyboard_motion(true)
	local handled = AnsiPreview.super.fast_scroll_vertical(self, direction)

	return handled
end

--- Start ANSi panning through the shared mouse-binding command
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean started
function AnsiPreview:start_mouse_pan(button, x, y)
	if self.scrollable and self:scrollbar_overlaps_point(x, y) then
		return false
	end

	self:cancel_keyboard_motion(true)

	-- Preserve hover feedback while the common drag cursor is active
	self._ansi_pan = { x = x, y = y, button = button, previous_cursor = self.mouse_cursor }
	self.mouse_cursor = "grabbing"

	return true
end

--- Defer ordinary mouse presses to 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 AnsiPreview:on_mouse_down(button, x, y, clicks)
	if self:try_middle_click_zoom_reset(button) then
		return true
	end

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

--- Keep wheel movement separate from keyboard movement
-- @param a number first wheel argument
-- @param b number|nil second wheel argument
-- @param c number|nil third wheel argument
-- @param d number|nil fourth wheel argument
-- @return boolean|nil handled
function AnsiPreview: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 and not keymap.modkeys["shift"] then
		-- Remember the user's last vertical wheel direction
		self._ansi_auto_direction = wheel_y > 0 and -1 or 1
	end

	if motion.is_active(self._ansi_motion) or self._ansi_auto_scroll then
		self:cancel_keyboard_motion(true)
	end

	return AnsiPreview.super.on_mouse_wheel(self, a, b, c, d)
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 AnsiPreview: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.mouse_cursor = "grabbing"
		core.redraw = true
		return
	end

	return AnsiPreview.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 AnsiPreview:on_mouse_up(button, x, y)
	if self._ansi_pan and button == self._ansi_pan.button then
		local previous_cursor = self._ansi_pan.previous_cursor
		self._ansi_pan = nil
		self.mouse_cursor = previous_cursor or "arrow"
	end

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

--- Clear transient pan state when the pointer leaves the view
function AnsiPreview:on_mouse_left(...)
	local previous_cursor = self._ansi_pan and self._ansi_pan.previous_cursor
	self._ansi_pan = nil
	self.mouse_cursor = previous_cursor or self.mouse_cursor
	return AnsiPreview.super.on_mouse_left(self, ...)
end

--- Draw the replayed ANSi grid
function AnsiPreview: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:get_content_origin_x() + padding.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

--- Return one ANSi row height
-- @return number line_height
function AnsiPreview:get_line_height()
	return get_line_height()
end

--- Return the distance used by Page Up and Page Down
-- @return number page_height
function AnsiPreview:get_page_scroll_delta()
	return math.max(1, self.size.y - get_line_height() * 2)
end

--- Stop keyboard movement and optionally discard its pending destination
-- @param sync_target boolean use the visible position as the next target
function AnsiPreview:cancel_keyboard_motion(sync_target)
	motion.cancel(self._ansi_motion)
	self._ansi_held_line_keys = {}
	self._ansi_auto_scroll = nil
	self._ansi_motion_last_time = system.get_time()

	if sync_target then
		self.scroll.to.y = self.scroll.y or 0
	end
end

--- Start automatic edge-to-edge scrolling or ease it to a halt
-- @return boolean running true when starting and false when stopping
function AnsiPreview:toggle_auto_scroll()
	local auto_scroll = self._ansi_auto_scroll
	if auto_scroll and auto_scroll.running then
		auto_scroll.running = false
		auto_scroll.wait_until = nil
		core.redraw = true
		return false
	end

	motion.cancel(self._ansi_motion)
	self._ansi_held_line_keys = {}
	local maximum = self:get_scroll_max_y()
	if maximum <= 0 then
		self._ansi_auto_scroll = nil
		return false
	end

	local position = self.scroll.y or 0
	local direction = self._ansi_auto_direction or 1
	self._ansi_auto_scroll = {
		running = true,
		direction = direction,
		speed = auto_scroll and auto_scroll.speed or 0,
		wait_until = nil,
	}
	self.scroll.to.y = position
	self._ansi_motion_last_time = system.get_time()
	core.redraw = true

	return true
end

--- Mark one arrow key as pressed or released
-- @param key string physical key name
-- @param direction number negative for up and positive for down
-- @param pressed boolean true while held
function AnsiPreview:set_line_scroll_key(key, direction, pressed)
	key = tostring(key or "")
	direction = direction < 0 and -1 or 1

	if pressed then
		-- Manual movement takes control from automatic scrolling
		self._ansi_auto_scroll = nil
		self._ansi_auto_direction = direction

		if self._ansi_held_line_keys[key] == direction then
			return
		end

		self._ansi_held_line_keys[key] = direction
	else
		if self._ansi_held_line_keys[key] == nil then
			return
		end

		self._ansi_held_line_keys[key] = nil
	end

	local combined_direction = 0

	for _, held_direction in pairs(self._ansi_held_line_keys) do
		combined_direction = combined_direction + held_direction
	end

	combined_direction = combined_direction < 0 and -1 or (combined_direction > 0 and 1 or 0)
	motion.set_line_direction(self._ansi_motion, combined_direction, get_motion_preset().line)
	self.scroll.to.y = self.scroll.y or 0
	self._ansi_motion_last_time = system.get_time()
	core.redraw = true
end

--- Release one arrow key when its key-up event arrives
-- @param key string physical key name
function AnsiPreview:release_line_scroll_key(key)
	local direction = self._ansi_held_line_keys[tostring(key or "")]

	if direction then
		self:set_line_scroll_key(key, direction, false)
	end
end

--- Start exact movement using the page or jump settings
-- @param kind string 'page' or 'jump'
-- @param target number exact vertical destination
function AnsiPreview:start_target_scroll(kind, target)
	-- Page and edge jumps take control from automatic scrolling
	self._ansi_auto_scroll = nil
	if target ~= (self.scroll.y or 0) then
		self._ansi_auto_direction = target < (self.scroll.y or 0) and -1 or 1
	end
	self._ansi_held_line_keys = {}
	motion.start_target(
		self._ansi_motion,
		kind,
		self.scroll.y or 0,
		target,
		self:get_scroll_max_y()
	)
	self.scroll.to.y = common.clamp(target, 0, self:get_scroll_max_y())
	self._ansi_motion_last_time = system.get_time()
	core.redraw = true
end

--- Move the vertical scroll target by a page
-- @param delta number signed distance
function AnsiPreview:scroll_page_by(delta)
	self._ansi_auto_direction = delta < 0 and -1 or 1
	local target = common.clamp(
		get_target_scroll_y(self) + delta,
		0,
		self:get_scroll_max_y()
	)

	self:start_target_scroll("page", target)
end

--- Move toward the top or bottom of the preview
-- @param ratio number zero for top and one for bottom
function AnsiPreview:scroll_to_ratio(ratio)
	self._ansi_auto_direction = ratio <= 0 and -1 or 1
	local target = self:get_scroll_max_y() * common.clamp(ratio, 0, 1)

	self:start_target_scroll("jump", target)
end

--- Move the horizontal scroll target
-- @param delta number signed distance
function AnsiPreview:scroll_x_by(delta)
	scroll_x_by(self, delta)
end

--- Select the next or previous keyboard movement preset
-- Stops current keyboard movement so one action never mixes two presets
-- @param direction number negative for previous and positive for next
-- @return string preset_name
function AnsiPreview:cycle_motion_preset(direction)
	local current_index = 1

	for index, name in ipairs(MOTION_PRESET_NAMES) do
		if name == ACTIVE_MOTION_PRESET then
			current_index = index
			break
		end
	end

	local step = direction < 0 and -1 or 1
	local next_index = ((current_index - 1 + step) % #MOTION_PRESET_NAMES) + 1
	ACTIVE_MOTION_PRESET = MOTION_PRESET_NAMES[next_index]
	self:cancel_keyboard_motion(true)
	core.redraw = true

	return ACTIVE_MOTION_PRESET
end

--- Return active preview settings for focused tests
-- @return table metrics
function AnsiPreview.debug_get_metrics()
	return {
		mode = ACTIVE_DOUBLING_MODE,
		motion_preset = ACTIVE_MOTION_PRESET,
		motion_easings = motion.easing_names,
		scale = get_render_scale(),
		line_height = get_line_height(),
		cell_advance = get_cell_advance(),
	}
end

return AnsiPreview
