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

--- DOCX content extractor
-- @module plugins.msoutils.docx

local native_text = require("text") -- Split large extracted documents without a Lua pattern loop
local roman = require("core.lib.roman")
local ooxml = require("plugins.msoutils.ooxml")
local xml = require("xml")

local reader = {}

-- Treat one tenth of an inch in Word's page coordinates as one plain-text column
local WORD_LAYOUT_COLUMN_TWIPS = 144
-- Word drawing sizes use English Metric Units while Markdown uses 96 logical pixels per inch
local WORD_EMUS_PER_INCH = 914400
local WORD_POINTS_PER_INCH = 72
local MARKDOWN_PIXELS_PER_INCH = 96

--- Reject characters the XML API decodes but XML 1.0 does not permit
-- @param text string decoded XML text
-- @return string text unchanged valid text
local function validate_xml_text(text)
	return ooxml.validate_text(text, "invalid XML character")
end

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

--- Wrap one generated Lx resource and preserve its intended display size
-- @param target string virtual file reference
-- @param width number|nil logical image width
-- @param height number|nil logical image height
-- @return string destination safe Markdown destination
local function markdown_destination(target, width, height)
	target = tostring(target or ""):gsub("\\", "/"):gsub("([<>])", "\\%1")

	if width and height then
		target = string.format("%s?width=%d&height=%d", target, width, height)
	end

	return "<lx:" .. target .. ">"
end

--- Convert one Word drawing size to logical preview pixels
-- @param value string|nil drawing extent in English Metric Units
-- @return number|nil pixels rounded logical size
local function drawing_pixels(value)
	local emus = tonumber(value)
	if not emus or emus <= 0 then
		return nil
	end

	return math.max(1, math.floor(emus * MARKDOWN_PIXELS_PER_INCH / WORD_EMUS_PER_INCH + 0.5))
end

--- Convert one legacy Word shape size to logical preview pixels
-- @param style string|nil VML shape style
-- @param name string requested size name
-- @return number|nil pixels rounded logical size
local function shape_pixels(style, name)
	if type(style) ~= "string" then
		return nil
	end

	-- Pattern breakdown: find the named VML style value followed by its point unit
	local points = tonumber(style:match(name .. "%s*:%s*([%d.]+)%s*pt"))
	if not points or points <= 0 then
		return nil
	end

	return math.max(1, math.floor(points * MARKDOWN_PIXELS_PER_INCH / WORD_POINTS_PER_INCH + 0.5))
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 child_nodes = ooxml.child_nodes

--- Read one child element's attribute
-- @param parent userdata|nil XML parent node
-- @param child_name string qualified child name
-- @param attribute_name string qualified attribute name
-- @return string|nil value attribute value
local function child_attribute(parent, child_name, attribute_name)
	local child = parent and parent:first_child(child_name) or nil

	return child and child:attribute(attribute_name) or nil
end

--- Read package relationships by ID
-- @param document userdata|nil relationships XML document
-- @return table rels relationships by ID
local function parse_relationships(document)
	local rels = {}
	local root = document and document:root() or nil
	if not root then
		return rels
	end

	for relationship in child_nodes(root, "Relationship") do
		local id = relationship:attribute("Id")
		if id then
			rels[id] = {
				type = relationship:attribute("Type"),
				target = relationship:attribute("Target"),
				target_mode = relationship:attribute("TargetMode"),
			}
		end
	end

	return rels
end

--- Read one WordprocessingML element's 'w:val' attribute
-- @param parent userdata XML parent node
-- @param name string element name without its namespace
-- @return string|nil value element value
local function tag_value(parent, name)
	return child_attribute(parent, "w:" .. name, "w:val")
end

--- Read direct numbering settings from paragraph properties
-- @param properties userdata|nil paragraph properties
-- @return table|nil numbering direct numbering selection
local function parse_num_pr(properties)
	local num_pr = properties and properties:first_child("w:numPr") or nil
	if not num_pr then
		return nil
	end

	return {
		num_id = tag_value(num_pr, "numId"),
		ilvl = tonumber(tag_value(num_pr, "ilvl")),
	}
end

