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

--- Comment editing implementation loaded by its first command
-- @module plugins.comments.main

local core = require("core")
local config = require("core.config")
local native_text = require("text") -- Split complete multiline comment selections without a Lua pattern loop

-- Advanced comment toggling with line/block/smart modes
-- This module centralizes comment operations and syntax-driven policies
-- It supports line comment toggling, block comment toggling, inline block
-- wrapping, and a smart mode that chooses the action from selection context
--
-- Required syntax fields:
-- - 'comment' for line comments (for example "//", "#", "--")
-- Optional syntax fields:
-- - 'block_comment = { block_open, block_close, ... }' for block/inline block features
-- - 'guard_comment = { guard_open, guard_close, ... }' for directive-style
--   block guards (for example '#if 0' ... '#endif')
--
-- Supported block policy fields:
-- - 'block_open', 'block_close': delimiters
-- - 'block_line_prefix': prefix for continuation lines when block spans lines
-- - 'open_on_own_line', 'close_on_own_line': delimiter placement control
-- - 'close_line_prefix': prefix before closing delimiter when on own line
-- - 'strip_line_prefix': pattern to remove decoration on uncomment
-- - 'allow_nested' (boolean, default false): allow wrapping text that already
--   contains block delimiters
--
-- Supported guard policy fields:
-- - 'guard_open', 'guard_close': delimiters
--   'guard_open' may be a string or a list of equivalent opening forms
--   (for example '{ "#if 0", "#if false" }')
-- - 'guard_body_prefix' (string|"auto"): prefix added to each guarded payload
--   line. '"auto"' uses one indent unit from editor settings ('indent_type' /
--   'indent_size')
--   (for example '"    "' for Python 'if False:')
-- - 'guard_close' may be empty to model open-only guards (Python style)
-- - 'guard_align_to_indent' (boolean): align guard delimiters to the selected
--   block's first non-empty line indentation
-- - 'allow_nested' (boolean, default false): allow wrapping guarded text that
--   already contains guard delimiters
--
-- Supported line policy fields (via 'format.line_comment' or global config):
-- - 'place_after_indent' (boolean):
--    true  => keep indentation before marker
--    false => force marker at physical column 1
--
-- Global block policy fields (via config):
-- - 'comments_block_pad_inline' (boolean): '/* text */' vs '/*text*/'

-- Return the active document, or nil when there is no active doc view
local function get_doc()
	return core.active_view and core.active_view.doc
end

-- Forward declaration used by with_post_action_selection
local line_end_col
local notify_missing_any_comment_support
local toggle_selection_line_comment

