-- lx-bundle:true

--- Matching-text multiple-selection commands
-- @module core.text.occurrence

local core = require("core")
local command = require("core.command")
local config = require("core.config")
local keymap = require("core.keymap")
local search = require("core.text.search")
local translate = require("core.text.translate")

local wrapped_search_options = { wrap = true }

--- Return whether multi-cursor occurrence commands are enabled
-- @return boolean enabled
local function multi_cursor_commands_enabled()
	return config.enable_multi_cursor ~= false
end

--- Return the active document view when it supports selection
-- @return table|nil view active selectable view
local function get_view()
	local view = core.active_view

	if not (view and view.doc and core.view_can_select(view)) then
		return nil
	end

	return view
end

--- Build one stable key for an ordered selection range
-- @param line1 number start line
-- @param col1 number start column
-- @param line2 number end line
-- @param col2 number end column
-- @return string key range key
local function range_key(line1, col1, line2, col2)
	return table.concat({ line1, col1, line2, col2 }, ":")
end

--- Return ordered endpoints without changing one selection's caret direction
-- @param selection table detached selection endpoints
-- @return number line1 ordered start line
-- @return number col1 ordered start column
-- @return number line2 ordered end line
-- @return number col2 ordered end column
local function selection_bounds(selection)
	local line1, col1, line2, col2 = selection[1], selection[2], selection[3], selection[4]

	if line1 > line2 or line1 == line2 and col1 > col2 then
		return line2, col2, line1, col1
	end

	return line1, col1, line2, col2
end

--- Build one stable key for a selection regardless of caret direction
-- @param selection table detached selection endpoints
-- @return string key ordered range key
local function selection_key(selection)
	return range_key(selection_bounds(selection))
end

--- Return the shared single-line text selected by every caret
-- @param doc table text document
-- @return string|nil query selected text
local function get_occurrence_query(doc)
	if not doc:has_selection() then
		return nil
	end

	local primary_line1, primary_col1, primary_line2, primary_col2 = doc:get_selection(true)

	if primary_line1 ~= primary_line2 then
		return nil
	end

	local query = doc:get_text(primary_line1, primary_col1, primary_line2, primary_col2)

	if query == "" then
		return nil
	end

	local selections = doc:get_selections(true)

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

		if selection[1] ~= selection[3]
			or doc:get_text(selection[1], selection[2], selection[3], selection[4]) ~= query
		then
			return nil
		end
	end

	return query
end

--- Return whether one range covers exactly one complete word
-- @param doc table text document
-- @param line1 number start line
-- @param col1 number start column
-- @param line2 number end line
-- @param col2 number end column
-- @return boolean complete true when the range matches configured word boundaries
local function is_complete_word(doc, line1, col1, line2, col2)
	if line1 ~= line2 then
		return false
	end

	local start_line, start_col = translate.start_of_word(doc, line1, col1)
	local end_line, end_col = translate.end_of_word(doc, line1, col1)

	return start_line == line1 and start_col == col1
		and end_line == line2 and end_col == col2
end

