/*  PCSX2 - PS2 Emulator for PCs
 *  Copyright (C) 2002-2022  PCSX2 Dev Team
 *
 *  PCSX2 is free software: you can redistribute it and/or modify it under the terms
 *  of the GNU Lesser General Public License as published by the Free Software Found-
 *  ation, either version 3 of the License, or (at your option) any later version.
 *
 *  PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 *  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
 *  PURPOSE.  See the GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License along with PCSX2.
 *  If not, see <http://www.gnu.org/licenses/>.
 */

#include "Updater.h"

#include "common/Console.h"
#include "common/FileSystem.h"
#include "common/Path.h"
#include "common/ScopedGuard.h"
#include "common/StringUtil.h"

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <memory>
#include <set>
#include <string>
#include <vector>

#ifdef _WIN32
#include <shellapi.h>
#endif

#ifdef _WIN32

#include "7zAlloc.h"
#include "7zCrc.h"
#include "SZErrors.h"

static constexpr size_t kInputBufSize = ((size_t)1 << 18);
static constexpr ISzAlloc g_Alloc = {SzAlloc, SzFree};
#endif

static std::FILE* s_file_console_stream;
static constexpr IConsoleWriter s_file_console_writer = {
	[](const char* fmt) { // WriteRaw
		std::fputs(fmt, s_file_console_stream);
		std::fflush(s_file_console_stream);
	},
	[](const char* fmt) { // DoWriteLn
		std::fputs(fmt, s_file_console_stream);
		std::fputc('\n', s_file_console_stream);
		std::fflush(s_file_console_stream);
	},
	[](ConsoleColors) { // DoSetColor
	},
	[](const char* fmt) { // DoWriteFromStdout
		std::fputs(fmt, s_file_console_stream);
		std::fflush(s_file_console_stream);
	},
	[]() { // Newline
		std::fputc('\n', s_file_console_stream);
		std::fflush(s_file_console_stream);
	},
	[](const char*) { // SetTitle
	}};

static void CloseConsoleFile()
{
	if (s_file_console_stream)
		std::fclose(s_file_console_stream);
}

Updater::Updater(ProgressCallback* progress)
	: m_progress(progress)
{
	progress->SetTitle("PCSX2 Update Installer");
}

Updater::~Updater()
{
	CloseUpdateZip();
}

void Updater::SetupLogging(ProgressCallback* progress, const std::string& destination_directory)
{
	const std::string log_path(Path::Combine(destination_directory, "updater.log"));
	s_file_console_stream = FileSystem::OpenCFile(log_path.c_str(), "w");
	if (!s_file_console_stream)
	{
		progress->DisplayFormattedModalError("Failed to open log file '%s'", log_path.c_str());
		return;
	}

	Console_SetActiveHandler(s_file_console_writer);
	std::atexit(CloseConsoleFile);
}

bool Updater::Initialize(std::string destination_directory)
{
	m_destination_directory = std::move(destination_directory);
	m_staging_directory = StringUtil::StdStringFromFormat("%s" FS_OSPATH_SEPARATOR_STR "%s",
		m_destination_directory.c_str(), "UPDATE_STAGING");
	m_progress->DisplayFormattedInformation("Destination directory: '%s'", m_destination_directory.c_str());
	m_progress->DisplayFormattedInformation("Staging directory: '%s'", m_staging_directory.c_str());
	return true;
}

