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

--- CSV alignment and conversion commands
-- @module plugins.csvutils.csvutils

local core = require("core")
local command = require("core.command")
local Doc = require("core.doc")
local utf8help = require("core.text.utf8help")

local function active_csv_doc()
	local view = core.active_view
	local doc = view and view.doc
	local filename = doc and doc.filename
	if type(filename) == "string" and filename:lower():match("%.csv$") then
		return doc
	end
	return nil
end

local function is_active_csv()
	return active_csv_doc() ~= nil
end

-- Keeps trailing-empty-line selection semantics consistent with other line commands
local function get_target_line_range(doc)
	if doc:has_selection() then
		local line1, _, line2, col2 = doc:get_selection(true)
		if line2 > line1 and col2 == 1 then
			line2 = line2 - 1
		end
		if line2 < line1 then
			line2 = line1
		end
		return line1, line2, true
	end
	return 1, #doc.lines, false
end

--- Parses standard double-quoted CSV text into unquoted cell values
-- @param text string CSV source
-- @return table records
local function parse_csv_records(text)
	local records = {}
	local record = {}
	local len = #text
	local field_parts = {}
	local field_start = 1
	local field_empty = true
	local i = 1
	local in_quotes = false

	if len == 0 then
		return { { "" } }
	end

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

	--- Finish the current field and prepare the next one
	-- @param last_index number inclusive final source byte
	local function finish_field(last_index)
		append_field_span(last_index)
		record[#record + 1] = table.concat(field_parts)
		field_parts = {}
		field_empty = true
	end

	while i <= len do
		local byte = text:byte(i)

		if in_quotes then
			if byte == 34 and text:byte(i + 1) == 34 then
				append_field_span(i - 1)
				field_parts[#field_parts + 1] = '"'
				i = i + 2
				field_start = i
				field_empty = false
			elseif byte == 34 then
				append_field_span(i - 1)
				in_quotes = false
				i = i + 1
				field_start = i
			else
				i = i + 1
				field_empty = false
			end
		elseif byte == 34 and field_empty then
			in_quotes = true
			i = i + 1
			field_start = i
		elseif byte == 44 then
			finish_field(i - 1)
			i = i + 1
			field_start = i
		elseif byte == 10 or byte == 13 then
			finish_field(i - 1)
			records[#records + 1] = record
			record = {}
			if byte == 13 and text:byte(i + 1) == 10 then
				i = i + 2
			else
				i = i + 1
			end
			field_start = i
		else
			i = i + 1
			field_empty = false
		end
	end

	finish_field(len)
	records[#records + 1] = record
	return records
end

--- Align CSV text by displayed column width
-- @param text string CSV text
-- @return string aligned_text Space-aligned text
local function align_csv_text(text)
	local records = parse_csv_records(text)
	local column_widths = {}
	local padding_cache = {}
	local output_lines = {}

	for _, record in ipairs(records) do
		for column_index = 1, #record do
			local value = record[column_index]
			local value_len = utf8help.width(value)
			local width = column_widths[column_index] or 0
			if value_len > width then
				column_widths[column_index] = value_len
			end
		end
	end

	for _, record in ipairs(records) do
		local parts = {}
		for column_index = 1, #record do
			local value = record[column_index]
			if column_index < #record then
				local pad = (column_widths[column_index] - utf8help.width(value)) + 2
				local spaces = padding_cache[pad]
				if not spaces then
					spaces = string.rep(" ", pad)
					padding_cache[pad] = spaces
				end
				parts[#parts + 1] = value .. spaces
			else
				parts[#parts + 1] = value
			end
		end
		output_lines[#output_lines + 1] = table.concat(parts)
	end

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

--- Convert CSV only when every field has an exact TSV representation
-- @param text string CSV source
-- @return string|nil tsv converted text
-- @return string|nil error conversion error
local function csv_to_tsv_text(text)
	local records = parse_csv_records(text)
	local output_lines = {}
	for _, record in ipairs(records) do
		for _, value in ipairs(record) do
			if value:find("\t", 1, true) or value:find("\r", 1, true) or value:find("\n", 1, true) then
				return nil, "CSV fields with tabs or line breaks cannot be converted to TSV safely"
			end
		end
		output_lines[#output_lines + 1] = table.concat(record, "\t")
	end

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

local function replace_extension(filename, extension)
	local base = filename or "untitled"
	local slash = base:match("^.*()[/\\]")
	local dot = base:match("^.*()%.")
	if dot and (not slash or dot > slash) then
		return base:sub(1, dot - 1) .. extension
	end
	return base .. extension
end

-- Opens a new document tab and fills it with provided content
local function open_generated_doc(source_doc, content, extension)
	local doc = Doc.create(replace_extension(source_doc.filename, extension))
	core.workspace_view:open_doc(doc)
	local last_line = #doc.lines
	local last_col = #(doc.lines[last_line] or "") + 1
	doc:remove(1, 1, last_line, last_col)
	doc:insert(1, 1, content)
	doc:set_selection(1, 1)
end

-- Returns selected text or whole-doc text for CSV alignment
local function get_target_text(doc)
	local line1, line2, has_selection = get_target_line_range(doc)
	if has_selection then
		return doc:get_text(line1, 1, line2, math.huge)
	end
	return doc:get_text(1, 1, #doc.lines, #doc.lines[#doc.lines])
end

command.register({
	["csv:align"] = {
		requires = { "doc", "edit" },
		predicate = is_active_csv,
		perform = function()
			local doc = active_csv_doc()
			local input_text = get_target_text(doc)
			local aligned_text = align_csv_text(input_text)
			open_generated_doc(doc, aligned_text, ".txt")
		end,
	},
	["csv:align-inplace"] = {
		requires = { "doc", "edit" },
		predicate = is_active_csv,
		perform = function()
			local doc = active_csv_doc()
			local line1, line2, has_selection = get_target_line_range(doc)
			if has_selection then
				doc:replace_range(line1, 1, line2, math.huge, function(text)
					return align_csv_text(text)
				end)
				return
			end
			doc:replace(function(text)
				return align_csv_text(text)
			end)
		end,
	},
	["csv:convert-to-tsv"] = {
		requires = { "doc", "edit" },
		predicate = is_active_csv,
		perform = function()
			local doc = active_csv_doc()
			local input_text = get_target_text(doc)
			local tsv_text, conversion_error = csv_to_tsv_text(input_text)
			if not tsv_text then
				core.error(conversion_error)
				return
			end
			open_generated_doc(doc, tsv_text, ".tsv")
		end,
	},
})

return {
	align = align_csv_text,
	parse = parse_csv_records,
	to_tsv = csv_to_tsv_text,
}
