local utils = require("luacheck.utils")
local cache = {}
-- Cache file contains check results for n unique filenames.
-- Cache file consists of 3n+2 lines, the first line is empty and the second is cache format version.
-- The rest are contain file records, 3 lines per file.
-- For each file, first line is the filename, second is modification time,
-- third is check result in lua table format.
-- String fields are compressed into array indexes.
cache.format_version = 20
local option_fields = {
	"ignore",
	"std",
	"globals",
	"unused_args",
	"self",
	"compat",
	"global",
	"unused",
	"redefined",
	"unused_secondaries",
	"allow_defined",
	"allow_defined_top",
	"module",
	"read_globals",
	"new_globals",
	"new_read_globals",
	"enable",
	"only",
	"not_globals",
	"max_line_length",
	"max_code_line_length",
	"max_string_line_length",
	"max_comment_line_length",
}
local event_fields = {
	"code",
	"name",
	"line",
	"column",
	"end_column",
	"prev_line",
	"prev_column",
	"secondary",
	"self",
	"func",
	"top",
	"msg",
	"index",
	"recursive",
	"mutually_recursive",
	"useless",
	"field",
	"label",
	"push",
	"pop",
	"options",
	"indirect",
	"indexing",
	"previous_indexing_len",
	"overwritten_line",
	"overwritten_column",
}

-- Recursively replace string keys with integer keys.
local function compress(t, fields)
	fields = fields or event_fields
	local res = {}

	for index, field in ipairs(fields) do
		local value = t[field]

		if value ~= nil then
			if field == "options" then value = compress(value, option_fields) end

			res[index] = value
		end
	end

	return res
end

local function compress_report(report)
	local res = {}
	res[1] = utils.map(compress, report.events)
	res[2] = {}

	for line, events in pairs(report.per_line_options) do
		res[2][line] = utils.map(compress, events)
	end

	res[3] = report.line_lengths
	res[4] = report.line_endings
	return res
end

-- Recursively restores a table from a compressed array.
local function decompress(t, fields)
	fields = fields or event_fields
	local res = {}

	for index, field in ipairs(fields) do
		local value = t[index]

		if value ~= nil then
			if field == "options" then value = decompress(value, option_fields) end

			res[field] = value
		end
	end

	return res
end

local function decompress_report(compressed)
	local report = {}
	report.events = utils.map(decompress, compressed[1])
	report.per_line_options = {}

	for line, events in pairs(compressed[2]) do
		report.per_line_options[line] = utils.map(decompress, events)
	end

	report.line_lengths = compressed[3]
	report.line_endings = compressed[4]
	return report
end

local function get_local_name(index)
	return string.char(index + (index > 26 and 70 or 64))
end

local function max_n(t)
	local res = 0

	for k in pairs(t) do
		res = math.max(res, k)
	end

	return res
end

