-- lx-bundle:true

-- entities_decode.lua
-- Full HTML entity decoder (named + numeric)
-- Derived from the original span-based decoder with preserved comments

-- Keep generated helper data on the canonical plugin namespace
local decode_map = require("plugins.htmlentities.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("lib.entities_decode_map returned non-table")
end

local M = {}

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

-- 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
			break
		end
	end

	if digits == 0 then
		return nil, nil
	end
	-- Convert to UTF-8
	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)
end

function M.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
				-- 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 entity path (original behavior)
			local semi = _find(src, ";", amp + 1, true)
			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 M
