-- lx-bundle:true

--- Read-only delimited-text table built directly on core preview
-- @module plugins.tabpreview.view

local common = require("core.common")
local core = require("core")
local fonts = require("core.style.fonts")
local open_handler = require("core.open_handler")
local spreadsheet = require("core.lib.spreadsheet")
local utf8help = require("core.text.utf8help")
local preview_export = require("core.preview.export")
local PreviewView = require("core.preview.view")
local preview_design = require("plugins.tabpreview.design")

local TabPreview = PreviewView:extend()

local MAX_PREVIEW_COLUMNS = 128
local MAX_PREVIEW_ERRORS = 20
local MAX_CELL_CHARACTERS = 80

-- Keep delimiter behavior explicit for every format routed to this plugin
local format_options = {
	csv = { delimiter = "," },
	tsv = { delimiter = "\t", quote = false },
	psv = { delimiter = "|" },
}

TabPreview.is_tabular_preview = true
TabPreview.preview_design = preview_design
TabPreview.preview_horizontal_scroll = true
TabPreview.preview_line_height_scale = preview_design.typography.line_height_scale

--- Scale one logical measurement to screen pixels
-- @param value number logical measurement
-- @return number pixels
local function scale_measurement(value)
	return value * (SCALE or 1)
end

--- Load the two faces needed by table cells and headers
-- @param zoom_multiplier number view-local content zoom
-- @return table preview_fonts
local function load_preview_fonts(zoom_multiplier)
	local size = preview_design.typography.font_size
		* (SCALE or 1)
		* (tonumber(zoom_multiplier) or 1)

	return {
		body = fonts.load_style(size),
		header = fonts.load_style(size, { bold = true }),
	}
end

--- Return a bounded single-line cell label for stable preview layout
-- @param value any parsed cell value
-- @return string text
local function get_preview_cell_text(value)
	local text = spreadsheet.cell_text(value)

	if utf8help.len(text) <= MAX_CELL_CHARACTERS then
		return text
	end

	return utf8help.sub(text, 1, MAX_CELL_CHARACTERS) .. "…"
end

--- Build one fixed-width cell run
-- @param font userdata cell font
-- @param text string visible cell text
-- @param width number complete cell width
-- @param syntax_type string|nil optional live color role
-- @return table run measured preview run
local function make_cell_run(font, text, width, syntax_type)
	return {
		font = font,
		text = text,
		text_offset = scale_measurement(preview_design.table.cell_padding_horizontal),
		width = width,
		syntax_type = syntax_type,
		options = {},
	}
end

--- Return the complete width represented by table columns
-- @param column_widths table measured widths
-- @return number width
local function get_table_width(column_widths)
	local width = 0

	for column_index = 1, #column_widths do
		width = width + column_widths[column_index]
	end

	return width
end

--- Draw one table row background and its complete grid
-- @param x number row left edge
-- @param y number row top edge
-- @param height number row height
-- @param row table table decoration
local function draw_table_row(x, y, height, row)
	local line_width = preview_design.get_table_frame_width()
	local frame_color = preview_design.get_table_frame_color()
	local inner_color = preview_design.get_table_inner_line_color()
	local table_width = get_table_width(row.column_widths)

	if row.header then
		renderer.draw_rect(x, y, table_width, height, preview_design.get_table_header_background_color())
	elseif row.row_index % 2 == 0 then
		renderer.draw_rect(x, y, table_width, height, preview_design.get_table_alternate_background_color())
	end

	-- Row numbers retain a header surface even across alternating data rows
	if not row.header then
		renderer.draw_rect(
			x,
			y,
			row.column_widths[1],
			height,
			preview_design.get_table_header_background_color()
		)
	end

	local divider_x = x
	renderer.draw_rect(divider_x, y, line_width, height, frame_color)

	for column_index = 1, #row.column_widths do
		divider_x = divider_x + row.column_widths[column_index]
		local divider_color = column_index == #row.column_widths and frame_color or inner_color

		renderer.draw_rect(divider_x - line_width, y, line_width, height, divider_color)
	end

	if row.header then
		renderer.draw_rect(x, y, table_width, line_width, frame_color)
	end

	renderer.draw_rect(
		x,
		y + height - line_width,
		table_width,
		line_width,
		row.last and frame_color or inner_color
	)
