/******************************************************************************
    Copyright (C) 2023 by Dennis Sädtler <dennis@obsproject.com>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 2 of the License, or
    (at your option) any later version.

    This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/

#include "OBSApp.hpp"

#include <utility/OBSProxyStyle.hpp>
#include <utility/OBSThemeVariable.hpp>
#include <utility/platform.hpp>

#include <qt-wrappers.hpp>
#include <ui-config.h>
#include <util/cf-parser.h>

#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileSystemWatcher>
#include <QMetaEnum>
#include <QRandomGenerator>
#include <QTimer>

using namespace std;

struct CFParser {
	cf_parser cfp = {};
	~CFParser() { cf_parser_free(&cfp); }
	operator cf_parser *() { return &cfp; }
	cf_parser *operator->() { return &cfp; }
};

static optional<OBSTheme> ParseThemeMeta(const QString &path)
{
	QFile themeFile(path);
	if (!themeFile.open(QIODeviceBase::ReadOnly))
		return nullopt;

	OBSTheme meta;
	const QByteArray data = themeFile.readAll();
	CFParser cfp;
	int ret;

	if (!cf_parser_parse(cfp, data.constData(), QT_TO_UTF8(path)))
		return nullopt;

	if (cf_token_is(cfp, "@") || cf_go_to_token(cfp, "@", nullptr)) {
		while (cf_next_token(cfp)) {
			if (cf_token_is(cfp, "OBSThemeMeta")) {
				break;
			}

			if (!cf_go_to_token(cfp, "@", nullptr))
				return nullopt;
		}

		if (!cf_token_is(cfp, "OBSThemeMeta"))
			return nullopt;

		if (!cf_next_token(cfp))
			return nullopt;

		if (!cf_token_is(cfp, "{"))
			return nullopt;

		for (;;) {
			if (!cf_next_token(cfp))
				return nullopt;

			ret = cf_token_is_type(cfp, CFTOKEN_NAME, "name", nullptr);
			if (ret != PARSE_SUCCESS)
				break;

			string name(cfp->cur_token->str.array, cfp->cur_token->str.len);

			ret = cf_next_token_should_be(cfp, ":", ";", nullptr);
			if (ret != PARSE_SUCCESS)
				continue;

			if (!cf_next_token(cfp))
				return nullopt;

			ret = cf_token_is_type(cfp, CFTOKEN_STRING, "value", ";");

			if (ret != PARSE_SUCCESS)
				continue;

			BPtr str = cf_literal_to_str(cfp->cur_token->str.array, cfp->cur_token->str.len);

			if (str) {
				if (name == "dark")
					meta.isDark = strcmp(str, "true") == 0;
				else if (name == "extends")
					meta.extends = str;
				else if (name == "author")
					meta.author = str;
				else if (name == "id")
					meta.id = str;
				else if (name == "name")
					meta.name = str;
			}

			if (!cf_go_to_token(cfp, ";", nullptr))
				return nullopt;
		}
	}

	auto filepath = filesystem::u8path(path.toStdString());
	meta.isBaseTheme = filepath.extension() == ".obt";
	meta.filename = filepath.stem();

	if (meta.id.isEmpty() || meta.name.isEmpty() || (!meta.isBaseTheme && meta.extends.isEmpty())) {
		/* Theme is invalid */
		return nullopt;
	} else {
		meta.location = absolute(filepath);
		meta.isHighContrast = path.endsWith(".oha");
		meta.isVisible = !path.contains("System");
	}

	return meta;
}

static bool ParseVarName(CFParser &cfp, QString &value)
{
	int ret;

	ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
	if (ret != PARSE_SUCCESS)
		return false;
	ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
	if (ret != PARSE_SUCCESS)
		return false;
	ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
	if (ret != PARSE_SUCCESS)
		return false;
	if (!cf_next_token(cfp))
		return false;

	value = QString::fromUtf8(cfp->cur_token->str.array, cfp->cur_token->str.len);

	ret = cf_next_token_should_be(cfp, ")", ";", nullptr);
	if (ret != PARSE_SUCCESS)
		return false;

	return !value.isEmpty();
}

