-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Check and unpack one pending alpha update without changing installed files
-- @module core.update.stage

local json = require("core.lib.json")

local stage = {}

local ARCHIVE_PREFIX = "lx-alpha/"
local MANIFEST_ENTRY = ARCHIVE_PREFIX .. "manifest.json"
local MAX_FILE_SIZE = 64 * 1024 * 1024
local MAX_TOTAL_SIZE = 256 * 1024 * 1024
local WINDOWS_RESERVED_NAMES = {
	aux = true,
	con = true,
	["conin$"] = true,
	["conout$"] = true,
	nul = true,
	prn = true,
	com1 = true,
	com2 = true,
	com3 = true,
	com4 = true,
	com5 = true,
	com6 = true,
	com7 = true,
	com8 = true,
	com9 = true,
	lpt1 = true,
	lpt2 = true,
	lpt3 = true,
	lpt4 = true,
	lpt5 = true,
	lpt6 = true,
	lpt7 = true,
	lpt8 = true,
	lpt9 = true,
}

--- Return the portable comparison key for one safe package path
-- ASCII case stays portable while native comparison catches Unicode Windows aliases
-- @param path string safe relative path
-- @return string key
local function path_key(path)
	return system.path_key(path:lower())
end

--- Return whether one manifest path is safe below the pending directory
-- @param path any candidate path
-- @return boolean safe
local function is_safe_path(path)
	if type(path) ~= "string"
		or path == ""
		or path:find("\0", 1, true)
		or path:find("\\", 1, true)
		or path:find(":", 1, true)
		or path:sub(1, 1) == "/"
		or path:sub(-1) == "/"
		or path:find("//", 1, true)
	then
		return false
	end

	-- Pattern breakdown:
	--   each run without a forward slash is one path component
	--   ^([^.]+) captures a possible device name before its first dot
	--   [%. ]$ catches a final dot or space that Windows removes during lookup
	for component in path:gmatch("[^/]+") do
		local lower_component = component:lower()
		local device_name = lower_component:match("^([^.]+)")
		if component == "."
			or component == ".."
			or component:match("[%. ]$")
			or WINDOWS_RESERVED_NAMES[device_name]
		then
			return false
		end
	end

	return true
end

--- Validate one decoded update manifest
-- @param manifest any decoded value
-- @return boolean valid
-- @return string|nil error
local function validate_manifest(manifest)
	if type(manifest) ~= "table" then
		return false, "The update manifest is not an object"
	end
	if type(manifest.format) ~= "number" or manifest.format % 1 ~= 0 then
		return false, "The update manifest format is missing"
	end
	if type(manifest.build) ~= "string"
		or not manifest.build:match("^%d%d%d%d%d%d%d%d%-%d%d%d%d$")
	then
		return false, "The update manifest build is invalid"
	end
	if type(manifest.files) ~= "table" or #manifest.files == 0 then
		return false, "The update manifest has no files"
	end

	local seen = {}
	for index, file in ipairs(manifest.files) do
		if type(file) ~= "table" or not is_safe_path(file.path) then
			return false, string.format("The update manifest file %d has an unsafe path", index)
		end
		local key = path_key(file.path)
		if key == "manifest.json" or seen[key] then
			return false, "The update manifest has a duplicate path: " .. file.path
		end
		if type(file.size) ~= "number"
			or file.size < 0
			or file.size % 1 ~= 0
			or file.size > MAX_FILE_SIZE
		then
			return false, "The update manifest has an invalid size: " .. file.path
		end
		if type(file.sha256) ~= "string"
			or #file.sha256 ~= 64
			or not file.sha256:match("^[0-9a-fA-F]+$")
		then
			return false, "The update manifest has an invalid hash: " .. file.path
		end

		file.sha256 = file.sha256:lower()
		seen[key] = true
	end

	return true
end

