-- lx-bundle:true

--- Markdown table recognition, layout, and drawing
-- @module plugins.mdpreview.tables

local markdown_design = require("plugins.mdpreview.design")
local markdown_escapes = require("plugins.mdpreview.escapes")

local tables = {}

--- Return the horizontal padding used inside each table cell
-- @return number padding scaled cell padding
local function get_cell_padding_x()
	return markdown_design.get_table_cell_padding_horizontal()
end

--- Count visible Unicode characters while tolerating damaged source text
-- @param text string source text
-- @return number count character count
local function count_characters(text)
	return utf8.len(text) or #text
end

--- Return only the text that the inline Markdown parser will display
-- Link targets and formatting punctuation must not affect column demand
-- @param text string source cell text
-- @param parse_inline function shared inline Markdown parser
-- @return string text visible cell text
local function get_visible_cell_text(text, parse_inline)
	local runs = parse_inline(text, {})
	local parts = {}

	for run_index = 1, #runs do
		parts[run_index] = runs[run_index].text
	end

	return table.concat(parts)
end

--- Return whether every source header cell contains only whitespace
-- @param cells table source header cells
-- @return boolean empty true when the table has no visible header text
local function has_empty_header(cells)
	for cell_index = 1, #cells do
		-- Pattern breakdown: '^%s*$' accepts only an empty or whitespace-only cell
		if cells[cell_index]:match("^%s*$") == nil then
			return false
		end
	end

	return true
end

--- Return the longest whitespace-separated part of one cell
-- @param text string source cell text
-- @return number count longest unbreakable character count
local function get_longest_unbreakable_length(text)
	local longest_length = 0

	-- Pattern breakdown: '%S+' finds each run that cannot wrap at whitespace
	for part in text:gmatch("%S+") do
		longest_length = math.max(longest_length, count_characters(part))
	end

	return longest_length
end

--- Return one stable matching key for a table's normalized headers
-- @param header_cells table source header cells
-- @param parse_inline function shared inline Markdown parser
-- @return string key normalized header signature
local function get_header_key(header_cells, parse_inline)
	local normalized_headers = {}

	for column_index = 1, #header_cells do
		-- Pattern breakdown: '%s+' collapses changing header whitespace to one space
		normalized_headers[column_index] =
			get_visible_cell_text(header_cells[column_index], parse_inline):lower():gsub("%s+", " ")
	end

	return table.concat(normalized_headers, "\31")
end