-- Apply replacement text to a range and keep selection on the replaced block
local function replace_range(doc, line1, col1, line2, col2, text)
	doc:remove(line1, col1, line2, col2)
	doc:insert(line1, col1, text)
	local new_line2, new_col2 = doc:position_offset(line1, col1, #text)
	doc:set_selection(line1, col1, new_line2, new_col2)
end

-- Resolve the text range passed to syntax comment hooks
-- With selection: selected range. Without selection: full current line
local function get_comment_hook_range(doc)
	if doc:has_selection() then
		return doc:get_selection(true)
	end
	local line = select(1, doc:get_selection())
	local line_text = doc.lines[line] or ""
	local end_col = #line_text + 1
	if line_text:sub(-1) == "\n" then
		end_col = end_col - 1
	end
	return line, 1, line, end_col
end

-- Let syntax optionally intercept comment commands
-- Return true when the hook handled the action
local function run_comment_hook(action)
	local doc = get_doc()
	if not doc then
		return false
	end
	local hook = doc.format and doc.format.comment_hook
	if type(hook) ~= "function" then
		return false
	end

	local line1, col1, line2, col2 = get_comment_hook_range(doc)
	local text = doc:get_text(line1, col1, line2, col2)
	local ok, result, extra = pcall(hook, action, text, {
		doc = doc,
		line1 = line1,
		col1 = col1,
		line2 = line2,
		col2 = col2,
		has_selection = doc:has_selection(),
	})
	if not ok then
		core.error("comment_hook failed for %s: %s", action, tostring(result))
		return false
	end

	if result == true then
		if type(extra) == "string" then
			replace_range(doc, line1, col1, line2, col2, extra)
		end
		return true
	end
	if type(result) == "string" then
		replace_range(doc, line1, col1, line2, col2, result)
		return true
	end
	if type(result) == "table" and result.handled == true then
		if type(result.text) == "string" then
			replace_range(doc, line1, col1, line2, col2, result.text)
		end
		return true
	end
	return false
end

-- Run a comment operation and apply post-action selection policy
-- 'config.comments_keep_selection = true' keeps whatever selection/cursor the
-- command produced (important for stable re-toggle behavior)
-- 'false' collapses to the original cursor position from before the command
local function with_post_action_selection(fn)
	local doc = get_doc()
	if not doc then
		return
	end
	local had_selection = doc:has_selection()
	local cursor_line, cursor_col = doc:get_selection()
	local was_start_side = false
	if had_selection then
		local s1, c1, s2, c2 = doc:get_selection(true)
		-- Preserve which side held the cursor before command:
		-- - cursor at sorted start => start-side
		-- - otherwise cursor at sorted end => end-side
		was_start_side = (cursor_line == s1 and cursor_col == c1)
	end
	fn()
	-- If command started from a plain cursor (no selection):
	-- - preserve cursor column by default for line-wise toggles
	-- - clamp to line end only if resulting line became shorter
	if not had_selection then
		local line = math.min(math.max(cursor_line, 1), #doc.lines)
		local col = math.min(cursor_col, line_end_col(doc, line))
		doc:set_selection(line, col)
		return
	end
	if config.comments_keep_selection == false then
		doc:set_selection(cursor_line, cursor_col)
	else
		-- Keep resulting selection range, but restore original cursor side
		local n1, nc1, n2, nc2 = doc:get_selection(true)
		if was_start_side then
			doc:set_selection(n1, nc1, n2, nc2)
		else
			doc:set_selection(n2, nc2, n1, nc1)
		end
	end
end

-- Normalize line text by removing the storage newline, if present
local function strip_trailing_newline(s)
	if s:sub(-1) == "\n" then
		return s:sub(1, -2)
	end
	return s
end

-- Get the logical end column for a line (past-last non-newline character)
line_end_col = function(doc, line)
	local text = doc.lines[line] or ""
	local len = #strip_trailing_newline(text)
	return len + 1
end

-- Escape delimiter strings before embedding them in Lua patterns
local function escape_lua_pattern(s)
	-- Pattern breakdown:
	-- - Character class '[...]' enumerates all Lua magic chars we want to escape
	-- - Each match becomes '%%%1':
	--   '%' starts an escape in replacement strings,
	--   '%1' inserts the matched char,
	--   so result is a literal-escaped version of that char
	return (s:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end

-- Apply 'fn' to each logical line while preserving original newlines
local function map_lines(text, fn)
	if text == "" then
		return ""
	end
	local out = {}
	local pos = 1
	while pos <= #text do
		local nl = text:find("\n", pos, true)
		local line, eol
		if nl then
			line = text:sub(pos, nl - 1)
			eol = "\n"
			pos = nl + 1
		else
			line = text:sub(pos)
			eol = ""
			pos = #text + 1
		end
		out[#out + 1] = fn(line) .. eol
	end
	return table.concat(out)
end

-- Convert inclusive selection end into effective last line for line-wise actions
-- If selection ends at column 1 on a later line, treat that line as unselected
local function effective_last_line(line1, line2, col2)
	-- End-at-column-1 on a later line is an exclusive bound
	if line2 > line1 and col2 == 1 then
		return line2 - 1
	end
	return line2
end

-- Resolve the line comment token from syntax metadata
-- Handles compact char-class forms like "[;#]" by picking the first literal
local function resolve_line_comment_marker(raw)
	if type(raw) ~= "string" or raw == "" then
		return nil
	end
	-- Some syntaxes provide char classes (e.g. "[;#]"); pick first literal
	-- Pattern breakdown:
	-- - '^' and '$' anchor whole string
	-- - '%[' and '%]' match literal brackets
	-- - '(.+)' captures inner content between brackets
	local inner = raw:match("^%[(.+)%]$")
	if inner then
		local i = 1
		while i <= #inner do
			local ch = inner:sub(i, i)
			if ch ~= "%" then
				return ch
			end
			i = i + 2
		end
		return nil
	end
	return raw
end

-- Word-style markers (for example "rem") require a separator boundary
-- after the marker to be a valid comment prefix
local function marker_requires_separator(marker)
	-- Pattern '[%w_]$' means marker ends with a word character
	return type(marker) == "string" and marker:match("[%w_]$") ~= nil
end

-- Resolve effective line-comment style
-- Precedence:
-- 1) syntax.line_comment.* (per language)
-- 2) config.comments_line_* (global)
-- 3) default place_after_indent=true
local function get_line_comment_policy(doc)
	local syntax_cfg = (doc.format and doc.format.line_comment) or {}
	local place_after_indent = syntax_cfg.place_after_indent
	if place_after_indent == nil then
		if config.comments_place_after_indent == nil then
			place_after_indent = true
		else
			place_after_indent = config.comments_place_after_indent ~= false
		end
	end

	return {
		placement = place_after_indent and "after_indent" or "column_1",
	}
end

-- Classify a single line's current comment style
-- Returns one of: "empty", "none", "compact", "spaced"
local function classify_line_comment_style(line, marker, marker_pat, placement, requires_separator)
	-- '%S' means line has at least one non-whitespace character
	if not line:find("%S") then
		return "empty"
	end

	local s, e
	if placement == "column_1" then
		-- Pattern "^" .. marker_pat:
		-- - marker must start at physical column 1
		s, e = line:find("^" .. marker_pat)
	else
		-- Pattern "^%s*" .. marker_pat:
		-- - allow indentation before marker
		s, e = line:find("^%s*" .. marker_pat)
	end
	if not s then
		return "none"
	end

	local tail = line:sub(e + 1)
	if requires_separator then
		-- For word-style markers, the next char must be whitespace or EOL
		local ch = tail:sub(1, 1)
		-- Pattern '%s' means any whitespace separator after marker
		if ch ~= "" and not ch:match("%s") then
			return "none"
		end
	end
	if placement == "column_1" then
		-- In column-1 placement, preserve indentation after marker
		-- Treat only '// text' (single pad + non-space) as spaced
		-- Pattern "^ %S":
		-- - exactly one leading space
		-- - immediately followed by a non-space content char
		-- Anything with multiple spaces (for example '//    a') is treated as
		-- compact so indentation is never normalized away
		if tail:match("^ %S") then
			return "spaced"
		end
		return "compact"
	end
	-- Pattern "^%s" means at least one whitespace directly after marker
	if tail:match("^%s") then
		return "spaced"
	end
	return "compact"
end

-- Determine cycle action for the current selection:
-- all compact -> spaced
-- all spaced  -> uncomment
-- otherwise   -> compact
local function choose_line_cycle_action(doc, line1, last_line, marker, marker_pat, placement, requires_separator)
	if requires_separator then
		local saw_nonempty = false
		local saw_uncommented = false
		for line = line1, last_line do
			local content = strip_trailing_newline(doc.lines[line] or "")
			local style = classify_line_comment_style(content, marker, marker_pat, placement, true)
			if style ~= "empty" then
				saw_nonempty = true
				if style == "none" then
					saw_uncommented = true
				end
			end
		end
		-- Two-state cycle for word-style markers:
		-- uncommented -> spaced, commented -> uncommented
		if saw_nonempty and not saw_uncommented then
			return "uncomment"
		end
		return "spaced"
	end

	if placement == "column_1" then
		local saw_nonempty = false
		local saw_uncommented = false
		local saw_compact_plain = false
		local saw_spaced_plain = false

		for line = line1, last_line do
			local content = strip_trailing_newline(doc.lines[line] or "")
			if content:find("%S") then
				saw_nonempty = true
				local s, e = content:find("^" .. marker_pat)
				if not s then
					saw_uncommented = true
				else
					local tail = content:sub(e + 1)
					-- Pattern "^%S":
					-- - marker is followed immediately by non-space text ('//text')
					if tail:match("^%S") then
						saw_compact_plain = true
					-- Pattern "^ %S":
					-- - marker has exactly one pad before non-space text ('// text')
					elseif tail:match("^ %S") then
						saw_spaced_plain = true
					end
				end
			end
		end

		if not saw_nonempty then
			return "compact"
		end
		if saw_uncommented then
			return "compact"
		end
		if saw_compact_plain then
			return "spaced"
		end
		if saw_spaced_plain then
			return "uncomment"
		end
		-- Ambiguous all-indented commented block (e.g. only '//    x' lines):
		-- prefer uncomment to avoid destructive whitespace rewrites
		return "uncomment"
	end

	local saw_nonempty = false
	local all_compact = true
	local all_spaced = true

	for line = line1, last_line do
		local content = strip_trailing_newline(doc.lines[line] or "")
		local style = classify_line_comment_style(content, marker, marker_pat, placement, false)
		if style ~= "empty" then
			saw_nonempty = true
			if style ~= "compact" then
				all_compact = false
			end
			if style ~= "spaced" then
				all_spaced = false
			end
		end
	end

	if saw_nonempty and all_spaced then
		return "uncomment"
	end
	if saw_nonempty and all_compact then
		return "spaced"
	end
	return "compact"
end

-- Apply one cycle action to a single line
local function apply_line_cycle_action(line, action, marker, marker_pat, placement, requires_separator)
	-- '%S' means line has at least one non-whitespace character
	if not line:find("%S") then
		return line
	end

	local effective_action = action
	if requires_separator and action == "compact" then
		-- Word-style markers never use compact insertion ('remtext')
		effective_action = "spaced"
	end

	if effective_action == "uncomment" then
		if placement == "column_1" then
			-- Remove only the marker plus optional single synthetic pad for
			-- plain-content spaced form ('// text'). Do not consume indentation
			local s, e = line:find("^" .. marker_pat)
			if not s then
				return line
			end
			local tail = line:sub(e + 1)
			if requires_separator then
				local ch = tail:sub(1, 1)
				-- Require whitespace/EOL separator for word-style markers
				if ch ~= "" and not ch:match("%s") then
					return line
				end
			end
			if tail:match("^ %S") then
				return tail:sub(2)
			end
			return tail
		end
		-- Pattern "^(%s*)" .. marker_pat .. "%s?":
		-- - keep indentation capture (%1)
		-- - remove marker after indentation
		-- - remove at most one space after marker
		return (line:gsub("^(%s*)" .. marker_pat .. "%s?", "%1", 1))
	end

	if effective_action == "spaced" then
		if placement == "column_1" then
			if line:match("^" .. marker_pat) then
				-- Add one pad only for plain compact form ('//text' -> '// text')
				-- Keep indented content ('//    a') untouched to preserve indentation
				local s, e = line:find("^" .. marker_pat)
				local tail = line:sub(e + 1)
				if requires_separator then
					local ch = tail:sub(1, 1)
					if ch ~= "" and not ch:match("%s") then
						return line
					end
				end
				if tail:match("^%S") then
					return marker .. " " .. tail
				end
				return line
			end
			return marker .. " " .. line
		end
		if line:match("^%s*" .. marker_pat) then
			-- Pattern "^(%s*" .. marker_pat .. ")%s*":
			-- - keep indentation+marker (%1)
			-- - normalize any spaces after marker to exactly one
			return (line:gsub("^(%s*" .. marker_pat .. ")%s*", "%1 ", 1))
		end
		-- Pattern "^(%s*)":
		-- - insert marker plus one space after indentation
		return (line:gsub("^(%s*)", "%1" .. marker .. " ", 1))
	end

	-- Compact action
	if placement == "column_1" then
		if line:match("^" .. marker_pat) then
			-- Remove only one synthetic pad for plain spaced form ('// text' -> '//text')
			-- Do not strip indentation spaces ('//    a' stays unchanged)
			local s, e = line:find("^" .. marker_pat)
			local tail = line:sub(e + 1)
			if requires_separator then
				local ch = tail:sub(1, 1)
				if ch ~= "" and not ch:match("%s") then
					return line
				end
			end
			if tail:match("^ %S") then
				return marker .. tail:sub(2)
			end
			return line
		end
		return marker .. line
	end
	if line:match("^%s*" .. marker_pat) then
		-- Pattern "^(%s*" .. marker_pat .. ")%s*":
		-- - keep indentation+marker (%1)
		-- - remove all spaces immediately after marker
		return (line:gsub("^(%s*" .. marker_pat .. ")%s*", "%1", 1))
	end
	-- Pattern "^(%s*)":
	-- - insert marker directly after indentation (no padding)
	return (line:gsub("^(%s*)", "%1" .. marker, 1))
end

-- Resolve global block inline spacing behavior
-- 'true' => '/* text */', 'false' => '/*text*/'
local function get_block_pad_inline()
	if config.comments_block_pad_inline == nil then
		return true
	end
	return config.comments_block_pad_inline ~= false
end

-- Check if selection is line-wise:
-- starts at col 1 and ends at the next-line col 1 (or end-of-line in single line)
local function selection_is_linewise(doc, line1, col1, line2, col2)
	if col1 ~= 1 then
		return false
	end
	if line2 > line1 then
		return col2 == 1
	end
	return col2 >= line_end_col(doc, line1)
end

-- Toggle line comments across selected lines using a 3-step cycle:
-- 1) uncommented -> compact comment ('//text')
-- 2) compact comment -> spaced comment ('// text')
-- 3) spaced comment -> uncommented
-- Empty lines are left unchanged
local function toggle_line_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	local marker = resolve_line_comment_marker(doc.format and doc.format.comment)
	if not marker then
		notify_missing_any_comment_support(doc)
		return
	end
	local policy = get_line_comment_policy(doc)
	local requires_separator = marker_requires_separator(marker)

	local line1, _, line2, col2 = doc:get_selection(true)
	local last_line = effective_last_line(line1, line2, col2)
	if last_line < line1 then
		last_line = line1
	end

	local marker_pat = escape_lua_pattern(marker)
	local action =
		choose_line_cycle_action(doc, line1, last_line, marker, marker_pat, policy.placement, requires_separator)

	doc:set_selection(line1, 1, last_line, math.huge)
	doc:replace(function(text)
		return map_lines(text, function(line)
			return apply_line_cycle_action(line, action, marker, marker_pat, policy.placement, requires_separator)
		end)
	end)
