// Copyright (c) 2020 Michael Fabian Dirks <info@xaymar.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

#include "updater.hpp"
#include "version.hpp"
#include <mutex>
#include <string_view>
#include "configuration.hpp"
#include "plugin.hpp"

// TODO:
// - Cache result in the configuration directory (not as a configuration value).
// - Move 'autoupdater.last_checked_at' to out of the configuration.
// - Figure out if nightly updates are viable at all.

#define ST_PREFIX "<updater> "
#define D_LOG_ERROR(...) DLOG_ERROR(ST_PREFIX __VA_ARGS__)
#define D_LOG_WARNING(...) DLOG_WARNING(ST_PREFIX __VA_ARGS__)
#define D_LOG_INFO(...) DLOG_INFO(ST_PREFIX __VA_ARGS__)
#ifdef _DEBUG
#define D_LOG_DEBUG(...) DLOG_DEBUG(ST_PREFIX __VA_ARGS__)
#else
#define D_LOG_DEBUG(...)
#endif

#define ST_CFG_GDPR "updater.gdpr"
#define ST_CFG_AUTOMATION "updater.automation"
#define ST_CFG_CHANNEL "updater.channel"
#define ST_CFG_LASTCHECKEDAT "updater.lastcheckedat"

void streamfx::to_json(nlohmann::json& json, const update_info& info)
{
	auto version     = nlohmann::json::object();
	version["major"] = info.version_major;
	version["minor"] = info.version_minor;
	version["patch"] = info.version_patch;
	if (info.version_type)
		version["alpha"] = info.version_type == 'a' ? true : false;
	version["index"] = info.version_index;
	json["version"]  = version;
	json["preview"]  = info.channel == update_channel::TESTING;
	json["url"]      = info.url;
	json["name"]     = info.name;
}

void streamfx::from_json(const nlohmann::json& json, update_info& info)
{
	if (json.find("html_url") != json.end()) {
		// This is a response from GitHub.

		// Retrieve entries from the release object.
		auto entry_tag_name = json.find("tag_name");
		auto entry_name     = json.find("name");
		auto entry_url      = json.find("html_url");
		auto entry_preview  = json.find("prerelease");
		if ((entry_tag_name == json.end()) || (entry_name == json.end()) || (entry_url == json.end())
			|| (entry_preview == json.end())) {
			throw std::runtime_error("JSON is missing one or more required keys.");
		}

		// Initialize update information.
		info.channel = entry_preview->get<bool>() ? update_channel::TESTING : update_channel::RELEASE;
		info.url     = entry_url->get<std::string>();
		info.name    = entry_name->get<std::string>();

		// Parse the tag name as SemVer (A.B.C)
		{
			std::string tag_name = entry_tag_name->get<std::string>();

			size_t dot_1       = tag_name.find_first_of(".", 0) + 1;
			info.version_major = static_cast<uint16_t>(strtoul(&tag_name.at(0), nullptr, 10));
			if (dot_1 < tag_name.size()) {
				info.version_minor = static_cast<uint16_t>(strtoul(&tag_name.at(dot_1), nullptr, 10));
			}

			char*  endptr = nullptr;
			size_t dot_2  = tag_name.find_first_of(".", dot_1) + 1;
			if (dot_2 < tag_name.size()) {
				info.version_patch = static_cast<uint16_t>(strtoul(&tag_name.at(dot_2), &endptr, 10));
			}

			// Check if there's data following the SemVer structure. (A.B.CdE)
			if ((endptr != nullptr) && (endptr < (tag_name.data() + tag_name.size()))) {
				size_t last_num   = static_cast<size_t>(endptr - tag_name.data()) + 1;
				info.version_type = *endptr;
				if (last_num < tag_name.size())
					info.version_index = static_cast<uint16_t>(strtoul(&tag_name.at(last_num), nullptr, 10));
			} else {
				info.version_type  = 0;
				info.version_index = 0;
			}
		}
	} else {
		auto version       = json.at("version");
		info.version_major = version.at("major").get<uint16_t>();
		info.version_minor = version.at("minor").get<uint16_t>();
		info.version_patch = version.at("patch").get<uint16_t>();
		if (version.find("type") != version.end())
			info.version_type = version.at("alpha").get<bool>() ? 'a' : 'b';
		info.version_index = version.at("index").get<uint16_t>();
		info.channel       = json.at("preview").get<bool>() ? update_channel::TESTING : update_channel::RELEASE;
		info.url           = json.at("url").get<std::string>();
		info.name          = json.at("name").get<std::string>();
	}
}

