-- lx-bundle:true

--- Cached visual-row layout helpers for wrapped text surfaces
-- @module core.text.wrap

local common = require("core.common")
local config = require("core.config")

local wrap = {}

-- Keep lazy wrapping bounded to small logical-line batches
local WRAP_BLOCK_SIZE = 128

-- Keep visible wrap prep ahead of the viewport without touching the whole file
local WRAP_VISIBLE_OVERSCAN_ROWS = 8

-- Return true when wrap profiling counters should collect timing data
local function is_profile_enabled()
	return config.text_wrap_profile_enabled == true
end

-- Return one mutable profiling state table for this surface
local function get_profile_state(self)
	if not is_profile_enabled() then
		return nil
	end

	local profile = self._wrap_profile

	if profile then
		return profile
	end

	profile = {
		ensure_calls = 0,
		ensure_cache_hits = 0,
		rebuild_calls = 0,
		total_ensure_ms = 0,
		total_rebuild_ms = 0,
		max_rebuild_ms = 0,
		total_rebuilt_lines = 0,
		total_rebuilt_rows = 0,
		draw_calls = 0,
		total_draw_ms = 0,
		max_draw_ms = 0,
		total_drawn_rows = 0,
		total_drawn_lines = 0,
	}
	self._wrap_profile = profile

	return profile
end

-- Keep wrapped continuation rows from collapsing into almost the full line width
local function get_wrap_min_remaining_width(view, font)
	local minimum_columns = math.max(4, tonumber(config.text_wrap_min_columns) or 8)
	return font:get_width(" ") * minimum_columns
end

-- Return true when one byte range can be used as a word-wrap boundary
local function is_wrap_boundary_char(char)
	return char == " " or char == "\t"
end

-- Return total block count for the current document line count
local function get_block_count(line_count)
	return math.max(1, math.ceil(math.max(1, line_count) / WRAP_BLOCK_SIZE))
end

-- Return the current block index for one logical line
local function get_block_index_for_line(line)
	return math.max(1, math.floor((math.max(1, tonumber(line) or 1) - 1) / WRAP_BLOCK_SIZE) + 1)
end

-- Return the logical-line span covered by one block
local function get_block_line_range(block_index, line_count)
	local line_start = (block_index - 1) * WRAP_BLOCK_SIZE + 1
	local line_end = math.min(line_count, line_start + WRAP_BLOCK_SIZE - 1)

	return line_start, math.max(line_start, line_end)
end

-- Return one best-effort average wrapped-row multiplier for unknown blocks
local function get_average_rows_per_line(layout)
	local average = tonumber(layout.average_rows_per_line) or 1

	return math.max(1, average)
end

-- Return one estimated wrapped-row total for a logical-line span
local function estimate_rows_for_line_count(layout, line_total)
	local estimated_rows = math.floor(line_total * get_average_rows_per_line(layout) + 0.5)

	return math.max(line_total, estimated_rows)
end

-- Return one fresh estimated block entry without exact wrapped rows
local function build_estimated_block(layout, block_index, line_count)
	local line_start, line_end = get_block_line_range(block_index, line_count)
	local line_total = line_end - line_start + 1

	return {
		exact = false,
		line_start = line_start,
		line_end = line_end,
		line_count = line_total,
		row_total = estimate_rows_for_line_count(layout, line_total),
		row_start = 1,
	}
end

-- Recompute the sampled average using all currently exact blocks
local function refresh_average_rows_per_line(layout)
	local exact_line_total = 0
	local exact_row_total = 0

	for block_index = 1, layout.block_count or 0 do
		local block = layout.blocks and layout.blocks[block_index]

		if block and block.exact then
			exact_line_total = exact_line_total + (block.line_count or 0)
			exact_row_total = exact_row_total + (block.row_total or 0)
		end
	end

	if exact_line_total > 0 then
		layout.average_rows_per_line = math.max(1, exact_row_total / exact_line_total)
	elseif not layout.average_rows_per_line then
		layout.average_rows_per_line = 1
	end
end