end

-- Remove line comment markers across selected lines (or current line)
-- This never adds comments; it only removes one marker occurrence per line
local function uncomment_line_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	local marker = resolve_line_comment_marker(doc.format and doc.format.comment)
	if not marker then
		notify_missing_any_comment_support(doc)
		return
	end
	local policy = get_line_comment_policy(doc)
	local requires_separator = marker_requires_separator(marker)

	local line1, _, line2, col2 = doc:get_selection(true)
	local last_line = effective_last_line(line1, line2, col2)
	if last_line < line1 then
		last_line = line1
	end

	local marker_pat = escape_lua_pattern(marker)
	local marker_with_space_pat = marker_pat .. "%s?"
	doc:set_selection(line1, 1, last_line, math.huge)
	doc:replace(function(text)
		return map_lines(text, function(line)
			-- '%S' means line contains at least one non-space character
			if line:find("%S") then
				if policy.placement == "column_1" then
					-- Remove marker at column 1 while preserving indentation:
					-- - '// text'  -> 'text'
					-- - '//    x'  -> '    x'
					local s, e = line:find("^" .. marker_pat)
					if not s then
						return line
					end
					local tail = line:sub(e + 1)
					if requires_separator then
						local ch = tail:sub(1, 1)
						if ch ~= "" and not ch:match("%s") then
							return line
						end
					end
					if tail:match("^ %S") then
						return tail:sub(2)
					end
					return tail
				end
				if requires_separator then
					local s, e, indent = line:find("^(%s*)" .. marker_pat)
					if not s then
						return line
					end
					local tail = line:sub(e + 1)
					local ch = tail:sub(1, 1)
					if ch ~= "" and not ch:match("%s") then
						return line
					end
					if ch == " " then
						tail = tail:sub(2)
					end
					return (indent or "") .. tail
				end
				-- Pattern "^(%s*)" .. marker_with_space_pat removes one marker after indent
				return (line:gsub("^(%s*)" .. marker_with_space_pat, "%1", 1))
			end
			return line
		end)
	end)
