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

--- UTF-8 utility wrappers for Lua text paths
-- Keeps call sites explicit while delegating byte-sensitive work to the UTF-8 API

local utf8 = require("utf8")

local utf8help = {}
local regional_run_cache = {}

--- Read one codepoint at a byte offset
-- Returns nil when the offset is outside the string or malformed
-- @param text string source text
-- @param byte_offset number one-based byte offset
-- @return number|nil codepoint at the offset
local function codepoint_at(text, byte_offset)
	if not byte_offset or byte_offset < 1 or byte_offset > #text then
		return nil
	end

	local ok, codepoint = pcall(utf8.codepoint, text, byte_offset, byte_offset)

	if not ok then
		return nil
	end

	return codepoint
end

--- Safely resolve a UTF-8 byte offset query
-- Returns nil instead of raising when the offset lands on malformed input
-- @param text string source text
-- @param index number offset query
-- @param byte_offset number starting byte offset
-- @return number|nil resolved byte offset
local function safe_offset(text, index, byte_offset)
	local ok, resolved_offset = pcall(utf8.offset, text, index, byte_offset)

	if not ok then
		return nil
	end

	return resolved_offset
end

--- Return the previous UTF-8 codepoint start
-- @param text string source text
-- @param byte_offset number current codepoint start
-- @return number|nil previous codepoint start
local function previous_codepoint_offset(text, byte_offset)
	if byte_offset <= 1 then
		return nil
	end

	return safe_offset(text, -1, byte_offset)
end

--- Check whether the next codepoint extends the previous visible symbol
-- This is intentionally minimal and only flags boundaries where the next
-- codepoint commonly extends or joins with the previous visible symbol
-- @param previous_codepoint number|nil codepoint before the boundary
-- @param next_codepoint number|nil codepoint after the boundary
-- @return boolean suspicious boundary needs closer inspection
local function boundary_has_grapheme_extension(previous_codepoint, next_codepoint)
	-- Missing boundary codepoints mean there is nothing valid to repair
	if not previous_codepoint or not next_codepoint then
		return false
	end

	-- Variation selectors stay attached to the previous visible base
	if next_codepoint >= 0xFE00 and next_codepoint <= 0xFE0F then
		return true
	end
	if next_codepoint >= 0xE0100 and next_codepoint <= 0xE01EF then
		return true
	end

	-- Combining mark ranges extend the previous grapheme
	if next_codepoint >= 0x0300 and next_codepoint <= 0x036F then
		return true
	end
	if next_codepoint >= 0x1AB0 and next_codepoint <= 0x1AFF then
		return true
	end
	if next_codepoint >= 0x1DC0 and next_codepoint <= 0x1DFF then
		return true
	end
	if next_codepoint >= 0x20D0 and next_codepoint <= 0x20FF then
		return true
	end
	if next_codepoint >= 0xFE20 and next_codepoint <= 0xFE2F then
		return true
	end

	-- Joiners and keycap marks also belong to the previous base
	if next_codepoint == 0x200D or next_codepoint == 0x20E3 then
		return true
	end

	-- Skin tone modifiers extend the emoji before them
	if next_codepoint >= 0x1F3FB and next_codepoint <= 0x1F3FF then
		return true
	end

	-- Regional indicator pairs form one flag grapheme
	if previous_codepoint >= 0x1F1E6 and previous_codepoint <= 0x1F1FF
		and next_codepoint >= 0x1F1E6 and next_codepoint <= 0x1F1FF
	then
		return true
	end

	return false
end

--- Return true when a codepoint is a variation selector
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint is a variation selector
local function is_variation_selector(codepoint)
	return codepoint ~= nil and (
		(codepoint >= 0xFE00 and codepoint <= 0xFE0F) or
		(codepoint >= 0xE0100 and codepoint <= 0xE01EF)
	)
end

--- Return true when a codepoint is a combining mark
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint is a combining mark
local function is_combining_mark(codepoint)
	return codepoint ~= nil and (
		(codepoint >= 0x0300 and codepoint <= 0x036F) or
		(codepoint >= 0x1AB0 and codepoint <= 0x1AFF) or
		(codepoint >= 0x1DC0 and codepoint <= 0x1DFF) or
		(codepoint >= 0x20D0 and codepoint <= 0x20FF) or
		(codepoint >= 0xFE20 and codepoint <= 0xFE2F)
	)
end

--- Return true when a codepoint is an emoji modifier
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint is an emoji modifier
local function is_emoji_modifier(codepoint)
	return codepoint ~= nil and codepoint >= 0x1F3FB and codepoint <= 0x1F3FF
end

--- Return true when a codepoint is a regional indicator
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint is a regional indicator
local function is_regional_indicator(codepoint)
	return codepoint ~= nil and codepoint >= 0x1F1E6 and codepoint <= 0x1F1FF
end