static QColor ParseColor(CFParser &cfp)
{
	const char *array;
	uint32_t color = 0;
	QColor res(QColor::Invalid);

	if (cf_token_is(cfp, "#")) {
		if (!cf_next_token(cfp))
			return res;

		color = strtol(cfp->cur_token->str.array, nullptr, 16);
	} else if (cf_token_is(cfp, "rgb")) {
		int ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
		if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
			return res;

		array = cfp->cur_token->str.array;
		color |= strtol(array, nullptr, 10) << 16;

		ret = cf_next_token_should_be(cfp, ",", ";", nullptr);
		if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
			return res;

		array = cfp->cur_token->str.array;
		color |= strtol(array, nullptr, 10) << 8;

		ret = cf_next_token_should_be(cfp, ",", ";", nullptr);
		if (ret != PARSE_SUCCESS || !cf_next_token(cfp))
			return res;

		array = cfp->cur_token->str.array;
		color |= strtol(array, nullptr, 10);

		ret = cf_next_token_should_be(cfp, ")", ";", nullptr);
		if (ret != PARSE_SUCCESS)
			return res;
	} else if (cf_token_is(cfp, "bikeshed")) {
		color |= QRandomGenerator::global()->bounded(INT8_MAX) << 16;
		color |= QRandomGenerator::global()->bounded(INT8_MAX) << 8;
		color |= QRandomGenerator::global()->bounded(INT8_MAX);
	}

	res = color;
	return res;
}

static bool ParseMath(CFParser &cfp, QStringList &values, vector<OBSThemeVariable> &vars)
{
	int ret = cf_next_token_should_be(cfp, "(", ";", nullptr);
	if (ret != PARSE_SUCCESS)
		return false;
	if (!cf_next_token(cfp))
		return false;

	while (!cf_token_is(cfp, ")")) {
		if (cf_token_is(cfp, ";"))
			break;

		if (cf_token_is(cfp, "calc") || cf_token_is(cfp, "max") || cf_token_is(cfp, "min")) {
			/* Internal math operations do not have proper names.
			 * They are anonymous variables */
			OBSThemeVariable var;
			QStringList subvalues;

			var.name = QString("__unnamed_%1").arg(QRandomGenerator::global()->generate64());

			OBSThemeVariable::VariableType varType;
			if (cf_token_is(cfp, "calc"))
				varType = OBSThemeVariable::Calc;
			else if (cf_token_is(cfp, "max"))
				varType = OBSThemeVariable::Max;
			else if (cf_token_is(cfp, "min"))
				varType = OBSThemeVariable::Min;

			if (!ParseMath(cfp, subvalues, vars))
				return false;

			var.type = varType;
			var.value = subvalues;
			values << var.name;
			vars.push_back(std::move(var));
		} else if (cf_token_is(cfp, "var")) {
			QString value;
			if (!ParseVarName(cfp, value))
				return false;

			values << value;
		} else {
			values << QString::fromUtf8(cfp->cur_token->str.array, cfp->cur_token->str.len);
		}

		if (!cf_next_token(cfp))
			return false;
	}

	return !values.isEmpty();
}

