-- lx-bundle:true

--- Parse raw RTF into reusable structure nodes
-- Keeps RTF scanning and destination handling separate from output formatting
-- @module plugins.rtfutils.parser

local codepage = require("core.text.codepage")

local parser = {}

local string_byte = string.byte
local string_char = string.char
local string_sub = string.sub
local table_concat = table.concat
local utf8_char = utf8.char

local default_codepage_name = "cp1252"

-- Maps charset controls to the closest shipped Lx codepage tables
local charset_codepages = {
	ansi = "cp1252",
	pc = "cp437",
	pca = "cp850",
}

-- Converts direct RTF symbol words into visible Unicode text
local symbol_word_text = {
	bullet = "•",
	emdash = "—",
	endash = "–",
	ldblquote = "“",
	lquote = "‘",
	rdblquote = "”",
	rquote = "’",
}

-- Destinations that should never contribute visible reader text
local skipped_destinations = {
	colortbl = true,
	datastore = true,
	filetbl = true,
	fonttbl = true,
	footer = true,
	footerf = true,
	footerl = true,
	footerr = true,
	generator = true,
	header = true,
	headerf = true,
	headerl = true,
	headerr = true,
	info = true,
	listoverridetable = true,
	listtable = true,
	objdata = true,
	object = true,
	pict = true,
	revtbl = true,
	stylesheet = true,
	themedata = true,
	xmlattrname = true,
	xmlattrvalue = true,
	xmlopen = true,
}

-- Names that matter when a group opens with a destination control
local known_destinations = {
	colortbl = true,
	datastore = true,
	field = true,
	filetbl = true,
	fldinst = true,
	fldrslt = true,
	fonttbl = true,
	footer = true,
	footerf = true,
	footerl = true,
	footerr = true,
	generator = true,
	header = true,
	headerf = true,
	headerl = true,
	headerr = true,
	info = true,
	listoverridetable = true,
	listtable = true,
	listtext = true,
	objdata = true,
	object = true,
	pict = true,
	pntext = true,
	revtbl = true,
	stylesheet = true,
	themedata = true,
	xmlattrname = true,
	xmlattrvalue = true,
	xmlopen = true,
}

-- Structural controls map to visible output structure
local structural_controls = {
	cell = { kind = "text", text = "\t" },
	line = { kind = "break", break_kind = "line" },
	nestcell = { kind = "text", text = "\t" },
	nestrow = { kind = "break", break_kind = "line" },
	page = { kind = "break", break_kind = "paragraph" },
	par = { kind = "break", break_kind = "paragraph" },
	row = { kind = "break", break_kind = "line" },
	tab = { kind = "text", text = "\t" },
}

local codepage_cache = {}

--- Check whether one byte is an ASCII letter
-- @param byte_value integer|nil byte to inspect
-- @return boolean true when the byte is A-Z or a-z
local function is_ascii_letter(byte_value)
	if not byte_value then
		return false
	end

	return (byte_value >= 65 and byte_value <= 90) or (byte_value >= 97 and byte_value <= 122)
end

--- Check whether one byte is an ASCII digit
-- @param byte_value integer|nil byte to inspect
-- @return boolean true when the byte is 0-9
local function is_ascii_digit(byte_value)
	if not byte_value then
		return false
	end

	return byte_value >= 48 and byte_value <= 57
end

--- Check whether one byte is an ASCII hex digit
-- @param byte_value integer|nil byte to inspect
-- @return boolean true when the byte is 0-9, A-F, or a-f
local function is_ascii_hex_digit(byte_value)
	if not byte_value then
		return false
	end

	return
		(byte_value >= 48 and byte_value <= 57)
		or (byte_value >= 65 and byte_value <= 70)
		or (byte_value >= 97 and byte_value <= 102)
end

--- Convert one ASCII hex byte into its numeric nibble value
-- @param byte_value integer ASCII hex byte
-- @return integer nibble value from 0 to 15
local function hex_nibble(byte_value)
	if byte_value <= 57 then
		return byte_value - 48
	end

	if byte_value <= 70 then
		return byte_value - 55
	end

	return byte_value - 87
end