-- Serializes a value into buffer.
-- `strings` is a table mapping string values to where they first occured or to name of local
-- variable used to represent it.
-- Array part contains representations of values saved into locals.
local function add_value(buffer, strings, value)
	if type(value) == "string" then
		local prev = strings[value]

		if type(prev) == "string" then
			-- There is a local with such value.
			table.insert(buffer, prev)
		elseif type(prev) == "number" and #strings < 52 then
			-- Value is used second time, put it into a local.
			table.insert(strings, ("%q"):format(value))
			local local_name = get_local_name(#strings)
			buffer[prev] = local_name
			table.insert(buffer, local_name)
			strings[value] = local_name
		else
			table.insert(buffer, ("%q"):format(value))
			strings[value] = #buffer
		end
	elseif type(value) == "table" then
		local is_sparse
		local put_one
		table.insert(buffer, "{")

		for i = 1, max_n(value) do
			local item = value[i]

			if item == nil then
				is_sparse = true
			else
				if put_one then table.insert(buffer, ",") end

				if is_sparse then table.insert(buffer, ("[%d]="):format(i)) end

				add_value(buffer, strings, item)
				put_one = true
			end
		end

		table.insert(buffer, "}")
	else
		table.insert(buffer, tostring(value))
	end
end

-- Serializes check result into a string.
function cache.serialize(report)
	local strings = {}
	local buffer = {"", "return "}
	add_value(buffer, strings, compress_report(report))

	if strings[1] then
		local names = {}

		for index in ipairs(strings) do
			table.insert(names, get_local_name(index))
		end

		buffer[1] = "local " .. table.concat(names, ",") .. "=" .. table.concat(strings, ",") .. ";"
	end

	return table.concat(buffer)
end

-- Returns array of triplets of lines from cache fh.
local function read_triplets(fh)
	local res = {}

	while true do
		local filename = fh:read()

		if filename then
			local mtime = fh:read() or ""
			local cached = fh:read() or ""
			table.insert(res, {filename, mtime, cached})
		else
			break
		end
	end

	return res
end

-- Writes cache triplets into fh.
local function write_triplets(fh, triplets)
	for _, triplet in ipairs(triplets) do
		fh:write(triplet[1], "\n")
		fh:write(triplet[2], "\n")
		fh:write(triplet[3], "\n")
	end
end

-- Loads cached checking result from string, returns result or nil.
local function load_cached(cached)
	local func = utils.load(cached, {})

	if not func then return end

	local ok, res = pcall(func)

	if not ok then return end

	if type(res) == "table" then return decompress_report(res) end
end

local function check_version_header(fh)
	local first_line = fh:read()
	return (
			first_line == "" or
			first_line == "\r"
		)
		and
		tonumber(fh:read()) == cache.format_version
end

local function write_version_header(fh)
	fh:write("\n", tostring(cache.format_version), "\n")
end

-- Loads cache for filenames given mtimes from cache cache_filename.
-- Returns table mapping filenames to cached check results.
-- On corrupted cache returns nil, on version mismatch returns {}.
function cache.load(cache_filename, filenames, mtimes)
	local fh = io.open(cache_filename, "rb")

	if not fh then return {} end

	if not check_version_header(fh) then
		fh:close()
		return {}
	end

	local result = {}
	local not_yet_found = utils.array_to_set(filenames)

	while next(not_yet_found) do
		local filename = fh:read()

		if not filename then
			fh:close()
			return result
		end

		if filename:sub(-1) == "\r" then filename = filename:sub(1, -2) end

		local mtime = fh:read()
		local cached = fh:read()

		if not mtime or not cached then
			fh:close()
			return
		end

		mtime = tonumber(mtime)

		if not mtime then
			fh:close()
			return
		end

		if not_yet_found[filename] then
			if mtimes[not_yet_found[filename]] == mtime then
				result[filename] = load_cached(cached)

				if result[filename] == nil then
					fh:close()
					return
				end
			end

			not_yet_found[filename] = nil
		end
	end

	fh:close()
	return result
end

-- Updates cache at cache_filename with results for filenames.
-- Returns success flag + whether update was append-only.
function cache.update(cache_filename, filenames, mtimes, results)
	local old_triplets = {}
	local can_append = false
	local fh = io.open(cache_filename, "rb")

	if fh then
		if check_version_header(fh) then
			old_triplets = read_triplets(fh)
			can_append = true
		end

		fh:close()
	end

	local filename_set = utils.array_to_set(filenames)
	local old_filename_set = {}

	-- Update old cache for files which got a new result.
	for i, triplet in ipairs(old_triplets) do
		old_filename_set[triplet[1]] = true
		local file_index = filename_set[triplet[1]]

		if file_index then
			can_append = false
			old_triplets[i][2] = mtimes[file_index]
			old_triplets[i][3] = cache.serialize(results[file_index])
		end
	end

	local new_triplets = {}

	for _, filename in ipairs(filenames) do
		-- Use unique index (there could be duplicate filenames).
		local file_index = filename_set[filename]

		if file_index and not old_filename_set[filename] then
			table.insert(
				new_triplets,
				{
					filename,
					mtimes[file_index],
					cache.serialize(results[file_index]),
				}
			)
			-- Do not save result for this filename again.
			filename_set[filename] = nil
		end
	end

	if can_append then
		if #new_triplets > 0 then
			fh = io.open(cache_filename, "ab")

			if not fh then return false end

			write_triplets(fh, new_triplets)
			fh:close()
		end
	else
		fh = io.open(cache_filename, "wb")

		if not fh then return false end

		write_version_header(fh)
		write_triplets(fh, old_triplets)
		write_triplets(fh, new_triplets)
		fh:close()
	end

	return true, can_append
end

return cache