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

--- Optional syntax coloring for fenced Markdown code blocks
-- @module plugins.mdpreview.code_blocks

local format_registry = require("core.format")

local code_blocks = {}

--- Read first Markdown fence-info word for display and format lookup
-- @param info string|nil text following opening fence
-- @return string|nil language normalized language name
-- @return string|nil label language name written in Markdown
local function read_language(info)
	-- Pattern breakdown:
	-- '^%s*' skips whitespace after opening fence
	-- '([^%s]+)' captures only first info word
	local language = tostring(info or ""):match("^%s*([^%s]+)")

	if not language then
		return nil
	end

	-- Accept common fenced attribute spelling '{.lua}' without parsing other attributes
	language = language:match("^%{%.([^}]+)%}$") or language
	language = language:gsub("^%.", "")

	if language == "" then
		return nil
	end

	return language:lower(), language
end

--- Return whether a detected format explicitly owns one language extension
-- @param format_definition table resolved format
-- @param language string normalized language name
-- @return boolean owned true when this is not plain-text fallback
local function owns_language_extension(format_definition, language)
	local extensions = format_definition and format_definition.extensions

	if type(extensions) == "string" then
		return extensions:lower() == language
	end

	if type(extensions) == "table" then
		for extension_index = 1, #extensions do
			if tostring(extensions[extension_index]):lower() == language then
				return true
			end
		end
	end

	return false
end

--- Resolve a fenced language through target declared by Markdown syntax
-- @param language string normalized language name
-- @return table|nil format_definition matching declared syntax target
local function resolve_declared_fence_format(language)
	local markdown_format = format_registry.get_by_module("format.md")

	for _, pattern in ipairs(markdown_format and markdown_format.patterns or {}) do
		if type(pattern.fence_language) == "string"
			and pattern.fence_language:lower() == language then
			return format_registry.resolve_syntax_reference(pattern.syntax)
		end
	end

	return nil
end

--- Resolve an optional fence language without affecting ordinary editor documents
-- @param language string|nil normalized language name
-- @return table|nil format_definition matching syntax definition
local function resolve_format(language)
	if not language or language == "text" or language == "plain" or language == "plaintext" then
		return nil
	end

	-- Markdown owns fence labels and their '.extension' or 'module:name' targets
	local format_definition = resolve_declared_fence_format(language)

	if format_definition then
		return format_definition
	end

	format_definition = format_registry.get_by_name(language)

	if format_definition then
		return format_definition
	end

	-- Extension detection supports aliases such as 'js' when displayed format name differs
	format_definition = format_registry.get("markdown-fence." .. language, "", false)

	return owns_language_extension(format_definition, language) and format_definition or nil
end

--- Create one isolated fenced-code token cache
-- @param info string|nil text following opening fence
-- @return table block code block state
function code_blocks.new(info)
	local language, language_label = read_language(info)

	return {
		language = language,
		language_label = language_label,
		format = nil,
		format_resolved = false,
		lines = {},
		tokens = {},
		tokenized_line_count = 0,
		tokenizer_state = nil,
	}
end

--- Append one source line before incremental layout begins
-- @param block table code block state
-- @param text string literal source line
-- @return number line_index index inside code block
function code_blocks.add_line(block, text)
	block.lines[#block.lines + 1] = text

	return #block.lines
end

--- Remove synthetic line ending needed by line-oriented syntax rules
-- @param tokens table alternating syntax roles and text
local function remove_synthetic_line_ending(tokens)
	local text_index = #tokens
	local text = tokens[text_index]

	if type(text) ~= "string" then
		return
	end

	-- Pattern breakdown: '\r?\n$' removes only final synthetic line ending
	text = text:gsub("\r?\n$", "")

	if text == "" then
		tokens[text_index] = nil
		tokens[text_index - 1] = nil
	else
		tokens[text_index] = text
	end
end

--- Return colored tokens for one line, replaying prior block state only when needed
-- @param block table code block state
-- @param line_index number index inside code block
-- @return table|nil tokens alternating syntax role and text
function code_blocks.get_tokens(block, line_index)
	if not block or type(line_index) ~= "number" then
		return nil
	end

	if not block.format_resolved then
		block.format = resolve_format(block.language)
		block.format_resolved = true
	end

	if not block.format then
		return nil
	end

	-- Keep tokenizer and requested format lazy until this optional preview needs them
	local tokenizer = require("core.format.tokenizer")

	while block.tokenized_line_count < line_index do
		local next_index = block.tokenized_line_count + 1
		local tokens, next_state = tokenizer.tokenize(
			block.format,
			(block.lines[next_index] or "") .. "\n",
			block.tokenizer_state,
			block.lines[next_index + 1] and (block.lines[next_index + 1] .. "\n") or nil
		)

		remove_synthetic_line_ending(tokens)
		block.tokens[next_index] = tokens
		block.tokenizer_state = next_state
		block.tokenized_line_count = next_index
	end

	return block.tokens[line_index]
end

return code_blocks
