-- lx-bundle:true

--- Selection and position helpers for text documents
-- @module core.text.select

local common = require("core.common")

local sel = {}

--- Order two positions from earliest to latest and report whether they swapped
-- @param line1 number first line
-- @param col1 number first column
-- @param line2 number second line
-- @param col2 number second column
-- @return number line1 ordered first line
-- @return number col1 ordered first column
-- @return number line2 ordered second line
-- @return number col2 ordered second column
-- @return boolean swapped true when inputs were reversed
function sel.sort_positions(line1, col1, line2, col2)
	if line1 > line2 or line1 == line2 and col1 > col2 then
		return line2, col2, line1, col1, true
	end

	return line1, col1, line2, col2, false
end

--- Apply a position-offset callback after sanitizing the input position
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @param fn function offset callback
-- @return number line resolved line
-- @return number col resolved column
local function position_offset_func(self, line, col, fn, ...)
	line, col = self:sanitize_position(line, col)

	return fn(self, line, col, ...)
end

--- Offset a position by byte count across line boundaries
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @param offset number byte delta
-- @return number line resolved line
-- @return number col resolved column
local function position_offset_byte(self, line, col, offset)
	line, col = self:sanitize_position(line, col)
	col = col + offset

	while line > 1 and col < 1 do
		line = line - 1
		col = col + #self.lines[line]
	end

	while line < #self.lines and col > #self.lines[line] do
		col = col - #self.lines[line]
		line = line + 1
	end

	return self:sanitize_position(line, col)
end

--- Offset a position by line and column deltas
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @param lineoffset number line delta
-- @param coloffset number column delta
-- @return number line resolved line
-- @return number col resolved column
local function position_offset_linecol(self, line, col, lineoffset, coloffset)
	return self:sanitize_position(line + lineoffset, col + coloffset)
end

--- Set anchor and cursor selection endpoints with optional swapping
-- @param self table text document instance
function sel.set_selection(self, line1, col1, line2, col2, swap)
	assert(not line2 == not col2, "expected 2 or 4 arguments")
	local old_line1 = self.selection.start.line
	local old_col1 = self.selection.start.col
	local old_line2 = self.selection.stop.line
	local old_col2 = self.selection.stop.col

	if swap then
		line1, col1, line2, col2 = line2, col2, line1, col1
	end

	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2 or line1, col2 or col1)
	self.selection.start.line, self.selection.start.col = line1, col1
	self.selection.stop.line, self.selection.stop.col = line2, col2

	-- Clear stored match selection when user changes selection
	if self.match_selection then
		local match = self.match_selection

		if match[1] ~= line1 or match[2] ~= col1 or match[3] ~= line2 or match[4] ~= col2 then
			self.match_selection = nil
		end
	end
end

--- Return current selection endpoints with optional ordering
-- @param self table text document instance
-- @param sort boolean|nil order endpoints when true
-- @return number line1 first line
-- @return number col1 first column
-- @return number line2 second line
-- @return number col2 second column
-- @return boolean|nil swapped whether sorting swapped endpoints
function sel.get_selection(self, sort)
	local anchor, cursor = self.selection.start, self.selection.stop

	if sort then
		return sel.sort_positions(anchor.line, anchor.col, cursor.line, cursor.col)
	end

	return anchor.line, anchor.col, cursor.line, cursor.col
end

--- Return true when selection anchor and cursor differ
-- @param self table text document instance
-- @return boolean has_selection true when a range is selected
function sel.has_selection(self)
	local anchor, cursor = self.selection.start, self.selection.stop

	return not (anchor.line == cursor.line and anchor.col == cursor.col)
end

--- Return the selected full-line range for line-wise operations
-- @param self table text document instance
-- @return number start_line first selected line
-- @return number end_line last selected line
function sel.get_selected_line_range(self)
	local line = self:get_selection()

	if not self:has_selection() then
		return line, line
	end

	local line1, _, line2, col2 = self:get_selection(true)
	local start_line = line1
	local end_line = line2

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

	if end_line < start_line then
		end_line = start_line
	end

	return start_line, end_line
end

--- Normalize selection endpoints to valid in-bounds positions
-- @param self table text document instance
function sel.sanitize_selection(self)
	self:set_selection(self:get_selection())
end

--- Clamp a document position to valid line and column bounds
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @return number line clamped line
-- @return number col clamped column
function sel.sanitize_position(self, line, col)
	line = common.clamp(line, 1, #self.lines)

	-- Allow cursor past last char when final line has no trailing LF
	local maxcol = #self.lines[line]

	if line == #self.lines and self.lines[line]:sub(-1) ~= "\n" then
		maxcol = maxcol + 1
	end

	col = common.clamp(col, 1, maxcol)

	return line, col
end

--- Offset a position using a callback, byte delta, or line and column delta
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @return number line resolved line
-- @return number col resolved column
function sel.position_offset(self, line, col, ...)
	if type(...) ~= "number" then
		return position_offset_func(self, line, col, ...)
	elseif select("#", ...) == 1 then
		return position_offset_byte(self, line, col, ...)
	elseif select("#", ...) == 2 then
		return position_offset_linecol(self, line, col, ...)
	end

	error("bad number of arguments")
end

--- Return text between two positions
-- Start is inclusive and end is exclusive
-- @param self table text document instance
-- @return string text selected text
function sel.get_text(self, line1, col1, line2, col2)
	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2, col2)
	line1, col1, line2, col2 = sel.sort_positions(line1, col1, line2, col2)

	if line1 == line2 then
		return self.lines[line1]:sub(col1, col2 - 1)
	end

	local lines = { self.lines[line1]:sub(col1) }

	for line = line1 + 1, line2 - 1 do
		table.insert(lines, self.lines[line])
	end

	table.insert(lines, self.lines[line2]:sub(1, col2 - 1))

	return table.concat(lines)
end

--- Return line text without a trailing LF and whether one was present
-- @param self table text document instance
-- @param line number line index
-- @return string text logical line text
-- @return boolean had_lf true when the stored line ended with LF
function sel.get_logical_line(self, line)
	line = common.clamp(line, 1, #self.lines)
	local text = self.lines[line]

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

	return text, false
end

--- Return true when the specified line ends with LF
-- @param self table text document instance
-- @param line number line index
-- @return boolean has_lf true when the stored line ends with LF
function sel.line_has_lf(self, line)
	line = common.clamp(line, 1, #self.lines)

	return self.lines[line]:sub(-1) == "\n"
end

--- Return the character at a sanitized document position
-- @param self table text document instance
-- @param line number line
-- @param col number column
-- @return string char one-byte character or empty string
function sel.get_char(self, line, col)
	line, col = self:sanitize_position(line, col)

	return self.lines[line]:sub(col, col)
end

return sel
