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

--- Minimal localization + cached template compilation
-- Based on Resonic's Language.cpp by Liqube Audio
-- @module loc

--[[
	Goal:
	- English is embedded at callsites (fallback always available)
	- Translations are looked up by stable ids
	- Templates are compiled on demand into cached plain, variable, or expression plans

	Supports:
	- {name}
	- {p:n 0{...} one{...} other{...}}
	- {s:var key{...} other{...}}

	API:
	- text(english, values?) or text(id, english, values?)
	- plural(count, variants, values?) or plural(id, count, variants, values?)
	- select(selector, variants, values?) or select(id, selector, variants, values?)

	Notes:
	- Compilation is lazy (on-demand) by default
	- Newline support: Lua strings already support "\n" and long strings include real newlines
	- The compiler/renderer treat newlines as ordinary text. No special handling needed
	- Plural categories use the active locale's language rules
]]

local i18n = require("core.i18n.init")
local loc = {}
local MAX_SAFE_INTEGER = 9007199254740991

-- For very hot paths this saves one global lookup per call
local tostring = tostring

-- Locale storage
loc._locale = "en"

-- catalogs[owner][id][locale] = localized string or variant table
loc._catalogs = {}
loc._id_owners = {}

-- translation_cache[locale][id] = resolved value after regional fallback; false for not found
loc._translation_cache = {}

-- compile_cache[template_string] = compiled render plan
loc._compile_cache = {}

--- Set active locale used by localization lookups
-- @param locale Locale code (default "en")
function loc.set_locale(locale)
	loc._locale = i18n.normalize_locale_key(locale)
	loc._translation_cache = {}
end

--- Return the normalized active locale
-- @return string locale Active locale key
function loc.get_locale()
	return loc._locale
end

--- Check whether a value is a valid stable translation id
-- @param id any Candidate stable id
-- @return boolean valid True for sixteen lowercase Base32 characters
function loc.is_stable_id(id)
	-- Pattern breakdown: anchors require one or more characters from the fixed lowercase ID alphabet
	return type(id) == "string"
		and #id == 16
		and id:match("^[0123456789abcdefghjkmnpqrstvwxyz]+$") ~= nil
end

--- Register or replace one owner's message-centric translations
-- @param owner string Catalog owner such as "core" or "plugin.filetree"
-- @param translations table Mapping of stable id to locale-keyed translations
function loc.register_translations(owner, translations)
	if type(owner) ~= "string" or owner == "" then
		error("translation owner must be a non-empty string", 2)
	end
	if type(translations) ~= "table" then
		error("translations must be a table", 2)
	end

	local normalized_catalog = {}
	for id, locales in pairs(translations) do
		if not loc.is_stable_id(id) then
			error("invalid stable translation id: " .. tostring(id), 2)
		end
		local existing_owner = loc._id_owners[id]
		if existing_owner and existing_owner ~= owner then
			error("translation id " .. id .. " already belongs to " .. existing_owner, 2)
		end
		if type(locales) ~= "table" then
			error("translation entry " .. id .. " must contain locale keys", 2)
		end

		local normalized_locales = {}
		for locale, value in pairs(locales) do
			if type(locale) ~= "string" then
				error("translation locale keys must be strings", 2)
			end
			local locale_key = i18n.normalize_locale_key(locale)
			if i18n.normalize_locale(locale_key) == "en" then
				error("English fallbacks belong at source callsites, not in translation catalogs", 2)
			end
			if normalized_locales[locale_key] ~= nil then
				error("duplicate normalized locale " .. locale_key .. " for " .. id, 2)
			end
			if type(value) ~= "string" and type(value) ~= "table" then
				error("translation values must be strings or variant tables", 2)
			end
			if type(value) == "table" then
				if type(value.other) ~= "string" then
					error("translated variant tables require a string 'other' branch", 2)
				end
				for _, variant in pairs(value) do
					if type(variant) ~= "string" then
						error("translated variant values must be strings", 2)
					end
				end
			end
			normalized_locales[locale_key] = value
		end
		normalized_catalog[id] = normalized_locales
	end

	local previous = loc._catalogs[owner]
	if previous then
		for id in pairs(previous) do
			loc._id_owners[id] = nil
		end
	end
	loc._catalogs[owner] = normalized_catalog
	for id in pairs(normalized_catalog) do
		loc._id_owners[id] = owner
	end
	loc._translation_cache = {}
end