-- Recompute block row starts and the estimated total wrapped-row count
local function rebuild_block_prefix(layout, first_block)
	local block_count = layout.block_count or 0

	if block_count <= 0 then
		layout.total_rows = 1
		return
	end

	first_block = common.clamp(math.floor(first_block or 1), 1, block_count)

	local row_start = 1

	if first_block > 1 then
		local previous_block = layout.blocks[first_block - 1]
		row_start = (previous_block.row_start or 1) + (previous_block.row_total or 0)
	end

	for block_index = first_block, block_count do
		local block = layout.blocks[block_index]

		block.row_start = row_start
		row_start = row_start + (block.row_total or 0)
	end

	layout.total_rows = math.max(1, row_start - 1)
end

-- Reset one block list back to estimated totals only
local function reset_estimated_blocks(layout, line_count)
	layout.block_count = get_block_count(line_count)
	layout.blocks = {}
	layout.line_count = line_count

	for block_index = 1, layout.block_count do
		layout.blocks[block_index] = build_estimated_block(layout, block_index, line_count)
	end

	rebuild_block_prefix(layout, 1)
end

-- Invalidate one logical suffix back to estimated block totals
local function invalidate_blocks_from_line(layout, line, line_count)
	layout.line_count = line_count

	if not layout.blocks or not layout.block_count then
		reset_estimated_blocks(layout, line_count)
		return
	end

	local block_count = get_block_count(line_count)
	local first_block = common.clamp(get_block_index_for_line(line), 1, block_count)

	for block_index = 1, block_count do
		if block_index >= first_block or not layout.blocks[block_index] then
			layout.blocks[block_index] = build_estimated_block(layout, block_index, line_count)
		else
			local block = layout.blocks[block_index]
			local line_start, line_end = get_block_line_range(block_index, line_count)

			block.line_start = line_start
			block.line_end = line_end
			block.line_count = line_end - line_start + 1
		end
	end

	for block_index = block_count + 1, layout.block_count do
		layout.blocks[block_index] = nil
	end

	layout.block_count = block_count
	refresh_average_rows_per_line(layout)
	rebuild_block_prefix(layout, first_block)
end

-- Return one best-effort block and block index for a global wrapped row
local function find_block_for_row(layout, row)
	local block_count = layout.block_count or 0

	if block_count <= 0 then
		return nil, nil
	end

	row = common.clamp(math.floor(row or 1), 1, layout.total_rows or 1)

	local low = 1
	local high = block_count

	while low <= high do
		local middle = math.floor((low + high) / 2)
		local block = layout.blocks[middle]
		local row_start = block.row_start or 1
		local row_end = row_start + math.max(1, block.row_total or 1) - 1

		if row < row_start then
			high = middle - 1
		elseif row > row_end then
			low = middle + 1
		else
			return middle, block
		end
	end

	local resolved_index = common.clamp(low, 1, block_count)
	return resolved_index, layout.blocks[resolved_index]
end

-- Measure one visual continuation indent from leading whitespace
local function get_continuation_indent_x(view, line, wrap_width, font, tab_width)
	if config.text_wrap_preserve_indent ~= true then
		return 0
	end

	local text = view.doc.lines[line] or ""
	local indent_end = 0
	local index = 1
	local line_end_col = view:get_line_end_col(line)

	while index < line_end_col do
		local char, _, end_index = common.next_utf8_char(text, index)

		if char ~= " " and char ~= "\t" then
			break
		end

		indent_end = end_index
		index = end_index + 1
	end

	if indent_end <= 0 then
		return 0
	end

	local indent_text = text:sub(1, indent_end)
	local indent_width = common.calculate_text_width(indent_text, font, tab_width)
	local max_indent = math.max(0, wrap_width - get_wrap_min_remaining_width(view, font))

	return math.min(indent_width, max_indent)
end

