-- lx-bundle:true

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local utf8help = require("core.text.utf8help")

-- functions for translating a Doc position to another position these functions
-- can be passed to Doc:move_to|select_to|delete_to()

local translate = {}

-- Return true when 'char' is whitespace used as a word boundary
local function is_space(char)
	return char == " " or char == "\t" or char == "\n" or char == "\r"
end

-- Return true when 'char' is horizontal whitespace
local function is_hspace(char)
	return char == " " or char == "\t"
end

-- Return true when 'char' is a plain space (not tab/newline)
local function is_plain_space(char)
	return char == " "
end

-- Return true when 'char' is a line break boundary
local function is_newline(char)
	return char == "\n" or char == "\r"
end

-- Return true when 'char' is a configured punctuation divider
local function is_divider(char)
	return config.word_dividers:find(char, nil, true)
end

-- Return true when 'char' should break word navigation
local function is_non_word(char)
	return is_space(char) or is_divider(char)
end

-- Prefer indentation-grid stops while the caret sits inside leading whitespace
-- This keeps movement, deletion, and selection aligned to the shared indent grid

--- Resolve a previous-character move through indentation when possible
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number|nil target_line resolved target line
-- @return number|nil target_col resolved target column
local function resolve_previous_indent_char(doc, line, col)
	local previous_col = doc:get_previous_indent_caret_col(line, col)

	if previous_col == nil then
		return nil
	end

	return line, previous_col
end

--- Resolve a next-character move through indentation when possible
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number|nil target_line resolved target line
-- @return number|nil target_col resolved target column
local function resolve_next_indent_char(doc, line, col)
	local next_col = doc:get_next_indent_caret_col(line, col)

	if next_col == nil then
		return nil
	end

	return line, next_col
end

-- Move one UTF-8 character to the left
function translate.previous_char(doc, line, col)
	local line_end_col = doc:get_line_end_col(line)

	if doc:is_virtual_space_enabled("enabled") and col > line_end_col then
		return line, col - 1
	end

	local indent_line, indent_col = resolve_previous_indent_char(doc, line, col)

	if indent_line ~= nil then
		return indent_line, indent_col
	end

	local previous_line, previous_col = doc:position_offset(line, col, -1)

	if previous_line ~= line then
		return previous_line, previous_col
	end

	local text = doc.lines[line]
	local previous_char = doc:get_char(previous_line, previous_col)

	-- Keep newline traversal on the existing byte-based boundary
	if not text or previous_char == "\n" or previous_char == "\r" then
		return previous_line, previous_col
	end

	-- Move to the start of the current visual cluster when coming from an internal stop
	-- Otherwise move to the start of the previous visual cluster
	local target_offset = math.max(1, col - 1)
	local cluster_start = utf8help.visual_cluster_start_offset(text, target_offset)

	return line, cluster_start or previous_col
end

--- Resolve next real UTF-8 character position
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number line resolved line
-- @return number col resolved column
local function next_document_char(doc, line, col)
	local indent_line, indent_col = resolve_next_indent_char(doc, line, col)

	if indent_line ~= nil then
		return indent_line, indent_col
	end

	local text = doc.lines[line]
	local current_char = doc:get_char(line, col)

	-- Keep newline traversal on the existing byte-based boundary
	if not text or current_char == "\n" or current_char == "\r" then
		return doc:position_offset(line, col, 1)
	end

	local cluster_start = utf8help.visual_cluster_start_offset(text, col)

	if not cluster_start then
		return doc:position_offset(line, col, 1)
	end

	local next_col = utf8help.next_visual_cluster_offset(text, cluster_start)

	if next_col and next_col <= #text then
		return line, next_col
	end

	return doc:position_offset(line, col, 1)
end

--- Move one UTF-8 character to the right, including virtual space when enabled
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number line resolved line
-- @return number col resolved column
function translate.next_char(doc, line, col)
	if doc:is_virtual_space_enabled("enabled") and col >= doc:get_line_end_col(line) then
		return line, col + 1
	end

	return next_document_char(doc, line, col)
