-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Portions derived from json.lua, Copyright (c) 2019 rxi, under the MIT License.
-- Lx Portions: Lx Interim License. See LICENSE for details.

local core = require("core")
local common = require("core.common")
local command = require("core.command")
local config = require("core.config")
local style = require("core.style")
local text_navigate = require("core.text.navigate")
local native_text = require("text") -- Split complete JSON Lines input without a Lua pattern loop

local parse_format

config.tidyjson_indent_size = config.tidyjson_indent_size or 4
-- Preserve explicit false values from user configuration
if config.tidyjson_use_tabs == nil then
	config.tidyjson_use_tabs = true
end
if config.tidyjson_preserve_multiline_empty == nil then
	config.tidyjson_preserve_multiline_empty = true
end

-- 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 validated JSON Unicode escape or surrogate pair
-- @param s string matched escape text
-- @return string text decoded UTF-8 text
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 one JSON string token
-- @param str string source JSON
-- @param i integer opening quote position
-- @return string value decoded text
-- @return integer next_index first byte after the closing quote
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 + 4)
				if #hex ~= 4 or not hex:find("^%x%x%x%x$") then
					decode_error(str, j, "Invalid Unicode escape in string")
				end

				local code_unit = tonumber(hex, 16)
				if code_unit >= 0xd800 and code_unit <= 0xdbff then
					local low_unit = tonumber(str:sub(j + 7, j + 10), 16)
					if str:sub(j + 5, j + 6) ~= "\\u"
						or not low_unit
						or low_unit < 0xdc00
						or low_unit > 0xdfff
					then
						decode_error(str, j, "Invalid Unicode surrogate pair in string")
					end
					has_surrogate_escape = true
				elseif code_unit >= 0xdc00 and code_unit <= 0xdfff then
					local high_unit = tonumber(str:sub(j - 5, j - 2), 16)
					if str:sub(j - 7, j - 6) ~= "\\u"
						or not high_unit
						or high_unit < 0xd800
						or high_unit > 0xdbff
					then
						decode_error(str, j, "Invalid Unicode surrogate pair in string")
					end
				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 one JSON value with optional object-key sorting and compact output
