-- lx-bundle:true

--- Lua DOCX text/Markdown extractor.
-- Ports the useful reader path from PHPWord's Word2007 reader into a small
-- read-only extractor for Lx.
-- @module plugins.docxutils.reader

local native_text = require("text") -- Split large extracted documents without a Lua pattern loop

local reader = {}

local function attr(text, name)
	local escaped = name:gsub(":", "%%:")
	return text:match(escaped .. '%s*=%s*"([^"]*)"') or text:match(escaped .. "%s*=%s*'([^']*)'")
end

local function tag_local_name(tag_text)
	local name = tag_text:match("^%s*/?%s*([%w_:%-]+)")
	if not name then
		return nil
	end
	return name:match(":([^:]+)$") or name
end

--- Decode one numeric XML character reference
-- @param digits string captured hexadecimal or decimal digits
-- @param base integer numeric base used by the reference
-- @param reference string original XML reference for error reporting
-- @return string UTF-8 character
local function decode_xml_character(digits, base, reference)
	local value = tonumber(digits, base)
	local valid = value == 0x09
		or value == 0x0A
		or value == 0x0D
		or (value and value >= 0x20 and value <= 0xD7FF)
		or (value and value >= 0xE000 and value <= 0xFFFD)
		or (value and value >= 0x10000 and value <= 0x10FFFF)
	if not valid then
		error("invalid XML character reference '" .. reference .. "'", 0)
	end

	return utf8.char(value)
end

--- Decode predefined and numeric XML entities in text
-- @param text string|nil encoded XML text
-- @return string decoded text
local function xml_unescape(text)
	text = tostring(text or "")
	text = text:gsub("&lt;", "<")
	text = text:gsub("&gt;", ">")
	text = text:gsub("&quot;", '"')
	text = text:gsub("&apos;", "'")
	text = text:gsub("&amp;", "&")
	-- Pattern breakdown: capture hexadecimal digits between the numeric XML prefix and semicolon
	text = text:gsub("&#x([%da-fA-F]+);", function(hex)
		return decode_xml_character(hex, 16, "&#x" .. hex .. ";")
	end)
	-- Pattern breakdown: capture decimal digits between the numeric XML prefix and semicolon
	text = text:gsub("&#(%d+);", function(decimal)
		return decode_xml_character(decimal, 10, "&#" .. decimal .. ";")
	end)
	return text
end

local function markdown_escape(text)
	text = tostring(text or "")
	text = text:gsub("\\", "\\\\")
	text = text:gsub("([%[%]%(%)`*_{}#!|>])", "\\%1")
	return text
end

