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

--- Shared OpenDocument archive and XML helpers
-- @module plugins.odfutils.package

local pack = require("pack")
local spreadsheet = require("core.lib.spreadsheet")
local xml = require("xml")

local odf_package = {}

local MAX_ROWS = 2000
local MAX_COLUMNS = 128

--- Walk sibling XML nodes with an optional name filter
-- @param parent userdata|nil XML parent node
-- @param name string|nil child name
-- @return function iterator child iterator
function odf_package.children(parent, name)
	local next_node = parent and parent:first_child(name) or nil

	return function()
		local node = next_node
		if node then
			next_node = node:next_sibling(name)
		end

		return node
	end
end

--- Read one XML attribute
-- @param node userdata|nil XML node
-- @param name string qualified attribute name
-- @return string|nil value attribute value
function odf_package.attribute(node, name)
	return node and node:attribute(name) or nil
end

--- Read and parse one XML archive entry
-- @param archive userdata open ZIP archive
-- @param path string archive entry path
-- @param required boolean whether a missing entry is an error
-- @return userdata|nil document parsed XML document
-- @return string|nil error read or parse error
function odf_package.read_xml(archive, path, required)
	local data, err = archive:read(path)
	if not data then
		return nil, required and (err or 'missing "' .. path .. '"') or nil
	end

	local document, parse_error = xml.parse(data)
	if not document then
		return nil, 'invalid OpenDocument XML in "' .. path .. '": ' .. tostring(parse_error)
	end

	return document
end

--- Open one OpenDocument file and parse its main content
-- @param filename string archive path
-- @return userdata|nil archive open ZIP archive
-- @return userdata|nil document parsed content document
-- @return string|nil error opening or parsing error
function odf_package.open(filename)
	local archive, err = pack.zip.open_file_read(filename)
	if not archive then
		return nil, nil, err
	end

	local document, content_error = odf_package.read_xml(archive, "content.xml", true)
	if not document then
		archive:close()
		return nil, nil, content_error
	end

	return archive, document
end

--- Return the requested OpenDocument body element
-- @param document userdata parsed content document
-- @param body_name string qualified body element name
-- @return userdata|nil body requested body
function odf_package.body(document, body_name)
	local root = document and document:root() or nil
	local office_body = root and root:first_child("office:body") or nil

	return office_body and office_body:first_child(body_name) or nil
end