static vector<OBSThemeVariable> ParseThemeVariables(const char *themeData)
{
	CFParser cfp;
	int ret;

	std::vector<OBSThemeVariable> vars;

	if (!cf_parser_parse(cfp, themeData, nullptr))
		return vars;

	if (!cf_token_is(cfp, "@") && !cf_go_to_token(cfp, "@", nullptr))
		return vars;

	while (cf_next_token(cfp)) {
		if (cf_token_is(cfp, "OBSThemeVars"))
			break;

		if (!cf_go_to_token(cfp, "@", nullptr))
			return vars;
	}

	if (!cf_next_token(cfp))
		return {};

	if (!cf_token_is(cfp, "{"))
		return {};

	for (;;) {
		if (!cf_next_token(cfp))
			return vars;

		if (!cf_token_is(cfp, "-"))
			return vars;

		ret = cf_next_token_should_be(cfp, "-", ";", nullptr);
		if (ret != PARSE_SUCCESS)
			continue;

		if (!cf_next_token(cfp))
			return vars;

		ret = cf_token_is_type(cfp, CFTOKEN_NAME, "key", nullptr);
		if (ret != PARSE_SUCCESS)
			break;

		QString key = QString::fromUtf8(cfp->cur_token->str.array, cfp->cur_token->str.len);
		OBSThemeVariable var;
		var.name = key;

#ifdef _WIN32
		const QString osPrefix = "os_win_";
#elif __APPLE__
		const QString osPrefix = "os_mac_";
#else
		const QString osPrefix = "os_lin_";
#endif

		if (key.startsWith(osPrefix) && key.length() > osPrefix.length()) {
			var.name = key.sliced(osPrefix.length());
		}

		ret = cf_next_token_should_be(cfp, ":", ";", nullptr);
		if (ret != PARSE_SUCCESS)
			continue;

		if (!cf_next_token(cfp))
			return vars;

		/* Special values passed to the theme by OBS are prefixed with 'obs', so we
		 * prevent theme variables from using it as a prefix. */
		if (key.startsWith("obs"))
			continue;

		if (cfp->cur_token->type == CFTOKEN_NUM) {
			const char *ch = cfp->cur_token->str.array;
			const char *end = ch + cfp->cur_token->str.len;
			double f = os_strtod(ch);

			var.value = f;
			var.type = OBSThemeVariable::Number;

			/* Look for a suffix and mark variable as size if it exists */
			while (ch < end) {
				if (!isdigit(*ch) && !isspace(*ch) && *ch != '.') {
					var.suffix = QString::fromUtf8(ch, end - ch);
					var.type = OBSThemeVariable::Size;
					break;
				}
				ch++;
			}
		} else if (cf_token_is(cfp, "rgb") || cf_token_is(cfp, "#") || cf_token_is(cfp, "bikeshed")) {
			QColor color = ParseColor(cfp);
			if (!color.isValid())
				continue;

			var.value = color;
			var.type = OBSThemeVariable::Color;
		} else if (cf_token_is(cfp, "var")) {
			QString value;

			if (!ParseVarName(cfp, value))
				continue;

			var.value = value;
			var.type = OBSThemeVariable::Alias;
		} else if (cf_token_is(cfp, "calc") || cf_token_is(cfp, "max") || cf_token_is(cfp, "min")) {
			QStringList values;

			if (cf_token_is(cfp, "calc"))
				var.type = OBSThemeVariable::Calc;
			else if (cf_token_is(cfp, "max"))
				var.type = OBSThemeVariable::Max;
			else if (cf_token_is(cfp, "min"))
				var.type = OBSThemeVariable::Min;

			if (!ParseMath(cfp, values, vars))
				continue;

			var.value = values;
		} else {
			var.type = OBSThemeVariable::String;
			BPtr strVal = cf_literal_to_str(cfp->cur_token->str.array, cfp->cur_token->str.len);
			var.value = QString::fromUtf8(strVal.Get());
		}

		if (!cf_next_token(cfp))
			return vars;

		if (cf_token_is(cfp, "!") &&
		    cf_next_token_should_be(cfp, "editable", nullptr, nullptr) == PARSE_SUCCESS) {
			if (var.type == OBSThemeVariable::Calc || var.type == OBSThemeVariable::Max ||
			    var.type == OBSThemeVariable::Min || var.type == OBSThemeVariable::Alias) {
				blog(LOG_WARNING, "Math or alias variable type cannot be editable: %s",
				     QT_TO_UTF8(var.name));
			} else {
				var.editable = true;
			}
		}

		vars.push_back(std::move(var));

		if (!cf_token_is(cfp, ";") && !cf_go_to_token(cfp, ";", nullptr))
			return vars;
	}

	return vars;
}

