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

--- Fixed-page PNG export for rendered Markdown previews
-- @module plugins.mdpreview.export

local common = require("core.common")
local config = require("core.config")
local core = require("core")
local markdown_design = require("plugins.mdpreview.design")

local export = {}

config.plugins.mdpreview = common.merge({
	-- PNG export uses stable page dimensions independently of window size
	export_width = 2560,
	export_height = 3840,
	-- Auto preserves live document scale after fitting it to export width
	export_scale = "auto",
},
config.plugins.mdpreview)

--- Resolve final export zoom from configured scale and live document width
-- @param view table live Markdown preview
-- @param export_width number output page width
-- @return number scale final export zoom multiplier
function export.resolve_scale(view, export_width)
	local configured_scale = tonumber((config.plugins.mdpreview or {}).export_scale)

	if configured_scale and configured_scale > 0 then
		return configured_scale
	end

	local live_width = math.max(1, view:get_document_viewport_width())
	local live_scale = tonumber(view.zoom_multiplier) or 1

	-- Scaling width and text equally preserves live line wrapping on wider pages
	return live_scale * export_width / live_width
end

--- Build numbered PNG path beside source document
-- @param filename string source document path
-- @param page_number number one-based page number
-- @return string path numbered PNG path
function export.get_output_path(filename, page_number)
	-- Pattern breakdown: final dot-extension after last path separator is removed
	local path_without_extension = filename:gsub("%.[^%.\\/]+$", "")

	return string.format("%s-%03d.png", path_without_extension, page_number)
end