--- Clean extracted DOCX text while keeping one intentional blank line
-- @param text string extracted document text
-- @return string normalized_text cleaned text
local function normalize_text(text)
	local lines = {}
	local blank_run = 0

	text = tostring(text or ""):gsub("\r", "")

	local source_lines = native_text.split_lines(text, { keep_eol = false })

	for index = 1, #source_lines do
		local line = source_lines[index]
		local trimmed_line = line:gsub("[ \t]+$", "")
		if trimmed_line == "" then
			blank_run = blank_run + 1
			if blank_run <= 1 then
				lines[#lines + 1] = ""
			end
		else
			blank_run = 0
			lines[#lines + 1] = trimmed_line
		end
	end

	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

local function parse_relationships(xml)
	local rels = {}

	if type(xml) ~= "string" then
		return rels
	end

	for tag in xml:gmatch("<Relationship%s+([^>]*)/?>") do
		local id = attr(tag, "Id")
		if id then
			rels[id] = {
				type = attr(tag, "Type"),
				target = attr(tag, "Target"),
				target_mode = attr(tag, "TargetMode"),
			}
		end
	end

	return rels
end

local function parse_styles(xml)
	local styles = {}

	if type(xml) ~= "string" then
		return styles
	end

	for block in xml:gmatch("<w:style%s+.-</w:style>") do
		local open_tag = block:match("<w:style%s+([^>]*)>")
		local style_id = open_tag and attr(open_tag, "w:styleId")
		local name_tag = block:match("<w:name%s+([^>]*)/?>")
		local name = name_tag and attr(name_tag, "w:val")

		if style_id and name then
			styles[style_id] = name
		end
	end

	return styles
end

local function parse_numbering(xml)
	local abstract_formats = {}
	local num_to_abstract = {}

	if type(xml) ~= "string" then
		return {}
	end

	for block in xml:gmatch("<w:abstractNum%s+.-</w:abstractNum>") do
		local open_tag = block:match("<w:abstractNum%s+([^>]*)>")
		local abstract_id = open_tag and attr(open_tag, "w:abstractNumId")

		if abstract_id then
			abstract_formats[abstract_id] = abstract_formats[abstract_id] or {}
			for level_block in block:gmatch("<w:lvl%s+.-</w:lvl>") do
				local level_tag = level_block:match("<w:lvl%s+([^>]*)>")
				local ilvl = level_tag and attr(level_tag, "w:ilvl")
				local fmt_tag = level_block:match("<w:numFmt%s+([^>]*)/?>")
				local fmt = fmt_tag and attr(fmt_tag, "w:val")

				if ilvl and fmt then
					abstract_formats[abstract_id][ilvl] = fmt
				end
			end
		end
	end

	for block in xml:gmatch("<w:num%s+.-</w:num>") do
		local open_tag = block:match("<w:num%s+([^>]*)>")
		local num_id = open_tag and attr(open_tag, "w:numId")
		local abstract_tag = block:match("<w:abstractNumId%s+([^>]*)/?>")
		local abstract_id = abstract_tag and attr(abstract_tag, "w:val")

		if num_id and abstract_id then
			num_to_abstract[num_id] = abstract_id
		end
	end

	local numbering = {}
	for num_id, abstract_id in pairs(num_to_abstract) do
		numbering[num_id] = abstract_formats[abstract_id] or {}
	end

	return numbering
end

local function find_body(xml)
	return xml:match("<w:body[^>]*>(.*)</w:body>") or xml
end

local function find_balanced_block(xml, start_pos)
	local tag_start, tag_end, tag = xml:find("<([^>]+)>", start_pos)
	if tag_start ~= start_pos or not tag_end then
		return nil
	end

	local name = tag_local_name(tag)
	if not name then
		return nil
	end

	if tag:sub(-1) == "/" then
		return xml:sub(tag_start, tag_end), tag_end + 1, name
	end

	local depth = 1
	local pos = tag_end + 1

	while true do
		local next_start, next_end, next_tag = xml:find("<([^>]+)>", pos)
		if not next_start then
			return nil
		end

		local next_name = tag_local_name(next_tag)
		if next_name == name then
			if next_tag:match("^%s*/") then
				depth = depth - 1
				if depth == 0 then
					return xml:sub(tag_start, next_end), next_end + 1, name
				end
			elseif next_tag:sub(-1) ~= "/" and not next_tag:match("^%s*[?!]") then
				depth = depth + 1
			end
		end

		pos = next_end + 1
	end
end

local function iter_body_blocks(body)
	local blocks = {}
	local pos = 1

	while pos <= #body do
		local p_start = body:find("<w:p[%s>]", pos)
		local t_start = body:find("<w:tbl[%s>]", pos)
		local start_pos

		if p_start and t_start then
			start_pos = math.min(p_start, t_start)
		else
			start_pos = p_start or t_start
		end

		if not start_pos then
			break
		end

		local block, next_pos, name = find_balanced_block(body, start_pos)
		if not block then
			break
		end
		---@cast next_pos integer

		blocks[#blocks + 1] = {
			kind = name == "tbl" and "table" or "paragraph",
			xml = block,
		}
		pos = next_pos
	end

	return blocks
end

local function inline_chunks(fragment, rels)
	local chunks = {}
	local hyperlink_stack = {}
	local text_capture = false
	local pos = 1

	local function current_link()
		return hyperlink_stack[#hyperlink_stack]
	end

	local function push_text(text)
		if text == "" then
			return
		end
		chunks[#chunks + 1] = {
			text = xml_unescape(text),
			link = current_link(),
		}
	end

	while pos <= #fragment do
		local tag_start, tag_end, tag = fragment:find("<([^>]+)>", pos)
		if not tag_start then
			if text_capture then
				push_text(fragment:sub(pos))
			end
			break
		end

		if text_capture and tag_start > pos then
			push_text(fragment:sub(pos, tag_start - 1))
		end

		local closing = tag:match("^%s*/") ~= nil
		local self_closing = tag:sub(-1) == "/"
		local name = tag_local_name(tag)

		if name == "hyperlink" then
			if closing then
				hyperlink_stack[#hyperlink_stack] = nil
			else
				local rid = attr(tag, "r:id")
				local rel = rid and rels[rid]
				hyperlink_stack[#hyperlink_stack + 1] = rel and rel.target or nil
			end
		elseif name == "t" or name == "delText" then
			text_capture = not closing and not self_closing
		elseif name == "tab" and self_closing then
			chunks[#chunks + 1] = { text = "\t", link = current_link() }
		elseif name == "br" and self_closing then
			chunks[#chunks + 1] = { text = "\n", link = current_link() }
		end

		pos = tag_end + 1
	end

	return chunks
end

local function chunks_to_text(chunks, mode)
	if mode ~= "markdown" then
		local out = {}
		for _, chunk in ipairs(chunks) do
			out[#out + 1] = chunk.text
		end
		return table.concat(out)
	end

	local out = {}
	local index = 1

	while index <= #chunks do
		local link = chunks[index].link
		local text = chunks[index].text
		index = index + 1

		while index <= #chunks and chunks[index].link == link do
			text = text .. chunks[index].text
			index = index + 1
		end

		if link and link ~= "" and text ~= "" then
			out[#out + 1] = "[" .. markdown_escape(text) .. "](" .. link:gsub("%)", "%%29") .. ")"
		else
			out[#out + 1] = markdown_escape(text)
		end
	end

	return table.concat(out)
end

local function paragraph_meta(paragraph_xml, styles, numbering)
	local ppr = paragraph_xml:match("<w:pPr[^>]*>.-</w:pPr>") or ""
	local style_tag = ppr:match("<w:pStyle%s+([^>]*)/?>")
	local style_id = style_tag and attr(style_tag, "w:val")
	local style_name = style_id and (styles[style_id] or style_id) or nil
	local heading_level = nil

	if style_name then
		local normalized = style_name:gsub("%s+", "")
		heading_level = tonumber(normalized:match("[Hh]eading(%d+)"))
		if normalized == "Title" then
			heading_level = 1
		end
	end

	local num_id_tag = ppr:match("<w:numId%s+([^>]*)/?>")
	local ilvl_tag = ppr:match("<w:ilvl%s+([^>]*)/?>")
	local num_id = num_id_tag and attr(num_id_tag, "w:val")
	local ilvl = ilvl_tag and attr(ilvl_tag, "w:val") or "0"
	local list_format = num_id and numbering[num_id] and numbering[num_id][ilvl] or nil

	return {
		style_id = style_id,
		style_name = style_name,
		heading_level = heading_level,
		num_id = num_id,
		ilvl = tonumber(ilvl) or 0,
		list_format = list_format,
	}
end

local function render_paragraph(paragraph_xml, context, mode)
	local chunks = inline_chunks(paragraph_xml, context.rels)
	local text = chunks_to_text(chunks, mode):gsub("^[\r\n]+", ""):gsub("[ \t\r\n]+$", "")
	local meta = paragraph_meta(paragraph_xml, context.styles, context.numbering)

	if text == "" then
		return ""
	end

	if mode == "markdown" then
		if meta.heading_level then
			local level = math.max(1, math.min(6, meta.heading_level))
			return string.rep("#", level) .. " " .. text
		end

		if meta.num_id then
			local indent = string.rep("  ", math.max(0, meta.ilvl))
			local marker = (meta.list_format == "decimal" or meta.list_format == "decimalZero") and "1. " or "- "
			return indent .. marker .. text
		end
	end

	return text
end

local function cell_text(cell_xml, context, mode)
	local body = {}

	for _, block in ipairs(iter_body_blocks(cell_xml)) do
		if block.kind == "paragraph" then
			local text = render_paragraph(block.xml, context, mode)
			if text ~= "" then
				body[#body + 1] = text
			end
		end
	end

	return table.concat(body, " "):gsub("|", "\\|")
end

local function render_table(table_xml, context, mode)
	local rows = {}

	for row_xml in table_xml:gmatch("<w:tr[%s>].-</w:tr>") do
		local row = {}
		for cell_xml in row_xml:gmatch("<w:tc[%s>].-</w:tc>") do
			row[#row + 1] = cell_text(cell_xml, context, mode)
		end
		if #row > 0 then
			rows[#rows + 1] = row
		end
	end

	if #rows == 0 then
		return ""
	end

	if mode == "markdown" then
		local out = {}
		local columns = 0
		for _, row in ipairs(rows) do
			columns = math.max(columns, #row)
		end

		for _, row in ipairs(rows) do
			while #row < columns do
				row[#row + 1] = ""
			end
		end

		out[#out + 1] = "| " .. table.concat(rows[1], " | ") .. " |"
		local separator = {}
		for _ = 1, columns do separator[#separator + 1] = "---" end
		out[#out + 1] = "| " .. table.concat(separator, " | ") .. " |"

		for index = 2, #rows do
			out[#out + 1] = "| " .. table.concat(rows[index], " | ") .. " |"
		end

		return table.concat(out, "\n")
	end

	local out = {}
	for _, row in ipairs(rows) do
		out[#out + 1] = table.concat(row, "\t")
	end
	return table.concat(out, "\n")
end

local function read_optional(archive, name)
	local text = archive:read(name)
	return text
end

--- Read and render one DOCX archive without its public error boundary
-- @param filename string archive path
-- @param mode string|nil 'text' or 'markdown'
-- @return string|nil content rendered content
-- @return string|nil error extraction error
local function read_document(filename, mode)
	mode = mode == "markdown" and "markdown" or "text"

	local archive <close>, err = pack.zip.open_file_read(filename)
	if not archive then
		return nil, err
	end

	local document_xml
	document_xml, err = archive:read("word/document.xml")
	if not document_xml then
		return nil, err
	end

	local rels = parse_relationships(read_optional(archive, "word/_rels/document.xml.rels"))
	local styles = parse_styles(read_optional(archive, "word/styles.xml"))
	local numbering = parse_numbering(read_optional(archive, "word/numbering.xml"))
	local context = {
		rels = rels,
		styles = styles,
		numbering = numbering,
	}

	local out = {}
	local body = find_body(document_xml)

	for _, block in ipairs(iter_body_blocks(body)) do
		local text

		if block.kind == "table" then
			text = render_table(block.xml, context, mode)
		else
			text = render_paragraph(block.xml, context, mode)
		end

		if text and text ~= "" then
			out[#out + 1] = text
		end
	end

	local separator = mode == "markdown" and "\n\n" or "\n\n"
	return normalize_text(table.concat(out, separator))
end

--- Read and render one DOCX archive
-- @param filename string archive path
-- @param mode string|nil 'text' or 'markdown'
-- @return string|nil content rendered content
-- @return string|nil error extraction error
function reader.read(filename, mode)
	local ok, content, err = pcall(read_document, filename, mode)
	if not ok then
		return nil, "invalid DOCX XML: " .. tostring(content)
	end

	return content, err
end

function reader.extract_text(filename)
	return reader.read(filename, "text")
end

function reader.extract_markdown(filename)
	return reader.read(filename, "markdown")
end

return reader
