-- lx-bundle:true

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

local common = require("core.common")

local sel = {}

--- 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 true when the first position is earlier
local function position_before(line1, col1, line2, col2)
	return line1 < line2 or line1 == line2 and col1 < col2
end

--- Return true when two positions are equal
-- @param line1 number first line
-- @param col1 number first column
-- @param line2 number second line
-- @param col2 number second column
-- @return boolean equal true when both positions match
local function positions_equal(line1, col1, line2, col2)
	return line1 == line2 and col1 == col2
end

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

--- Return ordered endpoints for one stored selection
-- @param selection table stored selection
-- @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 stored endpoints were reversed
local function get_selection_bounds(selection)
	return sel.sort_positions(
		selection.start.line,
		selection.start.col,
		selection.stop.line,
		selection.stop.col
	)
end

--- Return true when two ordered selection ranges conflict
-- Empty carets conflict with identical carets or ranges that contain them
-- @param left table first selection
-- @param right table second selection
-- @return boolean overlaps true when the selections must merge
local function selections_overlap(left, right)
	local left_line1, left_col1, left_line2, left_col2 = get_selection_bounds(left)
	local right_line1, right_col1, right_line2, right_col2 = get_selection_bounds(right)
	local left_empty = positions_equal(left_line1, left_col1, left_line2, left_col2)
	local right_empty = positions_equal(right_line1, right_col1, right_line2, right_col2)

	if left_empty then
		return not position_before(left_line1, left_col1, right_line1, right_col1)
			and not position_before(right_line2, right_col2, left_line1, left_col1)
	end

	if right_empty then
		return not position_before(right_line1, right_col1, left_line1, left_col1)
			and not position_before(left_line2, left_col2, right_line1, right_col1)
	end

	return position_before(left_line1, left_col1, right_line2, right_col2)
		and position_before(right_line1, right_col1, left_line2, left_col2)
end

--- Expand the target selection to include the source selection
-- The target direction and identity remain stable
-- @param target table selection receiving the merged range
-- @param source table selection merged into the target
local function merge_selection_into(target, source)
	local target_line1, target_col1, target_line2, target_col2, target_swapped = get_selection_bounds(target)
	local source_line1, source_col1, source_line2, source_col2 = get_selection_bounds(source)

	if position_before(source_line1, source_col1, target_line1, target_col1) then
		target_line1, target_col1 = source_line1, source_col1
	end

	if position_before(target_line2, target_col2, source_line2, source_col2) then
		target_line2, target_col2 = source_line2, source_col2
	end

	if target_swapped then
		target.start.line, target.start.col = target_line2, target_col2
		target.stop.line, target.stop.col = target_line1, target_col1
	else
		target.start.line, target.start.col = target_line1, target_col1
		target.stop.line, target.stop.col = target_line2, target_col2
	end
end

