-- lx-bundle:true

local common = require("core.common")

local syntax = {}
syntax.items = {}
syntax.REMOVE = {}

-- If this syntax module is present, it will override plain_text_syntax,
-- which is the default for plain text files
local plain_text_override_module = "syntax.plain"

-- Default syntax for plain text files
local plain_text_syntax = {
	name = "Plain Text",
	shortname = "Plain Text",
	patterns = {},
	symbols = {},
	post_symbols = {},
}

-- Default syntax for binary files
local plain_binary_syntax = {
	name = "Binary Data",
	shortname = "Binary Data",
	patterns = {},
	symbols = {},
	post_symbols = {},
}

-- Lazy syntax registry/cache state
local registry_loaded = false
local registry_entries = {}
local registry_name_index = {}
local loaded_modules = {}
local cache_disabled = false
local session_no_cache = false
local loading_module = nil
local reported_module_errors = {}
local reported_layout_conflicts = {}
local reported_bad_inherit_refs = {}

-- Forward declaration used by syntax.add before helper definition
local cache_file_spec_lookups

-- Keep detect-hook presence cheap to query from loaded syntax tables and cached entries
local function has_detect_hook_flag(target)
	return target and target.has_detect_hook == true
end

-- Keep detect-hook candidates ahead of generic fallbacks when scores tie
local function candidate_has_detect_hook(target)
	return (type(target) == "table" and type(target.detect_hook) == "function") or has_detect_hook_flag(target)
end

-- Sort candidates by specificity first, then by detect-hook priority, then by recency
local function sort_candidates(matches)
	table.sort(matches, function(a, b)
		if a.score ~= b.score then
			return a.score > b.score
		end

		local a_hook = candidate_has_detect_hook(a.target)
		local b_hook = candidate_has_detect_hook(b.target)

		if a_hook ~= b_hook then
			return a_hook
		end

		return a.order > b.order
	end)
end

-- Run a callback next frame when thread scheduling is available
local function run_next_frame(core_mod, fn)
	if type(core_mod) == "table" then
		core_mod.add_thread(function()
			coroutine.yield(0)
			fn()
		end)
		return
	end

	fn()
end

-- Trigger visible error attention with best-effort fallbacks
local function flash_error_attention(core_mod, msg)
	local function pulse_once()
		if type(core_mod) == "table" and core_mod.status_view then
			core_mod.status_view:show_error(msg)
		end

		local command = package.loaded["core.command"]

		if command and type(command.perform) == "function" then
			pcall(command.perform, "frameflash:error")
		end
	end

	pulse_once()

	run_next_frame(core_mod, pulse_once)
end

-- Bring startup focus to the latest error entry in the log view
local function focus_last_error_log(core_mod)
	run_next_frame(core_mod, function()
		local command = package.loaded["core.command"]

		if command and type(command.perform) == "function" then
			pcall(command.perform, "log:open-last-error")
		end
	end)
end

local function report_syntax_module_error(modname, err)
	if reported_module_errors[modname] then
		return
	end
	reported_module_errors[modname] = true
	local short_msg = string.format('Failed to load syntax module "%s"', tostring(modname))
	local detail_msg = tostring(err)
	local msg = string.format("%s: %s", short_msg, detail_msg)

	local core_mod = package.loaded["core"]
	local item = nil
	if core_mod then
		item = core_mod.error("%s", short_msg)
		if item and detail_msg ~= "" then
			item.info = detail_msg
		end
	else
		print("[syntax] " .. msg)
	end

	if item and core_mod then
		item.startup_error = true
		item.startup_error_source = core_mod._startup_error_source
		core_mod.startup_error_log_id = item.id
	end

	flash_error_attention(core_mod, short_msg)

	focus_last_error_log(core_mod)

	-- Avoid duplicate diagnostics: only persist to error.txt when app log isn't available
	if not (core_mod and core_mod.error) then
		local path = (EXE_DIR or ".") .. "/error.txt"
		local ok, fp = pcall(io.open, path, "ab")
		if ok and fp then
			fp:write("[syntax] ", msg, "\n")
			fp:close()
		end
	end
end

-- Warn once when both flat and nested syntax entry files exist for one module name
local function report_layout_conflict(name, flat_path, nested_path)
	if reported_layout_conflicts[name] then
		return
	end

	reported_layout_conflicts[name] = true

	local msg = string.format(
		'Syntax module "%s" has both flat and nested entries; using flat "%s", ignoring "%s"',
		tostring(name),
		tostring(flat_path),
		tostring(nested_path)
	)

	local core_mod = package.loaded["core"]
	if core_mod then
		core_mod.warning("%s", msg)
	else
		print("[syntax] " .. msg)
	end
end

-- Write one syntax-cache status line to the log view when core logging is available
local function log_syntax_cache_status(message, detail)
	local core_mod = package.loaded["core"]
	if not core_mod or type(core_mod.log_quiet) ~= "function" then
		return
	end

	if type(detail) == "string" and detail ~= "" then
		core_mod.log_quiet("%s: %s", message, detail)
		return
	end

	core_mod.log_quiet("%s", message)
end

-- Return nested fallback module id for canonical 'syntax.<name>' when flat file is absent
local function get_nested_fallback_module(modname)
	if type(modname) ~= "string" then
		return nil
	end

	local base = modname:match("^syntax%.([%w_]+)$")
	if not base then
		return nil
	end

	if type(SYNTAX_DIR) ~= "string" then
		return modname .. "." .. base
	end

	local flat_path = system.join_path(SYNTAX_DIR, base .. ".lua")
	if system.is_file(flat_path) == true then
		return nil
	end

	local dir_path = system.join_path(SYNTAX_DIR, base)
	local nested_path = system.join_path(dir_path, base .. ".lua")
	if system.is_file(nested_path) == true then
		return modname .. "." .. base
	end

	return nil
end

-- Require syntax module by canonical id, with nested-file fallback when flat entry is missing
local function require_syntax_module(modname)
	loading_module = modname
	local ok, err = pcall(require, modname)
	loading_module = nil
	if ok then
		return true
	end

	local fallback = get_nested_fallback_module(modname)
	if not fallback then
		return false, err
	end

	loading_module = modname
	local ok_fallback, err_fallback = pcall(require, fallback)
	loading_module = nil
	if ok_fallback then
		return true
	end

	return false, err_fallback
end

-- Registry cache path outside 'data/' and temp-write path
local function get_registry_paths()
	return CACHE_DIR,
		CACHE_DIR .. "syntax$.lua",
		CACHE_DIR .. "syntax$.tmp"
end

-- Read CLI flags defensively from raw ARGS (supports sparse/non-1-based layouts)
local function has_cli_flag(flag)
	if type(ARGS) ~= "table" then
		return false
	end
	for _, v in pairs(ARGS) do
		if v == flag then
			return true
		end
	end
	return false
end

-- Activate process-wide no-cache mode if CLI flag is present
local function apply_no_cache_from_cli()
	if not session_no_cache and has_cli_flag("--no-cache") then
		session_no_cache = true
	end
	if session_no_cache then
		cache_disabled = true
	end
	return session_no_cache
end

