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

--- Validate and normalize delimited-text parser settings
-- @param text string source text
-- @param delimiter string one-byte field delimiter
-- @param options table|nil parser options
-- @return table settings normalized parser settings
local function get_delimited_settings(text, delimiter, options)
	assert(type(text) == "string", "delimited text must be a string")
	assert(type(delimiter) == "string" and #delimiter == 1, "delimiter must be one byte")
	assert(delimiter ~= "\r" and delimiter ~= "\n", "delimiter cannot be a line ending")
	assert(options == nil or type(options) == "table", "parser options must be a table")

	options = options or {}
	local quote = options.quote
	if quote == nil then
		quote = '"'
	end
	assert(quote == false or type(quote) == "string" and #quote == 1, "quote must be false or one byte")
	assert(quote == false or quote ~= delimiter, "quote and delimiter must differ")
	assert(quote == false or quote ~= "\r" and quote ~= "\n", "quote cannot be a line ending")

	local line_endings = options.line_endings or "any"
	assert(line_endings == "any" or line_endings == "lf", "line_endings must be 'any' or 'lf'")

	return {
		delimiter = delimiter,
		delimiter_byte = delimiter:byte(),
		quote = quote,
		quote_byte = quote and quote:byte(),
		line_endings = line_endings,
	}
end

--- Scan logical records and optionally retain their cell values
-- @param text string source text
-- @param settings table normalized parser settings
-- @param collect_rows boolean true when cell values are required
-- @return table sheet parsed values or lightweight record index
local function scan_delimited(text, settings, collect_rows)
	local rows = collect_rows and {} or nil
	local row_source_offsets = { 1 }
	local row_end_offsets = {}

	local row_source_lines = {}
	local row_end_lines = {}
	local errors = {}
	local row = {}
	local field_parts = {}
	local delimiter_byte = settings.delimiter_byte
	local quote_byte = settings.quote_byte
	local text_length = #text
	local field_start = 1
	local field_empty = true
	local column_count = 0
	local row_start_line = 1
	local line_number = 1
	local quote_start_line
	local after_quote = false
	local ended_with_row_separator = false
	local index = 1
	local in_quotes = false
	local max_columns = 0

	--- Retains one ordinary source span without creating per-byte strings
	-- @param last_index number inclusive final byte
	local function append_field_span(last_index)
		if collect_rows and last_index >= field_start then
			field_parts[#field_parts + 1] = text:sub(field_start, last_index)
		end
	end

	--- Finishes the current field and prepares the next one
	-- @param last_index number inclusive final source byte
	local function finish_field(last_index)
		append_field_span(last_index)
		column_count = column_count + 1
		if collect_rows then
			row[#row + 1] = table.concat(field_parts)
		end
		field_parts = {}
		field_empty = true
		quote_start_line = nil
		after_quote = false
	end

	--- Stores one parsed row with its physical source line range
	-- @param end_line number final physical source line
	-- @param end_offset number final source byte before the row separator
	local function finish_row(end_line, end_offset)
		if collect_rows then
			rows[#rows + 1] = row
		end
		row_source_lines[#row_source_lines + 1] = row_start_line
		row_end_lines[#row_end_lines + 1] = end_line
		row_end_offsets[#row_end_offsets + 1] = end_offset
		max_columns = math.max(max_columns, column_count)
		row = {}
		column_count = 0
	end

	--- Records a recoverable syntax problem for preview callers
	-- @param kind string stable error kind
	-- @param line number physical source line
	-- @param message string readable error
	local function add_error(kind, line, message)
		errors[#errors + 1] = {
			kind = kind,
			line = line,
			message = message,
		}
	end

	while index <= text_length do
		local byte = text:byte(index)
		ended_with_row_separator = false

		if in_quotes then
			if byte == quote_byte and text:byte(index + 1) == quote_byte then
				-- A doubled quote becomes one literal quote inside a quoted field
				append_field_span(index - 1)
				if collect_rows then
					field_parts[#field_parts + 1] = settings.quote
				end
				index = index + 2
				field_start = index
				field_empty = false
			elseif byte == quote_byte then
				append_field_span(index - 1)
				in_quotes = false
				after_quote = true
				index = index + 1
				field_start = index
			else
				-- Quoted line breaks remain cell data but still advance source locations
				if byte == 10 then
					line_number = line_number + 1
				elseif byte == 13 and settings.line_endings == "any" then
					line_number = line_number + 1
					if text:byte(index + 1) == 10 then
						index = index + 1
					end
				end
				index = index + 1
				field_empty = false
			end
		elseif quote_byte and byte == quote_byte and field_empty then
			in_quotes = true
			quote_start_line = line_number
			index = index + 1
			field_start = index
		elseif byte == delimiter_byte then
			finish_field(index - 1)
			index = index + 1
			field_start = index
		elseif byte == 10 or byte == 13 and settings.line_endings == "any" then
			finish_field(index - 1)
			finish_row(line_number, index - 1)
			if byte == 13 and text:byte(index + 1) == 10 then
				index = index + 2
			else
				index = index + 1
			end
			line_number = line_number + 1
			row_start_line = line_number
			field_start = index
			row_source_offsets[#row_source_offsets + 1] = index
			ended_with_row_separator = index > text_length
		else
			if after_quote then
				-- Preserve loose trailing text and expose the malformed field to previews
				add_error("trailing_text", line_number, "Unexpected text after closing quote")
				after_quote = false
			end
			index = index + 1
			field_empty = false
		end
	end

	if in_quotes then
		add_error("unclosed_quote", quote_start_line, "Unclosed quoted field")
	end

	finish_field(text_length)
	finish_row(line_number, text_length)

	return {
		rows = rows,
		row_source_offsets = row_source_offsets,
		row_end_offsets = row_end_offsets,
		row_source_lines = row_source_lines,
		row_end_lines = row_end_lines,
		max_columns = max_columns,
		errors = errors,
		row_count = #row_source_lines,
		has_terminal_empty_row = ended_with_row_separator,
		text_length = text_length,
		delimiter = settings.delimiter,
		quote = settings.quote,
		line_endings = settings.line_endings,
	}
end

--- Parses byte-delimited text into rows while preserving empty cells and rows
-- @param text string source text
-- @param delimiter string one-byte field delimiter
-- @param options table|nil parser options
-- @return table sheet parsed rows, source lines, column count, and recoverable errors
function spreadsheet.parse_delimited(text, delimiter, options)
	local settings = get_delimited_settings(text, delimiter, options)

	return scan_delimited(text, settings, true)
end

--- Index logical record boundaries without retaining cell values
-- @param text string source text
-- @param delimiter string one-byte field delimiter
-- @param options table|nil parser options
-- @return table index record offsets, source lines, column count, and errors
function spreadsheet.index_delimited(text, delimiter, options)
	local settings = get_delimited_settings(text, delimiter, options)

	return scan_delimited(text, settings, false)
end

--- Parse an indexed inclusive record range and retain absolute source locations
-- @param text string source text used to create the index
-- @param delimiter string one-byte field delimiter
-- @param record_index table result from 'index_delimited'
-- @param first_row number first logical record
-- @param last_row number last logical record
-- @param options table|nil parser options
-- @return table sheet parsed rows within the requested range
function spreadsheet.parse_delimited_range(text, delimiter, record_index, first_row, last_row, options)
	local settings = get_delimited_settings(text, delimiter, options)
	assert(type(record_index) == "table", "record index must be a table")
	assert(record_index.text_length == #text, "record index does not match source length")
	assert(record_index.delimiter == settings.delimiter, "record index delimiter differs")
	assert(record_index.quote == settings.quote, "record index quote differs")
	assert(record_index.line_endings == settings.line_endings, "record index line endings differ")

	first_row = math.max(1, math.floor(tonumber(first_row) or 1))
	last_row = math.min(record_index.row_count, math.floor(tonumber(last_row) or first_row))
	assert(first_row <= last_row, "record range must contain at least one row")

	local first_offset = record_index.row_source_offsets[first_row]
	local last_offset = record_index.row_end_offsets[last_row]
	local sheet = scan_delimited(text:sub(first_offset, last_offset), settings, true)
	local source_line_offset = record_index.row_source_lines[first_row] - 1

	for row_index = 1, #sheet.rows do
		local absolute_row = first_row + row_index - 1
		sheet.row_source_offsets[row_index] = record_index.row_source_offsets[absolute_row]
		sheet.row_end_offsets[row_index] = record_index.row_end_offsets[absolute_row]
		sheet.row_source_lines[row_index] = record_index.row_source_lines[absolute_row]
		sheet.row_end_lines[row_index] = record_index.row_end_lines[absolute_row]
	end

	for error_index = 1, #sheet.errors do
		sheet.errors[error_index].line = sheet.errors[error_index].line + source_line_offset
	end

	sheet.first_row = first_row
	sheet.last_row = last_row
	sheet.total_rows = record_index.row_count

	return sheet
end

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