-- lx-bundle:true

--- Parse a readable flattened LiqubePage proof into preview blocks
-- @module plugins.lppreview.parser

local preview_dom = require("core.preview.dom")

local parser = {}

local block_tags = {
	code = "Code",
	command = "Command",
	paths = "Paths",
	table = "Table",
}

local hidden_tags = {
	scroll_top = true,
}

local media_tags = {
	img = "Image",
	inline_img = "Image",
	left_picture = "Picture",
	picture = "Picture",
	right_picture = "Picture",
}

local wrapper_tags = {
	answer = true,
	dimmed = true,
	intro = true,
	large = true,
	lead = true,
	medium = true,
	right = true,
	small = true,
	soft = true,
	tip = true,
	xsmall = true,
}

--- Remove one stored line ending
-- @param line string|nil source line
-- @return string text line without its ending
local function strip_line_ending(line)
	-- Pattern breakdown:
	-- '\r?' accepts the optional carriage return and '\n$' requires the final newline
	return (line or ""):gsub("\r?\n$", "")
end

--- Trim ordinary LiqubePage text
-- @param text string source text
-- @return string trimmed visible text
local function trim(text)
	-- Pattern breakdown:
	-- '^%s+' removes leading whitespace and '%s+$' removes trailing whitespace
	return (text:gsub("^%s+", ""):gsub("%s+$", ""))
end

--- Convert a tag name to the parser's lookup form
-- @param name string source tag name
-- @return string normalized_name lookup name
local function normalize_tag_name(name)
	return name:gsub("-", "_")
end