void OBSApp::FindThemes()
{

	QStringList filters;
	filters << "*.obt" // OBS Base Theme
		<< "*.ovt" // OBS Variant Theme
		<< "*.oha" // OBS High-contrast Adjustment layer
		;

	{
		string themeDir;
		GetDataFilePath("themes/", themeDir);
		QDirIterator it(QString::fromStdString(themeDir), filters, QDir::Files);
		while (it.hasNext()) {
			auto theme = ParseThemeMeta(it.next());
			if (theme && !themes.contains(theme->id))
				themes[theme->id] = std::move(*theme);
		}
	}

	{
		const std::string themeDir = App()->userConfigLocation.u8string() + "/obs-studio/themes";

		QDirIterator it(QString::fromStdString(themeDir), filters, QDir::Files);

		while (it.hasNext()) {
			auto theme = ParseThemeMeta(it.next());
			if (theme && !themes.contains(theme->id))
				themes[theme->id] = std::move(*theme);
		}
	}

	/* Build dependency tree for all themes, removing ones that have items missing. */
	QSet<QString> invalid;

	for (OBSTheme &theme : themes) {
		if (theme.extends.isEmpty()) {
			if (!theme.isBaseTheme) {
				blog(LOG_ERROR, R"(Theme "%s" is not base, but does not specify parent!)",
				     QT_TO_UTF8(theme.id));
				invalid.insert(theme.id);
			}

			continue;
		}

		QString parentId = theme.extends;
		while (!parentId.isEmpty()) {
			OBSTheme *parent = GetTheme(parentId);
			if (!parent) {
				blog(LOG_ERROR, R"(Theme "%s" is missing ancestor "%s"!)", QT_TO_UTF8(theme.id),
				     QT_TO_UTF8(parentId));
				invalid.insert(theme.id);
				break;
			}

			if (theme.isBaseTheme && !parent->isBaseTheme) {
				blog(LOG_ERROR, R"(Ancestor "%s" of base theme "%s" is not a base theme!)",
				     QT_TO_UTF8(parent->id), QT_TO_UTF8(theme.id));
				invalid.insert(theme.id);
				break;
			}

			if (parent->id == theme.id || theme.dependencies.contains(parent->id)) {
				blog(LOG_ERROR, R"(Dependency chain of "%s" ("%s") contains recursion!)",
				     QT_TO_UTF8(theme.id), QT_TO_UTF8(parent->id));
				invalid.insert(theme.id);
				break;
			}

			/* Mark this theme as a variant of first parent that is a base theme. */
			if (!theme.isBaseTheme && parent->isBaseTheme && theme.parent.isEmpty())
				theme.parent = parent->id;

			theme.dependencies.push_front(parent->id);
			parentId = parent->extends;

			if (parentId.isEmpty() && !parent->isBaseTheme) {
				blog(LOG_ERROR, R"(Final ancestor of "%s" ("%s") is not a base theme!)",
				     QT_TO_UTF8(theme.id), QT_TO_UTF8(parent->id));
				invalid.insert(theme.id);
				break;
			}
		}
	}

	for (const QString &name : invalid) {
		themes.remove(name);
	}
}

static bool ResolveVariable(const QHash<QString, OBSThemeVariable> &vars, OBSThemeVariable &var)
{
	if (var.type != OBSThemeVariable::Alias)
		return true;

	QString key = var.value.toString();
	while (vars[key].type == OBSThemeVariable::Alias) {
		key = vars[key].value.toString();

		if (!vars.contains(key)) {
			blog(LOG_ERROR, R"(Variable "%s" (aliased by "%s") does not exist!)", QT_TO_UTF8(key),
			     QT_TO_UTF8(var.name));
			return false;
		}
	}

	var = vars[key];

	return true;
}

static QString EvalMath(const QHash<QString, OBSThemeVariable> &vars, const OBSThemeVariable &var,
			const OBSThemeVariable::VariableType type, const int recursion = 0);

static OBSThemeVariable ParseMathVariable(const QHash<QString, OBSThemeVariable> &vars, const QString &value,
					  const int recursion = 0)
{
	OBSThemeVariable var;
	const QByteArray utf8 = value.toUtf8();
	const char *data = utf8.constData();

	if (isdigit(*data)) {
		double f = os_strtod(data);
		var.type = OBSThemeVariable::Number;
		var.value = f;

		const char *dataEnd = data + utf8.size();
		while (data < dataEnd) {
			if (*data && !isdigit(*data) && *data != '.') {
				var.suffix = QString::fromUtf8(data, dataEnd - data);
				var.type = OBSThemeVariable::Size;
				break;
			}

			data++;
		}
	} else {
		/* Treat value as an alias/key and resolve it */
		var.type = OBSThemeVariable::Alias;
		var.value = value;
		ResolveVariable(vars, var);

		/* Handle nested math calculations */
		if (var.type == OBSThemeVariable::Calc || var.type == OBSThemeVariable::Max ||
		    var.type == OBSThemeVariable::Min) {
			QString val = EvalMath(vars, var, var.type, recursion + 1);
			var = ParseMathVariable(vars, val);
		}

		/* Only number or size would be valid here */
		if (var.type != OBSThemeVariable::Number && var.type != OBSThemeVariable::Size) {
			blog(LOG_ERROR, "Math operand is not a size or number: %s %s %d", QT_TO_UTF8(var.name),
			     QT_TO_UTF8(var.value.toString()), var.type);
			throw invalid_argument("Operand not of numeric type");
		}
	}

	return var;
}

