-- lx-bundle:true

local common = require("core.common")
local config = require("core.config")
local file = require("core.text.file")
local hook = require("core.hook")
local native_text = require("text") -- Reuse the native line scan for large multiline edits
local sel = require("core.text.select")
local undo = require("core.doc.undo")
local utf8help = require("core.text.utf8help")

local edit = {}

local sort_positions = sel.sort_positions
local push_undo = undo.push_undo
local count_trailing_empty = file.count_trailing_empty
local last_nonempty_line = file.last_nonempty_line

--- Clears redo history and gives divergent edits a new history identity
-- @param self table text document instance
local function clear_redo(self)
	if self.redo_stack.idx > 1 then
		self._history_branch = self._history_branch + 1
	end

	self.redo_stack = { idx = 1 }
end

edit.clear_redo = clear_redo

--- Returns one indentation step based on tab/space settings
-- This remains the block-indent unit for selected lines
-- @param doc table|nil text document instance
-- @return string text one literal tab or one full space indent unit
function edit.get_indent_string(doc)
	local indent_type = doc and doc:get_indent_type() or config.indent_type

	if indent_type == "tabs" then
		return "\t"
	end

	-- Space indentation follows the shared indentation grid size
	local indent_size = doc and doc:get_indent_size()
		or math.max(1, math.floor(tonumber(config.indent_size) or 4))

	return string.rep(" ", indent_size)
end

--- Returns the spaces needed to reach the next grid stop
-- @param visual_column number zero-based visual column
-- @param indent_size number active indentation grid size
-- @return string text spaces that advance to the next grid stop
local function get_spaces_to_next_grid_stop(visual_column, indent_size)
	local space_count = indent_size - (visual_column % indent_size)

	return string.rep(" ", space_count)
end