-- Append wrapped rows for one logical line directly into one block-local row cache
local function append_wrapped_rows_for_line(view, block, line, wrap_width, font, tab_width, next_row_index)
	local text = view.doc.lines[line] or ""
	local line_end_col = view:get_line_end_col(line)
	local continuation_indent_x = get_continuation_indent_x(view, line, wrap_width, font, tab_width)
	local row_start_col = 1
	local row_start_x = 0
	local current_x = 0
	local last_boundary_col = nil
	local last_boundary_x = nil
	local index = 1
	local row_count = 0

	-- Keep every logical line addressable even when it is empty or only newline
	if line_end_col <= 1 then
		block.row_lines[next_row_index] = line
		block.row_start_cols[next_row_index] = 1
		block.row_end_cols[next_row_index] = line_end_col
		block.row_start_xs[next_row_index] = 0
		block.row_end_xs[next_row_index] = 0
		block.row_indent_xs[next_row_index] = 0
		return next_row_index + 1, 1, continuation_indent_x
	end

	while index < line_end_col do
		local char, _, end_index = common.next_utf8_char(text, index)
		local char_width

		if char == "\t" then
			char_width = tab_width - (current_x % tab_width)
		else
			char_width = font:get_width(char)
		end

		local row_indent_x = (row_start_col == 1) and 0 or continuation_indent_x
		local visible_x = row_indent_x + (current_x - row_start_x)

		if visible_x + char_width > wrap_width and row_start_col < index then
			local break_col = last_boundary_col or index
			local break_x = last_boundary_x or current_x

			-- Guarantee forward progress when a single glyph is wider than the wrap width
			if break_col <= row_start_col then
				break_col = end_index + 1
				break_x = current_x + char_width
				current_x = break_x
				index = end_index + 1
			end

			block.row_lines[next_row_index] = line
			block.row_start_cols[next_row_index] = row_start_col
			block.row_end_cols[next_row_index] = break_col
			block.row_start_xs[next_row_index] = row_start_x
			block.row_end_xs[next_row_index] = break_x
			block.row_indent_xs[next_row_index] = row_indent_x
			next_row_index = next_row_index + 1
			row_count = row_count + 1

			row_start_col = break_col
			row_start_x = break_x
			last_boundary_col = nil
			last_boundary_x = nil
		else
			current_x = current_x + char_width

			if is_wrap_boundary_char(char) then
				last_boundary_col = end_index + 1
				last_boundary_x = current_x
			end

			index = end_index + 1
		end
	end

	block.row_lines[next_row_index] = line
	block.row_start_cols[next_row_index] = row_start_col
	block.row_end_cols[next_row_index] = line_end_col
	block.row_start_xs[next_row_index] = row_start_x
	block.row_end_xs[next_row_index] = current_x
	block.row_indent_xs[next_row_index] = (row_start_col == 1) and 0 or continuation_indent_x

	return next_row_index + 1, row_count + 1, continuation_indent_x
end

