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

--- OpenDocument text extractor
-- @module plugins.odfutils.odt

local odf = require("plugins.odfutils.package")

local reader = {}

-- Extremely long source paragraphs must not become pathological editor or preview lines
local MAX_GENERATED_LINE_BYTES = 4096

--- Normalize spacing that OpenDocument stores as text characters
-- @param text string source text
-- @return string text normalized text
local function normalize_text(text)
	return text:gsub("\194\160", " "):gsub("[ \t]+$", "")
end

--- Return whether one byte is ordinary text whitespace
-- @param byte number|nil source byte
-- @return boolean whitespace
local function is_whitespace(byte)
	return byte == 9 or byte == 10 or byte == 13 or byte == 32
end

--- Append text in bounded lines without cutting a UTF-8 character
-- Consecutive Markdown source lines remain one rendered paragraph
-- @param out table output lines
-- @param text string generated text
-- @param continuation_prefix string|nil prefix for wrapped list lines
local function append_bounded_lines(out, text, continuation_prefix)
	local start_index = 1
	local prefix = ""

	while #text - start_index + 1 > MAX_GENERATED_LINE_BYTES do
		local target_end = start_index + MAX_GENERATED_LINE_BYTES - 1
		local split_at
		for index = target_end, start_index, -1 do
			if is_whitespace(text:byte(index)) then
				split_at = index
				break
			end
		end

		if split_at then
			out[#out + 1] = prefix .. text:sub(start_index, split_at - 1)
			start_index = split_at + 1
		else
			local line_end = target_end
			-- UTF-8 continuation bytes cannot begin the next generated line
			while line_end > start_index do
				local next_byte = text:byte(line_end + 1)
				if not next_byte or next_byte < 0x80 or next_byte > 0xBF then
					break
				end
				line_end = line_end - 1
			end

			out[#out + 1] = prefix .. text:sub(start_index, line_end)
			start_index = line_end + 1
		end

		prefix = continuation_prefix or ""
	end

	out[#out + 1] = prefix .. text:sub(start_index)
end

--- Add one generated block with only the spacing needed between block types
-- Lists and code stay compact while ordinary paragraphs remain separate
-- @param out table output lines
-- @param text string generated text
-- @param group string|nil compact block group
-- @param continuation_prefix string|nil prefix for wrapped lines
local function append_output(out, text, group, continuation_prefix)
	local compact = group and out.group == group
	if #out > 0 and out[#out] ~= "" and not compact then
		out[#out + 1] = ""
	end

	append_bounded_lines(out, text, continuation_prefix)
	out.group = group
end

--- Read list styles declared in the content document
-- @param document userdata parsed content document
-- @return table styles list style names indexed by ordered state
local function list_styles(document)
	local styles = {}
	local root = document:root()
	local automatic_styles = root and root:first_child("office:automatic-styles") or nil

	for style in odf.children(automatic_styles, "text:list-style") do
		local name = style:attribute("style:name")
		local level = style:first_child()
		if name and level then
			styles[name] = level:name() == "text:list-level-style-number"
		end
	end

	return styles
end

--- Append one paragraph and any embedded images
-- @param node userdata paragraph or heading
-- @param mode string text or markdown
-- @param filename string source archive filename
-- @param out table output lines
local function append_paragraph(node, mode, filename, out)
	local source_text = normalize_text(odf.text(node))
	local text = source_text
	if source_text ~= "" then
		local group
		local continuation_prefix

		if mode == "markdown" and node:name() == "text:h" then
			local level = math.max(1, math.min(tonumber(node:attribute("text:outline-level")) or 1, 6))
			text = string.rep("#", level) .. " " .. odf.markdown_escape(source_text:gsub("^%s+", ""))
		elseif mode == "markdown" and node:name() == "text:p" then
			-- Pattern breakdown: recognize literal bullets and decimal list markers used by older ODT writers
			local bullet = source_text:match("^%s*[·•]%s*(.+)$")
			local number, item = source_text:match("^%s*(%d+)[%.、]%s*(.+)$")
			local letter, subitem = source_text:match("^%s*([%a])%)%s*(.+)$")
			local code = source_text:match("^%s*<[%a_]")
				or source_text:match("^%s*</[%a_]")
				or source_text:match("^%s*//")

			if not number then
				number = source_text:match("^%s*(%d+)[%.、]%s*$")
				item = ""
			end

			if bullet then
				text = "- " .. odf.markdown_escape(bullet)
				group = "list"
				continuation_prefix = "  "
			elseif number then
				text = number .. ". " .. odf.markdown_escape(item)
				group = "list"
				continuation_prefix = "   "
			elseif letter then
				text = "   " .. letter .. ") " .. odf.markdown_escape(subitem)
				group = "list"
				continuation_prefix = "      "
			elseif code then
				-- Indented Markdown code keeps source spacing and does not need escaping
				text = "    " .. source_text
				group = "code"
			else
				text = odf.markdown_escape(source_text:gsub("^%s+", ""))
			end
		end

		append_output(out, text, group, continuation_prefix)
	end

	if mode == "markdown" then
		for _, image in ipairs(odf.images(node, filename)) do
			append_output(out, image)
		end
	end
end

local append_blocks

--- Append one OpenDocument list and its nested blocks
-- @param node userdata list node
-- @param mode string text or markdown
-- @param filename string source archive filename
-- @param styles table list style lookup
-- @param depth number nesting depth
-- @param out table output lines
local function append_list(node, mode, filename, styles, depth, out)
	local ordered = styles[node:attribute("text:style-name")] == true
	local number = tonumber(node:attribute("text:start-value")) or 1

	for item in odf.children(node, "text:list-item") do
		local paragraph = item:first_child("text:p") or item:first_child("text:h")
		local text = paragraph and odf.text(paragraph) or ""
		local marker = ordered and tostring(number) .. ". " or "- "
		local indent = string.rep("  ", depth)

		if mode == "markdown" then
			text = odf.markdown_escape(text)
		end
		append_output(out, indent .. marker .. text, "list", indent .. "  ")
		number = number + 1

		for child in odf.children(item) do
			if child:name() == "text:list" then
				append_list(child, mode, filename, styles, depth + 1, out)
			end
		end
	end

end

--- Append supported text-document blocks in source order
-- @param parent userdata parent node
-- @param mode string text or markdown
-- @param filename string source archive filename
-- @param styles table list style lookup
-- @param out table output lines
append_blocks = function(parent, mode, filename, styles, out)
	for node in odf.children(parent) do
		local name = node:name()

		if name == "text:p" or name == "text:h" then
			append_paragraph(node, mode, filename, out)
		elseif name == "text:list" then
			append_list(node, mode, filename, styles, 0, out)
		elseif name == "table:table" then
			if #out > 0 and out[#out] ~= "" then
				out[#out + 1] = ""
			end
			for _, line in ipairs(odf.render_table(odf.parse_table(node), mode)) do
				out[#out + 1] = line
			end
			out.group = nil
		elseif name == "text:section" then
			append_blocks(node, mode, filename, styles, out)
		elseif name == "draw:frame" and mode == "markdown" then
			for _, image in ipairs(odf.images(node, filename)) do
				append_output(out, image)
			end
		end
	end
end

--- Extract one ODT file in the requested mode
-- @param filename string archive path
-- @param mode string text or markdown
-- @return string|nil content extracted content
-- @return string|nil error extraction error
local function extract(filename, mode)
	local archive, document, err = odf.open(filename)
	if not archive then
		return nil, err
	end
	local archive_guard <close> = archive
	local body = odf.body(document, "office:text")
	if not body then
		return nil, "missing OpenDocument text body"
	end

	local out = {}
	append_blocks(body, mode, filename, list_styles(document), out)

	return odf.finish(out)
end

--- Read one ODT file as plain text
-- @param filename string archive path
-- @return string|nil content extracted text
-- @return string|nil error extraction error
function reader.extract_text(filename)
	return extract(filename, "text")
end

--- Read one ODT file as Markdown
-- @param filename string archive path
-- @return string|nil content extracted Markdown
-- @return string|nil error extraction error
function reader.extract_markdown(filename)
	return extract(filename, "markdown")
end

return reader