--- Return the paired cluster start inside a regional-indicator run
-- Caches the last run because exact flag pairing depends on its first symbol
-- @param text string source text
-- @param target_offset number regional-indicator byte offset
-- @return number paired cluster start byte offset
local function regional_cluster_start_offset(text, target_offset)
	if regional_run_cache.text ~= text
		or target_offset < regional_run_cache.start_offset
		or target_offset >= regional_run_cache.end_offset
	then
		local run_start = target_offset
		local run_end = safe_offset(text, 2, target_offset) or (#text + 1)

		while true do
			local previous_offset = previous_codepoint_offset(text, run_start)

			if not is_regional_indicator(codepoint_at(text, previous_offset)) then
				break
			end

			run_start = previous_offset
		end

		while is_regional_indicator(codepoint_at(text, run_end)) do
			run_end = safe_offset(text, 2, run_end) or (#text + 1)
		end

		regional_run_cache = {
			text = text,
			start_offset = run_start,
			end_offset = run_end,
		}
	end

	-- Every regional indicator occupies four UTF-8 bytes
	local regional_index = (target_offset - regional_run_cache.start_offset) / 4

	if regional_index % 2 == 1 then
		return target_offset - 4
	end

	return target_offset
end

--- Return true when a codepoint can start a keycap cluster
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint is a keycap base
local function is_keycap_base(codepoint)
	return codepoint == 0x23 or codepoint == 0x2A
		or (codepoint ~= nil and codepoint >= 0x30 and codepoint <= 0x39)
end

--- Return true when a codepoint trails a visible cluster base
-- @param codepoint number|nil source codepoint
-- @return boolean codepoint extends the visible cluster
local function is_cluster_trailing_codepoint(codepoint)
	return is_variation_selector(codepoint)
		or is_combining_mark(codepoint)
		or is_emoji_modifier(codepoint)
end

--- Check whether a token boundary looks suspicious for Unicode text
-- @param text string source text
-- @param boundary_byte_offset number last byte kept on the left side
-- @return boolean suspicious boundary needs closer inspection
-- @return number|nil previous codepoint before the boundary
-- @return number|nil next codepoint after the boundary
function utf8help.has_suspicious_boundary(text, boundary_byte_offset)
	local next_byte_offset = boundary_byte_offset + 1

	-- Read only the codepoints directly adjacent to the byte boundary
	local previous_codepoint = codepoint_at(text, safe_offset(text, 0, boundary_byte_offset))
	local next_codepoint = codepoint_at(text, safe_offset(text, 0, next_byte_offset))

	return boundary_has_grapheme_extension(previous_codepoint, next_codepoint), previous_codepoint, next_codepoint
end

--- Resolve a suspicious token boundary by grapheme ownership
-- When a grapheme spans the boundary, its trailing bytes stay attached to the
-- previous token text instead of starting the next token on their own
-- @param previous_text string finalized text on the left side
-- @param next_text string incoming text on the right side
-- @return string updated left-side text
-- @return string updated right-side text
function utf8help.resolve_boundary(previous_text, next_text)
	-- Empty sides cannot contribute a spanning grapheme
	if previous_text == "" or next_text == "" then
		return previous_text, next_text
	end

	local first_byte = next_text:byte(1)

	-- ASCII-leading chunks cannot start with a grapheme extender
	if not first_byte or first_byte < 0x80 then
		return previous_text, next_text
	end

	local left_text = previous_text
	local right_text = next_text

	while right_text ~= "" do
		-- Inspect only the last codepoint on the left and the first on the right
		local previous_codepoint = codepoint_at(left_text, safe_offset(left_text, 0, #left_text))
		local next_codepoint = codepoint_at(right_text, 1)

		if not boundary_has_grapheme_extension(previous_codepoint, next_codepoint) then
			break
		end

		-- Transfer exactly one leading codepoint from right to left per step
		local next_codepoint_start = safe_offset(right_text, 0, 1)

		if not next_codepoint_start then
			break
		end

		local after_next_codepoint = safe_offset(right_text, 2, 1)
		local next_codepoint_end = after_next_codepoint and (after_next_codepoint - 1) or #right_text
		local boundary_chunk = right_text:sub(next_codepoint_start, next_codepoint_end)

		left_text = left_text .. boundary_chunk

		-- Keep scanning in case multiple extenders follow in sequence
		right_text = right_text:sub(next_codepoint_end + 1)
	end

	return left_text, right_text
end

--- Return the next visual cluster start byte offset
-- Uses the same lightweight cluster ownership rules as tokenizer boundaries
-- @param text string source text
-- @param byte_offset number one-based byte offset at cluster start
-- @return number|nil next cluster start byte offset
function utf8help.next_visual_cluster_offset(text, byte_offset)
	local cluster_start = safe_offset(text, 0, byte_offset)

	if not cluster_start then
		return nil
	end

	local codepoint = codepoint_at(text, cluster_start)

	if not codepoint then
		return nil
	end

	local next_offset = safe_offset(text, 2, cluster_start) or (#text + 1)

	-- Regional indicators form flag pairs
	if is_regional_indicator(codepoint) then
		local next_codepoint = codepoint_at(text, next_offset)

		if is_regional_indicator(next_codepoint) then
			return safe_offset(text, 2, next_offset) or (#text + 1)
		end

		return next_offset
	end

	-- Keycap clusters bind base, optional variation selector, and keycap mark
	if is_keycap_base(codepoint) then
		local keycap_offset = next_offset
		local keycap_codepoint = codepoint_at(text, keycap_offset)

		if is_variation_selector(keycap_codepoint) then
			keycap_offset = safe_offset(text, 2, keycap_offset) or (#text + 1)
			keycap_codepoint = codepoint_at(text, keycap_offset)
		end

		if keycap_codepoint == 0x20E3 then
			return safe_offset(text, 2, keycap_offset) or (#text + 1)
		end
	end

	-- Consume trailing variation selectors, combining marks, and emoji modifiers
	while next_offset <= #text and is_cluster_trailing_codepoint(codepoint_at(text, next_offset)) do
		next_offset = safe_offset(text, 2, next_offset) or (#text + 1)
	end

	-- Consume joined emoji-style sequences across zero-width joiners
	while next_offset <= #text and codepoint_at(text, next_offset) == 0x200D do
		local joined_base_offset = safe_offset(text, 2, next_offset)

		if not joined_base_offset then
			return #text + 1
		end

		next_offset = safe_offset(text, 2, joined_base_offset) or (#text + 1)

		while next_offset <= #text and is_cluster_trailing_codepoint(codepoint_at(text, next_offset)) do
			next_offset = safe_offset(text, 2, next_offset) or (#text + 1)
		end
	end

	return next_offset
end

--- Return the visual cluster start at or before a byte offset
-- Scans backward only through the adjacent cluster, then resolves its local
-- boundaries forward to stay consistent with next_visual_cluster_offset
-- @param text string source text
-- @param byte_offset number one-based byte offset inside or at a cluster
-- @return number|nil cluster start byte offset
function utf8help.visual_cluster_start_offset(text, byte_offset)
	if text == "" then
		return nil
	end

	local target_offset = safe_offset(text, 0, byte_offset)

	if not target_offset then
		return nil
	end

	if not codepoint_at(text, target_offset) then
		return nil
	end

	local local_start = target_offset

	-- Find a nearby safe start before trailing marks or joined sequences
	while true do
		local codepoint = codepoint_at(text, local_start)
		local previous_offset = previous_codepoint_offset(text, local_start)
		local previous_codepoint = codepoint_at(text, previous_offset)

		if is_cluster_trailing_codepoint(codepoint) or codepoint == 0x200D then
			if not previous_offset then
				break
			end

			local_start = previous_offset
		elseif previous_codepoint == 0x200D then
			local joined_base_offset = previous_codepoint_offset(text, previous_offset)

			if not joined_base_offset then
				break
			end

			local_start = joined_base_offset
		else
			break
		end
	end

	-- Regional indicators are paired from the beginning of their run
	if is_regional_indicator(codepoint_at(text, local_start)) then
		return regional_cluster_start_offset(text, local_start)
	end

	if regional_run_cache.text ~= text then
		regional_run_cache = {}
	end

	local cluster_start = local_start

	while cluster_start < target_offset do
		local next_offset = utf8help.next_visual_cluster_offset(text, cluster_start)

		if not next_offset or next_offset > target_offset then
			break
		end

		cluster_start = next_offset
	end

	return cluster_start
end

--- Return UTF-8 codepoint length for a text chunk
-- Falls back to byte length when malformed text reaches plain draw code
-- @param text string text chunk
-- @return number codepoint-aware chunk length
function utf8help.len(text)
	local length = utf8.len(text)

	if length == nil then
		return #text
	end

	return length
end

--- Return displayed columns for a text chunk
-- Falls back to byte length when malformed text reaches plain draw code
-- @param text string text chunk
-- @return number display_width displayed columns
function utf8help.width(text)
	local ok, width = pcall(utf8.width, text)

	return ok and type(width) == "number" and width or #text
end

--- Slice a text chunk by one-based UTF-8 codepoint indices
-- Falls back to byte slicing when malformed text reaches plain draw code
-- @param text string text chunk
-- @param first number first codepoint index
-- @param last number|nil last codepoint index
-- @return string sliced chunk
function utf8help.sub(text, first, last)
	local length = utf8.len(text)

	if length == nil then
		return text:sub(first, last)
	end

	local start_offset = utf8.offset(text, first)

	if start_offset == nil then
		return ""
	end

	if last == nil or last >= length then
		return text:sub(start_offset)
	end

	local end_offset = utf8.offset(text, last + 1)

	if end_offset == nil then
		return text:sub(start_offset)
	end

	return text:sub(start_offset, end_offset - 1)
end

return utf8help