static QString EvalMath(const QHash<QString, OBSThemeVariable> &vars, const OBSThemeVariable &var,
			const OBSThemeVariable::VariableType type, const int recursion)
{
	if (recursion >= 10) {
		/* Abort after 10 levels of recursion */
		blog(LOG_ERROR, "Maximum recursion levels hit!");
		return "'Invalid expression'";
	}

	if (type != OBSThemeVariable::Calc && type != OBSThemeVariable::Max && type != OBSThemeVariable::Min) {
		blog(LOG_ERROR, "Invalid type for math operation!");
		return "'Invalid expression'";
	}

	QStringList args = var.value.toStringList();
	QString &opt = args[1];
	if (type == OBSThemeVariable::Calc && (opt != '*' && opt != '+' && opt != '-' && opt != '/')) {
		blog(LOG_ERROR, "Unknown/invalid calc() operator: %s", QT_TO_UTF8(opt));
		return "'Invalid expression'";
	}

	if ((type == OBSThemeVariable::Max || type == OBSThemeVariable::Min) && opt != ',') {
		blog(LOG_ERROR, "Invalid math separator: %s", QT_TO_UTF8(opt));
		return "'Invalid expression'";
	}

	if (args.length() != 3) {
		blog(LOG_ERROR, "Math parse had invalid number of arguments: %lld (%s)", args.length(),
		     QT_TO_UTF8(args.join(", ")));
		return "'Invalid expression'";
	}

	OBSThemeVariable val1, val2;
	try {
		val1 = ParseMathVariable(vars, args[0], 0);
		val2 = ParseMathVariable(vars, args[2], 0);
	} catch (...) {
		return "'Invalid expression'";
	}

	/* Ensure that suffixes match (if any) */
	if (!val1.suffix.isEmpty() && !val2.suffix.isEmpty() && val1.suffix != val2.suffix) {
		blog(LOG_ERROR, "Math operation requires suffixes to match or only one to be present! %s != %s",
		     QT_TO_UTF8(val1.suffix), QT_TO_UTF8(val2.suffix));
		return "'Invalid expression'";
	}

	double d1 = val1.userValue.isValid() ? val1.userValue.toDouble() : val1.value.toDouble();
	double d2 = val2.userValue.isValid() ? val2.userValue.toDouble() : val2.value.toDouble();

	if (!isfinite(d1) || !isfinite(d2)) {
		blog(LOG_ERROR,
		     "At least one invalid math value:"
		     " op1: %f, op2: %f",
		     d1, d2);
		return "'Invalid expression'";
	}

	double val = numeric_limits<double>::quiet_NaN();

	if (type == OBSThemeVariable::Calc) {
		if (opt == "+")
			val = d1 + d2;
		else if (opt == "-")
			val = d1 - d2;
		else if (opt == "*")
			val = d1 * d2;
		else if (opt == "/")
			val = d1 / d2;

		if (!isnormal(val)) {
			blog(LOG_ERROR, "Invalid calc() resulted in non-normal number: %f %s %f = %f", d1,
			     QT_TO_UTF8(opt), d2, val);
			return "'Invalid expression'";
		}
	} else if (type == OBSThemeVariable::Max) {
		val = d1 > d2 ? d1 : d2;
	} else if (type == OBSThemeVariable::Min) {
		val = d1 < d2 ? d1 : d2;
	}

	bool isInteger = ceill(val) == val;
	QString result = QString::number(val, 'f', isInteger ? 0 : -1);

	/* Carry-over suffix */
	if (!val1.suffix.isEmpty())
		result += val1.suffix;
	else if (!val2.suffix.isEmpty())
		result += val2.suffix;

	return result;
}