bool Updater::OpenUpdateZip(const char* path)
{
#ifdef _WIN32
	FileInStream_CreateVTable(&m_archive_stream);
	LookToRead2_CreateVTable(&m_look_stream, False);
	CrcGenerateTable();

	m_zip_path = path;

	m_look_stream.buf = (Byte*)ISzAlloc_Alloc(&g_Alloc, kInputBufSize);
	if (!m_look_stream.buf)
	{
		m_progress->DisplayFormattedError("Failed to allocate input buffer?!");
		return false;
	}

	m_look_stream.bufSize = kInputBufSize;
	m_look_stream.realStream = &m_archive_stream.vt;
	LookToRead2_Init(&m_look_stream);

#ifdef _WIN32
	WRes wres = InFile_OpenW(&m_archive_stream.file, StringUtil::UTF8StringToWideString(path).c_str());
#else
	WRes wres = InFile_Open(&m_archive_stream.file, path);
#endif
	if (wres != 0)
	{
		m_progress->DisplayFormattedModalError("Failed to open '%s': %d", path, wres);
		return false;
	}

	m_file_opened = true;
	SzArEx_Init(&m_archive);

	SRes res = SzArEx_Open(&m_archive, &m_look_stream.vt, &g_Alloc, &g_Alloc);
	if (res != SZ_OK)
	{
		m_progress->DisplayFormattedModalError("SzArEx_Open() failed: %s [%d]", SZErrorToString(res), res);
		return false;
	}

	m_archive_opened = true;
	m_progress->SetStatusText("Parsing update zip...");
	return ParseZip();
#else
	return false;
#endif
}

void Updater::CloseUpdateZip()
{
#ifdef _WIN32
	if (m_archive_opened)
	{
		SzArEx_Free(&m_archive, &g_Alloc);
		m_archive_opened = false;
	}

	if (m_look_stream.buf)
	{
		ISzAlloc_Free(&g_Alloc, m_look_stream.buf);
		m_look_stream.buf = nullptr;
	}

	if (m_file_opened)
	{
		File_Close(&m_archive_stream.file);
		m_file_opened = false;
	}
#endif
}

bool Updater::RecursiveDeleteDirectory(const char* path)
{
#ifdef _WIN32
	// making this safer on Win32...
	std::wstring wpath(StringUtil::UTF8StringToWideString(path));
	wpath += L'\0';

	SHFILEOPSTRUCTW op = {};
	op.wFunc = FO_DELETE;
	op.pFrom = wpath.c_str();
	op.fFlags = FOF_NOCONFIRMATION;

	return (SHFileOperationW(&op) == 0 && !op.fAnyOperationsAborted);
#else
	return FileSystem::RecursiveDeleteDirectory(path);
#endif
}

bool Updater::ParseZip()
{
#ifdef _WIN32
	std::vector<UInt16> filename_buffer;

	for (u32 file_index = 0; file_index < m_archive.NumFiles; file_index++)
	{
		// skip directories, we handle them ourselves
		if (SzArEx_IsDir(&m_archive, file_index))
			continue;

		size_t filename_len = SzArEx_GetFileNameUtf16(&m_archive, file_index, nullptr);
		if (filename_len <= 1)
			continue;

		filename_buffer.resize(filename_len);
		SzArEx_GetFileNameUtf16(&m_archive, file_index, filename_buffer.data());

		// TODO: This won't work on Linux (4-byte wchar_t).
		FileToUpdate entry;
		entry.file_index = file_index;
		entry.destination_filename = StringUtil::WideStringToUTF8String(reinterpret_cast<wchar_t*>(filename_buffer.data()));
		if (entry.destination_filename.empty())
			continue;

		// replace forward slashes with backslashes
		for (size_t i = 0; i < entry.destination_filename.length(); i++)
		{
			if (entry.destination_filename[i] == '/' || entry.destination_filename[i] == '\\')
				entry.destination_filename[i] = FS_OSPATH_SEPARATOR_CHARACTER;
		}

		// should never have a leading slash. just in case.
		while (entry.destination_filename[0] == FS_OSPATH_SEPARATOR_CHARACTER)
			entry.destination_filename.erase(0, 1);

		// skip directories (we sort them out later)
		if (!entry.destination_filename.empty() && entry.destination_filename.back() != FS_OSPATH_SEPARATOR_CHARACTER)
		{
			// skip updater itself, since it was already pre-extracted.
			// also skips portable.ini to not mess with future non-portable installs.
			if (StringUtil::Strcasecmp(entry.destination_filename.c_str(), "updater.exe") != 0 &&
				StringUtil::Strcasecmp(entry.destination_filename.c_str(), "portable.ini") != 0)
			{
				m_progress->DisplayFormattedInformation("Found file in zip: '%s'", entry.destination_filename.c_str());
				m_update_paths.push_back(std::move(entry));
			}
		}
	}

	if (m_update_paths.empty())
	{
		m_progress->ModalError("No files found in update zip.");
		return false;
	}

	for (const FileToUpdate& ftu : m_update_paths)
	{
		const size_t len = ftu.destination_filename.length();
		for (size_t i = 0; i < len; i++)
		{
			if (ftu.destination_filename[i] == FS_OSPATH_SEPARATOR_CHARACTER)
			{
				std::string dir(ftu.destination_filename.begin(), ftu.destination_filename.begin() + i);
				while (!dir.empty() && dir[dir.length() - 1] == FS_OSPATH_SEPARATOR_CHARACTER)
					dir.erase(dir.length() - 1);

				if (std::find(m_update_directories.begin(), m_update_directories.end(), dir) == m_update_directories.end())
					m_update_directories.push_back(std::move(dir));
			}
		}
	}

	std::sort(m_update_directories.begin(), m_update_directories.end());
	for (const std::string& dir : m_update_directories)
		m_progress->DisplayFormattedDebugMessage("Directory: %s", dir.c_str());

	return true;
#else
	return false;
#endif
}

