-- lx-bundle:true

--- Multicursor-aware whole-line transformations
-- @module core.text.line

local text_select = require("core.text.select")

local line = {}
local sort_positions = text_select.sort_positions

--- Return the last included line for one line-wise selection
-- A selection ending at column one treats that final line as exclusive
-- @param selection table detached selection snapshot
-- @return number line2 last included line
local function get_selection_last_line(selection)
	local line1, _, line2, col2 = sort_positions(
		selection[1],
		selection[2],
		selection[3],
		selection[4]
	)

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

	return line2
end

--- Collect selected line ranges and merge touching blocks
-- @param selections table detached selection snapshots
-- @param merge_adjacent boolean true to combine neighboring blocks
-- @param extend_single boolean true to include the following line for one-line ranges
-- @param last_line number final document line
-- @return table blocks ordered disjoint line blocks
local function collect_blocks(selections, merge_adjacent, extend_single, last_line)
	local raw_blocks = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line1 = math.min(selection[1], selection[3])
		local line2 = get_selection_last_line(selection)

		if extend_single and line1 == line2 then
			line2 = math.min(last_line, line2 + 1)
		end

		raw_blocks[#raw_blocks + 1] = {
			line1 = line1,
			line2 = line2,
			selection_indexes = { index },
		}
	end

	table.sort(raw_blocks, function(left, right)
		if left.line1 ~= right.line1 then
			return left.line1 < right.line1
		end

		return left.line2 < right.line2
	end)

	local blocks = {}

	for index = 1, #raw_blocks do
		local block = raw_blocks[index]
		local previous = blocks[#blocks]
		local merge_limit = previous and previous.line2 + (merge_adjacent and 1 or 0) or 0

		if previous and block.line1 <= merge_limit then
			previous.line2 = math.max(previous.line2, block.line2)

			for selection_index = 1, #block.selection_indexes do
				previous.selection_indexes[#previous.selection_indexes + 1] = block.selection_indexes[selection_index]
			end
		else
			blocks[#blocks + 1] = block
		end
	end

	return blocks
end

--- Return selected complete-line blocks, or the whole document without selections
-- Empty carets are ignored when another cursor has a selection
-- @param doc table text document
-- @param whole_document boolean|nil ignore selections when true
-- @return table blocks ordered disjoint line blocks
function line.get_target_ranges(doc, whole_document)
	if whole_document or not doc:has_any_selection() then
		return { { line1 = 1, line2 = #doc.lines } }
	end

	local selections = doc:get_selections(true)
	local selected = {}

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

		if selection[1] ~= selection[3] or selection[2] ~= selection[4] then
			selected[#selected + 1] = selection
		end
	end

	return collect_blocks(selected, true, false, #doc.lines)
end

--- Return the document position immediately before one line's trailing LF
-- @param doc table text document
-- @param line_index number target line
-- @return number col content-end column
local function get_content_end_col(doc, line_index)
	local text = doc.lines[line_index]

	if text:sub(-1) == "\n" then
		return #text
	end

	return #text + 1
end

--- Return a complete line block replacement range
-- The final LF stays outside the range at EOF so the document retains its sentinel
-- @param doc table text document
-- @param line2 number last line
-- @return number range_line2
-- @return number range_col2
local function get_block_end(doc, line2)
	if line2 < #doc.lines then
		return line2 + 1, 1
	end

	return line2, get_content_end_col(doc, line2)
end

--- Return the end position for replacing complete lines
-- @param doc table text document
-- @param line2 number last included line
-- @return number range_line2
-- @return number range_col2
function line.get_block_end(doc, line2)
	return get_block_end(doc, line2)
end

--- Concatenate lines in the requested order for one block replacement
-- @param doc table text document
-- @param ordered_lines table source line indexes
-- @param ends_at_eof boolean true when the replaced range leaves its final LF outside
-- @return string text replacement text
local function get_ordered_line_text(doc, ordered_lines, ends_at_eof)
	local parts = {}

	for index = 1, #ordered_lines do
		parts[index] = doc.lines[ordered_lines[index]]
	end

	local text = table.concat(parts)

	if ends_at_eof and text:sub(-1) == "\n" then
		text = text:sub(1, -2)
	end

	return text
end

--- Append one inclusive line range to an ordered line index list
-- @param out table destination list
-- @param line1 number first line
-- @param line2 number last line
local function append_line_indexes(out, line1, line2)
	for line_index = line1, line2 do
		out[#out + 1] = line_index
	end
end

--- Copy one selection while shifting both endpoints vertically
-- @param selection table detached selection snapshot
-- @param line_delta number line offset
-- @return table shifted selection snapshot
local function shift_selection_lines(selection, line_delta)
	return {
		selection[1] + line_delta,
		selection[2],
		selection[3] + line_delta,
		selection[4],
		id = selection.id,
	}
end

--- Duplicate every selected line block below itself
-- @param doc table text document
-- @return boolean changed
function line.duplicate(doc)
	if package.loaded["core"].gridlock then
		return false
	end

	local selections, primary_index = doc:get_selections()
	local blocks = collect_blocks(selections, true, false, #doc.lines)
	local replacements = {}
	local after_selections = {}
	local line_delta = 0

	for block_index = 1, #blocks do
		local block = blocks[block_index]
		local ordered_lines = {}
		local block_height = block.line2 - block.line1 + 1
		local ends_at_eof = block.line2 == #doc.lines
		local range_line2, range_col2 = get_block_end(doc, block.line2)

		append_line_indexes(ordered_lines, block.line1, block.line2)
		append_line_indexes(ordered_lines, block.line1, block.line2)
		replacements[#replacements + 1] = {
			line1 = block.line1,
			col1 = 1,
			line2 = range_line2,
			col2 = range_col2,
			text = get_ordered_line_text(doc, ordered_lines, ends_at_eof),
		}

		for index = 1, #block.selection_indexes do
			local selection_index = block.selection_indexes[index]
			after_selections[selection_index] = shift_selection_lines(
				selections[selection_index],
				line_delta + block_height
			)
		end

		line_delta = line_delta + block_height
	end

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

--- Delete every selected line block while retaining one empty row at EOF
-- @param doc table text document
-- @return boolean changed
function line.delete(doc)
	if package.loaded["core"].gridlock then
		return false
	end

	local selections, primary_index = doc:get_selections()
	local blocks = collect_blocks(selections, true, false, #doc.lines)
	local replacements = {}
	local after_selections = {}
	local line_delta = 0

	for block_index = 1, #blocks do
		local block = blocks[block_index]
		local block_height = block.line2 - block.line1 + 1
		local target_line = block.line1 + line_delta
		local removed_lines = block_height
		local replacement

		if block.line2 < #doc.lines then
			replacement = {
				line1 = block.line1,
				col1 = 1,
				line2 = block.line2 + 1,
				col2 = 1,
				text = "",
			}
		else
			-- Keep one empty EOF row so repeated deletion cannot walk backward through the document
			if block_height > 1 or doc.lines[block.line1] ~= "\n" then
				replacement = {
					line1 = block.line1,
					col1 = 1,
					line2 = block.line2,
					col2 = get_content_end_col(doc, block.line2),
					text = "",
				}
			end

			removed_lines = math.max(0, block_height - 1)
		end

		if replacement then
			replacements[#replacements + 1] = replacement
		end

		for index = 1, #block.selection_indexes do
			local selection_index = block.selection_indexes[index]
			local selection = selections[selection_index]
			local _, col = sort_positions(selection[1], selection[2], selection[3], selection[4])

			after_selections[selection_index] = {
				target_line,
				col,
				target_line,
				col,
				id = selection.id,
			}
		end

		line_delta = line_delta - removed_lines
	end

	if #replacements == 0 then
		return false
	end

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

--- Move every selected line block by one row
-- @param doc table text document
-- @param direction number negative for up and positive for down
-- @return boolean changed
function line.move(doc, direction)
	if package.loaded["core"].gridlock then
		return false
	end

	direction = direction < 0 and -1 or 1
	local selections, primary_index = doc:get_selections()
	local blocks = collect_blocks(selections, true, false, #doc.lines)

	if direction < 0 and blocks[1].line1 <= 1 then
		return false
	end

	if direction > 0 and blocks[#blocks].line2 >= #doc.lines then
		return false
	end

	local replacements = {}
	local after_selections = {}

	for block_index = 1, #blocks do
		local block = blocks[block_index]
		local range_line1 = direction < 0 and block.line1 - 1 or block.line1
		local range_line2 = direction < 0 and block.line2 or block.line2 + 1
		local ordered_lines = {}

		if direction < 0 then
			append_line_indexes(ordered_lines, block.line1, block.line2)
			ordered_lines[#ordered_lines + 1] = block.line1 - 1
		else
			ordered_lines[#ordered_lines + 1] = block.line2 + 1
			append_line_indexes(ordered_lines, block.line1, block.line2)
		end

		local end_line, end_col = get_block_end(doc, range_line2)
		replacements[#replacements + 1] = {
			line1 = range_line1,
			col1 = 1,
			line2 = end_line,
			col2 = end_col,
			text = get_ordered_line_text(doc, ordered_lines, range_line2 == #doc.lines),
		}

		for index = 1, #block.selection_indexes do
			local selection_index = block.selection_indexes[index]
			after_selections[selection_index] = shift_selection_lines(
				selections[selection_index],
				direction
			)
		end
	end

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

--- Join every selected line block using one separating space
-- @param doc table text document
-- @return boolean changed
function line.join(doc)
	if package.loaded["core"].gridlock then
		return false
	end

	local selections, primary_index = doc:get_selections()
	local blocks = collect_blocks(selections, false, true, #doc.lines)
	local replacements = {}
	local after_selections = {}
	local line_delta = 0

	for block_index = 1, #blocks do
		local block = blocks[block_index]

		if block.line1 < block.line2 then
			local parts = {}

			for line_index = block.line1, block.line2 do
				local text = doc:get_logical_line(line_index)

				if line_index > block.line1 then
					-- Pattern breakdown:
					-- '^' anchors at line start and '[\t ]*' removes leading indentation
					text = text:gsub("^[\t ]*", "")
				end

				parts[#parts + 1] = text
			end

			local joined = table.concat(parts, " ")
			local end_line, end_col = get_block_end(doc, block.line2)
			local replacement_text = joined

			if block.line2 < #doc.lines then
				replacement_text = replacement_text .. "\n"
			end

			replacements[#replacements + 1] = {
				line1 = block.line1,
				col1 = 1,
				line2 = end_line,
				col2 = end_col,
				text = replacement_text,
			}

			local result_line = block.line1 + line_delta

			for index = 1, #block.selection_indexes do
				local selection_index = block.selection_indexes[index]
				after_selections[selection_index] = {
					result_line,
					#joined + 1,
					result_line,
					#joined + 1,
					id = selections[selection_index].id,
				}
			end

			line_delta = line_delta - (block.line2 - block.line1)
		else
			for index = 1, #block.selection_indexes do
				local selection_index = block.selection_indexes[index]
				after_selections[selection_index] = shift_selection_lines(
					selections[selection_index],
					line_delta
				)
			end
		end
	end

	if #replacements == 0 then
		return false
	end

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

--- Insert one indented line above or below every unique selection head line
-- @param doc table text document
-- @param below boolean true to insert below, false to insert above
-- @return boolean changed
function line.insert(doc, below)
	if package.loaded["core"].gridlock then
		return false
	end

	local selections, primary_index = doc:get_selections()
	local targets = {}
	local target_by_line = {}

	for index = 1, #selections do
		local source_line = selections[index][1]
		local target = target_by_line[source_line]

		if not target then
			-- Pattern breakdown:
			-- '^' anchors at line start and '[\t ]*' captures leading indentation
			target = {
				line = source_line,
				indent = doc.lines[source_line]:match("^[\t ]*"),
				selection_indexes = {},
			}
			target_by_line[source_line] = target
			targets[#targets + 1] = target
		end

		target.selection_indexes[#target.selection_indexes + 1] = index
	end

	table.sort(targets, function(left, right)
		return left.line < right.line
	end)

	local replacements = {}
	local after_selections = {}
	local line_delta = 0

	for target_index = 1, #targets do
		local target = targets[target_index]
		local insert_col = below and get_content_end_col(doc, target.line) or 1
		local text = below and ("\n" .. target.indent) or (target.indent .. "\n")
		local result_line = target.line + line_delta + (below and 1 or 0)

		replacements[#replacements + 1] = {
			line1 = target.line,
			col1 = insert_col,
			line2 = target.line,
			col2 = insert_col,
			text = text,
		}

		for index = 1, #target.selection_indexes do
			local selection_index = target.selection_indexes[index]
			after_selections[selection_index] = {
				result_line,
				#target.indent + 1,
				result_line,
				#target.indent + 1,
				id = selections[selection_index].id,
			}
		end

		line_delta = line_delta + 1
	end

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

return line