--- Collect every exact case-sensitive occurrence in document order
-- @param doc table text document
-- @param query string non-empty single-line query
-- @param whole_word boolean require configured word boundaries
-- @return table matches ordered match ranges
local function collect_occurrences(doc, query, whole_word)
	local matches = {}
	local line, col = 1, 1

	while line <= #doc.lines do
		local line1, col1, line2, col2 = search.find(doc, line, col, query)

		if not line1 then
			break
		end

		if not whole_word or is_complete_word(doc, line1, col1, line2, col2) then
			matches[#matches + 1] = { line1, col1, line2, col2 }
		end

		line, col = line2, col2
	end

	return matches
end

--- Find the next eligible occurrence without collecting the whole document
-- @param doc table text document
-- @param query string non-empty single-line query
-- @param line number search start line
-- @param col number search start column
-- @param selected table selected range-key set
-- @param whole_word boolean require configured word boundaries
-- @return table|nil match next unselected occurrence
local function find_next_unselected_occurrence(doc, query, line, col, selected, whole_word)
	local first_key

	while true do
		local line1, col1, line2, col2 = search.find(
			doc, line, col, query, wrapped_search_options
		)

		if not line1 then
			return nil
		end

		local key = range_key(line1, col1, line2, col2)

		if key == first_key then
			return nil
		end

		first_key = first_key or key

		if not selected[key]
			and (not whole_word or is_complete_word(doc, line1, col1, line2, col2))
		then
			return { line1, col1, line2, col2 }
		end

		line, col = line2, col2
	end
end

--- Build shared state for selecting or skipping one occurrence
-- @param doc table text document
-- @param query string shared selected text
-- @return table selections directed selection snapshots
-- @return number primary_index primary selection index
-- @return table|nil match next eligible occurrence
local function get_occurrence_step(doc, query)
	local selections, primary_index = doc:get_selections()
	local primary = selections[primary_index]
	local line1, col1, line2, col2 = selection_bounds(primary)
	local whole_word = is_complete_word(doc, line1, col1, line2, col2)
	local selected = {}

	for index = 1, #selections do
		selected[selection_key(selections[index])] = true
	end

	local match = find_next_unselected_occurrence(
		doc, query, line2, col2, selected, whole_word
	)

	return selections, primary_index, match
end

--- Refresh caret state and reveal the active occurrence
-- @param view table text surface
local function finish_occurrence_selection(view)
	if view.sync_cursor_column_from_selection then
		view:sync_cursor_column_from_selection()
	end

	if view.scroll_to_make_visible then
		view:scroll_to_make_visible(view.doc:get_selection())
	end

	view.blink_timer = 0
	core.redraw = true
end

--- Select the word under the primary caret when no text is selected
-- @param doc table text document
-- @return boolean selected true when a non-empty word is selected
-- @return boolean selected_now true when this call created the selection
local function ensure_occurrence_query(doc)
	if doc:has_selection() then
		return true, false
	end

	command.perform("doc:select-word")

	return doc:has_selection(), true
end

--- Add the next unselected occurrence and make it primary
-- @return boolean handled true when a word or occurrence was selected
local function select_next_occurrence()
	local view = get_view()

	if not view then
		return false
	end

	local doc = view.doc

	local selected, selected_now = ensure_occurrence_query(doc)

	if not selected then
		return false
	end

	if selected_now then
		return true
	end

	local query = get_occurrence_query(doc)

	if not query then
		return false
	end

	local selections, _, match = get_occurrence_step(doc, query)

	if not match then
		return false
	end

	selections[#selections + 1] = { match[3], match[4], match[1], match[2] }
	doc:set_selections(selections, #selections)
	finish_occurrence_selection(view)

	return true
end

--- Select every exact occurrence of the current selected text
-- @return boolean handled true when occurrence selections were created
local function select_all_occurrences()
	local view = get_view()

	if not view then
		return false
	end

	local doc = view.doc

	if not ensure_occurrence_query(doc) then
		return false
	end

	local query = get_occurrence_query(doc)

	if not query then
		return false
	end

	local current_line1, current_col1, current_line2, current_col2 = doc:get_selection(true)
	local whole_word = is_complete_word(
		doc, current_line1, current_col1, current_line2, current_col2
	)
	local matches = collect_occurrences(doc, query, whole_word)

	if #matches == 0 then
		return false
	end

	local selections = {}
	local primary_index = 1

	for index = 1, #matches do
		local match = matches[index]
		selections[index] = { match[3], match[4], match[1], match[2] }

		if range_key(match[1], match[2], match[3], match[4])
			== range_key(current_line1, current_col1, current_line2, current_col2)
		then
			primary_index = index
		end
	end

	doc:set_selections(selections, primary_index)
	finish_occurrence_selection(view)

	return true
end

--- Replace the latest selected occurrence with the following unselected match
-- @return boolean handled true when one occurrence was skipped
local function skip_next_occurrence()
	local view = get_view()

	if not view then
		return false
	end

	local doc = view.doc
	local query = get_occurrence_query(doc)

	if not query then
		return false
	end

	local selections, primary_index, match = get_occurrence_step(doc, query)

	if not match then
		return false
	end

	selections[primary_index] = {
		match[3], match[4], match[1], match[2],
		id = selections[primary_index].id,
	}
	doc:set_selections(selections, primary_index)
	finish_occurrence_selection(view)

	return true
end

command.register({
	["doc:select-occurrence"] = {
		requires = { "doc", "edit", "scroll" },
		predicate = multi_cursor_commands_enabled,
		perform = select_next_occurrence,
	},

	["doc:select-all-occurrences"] = {
		requires = { "doc", "edit", "scroll" },
		predicate = multi_cursor_commands_enabled,
		perform = select_all_occurrences,
	},

	["doc:skip-occurrence"] = {
		requires = { "doc", "edit", "scroll" },
		predicate = multi_cursor_commands_enabled,
		perform = skip_next_occurrence,
	},
})

keymap.bind("core", {
	text_editor = {
		["alt+d"] = "doc:select-occurrence",
	},
})

return true