static qsizetype FindEndOfOBSMetadata(const QString &content)
{
	/* Find end of last OBS-specific section and strip it, kinda jank but should work */
	qsizetype end = 0;

	for (auto section : {"OBSThemeMeta", "OBSThemeVars", "OBSTheme"}) {
		qsizetype idx = content.indexOf(section, 0);
		if (idx > end) {
			end = content.indexOf('}', idx) + 1;
		}
	}

	return end;
}

static QString PrepareQSS(const QHash<QString, OBSThemeVariable> &vars, const QStringList &contents)
{
	QString stylesheet;
	QString needleTemplate("var(--%1)");

	for (const QString &content : contents) {
		qsizetype offset = FindEndOfOBSMetadata(content);
		if (offset >= 0) {
			stylesheet += "\n";
			stylesheet += content.sliced(offset);
		}
	}

	for (const OBSThemeVariable &var_ : vars) {
		OBSThemeVariable var(var_);

		if (!ResolveVariable(vars, var))
			continue;

		QString needle = needleTemplate.arg(var_.name);
		QString replace;

		QVariant value = var.userValue.isValid() ? var.userValue : var.value;

		if (var.type == OBSThemeVariable::Color) {
			replace = value.value<QColor>().name(QColor::HexRgb);
		} else if (var.type == OBSThemeVariable::Calc || var.type == OBSThemeVariable::Max ||
			   var.type == OBSThemeVariable::Min) {
			replace = EvalMath(vars, var, var.type);
		} else if (var.type == OBSThemeVariable::Size || var.type == OBSThemeVariable::Number) {
			double val = value.toDouble();

			bool isInteger = ceill(val) == val;
			replace = QString::number(val, 'f', isInteger ? 0 : -1);

			if (!var.suffix.isEmpty())
				replace += var.suffix;
		} else {
			replace = value.toString();
		}

		// Round any values with a px suffix. Qt does this anyway for some properties,
		// but then will flat out break with decimals for others.
		if (replace.right(2) == "px") {
			QString replaceValue = replace.sliced(0, replace.length() - 2);
			int val = (int)std::roundf(replaceValue.toDouble());

			replace = QString::number(val) + "px";
		}

		stylesheet = stylesheet.replace(needle, replace);
	}

	return stylesheet;
}

template<typename T> static void FillEnumMap(QHash<QString, T> &map)
{
	QMetaEnum meta = QMetaEnum::fromType<T>();

	int numKeys = meta.keyCount();
	for (int i = 0; i < numKeys; i++) {
		const char *key = meta.key(i);
		QString keyName(key);
		map[keyName.toLower()] = static_cast<T>(meta.keyToValue(key));
	}
}

static QPalette PreparePalette(const QHash<QString, OBSThemeVariable> &vars, const QPalette &defaultPalette)
{
	static QHash<QString, QPalette::ColorRole> roleMap;
	static QHash<QString, QPalette::ColorGroup> groupMap;

	if (roleMap.empty())
		FillEnumMap<QPalette::ColorRole>(roleMap);
	if (groupMap.empty())
		FillEnumMap<QPalette::ColorGroup>(groupMap);

	QPalette pal(defaultPalette);

	for (const OBSThemeVariable &var_ : vars) {
		if (!var_.name.startsWith("palette_"))
			continue;
		if (var_.name.count("_") < 1 || var_.name.count("_") > 2)
			continue;

		OBSThemeVariable var(var_);
		if (!ResolveVariable(vars, var) || var.type != OBSThemeVariable::Color)
			continue;

		/* Determine role and optionally group based on name.
		 * Format is: palette_<role>[_<group>] */
		QPalette::ColorRole role = QPalette::NoRole;
		QPalette::ColorGroup group = QPalette::All;

		QStringList parts = var_.name.split("_");
		if (parts.length() >= 2) {
			QString key = parts[1].toLower();
			if (!roleMap.contains(key)) {
				blog(LOG_WARNING, "Palette role \"%s\" is not valid!", QT_TO_UTF8(parts[1]));
				continue;
			}
			role = roleMap[key];
		}

		if (parts.length() == 3) {
			QString key = parts[2].toLower();
			if (!groupMap.contains(key)) {
				blog(LOG_WARNING, "Palette group \"%s\" is not valid!", QT_TO_UTF8(parts[2]));
				continue;
			}
			group = groupMap[key];
		}

		QVariant value = var.userValue.isValid() ? var.userValue : var.value;

		QColor color = value.value<QColor>().name(QColor::HexRgb);
		pal.setColor(group, role, color);
	}

	return pal;
}