bool streamfx::update_info::is_newer(update_info& other)
{
	// 1. Compare Major version:
	//     A. Ours is greater: Remote is older.
	//     B. Theirs is greater: Remote is newer.
	//     C. Continue the check.
	if (version_major > other.version_major)
		return false;
	if (version_major < other.version_major)
		return true;

	// 2. Compare Minor version:
	//     A. Ours is greater: Remote is older.
	//     B. Theirs is greater: Remote is newer.
	//     C. Continue the check.
	if (version_minor > other.version_minor)
		return false;
	if (version_minor < other.version_minor)
		return true;

	// 3. Compare Patch version:
	//     A. Ours is greater: Remote is older.
	//     B. Theirs is greater: Remote is newer.
	//     C. Continue the check.
	if (version_patch > other.version_patch)
		return false;
	if (version_patch < other.version_patch)
		return true;

	// 4. Compare Type:
	//     A. Ours empty: Remote is older.
	//     B. Theirs empty: Remote is newer.
	//     C. Continue the check.
	// A automatically implies that ours is not empty for B. A&B combined imply that both are not empty for step 5.
	if (version_type == 0)
		return false;
	if (other.version_type == 0)
		return true;

	// 5. Compare Type value:
	//     A. Ours is greater: Remote is older.
	//     B. Theirs is greater: Remote is newer.
	//     C. Continue the check.
	if (version_type > other.version_type)
		return false;
	if (version_type < other.version_type)
		return true;

	// 6. Compare Index:
	//    A. Ours is greater or equal: Remote is older or identical.
	//    B. Remote is newer
	if (version_index >= other.version_index)
		return false;

	return true;
}

void streamfx::updater::task(util::threadpool_data_t)
try {
	{
		std::vector<char> buffer;
		task_query(buffer);
		task_parse(buffer);
	}

#ifdef _DEBUG
	{
		std::lock_guard<std::mutex> lock(_lock);
		nlohmann::json              current = _current_info;
		nlohmann::json              stable  = _release_info;
		nlohmann::json              preview = _testing_info;

		D_LOG_DEBUG("Current Version: %s", current.dump().c_str());
		D_LOG_DEBUG("Stable Version: %s", stable.dump().c_str());
		D_LOG_DEBUG("Preview Version: %s", preview.dump().c_str());
	}
#endif

	// Log that we have a potential update.
	if (have_update()) {
		auto info = get_update_info();

		if (info.version_type) {
			D_LOG_INFO("Update to version %d.%d.%d%.1s%d is available at \"%s\".", info.version_major,
					   info.version_minor, info.version_patch, &info.version_type, info.version_index,
					   info.url.c_str());
		} else {
			D_LOG_INFO("Update to version %d.%d.%d is available at \"%s\".", info.version_major, info.version_minor,
					   info.version_patch, info.url.c_str());
		}
	} else {
		D_LOG_DEBUG("No update available.");
	}

	// Notify listeners of the update.
	events.refreshed.call(*this);
} catch (const std::exception& ex) {
	// Notify about the error.
	std::string message = ex.what();
	events.error.call(*this, message);
}