-- Build one block exactly and refresh the estimated prefix from that block onward
local function build_exact_block(view, layout, block_index, wrap_width, font, line_height)
	local block = layout.blocks[block_index]

	if not block then
		block = build_estimated_block(layout, block_index, layout.line_count or #view.doc.lines)
		layout.blocks[block_index] = block
	end

	local tab_width = font:get_width(" ") * view.doc:get_indent_size()
	local line_infos = {}
	local next_row_index = 1
	local line_start = block.line_start
	local line_end = block.line_end

	block.row_lines = {}
	block.row_start_cols = {}
	block.row_end_cols = {}
	block.row_start_xs = {}
	block.row_end_xs = {}
	block.row_indent_xs = {}

	for line = line_start, line_end do
		local row_start = next_row_index
		local row_count, continuation_indent_x
		next_row_index, row_count, continuation_indent_x =
			append_wrapped_rows_for_line(view, block, line, wrap_width, font, tab_width, next_row_index)

		line_infos[line - line_start + 1] = {
			row_start = row_start,
			row_count = row_count,
			continuation_indent_x = continuation_indent_x,
		}
	end

	block.line_infos = line_infos
	block.row_total = next_row_index - 1
	block.exact = true
	layout.wrap_width = wrap_width
	layout.font = font
	layout.line_height = line_height

	refresh_average_rows_per_line(layout)
	rebuild_block_prefix(layout, block_index)

	return block.line_count or (line_end - line_start + 1), block.row_total
end

-- Return one exact block for the current view, building it lazily when needed
local function ensure_block_exact(view, layout, block_index)
	local block = layout.blocks[block_index]

	if not block then
		layout.blocks[block_index] = build_estimated_block(layout, block_index, layout.line_count or #view.doc.lines)
		block = layout.blocks[block_index]
		rebuild_block_prefix(layout, block_index)
	end

	if block.exact then
		return block, 0, 0
	end

	local rebuilt_lines, rebuilt_rows =
		build_exact_block(view, layout, block_index, layout.wrap_width, layout.font, layout.line_height)

	return layout.blocks[block_index], rebuilt_lines, rebuilt_rows
end

-- Return one best-effort exact block for the requested global wrapped row
local function ensure_row_block_exact(view, layout, row)
	local attempts = 0

	row = common.clamp(math.floor(row or 1), 1, layout.total_rows or 1)

	while attempts < 3 do
		local block_index, block = find_block_for_row(layout, row)

		if not block then
			return nil, nil, 0, row, 0, 0
		end

		if block.exact then
			local resolved_row = common.clamp(row, block.row_start, block.row_start + block.row_total - 1)
			local local_row = common.clamp(resolved_row - block.row_start + 1, 1, block.row_total)

			return block_index, block, local_row, resolved_row, 0, 0
		end

		local _, rebuilt_lines, rebuilt_rows = ensure_block_exact(view, layout, block_index)
		attempts = attempts + 1

		if rebuilt_lines == 0 and rebuilt_rows == 0 then
			local resolved_row = common.clamp(row, block.row_start, block.row_start + math.max(1, block.row_total or 1) - 1)
			local local_row = common.clamp(resolved_row - block.row_start + 1, 1, math.max(1, block.row_total or 1))

			return block_index, block, local_row, resolved_row, 0, 0
		end
	end

	local block_index, block = find_block_for_row(layout, row)
	local resolved_row = common.clamp(row, block.row_start, block.row_start + block.row_total - 1)
	local local_row = common.clamp(resolved_row - block.row_start + 1, 1, block.row_total)

	return block_index, block, local_row, resolved_row, 0, 0
end

-- Return one local line info entry for an exact block and logical line
local function get_block_line_info(block, line)
	if not block or not block.exact or not block.line_infos then
		return nil
	end

	local offset = line - block.line_start + 1
	return block.line_infos[offset]
end

-- Capture one logical top-of-viewport anchor before wrapped row estimates are reset
local function capture_scroll_anchor(view, layout)
	if not layout or not layout.blocks or not layout.line_height or layout.total_rows <= 0 then
		return nil
	end

	local scroll_y = view.scroll.to.y or view.scroll.y or 0
	local top_row = math.max(1, math.floor(scroll_y / layout.line_height) + 1)
	local _, block = find_block_for_row(layout, top_row)

	if not block or not block.exact then
		return nil
	end

	local resolved_row = common.clamp(top_row, block.row_start, block.row_start + block.row_total - 1)
	local local_row = resolved_row - block.row_start + 1
	local line = block.row_lines[local_row]
	local line_info = get_block_line_info(block, line)

	if not line_info then
		return nil
	end

	local anchor = {
		line = line,
		subrow = math.max(1, local_row - line_info.row_start + 1),
		row_offset = scroll_y - (top_row - 1) * layout.line_height,
	}

	return anchor
end

-- Restore one logical viewport anchor after wrapped width metrics changed
local function restore_scroll_anchor(view, layout, anchor)
	if not anchor then
		return
	end

	local line = common.clamp(anchor.line or 1, 1, layout.line_count or #view.doc.lines)
	local block = ensure_block_exact(view, layout, get_block_index_for_line(line))
	local line_info = get_block_line_info(block, line)

	if not line_info then
		return
	end

	local subrow = common.clamp(anchor.subrow or 1, 1, line_info.row_count)
	local local_row = line_info.row_start + subrow - 1
	local resolved_row = block.row_start + local_row - 1
	local target_y = math.max(0, (resolved_row - 1) * layout.line_height + (anchor.row_offset or 0))

	view.scroll.to.y = target_y
	view.scroll.y = target_y
end

--- Capture one logical top-of-viewport anchor for wrapped content
-- Zoom and other wrapped viewport operations can reuse the same anchor model
-- so they keep the visible top row stable across reflow
-- @param self table wrapped text surface
-- @return table|nil anchor wrapped viewport anchor when available
function wrap.capture_viewport_anchor(self)
	if not self:is_wrap_enabled() then
		return nil
	end

	local layout = self:ensure_wrap_layout()

	return capture_scroll_anchor(self, layout)
end

--- Restore one logical top-of-viewport anchor for wrapped content
-- @param self table wrapped text surface
-- @param anchor table|nil wrapped viewport anchor
-- @return boolean restored true when the anchor was applied
function wrap.restore_viewport_anchor(self, anchor)
	if not self:is_wrap_enabled() or type(anchor) ~= "table" then
		return false
	end

	local layout = self:ensure_wrap_layout()

	if not layout then
		return false
	end

	restore_scroll_anchor(self, layout, anchor)

	return true
end

-- Ensure the current visible wrapped window is exact while leaving the rest estimated
local function ensure_visible_window_exact(view, layout)
	local line_height = layout.line_height
	local viewport_height = select(4, view:get_content_viewport_rect())
	local visible_rows = math.max(1, math.ceil(viewport_height / line_height))
	local overscan_rows = WRAP_VISIBLE_OVERSCAN_ROWS
	local source_scroll_y = view.scroll.to.y or view.scroll.y or 0
	local top_row = math.max(1, math.floor(source_scroll_y / line_height) + 1 - overscan_rows)
	local bottom_row = math.min(layout.total_rows or 1, top_row + visible_rows + overscan_rows * 2)
	local current_row = top_row
	local rebuilt_lines = 0
	local rebuilt_rows = 0

	while current_row <= math.min(bottom_row, layout.total_rows or bottom_row) do
		local block_index, block = find_block_for_row(layout, current_row)

		if not block then
			break
		end

		if not block.exact then
			local exact_block, block_lines, block_rows = ensure_block_exact(view, layout, block_index)
			block = exact_block or block
			rebuilt_lines = rebuilt_lines + block_lines
			rebuilt_rows = rebuilt_rows + block_rows
		end

		-- Keep the visible window walk tied to the current total row count because
		-- exactifying one estimated block can shrink later row starts near EOF
		local next_row = (block.row_start or current_row) + math.max(1, block.row_total or 1)

		-- Guarantee forward progress even when stale estimated rows collapse onto
		-- the final exact block after prefix totals are rebuilt
		if next_row <= current_row then
			next_row = current_row + 1
		end

		current_row = next_row
	end

	return rebuilt_lines, rebuilt_rows
end

-- Return one shallow row info table from an exact block-local row cache
local function get_block_row_info(block, local_row)
	if not block or not block.exact then
		return nil
	end

	return {
		line = block.row_lines[local_row],
		start_col = block.row_start_cols[local_row],
		end_col = block.row_end_cols[local_row],
		start_x = block.row_start_xs[local_row],
		end_x = block.row_end_xs[local_row],
		indent_x = block.row_indent_xs[local_row],
	}
end

--- Return true when wrapping is active for this surface
-- @param self table wrapped text surface
-- @return boolean enabled true when wrap layout should be used
function wrap.is_wrap_enabled(self)
	return self.wrap_enabled == true
end

--- Return current wrapped text width inside the content viewport
-- @param self table wrapped text surface
-- @return number width available text width used for wrapping
function wrap.get_wrap_width(self)
	return self:get_text_viewport_width()
end

--- Mark one logical-line range dirty for incremental wrap repair
-- @param self table wrapped text surface
-- @param line1 number first dirty logical line
-- @param _line2 number ignored legacy end line
-- @param reason string|nil invalidation reason for profiling
function wrap.invalidate_wrap_layout(self, line1, _line2, reason)
	local layout = self._wrap_layout

	if not layout then
		self._wrap_layout = {
			dirty_from_line = math.max(1, tonumber(line1) or 1),
			change_id = false,
			rebuild_count = 0,
			average_rows_per_line = 1,
			last_reason = reason,
		}
		return
	end

	layout.dirty_from_line = math.min(layout.dirty_from_line or math.huge, math.max(1, tonumber(line1) or 1))
	layout.change_id = false
	layout.last_reason = reason
end

--- Keep cached wrap rows synchronized with width, font, and doc edits
-- @param self table wrapped text surface
-- @return table|nil layout cached wrap layout when wrapping is enabled
function wrap.ensure_wrap_layout(self)
	if not self:is_wrap_enabled() then
		return nil
	end

	local profile = get_profile_state(self)
	local start_time = profile and system.get_time() or nil
	local layout = self._wrap_layout or {
		dirty_from_line = 1,
		change_id = false,
		rebuild_count = 0,
		average_rows_per_line = 1,
	}
	local doc = self.doc
	local line_count = #doc.lines
	local change_id = (doc.get_change_id and doc:get_change_id()) or false
	local text_change = doc._last_text_change
	local wrap_width = self:get_wrap_width()
	local font = self:get_font()
	local line_height = self:get_line_height()
	local scroll_anchor = nil
	local has_metric_change =
		layout.wrap_width ~= wrap_width
		or layout.font ~= font
		or layout.line_height ~= line_height
		or layout.line_count ~= line_count and not text_change
	local did_reset = false
	local rebuilt_lines = 0
	local rebuilt_rows = 0

	layout.wrap_width = wrap_width
	layout.font = font
	layout.line_height = line_height
	layout.line_count = line_count

	if profile then
		profile.ensure_calls = profile.ensure_calls + 1
	end

	if has_metric_change or not layout.blocks then
		scroll_anchor = capture_scroll_anchor(self, layout)
		reset_estimated_blocks(layout, line_count)
		layout.dirty_from_line = line_count + 1
		layout.last_reason = layout.last_reason or "wrap_metric_change"
		did_reset = true
	elseif layout.change_id ~= change_id then
		if type(text_change) == "table" and text_change.change_id == change_id then
			invalidate_blocks_from_line(layout, text_change.line1 or 1, line_count)
			layout.last_reason = text_change.kind or "wrap_text_change"
		else
			reset_estimated_blocks(layout, line_count)
			layout.last_reason = "wrap_unknown_change"
		end
		layout.dirty_from_line = line_count + 1
		did_reset = true
	elseif (layout.dirty_from_line or (line_count + 1)) <= line_count then
		invalidate_blocks_from_line(layout, layout.dirty_from_line, line_count)
		layout.dirty_from_line = line_count + 1
		layout.last_reason = layout.last_reason or "wrap_dirty_suffix"
		did_reset = true
	end

	if scroll_anchor then
		restore_scroll_anchor(self, layout, scroll_anchor)
	end

	local visible_lines, visible_rows = ensure_visible_window_exact(self, layout)
	rebuilt_lines = rebuilt_lines + visible_lines
	rebuilt_rows = rebuilt_rows + visible_rows

	layout.change_id = change_id
	self._wrap_layout = layout

	if profile and start_time then
		local elapsed_ms = (system.get_time() - start_time) * 1000

		profile.last_ensure_ms = elapsed_ms
		profile.total_ensure_ms = profile.total_ensure_ms + elapsed_ms

		if did_reset or rebuilt_lines > 0 or rebuilt_rows > 0 then
			layout.rebuild_count = (layout.rebuild_count or 0) + 1
			profile.rebuild_calls = profile.rebuild_calls + 1
			profile.total_rebuild_ms = profile.total_rebuild_ms + elapsed_ms
			profile.max_rebuild_ms = math.max(profile.max_rebuild_ms or 0, elapsed_ms)
			profile.last_rebuild_ms = elapsed_ms
			profile.last_rebuilt_lines = rebuilt_lines
			profile.last_rebuilt_rows = rebuilt_rows
			profile.total_rebuilt_lines = profile.total_rebuilt_lines + rebuilt_lines
			profile.total_rebuilt_rows = profile.total_rebuilt_rows + rebuilt_rows
			profile.last_rebuild_reason = layout.last_reason
			if type(text_change) == "table" and text_change.change_id == change_id then
				profile.last_rebuild_from_line = text_change.line1 or 1
			elseif has_metric_change then
				profile.last_rebuild_from_line = 1
			else
				profile.last_rebuild_from_line = layout.dirty_from_line or 1
			end
		else
			profile.ensure_cache_hits = profile.ensure_cache_hits + 1
		end
	else
		if did_reset or rebuilt_lines > 0 or rebuilt_rows > 0 then
			layout.rebuild_count = (layout.rebuild_count or 0) + 1
		end
	end

	return layout
end

--- Return total wrapped-row count for this surface
-- @param self table wrapped text surface
-- @return number total_rows total wrapped rows or logical lines
function wrap.get_visual_row_count(self)
	if not self:is_wrap_enabled() then
		return #self.doc.lines
	end

	local layout = self:ensure_wrap_layout()
	return math.max(1, layout and layout.total_rows or #self.doc.lines)
end

--- Return wrapped-row span for one logical line
-- @param self table wrapped text surface
-- @param line number logical line
-- @return number first_row first wrapped row for the line
-- @return number last_row last wrapped row for the line
function wrap.get_visual_rows_for_line(self, line)
	if not self:is_wrap_enabled() then
		line = common.clamp(line, 1, #self.doc.lines)
		return line, line
	end

	local layout = self:ensure_wrap_layout()

	line = common.clamp(line, 1, #self.doc.lines)
	local block_index = get_block_index_for_line(line)
	local block = ensure_block_exact(self, layout, block_index)
	local info = get_block_line_info(block, line)

	return block.row_start + info.row_start - 1, block.row_start + info.row_start + info.row_count - 2
end

--- Return the wrapped row and row info for one logical position
-- @param self table wrapped text surface
-- @param line number logical line
-- @param col number logical byte column
-- @param line_end boolean|nil keep positions at one wrapped row end
-- @return number row wrapped row that contains the position
-- @return table|nil row_info wrapped row metadata for the position
function wrap.get_visual_row_for_position(self, line, col, line_end)
	if not self:is_wrap_enabled() then
		line = common.clamp(line, 1, #self.doc.lines)
		return line, nil
	end

	local layout = self:ensure_wrap_layout()

	line, col = self.doc:sanitize_position(line, col)
	local block_index = get_block_index_for_line(line)
	local block = ensure_block_exact(self, layout, block_index)
	local info = get_block_line_info(block, line)
	local row_start = info.row_start
	local row_stop = row_start + info.row_count - 1
	local resolved_local_row = row_start

	for local_row = row_start, row_stop do
		local next_start = (local_row < row_stop) and block.row_start_cols[local_row + 1] or nil

		if next_start and col > next_start then
			resolved_local_row = local_row + 1
		elseif next_start and col == next_start and not line_end then
			resolved_local_row = local_row + 1
		else
			break
		end
	end

	local resolved_row = block.row_start + resolved_local_row - 1

	return resolved_row, get_block_row_info(block, resolved_local_row)
end

--- Convert one wrapped-row x offset back into a logical position
-- @param self table wrapped text surface
-- @param row number wrapped row
-- @param x number wrapped-row local x offset
-- @return number line logical line resolved from the row
-- @return number col logical byte column nearest to the x offset
function wrap.get_position_for_visual_row_x(self, row, x)
	local layout = self:ensure_wrap_layout()

	if not layout or layout.total_rows <= 0 then
		return 1, 1
	end

	local _, block, local_row = ensure_row_block_exact(self, layout, row)

	if not block then
		return 1, 1
	end

	x = tonumber(x) or 0

	local raw_x = block.row_start_xs[local_row] + math.max(0, x - block.row_indent_xs[local_row])
	local line = block.row_lines[local_row]
	local col = self:get_x_offset_col(line, raw_x)

	col = common.clamp(col, block.row_start_cols[local_row], block.row_end_cols[local_row])

	return line, col
end

--- Return wrapped-row metadata for one row index
-- @param self table wrapped text surface
-- @param row number wrapped row
-- @return table|nil row_info wrapped row metadata when available
function wrap.get_visual_row_info(self, row)
	if not self:is_wrap_enabled() then
		return nil
	end

	local layout = self:ensure_wrap_layout()

	if not layout or layout.total_rows <= 0 then
		return nil
	end

	local _, block, local_row = ensure_row_block_exact(self, layout, row)

	if not block then
		return nil
	end

	return get_block_row_info(block, local_row)
end

--- Resolve one possibly estimated wrapped row to an exact visible row
-- @param self table wrapped text surface
-- @param row number wrapped row candidate
-- @return number resolved_row exact wrapped row after lazy exactification
-- @return table|nil row_info wrapped row metadata for the resolved row
function wrap.resolve_visual_row(self, row)
	if not self:is_wrap_enabled() then
		row = common.clamp(math.floor(row or 1), 1, #self.doc.lines)
		return row, nil
	end

	local layout = self:ensure_wrap_layout()

	if not layout or layout.total_rows <= 0 then
		return 1, nil
	end

	local _, block, local_row, resolved_row = ensure_row_block_exact(self, layout, row)

	if not block then
		return 1, nil
	end

	return resolved_row, get_block_row_info(block, local_row)
end

--- Return row-local x offset for a logical position inside its wrapped row
-- @param self table wrapped text surface
-- @param line number logical line
-- @param col number logical byte column
-- @param line_end boolean|nil keep positions at one wrapped row end
-- @return number x wrapped-row local x offset for the logical position
function wrap.get_visual_position_x(self, line, col, line_end)
	if not self:is_wrap_enabled() then
		return self:get_col_x_offset(line, col)
	end

	local _, row_info = self:get_visual_row_for_position(line, col, line_end)

	return row_info.indent_x + (self:get_col_x_offset(line, col) - row_info.start_x)
end

--- Return one shallow row info table from the current layout when that row is exact
-- @param layout table wrap layout
-- @param row number wrapped row
-- @return table|nil row_info wrapped row metadata when the block is exact
function wrap.get_layout_row_info(layout, row)
	if not layout or not layout.total_rows or layout.total_rows <= 0 then
		return nil
	end

	local _, block = find_block_for_row(layout, row)

	if not block or not block.exact then
		return nil
	end

	local local_row = common.clamp(row - block.row_start + 1, 1, block.row_total)

	return get_block_row_info(block, local_row)
end

-- Record one wrapped draw timing sample when profiling is enabled
-- @param self table wrapped text surface
-- @param elapsed_ms number wrapped draw time in milliseconds
-- @param drawn_rows number visible wrapped rows drawn this frame
-- @param drawn_lines number visible logical lines drawn this frame
function wrap.record_draw_profile(self, elapsed_ms, drawn_rows, drawn_lines)
	local profile = get_profile_state(self)

	if not profile then
		return
	end

	profile.draw_calls = profile.draw_calls + 1
	profile.total_draw_ms = profile.total_draw_ms + (elapsed_ms or 0)
	profile.max_draw_ms = math.max(profile.max_draw_ms or 0, elapsed_ms or 0)
	profile.last_draw_ms = elapsed_ms or 0
	profile.last_drawn_rows = drawn_rows or 0
	profile.last_drawn_lines = drawn_lines or 0
	profile.total_drawn_rows = profile.total_drawn_rows + (drawn_rows or 0)
	profile.total_drawn_lines = profile.total_drawn_lines + (drawn_lines or 0)
end

--- Return one copy of the current wrap profiling counters
-- @param self table wrapped text surface
-- @return table|nil snapshot immutable summary table when profiling is enabled
function wrap.get_profile_snapshot(self)
	local profile = get_profile_state(self)

	if not profile then
		return nil
	end

	local snapshot = {}

	for key, value in pairs(profile) do
		snapshot[key] = value
	end

	return snapshot
end

--- Reset wrap profiling counters for this surface
-- @param self table wrapped text surface
function wrap.reset_profile(self)
	self._wrap_profile = nil
end

return wrap
