---@diagnostic disable: duplicate-set-field
-- 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.

-- Minimap overlay for text editor surface (right-side scrollbar-style map)

local common = require("core.common")
local core = require("core")
local command = require("core.command")
local config = require("core.config")
local hook = require("core.hook")
local style = require("core.style")
local DocView = require("core.text.editor")

local LANE_ID = DocView.LaneIDs.MINIMAP
local LANE_ORDER = DocView.LaneOrders.MINIMAP
local latest_minimap_line_changes = setmetatable({}, { __mode = "k" })

-- Minimap configuration defaults
config.minimap_enabled = config.minimap_enabled ~= false

-- Dynamic minimap width as pane-width fraction with a fixed minimum
config.minimap_width_fract = config.minimap_width_fract or 0.07
config.minimap_min_width_pixels = config.minimap_min_width_pixels or 24
config.minimap_max_width_fract = config.minimap_max_width_fract or 0.07

-- Show minimap by default even for short files; users can raise this threshold
config.minimap_min_file_lines = config.minimap_min_file_lines or 1
config.minimap_max_sample_chars = config.minimap_max_sample_chars or 240

-- Cap sampled minimap rows so tall windows do not draw one bar per pixel
config.minimap_max_rows = config.minimap_max_rows or 320

-- Cap sliding minimap coverage so wrapped docs do not need full-file projection
config.minimap_window_lines = config.minimap_window_lines or 500

-- Expand the current logical viewport by this multiplier when building the sliding map window
config.minimap_window_rows_per_viewport = config.minimap_window_rows_per_viewport or 6

-- Anchor sliding minimap window from absolute file progress by default
config.minimap_window_anchor = config.minimap_window_anchor or "absolute"
config.minimap_window_bias = config.minimap_window_bias

-- Build only this many missing minimap rows per frame before deferring the rest
config.minimap_build_rows_per_frame = config.minimap_build_rows_per_frame or 24

-- Logical line count per persistent minimap cache block
config.minimap_block_lines = config.minimap_block_lines or 2

-- Number of minimap windows to lazily backfill around the visible window
config.minimap_block_overscan_windows = config.minimap_block_overscan_windows or 1

-- Enable minimap cache instrumentation for manual profiling
config.minimap_debug = config.minimap_debug == true

-- Enable detailed minimap phase timing only for focused performance tests
config.minimap_profile_enabled = config.minimap_profile_enabled == true

-- Tab width used by minimap column math
config.minimap_indent_size = config.minimap_indent_size or (config.indent_size or 4)

-- Minimum viewport thumb height (pixels) for very long files
config.minimap_viewport_min_height = config.minimap_viewport_min_height or 10

-- Toggle overlay marker groups in minimap
config.minimap_bookmarks_enabled = config.minimap_bookmarks_enabled ~= false
config.minimap_change_markers_enabled = config.minimap_change_markers_enabled ~= false

-- Minimap cursor marker inside viewport
config.minimap_cursor_enabled = config.minimap_cursor_enabled ~= false
config.minimap_cursor_width = config.minimap_cursor_width or (style.cursor_width or 1)

-- Optional scale multiplier applied to minimap-line cursor height
config.minimap_cursor_length_scale = config.minimap_cursor_length_scale or 1
config.minimap_cursor_margin = config.minimap_cursor_margin or 3

-- Keep mini-cursor visible when cursor line is outside viewport
config.minimap_cursor_keep_visible = config.minimap_cursor_keep_visible ~= false

-- Toggle minimap whitespace span analysis and overlay drawing
config.minimap_invisible_spans_enabled = config.minimap_invisible_spans_enabled ~= false

-- Minimap style defaults
style.minimap_background = style.minimap_background
style.minimap_line = style.minimap_line

-- Optional explicit minimap viewport color override
style.minimap_viewport = style.minimap_viewport
style.minimap_viewport_content = style.minimap_viewport_content
style.minimap_border = style.minimap_border

-- Optional explicit minimap cursor color override
style.minimap_cursor = style.minimap_cursor

local function same_path(a, b)
	if not (a and b) then
		return false
	end
	local pa = tostring(system.absolute_path(a) or a):gsub("\\", "/"):gsub("/+", "/")
	local pb = tostring(system.absolute_path(b) or b):gsub("\\", "/"):gsub("/+", "/")
	if #pa > 1 then
		pa = pa:gsub("/$", "")
	end
	if #pb > 1 then
		pb = pb:gsub("/$", "")
	end
	if pa:match("^%a:/") then
		pa = pa:lower()
	end
	if pb:match("^%a:/") then
		pb = pb:lower()
	end
	return pa == pb
end

--- Return one stable label for minimap debug output
-- @param dv table active doc view
-- @return string label doc label
local function minimap_debug_doc_label(dv)
	local doc = dv and dv.doc
	if doc and doc.get_name then
		return doc:get_name()
	end
	return "<no doc>"
end

--- Return mutable minimap debug counters for one view
-- @param dv table active doc view
-- @return table stats debug stats
local function get_minimap_debug_stats(dv)
	if config.minimap_debug ~= true then
		return nil
	end

	local stats = dv._minimap_debug_stats
	if not stats then
		stats = {
			cache_hits = 0,
			cache_misses = 0,
			cache_replacements = 0,
			block_invalidations = 0,
			row_builds = 0,
			budget_yields = 0,
			last_miss_reason = "",
		}
		dv._minimap_debug_stats = stats
	end
	return stats
end

--- Return mutable minimap performance counters for one view
-- Detailed timing stays outside normal editor use unless a benchmark enables it
-- @param dv DocView active doc view
-- @return table profile active phase counters
local function get_minimap_profile(dv)
	local profile = dv._minimap_profile

	if not profile then
		profile = {
			draws = 0,
			total = 0,
			setup = 0,
			cache = 0,
			build = 0,
			project = 0,
			preview = 0,
			markers = 0,
			viewport = 0,
			cursor = 0,
			blocks_built = 0,
			blocks_visited = 0,
			blocks_drawn = 0,
			project_calls = 0,
			preview_rects = 0,
			viewport_rects = 0,
			covered_rects = 0,
		}
		dv._minimap_profile = profile
	end

	return profile
end

--- Return the current coalesced document change when it covers the cached revision
-- @param doc table text document instance
-- @param text_revision number current text revision
-- @param cached_revision number cached text revision
-- @return table|nil change affected line range
local function get_current_minimap_line_change(doc, text_revision, cached_revision)
	local function covers_cached_revision(change)
		local first_revision = change and change.first_text_revision
		local last_revision = change and change.text_revision

		return last_revision == text_revision
			and type(first_revision) == "number"
			and cached_revision >= first_revision - 1
			and cached_revision <= last_revision
	end

	local pending = core.line_change_pending and core.line_change_pending[doc] or nil
	if covers_cached_revision(pending) then
		return pending
	end

	local latest = latest_minimap_line_changes[doc]
	if covers_cached_revision(latest) then
		return latest
	end

	return nil
end

--- Log one minimap debug line when instrumentation is enabled
-- @param dv table active doc view
-- @param fmt string message format
local function minimap_debug_log(dv, fmt, ...)
	if config.minimap_debug ~= true then
		return
	end

	core.log_quiet("[minimap-debug] %s: %s", minimap_debug_doc_label(dv), string.format(fmt, ...))
end