--- Remove every translation owned by one catalog
-- @param owner string Catalog owner
function loc.unregister_translations(owner)
	local catalog = loc._catalogs[owner]
	if not catalog then
		return
	end

	for id in pairs(catalog) do
		loc._id_owners[id] = nil
	end
	loc._catalogs[owner] = nil
	loc._translation_cache = {}
end

--- Clear lookup and compile caches
function loc.clear_caches()
	loc._translation_cache = {}
	loc._compile_cache = {}
end

--- Dedent a Lua multiline string created with [[ ... ]]
-- Normalizes line endings, trims a single leading and trailing newline,
-- and removes the smallest shared indentation across non-empty lines
-- Intended purely as an authoring convenience for locale content
-- @param s any Input value, nil treated as ""
-- @return string Dedented and normalized string
function loc.dedent(s)
	s = tostring(s or "")

	-- Pattern breakdown: carriage return followed by newline becomes one newline
	s = s:gsub("\r\n", "\n")

	local lines = {}
	local line_start = 1
	for index = 1, #s + 1 do
		if index > #s or s:sub(index, index) == "\n" then
			lines[#lines + 1] = s:sub(line_start, index - 1)
			line_start = index + 1
		end
	end

	local function get_indentation(line)
		local indentation = 0
		local character = line:sub(1, 1)
		while character == " " or character == "\t" do
			indentation = indentation + 1
			character = line:sub(indentation + 1, indentation + 1)
		end

		return indentation, character == ""
	end

	if lines[1] and select(2, get_indentation(lines[1])) then
		table.remove(lines, 1)
	end
	if lines[#lines] and select(2, get_indentation(lines[#lines])) then
		table.remove(lines)
	end

	local minimum = nil
	for _, line in ipairs(lines) do
		local indentation, blank = get_indentation(line)
		if not blank then
			minimum = minimum and math.min(minimum, indentation) or indentation
		end
	end
	if minimum and minimum > 0 then
		for index, line in ipairs(lines) do
			local _, blank = get_indentation(line)
			lines[index] = blank and "" or line:sub(minimum + 1)
		end
	end

	return table.concat(lines, "\n")
end

--- Perform a direct catalog lookup without caching
-- @param locale string Locale code to query
-- @param key string Translation key
-- @return string|table|nil Localized value if found, otherwise nil
local function raw_lookup(locale, key)
	local owner = loc._id_owners[key]
	local catalog = owner and loc._catalogs[owner]
	local entry = catalog and catalog[key]

	return entry and entry[locale] or nil
end

--- Lookup a translation using exact and base-locale fallback
-- Caches both hits and misses for faster repeated access
-- @param locale string Locale code to query
-- @param key string Translation key
-- @return string|table|nil Localized value if found, otherwise nil
local function cached_lookup(locale, key)
	local lc = loc._translation_cache[locale]
	if not lc then
		lc = {}
		loc._translation_cache[locale] = lc
	end

	local v = lc[key]
	if v ~= nil then
		--- Stored false represents a cached miss
		return v ~= false and v or nil
	end

	--- Attempt the complete normalized locale first
	local s = raw_lookup(locale, key)

	--- Fall back from a regional locale such as de_at to its base language
	local base_locale = i18n.normalize_locale(locale)
	if s == nil and base_locale ~= locale then
		s = raw_lookup(base_locale, key)
	end

	--- Cache result, storing false to memoize misses
	lc[key] = (s ~= nil) and s or false
	return s
end

--- Resolve the plural category for a numeric value
-- Delegates to the language plural rules implementation
-- Current rule set is intentionally minimal but supports extension
-- @param locale string Locale code used to select plural rules
-- @param n number Numeric value to categorize
-- @return string Plural category identifier such as "one" or "other"
local function get_plural_category(locale, n)
	--- Forward to language-level plural resolver
	return i18n.get_plural_category(locale, n)
end

--- Require a finite integer that plural resolvers can handle consistently
-- @param count any Candidate plural count
-- @param level integer Error stack level
-- @return number count Validated count
local function validate_plural_count(count, level)
	if type(count) ~= "number"
		or count ~= count
		or count == math.huge
		or count == -math.huge
		or count % 1 ~= 0
		or count < -MAX_SAFE_INTEGER
		or count > MAX_SAFE_INTEGER
	then
		error("plural count must be a finite safe integer", level or 2)
	end

	return count
end

--- Require a supported scalar selection value
-- @param selector any Candidate selector
-- @param level integer Error stack level
-- @return any selector Validated selector
local function validate_selector(selector, level)
	local selector_type = type(selector)
	if selector_type ~= "nil"
		and selector_type ~= "string"
		and selector_type ~= "number"
		and selector_type ~= "boolean"
	then
		error("selector must be a string, number, boolean, or nil", level or 2)
	end
	if selector_type == "number"
		and (selector ~= selector or selector == math.huge or selector == -math.huge)
	then
		error("numeric selector must be finite", level or 2)
	end

	return selector
end

--[[
	Template compiler
	Compiles a template string into a render plan.

	Token shapes:
		{ t="text",   v="..." }
		{ t="var",    k="name" }
		{ t="plural", k="n", exact={ [0]=tokens }, cats={ one=tokens, other=tokens }, other=tokens }
		{ t="select", k="gender", cases={ male=tokens, ... }, other=tokens }

	Branch bodies are compiled recursively (and thus cached as part of the parent compile).
	Rendering never reparses braces.
]]

--- Check whether a character is treated as whitespace by the template parser
-- Supports spaces, tabs, and common newline forms
-- @param c string Single-character string to test
-- @return boolean True when c is whitespace
local function is_space(c)
	return c == " " or c == "\t" or c == "\n" or c == "\r"
end

--- Skip over consecutive whitespace in a string starting at a given index
-- Returns the index of the first non-whitespace character, or #s + 1
-- @param s string Input string
-- @param i integer 1-based start index
-- @return integer Next index after whitespace
local function skip_ws(s, i)
	while i <= #s and is_space(s:sub(i, i)) do
		i = i + 1
	end
	return i
end

--- Read an identifier token from a string at a given index
-- Leading whitespace is skipped before reading
-- Grammar is ASCII-safe
-- First character: [A-Za-z_]
-- Remaining: [A-Za-z0-9_.-]
-- @param s string Input string
-- @param i integer 1-based start index
-- @return string|nil ident Identifier when present, otherwise nil
-- @return integer next_i Index after the identifier or after whitespace when missing
local function read_ident(s, i)
	i = skip_ws(s, i)
	local start = i
	local c = s:sub(i, i)
	-- Pattern breakdown: alphabetic ASCII or underscore may start an identifier
	if c == "" or not c:match("[%a_]") then
		return nil, i
	end
	i = i + 1
	while true do
		c = s:sub(i, i)
		-- Pattern breakdown: alphanumeric ASCII, underscore, dot, or hyphen may continue an identifier
		if c == "" or not c:match("[%w_%.%-]") then
			break
		end
		i = i + 1
	end
	return s:sub(start, i - 1), i
end

--- Read a signed integer from a string at a given index
-- Leading whitespace is skipped before reading
-- @param s string Input string
-- @param i integer 1-based start index
-- @return number|nil n Parsed number when present, otherwise nil
-- @return integer next_i Index after the number or after whitespace when missing
local function read_integer(s, i)
	i = skip_ws(s, i)
	local start = i
	local c = s:sub(i, i)
	if c == "-" then
		i = i + 1
		c = s:sub(i, i)
	end
	-- Pattern breakdown: one decimal digit must follow the optional sign
	if c == "" or not c:match("[%d]") then
		return nil, start
	end
	i = i + 1
	while true do
		c = s:sub(i, i)
		-- Pattern breakdown: decimal digits continue the exact integer label
		if c == "" or not c:match("[%d]") then
			break
		end
		i = i + 1
	end
	return tonumber(s:sub(start, i - 1)), i
end

--- Extract a balanced brace block starting at a "{" character
-- Scans nested braces and returns the inner substring and the next index
-- @param s string Source string
-- @param i integer 1-based index where s:sub(i,i) is expected to be "{"
-- @return string inner Text inside the outermost braces
-- @return integer next_i Index immediately after closing brace or end of source
-- @return boolean complete Whether closing brace was found
local function read_brace_block(s, i)
	--- Expect s[i] to be "{"
	local depth = 0
	local start = i + 1
	while true do
		local c = s:sub(i, i)
		if c == "" then
			return s:sub(start), #s + 1, false
		end
		if c == "{" then
			depth = depth + 1
		elseif c == "}" then
			depth = depth - 1
			if depth == 0 then
				return s:sub(start, i - 1), i + 1, true
			end
		end
		i = i + 1
	end
end

--- Forward declaration for recursive compilation of branch bodies
local compile_template

--- Read a single branch clause inside a plural or select block
-- Accepts either an exact signed integer label like "-1" or an identifier label like "one"
-- Expects a "{...}" body immediately after the label
-- @param inner string Inner contents of the plural or select expression
-- @param i integer 1-based start index within inner
-- @return string|number|nil label Branch label when present, otherwise nil
-- @return string|nil body Raw branch body text when present, otherwise nil
-- @return integer next_i Index after the branch body or position of failure
local function read_branch(inner, i)
	i = skip_ws(inner, i)

	local label
	local c = inner:sub(i, i)

	-- Pattern breakdown: a decimal digit identifies an unsigned numeric label
	if c == "-" or c:match("[%d]") then
		label, i = read_integer(inner, i)
		if label == nil then
			return nil, nil, i
		end
	else
		label, i = read_ident(inner, i)
		if not label then
			return nil, nil, i
		end
	end

	i = skip_ws(inner, i)
	if inner:sub(i, i) ~= "{" then
		return nil, nil, i
	end

	local body, next_i = read_brace_block(inner, i)
	return label, body, next_i
end

--- Compile a single placeholder expression into a render token
-- Supports simple variables, plural blocks, and select blocks
-- Malformed or unknown placeholders are preserved as literal text
-- @param inner string Placeholder inner content without surrounding braces
-- @return table Token describing how the placeholder should be rendered
local function compile_placeholder(inner)
	inner = inner or ""
	local op = inner:sub(1, 2)

	--- plural or select blocks, prefixed by an operator like "p:" or "s:"
	if op == "p:" or op == "s:" then
		local kind = (op == "p:") and "plural" or "select"
		local j = 3

		--- Read controlling variable name like n or gender
		local varname
		varname, j = read_ident(inner, j)
		if not varname then
			--- Malformed placeholder, keep literal so strings stay debuggable
			return { t = "text", v = "{" .. inner .. "}" }
		end

		if kind == "plural" then
			local exact = {}
			local cats = {}
			local other_tokens = nil

			while true do
				local label, body, nj = read_branch(inner, j)
				if not label then
					break
				end
				j = nj

				--- Compile branch body recursively into tokens
				local body_tokens = compile_template(body).tokens
				if label == "other" then
					other_tokens = body_tokens
				elseif type(label) == "number" then
					--- Numeric labels are exact matches and take precedence over categories
					exact[label] = body_tokens
				else
					--- Category branch like "one" or "few"
					cats[label] = body_tokens
				end
			end

			return {
				t = "plural",
				k = varname,
				exact = exact,
				cats = cats,
				--- "other" is required by convention, default to empty text when missing
				other = other_tokens or { { t = "text", v = "" } },
			}
		else
			local cases = {}
			local other_tokens = nil

			while true do
				local label, body, nj = read_branch(inner, j)
				if not label then
					break
				end
				j = nj

				--- Compile branch body recursively into tokens
				local body_tokens = compile_template(body).tokens
				if label == "other" then
					other_tokens = body_tokens
				else
					cases[label] = body_tokens
				end
			end

			return {
				t = "select",
				k = varname,
				cases = cases,
				--- "other" is required by convention, default to empty text when missing
				other = other_tokens or { { t = "text", v = "" } },
			}
		end
	end

	--- Simple variable placeholder like "{name}"
	-- Pattern breakdown: optional outer whitespace surrounds one complete identifier
	local name = inner:match("^%s*([%a_][%w_%.%-]*)%s*$")
	if name then
		return { t = "var", k = name }
	end

	--- Unknown placeholder shape, keep literal for safety
	return { t = "text", v = "{" .. inner .. "}" }
end

--- Compile a template string into a cached render plan
-- The single parser pass classifies plain text, variable interpolation, and embedded expressions
-- @param template string Template text containing placeholders like {name} or {p:n ...}
-- @return table plan Compiled plan with mode, text, and tokens
compile_template = function(template)
	template = tostring(template or "")

	--- Reuse previously compiled token stream for identical template text
	local cached = loc._compile_cache[template]
	if cached then
		return cached
	end

	local tokens = {}
	local mode = "plain"
	local i = 1
	local n = #template

	local function push_text(txt)
		if txt == "" then
			return
		end

		local previous = tokens[#tokens]
		if previous and previous.t == "text" then
			previous.v = previous.v .. txt
		else
			tokens[#tokens + 1] = { t = "text", v = txt }
		end
	end

	while i <= n do
		--- Find next opening brace and emit any preceding literal text
		local brace = template:find("{", i, true)
		if not brace then
			push_text(template:sub(i))
			break
		end

		push_text(template:sub(i, brace - 1))

		--- Read balanced brace block and compile placeholder into a token
		local inner, next_i, complete = read_brace_block(template, brace)
		if not complete then
			-- Malformed placeholder remains exact literal source
			push_text(template:sub(brace))
			break
		end

		local token = compile_placeholder(inner)
		tokens[#tokens + 1] = token
		if token.t == "plural" or token.t == "select" then
			mode = "expressions"
		elseif token.t == "var" and mode == "plain" then
			mode = "variables"
		end
		i = next_i
	end

	local plan = {
		mode = mode,
		text = template,
		tokens = tokens,
	}
	loc._compile_cache[template] = plan

	return plan
end

-- Renderer

--- Render a compiled token stream into a final string
-- Expands variables, select cases, and plural branches recursively
-- @param tokens table[] Compiled tokens from compile_template
-- @param vars table Variable map used for {name} and control values for plural or select
-- @param locale string Locale code used for plural category resolution, defaults to active locale
-- @param reserved_count number|nil Standalone plural count exposed as {count}
-- @return string Rendered string
local function render_tokens(tokens, vars, locale, reserved_count)
	vars = vars or {}
	locale = locale or loc._locale

	local out = {}

	for i = 1, #tokens do
		local node = tokens[i]
		local t = node.t

		if t == "text" then
			out[#out + 1] = node.v

		elseif t == "var" then
			local v = node.k == "count" and reserved_count or vars[node.k]
			if v == nil then
				--- Missing variables are left visible to aid debugging
				out[#out + 1] = "{" .. node.k .. "}"
			else
				out[#out + 1] = tostring(v)
			end

		elseif t == "select" then
			local v = vars[node.k]
			local branch = node.cases[v] or node.other

			--- Render chosen branch recursively
			out[#out + 1] = render_tokens(branch, vars, locale, reserved_count)

		elseif t == "plural" then
			local v = vars[node.k]
			local number = validate_plural_count(v, 3)
			local branch = node.exact[number]
			if not branch then
				local category = get_plural_category(locale, math.abs(number))
				branch = node.cats[category] or node.other
			end

			--- Render the chosen branch with the same input values
			out[#out + 1] = render_tokens(branch, vars, locale, reserved_count)
		end
	end

	return table.concat(out)
end

--- Render a compiled plan while bypassing token traversal for plain text
-- @param plan table Compiled render plan
-- @param vars table|nil Variable map
-- @param locale string|nil Locale code
-- @param reserved_count number|nil Standalone plural count exposed as {count}
-- @return string rendered Rendered message
local function render_plan(plan, vars, locale, reserved_count)
	if plan.mode == "plain" then
		return plan.text
	end

	return render_tokens(plan.tokens, vars, locale, reserved_count)
end

--- Render a localized template when input values are provided
-- @param template string Localized template
-- @param values table|nil Message input values
-- @param locale string Active locale
-- @param reserved_count number|nil Standalone plural count exposed as {count}
-- @return string Rendered or unchanged template
local function render_message(template, values, locale, reserved_count)
	if not values and reserved_count == nil then
		return template
	end

	return render_plan(compile_template(template), values, locale, reserved_count)
end

--- Require message input values to be a table when present
-- @param values any Candidate message input values
-- @param level integer Error stack level
-- @return table|nil values Validated values
local function validate_values(values, level)
	if values ~= nil and type(values) ~= "table" then
		error("message values must be a table or nil", level or 2)
	end

	return values
end

--- Look up a stamped translation without changing the English fallback
-- @param id string|nil Stable translation id
-- @return string|table|nil value Registered translation value
local function lookup_translation(id)
	if id == nil then
		return nil
	end

	return cached_lookup(loc._locale, tostring(id))
end

--- Select one plural branch from a variant table
-- @param variants table|nil Candidate variants
-- @param count number Validated signed count
-- @param locale string Active locale
-- @return string|nil template Selected template
local function select_plural_branch(variants, count, locale)
	if type(variants) ~= "table" then
		return nil
	end

	local template = variants[count]
	if type(template) == "string" then
		return template
	end

	local category = get_plural_category(locale, math.abs(count))
	template = variants[category]
	if type(template) == "string" then
		return template
	end

	return type(variants.other) == "string" and variants.other or nil
end

--- Select one exact or fallback branch from a selection table
-- @param variants table|nil Candidate variants
-- @param selector any Validated scalar selector
-- @return string|nil template Selected template
local function select_named_branch(variants, selector)
	if type(variants) ~= "table" then
		return nil
	end

	local template = variants[selector]
	if type(template) == "string" then
		return template
	end

	return type(variants.other) == "string" and variants.other or nil
end

--- Localize a string and interpolate optional message values
-- @param a string English fallback or stable translation id
-- @param b string|table|nil English fallback or message input values
-- @param c table|nil Message input values for a stamped call
-- @return string Localized and optionally rendered message
function loc.text(a, b, c)
	local id, english, values
	if type(b) == "string" then
		id, english, values = a, b, c
	else
		id, english, values = nil, a, b
	end
	values = validate_values(values, 2)
	english = tostring(english or "")

	local translated = lookup_translation(id)
	local template = type(translated) == "string" and translated or english

	return render_message(template, values, loc._locale)
end

--- Localize plural variants and interpolate optional message values
-- @param a string|number Stable translation id or plural count
-- @param b number|table Plural count or English fallback variants
-- @param c table|nil English fallback variants or message input values
-- @param d table|nil Message input values for a stamped call
-- @return string Localized and rendered plural message
function loc.plural(a, b, c, d)
	local id, count, english_variants, values
	if type(a) == "string" then
		id, count, english_variants, values = a, b, c, d
	else
		id, count, english_variants, values = nil, a, b, c
	end
	count = validate_plural_count(count, 2)
	values = validate_values(values, 2)
	if type(english_variants) ~= "table" or type(english_variants.other) ~= "string" then
		error("English plural variants require a string 'other' branch", 2)
	end
	if values and values.count ~= nil and values.count ~= count then
		error("values.count must equal the plural count", 2)
	end

	local translated = lookup_translation(id)
	local template = select_plural_branch(translated, count, loc._locale)
		or select_plural_branch(english_variants, count, loc._locale)

	return render_message(template, values, loc._locale, count)
end

--- Localize selection variants and interpolate optional message values
-- @param a any Stable translation id or scalar selector
-- @param b any Scalar selector or English fallback variants
-- @param c table|nil English fallback variants or message input values
-- @param d table|nil Message input values for a stamped call
-- @return string Localized and rendered selected message
function loc.select(a, b, c, d)
	local id, selector, english_variants, values
	if type(b) == "table" then
		id, selector, english_variants, values = nil, a, b, c
	else
		id, selector, english_variants, values = a, b, c, d
	end
	selector = validate_selector(selector, 2)
	values = validate_values(values, 2)
	if type(english_variants) ~= "table" or type(english_variants.other) ~= "string" then
		error("English selection variants require a string 'other' branch", 2)
	end

	local translated = lookup_translation(id)
	local template = select_named_branch(translated, selector)
		or select_named_branch(english_variants, selector)

	return render_message(template, values, loc._locale)
end

--- Compile a template string into a render plan
-- Exposes the internal compiler so templates can be cached ahead of time
-- @param template_string string Template text containing placeholders
-- @return table plan Cached plain, variable, or expression plan
function loc.compile(template_string)
	return compile_template(template_string)
end

--- Render a template string or compiled plan using variables
-- @param template_or_plan string|table Template string or compiled render plan
-- @param vars table Variable map used for rendering
-- @param locale string Locale code for plural category resolution, default active locale
-- @return string Rendered string
function loc.render(template_or_plan, vars, locale)
	local plan = type(template_or_plan) == "string"
		and compile_template(template_or_plan)
		or template_or_plan

	return render_plan(plan, vars, locale)
end

--- Precompile every string nested in a localized value
-- @param value any Localized string, variant table, or unsupported value
local function precompile_value(value)
	if type(value) == "string" then
		compile_template(value)
	elseif type(value) == "table" then
		for _, variant in pairs(value) do
			precompile_value(variant)
		end
	end
end

--- Precompile all templates for a specific locale
-- Walks strings and variant tables to avoid first-use parsing cost
-- @param locale string Locale code to precompile
function loc.precompile_locale(locale)
	local locale_key = i18n.normalize_locale_key(locale)
	local base_locale = i18n.normalize_locale(locale_key)

	for _, catalog in pairs(loc._catalogs) do
		for _, translations in pairs(catalog) do
			local value = translations[locale_key]
				or (base_locale ~= locale_key and translations[base_locale])
			if value ~= nil then
				precompile_value(value)
			end
		end
	end
end

--- Precompile all currently loaded locales
-- Iterates every loaded locale and warms the compile cache for all string values
function loc.precompile_all()
	for _, catalog in pairs(loc._catalogs) do
		for _, translations in pairs(catalog) do
			for _, value in pairs(translations) do
				precompile_value(value)
			end
		end
	end
end

return loc