--- Detect the byte length of one UTF-8 sequence at a raw byte offset
-- Keeps already-decoded editor text intact while still letting the parser scan by byte
-- @param text string source text
-- @param index integer 1-based byte offset
-- @param text_length integer cached source length
-- @return integer byte length to consume
local function utf8_sequence_length(text, index, text_length)
	local first_byte = string_byte(text, index)
	if not first_byte or first_byte < 0x80 then
		return 1
	end

	local second_byte = (index + 1 <= text_length) and string_byte(text, index + 1) or nil

	if first_byte >= 0xC2 and first_byte <= 0xDF then
		if second_byte and second_byte >= 0x80 and second_byte <= 0xBF then
			return 2
		end

		return 1
	end

	local third_byte = (index + 2 <= text_length) and string_byte(text, index + 2) or nil

	if first_byte >= 0xE0 and first_byte <= 0xEF then
		if not (second_byte and third_byte) then
			return 1
		end

		if first_byte == 0xE0 then
			if second_byte < 0xA0 or second_byte > 0xBF then
				return 1
			end
		elseif first_byte == 0xED then
			if second_byte < 0x80 or second_byte > 0x9F then
				return 1
			end
		elseif second_byte < 0x80 or second_byte > 0xBF then
			return 1
		end

		if third_byte < 0x80 or third_byte > 0xBF then
			return 1
		end

		return 3
	end

	local fourth_byte = (index + 3 <= text_length) and string_byte(text, index + 3) or nil

	if first_byte >= 0xF0 and first_byte <= 0xF4 then
		if not (second_byte and third_byte and fourth_byte) then
			return 1
		end

		if first_byte == 0xF0 then
			if second_byte < 0x90 or second_byte > 0xBF then
				return 1
			end
		elseif first_byte == 0xF4 then
			if second_byte < 0x80 or second_byte > 0x8F then
				return 1
			end
		elseif second_byte < 0x80 or second_byte > 0xBF then
			return 1
		end

		if third_byte < 0x80 or third_byte > 0xBF or fourth_byte < 0x80 or fourth_byte > 0xBF then
			return 1
		end

		return 4
	end

	return 1
end

--- Safely encode one Unicode codepoint as UTF-8
-- Uses the replacement character when the RTF value is invalid for UTF-8 emission
-- @param codepoint integer Unicode scalar value
-- @return string UTF-8 text for the codepoint
local function safe_utf8_char(codepoint)
	local ok, result = pcall(utf8_char, codepoint)
	if ok and result then
		return result
	end

	return utf8_char(0xFFFD)
end

--- Load one Lx codepage table with memoization
-- @param name string|nil codepage name
-- @return table|nil codepage mapping table
local function resolve_codepage(name)
	local resolved_name = name or default_codepage_name
	local cached = codepage_cache[resolved_name]
	if cached == false then
		return nil
	end

	if cached then
		return cached
	end

	local map = codepage.resolve(resolved_name)
	if type(map) == "table" then
		codepage_cache[resolved_name] = map
		return map
	end

	codepage_cache[resolved_name] = false
	return nil
end

--- Decode one escaped RTF byte through the active codepage
-- @param byte_value integer escaped byte value
-- @param codepage_name string|nil active codepage name
-- @return string decoded text
local function decode_escaped_byte(byte_value, codepage_name)
	local map = resolve_codepage(codepage_name) or resolve_codepage(default_codepage_name)
	if map and type(map[byte_value]) == "string" and map[byte_value] ~= "" then
		return map[byte_value]
	end

	return string_char(byte_value)
end

--- Extract a hyperlink URL from one field instruction
-- Supports the common form HYPERLINK "https://example"
-- @param field_context table parsed field context
-- @return string|nil url
local function resolve_field_link_url(field_context)
	if not field_context then
		return nil
	end

	if field_context.link_url_resolved == true then
		return field_context.link_url
	end

	field_context.link_url_resolved = true

	local instruction = table_concat(field_context.instruction_parts or {})
	local upper_instruction = instruction:upper()
	local keyword_index = upper_instruction:find("HYPERLINK", 1, true)
	if not keyword_index then
		return nil
	end

	local index = keyword_index + #"HYPERLINK"
	local instruction_length = #instruction

	while index <= instruction_length do
		local ch = instruction:sub(index, index)
		if ch ~= " " and ch ~= "\t" and ch ~= "\r" and ch ~= "\n" then
			break
		end
		index = index + 1
	end

	if instruction:sub(index, index) == '"' then
		local start_index = index + 1
		local end_index = start_index
		while end_index <= instruction_length do
			if instruction:sub(end_index, end_index) == '"' then
				field_context.link_url = instruction:sub(start_index, end_index - 1)
				return field_context.link_url
			end
			end_index = end_index + 1
		end
	end

	local start_index = index
	local end_index = start_index
	while end_index <= instruction_length do
		local ch = instruction:sub(end_index, end_index)
		if ch == " " or ch == "\t" or ch == "\r" or ch == "\n" then
			break
		end
		end_index = end_index + 1
	end

	if end_index > start_index then
		field_context.link_url = instruction:sub(start_index, end_index - 1)
		return field_context.link_url
	end

	return nil