--- Returns visual column before one caret, including literal tab advances
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @param indent_size number active indentation grid size
-- @return number visual_column zero-based visual column
local function get_text_visual_column(doc, line, col, indent_size)
	local text = doc:get_logical_line(line)
	local physical_col = math.min(col, #text + 1)
	local prefix = text:sub(1, physical_col - 1)
	local visual_column = 0
	local offset = 1

	while offset <= #prefix do
		local tab_offset = prefix:find("\t", offset, true)

		if not tab_offset then
			visual_column = visual_column + utf8help.width(prefix:sub(offset))
			break
		end

		visual_column = visual_column + utf8help.width(prefix:sub(offset, tab_offset - 1))
		visual_column = visual_column + indent_size - (visual_column % indent_size)
		offset = tab_offset + 1
	end

	return visual_column + math.max(0, col - physical_col)
end

--- Returns the text to insert for one Tab key press
-- Tab indentation stays literal while spaces advance to the same visual grid
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return string text one tab press insertion
function edit.get_indent_input(doc, line, col)
	if doc:get_indent_type() == "tabs" then
		return "\t"
	end

	local indent_size = doc:get_indent_size()
	local visual_column = get_text_visual_column(doc, line, col, indent_size)

	return get_spaces_to_next_grid_stop(visual_column, indent_size)
end

--- Build one leading indentation string for a visual width
-- @param visual_width number indentation width in visual columns
-- @param indent_size number active indentation grid size
-- @param indent_type string target 'spaces' or 'tabs'
-- @return string indentation converted leading whitespace
local function build_converted_indentation(visual_width, indent_size, indent_type)
	if indent_type == "tabs" then
		local tab_count = math.floor(visual_width / indent_size)
		local space_count = visual_width % indent_size

		return string.rep("\t", tab_count) .. string.rep(" ", space_count)
	end

	return string.rep(" ", visual_width)
end

--- Map a visual column into one converted indentation string
-- Positions inside a new tab move to its right edge because stored text cannot place a caret inside a tab
-- @param visual_column number original visual indentation column
-- @param conversion table line conversion details
-- @return number col converted document column
local function get_converted_indent_col(visual_column, conversion)
	if conversion.indent_type == "spaces" then
		return visual_column + 1
	end

	local tab_count = math.floor(conversion.visual_width / conversion.indent_size)
	local tabbed_width = tab_count * conversion.indent_size

	if visual_column <= tabbed_width then
		return math.min(tab_count, math.ceil(visual_column / conversion.indent_size)) + 1
	end

	return tab_count + visual_column - tabbed_width + 1
end

--- Map one selection endpoint through a leading indentation conversion
-- @param self table text document instance
-- @param line number original line
-- @param col number original column
-- @param conversions table conversion details keyed by line
-- @return number line unchanged line
-- @return number col converted column
local function map_converted_indent_position(self, line, col, conversions)
	local conversion = conversions[line]
	if not conversion then
		return line, col
	end

	if col >= conversion.old_end_col then
		return line, conversion.new_end_col + col - conversion.old_end_col
	end

	local visual_column = self:get_indent_visual_column(line, col) or 0

	return line, get_converted_indent_col(visual_column, conversion)
end

--- Convert every line's leading indentation as one undoable edit
-- @param self table text document instance
-- @param indent_type string target 'spaces' or 'tabs'
-- @return boolean changed true when leading whitespace changed
function edit.convert_indentation(self, indent_type)
	if indent_type ~= "spaces" and indent_type ~= "tabs" then
		return false
	end

	local indent_size = self:get_indent_size()
	local replacements = {}
	local conversions = {}

	for line = 1, #self.lines do
		local old_end_col = self:get_indent_end_col(line)
		local old_indentation = self.lines[line]:sub(1, old_end_col - 1)
		local visual_width = self:get_indent_visual_width(line)
		local new_indentation = build_converted_indentation(visual_width, indent_size, indent_type)

		if old_indentation ~= new_indentation then
			replacements[#replacements + 1] = {
				line1 = line,
				col1 = 1,
				line2 = line,
				col2 = old_end_col,
				text = new_indentation,
			}
			conversions[line] = {
				old_end_col = old_end_col,
				new_end_col = #new_indentation + 1,
				visual_width = visual_width,
				indent_size = indent_size,
				indent_type = indent_type,
			}
		end
	end

	if #replacements == 0 then
		return false
	end

	local selections, primary_index = self:get_selections()
	local after_selections = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line1, col1 = map_converted_indent_position(self, selection[1], selection[2], conversions)
		local line2, col2 = map_converted_indent_position(self, selection[3], selection[4], conversions)
		after_selections[index] = { line1, col1, line2, col2, id = selection.id }
	end

	return self:replace_ranges(replacements, after_selections, primary_index)
end

--- Return the lines included by the primary selection
-- A later endpoint at column one does not include that final line
-- @param doc table text document
-- @return number line1 first included line
-- @return number line2 last included line
function edit.get_selected_line_range(doc)
	local line1, _, line2, col2 = doc:get_selection(true)

	if line2 > line1 and col2 == 1 then
		line2 = line2 - 1
	end

	return line1, line2
end

--- Insert text at column one for each selected line
-- @param doc table text document
-- @param text string text to insert
-- @param skip_empty boolean|nil true to leave blank lines unchanged
function edit.insert_at_start_of_selected_lines(doc, text, skip_empty)
	local selection_line1, col1, selection_line2, col2, swap = doc:get_selection(true)
	local line1, line2 = edit.get_selected_line_range(doc)
	local changed = {}

	for line = line1, line2 do
		local line_text = doc.lines[line]
		if not skip_empty or line_text:find("%S") then
			doc:insert(line, 1, text)
			changed[line] = true
		end
	end

	doc:set_selection(
		selection_line1,
		col1 + (changed[selection_line1] and #text or 0),
		selection_line2,
		col2 + (changed[selection_line2] and #text or 0),
		swap
	)
end

--- Remove leading text from each selected line when present
-- @param doc table text document
-- @param text string text to remove
-- @param skip_empty boolean|nil true to leave blank lines unchanged
function edit.remove_from_start_of_selected_lines(doc, text, skip_empty)
	local selection_line1, col1, selection_line2, col2, swap = doc:get_selection(true)
	local line1, line2 = edit.get_selected_line_range(doc)
	local changed = {}

	for line = line1, line2 do
		local line_text = doc.lines[line]
		if line_text:sub(1, #text) == text and (not skip_empty or line_text:find("%S")) then
			doc:remove(line, 1, line, #text + 1)
			changed[line] = true
		end
	end

	doc:set_selection(
		selection_line1,
		math.max(1, col1 - (changed[selection_line1] and #text or 0)),
		selection_line2,
		math.max(1, col2 - (changed[selection_line2] and #text or 0)),
		swap
	)
end

-- Treat selection end at column 1 as an exclusive line bound for line moves
function edit.selected_last_line_for_move(line1, col1, line2, col2)
	if line2 > line1 and col2 == 1 then
		return line2 - 1
	end
	return line2
end

--- Split text into editable fragments while preserving a final empty fragment
-- @param text string source text
-- @return table fragments text parts without line endings
local function split_lines(text)
	return native_text.split_lines(text, { keep_eol = false })
end

-- Replaces 'remove' items at index 'at' with an optional insert list
local function splice(t, at, remove, insert)
	insert = insert or {}
	local offset = #insert - remove
	local old_len = #t

	if offset < 0 then
		for i = at - offset, old_len - offset do
			t[i + offset] = t[i]
		end
	elseif offset > 0 then
		for i = old_len, at, -1 do
			t[i + offset] = t[i]
		end
	end

	for i, item in ipairs(insert) do
		t[at + i - 1] = item
	end
end

-- Overwrite text without inserting or removing lines in gridlock mode
local function gridlock_overwrite(doc, text)
	local line, col = doc:get_selection()
	local parts = split_lines(text)

	local last_line = #doc.lines

	for i, part in ipairs(parts) do
		if line > last_line then
			break
		end

		local line_text = doc.lines[line]
		local line_len = #line_text

		if line_text:sub(-1) == "\n" then
			line_len = line_len - 1
		end

		local start_col = (i == 1) and col or 1
		start_col = common.clamp(start_col, 1, line_len + 1)

		-- Clear the line from start_col before inserting pasted text
		if start_col <= line_len + 1 then
			doc:raw_remove(line, start_col, line, line_len + 1, doc.undo_stack, system.get_time())
		end

		if #part > 0 then
			doc:raw_insert(line, start_col, part, doc.undo_stack, system.get_time())
			col = start_col + #part
		else
			col = start_col
		end

		line = line + 1
	end

	doc:set_selection(line - 1, col)
end

-- Clear selection contents without changing line count in gridlock mode
local function gridlock_clear_selection(doc)
	local line1, col1, line2, col2 = doc:get_selection(true)

	if line1 == line2 then
		if col1 ~= col2 then
			doc:raw_remove(line1, col1, line1, col2, doc.undo_stack, system.get_time())
		end

		return
	end

	-- Clear tail of first line
	local line_text = doc.lines[line1]
	local line_len = #line_text

	if line_text:sub(-1) == "\n" then
		line_len = line_len - 1
	end

	doc:raw_remove(line1, col1, line1, line_len + 1, doc.undo_stack, system.get_time())

	-- Clear full middle lines
	for line = line1 + 1, line2 - 1 do
		line_text = doc.lines[line]
		line_len = #line_text

		if line_text:sub(-1) == "\n" then
			line_len = line_len - 1
		end

		if line_len > 0 then
			doc:raw_remove(line, 1, line, line_len + 1, doc.undo_stack, system.get_time())
		end
	end

	-- Clear head of last line
	if col2 > 1 then
		doc:raw_remove(line2, 1, line2, col2, doc.undo_stack, system.get_time())
	end
end

--- Adjust the cached content size without rescanning document lines
-- @param doc table changed text document
-- @param byte_delta number estimated byte difference
-- @return nil
local function adjust_content_size(doc, byte_delta)
	doc.content_size_bytes = math.max(0, (tonumber(doc.content_size_bytes) or 0) + byte_delta)
	doc.content_size_known = true
end

--- Insert text at a position and record inverse undo operations
-- @param self table text document instance
function edit.raw_insert(self, line, col, text, undo_stack, time)
	if text == "" then
		return
	end

	-- Longest-line cache from load is no longer guaranteed after edits
	self.longest_line_length = nil
	self.longest_line_index = nil

	-- Gridlock prevents changes to line count
	local core = package.loaded["core"]

	if core.gridlock and text:find("\n", 1, true) then
		core.error("Gridlock: line count cannot change")
		return
	end

	local sel1, sel2, sel3, sel4 = self:get_selection()
	local pre_last_nonempty = last_nonempty_line(self.lines)

	-- Split text into lines and merge with line at insertion point
	local lines = split_lines(text)
	local before = self.lines[line]:sub(1, col - 1)
	local after = self.lines[line]:sub(col)

	for i = 1, #lines - 1 do
		lines[i] = lines[i] .. "\n"
	end

	lines[1] = before .. lines[1]
	lines[#lines] = lines[#lines] .. after

	-- Splice lines into line array
	splice(self.lines, line, 1, lines)
	adjust_content_size(self, #text)

	-- Push undo
	local line2, col2 = self:position_offset(line, col, #text)

	push_undo(undo_stack, time, "selection", sel1, sel2, sel3, sel4)
	push_undo(undo_stack, time, "remove", line, col, line2, col2)
	self._last_text_change = {
		change_id = self:get_change_id(),
		line1 = line,
		old_line2 = line,
		new_line2 = line2,
		line2 = line2,
		net_lines = line2 - line,
		kind = "insert",
	}

	-- Notify debounced line-change subscribers about the affected range
	core.notify_line_change(self, line, line2, "insert", {
		added_lines = math.max(0, #lines - 1),
		removed_lines = 0,
	})

	-- Update highlighter and assure selection is in bounds
	self.highlighter:invalidate(line)
	self:sanitize_selection()

	-- Update EOF EOL count only if edit touched final lines
	if line >= pre_last_nonempty then
		self.eof_eol_count = count_trailing_empty(self.lines)
	end

end

--- Remove a text range and record inverse undo operations
-- @param self table text document instance
function edit.raw_remove(self, line1, col1, line2, col2, undo_stack, time)
	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)

	if line1 == line2 and col1 == col2 then
		return
	end

	-- Longest-line cache from load is no longer guaranteed after edits
	self.longest_line_length = nil
	self.longest_line_index = nil

	-- Gridlock prevents changes to line count
	local core = package.loaded["core"]

	if core.gridlock and line1 ~= line2 then
		core.error("Gridlock: line count cannot change")
		return
	end

	local sel1, sel2, sel3, sel4 = self:get_selection()
	local pre_last_nonempty = last_nonempty_line(self.lines)

	-- Push undo
	local text = self:get_text(line1, col1, line2, col2)
	push_undo(undo_stack, time, "selection", sel1, sel2, sel3, sel4)
	push_undo(undo_stack, time, "insert", line1, col1, text)
	self._last_text_change = {
		change_id = self:get_change_id(),
		line1 = line1,
		old_line2 = line2,
		new_line2 = line1,
		line2 = line2,
		net_lines = line1 - line2,
		kind = "remove",
	}

	-- Get line content before and after removed text
	local before = self.lines[line1]:sub(1, col1 - 1)
	local after = self.lines[line2]:sub(col2)

	-- Splice line into line array
	splice(self.lines, line1, line2 - line1 + 1, { before .. after })
	adjust_content_size(self, -#text)

	-- Notify debounced line-change subscribers about the affected range
	core.notify_line_change(self, line1, line2, "remove", {
		added_lines = 0,
		removed_lines = math.max(0, line2 - line1),
	})

	-- Update highlighter and assure selection is in bounds
	self.highlighter:invalidate(line1)
	self:sanitize_selection()

	-- Update EOF EOL count only if edit touched final lines
	if line2 >= pre_last_nonempty then
		self.eof_eol_count = count_trailing_empty(self.lines)
	end
end

--- Replace a range atomically and record inverse replace undo data
-- @param self table text document instance
function edit.raw_replace(self, line1, col1, line2, col2, new_text, undo_stack, time, old_text_hint)
	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)
	local lines = split_lines(new_text)
	local core = package.loaded["core"]

	-- Gridlock permits replacement only when the document keeps the same line count
	if core.gridlock and #lines - 1 ~= line2 - line1 then
		core.error("Gridlock: line count cannot change")
		return false
	end

	-- Longest-line cache from load is no longer guaranteed after edits
	self.longest_line_length = nil
	self.longest_line_index = nil
	local sel1, sel2, sel3, sel4 = self:get_selection()

	local pre_last_nonempty = last_nonempty_line(self.lines)
	local old_text = old_text_hint or self:get_text(line1, col1, line2, col2)

	-- Split replacement text into lines and merge with surrounding range bounds
	local before = self.lines[line1]:sub(1, col1 - 1)
	local after = self.lines[line2]:sub(col2)

	for i = 1, #lines - 1 do
		lines[i] = lines[i] .. "\n"
	end

	lines[1] = before .. lines[1]
	lines[#lines] = lines[#lines] .. after

	-- Replace the full range in a single splice to avoid insert and remove churn
	splice(self.lines, line1, line2 - line1 + 1, lines)
	adjust_content_size(self, #new_text - #old_text)

	-- Store one atomic inverse replace entry instead of paired insert and remove
	local new_line2, new_col2 = self:position_offset(line1, col1, #new_text)
	push_undo(undo_stack, time, "selection", sel1, sel2, sel3, sel4)
	push_undo(undo_stack, time, "replace", line1, col1, new_line2, new_col2, old_text)
	self._last_text_change = {
		change_id = self:get_change_id(),
		line1 = line1,
		old_line2 = line2,
		new_line2 = new_line2,
		line2 = math.max(line2, new_line2),
		net_lines = new_line2 - line2,
		kind = "replace",
	}

	-- Notify debounced line-change subscribers about the affected range
	local old_line_count = line2 - line1 + 1
	local new_line_count = #lines

	core.notify_line_change(self, line1, math.max(line2, new_line2), "replace", {
		added_lines = math.max(0, new_line_count - old_line_count),
		removed_lines = math.max(0, old_line_count - new_line_count),
	})

	self.highlighter:invalidate(line1)
	self:sanitize_selection()

	-- Update EOF EOL count only if edit touched final lines
	if line2 >= pre_last_nonempty or new_line2 >= pre_last_nonempty then
		self.eof_eol_count = count_trailing_empty(self.lines)
	end

	return true
end

--- Insert text with undo and redo reset and sanitized position handling
-- @param self table text document instance
function edit.insert(self, line, col, text)
	if self.is_binary_placeholder or text == "" then
		return
	end

	clear_redo(self)
	line, col = self:sanitize_position(line, col)
	self:raw_insert(line, col, text, self.undo_stack, system.get_time())
end

--- Remove a sorted range with undo and redo reset
-- @param self table text document instance
function edit.remove(self, line1, col1, line2, col2)
	if self.is_binary_placeholder then
		return
	end

	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)
	if line1 == line2 and col1 == col2 then
		return
	end

	clear_redo(self)
	self:raw_remove(line1, col1, line2, col2, self.undo_stack, system.get_time())
end

--- Return true when every selection is an empty caret
-- @param selections table detached selection snapshots
-- @return boolean carets_only
local function selections_are_carets(selections)
	for index = 1, #selections do
		local selection = selections[index]

		if selection[1] ~= selection[3] or selection[2] ~= selection[4] then
			return false
		end
	end

	return true
end

--- Return true when the first position precedes the second
-- @param line1 number first line
-- @param col1 number first column
-- @param line2 number second line
-- @param col2 number second column
-- @return boolean before
local function position_before(line1, col1, line2, col2)
	return line1 < line2 or line1 == line2 and col1 < col2
end

--- Return true when the first position precedes or equals the second
-- @param line1 number first line
-- @param col1 number first column
-- @param line2 number second line
-- @param col2 number second column
-- @return boolean before_or_equal
local function position_before_or_equal(line1, col1, line2, col2)
	return not position_before(line2, col2, line1, col1)
end

--- Return line-break and trailing-byte counts for inserted text
-- @param text string inserted text
-- @return number line_break_count
-- @return number trailing_length
local function get_caret_input_shape(text)
	local line_break_count = 0
	local last_line_break = 0
	local search_from = 1

	while true do
		local line_break = text:find("\n", search_from, true)

		if not line_break then
			break
		end

		line_break_count = line_break_count + 1
		last_line_break = line_break
		search_from = line_break + 1
	end

	return line_break_count, #text - last_line_break
end

--- Map one later original caret through the preceding insertion
-- @param line number original line
-- @param col number original column
-- @param preceding table preceding insertion position
-- @return number line mapped line
-- @return number col mapped column
local function map_position_after_caret_input(line, col, preceding)
	if line == preceding.line then
		if preceding.line_break_count == 0 then
			return preceding.result_line, preceding.result_col + #preceding.text + col - preceding.col
		end

		return preceding.result_line + preceding.line_break_count,
			preceding.trailing_length + 1 + col - preceding.col
	end

	return preceding.result_line + preceding.line_break_count + line - preceding.line, col
end

--- Build positions and resulting selections for caret insertion
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
-- @param texts table text inserted at each caret
-- @return table operation undoable multicaret operation
local function create_caret_input_operation(selections, primary_index)
	return {
		action = "remove",
		positions = {},
		before_selections = selections,
		before_primary_index = primary_index,
		after_selections = {},
		after_primary_index = primary_index,
		inserted_lines = 0,
	}
end

--- Apply text at ordered carets while recording exact physical insertion ranges
-- Forward application lets earlier virtual carets materialize space before later carets are mapped
-- @param self table text document instance
-- @param operation table operation being filled
-- @param texts table text inserted at each caret
-- @param time number shared undo timestamp
-- @param materialize_virtual boolean|nil insert virtual gap spaces unless false
local function apply_caret_inputs(self, operation, texts, time, materialize_virtual)
	local text_shapes = {}
	local preceding
	local discarded_undo = { idx = 1 }

	for index = 1, #operation.before_selections do
		local selection = operation.before_selections[index]
		local line = selection[1]
		local col = selection[2]
		local text = texts[index]
		local result_line = line
		local result_col = col

		if preceding then
			result_line, result_col = map_position_after_caret_input(line, col, preceding)
		end

		local insert_line, insert_col = self:sanitize_position(result_line, result_col)
		if materialize_virtual ~= false and result_line == insert_line and result_col > insert_col then
			text = string.rep(" ", result_col - insert_col) .. text
		end

		local shape = text_shapes[text]

		if not shape then
			shape = { get_caret_input_shape(text) }
			text_shapes[text] = shape
		end

		local line_break_count = shape[1]
		local trailing_length = shape[2]

		local end_line = insert_line + line_break_count
		local end_col = (line_break_count == 0) and (insert_col + #text) or (trailing_length + 1)

		operation.positions[index] = {
			line = line,
			col = col,
			text = text,
			line_break_count = line_break_count,
			trailing_length = trailing_length,
			result_line = insert_line,
			result_col = insert_col,
			end_line = end_line,
			end_col = end_col,
		}
		operation.after_selections[index] = {
			end_line,
			end_col,
			end_line,
			end_col,
			id = selection.id,
		}
		preceding = operation.positions[index]
		operation.inserted_lines = operation.inserted_lines + line_break_count
		self:raw_insert(insert_line, insert_col, text, discarded_undo, time)
	end
end

--- Record the combined text-change span for cache consumers
-- @param self table text document instance
-- @param operation table multicaret operation
-- @param kind string edit kind
local function record_caret_input_change(self, operation, kind)
	local first_position = operation.positions[1]
	local last_position = operation.positions[#operation.positions]
	local inserted_lines = operation.inserted_lines

	if kind == "insert" then
		self._last_text_change = {
			change_id = self:get_change_id(),
			line1 = first_position.line,
			old_line2 = last_position.line,
			new_line2 = last_position.end_line,
			line2 = last_position.end_line,
			net_lines = inserted_lines,
			kind = kind,
		}
	else
		self._last_text_change = {
			change_id = self:get_change_id(),
			line1 = first_position.result_line,
			old_line2 = last_position.end_line,
			new_line2 = last_position.line,
			line2 = last_position.end_line,
			net_lines = -inserted_lines,
			kind = kind,
		}
	end
end

--- Apply one multicaret insertion or removal and record its inverse
-- @param self table text document instance
-- @param operation table multicaret operation
-- @param undo_stack table inverse undo stack
-- @param time number shared undo timestamp
function edit.replay_caret_text_input(self, operation, undo_stack, time)
	local inverse = {
		action = (operation.action == "remove") and "insert" or "remove",
		positions = operation.positions,
		before_selections = operation.before_selections,
		before_primary_index = operation.before_primary_index,
		after_selections = operation.after_selections,
		after_primary_index = operation.after_primary_index,
		inserted_lines = operation.inserted_lines,
	}
	local discarded_undo = { idx = 1 }

	push_undo(undo_stack, time, "caret_text_input", inverse)

	if operation.action == "remove" then
		for index = #operation.positions, 1, -1 do
			local position = operation.positions[index]

			self:raw_remove(
				position.result_line,
				position.result_col,
				position.end_line,
				position.end_col,
				discarded_undo,
				time
			)
		end

		self:set_selections(operation.before_selections, operation.before_primary_index)
		record_caret_input_change(self, operation, "remove")
	else
		for index = 1, #operation.positions do
			local position = operation.positions[index]
			self:raw_insert(position.result_line, position.result_col, position.text, discarded_undo, time)
		end

		self:set_selections(operation.after_selections, operation.after_primary_index)
		record_caret_input_change(self, operation, "insert")
	end
end

--- Insert text at every caret as one undoable edit
-- @param self table text document instance
-- @param texts table text inserted at each caret
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
local function text_input_at_carets(self, texts, selections, primary_index, materialize_virtual)
	local time = system.get_time()
	local operation = create_caret_input_operation(selections, primary_index)

	clear_redo(self)
	push_undo(self.undo_stack, time, "caret_text_input", operation)
	apply_caret_inputs(self, operation, texts, time, materialize_virtual)

	self:set_selections(operation.after_selections, operation.after_primary_index)
	record_caret_input_change(self, operation, "insert")
end

--- Map a later original position through the preceding replacement
-- @param line number original line
-- @param col number original column
-- @param preceding table preceding replacement
-- @return number line mapped line
-- @return number col mapped column
local function map_position_after_selection_replace(line, col, preceding)
	if line == preceding.line2 then
		return preceding.result_line2, preceding.result_col2 + col - preceding.col2
	end

	return preceding.result_line2 + line - preceding.line2, col
end

--- Build one grouped replacement operation for every active selection
-- @param self table text document instance
-- @param texts table replacement text for each selection
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
-- @return table operation undoable grouped replacement
local function build_selection_replace_operation(self, texts, selections, primary_index)
	local replacements = {}
	local after_selections = {}
	local line_delta = 0
	local preceding

	for index = 1, #selections do
		local selection = selections[index]
		local line1, col1, line2, col2 = sort_positions(
			selection[1], selection[2], selection[3], selection[4]
		)
		local new_text = texts[index]
		local line_break_count, trailing_length = get_caret_input_shape(new_text)
		local result_line1 = line1
		local result_col1 = col1

		if preceding then
			result_line1, result_col1 = map_position_after_selection_replace(line1, col1, preceding)
		end

		local result_line2 = result_line1 + line_break_count
		local result_col2 = line_break_count == 0
			and result_col1 + #new_text
			or trailing_length + 1
		local replacement = {
			line1 = line1,
			col1 = col1,
			line2 = line2,
			col2 = col2,
			old_text = self:get_text(line1, col1, line2, col2),
			new_text = new_text,
			result_line1 = result_line1,
			result_col1 = result_col1,
			result_line2 = result_line2,
			result_col2 = result_col2,
		}

		replacements[index] = replacement
		after_selections[index] = {
			result_line2,
			result_col2,
			result_line2,
			result_col2,
			id = selection.id,
		}
		line_delta = line_delta + line_break_count - (line2 - line1)
		preceding = replacement
	end

	return {
		action = "restore",
		replacements = replacements,
		before_selections = selections,
		before_primary_index = primary_index,
		after_selections = after_selections,
		after_primary_index = primary_index,
		line_delta = line_delta,
	}
end

--- Return true when any grouped replacement would change document line count
-- @param operation table grouped replacement operation
-- @return boolean changes_line_count
local function selection_replace_changes_line_count(operation)
	for index = 1, #operation.replacements do
		local replacement = operation.replacements[index]
		if replacement.line2 - replacement.line1 ~= replacement.result_line2 - replacement.result_line1 then
			return true
		end
	end

	return false
end

--- Record the combined text-change span for grouped replacements
-- @param self table text document instance
-- @param operation table grouped replacement operation
-- @param restoring boolean true when restoring original text
local function record_selection_replace_change(self, operation, restoring)
	local first = operation.replacements[1]
	local last = operation.replacements[#operation.replacements]
	local old_line2 = restoring and last.result_line2 or last.line2
	local new_line2 = restoring and last.line2 or last.result_line2

	self._last_text_change = {
		change_id = self:get_change_id(),
		line1 = restoring and first.result_line1 or first.line1,
		old_line2 = old_line2,
		new_line2 = new_line2,
		line2 = math.max(old_line2, new_line2),
		net_lines = restoring and -operation.line_delta or operation.line_delta,
		kind = "replace",
	}
end

--- Apply one prepared grouped replacement operation
-- @param self table text document instance
-- @param operation table grouped replacement operation
-- @param time number shared undo timestamp
local function apply_selection_replace_operation(self, operation, time)
	local discarded_undo = { idx = 1 }

	for index = #operation.replacements, 1, -1 do
		local replacement = operation.replacements[index]

		self:raw_replace(
			replacement.line1,
			replacement.col1,
			replacement.line2,
			replacement.col2,
			replacement.new_text,
			discarded_undo,
			time,
			replacement.old_text
		)
	end

	self:set_selections(operation.after_selections, operation.after_primary_index)
	record_selection_replace_change(self, operation, false)
end

--- Apply or restore grouped selection replacements and record their inverse
-- @param self table text document instance
-- @param operation table grouped replacement operation
-- @param undo_stack table inverse undo stack
-- @param time number shared undo timestamp
function edit.replay_selection_text_replace(self, operation, undo_stack, time)
	local core = package.loaded["core"]

	if core.gridlock and selection_replace_changes_line_count(operation) then
		core.error("Gridlock: line count cannot change")
		return false
	end

	local restoring = operation.action == "restore"
	local inverse = {
		action = restoring and "apply" or "restore",
		sequential = operation.sequential,
		replacements = operation.replacements,
		before_selections = operation.before_selections,
		before_primary_index = operation.before_primary_index,
		after_selections = operation.after_selections,
		after_primary_index = operation.after_primary_index,
		line_delta = operation.line_delta,
	}
	local discarded_undo = { idx = 1 }

	push_undo(undo_stack, time, "selection_text_replace", inverse)

	if restoring then
		for index = #operation.replacements, 1, -1 do
			local replacement = operation.replacements[index]
			self:raw_replace(
				replacement.result_line1,
				replacement.result_col1,
				replacement.result_line2,
				replacement.result_col2,
				replacement.old_text,
				discarded_undo,
				time,
				replacement.new_text
			)
		end
	else
		local first_index = operation.sequential and 1 or #operation.replacements
		local last_index = operation.sequential and #operation.replacements or 1
		local step = operation.sequential and 1 or -1

		for index = first_index, last_index, step do
			local replacement = operation.replacements[index]
			self:raw_replace(
				replacement.line1,
				replacement.col1,
				replacement.line2,
				replacement.col2,
				replacement.new_text,
				discarded_undo,
				time,
				replacement.old_text
			)
		end
	end

	if restoring then
		self:set_selections(operation.before_selections, operation.before_primary_index)
	else
		self:set_selections(operation.after_selections, operation.after_primary_index)
	end

	record_selection_replace_change(self, operation, restoring)

	return true
end

--- Return whether any selection endpoint is beyond its physical line end
-- @param self table text document instance
-- @param selections table detached selection snapshots
-- @return boolean virtual
local function selections_use_virtual_space(self, selections)
	for index = 1, #selections do
		local selection = selections[index]

		if selection[2] > self:get_line_end_col(selection[1])
			or selection[4] > self:get_line_end_col(selection[3])
		then
			return true
		end
	end

	return false
end

--- Map one later source position through a completed sequential replacement
-- @param line number original source line
-- @param col number original source column
-- @param preceding table preceding replacement
-- @return number line mapped current line
-- @return number col mapped current column
local function map_position_after_virtual_replace(line, col, preceding)
	if line == preceding.source_line2 then
		return preceding.result_line2, preceding.result_col2 + col - preceding.source_col2
	end

	return preceding.result_line2 + line - preceding.source_line2, col
end

--- Replace virtual selections in forward order so each next gap sees earlier materialized space
-- @param self table text document instance
-- @param texts table replacement text for each selection
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
-- @param materialize_virtual boolean|nil insert virtual gap spaces unless false
-- @return boolean changed
local function replace_virtual_selections(self, texts, selections, primary_index, materialize_virtual)
	local operation = {
		action = "restore",
		sequential = true,
		replacements = {},
		before_selections = selections,
		before_primary_index = primary_index,
		after_selections = {},
		after_primary_index = primary_index,
		line_delta = 0,
	}
	local core = package.loaded["core"]

	if core.gridlock then
		for index = 1, #selections do
			local line1, _, line2 = sort_positions(
				selections[index][1], selections[index][2], selections[index][3], selections[index][4]
			)
			local line_break_count = get_caret_input_shape(texts[index])

			if line_break_count ~= line2 - line1 then
				core.error("Gridlock: line count cannot change")

				return false
			end
		end
	end

	local time = system.get_time()
	local discarded_undo = { idx = 1 }
	local preceding

	clear_redo(self)
	push_undo(self.undo_stack, time, "selection_text_replace", operation)

	for index = 1, #selections do
		local selection = selections[index]
		local source_line1, source_col1, source_line2, source_col2 = sort_positions(
			selection[1], selection[2], selection[3], selection[4]
		)
		local mapped_line1, mapped_col1 = source_line1, source_col1
		local mapped_line2, mapped_col2 = source_line2, source_col2

		if preceding then
			mapped_line1, mapped_col1 = map_position_after_virtual_replace(source_line1, source_col1, preceding)
			mapped_line2, mapped_col2 = map_position_after_virtual_replace(source_line2, source_col2, preceding)
		end

		local line1, col1 = self:sanitize_position(mapped_line1, mapped_col1)
		local line2, col2 = self:sanitize_position(mapped_line2, mapped_col2)
		line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)
		local old_text = self:get_text(line1, col1, line2, col2)
		local new_text = texts[index]

		if materialize_virtual ~= false and mapped_line1 == mapped_line2 and mapped_col1 > col1 then
			new_text = string.rep(" ", mapped_col1 - col1) .. new_text
		end

		local line_break_count, trailing_length = get_caret_input_shape(new_text)
		local result_line2 = line1 + line_break_count
		local result_col2 = line_break_count == 0 and col1 + #new_text or trailing_length + 1
		local replacement = {
			line1 = line1,
			col1 = col1,
			line2 = line2,
			col2 = col2,
			source_line2 = source_line2,
			source_col2 = source_col2,
			old_text = old_text,
			new_text = new_text,
			result_line1 = line1,
			result_col1 = col1,
			result_line2 = result_line2,
			result_col2 = result_col2,
		}

		operation.replacements[index] = replacement
		operation.after_selections[index] = {
			result_line2,
			result_col2,
			result_line2,
			result_col2,
			id = selection.id,
		}
		operation.line_delta = operation.line_delta + line_break_count - (line2 - line1)
		self:raw_replace(line1, col1, line2, col2, new_text, discarded_undo, time, old_text)
		preceding = replacement
	end

	self:set_selections(operation.after_selections, operation.after_primary_index)
	record_selection_replace_change(self, operation, false)

	return true
end

--- Replace every active selection as one undoable edit
-- @param self table text document instance
-- @param texts table replacement text for each selection
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
local function replace_at_selections(self, texts, selections, primary_index, materialize_virtual)
	if selections_use_virtual_space(self, selections) then
		return replace_virtual_selections(
			self, texts, selections, primary_index, materialize_virtual
		)
	end

	local time = system.get_time()
	local operation = build_selection_replace_operation(
		self, texts, selections, primary_index
	)
	local core = package.loaded["core"]

	if core.gridlock and selection_replace_changes_line_count(operation) then
		core.error("Gridlock: line count cannot change")
		return false
	end

	clear_redo(self)
	push_undo(self.undo_stack, time, "selection_text_replace", operation)
	apply_selection_replace_operation(self, operation, time)
end

--- Apply ordered non-overlapping replacements while preserving an explicit selection result
-- This keeps higher-level transformations atomic without copying unaffected document gaps
-- @param self table text document instance
-- @param replacements table ordered replacement specifications
-- @param after_selections table|nil selection snapshots, or nil for carets after replacements
-- @param after_primary_index number|nil primary selection index after replacement
-- @return boolean changed true when replacements were applied
function edit.replace_ranges(self, replacements, after_selections, after_primary_index)
	if self.is_binary_placeholder or #replacements == 0 then
		return false
	end

	local before_selections, before_primary_index = self:get_selections()
	local replacement_selections = {}
	local replacement_texts = {}

	for index = 1, #replacements do
		local replacement = replacements[index]
		replacement_selections[index] = {
			replacement.line1,
			replacement.col1,
			replacement.line2,
			replacement.col2,
		}
		replacement_texts[index] = replacement.text
	end

	local operation = build_selection_replace_operation(
		self,
		replacement_texts,
		replacement_selections,
		1
	)
	local time = system.get_time()

	operation.before_selections = before_selections
	operation.before_primary_index = before_primary_index

	if after_selections then
		operation.after_selections = after_selections
	end
	if after_primary_index then
		operation.after_primary_index = after_primary_index
	end
	local core = package.loaded["core"]

	if core.gridlock and selection_replace_changes_line_count(operation) then
		core.error("Gridlock: line count cannot change")
		return false
	end

	clear_redo(self)
	push_undo(self.undo_stack, time, "selection_text_replace", operation)
	apply_selection_replace_operation(self, operation, time)

	return true
end

--- Map a position after one preceding deletion range
-- @param line number original line
-- @param col number original column
-- @param range table preceding merged deletion range
-- @return number line mapped line
-- @return number col mapped column
local function map_position_after_delete_range(line, col, range)
	if line == range.line2 then
		return range.result_line, range.result_col + col - range.col2
	end

	return range.result_line + line - range.line2, col
end

--- Build mapped caret snapshots in one pass through ordered deletion ranges
-- @param selections table detached caret snapshots
-- @param ranges table merged deletion ranges
-- @return table after_selections
local function build_after_delete_selections(selections, ranges)
	local after_selections = {}
	local preceding_range
	local range_index = 1

	for index = 1, #selections do
		local selection = selections[index]
		local line = selection[1]
		local col = selection[2]
		local range = ranges[range_index]

		while range and position_before(range.line2, range.col2, line, col) do
			preceding_range = range
			range_index = range_index + 1
			range = ranges[range_index]
		end

		if range and not position_before(line, col, range.line1, range.col1) then
			line, col = range.result_line, range.result_col
		elseif preceding_range then
			line, col = map_position_after_delete_range(line, col, preceding_range)
		end

		after_selections[index] = {
			line,
			col,
			line,
			col,
			id = selection.id,
			virtual = selection.virtual,
		}
	end

	return after_selections
end

--- Merge overlapping or adjacent deletion ranges in document order
-- @param ranges table ordered deletion ranges
-- @return table merged_ranges
local function merge_caret_delete_ranges(ranges)
	table.sort(ranges, function(left, right)
		if left.line1 ~= right.line1 then
			return left.line1 < right.line1
		end

		if left.col1 ~= right.col1 then
			return left.col1 < right.col1
		end

		return position_before(left.line2, left.col2, right.line2, right.col2)
	end)

	local merged = {}

	for index = 1, #ranges do
		local range = ranges[index]
		local previous = merged[#merged]

		if previous and position_before_or_equal(range.line1, range.col1, previous.line2, previous.col2) then
			if position_before(previous.line2, previous.col2, range.line2, range.col2) then
				previous.line2 = range.line2
				previous.col2 = range.col2
			end
		else
			merged[#merged + 1] = range
		end
	end

	return merged
end

--- Build one grouped deletion operation for every active selection
-- @param self table text document instance
-- @param selections table detached caret snapshots
-- @param primary_index number primary selection index
-- @return table|nil operation
local function build_selection_delete_operation(self, selections, primary_index, ...)
	local core = package.loaded["core"]
	local ranges = {}
	local collapse_selections = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line1, col1, line2, col2

		if selection[1] ~= selection[3] or selection[2] ~= selection[4] then
			line1, col1, line2, col2 = sort_positions(
				selection[1], selection[2], selection[3], selection[4]
			)
		else
			local target_line = selection[1]
			local target_col = selection[2]

			if select("#", ...) > 0 then
				target_line, target_col = self:position_offset(selection[1], selection[2], ...)
			end

			line1, col1, line2, col2 = sort_positions(
				selection[1], selection[2], target_line, target_col
			)
		end

		collapse_selections[index] = {
			line1,
			col1,
			line1,
			col1,
			id = selection.id,
			virtual = selection.virtual,
		}
		line1, col1 = self:sanitize_position(line1, col1)
		line2, col2 = self:sanitize_position(line2, col2)
		line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)

		local is_empty = line1 == line2 and col1 == col2
		local crosses_gridlock_line = core.gridlock and line1 ~= line2

		if not is_empty and not crosses_gridlock_line then
			ranges[#ranges + 1] = {
				line1 = line1,
				col1 = col1,
				line2 = line2,
				col2 = col2,
			}
		end
	end

	ranges = merge_caret_delete_ranges(ranges)
	local previous

	for index = 1, #ranges do
		local range = ranges[index]
		range.text = self:get_text(range.line1, range.col1, range.line2, range.col2)

		if previous then
			range.result_line, range.result_col = map_position_after_delete_range(
				range.line1,
				range.col1,
				previous
			)
		else
			range.result_line = range.line1
			range.result_col = range.col1
		end

		previous = range
	end

	return {
		action = "insert",
		ranges = ranges,
		before_selections = selections,
		before_primary_index = primary_index,
		after_selections = build_after_delete_selections(collapse_selections, ranges),
		after_primary_index = primary_index,
	}
end

--- Record the combined text-change span for a caret deletion operation
-- @param self table text document instance
-- @param operation table grouped selection deletion operation
-- @param kind string edit kind
local function record_selection_delete_change(self, operation, kind)
	local first_range = operation.ranges[1]
	local last_range = operation.ranges[#operation.ranges]
	local removed_lines = 0

	for index = 1, #operation.ranges do
		local range = operation.ranges[index]
		removed_lines = removed_lines + range.line2 - range.line1
	end

	if kind == "remove" then
		self._last_text_change = {
			change_id = self:get_change_id(),
			line1 = first_range.line1,
			old_line2 = last_range.line2,
			new_line2 = last_range.line2 - removed_lines,
			line2 = last_range.line2,
			net_lines = -removed_lines,
			kind = kind,
		}
	else
		self._last_text_change = {
			change_id = self:get_change_id(),
			line1 = first_range.result_line,
			old_line2 = last_range.result_line,
			new_line2 = last_range.line2,
			line2 = last_range.line2,
			net_lines = removed_lines,
			kind = kind,
		}
	end
end

--- Apply one grouped caret deletion or restoration and record its inverse
-- @param self table text document instance
-- @param operation table multicaret deletion operation
-- @param undo_stack table inverse undo stack
-- @param time number shared undo timestamp
function edit.replay_caret_text_delete(self, operation, undo_stack, time)
	local inverse = {
		action = (operation.action == "insert") and "remove" or "insert",
		ranges = operation.ranges,
		before_selections = operation.before_selections,
		before_primary_index = operation.before_primary_index,
		after_selections = operation.after_selections,
		after_primary_index = operation.after_primary_index,
	}
	local discarded_undo = { idx = 1 }

	push_undo(undo_stack, time, "caret_text_delete", inverse)

	if operation.action == "insert" then
		for index = #operation.ranges, 1, -1 do
			local range = operation.ranges[index]
			self:raw_insert(range.result_line, range.result_col, range.text, discarded_undo, time)
		end

		self:set_selections(operation.before_selections, operation.before_primary_index)
		record_selection_delete_change(self, operation, "insert")
	else
		for index = #operation.ranges, 1, -1 do
			local range = operation.ranges[index]
			self:raw_remove(range.line1, range.col1, range.line2, range.col2, discarded_undo, time)
		end

		self:set_selections(operation.after_selections, operation.after_primary_index)
		record_selection_delete_change(self, operation, "remove")
	end
end

--- Delete every selected or translated range as one undoable edit
-- @param self table text document instance
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
local function delete_at_selections(self, selections, primary_index, ...)
	local operation = build_selection_delete_operation(self, selections, primary_index, ...)

	if not operation then
		return
	end
	if #operation.ranges == 0 then
		self:set_selections(operation.after_selections, operation.after_primary_index)

		return
	end

	local time = system.get_time()
	local discarded_undo = { idx = 1 }

	clear_redo(self)
	push_undo(self.undo_stack, time, "caret_text_delete", operation)

	for index = #operation.ranges, 1, -1 do
		local range = operation.ranges[index]
		self:raw_remove(range.line1, range.col1, range.line2, range.col2, discarded_undo, time)
	end

	self:set_selections(operation.after_selections, operation.after_primary_index)
	record_selection_delete_change(self, operation, "remove")
end

--- Handle typed text insertion at active carets, replacing selection and honoring gridlock mode
-- @param self table text document instance
-- @param text string inserted text
-- @param materialize_virtual boolean|nil insert virtual gap spaces unless false
function edit.text_input(self, text, materialize_virtual)
	if self.is_binary_placeholder then
		return
	end
	if text == "" then
		return
	end

	-- Gridlock prevents newline input from changing line count
	local core = package.loaded["core"]

	if core.gridlock and text:find("\n", 1, true) then
		-- Enter moves to next line without clearing it, selection is cleared in place
		if text == "\n" then
			local line, col = self:get_selection()

			if self:has_selection() then
				gridlock_clear_selection(self)
			end

			local next_line = math.min(#self.lines, line + 1)
			self:set_selection(next_line, 1)

			return
		end

		gridlock_overwrite(self, text)

		return
	end

	local selections, primary_index = self:get_selections()
	local function has_virtual_position(selection)
		return selection[2] > self:get_line_end_col(selection[1])
			or selection[4] > self:get_line_end_col(selection[3])
	end

	if #selections > 1 then
		local texts = {}

		for index = 1, #selections do
			texts[index] = text
		end

		if selections_are_carets(selections) then
			text_input_at_carets(self, texts, selections, primary_index, materialize_virtual)

			return
		elseif not core.gridlock then
			replace_at_selections(self, texts, selections, primary_index, materialize_virtual)

			return
		end
	end

	if has_virtual_position(selections[1]) then
		if self:has_selection() then
			replace_at_selections(self, { text }, selections, primary_index, materialize_virtual)
		else
			text_input_at_carets(self, { text }, selections, primary_index, materialize_virtual)
		end

		return
	end

	if self:has_selection() then
		self:delete_to()
	end

	local line, col = self:get_selection()
	self:insert(line, col, text)
	self:move_to(#text)
end

--- Paste matching clipboard lines across active carets
-- @param self table text document instance
-- @param text string normalized clipboard text
function edit.paste_text(self, text)
	if self.is_binary_placeholder or text == "" then
		return
	end

	local selections, primary_index = self:get_selections()

	if #selections > 1 then
		local texts = split_lines(text)

		-- A final line break terminates the last clipboard line instead of adding another target
		if text:sub(-1) == "\n" and texts[#texts] == "" then
			table.remove(texts)
		end

		if #texts ~= #selections then
			texts = {}

			for index = 1, #selections do
				texts[index] = text
			end
		end

		if selections_are_carets(selections) then
			text_input_at_carets(self, texts, selections, primary_index)

			return
		elseif not package.loaded["core"].gridlock then
			replace_at_selections(self, texts, selections, primary_index)

			return
		end
	end

	self:text_input(text)
end

--- Return inherited indentation for a newline at one position
-- @param self table text document instance
-- @param line number source line
-- @param col number source column
-- @return string indent
local function get_newline_indent(self, line, col)
	-- Pattern breakdown:
	-- '^' anchors at the line start and '[\t ]*' captures leading tabs and spaces
	local indent = self.lines[line]:match("^[\t ]*")

	if col <= #indent then
		indent = indent:sub(#indent + 2 - col)
	end

	return indent
end

--- Insert an indented newline at every active caret
-- @param self table text document instance
function edit.insert_newline(self)
	if self.is_binary_placeholder then
		return
	end

	local selections, primary_index = self:get_selections()
	local core = package.loaded["core"]

	if #selections > 1 and not core.gridlock then
		local texts = {}

		for index = 1, #selections do
			local selection = selections[index]
			local line, col = sort_positions(
				selection[1], selection[2], selection[3], selection[4]
			)
			texts[index] = "\n" .. get_newline_indent(self, line, col)
		end

		if selections_are_carets(selections) then
			text_input_at_carets(self, texts, selections, primary_index, false)
		else
			replace_at_selections(self, texts, selections, primary_index, false)
		end

		return
	end

	local line, col = self:get_selection()
	self:text_input("\n" .. get_newline_indent(self, line, col), false)
end

--- Collect unique lines touched by active selections
-- @param selections table detached selection snapshots
-- @return table lines ordered line indexes
local function get_selection_lines(selections)
	local included = {}
	local lines = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line1, _, line2, col2 = sort_positions(
			selection[1], selection[2], selection[3], selection[4]
		)

		if line2 > line1 and col2 == 1 then
			line2 = line2 - 1
		end

		for line = line1, line2 do
			if not included[line] then
				included[line] = true
				lines[#lines + 1] = line
			end
		end
	end

	table.sort(lines)

	return lines
end

--- Shift selection endpoints through line-start indentation changes
-- @param selections table detached selection snapshots
-- @param line_changes table changed byte count by line
-- @param direction number positive for indent, negative for unindent
-- @return table shifted_selections
local function shift_selections_for_line_indent(selections, line_changes, direction)
	local shifted = {}

	for index = 1, #selections do
		local selection = selections[index]
		local start_delta = line_changes[selection[1]] or 0
		local stop_delta = line_changes[selection[3]] or 0
		local start_col = selection[2] + start_delta * direction
		local stop_col = selection[4] + stop_delta * direction

		shifted[index] = {
			selection[1],
			math.max(1, start_col),
			selection[3],
			math.max(1, stop_col),
			id = selection.id,
		}
	end

	return shifted
end

--- Return leading bytes removed by one unindent step
-- @param text string source line
-- @param indent string configured indent unit
-- @return number length removable byte count
local function get_unindent_length(text, indent)
	if text:sub(1, 1) == "\t" then
		return 1
	end
	if indent == "\t" then
		return 0
	end

	local spaces = text:match("^ +") or ""

	return math.min(#spaces, #indent)
end

--- Apply one line indentation change across active selections
-- @param self table text document instance
-- @param selections table detached selection snapshots
-- @param primary_index number primary selection index
-- @param unindent boolean true to remove one indent step
local function change_selection_line_indent(self, selections, primary_index, unindent)
	local indent = edit.get_indent_string(self)
	local lines = get_selection_lines(selections)
	local replacement_selections = {}
	local replacement_texts = {}
	local line_changes = {}

	for index = 1, #lines do
		local line = lines[index]
		local change_length = unindent and get_unindent_length(self.lines[line], indent) or #indent

		if change_length > 0 then
			replacement_selections[#replacement_selections + 1] = {
				line,
				1,
				line,
				unindent and change_length + 1 or 1,
			}
			replacement_texts[#replacement_texts + 1] = unindent and "" or indent
			line_changes[line] = change_length
		end
	end

	if #replacement_selections == 0 then
		return
	end

	local operation = build_selection_replace_operation(
		self,
		replacement_texts,
		replacement_selections,
		1
	)
	local direction = unindent and -1 or 1
	local time = system.get_time()

	operation.before_selections = selections
	operation.before_primary_index = primary_index
	operation.after_selections = shift_selections_for_line_indent(selections, line_changes, direction)
	operation.after_primary_index = primary_index
	clear_redo(self)
	push_undo(self.undo_stack, time, "selection_text_replace", operation)
	apply_selection_replace_operation(self, operation, time)
end

--- Insert Tab input or indent every line touched by multiple selections
-- @param self table text document instance
function edit.indent_at_selections(self)
	if self.is_binary_placeholder then
		return
	end

	local selections, primary_index = self:get_selections()

	if selections_are_carets(selections) then
		local texts = {}

		for index = 1, #selections do
			local selection = selections[index]
			texts[index] = edit.get_indent_input(self, selection[1], selection[2])
		end

		text_input_at_carets(self, texts, selections, primary_index)

		return
	end

	change_selection_line_indent(self, selections, primary_index, false)
end

--- Unindent every line touched by multiple selections
-- @param self table text document instance
function edit.unindent_at_selections(self)
	if self.is_binary_placeholder then
		return
	end

	local selections, primary_index = self:get_selections()
	change_selection_line_indent(self, selections, primary_index, true)
end

--- Replace selected or full-document text via callback-generated content
-- @param self table text document instance
-- @param fn function replacement callback
-- @return number replacement_count callback result
function edit.replace(self, fn)
	if self.is_binary_placeholder then
		return 0
	end

	local line1, col1, line2, col2, swap
	local had_selection = self:has_selection()

	if had_selection then
		line1, col1, line2, col2, swap = self:get_selection(true)
	else
		line1, col1 = 1, 1
		-- A final line without LF has one legal position after its last byte
		line2, col2 = self:sanitize_position(#self.lines, math.huge)
	end

	local old_text = self:get_text(line1, col1, line2, col2)
	local new_text, n = fn(old_text)

	-- Route document-wide replacements through atomic raw_replace
	if old_text ~= new_text then
		local core = package.loaded["core"]

		if core.gridlock then
			self:set_selection(line1, col1, line2, col2, swap)
			gridlock_clear_selection(self)
			self:set_selection(line1, col1)
			gridlock_overwrite(self, new_text)
		else
			-- Use single atomic replace for better undo and large-range speed
			clear_redo(self)

			-- Reuse previously computed old_text to avoid duplicate range copy
			self:raw_replace(line1, col1, line2, col2, new_text, self.undo_stack, system.get_time(), old_text)

			if had_selection then
				line2, col2 = self:position_offset(line1, col1, #new_text)
				self:set_selection(line1, col1, line2, col2, swap)
			end
		end
	end

	return n
end

--- Replace an explicit range via callback-generated content
-- @param self table text document instance
-- @param fn function replacement callback
-- @return number replacement_count callback result
function edit.replace_range(self, line1, col1, line2, col2, fn)
	if self.is_binary_placeholder then
		return 0
	end

	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)
	local old_text = self:get_text(line1, col1, line2, col2)
	local new_text, n = fn(old_text)

	if old_text ~= new_text then
		local core = package.loaded["core"]

		if core.gridlock then
			self:set_selection(line1, col1, line2, col2)
			gridlock_clear_selection(self)
			self:set_selection(line1, col1)
			gridlock_overwrite(self, new_text)
		else
			clear_redo(self)
			self:raw_replace(line1, col1, line2, col2, new_text, self.undo_stack, system.get_time(), old_text)
		end
	end

	return n
end

--- Delete selection or a computed range and move cursor to the start
-- @param self table text document instance
function edit.delete_to(self, ...)
	if self.is_binary_placeholder then
		return
	end

	local selections, primary_index = self:get_selections()

	if #selections > 1 then
		delete_at_selections(self, selections, primary_index, ...)

		return
	end

	local line, col = self:get_selection(true)
	local allow_virtual = self.selection.virtual or self:is_virtual_space_enabled("enabled")

	if self:has_selection() then
		self:remove(self:get_selection())
	else
		local line2, col2 = self:position_offset(line, col, ...)
		self:remove(line, col, line2, col2)
		line, col = sort_positions(line, col, line2, col2)
	end

	self:set_selection(line, col, nil, nil, nil, allow_virtual)
end

--- Translate one selection head while passing its identity to view-aware movement
-- @param self table text document instance
-- @param selection table detached selection snapshot
-- @return number line translated line
-- @return number col translated column
local function offset_selection_head(self, selection, ...)
	local argument_count = select("#", ...)
	local translator, argument = ...

	if type(translator) ~= "function" then
		return self:position_offset(selection[1], selection[2], ...)
	end

	if argument_count == 1 then
		return self:position_offset(selection[1], selection[2], translator, selection.id)
	end

	if argument_count == 2 then
		return self:position_offset(selection[1], selection[2], translator, argument, selection.id)
	end

	local arguments = table.pack(...)
	arguments.n = arguments.n + 1
	arguments[arguments.n] = selection.id

	return self:position_offset(selection[1], selection[2], table.unpack(arguments, 1, arguments.n))
end

--- Move every selection head while clearing its range
-- @param self table text document instance
function edit.move_to(self, ...)
	local selections, primary_index = self:get_selections()

	if #selections == 1 then
		local line, col = self:get_selection()
		local target_line, target_col = self:position_offset(line, col, ...)
		self:set_selection(
			target_line, target_col, nil, nil, nil, self:is_virtual_space_enabled("enabled")
		)

		return
	end

	local moved = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line, col = offset_selection_head(self, selection, ...)
		moved[index] = {
			line,
			col,
			line,
			col,
			id = selection.id,
			virtual = self:is_virtual_space_enabled("enabled"),
		}
	end

	self:set_selections(moved, primary_index)
end

--- Collapse ranges toward one edge and move carets without ranges
-- @param self table text document instance
-- @param to_end boolean true to collapse toward the range end
function edit.collapse_selections(self, to_end, ...)
	local selections, primary_index = self:get_selections()
	local collapsed = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line, col

		if selection[1] == selection[3] and selection[2] == selection[4] then
			line, col = offset_selection_head(self, selection, ...)
		else
			local line1, col1, line2, col2 = sort_positions(
				selection[1], selection[2], selection[3], selection[4]
			)
			line, col = to_end and line2 or line1, to_end and col2 or col1
		end

		collapsed[index] = {
			line,
			col,
			line,
			col,
			id = selection.id,
			virtual = selection.virtual or self:is_virtual_space_enabled("enabled"),
		}
	end

	self:set_selections(collapsed, primary_index)
end

--- Store a transient flash overlay for a single line
-- @param self table text document instance
-- @param line number target line
-- @param opts table|nil flash options
-- @return boolean ok
-- @return string|nil err error when line is invalid
function edit.flash_line(self, line, opts)
	if type(line) ~= "number" then
		return false, "Invalid line number"
	end

	line = math.floor(line)

	if line < 1 or line > #self.lines then
		return false, string.format("Line out of range: %d (1-%d)", line, #self.lines)
	end

	opts = opts or {}
	self._line_flash = {
		line = line,
		time = system.get_time(),
		kind = opts.kind,
		color = opts.color,
	}
	-- Notify optional visual feedback after line-flash state is ready
	hook.emit("doc.flash_line", "after", {
		doc = self,
		flash = self._line_flash,
	})

	return true
end

--- Extend the selection cursor by an offset while keeping the anchor fixed
-- @param self table text document instance
function edit.select_to(self, ...)
	local selections, primary_index = self:get_selections()

	if #selections == 1 then
		local line, col, line2, col2 = self:get_selection()
		line, col = self:position_offset(line, col, ...)
		self:set_selection(
			line, col, line2, col2, nil, self:is_virtual_space_enabled("enabled")
		)

		return
	end

	local extended = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line, col = offset_selection_head(self, selection, ...)
		extended[index] = {
			line,
			col,
			selection[3],
			selection[4],
			id = selection.id,
			virtual = self:is_virtual_space_enabled("enabled"),
		}
	end

	self:set_selections(extended, primary_index)
end

return edit
