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

--- Syntax-controlled wrapping for selected text
-- @module core.text.surround

local config = require("core.config")
local core = require("core")
local format = require("core.text.format")

local surround = {}

local valid_behaviors = {
	stop = true,
	cycle = true,
	unwrap = true,
}

--- Return whether every active selection contains text
-- Empty or mixed selections retain ordinary text input behavior
-- @param doc table text document
-- @return boolean has_selected_text
local function has_only_non_empty_selections(doc)
	local selections = doc:get_selections()

	for index = 1, #selections do
		local selection = selections[index]
		if selection[1] == selection[3] and selection[2] == selection[4] then
			return false
		end
	end

	return #selections > 0
end

--- Resolve static or syntax-generated markers for one selection
-- @param pair table normalized syntax pair
-- @param typed string committed input text
-- @param selected table prepared selection
-- @param doc table text document
-- @return string|nil open_marker
-- @return string|nil close_marker
-- @return string|nil resolve_error
local function resolve_markers(pair, typed, selected, doc)
	local open_marker = pair.open
	local close_marker = pair.close

	if pair.resolve then
		open_marker, close_marker = pair.resolve({
			typed = typed,
			text = selected.text,
			doc = doc,
			syntax = doc.format,
			line = selected.line,
			col1 = selected.col1,
			col2 = selected.col2,
		})
	end

	if type(open_marker) ~= "string" or open_marker == ""
		or type(close_marker) ~= "string" or close_marker == "" then
		return nil, nil, "Syntax returned invalid surrounding markers"
	end

	return open_marker, close_marker, nil
end

