-- lx-bundle:true

--- Decode JSON command args with optional unquoted object keys
-- Based on json.lua by rxi <https://github.com/rxi/json.lua>
-- Copyright (c) 2019 rxi. License: MIT

-- Command invocation structure:
-- context:command(arg name: value, "quoted name": value)

-- This module separates context:command from the args object
-- The object parser returns its values as Lua tables
-- Keys may be quoted or unquoted, while values use normal JSON syntax
-- Trailing commas are accepted and null or nil is represented by parser.null

local parser = {}

parser.null = {}

local utf8 = require("utf8")
local escapes = {
	['"'] = '"',
	["\\"] = "\\",
	["/"] = "/",
	["b"] = "\b",
	["f"] = "\f",
	["n"] = "\n",
	["r"] = "\r",
	["t"] = "\t",
}
local parse_value

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

local function is_space(byte)
	return byte == 32 or byte == 9 or byte == 10 or byte == 13
end

local function skip_space(text, index)
	while is_space(text:byte(index)) do
		index = index + 1
	end
	return index
end

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

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
			parts[#parts + 1] = text:sub(chunk_start, i - 1)
			return table.concat(parts), i + 1
		elseif byte == 92 then
			parts[#parts + 1] = text:sub(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

local function is_digit(byte)
	return byte and byte >= 48 and byte <= 57
end

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)) and text:byte(i) ~= 48 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

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)
		if text:sub(i, i) == "]" then
			return result, i + 1
		end
	end
end

local function parse_unquoted_key(text, index)
	local i = index
	while i <= #text do
		local byte = text:byte(i)
		if byte == 58 then
			break
		end
		if (byte < 32 and byte ~= 9) or byte == 34 or byte == 44
			or byte == 91 or byte == 93 or byte == 123 or byte == 125
		then
			fail(text, i, "Invalid character in unquoted key")
		end
		i = i + 1
	end
	if i > #text then
		fail(text, index, "Expected ':' after object key")
	end

	local key_end = i - 1
	while key_end >= index and (text:byte(key_end) == 32 or text:byte(key_end) == 9) do
		key_end = key_end - 1
	end
	if key_end < index then
		fail(text, index, "Empty object key")
	end

	return text:sub(index, key_end), i
end

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
		local key
		if text:sub(i, i) == '"' then
			key, i = parse_string(text, i)
			i = skip_space(text, i)
		else
			key, i = parse_unquoted_key(text, i)
		end
		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)
		if text:sub(i, i) == "}" then
			return result, i + 1
		end
	end
end

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 parser.null, index + 4
	elseif text:sub(index, index + 2) == "nil" then
		return parser.null, index + 3
	end

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

--- Decode JSON text into Lua values
-- Object keys may be unquoted and contain spaces; trailing commas are accepted
-- @param text string JSON or relaxed command args text
-- @return any value decoded Lua value
function parser.parse(text)
	assert(type(text) == "string", "command args 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

--- Separate a command name from its optional parenthesized args text
-- This does not decode the object so incomplete palette input remains usable
-- @param invocation string complete or partial command invocation
-- @return string name command name without surrounding whitespace
-- @return string|nil payload args text beginning with '('
function parser.split_invocation(invocation)
	assert(type(invocation) == "string", "command invocation must be a string")
	local payload_index = invocation:find("(", 1, true)
	local name_start = skip_space(invocation, 1)
	local name_end = (payload_index or (#invocation + 1)) - 1

	while name_end >= name_start and is_space(invocation:byte(name_end)) do
		name_end = name_end - 1
	end

	local name = invocation:sub(name_start, name_end)
	local payload = payload_index and invocation:sub(payload_index) or nil

	return name, payload
end

--- Decode one parenthesized args list as an implicit object
-- The same-width delimiter replacement keeps parse error coordinates accurate
-- @param payload string args text beginning with '('
-- @return table args decoded args table
function parser.parse_args(payload)
	if payload:sub(1, 1) ~= "(" then
		fail(payload, 1, "Expected '(' before command args")
	end

	local close_index = #payload
	while close_index > 1 and is_space(payload:byte(close_index)) do
		close_index = close_index - 1
	end
	if payload:sub(close_index, close_index) ~= ")" then
		fail(payload, close_index, "Expected ')' after command args")
	end

	local object_text = "{" .. payload:sub(2, close_index - 1) .. "}" .. payload:sub(close_index + 1)

	return parser.parse(object_text)
end

--- Decode one complete command invocation
-- @param invocation string command name with optional parenthesized args
-- @return string name resolved command name
-- @return table|nil args decoded args table
function parser.parse_invocation(invocation)
	local name, payload = parser.split_invocation(invocation)
	if name == "" then
		error("Command name is empty", 0)
	end
	if not payload then
		return name, nil
	end

	return name, parser.parse_args(payload)
end

return parser
