-- lx-bundle:true

local utf8 = require("utf8")

local search = {}

local default_opt = {}

-- Lowercases pattern fragments unless they are escaped character classes
local function pattern_lower(str)
	if str:sub(1, 1) == "%" then
		return str
	end
	return str:lower()
end

--- Converts visible plain text to one stable form for matching
-- @param text string source or query text
-- @param no_case boolean fold Unicode letter case
-- @return string searchable_text
local function prepare_plain_text(text, no_case)
	text = utf8.normalize_nfc(text)
	if no_case then
		text = utf8.fold(text)
		text = utf8.normalize_nfc(text)
	end

	return text
end

--- Converts an original document column to its prepared-text column
-- @param line_text string original document line
-- @param col number original byte column
-- @param no_case boolean fold Unicode letter case
-- @return number prepared_col
local function prepare_start_col(line_text, col, no_case)
	if col <= 1 then
		return 1
	end

	return #prepare_plain_text(line_text:sub(1, col - 1), no_case) + 1
end

--- Maps prepared matches back to complete original character clusters
-- @param line_text string original document line
-- @param matches table prepared inclusive byte ranges
-- @param no_case boolean fold Unicode letter case
local function restore_match_ranges(line_text, matches, no_case)
	local match_index = 1
	local match = matches[match_index]
	local prepared_col = 1

	for cluster_start, cluster_end in utf8.grapheme_indices(line_text) do
		if not match then
			break
		end

		local cluster = line_text:sub(cluster_start, cluster_end)
		local next_prepared_col = prepared_col + #prepare_plain_text(cluster, no_case)

		while match do
			if not match.original_start and match[1] < next_prepared_col then
				match.original_start = cluster_start
			end
			if match[2] >= next_prepared_col then
				break
			end

			match[1] = match.original_start or cluster_start
			match[2] = cluster_end + 1
			match.original_start = nil
			match_index = match_index + 1
			match = matches[match_index]
		end

		prepared_col = next_prepared_col
	end
end

--- Maps one prepared match back to its original document range
-- @param line_text string original document line
-- @param match_start number prepared start byte
-- @param match_end number prepared inclusive end byte
-- @param no_case boolean fold Unicode letter case
-- @return number original_start
-- @return number original_end exclusive end byte
local function restore_match_range(line_text, match_start, match_end, no_case)
	local original_start
	local prepared_col = 1

	for cluster_start, cluster_end in utf8.grapheme_indices(line_text) do
		local cluster = line_text:sub(cluster_start, cluster_end)
		local next_prepared_col = prepared_col + #prepare_plain_text(cluster, no_case)
		if not original_start and match_start < next_prepared_col then
			original_start = cluster_start
		end
		if match_end < next_prepared_col then
			return original_start or cluster_start, cluster_end + 1
		end

		prepared_col = next_prepared_col
	end

	return original_start or match_start, #line_text + 1
end

--- Build a reusable matcher that returns only the first plain-text range
-- The query is normalized once while each source line keeps its original byte columns
-- @param text string Search query
-- @param no_case boolean|nil Fold Unicode letter case
-- @return function matcher Returns original start and exclusive end byte columns
function search.plain_first_matcher(text, no_case)
	no_case = no_case == true
	local searchable_query = prepare_plain_text(text, no_case)

	return function(line_text)
		if searchable_query == "" then
			return nil
		end

		local searchable_text = prepare_plain_text(line_text, no_case)
		local match_start, match_end = searchable_text:find(searchable_query, 1, true)
		if not match_start then
			return nil
		end
		if searchable_text ~= line_text then
			return restore_match_range(line_text, match_start, match_end, no_case)
		end

	return match_start, match_end + 1
end
end

--- Finds every non-overlapping plain-text match in one string
-- @param line_text string original text
-- @param text string search query
-- @param no_case boolean|nil fold Unicode letter case
-- @return table matches original exclusive byte ranges
function search.find_plain_ranges(line_text, text, no_case)
	local searchable_text = prepare_plain_text(line_text, no_case == true)
	local searchable_query = prepare_plain_text(text, no_case == true)
	local matches = {}
	local col = 1

	if searchable_query == "" then
		return matches
	end

	while true do
		local match_start, match_end = searchable_text:find(searchable_query, col, true)
		if not match_start then
			break
		end

		matches[#matches + 1] = { match_start, match_end }
		col = match_end + 1
	end

	if searchable_text ~= line_text then
		restore_match_ranges(line_text, matches, no_case == true)
	else
		for i = 1, #matches do
			matches[i][2] = matches[i][2] + 1
		end
	end

	return matches
end

-- Normalizes search args and applies optional case-folding behavior
local function init_args(doc, line, col, text, opt)
	opt = opt or default_opt
	line, col = doc:sanitize_position(line, col)

	if opt.no_case then
		if opt.pattern then
			text = text:gsub("%%?.", pattern_lower)
		else
			text = prepare_plain_text(text, true)
		end
	elseif not opt.pattern then
		text = prepare_plain_text(text, false)
	end

	return doc, line, col, text, opt
end

-- Finds the next match in the document, with optional pattern and wrap support
function search.find(doc, line, col, text, opt)
	doc, line, col, text, opt = init_args(doc, line, col, text, opt)

	for line = line, #doc.lines do
		local line_text = doc.lines[line]
		local searchable_text = line_text
		local search_col = col

		if opt.pattern then
			if opt.no_case then
				searchable_text = searchable_text:lower()
			end
		else
			searchable_text = prepare_plain_text(searchable_text, opt.no_case == true)
			if searchable_text ~= line_text then
				search_col = prepare_start_col(line_text, col, opt.no_case == true)
			end
		end

		local s, e = searchable_text:find(text, search_col, not opt.pattern)
		if s then
			if not opt.pattern and searchable_text ~= line_text then
				local original_start, original_end = restore_match_range(
					line_text,
					s,
					e,
					opt.no_case == true
				)

				return line, original_start, line, original_end
			end

			return line, s, line, e + 1
		end
		col = 1
	end

	if opt.wrap then
		opt = { no_case = opt.no_case, pattern = opt.pattern }
		return search.find(doc, 1, 1, text, opt)
	end
end

return search
