-- lx-bundle:true

local core = require("core")
local command = require("core.command")
local config = require("core.config")
local style = require("core.style")
local text_navigate = require("core.text.navigate")

-- Based on json.lua by rxi <https://github.com/rxi/json.lua>
-- Copyright (c) 2019 rxi. License: MIT
local parse_format

config.tidyjson_indent_size = config.tidyjson_indent_size or 4
config.tidyjson_use_tabs = (config.tidyjson_use_tabs or true) == true
config.tidyjson_preserve_multiline_empty = (config.tidyjson_preserve_multiline_empty or true) == true

-- Build the indentation unit based on tab/space settings
local function get_indent_unit()
	if config.tidyjson_use_tabs then
		return "\t"
	end
	local width = tonumber(config.tidyjson_indent_size) or 4
	if width < 0 then
		width = 0
	end
	return string.rep(" ", math.floor(width))
end

-- Create a set table from a vararg list of string keys
local function create_set(...)
	local res = {}
	for i = 1, select("#", ...) do
		res[select(i, ...)] = true
	end
	return res
end

local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")

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

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

-- Escape one character as a JSON string escape sequence
local function escape_char(c)
	return escape_char_map[c] or string.format("\\u%04x", c:byte())
end

-- Encode a Lua string as a JSON string literal
local function encode_string(val)
	return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end

-- Find the next character index matching (or not matching) a set
local function next_char(str, idx, set, negate)
	for i = idx, #str do
		if set[str:sub(i, i)] ~= negate then
			return i
		end
	end
	return #str + 1
end

-- Raise a parse error with line/column coordinates
local function decode_error(str, idx, msg)
	local line_count = 1
	local col_count = 1
	for i = 1, idx - 1 do
		col_count = col_count + 1
		if str:sub(i, i) == "\n" then
			line_count = line_count + 1
			col_count = 1
		end
	end
	-- We use arg2 = 0 to not prefix the stack message with lua file path + error line
	error(string.format("%s at line %d col %d", msg, line_count, col_count), 0)
end

-- Encode a Unicode codepoint as UTF-8 bytes
local function codepoint_to_utf8(n)
	local f = math.floor
	if n <= 0x7f then
		return string.char(n)
	elseif n <= 0x7ff then
		return string.char(f(n / 64) + 192, n % 64 + 128)
	elseif n <= 0xffff then
		return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
	elseif n <= 0x10ffff then
		return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, f(n % 4096 / 64) + 128, n % 64 + 128)
	end
	error(string.format("Invalid unicode codepoint '%x'", n))
end

-- Decode a JSON \uXXXX (and optional surrogate pair) escape
local function parse_unicode_escape(s)
	local n1 = tonumber(s:sub(3, 6), 16)
	local n2 = tonumber(s:sub(9, 12), 16)
	if n2 then
		return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
	end
	return codepoint_to_utf8(n1)
end

-- Parse a JSON string token and return decoded value plus next index
local function parse_string(str, i)
	local has_unicode_escape = false
	local has_surrogate_escape = false
	local has_escape = false
	local last

	for j = i + 1, #str do
		local x = str:byte(j)

		if x < 32 then
			decode_error(str, j, "Control character in string")
		end

		if last == 92 then
			if x == 117 then
				local hex = str:sub(j + 1, j + 5)
				if not hex:find("%x%x%x%x") then
					decode_error(str, j, "Invalid Unicode escape in string")
				end
				if hex:find("^[dD][89aAbB]") then
					has_surrogate_escape = true
				else
					has_unicode_escape = true
				end
			else
				local c = string.char(x)
				if not escape_chars[c] then
					decode_error(str, j, "Invalid escape char '" .. c .. "' in string")
				end
				has_escape = true
			end
			last = nil
		elseif x == 34 then
			local s = str:sub(i + 1, j - 1)
			if has_surrogate_escape then
				s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
			end
			if has_unicode_escape then
				s = s:gsub("\\u....", parse_unicode_escape)
			end
			if has_escape then
				s = s:gsub("\\.", escape_char_map_inv)
			end
			return s, j + 1
		else
			last = x
		end
	end

	decode_error(str, i, "Expected closing quote for string")
end

-- Check if a single-character string is an ASCII digit
local function is_digit(c)
	return c >= "0" and c <= "9"
end