--- Count exact matching pair layers immediately outside one selection
-- @param line_text string complete source line
-- @param col1 number selected range start
-- @param col2 number selected range end
-- @param open_marker string opening marker
-- @param close_marker string closing marker
-- @return number repeat_count
-- @return number range_col1 complete wrapped range start
-- @return number range_col2 complete wrapped range end
local function read_repeat_count(line_text, col1, col2, open_marker, close_marker)
	local repeat_count = 0
	local range_col1 = col1
	local range_col2 = col2

	while range_col1 > #open_marker
		and line_text:sub(range_col1 - #open_marker, range_col1 - 1) == open_marker
		and line_text:sub(range_col2, range_col2 + #close_marker - 1) == close_marker do
		repeat_count = repeat_count + 1
		range_col1 = range_col1 - #open_marker
		range_col2 = range_col2 + #close_marker
	end

	return repeat_count, range_col1, range_col2
end

--- Apply one pair using syntax repeat behavior or an exact raw depth
-- @param doc table text document
-- @param pair table normalized or command-created pair
-- @param preset string preset value passed to syntax resolvers
-- @param exact_depth number|nil exact raw wrapper count
-- @return boolean changed
-- @return string|nil surround_error friendly rejection reason
local function apply_pair(doc, pair, preset, exact_depth)
	return format.transform_inline_selections(doc, function(selected)
		local open_marker, close_marker, marker_error = resolve_markers(pair, preset, selected, doc)
		if marker_error then
			return nil, marker_error
		end
		-- The resolver has already rejected absent or malformed marker values
		assert(type(open_marker) == "string" and type(close_marker) == "string")

		local repeat_count, range_col1, range_col2 = read_repeat_count(
			selected.line_text,
			selected.col1,
			selected.col2,
			open_marker,
			close_marker
		)

		if exact_depth then
			local repeated_open = string.rep(open_marker, exact_depth)
			local repeated_close = string.rep(close_marker, exact_depth)

			return {
				range_col1 = range_col1,
				range_col2 = range_col2,
				text = repeated_open .. selected.text .. repeated_close,
				selection_offset1 = #repeated_open,
				selection_offset2 = #repeated_open + #selected.text,
			}
		end

		local min_repeat = pair.min_repeat or 1
		local max_repeat = pair.max_repeat
		assert(type(min_repeat) == "number", "Surround min_repeat must be a number")
		assert(max_repeat == nil or type(max_repeat) == "number", "Surround max_repeat must be a number")
		local behavior = pair.behavior or config.auto_surround_behavior
		if not valid_behaviors[behavior] then
			behavior = "unwrap"
		end

		if repeat_count == 0 or max_repeat == nil or repeat_count < max_repeat then
			local added_repeat = repeat_count == 0 and min_repeat or 1
			local repeated_open = string.rep(open_marker, added_repeat)
			local repeated_close = string.rep(close_marker, added_repeat)

			return {
				range_col1 = selected.col1,
				range_col2 = selected.col2,
				text = repeated_open .. selected.text .. repeated_close,
				selection_offset1 = #repeated_open,
				selection_offset2 = #repeated_open + #selected.text,
			}
		end
		if behavior == "stop" then
			return nil, "Maximum surround repeat reached"
		end

		local retained_repeat = behavior == "cycle" and min_repeat or 0
		local retained_open = string.rep(open_marker, retained_repeat)
		local retained_close = string.rep(close_marker, retained_repeat)
		return {
			range_col1 = range_col1,
			range_col2 = range_col2,
			text = retained_open .. selected.text .. retained_close,
			selection_offset1 = #retained_open,
			selection_offset2 = #retained_open + #selected.text,
		}
	end)
end

--- Handle syntax-controlled wrapping for committed text input
-- Returns false when normal text input should continue
-- @param doc table text document
-- @param typed string committed input text
-- @return boolean consumed
function surround.handle_text_input(doc, typed)
	if config.auto_surround == false or not has_only_non_empty_selections(doc) then
		return false
	end

	local pairs = doc.format and doc.format.surrounding_pairs
	local pair = type(pairs) == "table" and pairs[typed] or nil
	if type(pair) ~= "table" then
		return false
	end

	local changed, surround_error = apply_pair(doc, pair, typed, nil)
	if not changed and surround_error then
		core.notice("Surround: %s", surround_error)
	end

	return true
end

--- Surround selections from a syntax preset or explicit raw markers
-- Exactly one of 'preset', 'text', or the 'open' and 'close' pair must be supplied
-- @param doc table text document
-- @param options table|nil decoded command arguments
-- @return boolean changed
function surround.perform(doc, options)
	if type(options) ~= "table" then
		core.notice("Surround: Expected preset, text, or open and close arguments")
		return false
	end

	local has_preset = options.preset ~= nil
	local has_text = options.text ~= nil
	local has_explicit_pair = options.open ~= nil or options.close ~= nil
	local mode_count = (has_preset and 1 or 0) + (has_text and 1 or 0) + (has_explicit_pair and 1 or 0)
	if mode_count ~= 1 then
		core.notice("Surround: Choose exactly one of preset, text, or open and close")
		return false
	end

	local pair
	local preset = ""
	local exact_depth
	if has_preset then
		if type(options.preset) ~= "string" or options.preset == "" or options.depth ~= nil then
			core.notice("Surround: Preset must be non-empty and cannot use depth")
			return false
		end

		preset = options.preset
		local pairs = doc.format and doc.format.surrounding_pairs
		pair = type(pairs) == "table" and pairs[preset] or nil
		if type(pair) ~= "table" then
			core.notice("Surround: Active syntax has no '%s' preset", preset)
			return false
		end
	else
		local open_marker = has_text and options.text or options.open
		local close_marker = has_text and options.text or options.close
		exact_depth = options.depth == nil and 1 or options.depth

		if type(open_marker) ~= "string" or open_marker == ""
			or type(close_marker) ~= "string" or close_marker == "" then
			core.notice("Surround: Raw markers must be non-empty strings")
			return false
		end
		if type(exact_depth) ~= "number" or exact_depth < 1 or exact_depth % 1 ~= 0 then
			core.notice("Surround: Depth must be a positive integer")
			return false
		end

		pair = { open = open_marker, close = close_marker }
	end

	local changed, surround_error = apply_pair(doc, pair, preset, exact_depth)
	if not changed and surround_error then
		core.notice("Surround: %s", surround_error)
	end

	return changed == true
end

return surround