void streamfx::updater::task_query(std::vector<char>& buffer)
{
	static constexpr std::string_view ST_API_URL = "https://api.github.com/repos/Xaymar/obs-StreamFX/releases";

	util::curl curl;
	size_t     buffer_offset = 0;

	// Set headers (User-Agent is needed so Github can contact us!).
	curl.set_header("User-Agent", "StreamFX Updater v" STREAMFX_VERSION_STRING);
	curl.set_header("Accept", "application/vnd.github.v3+json");

	// Set up request.
	curl.set_option(CURLOPT_HTTPGET, true); // GET
	curl.set_option(CURLOPT_POST, false);   // Not POST
	curl.set_option(CURLOPT_URL, ST_API_URL);
	curl.set_option(CURLOPT_TIMEOUT, 10); // 10s until we fail.

	// Callbacks
	curl.set_write_callback([this, &buffer, &buffer_offset](void* data, size_t s1, size_t s2) {
		size_t size = s1 * s2;
		if (buffer.size() < (size + buffer_offset))
			buffer.resize(buffer_offset + size);

		memcpy(buffer.data() + buffer_offset, data, size);
		buffer_offset += size;

		return s1 * s2;
	});
	//std::bind(&streamfx::updater::task_write_cb, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)

	// Clear any unknown data and reserve 64KiB of memory.
	buffer.clear();
	buffer.reserve(0xFFFF);

	// Finally, execute the request.
	D_LOG_DEBUG("Querying for latest releases...");
	if (CURLcode res = curl.perform(); res != CURLE_OK) {
		D_LOG_ERROR("Performing query failed with error: %s", curl_easy_strerror(res));
		throw std::runtime_error(curl_easy_strerror(res));
	}

	int32_t status_code = 0;
	if (CURLcode res = curl.get_info(CURLINFO_HTTP_CODE, status_code); res != CURLE_OK) {
		D_LOG_ERROR("Retrieving status code failed with error: %s", curl_easy_strerror(res));
		throw std::runtime_error(curl_easy_strerror(res));
	}
	D_LOG_DEBUG("API returned status code %d.", status_code);

	if (status_code != 200) {
		D_LOG_ERROR("API returned unexpected status code %d.", status_code);
		throw std::runtime_error("Request failed due to one or more reasons.");
	}
}

void streamfx::updater::task_parse(std::vector<char>& buffer)
{
	// Parse the JSON response from the API.
	nlohmann::json json = nlohmann::json::parse(buffer.begin(), buffer.end());

	// Check if it was parsed as an object.
	if (json.type() != nlohmann::json::value_t::array) {
		throw std::runtime_error("Response is not a JSON array.");
	}

	// Iterate over all entries.
	std::lock_guard<std::mutex> lock(_lock);
	for (auto obj : json) {
		try {
			auto info = obj.get<streamfx::update_info>();

			if (info.channel == update_channel::RELEASE) {
				if (_release_info.is_newer(info))
					_release_info = info;
				if (_testing_info.is_newer(info))
					_testing_info = info;
			} else {
				if (_testing_info.is_newer(info))
					_testing_info = info;
			}
		} catch (const std::exception& ex) {
			D_LOG_DEBUG("Failed to parse entry, error: %s", ex.what());
		}
	}
}

bool streamfx::updater::can_check()
{
#ifdef _DEBUG
	return true;
#else
	auto now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch());
	auto threshold = (_lastcheckedat + std::chrono::minutes(10));
	return (now > threshold);
#endif
}

void streamfx::updater::load()
{
	std::lock_guard<std::mutex> lock(_lock);
	if (auto config = streamfx::configuration::instance(); config) {
		auto dataptr = config->get();

		if (obs_data_has_user_value(dataptr.get(), ST_CFG_GDPR))
			_gdpr = obs_data_get_bool(dataptr.get(), ST_CFG_GDPR);
		if (obs_data_has_user_value(dataptr.get(), ST_CFG_AUTOMATION))
			_automation = obs_data_get_bool(dataptr.get(), ST_CFG_AUTOMATION);
		if (obs_data_has_user_value(dataptr.get(), ST_CFG_CHANNEL))
			_channel = static_cast<update_channel>(obs_data_get_int(dataptr.get(), ST_CFG_CHANNEL));
		if (obs_data_has_user_value(dataptr.get(), ST_CFG_LASTCHECKEDAT))
			_lastcheckedat = std::chrono::seconds(obs_data_get_int(dataptr.get(), ST_CFG_LASTCHECKEDAT));
	}
}