static double getPaddingForDensityId(int id)
{
	double paddingValue = 4;

	if (id == -2) {
		paddingValue = 0.25;
	} else if (id == -3) {
		paddingValue = 2;
	} else if (id == -4) {
		paddingValue = 4;
	} else if (id == -5) {
		paddingValue = 6;
	}

	return paddingValue;
}

OBSTheme *OBSApp::GetTheme(const QString &name)
{
	if (!themes.contains(name))
		return nullptr;

	return &themes[name];
}

bool OBSApp::SetTheme(const QString &name)
{
	OBSTheme *theme = GetTheme(name);
	if (!theme)
		return false;

	if (themeWatcher) {
		themeWatcher->blockSignals(true);
		themeWatcher->removePaths(themeWatcher->files());
	}

	setStyleSheet("");
	currentTheme = theme;

	QStringList contents;
	QHash<QString, OBSThemeVariable> vars;
	/* Build list of themes to load (in order) */
	QStringList themeIds(theme->dependencies);
	themeIds << theme->id;

	/* Inject Appearance settings into theme vars */
	OBSThemeVariable fontScale;
	fontScale.name = "obsFontScale";
	fontScale.type = OBSThemeVariable::Number;
	fontScale.value = QVariant::fromValue(config_get_int(App()->GetUserConfig(), "Appearance", "FontScale"));

	const int density = config_get_int(App()->GetUserConfig(), "Appearance", "Density");

	OBSThemeVariable padding;
	padding.name = "obsPadding";
	padding.type = OBSThemeVariable::Number;
	padding.value = QVariant::fromValue(getPaddingForDensityId(density));

	vars[fontScale.name] = std::move(fontScale);
	vars[padding.name] = std::move(padding);

	/* Find and add high contrast adjustment layer if available */
	if (HighContrastEnabled()) {
		for (const OBSTheme &theme_ : themes) {
			if (!theme_.isHighContrast)
				continue;
			if (theme_.parent != theme->id)
				continue;
			themeIds << theme_.id;
			break;
		}
	}

	QStringList filenames;
	for (const QString &themeId : themeIds) {
		OBSTheme *cur = GetTheme(themeId);

		QFile file(cur->location);
		filenames << file.fileName();

		if (!file.open(QIODeviceBase::ReadOnly))
			return false;
		const QByteArray content = file.readAll();

		for (OBSThemeVariable &var : ParseThemeVariables(content.constData())) {
			vars[var.name] = std::move(var);
		}

		contents.emplaceBack(content.constData());
	}

	/* Check if OBS appearance settings are used in the theme */
	currentTheme->usesFontScale = false;
	currentTheme->usesDensity = false;
	for (const OBSThemeVariable &var : vars) {
		switch (var.type) {
		case OBSThemeVariable::Color:
		case OBSThemeVariable::Size:
		case OBSThemeVariable::Number:
		case OBSThemeVariable::String:
			continue;
		case OBSThemeVariable::Alias:
			if (var.value.toString() == "obsFontScale") {
				currentTheme->usesFontScale = true;
			}

			if (var.value.toString() == "obsPadding") {
				currentTheme->usesDensity = true;
			}
			break;
		case OBSThemeVariable::Calc:
		case OBSThemeVariable::Max:
		case OBSThemeVariable::Min:
			if (var.value.toStringList().contains("obsFontScale")) {
				currentTheme->usesFontScale = true;
			}
			if (var.value.toStringList().contains("obsPadding")) {
				currentTheme->usesDensity = true;
			}
			break;
		}
	}

	const QString stylesheet = PrepareQSS(vars, contents);
	const QPalette palette = PreparePalette(vars, defaultPalette);
	setPalette(palette);
	setStyleSheet(stylesheet);

#ifdef _DEBUG
	/* Write resulting QSS to file in config dir "themes" folder. */
	string filename("obs-studio/themes/");
	filename += theme->id.toStdString();
	filename += ".out";

	filesystem::path debugOut;
	char configPath[512];
	if (GetAppConfigPath(configPath, sizeof(configPath), filename.c_str())) {
		debugOut = absolute(filesystem::u8path(configPath));
		filesystem::create_directories(debugOut.parent_path());
	}

	QFile debugFile(debugOut);
	if (debugFile.open(QIODeviceBase::WriteOnly)) {
		debugFile.write(stylesheet.toUtf8());
		debugFile.flush();
	}
#endif

#ifdef __APPLE__
	SetMacOSDarkMode(theme->isDark);
#endif

	emit StyleChanged();

	if (themeWatcher) {
		themeWatcher->addPaths(filenames);
		/* Give it 250 ms before re-enabling the watcher to prevent too
		 * many reloads when edited with an auto-saving IDE. */
		QTimer::singleShot(250, this, [&] { themeWatcher->blockSignals(false); });
	}

	return true;
}