end

--- Move to next deletable character without crossing empty virtual space
-- @param doc table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number line resolved line
-- @return number col resolved column
function translate.next_char_delete(doc, line, col)
	if col > doc:get_line_end_col(line) then
		return line, col
	end

	return next_document_char(doc, line, col)
end

-- Move to the start of the previous word, skipping mixed boundary runs
function translate.previous_word_start(doc, line, col)
	local start_line, start_col = 1, 1
	-- First step left once so previous-word jump works from word boundaries
	if line > start_line or col > start_col then
		line, col = doc:position_offset(line, col, -1)
	end

	local function at_start()
		return line == start_line and col == start_col
	end
	local function retreat()
		local l, c = doc:position_offset(line, col, -1)
		if l == line and c == col then
			return false
		end
		line, col = l, c
		return true
	end
	local function left_char()
		if at_start() then
			return ""
		end
		local l, c = doc:position_offset(line, col, -1)
		return doc:get_char(l, c)
	end

	local char = doc:get_char(line, col)

	if is_space(char) then
		-- Skip spaces/newlines leftward first
		while not at_start() do
			char = doc:get_char(line, col)
			if not is_space(char) then
				break
			end
			if not retreat() then
				break
			end
		end
		char = doc:get_char(line, col)
	end

	if is_divider(char) then
		-- Opening punctuation (preceded by space/start) is a hard stop
		if at_start() or is_space(left_char()) then
			return line, col
		end
		-- Divider attached to a left word (e.g. "-" or closing quote) is skipped
		while is_divider(char) and not at_start() and not is_space(left_char()) do
			if not retreat() then
				break
			end
			char = doc:get_char(line, col)
		end
		if is_divider(char) and (at_start() or is_space(left_char())) then
			return line, col
		end
	end

	return translate.start_of_word(doc, line, col)
end

-- Move to the end of the next word, skipping mixed boundary runs
function translate.next_word_end(doc, line, col)
	local end_line, end_col = translate.end_of_doc(doc, line, col)
	local function at_end()
		return line == end_line and col == end_col
	end
	local function advance()
		local l, c = doc:position_offset(line, col, 1)
		if l == line and c == col then
			return false
		end
		line, col = l, c
		return true
	end

	local char = doc:get_char(line, col)
	-- Treat line breaks as single-step boundaries (preserve EOL/blank-line stops)
	if is_newline(char) then
		advance()
		return line, col
	end
	if is_non_word(char) then
		-- Skip horizontal spaces/dividers; keep newline as an explicit stop
		local saw_hspace = false
		while not at_end() do
			char = doc:get_char(line, col)
			if is_newline(char) then
				break
			end
			if not (is_hspace(char) or is_divider(char)) then
				break
			end
			if is_hspace(char) then
				saw_hspace = true
			end
			if not advance() then
				break
			end
		end
		if saw_hspace or at_end() then
			return line, col
		end
	end

	-- Consume current word
	while not at_end() do
		char = doc:get_char(line, col)
		if is_non_word(char) then
			break
		end
		if not advance() then
			break
		end
	end

	-- Consume trailing horizontal spaces only; keep EOL as a stop
	while not at_end() do
		char = doc:get_char(line, col)
		if not is_hspace(char) then
			break
		end
		if not advance() then
			break
		end
	end
	return line, col
end

-- Trim trailing whitespace from a rightward selection target
local function trim_selection_left_whitespace(doc, line, col)
	while true do
		local l, c = translate.previous_char(doc, line, col)
		if l == line and c == col then
			break
		end
		local char = doc:get_char(l, c)
		if not is_hspace(char) then
			break
		end
		line, col = l, c
	end
	return line, col
end

-- Trim leading whitespace from a leftward selection target
local function trim_selection_right_whitespace(doc, line, col)
	local end_line, end_col = translate.end_of_doc(doc, line, col)
	while line < end_line or col < end_col do
		local char = doc:get_char(line, col)
		if not is_hspace(char) then
			break
		end
		local l, c = translate.next_char(doc, line, col)
		if l == line and c == col then
			break
		end
		line, col = l, c
	end
	return line, col
