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

--- Bounded dense and sparse spreadsheet text rendering
-- @module core.lib.spreadsheet

local spreadsheet = {}

local MAX_RENDER_ROWS = 2000
local MAX_RENDER_COLUMNS = 128
local MAX_DENSE_CELLS = 250000

--- Converts one cell value to single-line text
-- @param value any cell value
-- @return string text
function spreadsheet.cell_text(value)
	-- Only absent cells become empty; false remains visible data
	value = value == nil and "" or tostring(value)

	return value:gsub("\r\n", "\n"):gsub("\r", "\n"):gsub("\t", " "):gsub("\n", " ")
end

local function column_name(index)
	local out = {}

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

	return table.concat(out)
end

--- Return terminal column width for one cell value
-- @param value any cell value
-- @return number width displayed columns
local function display_length(value)
	local text = tostring(value or "")
	local ok, width = pcall(utf8.width, text)

	return ok and type(width) == "number" and width or #text
end

local function pad_right(value, width)
	value = tostring(value or "")
	local missing = width - display_length(value)

	return missing > 0 and value .. string.rep(" ", missing) or value
end

local function markdown_cell(value)
	return spreadsheet.cell_text(value):gsub("\\", "\\\\"):gsub("|", "\\|")
end

local function collect_layout(sheet)
	local row_indexes = {}
	local column_indexes = {}
	local columns = {}
	local last_row = 0
	local last_column = 0

	for row_index, row in pairs(sheet.rows or {}) do
		local row_has_value = false

		for column_index, value in pairs(row) do
			if spreadsheet.cell_text(value) ~= "" then
				row_has_value = true
				columns[column_index] = true
				last_column = math.max(last_column, column_index)
			end
		end

		if row_has_value then
			row_indexes[#row_indexes + 1] = row_index
			last_row = math.max(last_row, row_index)
		end
	end

	for column_index in pairs(columns) do
		column_indexes[#column_indexes + 1] = column_index
	end

	table.sort(row_indexes)
	table.sort(column_indexes)

	local dense = last_row <= MAX_RENDER_ROWS
		and last_column <= MAX_RENDER_COLUMNS
		and last_row * last_column <= MAX_DENSE_CELLS
	local truncated = #row_indexes > MAX_RENDER_ROWS or #column_indexes > MAX_RENDER_COLUMNS

	if dense then
		row_indexes = {}
		column_indexes = {}

		for row_index = 1, last_row do
			row_indexes[row_index] = row_index
		end

		for column_index = 1, last_column do
			column_indexes[column_index] = column_index
		end
	else
		while #row_indexes > MAX_RENDER_ROWS do
			table.remove(row_indexes)
		end

		while #column_indexes > MAX_RENDER_COLUMNS do
			table.remove(column_indexes)
		end
	end

	return {
		rows = row_indexes,
		columns = column_indexes,
		dense = dense,
		truncated = truncated,
	}
end

local function layout_notice(layout)
	if layout.truncated then
		return string.format(
			"[Spreadsheet output truncated to %d stored rows and %d stored columns]",
			MAX_RENDER_ROWS,
			MAX_RENDER_COLUMNS
		)
	end

	if not layout.dense then
		return "[Sparse spreadsheet: empty row and column ranges omitted]"
	end
end

--- Renders one sheet as padded plain text lines
-- @param sheet table parsed sheet
-- @return table lines
function spreadsheet.render_text(sheet)
	local layout = collect_layout(sheet)
	local notice = layout_notice(layout)
	local out = notice and { notice } or {}

	if not layout.dense then
		for _, row_index in ipairs(layout.rows) do
			local row = sheet.rows[row_index] or {}
			local cells = {}

			for _, column_index in ipairs(layout.columns) do
				local value = spreadsheet.cell_text(row[column_index])

				if value ~= "" then
					cells[#cells + 1] = string.format("[%s] %s", column_name(column_index), value)
				end
			end

			out[#out + 1] = string.format("Row %d: %s", row_index, table.concat(cells, "  "))
		end

		return out
	end

	local widths = {}
	local rows = {}

	for _, row_index in ipairs(layout.rows) do
		local source = sheet.rows[row_index] or {}
		local row = {}

		for _, column_index in ipairs(layout.columns) do
			local value = spreadsheet.cell_text(source[column_index])
			row[column_index] = value
			widths[column_index] = math.max(widths[column_index] or 0, display_length(value))
		end

		rows[row_index] = row
	end

	for _, row_index in ipairs(layout.rows) do
		local cells = {}

		for _, column_index in ipairs(layout.columns) do
			cells[column_index] = pad_right(rows[row_index][column_index], widths[column_index] or 0)
		end

		out[#out + 1] = table.concat(cells, "  "):gsub("%s+$", "")
	end

	return out
end

--- Renders one sheet as Markdown table lines
-- @param sheet table parsed sheet
-- @return table lines
function spreadsheet.render_markdown(sheet)
	local layout = collect_layout(sheet)

	if #layout.columns == 0 then
		layout.columns = { 1 }
	end

	local notice = layout_notice(layout)
	local out = notice and { notice, "" } or {}
	local widths = {}
	local rows = {}
	local include_row_number = not layout.dense

	for _, column_index in ipairs(layout.columns) do
		widths[column_index] = display_length(column_name(column_index))
	end

	for _, row_index in ipairs(layout.rows) do
		local source = sheet.rows[row_index] or {}
		local row = {}

		for _, column_index in ipairs(layout.columns) do
			local value = markdown_cell(source[column_index])
			row[column_index] = value
			widths[column_index] = math.max(widths[column_index], display_length(value))
		end

		rows[row_index] = row
	end

	local header = include_row_number and { " Row " } or {}
	local separator = include_row_number and { "-----" } or {}

	for _, column_index in ipairs(layout.columns) do
		header[#header + 1] = " " .. pad_right(column_name(column_index), widths[column_index]) .. " "
		separator[#separator + 1] = string.rep("-", widths[column_index] + 2)
	end

	out[#out + 1] = "|" .. table.concat(header, "|") .. "|"
	out[#out + 1] = "|" .. table.concat(separator, "|") .. "|"

	for _, row_index in ipairs(layout.rows) do
		local cells = include_row_number and { " " .. row_index .. " " } or {}

		for _, column_index in ipairs(layout.columns) do
			cells[#cells + 1] = " " .. pad_right(rows[row_index][column_index], widths[column_index]) .. " "
		end

		out[#out + 1] = "|" .. table.concat(cells, "|") .. "|"
	end

	return out
end

--- Renders one sheet as tab-separated lines
-- @param sheet table parsed sheet
-- @return table lines
function spreadsheet.render_tsv(sheet)
	local layout = collect_layout(sheet)
	local notice = layout_notice(layout)
	local out = notice and { notice } or {}

	for _, row_index in ipairs(layout.rows) do
		local source = sheet.rows[row_index] or {}
		local cells = not layout.dense and { "Row " .. row_index } or {}

		for _, column_index in ipairs(layout.columns) do
			local value = spreadsheet.cell_text(source[column_index])

			if layout.dense then
				cells[#cells + 1] = value
			elseif value ~= "" then
				cells[#cells + 1] = column_name(column_index) .. "=" .. value
			end
		end

		out[#out + 1] = table.concat(cells, "\t")
	end

	return out
end

return spreadsheet