end

-- Core preview calls this narrow adapter before it paints each row's text
TabPreview.preview_table_renderer = {
	draw_row = draw_table_row,
}

--- Build one read-only preview for a delimited text document
-- @param doc table source document
function TabPreview:new(doc)
	TabPreview.super.new(self, doc)
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.zoom_multiplier = tonumber(doc.tabpreview_zoom_multiplier) or 1
	self.preview_fonts = load_preview_fonts(self.zoom_multiplier)
	self.preview_source_text = nil
	self.preview_index = nil
	self.loaded_sheet = nil
	self.loaded_first_row = nil
	self.loaded_last_row = nil
	self.column_widths = nil
	self.parse_change_id = nil
	self.parse_format_name = nil
	self.layout_key = nil
	self:reset_layout()
end

--- Keep shared zoom changes local to this table preview
-- @return boolean apply_absolute_scale
function TabPreview:should_apply_absolute_scale()
	return false
end

--- Return current view-local content zoom
-- @return number zoom_multiplier
function TabPreview:get_zoom_multiplier()
	return self.zoom_multiplier
end

--- Apply view-local zoom while retaining the visible source row
-- @param zoom_multiplier number requested zoom multiplier
-- @return boolean changed
function TabPreview:set_zoom_multiplier(zoom_multiplier)
	local requested_zoom = common.clamp(tonumber(zoom_multiplier) or 1, 0.25, 4)

	if math.abs(requested_zoom - self.zoom_multiplier) < 1e-6 then
		return false
	end

	self:rebuild_layout()
	local anchor = self:capture_layout_anchor()
	self.zoom_multiplier = requested_zoom
	self.doc.tabpreview_zoom_multiplier = requested_zoom
	self.preview_fonts = load_preview_fonts(requested_zoom)
	self.column_widths = nil
	self.layout_key = nil
	self:rebuild_layout()
	self:scroll_to_source_line(anchor.source_line)
	self:clamp_scroll_position()
	core.redraw = true

	return true
end

--- Return ordinary table-row height used by document scrolling
-- @return number line_height
function TabPreview:get_line_height()
	return self.preview_fonts.body:get_height()
		+ scale_measurement(preview_design.table.cell_padding_vertical) * 2
end

--- Return the complete pane width used by the table
-- @return number width
function TabPreview:get_document_viewport_width()
	return self.size.x
end

--- Return width available inside document padding
-- @return number width
function TabPreview:get_render_width()
	return math.max(
		preview_design.document.minimum_content_width,
		self:get_document_viewport_width()
			- scale_measurement(preview_design.document.padding_horizontal) * 2
	)
end

--- Create a detached virtualized view for fixed-page export
-- @return table view detached table preview
-- @return number width output page width
-- @return number height output page height
function TabPreview:create_export_view()
	local export_width, export_height = preview_export.get_page_size()
	local export_view = TabPreview(self.doc)
	local live_width = math.max(1, self:get_document_viewport_width())
	local export_zoom = self.zoom_multiplier * export_width / live_width

	-- Match the live table's width proportion on the fixed export page
	export_view.position.x = 0
	export_view.position.y = 0
	export_view.size.x = export_width
	export_view.size.y = export_height
	export_view.zoom_multiplier = export_zoom
	export_view.preview_fonts = load_preview_fonts(export_zoom)
	export_view:rebuild_layout()

	return export_view, export_width, export_height
end

