-- lx-bundle:true

-- Alignblock line-transform plugin:
-- - wrap/split lines by width
-- - merge selected/full lines and blocks
-- - normalize paragraph-like prose
-- - preserve predictable cursor placement after transforms
local core = require("core")
local common = require("core.common")
local config = require("core.config")

-- Cursor landing policy after wrap/split/normalize operations
-- Supported: "start-first", "end-first", "start-last", "end-last"
config.alignblock_cursor_target = config.alignblock_cursor_target or "start-last"

-- Optional explicit width in columns; nil falls back to rulers/line_limit/80
config.alignblock_default_width = config.alignblock_default_width

-- When true, wrap tokenization additionally splits words on configured symbols
config.alignblock_split_word_boundaries = config.alignblock_split_word_boundaries == true

-- UTF-8 symbol list used when split_word_boundaries is enabled
config.alignblock_split_symbols = config.alignblock_split_symbols or "=-+/\\"

-- If > 0, avoid ending wrapped lines with very short trailing words
config.alignblock_short_word_length = config.alignblock_short_word_length or 4

-- Join string used by merge operations
config.alignblock_merge_glue = config.alignblock_merge_glue or " "

-- Cursor target after 'lines:merge'
config.alignblock_merge_cursor_target = config.alignblock_merge_cursor_target or "start"

-- Cursor target after 'lines:merge-all'
config.alignblock_merge_all_cursor_target = config.alignblock_merge_all_cursor_target or "end"

-- Builds a lookup-set for configured alignblock split symbols
local function build_separator_set()
	local set = {}
	for ch in common.utf8_chars(config.alignblock_split_symbols or "") do
		set[ch] = true
	end
	return set
end

-- Returns true when a character is a wrap boundary
local function is_wrap_boundary_char(c, separators)
	if not c or c == "" then
		return true
	end
	-- Pattern breakdown:
	-- %s matches any single whitespace char
	if c:match("%s") then
		return true
	end
	return separators and separators[c] or false
end

