-- lx-bundle:true

--- Read-only basic LiqubePage preview built directly on core preview
-- @module plugins.lppreview.view

local preview_design = require("plugins.lppreview.design")
local parser = require("plugins.lppreview.parser")
local preview_export = require("core.preview.export")
local common = require("core.common")
local core = require("core")
local fonts = require("core.style.fonts")
local PreviewView = require("core.preview.view")

local LiqubePagePreview = PreviewView:extend()

-- Reuse immutable font requests when emphasis first appears
local body_font_options = {
	body_bold = { bold = true },
	body_bold_italic = { bold = true, italic = true },
	body_italic = { italic = true },
}

LiqubePagePreview.is_liqubepage_preview = true
LiqubePagePreview.preview_design = preview_design
LiqubePagePreview.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 small font set needed by the basic proof
-- @param zoom_multiplier number|nil view-local content zoom
-- @return table preview_fonts
local function load_preview_fonts(zoom_multiplier)
	local scale = (SCALE or 1) * (tonumber(zoom_multiplier) or 1)
	local body_size = preview_design.typography.font_size * scale
	local preview_fonts = {
		body = fonts.load_style(body_size),
		body_size = body_size,
		headings = {},
	}

	for level = 1, #preview_design.typography.heading_scales do
		preview_fonts.headings[level] = fonts.load_style(
			preview_design.typography.font_size
				* preview_design.typography.heading_scales[level]
				* scale,
			{ bold = level <= 2 }
		)
	end

	return preview_fonts
end

--- Build one plain painter run
-- @param font userdata text font
-- @param text string visible text
-- @param width number|nil already measured text width
-- @param syntax_type string|nil optional live color role
-- @param link string|nil optional link target
-- @return table run measured painter run
local function make_run(font, text, width, syntax_type, link)
	return {
		font = font,
		syntax_type = syntax_type,
		text = text,
		width = width or font:get_width(text),
		options = { link = link },
	}
end

