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

local core = require("core")
local command = require("core.command")
local keymap = require("core.keymap")
local config = require("core.config")
-- Text cursor translation helpers are scoped under core.text
local text_select = require("core.text.select")
local translate = require("core.text.translate")
local utf8 = require("utf8")

-- Case conversion utilities for symbol and selected text transforms

-- Converts camel hump boundaries captured by gsub into snake segments
local function camel_to_snake(a, b)
	return a .. "_" .. utf8.lower(b)
end

-- Converts camelCase or TitleCase into snake_case
local function to_snake(symbol)
	-- Pattern breakdown:
	-- (.) captures the previous Unicode character
	-- (%u) captures an uppercase Unicode character that starts a new hump
	return utf8.lower(utf8.gsub(symbol, "(.)(%u)", camel_to_snake))
end

-- Converts snake_case tokens into Title Case words without separators
local function to_title(symbol)
	local parts = {}
	-- Pattern breakdown:
	-- [^_]+ matches one or more non-underscore characters
	for part in utf8.gmatch(symbol, "[^_]+") do
		parts[#parts + 1] = utf8.upper(utf8.sub(part, 1, 1)) .. utf8.lower(utf8.sub(part, 2))
	end
	return table.concat(parts)
end

-- Converts snake_case into camelCase
local function to_camel_from_snake(symbol)
	local title = to_title(symbol)
	return utf8.lower(utf8.sub(title, 1, 1)) .. utf8.sub(title, 2)
end

-- Cycles word style across snake_case camelCase and TitleCase
local function toggle(symbol)
	-- Pattern breakdown:
	-- %l checks for at least one lowercase Unicode letter before transforming
	if not utf8.match(symbol, "%l") then
		return symbol
	-- Pattern breakdown:
	-- _ checks for snake_case separator presence
	elseif symbol:match("_") then
		-- snake_case -> camelCase
		return to_camel_from_snake(symbol)
	-- Pattern breakdown:
	-- ^ anchors at start
	-- %u checks for TitleCase leading uppercase Unicode text
	elseif utf8.match(symbol, "^%u") then
		-- TitleCase -> snake_case
		return to_snake(symbol)
	-- Pattern breakdown:
	-- ^ anchors at start
	-- %l checks for camelCase leading lowercase Unicode text
	elseif utf8.match(symbol, "^%l") then
		-- camelCase -> TitleCase
		return utf8.upper(utf8.sub(symbol, 1, 1)) .. utf8.sub(symbol, 2)
	end
	return symbol
end

-- Title-cases each whitespace-delimited token in free text
local function titlecase_text(text)
	-- Pattern breakdown:
	-- (%S) captures first non-space character in each token
	-- (%S*) captures the rest of that token
	return utf8.gsub(text, "(%S)(%S*)", function(a, b)
		return utf8.upper(a) .. utf8.lower(b)
	end)
end

-- Uppercases the first non-space character in the text
local function first_capital_text(text)
	-- Pattern breakdown:
	-- ^ anchors at start
	-- (%s*) captures leading whitespace
	-- (%S) captures first non-space character
	return (utf8.gsub(text, "^(%s*)(%S)", function(ws, c)
		return ws .. utf8.upper(c)
	end))
end

--- Resolve one selected range or the word under one caret
-- @param doc table active text document
-- @param selection table detached selection snapshot
-- @return number line1
-- @return number col1
-- @return number line2
-- @return number col2
local function get_range_or_word(doc, selection)
	if selection[1] ~= selection[3] or selection[2] ~= selection[4] then
		return text_select.sort_positions(
			selection[1],
			selection[2],
			selection[3],
			selection[4]
		)
	end

	local line, col = selection[1], selection[2]
	local line1, col1 = doc:position_offset(line, col, translate.start_of_word)
	local line2, col2 = doc:position_offset(line, col, translate.end_of_word)

	return line1, col1, line2, col2
end

--- Return one stable key for a single-line transformation range
-- @param line1 number range line
-- @param col1 number start column
-- @param col2 number end column
-- @return string key
local function get_range_key(line1, col1, col2)
	return string.format("%d:%d:%d", line1, col1, col2)
end

--- Apply one transformation to every selection or caret word
-- @param doc table active text document
-- @param fn function text transformation
-- @param validator function|nil optional source-text predicate
-- @return boolean changed
local function apply_to_ranges(doc, fn, validator)
	local selections, primary_index = doc:get_selections()
	local targets = {}
	local targets_by_key = {}

	for index = 1, #selections do
		local selection = selections[index]
		local line1, col1, line2, col2 = get_range_or_word(doc, selection)

		if line1 == line2 and col1 ~= col2 then
			local key = get_range_key(line1, col1, col2)
			local target = targets_by_key[key]

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

				local next_text = text

				if validator == nil or validator(text) then
					next_text = fn(text) or text
				end

				target = {
					line = line1,
					col1 = col1,
					col2 = col2,
					text = text,
					next_text = next_text,
					selection_indexes = {},
				}
				targets_by_key[key] = target
				targets[#targets + 1] = target
			end

			target.selection_indexes[#target.selection_indexes + 1] = index
		end
	end

	table.sort(targets, function(left, right)
		if left.line ~= right.line then
			return left.line < right.line
		end

		return left.col1 < right.col1
	end)

	local replacements = {}
	local line_deltas = {}
	local target_by_selection = {}

	for target_index = 1, #targets do
		local target = targets[target_index]
		local line_delta = line_deltas[target.line] or 0
		target.result_col1 = target.col1 + line_delta

		if target.next_text ~= target.text then
			replacements[#replacements + 1] = {
				line1 = target.line,
				col1 = target.col1,
				line2 = target.line,
				col2 = target.col2,
				text = target.next_text,
			}
			line_deltas[target.line] = line_delta + #target.next_text - #target.text
		end

		for index = 1, #target.selection_indexes do
			target_by_selection[target.selection_indexes[index]] = target
		end
	end

	if #replacements == 0 then
		return false
	end

	local after_selections = {}

	for index = 1, #selections do
		local selection = selections[index]
		local target = target_by_selection[index]

		if target then
			local has_range = selection[1] ~= selection[3] or selection[2] ~= selection[4]
			local result_col2 = target.result_col1 + #target.next_text

			if has_range then
				local forward = selection[1] < selection[3]
					or selection[1] == selection[3] and selection[2] <= selection[4]

				if forward then
					after_selections[index] = {
						target.line,
						target.result_col1,
						target.line,
						result_col2,
						id = selection.id,
					}
				else
					after_selections[index] = {
						target.line,
						result_col2,
						target.line,
						target.result_col1,
						id = selection.id,
					}
				end
			else
				local offset = math.min(selection[2] - target.col1, #target.next_text)
				local col = target.result_col1 + math.max(0, offset)
				after_selections[index] = { target.line, col, target.line, col, id = selection.id }
			end
		else
			after_selections[index] = selection
		end
	end

	return doc:replace_ranges(replacements, after_selections, primary_index)
end

command.register({
	["case:cameltitlesnake"] = {
		requires = { "doc", "edit" },
		perform = function()
		-- Pattern breakdown:
		-- '^' anchors at start, config.symbol_pattern matches symbol text, and '$' anchors at end
		apply_to_ranges(core.active_view.doc, toggle, function(text)
			return utf8.match(text, "^" .. config.symbol_pattern .. "$") ~= nil
		end)
		end,
	},

	["case:lower"] = {
		requires = { "doc", "edit" },
		perform = function()
		apply_to_ranges(core.active_view.doc, utf8.lower)
		end,
	},

	["case:upper"] = {
		requires = { "doc", "edit" },
		perform = function()
		apply_to_ranges(core.active_view.doc, utf8.upper)
		end,
	},

	["case:title"] = {
		requires = { "doc", "edit" },
		perform = function()
		apply_to_ranges(core.active_view.doc, titlecase_text)
		end,
	},

	["case:capital"] = {
		requires = { "doc", "edit" },
		perform = function()
		apply_to_ranges(core.active_view.doc, first_capital_text)
		end,
	},
})

keymap.bind("plugin", {
	text_editor = {
		["ctrl+shift+l"] = "case:lower",
		["ctrl+shift+u"] = "case:upper",
		["ctrl+shift+t"] = "case:title",
		["f6"] = "case:cameltitlesnake",
	},
})
