-- lx-bundle:true

--- Strict standalone JSON encoding and decoding
-- Decoded JSON null values use json.null so array positions and object fields survive

local json = {
	_version = "1.0.0",
	null = {},
}

local utf8 = require("utf8")
local parse_value
local encode_value

local escapes = {
	['"'] = '"',
	["\\"] = "\\",
	["/"] = "/",
	["b"] = "\b",
	["f"] = "\f",
	["n"] = "\n",
	["r"] = "\r",
	["t"] = "\t",
}

local escaped_characters = {
	['"'] = '\\"',
	["\\"] = "\\\\",
	["\b"] = "\\b",
	["\f"] = "\\f",
	["\n"] = "\\n",
	["\r"] = "\\r",
	["\t"] = "\\t",
}

--- Raise a JSON error with its source location
-- @param text string complete JSON source
-- @param index integer byte position where parsing failed
-- @param message string plain error description
local function fail(text, index, message)
	local line, column = 1, 1

	for i = 1, index - 1 do
		if text:byte(i) == 10 then
			line, column = line + 1, 1
		else
			column = column + 1
		end
	end

	error(string.format("%s at line %d col %d", message, line, column), 0)
end

--- Check whether one byte is JSON whitespace
-- @param byte integer|nil source byte
-- @return boolean is_space
local function is_space(byte)
	return byte == 32 or byte == 9 or byte == 10 or byte == 13
end

--- Advance over JSON whitespace
-- @param text string JSON source
-- @param index integer starting byte position
-- @return integer index first non-space position
local function skip_space(text, index)
	while is_space(text:byte(index)) do
		index = index + 1
	end

	return index
end

--- Check whether one byte is an ASCII decimal digit
-- @param byte integer|nil source byte
-- @return boolean is_digit
local function is_digit(byte)
	return byte ~= nil and byte >= 48 and byte <= 57
end