-- Enumerate all syntax module names from 'data/syntax'
local function get_syntax_modules()
	local bundled = rawget(_G, "BUNDLED_SYNTAX")
	if type(bundled) == "table" and #bundled > 0 then
		local modules = {}
		local seen = {}

		local function add_bundled_module(modname)
			if type(modname) ~= "string" or not modname:match("^syntax%.") or modname == "syntax._" then
				return
			end

			local canonical = modname
			local base = modname:match("^syntax%.([%w_]+)%.%1$")
			if base then
				canonical = "syntax." .. base
			end

			if seen[canonical] then
				return
			end

			seen[canonical] = true
			modules[#modules + 1] = canonical
		end

		for _, modname in ipairs(bundled) do
			add_bundled_module(modname)
		end
		table.sort(modules, function(a, b)
			local an = a:gsub("^syntax%.", "")
			local bn = b:gsub("^syntax%.", "")
			if an ~= bn then
				if an:match("^" .. bn:gsub("([^%w])", "%%%1") .. "_") then
					return true
				end
				if bn:match("^" .. an:gsub("([^%w])", "%%%1") .. "_") then
					return false
				end
			end
			return an < bn
		end)
		return modules
	end

	if type(SYNTAX_DIR) ~= "string" then
		return {}
	end

	local files = system.list_dir(SYNTAX_DIR) or {}

	local module_names = {}
	local seen_modules = {}

	local function add_module_name(name)
		if type(name) ~= "string" or name == "" then
			return
		end

		if seen_modules[name] then
			return
		end

		seen_modules[name] = true
		module_names[#module_names + 1] = name
	end

	local flat_entries = {}

	-- Discover canonical syntax.<name> from data/syntax/<name>.lua
	for _, filename in ipairs(files) do
		if filename ~= "_.lua" and filename:match("%.lua$") then
			local name = filename:gsub("%.lua$", "")
			flat_entries[name] = true
			add_module_name(name)
		end
	end

	-- Discover canonical syntax.<name> from data/syntax/<name>/<name>.lua
	for _, entry in ipairs(files) do
		local dir_path = system.join_path(SYNTAX_DIR, entry)
		local info = system.get_file_info(dir_path)

		if info and info.type == "dir" then
			local nested = system.join_path(dir_path, entry .. ".lua")

			if system.is_file(nested) == true then
				if flat_entries[entry] then
					local flat_path = system.join_path(SYNTAX_DIR, entry .. ".lua")
					report_layout_conflict(entry, flat_path, nested)
				else
					add_module_name(entry)
				end
			end
		end
	end

	-- Order specific module variants before prefix aggregators
	table.sort(module_names, function(a, b)
		local an = a
		local bn = b

		if an ~= bn then
			if an:match("^" .. bn:gsub("([^%w])", "%%%1") .. "_") then
				return true
			end

			if bn:match("^" .. an:gsub("([^%w])", "%%%1") .. "_") then
				return false
			end
		end

		return an < bn
	end)

	local modules = {}

	for _, name in ipairs(module_names) do
		modules[#modules + 1] = "syntax." .. name
	end

	return modules
end

-- Require syntax modules and tag newly added syntax entries with module id
local function load_syntax_modules(modules)
	for _, mod in ipairs(modules) do
		if loaded_modules[mod] == nil then
			local before = #syntax.items
			local ok, err = require_syntax_module(mod)
			loaded_modules[mod] = ok and true or false
			if ok then
				for i = before + 1, #syntax.items do
					local item = syntax.items[i]
					if type(item) == "table" and (type(item._module_name) ~= "string" or item._module_name == "") then
						item._module_name = mod
					end
				end
			else
				report_syntax_module_error(mod, err)
			end
		end
	end
end

-- Expand literal letters in a Lua pattern to case-insensitive classes
-- Keeps escaped classes (e.g. %a, %f, %s) and bracket classes untouched
local function make_pattern_caseless(pattern)
	local out = {}
	local i = 1
	local in_class = false
	while i <= #pattern do
		local ch = pattern:sub(i, i)
		if ch == "%" then
			out[#out + 1] = ch
			i = i + 1
			if i <= #pattern then
				out[#out + 1] = pattern:sub(i, i)
			end
		elseif ch == "[" then
			in_class = true
			out[#out + 1] = ch
		elseif ch == "]" and in_class then
			in_class = false
			out[#out + 1] = ch
		else
			if not in_class and ch:match("%a") then
				local lo = ch:lower()
				local up = ch:upper()
				if lo ~= up then
					out[#out + 1] = "[" .. lo .. up .. "]"
				else
					out[#out + 1] = ch
				end
			else
				out[#out + 1] = ch
			end
		end
		i = i + 1
	end
	return table.concat(out)
end

-- Derive a stable module label from syntax module/source id
local function get_module_label(module_name)
	if type(module_name) ~= "string" or module_name == "" then
		return "syntax"
	end
	local label = module_name:gsub("\\", "/"):match("([^/]+)$") or module_name
	label = label:gsub("%.lua$", "")
	label = label:match("([^%.]+)$") or label
	if label == "" then
		return "syntax"
	end
	return label
end

--- Collapse nested self-name source paths back to canonical module ids
-- @param prefix string module namespace prefix
-- @param path string normalized source path
-- @return string|nil canonical module id
local function canonicalize_nested_self_name_source(prefix, path)
	local rel = path:match("data/" .. prefix .. "/(.+)%.lua$")
	if not rel then
		return nil
	end

	local parts = {}
	for part in rel:gmatch("[^/]+") do
		parts[#parts + 1] = part
	end

	if #parts == 2 and parts[1] == parts[2] then
		return prefix .. "." .. parts[1]
	end

	return prefix .. "." .. rel:gsub("/", ".")
end

-- Derive fallback label from syntax file patterns when module id is generic
local function get_label_from_files(files)
	local list = type(files) == "string" and { files } or files
	if type(list) ~= "table" then
		return nil
	end
	for _, pat in ipairs(list) do
		if type(pat) == "string" then
			local ext = pat:match("^%%%.([%w_]+)%$$")
			if ext and ext ~= "" then
				return ext
			end
		end
	end
	return nil
end

-- Apply/repair syntax display names using module-derived fallback labels
local function apply_syntax_names(t, module_hint)
	local module_name = module_hint or t._module_name
	local label = get_module_label(module_name)
	if label == "syntax" then
		label = get_label_from_files(t.files) or label
	end
	local fallback_name = "[" .. label .. "]"

	if not t.name and not t.shortname then
		t.name = fallback_name
		t.shortname = fallback_name
	elseif t.name and not t.shortname then
		t.shortname = t.name
	elseif t.shortname and not t.name then
		t.name = t.shortname
	end

	-- Repair placeholder values when a concrete module label is known
	if label ~= "syntax" then
		if t.name == "[syntax]" then
			t.name = fallback_name
		end
		if t.shortname == "[syntax]" then
			t.shortname = fallback_name
		end
	end
end

-- Deep-copy helper used by syntax inheritance
local function deep_copy(v, seen)
	if type(v) ~= "table" then
		return v
	end
	if v == syntax.REMOVE then
		return syntax.REMOVE
	end
	seen = seen or {}
	if seen[v] then
		return seen[v]
	end
	local out = {}
	seen[v] = out
	for k, vv in pairs(v) do
		out[deep_copy(k, seen)] = deep_copy(vv, seen)
	end
	local mt = getmetatable(v)
	if mt then
		setmetatable(out, mt)
	end
	return out
end

-- Deep-merge 'src' onto 'dst'; supports tombstone deletes via 'syntax.REMOVE'
local function deep_merge(dst, src)
	for k, v in pairs(src) do
		if v == syntax.REMOVE then
			dst[k] = nil
		else
			local cur = dst[k]
			if type(cur) == "table" and type(v) == "table" then
				deep_merge(cur, v)
			else
				dst[k] = deep_copy(v)
			end
		end
	end
end

--- Report one compact symbol-spec validation error
-- Uses the shared core error channel when available
-- @param message string human-readable error
-- @return nil
local function report_symbol_spec_error(message)
	local core_mod = package.loaded["core"]
	if core_mod and type(core_mod.error) == "function" then
		core_mod.error("%s", tostring(message))
	else
		print("[syntax] " .. tostring(message))
	end
end

--- Expand one symbol name into the normalized lookup map
-- Applies raw/lower/upper/capital variants once at syntax load time
-- @param out table normalized symbol map
-- @param symbol_name string source symbol text
-- @param symbol_type string token type
-- @param flags string expansion flags
-- @return nil
local function expand_symbol_entry(out, symbol_name, symbol_type, flags)
	if flags:find("r", 1, true) then
		out[symbol_name] = symbol_type
	end
	if flags:find("l", 1, true) then
		out[symbol_name:lower()] = symbol_type
	end
	if flags:find("u", 1, true) then
		out[symbol_name:upper()] = symbol_type
	end
	if flags:find("c", 1, true) then
		out[symbol_name:sub(1, 1):upper() .. symbol_name:sub(2):lower()] = symbol_type
	end
end

--- Validate compact symbol expansion flags
-- Unknown flags are rejected so syntax typos do not silently change matching
-- @param owner string syntax name used in diagnostics
-- @param symbol_name string symbol key or '_'
-- @param flags string|any compact expansion flags
-- @return boolean valid
local function validate_symbol_flags(owner, symbol_name, flags)
	if type(flags) ~= "string" or flags == "" then
		report_symbol_spec_error(string.format(
			"Invalid symbol flags in %s[%s]: expected non-empty string, got %s",
			tostring(owner or "syntax"),
			tostring(symbol_name),
			type(flags)
		))
		return false
	end

	for i = 1, #flags do
		local flag = flags:sub(i, i)
		if flag ~= "r" and flag ~= "l" and flag ~= "u" and flag ~= "c" then
			report_symbol_spec_error(string.format(
				"Invalid symbol flag '%s' in %s[%s]",
				tostring(flag),
				tostring(owner or "syntax"),
				tostring(symbol_name)
			))
			return false
		end
	end

	return true
end

--- Normalize author-facing symbol specs into a flat lookup table
-- Supports '_expansion_flags' defaults and optional per-symbol override flags
-- @param owner string syntax name used in diagnostics
-- @param raw_symbols table|any source symbol spec
-- @return table normalized symbol lookup table
local function normalize_symbol_map(owner, raw_symbols)
	if type(raw_symbols) ~= "table" then
		return {}
	end

	local normalized = {}
	local default_flags = "r"
	local configured_default = raw_symbols._expansion_flags
	if configured_default ~= nil then
		if validate_symbol_flags(owner, "_expansion_flags", configured_default) then
			default_flags = configured_default
		end
	end

	-- Expand each author-facing entry once so token lookup stays a simple hash-table read
	for symbol_name, symbol_spec in pairs(raw_symbols) do
		if symbol_name ~= "_expansion_flags" then
			if symbol_spec == syntax.REMOVE then
				normalized[symbol_name] = syntax.REMOVE
			elseif type(symbol_spec) == "string" then
				expand_symbol_entry(normalized, symbol_name, symbol_spec, default_flags)
			elseif type(symbol_spec) == "table" then
				local symbol_type = symbol_spec[1]
				local symbol_flags = symbol_spec[2] or default_flags
				if type(symbol_type) ~= "string" or symbol_type == "" then
					report_symbol_spec_error(string.format(
						"Invalid symbol spec in %s[%s]: expected type string at index 1",
						tostring(owner or "syntax"),
						tostring(symbol_name)
					))
				elseif validate_symbol_flags(owner, symbol_name, symbol_flags) then
					expand_symbol_entry(normalized, symbol_name, symbol_type, symbol_flags)
				else
					expand_symbol_entry(normalized, symbol_name, symbol_type, default_flags)
				end
			else
				report_symbol_spec_error(string.format(
					"Invalid symbol spec in %s[%s]: expected string or table, got %s",
					tostring(owner or "syntax"),
					tostring(symbol_name),
					type(symbol_spec)
				))
			end
		end
	end

	return normalized
end

-- Flattens pattern helper output so one helper can return one rule or many rules
local function flatten_pattern_rules(items)
	local out = {}

	local function append_item(item)
		if type(item) ~= "table" then
			return
		end
		if item.pattern ~= nil then
			out[#out + 1] = item
			return
		end
		for i = 1, #item do
			append_item(item[i])
		end
	end

	for i = 1, #items do
		append_item(items[i])
	end

	return out
end

-- Resolve inherit source by module id, then by extension alias
local function resolve_inherit_source(syntax_ref)
	if type(syntax_ref) ~= "string" or syntax_ref == "" then
		return nil
	end

	local function warn_bad_inherit_ref_once(ref)
		local key = tostring(ref)
		if reported_bad_inherit_refs[key] then
			return
		end
		reported_bad_inherit_refs[key] = true

		local msg = string.format(
			"Invalid inherit.syntax value, expected 'module:<name>' or '.<ext>': %q",
			tostring(ref)
		)
		local core_mod = package.loaded["core"]
		if core_mod then
			core_mod.error("%s", msg)
		else
			print("[syntax] " .. msg)
		end
	end

	local module_name = nil
	local ext_query = nil
	if syntax_ref:sub(1, 7) == "module:" then
		local tail = syntax_ref:sub(8)
		if tail == "" then
			warn_bad_inherit_ref_once(syntax_ref)
			return nil
		end
		module_name = "syntax." .. tail
	elseif syntax_ref:sub(1, 1) == "." then
		ext_query = syntax_ref:sub(2):lower()
		if ext_query == "" then
			warn_bad_inherit_ref_once(syntax_ref)
			return nil
		end
	else
		warn_bad_inherit_ref_once(syntax_ref)
		return nil
	end

	local function find_loaded_extension_match()
		if not ext_query or ext_query == "" then
			return nil
		end
		for i = #syntax.items, 1, -1 do
			local item = syntax.items[i]
			if type(item) == "table" then
				local exts = item.extensions
				if type(exts) == "string" then
					if exts:lower() == ext_query then
						return item
					end
				elseif type(exts) == "table" then
					for _, ext in ipairs(exts) do
						if type(ext) == "string" and ext:lower() == ext_query then
							return item
						end
					end
				end
			end
		end
		return nil
	end

	if module_name then
		-- Preserve module context so inherited syntax is tagged with its real module id
		local prev_loading_module = loading_module
		require_syntax_module(module_name)
		loading_module = prev_loading_module
		for i = #syntax.items, 1, -1 do
			local item = syntax.items[i]
			if type(item) == "table" and item._module_name == module_name then
				return item
			end
		end
		return nil
	end

	local ext_match = find_loaded_extension_match()
	if ext_match then
		return ext_match
	end

	-- Extension fallback can require modules not loaded yet
	load_syntax_modules(get_syntax_modules())
	return find_loaded_extension_match()
end

-- Applies optional inline inheritance spec:
--   inherit = { syntax = "xml", keys = { "patterns", "comment" } }
-- or:
--   inherit = {
--     { syntax = "xml", keys = { "patterns" } },
--     { syntax = "c", keys = { "symbols" } },
--   }
local function apply_inherit(t)
	if type(t) ~= "table" then
		return t
	end
	local inherit = t.inherit
	if type(inherit) ~= "table" then
		return t
	end

	local specs = {}
	if inherit.syntax ~= nil or inherit.keys ~= nil then
		specs[1] = inherit
	else
		for _, spec in ipairs(inherit) do
			if type(spec) == "table" then
				specs[#specs + 1] = spec
			end
		end
	end
	if #specs == 0 then
		return t
	end

	local out = {}
	local inherited_any = false
	for _, spec in ipairs(specs) do
		local keys = spec.keys
		if type(keys) == "table" then
			local selected = {}
			local selected_count = 0
			for _, k in ipairs(keys) do
				if type(k) == "string" and not selected[k] then
					selected[k] = true
					selected_count = selected_count + 1
				end
			end
			for k, v in pairs(keys) do
				if type(k) == "string" and v and not selected[k] then
					selected[k] = true
					selected_count = selected_count + 1
				end
			end

			if selected_count > 0 then
				local base = resolve_inherit_source(spec.syntax)
				if type(base) == "table" then
					for k in pairs(selected) do
						if base[k] ~= nil then
							out[k] = deep_copy(base[k])
							inherited_any = true
						end
					end
				end
			end
		end
	end
	if not inherited_any then
		return t
	end

	for k, v in pairs(t) do
		if k ~= "inherit" then
			if v == syntax.REMOVE then
				out[k] = nil
			else
				local cur = out[k]
				if type(cur) == "table" and type(v) == "table" then
					-- Keep child pattern rules first, then inherited fallback rules
					if k == "patterns" or k == "outline_patterns" then
						local merged = deep_copy(v)
						for i = 1, #cur do
							merged[#merged + 1] = deep_copy(cur[i])
						end
						out[k] = merged
					else
						deep_merge(cur, v)
					end
				else
					out[k] = deep_copy(v)
				end
			end
		end
	end
	return out
end

-- Registers a syntax definition and normalizes optional metadata
function syntax.add(t)
	-- Normalize compact symbol specs before inheritance so inherited maps stay plain and predictable
	t.symbols = normalize_symbol_map(t.shortname or t.name or t._module_name or "syntax", t.symbols)

	t = apply_inherit(t)

	-- Normalize optional syntax fields so partial definitions cannot crash consumers
	-- This allows syntax modules to omit 'patterns'/'symbols' safely
	t.patterns = flatten_pattern_rules(type(t.patterns) == "table" and t.patterns or {})
	t.outline_patterns = flatten_pattern_rules(type(t.outline_patterns) == "table" and t.outline_patterns or {})
	t.symbols = type(t.symbols) == "table" and t.symbols or {}
	t.post_symbols = type(t.post_symbols) == "table" and t.post_symbols or {}

	-- Keep header matcher compatibility: 'headers' may be a string or a pattern list
	if type(t.headers) ~= "string" and type(t.headers) ~= "table" then
		t.headers = {}
	end
	-- This forces a re-check of all headers to find a possibly better
	-- match across all available syntax files
	-- Useful if a subformat syntax of a format may be recognized via header,
	-- for example xml schema, or comment
	t.force_header_check = t.force_header_check == true

	-- Prefer explicit loader context for stable module attribution
	if not t._module_name and loading_module then
		t._module_name = loading_module
	end

	-- Support 'caseless = true' on syntax patterns and outline_patterns
	for _, group in ipairs({ t.patterns or {}, t.outline_patterns or {} }) do
		for _, p in ipairs(group) do
			if p.caseless and p.pattern then
				if type(p.pattern) == "table" then
					p.pattern[1] = make_pattern_caseless(p.pattern[1])
					p.pattern[2] = make_pattern_caseless(p.pattern[2])
				else
					p.pattern = make_pattern_caseless(p.pattern)
				end
			end
		end
	end

	-- Capture source/module hint for diagnostics (e.g. doc:show-syntax)
	if not t._module_name then
		local ok, info = pcall(debug.getinfo, 2, "S")
		if ok and info and type(info.source) == "string" then
			local src = info.source
			local path = src:gsub("^@", ""):gsub("\\", "/")
			local mod = canonicalize_nested_self_name_source("plugins", path)
			if mod then
				t._module_name = mod
			else
				mod = canonicalize_nested_self_name_source("syntax", path)
				if mod then
					t._module_name = mod
				else
					t._module_name = path:match("([^/]+)%.lua$") or path
				end
			end
		end
	end

	apply_syntax_names(t)
	cache_file_spec_lookups(t)

	table.insert(syntax.items, t)
end

-- Finds the newest syntax entry whose pattern list matches the given string
local function find(string, field)
	for i = #syntax.items, 1, -1 do
		local t = syntax.items[i]
		if common.match_pattern(string, t[field] or {}) then
			return t
		end
	end
end

-- Score header match strength so stronger header hits can override extension-only matches
local function get_header_match_score(target, header)
	local s, e = common.match_pattern(header, target.headers or {})
	if not s then
		return nil
	end
	local score = (e - s + 1)
	if s == 1 then
		score = score + 1000000
	end
	if s == 1 and e == #header then
		score = score + 1000000
	end
	return score
end

-- Return the strongest loaded syntax header match
local function find_best_loaded_header_match(header)
	local best, best_score
	for i = #syntax.items, 1, -1 do
		local t = syntax.items[i]
		local score = get_header_match_score(t, header)
		if score and (not best or score > best_score) then
			best, best_score = t, score
		end
	end
	return best, best_score
end

-- Build extension candidates (max two parts): "sql", "sqlite.sql"
local function build_extension_candidates(base_lower)
	local out = {}
	local short = base_lower:match("%.([^%.]+)$")
	if short then
		out[#out + 1] = short
	end
	local long = base_lower:match("%.([^%.]+%.[^%.]+)$")
	if long and long ~= short then
		-- Put longer candidate first so it wins inside extension-set matching
		table.insert(out, 1, long)
	end
	return out
end

-- Extract basename and lowercase extension candidates for syntax file matching
local function parse_file_parts(filename)
	-- Normalize basename and extension once per syntax lookup
	local path = tostring(filename or "")
	local base = path:match("([^/\\]+)$") or path
	local base_lower = base:lower()
	local ext_candidates = build_extension_candidates(base_lower)
	return base, base_lower, ext_candidates
end

-- Copy only string values from a string-or-list input
local function copy_string_list(list_like)
	if type(list_like) == "string" then
		return { list_like }
	end
	local out = {}
	if type(list_like) ~= "table" then
		return out
	end
	for _, v in ipairs(list_like) do
		if type(v) == "string" then
			out[#out + 1] = v
		end
	end
	return out
end

-- Normalize extension candidates to lowercase without leading dots
local function normalize_extensions(list_like)
	local out = {}
	for _, v in ipairs(copy_string_list(list_like)) do
		local ext = v:gsub("^%.+", ""):lower()
		if ext ~= "" then
			out[#out + 1] = ext
		end
	end
	return out
end

-- Normalize filename candidates to lowercase
local function normalize_filenames(list_like)
	local out = {}
	for _, v in ipairs(copy_string_list(list_like)) do
		out[#out + 1] = v:lower()
	end
	return out
end

-- Normalize syntax file match config into { files, filenames, extensions } lists
local function normalize_file_spec(target)
	local spec = { files = {}, filenames = {}, extensions = {} }

	-- Keep list entries unique while preserving insertion order
	local function add_unique(dst, src)
		local seen = {}
		for _, v in ipairs(dst) do
			seen[v] = true
		end
		for _, v in ipairs(src) do
			if not seen[v] then
				dst[#dst + 1] = v
				seen[v] = true
			end
		end
	end

	local files = target and target.files

	if type(files) == "string" then
		spec.files = { files }
	elseif type(files) == "table" then
		-- Backward-compatible read for temporary nested file-spec shape
		if files.patterns ~= nil or files.exact ~= nil or files.extensions ~= nil then
			add_unique(spec.files, copy_string_list(files.patterns))
			add_unique(spec.filenames, normalize_filenames(files.exact))
			add_unique(spec.extensions, normalize_extensions(files.extensions))
			for _, v in ipairs(files) do
				if type(v) == "string" then
					spec.files[#spec.files + 1] = v
				end
			end
		else
			spec.files = copy_string_list(files)
		end
	end

	add_unique(spec.filenames, normalize_filenames(target and target.filenames))
	add_unique(spec.extensions, normalize_extensions(target and target.extensions))
	return spec
end

-- Convert a list into a key-lookup set for O(1) membership checks
local function build_lookup_set(list)
	local out = {}
	for _, v in ipairs(list or {}) do
		out[v] = true
	end
	return out
end

-- Materialize file-match lookup fields on syntax entries and cache records
cache_file_spec_lookups = function(target)
	-- Build exact and extension lookup sets for fast file matching
	local spec = normalize_file_spec(target)
	target.files = spec.files
	target.filenames = spec.filenames
	target.extensions = spec.extensions
	target._files_name_set = build_lookup_set(spec.filenames)
	target._files_ext_set = build_lookup_set(spec.extensions)
end

-- Returns a numeric match score (higher is better) or nil if no match
local function match_file_spec(target, filename, base_lower, ext_candidates)
	local names = target._files_name_set
	if names and names[base_lower] then
		return 3000000
	end
	local exts = target._files_ext_set
	if exts and ext_candidates then
		for _, ext in ipairs(ext_candidates) do
			if exts[ext] then
				-- Prefer longer compound extensions like "sqlite.sql" over "sql"
				return 2000000 + #ext
			end
		end
	end
	if common.match_pattern(filename, target.files or {}) ~= false then
		return 1000000
	end
end

-- Resolve by file pattern with optional 'require_header_match' gate
local function find_by_file(filename, header)
	local _, base_lower, ext_candidates = parse_file_parts(filename)
	local best, best_score
	for i = #syntax.items, 1, -1 do
		local t = syntax.items[i]
		local score = match_file_spec(t, filename, base_lower, ext_candidates)
		if score then
			if t.require_header_match then
				if common.match_pattern(header, t.headers or {}) then
					if not best or score > best_score then
						best, best_score = t, score
					end
				end
			else
				if not best or score > best_score then
					best, best_score = t, score
				end
			end
		end
	end
	return best
end

-- Copies string values from a list-like field into a normalized array
local function copy_list(t)
	if type(t) == "string" then
		return { t }
	end
	local out = {}
	if type(t) ~= "table" then
		return out
	end
	for _, v in ipairs(t) do
		if type(v) == "string" then
			out[#out + 1] = v
		end
	end
	return out
end

-- Copy normalized file-spec lists for syntax registry serialization
local function copy_file_spec_fields(target)
	local spec = normalize_file_spec(target)
	return {
		files = copy_list(spec.files),
		filenames = copy_list(spec.filenames),
		extensions = copy_list(spec.extensions),
	}
end

-- Derive stable short syntax name used by CLI lookup
local function derive_entry_name(entry)
	if type(entry) ~= "table" then
		return nil
	end
	if type(entry.name) == "string" and entry.name ~= "" then
		return entry.name:lower()
	end
	if type(entry.extensions) == "table" and type(entry.extensions[1]) == "string" and entry.extensions[1] ~= "" then
		return entry.extensions[1]:lower()
	end
	if type(entry.module) == "string" then
		local tail = entry.module:match("^syntax%.(.+)$")
		if type(tail) == "string" and tail ~= "" then
			return tail:lower()
		end
	end
	return nil
end

-- Rebuild fast syntax-name index from current registry entries
local function rebuild_registry_name_index()
	registry_name_index = {}
	for i = 1, #registry_entries do
		local entry = registry_entries[i]
		local name = derive_entry_name(entry)
		if type(name) == "string" and name ~= "" then
			registry_name_index[name] = entry
		end
	end
end

-- Merge list values while preserving insertion order
local function append_unique(dst, src)
	local seen = {}
	for _, v in ipairs(dst) do
		seen[v] = true
	end
	for _, v in ipairs(src) do
		if not seen[v] then
			dst[#dst + 1] = v
			seen[v] = true
		end
	end
end

-- Serialize Lua string arrays for generated registry output
local function serialize_list(list)
	local parts = {}
	for i, v in ipairs(list) do
		parts[i] = string.format("%q", v)
	end
	-- Emit compact empty arrays in cache output
	if #parts == 0 then
		return "{}"
	end
	return "{ " .. table.concat(parts, ", ") .. " }"
end

-- Write registry cache via temp file 'syntax$.tmp' then rename to 'syntax$.lua'
local function write_registry(entries)
	if cache_disabled or session_no_cache then
		return nil, "cache disabled"
	end
	local dir, path, temp = get_registry_paths()

	local fp, err = io.open(temp, "wb")
	if not fp then
		-- Create cache directory on-demand
		os.execute(string.format('mkdir "%s"', dir))
		fp, err = io.open(temp, "wb")
	end
	if not fp then
		return nil, err
	end

	fp:write("-- Auto-generated syntax registry cache\n")
	fp:write("return {\n")
	for _, e in ipairs(entries) do
		fp:write("  { module = ", string.format("%q", e.module), ", ")
		fp:write("name = ", string.format("%q", e.name or derive_entry_name(e) or ""), ", ")
		fp:write("display_name = ", string.format("%q", e.display_name or e.name or derive_entry_name(e) or ""), ", ")
		fp:write("files = ", serialize_list(e.files), ", ")
		fp:write("filenames = ", serialize_list(e.filenames), ", ")
		fp:write("extensions = ", serialize_list(e.extensions), ", ")
		fp:write("headers = ", serialize_list(e.headers), ", ")
		fp:write("has_detect_hook = ", e.has_detect_hook and "true" or "false", ", ")
		fp:write("require_header_match = ", e.require_header_match and "true" or "false", ", ")
		fp:write("force_header_check = ", e.force_header_check and "true" or "false", " },\n")
	end
	fp:write("}\n")
	fp:close()

	os.remove(path)
	local ok, rename_err = os.rename(temp, path)
	if not ok then
		local rf, rerr = io.open(path, "wb")
		if not rf then
			os.remove(temp)
			return nil, rename_err or rerr
		end
		local tf = io.open(temp, "rb")
		if tf then
			local data = tf:read("*a")
			tf:close()
			if data then
				rf:write(data)
			end
		end
		rf:close()
		os.remove(temp)
	end

	return true
end

-- Generate syntax registry from current 'data/syntax/*.lua' modules
local function generate_registry()
	local modules = get_syntax_modules()

	local old_items = syntax.items
	local old_loaded = loaded_modules
	syntax.items = {}
	loaded_modules = {}

	-- Require each syntax module and force module attribution for new entries
	for _, mod in ipairs(modules) do
		package.loaded[mod] = nil
		local fallback = get_nested_fallback_module(mod)
		if fallback then
			package.loaded[fallback] = nil
		end
	end
	load_syntax_modules(modules)

	local by_module = {}
	local entries = {}
	for _, t in ipairs(syntax.items) do
		local mod = t._module_name
		if type(mod) == "string" and mod:match("^syntax%.") and mod ~= "syntax._" then
			local entry = by_module[mod]
			if not entry then
				entry = {
					module = mod,
					name = nil,
					display_name = nil,
					files = {},
					filenames = {},
					extensions = {},
					headers = {},
					has_detect_hook = false,
					require_header_match = false,
					force_header_check = false,
				}
				by_module[mod] = entry
				entries[#entries + 1] = entry
			end
			local spec = copy_file_spec_fields(t)
			if type(entry.display_name) ~= "string" or entry.display_name == "" then
				entry.display_name = t.name or t.shortname
			end
			append_unique(entry.files, spec.files)
			append_unique(entry.filenames, spec.filenames)
			append_unique(entry.extensions, spec.extensions)
			append_unique(entry.headers, copy_list(t.headers))
			if type(t.detect_hook) == "function" then
				entry.has_detect_hook = true
			end
			if t.require_header_match then
				entry.require_header_match = true
			end
			if t.force_header_check then
				entry.force_header_check = true
			end
		end
	end

	local filtered = {}
	for _, e in ipairs(entries) do
		e.name = derive_entry_name(e)
		filtered[#filtered + 1] = {
			module = e.module,
			name = e.name,
			display_name = e.display_name,
			files = e.files,
			filenames = e.filenames,
			extensions = e.extensions,
			headers = e.headers,
			has_detect_hook = e.has_detect_hook,
			require_header_match = e.require_header_match,
			force_header_check = e.force_header_check,
		}
	end

	table.sort(filtered, function(a, b)
		return a.module < b.module
	end)

	local ok = write_registry(filtered)
	if not ok then
		return {}
	end

	syntax.items = old_items
	loaded_modules = old_loaded
	for _, mod in ipairs(modules) do
		package.loaded[mod] = nil
		local fallback = get_nested_fallback_module(mod)
		if fallback then
			package.loaded[fallback] = nil
		end
	end

	return filtered
end

-- Detect stale cache when syntax files exist without registry entries
local function is_registry_stale(entries)
	if type(entries) ~= "table" then
		return true, "cache payload is invalid"
	end

	local syntax_dir = type(SYNTAX_DIR) == "string" and SYNTAX_DIR or ""
	local entry_modules = {}

	-- Track every module name currently recorded in the cache snapshot
	for _, e in ipairs(entries) do
		if type(e) == "table" and type(e.module) == "string" then
			entry_modules[e.module] = true
		end
	end

	local files = system.list_dir(syntax_dir) or {}
	local discovered_modules = {}

	-- Rebuild the module set from disk so cache-only deleted entries can be detected
	for _, filename in ipairs(files) do
		if filename ~= "_.lua" and filename:match("%.lua$") then
			local mod = "syntax." .. filename:gsub("%.lua$", "")
			discovered_modules[mod] = true
			if not entry_modules[mod] then
				return true, string.format("new syntax module found: %s", mod)
			end
		end

		local dir_path = system.join_path(syntax_dir, filename)
		local info = system.get_file_info(dir_path)
		if info and info.type == "dir" then
			local nested = system.join_path(dir_path, filename .. ".lua")
			if system.is_file(nested) == true then
				local mod = "syntax." .. filename
				discovered_modules[mod] = true
				if not entry_modules[mod] then
					return true, string.format("new syntax module found: %s", mod)
				end
			end
		end
	end

	-- Any cached module missing from the current on-disk scan makes the registry stale
	for module_name in pairs(entry_modules) do
		if not discovered_modules[module_name] then
			return true, string.format("cached syntax module removed: %s", module_name)
		end
	end

	return false, nil
end

-- Detect stale cache when any syntax source file is newer than cache file
local function is_registry_older_than_syntax_files()
	local _, cache_path = get_registry_paths()
	local cache_info = system.get_file_info(cache_path)

	if not cache_info or type(cache_info.modified) ~= "number" then
		return true, "cache timestamp is unavailable"
	end

	local syntax_dir = type(SYNTAX_DIR) == "string" and SYNTAX_DIR or ""
	local files = system.list_dir(syntax_dir) or {}

	for _, filename in ipairs(files) do
		if filename ~= "_.lua" and filename:match("%.lua$") then
			local full = syntax_dir .. filename
			local info = system.get_file_info(full)

			if info and type(info.modified) == "number" and info.modified > cache_info.modified then
				return true, string.format("source changed: %s", full)
			end
		end

		local dir_path = system.join_path(syntax_dir, filename)
		local dir_info = system.get_file_info(dir_path)
		if dir_info and dir_info.type == "dir" then
			local nested = system.join_path(dir_path, filename .. ".lua")
			local info = system.get_file_info(nested)
			if info and type(info.modified) == "number" and info.modified > cache_info.modified then
				return true, string.format("source changed: %s", nested)
			end
		end
	end

	return false, nil
end

-- Load cached registry; auto-generate if missing or invalid
local function load_registry()
	-- Honor no-cache mode even if syntax is queried before init_registry()
	if apply_no_cache_from_cli() then
		registry_loaded = true
		if #syntax.items == 0 then
			loaded_modules = {}
			local modules = get_syntax_modules()
			for _, mod in ipairs(modules) do
				package.loaded[mod] = nil
			end
			load_syntax_modules(modules)
		end
		return
	end
	if cache_disabled or session_no_cache then
		return
	end
	if registry_loaded then
		return
	end

	-- Empty/stale/missing caches are rebuilt automatically
	local _, path, temp = get_registry_paths()
	-- Remove stale temp cache from interrupted prior writes
	os.remove(temp)
	local entries
	local refresh_reason = nil
	local fn = loadfile(path)
	if fn then
		local ok, res = pcall(fn)
		if ok and type(res) == "table" then
			entries = res
		else
			refresh_reason = "cache file could not be read"
		end
	else
		refresh_reason = "cache file is missing"
	end

	if type(entries) == "table" and #entries == 0 then
		refresh_reason = "cache is empty"
	end

	if type(entries) == "table" and refresh_reason == nil then
		local stale, stale_reason = is_registry_stale(entries)
		if stale then
			refresh_reason = stale_reason or "cache contents changed"
		end
	end

	if type(entries) == "table" and refresh_reason == nil then
		local outdated, outdated_reason = is_registry_older_than_syntax_files()
		if outdated then
			refresh_reason = outdated_reason or "syntax sources are newer than cache"
		end
	end

	if refresh_reason ~= nil then
		-- Tell the user why the cache will be refreshed before the rebuild starts
		log_syntax_cache_status("Syntax cache needs updating", refresh_reason)
		log_syntax_cache_status("Rebuilding syntax cache", path)
		entries = generate_registry()
	else
		log_syntax_cache_status("Using syntax cache", string.format("%d entries from %s", #entries, path))
	end

	registry_entries = type(entries) == "table" and entries or {}
	for _, e in ipairs(registry_entries) do
		e.name = derive_entry_name(e)
		e.has_detect_hook = e.has_detect_hook == true
		e.force_header_check = e.force_header_check == true
		cache_file_spec_lookups(e)
	end
	rebuild_registry_name_index()
	registry_loaded = true
end

-- Lazy-load a syntax module once and memoize status
local function ensure_module_loaded(modname)
	if loaded_modules[modname] ~= nil then
		return loaded_modules[modname]
	end

	-- Preserve module context for syntax.add during lazy loads
	local before = #syntax.items
	local ok, err = require_syntax_module(modname)

	if ok then
		for i = before + 1, #syntax.items do
			local item = syntax.items[i]

			-- Bind missing/placeholder module names to the concrete loaded module
			if type(item._module_name) ~= "string" or item._module_name == "" or item._module_name == "syntax" then
				item._module_name = modname
			end

			-- Ensure fallback names reflect resolved module labels
			apply_syntax_names(item, modname)
		end
	else
		report_syntax_module_error(modname, err)
	end

	loaded_modules[modname] = ok and true or false
	return loaded_modules[modname]
end

-- Return newest loaded syntax table attributed to a module name
local function find_loaded_by_module(modname)
	if type(modname) ~= "string" or modname == "" then
		return nil
	end

	for i = #syntax.items, 1, -1 do
		local item = syntax.items[i]

		if type(item) == "table" and item._module_name == modname then
			return item
		end
	end
	return nil
end

--- Find one cached registry entry by module name
-- Keeps plain text override lookup on the existing cache path
-- @param modname string|nil module name
-- @return table|nil registry entry
local function find_registry_entry_by_module(modname)
	if type(modname) ~= "string" or modname == "" then
		return nil
	end

	for i = #registry_entries, 1, -1 do
		local entry = registry_entries[i]

		if type(entry) == "table" and entry.module == modname then
			return entry
		end
	end

	return nil
end

--- Resolve the plain text fallback syntax
-- Loads an optional syntax.plain_text override through the shared registry loader
-- @return table plain text syntax
local function get_plain_text_syntax()
	local loaded_override = find_loaded_by_module(plain_text_override_module)

	if loaded_override then
		return loaded_override
	end

	load_registry()

	local entry = find_registry_entry_by_module(plain_text_override_module)

	if entry and ensure_module_loaded(entry.module) then
		return find_loaded_by_module(entry.module) or plain_text_syntax
	end

	return plain_text_syntax
end

-- Match filename against cached registry entries
local function find_registry_file_match(filename, header)
	local _, base_lower, ext_candidates = parse_file_parts(filename)
	local best, best_score

	for i = #registry_entries, 1, -1 do
		local e = registry_entries[i]
		local score = match_file_spec(e, filename, base_lower, ext_candidates)

		if score then
			if e.require_header_match then
				if common.match_pattern(header, e.headers or {}) then
					if not best or score > best_score then
						best, best_score = e, score
					end
				end
			else
				if not best or score > best_score then
					best, best_score = e, score
				end
			end
		end
	end
	return best
end

-- Match file header against cached registry entries
local function find_registry_header_match(header)
	for i = #registry_entries, 1, -1 do
		local e = registry_entries[i]

		if common.match_pattern(header, e.headers or {}) then
			return e
		end
	end
end

-- Return the strongest registry header match
local function find_registry_best_header_match(header)
	local best, best_score

	for i = #registry_entries, 1, -1 do
		local e = registry_entries[i]
		local score = get_header_match_score(e, header)

		if score and (not best or score > best_score) then
			best, best_score = e, score
		end
	end
	return best, best_score
end

-- Collect loaded header candidates in score order so detect hooks can validate ambiguous headers
local function find_loaded_header_matches(header)
	local matches = {}

	for i = #syntax.items, 1, -1 do
		local item = syntax.items[i]
		local score = get_header_match_score(item, header)

		if score then
			matches[#matches + 1] = { target = item, score = score, order = i }
		end
	end

	sort_candidates(matches)

	return matches
end

-- Collect cached header candidates in score order so registry-backed detect hooks can reject safely
local function find_registry_header_matches(header)
	local matches = {}

	for i = #registry_entries, 1, -1 do
		local entry = registry_entries[i]
		local score = get_header_match_score(entry, header)

		if score then
			matches[#matches + 1] = { target = entry, score = score, order = i }
		end
	end

	sort_candidates(matches)

	return matches
end

-- Collect loaded file candidates in match order so detect hooks can reject weak extension hits
local function find_loaded_file_matches(filename, header)
	local _, base_lower, ext_candidates = parse_file_parts(filename)
	local matches = {}

	for i = #syntax.items, 1, -1 do
		local item = syntax.items[i]
		local score = match_file_spec(item, filename, base_lower, ext_candidates)

		if score then
			if item.require_header_match then
				if common.match_pattern(header, item.headers or {}) then
					matches[#matches + 1] = { target = item, score = score, order = i }
				end
			else
				matches[#matches + 1] = { target = item, score = score, order = i }
			end
		end
	end

	sort_candidates(matches)

	return matches
end

-- Collect cached file candidates in match order so the registry can drive lazy detect-hook checks
local function find_registry_file_matches(filename, header)
	local _, base_lower, ext_candidates = parse_file_parts(filename)
	local matches = {}

	for i = #registry_entries, 1, -1 do
		local entry = registry_entries[i]
		local score = match_file_spec(entry, filename, base_lower, ext_candidates)

		if score then
			if entry.require_header_match then
				if common.match_pattern(header, entry.headers or {}) then
					matches[#matches + 1] = { target = entry, score = score, order = i }
				end
			else
				matches[#matches + 1] = { target = entry, score = score, order = i }
			end
		end
	end

	sort_candidates(matches)

	return matches
end

-- Merge detect metadata without overwriting an earlier accepted hint
local function merge_detect_meta(dst, src)
	if type(dst) ~= "table" or type(src) ~= "table" then
		return
	end

	for key, value in pairs(src) do
		if dst[key] == nil and value ~= nil then
			dst[key] = value
		end
	end
end

-- Normalize detect-hook calls so syntax lookup and load-time codepage detection share one contract
local function run_detect_hook(target, ctx)
	local hook = target and target.detect_hook

	if type(hook) ~= "function" then
		return nil
	end

	local ok, result = pcall(hook, ctx)

	if not ok then
		return nil
	end

	if result == nil or result == false then
		return nil
	end

	if result == true then
		return { action = "accept" }
	end

	if type(result) ~= "table" then
		return nil
	end

	local action = result.action

	if action == "accept" or action == "continue" then
		return result
	end

	return nil
end

-- Resolve already-loaded candidates without extra module work when no detect hook is present
local function resolve_loaded_candidates(candidates, ctx, detect_state)
	for i = 1, #candidates do
		local target = candidates[i].target

		if type(target) == "table" then
			if type(target.detect_hook) == "function" then
				local result = run_detect_hook(target, ctx)

				if result and result.action == "accept" then
					merge_detect_meta(detect_state.meta, result)
					return target
				end

				if result and result.action == "continue" then
					merge_detect_meta(detect_state.meta, result)
				end
			else
				return target
			end
		end
	end
end

-- Resolve cached candidates while only loading modules that must be inspected
local function resolve_registry_candidates(candidates, ctx, detect_state)
	for i = 1, #candidates do
		local entry = candidates[i].target

		if type(entry) == "table" and type(entry.module) == "string" and entry.module ~= "" then
			if ensure_module_loaded(entry.module) then
				local loaded = find_loaded_by_module(entry.module)

				if loaded then
					if has_detect_hook_flag(entry) then
						local result = run_detect_hook(loaded, ctx)

						if result and result.action == "accept" then
							merge_detect_meta(detect_state.meta, result)
							return loaded
						end

						if result and result.action == "continue" then
							merge_detect_meta(detect_state.meta, result)
						end
					else
						return loaded
					end
				end
			end
		end
	end
end

-- Resolve syntax from loaded entries, then fallback to lazy registry load
function syntax.get(filename, header, is_binary)
	if is_binary then
		return plain_binary_syntax, nil
	end

	-- Let detect hooks validate extension-based candidates before plain fallback wins
	local detect_ctx = {
		filename = filename,
		header = header,
		data = header,
		is_binary = false,
	}
	local detect_state = {
		meta = {},
	}
	local found_by_file = resolve_loaded_candidates(find_loaded_file_matches(filename, header), detect_ctx, detect_state)

	if found_by_file and found_by_file.force_header_check then
		local file_header_score = get_header_match_score(found_by_file, header)
		local best_header, best_header_score = find_best_loaded_header_match(header)

		if best_header and best_header ~= found_by_file and (not file_header_score or best_header_score > file_header_score) then
			return best_header, next(detect_state.meta) and detect_state.meta or nil
		end
	end

	local found = found_by_file or resolve_loaded_candidates(find_loaded_header_matches(header), detect_ctx, detect_state)
	if found then
		return found, next(detect_state.meta) and detect_state.meta or nil
	end

	load_registry()

	-- Reuse the cache for candidate ordering, then lazy-load only the modules we need to inspect
	local entry = resolve_registry_candidates(find_registry_file_matches(filename, header), detect_ctx, detect_state)

	if not entry then
		entry = resolve_registry_candidates(find_registry_header_matches(header), detect_ctx, detect_state)
	end

	if entry and entry.force_header_check then
		local file_header_score = get_header_match_score(entry, header)
		local best_header, best_header_score = find_best_loaded_header_match(header)

		if best_header and best_header ~= entry and (not file_header_score or best_header_score > file_header_score) then
			entry = best_header
		end
	end

	if entry then
		local loaded_by_file = resolve_loaded_candidates(find_loaded_file_matches(filename, header), detect_ctx, detect_state)

		if loaded_by_file and loaded_by_file.force_header_check then
			local file_header_score = get_header_match_score(loaded_by_file, header)
			local best_header, best_header_score = find_best_loaded_header_match(header)

			if best_header and best_header ~= loaded_by_file and (not file_header_score or best_header_score > file_header_score) then
				return best_header, next(detect_state.meta) and detect_state.meta or nil
			end
		end
		return loaded_by_file or resolve_loaded_candidates(find_loaded_header_matches(header), detect_ctx, detect_state) or get_plain_text_syntax(), next(detect_state.meta) and detect_state.meta or nil
	end

	return get_plain_text_syntax(), next(detect_state.meta) and detect_state.meta or nil
end

-- Resolve syntax by short syntax name (for example 'md', 'lua', 'json')
function syntax.get_by_name(name)
	if type(name) ~= "string" or name == "" then
		return nil
	end

	local q = name:lower()
	load_registry()

	local entry = registry_name_index[q]

	if entry and ensure_module_loaded(entry.module) then
		return find_loaded_by_module(entry.module)
	end
	return nil
end

-- Resolve syntax by owning module name (for example 'syntax.lua')
function syntax.get_by_module(module_name)
	if type(module_name) ~= "string" or module_name == "" then
		return nil
	end

	load_registry()

	if ensure_module_loaded(module_name) then
		return find_loaded_by_module(module_name)
	end
	return nil
end

-- Resolve default extension for one syntax module
function syntax.get_default_extension_by_module(module_name)
	if type(module_name) ~= "string" or module_name == "" then
		return nil
	end

	local function normalize_extension(ext)
		if type(ext) ~= "string" then
			return nil
		end

		ext = ext:match("^%s*(.-)%s*$") or ""
		ext = ext:gsub("^%.*", "")

		if ext == "" then
			return nil
		end
		return ext
	end

	local function syntax_default_extension(syn)
		if type(syn) ~= "table" then
			return nil
		end

		local ext = normalize_extension(syn.default_extension)

		if ext then
			return ext
		end

		if type(syn.extensions) == "table" then
			return normalize_extension(syn.extensions[1])
		end

		return normalize_extension(syn.extensions)
	end

	load_registry()

	if ensure_module_loaded(module_name) then
		local loaded = find_loaded_by_module(module_name)
		local ext = syntax_default_extension(loaded)

		if ext then
			return ext
		end
	end

	for i = #registry_entries, 1, -1 do
		local entry = registry_entries[i]

		if type(entry) == "table" and entry.module == module_name then
			if type(entry.extensions) == "table" then
				return normalize_extension(entry.extensions[1])
			end
			return nil
		end
	end

	return nil
end

-- Return display-ready list of all available syntaxes without eager module loading
function syntax.get_available()
	load_registry()
	local out = {}

	if #registry_entries > 0 then
		for i = 1, #registry_entries do
			local entry = registry_entries[i]
			local name = entry and entry.name

			if type(name) == "string" and name ~= "" then
				out[#out + 1] = {
					name = name,
					module = entry.module,
				}
			end
		end
		return out
	end

	for i = 1, #syntax.items do
		local syn = syntax.items[i]

		if type(syn) == "table" then
			local name = syn.shortname or syn.name

			if type(name) == "string" and name ~= "" then
				out[#out + 1] = {
					name = name:lower(),
					module = syn._module_name,
				}
			end
		end
	end
	return out
end

-- Return syntax registry entries with file association metadata.
-- The returned tables are copies so callers cannot mutate the registry cache.
function syntax.get_file_association_entries()
	load_registry()
	local out = {}
	local source = #registry_entries > 0 and registry_entries or syntax.items

	for i = 1, #source do
		local entry = source[i]
		if type(entry) == "table" then
			local extensions = copy_list(entry.extensions)
			if #extensions > 0 then
				out[#out + 1] = {
					module = entry.module or entry._module_name,
					name = entry.display_name or entry.name or derive_entry_name(entry) or "",
					extensions = extensions,
				}
			end
		end
	end

	return out
end

-- Ensure syntax registry is available during startup without eager syntax loads
function syntax.init_registry(opts)
	opts = opts or {}

	-- Honor CLI flags even if caller omitted options
	if opts.no_cache == nil then
		opts.no_cache = has_cli_flag("--no-cache")
	end

	if opts.force_update == nil then
		opts.force_update = has_cli_flag("--update-cache")
	end

	-- Respect raw CLI no-cache even when option is omitted/false
	if apply_no_cache_from_cli() then
		opts.no_cache = true
	end

	-- Keep no-cache active for the entire process once requested
	if opts.no_cache then
		session_no_cache = true
	end

	if session_no_cache then
		if registry_loaded and cache_disabled and #syntax.items > 0 then
			return #syntax.items
		end

		-- Bypass cache file use/creation and preload all syntax modules
		cache_disabled = true
		registry_loaded = true
		registry_entries = {}
		registry_name_index = {}

		-- Force fresh module registration when cache is disabled
		loaded_modules = {}
		local modules = get_syntax_modules()

		for _, mod in ipairs(modules) do
			package.loaded[mod] = nil
		end

		load_syntax_modules(modules)

		return #syntax.items
	end

	cache_disabled = false

	if opts.force_update then
		-- Force cache regeneration regardless of existing cache file
		local _, path = get_registry_paths()

		log_syntax_cache_status("Syntax cache needs updating", "forced by --update-cache")
		log_syntax_cache_status("Rebuilding syntax cache", path)

		registry_entries = generate_registry()

		for _, e in ipairs(registry_entries) do
			e.name = derive_entry_name(e)
			cache_file_spec_lookups(e)
		end

		rebuild_registry_name_index()
		registry_loaded = true
	else
		load_registry()
	end

	return #registry_entries
end

return syntax