--- Read paragraph styles needed for heading and numbering inheritance
-- @param document userdata|nil styles XML document
-- @return table styles style details by ID
local function parse_styles(document)
	local styles = {}
	local root = document and document:root() or nil
	if not root then
		return styles
	end

	for style in child_nodes(root, "w:style") do
		local style_id = style:attribute("w:styleId")
		if style_id then
			local properties = style:first_child("w:pPr")
			styles[style_id] = {
				name = tag_value(style, "name"),
				based_on = tag_value(style, "basedOn"),
				outline_level = tonumber(tag_value(properties, "outlineLvl")),
				numbering = parse_num_pr(properties),
			}
		end
	end

	return styles
end

--- Read one numbering level and its visible label recipe
-- @param node userdata level XML node
-- @return table|nil level numbering level
local function parse_number_level(node)
	local ilvl = tonumber(node:attribute("w:ilvl"))
	if not ilvl then
		return nil
	end

	local format = tag_value(node, "numFmt") or "decimal"
	local label = tag_value(node, "lvlText")
	local legal = node:first_child("w:isLgl")
	local legal_value = legal and legal:attribute("w:val") or nil

	if not label and format == "none" then
		label = ""
	elseif not label and format == "bullet" then
		label = "•"
	elseif not label then
		label = "%" .. tostring(ilvl + 1) .. "."
	end

	return {
		ilvl = ilvl,
		start = tonumber(tag_value(node, "start")) or 1,
		format = format,
		label = validate_xml_text(label),
		suffix = tag_value(node, "suff") or "tab",
		restart = tonumber(tag_value(node, "lvlRestart")),
		is_legal = legal ~= nil and legal_value ~= "0" and legal_value ~= "false",
		paragraph_style = tag_value(node, "pStyle"),
	}
end

--- Copy one numbering level before applying a list-specific override
-- @param source table|nil source level
-- @return table|nil copy independent level
local function copy_number_level(source)
	if not source then
		return nil
	end

	local copy = {}
	for key, value in pairs(source) do
		copy[key] = value
	end

	return copy
end

