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

--- Full named and numeric HTML character-reference decoder
-- @module core.lib.html_decode

local decode_map = require("core.lib.html_decode_map")

local MAP, MAXLEN
if type(decode_map) == "table" and type(decode_map.ENTITIES) == "table" then
	MAP = decode_map.ENTITIES
	MAXLEN = tonumber(decode_map.MAXLEN) or 64
elseif type(decode_map) == "table" then
	MAP = decode_map
	local m = 0
	for k in pairs(MAP) do
		if type(k) == "string" and #k > m then
			m = #k
		end
	end
	MAXLEN = m
else
	error("core.lib.html_decode_map returned a non-table value")
end

local html_decode = {}

-- Localize globals for speed (fewer table lookups)
local _find = string.find
local _sub = string.sub
local _byte = string.byte
local _char = string.char
local _min = math.min
local _utf8_char = (utf8 and utf8.char) or nil

-- HTML keeps these legacy numeric references compatible with Windows-1252
local C1_REPLACEMENTS = {
	[0x80] = 0x20ac,
	[0x82] = 0x201a,
	[0x83] = 0x0192,
	[0x84] = 0x201e,
	[0x85] = 0x2026,
	[0x86] = 0x2020,
	[0x87] = 0x2021,
	[0x88] = 0x02c6,
	[0x89] = 0x2030,
	[0x8a] = 0x0160,
	[0x8b] = 0x2039,
	[0x8c] = 0x0152,
	[0x8e] = 0x017d,
	[0x91] = 0x2018,
	[0x92] = 0x2019,
	[0x93] = 0x201c,
	[0x94] = 0x201d,
	[0x95] = 0x2022,
	[0x96] = 0x2013,
	[0x97] = 0x2014,
	[0x98] = 0x02dc,
	[0x99] = 0x2122,
	[0x9a] = 0x0161,
	[0x9b] = 0x203a,
	[0x9c] = 0x0153,
	[0x9e] = 0x017e,
	[0x9f] = 0x0178,
}

--- Apply HTML's required numeric character-reference replacements
-- @param codepoint number parsed numeric value
-- @return number codepoint valid replacement value
local function normalize_numeric_codepoint(codepoint)
	if codepoint == 0
		or codepoint > 0x10ffff
		or codepoint >= 0xd800 and codepoint <= 0xdfff
	then
		return 0xfffd
	end

	return C1_REPLACEMENTS[codepoint] or codepoint
end

-- Convert Unicode scalar value to UTF-8 string
-- Returns nil if the codepoint is invalid
local function codepoint_to_utf8(cp)
	-- Reject invalid Unicode scalar values (range + surrogate halves)
	if cp < 0 or cp > 0x10FFFF or (cp >= 0xD800 and cp <= 0xDFFF) then
		return nil
	end
	-- Fast path: use C implementation if available
	if _utf8_char then
		local ok, ch = pcall(_utf8_char, cp)
		if ok and type(ch) == "string" then
			return ch
		end
	end
	-- Manual UTF-8 encoding fallback (rarely used)
	if cp <= 0x7F then
		return _char(cp)
	elseif cp <= 0x7FF then
		return _char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40))
	elseif cp <= 0xFFFF then
		return _char(0xE0 + math.floor(cp / 0x1000), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40))
	end
	return _char(
		0xF0 + math.floor(cp / 0x40000),
		0x80 + (math.floor(cp / 0x1000) % 0x40),
		0x80 + (math.floor(cp / 0x40) % 0x40),
		0x80 + (cp % 0x40)
	)
end

-- Attempt to parse numeric entity starting at '&'
-- Supports: &#123; and &#x1F600; (semicolon optional)
-- Returns (replacement, next_index) or (nil, nil)
local function try_numeric_entity(s, amp, n)
	local p = amp + 2 -- position after '&#'
	if p > n then
		return nil, nil
	end
	-- Detect hex prefix '&#x' or '&#X'
	local c = _byte(s, p)
	local is_hex = (c == 120 or c == 88)
	if is_hex then
		p = p + 1
		if p > n then
			return nil, nil
		end
	end

	local max_digits = is_hex and 6 or 10
	local cp = 0
	local digits = 0
	local has_semi = false
	-- Hard bound on scan window (small and constant)
	local pend = _min(n, p + max_digits)

	while p <= pend do
		local ch = _byte(s, p)
		-- Optional semicolon terminator
		if ch == 59 then
			has_semi = true
			break
		end
		if not is_hex then
			-- Decimal digit
			if ch < 48 or ch > 57 then
				break
			end
			cp = cp * 10 + (ch - 48)
		else
			-- Hex digit
			local v
			if ch >= 48 and ch <= 57 then
				v = ch - 48
			elseif ch >= 65 and ch <= 70 then
				v = ch - 55
			elseif ch >= 97 and ch <= 102 then
				v = ch - 87
			else
				break
			end
			cp = cp * 16 + v
		end
		digits = digits + 1
		p = p + 1
		if digits >= max_digits then
			has_semi = _byte(s, p) == 59
			break
		end
	end

	if digits == 0 then
		return nil, nil
	end
	-- Replace invalid and legacy values before UTF-8 conversion
	cp = normalize_numeric_codepoint(cp)
	local rep = codepoint_to_utf8(cp)
	if not rep then
		return nil, nil
	end
	-- Advance index (skip ';' if present)
	return rep, (has_semi and (p + 1) or p), cp