-- Validate text against JSON number grammar without numeric coercion
local function is_valid_json_number(s)
	local p = 1
	local n = #s
	if n == 0 then
		return false
	end

	-- optional minus
	if s:sub(p, p) == "-" then
		p = p + 1
		if p > n then
			return false
		end
	end

	-- integer part
	local c = s:sub(p, p)
	if c == "0" then
		p = p + 1
		-- no leading zeros (e.g. 01)
		if p <= n and is_digit(s:sub(p, p)) then
			return false
		end
	elseif c >= "1" and c <= "9" then
		p = p + 1
		while p <= n and is_digit(s:sub(p, p)) do
			p = p + 1
		end
	else
		return false
	end

	-- fraction
	if p <= n and s:sub(p, p) == "." then
		p = p + 1
		if p > n or not is_digit(s:sub(p, p)) then
			return false
		end
		while p <= n and is_digit(s:sub(p, p)) do
			p = p + 1
		end
	end

	-- exponent
	if p <= n then
		c = s:sub(p, p)
		if c == "e" or c == "E" then
			p = p + 1
			if p <= n then
				c = s:sub(p, p)
				if c == "+" or c == "-" then
					p = p + 1
				end
			end
			if p > n or not is_digit(s:sub(p, p)) then
				return false
			end
			while p <= n and is_digit(s:sub(p, p)) do
				p = p + 1
			end
		end
	end

	return p == n + 1
end

-- Parse a JSON number token while preserving original lexeme text
local function parse_number_token(str, i)
	local x = next_char(str, i, delim_chars)
	local s = str:sub(i, x - 1)
	-- Preserve numeric lexeme to avoid precision loss on large integers
	if not is_valid_json_number(s) then
		decode_error(str, i, "Invalid number '" .. s .. "'")
	end
	return s, x
end

-- Parse JSON literals: true, false, null
local function parse_literal_token(str, i)
	local x = next_char(str, i, delim_chars)
	local word = str:sub(i, x - 1)
	if not literals[word] then
		decode_error(str, i, "Invalid literal '" .. word .. "'")
	end
	return word, x
end