end

-- Return valid block comment metadata table or nil
local function get_block_comment_info(doc)
	local b = doc.format and doc.format.block_comment
	if type(b) ~= "table" then
		return nil
	end
	if type(b.block_open) ~= "string" or b.block_open == "" then
		return nil
	end
	if type(b.block_close) ~= "string" or b.block_close == "" then
		return nil
	end
	return b
end

-- Return valid guard-comment metadata mapped to block-style keys, or nil
local function normalize_guard_open_variants(raw)
	local variants = {}
	if type(raw) == "string" and raw ~= "" then
		variants[1] = raw
	elseif type(raw) == "table" then
		for _, v in ipairs(raw) do
			if type(v) == "string" and v ~= "" then
				variants[#variants + 1] = v
			end
		end
	end
	return variants
end

-- Resolve one logical indentation unit from the active document
local function get_indent_unit(doc)
	if doc:get_indent_type() == "tabs" then
		return "\t"
	end

	return string.rep(" ", doc:get_indent_size())
end

-- Resolve guard body prefix value from syntax metadata
local function resolve_guard_body_prefix(raw, doc)
	if raw == "auto" then
		return get_indent_unit(doc)
	end
	if type(raw) == "string" then
		return raw
	end
	return ""
end

local function get_guard_comment_info(doc)
	local g = doc.format and doc.format.guard_comment
	if type(g) ~= "table" then
		return nil
	end
	local opens = normalize_guard_open_variants(g.guard_open)
	if #opens == 0 then
		return nil
	end
	if g.guard_close ~= nil and type(g.guard_close) ~= "string" then
		return nil
	end
	local close = g.guard_close or ""
	local body_prefix = resolve_guard_body_prefix(g.guard_body_prefix, doc)
	return {
		block_open = opens[1],
		block_open_variants = opens,
		block_close = close,
		-- Guard wrappers are always structural own-line delimiters
		-- Payload lines may optionally be prefixed (e.g. Python indentation)
		block_line_prefix = body_prefix,
		open_on_own_line = true,
		close_on_own_line = close ~= "",
		close_line_prefix = "",
		strip_line_prefix = (body_prefix ~= "") and ("^" .. escape_lua_pattern(body_prefix)) or "",
		align_to_indent = g.guard_align_to_indent == true,
		allow_nested = g.allow_nested == true,
		_kind = "guard",
	}
end

-- Return block-comment metadata normalized with strategy kind, or nil
local function get_primary_block_comment_info(doc)
	local b = get_block_comment_info(doc)
	if not b then
		return nil
	end
	return {
		block_open = b.block_open,
		block_close = b.block_close,
		block_line_prefix = b.block_line_prefix or "",
		open_on_own_line = b.open_on_own_line == true,
		close_on_own_line = b.close_on_own_line == true,
		close_line_prefix = b.close_line_prefix or "",
		strip_line_prefix = b.strip_line_prefix or "",
		allow_nested = b.allow_nested == true,
		_kind = "block",
	}
end

-- Forward declaration used by choose_wrap_comment_info
local selection_contains_block_markers

-- Choose a safe wrap strategy for text: primary block first, then guard
local function choose_wrap_comment_info(doc, text)
	local primary = get_primary_block_comment_info(doc)
	if primary then
		local has_markers = selection_contains_block_markers(text, primary)
		if primary.allow_nested == true or not has_markers then
			return primary
		end
	end

	local guard = get_guard_comment_info(doc)
	if guard then
		local has_markers = selection_contains_block_markers(text, guard)
		if guard.allow_nested == true or not has_markers then
			return guard
		end
	end

	return nil
end

-- Return true when line-comment marker exists for current format
local function has_line_comment_support(doc)
	return resolve_line_comment_marker(doc and doc.format and doc.format.comment) ~= nil
end

-- Nested block comments are disallowed unless syntax explicitly opts in
local function block_allows_nested(info)
	return info and info.allow_nested == true
end

-- True when selection text already contains configured block delimiters
selection_contains_block_markers = function(text, info)
	if not info or not text or text == "" then
		return false
	end
	if info.block_close ~= "" and text:find(info.block_close, 1, true) ~= nil then
		return true
	end
	local opens = info.block_open_variants or { info.block_open }
	for _, open in ipairs(opens) do
		if text:find(open, 1, true) ~= nil then
			return true
		end
	end
	return false
end

-- Show a status-bar/log message when block comment features are unavailable
local function notify_missing_block_comment_support(doc)
	local format_name = (doc and doc.format and doc.format._module_name) or "current format"
	core.log("No block comment support configured for %s", format_name)
end

local function notify_missing_guard_comment_support(doc)
	local format_name = (doc and doc.format and doc.format._module_name) or "current format"
	core.log("No guard comment support configured for %s", format_name)
end

notify_missing_any_comment_support = function(doc)
	local format_name = (doc and doc.format and doc.format._module_name) or "current format"
	core.log("No comment support configured for %s", format_name)
end

local function notify_nested_block_not_supported(doc)
	local format_name = (doc and doc.format and doc.format._module_name) or "current format"
	core.log("Nested block comments are not supported for %s", format_name)
end

-- Find block-comment bounds around the cursor line for no-selection block toggles
-- Returns 'start_line, end_line' when a block boundary is found, otherwise nil
local function find_open_at_pos(text, pos, opens)
	for _, open in ipairs(opens) do
		if text:find(open, pos, true) == pos then
			return open
		end
	end
	return nil
end

--- Find an open-ended guard and its indented body around one line
-- @param doc table text document
-- @param cursor_line number line inside the guard
-- @param info table normalized guard settings
-- @return number|nil start_line guard opener line
-- @return number|nil end_line last guarded body line
local function find_open_guard_bounds_around_line(doc, cursor_line, info)
	local body_prefix = info.block_line_prefix or ""
	if body_prefix == "" then
		return nil
	end

	local opens = info.block_open_variants or { info.block_open }

	for start_line = cursor_line, 1, -1 do
		local opener_text = strip_trailing_newline(doc.lines[start_line] or "")
		-- Pattern breakdown: '%S' finds the first non-whitespace byte
		local first = opener_text:find("%S")
		local matched_open = first and find_open_at_pos(opener_text, first, opens)

		if matched_open then
			local body_indent = opener_text:sub(1, first - 1) .. body_prefix
			local end_line = start_line

			for line = start_line + 1, #doc.lines do
				local text = strip_trailing_newline(doc.lines[line] or "")

				if text:find("%S") then
					if text:sub(1, #body_indent) ~= body_indent then
						break
					end

					end_line = line
				end
			end

			if cursor_line <= end_line then
				return start_line, end_line
			end

			return nil
		end
	end

	return nil
end

local function find_block_bounds_around_line(doc, cursor_line, block_open, block_close, block_open_variants)
	if not block_open or block_open == "" then
		return nil
	end
	if block_open:find("\n", 1, true) then
		return nil
	end
	if not block_close or block_close == "" then
		return nil
	end
	local opens = block_open_variants or { block_open }
	local start_line
	local start_open
	for line = cursor_line, 1, -1 do
		local text = strip_trailing_newline(doc.lines[line] or "")
		-- Pattern '%S' finds first non-whitespace position
		local first = text:find("%S")
		if first then
			local found = find_open_at_pos(text, first, opens)
			if found then
				start_line = line
				start_open = found
				break
			end
		end
	end
	if not start_line then
		return nil
	end

	local start_text = strip_trailing_newline(doc.lines[start_line] or "")
	local first = start_text:find("%S") or 1
	local same_line_close = start_text:find(block_close, first + #start_open, true)
	if same_line_close and start_line >= cursor_line then
		return start_line, start_line
	end

	for line = start_line + 1, #doc.lines do
		local text = strip_trailing_newline(doc.lines[line] or "")
		local close_pos = text:find(block_close, 1, true)
		if close_pos then
			-- Only treat as surrounding range when cursor lies inside bounds
			if line >= cursor_line then
				return start_line, line
			end
			-- This opener closed before cursor; continue searching further up
			break
		end
	end

	-- No range containing cursor for this opener
	return nil
end

-- Format wrapped block text according to syntax block policy
local function format_block_text(text, info)
	local open = info.block_open
	local close = info.block_close
	local lines = native_text.split_lines(text, { keep_eol = false })
	-- Drop the trailing empty fragment so a selected final newline does not
	-- emit an extra decorated blank line such as ' * '
	-- Drop all trailing empty logical lines introduced by selection artifacts
	-- (for example ranges that accidentally include one or more terminal '\n')
	-- Keep at least one logical line so empty selections still format safely
	while #lines > 1 and lines[#lines] == "" do
		table.remove(lines)
	end

	local line_prefix = info.block_line_prefix or ""
	local close_line_prefix = info.close_line_prefix or ""
	local open_own_line = info.open_on_own_line == true
	local close_own_line = info.close_on_own_line == true
	local pad_inline = get_block_pad_inline()

	-- Compact single-line form when delimiters are not forced onto their own lines
	if #lines <= 1 and not open_own_line and not close_own_line then
		if pad_inline then
			return open .. " " .. text .. " " .. close
		end
		return open .. text .. close
	end

	local out = {}
	-- Boxed form: opening delimiter, then prefixed content lines, then closing delimiter
	if open_own_line then
		out[#out + 1] = open
		for _, line in ipairs(lines) do
			out[#out + 1] = "\n" .. line_prefix .. line
		end
		if close ~= "" then
			out[#out + 1] = "\n" .. close
		end
		return table.concat(out)
	end

	-- Inline-open form: opening delimiter starts line 1; later lines get line_prefix
	out[#out + 1] = open
	if pad_inline then
		out[#out + 1] = " "
	end
	out[#out + 1] = lines[1] or ""
	for i = 2, #lines do
		out[#out + 1] = "\n" .. line_prefix .. lines[i]
	end
	if close_own_line then
		out[#out + 1] = "\n" .. close_line_prefix .. close
	else
		if pad_inline then
			out[#out + 1] = " "
		end
		out[#out + 1] = close
	end
	return table.concat(out)
end

-- Return leading whitespace of the first non-empty line in 'text'
local function first_nonempty_indent(text)
	for line in (text .. "\n"):gmatch("(.-)\n") do
		if line:find("%S") then
			return line:match("^(%s*)") or ""
		end
	end
	return ""
end

-- For guard wrappers, align open/close markers to selection base indent
-- Body lines are left unchanged (except configured guard/body prefix)
local function guard_aligned_wrap_info(info, text)
	if not info or info._kind ~= "guard" then
		return info
	end
	if info.align_to_indent ~= true then
		return info
	end
	local indent = first_nonempty_indent(text)
	if indent == "" then
		return info
	end
	local aligned = {}
	for k, v in pairs(info) do
		aligned[k] = v
	end
	aligned.block_open = indent .. (info.block_open or ""):gsub("\n", "\n" .. indent)
	if type(info.block_close) == "string" and info.block_close ~= "" then
		aligned.block_close = indent .. info.block_close:gsub("\n", "\n" .. indent)
	end
	return aligned
end

-- Try to unwrap an already block-commented selection
-- Returns unwrapped text when selection is wrapped by configured delimiters
-- Returns nil when selection is not wrapped
local function try_unwrap_block(text, info)
	local close_pat = escape_lua_pattern(info.block_close)
	local opens = info.block_open_variants or { info.block_open }
	-- Pattern breakdown for "^(%s*)(.-)(%s*)$":
	-- - Capture leading whitespace (group 1),
	-- - capture core text minimally (group 2),
	-- - capture trailing whitespace (group 3),
	-- while spanning whole string via anchors
	local lead, core_text, trail = text:match("^(%s*)(.-)(%s*)$")
	if not core_text then
		return nil
	end
	local matched_open
	for _, open in ipairs(opens) do
		local open_pat = escape_lua_pattern(open)
		if core_text:match("^" .. open_pat) then
			matched_open = open
			break
		end
	end
	if not matched_open then
		return nil
	end
	if info.block_close ~= "" and not core_text:match(close_pat .. "$") then
		return nil
	end

	-- Remove delimiters first
	local inner
	if info.block_close == "" then
		inner = core_text:sub(#matched_open + 1)
	else
		inner = core_text:sub(#matched_open + 1, #core_text - #info.block_close)
	end
	-- Remove structural newlines added by formatter when delimiters are on their
	-- own lines:
	-- - open_on_own_line adds one leading "\n" before content
	-- - close_on_own_line adds one trailing "\n" before closing delimiter
	if info.open_on_own_line == true and inner:sub(1, 1) == "\n" then
		inner = inner:sub(2)
	end
	-- Remove delimiter-adjacent padding that may have been added during wrapping
	-- For non-own-line delimiters:
	-- - '^ ' removes one leading space after opening delimiter
	-- - ' $' removes one trailing space before closing delimiter
	if get_block_pad_inline() then
		if info.open_on_own_line ~= true then
			inner = inner:gsub("^ ", "", 1)
		end
		if info.close_on_own_line ~= true then
			inner = inner:gsub(" $", "", 1)
		end
	end
	-- If closing delimiter was emitted on its own line with a prefix
	-- (for example " */"), remove that prefix from inner trailing content
	if info.close_on_own_line == true and type(info.close_line_prefix) == "string" and info.close_line_prefix ~= "" then
		local close_prefix_pat = escape_lua_pattern(info.close_line_prefix) .. "$"
		-- Pattern breakdown:
		-- - escaped literal prefix
		-- - '$' ensures we only strip it at the very end of inner text
		inner = inner:gsub(close_prefix_pat, "", 1)
	end
	-- For indent-aligned guard wrappers, the closing delimiter line may carry
	-- base indentation (captured in 'lead') that is not part of payload
	-- Strip that suffix before structural newline cleanup so repeated toggles
	-- do not accumulate a blank trailing line
	if info._kind == "guard" and info.align_to_indent == true and lead ~= "" then
		local lead_pat = escape_lua_pattern(lead) .. "$"
		inner = inner:gsub(lead_pat, "", 1)
	end
	-- For close-own-line formats, remove the structural newline that separated
	-- the last content line from the closing delimiter line. This must happen
	-- after 'close_line_prefix' stripping because that prefix may hide the final
	-- '\n' until it is removed
	if info.close_on_own_line == true and inner:sub(-1) == "\n" then
		inner = inner:sub(1, -2)
	end
	-- Remove per-line decoration prefixes (e.g. leading " * ") when configured
	if type(info.strip_line_prefix) == "string" and info.strip_line_prefix ~= "" then
		inner = map_lines(inner, function(line)
			-- 'strip_line_prefix' is syntax-provided Lua pattern, usually anchored
			-- to line start (for example "^%s*%*%s?") to remove star prefixes
			return (line:gsub(info.strip_line_prefix, "", 1))
		end)
	end
	-- Preserve outer whitespace only for single-line unwraps
	-- For multiline unwraps, re-attaching outer whitespace can reintroduce
	-- structural blank lines or over-indent the first payload line
	if text:find("\n", 1, true) then
		return inner
	end
	return lead .. inner .. trail
end

-- Toggle block comments around the current selection
-- If already wrapped, unwrap; otherwise wrap using format policy
local function toggle_block_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	local primary_info = get_primary_block_comment_info(doc)
	local guard_info = get_guard_comment_info(doc)
	if not primary_info and not guard_info then
		notify_missing_block_comment_support(doc)
		return
	end

	if not doc:has_selection() then
		local line = select(1, doc:get_selection())
		local start_line, end_line
		if primary_info then
			start_line, end_line = find_block_bounds_around_line(
				doc,
				line,
				primary_info.block_open,
				primary_info.block_close,
				primary_info.block_open_variants
			)
		end
		if not start_line and guard_info then
			start_line, end_line = find_block_bounds_around_line(
				doc,
				line,
				guard_info.block_open,
				guard_info.block_close,
				guard_info.block_open_variants
			)
		end
		if start_line and end_line then
			doc:set_selection(start_line, 1, end_line, line_end_col(doc, end_line))
		else
			doc:set_selection(line, 1, line, line_end_col(doc, line))
		end
	end

	local line1, col1, line2, col2 = doc:get_selection(true)
	-- Normalize multiline block toggles to whole-line envelopes so boundary
	-- cursor states (especially trailing empty-line selections) cannot leak
	-- extra newlines into the edited payload
	if line2 > line1 then
		line2 = effective_last_line(line1, line2, col2)
		if line2 < line1 then
			line2 = line1
		end
		col1 = 1
		col2 = line_end_col(doc, line2)
	end

	local text = doc:get_text(line1, col1, line2, col2)
	local unwrapped
	if primary_info then
		unwrapped = try_unwrap_block(text, primary_info)
	end
	if unwrapped == nil and guard_info then
		unwrapped = try_unwrap_block(text, guard_info)
	end
	if unwrapped ~= nil then
		doc:remove(line1, col1, line2, col2)
		doc:insert(line1, col1, unwrapped)
		local new_line2, new_col2 = doc:position_offset(line1, col1, #unwrapped)
		doc:set_selection(line1, col1, new_line2, new_col2)
		return
	end

	local wrap_info = choose_wrap_comment_info(doc, text)
	if not wrap_info then
		if has_line_comment_support(doc) then
			toggle_line_comments()
		else
			notify_nested_block_not_supported(doc)
		end
		return
	end

	-- Single-line targets should remain single-line block comments even if
	-- syntax close delimiter is configured on its own line for multiline blocks
	if wrap_info._kind == "block" and line1 == line2 and not text:find("\n", 1, true) then
		local single = {}
		for k, v in pairs(wrap_info) do
			single[k] = v
		end
		single.open_on_own_line = false
		single.close_on_own_line = false
		single.close_line_prefix = ""
		wrap_info = single
	end

	-- Multiline selection ranges can carry a terminal '\n' artifact depending on
	-- how the cursor ended the selection. Remove exactly one trailing newline so
	-- block wrapping does not synthesize an extra blank content line before
	-- the closing delimiter
	local text_for_wrap = text
	if line1 ~= line2 then
		-- Remove any trailing newline artifacts from the selected payload before
		-- formatting, otherwise multiline wrapping can synthesize a blank line
		-- after the closing delimiter
		text_for_wrap = text_for_wrap:gsub("\n+$", "")
	end

	wrap_info = guard_aligned_wrap_info(wrap_info, text_for_wrap)
	local wrapped = format_block_text(text_for_wrap, wrap_info)
	-- Never insert a trailing newline as part of the wrapped payload itself
	-- The document already owns line terminators; adding one here would create
	-- an extra empty follow-up line after block commenting
	wrapped = wrapped:gsub("\n+$", "")
	doc:remove(line1, col1, line2, col2)
	doc:insert(line1, col1, wrapped)
	local new_line2, new_col2 = doc:position_offset(line1, col1, #wrapped)
	doc:set_selection(line1, col1, new_line2, new_col2)
end

-- Toggle guard comments around the current selection
-- If already wrapped with a configured guard form, unwrap; otherwise wrap
local function toggle_guard_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	local info = get_guard_comment_info(doc)
	if not info then
		notify_missing_guard_comment_support(doc)
		return
	end

	if not doc:has_selection() then
		local line = select(1, doc:get_selection())
		local start_line, end_line

		if info.block_close == "" then
			start_line, end_line = find_open_guard_bounds_around_line(doc, line, info)
		else
			start_line, end_line =
				find_block_bounds_around_line(doc, line, info.block_open, info.block_close, info.block_open_variants)
		end

		if start_line and end_line then
			doc:set_selection(start_line, 1, end_line, line_end_col(doc, end_line))
		else
			doc:set_selection(line, 1, line, line_end_col(doc, line))
		end
	end

	local line1, col1, line2, col2 = doc:get_selection(true)
	if line2 > line1 then
		line2 = effective_last_line(line1, line2, col2)
		if line2 < line1 then
			line2 = line1
		end
		col1 = 1
		col2 = line_end_col(doc, line2)
	end

	local text = doc:get_text(line1, col1, line2, col2)
	local unwrapped = try_unwrap_block(text, info)
	if unwrapped ~= nil then
		doc:remove(line1, col1, line2, col2)
		doc:insert(line1, col1, unwrapped)
		local new_line2, new_col2 = doc:position_offset(line1, col1, #unwrapped)
		doc:set_selection(line1, col1, new_line2, new_col2)
		return
	end

	if not block_allows_nested(info) and selection_contains_block_markers(text, info) then
		if has_line_comment_support(doc) then
			toggle_line_comments()
		else
			notify_nested_block_not_supported(doc)
		end
		return
	end

	local text_for_wrap = text
	if line1 ~= line2 then
		text_for_wrap = text_for_wrap:gsub("\n+$", "")
	end
	local wrap_info = guard_aligned_wrap_info(info, text_for_wrap)
	local wrapped = format_block_text(text_for_wrap, wrap_info)
	wrapped = wrapped:gsub("\n+$", "")
	doc:remove(line1, col1, line2, col2)
	doc:insert(line1, col1, wrapped)
	local new_line2, new_col2 = doc:position_offset(line1, col1, #wrapped)
	doc:set_selection(line1, col1, new_line2, new_col2)
end

-- Toggle inline block comment wrapping only
-- Uses a fixed 4-state cycle:
-- - for single-line selections:
--   1) uncommented -> compact ('/*text*/')
--   2) compact -> spaced ('/* text */')
--   3) spaced -> uncommented ('text')
-- - for multiline selections:
--   1) uncommented -> compact ('/*text*/')
--   2) compact -> spaced ('/* text */')
--   3) spaced -> boxed ('/*\ntext\n*/')
--   4) boxed -> uncommented ('text')
local function toggle_inline_block_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	if not doc:has_selection() then
		local line = select(1, doc:get_selection())
		local col2 = line_end_col(doc, line)
		doc:set_selection(line, 1, line, col2)
	end

	local info = get_block_comment_info(doc)
	if not info then
		notify_missing_block_comment_support(doc)
		return
	end

	local line1, col1, line2, col2 = doc:get_selection(true)
	-- Treat trailing cursor-only line as exclusive bound so a selected final empty
	-- line remains untouched by the inline-block cycle
	if line2 > line1 and col2 == 1 then
		line2 = line2 - 1
		col2 = line_end_col(doc, line2)
	end
	local text = doc:get_text(line1, col1, line2, col2)
	local open = info.block_open
	local close = info.block_close
	local open_pat = escape_lua_pattern(open)
	local close_pat = escape_lua_pattern(close)

	local next_text
	-- Pattern checks:
	-- - "^" .. open_pat ensures opening delimiter is at selection start
	-- - close_pat .. "$" ensures closing delimiter is at selection end
	if not text:match("^" .. open_pat) or not text:match(close_pat .. "$") then
		if not block_allows_nested(info) and selection_contains_block_markers(text, info) then
			if has_line_comment_support(doc) then
				toggle_selection_line_comment()
			else
				notify_nested_block_not_supported(doc)
			end
			return
		end
		-- State 0 -> 1: uncommented => compact
		next_text = open .. text .. close
	else
		local inner = text:sub(#open + 1, #text - #close)
		local is_boxed = (#inner >= 2 and inner:sub(1, 1) == "\n" and inner:sub(-1) == "\n")
		local is_spaced = (#inner >= 2 and inner:sub(1, 1) == " " and inner:sub(-1) == " ")
		local is_multiline_inner = inner:find("\n", 1, true) ~= nil

		if is_boxed then
			-- Multiline state 4 -> 0: boxed => uncommented
			next_text = inner:sub(2, -2)
		elseif is_spaced then
			if is_multiline_inner then
				-- Multiline state 3 -> 4: spaced => boxed
				local content = inner:sub(2, -2)
				next_text = open .. "\n" .. content .. "\n" .. close
			else
				-- Single-line state 3 -> 0: spaced => uncommented
				next_text = inner:sub(2, -2)
			end
		else
			-- State 2 -> 3: compact => spaced
			next_text = open .. " " .. inner .. " " .. close
		end
	end

	doc:remove(line1, col1, line2, col2)
	doc:insert(line1, col1, next_text)
	local new_line2, new_col2 = doc:position_offset(line1, col1, #next_text)
	doc:set_selection(line1, col1, new_line2, new_col2)
end

-- Toggle line-comment marker only at the start of the selected span
-- This is used for end-of-line partial selections in smart mode:
-- 'this is a line of text' with selection 'line of text' becomes
-- 'this is a // line of text'
toggle_selection_line_comment = function()
	local doc = get_doc()
	if not doc then
		return
	end
	if not doc:has_selection() then
		return
	end

	local marker = resolve_line_comment_marker(doc.format and doc.format.comment)
	if not marker then
		notify_missing_any_comment_support(doc)
		return
	end

	local line1, col1, line2, col2 = doc:get_selection(true)
	if line1 ~= line2 then
		return
	end

	local text = doc:get_text(line1, col1, line2, col2)
	local marker_pat = escape_lua_pattern(marker)
	local requires_separator = marker_requires_separator(marker)
	-- Pattern "^" .. marker_pat .. "%s":
	-- - marker at selected-text start
	-- - at least one whitespace right after marker -> spaced style
	local is_spaced = text:match("^" .. marker_pat .. "%s") ~= nil
	-- Pattern "^" .. marker_pat:
	-- - marker at selected-text start -> some commented style
	local is_commented = text:match("^" .. marker_pat) ~= nil
	if is_commented and requires_separator then
		local tail = text:gsub("^" .. marker_pat, "", 1)
		local ch = tail:sub(1, 1)
		if ch ~= "" and not ch:match("%s") then
			is_commented = false
			is_spaced = false
		end
	end
	local next_text
	if is_spaced then
		-- Spaced -> uncomment
		-- Pattern "^" .. marker_pat .. "%s?":
		-- - remove marker
		-- - remove at most one following space
		next_text = text:gsub("^" .. marker_pat .. "%s?", "", 1)
	elseif is_commented then
		-- Compact -> spaced
		-- Pattern "^(" .. marker_pat .. ")%s*":
		-- - keep marker (%1)
		-- - normalize following spaces to exactly one
		next_text = text:gsub("^(" .. marker_pat .. ")%s*", "%1 ", 1)
	else
		-- Uncommented -> compact
		if requires_separator then
			next_text = marker .. " " .. text
		else
			next_text = marker .. text
		end
	end

	doc:remove(line1, col1, line2, col2)
	doc:insert(line1, col1, next_text)
	local new_line2, new_col2 = doc:position_offset(line1, col1, #next_text)
	doc:set_selection(line1, col1, new_line2, new_col2)
end

-- Dedicated uncomment command
-- Priority:
-- 1) If selection is wrapped block/guard comment, unwrap it
-- 2) If single-line selection starts with line marker, remove marker from selection
-- 3) Otherwise remove line markers from selected/current lines
local function uncomment_comments()
	local doc = get_doc()
	if not doc then
		return
	end

	local line1, col1, line2, col2 = doc:get_selection(true)

	if doc:has_selection() then
		local text = doc:get_text(line1, col1, line2, col2)

		local primary_info = get_primary_block_comment_info(doc)
		local guard_info = get_guard_comment_info(doc)
		local unwrapped
		if primary_info then
			unwrapped = try_unwrap_block(text, primary_info)
		end
		if unwrapped == nil and guard_info then
			unwrapped = try_unwrap_block(text, guard_info)
		end
		if unwrapped ~= nil then
			doc:remove(line1, col1, line2, col2)
			doc:insert(line1, col1, unwrapped)
			local new_line2, new_col2 = doc:position_offset(line1, col1, #unwrapped)
			doc:set_selection(line1, col1, new_line2, new_col2)
			return
		end

		if line1 == line2 then
			local marker = resolve_line_comment_marker(doc.format and doc.format.comment)
			if marker then
				local marker_pat = escape_lua_pattern(marker)
				-- Pattern breakdown for "^" .. marker_pat .. "%s?":
				-- - '^' start of selected text
				-- - literal marker
				-- - optional one following space
				if text:match("^" .. marker_pat .. "%s?") then
					local next_text = text:gsub("^" .. marker_pat .. "%s?", "", 1)
					doc:remove(line1, col1, line2, col2)
					doc:insert(line1, col1, next_text)
					local new_line2, new_col2 = doc:position_offset(line1, col1, #next_text)
					doc:set_selection(line1, col1, new_line2, new_col2)
					return
				end
			end
		end
	end

	uncomment_line_comments()
end

-- True when there is non-whitespace text after 'col' on 'line'
-- Used by smart mode to distinguish "middle of line" from "end of line"
local function has_nonspace_after_col(doc, line, col)
	local line_text = strip_trailing_newline(doc.lines[line] or "")
	if col > #line_text + 1 then
		return false
	end
	local rest = line_text:sub(col)
	-- '%S' means at least one non-whitespace char remains after selection end
	return rest:find("%S") ~= nil
end

-- Smart behavior:
-- 1) Whole-line selection (or no chars on trailing line) -> line comments
-- 2) Single-line partial selection with text after selection -> inline block comments
-- 3) Single-line partial selection at end-of-line -> line-comment at selection start
-- 4) Any multi-line selection -> line comments
local function toggle_smart_comments()
	local doc = get_doc()
	if not doc then
		return
	end
	local has_line_comment = has_line_comment_support(doc)
	local primary_info = get_primary_block_comment_info(doc)
	local guard_info = get_guard_comment_info(doc)
	local has_block_like = (primary_info ~= nil or guard_info ~= nil)

	if not doc:has_selection() then
		if has_line_comment then
			toggle_line_comments()
		elseif has_block_like then
			toggle_block_comments()
		else
			notify_missing_any_comment_support(doc)
		end
		return
	end

	local line1, col1, line2, col2 = doc:get_selection(true)

	-- If the current selection is already wrapped in configured block delimiters,
	-- smart toggle should prioritize unwrapping instead of switching to line
	-- comments. This prevents repeated '//' layering on top of '/* ... */'
	if primary_info or guard_info then
		local selected_text = doc:get_text(line1, col1, line2, col2)
		local unwrapped
		if primary_info then
			unwrapped = try_unwrap_block(selected_text, primary_info)
		end
		if unwrapped == nil and guard_info then
			unwrapped = try_unwrap_block(selected_text, guard_info)
		end
		if unwrapped ~= nil then
			doc:remove(line1, col1, line2, col2)
			doc:insert(line1, col1, unwrapped)
			local new_line2, new_col2 = doc:position_offset(line1, col1, #unwrapped)
			doc:set_selection(line1, col1, new_line2, new_col2)
			return
		end
	end

	local is_linewise = selection_is_linewise(doc, line1, col1, line2, col2)
	if is_linewise then
		if has_line_comment then
			toggle_line_comments()
		elseif has_block_like then
			toggle_block_comments()
		else
			notify_missing_any_comment_support(doc)
		end
		return
	end

	-- Multi-line smart behavior is always line comments to keep toggling stable
	if line1 ~= line2 then
		if has_line_comment then
			toggle_line_comments()
		elseif has_block_like then
			toggle_block_comments()
		else
			notify_missing_any_comment_support(doc)
		end
		return
	end

	-- Single-line partial selection behavior
	if line1 == line2 then
		if has_nonspace_after_col(doc, line2, col2) then
			if primary_info then
				toggle_inline_block_comments()
			elseif has_line_comment then
				toggle_selection_line_comment()
			elseif has_block_like then
				toggle_block_comments()
			else
				notify_missing_any_comment_support(doc)
			end
		else
			if has_line_comment then
				toggle_selection_line_comment()
			elseif primary_info then
				toggle_inline_block_comments()
			elseif has_block_like then
				toggle_block_comments()
			else
				notify_missing_any_comment_support(doc)
			end
		end
		return
	end
end

--- Toggle line comments in the active document
local function toggle_line()
	with_post_action_selection(function()
		if run_comment_hook("toggle-line") then
			return
		end
		toggle_line_comments()
	end)
end

--- Toggle block comments in the active document
local function toggle_block()
	with_post_action_selection(function()
		if run_comment_hook("toggle-block") then
			return
		end
		toggle_block_comments()
	end)
end

--- Toggle guard comments in the active document
local function toggle_guard()
	with_post_action_selection(function()
		if run_comment_hook("toggle-guard") then
			return
		end
		toggle_guard_comments()
	end)
end

--- Toggle inline block comments in the active document
local function toggle_inline_block()
	with_post_action_selection(function()
		if run_comment_hook("toggle-inline-block") then
			return
		end
		toggle_inline_block_comments()
	end)
end

--- Choose and toggle the suitable comment style in the active document
local function toggle_smart()
	with_post_action_selection(function()
		if run_comment_hook("toggle-smart") then
			return
		end
		toggle_smart_comments()
	end)
end

--- Remove recognized comments from the active document
local function uncomment()
	with_post_action_selection(function()
		if run_comment_hook("uncomment") then
			return
		end
		uncomment_comments()
	end)
end

return {
	toggle_line = toggle_line,
	toggle_block = toggle_block,
	toggle_guard = toggle_guard,
	toggle_inline_block = toggle_inline_block,
	toggle_smart = toggle_smart,
	uncomment = uncomment,
}
