-- lx format
-- markdown syntax <liqube>
local format = require("core.format")
local common = require("core.common")
local text_format = require("core.text.format")
local quotes = require("core.quotes")

local md_cycle_heading_prefix
local md_remove_heading_prefix
local md_comment_hook
local md_outline_hook
local md_blockquote_profile
local md_inline_profile
local md_toggle_inline_format

-- Wrapper recognition is inner-first while output always uses one canonical outer order
local md_inline_format_wrappers = {
	{ open = "***", close = "***", formats = { "bold", "italic" } },
	{ open = "<u>", close = "</u>", formats = { "underline" } },
	{ open = "~~", close = "~~", formats = { "strikethrough" } },
	{ open = "**", close = "**", formats = { "bold" } },
	{ open = "*", close = "*", formats = { "italic" } },
}

--- Choose a backtick fence longer than every run inside selected text
-- @param text string code-span content
-- @return string fence safe backtick fence
local function md_pick_code_fence(text)
	local max_run = 0

	-- Pattern breakdown:
	-- The plus quantifier matches each uninterrupted backtick run
	for run in tostring(text or ""):gmatch("`+") do
		if #run > max_run then
			max_run = #run
		end
	end

	return string.rep("`", max_run + 1)
end

--- Build code-span markers with padding when content touches a backtick
-- @param text string code-span content
-- @return string open_marker opening fence and optional padding
-- @return string close_marker optional padding and closing fence
local function md_build_code_span_markers(text)
	local source = tostring(text or "")
	local fence = md_pick_code_fence(source)

	-- Pattern breakdown:
	-- The start and end anchors detect a backtick at either content boundary
	if source:match("^`") or source:match("`$") then
		return fence .. " ", " " .. fence
	end

	return fence, fence
end

--- Count a backtick run ending at one source position
-- @param text string complete source line
-- @param col number final backtick column
-- @return number width run width
local function md_count_backticks_left(text, col)
	local width = 0

	while col - width >= 1 and text:sub(col - width, col - width) == "`" do
		width = width + 1
	end

	return width
end

--- Count a backtick run starting at one source position
-- @param text string complete source line
-- @param col number first backtick column
-- @return number width run width
local function md_count_backticks_right(text, col)
	local width = 0

	while text:sub(col + width, col + width) == "`" do
		width = width + 1
	end

	return width
end

--- Detect a code-span fence immediately around one current wrapper range
-- Supports direct fences and the one-space padding used for boundary backticks
-- @param line_text string complete source line
-- @param col1 number current wrapped range start
-- @param col2 number current wrapped range end
-- @return number|nil open_width opening fence and padding width
-- @return number|nil close_width closing padding and fence width
local function md_read_code_span_wrapper(line_text, col1, col2)
	local left_width = md_count_backticks_left(line_text, col1 - 1)
	local right_width = md_count_backticks_right(line_text, col2)

	if left_width > 0 and left_width == right_width then
		return left_width, right_width
	end

	local has_padding = line_text:sub(col1 - 1, col1 - 1) == " "
		and line_text:sub(col2, col2) == " "

	if not has_padding then
		return nil, nil
	end

	left_width = md_count_backticks_left(line_text, col1 - 2)
	right_width = md_count_backticks_right(line_text, col2 + 1)

	if left_width > 0 and left_width == right_width then
		return left_width + 1, right_width + 1
	end

	return nil, nil
end

--- Read every recognized inline wrapper around one selection in any nesting order
-- @param line_text string complete source line
-- @param col1 number selected text start column
-- @param col2 number selected text end column
-- @return table formats active format set
-- @return number range_col1 complete wrapper start
-- @return number range_col2 complete wrapper end
local function md_read_inline_formats(line_text, col1, col2)
	local formats = {}
	local range_col1 = col1
	local range_col2 = col2
	local found_wrapper = true

	while found_wrapper do
		found_wrapper = false
		local code_open_width, code_close_width = md_read_code_span_wrapper(
			line_text,
			range_col1,
			range_col2
		)

		if code_open_width then
			formats.code = true
			range_col1 = range_col1 - code_open_width
			range_col2 = range_col2 + code_close_width
			found_wrapper = true
		end

		if not found_wrapper then
			for index = 1, #md_inline_format_wrappers do
				local wrapper = md_inline_format_wrappers[index]
				local open_width = #wrapper.open
				local close_width = #wrapper.close
				local left_marker = line_text:sub(range_col1 - open_width, range_col1 - 1)
				local right_marker = line_text:sub(range_col2, range_col2 + close_width - 1)

				if left_marker == wrapper.open and right_marker == wrapper.close then
					for format_index = 1, #wrapper.formats do
						formats[wrapper.formats[format_index]] = true
					end

					range_col1 = range_col1 - open_width
					range_col2 = range_col2 + close_width
					found_wrapper = true
					break
				end
			end
		end
	end

	return formats, range_col1, range_col2