--- Remove numbered pages left over from a longer successful export
-- @param filename string source document path
-- @param page_count number number of pages in latest export
-- @return number removed_count removed stale page count
function export.remove_stale_pages(filename, page_count)
	-- Pattern breakdown: final dot-extension after last path separator is removed
	local path_without_extension = filename:gsub("%.[^%.\\/]+$", "")
	local directory = system.path_dirname(path_without_extension)
	directory = directory == "" and "." or directory
	local base_name = path_without_extension:match("[^\\/]+$") or path_without_extension
	local prefix = base_name .. "-"
	local names, list_error = system.list_dir(directory)
	local removed_count = 0

	if not names then
		error(list_error or "Could not inspect previous Markdown images", 0)
	end

	for _, name in ipairs(names) do
		if name:sub(1, #prefix) == prefix then
			-- Pattern breakdown: numbered page suffix ends with lowercase PNG extension
			local number_text = name:sub(#prefix + 1):match("^(%d+)%.png$")
			local page_number = number_text and tonumber(number_text)
			local path = system.join_path(directory, name)

			if page_number and #number_text >= 3 and page_number > page_count
				and system.is_file(path) == true
			then
				local removed, remove_error = os.remove(path)

				if not removed then
					error(remove_error or "Could not remove stale Markdown image", 0)
				end

				removed_count = removed_count + 1
			end
		end
	end

	return removed_count
end

--- Return page starts that keep ordinary rendered rows intact
-- @param view table detached fully laid-out preview
-- @param page_height number output page height
-- @return table starts document scroll position for every page
function export.get_page_starts(view, page_height)
	local starts = { 0 }
	local page_start = 0
	local padding_top = markdown_design.document.padding_vertical * (SCALE or 1)

	for row_index = 1, #view.layout_rows do
		local row = view.layout_rows[row_index]
		local row_top = padding_top + row.y
		local row_bottom = row_top + row.height

		-- Single row taller than whole page is only unavoidable clipped case
		if row_top > page_start and row_bottom > page_start + page_height then
			page_start = row_top
			starts[#starts + 1] = page_start
		end
	end

	return starts
end

--- Add fixed-page export support to Markdown preview class
-- @param MarkdownPreview table Markdown preview class
-- @param load_preview_fonts function font loader shared with live previews
function export.install(MarkdownPreview, load_preview_fonts)
	--- Create detached fully measured preview for one export
	-- @return table view detached Markdown preview
	-- @return number width output page width
	-- @return number height output page height
	function MarkdownPreview:create_export_view()
		local plugin_config = config.plugins.mdpreview or {}
		local export_width = math.max(1, math.floor(tonumber(plugin_config.export_width) or 2560))
		local export_height = math.max(1, math.floor(tonumber(plugin_config.export_height) or 3840))
		local export_scale = export.resolve_scale(self, export_width)
		local export_view = MarkdownPreview(self.doc)

		-- Detached view reflows document at page width without changing live preview state
		export_view.position.x = 0
		export_view.position.y = 0
		export_view.size.x = export_width
		export_view.document_viewport_width_override = export_width
		export_view.zoom_multiplier = export_scale
		export_view.preview_fonts = load_preview_fonts(export_scale)
		-- Tall temporary viewport makes existing lazy layout measure every source line once
		export_view.size.y = math.max(
			export_height,
			(#self.doc.lines + 1) * export_view:get_line_height()
		)
		export_view:rebuild_layout()
		export_view.size.y = export_height
		if not export_view.layout_complete then
			error("Could not complete Markdown layout for image export", 0)
		end

		return export_view, export_width, export_height
	end

	--- Render one prepared export page without preview interface
	-- @param export_view table detached fully laid-out preview
	-- @param export_width number output page width
	-- @param export_height number output page height
	-- @param page_start number document scroll position at page top
	-- @return userdata page rendered page pixels
	function MarkdownPreview:render_prepared_export_page(
		export_view,
		export_width,
		export_height,
		page_start
	)
		local live_clip_rect_stack = core.clip_rect_stack

		export_view.scroll.y = page_start
		export_view.scroll.to.y = page_start
		-- Core clip nesting still describes visible window, so replace root only during export
		core.clip_rect_stack = { { 0, 0, export_width, export_height } }
		local rendered, page = pcall(renderer.render_to_canvas, export_width, export_height, function()
			-- Native renderer clipping also carries last visible window rectangle
			renderer.set_clip_rect(0, 0, export_width, export_height)
			export_view:draw_document()
		end)
		core.clip_rect_stack = live_clip_rect_stack

		if not rendered then
			error(page, 0)
		end

		return page
	end

	--- Render first fixed-size document page without preview interface
	-- @return userdata canvas rendered page pixels
	function MarkdownPreview:render_export_page()
		local export_view, export_width, export_height = self:create_export_view()

		return self:render_prepared_export_page(export_view, export_width, export_height, 0)
	end

	--- Render and save every page beside source document
	-- @param on_page function|nil callback before each page is rendered
	-- @return table paths saved PNG paths
	function MarkdownPreview:export_images(on_page)
		local filename = self.doc and self.doc.filename

		if type(filename) ~= "string" or filename == "" then
			error("Markdown document must be saved before exporting images", 0)
		end

		local paths = {}
		local exported, export_error = pcall(function()
			local export_view, export_width, export_height = self:create_export_view()
			local page_starts = export.get_page_starts(export_view, export_height)

			for page_number = 1, #page_starts do
				local path = export.get_output_path(filename, page_number)

				if on_page then
					on_page(page_number, path)
				end

				-- First draw starts any asynchronous color emoji rasterization for this page
				self:render_prepared_export_page(
					export_view,
					export_width,
					export_height,
					page_starts[page_number]
				)

				-- Main renderer frames apply completed glyphs between cooperative yields
				while renderer.has_pending_glyphs() do
					core.redraw = true
					coroutine.yield()
				end

				local page = self:render_prepared_export_page(
					export_view,
					export_width,
					export_height,
					page_starts[page_number]
				)
				local saved, save_error = canvas.save_png(page, path)

				if not saved then
					error(save_error or "Could not save exported Markdown image", 0)
				end

				paths[#paths + 1] = path
			end
		end)

		if not exported then
			-- Failed or old pages above last completed page must not look current
			local cleaned, cleanup_error = pcall(export.remove_stale_pages, filename, #paths)

			if not cleaned then
				error(string.format(
					"%s\nCleanup failed: %s",
					tostring(export_error),
					tostring(cleanup_error)
				), 0)
			end

			error(export_error, 0)
		end

		-- Keep higher old pages until every replacement page is safely saved
		export.remove_stale_pages(filename, #paths)

		return paths
	end
end

--- Start responsive multi-page export for active Markdown preview
-- @param view table live Markdown preview
-- @return boolean started true when export was scheduled
function export.start(view)
	core.add_coroutine(function()
		local paths = view:export_images(function(page_number)
			if core.status_view then
				core.status_view:show_notice(string.format("Rendering image %03d...", page_number))
			end

			-- Let status bar paint before next off-screen page is processed
			core.redraw = true
			coroutine.yield()
		end)

		if core.status_view then
			core.status_view:show_success(string.format("Exported %d images", #paths))
		end
		core.redraw = true
	end, view)

	return true
end

return export
