-- lx-bundle:true

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

--[[
	Goal:
	- English is embedded at callsites (fallback always available)
	- Translations are looked up by id (or auto-id for text(english))
	- Templates are compiled on demand into token arrays, cached, then rendered fast

	Supports:
	- {name}
	- {p:n =0{...} one{...} other{...}} -- # expands to the numeric value
	- {s:var key{...} other{...}}

	API:
	- text(english)				-> auto-id lookup, falls back to english
	- text(english, opts)		-> same, with formatting
	- text(id, english, opts?)	-> id lookup, falls back to english

	Opts:
		opts.vars = { name="Tom", n=3, gender="female", formality="formal", ... }

	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
	- This is intentionally minimal: plural rules are only "one" vs "other" (n == 1 => one)
]]

local i18n = require("init")
local loc = {}

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

-- Locale storage
loc._locale = "en"
loc._fallback = "en"

-- dict[locale][key] = string
loc._dict = {}

-- lookup_cache[locale][key] = resolved string (after fallback); false for not found
loc._locale_cache = {}

-- compile_cache[template_string] = compiled token array
loc._compile_cache = {}

--- Set active locale used by loc.text() lookup
-- @param locale Locale code (default "en")
function loc.set_locale(locale)
	loc._locale = locale or "en"
end

--- Set fallback locale used when key missing in active locale
-- Updates the global fallback and clears the lookup cache so future
-- resolutions reflect the new fallback behavior
-- @param locale string Locale code to use as fallback, default "en"
function loc.set_fallback(locale)
	loc._fallback = locale or "en"

	--- Fallback changes affect all resolved lookups, so perform a full cache flush
	loc._locale_cache = {}
end

--- Add or replace a locale dictionary
-- Registers the dictionary for the given locale and invalidates the
-- per-locale lookup cache so future lookups use the updated data
-- @param locale string Locale code identifier
-- @param dict table Mapping of key to localized string, default {}
function loc.add_locale(locale, dict)
	loc._dict[locale] = dict or {}

	--- Invalidate per-locale lookup cache after dictionary update
	loc._locale_cache[locale] = nil
end

--- Clear lookup and compile caches
function loc.clear_caches()
	loc._locale_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 "")

	--- Normalize Windows CRLF to LF for consistent processing
	s = s:gsub("\r\n", "\n")

	--- Strip exactly one leading and one trailing newline
	s = s:gsub("^\n", ""):gsub("\n$", "")

	--- Compute minimum indentation across non-empty lines
	local min = nil
	for line in s:gmatch("([^\n]*)\n?") do
		if line ~= "" then
			local indent = line:match("^(%s*)") or ""
			local n = #indent
			if min == nil or n < min then
				min = n
			end
		end
	end

	if min and min > 0 then
		local prefix = string.rep(" ", min)

		--- Remove the shared indentation from all lines
		s = s:gsub("\n" .. prefix, "\n")
		s = s:gsub("^" .. prefix, "")
	end

	return s
end

local hash = require("hash")
local format = require("format")

--- Generate a deterministic automatic key from an English source string
-- Produces a stable identifier using FNV-1a 64-bit hashing
-- Note that changing the English input will change the generated id
-- @param english string Source English text used to derive the id
-- @return string Auto-generated key in the form "auto:<hash>"
local function generate_auto_id(english)
	--- Hash the input (nil treated as empty string) and prefix with namespace
	return "auto:" .. format.i64_to_u64_string(hash.fnv1a64(english or ""))
end

--- Perform a direct dictionary lookup without caching
-- Returns the raw translation value for the given locale and key
-- @param locale string Locale code to query
-- @param key string Translation key
-- @return string|nil Localized string if found, otherwise nil
local function raw_lookup(locale, key)
	local d = loc._dict[locale]
	if d then
		return d[key]
	end
	return nil
end

--- Lookup a translation using the per-locale cache with fallback support
-- Caches both hits and misses for faster repeated access
-- @param locale string Locale code to query
-- @param key string Translation key
-- @return string|nil Localized string if found, otherwise nil
local function cached_lookup(locale, key)
	local lc = loc._locale_cache[locale]
	if not lc then
		lc = {}
		loc._locale_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 primary locale lookup
	local s = raw_lookup(locale, key)

	--- Fallback lookup when enabled and different from active locale
	if s == nil and loc._fallback and loc._fallback ~= locale then
		s = raw_lookup(loc._fallback, 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

--[[
	Template compiler
	Compiles a template string into an array of tokens.

	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)
	if c == "" or not c:match("[%a_]") then
		return nil, i
	end
	i = i + 1
	while true do
		c = s:sub(i, i)
		if c == "" or not c:match("[%w_%.%-]") then
			break
		end
		i = i + 1
	end
	return s:sub(start, i - 1), i
end

--- Read a non-negative 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_number(s, i)
	i = skip_ws(s, i)
	local start = i
	local c = s:sub(i, i)
	if c == "" or not c:match("[%d]") then
		return nil, i
	end
	i = i + 1
	while true do
		c = s:sub(i, i)
		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
-- If braces are unbalanced, returns the remainder as best-effort
-- @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 the closing brace, or #s + 1 on best-effort exit
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
			--- Unbalanced braces, return remaining input
			return s:sub(start), #s + 1
		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
			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 numeric label like "=0" 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|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)

	if c == "=" then
		i = i + 1
		local num
		num, i = read_number(inner, i)
		if num == nil then
			return nil, nil, i
		end
		label = "=" .. tostring(num)
	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)
				if label == "other" then
					other_tokens = body_tokens
				elseif label:sub(1, 1) == "=" then
					--- Exact match branch like "=0"
					local num = tonumber(label:sub(2))
					if num ~= nil then
						exact[num] = body_tokens
					end
				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)
				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}"
	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 token array with caching
