-- lx-bundle:true

local common = require("core.common")
local config = require("core.config")
local file = require("core.text.file")
local sel = require("core.text.select")
local undo = require("core.doc.undo")

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

--- Returns one indentation step based on tab/space settings
-- This remains the block-indent unit for selected lines
-- @return string text one literal tab or one full soft indent unit
function edit.get_indent_string()
	if config.tab_type == "hard" then
		return "\t"
	end
	-- Soft indentation follows the shared indentation grid size
	return string.rep(" ", math.max(1, math.floor(tonumber(config.indent_size) or 4)))
end

--- Returns the soft-tab spaces needed to reach the next grid stop
-- This keeps Tab aligned to the shared indentation grid instead of always
-- inserting a full indent chunk
-- @param visual_column number zero-based visual column
-- @return string text spaces that advance to the next grid stop
local function get_soft_tab_spaces(visual_column)
	local indent_size = math.max(1, math.floor(tonumber(config.indent_size) or 4))
	local remainder = visual_column % indent_size
	local space_count = indent_size - remainder

	if space_count == 0 then
		space_count = indent_size
	end

	return string.rep(" ", space_count)
end

--- Returns the text to insert for one Tab key press
-- Hard tabs stay literal while soft tabs advance only to the next grid stop
-- when the caret is inside leading indentation
-- @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 config.tab_type == "hard" then
		return "\t"
	end

	local visual_column = doc:get_indent_visual_column(line, col)

	if visual_column == nil then
		return edit.get_indent_string()
	end

	return get_soft_tab_spaces(visual_column)
end

-- Inserts 'text' at column 1 for each selected line
function edit.insert_at_start_of_selected_lines(doc, text, skip_empty)
	local line1, col1, line2, col2, swap = doc:get_selection(true)
	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)
		end
	end
	doc:set_selection(line1, col1 + #text, line2, col2 + #text, swap)
end

-- Removes leading 'text' from each selected line when present
function edit.remove_from_start_of_selected_lines(doc, text, skip_empty)
	local line1, col1, line2, col2, swap = doc:get_selection(true)
	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)
		end
	end
	doc:set_selection(line1, col1 - #text, line2, col2 - #text, swap)
end

-- Ensures there is a following line so line-range edits can include EOF
function edit.append_line_if_last_line(doc, line)
	if line >= #doc.lines then
		doc:insert(line, math.huge, "\n")
	end
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

-- Splits text into lines while preserving a final empty fragment
local function split_lines(text)
	local result = {}

	for line in (text .. "\n"):gmatch("(.-)\n") do
		table.insert(result, line)
	end

	return result
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 = {}

	for part in (text .. "\n"):gmatch("(.-)\n") do
		table.insert(parts, part)
	end

	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

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

	-- 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)
	-- 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 })

	-- 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)
	-- Longest-line cache from load is no longer guaranteed after edits
	self.longest_line_length = nil
	self.longest_line_index = nil
	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 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 lines = split_lines(new_text)
	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)

	-- 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 core = package.loaded["core"]
	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
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 then
		return
	end

	self.redo_stack = { idx = 1 }
	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

	self.redo_stack = { idx = 1 }
	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2)
	self:raw_remove(line1, col1, line2, col2, self.undo_stack, system.get_time())
end

--- Handle typed text insertion, replacing selection and honoring gridlock mode
-- @param self table text document instance
function edit.text_input(self, text)
	if self.is_binary_placeholder 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

	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

--- 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, line2, col2 = 1, 1, #self.lines, #self.lines[#self.lines]
	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
			self.redo_stack = { idx = 1 }

			-- 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
			self.redo_stack = { idx = 1 }
			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, ...)
	local line, col = self:get_selection(true)

	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)
end

--- Move the cursor by an offset while clearing selection
-- @param self table text document instance
function edit.move_to(self, ...)
	local line, col = self:get_selection()
	self:set_selection(self:position_offset(line, col, ...))
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,
	}

	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 line, col, line2, col2 = self:get_selection()
	line, col = self:position_offset(line, col, ...)
	self:set_selection(line, col, line2, col2)
end

return edit
