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

--- Shared quote matching, wrapping, and escaping
-- @module core.text.quotes

local config = require("core.config")

-- Core quote engine:
-- - syntax-owned profiles via 'syntax.quotes'
-- - pluggable escape/unescape functions
-- - optional full custom wrap/unwrap hooks per profile
local quotes = {}
local warned_missing_primary = setmetatable({}, { __mode = "k" })
local warned_missing_profile_ref = setmetatable({}, { __mode = "k" })
local function no_op_function(text)
	return tostring(text or ""), nil
end

config.quote_default = config.quote_default
config.quote_preserve_selection = config.quote_preserve_selection ~= false
config.quote_empty_selection_mode = config.quote_empty_selection_mode or "line"
config.quote_json_escape_unicode = config.quote_json_escape_unicode == true

local function copy_table(v)
	if type(v) ~= "table" then
		return v
	end
	local out = {}
	for k, vv in pairs(v) do
		out[k] = copy_table(vv)
	end
	return out
end

local function utf8_from_codepoint(cp)
	if cp < 0 or cp > 0x10FFFF then
		return nil, "invalid unicode codepoint"
	end
	if cp >= 0xD800 and cp <= 0xDFFF then
		return nil, "invalid surrogate unicode codepoint"
	end
	if utf8 and type(utf8.char) == "function" then
		local ok, ch = pcall(utf8.char, cp)
		if ok and type(ch) == "string" then
			return ch, nil
		end
	end
	if cp <= 0x7F then
		return string.char(cp), nil
	end
	if cp <= 0x7FF then
		return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40)), nil
	end
	-- stylua: ignore
	if cp <= 0xFFFF then
		return string.char(
			0xE0 + math.floor(cp / 0x1000),
			0x80 + (math.floor(cp / 0x40) % 0x40),
			0x80 + (cp % 0x40)
		), nil
	end
	return string.char(
		0xF0 + math.floor(cp / 0x40000),
		0x80 + (math.floor(cp / 0x1000) % 0x40),
		0x80 + (math.floor(cp / 0x40) % 0x40)
	),
		nil
end