-- Parses placeholders once and stores the compiled token stream keyed by the
-- full template string so rendering never reparses braces
-- @param template string Template text containing placeholders like {name} or {p:n ...}
-- @return table[] Array of compiled 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 i = 1
	local n = #template

	local function push_text(txt)
		if txt ~= "" then
			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 = read_brace_block(template, brace)
		tokens[#tokens + 1] = compile_placeholder(inner)
		i = next_i
	end

	--- Cache compiled result by template string for fast repeated rendering
	loc._compile_cache[template] = tokens
	return tokens
end

-- Renderer

--- Render a compiled token stream into a final string
-- Expands variables, select cases, and plural branches recursively
-- Supports "#" substitution inside plural branches to inject the numeric value
-- @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
-- @return string Rendered string
local function render_tokens(tokens, vars, locale)
	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 = 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 key = (v ~= nil) and tostring(v) or nil
			local branch = (key and node.cases[key]) or node.other

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

		elseif t == "plural" then
			local v = vars[node.k]
			local num = tonumber(v)
			local branch

			if num ~= nil then
				--- Prefer exact numeric match, otherwise use category match
				branch = node.exact[num]
				if not branch then
					local cat = get_plural_category(locale, num)
					branch = node.cats[cat] or node.other
				end
			else
				--- Non-numeric value falls back to "other"
				branch = node.other
			end

			--- Render branch and replace "#" with the numeric value when available
			local rendered = render_tokens(branch, vars, locale)
			if num ~= nil then
				rendered = rendered:gsub("#", tostring(num))
			end
			out[#out + 1] = rendered
		end
	end

	return table.concat(out)
end

--[[
	Public API

	Supported calls:
	- text("Hello")							-> text(english)
	- text("Hello {name}", {vars={...}})	-> text(english, opts)
	- text("id", "Hello", {vars={...}})		-> text(id, english, opts)
]]

--- Resolve localized text by id or English source, with optional template rendering
-- Supports call forms:
--   text(english)
--   text(english, opts)
--   text(id, english, opts)
-- Uses the active locale with fallback, returning English when no translation exists
-- When opts.vars is provided, the resolved string is treated as a template and rendered
-- @param a string Id or English source depending on call form
-- @param b string|table English source or opts depending on call form
-- @param c table|nil opts depending on call form
-- @return string Localized and optionally rendered string
function loc.text(a, b, c)
	local id, english, opts

	if b == nil then
		--- text(english)
		english = tostring(a or "")
		id = generate_auto_id(english)
		opts = nil
	elseif type(b) == "table" and c == nil then
		--- text(english, opts)
		english = tostring(a or "")
		id = generate_auto_id(english)
		opts = b
	else
		--- text(id, english, opts)
		id = tostring(a or "")
		english = tostring(b or "")
		opts = c
	end

	local locale = loc._locale

	--- Lookup by id, falling back to English source text
	local s = cached_lookup(locale, id) or english

	--- Fast path when no template variables are provided
	local vars = (opts and opts.vars) or nil
	if not vars then
		return s
	end

	--- Compile once via cache and render with variables
	local tokens = compile_template(s)
	return render_tokens(tokens, vars, locale)
end

--- Compile a template string into tokens
-- Exposes the internal compiler so templates can be cached ahead of time
-- @param template_string string Template text containing placeholders
-- @return table[] Compiled token array
function loc.compile(template_string)
	return compile_template(template_string)
end

--- Render a template string or token array using variables
-- Accepts a raw template string (compiled via cache) or a precompiled token array
-- @param template_or_tokens string|table[] Template string or compiled tokens
-- @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_tokens, vars, locale)
	if type(template_or_tokens) == "string" then
		--- Compile string template through cache before rendering
		return render_tokens(compile_template(template_or_tokens), vars, locale)
	else
		return render_tokens(template_or_tokens, vars, locale)
	end
end

--- Precompile all string templates for a specific locale
-- Walks the locale dictionary and warms the compile cache to avoid first-use parsing cost
-- @param locale string Locale code to precompile
function loc.precompile_locale(locale)
	local dict = loc._dict[locale]

	if not dict then
		return
	end
	for _, text in pairs(dict) do
		if type(text) == "string" then
			compile_template(text)
		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()
local format = require("format")

	for locale, _ in pairs(loc._dict) do
io.write(format.dump(locale))
		loc.precompile_locale(locale)
	end

--io.write(format.dump(loc._locale_cache))

end

return loc