--- Sort and merge one selection collection while retaining the primary selection
-- @param selections table stored selections
-- @param primary table primary selection object
-- @return table selections normalized selections
-- @return table primary retained primary selection
local function normalize_selections(selections, primary)
	table.sort(selections, function(left, right)
		local left_line1, left_col1, left_line2, left_col2 = get_selection_bounds(left)
		local right_line1, right_col1, right_line2, right_col2 = get_selection_bounds(right)

		if not positions_equal(left_line1, left_col1, right_line1, right_col1) then
			return position_before(left_line1, left_col1, right_line1, right_col1)
		end

		return position_before(left_line2, left_col2, right_line2, right_col2)
	end)

	local normalized = {}

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

		if previous and selections_overlap(previous, selection) then
			if selection == primary and previous ~= primary then
				merge_selection_into(selection, previous)
				normalized[#normalized] = selection
			else
				merge_selection_into(previous, selection)
			end
		else
			normalized[#normalized + 1] = selection
		end
	end

	return normalized, primary
end

--- Allocate one stable selection identifier
-- @param self table text document instance
-- @param requested_id number|nil identifier restored from a snapshot
-- @return number id allocated identifier
local function allocate_selection_id(self, requested_id)
	local id = tonumber(requested_id)

	if id then
		id = math.floor(id)
	end

	if not id or id < 1 then
		id = self._next_selection_id or 1
	end

	self._next_selection_id = math.max(self._next_selection_id or 1, id + 1)

	return id
end

--- Build one sanitized stored selection
-- @param self table text document instance
-- @param line1 number anchor line
-- @param col1 number anchor column
-- @param line2 number|nil cursor line
-- @param col2 number|nil cursor column
-- @param id number|nil restored selection identifier
-- @return table selection stored selection
local function create_selection(self, line1, col1, line2, col2, id)
	line1, col1 = self:sanitize_position(line1, col1)
	line2, col2 = self:sanitize_position(line2 or line1, col2 or col1)

	return {
		id = allocate_selection_id(self, id),
		start = { line = line1, col = col1 },
		stop = { line = line2, col = col2 },
	}
end

--- Clear a stored find match when the primary selection no longer matches it
-- @param self table text document instance
local function clear_stale_match_selection(self)
	if not self.match_selection then
		return
	end

	local line1, col1, line2, col2 = self:get_selection()
	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

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

	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
	self.selections = { self.selection }
	clear_stale_match_selection(self)
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

--- Replace the complete selection collection from detached snapshots
-- @param self table text document instance
-- @param snapshots table selection endpoint snapshots
-- @param primary_index number|nil primary snapshot index
function sel.set_selections(self, snapshots, primary_index)
	assert(type(snapshots) == "table" and #snapshots > 0, "expected at least one selection")
	primary_index = math.floor(tonumber(primary_index) or 1)
	assert(primary_index >= 1 and primary_index <= #snapshots, "primary selection index out of bounds")

	local selections = {}
	local used_ids = {}
	local primary

	for index = 1, #snapshots do
		local snapshot = snapshots[index]
		assert(type(snapshot) == "table", "expected selection table")
		assert(not snapshot[3] == not snapshot[4], "expected 2 or 4 selection values")

		local id = tonumber(snapshot.id)
		if id then
			id = math.floor(id)
		end

		if not id or id < 1 or used_ids[id] then
			id = nil
		end

		local selection = create_selection(self, snapshot[1], snapshot[2], snapshot[3], snapshot[4], id)
		used_ids[selection.id] = true
		selections[index] = selection

		if index == primary_index then
			primary = selection
		end
	end

	self.selections, self.selection = normalize_selections(selections, primary)
	clear_stale_match_selection(self)
end

--- Return detached snapshots of all selections in document order
-- @param self table text document instance
-- @param sort boolean|nil order each selection's endpoints when true
-- @return table snapshots selection endpoint snapshots
-- @return number primary_index primary snapshot index
function sel.get_selections(self, sort)
	local snapshots = {}
	local primary_index = 1

	for index = 1, #self.selections do
		local selection = self.selections[index]
		local line1 = selection.start.line
		local col1 = selection.start.col
		local line2 = selection.stop.line
		local col2 = selection.stop.col

		if sort then
			line1, col1, line2, col2 = sel.sort_positions(line1, col1, line2, col2)
		end

		snapshots[index] = { line1, col1, line2, col2, id = selection.id }

		if selection == self.selection then
			primary_index = index
		end
	end

	return snapshots, primary_index
end

--- Add one secondary selection and normalize the collection
-- @param self table text document instance
-- @return number|nil id added identifier, or nil when merged into an existing selection
function sel.add_selection(self, line1, col1, line2, col2)
	assert(not line2 == not col2, "expected 2 or 4 arguments")
	local selection = create_selection(self, line1, col1, line2, col2)
	local id = selection.id
	self.selections[#self.selections + 1] = selection
	self.selections, self.selection = normalize_selections(self.selections, self.selection)

	for index = 1, #self.selections do
		if self.selections[index].id == id then
			return id
		end
	end

	return nil
end

--- Remove one selection by identifier and promote the next when needed
-- The final remaining selection cannot be removed
-- @param self table text document instance
-- @param id number selection identifier
-- @return boolean removed true when one selection was removed
function sel.remove_selection(self, id)
	if #self.selections <= 1 then
		return false
	end

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

		if selection.id == id then
			local was_primary = selection == self.selection
			table.remove(self.selections, index)

			if was_primary then
				local next_index = index <= #self.selections and index or 1
				self.selection = self.selections[next_index]
				clear_stale_match_selection(self)
			end

			return true
		end
	end

	return false
end

--- Return the number of normalized selections
-- @param self table text document instance
-- @return number count selection count
function sel.get_selection_count(self)
	return #self.selections
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 true when any stored selection has a range
-- @param self table text document instance
-- @return boolean has_selection
function sel.has_any_selection(self)
	for index = 1, #self.selections do
		local selection = self.selections[index]
		local anchor, cursor = selection.start, selection.stop

		if anchor.line ~= cursor.line or anchor.col ~= cursor.col then
			return true
		end
	end

	return false
end

--- Return text from every non-empty selection in document order
-- @param self table text document instance
-- @return table texts selected text fragments
function sel.get_selected_texts(self)
	local selections = self:get_selections(true)
	local texts = {}

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

		if selection[1] ~= selection[3] or selection[2] ~= selection[4] then
			texts[#texts + 1] = self:get_text(selection[1], selection[2], selection[3], selection[4])
		end
	end

	return texts
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)
	local snapshots, primary_index = self:get_selections()
	self:set_selections(snapshots, primary_index)
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