bool Updater::PrepareStagingDirectory()
{
	if (FileSystem::DirectoryExists(m_staging_directory.c_str()))
	{
		m_progress->DisplayFormattedWarning("Update staging directory already exists, removing");
		if (!RecursiveDeleteDirectory(m_staging_directory.c_str()) ||
			FileSystem::DirectoryExists(m_staging_directory.c_str()))
		{
			m_progress->ModalError("Failed to remove old staging directory");
			return false;
		}
	}
	if (!FileSystem::CreateDirectoryPath(m_staging_directory.c_str(), false))
	{
		m_progress->DisplayFormattedModalError("Failed to create staging directory %s", m_staging_directory.c_str());
		return false;
	}

	// create subdirectories in staging directory
	for (const std::string& subdir : m_update_directories)
	{
		m_progress->DisplayFormattedInformation("Creating subdirectory in staging: %s", subdir.c_str());

		const std::string staging_subdir =
			StringUtil::StdStringFromFormat("%s" FS_OSPATH_SEPARATOR_STR "%s", m_staging_directory.c_str(), subdir.c_str());
		if (!FileSystem::CreateDirectoryPath(staging_subdir.c_str(), false))
		{
			m_progress->DisplayFormattedModalError("Failed to create staging subdirectory %s", staging_subdir.c_str());
			return false;
		}
	}

	return true;
}

bool Updater::StageUpdate()
{
	m_progress->SetProgressRange(static_cast<u32>(m_update_paths.size()));
	m_progress->SetProgressValue(0);

#ifdef _WIN32
	UInt32 block_index = 0xFFFFFFFF; /* it can have any value before first call (if outBuffer = 0) */
	Byte* out_buffer = 0; /* it must be 0 before first call for each new archive. */
	size_t out_buffer_size = 0; /* it can have any value before first call (if outBuffer = 0) */
	ScopedGuard out_buffer_guard([&out_buffer]() {
		if (out_buffer)
			ISzAlloc_Free(&g_Alloc, out_buffer);
	});

	for (const FileToUpdate& ftu : m_update_paths)
	{
		m_progress->SetFormattedStatusText("Extracting '%s'...", ftu.destination_filename.c_str());
		m_progress->DisplayFormattedInformation("Decompressing '%s'...", ftu.destination_filename.c_str());

		size_t out_offset = 0;
		size_t extracted_size = 0;
		SRes res = SzArEx_Extract(&m_archive, &m_look_stream.vt, ftu.file_index,
			&block_index, &out_buffer, &out_buffer_size, &out_offset, &extracted_size, &g_Alloc, &g_Alloc);
		if (res != SZ_OK)
		{
			m_progress->DisplayFormattedModalError("Failed to decompress file '%s' from 7z (file index=%u, error=%s)",
				ftu.destination_filename.c_str(), ftu.file_index, SZErrorToString(res));
			return false;
		}

		m_progress->DisplayFormattedInformation("Writing '%s' to staging (%zu bytes)...", ftu.destination_filename.c_str(), extracted_size);

		const std::string destination_file = StringUtil::StdStringFromFormat(
			"%s" FS_OSPATH_SEPARATOR_STR "%s", m_staging_directory.c_str(), ftu.destination_filename.c_str());
		std::FILE* fp = FileSystem::OpenCFile(destination_file.c_str(), "wb");
		if (!fp)
		{
			m_progress->DisplayFormattedModalError("Failed to open staging output file '%s'", destination_file.c_str());
			return false;
		}

		const bool wrote_completely = std::fwrite(out_buffer + out_offset, extracted_size, 1, fp) == 1 && std::fflush(fp) == 0;
		if (std::fclose(fp) != 0 || !wrote_completely)
		{
			m_progress->DisplayFormattedModalError("Failed to write output file '%s'", destination_file.c_str());
			FileSystem::DeleteFilePath(destination_file.c_str());
			return false;
		}

		m_progress->IncrementProgressValue();
	}

	return true;
#else
	return false;
#endif
}

