-- 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.

--- Install the prepared update before the normal Lx startup
-- @module core.update.apply

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

local apply = {}

--- Read one small JSON file
-- @param path string source path
-- @return table|nil value
-- @return string|nil error
local function read_json(path)
	local text, read_error = system.read_file(path, 1024 * 1024)
	if not text then
		return nil, "Could not read '" .. path .. "': " .. tostring(read_error)
	end

	local decoded_ok, value = pcall(json.decode, text)
	if not decoded_ok or type(value) ~= "table" then
		return nil, "Invalid JSON in '" .. path .. "': " .. tostring(value)
	end

	return value
end

--- Copy one file with ordinary buffered writes
-- @param source string source path
-- @param destination string destination path
-- @return boolean copied
-- @return string|nil error
local function copy_file(source, destination)
	local input, open_error = io.open(source, "rb")
	if not input then
		return false, open_error
	end
	local contents, read_error = input:read("a")
	input:close()
	if not contents then
		return false, read_error
	end

	local output, output_error = io.open(destination, "wb")
	if not output then
		return false, output_error
	end
	local written, write_error = output:write(contents)
	local closed, close_error = output:close()

	return written and closed and true or false, write_error or close_error
end

--- Create every parent directory for one safe relative path
-- @param root string root directory
-- @param relative_path string forward-slash 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, create_error
		end
	end

	return true
end

--- Move one old installed file after the previous Lx process exits
-- @param source string installed file
-- @param destination string temporary rollback file
-- @return boolean moved
-- @return string|nil error
local function move_old_file(source, destination)
	if not system.is_file(source) then
		return true
	end

	for _ = 1, 300 do
		local moved = os.rename(source, destination)
		if moved then
			return true
		end

		system.sleep(0.1)
	end

	return false, "the previous Lx process did not release the file"
end

--- Return validated manifest paths
-- @param manifest table manifest document
-- @return table|nil paths
-- @return string|nil error
local function manifest_paths(manifest)
	if manifest.format ~= 1 or type(manifest.files) ~= "table" then
		return nil, "unsupported manifest"
	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 stage._is_safe_path(path) or path == "manifest.json" then
			return nil, string.format("invalid manifest path at file %d", index)
		end
		local key = stage._path_key(path)
		if seen[key] then
			return nil, "duplicate manifest path: " .. path
		end

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

	return paths
end

--- Reject package targets that belong to neither the installed manifest nor the updater
-- @param install_directory string real Lx directory
-- @param old_paths table paths owned by the installed build
-- @param new_paths table paths requested by the pending build
-- @return boolean safe
-- @return string|nil error
local function validate_new_targets(install_directory, old_paths, new_paths)
	local owned_paths = {}
	for _, path in ipairs(old_paths) do
		owned_paths[stage._path_key(path)] = true
	end

	for _, path in ipairs(new_paths) do
		local destination = system.join_path(install_directory, path)
		if not owned_paths[stage._path_key(path)] and system.get_file_info(destination) then
			return false, "The update would replace unmanaged file '" .. path .. "'"
		end
	end

	return true
end

--- Put moved old files back after a failed installation
-- @param replaced_directory string temporary old-file root
-- @param install_directory string real Lx directory
-- @param moved_paths table moved relative paths
-- @param new_paths table pending paths to remove
-- @return boolean restored
-- @return string|nil error
local function restore_moved_files(
	replaced_directory,
	install_directory,
	moved_paths,
	new_paths
)
	for _, path in ipairs(new_paths) do
		os.remove(system.join_path(install_directory, path))
	end
	os.remove(system.join_path(install_directory, "manifest.json"))

	for index = #moved_paths, 1, -1 do
		local path = moved_paths[index]
		local parents_ok, parents_error = ensure_parent(install_directory, path)
		if not parents_ok then
			return false, "Could not recreate old directory: " .. tostring(parents_error)
		end

		os.remove(system.join_path(install_directory, path))
		local restored, restore_error = os.rename(
			system.join_path(replaced_directory, path),
			system.join_path(install_directory, path)
		)
		if not restored then
			return false, "Could not restore '" .. path .. "': " .. tostring(restore_error)
		end
	end

	system.remove_tree(replaced_directory)

	return true
end