--- Return row-aligned page starts across the complete virtualized table
-- @param page_height number output page height
-- @return table starts document scroll position for every page
function TabPreview:get_export_page_starts(page_height)
	local starts = { 0 }
	local page_start = 0
	local row_height = self:get_line_height()
	local padding_top = scale_measurement(preview_design.document.padding_vertical)
	local total_height = self:get_scrollable_size()

	while page_start + page_height < total_height do
		local boundary = page_start + page_height
		local next_start = padding_top
			+ math.floor(math.max(0, boundary - padding_top) / row_height) * row_height

		-- Pages shorter than one row are the only case that cannot preserve a whole row
		if next_start <= page_start then
			next_start = page_start + page_height
		end

		starts[#starts + 1] = next_start
		page_start = next_start
	end

	return starts
end

--- Load the virtualized rows surrounding one export page
function TabPreview:prepare_export_page()
	self.layout_key = nil
	self:rebuild_layout()
end

--- Return the text represented by the current document model
-- @return string text
function TabPreview:get_document_text()
	local last_line = #self.doc.lines
	local last_column = #(self.doc.lines[last_line] or "")

	return self.doc:get_text(1, 1, last_line, last_column)
end

--- Return delimiter settings for the document's detected format
-- @return string format_name
-- @return table options
function TabPreview:get_format_options()
	local format_name = open_handler.get_format_name(self.doc.format)
	local options = format_options[format_name]

	assert(options, "tabpreview received an unsupported format")

	return format_name, options
end

--- Return a cached record index and refresh it only after source or format changes
-- @param change_id number source change identifier
-- @return table record_index lightweight logical row locations and diagnostics
function TabPreview:get_preview_index(change_id)
	local format_name, options = self:get_format_options()

	if self.preview_index
		and self.parse_change_id == change_id
		and self.parse_format_name == format_name
	then
		return self.preview_index
	end

	self.preview_source_text = self:get_document_text()
	self.preview_index = spreadsheet.index_delimited(self.preview_source_text, options.delimiter, {
		quote = options.quote,
	})
	self.loaded_sheet = nil
	self.loaded_first_row = nil
	self.loaded_last_row = nil
	self.column_widths = nil
	self.parse_change_id = change_id
	self.parse_format_name = format_name

	return self.preview_index
end

--- Return data rows after hiding only the synthetic row after a terminal separator
-- @param record_index table lightweight logical row index
-- @return number row_count displayed data rows
local function get_data_row_count(record_index)
	return record_index.row_count - (record_index.has_terminal_empty_row and 1 or 0)
end

--- Return the page-aligned data range covering the viewport and adjacent pages
-- @param view table tabular preview
-- @param row_count number complete displayed data row count
-- @return number first_row first materialized data row
-- @return number last_row last materialized data row
-- @return number page_rows number of rows represented by one viewport page
local function get_loaded_range(view, row_count)
	local row_height = view:get_line_height()
	local page_rows = math.max(1, math.ceil(view:get_scroll_viewport_height() / row_height))
	local visible_first = common.clamp(
		math.floor(math.max(0, (view.scroll.y or 0) - row_height) / row_height) + 1,
		1,
		row_count
	)
	local page_index = math.floor((visible_first - 1) / page_rows)
	local first_row = math.max(1, (page_index - 1) * page_rows + 1)
	local last_row = math.min(row_count, (page_index + 2) * page_rows)

	return first_row, last_row, page_rows
end

--- Create minimum widths before loaded values can expand their columns
-- @param view table tabular preview
-- @param row_count number complete displayed data row count
-- @param column_count number displayed data column count
-- @return table widths including row-number column
local function create_column_widths(view, row_count, column_count)
	local body_font = view.preview_fonts.body
	local header_font = view.preview_fonts.header
	local padding = scale_measurement(preview_design.table.cell_padding_horizontal) * 2
	local minimum_width = scale_measurement(preview_design.table.minimum_column_width)
	local widths = {
		math.max(
			scale_measurement(preview_design.table.minimum_row_number_width),
			body_font:get_width(tostring(row_count)) + padding
		),
	}

	for column_index = 1, column_count do
		widths[column_index + 1] = math.max(
			minimum_width,
			header_font:get_width(tostring(column_index)) + padding
		)
	end

	return widths
end

--- Expand persistent column widths from the currently loaded rows
-- @param view table tabular preview
-- @param sheet table parsed sheet
-- @param column_count number displayed data columns
-- @return table widths including row-number column
local function measure_loaded_column_widths(view, sheet, column_count)
	local body_font = view.preview_fonts.body
	local padding = scale_measurement(preview_design.table.cell_padding_horizontal) * 2
	local widths = view.column_widths

	for row_index = 1, #sheet.rows do
		local row = sheet.rows[row_index]

		for column_index = 1, column_count do
			local text = get_preview_cell_text(row[column_index])
			widths[column_index + 1] = math.max(
				widths[column_index + 1],
				body_font:get_width(text) + padding
			)
		end
	end

	return widths
end

--- Add one grid row through core preview's measured layout
-- @param runs table fixed-width cell runs
-- @param widths table shared column widths
-- @param source_line number owning physical source line
-- @param row_index number zero for header, otherwise data row
-- @param last boolean true for final displayed data row
function TabPreview:add_table_row(runs, widths, source_line, row_index, last)
	self.layout_source_line = source_line
	self:add_layout_row(
		{
			runs = runs,
			min_height = self.preview_fonts.body:get_height(),
			width = get_table_width(widths),
		},
		0,
		{
			fixed_height = true,
			content_padding_top = scale_measurement(preview_design.table.cell_padding_vertical),
			content_padding_bottom = scale_measurement(preview_design.table.cell_padding_vertical),
			table_row = {
				column_widths = widths,
				header = row_index == 0,
				row_index = row_index,
				last = last,
			},
		}
	)
end

--- Add one visible parser or truncation notice beneath the grid
-- @param text string notice text
-- @param source_line number owning physical source line
-- @param is_error boolean true for parser diagnostics
function TabPreview:add_notice_row(text, source_line, is_error)
	local font = self.preview_fonts.body
	local padding = scale_measurement(preview_design.table.cell_padding_horizontal)
	self.layout_source_line = source_line
	self:add_layout_row(
		{
			runs = {
				{
					font = font,
					text = text,
					width = font:get_width(text),
					syntax_type = is_error and nil or "comment",
					options = {},
				},
			},
		},
		scale_measurement(10),
		{
			x_offset = is_error and padding or 0,
			content_padding_top = is_error and padding or 0,
			content_padding_bottom = is_error and padding or 0,
			error_block = is_error,
		}
	)
end

--- Rebuild only the page-aligned record window surrounding the viewport
function TabPreview:rebuild_layout()
	if self.size.x <= 0 or self.size.y <= 0 then
		return
	end

	local change_id = self.doc:get_change_id()
	local format_name = open_handler.get_format_name(self.doc.format)
	local record_index = self:get_preview_index(change_id)
	local row_count = get_data_row_count(record_index)
	local first_row, last_row, page_rows = get_loaded_range(self, row_count)
	local layout_key = table.concat({
		change_id,
		tostring(format_name),
		tostring(self.size.x),
		tostring(self.size.y),
		tostring(self.zoom_multiplier),
		first_row,
		last_row,
		page_rows,
	}, ":")

	if self.layout_key == layout_key then
		return
	end

	local _, options = self:get_format_options()
	local sheet = spreadsheet.parse_delimited_range(
		self.preview_source_text,
		options.delimiter,
		record_index,
		first_row,
		last_row,
		{ quote = options.quote }
	)
	local column_count = math.min(math.max(1, record_index.max_columns), MAX_PREVIEW_COLUMNS)
	self.column_widths = self.column_widths
		or create_column_widths(self, row_count, column_count)
	local widths = measure_loaded_column_widths(self, sheet, column_count)
	self.loaded_sheet = sheet
	self.loaded_first_row = first_row
	self.loaded_last_row = last_row
	self:reset_layout()

	if first_row == 1 then
		local header_runs = { make_cell_run(self.preview_fonts.header, "#", widths[1], "comment") }
		for column_index = 1, column_count do
			header_runs[#header_runs + 1] = make_cell_run(
				self.preview_fonts.header,
				tostring(column_index),
				widths[column_index + 1],
				"keyword"
			)
		end
		self:add_table_row(header_runs, widths, 1, 0, false)
	else
		-- Reserve exact vertical space for the header and preceding unloaded records
		self.layout_height = first_row * self:get_line_height()
	end

	for local_row = 1, #sheet.rows do
		local row_index = first_row + local_row - 1
		local source_row = sheet.rows[local_row]
		local runs = {
			make_cell_run(self.preview_fonts.body, tostring(row_index), widths[1], "comment"),
		}

		for column_index = 1, column_count do
			runs[#runs + 1] = make_cell_run(
				self.preview_fonts.body,
				get_preview_cell_text(source_row[column_index]),
				widths[column_index + 1]
			)
		end

		self:add_table_row(
			runs,
			widths,
			sheet.row_source_lines[local_row] or row_index,
			row_index,
			row_index == row_count
		)
	end

	-- Keep the scrollbar proportional to the whole table while rows remain unloaded
	self.layout_height = (row_count + 1) * self:get_line_height()
	self.layout_width = math.max(self.layout_width, get_table_width(widths))

	if record_index.max_columns > column_count then
		self:add_notice_row(
			string.format("Preview limited to the first %d columns", MAX_PREVIEW_COLUMNS),
			record_index.row_source_lines[row_count] or 1,
			false
		)
	end

	local error_count = math.min(#record_index.errors, MAX_PREVIEW_ERRORS)

	for error_index = 1, error_count do
		local parse_error = record_index.errors[error_index]
		self:add_notice_row(
			string.format("Line %d: %s", parse_error.line, parse_error.message),
			parse_error.line,
			true
		)
	end

	if #record_index.errors > error_count then
		self:add_notice_row(
			string.format(
				"%d additional parse errors not shown",
				#record_index.errors - error_count
			),
			record_index.errors[error_count].line,
			false
		)
	end

	self.layout_key = layout_key
end

--- Find the logical record containing or preceding one physical source line
-- @param record_index table lightweight logical row index
-- @param source_line number editor source line
-- @param row_count number displayed data row count
-- @return number row_index closest logical record
local function find_source_row(record_index, source_line, row_count)
	local low = 1
	local high = row_count
	local target = 1

	while low <= high do
		local middle = math.floor((low + high) / 2)

		if record_index.row_source_lines[middle] <= source_line then
			target = middle
			low = middle + 1
		else
			high = middle - 1
		end
	end

	return target
end

--- Place the closest indexed data row at the top and load its surrounding pages
-- @param source_line number editor source line
-- @return boolean found
function TabPreview:scroll_to_source_line(source_line)
	local record_index = self:get_preview_index(self.doc:get_change_id())
	local row_count = get_data_row_count(record_index)

	if row_count < 1 then
		return false
	end

	source_line = math.max(1, math.floor(tonumber(source_line) or 1))
	local row_index = find_source_row(record_index, source_line, row_count)
	local target_y = row_index * self:get_line_height()
	self.scroll.y = common.clamp(target_y, 0, self:get_scroll_max_y())
	self.scroll.to.y = self.scroll.y
	self.layout_key = nil
	self:rebuild_layout()

	return true
end

--- Return complete scrollable table height
-- @return number height
function TabPreview:get_scrollable_size()
	self:rebuild_layout()

	return self.layout_height + scale_measurement(preview_design.document.padding_vertical) * 2
end

--- Capture source position before an automatic file reload
-- @return table anchor
function TabPreview:capture_reload_view_state()
	self:rebuild_layout()

	return self:capture_layout_anchor()
end

--- Reparse changed source and restore the previous source row
-- @param anchor table|nil source position
function TabPreview:restore_reload_view_state(anchor)
	self.preview_source_text = nil
	self.preview_index = nil
	self.loaded_sheet = nil
	self.loaded_first_row = nil
	self.loaded_last_row = nil
	self.column_widths = nil
	self.parse_change_id = nil
	self.parse_format_name = nil
	self.layout_key = nil
	self:rebuild_layout()

	if type(anchor) == "table" then
		self:scroll_to_source_line(anchor.source_line)
	end
end

--- Draw the rendered table and both shared scrollbars
function TabPreview:draw()
	self:draw_document()
	self:draw_scrollbar()
end

return TabPreview