--- Read and validate the packaged manifest
-- @param archive userdata opened pending archive
-- @return table|nil manifest
-- @return string|nil manifest_text_or_error
local function read_manifest(archive)
	local text, read_error = archive:read(MANIFEST_ENTRY)
	if not text then
		return nil, "The update has no manifest.json: " .. tostring(read_error)
	end

	local decoded_ok, manifest = pcall(json.decode, text)
	if not decoded_ok then
		return nil, "The update manifest is not valid JSON: " .. tostring(manifest)
	end
	local valid, validation_error = validate_manifest(manifest)
	if not valid then
		return nil, validation_error
	end

	return manifest, text
end

--- Check that the ZIP contains exactly the manifest files
-- @param archive userdata opened pending archive
-- @param manifest table validated manifest
-- @return boolean valid
-- @return string|nil error
local function check_entries(archive, manifest)
	local expected = { [MANIFEST_ENTRY] = true }
	for _, file in ipairs(manifest.files) do
		expected[ARCHIVE_PREFIX .. file.path] = true
	end

	if archive:count() ~= #manifest.files + 1 then
		return false, "The update ZIP contains unexpected files"
	end

	for index = 1, archive:count() do
		local info, info_error = archive:info(index)
		if not info then
			return false, "The update ZIP could not be inspected: " .. tostring(info_error)
		end
		if not expected[info.name]
			or info.directory
			or info.encrypted
			or not info.supported
		then
			return false, "The update ZIP contains an invalid file: " .. tostring(info.name)
		end
	end

	return true
end

--- Create every parent directory for one pending relative path
-- @param root string pending root
-- @param relative_path string safe forward-slash file path
-- @return boolean created
-- @return string|nil error
local function ensure_parent(root, relative_path)
	local directory = root

	-- Pattern breakdown:
	--   each component followed by a slash is one required parent directory
	for component in relative_path:gmatch("([^/]+)/") do
		directory = system.join_path(directory, component)
		local created, create_error = system.mkdir(directory)
		if not created then
			return false, string.format(
				"Could not create pending directory '%s': %s",
				directory,
				tostring(create_error)
			)
		end
	end

	return true
end

--- Write one pending file without the durable flush used for user saves
-- The final manifest marks a complete pending directory
-- @param path string destination path
-- @param contents string file bytes
-- @return boolean written
-- @return string|nil error
function stage._write_file(path, contents)
	local file, open_error = io.open(path, "wb")
	if not file then
		return false, open_error
	end

	local written, write_error = file:write(contents)
	local closed, close_error = file:close()
	if not written then
		return false, write_error
	end
	if not closed then
		return false, close_error
	end

	return true
end

