-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

-- 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")
local text_line = require("core.text.line")
local utf8 = require("utf8")

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

--- Wrap remainder text into lines without splitting words
-- @param prefix string text already placed on the first line
-- @param text string text still waiting to be wrapped
-- @param max_width number maximum rendered line width
-- @param font table font used to measure text
-- @param tab_size number rendered tab width
-- @param separators table|nil optional word separator lookup
-- @param short_len number maximum word length carried to the next line
-- @return table wrapped_lines
local function wrap_text(prefix, text, max_width, font, tab_size, separators, short_len)
	local queue = tokenize_wrap_words(text, separators)
	local queue_index = 1
	local returned_words = {}
	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)

	-- Returned words form a small stack in front of the unread queue
	local function has_waiting_word()
		return #returned_words > 0 or queue_index <= #queue
	end

	local function peek_waiting_word()
		return returned_words[#returned_words] or queue[queue_index]
	end

	local function take_waiting_word()
		if #returned_words > 0 then
			return table.remove(returned_words)
		end

		local word = queue[queue_index]
		queue_index = queue_index + 1

		return word
	end

	local function return_word(word)
		returned_words[#returned_words + 1] = word
	end

	while has_waiting_word() or #line_words > 0 do
		while has_waiting_word() do
			local next_word = peek_waiting_word()
			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] = take_waiting_word()
			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
			return_word(table.remove(line_words))
		end

		-- Move short trailing words to the next line while keeping one word here
		if short_len > 0 and has_waiting_word() then
			while #line_words > 1 and is_short_word(line_words[#line_words], short_len) do
				return_word(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

-- Keep leading punctuation and list markers while recognizing Unicode words
-- Pattern breakdown: '^' starts at the line and the negated class stops at a word or bracket
local PARAGRAPH_PREFIX_PATTERN = "^[^%w%[%](){}''\"]*"

--- Split a paragraph marker from its Unicode body text
-- @param text any source line
-- @return string prefix leading punctuation or list marker
-- @return string body remaining paragraph text
local function split_paragraph_prefix(text)
	local s = tostring(text or "")
	local prefix = utf8.match(s, 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

--- Apply alignblock mode to one contiguous line range
-- @param doc table text document
-- @param av table active document view
-- @param mode string wrap or split
-- @param line_start number first target line
-- @param line_end number last target line
-- @param max_width number target width in pixels
-- @return boolean changed whether text changed
-- @return number produced_count resulting line count
local function process_line_range(doc, av, mode, line_start, line_end, max_width)
	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

	return changed, count + delta
end

--- Run wrap or split on selected line blocks, or the whole document
-- @param mode string wrap or split
-- @param where string cursor or default width source
-- @param all_lines boolean ignore selections when true
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 ranges = text_line.get_target_ranges(doc, all_lines)
	local changed = false
	local cursor_line = ranges[1].line1
	local cursor_count = ranges[1].line2 - ranges[1].line1 + 1

	-- Work upward so inserted lines cannot shift an unprocessed block
	for index = #ranges, 1, -1 do
		local range = ranges[index]
		local range_changed, produced_count = process_line_range(
			doc,
			av,
			mode,
			range.line1,
			range.line2,
			max_width
		)

		changed = range_changed or changed
		if index == 1 then
			cursor_count = produced_count
		end
	end

	local msg = (mode == "wrap") and "Nothing to wrap" or "Nothing to split"

	if changed then
		place_cursor_after(doc, cursor_line, cursor_count)
	else
		show_alignblock_notice(msg)
	end
end

--- Merge selected line blocks, or every line without a selection
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 ranges = text_line.get_target_ranges(doc)
	local glue = tostring(config.alignblock_merge_glue or " ")
	local changed = false

	-- Work upward so each replacement keeps the original line numbers above it
	for index = #ranges, 1, -1 do
		local range = ranges[index]
		local pieces = {}

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

		local end_line, end_col = text_line.get_block_end(doc, range.line2)
		local replacement = table.concat(pieces, glue)
		local original = doc:get_text(range.line1, 1, end_line, end_col)

		if end_line > range.line2 then
			replacement = replacement .. "\n"
		end

		if replacement ~= original then
			doc:replace_range(range.line1, 1, end_line, end_col, function()
				return replacement
			end)
			changed = true
		end
	end

	if not changed then
		show_alignblock_notice("Nothing to merge")

		return
	end

	local line_start = ranges[1].line1
	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

--- Normalize paragraphs in selected line blocks, or the whole document
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 ranges = text_line.get_target_ranges(doc)
	local changed = false
	local cursor_count = ranges[1].line2 - ranges[1].line1 + 1

	-- Work upward so paragraph reflow cannot shift an unprocessed block
	for index = #ranges, 1, -1 do
		local range = ranges[index]
		local out = {}
		local paragraph = {}

		local function flush_paragraph()
			if #paragraph == 0 then
				return
			end

			local normalized = normalize_paragraph(paragraph, max_width, font, tab_size)

			for output_index = 1, #normalized do
				out[#out + 1] = normalized[output_index]
			end
			paragraph = {}
		end

		for line = range.line1, range.line2 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 end_line, end_col = text_line.get_block_end(doc, range.line2)
		local original = doc:get_text(range.line1, 1, end_line, end_col)
		local trailing_lf = original:match("(\n*)$") or ""
		local replacement = table.concat(out, "\n")

		-- Keep the line boundary beside the selected block unchanged
		replacement = replacement:gsub("\n*$", "") .. trailing_lf

		if replacement ~= original then
			doc:replace_range(range.line1, 1, end_line, end_col, function()
				return replacement
			end)
			changed = true
		end

		if index == 1 then
			cursor_count = #out
		end
	end

	if not changed then
		show_alignblock_notice("Nothing to normalize")

		return
	end

	place_cursor_after(doc, ranges[1].line1, cursor_count)
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,
}