--- Decode C-style escapes, including JSON UTF-16 surrogate pairs
-- @param text string escaped text
-- @param mode string|nil escape format
-- @return string|nil decoded_text
-- @return string|nil error_message
local function decode_c_style_escapes(text, mode)
	local out = {}
	local i = 1
	local n = #text
	while i <= n do
		local ch = text:sub(i, i)
		if ch ~= "\\" then
			out[#out + 1] = ch
			i = i + 1
		else
			if i == n then
				return nil, "dangling escape at end of string"
			end
			local esc = text:sub(i + 1, i + 1)
			-- JSON has no apostrophe escape, while C-style strings still accept it
			if esc == "\\" or esc == '"' or esc == "/" or (esc == "'" and mode ~= "json") then
				out[#out + 1] = esc
				i = i + 2
			elseif esc == "n" then
				out[#out + 1] = "\n"
				i = i + 2
			elseif esc == "r" then
				out[#out + 1] = "\r"
				i = i + 2
			elseif esc == "t" then
				out[#out + 1] = "\t"
				i = i + 2
			elseif esc == "b" then
				out[#out + 1] = "\b"
				i = i + 2
			elseif esc == "f" then
				out[#out + 1] = "\f"
				i = i + 2
			elseif esc == "0" then
				if mode == "json" then
					return nil, "invalid escape \\0 in json string"
				end
				out[#out + 1] = "\0"
				i = i + 2
			elseif esc == "x" then
				if mode == "json" then
					return nil, "invalid escape \\x in json string"
				end
				local hex = text:sub(i + 2, i + 3)
				if not hex:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then
					return nil, "invalid hex escape"
				end
				out[#out + 1] = string.char(tonumber(hex, 16))
				i = i + 4
			elseif esc == "u" then
				local hex = text:sub(i + 2, i + 5)
				-- Pattern breakdown: four hexadecimal digits covering one UTF-16 code unit
				if not hex:match("^[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]$") then
					return nil, "invalid unicode escape"
				end
				local cp = tonumber(hex, 16)
				local escape_size = 6

				if mode == "json" and cp >= 0xD800 and cp <= 0xDBFF then
					local low_escape = text:sub(i + 6, i + 11)
					-- Pattern breakdown: literal '\u' followed by one four-digit hexadecimal code unit
					local low_hex = low_escape:match("^\\u([0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f])$")
					local low_cp = low_hex and tonumber(low_hex, 16)

					if not low_cp or low_cp < 0xDC00 or low_cp > 0xDFFF then
						return nil, "invalid unicode surrogate pair"
					end

					cp = 0x10000 + (cp - 0xD800) * 0x400 + (low_cp - 0xDC00)
					escape_size = 12
				end

				local uch, err = utf8_from_codepoint(cp)
				if not uch then
					return nil, err
				end
				out[#out + 1] = uch
				i = i + escape_size
			else
				return nil, string.format("unsupported escape \\%s", esc)
			end
		end
	end
	return table.concat(out), nil
end

local function cstyle_escape(text, quote_char)
	local esc = {
		["\\"] = "\\\\",
		["\n"] = "\\n",
		["\r"] = "\\r",
		["\t"] = "\\t",
		["\b"] = "\\b",
		["\f"] = "\\f",
	}
	esc[quote_char] = "\\" .. quote_char
	local pat = "[\0-\31\\" .. quote_char .. "]"
	return (tostring(text or ""):gsub(pat, function(ch)
		return esc[ch] or string.format("\\x%02X", ch:byte())
	end))
end

--- Escape non-ASCII code points as JSON UTF-16 units
-- @param text string valid UTF-8 text
-- @return string escaped_text
local function json_escape_unicode(text)
	local ok, escaped = pcall(function()
		local out = {}

		for _, codepoint in utf8.codes(text) do
			if codepoint <= 0x7f then
				out[#out + 1] = string.char(codepoint)
			elseif codepoint <= 0xffff then
				out[#out + 1] = string.format("\\u%04X", codepoint)
			else
				local offset = codepoint - 0x10000
				local high = 0xd800 + math.floor(offset / 0x400)
				local low = 0xdc00 + offset % 0x400
				out[#out + 1] = string.format("\\u%04X\\u%04X", high, low)
			end
		end

		return table.concat(out)
	end)

	-- Preserve previous byte behavior when input is not valid UTF-8
	return ok and escaped or text
end

--- Escape one string for JSON quoting
-- @param text any source value
-- @return string escaped_text
local function json_escape(text)
	local esc = {
		['"'] = '\\"',
		["\\"] = "\\\\",
		["/"] = "\\/",
		["\b"] = "\\b",
		["\f"] = "\\f",
		["\n"] = "\\n",
		["\r"] = "\\r",
		["\t"] = "\\t",
	}
	local escaped = (
		tostring(text or ""):gsub('[\0-\31\\/"]', function(ch)
			return esc[ch] or string.format("\\u%04X", ch:byte())
		end)
	)

	return config.quote_json_escape_unicode and json_escape_unicode(escaped) or escaped
end

quotes.escape = {}
quotes.unescape = {}

function quotes.escape.cstyle()
	return function(text, profile)
		local open = nil
		if profile and profile.delimiters then
			if type(profile.delimiters) == "string" then
				open = profile.delimiters
			elseif type(profile.delimiters) == "table" then
				open = profile.delimiters.open or profile.delimiters[1]
			end
		end
		if type(open) ~= "string" or #open ~= 1 then
			open = '"'
		end
		return cstyle_escape(text, open), nil
	end
end

function quotes.unescape.cstyle()
	return function(text)
		return decode_c_style_escapes(tostring(text or ""), "cstyle")
	end
end

function quotes.escape.json_style()
	return function(text)
		return json_escape(text), nil
	end
end

function quotes.unescape.json_style()
	return function(text)
		return decode_c_style_escapes(tostring(text or ""), "json")
	end
end

local default_escape_registry = {
	cstyle = quotes.escape.cstyle(),
	json_style = quotes.escape.json_style(),
}

local default_unescape_registry = {
	cstyle = quotes.unescape.cstyle(),
	json_style = quotes.unescape.json_style(),
}

quotes.profile = {}

function quotes.profile.c_style_double()
	return {
		input_multiline = true,
		output_multiline = false,
		delimiters = '"',
		escape = default_escape_registry.cstyle,
		unescape = default_unescape_registry.cstyle,
	}
end

function quotes.profile.c_style_single()
	return {
		input_multiline = true,
		output_multiline = false,
		delimiters = "'",
		escape = default_escape_registry.cstyle,
		unescape = default_unescape_registry.cstyle,
	}
end

local function get_quotes_spec(doc)
	local q = doc and doc.format and doc.format.quotes
	if type(q) ~= "table" then
		return nil
	end
	return q
end

local function warn_missing_primary_config(doc)
	local format_definition = doc and doc.format
	if type(format_definition) ~= "table" or warned_missing_primary[format_definition] then
		return
	end
	warned_missing_primary[format_definition] = true
	local format_name = format_definition._module_name or format_definition.shortname or format_definition.name or "current format"
	local core = package.loaded["core"]
	core.warning("No quotes.primary configured for %s; set format.quotes.primary", tostring(format_name))
end

local function warn_missing_profile_ref(doc, alias_name, target_id)
	local format_definition = doc and doc.format
	if type(format_definition) ~= "table" then
		return
	end
	local state = warned_missing_profile_ref[format_definition]
	if not state then
		state = {}
		warned_missing_profile_ref[format_definition] = state
	end
	if state[alias_name] then
		return
	end
	state[alias_name] = true
	local format_name = format_definition._module_name or format_definition.shortname or format_definition.name or "current format"
	local core = package.loaded["core"]
	local msg = string.format(
		'quotes.%s points to missing profile "%s" in %s',
		tostring(alias_name),
		tostring(target_id),
		tostring(format_name)
	)
	local core = package.loaded["core"]
	core.warning("%s", msg)
end

local function profile_input_multiline(profile)
	if profile.input_multiline ~= nil then
		return profile.input_multiline ~= false
	end
	return true
end

local function profile_output_multiline(profile)
	if profile.output_multiline ~= nil then
		return profile.output_multiline ~= false
	end
	return false
end

local function normalize_delimiters(profile)
	local d = profile and profile.delimiters
	if type(d) == "string" and d ~= "" then
		return d, d
	end
	if type(d) == "table" then
		local open = d.open or d[1]
		local close = d.close or d[2] or open
		if type(open) == "string" and open ~= "" and type(close) == "string" and close ~= "" then
			return open, close
		end
	end
	return nil, nil
end

local function run_validate(validator, text, ctx, stage)
	if validator == nil then
		return true, nil
	end
	if type(validator) ~= "function" then
		return false, string.format("invalid %s validator", stage)
	end
	local ok, a, b = pcall(validator, text, ctx)
	if not ok then
		return false, tostring(a)
	end
	if a == false then
		return false, tostring(b or (stage .. " validation failed"))
	end
	return true, nil
end

local function resolve_function(fn_or_id, defaults, function_role)
	if fn_or_id == nil then
		return no_op_function
	end
	if type(fn_or_id) == "function" then
		return fn_or_id
	end
	if type(fn_or_id) == "string" and type(defaults[fn_or_id]) == "function" then
		return defaults[fn_or_id]
	end
	return nil, string.format("invalid %s function", function_role)
end

-- Returns a normalized profile map for the active syntax quotes table
function quotes.get_profiles(doc)
	local profiles = {}
	local q = get_quotes_spec(doc)
	local src = q and q.profiles
	if type(src) ~= "table" then
		return profiles
	end
	for key, profile in pairs(src) do
		if type(profile) == "table" then
			local id = nil
			if type(key) == "string" and key ~= "" then
				id = key
			elseif type(profile.id) == "string" and profile.id ~= "" then
				id = profile.id
			end
			if id then
				local p = copy_table(profile)
				p.id = id
				profiles[id] = p
			end
		end
	end
	return profiles
end

-- Resolves profile by explicit id, syntax primary, then global default
function quotes.resolve_profile(doc, profile_id)
	local q = get_quotes_spec(doc)
	local profiles = quotes.get_profiles(doc)
	if q and q.primary and not profiles[q.primary] then
		warn_missing_profile_ref(doc, "primary", q.primary)
	end
	if q and q.secondary and not profiles[q.secondary] then
		warn_missing_profile_ref(doc, "secondary", q.secondary)
	end
	local explicit = profile_id ~= nil
	local id

	if explicit then
		if profile_id == "primary" then
			id = q and q.primary or nil
		elseif profile_id == "secondary" then
			id = q and q.secondary or nil
		else
			id = profile_id
		end
		if id == nil then
			return nil, profile_id
		end
	else
		id = (q and q.primary) or config.quote_default
	end

	if not explicit and id == nil then
		local only_id = nil
		local count = 0
		for pid in pairs(profiles) do
			count = count + 1
			only_id = pid
			if count > 1 then
				break
			end
		end
		if count == 1 then
			id = only_id
		elseif count > 1 then
			warn_missing_primary_config(doc)
		end
	end
	if id == nil then
		return nil, nil
	end
	return profiles[id], id
end

function quotes.list_profile_ids(doc)
	local profiles = quotes.get_profiles(doc)
	local ids = {}
	for id in pairs(profiles) do
		ids[#ids + 1] = id
	end
	table.sort(ids)
	return ids
end

local function run_wrap(doc, profile, profile_id, text)
	local source = tostring(text or "")
	local ctx = {
		doc = doc,
		profile = profile,
		profile_id = profile_id,
		operation = "quote",
	}
	local ok, err = run_validate(profile.validate_input, source, ctx, "input")
	if not ok then
		return nil, err
	end
	if not profile_input_multiline(profile) and source:find("\n", 1, true) then
		return nil, "profile does not allow multiline input"
	end

	local wrapped
	if type(profile.wrap) == "function" then
		local wok, wres, werr = pcall(profile.wrap, source, profile, ctx)
		if not wok then
			return nil, tostring(wres)
		end
		if type(wres) ~= "string" then
			return nil, tostring(werr or "wrap hook did not return a string")
		end
		wrapped = wres
	else
		local open, close = normalize_delimiters(profile)
		if not open or not close then
			return nil, "missing delimiters"
		end
		local escape_fn, eerr = resolve_function(profile.escape, default_escape_registry, "escape")
		if not escape_fn then
			return nil, eerr
		end
		local eok, escaped, efn_err = pcall(escape_fn, source, profile, ctx)
		if not eok then
			return nil, tostring(escaped)
		end
		if type(escaped) ~= "string" then
			return nil, tostring(efn_err or "escape function did not return a string")
		end
		wrapped = open .. escaped .. close
	end

	local vok, verr = run_validate(profile.validate_output, wrapped, ctx, "output")
	if not vok then
		return nil, verr
	end
	if not profile_output_multiline(profile) and wrapped:find("\n", 1, true) then
		return nil, "profile does not allow multiline output"
	end
	return wrapped, nil
end

function quotes.wrap_text(doc, text, profile_id)
	local profile, id = quotes.resolve_profile(doc, profile_id)
	if not profile then
		if id == nil then
			return nil, "no quote profile selected"
		end
		return nil, string.format('unknown quote profile "%s"', tostring(id))
	end
	return run_wrap(doc, profile, id, text)
end

local function run_unwrap(doc, profile, profile_id, text)
	local source = tostring(text or "")
	local ctx = {
		doc = doc,
		profile = profile,
		profile_id = profile_id,
		operation = "unquote",
	}

	local payload
	if type(profile.unwrap) == "function" then
		local uok, ures, uerr, ustate = pcall(profile.unwrap, source, profile, ctx)
		if not uok then
			return nil, tostring(ures), "malformed"
		end
		if ures == nil then
			-- Custom unwrap hooks can signal non-match without treating it as a hard parse error
			-- Convention:
			--   return nil, "reason"                  -> mismatch
			--   return nil, "reason", "malformed"     -> malformed
			local state = (ustate == "malformed") and "malformed" or "mismatch"
			return nil, tostring(uerr or "wrapper mismatch"), state
		end
		if type(ures) ~= "string" then
			return nil, tostring(uerr or "unwrap function did not return a string"), "malformed"
		end
		payload = ures
	else
		local open, close = normalize_delimiters(profile)
		if not open or not close then
			return nil, "missing delimiters", "malformed"
		end
		if #source < (#open + #close) then
			return nil, "too short for delimiters", "mismatch"
		end
		if source:sub(1, #open) ~= open or source:sub(#source - #close + 1) ~= close then
			return nil, "wrapper mismatch", "mismatch"
		end
		payload = source:sub(#open + 1, #source - #close)
	end

	local unescape_fn, uerr = resolve_function(profile.unescape, default_unescape_registry, "unescape")
	if not unescape_fn then
		return nil, uerr, "malformed"
	end
	-- 'output_multiline' constrains literal newlines in wrapped payload text
	-- Escaped newlines (e.g. "\n") are still valid and decode to multiline content
	if not profile_output_multiline(profile) and payload:find("\n", 1, true) then
		return nil, "profile does not allow multiline output", "malformed"
	end
	local uok, decoded, derr = pcall(unescape_fn, payload, profile, ctx)
	if not uok then
		return nil, tostring(decoded), "malformed"
	end
	if type(decoded) ~= "string" then
		return nil, tostring(derr or "unescape function did not return a string"), "malformed"
	end
	local vok, verr = run_validate(profile.validate_input, decoded, ctx, "input")
	if not vok then
		return nil, verr, "malformed"
	end
	return decoded, nil, "ok"
end

local function ordered_profile_ids_for_auto(doc)
	local profiles = quotes.get_profiles(doc)
	local ids = {}
	for id in pairs(profiles) do
		ids[#ids + 1] = id
	end
	table.sort(ids)
	local q = get_quotes_spec(doc)
	if q and q.primary and not profiles[q.primary] then
		warn_missing_profile_ref(doc, "primary", q.primary)
	end
	if q and q.secondary and not profiles[q.secondary] then
		warn_missing_profile_ref(doc, "secondary", q.secondary)
	end
	local primary_id = (q and q.primary) or config.quote_default
	local secondary_id = q and q.secondary or nil
	if (primary_id and profiles[primary_id]) or (secondary_id and profiles[secondary_id]) then
		local out = {}
		if primary_id and profiles[primary_id] then
			out[#out + 1] = primary_id
		end
		if secondary_id and profiles[secondary_id] and secondary_id ~= primary_id then
			out[#out + 1] = secondary_id
		end
		for i = 1, #ids do
			if ids[i] ~= primary_id and ids[i] ~= secondary_id then
				out[#out + 1] = ids[i]
			end
		end
		return out
	end
	return ids
end

function quotes.unquote_text(doc, text, profile_id)
	if profile_id ~= nil then
		local profile, id = quotes.resolve_profile(doc, profile_id)
		if not profile then
			if id == nil then
				return nil, "no quote profile selected"
			end
			return nil, string.format('unknown quote profile "%s"', tostring(id))
		end
		local decoded, err = run_unwrap(doc, profile, id, text)
		if not decoded then
			return nil, err
		end
		return decoded, nil
	end

	local profiles = quotes.get_profiles(doc)
	local ids = ordered_profile_ids_for_auto(doc)
	local malformed_err = nil
	for i = 1, #ids do
		local id = ids[i]
		local profile = profiles[id]
		if profile then
			local decoded, err, state = run_unwrap(doc, profile, id, text)
			if decoded then
				return decoded, nil
			end
			if state == "malformed" and not malformed_err then
				malformed_err = err
			end
		end
	end
	if malformed_err then
		return nil, malformed_err
	end
	return nil, "text is not quoted for current format profiles"
end

return quotes