--- Append a plain string section after validating its UTF-8
-- @param parts string[] collected decoded sections
-- @param text string JSON source
-- @param first integer first included byte
-- @param last integer last included byte
local function append_string_chunk(parts, text, first, last)
	if last < first then
		return
	end

	local chunk = text:sub(first, last)
	local valid, invalid_index = utf8.len(chunk)
	if not valid then
		fail(text, first + invalid_index - 1, "Invalid UTF-8 in string")
	end

	parts[#parts + 1] = chunk
end

--- Decode one Unicode escape and an optional matching low surrogate
-- @param text string JSON source
-- @param index integer first hexadecimal digit
-- @return string value decoded UTF-8 character
-- @return integer next_index first unread byte
local function parse_unicode(text, index)
	local digits = text:sub(index, index + 3)
	local codepoint = #digits == 4 and tonumber(digits, 16)
	if not codepoint then
		fail(text, index, "Invalid Unicode escape")
	end
	---@cast codepoint integer

	local next_index = index + 4

	if codepoint >= 0xd800 and codepoint <= 0xdbff then
		if text:sub(next_index, next_index + 1) ~= "\\u" then
			fail(text, index, "Missing low Unicode surrogate")
		end

		local low_digits = text:sub(next_index + 2, next_index + 5)
		local low = #low_digits == 4 and tonumber(low_digits, 16)
		if not low or low < 0xdc00 or low > 0xdfff then
			fail(text, next_index + 2, "Invalid low Unicode surrogate")
		end

		codepoint = 0x10000 + (codepoint - 0xd800) * 0x400 + low - 0xdc00
		next_index = next_index + 6
	elseif codepoint >= 0xdc00 and codepoint <= 0xdfff then
		fail(text, index, "Unexpected low Unicode surrogate")
	end

	return utf8.char(codepoint), next_index
end

--- Decode one quoted JSON string
-- @param text string JSON source
-- @param index integer opening quote position
-- @return string value decoded string
-- @return integer next_index first unread byte
local function parse_string(text, index)
	local parts = {}
	local chunk_start = index + 1
	local i = chunk_start

	while i <= #text do
		local byte = text:byte(i)

		if byte == 34 then
			append_string_chunk(parts, text, chunk_start, i - 1)

			return table.concat(parts), i + 1
		elseif byte == 92 then
			append_string_chunk(parts, text, chunk_start, i - 1)

			local escape = text:sub(i + 1, i + 1)
			if escape == "u" then
				local value
				value, i = parse_unicode(text, i + 2)
				parts[#parts + 1] = value
			else
				local value = escapes[escape]
				if not value then
					fail(text, i, "Invalid string escape")
				end

				parts[#parts + 1] = value
				i = i + 2
			end

			chunk_start = i
		elseif byte < 32 then
			fail(text, i, "Control character in string")
		else
			i = i + 1
		end
	end

	fail(text, index, "Unterminated string")
end

--- Decode one number using the exact JSON number grammar
-- @param text string JSON source
-- @param index integer first number byte
-- @return number value decoded number
-- @return integer next_index first unread byte
local function parse_number(text, index)
	local i = index

	if text:byte(i) == 45 then
		i = i + 1
	end

	if text:byte(i) == 48 then
		i = i + 1
		if is_digit(text:byte(i)) then
			fail(text, i, "Leading zero in number")
		end
	elseif is_digit(text:byte(i)) then
		repeat
			i = i + 1
		until not is_digit(text:byte(i))
	else
		fail(text, index, "Invalid number")
	end

	if text:byte(i) == 46 then
		i = i + 1
		if not is_digit(text:byte(i)) then
			fail(text, i, "Expected digit after decimal point")
		end

		repeat
			i = i + 1
		until not is_digit(text:byte(i))
	end

	local byte = text:byte(i)
	if byte == 69 or byte == 101 then
		i = i + 1
		byte = text:byte(i)

		if byte == 43 or byte == 45 then
			i = i + 1
		end
		if not is_digit(text:byte(i)) then
			fail(text, i, "Expected digit in exponent")
		end

		repeat
			i = i + 1
		until not is_digit(text:byte(i))
	end

	local value = tonumber(text:sub(index, i - 1))
	if not value or value == math.huge or value == -math.huge then
		fail(text, index, "Number is outside the supported range")
	end

	return value, i
end

--- Decode one strict JSON array
-- @param text string JSON source
-- @param index integer opening bracket position
-- @return table value decoded array
-- @return integer next_index first unread byte
local function parse_array(text, index)
	local result = {}
	local i = skip_space(text, index + 1)

	if text:sub(i, i) == "]" then
		return result, i + 1
	end

	while true do
		local value
		value, i = parse_value(text, i)
		result[#result + 1] = value
		i = skip_space(text, i)

		local character = text:sub(i, i)
		if character == "]" then
			return result, i + 1
		end
		if character ~= "," then
			fail(text, i, "Expected ',' or ']' in array")
		end

		i = skip_space(text, i + 1)
	end
end

--- Decode one strict JSON object
-- @param text string JSON source
-- @param index integer opening brace position
-- @return table value decoded object
-- @return integer next_index first unread byte
local function parse_object(text, index)
	local result = {}
	local i = skip_space(text, index + 1)

	if text:sub(i, i) == "}" then
		return result, i + 1
	end

	while true do
		if text:sub(i, i) ~= '"' then
			fail(text, i, "Expected quoted object key")
		end

		local key
		key, i = parse_string(text, i)
		i = skip_space(text, i)

		if text:sub(i, i) ~= ":" then
			fail(text, i, "Expected ':' after object key")
		end

		local value
		value, i = parse_value(text, skip_space(text, i + 1))
		result[key] = value
		i = skip_space(text, i)

		local character = text:sub(i, i)
		if character == "}" then
			return result, i + 1
		end
		if character ~= "," then
			fail(text, i, "Expected ',' or '}' in object")
		end

		i = skip_space(text, i + 1)
	end
end

--- Decode one JSON value at a known non-space position
-- @param text string JSON source
-- @param index integer first value byte
-- @return any value decoded Lua value
-- @return integer next_index first unread byte
parse_value = function(text, index)
	local character = text:sub(index, index)

	if character == '"' then
		return parse_string(text, index)
	elseif character == "{" then
		return parse_object(text, index)
	elseif character == "[" then
		return parse_array(text, index)
	elseif character == "-" or is_digit(text:byte(index)) then
		return parse_number(text, index)
	elseif text:sub(index, index + 3) == "true" then
		return true, index + 4
	elseif text:sub(index, index + 4) == "false" then
		return false, index + 5
	elseif text:sub(index, index + 3) == "null" then
		return json.null, index + 4
	end

	fail(text, index, "Expected JSON value")
end

--- Escape one control byte for a JSON string
-- @param character string single byte
-- @return string escaped JSON text
local function escape_character(character)
	return escaped_characters[character]
		or string.format("\\u%04x", character:byte())
end

--- Encode one valid UTF-8 string
-- @param value string Lua string
-- @return string encoded JSON string
local function encode_string(value)
	local valid, invalid_index = utf8.len(value)
	if not valid then
		error(string.format("Invalid UTF-8 in string at byte %d", invalid_index), 0)
	end

	-- Pattern breakdown:
	-- Match NUL, control bytes, backslash, or quote so each receives a JSON escape
	return '"' .. value:gsub('[%z\1-\31\\"]', escape_character) .. '"'
end

--- Encode one finite Lua number
-- @param value number Lua number
-- @return string encoded JSON number
local function encode_number(value)
	if value ~= value or value == math.huge or value == -math.huge then
		error("JSON cannot encode NaN or infinity", 0)
	end

	return tostring(value)
end

--- Classify a Lua table and validate all of its keys
-- Empty tables retain the established array representation
-- @param value table Lua table
-- @return string kind either 'array' or 'object'
-- @return integer length validated array length or zero for an object
local function classify_table(value)
	if next(value) == nil then
		return "array", 0
	end

	if rawget(value, 1) ~= nil then
		local count, highest = 0, 0

		for key in pairs(value) do
			if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
				error("JSON arrays require consecutive positive integer keys", 0)
			end

			count = count + 1
			highest = math.max(highest, key)
		end

		if count ~= highest then
			error("JSON cannot encode a sparse array", 0)
		end

		return "array", highest
	end

	for key in pairs(value) do
		if type(key) ~= "string" then
			error("JSON object keys must be strings", 0)
		end
	end

	return "object", 0
end

--- Encode one Lua table while detecting circular references
-- @param value table Lua table
-- @param active table<table, boolean> tables in the current recursion path
-- @param indent string|nil indentation unit for readable output
-- @param depth integer current container depth
-- @return string encoded JSON container
local function encode_table(value, active, indent, depth)
	if active[value] then
		error("JSON cannot encode a circular table", 0)
	end

	active[value] = true

	local kind, length = classify_table(value)
	local parts = {}

	if kind == "array" then
		for index = 1, length do
			parts[index] = encode_value(value[index], active, indent, depth + 1)
		end

		active[value] = nil

		if indent and length > 0 then
			local inner_indent = indent:rep(depth + 1)
			local outer_indent = indent:rep(depth)

			return "[\n"
				.. inner_indent
				.. table.concat(parts, ",\n" .. inner_indent)
				.. "\n"
				.. outer_indent
				.. "]"
		end

		return "[" .. table.concat(parts, ",") .. "]"
	end

	for key, item in pairs(value) do
		local separator = indent and ": " or ":"
		parts[#parts + 1] = encode_string(key)
			.. separator
			.. encode_value(item, active, indent, depth + 1)
	end

	active[value] = nil

	if indent and #parts > 0 then
		local inner_indent = indent:rep(depth + 1)
		local outer_indent = indent:rep(depth)

		return "{\n"
			.. inner_indent
			.. table.concat(parts, ",\n" .. inner_indent)
			.. "\n"
			.. outer_indent
			.. "}"
	end

	return "{" .. table.concat(parts, ",") .. "}"
end

--- Encode one supported Lua value
-- @param value any Lua value
-- @param active table<table, boolean> tables in the current recursion path
-- @param indent string|nil indentation unit for readable output
-- @param depth integer current container depth
-- @return string encoded JSON value
encode_value = function(value, active, indent, depth)
	if value == json.null or value == nil then
		return "null"
	end

	local value_type = type(value)

	if value_type == "string" then
		return encode_string(value)
	elseif value_type == "number" then
		return encode_number(value)
	elseif value_type == "boolean" then
		return tostring(value)
	elseif value_type == "table" then
		return encode_table(value, active, indent, depth)
	end

	error("JSON cannot encode Lua type '" .. value_type .. "'", 0)
end

--- Decode strict JSON text into Lua values
-- JSON null values are represented by json.null
-- @param text string complete JSON text
-- @return any value decoded Lua value
function json.decode(text)
	assert(type(text) == "string", "JSON text must be a string")

	local index = skip_space(text, 1)
	local value
	value, index = parse_value(text, index)
	index = skip_space(text, index)

	if index <= #text then
		fail(text, index, "Trailing content")
	end

	return value
end

--- Encode one Lua value as compact or readable JSON
-- Lua nil and json.null encode as JSON null
-- @param value any supported Lua value
-- @param options table|nil set 'pretty' to true for tab-indented output
-- @return string text encoded JSON text
function json.encode(value, options)
	if options ~= nil and type(options) ~= "table" then
		error("JSON encode options must be a table", 0)
	end

	local indent = options and options.pretty and "\t" or nil

	return encode_value(value, {}, indent, 0)
end

return json