--- Wrap one basic text block at word boundaries
-- @param font userdata text font
-- @param text string visible text
-- @param width number available width
-- @param syntax_type string|nil optional live color role
-- @return table rows measured preview rows
local function wrap_text(font, text, width, syntax_type)
	local rows = {}
	local current = ""
	local current_width = 0
	local space_width = font:get_width(" ")

	for word in text:gmatch("%S+") do
		local word_width = font:get_width(word)
		local candidate_width = current == ""
			and word_width
			or current_width + space_width + word_width

		if current ~= "" and candidate_width > width then
			rows[#rows + 1] = { runs = { make_run(font, current, current_width, syntax_type) } }
			current = word
			current_width = word_width
		else
			current = current == "" and word or current .. " " .. word
			current_width = candidate_width
		end
	end

	if current ~= "" then
		rows[#rows + 1] = { runs = { make_run(font, current, current_width, syntax_type) } }
	end

	return rows
end

--- Select one body font for a parsed style span
-- @param preview_fonts table loaded preview font set
-- @param span table parsed inline span
-- @return userdata font matching span emphasis
local function get_span_font(preview_fonts, span)
	local name
	local options

	if span.bold and span.italic then
		name = "body_bold_italic"
	elseif span.bold then
		name = "body_bold"
	elseif span.italic then
		name = "body_italic"
	else
		return preview_fonts.body
	end
	options = body_font_options[name]

	if not preview_fonts[name] then
		preview_fonts[name] = fonts.load_style(preview_fonts.body_size, options)
	end

	return preview_fonts[name]
end

--- Wrap styled spans while merging adjacent text using the same font
-- @param preview_fonts table loaded preview font set
-- @param spans table parsed styled spans
-- @param width number available width
-- @param prefix string|nil plain prefix added before the first span
-- @return table rows measured preview rows
local function wrap_spans(preview_fonts, spans, width, prefix)
	local rows = {}
	local row = { runs = {} }
	local row_width = 0
	local space_widths = {}
	local pending_space = false

	local function append_word(font, word, source_space, link)
		local needs_space = #row.runs > 0 and source_space
		local space_width = space_widths[font]

		if not space_width then
			space_width = font:get_width(" ")
			space_widths[font] = space_width
		end

		local word_width = font:get_width(word)
		local added_width = word_width + (needs_space and space_width or 0)

		if needs_space and row_width + added_width > width then
			rows[#rows + 1] = row
			row = { runs = {} }
			row_width = 0
			needs_space = false
			added_width = word_width
		end

		local previous = row.runs[#row.runs]

		-- Keep leading link space outside its colored underline
		if needs_space and link and (not previous or previous.options.link ~= link) then
			if previous and not previous.options.link and previous.font == font then
				previous.text = previous.text .. " "
				previous.width = previous.width + space_width
			else
				row.runs[#row.runs + 1] = make_run(font, " ", space_width)
			end

			row_width = row_width + space_width
			needs_space = false
			added_width = word_width
			previous = row.runs[#row.runs]
		end

		if previous and previous.font == font and previous.options.link == link then
			previous.text = previous.text .. (needs_space and " " or "") .. word
			previous.width = previous.width + (needs_space and space_width or 0) + word_width
		else
			local run_text = needs_space and " " .. word or word
			row.runs[#row.runs + 1] = make_run(font, run_text, added_width, nil, link)
		end

		row_width = row_width + added_width
	end

	if prefix then
		append_word(preview_fonts.body, prefix, false, nil)
		pending_space = true
	end

	for span_index = 1, #spans do
		local span = spans[span_index]
		local font = get_span_font(preview_fonts, span)

		for whitespace, word in span.text:gmatch("(%s*)(%S+)") do
			append_word(font, word, pending_space or whitespace ~= "", span.link)
			pending_space = false
		end

		pending_space = span.text:match("%s+$") ~= nil
	end

	if #row.runs > 0 then
		rows[#rows + 1] = row
	end

	return rows
end

--- Build a read-only preview for one LiqubePage document
-- @param doc table source document
function LiqubePagePreview:new(doc)
	LiqubePagePreview.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.liqubepage_preview_zoom_multiplier) or 1
	self.preview_fonts = load_preview_fonts(self.zoom_multiplier)
	self.preview_document = nil
	self.parse_change_id = nil
	self.layout_rows = {}
	self.layout_height = 0
	self.layout_key = nil
	self.heading_anchors = {}
end

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

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

--- Apply view-local zoom while retaining the visible source position
-- @param zoom_multiplier number requested zoom multiplier
-- @return boolean changed true when visible scale changed
function LiqubePagePreview: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.liqubepage_preview_zoom_multiplier = requested_zoom
	self.preview_fonts = load_preview_fonts(requested_zoom)
	self.layout_key = nil
	self:rebuild_layout(anchor)
	self:clamp_scroll_position()
	core.redraw = true

	return true
end

--- Return ordinary body-row height used by document scrolling
-- @return number line_height
function LiqubePagePreview:get_line_height()
	return self.preview_fonts.body:get_height() * self.preview_line_height_scale
end

--- Return the complete pane width used by the document
-- @return number width
function LiqubePagePreview:get_document_viewport_width()
	return self.document_viewport_width_override or self.size.x
end

--- Return width available to rendered content
-- @return number width
function LiqubePagePreview: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 fully measured view for fixed-page export
-- @return table view detached LiqubePage preview
-- @return number width output page width
-- @return number height output page height
function LiqubePagePreview:create_export_view()
	local export_width, export_height = preview_export.get_page_size()
	local export_view = LiqubePagePreview(self.doc)

	-- Detached eager layout reflows at page width without changing the live preview
	export_view.position.x = 0
	export_view.position.y = 0
	export_view.size.x = export_width
	export_view.size.y = export_height
	export_view.document_viewport_width_override = export_width
	export_view:rebuild_layout()

	return export_view, export_width, export_height
end

--- Return a cached source parse and refresh it only after text changes
-- @param change_id number source change identifier
-- @return table document parsed preview document
function LiqubePagePreview:get_preview_document(change_id)
	if self.preview_document and self.parse_change_id == change_id then
		return self.preview_document
	end

	self.preview_document = parser.parse(self.doc)
	self.parse_change_id = change_id

	return self.preview_document
end

--- Add one parsed block to the measured row layout
-- @param block table parsed preview block
-- @param render_width number available content width
-- @param first_block boolean true before any visible block
function LiqubePagePreview:layout_block(block, render_width, first_block)
	self.layout_source_line = block.source_line
	local paragraph_gap = block.separated and not first_block
		and scale_measurement(preview_design.paragraphs.gap)
		or 0

	if block.kind == "separator" then
		self:add_layout_row(
			{ runs = {}, min_height = self.preview_fonts.body:get_height() },
			paragraph_gap,
			{ rule = true }
		)

		return
	end

	local font = self.preview_fonts.body
	local text = block.text
	local x_offset = 0
	local decoration = {}
	local gap_before = paragraph_gap
	local syntax_type

	if block.kind == "heading" then
		font = self.preview_fonts.headings[block.level] or font
		gap_before = math.max(
			gap_before,
			scale_measurement(preview_design.headings.gap_above[block.level] or 0)
		)

		-- Keep the first matching heading stable when malformed source repeats an anchor
		if block.anchor and self.heading_anchors[block.anchor] == nil then
			self.heading_anchors[block.anchor] = self.layout_height + gap_before
		end
	elseif block.kind == "list_item" then
		x_offset = scale_measurement(preview_design.lists.indent * (block.level - 1))
		decoration.x_offset = x_offset
	elseif block.kind == "placeholder" then
		font = get_span_font(self.preview_fonts, { italic = true })
		syntax_type = "comment"
	end

	local available_width = math.max(1, render_width - x_offset)
	local rows = block.spans and block.kind ~= "heading"
		and wrap_spans(
			self.preview_fonts,
			block.spans,
			available_width,
			block.kind == "list_item" and "•" or nil
		)
		or wrap_text(
			font,
			block.kind == "list_item" and "• " .. text or text,
			available_width,
			syntax_type
		)

	for row_index = 1, #rows do
		if block.kind == "heading"
			and row_index == #rows
			and preview_design.is_heading_separator_enabled(block.level)
		then
			decoration.heading_separator = block.level
		end

		self:add_layout_row(rows[row_index], row_index == 1 and gap_before or 0, decoration)
	end
end

--- Rebuild parsed and wrapped rows when source or width changes
-- @param forced_anchor table|nil source position restored after reload
function LiqubePagePreview:rebuild_layout(forced_anchor)
	if self.size.x <= 0 or self.size.y <= 0 then
		return
	end

	local change_id = self.doc:get_change_id()
	local render_width = self:get_render_width()
	local layout_key = table.concat({ change_id, tostring(render_width) }, ":")

	if self.layout_key == layout_key then
		return
	end

	local anchor = forced_anchor or (self.layout_key and self:capture_layout_anchor())
	local document = self:get_preview_document(change_id)
	self.layout_rows = {}
	self.layout_height = 0
	self.heading_anchors = {}

	for block_index = 1, #document.blocks do
		self:layout_block(document.blocks[block_index], render_width, block_index == 1)
	end

	self.layout_key = layout_key

	if anchor then
		self:restore_layout_anchor(anchor)
	end
end

--- Scroll to one measured LiqubePage heading anchor
-- @param anchor string exact heading anchor
-- @return boolean found true when the anchor exists
function LiqubePagePreview:scroll_to_anchor(anchor)
	self:rebuild_layout()
	local target = self.heading_anchors[anchor]

	if target == nil then
		return false
	end

	self.scroll.to.y = common.clamp(target, 0, self:get_scroll_max_y())
	core.redraw = true

	return true
end

--- Open one basic LiqubePage link target
-- Current-page anchors and external links need no unresolved site settings
-- @param target string raw LiqubePage target
-- @return boolean handled true when the link consumed its click
function LiqubePagePreview:open_link(target)
	if target:sub(1, 1) == "#" then
		local opened = self:scroll_to_anchor(target:sub(2))

		if not opened and core.status_view then
			core.status_view:show_error("LiqubePage anchor does not exist: " .. target)
		end

		return true
	end

	local lower_target = target:lower()
	local external_target = lower_target:match("^https?:")
		or lower_target:match("^ftps?:")
		or lower_target:match("^www%.")
		or target:sub(1, 2) == "//"

	if external_target then
		local resolved_target = lower_target:match("^www%.") and "https://" .. target
			or target:sub(1, 2) == "//" and "https:" .. target
			or target
		local opened, open_error = system.open_url(resolved_target)

		if not opened and core.status_view then
			core.status_view:show_error(
				"Could not open LiqubePage link: " .. tostring(open_error or resolved_target)
			)
		end

		return true
	end

	-- Site-relative pages require site-root information that the basic proof does not own
	if core.status_view then
		core.status_view:show_error("LiqubePage page link needs a site root: " .. target)
	end

	return true
end

--- Show link intent while preserving normal preview mouse behavior
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean|nil handled
function LiqubePagePreview:on_mouse_move(x, y, dx, dy)
	local handled = LiqubePagePreview.super.on_mouse_move(self, x, y, dx, dy)

	if not self._mouse_pan then
		self.mouse_cursor = self:get_link_at(x, y) and "hand" or "arrow"
	end

	return handled
end

--- Clear link hover when the pointer leaves the preview
-- @return boolean|nil handled
function LiqubePagePreview:on_mouse_left(...)
	local handled = LiqubePagePreview.super.on_mouse_left(self, ...)
	self.mouse_cursor = "arrow"

	return handled
end

--- Return complete scrollable document height
-- @return number height
function LiqubePagePreview: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 LiqubePagePreview:capture_reload_view_state()
	self:rebuild_layout()

	return self:capture_layout_anchor()
end

--- Reparse changed source and restore the previous source position
-- @param anchor table|nil source position
function LiqubePagePreview:restore_reload_view_state(anchor)
	self.preview_document = nil
	self.parse_change_id = nil
	self.layout_key = nil
	self:rebuild_layout(anchor)
end

--- Draw the rendered document and shared scrollbar
function LiqubePagePreview:draw()
	self:draw_document()
	self:draw_scrollbar()
end

return LiqubePagePreview