--- Read numbering definitions, list instances, and linked paragraph styles
-- @param document userdata|nil numbering XML document
-- @return table numbering resolved list and style numbering
local function parse_numbering(document)
	local abstract_levels = {}
	local instances = {}
	local root = document and document:root() or nil
	if not root then
		return { lists = {}, styles = {} }
	end

	for abstract_numbering in child_nodes(root, "w:abstractNum") do
		local abstract_id = abstract_numbering:attribute("w:abstractNumId")
		if abstract_id then
			local levels = {}
			for level_node in child_nodes(abstract_numbering, "w:lvl") do
				local level = parse_number_level(level_node)
				if level then
					levels[level.ilvl] = level
				end
			end
			abstract_levels[abstract_id] = levels
		end
	end

	for number in child_nodes(root, "w:num") do
		local num_id = number:attribute("w:numId")
		local abstract_id = tag_value(number, "abstractNumId")

		if num_id and abstract_id then
			local instance = {
				num_id = num_id,
				abstract_id = abstract_id,
				overrides = {},
			}

			for override_node in child_nodes(number, "w:lvlOverride") do
				local ilvl = tonumber(override_node:attribute("w:ilvl"))
				if ilvl then
					local level_node = override_node:first_child("w:lvl")
					instance.overrides[ilvl] = {
						level = level_node and parse_number_level(level_node) or nil,
						start = tonumber(tag_value(override_node, "startOverride")),
					}
				end
			end

			instances[#instances + 1] = instance
		end
	end

	local numbering = { lists = {}, styles = {} }
	for _, instance in ipairs(instances) do
		local levels = {}

		for ilvl, level in pairs(abstract_levels[instance.abstract_id] or {}) do
			levels[ilvl] = copy_number_level(level)
		end

		for ilvl, override in pairs(instance.overrides) do
			local level = copy_number_level(override.level) or levels[ilvl] or {
				ilvl = ilvl,
				start = 1,
				format = "decimal",
				label = "%" .. tostring(ilvl + 1) .. ".",
				suffix = "tab",
			}
			if override.start then
				level.start = override.start
			end
			levels[ilvl] = level
		end

		numbering.lists[instance.num_id] = levels
		for ilvl, level in pairs(levels) do
			if level.paragraph_style and not numbering.styles[level.paragraph_style] then
				numbering.styles[level.paragraph_style] = {
					num_id = instance.num_id,
					ilvl = ilvl,
				}
			end
		end
	end

	return numbering
end

--- Collect visible inline content from one paragraph node
-- @param paragraph userdata paragraph XML node
-- @param context table document relationships and source filename
-- @return table chunks visible text, links, and images in document order
local function inline_chunks(paragraph, context)
	local chunks = {}
	local rels = context.rels

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

	--- Add one embedded image through its existing virtual-file reference
	-- @param relationship table image relationship
	-- @param link string|nil surrounding hyperlink
	-- @param width number|nil intended logical width
	-- @param height number|nil intended logical height
	local function push_image(relationship, link, width, height)
		if not relationship or relationship.target_mode == "External" then
			return
		end
		if type(relationship.type) ~= "string" or not relationship.type:match("/image$") then
			return
		end
		if type(relationship.target) ~= "string" or relationship.target == "" then
			return
		end

		local image_path = system.normalize_path("word/" .. relationship.target):gsub("\\", "/")
		local image_name = image_path:match("([^/]+)$") or image_path
		chunks[#chunks + 1] = {
			image = system.format_filename(context.source_filename, image_path),
			alt = image_name,
			link = link,
			width = width,
			height = height,
		}
	end

	--- Walk visible paragraph content while carrying the nearest drawing extent
	-- @param node userdata|nil first XML node
	-- @param link string|nil surrounding hyperlink
	-- @param image_width number|nil intended logical image width
	-- @param image_height number|nil intended logical image height
	local function walk(node, link, image_width, image_height)
		while node do
			local name = node:name()

			if name == "w:del" then
				-- Deleted revisions are not visible document content
			elseif name == "w:hyperlink" then
				local relationship = rels[node:attribute("r:id")]
				walk(
					node:first_child(),
					relationship and relationship.target or link,
					image_width,
					image_height
				)
			elseif name == "w:t" then
				for text_node in child_nodes(node) do
					local node_type = text_node:type()
					if node_type == "text" or node_type == "cdata" then
						push_text(text_node:value(), link)
					end
				end
			elseif name == "w:tab" then
				push_text("\t", link)
			elseif name == "w:br" or name == "w:cr" then
				push_text("\n", link)
			elseif name == "v:shape" then
				local style = node:attribute("style")
				local width = shape_pixels(style, "width") or image_width
				local height = shape_pixels(style, "height") or image_height
				walk(node:first_child(), link, width, height)
			elseif name == "wp:inline" or name == "wp:anchor" then
				local extent = node:first_child("wp:extent")
				local width = extent and drawing_pixels(extent:attribute("cx")) or image_width
				local height = extent and drawing_pixels(extent:attribute("cy")) or image_height
				walk(node:first_child(), link, width, height)
			elseif name == "a:blip" then
				-- Base image is Word's readable fallback when a vector copy also exists
				push_image(rels[node:attribute("r:embed")], link, image_width, image_height)
			elseif name == "v:imagedata" then
				-- Older Word documents keep the same image relationship inside VML
				push_image(rels[node:attribute("r:id")], link, image_width, image_height)
			elseif name ~= "w:pPr" and name ~= "w:rPr" then
				walk(node:first_child(), link, image_width, image_height)
			end

			node = node:next_sibling()
		end
	end

	-- Paragraph properties contain layout tabs which must never become visible text
	walk(paragraph:first_child(), nil, nil, nil)

	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 or ""
		end
		return table.concat(out)
	end

	local out = {}
	local index = 1

	while index <= #chunks do
		if chunks[index].image then
			local chunk = chunks[index]
			local image = "![" .. markdown_escape(chunk.alt) .. "](" .. markdown_destination(
				chunk.image,
				chunk.width,
				chunk.height
			) .. ")"

			if chunk.link and chunk.link ~= "" then
				image = "[" .. image .. "](" .. chunk.link:gsub("%)", "%%29") .. ")"
			end

			out[#out + 1] = image
			index = index + 1
		else
			local link = chunks[index].link
			local text = chunks[index].text
			index = index + 1

			while index <= #chunks and not chunks[index].image 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
	end

	return table.concat(out)
end

--- Return a heading level carried by one standard style name
-- @param style_name string|nil style ID or visible name
-- @return number|nil level heading level
local function heading_level_from_style(style_name)
	if not style_name then
		return nil
	end

	local normalized = style_name:gsub("%s+", ""):lower()
	if normalized == "title" then
		return 1
	end

	return tonumber(normalized:match("^heading(%d+)$"))
end

--- Convert Word's zero-based outline level to one Markdown heading level
-- @param outline_level number|nil Word outline level
-- @return number|nil level heading level
local function heading_level_from_outline(outline_level)
	if type(outline_level) == "number" and outline_level >= 0 and outline_level <= 8 then
		return outline_level + 1
	end

	return nil
end

--- Resolve inherited heading and numbering details for one paragraph style
-- @param styles table style details by ID
-- @param numbering table resolved document numbering
-- @param style_id string|nil starting style ID
-- @return table meta inherited style details
local function resolve_style_meta(styles, numbering, style_id)
	local meta = {}
	local seen = {}
	local current_id = style_id
	local numbering_resolved = false

	while current_id and not seen[current_id] do
		seen[current_id] = true
		local style = styles[current_id]

		if not meta.heading_level then
			meta.heading_level = heading_level_from_outline(style and style.outline_level)
				or heading_level_from_style(current_id)
				or heading_level_from_style(style and style.name)
		end

		if not numbering_resolved then
			local linked_numbering = numbering.styles[current_id]
			local style_numbering = style and style.numbering or linked_numbering
			if style_numbering then
				numbering_resolved = true
				if style_numbering.num_id ~= "0" then
					local linked_level = linked_numbering
						and linked_numbering.num_id == style_numbering.num_id
						and linked_numbering.ilvl
					meta.num_id = style_numbering.num_id
					meta.ilvl = style_numbering.ilvl or linked_level
				end
			end
		end

		current_id = style and style.based_on or nil
	end

	return meta
end

--- Read style and numbering details for one DOCX paragraph
-- @param paragraph userdata paragraph XML node
-- @param styles table style details by ID
-- @param numbering table resolved document numbering
-- @return table meta paragraph structure
local function paragraph_meta(paragraph, styles, numbering)
	local properties = paragraph:first_child("w:pPr")
	local style_id = child_attribute(properties, "w:pStyle", "w:val")
	local style = styles[style_id]
	local style_meta = resolve_style_meta(styles, numbering, style_id)
	local numbering_properties = properties and properties:first_child("w:numPr") or nil
	local direct_numbering = numbering_properties and {
		num_id = child_attribute(numbering_properties, "w:numId", "w:val"),
		ilvl = tonumber(child_attribute(numbering_properties, "w:ilvl", "w:val")),
	} or nil
	local indentation = properties and properties:first_child("w:ind") or nil
	local left_indent = indentation and tonumber(
		indentation:attribute("w:left") or indentation:attribute("w:start")
	) or nil
	local tab_stops = {}
	local tabs = properties and properties:first_child("w:tabs") or nil

	for tab in child_nodes(tabs, "w:tab") do
		local position = tonumber(tab:attribute("w:pos"))
		if position and tab:attribute("w:val") ~= "clear" then
			tab_stops[#tab_stops + 1] = position
		end
	end

	table.sort(tab_stops)

	local outline_level = tonumber(child_attribute(properties, "w:outlineLvl", "w:val"))
	local num_id = style_meta.num_id
	local ilvl = style_meta.ilvl

	if direct_numbering then
		num_id = direct_numbering.num_id
		ilvl = direct_numbering.ilvl
		if ilvl == nil and num_id == style_meta.num_id then
			ilvl = style_meta.ilvl
		end
	end

	ilvl = ilvl or 0

	if num_id == "0" then
		num_id = nil
	end

	local list = num_id and numbering.lists[num_id] or nil
	local number_level = list and list[ilvl] or nil

	return {
		style_id = style_id,
		style_name = style and style.name or style_id,
		heading_level = heading_level_from_outline(outline_level) or style_meta.heading_level,
		num_id = num_id,
		ilvl = ilvl,
		left_indent = left_indent,
		tab_stops = tab_stops,
		list_format = number_level and number_level.format or nil,
		number_level = number_level,
	}
end

--- Format one positive counter as spreadsheet-style letters
-- @param value number positive integer counter
-- @param lowercase boolean use lowercase letters
-- @return string|nil letters formatted counter
local function letter_counter(value, lowercase)
	if type(value) ~= "number" or value % 1 ~= 0 or value < 1 then
		return nil
	end

	local out = {}
	local first_letter = lowercase and string.byte("a") or string.byte("A")

	while value > 0 do
		local remainder = (value - 1) % 26
		table.insert(out, 1, string.char(first_letter + remainder))
		value = (value - remainder - 1) // 26
	end

	return table.concat(out)
end

--- Format one Word numbering counter with a safe decimal fallback
-- @param value number counter value
-- @param format string Word numbering format
-- @return string text visible counter text
local function format_counter(value, format)
	if format == "none" then
		return ""
	elseif format == "decimalZero" then
		return string.format("%02d", value)
	elseif format == "upperRoman" then
		return roman.upper(value) or tostring(value)
	elseif format == "lowerRoman" then
		return roman.lower(value) or tostring(value)
	elseif format == "upperLetter" then
		return letter_counter(value, false) or tostring(value)
	elseif format == "lowerLetter" then
		return letter_counter(value, true) or tostring(value)
	end

	return tostring(value)
end

--- Advance one list and build the visible label for its current paragraph
-- @param meta table paragraph numbering details
-- @param context table document rendering state
-- @return string|nil label visible numbering label
-- @return string separator spacing between label and text
-- @return number|nil counter current list counter
local function next_number_label(meta, context)
	if not meta.num_id or not meta.number_level then
		return nil, "", nil
	end

	local levels = context.numbering.lists[meta.num_id]
	local counters = context.numbering_counters[meta.num_id]
	if not counters then
		counters = {}
		context.numbering_counters[meta.num_id] = counters
	end

	for ilvl, level in pairs(levels) do
		if ilvl > meta.ilvl then
			local restart = level.restart
			if restart == nil or restart > 0 and meta.ilvl < restart then
				counters[ilvl] = nil
			end
		end
	end

	local level = meta.number_level
	counters[meta.ilvl] = counters[meta.ilvl] and counters[meta.ilvl] + 1 or level.start
	local counter = counters[meta.ilvl]

	if level.format == "none" or level.label == "" then
		return nil, "", counter
	end

	-- Pattern breakdown: replace each Word '%1' through '%9' placeholder with that level's current counter
	local label = level.label:gsub("%%([1-9])", function(index)
		local referenced_ilvl = tonumber(index) - 1
		local referenced_level = levels[referenced_ilvl]
		local value = counters[referenced_ilvl] or referenced_level and referenced_level.start or 1
		local format = level.is_legal and "decimal" or referenced_level and referenced_level.format or "decimal"

		return format_counter(value, format)
	end)
	local separator = level.suffix == "nothing" and "" or " "

	return label, separator, counter
end

--- Render one DOCX paragraph as plain text or Markdown
-- @param paragraph userdata paragraph XML node
-- @param context table relationships, styles, and numbering
-- @param mode string 'text' or 'markdown'
-- @return string text rendered paragraph
-- @return table meta paragraph structure
-- @return boolean has_image true when the paragraph contains an image
-- @return string plain_text unescaped paragraph text
local function render_paragraph(paragraph, context, mode)
	local chunks = inline_chunks(paragraph, context)
	local plain_text = chunks_to_text(chunks, "text"):gsub("^[\r\n]+", ""):gsub("[ \t\r\n]+$", "")
	local text = mode == "markdown" and chunks_to_text(chunks, mode) or plain_text
	text = text:gsub("^[\r\n]+", ""):gsub("[ \t\r\n]+$", "")
	local meta = paragraph_meta(paragraph, context.styles, context.numbering)
	local number_label, number_separator, list_number = next_number_label(meta, context)
	local has_image = false

	for _, chunk in ipairs(chunks) do
		if chunk.image then
			has_image = true
			break
		end
	end

	if text == "" then
		return "", meta, has_image, plain_text
	end

	if mode == "markdown" then
		if meta.heading_level then
			local level = math.max(1, math.min(6, meta.heading_level))
			local prefix = number_label and markdown_escape(number_label) .. number_separator or ""

			return string.rep("#", level) .. " " .. prefix .. text, meta, has_image, plain_text
		end

		if meta.num_id then
			local indent = string.rep("  ", math.max(0, meta.ilvl))
			-- Use the real counter while keeping Markdown's portable ordered-list form
			local marker = meta.list_format == "bullet" and "- "
				or meta.list_format == "none" and ""
				or tostring(list_number or 1) .. ". "
			return indent .. marker .. text, meta, has_image, plain_text
		end
	end

	return text, meta, has_image, plain_text
end

--- Group paragraphs which should stay on adjacent output lines
-- @param meta table paragraph structure
-- @return string kind paragraph group kind
-- @return string|nil group list instance shared by adjacent items
local function paragraph_group(meta)
	if meta.heading_level then
		return "paragraph"
	elseif meta.num_id then
		return "list", meta.num_id
	elseif not meta.style_id and meta.left_indent and meta.left_indent > 0 then
		return "layout"
	end

	return "paragraph"
end

--- Convert one Word layout position to a stable text column
-- @param position number position relative to the layout origin
-- @return number column zero-based text column
local function layout_column(position)
	return math.max(0, math.floor(position / WORD_LAYOUT_COLUMN_TWIPS + 0.5))
end

--- Preserve one aligned Word paragraph using relative spaces
-- @param text string paragraph text containing literal tabs
-- @param meta table paragraph indentation and tab stops
-- @param origin number smallest left indent in the group
-- @return string text aligned plain text
local function align_layout_paragraph(text, meta, origin)
	local line_indent = layout_column((meta.left_indent or origin) - origin)
	local column = line_indent
	local out = { string.rep(" ", line_indent) }

	for _, codepoint in utf8.codes(text) do
		if codepoint == 0x09 then
			local target_column

			for _, tab_stop in ipairs(meta.tab_stops) do
				local candidate = layout_column(tab_stop - origin)
				if candidate > column then
					target_column = candidate
					break
				end
			end

			-- A missing custom stop falls back to the next four-column boundary
			target_column = target_column or column + 4 - column % 4
			out[#out + 1] = string.rep(" ", target_column - column)
			column = target_column
		elseif codepoint == 0x0A then
			out[#out + 1] = "\n" .. string.rep(" ", line_indent)
			column = line_indent
		else
			out[#out + 1] = utf8.char(codepoint)
			column = column + 1
		end
	end

	return table.concat(out)
end

--- Wrap preformatted text in a fence longer than any fence inside it
-- @param text string preformatted text
-- @return string markdown fenced block
local function markdown_code_block(text)
	local fence_length = 3

	for run in text:gmatch("`+") do
		fence_length = math.max(fence_length, #run + 1)
	end

	local fence = string.rep("`", fence_length)

	return fence .. "\n" .. text .. "\n" .. fence
end

--- Render one DOCX table cell for its requested output mode
-- @param cell userdata table-cell XML node
-- @param context table document relationships and styles
-- @param mode string 'text' or 'markdown'
-- @return table content rendered cell content
local function cell_content(cell, context, mode)
	local body = {}
	local has_image = false

	for block in child_nodes(cell) do
		if block:name() == "w:p" then
			local text, _, paragraph_has_image = render_paragraph(block, context, mode)
			if text ~= "" then
				body[#body + 1] = text
			end
			has_image = has_image or paragraph_has_image
		end
	end

	return {
		blocks = body,
		has_image = has_image,
		text = table.concat(body, mode == "markdown" and "<br><br>" or " "),
	}
end

--- Render one DOCX table as plain text or Markdown
-- @param table_node userdata table XML node
-- @param context table document relationships and styles
-- @param mode string 'text' or 'markdown'
-- @return string text rendered table
local function render_table(table_node, context, mode)
	local rows = {}
	local has_image = false

	for row_node in child_nodes(table_node, "w:tr") do
		local row = {}
		for cell_node in child_nodes(row_node, "w:tc") do
			local content = cell_content(cell_node, context, mode)
			row[#row + 1] = content
			has_image = has_image or content.has_image
		end
		if #row > 0 then
			rows[#rows + 1] = row
		end
	end

	if #rows == 0 then
		return ""
	end

	if mode == "markdown" and has_image then
		local out = {}
		local columns = 0

		for _, row in ipairs(rows) do
			columns = math.max(columns, #row)
		end

		-- An empty header keeps layout-only Word tables from turning their first row bold
		local empty_header = {}
		local separator = {}
		for _ = 1, columns do
			empty_header[#empty_header + 1] = ""
			separator[#separator + 1] = "---"
		end
		out[#out + 1] = "| " .. table.concat(empty_header, " | ") .. " |"
		out[#out + 1] = "| " .. table.concat(separator, " | ") .. " |"

		for _, row in ipairs(rows) do
			local cells = {}
			for index = 1, columns do
				cells[index] = row[index] and row[index].text or ""
			end
			out[#out + 1] = "| " .. table.concat(cells, " | ") .. " |"
		end

		return table.concat(out, "\n")
	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
			for index = 1, #row do
				row[index] = row[index].text
			end
			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
		local cells = {}
		for index = 1, #row do
			cells[index] = row[index].text
		end
		out[#out + 1] = table.concat(cells, "\t")
	end
	return table.concat(out, "\n")
end

--- Parse one optional XML file from a DOCX archive
-- @param archive userdata open DOCX archive
-- @param name string archive entry name
-- @return userdata|nil document parsed XML document
-- @return string|nil error parse error for a present entry
local function read_optional_xml(archive, name)
	local source = archive:read(name)
	if not source then
		return nil
	end

	local document, err = xml.parse(source)
	if not document then
		return nil, "invalid DOCX XML in '" .. name .. "': " .. tostring(err)
	end

	return document
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 document, parse_error = xml.parse(document_xml)
	if not document then
		return nil, "invalid DOCX XML: " .. tostring(parse_error)
	end

	local root = document:root()
	local body = root and root:first_child("w:body") or nil
	if not body then
		return nil, "invalid DOCX XML: missing word/document.xml body"
	end

	local relationships_document
	relationships_document, err = read_optional_xml(archive, "word/_rels/document.xml.rels")
	if err then
		return nil, err
	end

	local styles_document
	styles_document, err = read_optional_xml(archive, "word/styles.xml")
	if err then
		return nil, err
	end

	local numbering_document
	numbering_document, err = read_optional_xml(archive, "word/numbering.xml")
	if err then
		return nil, err
	end

	local rels = parse_relationships(relationships_document)
	local styles = parse_styles(styles_document)
	local numbering = parse_numbering(numbering_document)
	local context = {
		rels = rels,
		styles = styles,
		numbering = numbering,
		numbering_counters = {},
		source_filename = filename:match("([^/\\]+)$") or filename,
	}

	local out = {}
	local previous_kind
	local previous_group
	local blank_between = false
	local pending_layout = {}

	--- Add one rendered block with spacing based on adjacent Word structure
	-- @param text string rendered block text
	-- @param kind string paragraph, list, layout, or table
	-- @param group string|nil shared list instance
	local function append_block(text, kind, group)
		if text == "" then
			blank_between = #out > 0
			return
		end

		if #out > 0 then
			local same_list = kind == "list" and previous_kind == "list" and group == previous_group
			local same_layout = kind == "layout" and previous_kind == "layout"
			out[#out + 1] = not blank_between and (same_list or same_layout) and "\n" or "\n\n"
		end

		out[#out + 1] = text
		previous_kind = kind
		previous_group = group
		blank_between = false
	end

	--- Write buffered aligned paragraphs as one preformatted Markdown block
	local function flush_layout()
		if #pending_layout == 0 then
			return
		end

		local last_content = 0
		local origin
		local paragraph_count = 0
		local has_tab_stops = false

		for index, item in ipairs(pending_layout) do
			if item.text ~= "" then
				last_content = index
				paragraph_count = paragraph_count + 1
				origin = math.min(origin or item.meta.left_indent, item.meta.left_indent)
				has_tab_stops = has_tab_stops or #item.meta.tab_stops >= 2
			end
		end

		if mode == "markdown" and paragraph_count >= 2 and has_tab_stops then
			local lines = {}

			for index = 1, last_content do
				local item = pending_layout[index]
				lines[#lines + 1] = item.text == ""
					and ""
					or align_layout_paragraph(item.plain_text, item.meta, origin)
			end

			append_block(markdown_code_block(table.concat(lines, "\n")), "preformatted")
		else
			for _, item in ipairs(pending_layout) do
				append_block(item.text, item.kind, item.group)
			end
		end

		pending_layout = {}
	end

	for block in child_nodes(body) do
		if block:name() == "w:tbl" then
			flush_layout()
			append_block(render_table(block, context, mode), "table")
		elseif block:name() == "w:p" then
			local text, meta, _, plain_text = render_paragraph(block, context, mode)
			local kind, group = paragraph_group(meta)

			if kind == "layout" or text == "" and #pending_layout > 0 then
				pending_layout[#pending_layout + 1] = {
					text = text,
					plain_text = plain_text,
					meta = meta,
					kind = kind,
					group = group,
				}
			else
				flush_layout()
				append_block(text, kind, group)
			end
		end
	end

	flush_layout()

	return normalize_text(table.concat(out))
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