end

--- Decode one complete, semicolon-terminated character reference
-- This strict form is suitable for Markdown and other formats that do not accept legacy omitted semicolons
-- @param text string source text
-- @param position number byte position of the opening ampersand
-- @return string|nil replacement decoded UTF-8 text
-- @return number|nil next_position first byte after the reference
function html_decode.decode_reference(text, position)
	local src = tostring(text or "")
	local amp = tonumber(position) or 1
	local n = #src

	if amp < 1 or amp > n or _byte(src, amp) ~= 38 then
		return nil
	end

	if _byte(src, amp + 1) == 35 then
		local replacement, next_position, codepoint = try_numeric_entity(src, amp, n)

		if replacement and _byte(src, next_position - 1) == 59 then
			return replacement, next_position
		end

		return nil
	end

	local semicolon
	local last_position = _min(n, amp + MAXLEN)

	for current_position = amp + 1, last_position do
		if _byte(src, current_position) == 59 then
			semicolon = current_position
			break
		end
	end

	if not semicolon then
		return nil
	end

	local replacement = MAP[_sub(src, amp + 1, semicolon)]

	if replacement then
		return replacement, semicolon + 1
	end

	return nil
end

--- Decode every supported HTML character reference in text
-- Legacy named and numeric references without semicolons remain supported for the editor command
-- @param text any source value
-- @return string decoded_text
function html_decode.decode(text)
	local src = tostring(text or "")
	local n = #src
	local out = {}
	local outn = 0
	local i = 1 -- current scan position
	local run_start = 1 -- start of untouched span

	while true do
		-- Find next '&' (plain search)
		local amp = _find(src, "&", i, true)
		if not amp then
			break
		end
		-- Flush text before '&'
		if amp > run_start then
			outn = outn + 1
			out[outn] = _sub(src, run_start, amp - 1)
		end

		local after = amp + 1
		local b1 = (after <= n) and _byte(src, after) or nil
		if b1 == 35 then
			-- Numeric entity fast path (only when seeing '&#')
			local rep, next_i = try_numeric_entity(src, amp, n)
			if rep then
				---@cast next_i integer
				-- Successful numeric decode
				outn = outn + 1
				out[outn] = rep
				i = next_i
				run_start = i
			else
				-- Not a valid entity -> keep literal '&'
				outn = outn + 1
				out[outn] = "&"
				i = amp + 1
				run_start = i
			end
		else
			-- Named references use the generated map's bounded maximum length
			local semi
			local last_named_position = _min(n, amp + MAXLEN)
			-- Entity names stop before punctuation or whitespace from later text
			for current_position = amp + 1, last_named_position do
				local current_byte = _byte(src, current_position)
				if current_byte == 59 then
					semi = current_position
					break
				elseif not ((current_byte >= 48 and current_byte <= 57)
					or (current_byte >= 65 and current_byte <= 90)
					or (current_byte >= 97 and current_byte <= 122))
				then
					break
				end
			end

			if semi then
				-- Fast semicolon form
				local key = _sub(src, amp + 1, semi)
				local rep = MAP[key]
				if rep then
					outn = outn + 1
					out[outn] = rep
					i = semi + 1
					run_start = i
				else
					outn = outn + 1
					out[outn] = "&"
					i = amp + 1
					run_start = i
				end
			else
				-- Legacy no-semicolon bounded longest match
				local best_j, best_rep = nil, nil
				local jmax = _min(n, amp + MAXLEN)
				for j = jmax, amp + 1, -1 do
					local rep = MAP[_sub(src, amp + 1, j)]
					if rep then
						best_j = j
						best_rep = rep
						break
					end
				end
				if best_rep then
					outn = outn + 1
					out[outn] = best_rep
					i = best_j + 1
					run_start = i
				else
					outn = outn + 1
					out[outn] = "&"
					i = amp + 1
					run_start = i
				end
			end
		end
	end

	-- Flush remaining tail
	if run_start <= n then
		outn = outn + 1
		out[outn] = _sub(src, run_start)
	end

	return table.concat(out), nil
end

return html_decode