end

--- Build canonical Markdown wrappers for selected text and active formats
-- @param formats table active format set
-- @param text string selected text
-- @return string open_marker complete opening wrappers
-- @return string close_marker complete closing wrappers
local function md_build_inline_format_markers(formats, text)
	if formats.code then
		return md_build_code_span_markers(text)
	end

	local emphasis_marker = ""

	if formats.bold and formats.italic then
		emphasis_marker = "***"
	elseif formats.bold then
		emphasis_marker = "**"
	elseif formats.italic then
		emphasis_marker = "*"
	end

	local open_marker = (formats.underline and "<u>" or "")
		.. (formats.strikethrough and "~~" or "")
		.. emphasis_marker
	local close_marker = emphasis_marker
		.. (formats.strikethrough and "~~" or "")
		.. (formats.underline and "</u>" or "")

	return open_marker, close_marker
end

local function add_fence_patterns(patterns, marker, entries)
	for _, entry in ipairs(entries) do
		patterns[#patterns + 1] = {
			-- Pattern breakdown:
			-- Match the fence and language label, then require spacing before optional details
			pattern = { "%s*(" .. marker .. "%s*" .. entry[1] .. ")%f[%s][^\n]*\n", marker },
			type = { "meta" },
			syntax = entry[2],
			-- Retain the declared fence label so preview uses this same target
			fence_language = entry[1],
			line_bg = "code",
			line_start = true,
			caseless = true,
		}
	end
	patterns[#patterns + 1] = {
		pattern = { "%s*" .. marker, marker },
		type = "string",
		line_bg = "code",
		line_start = true,
	}
end

local md_fence_entries = {
	{ "c%+%+", ".cpp" },
	{ "cpp", ".cpp" },
	{ "c++", ".cpp" },
	{ "cc", ".cpp" },
	{ "cxx", ".cpp" },
	{ "csharp", ".cs" },
	{ "cmake", ".cmake" },
	{ "c#", ".cs" },
	{ "cs", ".cs" },
	{ "c", ".c" },
	{ "go", ".go" },
	{ "golang", ".go" },
	{ "html", ".html" },
	{ "htm", ".html" },
	{ "ini", ".ini" },
	{ "cfg", ".cfg" },
	{ "css", ".css" },
	{ "scss", ".scss" },
	{ "sass", ".scss" },
	{ "less", ".css" },
	{ "json", ".json" },
	{ "json5", ".json" },
	{ "jsonl", ".json" },
	{ "ndjson", ".json" },
	{ "javascript", ".js" },
	{ "js", ".js" },
	{ "typescript", ".js" },
	{ "ts", ".js" },
	{ "jsx", ".js" },
	{ "tsx", ".js" },
	{ "lua", ".lua" },
	{ "php", ".php" },
	{ "pascal", ".pas" },
	{ "pas", ".pas" },
	{ "python", ".py" },
	{ "py", ".py" },
	{ "reg", ".reg" },
	{ "rust", ".rs" },
	{ "rs", ".rs" },
	{ "r", ".r" },
	{ "md", ".md" },
	{ "markdown", ".md" },
	{ "cli", "module:cli" },
	{ "shell", ".sh" },
	{ "bash", ".sh" },
	{ "sh", ".sh" },
	{ "zsh", ".sh" },
	{ "batch", ".bat" },
	{ "bat", ".bat" },
	{ "cmd", ".cmd" },
	{ "text", ".txt" },
	{ "txt", ".txt" },
	{ "toml", ".toml" },
	{ "plain", ".txt" },
	{ "yaml", "module:yaml" },
	-- Use explicit format modules for dialects that cannot be resolved by file extension
	{ "mysql", "module:sql_mysql" },
	{ "psql", "module:sql_pgsql" },
	{ "pgsql", "module:sql_pgsql" },
	{ "postgresql", "module:sql_pgsql" },
	{ "sqlite", "module:sql_sqlite" },
	{ "sql", ".sql" },
	{ "xml", ".xml" },
	{ "csv", ".csv" },
	{ "tsv", ".tsv" },
}

