--- Create complete rollback archives without touching the installed build
-- @module core.update.backup

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

local backup = {}

local MANIFEST_MAX_SIZE = 16 * 1024 * 1024
local excluded_user_directories = {
	cache = true,
	temp = true,
	update = true,
}

--- Return whether one manifest path is a safe relative archive path
-- @param path any candidate path
-- @return boolean safe
local function is_safe_relative_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
	for component in path:gmatch("[^/]+") do
		if component == "." or component == ".." then
			return false
		end
	end

	return true
end

--- Read and validate the packager-owned installed manifest
-- @param install_directory string current Lx directory
-- @return table|nil manifest
-- @return string|nil manifest_path_or_error
local function read_manifest(install_directory)
	local manifest_path = system.join_path(install_directory, "manifest.json")
	if not system.is_file(manifest_path) then
		return nil, "Installed manifest is missing: " .. manifest_path
	end

	local manifest_text, read_error = system.read_file(manifest_path, MANIFEST_MAX_SIZE)
	if not manifest_text then
		return nil, string.format(
			"Could not read installed manifest '%s': %s",
			manifest_path,
			tostring(read_error or "unknown file error")
		)
	end

	local decoded_ok, manifest = pcall(json.decode, manifest_text)
	if not decoded_ok then
		return nil, string.format(
			"Installed manifest '%s' is not valid JSON: %s",
			manifest_path,
			tostring(manifest)
		)
	end
	if type(manifest) ~= "table" then
		return nil, "Installed manifest root is not an object: " .. manifest_path
	end
	if manifest.format ~= 1 then
		return nil, "Installed manifest format is unsupported: " .. manifest_path
	end
	-- Pattern breakdown:
	--   eight date digits, one hyphen, and four time digits identify a packaged build
	if type(manifest.build) ~= "string"
		or not manifest.build:match("^%d%d%d%d%d%d%d%d%-%d%d%d%d$")
	then
		return nil, "Installed manifest build is invalid: " .. manifest_path
	end
	if type(manifest.files) ~= "table" or #manifest.files == 0 then
		return nil, "Installed manifest has no files: " .. manifest_path
	end

	local paths = {}
	local seen = {}
	for index, file in ipairs(manifest.files) do
		local path = type(file) == "table" and file.path or nil
		if not is_safe_relative_path(path) then
			return nil, string.format(
				"Installed manifest '%s' file %d has an unsafe path",
				manifest_path,
				index
			)
		end
		if seen[path] then
			return nil, string.format(
				"Installed manifest '%s' contains a duplicate path: %s",
				manifest_path,
				path
			)
		end

		seen[path] = true
		paths[#paths + 1] = path
	end

	return {
		build = manifest.build,
		document = manifest,
		paths = paths,
	}, manifest_path
end

--- Collect regular user files while avoiding links and updater-owned directories
-- @param user_directory string current user directory
-- @return table|nil files
-- @return string|nil error
local function collect_user_files(user_directory)
	local files = {}

	--- Walk one real directory below the user root
	-- @param directory string native directory path
	-- @param relative_directory string forward-slash path inside the backup
	-- @return boolean success
	-- @return string|nil error
	local function walk(directory, relative_directory)
		local names, list_error = system.list_dir(directory)
		if not names then
			return false, string.format(
				"Could not list user directory '%s': %s",
				directory,
				tostring(list_error or "unknown directory error")
			)
		end
		table.sort(names)

		for _, name in ipairs(names) do
			local relative_path = relative_directory == ""
				and name
				or relative_directory .. "/" .. name
			local native_path = system.join_path(directory, name)
			local info, info_error = system.get_file_info(native_path)
			if not info then
				return false, string.format(
					"Could not inspect user path '%s': %s",
					native_path,
					tostring(info_error or "unknown file error")
				)
			end

			local excluded_root = relative_directory == ""
				and excluded_user_directories[name:lower()] == true
			if not excluded_root and not info.is_link then
				if info.type == "dir" then
					local walked, walk_error = walk(native_path, relative_path)
					if not walked then
						return false, walk_error
					end
				elseif info.type == "file" then
					files[#files + 1] = {
						path = native_path,
						entry = "user/" .. relative_path,
					}
				end
			end
		end

		return true
	end

	local collected, collect_error = walk(user_directory, "")
	if not collected then
		return nil, collect_error
	end

	return files
end

--- Build backup metadata while retaining the complete installed manifest
-- @param manifest table validated installed manifest
-- @param install_directory string current Lx directory
-- @param user_directory string current user directory
-- @param created string UTC creation time
-- @return string json_text
local function build_metadata(
	manifest,
	install_directory,
	user_directory,
	created
)
	return json.encode({
		format = 1,
		build = manifest.build,
		created = created,
		install_directory = install_directory,
		user_directory = user_directory,
		installed_manifest = manifest.document,
	}, { pretty = true }) .. "\n"
end

--- Create directories used for backup archives
-- @param user_directory string current user directory
-- @return string|nil backup_directory
-- @return string|nil error
local function ensure_backup_directory(user_directory)
	local update_directory = system.join_path(user_directory, "update")
	local backup_directory = system.join_path(update_directory, "backup")

	for _, directory in ipairs({ update_directory, backup_directory }) do
		local created, create_error = system.mkdir(directory)
		if not created or not system.is_dir(directory) then
			return nil, string.format(
				"Could not create backup directory '%s': %s",
				directory,
				tostring(create_error or "path is not a directory")
			)
		end
	end

	return backup_directory
end

--- Choose a new backup name without replacing an earlier archive
-- @param directory string backup directory
-- @param build string installed build
-- @param timestamp string filename-safe UTC time
-- @return string path
local function choose_backup_path(directory, build, timestamp)
	local stem = build .. "-" .. timestamp
	local path = system.join_path(directory, stem .. ".zip")
	local suffix = 2

	while system.is_file(path) do
		path = system.join_path(directory, stem .. "-" .. suffix .. ".zip")
		suffix = suffix + 1
	end

	return path
end

--- Create one complete backup using explicit paths and times
-- @param options table backup inputs
-- @param progress function|nil called after every archived file
-- @return table|nil result
-- @return string|nil error
local function create_backup(options, progress)
	local install_directory = options.install_directory
	local user_directory = options.user_directory
	local manifest, manifest_path_or_error = read_manifest(install_directory)
	if not manifest then
		return nil, manifest_path_or_error
	end
	local manifest_path = manifest_path_or_error

	local user_files, user_error = collect_user_files(user_directory)
	if not user_files then
		return nil, user_error
	end

	local backup_directory, directory_error = ensure_backup_directory(user_directory)
	if not backup_directory then
		return nil, directory_error
	end

	local backup_path = choose_backup_path(
		backup_directory,
		manifest.build,
		options.timestamp
	)
	local writer, writer_error = pack.zip.open_file_create(backup_path)
	if not writer then
		return nil, string.format(
			"Could not create backup archive '%s': %s",
			backup_path,
			tostring(writer_error or "unknown ZIP error")
		)
	end

	local user_entries = {}
	for _, file in ipairs(user_files) do
		user_entries[file.entry] = true
	end

	local installed_count = 1
	for _, relative_path in ipairs(manifest.paths) do
		-- The live user copy is the authoritative backup when package and user paths overlap
		if relative_path ~= "manifest.json" and not user_entries[relative_path] then
			installed_count = installed_count + 1
		end
	end

	local total_count = installed_count + #user_files + 1
	local completed_count = 0

	--- Report one completed archive entry
	-- @param group string entry group
	-- @param entry string archive path
	local function report(group, entry)
		completed_count = completed_count + 1
		if progress then
			progress(group, entry, completed_count, total_count)
		end
	end

	--- Add one source file and retain a clear path in its failure
	-- @param entry string archive path
	-- @param source string native source path
	-- @param group string entry group
	-- @return boolean success
	-- @return string|nil error
	local function add_file(entry, source, group)
		local added, add_error = writer:add_file(entry, source)
		if not added then
			return false, string.format(
				"Could not add '%s' from '%s': %s",
				entry,
				source,
				tostring(add_error or "unknown ZIP error")
			)
		end

		report(group, entry)

		return true
	end

	local write_ok, result_or_error, returned_error = xpcall(function()
		local metadata = build_metadata(
			manifest,
			install_directory,
			user_directory,
			options.created
		)
		local metadata_added, metadata_error = writer:add_memory("backup.json", metadata)
		if not metadata_added then
			return nil, "Could not add 'backup.json': "
				.. tostring(metadata_error or "unknown ZIP error")
		end
		report("metadata", "backup.json")

		local manifest_added, manifest_error = add_file(
			"manifest.json",
			manifest_path,
			"installed"
		)
		if not manifest_added then
			return nil, manifest_error
		end

		for _, relative_path in ipairs(manifest.paths) do
			if relative_path ~= "manifest.json" and not user_entries[relative_path] then
				local source_path = system.join_path(install_directory, relative_path)
				local info, info_error = system.get_file_info(source_path)
				if not info or info.type ~= "file" or info.is_link then
					return nil, string.format(
						"Installed file is unavailable '%s': %s",
						source_path,
						tostring(info_error or "not a regular local file")
					)
				end

				local added, add_error = add_file(
					relative_path,
					source_path,
					"installed"
				)
				if not added then
					return nil, add_error
				end
			end
		end

		for _, file in ipairs(user_files) do
			local added, add_error = add_file(file.entry, file.path, "user")
			if not added then
				return nil, add_error
			end
		end

		local closed, close_error = writer:close()
		if not closed then
			return nil, string.format(
				"Could not finalize backup archive '%s': %s",
				backup_path,
				tostring(close_error or "unknown ZIP error")
			)
		end

		return {
			path = backup_path,
			build = manifest.build,
			installed_files = installed_count,
			user_files = #user_files,
			total_files = total_count,
		}
	end, debug.traceback)

	if not write_ok then
		writer:discard()

		return nil, result_or_error
	end
	if not result_or_error then
		writer:discard()

		return nil, returned_error or "backup creation failed"
	end

	return result_or_error
end

--- Create one backup of the running installation and its durable user data
-- @param progress function|nil called after every archived file
-- @return table|nil result
-- @return string|nil error
function backup.create(progress)
	return create_backup({
		install_directory = EXE_DIR,
		user_directory = USER_DIR,
		timestamp = os.date("!%Y%m%d-%H%M%S"),
		created = os.date("!%Y-%m-%dT%H:%M:%SZ"),
	}, progress)
end

backup._create = create_backup
backup._is_safe_relative_path = is_safe_relative_path

return backup