--- Add one mismatch reason when a cache key component changed
-- @param reasons table mutable reason list
-- @param name string reason name
-- @param current any current value
-- @param target any target value
local function add_cache_miss_reason(reasons, name, current, target)
	if current ~= target then
		reasons[#reasons + 1] = string.format("%s %s -> %s", name, tostring(current), tostring(target))
	end
end

local function diff_side(dv)
	local ctx = core.diff_context
	if not (ctx and ctx.enabled and dv and dv.doc and dv.doc.filename) then
		return nil
	end
	local fn = dv.doc.filename
	if same_path(fn, ctx.left) then
		return "left"
	end
	if same_path(fn, ctx.right) then
		return "right"
	end
	return nil
end

local function tick_color(kind)
	if kind == "remove" then
		return style.error or style.syntax.error or style.accent
	elseif kind == "add" or kind == "change" then
		return style.success or style.syntax.success or style.accent
	end
end

local function changetracker_tick_color(kind)
	if kind == "deleted" then
		return style.removed or style.error or style.accent
	elseif kind == "added" then
		return style.added or style.success or style.accent
	elseif kind == "changed" then
		return style.changed or style.warning or style.accent
	end
end

-- Returns the effective per-line height in minimap space for current doc length
local function get_minimap_line_height(line_count, h)
	if line_count <= 0 then
		return h
	end
	return math.max(1, math.floor((h / line_count) + 0.5))
end

-- Clamp the sliding minimap window to the available document lines
local function clamp_window_lines(line_count, start_line, window_line_count)
	if line_count <= 0 then
		return 1, 0, 0
	end

	window_line_count = common.clamp(math.floor(window_line_count or 1), 1, line_count)
	start_line = common.clamp(math.floor(start_line or 1), 1, math.max(1, line_count - window_line_count + 1))

	local end_line = math.min(line_count, start_line + window_line_count - 1)

	return start_line, end_line, end_line - start_line + 1
end

-- Return the current visible logical line span so wrapped docs stay cheap to follow
local function get_visible_line_span(dv, line_count)
	local line1
	local line2
	if dv.get_visible_line_range then
		line1, line2 = dv:get_visible_line_range()
	end

	if type(line1) ~= "number" or type(line2) ~= "number" then
		local line_height = math.max(1, dv:get_line_height())
		local viewport_h = select(4, dv:get_content_viewport_rect())
		local visible_lines = math.max(1, math.floor(viewport_h / line_height))
		line1 = math.floor((dv.scroll.y or 0) / line_height) + 1
		line2 = line1 + visible_lines - 1
	end

	line1 = common.clamp(math.floor(line1 or 1), 1, math.max(1, line_count))
	line2 = common.clamp(math.floor(line2 or line1), line1, math.max(line1, line_count))

	return line1, line2
end

-- Return the sliding logical line window while keeping scrollbar thumb math independent
local function get_minimap_window(dv)
	local line_count = #(dv.doc and dv.doc.lines or {})
	if line_count <= 0 then
		return nil
	end

	local visible_line1, visible_line2 = get_visible_line_span(dv, line_count)
	local visible_line_count = math.max(1, visible_line2 - visible_line1 + 1)
	local line_height = math.max(1, dv:get_line_height())
	local viewport_h = select(4, dv:get_content_viewport_rect())
	local viewport_rows = math.max(1, math.floor(viewport_h / line_height))
	local window_multiplier = math.max(1, math.floor(tonumber(config.minimap_window_rows_per_viewport) or 6))
	local window_line_count = math.max(viewport_rows * window_multiplier, math.floor(tonumber(config.minimap_window_lines) or 720))
	window_line_count = common.clamp(window_line_count, visible_line_count, line_count)

	local bias
	if config.minimap_window_anchor == "fixed" then
		bias = common.clamp(tonumber(config.minimap_window_bias) or 0.5, 0, 1)
	else
		local visible_window_ratio = common.clamp(visible_line_count / window_line_count, 0, 1)
		local viewport_center_progress = common.clamp(
			((visible_line1 - 1) + visible_line_count * 0.5) / line_count,
			0,
			1
		)
		if visible_window_ratio >= 1 then
			bias = 0.5
		else
			bias = common.clamp((viewport_center_progress - visible_window_ratio * 0.5) / (1 - visible_window_ratio), 0, 1)
		end
	end
	local anchor_line = math.floor(visible_line1 + (visible_line_count - 1) * bias + 0.5)
	local start_line = anchor_line - math.floor((window_line_count - 1) * bias + 0.5)
	local end_line
	start_line, end_line, window_line_count = clamp_window_lines(line_count, start_line, window_line_count)

	return {
		line_count = line_count,
		start_line = start_line,
		end_line = end_line,
		window_line_count = window_line_count,
		visible_line1 = visible_line1,
		visible_line2 = visible_line2,
		visible_line_count = visible_line_count,
	}
end

-- Return one sliding-window local line index for a document line
local function get_window_line_index(window, line)
	if not window or line < window.start_line or line > window.end_line then
		return nil
	end

	return line - window.start_line + 1
end

-- Returns the exact pixel band for a document line in minimap space
local function line_band_span(line, line_count, y0, h)
	if line_count <= 0 then
		return y0, h
	end
	line = common.clamp(line, 1, line_count)
	local y1 = y0 + math.floor(((line - 1) * h) / line_count)
	local y2 = y0 + math.floor((line * h) / line_count)
	if line == line_count then
		y2 = y0 + h
	end
	if y2 <= y1 then
		y2 = y1 + 1
	end
	return y1, y2 - y1
end

-- Returns y for a marker centered inside the minimap band for a document line
local function line_top_for_marker(line, window, y0, h, marker_h)
	marker_h = math.min(math.max(1, marker_h or 1), h)

	local local_line = get_window_line_index(window, line)
	if not local_line then
		return nil
	end

	local band_y, band_h = line_band_span(local_line, window.window_line_count, y0, h)
	local center_y = band_y + band_h * 0.5
	local y = math.floor(center_y - marker_h * 0.5 + 0.5)
	return common.clamp(y, y0, y0 + h - marker_h)
end

-- Returns y/height for a vertical minimap tick
local function tick_span_for_line(line, window, y0, h, row_tick_h)
	local tick_h = math.max(3, row_tick_h)
	tick_h = math.min(tick_h, h)
	local y = line_top_for_marker(line, window, y0, h, tick_h)
	if not y then
		return nil
	end
	return y, tick_h
end

-- Computes minimap viewport geometry
-- Keep wrapped thumb math in logical-line space so it follows the same
-- sliding minimap projection instead of raw wrapped scroll pixels
local function get_wrapped_viewport_line_progress(dv)
	local line_count = #(dv.doc and dv.doc.lines or {})

	if line_count <= 0 then
		return 0, 0, 0
	end

	local line_height = math.max(1, dv:get_line_height())
	local viewport_h = select(4, dv:get_content_viewport_rect())
	local scrollable = dv:get_scrollable_size()
	local max_scroll = math.max(0, scrollable - viewport_h)
	local scroll_y = common.clamp(dv.scroll.y or 0, 0, max_scroll)
	local top_row = common.clamp(math.floor(scroll_y / line_height) + 1, 1, dv:get_visual_row_count())
	local bottom_edge_y = common.clamp(scroll_y + viewport_h, 0, math.max(viewport_h, scrollable))
	local bottom_row = common.clamp(math.floor(bottom_edge_y / line_height) + 1, top_row, dv:get_visual_row_count())

	local function row_to_line_progress(row, row_y)
		local row_info = dv:get_visual_row_info(row)

		if not row_info then
			return 0
		end

		local line = common.clamp(row_info.line or 1, 1, line_count)
		local line_first_row, line_last_row = dv:get_visual_rows_for_line(line)
		local line_row_count = math.max(1, line_last_row - line_first_row + 1)
		local subrow = common.clamp(row - line_first_row, 0, line_row_count - 1)
		local row_offset = common.clamp(row_y - ((row - 1) * line_height), 0, line_height)
		local row_progress = subrow + (row_offset / line_height)

		return common.clamp((line - 1) + (row_progress / line_row_count), 0, line_count)
	end

	local top_progress = row_to_line_progress(top_row, scroll_y)
	local bottom_progress = row_to_line_progress(bottom_row, bottom_edge_y)
	local viewport_progress = math.max(0, bottom_progress - top_progress)

	return top_progress, viewport_progress, line_count
end

-- Convert one wrapped logical-line progress value back into scroll y
-- Match the wrapped thumb projection so dragging and clicking stay aligned
local function get_wrapped_scroll_y_for_line_progress(dv, line_progress)
	local line_count = #(dv.doc and dv.doc.lines or {})

	if line_count <= 0 then
		return 0
	end

	local line_height = math.max(1, dv:get_line_height())
	local viewport_h = select(4, dv:get_content_viewport_rect())
	local max_scroll = math.max(0, dv:get_scrollable_size() - viewport_h)
	local clamped_progress = common.clamp(tonumber(line_progress) or 0, 0, line_count)
	local whole_lines = math.floor(math.min(clamped_progress, math.max(0, line_count - 1)))
	local line = common.clamp(whole_lines + 1, 1, line_count)
	local line_fraction = common.clamp(clamped_progress - whole_lines, 0, 1)
	local line_first_row, line_last_row = dv:get_visual_rows_for_line(line)
	local line_row_count = math.max(1, line_last_row - line_first_row + 1)
	local row_progress = line_fraction * line_row_count
	local row_index = common.clamp(math.floor(row_progress), 0, line_row_count - 1)
	local row_offset = (row_progress - row_index) * line_height
	local row = line_first_row + row_index
	local target_y = math.max(0, (row - 1) * line_height + row_offset)

	return common.clamp(target_y, 0, max_scroll)
end

local function get_viewport_metrics(dv, r)
	if dv.is_wrap_enabled and dv:is_wrap_enabled() then
		local top_progress, viewport_progress, line_count = get_wrapped_viewport_line_progress(dv)
		local min_vh = math.max(2, math.floor((config.minimap_viewport_min_height or 14) * SCALE + 0.5))
		local vh = math.max(min_vh, math.floor((viewport_progress / math.max(1, line_count)) * r.h + 0.5))

		vh = common.clamp(vh, 1, r.h)

		local max_top_progress = math.max(0, line_count - viewport_progress)
		local top_ratio = (max_top_progress > 0) and (top_progress / max_top_progress) or 0
		local vy = r.y + math.floor(top_ratio * (r.h - vh) + 0.5)

		return {
			vh = common.clamp(vh, 1, r.h),
			vy = common.clamp(vy, r.y, r.y + r.h - vh),
		}
	end

	local scrollable = dv:get_scrollable_size()
	local viewport_h = select(4, dv:get_content_viewport_rect())
	local max_scroll = math.max(0, scrollable - viewport_h)
	local vh
	if scrollable <= viewport_h then
		vh = r.h
	else
		local min_vh = math.max(2, math.floor((config.minimap_viewport_min_height or 14) * SCALE + 0.5))
		vh = math.max(min_vh, math.floor(r.h * r.h / scrollable))
	end
	vh = common.clamp(vh, 1, r.h)
	local scroll_y = common.clamp(dv.scroll.y, 0, max_scroll)
	local vt = (max_scroll > 0) and (scroll_y / max_scroll) or 0
	local vy = r.y + math.floor(vt * (r.h - vh))
	vy = common.clamp(vy, r.y, r.y + r.h - vh)

	return {
		vh = vh,
		vy = vy,
	}
end

-- Draws ticks from a map of 'line -> kind' using a color resolver
local function draw_tick_map(mark_map, window, r, tick_x, tick_w, row_tick_h, color_fn)
	for line, kind in pairs(mark_map or {}) do
		local color = color_fn(kind)
		if color then
			local yy, tick_h = tick_span_for_line(line, window, r.y, r.h, row_tick_h)
			if yy then
				renderer.draw_rect(tick_x, yy, tick_w, tick_h, color)
			end
		end
	end
end

-- Draws ticks from a set-like map of 'line -> true' with one color
local function draw_tick_set(mark_set, window, r, tick_x, tick_w, row_tick_h, color)
	if not color then
		return
	end
	for line in pairs(mark_set or {}) do
		local yy, tick_h = tick_span_for_line(line, window, r.y, r.h, row_tick_h)
		if yy then
			renderer.draw_rect(tick_x, yy, tick_w, tick_h, color)
		end
	end
end

-- Draws ticks from compact span list { {line1, line2}, ... } with one color
local function draw_tick_spans(spans, window, r, tick_x, tick_w, color)
	if not color then
		return
	end
	for i = 1, #(spans or {}) do
		local span = spans[i]
		local raw_l1 = span[1] or 1
		local raw_l2 = span[2] or raw_l1
		if raw_l2 >= window.start_line and raw_l1 <= window.end_line then
			local l1 = common.clamp(raw_l1, window.start_line, window.end_line)
			local l2 = common.clamp(raw_l2, l1, window.end_line)
			local local_l1 = get_window_line_index(window, l1)
			local local_l2 = get_window_line_index(window, l2)
			local y1 = line_band_span(local_l1, window.window_line_count, r.y, r.h)
			local y2, h2 = line_band_span(local_l2, window.window_line_count, r.y, r.h)
			local yb = y2 + h2
			local h = math.max(1, yb - y1)
			renderer.draw_rect(tick_x, y1, tick_w, h, color)
		end
	end
end

-- Compute minimap rect for a doc view
local function get_minimap_width(dv)
	if not config.minimap_enabled then
		return 0
	end
	if not dv.doc or not dv.doc.lines then
		return 0
	end
	-- Binary-rendered placeholder docs use a custom viewport; minimap interactions
	-- should be disabled to avoid conflicting scroll mappings
	if dv.doc.is_binary_placeholder then
		return 0
	end
	-- In diff mode, show only one minimap on the right pane
	if diff_side(dv) == "left" then
		return 0
	end
	-- Drawing needs at least two lines, so one-line files must not reserve a lane
	local minimum_lines = math.max(2, tonumber(config.minimap_min_file_lines) or 30)
	if #dv.doc.lines < minimum_lines then
		return 0
	end
	local pane_w = math.max(1, dv.size.x or 1)
	local width_fract = math.max(0, tonumber(config.minimap_width_fract) or 0.07)
	local max_width_fract = math.max(0.001, tonumber(config.minimap_max_width_fract) or 0.07)
	local effective_width_fract = math.min(width_fract, max_width_fract)
	local w = math.floor(pane_w * effective_width_fract + 0.5)
	local min_width = math.max(0, math.floor((tonumber(config.minimap_min_width_pixels) or 24) * SCALE + 0.5))
	w = math.max(w, min_width)
	w = common.clamp(w, 0, math.max(0, pane_w - 1))
	return w
end

local function sync_minimap_pane(dv)
	local w = get_minimap_width(dv)
	dv:set_lane_size("right", LANE_ID, w, LANE_ORDER)
	return w
end

local function get_minimap_rect(dv)
	if sync_minimap_pane(dv) <= 0 then
		return nil
	end
	local x, y, w, h = dv:get_lane_rect("right", LANE_ID)
	if not x then
		return nil
	end
	return { x = x, y = y, w = w, h = h }
end

-- Return the minimap thumb rect using the same absolute scroll math as the shared scrollbar
local function get_minimap_thumb_rect(dv, r)
	local vp = get_viewport_metrics(dv, r)

	return r.x, vp.vy, r.w, vp.vh
end

--- Return the zoomed preview viewport rect in sliding-window line space
-- @param window table current minimap window
-- @param r table minimap preview rect
-- @return number x highlight x
-- @return number y highlight y
-- @return number w highlight width
-- @return number h highlight height
local function get_window_viewport_rect(window, r)
	local local_line1 = get_window_line_index(window, window.visible_line1)
	local local_line2 = get_window_line_index(window, window.visible_line2)
	if not (local_line1 and local_line2) then
		return r.x, r.y, r.w, r.h
	end

	local y1 = line_band_span(local_line1, window.window_line_count, r.y, r.h)
	local y2, h2 = line_band_span(local_line2, window.window_line_count, r.y, r.h)
	local h = math.max(1, y2 + h2 - y1)
	local min_h = math.max(2, math.floor((config.minimap_viewport_min_height or 10) * SCALE + 0.5))
	if h < min_h then
		local center_y = y1 + h * 0.5
		h = math.min(min_h, r.h)
		y1 = math.floor(center_y - h * 0.5 + 0.5)
	end

	return r.x, common.clamp(y1, r.y, r.y + r.h - h), r.w, h
end

-- Convert one minimap pointer y-position into a vertical scroll target
local function get_minimap_scroll_target(dv, y, drag_offset)
	local r = get_minimap_rect(dv)
	if not r then
		return nil
	end

	local window = get_minimap_window(dv)
	if window and config.minimap_window_anchor ~= "fixed" then
		local viewport_h = select(4, dv:get_content_viewport_rect())
		local max_scroll = math.max(0, dv:get_scrollable_size() - viewport_h)
		local function projected_center_ratio(center_line)
			local visible_line1 = center_line - (window.visible_line_count - 1) * 0.5
			local visible_window_ratio = common.clamp(window.visible_line_count / window.window_line_count, 0, 1)
			local viewport_center_progress = common.clamp(center_line / window.line_count, 0, 1)
			local bias
			if visible_window_ratio >= 1 then
				bias = 0.5
			else
				bias = common.clamp((viewport_center_progress - visible_window_ratio * 0.5) / (1 - visible_window_ratio), 0, 1)
			end
			local anchor_line = math.floor(visible_line1 + (window.visible_line_count - 1) * bias + 0.5)
			local start_line = anchor_line - math.floor((window.window_line_count - 1) * bias + 0.5)
			start_line = clamp_window_lines(window.line_count, start_line, window.window_line_count)

			return common.clamp((center_line - start_line + 0.5) / window.window_line_count, 0, 1)
		end

		local first_center_line = 1 + (window.visible_line_count - 1) * 0.5
		local last_center_line = window.line_count - (window.visible_line_count - 1) * 0.5
		local min_center = projected_center_ratio(first_center_line)
		local max_center = projected_center_ratio(last_center_line)
		local desired_center = common.clamp((y - r.y) / math.max(1, r.h), min_center, max_center)
		local edge_epsilon = 0.5 / math.max(1, r.h)
		if desired_center <= min_center + edge_epsilon then
			return 0
		elseif desired_center >= max_center - edge_epsilon then
			return max_scroll
		end
		local lo = first_center_line
		local hi = last_center_line
		for _ = 1, 18 do
			local mid = (lo + hi) * 0.5
			if projected_center_ratio(mid) < desired_center then
				lo = mid
			else
				hi = mid
			end
		end
		local target_center_line = (lo + hi) * 0.5
		local target_top_line = target_center_line - (window.visible_line_count - 1) * 0.5

		if dv.is_wrap_enabled and dv:is_wrap_enabled() then
			local _, viewport_progress, line_count = get_wrapped_viewport_line_progress(dv)
			local max_top_progress = math.max(0, line_count - viewport_progress)
			local target_top_progress = common.clamp(target_center_line - viewport_progress * 0.5, 0, max_top_progress)

			return get_wrapped_scroll_y_for_line_progress(dv, target_top_progress)
		end

		local line_height = math.max(1, dv:get_line_height())

		return common.clamp((target_top_line - 1) * line_height, 0, max_scroll)
	end

	local _, thumb_y, _, thumb_h = get_minimap_thumb_rect(dv, r)
	local viewport_h = select(4, dv:get_content_viewport_rect())
	local max_scroll = math.max(0, dv:get_scrollable_size() - viewport_h)
	if max_scroll <= 0 then
		return 0
	end

	local track_h = math.max(1, r.h - thumb_h)
	local drag_y = common.clamp(drag_offset or (thumb_h * 0.5), 0, thumb_h)
	local thumb_top = common.clamp(y - drag_y, r.y, r.y + track_h)
	local t = (track_h > 0) and ((thumb_top - r.y) / track_h) or 0

	if dv.is_wrap_enabled and dv:is_wrap_enabled() then
		local _, viewport_progress, line_count = get_wrapped_viewport_line_progress(dv)
		local max_top_progress = math.max(0, line_count - viewport_progress)

		return get_wrapped_scroll_y_for_line_progress(dv, t * max_top_progress)
	end

	return common.clamp(t * max_scroll, 0, max_scroll)
end

--- Returns line byte length without trailing LF
-- Keep minimap width math on the logical line content only
-- @param text string|nil line text
-- @return number logical byte length
local function logical_line_bytes(text)
	text = text or ""
	local len = #text
	if text:sub(-1) == "\n" then
		return len - 1
	end
	return len
end

--- Measures visual columns from UTF-8 codepoints up to a byte limit
-- Tabs advance to the next fixed stop so minimap math matches editor columns
-- Partial UTF-8 byte positions stay on the previous column until the codepoint completes
-- @param text string|nil line text
-- @param stop_byte number|nil exclusive byte limit
-- @param tabw number tab size in columns
-- @param max_visual_cols number|nil stop once this many visual columns are reached
-- @return number visual columns
-- @return number logical byte length
local function visual_cols_for_text(text, stop_byte, tabw, max_visual_cols)
	text = text or ""
	tabw = math.max(1, tabw or 1)
	max_visual_cols = tonumber(max_visual_cols)

	local logical_len = logical_line_bytes(text)
	local stop = logical_len
	if stop_byte ~= nil then
		stop = common.clamp(stop_byte, 0, logical_len)
	end

	local visual = 0
	local i = 1

	while i <= logical_len and i <= stop do
		local c, _, end_i = common.next_utf8_char(text, i)
		if stop < end_i then
			break
		end
		if c == "\t" then
			visual = visual + (tabw - (visual % tabw))
		else
			visual = visual + 1
		end
		if max_visual_cols and visual >= max_visual_cols then
			visual = math.min(visual, max_visual_cols)
			break
		end
		i = end_i + 1
	end

	return visual, logical_len
end

--- Computes the shared minimap layout for one line preview
-- Keep line bars and mini-cursors on the same UTF-8 aware horizontal scale
-- @param visual_len number visual column width
-- @param r table minimap rect
-- @param pad number inner horizontal padding
-- @param max_chars number clamp width after this many visual columns
-- @return table layout metrics
local function get_line_preview_layout(visual_len, r, pad, max_chars)
	local x0 = r.x + pad
	local avail_w = math.max(1, r.w - (x0 - r.x) - pad)
	local vis_len = math.min(visual_len, max_chars)
	local preview_w = 0
	if vis_len > 0 then
		preview_w = math.max(1, math.floor(vis_len / max_chars * avail_w))
	end

	return {
		x0 = x0,
		avail_w = avail_w,
		visual_len = visual_len,
		preview_w = preview_w,
	}
end

--- Collects visible and optional invisible preview spans in visual columns
-- Visible span geometry must stay independent from whitespace overlay thresholds
-- Tabs add their full visual advance so leading and interior gaps keep their true x position
-- @param text string|nil line text
-- @param max_cols number maximum visual columns to map
-- @param tabw number tab size in columns
-- @param min_run_cols number minimum whitespace run width to keep
-- @param invisible_spans_enabled boolean whether to collect invisible spans
-- @return table visible spans as { {start_col, end_col}, ... }
-- @return table invisible spans as { {start_col, end_col}, ... }
-- @return number visual columns consumed up to max_cols
local function collect_preview_spans(text, max_cols, tabw, min_run_cols, invisible_spans_enabled)
	text = text or ""
	tabw = math.max(1, tabw or 1)
	max_cols = math.max(0, max_cols or 0)
	min_run_cols = math.max(1, min_run_cols or 1)

	local logical_len = logical_line_bytes(text)
	local visible_spans = {}
	local invisible_spans = invisible_spans_enabled and {} or nil
	local visual = 0
	local run_kind
	local run_start_col
	local run_cols = 0
	local i = 1

	-- Byte-walk the line once so minimap analysis avoids repeated UTF-8 substring allocation
	local function next_codepoint_end(start_i)
		local first_byte = text:byte(start_i)
		if first_byte and first_byte < 128 then
			return start_i
		end

		local j = start_i + 1
		while j <= logical_len and common.is_utf8_cont(text:byte(j)) do
			j = j + 1
		end
		return j - 1
	end

	local function commit_run()
		if run_start_col == nil or run_cols <= 0 then
			run_kind = nil
			run_start_col = nil
			run_cols = 0
			return
		end

		if run_kind == "visible" then
			visible_spans[#visible_spans + 1] = { run_start_col, run_start_col + run_cols }
		elseif invisible_spans and run_cols >= min_run_cols then
			invisible_spans[#invisible_spans + 1] = { run_start_col, run_start_col + run_cols }
		end

		run_kind = nil
		run_start_col = nil
		run_cols = 0
	end

	while i <= logical_len and visual < max_cols do
		local byte = text:byte(i)
		local end_i = next_codepoint_end(i)
		local advance = 1
		local kind = "visible"
		if byte == 9 then
			advance = tabw - (visual % tabw)
			kind = "invisible"
		elseif byte == 32 then
			kind = "invisible"
		end

		local mapped_advance = math.min(advance, max_cols - visual)
		if mapped_advance > 0 then
			if run_kind ~= kind then
				commit_run()
				run_kind = kind
				run_start_col = visual
			end
			run_cols = run_cols + mapped_advance
		end

		visual = visual + advance
		i = end_i + 1
	end

	commit_run()
	return visible_spans, invisible_spans or {}, math.min(visual, max_cols)
end

--- Returns cached minimap line analysis for the current doc state
-- Cache expensive UTF-8 span scans per line so unrelated edits do not force a full minimap rescan
-- @param dv DocView active doc view
-- @param line number one-based line index
-- @param max_cols number maximum visual columns to analyze
-- @param tabw number tab size in columns
-- @param min_run_cols number minimum whitespace run width to keep
-- @param invisible_spans_enabled boolean whether to collect invisible spans
-- @return table analysis
local function get_cached_line_preview_analysis(dv, line, max_cols, tabw, min_run_cols, invisible_spans_enabled)
	local doc = dv.doc
	local cache = dv._minimap_preview_cache
	if
		not cache
		or cache.doc ~= doc
		or cache.max_cols ~= max_cols
		or cache.tabw ~= tabw
		or cache.min_run_cols ~= min_run_cols
		or cache.invisible_spans_enabled ~= invisible_spans_enabled
	then
		cache = {
			doc = doc,
			max_cols = max_cols,
			tabw = tabw,
			min_run_cols = min_run_cols,
			invisible_spans_enabled = invisible_spans_enabled,
			lines = {},
		}
		dv._minimap_preview_cache = cache
	end

	local text = (doc and doc.lines and doc.lines[line]) or ""
	local analysis = cache.lines[line]
	if analysis and analysis.text == text then
		return analysis
	end

	local visible_spans, invisible_spans, visual_len =
		collect_preview_spans(text, max_cols, tabw, min_run_cols, invisible_spans_enabled)

	analysis = {
		text = text,
		visual_len = visual_len,
		visible_spans = visible_spans,
		invisible_spans = invisible_spans,
	}
	cache.lines[line] = analysis
	return analysis
end

--- Mark one horizontal occupancy-bin span from visual-column space
-- Block aggregation keeps minimap rows stable instead of re-sampling one source line per pixel move
-- @param bins table<boolean> mutable occupancy bins
-- @param bin_count number total bin count
-- @param span_start number start visual column
-- @param span_end number end visual column
-- @param max_cols number maximum mapped visual columns
local function mark_preview_bins(bins, bin_count, span_start, span_end, max_cols)
	if bin_count <= 0 or max_cols <= 0 then
		return
	end

	span_start = common.clamp(span_start or 0, 0, max_cols)
	span_end = common.clamp(span_end or span_start, span_start, max_cols)
	if span_end <= span_start then
		return
	end

	local bin1 = common.clamp(math.floor((span_start / max_cols) * bin_count) + 1, 1, bin_count)
	local bin2 = common.clamp(math.ceil((span_end / max_cols) * bin_count), bin1, bin_count)

	for index = bin1, bin2 do
		bins[index] = true
	end
end

--- Merge one source line directly into aggregate minimap occupancy bins
-- Block previews do not need temporary per-line span tables
-- @param text string|nil line text
-- @param max_cols number maximum visual columns to map
-- @param tabw number tab size in columns
-- @param min_run_cols number minimum whitespace run width to keep
-- @param invisible_spans_enabled boolean whether to collect invisible runs
-- @param visible_bins table<boolean> aggregate visible occupancy bins
-- @param invisible_bins table<boolean>|nil aggregate whitespace occupancy bins
-- @param bin_count number total horizontal bins
local function mark_line_preview_bins(
	text,
	max_cols,
	tabw,
	min_run_cols,
	invisible_spans_enabled,
	visible_bins,
	invisible_bins,
	bin_count
)
	text = text or ""
	tabw = math.max(1, tabw or 1)
	max_cols = math.max(0, max_cols or 0)
	min_run_cols = math.max(1, min_run_cols or 1)

	local logical_len = logical_line_bytes(text)
	local visual = 0
	local run_kind
	local run_start_col
	local run_cols = 0
	local i = 1

	while i <= logical_len and visual < max_cols do
		local byte = text:byte(i)
		local end_i = i
		if byte >= 128 then
			-- ASCII is overwhelmingly common; only inspect continuation bytes for UTF-8 text
			local j = i + 1
			while j <= logical_len and common.is_utf8_cont(text:byte(j)) do
				j = j + 1
			end
			end_i = j - 1
		end

		local advance = 1
		local kind = "visible"
		if byte == 9 then
			advance = tabw - (visual % tabw)
			kind = "invisible"
		elseif byte == 32 then
			kind = "invisible"
		end

		local mapped_advance = math.min(advance, max_cols - visual)
		if mapped_advance > 0 and run_kind ~= kind then
			if run_start_col ~= nil and run_cols > 0 then
				if run_kind == "visible" then
					mark_preview_bins(
						visible_bins,
						bin_count,
						run_start_col,
						run_start_col + run_cols,
						max_cols
					)
				elseif invisible_spans_enabled and invisible_bins and run_cols >= min_run_cols then
					mark_preview_bins(
						invisible_bins,
						bin_count,
						run_start_col,
						run_start_col + run_cols,
						max_cols
					)
				end
			end
			run_kind = kind
			run_start_col = visual
			run_cols = 0
		end
		run_cols = run_cols + mapped_advance
		visual = visual + advance
		i = end_i + 1
	end

	if run_start_col ~= nil and run_cols > 0 then
		if run_kind == "visible" then
			mark_preview_bins(visible_bins, bin_count, run_start_col, run_start_col + run_cols, max_cols)
		elseif invisible_spans_enabled and invisible_bins and run_cols >= min_run_cols then
			mark_preview_bins(invisible_bins, bin_count, run_start_col, run_start_col + run_cols, max_cols)
		end
	end
end

--- Convert occupied preview bins into compact start/end pairs
-- Cached runs avoid rescanning every horizontal bin on every minimap frame
-- @param bins table<boolean> horizontal occupancy bins
-- @param bin_count number total horizontal bin count
-- @return table runs flat start/end-exclusive pairs
local function collect_preview_bin_runs(bins, bin_count)
	local runs = {}
	local run_start

	for index = 1, bin_count + 1 do
		local filled = index <= bin_count and bins[index] == true
		if filled and not run_start then
			run_start = index
		elseif not filled and run_start then
			runs[#runs + 1] = run_start
			runs[#runs + 1] = index
			run_start = nil
		end
	end

	return runs
end

--- Return stable horizontal occupancy bins for one minimap line block
-- Aggregate all lines in the source block so long/short lines do not shimmer when the window slides
-- @param dv DocView active doc view
-- @param line1 number first logical line in the block
-- @param line2 number last logical line in the block
-- @param max_cols number maximum visual columns to analyze
-- @param tabw number tab size in columns
-- @param min_run_cols number minimum whitespace run width to keep
-- @param invisible_spans_enabled boolean whether to collect invisible spans
-- @param bin_count number total horizontal bins for the minimap row
-- @return table aggregate preview data
local function get_block_preview_analysis(
	dv,
	line1,
	line2,
	max_cols,
	tabw,
	min_run_cols,
	invisible_spans_enabled,
	bin_count
)
	local visible_bins = {}
	local invisible_bins = invisible_spans_enabled and {} or nil
	local has_visible = false
	local has_invisible = false

	for line = line1, line2 do
		local text = (dv.doc and dv.doc.lines and dv.doc.lines[line]) or ""
		mark_line_preview_bins(
			text,
			max_cols,
			tabw,
			min_run_cols,
			invisible_spans_enabled,
			visible_bins,
			invisible_bins,
			bin_count
		)
	end
	has_visible = next(visible_bins) ~= nil
	has_invisible = invisible_bins and next(invisible_bins) ~= nil or false

	return {
		visible_runs = collect_preview_bin_runs(visible_bins, bin_count),
		invisible_runs = collect_preview_bin_runs(invisible_bins or {}, bin_count),
		has_visible = has_visible,
		has_invisible = has_invisible,
	}
end

--- Return stable cache parameters for persistent minimap blocks
-- @param line_count number total logical lines in the document
-- @param window_line_count number current minimap window line count
-- @return number block_lines logical lines per cache block
-- @return number block_count total cache blocks
local function get_minimap_block_grid(line_count, window_line_count)
	local max_rows = math.max(1, math.floor(tonumber(config.minimap_max_rows) or 320))
	local configured_block_lines = math.max(1, math.floor(tonumber(config.minimap_block_lines) or 2))
	local capped_block_lines = math.max(1, math.ceil(math.max(1, window_line_count or 1) / max_rows))
	local block_lines = math.max(configured_block_lines, capped_block_lines)
	local block_count = math.max(1, math.ceil(math.max(1, line_count) / block_lines))

	return block_lines, block_count
end

--- Return the logical line span covered by one persistent minimap block
-- @param block_index number one-based block index
-- @param block_lines number logical lines per block
-- @param line_count number total logical lines
-- @return number line1 first logical line
-- @return number line2 last logical line
local function get_minimap_block_line_span(block_index, block_lines, line_count)
	local line1 = (block_index - 1) * block_lines + 1
	local line2 = math.min(line_count, line1 + block_lines - 1)

	return line1, math.max(line1, line2)
end

--- Return persistent minimap block cache for the current document/settings
-- @param dv DocView active doc view
-- @param line_count number total logical lines
-- @param max_cols number maximum visual columns to analyze
-- @param tabw number tab size in columns
-- @param min_run_cols number minimum whitespace run width to keep
-- @param invisible_spans_enabled boolean whether to collect invisible spans
-- @param bin_count number total horizontal bins for each block
-- @param window_line_count number current minimap window line count
-- @return table cache persistent block cache
local function get_or_create_minimap_block_cache(
	dv,
	line_count,
	max_cols,
	tabw,
	min_run_cols,
	invisible_spans_enabled,
	bin_count,
	window_line_count
)
	local doc = dv.doc
	local text_revision = (doc and doc._text_revision) or 0
	local block_lines, block_count = get_minimap_block_grid(line_count, window_line_count)
	local cache = dv._minimap_block_cache
	local debug_stats = get_minimap_debug_stats(dv)
	local can_reuse_cache =
		cache
		and cache.doc == doc
		and type(cache.text_revision) == "number"
		and cache.max_cols == max_cols
		and cache.tabw == tabw
		and cache.min_run_cols == min_run_cols
		and cache.invisible_spans_enabled == invisible_spans_enabled
		and cache.bin_count == bin_count
		and cache.block_lines == block_lines

	if can_reuse_cache
		and cache.line_count == line_count
		and cache.text_revision == text_revision
	then
		if debug_stats then
			debug_stats.cache_hits = debug_stats.cache_hits + 1
		end
		return cache
	end

	local line_change = can_reuse_cache
		and get_current_minimap_line_change(doc, text_revision, cache.text_revision)
		or nil

	if
		can_reuse_cache
		and line_change
	then
		local line1 = common.clamp(math.floor(tonumber(line_change.line1) or 1), 1, line_count)
		local line2 = common.clamp(math.floor(tonumber(line_change.line2) or line1), line1, line_count)
		local block1 = common.clamp(math.floor((line1 - 1) / block_lines) + 1, 1, block_count)
		local block2 = common.clamp(math.floor((line2 - 1) / block_lines) + 1, block1, block_count)
		local line_count_changed = cache.line_count ~= line_count

		if line_count_changed then
			-- Line insertion and removal shift every later block, but old drawings remain until replaced
			cache.structure_revision = (cache.structure_revision or 0) + 1
			cache.dirty_from_block = math.min(cache.dirty_from_block or block1, block1)
			for block_index in pairs(cache.blocks) do
				if block_index > block_count then
					cache.blocks[block_index] = nil
					cache.dirty_blocks[block_index] = nil
				end
			end
			block2 = block_count
		else
			for block_index = block1, block2 do
				cache.dirty_blocks[block_index] = true
			end
		end

		cache.text_revision = text_revision
		cache.line_count = line_count
		cache.block_count = block_count
		cache.next_lazy_block = math.min(cache.next_lazy_block or block1, block1)
		if debug_stats then
			debug_stats.cache_hits = debug_stats.cache_hits + 1
			debug_stats.block_invalidations = debug_stats.block_invalidations + 1
		end
		minimap_debug_log(
			dv,
			"block invalidation lines=%s-%s blocks=%s-%s text_revision=%s",
			tostring(line1),
			tostring(line2),
			tostring(block1),
			tostring(block2),
			tostring(text_revision)
		)
		return cache
	end

	local miss_reasons = {}
	if not cache then
		miss_reasons[#miss_reasons + 1] = "no block cache"
	else
		add_cache_miss_reason(miss_reasons, "doc", cache.doc, doc)
		add_cache_miss_reason(miss_reasons, "text_revision", cache.text_revision, text_revision)
		add_cache_miss_reason(miss_reasons, "line_count", cache.line_count, line_count)
		add_cache_miss_reason(miss_reasons, "block_lines", cache.block_lines, block_lines)
		add_cache_miss_reason(miss_reasons, "max_cols", cache.max_cols, max_cols)
		add_cache_miss_reason(miss_reasons, "tabw", cache.tabw, tabw)
		add_cache_miss_reason(miss_reasons, "min_run_cols", cache.min_run_cols, min_run_cols)
		add_cache_miss_reason(miss_reasons, "invisible_spans", cache.invisible_spans_enabled, invisible_spans_enabled)
		add_cache_miss_reason(miss_reasons, "bin_count", cache.bin_count, bin_count)
	end
	local miss_reason = table.concat(miss_reasons, ", ")
	if miss_reason == "" then
		miss_reason = "unknown"
	end

	if debug_stats then
		debug_stats.cache_misses = debug_stats.cache_misses + 1
		debug_stats.cache_replacements = debug_stats.cache_replacements + 1
		debug_stats.last_miss_reason = miss_reason
	end
	minimap_debug_log(
		dv,
		"block cache replace reason=%s line_count=%s block_lines=%s blocks=%s",
		miss_reason,
		tostring(line_count),
		tostring(block_lines),
		tostring(block_count)
	)

	cache = {
		doc = doc,
		text_revision = text_revision,
		line_count = line_count,
		block_lines = block_lines,
		block_count = block_count,
		max_cols = max_cols,
		tabw = tabw,
		min_run_cols = min_run_cols,
		invisible_spans_enabled = invisible_spans_enabled,
		bin_count = bin_count,
		blocks = {},
		dirty_blocks = {},
		structure_revision = 0,
		next_lazy_block = 1,
	}
	dv._minimap_block_cache = cache

	return cache
end

--- Build one persistent minimap block
-- @param dv DocView active doc view
-- @param cache table persistent block cache
-- @param block_index number one-based block index
-- @return table analysis cached block analysis
local function build_cached_minimap_block(dv, cache, block_index)
	local line1, line2 = get_minimap_block_line_span(block_index, cache.block_lines, cache.line_count)
	local block = get_block_preview_analysis(
		dv,
		line1,
		line2,
		cache.max_cols,
		cache.tabw,
		cache.min_run_cols,
		cache.invisible_spans_enabled,
		cache.bin_count
	)
	block.structure_revision = cache.structure_revision

	return block
end

--- Return whether one block is missing or stale
-- @param cache table persistent block cache
-- @param block_index number one-based block index
-- @return boolean needs_build
local function minimap_block_needs_build(cache, block_index)
	local block = cache.blocks[block_index]
	local structure_changed = cache.dirty_from_block
		and block_index >= cache.dirty_from_block
		and block
		and block.structure_revision ~= cache.structure_revision

	return not block or cache.dirty_blocks[block_index] == true or structure_changed == true
end

--- Ensure blocks around the current minimap window are ready within the frame budget
-- @param dv DocView active doc view
-- @param cache table persistent block cache
-- @param first_block number first block needed for the visible minimap window
-- @param last_block number last block needed for the visible minimap window
-- @param build_first_block number|nil first nearby block to backfill after visible blocks
-- @param build_last_block number|nil last nearby block to backfill after visible blocks
-- @return boolean complete true when no blocks remain missing
-- @return number built_blocks number of blocks built during this call
local function ensure_minimap_blocks_ready(dv, cache, first_block, last_block, build_first_block, build_last_block)
	local row_budget = math.max(1, math.floor(tonumber(config.minimap_build_rows_per_frame) or 24))
	local built_blocks = 0
	local debug_stats = get_minimap_debug_stats(dv)

	for block_index = first_block, last_block do
		if minimap_block_needs_build(cache, block_index) then
			cache.blocks[block_index] = build_cached_minimap_block(dv, cache, block_index)
			cache.dirty_blocks[block_index] = nil
			if debug_stats then
				debug_stats.row_builds = debug_stats.row_builds + 1
			end
			built_blocks = built_blocks + 1

			if built_blocks >= row_budget then
				cache.next_lazy_block = block_index + 1
				if debug_stats then
					debug_stats.budget_yields = debug_stats.budget_yields + 1
				end
				core.redraw = true
				return false, built_blocks
			end
		end
	end

	build_first_block = common.clamp(build_first_block or first_block, 1, cache.block_count)
	build_last_block = common.clamp(build_last_block or last_block, build_first_block, cache.block_count)
	for block_index = build_first_block, build_last_block do
		if block_index < first_block or block_index > last_block then
			if minimap_block_needs_build(cache, block_index) then
				cache.blocks[block_index] = build_cached_minimap_block(dv, cache, block_index)
				cache.dirty_blocks[block_index] = nil
				if debug_stats then
					debug_stats.row_builds = debug_stats.row_builds + 1
				end
				built_blocks = built_blocks + 1

				if built_blocks >= row_budget then
					cache.next_lazy_block = block_index + 1
					if debug_stats then
						debug_stats.budget_yields = debug_stats.budget_yields + 1
					end
					core.redraw = true
					return false, built_blocks
				end
			end
		end
	end

	cache.next_lazy_block = 1
	return true, built_blocks
end

--- Project one persistent block into the current minimap window
-- @param block_index number one-based block index
-- @param cache table persistent block cache
-- @param window table current minimap window
-- @param r table minimap rect
-- @return number|nil y top pixel
-- @return number|nil h projected pixel height
local function get_projected_block_span(block_index, cache, window, r)
	local raw_line1, raw_line2 = get_minimap_block_line_span(block_index, cache.block_lines, cache.line_count)
	if raw_line2 < window.start_line or raw_line1 > window.end_line then
		return nil
	end

	local line1 = common.clamp(raw_line1, window.start_line, window.end_line)
	local line2 = common.clamp(raw_line2, line1, window.end_line)
	local local_line1 = line1 - window.start_line + 1
	local local_line2 = line2 - window.start_line + 1
	local y1 = line_band_span(local_line1, window.window_line_count, r.y, r.h)
	local y2, h2 = line_band_span(local_line2, window.window_line_count, r.y, r.h)

	return y1, math.max(1, y2 + h2 - y1)
end

local update = DocView.update
local draw = DocView.draw
local lane_handler = {}

function lane_handler.on_mouse_down(dv, button, x, y, clicks)
	if button ~= "left" then
		return false
	end

	local r = get_minimap_rect(dv)
	if not r then
		return false
	end

	local _, thumb_y, _, thumb_h = get_minimap_thumb_rect(dv, r)
	local drag_offset = thumb_h * 0.5
	if y >= thumb_y and y <= thumb_y + thumb_h then
		drag_offset = y - thumb_y
	end

	dv.dragging_minimap = {
		drag_offset = drag_offset,
	}

	local target = get_minimap_scroll_target(dv, y, drag_offset)
	if target then
		dv.scroll.to.y = target
		dv.scroll.y = target
	end

	return true
end

function lane_handler.on_mouse_move(dv, x, y, dx, dy)
	if not dv.dragging_minimap then
		return false
	end

	local target = get_minimap_scroll_target(dv, y, dv.dragging_minimap.drag_offset)
	if target then
		dv.scroll.to.y = target
		dv.scroll.y = target
	end

	return true
end

function lane_handler.on_mouse_up(dv, button, x, y)
	dv.dragging_minimap = false
	return true
end

function lane_handler.get_mouse_cursor(dv, x, y)
	return "arrow"
end

function DocView:update(...)
	local w = sync_minimap_pane(self)
	self:set_lane_input_handler("right", LANE_ID, (w > 0) and lane_handler or nil)
	if w <= 0 then
		self.dragging_minimap = false
	end
	return update(self, ...)
end

function DocView:draw(...)
	draw(self, ...)

	local profile = config.minimap_profile_enabled == true and get_minimap_profile(self) or nil
	local minimap_start_time = profile and system.get_time() or nil
	local setup_start_time = minimap_start_time
	local r = get_minimap_rect(self)
	if not r then
		return
	end

	local window = get_minimap_window(self)
	if not window then
		return
	end

	local line_count = window.line_count
	if line_count < 2 then
		return
	end
	local preview_r = r
	local minimap_line_h = get_minimap_line_height(window.window_line_count, preview_r.h)

	-- Minimap background (covers standard scrollbar area)
	local minimap_bg = style.minimap_background or style.background
	local minimap_line = style.minimap_line or style.dim
	local minimap_viewport = style.minimap_viewport
	if not minimap_viewport then
		-- Default viewport is based on background2 with alpha
		local r2, g2, b2, a2 = table.unpack(style.text)
		local alpha = math.max(1, math.floor((a2 or 255) * 0.1 + 0.5))
		minimap_viewport = { r2, g2, b2, alpha }
	end
	local minimap_viewport_content = style.minimap_viewport_content or common.lerp(minimap_line, style.text, 0.35)
	local minimap_border = style.minimap_border or style.divider
	renderer.draw_rect(r.x, r.y, r.w, r.h, minimap_bg)

	-- Draw persistent logical blocks so sliding-window shifts reuse previously built minimap content
	local max_chars = math.max(8, config.minimap_max_sample_chars or 160)
	local pad = math.max(1, math.floor(SCALE))
	local avail_w = math.max(1, preview_r.w - pad * 2)
	local preview_bin_count = 32
	local tab_width = math.max(1, config.minimap_indent_size or config.indent_size or 4)
	local invisible_spans_enabled = config.minimap_invisible_spans_enabled ~= false
	local invisible_run_min_cols = 4
	local invisible_overlay_mix = 0.4
	local invisible_overlay
	if invisible_spans_enabled then
		-- Keep the overlay color lazy so disabling invisible spans skips all related work
		invisible_overlay = style.transparent(common.lerp(minimap_bg, minimap_line, invisible_overlay_mix), 0.9)
	end
	local viewport_invisible_overlay
	if invisible_spans_enabled then
		viewport_invisible_overlay =
			style.transparent(common.lerp(minimap_viewport, minimap_viewport_content, invisible_overlay_mix), 0.9)
	end

	--- Draw compact minimap runs without rescanning cached occupancy bins
	-- @param runs table flat start/end-exclusive bin pairs
	-- @param yy number top pixel
	-- @param bar_h number bar height
	-- @param color table bar color
	local function draw_preview_runs(runs, yy, bar_h, color)
		for index = 1, #runs, 2 do
			local run_start = runs[index]
			local run_end = runs[index + 1]
			local x1 = preview_r.x + pad + math.floor(((run_start - 1) / preview_bin_count) * avail_w)
			local x2 = preview_r.x + pad + math.ceil(((run_end - 1) / preview_bin_count) * avail_w)
			local w = math.max(1, x2 - x1)
			renderer.draw_rect(x1, yy, w, bar_h, color)
			if profile then
				profile.preview_rects = profile.preview_rects + 1
			end
		end
	end

	--- Draw compact minimap runs clipped to one vertical viewport
	-- @param runs table flat start/end-exclusive bin pairs
	-- @param yy number top pixel
	-- @param bar_h number bar height
	-- @param color table bar color
	-- @param clip_y number viewport top pixel
	-- @param clip_h number viewport height
	local function draw_preview_runs_clipped(runs, yy, bar_h, color, clip_y, clip_h)
		local y1 = math.max(yy, clip_y)
		local y2 = math.min(yy + bar_h, clip_y + clip_h)
		if y2 <= y1 then
			return
		end

		local previous_rects = profile and profile.preview_rects or 0
		draw_preview_runs(runs, y1, y2 - y1, color)
		if profile then
			local drawn_rects = profile.preview_rects - previous_rects
			profile.preview_rects = previous_rects
			profile.viewport_rects = profile.viewport_rects + drawn_rects
		end
	end

	--- Draw base minimap runs only where an opaque viewport foreground will not replace them
	-- @param runs table flat start/end-exclusive bin pairs
	-- @param yy number top pixel
	-- @param bar_h number bar height
	-- @param color table bar color
	-- @param clip_y number opaque viewport top pixel
	-- @param clip_h number opaque viewport height
	local function draw_preview_runs_outside(runs, yy, bar_h, color, clip_y, clip_h)
		local bar_y2 = yy + bar_h
		local clip_y2 = clip_y + clip_h
		local top_h = math.max(0, math.min(bar_y2, clip_y) - yy)
		local bottom_y = math.max(yy, clip_y2)
		local bottom_h = math.max(0, bar_y2 - bottom_y)

		if top_h > 0 then
			draw_preview_runs(runs, yy, top_h, color)
		end
		if bottom_h > 0 then
			draw_preview_runs(runs, bottom_y, bottom_h, color)
		end
		if profile and top_h == 0 and bottom_h == 0 then
			profile.covered_rects = profile.covered_rects + math.floor(#runs / 2)
		end
	end

	if profile then
		profile.setup = profile.setup + system.get_time() - setup_start_time
	end
	local cache_start_time = profile and system.get_time() or nil
	local preview_cache = get_or_create_minimap_block_cache(
		self,
		line_count,
		max_chars,
		tab_width,
		invisible_run_min_cols,
		invisible_spans_enabled,
		preview_bin_count,
		window.window_line_count
	)
	if profile then
		profile.cache = profile.cache + system.get_time() - cache_start_time
	end
	local first_block = common.clamp(
		math.floor((window.start_line - 1) / preview_cache.block_lines) + 1,
		1,
		preview_cache.block_count
	)
	local last_block = common.clamp(
		math.floor((window.end_line - 1) / preview_cache.block_lines) + 1,
		first_block,
		preview_cache.block_count
	)
	local visible_first_block = common.clamp(
		math.floor((window.visible_line1 - 1) / preview_cache.block_lines) + 1,
		first_block,
		last_block
	)
	local visible_last_block = common.clamp(
		math.floor((window.visible_line2 - 1) / preview_cache.block_lines) + 1,
		visible_first_block,
		last_block
	)
	local overscan_windows = math.max(0, tonumber(config.minimap_block_overscan_windows) or 1)
	local overscan_blocks = math.ceil((window.window_line_count * overscan_windows) / preview_cache.block_lines)
	local build_first_block = common.clamp(first_block - overscan_blocks, 1, preview_cache.block_count)
	local build_last_block = common.clamp(last_block + overscan_blocks, build_first_block, preview_cache.block_count)

	-- Build the highlighted viewport first, then fill the rest of the sliding window progressively
	local build_start_time = profile and system.get_time() or nil
	local visible_blocks_ready, blocks_built =
		ensure_minimap_blocks_ready(self, preview_cache, visible_first_block, visible_last_block, first_block, last_block)
	if visible_blocks_ready then
		local _, later_blocks_built =
			ensure_minimap_blocks_ready(self, preview_cache, first_block, last_block, build_first_block, build_last_block)
		blocks_built = blocks_built + later_blocks_built
	end
	if profile then
		profile.build = profile.build + system.get_time() - build_start_time
		profile.blocks_built = profile.blocks_built + blocks_built
	end

	local _, preview_thumb_y, _, preview_thumb_h = get_window_viewport_rect(window, preview_r)
	local viewport_content_opaque = (minimap_viewport_content[4] or 255) >= 255

	-- Reuse projected block records across frames and both minimap content passes
	local projected_blocks = self._minimap_projected_blocks
	if not projected_blocks then
		projected_blocks = {}
		self._minimap_projected_blocks = projected_blocks
	end
	local projected_count = 0
	local preview_start_time = profile and system.get_time() or nil
	for block_index = first_block, last_block do
		local cached_block = preview_cache.blocks[block_index]
		if profile then
			profile.blocks_visited = profile.blocks_visited + 1
		end

		if cached_block then
			local project_start_time = profile and system.get_time() or nil
			local yy, block_h = get_projected_block_span(block_index, preview_cache, window, preview_r)
			if profile then
				profile.project = profile.project + system.get_time() - project_start_time
				profile.project_calls = profile.project_calls + 1
			end

			if yy then
				projected_count = projected_count + 1
				local projected = projected_blocks[projected_count]
				if not projected then
					projected = {}
					projected_blocks[projected_count] = projected
				end
				projected.block = cached_block
				projected.y = yy
				projected.height = block_h
				if profile then
					profile.blocks_drawn = profile.blocks_drawn + 1
				end
				if cached_block.has_visible then
					if viewport_content_opaque then
						draw_preview_runs_outside(
							cached_block.visible_runs,
							yy,
							block_h,
							minimap_line,
							preview_thumb_y,
							preview_thumb_h
						)
					else
						draw_preview_runs(cached_block.visible_runs, yy, block_h, minimap_line)
					end
				end
				if invisible_spans_enabled and cached_block.has_invisible then
					draw_preview_runs(cached_block.invisible_runs, yy, block_h, invisible_overlay)
				end
			end
		end
	end
	for index = projected_count + 1, #projected_blocks do
		projected_blocks[index] = nil
	end
	if profile then
		profile.preview = profile.preview + system.get_time() - preview_start_time
	end

	-- Draw viewport content before markers so opaque styles cannot hide marker ticks
	renderer.draw_rect(preview_r.x, preview_thumb_y, preview_r.w, preview_thumb_h, minimap_viewport)

	local viewport_start_time = profile and system.get_time() or nil
	for index = 1, projected_count do
		local projected = projected_blocks[index]
		local cached_block = projected.block
		local yy = projected.y
		local block_h = projected.height
		if yy + block_h > preview_thumb_y and yy < preview_thumb_y + preview_thumb_h then
			if cached_block.has_visible then
				draw_preview_runs_clipped(
					cached_block.visible_runs,
					yy,
					block_h,
					minimap_viewport_content,
					preview_thumb_y,
					preview_thumb_h
				)
			end
			if invisible_spans_enabled and cached_block.has_invisible then
				draw_preview_runs_clipped(
					cached_block.invisible_runs,
					yy,
					block_h,
					viewport_invisible_overlay,
					preview_thumb_y,
					preview_thumb_h
				)
			end
		end
	end
	if profile then
		profile.viewport = profile.viewport + system.get_time() - viewport_start_time
	end

	-- Draw diff ticks/highlights in minimap when available
	local markers_start_time = profile and system.get_time() or nil
	local marks = self.doc and self.doc.differ_marks
	if marks then
		local tick_w = math.max(2, math.floor(3 * SCALE + 0.5))
		local tick_x = preview_r.x + preview_r.w - tick_w
		local row_tick_h = minimap_line_h
		draw_tick_map(marks, window, preview_r, tick_x, tick_w, row_tick_h, tick_color)
	end

	-- Draw bookmark ticks in minimap when plugin is loaded
	local bm = config.minimap_bookmarks_enabled and package.loaded["plugins.bookmarks"] or nil
	local bm_marks = bm and bm.get_doc_marks and bm.get_doc_marks(self.doc)
	if config.minimap_bookmarks_enabled and bm_marks then
		local tick_w = math.max(3, math.floor(4 * SCALE + 0.5))
		local tick_x = preview_r.x + 1
		local row_tick_h = minimap_line_h
		local color = style.bookmark or style.candidate or style.accent or style.text
		draw_tick_set(bm_marks, window, preview_r, tick_x, tick_w, row_tick_h, color)
	end

	-- Draw change-tracker ticks in minimap when plugin is loaded
	local ct = config.minimap_change_markers_enabled and package.loaded["plugins.changetracker"] or nil
	local ct_marks = ct and ct.get_doc_marks and ct.get_doc_marks(self.doc)
	local ct_spans = ct and ct.get_doc_mark_spans and ct.get_doc_mark_spans(self.doc)
	if config.minimap_change_markers_enabled and (ct_marks or ct_spans) then
		local tick_w = math.max(1, math.floor(1 * SCALE + 0.5))
		local tick_x = preview_r.x + preview_r.w - tick_w
		if marks then
			-- Keep diff ticks on the far right; shift changetracker ticks left
			local diff_tick_w = math.max(2, math.floor(3 * SCALE + 0.5))
			tick_x = tick_x - diff_tick_w - math.max(1, math.floor(SCALE + 0.5))
		end
		local row_tick_h = minimap_line_h
		local function draw_mark_set(set, kind)
			local color = changetracker_tick_color(kind)
			draw_tick_set(set, window, preview_r, tick_x, tick_w, row_tick_h, color)
		end
		local function draw_mark_spans(spans, kind)
			local color = changetracker_tick_color(kind)
			draw_tick_spans(spans, window, preview_r, tick_x, tick_w, color)
		end
		-- Priority by draw order: deleted (back), added, changed (top)
		if ct_spans then
			draw_mark_spans(ct_spans.deleted, "deleted")
			draw_mark_spans(ct_spans.added, "added")
			draw_mark_spans(ct_spans.changed, "changed")
		end
		if ct_marks then
			draw_mark_set(ct_marks.deleted, "deleted")
			draw_mark_set(ct_marks.added, "added")
			draw_mark_set(ct_marks.changed, "changed")
		end
	end
	if profile then
		profile.markers = profile.markers + system.get_time() - markers_start_time
	end

	-- Draw active-cursor marker as a mini vertical cursor inside viewport
	local cursor_start_time = profile and system.get_time() or nil
	if config.minimap_cursor_enabled and self == core.active_view then
		local cursor_line, cursor_col = self.doc:get_selection()
		if type(cursor_line) == "number" and line_count > 0 then
			local cursor_w = math.max(1, math.floor((config.minimap_cursor_width or style.cursor_width or 1) * SCALE + 0.5))

			-- Match mini-cursor height to one minimap document line
			local cursor_len = math.max(1, math.floor(minimap_line_h * (config.minimap_cursor_length_scale or 1) + 0.5))

			-- Anchor mini-cursor to the same local projection as the zoomed preview
			local cursor_line_y = line_top_for_marker(cursor_line, window, preview_r.y, preview_r.h, cursor_len)
			local viewport_y1 = preview_thumb_y
			local viewport_y2 = preview_thumb_y + preview_thumb_h - 1
			cursor_len = math.min(cursor_len, preview_thumb_h)
			local cursor_y
			if cursor_line_y and config.minimap_cursor_keep_visible then
				-- Keep marker at local preview Y; clamp only to preview bounds
				cursor_y = common.clamp(cursor_line_y, preview_r.y, preview_r.y + preview_r.h - cursor_len)
				cursor_y = math.floor(cursor_y + 0.5)
			elseif cursor_line_y then
				-- Draw only when cursor overlaps viewport, without changing mapped Y
				local y1 = cursor_line_y
				local y2 = cursor_line_y + cursor_len - 1
				if y2 >= viewport_y1 and y1 <= viewport_y2 then
					cursor_y = math.floor(cursor_line_y + 0.5)
				end
			end
			local function draw_mini_cursor_marker(marker_line, marker_col, marker_color)
				local marker_line_y = line_top_for_marker(marker_line, window, preview_r.y, preview_r.h, cursor_len)
				if not marker_line_y then
					return
				end
				local marker_y
				if config.minimap_cursor_keep_visible then
					marker_y = common.clamp(marker_line_y, preview_r.y, preview_r.y + preview_r.h - cursor_len)
					marker_y = math.floor(marker_y + 0.5)
				else
					local y1 = marker_line_y
					local y2 = marker_line_y + cursor_len - 1
					if y2 >= viewport_y1 and y1 <= viewport_y2 then
						marker_y = math.floor(marker_line_y + 0.5)
					end
				end
				if not marker_y then
					return
				end
				local margin = math.max(1, math.floor((config.minimap_cursor_margin or 3) * SCALE + 0.5))
				local line_text = self.doc.lines[marker_line] or ""
				local analysis =
					get_cached_line_preview_analysis(
						self,
						marker_line,
						max_chars,
						tab_width,
						invisible_run_min_cols,
						invisible_spans_enabled
					)
				-- Reuse the same preview layout as the minimap bars so cursor X matches line preview X
				local layout = get_line_preview_layout(analysis.visual_len, preview_r, margin, max_chars)
				local mapped_cols = math.min(layout.visual_len, max_chars)
				local x1 = layout.x0
				local x2 = x1 + math.max(0, layout.preview_w - cursor_w)
				local cursor_ratio = 0
				if mapped_cols > 0 and layout.preview_w > 0 then
					local logical_len = logical_line_bytes(line_text)
					local cursor_visual_col = visual_cols_for_text(
						line_text,
						common.clamp((marker_col or 1) - 1, 0, logical_len),
						tab_width,
						mapped_cols
					)
					-- Map marker columns inside the same rounded preview width used by sampled minimap bars
					cursor_ratio = common.clamp(cursor_visual_col / mapped_cols, 0, 1)
				end
				local cursor_x = x1
				if x2 > x1 then
					cursor_x = x1 + math.floor(cursor_ratio * (x2 - x1))
				end
				renderer.draw_rect(cursor_x, marker_y, cursor_w, cursor_len, marker_color)
			end
			if cursor_y then
				local cursor_color = style.minimap_cursor or style.cursor or style.text
				draw_mini_cursor_marker(cursor_line, cursor_col, cursor_color)
			end
			-- Draw selection endpoints with caret-style marker only when selection exists
			if self.doc:has_selection() then
				local sel_line1, sel_col1, sel_line2, sel_col2 = self.doc:get_selection(true)
				local selection_color = style.cursor or style.text
				draw_mini_cursor_marker(sel_line1, sel_col1, selection_color)
				draw_mini_cursor_marker(sel_line2, sel_col2, selection_color)
			end
		end
	end

	-- Border line
	renderer.draw_rect(r.x, r.y, 1, r.h, minimap_border)
	if profile then
		profile.cursor = profile.cursor + system.get_time() - cursor_start_time
		profile.draws = profile.draws + 1
		profile.total = profile.total + system.get_time() - minimap_start_time
	end
end

command.register({
	["minimap:toggle"] = function()
		config.minimap_enabled = not config.minimap_enabled
		core.redraw = true
	end,
	["minimap:toggle-debug"] = function()
		config.minimap_debug = config.minimap_debug ~= true
		core.log("Minimap debug logging: %s", config.minimap_debug and "ON" or "OFF")
		return true
	end,
	["minimap:dump-debug"] = function()
		local view = core.active_view
		if not (view and view.doc and view._minimap_debug_stats) then
			view = core.last_active_view
		end
		if not (view and view.doc and view._minimap_debug_stats) then
			core.log("No minimap debug counters available")
			return false
		end

		local stats = view._minimap_debug_stats
		core.log(
			"Minimap debug for %s\n"
				.. "cache_hits=%d cache_misses=%d cache_replacements=%d block_invalidations=%d "
				.. "row_builds=%d budget_yields=%d last_miss_reason=%s",
			minimap_debug_doc_label(view),
			stats.cache_hits or 0,
			stats.cache_misses or 0,
			stats.cache_replacements or 0,
			stats.block_invalidations or 0,
			stats.row_builds or 0,
			stats.budget_yields or 0,
			stats.last_miss_reason or ""
		)
		return true
	end,
})

hook.add("core.line_change", {
	id = "minimap-cache",
	-- Retain the coalesced range after the debounce queue dispatches it
	dispatch = function(event)
		if event and event.doc then
			latest_minimap_line_changes[event.doc] = event
		end
	end,
})