void OBSApp::themeFileChanged(const QString &path)
{
	themeWatcher->blockSignals(true);
	blog(LOG_INFO, "Theme file \"%s\" changed, reloading...", QT_TO_UTF8(path));
	SetTheme(currentTheme->id);
}

static map<string, string> themeMigrations = {
	{"Yami", DEFAULT_THEME},
	{"Grey", "com.obsproject.Yami.Grey"},
	{"Rachni", "com.obsproject.Yami.Rachni"},
	{"Light", "com.obsproject.Yami.Light"},
	{"Dark", "com.obsproject.Yami.Classic"},
	{"Acri", "com.obsproject.Yami.Acri"},
	{"System", "com.obsproject.System"},
};

bool OBSApp::InitTheme()
{
	defaultPalette = palette();
#if !defined(_WIN32) && !defined(__APPLE__)
	setStyle(new OBSProxyStyle("Fusion"));
#else
	setStyle(new OBSProxyStyle());
#endif

	/* Set search paths for custom 'theme:' URI prefix */
	string searchDir;
	if (GetDataFilePath("themes", searchDir)) {
		auto installSearchDir = filesystem::u8path(searchDir);
		QDir::addSearchPath("theme", absolute(installSearchDir));
	}

	char userDir[512];
	if (GetAppConfigPath(userDir, sizeof(userDir), "obs-studio/themes")) {
		auto configSearchDir = filesystem::u8path(userDir);
		QDir::addSearchPath("theme", absolute(configSearchDir));
	}

	/* Load list of themes and read their metadata */
	FindThemes();

	if (config_get_bool(userConfig, "Appearance", "AutoReload")) {
		/* Set up Qt file watcher to automatically reload themes */
		themeWatcher = new QFileSystemWatcher(this);
		connect(themeWatcher.get(), &QFileSystemWatcher::fileChanged, this, &OBSApp::themeFileChanged);
	}

	/* Migrate old theme config key */
	if (config_has_user_value(userConfig, "General", "CurrentTheme3") &&
	    !config_has_user_value(userConfig, "Appearance", "Theme")) {
		const char *old = config_get_string(userConfig, "General", "CurrentTheme3");

		if (themeMigrations.count(old)) {
			config_set_string(userConfig, "Appearance", "Theme", themeMigrations[old].c_str());
		}
	}

	QString themeName = config_get_string(userConfig, "Appearance", "Theme");

	if (themeName.isEmpty() || !GetTheme(themeName)) {
		if (!themeName.isEmpty()) {
			blog(LOG_WARNING,
			     "Loading theme \"%s\" failed, falling back to "
			     "default theme (\"%s\").",
			     QT_TO_UTF8(themeName), DEFAULT_THEME);
		}
#ifdef _WIN32
		themeName = HighContrastEnabled() ? "com.obsproject.System" : DEFAULT_THEME;
#else
		themeName = DEFAULT_THEME;
#endif
	}

	if (!SetTheme(themeName)) {
		blog(LOG_ERROR,
		     "Loading default theme \"%s\" failed, falling back to "
		     "system theme as last resort.",
		     QT_TO_UTF8(themeName));
		return SetTheme("com.obsproject.System");
	}

	return true;
}