--- Collect visible text while preserving explicit OpenDocument spacing
-- @param node userdata|nil XML node
-- @return string text visible text
function odf_package.text(node)
	local parts = {}

	local function walk(current)
		for child in odf_package.children(current) do
			local node_type = child:type()
			local name = child:name()

			if node_type == "text" or node_type == "cdata" then
				parts[#parts + 1] = child:value()
			elseif name == "text:s" then
				local count = math.max(1, math.min(tonumber(child:attribute("text:c")) or 1, 256))
				parts[#parts + 1] = string.rep(" ", count)
			elseif name == "text:tab" then
				parts[#parts + 1] = "\t"
			elseif name == "text:line-break" then
				parts[#parts + 1] = "\n"
			elseif name ~= "office:annotation" then
				walk(child)
			end
		end
	end

	walk(node)

	return table.concat(parts):gsub("[ \t]+\n", "\n"):gsub("[ \t]+$", "")
end

--- Escape plain text for generated Markdown
-- @param text string source text
-- @return string escaped Markdown text
function odf_package.markdown_escape(text)
	text = tostring(text or ""):gsub("\\", "\\\\")

	return text:gsub("([%[%]%(%)`*_{}#!|>])", "\\%1")
end

--- Return an Lx resource link for one file inside an OpenDocument archive
-- @param filename string source archive filename
-- @param path string path inside the archive
-- @return string destination Markdown destination
function odf_package.resource_destination(filename, path)
	local target = system.format_filename(filename, path:gsub("^%./", ""))
	target = target:gsub("\\", "/"):gsub("([<>])", "\\%1")

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

--- Collect embedded image links below one XML node
-- @param node userdata|nil XML node
-- @param filename string source archive filename
-- @return table images generated Markdown images
function odf_package.images(node, filename)
	local images = {}

	local function walk(current)
		for child in odf_package.children(current) do
			if child:name() == "draw:image" then
				local path = child:attribute("xlink:href")
				if path and path ~= "" and not path:match("^%a+:") then
					local name = path:match("([^/]+)$") or "image"
					images[#images + 1] = "![" .. odf_package.markdown_escape(name) .. "](" ..
						odf_package.resource_destination(filename, path) .. ")"
				end
			else
				walk(child)
			end
		end
	end

	walk(node)

	return images
end

--- Return one spreadsheet cell's displayed value
-- @param cell userdata XML cell
-- @return string value visible or stored value
local function cell_value(cell)
	local value = odf_package.text(cell)
	if value ~= "" then
		return value
	end

	local value_type = cell:attribute("office:value-type")
	if value_type == "boolean" then
		return cell:attribute("office:boolean-value") == "true" and "TRUE" or "FALSE"
	end

	return cell:attribute("office:string-value")
		or cell:attribute("office:value")
		or cell:attribute("office:date-value")
		or cell:attribute("office:time-value")
		or ""
end

--- Parse one OpenDocument table into the shared spreadsheet shape
-- Repeated rows and columns are bounded before expansion
-- @param table_node userdata table element
-- @return table sheet parsed sheet
function odf_package.parse_table(table_node)
	local sheet = {
		name = table_node:attribute("table:name") or "Table",
		rows = {},
		max_row = 0,
		max_col = 0,
	}
	local row_index = 1

	for row in odf_package.children(table_node, "table:table-row") do
		if row_index > MAX_ROWS then
			break
		end

		local values = {}
		local column_index = 1
		for cell in odf_package.children(row) do
			local name = cell:name()
			if name == "table:table-cell" or name == "table:covered-table-cell" then
				local repeated = math.max(1, math.min(
					tonumber(cell:attribute("table:number-columns-repeated")) or 1,
					MAX_COLUMNS - column_index + 1
				))
				local value = name == "table:table-cell" and cell_value(cell) or ""

				for _ = 1, repeated do
					if column_index > MAX_COLUMNS then
						break
					end
					values[column_index] = value
					column_index = column_index + 1
				end
			end
		end

		local repeated_rows = math.max(1, math.min(
			tonumber(row:attribute("table:number-rows-repeated")) or 1,
			MAX_ROWS - row_index + 1
		))
		for _ = 1, repeated_rows do
			sheet.rows[row_index] = {}
			for index, value in pairs(values) do
				sheet.rows[row_index][index] = value
			end
			row_index = row_index + 1
		end

		sheet.max_col = math.max(sheet.max_col, column_index - 1)
	end

	sheet.max_row = row_index - 1

	return sheet
end

--- Render one parsed table in the requested output mode
-- @param sheet table parsed sheet
-- @param mode string text, markdown, or tsv
-- @return table lines rendered lines
function odf_package.render_table(sheet, mode)
	if mode == "markdown" then
		return spreadsheet.render_markdown(sheet)
	elseif mode == "tsv" then
		return spreadsheet.render_tsv(sheet)
	end

	return spreadsheet.render_text(sheet)
end

--- Normalize generated block output to one blank line between blocks
-- @param lines table generated lines
-- @return string text normalized output
function odf_package.finish(lines)
	local out = {}
	local last_blank = true

	for _, line in ipairs(lines) do
		line = tostring(line or ""):gsub("[ \t]+$", "")
		local blank = line == ""
		if not blank or not last_blank then
			out[#out + 1] = line
		end
		last_blank = blank
	end

	while out[#out] == "" do
		out[#out] = nil
	end

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

return odf_package