--- Check and unpack one pending update
-- @param pending_path string downloaded ZIP path
-- @param pending_directory string fixed output directory
-- @param progress function|nil called after every pending file
-- @return table|nil result
-- @return string|nil error
function stage.unpack(pending_path, pending_directory, progress)
	local archive, open_error = pack.zip.open_file_read(pending_path, {
		max_entry_size = MAX_FILE_SIZE,
		max_total_size = MAX_TOTAL_SIZE,
	})
	if not archive then
		return nil, "Could not open the pending update: " .. tostring(open_error)
	end

	local function finish(result, finish_error)
		archive:close()

		return result, finish_error
	end

	local manifest, manifest_text_or_error = read_manifest(archive)
	if not manifest then
		return finish(nil, manifest_text_or_error)
	end
	local manifest_text = manifest_text_or_error

	local entries_ok, entries_error = check_entries(archive, manifest)
	if not entries_ok then
		return finish(nil, entries_error)
	end

	local checked_files = {}
	for _, file in ipairs(manifest.files) do
		local entry = ARCHIVE_PREFIX .. file.path
		local info, info_error = archive:info(entry)
		if not info or info.uncompressed_size ~= file.size then
			return finish(nil, string.format(
				"Update file size does not match for '%s': %s",
				file.path,
				tostring(info_error or "wrong size")
			))
		end

		local contents, read_error = archive:read(entry)
		if not contents then
			return finish(nil, "Could not read update file '" .. file.path .. "': " .. tostring(read_error))
		end
		if system.sha256(contents) ~= file.sha256 then
			return finish(nil, "Update file hash does not match for '" .. file.path .. "'")
		end
		checked_files[#checked_files + 1] = {
			path = file.path,
			contents = contents,
		}
	end

	local removed, remove_error = system.remove_tree(pending_directory)
	if not removed then
		return finish(nil, "Could not clear the pending update directory: " .. tostring(remove_error))
	end

	local created, create_error = system.mkdir(pending_directory)
	if not created then
		return finish(nil, "Could not create the pending update directory: " .. tostring(create_error))
	end

	for index, file in ipairs(checked_files) do
		local parents_ok, parents_error = ensure_parent(pending_directory, file.path)
		if not parents_ok then
			return finish(nil, parents_error)
		end

		local output_path = system.join_path(pending_directory, file.path)
		local written, write_error = stage._write_file(output_path, file.contents)
		if not written then
			return finish(nil, "Could not stage update file '" .. output_path .. "': " .. tostring(write_error))
		end

		if progress then
			progress(file.path, index, #manifest.files)
		end
	end

	local manifest_path = system.join_path(pending_directory, "manifest.json")
	local manifest_written, manifest_error = system.write_file_atomic(manifest_path, manifest_text)
	if not manifest_written then
		return finish(nil, "Could not finish the pending update: " .. tostring(manifest_error))
	end

	archive:close()
	local archive_removed, archive_remove_error = os.remove(pending_path)
	if not archive_removed then
		return nil, "The update was unpacked, but pending.zip could not be removed: "
			.. tostring(archive_remove_error)
	end

	return {
		format = manifest.format,
		build = manifest.build,
		path = pending_directory,
		files = #manifest.files,
		manifest = manifest,
	}
end

--- Validate the manifest and every file in one prepared update directory
-- @param pending_directory string pending build directory
-- @return table|nil manifest
-- @return string|nil error
function stage.verify_pending(pending_directory)
	local manifest_path = system.join_path(pending_directory, "manifest.json")
	local text, read_error = system.read_file(manifest_path, 1024 * 1024)
	if not text then
		return nil, "Could not read the pending manifest: " .. tostring(read_error)
	end

	local decoded_ok, manifest = pcall(json.decode, text)
	if not decoded_ok then
		return nil, "The pending manifest is invalid: " .. tostring(manifest)
	end
	local valid, validation_error = validate_manifest(manifest)
	if not valid then
		return nil, validation_error
	end

	for _, file in ipairs(manifest.files) do
		local path = system.join_path(pending_directory, file.path)
		local info, info_error = system.get_file_info(path)
		if not info or info.type ~= "file" or info.is_link or info.size ~= file.size then
			return nil, "Prepared update file size does not match for '"
				.. file.path .. "': " .. tostring(info_error or "wrong size")
		end

		local contents, file_error = system.read_file(path, file.size + 1)
		if not contents or #contents ~= file.size then
			return nil, "Could not read prepared update file '"
				.. file.path .. "': " .. tostring(file_error or "wrong size")
		end
		if system.sha256(contents) ~= file.sha256 then
			return nil, "Prepared update file hash does not match for '" .. file.path .. "'"
		end
	end

	return manifest
end

--- Read the one complete pending build
-- @param pending_directory string pending build directory
-- @return table|nil result
-- @return string|nil error
function stage.find_pending(pending_directory)
	local manifest_path = system.join_path(pending_directory, "manifest.json")
	if not system.is_file(manifest_path) then
		return nil, "No prepared update was found"
	end

	local manifest, verify_error = stage.verify_pending(pending_directory)
	if not manifest then
		return nil, verify_error
	end

	return {
		format = manifest.format,
		build = manifest.build,
		path = pending_directory,
		files = #manifest.files,
		manifest = manifest,
	}
end

--- Remove the pending directory after the updated Lx starts
-- @param pending_directory string pending build directory
-- @return boolean removed
-- @return string|nil error
function stage.cleanup(pending_directory)
	return system.remove_tree(pending_directory)
end

stage._is_safe_path = is_safe_path
stage._path_key = path_key
stage._validate_manifest = validate_manifest

return stage