end

-- Move to previous-word start and exclude leading whitespace from selection
function translate.previous_word_start_select(doc, line, col)
	line, col = translate.previous_word_start(doc, line, col)
	return trim_selection_right_whitespace(doc, line, col)
end

-- Move to previous-word start for deletion, treating whitespace runs as their own step
function translate.previous_word_start_delete(doc, line, col)
	local start_line, start_col = 1, 1
	if line == start_line and col == start_col then
		return line, col
	end
	local l, c = translate.previous_char(doc, line, col)
	local ch = doc:get_char(l, c)
	if is_space(ch) then
		line, col = l, c
		while line > start_line or col > start_col do
			local l2, c2 = translate.previous_char(doc, line, col)
			if l2 == line and c2 == col then
				break
			end
			if not is_space(doc:get_char(l2, c2)) then
				break
			end
			line, col = l2, c2
		end
		return line, col
	end
	return translate.previous_word_start(doc, line, col)
end

-- Move to next-word end and exclude trailing whitespace from selection
function translate.next_word_end_select(doc, line, col)
	local start_line, start_col = line, col
	line, col = translate.next_word_end(doc, line, col)
	line, col = trim_selection_left_whitespace(doc, line, col)

	-- If we landed at next word start from boundary chars, include that word
	local target_char = doc:get_char(line, col)
	if target_char ~= "" and not is_non_word(target_char) then
		local prev_line, prev_col = translate.previous_char(doc, line, col)
		local prev_char = doc:get_char(prev_line, prev_col)
		local start_char = doc:get_char(start_line, start_col)
		if is_non_word(prev_char) and is_non_word(start_char) then
			line, col = translate.end_of_word(doc, line, col)
			return line, col
		end
	end

	-- If trimming collapsed movement on whitespace, advance to the next word end
	if line == start_line and col == start_col and is_space(doc:get_char(start_line, start_col)) then
		local end_line, end_col = translate.end_of_doc(doc, line, col)
		while line < end_line or col < end_col do
			local ch = doc:get_char(line, col)
			if not is_space(ch) then
				break
			end
			local l, c = translate.next_char(doc, line, col)
			if l == line and c == col then
				break
			end
			line, col = l, c
		end
		local ch = doc:get_char(line, col)
		if ch ~= "" and not is_non_word(ch) then
			line, col = translate.end_of_word(doc, line, col)
		end
	end

	return line, col
end

-- Move to next-word end for deletion, using chunked forward delete semantics
function translate.next_word_end_delete(doc, line, col)
	local end_line, end_col = translate.end_of_doc(doc, line, col)
	local function at_end()
		return line == end_line and col == end_col
	end
	local function advance()
		local l, c = translate.next_char(doc, line, col)
		if l == line and c == col then
			return false
		end
		line, col = l, c
		return true
	end
	local function next_char_at(l, c)
		local l2, c2 = translate.next_char(doc, l, c)
		return l2, c2, doc:get_char(l2, c2)
	end
	local function consume_plain_spaces()
		while not at_end() and is_plain_space(doc:get_char(line, col)) do
			if not advance() then
				break
			end
		end
	end

	if at_end() then
		return line, col
	end

	local ch = doc:get_char(line, col)

	-- Whitespace deletes as its own run
	if is_space(ch) then
		while not at_end() and is_space(doc:get_char(line, col)) do
			if not advance() then
				break
			end
		end
		return line, col
	end

	-- Delete one complete punctuation run without taking following whitespace
	if is_divider(ch) then
		-- Apostrophe prefix deletes with its following word chunk (e.g. "'s ")
		if ch == "'" then
			if not advance() then
				return line, col
			end
			while not at_end() do
				local ch2 = doc:get_char(line, col)
				if is_non_word(ch2) then
					break
				end
				if not advance() then
					break
				end
			end
			consume_plain_spaces()
			return line, col
		end
		while not at_end() and is_divider(doc:get_char(line, col)) do
			if not advance() then
				break
			end
		end
		consume_plain_spaces()
		return line, col
	end

	-- Consume current word
	while not at_end() do
		ch = doc:get_char(line, col)
		if is_non_word(ch) then
			break
		end
		if not advance() then
			break
		end
	end
	if at_end() then
		return line, col
	end

	ch = doc:get_char(line, col)
	-- Include a connector hyphen when it joins to the next word (hand-)
	if ch == "-" then
		local _, _, ch2 = next_char_at(line, col)
		if ch2 ~= "" and not is_non_word(ch2) then
			advance()
			return line, col
		end
	end

	-- Consume trailing plain spaces after word chunks; keep tabs as hard stops
	consume_plain_spaces()
	return line, col
