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

--- Render parsed RTF nodes as plain text or Markdown
-- Keeps output formatting separate from raw RTF scanning
-- @module plugins.rtfutils.formatter

local parser = require("plugins.rtfutils.parser")

local formatter = {}

local string_byte = string.byte
local string_sub = string.sub
local table_concat = table.concat

--- Escape punctuation that can turn ordinary RTF text into Markdown structure
-- @param text string visible text
-- @return string escaped_text literal Markdown text
local function escape_markdown_text(text)
	text = text:gsub("\\", "\\\\")
	-- Pattern breakdown: capture one Markdown marker so the replacement can prefix it with a backslash
	return (text:gsub("([%[%]`*_<>#!|~])", "\\%1"))
end

--- Encode bytes that can end or split a Markdown link destination
-- @param url string field destination
-- @return string encoded_url safe Markdown destination
local function encode_markdown_url(url)
	local parts = {}

	for index = 1, #url do
		local byte_value = string_byte(url, index)
		local character = string_sub(url, index, index)
		if byte_value <= 32
			or byte_value == 127
			or character == "("
			or character == ")"
			or character == "<"
			or character == ">"
			or character == "\\"
		then
			parts[#parts + 1] = string.format("%%%02X", byte_value)
		else
			parts[#parts + 1] = character
		end
	end

	return table_concat(parts)
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 horizontal whitespace
-- Limits trimming to spaces and tabs so visible paragraph spacing stays intact
-- @param byte_value integer|nil byte to inspect
-- @return boolean true when the byte is a space or tab
local function is_horizontal_whitespace_byte(byte_value)
	return byte_value == 32 or byte_value == 9
end

--- Check whether text contains only spaces or tabs
-- @param text string text to inspect
-- @return boolean true when the text has no visible characters
local function is_horizontal_whitespace_text(text)
	if text == "" then
		return true
	end

	for index = 1, #text do
		if not is_horizontal_whitespace_byte(string_byte(text, index)) then
			return false
		end
	end

	return true
end

--- Split leading and trailing horizontal whitespace from visible inline text
-- Keeps spaces outside Markdown markers so emphasis stays balanced
-- @param text string text to split
-- @return string leading spaces or tabs
-- @return string core visible text
-- @return string trailing spaces or tabs
local function split_horizontal_edges(text)
	local start_index = 1
	local end_index = #text

	while start_index <= end_index and is_horizontal_whitespace_byte(string_byte(text, start_index)) do
		start_index = start_index + 1
	end

	while end_index >= start_index and is_horizontal_whitespace_byte(string_byte(text, end_index)) do
		end_index = end_index - 1
	end

	if start_index > end_index then
		return text, "", ""
	end

	return string_sub(text, 1, start_index - 1), string_sub(text, start_index, end_index), string_sub(text, end_index + 1)
end

--- Trim horizontal whitespace from the start of one text fragment
-- @param text string text to trim
-- @return string text without leading spaces or tabs
local function trim_leading_horizontal_whitespace(text)
	local start_index = 1

	while start_index <= #text and is_horizontal_whitespace_byte(string_byte(text, start_index)) do
		start_index = start_index + 1
	end

	return string_sub(text, start_index)
end

--- Normalize rendered output for reader-style text blocks
-- Collapses repeated spaces in-line while preserving paragraph structure
-- @param text string rendered output before cleanup
-- @return string normalized text
local function normalize_output(text)
	local lines = {}
	local current_line = {}
	local blank_run = 0
	local index = 1
	local text_length = #text
	local pending_space = false

	local function flush_line()
		local line = table_concat(current_line)
		current_line = {}
		pending_space = false

		if line == "" then
			blank_run = blank_run + 1
			if blank_run <= 2 then
				lines[#lines + 1] = ""
			end
			return
		end

		blank_run = 0
		lines[#lines + 1] = line
	end

	while index <= text_length do
		local byte_value = string_byte(text, index)
		local unit_text = string_sub(text, index, index)
		index = index + 1

		if unit_text == "\r" then
			-- Ignores CR so CRLF and LF share one cleanup path
		elseif unit_text == "\n" then
			flush_line()
		elseif unit_text == "\t" then
			current_line[#current_line + 1] = unit_text
			pending_space = false
		elseif byte_value == 32 then
			if #current_line > 0 and not pending_space then
				current_line[#current_line + 1] = " "
				pending_space = true
			end
		else
			current_line[#current_line + 1] = unit_text
			pending_space = false
		end
	end

	flush_line()

	while #lines > 0 and lines[1] == "" do
		table.remove(lines, 1)
	end

	while #lines > 0 and lines[#lines] == "" do
		lines[#lines] = nil
	end

	return table_concat(lines, "\n")
end

--- Wrap one visible text fragment in Markdown emphasis markers
-- Applies underline outside other styles through HTML tags because Markdown has no native underline
-- @param text string visible text without edge whitespace
-- @param node table style source node
-- @return string Markdown-formatted text
local function style_markdown_core(text, node)
	local styled_text = escape_markdown_text(text)

	if node.bold and node.italic then
		styled_text = "***" .. styled_text .. "***"
	elseif node.bold then
		styled_text = "**" .. styled_text .. "**"
	elseif node.italic then
		styled_text = "*" .. styled_text .. "*"
	end

	if node.strike then
		styled_text = "~~" .. styled_text .. "~~"
	end

	if node.underline and not node.link_url then
		styled_text = "<u>" .. styled_text .. "</u>"
	end

	if node.link_url then
		styled_text = "[" .. styled_text .. "](" .. encode_markdown_url(node.link_url) .. ")"
	end

	return styled_text
end

--- Render one parsed text node as Markdown
-- Keeps surrounding indentation outside style markers so list and tab layout survive
-- @param node table parsed text node
-- @return string Markdown fragment
local function render_markdown_text_node(node)
	local leading_text, core_text, trailing_text = split_horizontal_edges(node.text)
	if core_text == "" then
		return node.text
	end

	return leading_text .. style_markdown_core(core_text, node) .. trailing_text
end

--- Concatenate one inline node slice as plain text or Markdown
-- @param nodes table line node slice
-- @param renderer function fragment renderer
-- @param start_index integer first node index
-- @param end_index integer last node index
-- @return string rendered inline text
local function render_inline_nodes(nodes, renderer, start_index, end_index)
	local parts = {}

	for index = start_index, end_index do
		parts[#parts + 1] = renderer(nodes[index])
	end

	return table_concat(parts)
end

--- Split parsed nodes into logical lines with their trailing break kinds
-- @param nodes table parsed node list
-- @return table logical line objects
local function split_lines(nodes)
	local lines = {}
	local current_line = { nodes = {}, break_kind = nil }

	for index = 1, #nodes do
		local node = nodes[index]
		if node.type == "text" then
			current_line.nodes[#current_line.nodes + 1] = node
		else
			current_line.break_kind = node.kind
			lines[#lines + 1] = current_line
			current_line = { nodes = {}, break_kind = nil }
		end
	end

	if #current_line.nodes > 0 or #lines == 0 then
		lines[#lines + 1] = current_line
	end

	return lines
end

--- Map one visible RTF list prefix into a Markdown list marker
-- Supports common bullets and decimal numbering from pntext or listtext destinations
-- @param text string visible list prefix text
-- @return string|nil Markdown marker
local function detect_markdown_list_marker(text)
	if text == "•" or text == "·" or text == "-" or text == "*" then
		return "- "
	end

	local digit_end = 0
	for index = 1, #text do
		local byte_value = string_byte(text, index)
		if not is_ascii_digit(byte_value) then
			break
		end
		digit_end = index
	end

	if digit_end == 0 or digit_end >= #text then
		return nil
	end

	local marker_byte = string_byte(text, digit_end + 1)
	if (marker_byte == 46 or marker_byte == 41) and digit_end + 1 == #text then
		return string_sub(text, 1, digit_end) .. ". "
	end

	return nil
end

--- Detect whether one logical line starts with list prefix nodes
-- Keeps list heuristics local to the Markdown formatter so the parser stays generic
-- @param line_nodes table nodes that belong to one logical line
-- @return table|nil list metadata when the line is a list item
local function detect_markdown_list(line_nodes)
	local index = 1
	local leading_indent_parts = {}

	while index <= #line_nodes do
		local node = line_nodes[index]
		if node.role == "list_prefix" then
			break
		end

		if is_horizontal_whitespace_text(node.text) then
			leading_indent_parts[#leading_indent_parts + 1] = node.text
			index = index + 1
		else
			return nil
		end
	end

	if index > #line_nodes or line_nodes[index].role ~= "list_prefix" then
		return nil
	end

	local prefix_parts = {}
	while index <= #line_nodes and line_nodes[index].role == "list_prefix" do
		prefix_parts[#prefix_parts + 1] = line_nodes[index].text
		index = index + 1
	end

	local prefix_indent, marker_text = split_horizontal_edges(table_concat(prefix_parts))
	local markdown_marker = detect_markdown_list_marker(marker_text)
	if not markdown_marker then
		return nil
	end

	return {
		content_start_index = index,
		indent_text = table_concat(leading_indent_parts) .. prefix_indent,
		marker = markdown_marker,
	}
end

--- Render one logical line as Markdown and detect list items
-- @param line_nodes table nodes that belong to one logical line
-- @return string rendered line text
-- @return boolean true when the line is a Markdown list item
local function render_markdown_line(line_nodes)
	local list_info = detect_markdown_list(line_nodes)
	if list_info then
		local content_text = render_inline_nodes(line_nodes, render_markdown_text_node, list_info.content_start_index, #line_nodes)
		content_text = trim_leading_horizontal_whitespace(content_text)
		return list_info.indent_text .. list_info.marker .. content_text, true
	end

	local rendered = render_inline_nodes(line_nodes, render_markdown_text_node, 1, #line_nodes)
	-- Pattern breakdown: keep indentation, capture a list marker, and require following space
	local indent, marker, rest = rendered:match("^(%s*)([-+])(%s.*)$")
	if marker then
		return indent .. "\\" .. marker .. rest, false
	end

	-- Pattern breakdown: keep an opening number and escape its list-forming period before whitespace
	local numbered, numbered_rest = rendered:match("^(%s*%d+)%.(%s.*)$")
	if numbered then
		return numbered .. "\\." .. numbered_rest, false
	end

	return rendered, false
end

--- Render parsed nodes as readable plain text
-- @param nodes table parsed node list
-- @return string plain text output
function formatter.render_plain_text(nodes)
	local parts = {}

	for index = 1, #nodes do
		local node = nodes[index]
		if node.type == "text" then
			parts[#parts + 1] = node.text
		elseif node.kind == "paragraph" then
			parts[#parts + 1] = "\n\n"
		else
			parts[#parts + 1] = "\n"
		end
	end

	return normalize_output(table_concat(parts))
end

--- Render parsed nodes as lightweight Markdown
-- Keeps body text readable while converting simple inline styles, links, and lists
-- @param nodes table parsed node list
-- @return string Markdown output
function formatter.render_markdown(nodes)
	local lines = split_lines(nodes)
	local parts = {}
	local list_flags = {}

	for index = 1, #lines do
		local rendered_line, is_list_line = render_markdown_line(lines[index].nodes)
		lines[index].rendered_text = rendered_line
		list_flags[index] = is_list_line
	end

	for index = 1, #lines do
		local line = lines[index]
		parts[#parts + 1] = line.rendered_text or ""

		if line.break_kind == "paragraph" then
			if list_flags[index] and list_flags[index + 1] then
				parts[#parts + 1] = "\n"
			else
				parts[#parts + 1] = "\n\n"
			end
		elseif line.break_kind == "line" then
			parts[#parts + 1] = "\n"
		end
	end

	return normalize_output(table_concat(parts))
end

--- Extract readable plain text from raw RTF content
-- @param text string raw RTF content
-- @return string plain text output
function formatter.to_plain_text(text)
	return formatter.render_plain_text(parser.parse_nodes(text))
end

--- Extract lightweight Markdown from raw RTF content
-- @param text string raw RTF content
-- @return string Markdown output
function formatter.to_markdown_text(text)
	return formatter.render_markdown(parser.parse_nodes(text))
end

return formatter