-- @param text string source JSON
-- @param indent string indentation unit
-- @param sort_keys boolean sort object keys
-- @param compact boolean omit formatting whitespace
-- @param level number|nil initial indentation level
-- @return string formatted JSON
local function tidyjson(text, indent, sort_keys, compact, level)
	local i = next_char(text, 1, space_chars, true)
	local out, next_i = parse_format(text, i, indent, level or 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

--- Format non-empty JSON lines as values in one JSON array
-- @param text string source JSON Lines text
-- @param indent string indentation unit
-- @param sort_keys boolean sort object keys
-- @return string formatted JSON array
-- @return integer record_count number of formatted records
local function format_json_lines(text, indent, sort_keys)
	local values = {}
	local line_number = 0

	local source_lines = native_text.split_lines(text, { keep_eol = false })

	for index = 1, #source_lines do
		line_number = index
		local json_line = source_lines[index]

		-- Pattern breakdown:
		-- '%S' matches any non-whitespace character
		if json_line:find("%S") then
			local ok, formatted = pcall(tidyjson, json_line, indent, sort_keys, false, 1)

			if not ok then
				-- Pattern breakdown:
				-- '^' anchors the start and '(.-)' captures the parser message minimally
				-- '%d+' matches the local line and '(%d+)' captures the column
				-- '$' requires the coordinates to end the message
				local message, col = tostring(formatted):match("^(.-) at line %d+ col (%d+)$")

				if message and col then
					error(string.format("%s at line %d col %s", message, line_number, col), 0)
				end

				error(string.format("%s at line %d col 1", tostring(formatted), line_number), 0)
			end

			values[#values + 1] = indent .. formatted
		end
	end

	if #values == 0 then
		return "[]", 0
	end

	return "[\n" .. table.concat(values, ",\n") .. "\n]", #values
end

--- Return whether a filename explicitly identifies JSON Lines content
-- @param filename string|nil document filename
-- @return boolean is_json_lines
local function is_json_lines_filename(filename)
	if type(filename) ~= "string" then
		return false
	end

	local lower_filename = filename:lower()

	return lower_filename:sub(-6) == ".jsonl" or lower_filename:sub(-7) == ".ndjson"
end

--- Return whether the first two non-empty lines resemble separate JSON containers
-- @param text string source text
-- @return boolean looks_like_lines
local function looks_like_json_lines(text)
	local candidates = {}

	-- Pattern breakdown:
	-- '(.-)' captures one line minimally and the newline ends that capture
	for source_line in (text .. "\n"):gmatch("(.-)\n") do
		local candidate = common.trim(source_line)

		if candidate ~= "" then
			candidates[#candidates + 1] = candidate

			if #candidates == 2 then
				break
			end
		end
	end

	if #candidates < 2 then
		return false
	end

	local first_char = candidates[1]:sub(1, 1)
	local second_char = candidates[2]:sub(1, 1)

	return (first_char == "{" or first_char == "[")
		and (second_char == "{" or second_char == "[")
		and candidates[1]:sub(-1) ~= ","
end

--- Format JSON using filename and content-based JSON Lines detection
-- @param text string source JSON or JSON Lines text
-- @param indent string indentation unit
-- @param filename string|nil document filename
-- @param sort_keys boolean sort object keys
-- @return string formatted JSON
-- @return string mode 'single' or 'lines'
-- @return integer|nil record_count number of formatted JSON Lines records
local function format_json_auto(text, indent, filename, sort_keys)
	if is_json_lines_filename(filename) then
		local formatted, record_count = format_json_lines(text, indent, sort_keys)

		return formatted, "lines", record_count
	end

	if looks_like_json_lines(text) then
		local lines_ok, lines_result, record_count = pcall(format_json_lines, text, indent, sort_keys)

		if lines_ok then
			return lines_result, "lines", record_count
		end
	end

	return tidyjson(text, indent, sort_keys, false), "single"
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 the active document using one explicit or automatic input mode
-- @param mode string 'auto', 'single', or 'lines'
-- @param sort_keys boolean sort object keys
local function perform_format(mode, sort_keys)
	local doc = core.active_view.doc
	local indent = get_indent_unit()
	local formatted_mode = mode
	local record_count
	local ok, err = pcall(function()
		doc:replace(function(text)
			if mode == "lines" then
				local formatted

				formatted, record_count = format_json_lines(text, indent, sort_keys)

				return formatted
			elseif mode == "single" then
				return tidyjson(text, indent, sort_keys, false)
			end

			local formatted

			formatted, formatted_mode, record_count = format_json_auto(text, indent, doc.filename, sort_keys)

			return formatted
		end)
	end)

	if not ok then
		jump_to_parse_error(err)
		local input_name = mode == "lines" and "JSON Lines" or "JSON"
		core.status_view:show_error("Error in " .. input_name .. ": " .. tostring(err))

		return
	end

	local sorted_suffix = sort_keys and " + sorted" or ""

	if formatted_mode == "lines" then
		local record_label = record_count == 1 and "record" or "records"
		core.status_view:show_success(string.format(
			"JSON Lines formatted%s (%d %s)",
			sorted_suffix,
			record_count or 0,
			record_label
		))

		return
	end

	core.status_view:show_success("JSON formatted" .. sorted_suffix)
end

-- Register JSON formatting commands
command.register({
	-- Convert non-empty JSON lines into one formatted JSON array
	["tidyjson:format-lines"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("lines", false)
		end,
	},

	-- Convert and sort non-empty JSON lines into one formatted JSON array
	["tidyjson:format-lines-sorted"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("lines", true)
		end,
	},

	-- Automatically choose single JSON or JSON Lines formatting
	["tidyjson:format"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("auto", false)
		end,
	},

	-- Format exactly one JSON value
	["tidyjson:format-single"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("single", false)
		end,
	},

	-- Automatically choose input mode and sort object keys
	["tidyjson:format-sorted"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("auto", true)
		end,
	},

	-- Format exactly one JSON value and sort object keys
	["tidyjson:format-single-sorted"] = {
		requires = { "doc", "edit" },
		perform = function()
			perform_format("single", true)
		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