--- Return a configured percentile from one non-empty list of character counts
-- @param values table character counts
-- @return number value selected percentile value
local function get_length_percentile(values)
	local sorted_values = {}

	for value_index = 1, #values do
		sorted_values[value_index] = values[value_index]
	end

	table.sort(sorted_values)

	local percentile_index = math.max(
		1,
		math.ceil(#sorted_values * markdown_design.tables.column_length_percentile)
	)

	return sorted_values[percentile_index]
end

--- Allocate character demand across whole units of the configured table grid
-- Each column keeps a stable bound while higher demand receives units first
-- @param column_demands table preferred character demand per column
-- @param table_width number complete available table width
-- @return table column_widths snapped column widths
local function allocate_column_widths(column_demands, table_width)
	local unit_count = math.max(
		#column_demands,
		math.floor(markdown_design.tables.column_grid_units)
	)
	local unit_width = table_width / unit_count
	local minimum_units = math.max(
		1,
		math.min(
			markdown_design.tables.minimum_column_units,
			math.floor(unit_count / #column_demands)
		)
	)
	local maximum_units = math.max(
		markdown_design.tables.maximum_column_units,
		math.ceil(unit_count / #column_demands)
	)
	local assigned_units = {}
	local distributed_units = minimum_units * #column_demands

	for column_index = 1, #column_demands do
		assigned_units[column_index] = minimum_units
	end

	-- Assign each remaining unit to the strongest demand per unit already held
	while distributed_units < unit_count do
		local best_column
		local best_priority

		for column_index = 1, #column_demands do
			if assigned_units[column_index] < maximum_units then
				local priority = column_demands[column_index] / (assigned_units[column_index] + 1)

				if not best_priority or priority > best_priority then
					best_column = column_index
					best_priority = priority
				end
			end
		end

		-- The effective maximum always leaves enough capacity, so a column must remain
		assigned_units[best_column] = assigned_units[best_column] + 1
		distributed_units = distributed_units + 1
	end

	local column_widths = {}

	for column_index = 1, #assigned_units do
		column_widths[column_index] = assigned_units[column_index] * unit_width
	end

	return column_widths
end

--- Return whether a source range contains only blank lines
-- @param lines table Markdown source lines
-- @param first_line number first source line to inspect
-- @param last_line number last source line to inspect
-- @param strip_line_ending function line-ending helper
-- @return boolean blank true when no visible source appears
local function contains_only_blank_lines(lines, first_line, last_line, strip_line_ending)
	if first_line > last_line then
		return false
	end

	for line_index = first_line, last_line do
		local line = strip_line_ending(lines[line_index])

		-- Pattern breakdown: '^%s*$' accepts only an empty or whitespace-only separator line
		if line:match("^%s*$") == nil then
			return false
		end
	end

	return true
end

--- Split one possible Markdown table row into trimmed cells
-- Escaped pipes stay inside their cell instead of becoming separators
-- @param line string source line
-- @return table|nil cells parsed cells, or nil when no separator exists
local function read_cells(line)
	local text = line:match("^%s*(.-)%s*$")
	local cells = {}
	local cell = {}
	local found_separator = false
	local position = 1

	if text:sub(1, 1) == "|" then
		found_separator = true
		text = text:sub(2)
	end
	if text:sub(-1) == "|" and not markdown_escapes.is_escaped(text, #text) then
		found_separator = true
		text = text:sub(1, -2)
	end

	while position <= #text do
		local character = text:sub(position, position)
		local escaped_character
		local escaped_next_position

		if character == "\\" then
			escaped_character, escaped_next_position = markdown_escapes.read(text, position)
		end

		if escaped_character == "|" and escaped_next_position then
			cell[#cell + 1] = "|"
			position = escaped_next_position
		elseif escaped_character and escaped_next_position then
			-- Other escapes remain intact until the shared inline parser styles the cell
			cell[#cell + 1] = text:sub(position, escaped_next_position - 1)
			position = escaped_next_position
		elseif character == "|" then
			cells[#cells + 1] = table.concat(cell):match("^%s*(.-)%s*$")
			cell = {}
			found_separator = true
			position = position + 1
		else
			cell[#cell + 1] = character
			position = position + 1
		end
	end

	cells[#cells + 1] = table.concat(cell):match("^%s*(.-)%s*$")

	return found_separator and cells or nil
end

--- Read a Markdown table delimiter row and its column alignments
-- @param line string possible delimiter row
-- @return table|nil alignments 'left', 'center', or 'right' per column
local function read_delimiter(line)
	local cells = read_cells(line)
	local alignments = {}

	if not cells then
		return nil
	end

	for cell_index = 1, #cells do
		local marker = cells[cell_index]
		local starts_colon = marker:sub(1, 1) == ":"
		local ends_colon = marker:sub(-1) == ":"
		local dashes = marker:gsub("^:", ""):gsub(":$", "")

		-- Pattern breakdown: '^%-+$' accepts one or more delimiter dashes
		if dashes:match("^%-+$") == nil then
			return nil
		end

		alignments[cell_index] = starts_colon and ends_colon and "center"
			or ends_colon and "right"
			or "left"
	end

	return alignments
end

--- Find table blocks and attach lightweight layout details to source contexts
-- @param lines table document source lines
-- @param line_contexts table pre-scanned block contexts
-- @param options table width, cached definitions, inline parser, font, and line helpers
-- @param estimated_heights table estimated source heights to update
-- @return table definitions reusable width-independent table blocks
function tables.scan(lines, line_contexts, options, estimated_heights)
	local table_line_index = 1
	local previous_definition
	local definitions = options.definitions or {}

	while not options.definitions and table_line_index < #lines do
		local header_context = line_contexts[table_line_index]
		local delimiter_context = line_contexts[table_line_index + 1]
		local header_cells = read_cells(options.strip_line_ending(lines[table_line_index]))
		local alignments = read_delimiter(options.strip_line_ending(lines[table_line_index + 1]))
		local outside_fence = header_context.fence_marker == nil
			and header_context.is_fence_delimiter ~= true
			and header_context.code_block == nil
			and delimiter_context.fence_marker == nil
			and delimiter_context.is_fence_delimiter ~= true
			and delimiter_context.code_block == nil

		if outside_fence and header_cells and alignments and #header_cells == #alignments then
			local headerless = has_empty_header(header_cells)
			local follows_previous_table = previous_definition
				and contains_only_blank_lines(
					lines,
					previous_definition.last_line + 1,
					table_line_index - 1,
					options.strip_line_ending
				)
			local definition = {
				alignments = alignments,
				adjacent_previous = follows_previous_table == true,
				first_line = table_line_index,
				first_visible_line = headerless and table_line_index + 2 or table_line_index,
				header_cells = header_cells,
				headerless = headerless,
				last_line = table_line_index + 1,
			}
			local row_index = table_line_index + 2

			header_context.table_cells = header_cells
			header_context.table_definition = definition
			header_context.table_header = not headerless
			header_context.table_hidden_header = headerless
			delimiter_context.table_delimiter = true

			while row_index <= #lines do
				local row_context = line_contexts[row_index]
				local row_cells = read_cells(options.strip_line_ending(lines[row_index]))
				local row_outside_fence = row_context.fence_marker == nil
					and row_context.is_fence_delimiter ~= true
					and row_context.code_block == nil

				if not row_outside_fence or not row_cells then
					break
				end

				row_context.table_cells = row_cells
				row_context.table_definition = definition
				definition.last_line = row_index

				row_index = row_index + 1
			end

			definitions[#definitions + 1] = definition
			previous_definition = definition
			table_line_index = row_index
		else
			table_line_index = table_line_index + 1
		end
	end

	local families = {}

	for definition_index = 1, #definitions do
		local definition = definitions[definition_index]
		-- Empty headers do not identify a family, so unrelated headerless tables stay independent
		local header_key = definition.headerless
			and definition
			or get_header_key(definition.header_cells, options.parse_inline)
		local family = families[header_key]

		if not family then
			family = {
				definitions = {},
				lengths = {},
				longest_unbreakable = {},
			}
			families[header_key] = family

			for column_index = 1, #definition.alignments do
				family.lengths[column_index] = {}
				family.longest_unbreakable[column_index] = 0
			end
		end

		family.definitions[#family.definitions + 1] = definition

		for source_line = definition.first_line, definition.last_line do
			local row_context = line_contexts[source_line]

			if row_context.table_cells then
				row_context.table_visible_cells = {}

				for column_index = 1, #definition.alignments do
					local cell_text = row_context.table_cells[column_index] or ""
					local visible_text = get_visible_cell_text(cell_text, options.parse_inline)
					local column_lengths = family.lengths[column_index]

					row_context.table_visible_cells[column_index] = visible_text
					column_lengths[#column_lengths + 1] = count_characters(visible_text)
					family.longest_unbreakable[column_index] = math.max(
						family.longest_unbreakable[column_index],
						get_longest_unbreakable_length(visible_text)
					)
				end
			end
		end
	end

	for _, family in pairs(families) do
		local column_demands = {}

		for column_index = 1, #family.lengths do
			-- Typical long cells guide the width while an unbreakable part can raise the demand
			column_demands[column_index] = math.max(
				1,
				get_length_percentile(family.lengths[column_index]),
				family.longest_unbreakable[column_index]
			)
		end

		local column_widths = allocate_column_widths(column_demands, options.width)

		for definition_index = 1, #family.definitions do
			family.definitions[definition_index].column_widths = column_widths
		end
	end

	for definition_index = 1, #definitions do
		local definition = definitions[definition_index]

		for source_line = definition.first_line, definition.last_line do
			local row_context = line_contexts[source_line]

			if row_context.table_delimiter or row_context.table_hidden_header then
				row_context.estimated_height = 0
			else
				local maximum_rows = 1

				for column_index = 1, #definition.alignments do
					local content_width = math.max(
						1,
						definition.column_widths[column_index] - get_cell_padding_x() * 2
					)
					local estimated_width = count_characters(
						row_context.table_visible_cells[column_index]
					) * options.average_character_width
					maximum_rows = math.max(maximum_rows, math.ceil(estimated_width / content_width))
				end

				row_context.estimated_height = maximum_rows * options.line_height
					+ markdown_design.tables.cell_padding_vertical * 2 * (SCALE or 1)

				if row_context.table_header then
					row_context.estimated_height = row_context.estimated_height
						+ tables.get_gap_before(definition)
				end
			end

			estimated_heights[source_line] = row_context.estimated_height
		end
	end

	return definitions
end

--- Build aligned visual rows for one Markdown table source row
-- @param view table Markdown preview
-- @param cells table visible cell source
-- @param definition table shared column widths and alignments
-- @param is_header boolean true for the header row
-- @param parse_inline function inline Markdown parser
-- @return table rows wrapped table rows
function tables.build_rows(view, cells, definition, is_header, parse_inline)
	local padding_x = get_cell_padding_x()
	local cell_rows = {}
	local visual_row_count = 1

	for column_index = 1, #definition.column_widths do
		local column_width = definition.column_widths[column_index]
		local content_width = math.max(1, column_width - padding_x * 2)
		local options = is_header and { bold = true } or {}
		local wrapped = view:wrap_inline_runs(
			parse_inline(cells[column_index] or "", options),
			nil,
			content_width,
			true
		)

		if #wrapped == 0 then
			wrapped[1] = { runs = {}, width = 0 }
		end

		cell_rows[column_index] = wrapped
		visual_row_count = math.max(visual_row_count, #wrapped)
	end

	local rows = {}

	for visual_index = 1, visual_row_count do
		local row = { runs = {}, width = 0, min_height = view.preview_fonts.body:get_height() }

		--- Add non-drawing width used for cell padding and alignment
		-- @param width number spacer width
		local function add_spacer(width)
			if width <= 0 then
				return
			end

			row.runs[#row.runs + 1] = {
				text = "",
				font = view.preview_fonts.body,
				width = width,
				options = {},
				spacer = true,
			}
			row.width = row.width + width
		end

		for column_index = 1, #definition.column_widths do
			local column_width = definition.column_widths[column_index]
			local content_width = math.max(1, column_width - padding_x * 2)
			local cell_row = cell_rows[column_index][visual_index] or { runs = {}, width = 0 }
			local free_width = math.max(0, content_width - cell_row.width)
			local alignment = definition.alignments[column_index]
			local leading = alignment == "right" and free_width
				or alignment == "center" and free_width / 2
				or 0
			local trailing = free_width - leading

			add_spacer(padding_x + leading)
			for run_index = 1, #cell_row.runs do
				local run = cell_row.runs[run_index]
				row.runs[#row.runs + 1] = run
				row.width = row.width + run.width
			end
			add_spacer(trailing + padding_x)
		end

		rows[visual_index] = row
	end

	return rows
end

--- Draw one table row's background and separator lines
-- @param x number row left edge
-- @param y number row top edge
-- @param height number row height
-- @param row table table decoration
function tables.draw_row(x, y, height, row)
	local line_width = markdown_design.get_table_frame_width()
	local inner_line_color = markdown_design.get_table_inner_line_color()
	local frame_line_color = markdown_design.get_table_frame_color()
	local table_width = 0

	for column_index = 1, #row.column_widths do
		table_width = table_width + row.column_widths[column_index]
	end

	if row.header then
		renderer.draw_rect(
			x,
			y,
			table_width,
			height,
			markdown_design.get_table_header_background_color()
		)
	end

	local divider_x = x
	renderer.draw_rect(divider_x, y, line_width, height, frame_line_color)
	for column_index = 1, #row.column_widths do
		divider_x = divider_x + row.column_widths[column_index]
		local is_outer_edge = column_index == #row.column_widths
		renderer.draw_rect(
			divider_x - line_width,
			y,
			line_width,
			height,
			is_outer_edge and frame_line_color or inner_line_color
		)
	end

	if row.top then
		renderer.draw_rect(x, y, table_width, line_width, frame_line_color)
	end
	if row.bottom then
		local bottom_color = (row.header or row.last) and frame_line_color or inner_line_color
		renderer.draw_rect(x, y + height - line_width, table_width, line_width, bottom_color)
	end
end

--- Return spacing before one table block
-- Consecutive tables receive an extra visual break beyond their separating blank line
-- @param definition table table definition
-- @return number gap scaled table gap
function tables.get_gap_before(definition)
	local gap = markdown_design.get_table_gap_above()

	if definition and definition.adjacent_previous then
		gap = gap + markdown_design.tables.adjacent_table_gap * (SCALE or 1)
	end

	return gap
end

--- Return the extra spacing reserved between consecutive tables
-- @return number gap scaled adjacent-table gap
function tables.get_adjacent_table_gap()
	return markdown_design.tables.adjacent_table_gap * (SCALE or 1)
end

--- Return vertical padding for one outer table-cell edge
-- @return number padding logical edge padding
function tables.get_cell_padding_y()
	return markdown_design.tables.cell_padding_vertical * (SCALE or 1)
end

return tables