void streamfx::updater::save()
{
	if (auto config = streamfx::configuration::instance(); config) {
		auto dataptr = config->get();

		obs_data_set_bool(dataptr.get(), ST_CFG_GDPR, _gdpr);
		obs_data_set_bool(dataptr.get(), ST_CFG_AUTOMATION, _automation);
		obs_data_set_int(dataptr.get(), ST_CFG_CHANNEL, static_cast<long long>(_channel));
		obs_data_set_int(dataptr.get(), ST_CFG_LASTCHECKEDAT, static_cast<long long>(_lastcheckedat.count()));
	}
}

streamfx::updater::updater()
	: _lock(), _task(),

	  _gdpr(false), _automation(true), _channel(update_channel::RELEASE), _lastcheckedat(),

	  _current_info(), _release_info(), _testing_info(), _dirty(false)
{
	// Load information from configuration.
	load();

	// Build current version information.
	try {
		_current_info.version_major = STREAMFX_VERSION_MAJOR;
		_current_info.version_minor = STREAMFX_VERSION_MINOR;
		_current_info.version_patch = STREAMFX_VERSION_PATCH;
		_current_info.channel       = _channel;
		std::string suffix          = STREAMFX_VERSION_SUFFIX;
		if (suffix.length()) {
			_current_info.version_type  = suffix.at(0);
			_current_info.version_index = static_cast<uint16_t>(strtoul(&suffix.at(1), nullptr, 10));
		}
	} catch (...) {
		D_LOG_ERROR("Failed to parse current version information, results may be inaccurate.");
	}
}

streamfx::updater::~updater()
{
	save();
}

bool streamfx::updater::gdpr()
{
	return _gdpr;
}

void streamfx::updater::set_gdpr(bool value)
{
	_dirty = true;
	_gdpr  = value;
	events.gdpr_changed(*this, _gdpr);

	{
		std::lock_guard<std::mutex> lock(_lock);
		save();
	}

	D_LOG_INFO("User %s the processing of data.", _gdpr ? "allowed" : "disallowed");
}

bool streamfx::updater::automation()
{
	return _automation;
}

void streamfx::updater::set_automation(bool value)
{
	_automation = value;
	events.automation_changed(*this, _automation);

	{
		std::lock_guard<std::mutex> lock(_lock);
		save();
	}

	D_LOG_INFO("Automatic checks at launch are now %s.", value ? "enabled" : "disabled");
}

streamfx::update_channel streamfx::updater::channel()
{
	return _channel;
}

void streamfx::updater::set_channel(update_channel value)
{
	std::lock_guard<std::mutex> lock(_lock);

	_dirty   = true;
	_channel = value;
	events.channel_changed(*this, _channel);

	save();

	D_LOG_INFO("Update channel changed to %s.", value == update_channel::RELEASE ? "Release" : "Testing");
}

void streamfx::updater::refresh()
{
	if (!_task.expired() || !gdpr()) {
		return;
	}

	if (can_check()) {
		std::lock_guard<std::mutex> lock(_lock);

		// Update last checked time.
		_lastcheckedat =
			std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch());
		save();

		// Spawn a new task.
		_task = streamfx::threadpool()->push(std::bind(&streamfx::updater::task, this, std::placeholders::_1), nullptr);
	} else {
		events.refreshed(*this);
	}
}

bool streamfx::updater::have_update()
{
	auto info = get_update_info();
	return _current_info.is_newer(info);
}

streamfx::update_info streamfx::updater::get_current_info()
{
	return _current_info;
}

streamfx::update_info streamfx::updater::get_update_info()
{
	std::lock_guard<std::mutex> lock(_lock);
	update_info                 info = _release_info;
	if (info.is_newer(_testing_info) && (_channel == update_channel::TESTING))
		info = _testing_info;

	return info;
}

std::shared_ptr<streamfx::updater> streamfx::updater::instance()
{
	static std::weak_ptr<streamfx::updater> _instance;
	static std::mutex                       _lock;

	std::lock_guard<std::mutex> lock(_lock);
	if (_instance.expired()) {
		auto ptr  = std::make_shared<streamfx::updater>();
		_instance = ptr;
		return ptr;
	} else {
		return _instance.lock();
	}
}