--- Start the installed executable with the original arguments
-- @param pending table validated pending details
-- @param install_directory string real Lx directory
-- @param show_changes boolean whether a successful update may show its changelog
-- @return boolean launched
-- @return string|nil error
local function launch_installed(pending, install_directory, show_changes)
	local arguments = {}
	for _, argument in ipairs(pending.arguments or {}) do
		arguments[#arguments + 1] = argument
	end

	-- A successful update opens the installed changelog once through the restart command
	local changes_file = system.join_path(install_directory, "CHANGES.md")
	if show_changes and system.is_file(changes_file) then
		arguments[#arguments + 1] = "--preview"
		arguments[#arguments + 1] = changes_file
	end

	local executable = system.join_path(install_directory, "lx.exe")

	return system.spawn(executable, arguments)
end

--- Apply pending.json and restart the installed Lx
-- @param pending_info_file string pending JSON path
-- @return boolean success
-- @return string|nil error
function apply.run(pending_info_file)
	local pending, pending_error = read_json(pending_info_file)
	if not pending then
		return false, pending_error
	end

	local install_directory = pending.install_dir
	local pending_directory = pending.pending_dir
	local user_directory = pending.user_dir
	local update_directory = type(user_directory) == "string"
		and system.join_path(user_directory, "update")
		or ""
	local expected_pending_file = system.join_path(update_directory, "pending.zip")
	local expected_pending_directory = system.join_path(update_directory, "pending")
	local expected_info_file = system.join_path(update_directory, "pending.json")
	local backup_directory = system.join_path(update_directory, "backup")
	local backup_key = type(pending.backup_file) == "string"
		and system.path_key(pending.backup_file)
		or ""
	local backup_root_key = system.path_key(backup_directory)
	local separator = package.config:sub(1, 1)
	if pending.format ~= 1
		or type(install_directory) ~= "string"
		or type(user_directory) ~= "string"
		or type(pending.pending_file) ~= "string"
		or type(pending_directory) ~= "string"
		or type(pending.backup_file) ~= "string"
		or type(pending.arguments) ~= "table"
		or system.path_key(pending_info_file) ~= system.path_key(expected_info_file)
		or system.path_key(pending.pending_file) ~= system.path_key(expected_pending_file)
		or system.path_key(pending_directory) ~= system.path_key(expected_pending_directory)
		or system.path_key(pending_directory) ~= system.path_key(EXE_DIR)
		or backup_key:sub(1, #backup_root_key + 1) ~= backup_root_key .. separator
	then
		return false, "The pending update paths are invalid"
	end

	local old_manifest, old_error = read_json(
		system.join_path(install_directory, "manifest.json")
	)
	if not old_manifest then
		return false, old_error
	end
	-- Recheck prepared bytes immediately before any installed file is moved
	local new_manifest, new_error = stage.verify_pending(pending_directory)
	if not new_manifest then
		return false, new_error
	end

	local old_paths, old_paths_error = manifest_paths(old_manifest)
	if not old_paths then
		return false, old_paths_error
	end
	local new_paths, new_paths_error = manifest_paths(new_manifest)
	if not new_paths then
		return false, new_paths_error
	end
	local targets_safe, target_error = validate_new_targets(
		install_directory,
		old_paths,
		new_paths
	)
	if not targets_safe then
		return false, target_error
	end

	local replaced_directory = system.join_path(
		update_directory,
		"replaced"
	)
	if system.is_dir(replaced_directory) then
		return false, "An unfinished update remains at " .. replaced_directory
	end
	local replaced_created, replaced_error = system.mkdir(replaced_directory)
	if not replaced_created then
		return false, "Could not create temporary rollback directory: " .. tostring(replaced_error)
	end
	local moved_paths = {}

	--- Replace the old manifest files with the prepared build
	-- @return boolean installed
	-- @return string|nil error
	local function install_files()
		system.sleep(0.5)

		for _, path in ipairs(old_paths) do
			local parents_ok, parents_error = ensure_parent(replaced_directory, path)
			if not parents_ok then
				return false, "Could not prepare old file '" .. path .. "': " .. tostring(parents_error)
			end

			local source = system.join_path(install_directory, path)
			local existed = system.is_file(source)
			local moved, move_error = move_old_file(
				source,
				system.join_path(replaced_directory, path)
			)
			if not moved then
				return false, "Could not move old file '" .. path .. "': " .. tostring(move_error)
			end
			if existed then
				moved_paths[#moved_paths + 1] = path
			end
		end

		local old_manifest_path = system.join_path(install_directory, "manifest.json")
		local moved_manifest, manifest_move_error = move_old_file(
			old_manifest_path,
			system.join_path(replaced_directory, "manifest.json")
		)
		if not moved_manifest then
			return false, "Could not move old manifest.json: " .. tostring(manifest_move_error)
		end
		moved_paths[#moved_paths + 1] = "manifest.json"

		for _, path in ipairs(new_paths) do
			local parents_ok, parents_error = ensure_parent(install_directory, path)
			if not parents_ok then
				return false, "Could not create directory for '" .. path .. "': " .. tostring(parents_error)
			end

			local copied, copy_error = copy_file(
				system.join_path(pending_directory, path),
				system.join_path(install_directory, path)
			)
			if not copied then
				return false, "Could not install '" .. path .. "': " .. tostring(copy_error)
			end
		end

		local copied_manifest, manifest_error = copy_file(
			system.join_path(pending_directory, "manifest.json"),
			system.join_path(install_directory, "manifest.json")
		)
		if not copied_manifest then
			return false, "Could not install manifest.json: " .. tostring(manifest_error)
		end

		return true
	end

	local installed, install_error = install_files()
	if not installed then
		local restored, restore_error = restore_moved_files(
			replaced_directory,
			install_directory,
			moved_paths,
			new_paths
		)
		if restored then
			local relaunched, relaunch_error = launch_installed(pending, install_directory, false)
			local relaunch_message = relaunched
				and ""
				or "\n\nThe restored Lx could not be restarted: " .. tostring(relaunch_error)

			return false, tostring(install_error)
				.. "\n\nThe previous build was restored from backup."
				.. relaunch_message
		end

		-- Preserve any old files left in replaced if rollback could not finish
		os.remove(pending_info_file)

		return false, tostring(install_error)
			.. "\n\nRollback also failed: "
			.. tostring(restore_error)
			.. "\n\nBackup: "
			.. tostring(pending.backup_file)
	end

	-- The restarted Lx retries this cleanup if Windows still has a file open
	system.remove_tree(replaced_directory)
	os.remove(pending.pending_file)

	local launched, launch_error = launch_installed(pending, install_directory, true)
	if not launched then
		return false, "Could not restart updated Lx: " .. tostring(launch_error)
	end

	return true
end

-- Expose restart construction to focused updater tests
apply._launch_installed = launch_installed

return apply