bool Updater::CommitUpdate()
{
	m_progress->SetStatusText("Committing update...");

	// create directories in target
	for (const std::string& subdir : m_update_directories)
	{
		const std::string dest_subdir = StringUtil::StdStringFromFormat("%s" FS_OSPATH_SEPARATOR_STR "%s",
			m_destination_directory.c_str(), subdir.c_str());

		if (!FileSystem::DirectoryExists(dest_subdir.c_str()) && !FileSystem::CreateDirectoryPath(dest_subdir.c_str(), false))
		{
			m_progress->DisplayFormattedModalError("Failed to create target directory '%s'", dest_subdir.c_str());
			return false;
		}
	}

	// move files to target
	for (const FileToUpdate& ftu : m_update_paths)
	{
		const std::string staging_file_name = StringUtil::StdStringFromFormat(
			"%s" FS_OSPATH_SEPARATOR_STR "%s", m_staging_directory.c_str(), ftu.destination_filename.c_str());
		const std::string dest_file_name = StringUtil::StdStringFromFormat(
			"%s" FS_OSPATH_SEPARATOR_STR "%s", m_destination_directory.c_str(), ftu.destination_filename.c_str());
		m_progress->DisplayFormattedInformation("Moving '%s' to '%s'", staging_file_name.c_str(), dest_file_name.c_str());
#ifdef _WIN32
		const bool result =
			MoveFileExW(StringUtil::UTF8StringToWideString(staging_file_name).c_str(),
				StringUtil::UTF8StringToWideString(dest_file_name).c_str(), MOVEFILE_REPLACE_EXISTING);
#else
		const bool result = (rename(staging_file_name.c_str(), dest_file_name.c_str()) == 0);
#endif
		if (!result)
		{
			m_progress->DisplayFormattedModalError("Failed to rename '%s' to '%s'", staging_file_name.c_str(),
				dest_file_name.c_str());
			return false;
		}
	}

	return true;
}

void Updater::CleanupStagingDirectory()
{
	// remove staging directory itself
	if (!RecursiveDeleteDirectory(m_staging_directory.c_str()))
		m_progress->DisplayFormattedError("Failed to remove staging directory '%s'", m_staging_directory.c_str());
}

void Updater::RemoveUpdateZip()
{
	if (m_zip_path.empty())
		return;

	CloseUpdateZip();

	if (!FileSystem::DeleteFilePath(m_zip_path.c_str()))
		m_progress->DisplayFormattedError("Failed to remove update zip '%s'", m_zip_path.c_str());
}

std::string Updater::FindPCSX2Exe() const
{
	for (const FileToUpdate& file : m_update_paths)
	{
		const std::string& name = file.destination_filename;
		if (name.find(FS_OSPATH_SEPARATOR_CHARACTER) != name.npos)
			continue; // Main exe is expected to be at the top level
		if (!StringUtil::StartsWithNoCase(name, "pcsx2"))
			continue;
		if (!StringUtil::EndsWithNoCase(name, "exe"))
			continue;
		return name;
	}
	return {};
}