local md_line_patterns = {
	-- Horizontal rule
	{ pattern = "%-%-%-+", type = "comment", consume_line = true },
	{ pattern = "%*%*%*+", type = "comment", consume_line = true },
	{ pattern = "%_%_%_+", type = "comment", consume_line = true },
}

local md_patterns = {
	-- Escape sequence
	{ pattern = "\\.", type = "normal" },

	-- HTML comment block
	{ pattern = { "<!%-%-", "%-%->" }, type = "comment" },

	-- Unordered item: "* "
	{ pattern = "%s*(%*)%s+", type = { "bold" }, line_start = true },

	-- Ordered item: 1. 2. 10. 99. 05. (two digits)
	{ pattern = "%s*(%d%d?%.)%s+", type = { "bold" }, line_start = true },

	-- Enumeration item: "- "
	{ pattern = "%s*(%-)%s+", type = { "operator" }, line_start = true },

	-- Line numbers: ":274"
--	{ pattern = { "%:(%d-)[%]%)]", type = { "dimmed" } } },

	-- Bold+italic span
	{ pattern = "(%*%*%*)([^%*\n]-)(%*%*%*)", type = { "operator", "bold_italic", "operator" } },

	-- Bold span
	{ pattern = "(%*%*)([^%*\n]-)(%*%*)", type = { "operator", "bold", "operator" } },

	-- Italic span (single-line)
	{ pattern = "(%*)([^%*\n]-)(%*)", type = { "operator", "italic", "operator" } },

	-- Underscore emphasis span
	{ pattern = { "%_", "[%_\n]", "\\" }, type = "keyword2" },

	-- Underline span
	{ pattern = "(%<u%>)([^%*\n]-)(%<%/u%>)", type = { "operator", "underline", "operator" } },

	-- Markdown reference link: [label][id]
	{
		pattern = "(%[)([^\n%]]-)(%])(%[)([^\n%]]-)(%])",
		type = { "operator", "bold", "operator", "operator", "label", "operator" },
	},
	-- Markdown inline link: [label](url)
	{
		pattern = "(%[)([^\n%]]-)(%])(%()([^\n%)]-)(%))",
		type = { "operator", "bold", "operator", "operator", "uri", "operator" }, -- TODO: uri color
	},
	-- Markdown image link: ![label](url)
	{
		pattern = "(!%[)([^\n%]]-)(%])(%()([^\n%)]-)(%))",
		type = { "operator", "constant", "operator", "operator", "uri", "operator" }, -- TODO: uri color
	},

	-- TODO: ``` ... ``` without type specifier (currently all red)

	-- References: [name]: link
	{ pattern = "%s*(%[)(.+)(%]:)", type = { "operator", "label", "operator" }, line_start = true },

	-- TODO: [![Luacheck](https://img.shields.io/github/actions/workflow/status/lunarmodules/lua_cliargs/luacheck.yml?branch=master&label=Luacheck&logo=Lua)](https://github.com/lunarmodules/lua_cliargs/actions?workflow=Luacheck)

	-- TODO: ([#1426](https://github.com/lite-xl/lite-xl/pull/1426))

	-- TODO: Turn on or off certain plugins per syntax, e.g. #xxx colorhighlighter here
	-- (#162)
	{ pattern = "%(%#%d+%)", type = "label" },

	-- TODO: User defined syntax extensions (custom highlights on top of existing ones)
--	{ pattern = "#### You:", type = "quote", line_start = true },
--	{ pattern = "#### ChatGPT:", type = "quote", line_start = true },

	-- ATX heading line, up to three spaces allowed
	{ pattern = "%s?%s?%s?#.-\n", type = "heading", line_start = true },
	{ pattern = "%s?%s?%s?%>.-\n", type = "quote", line_start = true },

	-- Numeric entity
	{ pattern = "&#%d+;", type = "literal" },

	-- Hex entity
	{ pattern = "&#x[%x]+;", type = "hex" },

	-- Markdown link or image
	{ pattern = "!?%[.-%]%(.-%)", type = "function" },

	-- Bare URL
	{ pattern = "https?://%S+", type = "function" },
}

-- Keep fenced blocks low in the list to avoid unnecessary matching work
add_fence_patterns(md_patterns, "```", md_fence_entries)
add_fence_patterns(md_patterns, "~~~", md_fence_entries)

-- Keep these after fences so block delimiters win over inline tokens

-- Inline code (double backtick)
md_patterns[#md_patterns + 1] = {
	pattern = "(``)([^`\n]-)(``)",
	type = { "operator", "bold", "operator" },
}
-- Inline code (single backtick)
md_patterns[#md_patterns + 1] = {
	pattern = "(`)([^`\n]-)(`)",
	type = { "operator", "bold", "operator" },
	priority = 10,
}
-- Strikethrough span
md_patterns[#md_patterns + 1] = {
	pattern = { "~~", "~~", "\\" },
	type = "error",
}

md_blockquote_profile = function()
	local function wrap_blockquote(text)
		local s = tostring(text or "")
		if s == "" then
			return "> ", nil
		end
		return common.map_lines_keep_eol(s, function(line)
			return "> " .. line
		end), nil
	end

	local function unwrap_blockquote(text)
		local s = tostring(text or "")
		local had_any = false
		local out = common.map_lines_keep_eol(s, function(line)
			local stripped = line:gsub("^%s*>%s?", "", 1)
			if stripped ~= line then
				had_any = true
			end
			return stripped
		end)
		if not had_any then
			return nil, "wrapper mismatch"
		end
		return out, nil
	end

	return {
		input_multiline = true,
		output_multiline = true,
		wrap = wrap_blockquote,
		unwrap = unwrap_blockquote,
	}
end

md_inline_profile = function()
	local function wrap_inline(text)
		local s = tostring(text or "")
		local open_marker, close_marker = md_build_code_span_markers(s)

		return open_marker .. s .. close_marker, nil
	end

	local function unwrap_inline(text)
		local s = tostring(text or "")
		local fence, body = s:match("^(`+)(.*)%1$")
		if not fence then
			return nil, "wrapper mismatch"
		end
		return body, nil
	end

	return {
		input_multiline = false,
		output_multiline = false,
		wrap = wrap_inline,
		unwrap = unwrap_inline,
	}
end

--- Toggle canonical Markdown inline format markers around every selection
-- The selected words stay selected while surrounding markers are replaced atomically
-- @param doc table active text document
-- @param format_name string generic format name
-- @return boolean changed
-- @return string|nil format_error friendly rejection reason
md_toggle_inline_format = function(doc, format_name)
	return text_format.transform_inline_selections(doc, function(selected)
		local formats, range_col1, range_col2 = md_read_inline_formats(
			selected.line_text,
			selected.col1,
			selected.col2
		)

		if format_name == "code" then
			formats = formats.code and {} or { code = true }
		elseif formats.code then
			formats = { [format_name] = true }
		else
			formats[format_name] = not formats[format_name]
		end

		local open_marker, close_marker = md_build_inline_format_markers(formats, selected.text)

		return {
			range_col1 = range_col1,
			range_col2 = range_col2,
			text = open_marker .. selected.text .. close_marker,
			selection_offset1 = #open_marker,
			selection_offset2 = #open_marker + #selected.text,
		}
	end)
end

-- Markdown syntax
format.register({
	name = "Markdown Document",
	shortname = "Markdown",
	default_mode = "preview", -- Open rendered Markdown unless the user chooses another mode
	extensions = { "md", "markdown" },
	author = "liqube",
	created = "2026-02-16",

	line_patterns = md_line_patterns,
	patterns = md_patterns,

	-- Custom comment hook that conveniently produces headings from lines
	comment_hook = function(action, text)
		return md_comment_hook(action, text)
	end,

	outline_hook = function(action, text)
		return md_outline_hook(action, text)
	end,

	outline_patterns = {
		{ pattern = "%s?%s?%s?#%s+", line_start = true, outline_level = 1 }, -- H1
		{ pattern = "%s?%s?%s?##%s+", line_start = true, outline_level = 2 }, -- H2
		{ pattern = "%s?%s?%s?###%s+", line_start = true, outline_level = 3 }, -- H3
		{ pattern = "%s?%s?%s?####%s+", line_start = true, outline_level = 4 }, -- H4
		{ pattern = "%s?%s?%s?#####%s+", line_start = true, outline_level = 5 }, -- H5
		{ pattern = "%s?%s?%s?######%s+", line_start = true, outline_level = 6 }, -- H6
		--	{ pattern = "%s?%s?%s?>%s?([^%s>].*)", line_start = true, block_start_only = true }, -- Level 1 quotes
		{ pattern = "%s?%s?%s?>%s?", line_start = true, block_start_only = true }, -- Quotes
	},

	quotes = {
		primary = "blockquote",
		secondary = "inline",
		profiles = {
			blockquote = md_blockquote_profile(),
			inline = md_inline_profile(),
		},
	},
	-- Generic editor commands delegate Markdown marker normalization here
	format_commands = {
		bold = md_toggle_inline_format,
		code = md_toggle_inline_format,
		italic = md_toggle_inline_format,
		strikethrough = md_toggle_inline_format,
		underline = md_toggle_inline_format,
	},
	-- Typed pair definitions wrap selected text and control marker repeats
	surrounding_pairs = {
		["*"] = { close = "*", max_repeat = 3 },
		["_"] = { close = "_", max_repeat = 3 },
		["~"] = { close = "~", min_repeat = 2, max_repeat = 2 },
		["`"] = { close = "`", max_repeat = 4, behavior = "stop" },
		["$"] = { close = "$", max_repeat = 2 },
--		["("] = ")",
--		["["] = "]",
--		["{"] = "}",
--		["\""] = { close = "\"", max_repeat = 1 },
--		["'"] = { close = "'", max_repeat = 1 },
	},
	symbols = {},
})

-- Cycles a line through markdown heading levels:
-- plain text -> '#' -> '##' -> '###' -> '####' -> plain text
-- Leading whitespace is ignored so toggling is stable on indented lines
md_cycle_heading_prefix = function(line)
	local s = line:gsub("^%s*", "")
	local max_levels = 4
	if s == "" then
		return s
	end
	local hashes, body = s:match("^(#+)%s+(.*)$")
	if hashes then
		local level = #hashes
		if level >= max_levels then
			return body
		end
		if body == "" then
			return string.rep("#", level + 1) .. " "
		end
		return string.rep("#", level + 1) .. " " .. body
	end
	return "# " .. s
end

-- Removes leading heading markers and surrounding whitespace from one line
md_remove_heading_prefix = function(line)
	return (line:gsub("^[%s#]+", "", 1))
end

-- Reuses comment actions to provide markdown heading transforms
-- 'toggle-line'/'toggle-smart' cycle heading depth; 'uncomment' strips heading prefix
md_comment_hook = function(action, text)
	if action == "toggle-line" or action == "toggle-smart" then
		return common.map_lines_keep_eol(text, md_cycle_heading_prefix)
	end
	if action == "uncomment" then
		return common.map_lines_keep_eol(text, md_remove_heading_prefix)
	end
	return nil
end

-- Post-processes outliner row text for markdown:
-- when a line starts with '# '..'###### ' (after left trim), prefix equal spaces
-- to create pseudo-indentation by heading depth in the outline popup
md_outline_hook = function(action, text, level)
	if action ~= "format" then
		return nil
	end
	if type(level) == "number" and level > 0 and level <= 6 then
		return string.rep(" ", level) .. text:gsub("^%s*", "")
	end
	local len = #text
	local pos = 1
	while pos <= len and text:byte(pos) == 32 do
		pos = pos + 1
	end

	local hash_start = pos
	while pos <= len and (pos - hash_start) < 6 and text:byte(pos) == 35 do
		pos = pos + 1
	end

	local hash_count = pos - hash_start
	if hash_count < 1 or hash_count > 6 then
		return text
	end

	if text:byte(pos) ~= 32 then
		return text
	end

	return string.rep(" ", hash_count) .. text:sub(hash_start)
end