end

-- Move to the start of the current word
function translate.start_of_word(doc, line, col)
	while true do
		local line2, col2 = doc:position_offset(line, col, -1)
		local char = doc:get_char(line2, col2)
		if is_non_word(char) or line == line2 and col == col2 then
			break
		end
		line, col = line2, col2
	end
	return line, col
end

-- Move to the end of the current word
function translate.end_of_word(doc, line, col)
	while true do
		local line2, col2 = doc:position_offset(line, col, 1)
		local char = doc:get_char(line, col)
		if is_non_word(char) or line == line2 and col == col2 then
			break
		end
		line, col = line2, col2
	end
	return line, col
end

-- Move to the start of the previous non-empty block
function translate.previous_block_start(doc, line, col)
	while true do
		line = line - 1
		if line <= 1 then
			return 1, 1
		end
		if doc.lines[line - 1]:find("^%s*$") and not doc.lines[line]:find("^%s*$") then
			return line, (doc.lines[line]:find("%S"))
		end
	end
end

-- Move to the end of the next non-empty block
function translate.next_block_end(doc, line, col)
	while true do
		if line >= #doc.lines then
			return #doc.lines, #doc.lines[#doc.lines]
		end
		if doc.lines[line + 1]:find("^%s*$") and not doc.lines[line]:find("^%s*$") then
			return line + 1, #doc.lines[line + 1]
		end
		line = line + 1
	end
end

--- Move toward the start of the current logical or wrapped line
-- @param doc table text document
-- @param line number logical line
-- @param col number logical byte column
-- @return number line target logical line
-- @return number col target logical byte column
function translate.start_of_line(doc, line, col, view, selection_id)
	view = view or core.active_view

	if view and view.doc == doc and view.get_line_edge_position then
		local preferred_row = view.get_selection_visual_row and view:get_selection_visual_row(selection_id) or nil
		local target_line, target_col, visual_row = view:get_line_edge_position(line, col, false, preferred_row)

		if view.sync_caret_column then
			view:sync_caret_column(target_line, target_col, visual_row, selection_id)
		end

		return target_line, target_col
	end

	return line, 1
end

--- Move toward the end of the current logical or wrapped line
-- @param doc table text document
-- @param line number logical line
-- @param col number logical byte column
-- @return number line target logical line
-- @return number col target logical byte column
function translate.end_of_line(doc, line, col, view, selection_id)
	view = view or core.active_view

	if view and view.doc == doc and view.get_line_edge_position then
		local preferred_row = view.get_selection_visual_row and view:get_selection_visual_row(selection_id) or nil
		local target_line, target_col, visual_row = view:get_line_edge_position(line, col, true, preferred_row)

		if view.sync_caret_column then
			view:sync_caret_column(target_line, target_col, visual_row, selection_id)
		end

		return target_line, target_col
	end

	return line, math.huge
end

-- Move to start of document
function translate.start_of_doc(doc, line, col)
	return 1, 1
end

-- Move to end of document
function translate.end_of_doc(doc, line, col)
	return #doc.lines, #doc.lines[#doc.lines]
end

return translate