end

--- Copy one group state for a nested RTF group
-- @param parent table current parent state
-- @return table child state
local function clone_group_state(parent)
	return {
		bold = parent.bold,
		codepage_name = parent.codepage_name,
		destination_name = nil,
		destination_pending = true,
		field_context = parent.field_context,
		field_mode = parent.field_mode,
		ignorable_destination = false,
		italic = parent.italic,
		pending_star = false,
		skip_destination = parent.skip_destination,
		strike = parent.strike,
		uc_skip = parent.uc_skip,
		underline = parent.underline,
	}
end

--- Push a nested group state onto the stack
-- @param stack table state stack
-- @return table current top state
local function push_group_state(stack)
	local child = clone_group_state(stack[#stack])
	stack[#stack + 1] = child
	return child
end

--- Pop one group state while clamping root underflow safely
-- @param stack table state stack
-- @return table current top state
local function pop_group_state(stack)
	if #stack > 1 then
		stack[#stack] = nil
	end

	return stack[#stack]
end

--- Parse one RTF control word or control symbol
-- @param text string source text
-- @param index integer byte offset immediately after the backslash
-- @param text_length integer cached source length
-- @return table parsed token
-- @return integer next unread byte offset
local function parse_control(text, index, text_length)
	local first_byte = string_byte(text, index)
	if not first_byte then
		return { kind = "empty" }, index
	end

	if is_ascii_letter(first_byte) then
		local word_start = index
		index = index + 1

		while index <= text_length and is_ascii_letter(string_byte(text, index)) do
			index = index + 1
		end

		local word = string_sub(text, word_start, index - 1)
		local param = nil
		local sign = 1
		local sign_index = index
		local sign_byte = string_byte(text, index)

		if sign_byte == 45 then
			sign = -1
			index = index + 1
		elseif sign_byte == 43 then
			index = index + 1
		end

		local digit_start = index
		while index <= text_length and is_ascii_digit(string_byte(text, index)) do
			index = index + 1
		end

		if index > digit_start then
			param = tonumber(string_sub(text, digit_start, index - 1))
			if param then
				param = param * sign
			end
		else
			index = sign_index
		end

		local delimited = string_byte(text, index) == 32
		if delimited then
			index = index + 1
		end

		return {
			delimited = delimited,
			kind = "word",
			param = param,
			word = word,
		}, index
	end

	local symbol = string_char(first_byte)
	index = index + 1

	if symbol == "'" then
		local hi = string_byte(text, index)
		local lo = string_byte(text, index + 1)
		if is_ascii_hex_digit(hi) and is_ascii_hex_digit(lo) then
			local value = (hex_nibble(hi) * 16) + hex_nibble(lo)
			return {
				hex_value = value,
				kind = "symbol",
				symbol = symbol,
			}, index + 2
		end
	end

	return {
		kind = "symbol",
		symbol = symbol,
	}, index
end

--- Mark group-start destination metadata from the first control word in a group
-- @param state table current group state
-- @param word string control word
local function resolve_group_destination(state, word)
	if not state.destination_pending then
		return
	end

	if state.pending_star then
		state.ignorable_destination = true
		state.destination_name = word

		if not known_destinations[word] then
			state.skip_destination = true
		end
	elseif known_destinations[word] then
		state.destination_name = word
	end

	if skipped_destinations[word] then
		state.skip_destination = true
	elseif word == "field" then
		state.field_context = {
			instruction_parts = {},
			link_url = nil,
			link_url_resolved = false,
		}
		state.field_mode = "none"
	elseif word == "fldinst" then
		state.field_mode = "instruction"
		state.skip_destination = true
	elseif word == "fldrslt" then
		state.field_mode = "result"
	end

	state.destination_pending = false
	state.pending_star = false
end

--- Consume one plain-text unit from the source stream
-- Preserves already-decoded UTF-8 text and hides source layout newlines
-- @param text string source text
-- @param index integer current byte offset
-- @param text_length integer cached source length
-- @return string consumed text unit
-- @return integer next unread byte offset
-- @return boolean true when the unit is source layout newline that should be ignored
local function read_plain_unit(text, index, text_length)
	local unit_length = utf8_sequence_length(text, index, text_length)
	local unit_text = string_sub(text, index, index + unit_length - 1)

	return unit_text, index + unit_length, unit_text == "\r" or unit_text == "\n"
end

--- Append one styled text node and merge with adjacent compatible text
-- @param nodes table parsed node list
-- @param fragment string text to append
-- @param state table current group state
local function push_text(nodes, fragment, state)
	if fragment == "" then
		return
	end

	local role = "text"
	if state.destination_name == "pntext" or state.destination_name == "listtext" then
		role = "list_prefix"
	end

	local link_url = nil
	if state.field_mode == "result" and state.field_context then
		link_url = resolve_field_link_url(state.field_context)
	end

	local last = nodes[#nodes]
	if
		last
		and last.type == "text"
		and last.bold == state.bold
		and last.italic == state.italic
		and last.underline == state.underline
		and last.strike == state.strike
		and last.link_url == link_url
		and last.role == role
	then
		last.text = last.text .. fragment
		return
	end

	nodes[#nodes + 1] = {
		type = "text",
		role = role,
		text = fragment,
		bold = state.bold == true,
		italic = state.italic == true,
		underline = state.underline == true,
		strike = state.strike == true,
		link_url = link_url,
	}
end

--- Append one structural break node with light deduplication
-- Paragraph breaks absorb adjacent line breaks so renderers stay simpler
-- @param nodes table parsed node list
-- @param break_kind string line|paragraph
local function push_break(nodes, break_kind)
	local last = nodes[#nodes]
	if not last then
		nodes[#nodes + 1] = { type = "break", kind = break_kind }
		return
	end

	if last.type ~= "break" then
		nodes[#nodes + 1] = { type = "break", kind = break_kind }
		return
	end

	if last.kind == "paragraph" then
		return
	end

	if break_kind == "paragraph" then
		last.kind = "paragraph"
		return
	end

	nodes[#nodes + 1] = { type = "break", kind = break_kind }
end

--- Build a flat node stream from raw RTF input
-- The node stream keeps basic structure, simple inline styles, and field links
-- @param text string raw RTF content
-- @return table nodes
function parser.parse_nodes(text)
	local input = tostring(text or "")
	local text_length = #input
	local nodes = {}
	local pending_unicode_skip = 0
	local state_stack = {
		{
			bold = false,
			codepage_name = default_codepage_name,
			destination_name = nil,
			destination_pending = false,
			field_context = nil,
			field_mode = "none",
			ignorable_destination = false,
			italic = false,
			pending_star = false,
			skip_destination = false,
			strike = false,
			uc_skip = 1,
			underline = false,
		},
	}

	--- Append one text fragment while honoring pending Unicode fallback skipping
	-- Instruction groups still collect their hidden field text even when not visible
	-- @param fragment string text to append
	local function append_text(fragment)
		local state = state_stack[#state_stack]

		if pending_unicode_skip > 0 then
			pending_unicode_skip = pending_unicode_skip - 1
			return
		end

		if state.field_mode == "instruction" and state.field_context then
			state.field_context.instruction_parts[#state.field_context.instruction_parts + 1] = fragment
			return
		end

		if state.skip_destination then
			return
		end

		push_text(nodes, fragment, state)
	end

	--- Append one visible structure marker when the current state is visible
	-- @param break_kind string line|paragraph
	local function append_break(break_kind)
		local state = state_stack[#state_stack]
		if state.field_mode == "instruction" or state.skip_destination then
			return
		end

		push_break(nodes, break_kind)
	end

	--- Handle one parsed control token and return the next scan offset
	-- @param token table parsed token
	-- @param next_index integer next unread byte offset after the token
	-- @return integer next unread byte offset after control side effects
	local function handle_control(token, next_index)
		local state = state_stack[#state_stack]

		if token.kind == "empty" then
			state.destination_pending = false
			state.pending_star = false
			return next_index
		end

		if token.kind == "symbol" then
			if token.symbol == "*" and state.destination_pending then
				state.pending_star = true
				return next_index
			end

			state.destination_pending = false
			state.pending_star = false

			if token.symbol == "\\" then
				append_text("\\")
			elseif token.symbol == "{" then
				append_text("{")
			elseif token.symbol == "}" then
				append_text("}")
			elseif token.symbol == "\r" or token.symbol == "\n" then
				append_break("line")

				-- Keep CRLF emitted through one control-symbol path
				if token.symbol == "\r" and string_byte(input, next_index) == 10 then
					return next_index + 1
				end
			elseif token.symbol == "~" then
				append_text(" ")
			elseif token.symbol == "_" then
				append_text("-")
			elseif token.symbol == "-" then
				if pending_unicode_skip > 0 then
					pending_unicode_skip = pending_unicode_skip - 1
				end
			elseif token.symbol == "'" and token.hex_value ~= nil then
				append_text(decode_escaped_byte(token.hex_value, state.codepage_name))
			end

			return next_index
		end

		resolve_group_destination(state, token.word)

		local structural = structural_controls[token.word]
		if structural then
			if structural.kind == "text" then
				append_text(structural.text)
			else
				append_break(structural.break_kind)
			end
			return next_index
		end

		local symbol_text = symbol_word_text[token.word]
		if symbol_text then
			append_text(symbol_text)
			return next_index
		end

		if token.word == "u" and token.param ~= nil then
			local codepoint = token.param
			if codepoint < 0 then
				codepoint = codepoint + 65536
			end

			if not state.skip_destination then
				append_text(safe_utf8_char(codepoint))
				pending_unicode_skip = math.max(state.uc_skip or 1, 0)
			else
				pending_unicode_skip = 0
			end
			return next_index
		end

		if token.word == "uc" and token.param ~= nil then
			state.uc_skip = math.max(token.param, 0)
			return next_index
		end

		if token.word == "ansi" or token.word == "pc" or token.word == "pca" then
			state.codepage_name = charset_codepages[token.word] or default_codepage_name
			return next_index
		end

		if token.word == "mac" then
			-- Falls back to the default table because Lx does not ship MacRoman today
			state.codepage_name = default_codepage_name
			return next_index
		end

		if token.word == "ansicpg" and token.param ~= nil then
			local candidate = "cp" .. tostring(token.param)
			if resolve_codepage(candidate) then
				state.codepage_name = candidate
			else
				state.codepage_name = default_codepage_name
			end
			return next_index
		end

		if token.word == "b" then
			state.bold = token.param ~= 0
			return next_index
		end

		if token.word == "i" then
			state.italic = token.param ~= 0
			return next_index
		end

		if token.word == "ul" then
			state.underline = token.param ~= 0
			return next_index
		end

		if token.word == "ulnone" then
			state.underline = false
			return next_index
		end

		if token.word == "strike" then
			state.strike = token.param ~= 0
			return next_index
		end

		if token.word == "plain" then
			state.bold = false
			state.italic = false
			state.underline = false
			state.strike = false
			return next_index
		end

		if token.word == "bin" and token.param and token.param > 0 then
			return math.min(text_length + 1, next_index + token.param)
		end

		return next_index
	end

	local index = 1

	while index <= text_length do
		local state = state_stack[#state_stack]
		local byte_value = string_byte(input, index)

		if byte_value == 123 then
			push_group_state(state_stack)
			index = index + 1
		elseif byte_value == 125 then
			pop_group_state(state_stack)
			index = index + 1
		elseif byte_value == 92 then
			local token
			token, index = parse_control(input, index + 1, text_length)
			index = handle_control(token, index)
		else
			local unit_text
			local next_index
			local is_layout_newline
			unit_text, next_index, is_layout_newline = read_plain_unit(input, index, text_length)

			state.destination_pending = false
			state.pending_star = false

			if not is_layout_newline then
				append_text(unit_text)
			end

			index = next_index
		end
	end

	return nodes
end

return parser