-- Adjusts split column left to the current token start for wrap mode
-- Prevents cutting the first wrapped token at the initial split point
local function snap_split_col_to_token_start(text, col, separators)
	col = common.clamp(col, 1, #text + 1)
	if col <= 1 or col > #text then
		return col
	end

	local token_start = 1
	local prev_c
	local i = 1
	while i <= #text do
		local c, start_i, end_i = common.next_utf8_char(text, i)
		if is_wrap_boundary_char(c, separators) then
			token_start = end_i + 1
		end
		if start_i >= col then
			break
		end
		prev_c = c
		i = end_i + 1
	end

	local cur_c = text:sub(col, col)
	while common.is_utf8_cont(cur_c) and col < #text do
		col = col + 1
		cur_c = text:sub(col, col)
	end
	local c = (col <= #text) and select(1, common.next_utf8_char(text, col)) or nil
	if not is_wrap_boundary_char(prev_c, separators) and not is_wrap_boundary_char(c, separators) then
		return token_start
	end
	return col
end

-- Splits one word token by configured symbols (separator kept at end of chunk)
local function split_word_by_symbols(word, separators)
	local out = {}
	local cur = {}
	local i = 1
	while i <= #word do
		local c, _, end_i = common.next_utf8_char(word, i)
		cur[#cur + 1] = c
		if separators and separators[c] then
			out[#out + 1] = table.concat(cur)
			cur = {}
		end
		i = end_i + 1
	end
	if #cur > 0 then
		out[#out + 1] = table.concat(cur)
	end
	return out
end

-- Tokenizes text for wrap mode by whitespace-delimited words
-- Optional symbol splitting further divides words into smaller tokens
local function tokenize_wrap_words(text, separators)
	local tokens = {}
	local s = common.ltrim(text)
	-- Pattern breakdown:
	-- %S+ matches each whitespace-delimited word token
	for word in s:gmatch("%S+") do
		if separators then
			local parts = split_word_by_symbols(word, separators)
			for i = 1, #parts do
				tokens[#tokens + 1] = parts[i]
			end
		else
			tokens[#tokens + 1] = word
		end
	end
	return tokens
end

-- Returns whether a token is a short word for line-end prevention
local function is_short_word(token, max_len)
	if max_len <= 0 then
		return false
	end
	local n = 0
	for _ in common.utf8_chars(token or "") do
		n = n + 1
	end
	return n > 0 and n <= max_len
end

-- Returns configured short-word length
local function get_short_word_length()
	local v = tonumber(config.alignblock_short_word_length)
	return math.max(0, math.floor(v or 0))
end

-- Splits text into whitespace-delimited words
local function split_words(text)
	local out = {}
	-- Pattern breakdown:
	-- %S+ matches each whitespace-delimited word token
	for w in tostring(text or ""):gmatch("%S+") do
		out[#out + 1] = w
	end
	return out
end

-- Wraps remainder text into lines using one-pass packing
-- Guarantees no mid-word splits and applies short-word carry during packing
local function wrap_text(prefix, text, max_width, font, tab_size, separators, short_len)
	-- Queue holds unplaced tokens for this paragraph tail
	local queue = tokenize_wrap_words(text, separators)
	local out = {}
	-- Seed from prefix words so first wrapped line is measured as rendered
	local line_words = split_words(prefix)
	short_len = math.max(0, tonumber(short_len) or 0)

	while #queue > 0 or #line_words > 0 do
		while #queue > 0 do
			local next_word = queue[1]
			local candidate = (#line_words == 0) and next_word or (table.concat(line_words, " ") .. " " .. next_word)
			if #line_words > 0 and common.calculate_text_width(candidate, font, tab_size) > max_width then
				break
			end
			line_words[#line_words + 1] = table.remove(queue, 1)
			if #line_words == 1 and common.calculate_text_width(line_words[1], font, tab_size) > max_width then
				break
			end
		end

		-- If current line seed is already too wide, push trailing words forward
		while #line_words > 1 and common.calculate_text_width(table.concat(line_words, " "), font, tab_size) > max_width do
			table.insert(queue, 1, table.remove(line_words))
		end

		-- Move short trailing words to the next line while keeping one word here
		if short_len > 0 and #queue > 0 then
			while #line_words > 1 and is_short_word(line_words[#line_words], short_len) do
				table.insert(queue, 1, table.remove(line_words))
			end
		end

		if #line_words == 0 then
			-- Defensive break: prevents infinite loop on malformed token state
			break
		end
		out[#out + 1] = common.rtrim(table.concat(line_words, " "))
		line_words = {}
	end

	return out
end

-- Splits one remainder chunk for split mode using hard cuts only
local function split_text_for_width(text, max_width, font, tab_size)
	-- Split mode is width-limited but intentionally not token-aware:
	-- it cuts at the last fitting UTF-8 boundary
	local s = common.ltrim(text)
	if s == "" then
		return nil, ""
	end

	local width = 0
	local i = 1
	local last_end = 0
	while i <= #s do
		local c, _, end_i = common.next_utf8_char(s, i)
		local cw
		if c == "\t" then
			cw = tab_size - (width % tab_size)
		else
			cw = font:get_width(c)
		end
		if width + cw > max_width then
			local cut = (last_end > 0) and last_end or end_i
			local left = common.rtrim(s:sub(1, cut))
			-- Pattern breakdown:
			-- ^ anchors match at start of the right-side remainder
			-- %s+ trims any leading whitespace from the next chunk
			local right = s:sub(cut + 1):gsub("^%s+", "")
			return left, right
		end
		width = width + cw
		last_end = end_i
		i = end_i + 1
	end

	return common.rtrim(s), ""
end

-- Splits remainder text into lines for split mode using hard cuts only
local function split_text(text, max_width, font, tab_size)
	local out = {}
	local rest = text
	while rest ~= "" do
		local line, next_rest = split_text_for_width(rest, max_width, font, tab_size)
		if not line then
			break
		end
		out[#out + 1] = line
		rest = next_rest
	end
	return out
end

-- Wraps tokens into lines with per-line width budgets for first/subsequent lines
local function wrap_tokens(tokens, first_width, next_width, font, tab_size, short_len)
	local out = {}
	local idx = 1
	local count = #tokens
	local line_no = 1
	short_len = math.max(0, tonumber(short_len) or 0)

	while idx <= count do
		local limit = (line_no == 1) and first_width or next_width
		limit = math.max(1, limit)

		local line_words = {}
		local line_text = ""
		while idx <= count do
			local w = tokens[idx]
			local candidate = (#line_words == 0) and w or (line_text .. " " .. w)
			if #line_words > 0 and common.calculate_text_width(candidate, font, tab_size) > limit then
				break
			end
			line_words[#line_words + 1] = w
			line_text = candidate
			idx = idx + 1
			if #line_words == 1 and common.calculate_text_width(line_text, font, tab_size) > limit then
				break
			end
		end

		if short_len > 0 and idx <= count then
			-- Keep very short words from dangling at line end
			while #line_words > 1 and is_short_word(line_words[#line_words], short_len) do
				idx = idx - 1
				line_words[#line_words] = nil
				line_text = table.concat(line_words, " ")
			end
		end

		if #line_words == 0 then
			line_text = tokens[idx]
			idx = idx + 1
		end

		out[#out + 1] = line_text
		line_no = line_no + 1
	end

	return out
end

-- Prefix pattern used for paragraph normalization
-- Intention:
-- keep leading non-word punctuation/list markers (e.g. "> ", "- ", "• ")
-- while normalizing paragraph body text
local PARAGRAPH_PREFIX_PATTERN = "[^%w%[%](){}''\"]*"

-- Returns leading non-word prefix and remaining body
local function split_paragraph_prefix(text)
	local s = tostring(text or "")
	local prefix = s:match("^" .. PARAGRAPH_PREFIX_PATTERN) or ""
	return prefix, s:sub(#prefix + 1)
end

-- Appends whitespace-delimited words from 'text' to 'tokens'
local function append_words(tokens, text)
	for w in tostring(text or ""):gmatch("%S+") do
		tokens[#tokens + 1] = w
	end
end

-- Normalizes one paragraph: strip/rebuild prefixes, normalize spacing, and wrap
local function normalize_paragraph(lines, max_width, font, tab_size)
	if #lines == 0 then
		return {}
	end

	local prefix1 = split_paragraph_prefix(lines[1])
	local prefix2 = prefix1
	if #lines >= 2 then
		prefix2 = split_paragraph_prefix(lines[2])
	end

	local tokens = {}
	for i = 1, #lines do
		local line = lines[i]
		local prefix = (i == 1) and prefix1 or prefix2
		if prefix ~= "" and line:sub(1, #prefix) == prefix then
			line = line:sub(#prefix + 1)
		end
		line = common.trim(line)
		if line ~= "" then
			-- Collect body words with normalized internal spacing
			append_words(tokens, line)
		end
	end

	if #tokens == 0 then
		-- Preserve a stable single output line when paragraph has no tokens
		return { common.rtrim(lines[1]) }
	end

	local first_width = max_width - common.calculate_text_width(prefix1, font, tab_size)
	local next_width = max_width - common.calculate_text_width(prefix2, font, tab_size)
	local wrapped = wrap_tokens(tokens, first_width, next_width, font, tab_size, get_short_word_length())
	local out = {}
	for i = 1, #wrapped do
		local prefix = (i == 1) and prefix1 or prefix2
		out[#out + 1] = prefix .. wrapped[i]
	end
	return out
end

-- Shows a lightweight user notice for non-error no-op alignblock actions
local function show_alignblock_notice(text)
	if core.status_view and core.status_view.show_notice then
		core.status_view:show_notice("lines: " .. text)
	else
		core.log("lines: " .. text)
	end
end

-- Returns default split width in columns
-- Priority: explicit knob, first ruler, line_limit, then 80
local function get_default_split_width()
	if type(config.alignblock_default_width) == "number" then
		return math.max(1, math.floor(config.alignblock_default_width))
	end
	if type(config.rulers) == "table" and type(config.rulers[1]) == "number" then
		return math.max(1, math.floor(config.rulers[1]))
	end
	if type(config.line_limit) == "number" then
		return math.max(1, math.floor(config.line_limit))
	end
	return 80
end

-- Resolves split column/width from cursor position
-- When cursor is at start/end, falls back to default width and may return EOL+1
local function resolve_split_position(av, line, col, logical_len, font)
	if col > 1 and col <= logical_len then
		return col, math.max(1, av:get_col_x_offset(line, col))
	end

	local width_cols = get_default_split_width()
	local width_px = math.max(1, font:get_width(" ") * width_cols)
	local line_px = math.max(0, av:get_col_x_offset(line, logical_len + 1))
	if line_px <= width_px then
		return logical_len + 1, width_px
	end
	local split_col = av:get_x_offset_col(line, width_px)
	-- Keep split away from column 1 to avoid empty prefix artifacts
	split_col = common.clamp(split_col, 2, math.max(2, logical_len + 1))
	return split_col, width_px
end

-- Resolves wrap/split width from cursor or fixed default width
-- where:
--   "cursor"   -> use cursor-based width fallback logic
--   otherwise -> use configured default width only
local function resolve_target_width(av, doc, font, where)
	if where == "cursor" then
		local line, col = doc:get_selection()
		local logical = select(1, doc:get_logical_line(line))
		col = common.clamp(col, 1, #logical + 1)
		local _, width = resolve_split_position(av, line, col, #logical, font)
		return width
	end

	local width_cols = get_default_split_width()
	return math.max(1, font:get_width(" ") * width_cols)
end

-- Places cursor after alignblock operation based on configured target mode
-- Modes: "start-first", "end-first", "start-last", "end-last"
local function place_cursor_after(doc, start_line, produced_lines)
	local mode = tostring(config.alignblock_cursor_target or "start-first")
	local first_line = start_line
	local last_line = start_line + math.max(0, produced_lines - 1)

	local function set_start(line)
		doc:set_selection(line, 1)
	end

	local function set_end(line)
		local logical = select(1, doc:get_logical_line(line))
		doc:set_selection(line, #logical + 1)
	end

	if mode == "start-last" then
		set_start(last_line)
		return
	end
	if mode == "end-first" then
		set_end(first_line)
		return
	end
	if mode == "end-last" then
		set_end(last_line)
		return
	end
	-- Default fallback
	set_start(first_line)
end

-- Applies one alignblock mode to a single line
local function process_line(doc, av, mode, line, max_width, font, tab_size, separators)
	local raw = doc:get_text(line, 1, line, math.huge)
	local logical = raw
	local col = common.clamp(av:get_x_offset_col(line, max_width), 1, #logical + 1)

	if mode == "wrap" then
		-- Wrap mode is token-aware; snap to token start for stable first line
		col = snap_split_col_to_token_start(logical, col, separators)
	end

	local prefix = common.rtrim(logical:sub(1, col - 1))
	local rest = logical:sub(col)
	-- Pattern breakdown:
	-- ^ anchors at start
	-- %s* matches zero or more whitespace chars
	-- $ anchors at end
	if rest:match("^%s*$") then
		rest = ""
	end

	if rest == "" then
		-- Nothing after split point; operation becomes right-trim-only
		local replacement = prefix
		if replacement == raw then
			return false, 1
		end
		doc:replace_range(line, 1, line, math.huge, function()
			return replacement
		end)
		return true, 1
	end

	local chunks
	local out
	if mode == "wrap" then
		-- Wrap mode keeps words intact and applies short-word carry policy
		chunks = wrap_text(prefix, rest, max_width, font, tab_size, separators, get_short_word_length())
		out = {}
		for i = 1, #chunks do
			out[#out + 1] = common.rtrim(chunks[i])
		end
	else
		-- Split mode performs hard width cuts on UTF-8 boundaries
		chunks = split_text(rest, max_width, font, tab_size)
		out = { prefix }
		for i = 1, #chunks do
			out[#out + 1] = common.rtrim(chunks[i])
		end
	end

	local replacement = table.concat(out, "\n")
	if replacement == raw then
		return false, 1
	end

	doc:replace_range(line, 1, line, math.huge, function()
		return replacement
	end)
	return true, #out
end

-- Applies alignblock mode to a contiguous line range, adjusting for inserted lines
local function process_line_range(doc, av, mode, line_start, line_end, max_width, notice_text)
	local font = av:get_font()
	local tab_size = font:get_width(" ") * doc:get_indent_size()
	local separators = (mode == "wrap" and config.alignblock_split_word_boundaries) and build_separator_set() or nil

	local changed = false
	local delta = 0
	local count = line_end - line_start + 1
	for i = 0, count - 1 do
		-- Keep current_line aligned with prior insertions/removals
		local current_line = line_start + i + delta
		local line_changed, produced = process_line(doc, av, mode, current_line, max_width, font, tab_size, separators)
		if line_changed then
			changed = true
			delta = delta + (produced - 1)
		end
	end

	if changed then
		place_cursor_after(doc, line_start, count + delta)
	else
		show_alignblock_notice(notice_text)
	end
end

-- Runs alignblock mode for selected/current lines or full file
-- where controls width source: "cursor" or default-width mode
local function run_lines(mode, where, all_lines)
	local av = core.active_view
	if not av or not av.doc then
		return
	end
	local doc = av.doc
	local font = av:get_font()
	local max_width = resolve_target_width(av, doc, font, where)
	local line_start, line_end
	if all_lines then
		-- Full-file mode always uses the whole document
		line_start, line_end = 1, #doc.lines
	else
		-- Default mode respects selected line range or current line
		line_start, line_end = doc:get_selected_line_range()
	end
	local msg = (mode == "wrap") and "Nothing to wrap" or "Nothing to split"
	process_line_range(doc, av, mode, line_start, line_end, max_width, msg)
end

-- Merges selected/current full lines into one line
-- Any partially selected line is merged in full
-- Cursor placement is controlled by config.alignblock_merge_cursor_target
local function run_merge_lines()
	local av = core.active_view
	if not av or not av.doc then
		return
	end
	local doc = av.doc
	local line_start, line_end = doc:get_selected_line_range()
	local glue = tostring(config.alignblock_merge_glue or " ")
	local pieces = {}

	for line = line_start, line_end do
		local logical = select(1, doc:get_logical_line(line))
		-- Normalize per-line edges to avoid accidental double spacing at joins
		logical = common.trim(logical)
		pieces[#pieces + 1] = logical
	end

	local merged = table.concat(pieces, glue)
	local replacement = merged
	local original = doc:get_text(line_start, 1, line_end, math.huge)

	if replacement == original then
		show_alignblock_notice("Nothing to merge")
		return
	end

	doc:replace_range(line_start, 1, line_end, math.huge, function()
		return replacement
	end)
	local target = tostring(config.alignblock_merge_cursor_target or "start")
	if target == "end" then
		local logical = select(1, doc:get_logical_line(line_start))
		doc:set_selection(line_start, #logical + 1)
	else
		doc:set_selection(line_start, 1)
	end
end

-- Merges all non-empty blocks in the document
-- Blocks are separated by at least one empty line
-- If the next consecutive line is indented, it is not merged into the same line
local function run_merge_all_blocks()
	local av = core.active_view
	if not av or not av.doc then
		return
	end
	local doc = av.doc
	local glue = tostring(config.alignblock_merge_glue or " ")
	local out = {}
	local run = {}
	local cursor_line, cursor_col = doc:get_selection()
	local last_had_lf = false
	local line_start, line_end = 1, #doc.lines

	local function flush_run()
		if #run == 0 then
			return
		end
		-- Emit one logical merged line for current block run
		out[#out + 1] = table.concat(run, glue)
		run = {}
	end

	for line = line_start, line_end do
		local logical, had_lf = doc:get_logical_line(line)
		last_had_lf = had_lf

		if common.is_empty_line(logical) then
			-- Empty lines terminate the current block and are preserved
			flush_run()
			out[#out + 1] = logical
		elseif common.has_leading_indent(logical) then
			-- Indented continuation lines remain standalone by design
			flush_run()
			out[#out + 1] = common.rtrim(logical)
		else
			run[#run + 1] = common.trim(logical)
		end
	end
	flush_run()

	local replacement = table.concat(out, "\n")
	if last_had_lf then
		replacement = replacement .. "\n"
	end

	local original = doc:get_text(line_start, 1, line_end, math.huge)
	if replacement == original then
		show_alignblock_notice("Nothing to merge")
		return
	end

	doc:replace_range(line_start, 1, line_end, math.huge, function()
		return replacement
	end)

	local new_line, new_col = doc:sanitize_position(cursor_line, cursor_col)
	-- Preserve user's relative cursor intent after document-wide replacement
	doc:set_selection(new_line, new_col)
end

-- Merges every line in the document into one line
local function run_merge_all_lines()
	local av = core.active_view
	if not av or not av.doc then
		return
	end
	local doc = av.doc
	local glue = tostring(config.alignblock_merge_glue or " ")
	local pieces = {}
	local last_had_lf = doc:line_has_lf(#doc.lines)

	for line = 1, #doc.lines do
		local logical = select(1, doc:get_logical_line(line))
		pieces[#pieces + 1] = common.trim(logical)
	end

	local replacement = table.concat(pieces, glue)
	if last_had_lf then
		replacement = replacement .. "\n"
	end

	local original = doc:get_text(1, 1, #doc.lines, math.huge)
	if replacement == original then
		show_alignblock_notice("Nothing to merge")
		return
	end

	doc:replace_range(1, 1, #doc.lines, math.huge, function()
		return replacement
	end)
	local target = tostring(config.alignblock_merge_all_cursor_target or "end")
	if target == "end" then
		local logical = select(1, doc:get_logical_line(1))
		doc:set_selection(1, #logical + 1)
	else
		doc:set_selection(1, 1)
	end
end

-- Normalizes prose-like paragraphs in selected range or full document
-- Paragraphs are separated by empty lines
local function run_normalize_paragraphs()
	local av = core.active_view
	if not av or not av.doc then
		return
	end
	local doc = av.doc
	local font = av:get_font()
	local tab_size = font:get_width(" ") * doc:get_indent_size()
	local max_width = resolve_target_width(av, doc, font, "default")
	local line_start, line_end

	if doc:has_selection() then
		-- Selection mode normalizes only the selected line span
		line_start, line_end = doc:get_selected_line_range()
	else
		-- No selection => normalize full document
		line_start, line_end = 1, #doc.lines
	end

	local out = {}
	local paragraph = {}

	local function flush_paragraph()
		if #paragraph == 0 then
			return
		end
		-- Normalize and append one paragraph atomically
		local norm = normalize_paragraph(paragraph, max_width, font, tab_size)
		for i = 1, #norm do
			out[#out + 1] = norm[i]
		end
		paragraph = {}
	end

	for line = line_start, line_end do
		local logical = select(1, doc:get_logical_line(line))
		if common.is_empty_line(logical) then
			flush_paragraph()
			out[#out + 1] = ""
		else
			paragraph[#paragraph + 1] = logical
		end
	end
	flush_paragraph()

	local original = doc:get_text(line_start, 1, line_end, math.huge)
	local trailing_lf = original:match("(\n*)$") or ""
	local replacement = table.concat(out, "\n")
	-- Preserve exact trailing LF count from original range
	replacement = replacement:gsub("\n*$", "") .. trailing_lf
	if replacement == original then
		show_alignblock_notice("Nothing to normalize")
		return
	end

	doc:replace_range(line_start, 1, line_end, math.huge, function()
		return replacement
	end)
	place_cursor_after(doc, line_start, #out)
end

--- Run one exported line layout operation
-- @param operation_name string wrap or split
-- @param width_mode string default or cursor
-- @param whole_document boolean whether to ignore the selection
-- @return nil
local function run_exported_operation(operation_name, width_mode, whole_document)
	return run_lines(operation_name, width_mode, whole_document)
end

return {
	run = run_exported_operation,
	merge = run_merge_lines,
	merge_blocks = run_merge_all_blocks,
	merge_all = run_merge_all_lines,
	normalize_paragraphs = run_normalize_paragraphs,
}