--- Read one complete leading metadata block
-- @param lines table source lines
-- @param start_line number first metadata line
-- @return number|nil next_line line after the block
-- @return string|nil name metadata name
-- @return string|nil value folded metadata value
local function read_metadata(lines, start_line)
	local first = strip_line_ending(lines[start_line])
	-- Pattern breakdown:
	-- '^@' requires metadata at the line start
	-- '([%l%d%-]+)' captures the allowed leading metadata name
	-- '%s*{%s*' accepts spacing around the opening brace
	local name, first_value = first:match("^@([%l%d%-]+)%s*{%s*(.*)$")

	if not name then
		return nil
	end

	local value_lines = {}

	for line_index = start_line, #lines do
		local value_line = line_index == start_line
			and first_value
			or strip_line_ending(lines[line_index])
		local closing = value_line:find("}", 1, true)

		if closing then
			value_lines[#value_lines + 1] = value_line:sub(1, closing - 1)

			return line_index + 1, name, trim(table.concat(value_lines, " "):gsub("%s+", " "))
		end

		value_lines[#value_lines + 1] = value_line
	end

	-- Malformed metadata remains visible instead of swallowing the document
	return nil
end

--- Remove comment ranges from one source line
-- Lines without comment markers return immediately without allocating parts
-- @param source_text string source line without its ending
-- @param inside_comment boolean comment state carried from the previous line
-- @return string visible_text source outside comments
-- @return boolean inside_comment updated comment state
local function remove_line_comments(source_text, inside_comment)
	if not inside_comment and not source_text:find("[!--", 1, true) then
		return source_text, false
	end

	local visible_parts = {}
	local position = 1

	while position <= #source_text do
		if inside_comment then
			local comment_end = source_text:find("--]", position, true)

			if not comment_end then
				break
			end

			inside_comment = false
			position = comment_end + 3
		else
			local comment_start = source_text:find("[!--", position, true)

			if not comment_start then
				visible_parts[#visible_parts + 1] = source_text:sub(position)
				break
			end

			visible_parts[#visible_parts + 1] = source_text:sub(position, comment_start - 1)
			inside_comment = true
			position = comment_start + 4
		end
	end

	return table.concat(visible_parts), inside_comment
end

--- Read one complete whole-line tag
-- @param line string trimmed logical line
-- @return boolean|nil closing true for a closing tag
-- @return string|nil name normalized tag name
-- @return string|nil arguments source arguments after the name
local function read_whole_line_tag(line)
	-- Pattern breakdown:
	-- '^%[' and '%]$' require the tag to occupy the complete line
	-- '(/?)' captures an optional closing slash
	-- '([%w%-]+)' captures a tag name and '(.-)' retains its arguments
	local slash, name, arguments = line:match("^%[(/?)([%w%-]+)(.-)%]$")

	if not name then
		return nil
	end

	return slash == "/", normalize_tag_name(name), trim(arguments)
end

--- Return whether a logical line only controls rows or columns
-- @param line string trimmed logical line
-- @return boolean structural
local function is_structure_marker(line)
	if line:match("^{%s*.*$") or line == "}" then
		return true
	end

	return line == "|"
		or line:match("^|[%d%*]+%s*$") ~= nil
		or line:match("^|%s*%^.+$") ~= nil
end

--- Read one heading with optional anchor or layout options
-- @param line string trimmed source line
-- @return number|nil level heading level
-- @return string|nil text visible heading text
-- @return string|nil anchor explicit heading anchor
local function read_heading(line)
	-- Pattern breakdown:
	-- '^([=@]+)' captures the opening delimiter and therefore the level
	-- '%s+(.+)%s+' captures non-empty heading text
	-- '([=@]+)' captures the closing delimiter and '(.*)$' retains optional settings
	local opening, text, _, trailing = line:match("^([=@]+)%s+(.-)%s+([=@]+)%s*(.*)$")

	if not opening or (trailing ~= "" and not trailing:match("^[#%^]")) then
		return nil
	end

	local anchor = trailing:match("^#([^%^%s]+)") or trailing:match("%^#([^%^%s]+)")

	return math.min(6, #opening), trim(text), anchor
end

--- Turn visible heading text into its automatic LiqubePage anchor
-- @param text string visible heading text
-- @return string|nil anchor normalized heading anchor
local function make_heading_anchor(text)
	local anchor = text:lower()
	anchor = anchor:gsub("^[^%w]+", ""):gsub("[^%w]+$", "")
	anchor = anchor:gsub("[^%w]+", "-")

	return anchor ~= "" and anchor or nil
end

--- Return whether one line is a basic separator
-- @param line string trimmed source line
-- @return boolean separator
local function is_separator(line)
	return line == "----" or line == "////" or line == "...."
end

--- Remove non-link source syntax while retaining useful labels
-- @param text string source text
-- @return string visible_text readable plain text with style markers retained
local function normalize_non_link_inline(text)
	-- Named inline values display their useful payload without format decoration
	text = text:gsub("%[([%w%-]+):(.-)%]", function(name, body)
		local separator = body:find("|", 1, true)
		local value = separator and body:sub(1, separator - 1) or body

		if normalize_tag_name(name) == "abbr" and separator then
			value = body:sub(separator + 1)
		end

		return trim(value)
	end)

	-- Compact labeled tags use their label as the readable substitute
	text = text:gsub("%[([%w%-]+)%s*|%s*(.-)%]", function(_, label)
		return trim(label)
	end)

	text = text:gsub("%[%-%-%]", "—")
	text = text:gsub("%[/?[%w%-]+[^%]]*%]", "")
	text = text:gsub("``", "")
	text = text:gsub(",,,", "")

	return trim(text:gsub("%s+", " "))
end

--- Replace link source with labels while retaining their targets
-- @param text string source text
-- @return string visible_text normalized text with style markers retained
-- @return table links ordered visible link descriptions
local function normalize_inline(text)
	local links = {}

	-- Pattern breakdown:
	-- '%[%[' and '%]%]' bound one link while '(.-)' captures its compact body
	text = text:gsub("%[%[(.-)%]%]", function(body)
		local separator = body:find("|", 1, true)
		local target = trim(separator and body:sub(1, separator - 1) or body)
		local label = trim(separator and body:sub(separator + 1) or body)

		if not separator then
			local lower_target = target:lower()

			if lower_target:match("^https://") then
				label = target:sub(9)
			elseif lower_target:match("^http://") then
				label = target:sub(8)
			end
		end

		links[#links + 1] = target

		-- Control-byte markers retain exact link boundaries through later cleanup
		return "\1" .. #links .. "\2" .. label .. "\3"
	end)

	text = normalize_non_link_inline(text)

	return text, links
end

--- Split visible text into minimally styled spans
-- @param text string normalized text with quote markers
-- @return string visible_text text without style markers
-- @return table|nil spans styled runs only when styles were used
local function read_inline_spans(text)
	local spans = {}
	local plain_parts = {}
	local position = 1
	local segment_start = 1
	local bold = false
	local italic = false
	local styled = false

	local function add_segment(segment)
		if segment == "" then
			return
		end

		plain_parts[#plain_parts + 1] = segment
		local previous = spans[#spans]

		if previous and previous.bold == bold and previous.italic == italic then
			previous.text = previous.text .. segment
		else
			spans[#spans + 1] = {
				bold = bold,
				italic = italic,
				text = segment,
			}
		end
	end

	while position <= #text do
		local marker_length

		if text:sub(position, position + 3) == "''''" then
			marker_length = 4
		elseif text:sub(position, position + 2) == "'''" then
			marker_length = 3
		elseif text:sub(position, position + 1) == "''" then
			marker_length = 2
		end

		if marker_length then
			add_segment(text:sub(segment_start, position - 1))
			styled = true

			if marker_length == 4 then
				bold = not bold
				italic = not italic
			elseif marker_length == 3 then
				bold = not bold
			else
				italic = not italic
			end

			position = position + marker_length
			segment_start = position
		else
			position = position + 1
		end
	end

	add_segment(text:sub(segment_start))
	local visible_text = table.concat(plain_parts)

	return visible_text, styled and spans or nil
end

--- Remove internal markers while applying links to visible emphasis spans
-- @param spans table|nil emphasis spans
-- @param links table ordered link targets
-- @return string visible_text text without internal markers
-- @return table|nil linked_spans spans carrying optional link targets
local function apply_link_spans(spans, links)
	spans = spans or {}
	local linked_spans = {}
	local visible_parts = {}
	local active_link

	local function append_span(source_span, text, link)
		if text == "" then
			return
		end

		local previous = linked_spans[#linked_spans]

		if previous
			and previous.bold == source_span.bold
			and previous.italic == source_span.italic
			and previous.link == link
		then
			previous.text = previous.text .. text
		else
			linked_spans[#linked_spans + 1] = {
				bold = source_span.bold,
				italic = source_span.italic,
				link = link,
				text = text,
			}
		end
	end

	for span_index = 1, #spans do
		local source_span = spans[span_index]
		local position = 1

		while position <= #source_span.text do
			local marker_start = source_span.text:find("[\1\3]", position)
			local visible_end = marker_start and marker_start - 1 or #source_span.text
			local segment = source_span.text:sub(position, visible_end)
			append_span(source_span, segment, active_link)
			visible_parts[#visible_parts + 1] = segment

			if not marker_start then
				break
			end

			if source_span.text:byte(marker_start) == 1 then
				-- Pattern breakdown: digits identify the link and control byte 2 closes the marker
				local link_index, marker_end = source_span.text:match("(%d+)()\2", marker_start + 1)
				active_link = links[tonumber(link_index)]
				position = marker_end + 1
			else
				active_link = nil
				position = marker_start + 1
			end
		end
	end

	return table.concat(visible_parts), linked_spans
end

--- Build one visible parsed block
-- @param text string joined source text
-- @param source_line number first contributing source line
-- @param separated boolean paragraph separation before this block
-- @return table|nil block parsed preview block
local function parse_visible_block(text, source_line, separated)
	local first_byte = text:byte(1)
	local heading_level
	local heading_text
	local heading_anchor
	local list_marker
	local list_text

	if first_byte == 61 or first_byte == 64 then
		heading_level, heading_text, heading_anchor = read_heading(text)
	elseif first_byte == 42 then
		list_marker, list_text = text:match("^(%*+)%s+(.+)$")
	end

	local block

	if heading_level then
		block = {
			kind = "heading",
			level = heading_level,
			text = heading_text,
		}
	elseif is_separator(text) then
		block = {
			kind = "separator",
			text = text,
		}
	elseif list_marker then
		block = {
			kind = "list_item",
			level = math.min(4, #list_marker),
			text = list_text,
		}
	else
		block = {
			kind = "paragraph",
			text = text,
		}
	end

	local contains_tag = block.text:find("[", 1, true) ~= nil
	local contains_emphasis = block.text:find("''", 1, true) ~= nil
	local links

	if contains_tag then
		block.text, links = normalize_inline(block.text)
		contains_emphasis = contains_emphasis or block.text:find("''", 1, true) ~= nil
	end

	if contains_emphasis then
		block.text, block.spans = read_inline_spans(block.text)
	end

	if links then
		block.text, block.spans = apply_link_spans(
			block.spans or { { text = block.text } },
			links
		)

		for link_index = 1, #links do
			if links[link_index]:sub(1, 1) == "#" then
				block.references_anchor = true
				break
			end
		end
	end

	if block.kind == "heading" then
		block.anchor = heading_anchor
	end

	if block.text == "" then
		return nil
	end

	block.source_line = source_line
	block.separated = separated

	return block
end

--- Build one compact substitute for content outside the proof subset
-- @param source_line number first contributing source line
-- @param separated boolean paragraph separation before this block
-- @param label string readable placeholder name
-- @param arguments string|nil optional source detail
-- @return table block placeholder preview block
local function make_placeholder(source_line, separated, label, arguments)
	local detail = trim((arguments or ""):gsub("^:", ""))
	local separator = detail:find("|", 1, true)

	if separator then
		detail = trim(detail:sub(1, separator - 1))
	end

	return {
		kind = "placeholder",
		separated = separated,
		source_line = source_line,
		text = detail ~= "" and label .. " — " .. detail or label,
	}
end

--- Parse source lines into a readable format-neutral block shape
-- @param source table source document
-- @return table document preview document with metadata and blocks
function parser.parse(source)
	local document = preview_dom.new(source)
	document.metadata = {}
	local lines = source.lines or {}
	local line_index = 1

	-- Leading metadata is retained for future adapters but not rendered
	while line_index <= #lines do
		local next_line, name, value = read_metadata(lines, line_index)

		if not next_line then
			break
		end

		document.metadata[name] = value
		line_index = next_line
	end

	local inside_comment = false
	local pending_text
	local pending_source_line
	local pending_separated
	local skipped_tag
	local separated = true
	local needs_automatic_anchors = false

	-- One streaming pass avoids a second line array and temporary table per block
	for source_line = line_index, #lines + 1 do
		local text
		local logical_source_line
		local logical_separated

		if source_line <= #lines then
			local source_text = strip_line_ending(lines[source_line])

			if inside_comment or source_text:find("[!--", 1, true) then
				source_text, inside_comment = remove_line_comments(source_text, inside_comment)
			end

			text = trim(source_text)

			if text == "" then
				if pending_text then
					-- The first empty line ends and is consumed by an explicit join
					text = pending_text
					logical_source_line = pending_source_line
					logical_separated = pending_separated
					pending_text = nil
					pending_source_line = nil
					pending_separated = nil
				else
					text = nil
					separated = true
				end
			else
				local continues = text:byte(-1) == 94

				if continues then
					local join_start = #text

					while join_start > 0 and text:byte(join_start) == 94 do
						join_start = join_start - 1
					end

					text = trim(text:sub(1, join_start))
				end

				if pending_text then
					pending_text = pending_text .. " " .. text
				else
					pending_text = text
					pending_source_line = source_line
					pending_separated = separated
				end

				if continues then
					text = nil
				else
					text = pending_text
					logical_source_line = pending_source_line
					logical_separated = pending_separated
					pending_text = nil
					pending_source_line = nil
					pending_separated = nil
				end
			end
		elseif pending_text then
			text = pending_text
			logical_source_line = pending_source_line
			logical_separated = pending_separated
		end

		if text and logical_source_line then
			logical_separated = logical_separated or separated
			local first_byte = text:byte(1)
			local closing
			local tag_name
			local arguments
			local extension_tag

			if first_byte == 91 then
				closing, tag_name, arguments = read_whole_line_tag(text)
				-- Extension tags use a leading caret and stay compact until their renderer exists
				extension_tag = text:match("^%[%^([%w%-]+)")
			end

			if skipped_tag then
				if closing and tag_name == skipped_tag then
					skipped_tag = nil
					separated = true
				end
			elseif (first_byte == 123 or first_byte == 124 or first_byte == 125)
				and is_structure_marker(text)
			then
				separated = true
			elseif tag_name and not closing and block_tags[tag_name] then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					block_tags[tag_name],
					arguments
				)
				skipped_tag = tag_name
				separated = true
			elseif tag_name and wrapper_tags[tag_name] then
				separated = logical_separated
			elseif tag_name and hidden_tags[tag_name] then
				separated = true
			elseif tag_name and not closing and media_tags[tag_name] then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					media_tags[tag_name],
					arguments
				)
				separated = false
			elseif tag_name and not closing and tag_name == "include" then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					"Include",
					arguments
				)
				separated = false
			elseif tag_name and not closing and (tag_name == "page_toc" or tag_name == "generate_page_toc") then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					"Table of contents"
				)
				separated = false
			elseif tag_name then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					"Unsupported",
					tag_name
				)
				separated = false
			elseif extension_tag then
				document.blocks[#document.blocks + 1] = make_placeholder(
					logical_source_line,
					logical_separated,
					"Unsupported",
					extension_tag
				)
				separated = false
			else
				local block = parse_visible_block(text, logical_source_line, logical_separated)

				if block then
					if block.references_anchor then
						needs_automatic_anchors = true
						block.references_anchor = nil
					end

					document.blocks[#document.blocks + 1] = block
					separated = false
				end
			end
		end
	end

	if needs_automatic_anchors then
		for block_index = 1, #document.blocks do
			local block = document.blocks[block_index]

			if block.kind == "heading" and block.level >= 2 and not block.anchor then
				block.anchor = make_heading_anchor(block.text)
			end
		end
	end

	return document
end

return parser