-- Parse and format a JSON array (pretty or compact)
local function parse_array_format(str, i, indent, level, sort_keys, compact)
	local pad = string.rep(indent, level)
	local child_pad = string.rep(indent, level + 1)
	local parts = {}
	local open_i = i
	local close_i
	i = i + 1

	while true do
		i = next_char(str, i, space_chars, true)
		if str:sub(i, i) == "]" then
			close_i = i
			i = i + 1
			break
		end

		local value
		value, i = parse_format(str, i, indent, level + 1, sort_keys, compact)
		if compact then
			parts[#parts + 1] = value
		else
			parts[#parts + 1] = child_pad .. value
		end

		i = next_char(str, i, space_chars, true)
		local chr = str:sub(i, i)
		i = i + 1
		if chr == "]" then
			break
		end
		if chr ~= "," then
			decode_error(str, i, "Expected ']' or ','")
		end
	end

	if #parts == 0 then
		if
			not compact
			and config.tidyjson_preserve_multiline_empty
			and close_i
			and str:sub(open_i + 1, close_i - 1):find("\n", 1, true)
		then
			return "[\n" .. pad .. "]", i
		end
		return "[]", i
	end
	if compact then
		return "[" .. table.concat(parts, ",") .. "]", i
	end
	return "[\n" .. table.concat(parts, ",\n") .. "\n" .. pad .. "]", i
end

-- Parse and format a JSON object (pretty or compact)
local function parse_object_format(str, i, indent, level, sort_keys, compact)
	local pad = string.rep(indent, level)
	local child_pad = string.rep(indent, level + 1)
	local entries = {}
	local seq = 0
	local open_i = i
	local close_i
	i = i + 1

	while true do
		i = next_char(str, i, space_chars, true)
		if str:sub(i, i) == "}" then
			close_i = i
			i = i + 1
			break
		end

		if str:sub(i, i) ~= '"' then
			decode_error(str, i, "Expected string for key")
		end

		local key
		key, i = parse_string(str, i)
		i = next_char(str, i, space_chars, true)
		if str:sub(i, i) ~= ":" then
			decode_error(str, i, "Expected ':' after key")
		end
		i = next_char(str, i + 1, space_chars, true)

		local value
		value, i = parse_format(str, i, indent, level + 1, sort_keys, compact)
		seq = seq + 1
		entries[#entries + 1] = { key = key, value = value, seq = seq }

		i = next_char(str, i, space_chars, true)
		local chr = str:sub(i, i)
		i = i + 1
		if chr == "}" then
			break
		end
		if chr ~= "," then
			decode_error(str, i, "Expected '}' or ','")
		end
	end

	if #entries == 0 then
		if
			not compact
			and config.tidyjson_preserve_multiline_empty
			and close_i
			and str:sub(open_i + 1, close_i - 1):find("\n", 1, true)
		then
			return "{\n" .. pad .. "}", i
		end
		return "{}", i
	end

	if sort_keys then
		-- Sort object entries by key while keeping stable order for duplicates
		table.sort(entries, function(a, b)
			if a.key == b.key then
				return a.seq < b.seq
			end
			return a.key < b.key
		end)
	end

	local parts = {}
	for n = 1, #entries do
		local e = entries[n]
		if compact then
			parts[#parts + 1] = encode_string(e.key) .. ":" .. e.value
		else
			parts[#parts + 1] = child_pad .. encode_string(e.key) .. ": " .. e.value
		end
	end

	if compact then
		return "{" .. table.concat(parts, ",") .. "}", i
	end
	return "{\n" .. table.concat(parts, ",\n") .. "\n" .. pad .. "}", i
end

-- Parse a JSON value and return formatted text plus next index
parse_format = function(str, idx, indent, level, sort_keys, compact)
	local chr = str:sub(idx, idx)

	if chr == '"' then
		local s, i = parse_string(str, idx)
		return encode_string(s), i
	end

	if chr == "-" or chr:find("%d") then
		return parse_number_token(str, idx)
	end

	if chr == "t" or chr == "f" or chr == "n" then
		return parse_literal_token(str, idx)
	end

	if chr == "[" then
		return parse_array_format(str, idx, indent, level, sort_keys, compact)
	end

	if chr == "{" then
		return parse_object_format(str, idx, indent, level, sort_keys, compact)
	end

	decode_error(str, idx, "Unexpected character '" .. chr .. "'")
end

-- Format JSON text with optional object-key sorting and compact output
local function tidyjson(text, indent, sort_keys, compact)
	local i = next_char(text, 1, space_chars, true)
	local out, next_i = parse_format(text, i, indent, 0, sort_keys, compact == true)
	next_i = next_char(text, next_i, space_chars, true)
	if next_i <= #text then
		decode_error(text, next_i, "Trailing garbage")
	end
	return out
end

-- Jump cursor to parsed JSON error location and flash the error line
local function jump_to_parse_error(err)
	local line, col = tostring(err):match("at line (%d+) col (%d+)")
	if not line then
		return
	end
	line = tonumber(line)
	col = tonumber(col)
	local ok = text_navigate.jump_to_line_col(line, col)
	if not ok then
		return
	end
	local view = core.active_view
	if view and view.doc and view.doc.flash_line then
		view.doc:flash_line(line, { kind = "error" })
	end
end

-- Format active document JSON with configured indentation
command.add({
	["tidyjson:format"] = {
		requires = { "doc", "edit" },
		perform = function()
			local doc = core.active_view.doc
			local indent = get_indent_unit()
			local ok, err = pcall(function()
				doc:replace(function(text)
					return tidyjson(text, indent, false, false)
				end)
			end)
			if not ok then
				jump_to_parse_error(err)
				core.status_view:show_error("Error in JSON: " .. tostring(err))
			else
				core.status_view:show_success("JSON tidied")
			end
		end,
	},

	-- Format and sort active document JSON object keys
	["tidyjson:format-sorted"] = {
		requires = { "doc", "edit" },
		perform = function()
			local doc = core.active_view.doc
			local indent = get_indent_unit()
			local ok, err = pcall(function()
				doc:replace(function(text)
					return tidyjson(text, indent, true, false)
				end)
			end)
			if not ok then
				jump_to_parse_error(err)
				core.status_view:show_error("Error in JSON: " .. tostring(err))
			else
				core.status_view:show_success("JSON tidied + sorted")
			end
		end,
	},

	-- Minify active document JSON (no extra whitespace)
	["tidyjson:minimize"] = {
		requires = { "doc", "edit" },
		perform = function()
			local doc = core.active_view.doc
			local ok, err = pcall(function()
				doc:replace(function(text)
					return tidyjson(text, "", false, true)
				end)
			end)
			if not ok then
				jump_to_parse_error(err)
				core.status_view:show_error("Error in JSON: " .. tostring(err))
			else
				core.status_view:show_success("JSON minimized")
			end
		end,
	},
})

return true
