/* SPDX-FileCopyrightText: 2026 Project Tick
 * SPDX-License-Identifier: GPL-3.0-or-later */

#include "GitRepo.h"
#include <QStandardPaths>

namespace
{
	constexpr const char* kPluginIdentityName = "MeshMC GitVersioning";
	constexpr const char* kPluginIdentityEmail = "git-versioning@meshmc.local";

	constexpr const char* kDefaultGitIgnore =
		"# Auto-generated by the GitVersioning MMCO plugin.\n"
		"# Skip transient and bulky files that bloat the history.\n"
		"\n"
		"# Native MeshMC backup zips (handled by the BackupSystem plugin)\n"
		".backups/\n"
		"\n"
		"# Logs and crash dumps\n"
		"*.log\n"
		"crash-reports/\n"
		"logs/\n"
		"hs_err_pid*.log\n"
		"replay_pid*.log\n"
		"\n"
		"# JVM cache & assets symlink targets\n"
		"natives/\n"
		"assets/\n"
		"libraries/\n"
		"\n"
		"# Per-launch volatile files\n"
		".fabric/\n"
		".mixin.out/\n"
		"usercache.json\n"
		"usernamecache.json\n"
		"\n"
		"# Shader / texture pack caches\n"
		".shaderc/\n"
		"\n"
		"# Single big worlds: snapshot manually via BackupSystem instead\n"
		"saves/*/region/\n"
		"saves/*/playerdata/\n";

	/* Resolve "git" through PATH, preferring the launcher's bundled mirror
	 * on Windows if one is shipped. */
	QString gitExecutable()
	{
#ifdef Q_OS_WIN
		const auto bundled = QStandardPaths::locate(
			QStandardPaths::AppDataLocation, "git/bin/git.exe");
		if (!bundled.isEmpty())
			return bundled;
#endif
		return QStandardPaths::findExecutable(QStringLiteral("git"));
	}
} // namespace

GitRepo::GitRepo(const QString& instanceId, const QString& instanceRoot)
	: m_instanceId(instanceId), m_instanceRoot(instanceRoot)
{
	m_repoDir = QDir(m_instanceRoot).filePath(QStringLiteral(".history"));
}

bool GitRepo::gitAvailable()
{
	return !gitExecutable().isEmpty();
}

QString GitRepo::gitVersion()
{
	QProcess p;
	p.setProgram(gitExecutable());
	p.setArguments({"--version"});
	p.start();
	if (!p.waitForFinished(3000))
		return {};
	return QString::fromUtf8(p.readAllStandardOutput()).trimmed();
}

bool GitRepo::isInitialized() const
{
	// We use --separate-git-dir style: the .history dir IS the git dir.
	return QFileInfo::exists(QDir(m_repoDir).filePath("HEAD"));
}

GitRepo::GitResult GitRepo::runGit(const QStringList& args, int timeoutMs) const
{
	GitResult r;
	QString exe = gitExecutable();
	if (exe.isEmpty()) {
		r.stderrBytes = "git executable not found in PATH";
		return r;
	}

	QStringList full;
	full << QStringLiteral("--git-dir") << m_repoDir
		 << QStringLiteral("--work-tree") << m_instanceRoot << args;

	QProcess p;
	p.setProgram(exe);
	p.setArguments(full);
	// Detach from the user's HOME .gitconfig so a hostile global hook
	// can't intercept our calls.
	auto env = QProcessEnvironment::systemEnvironment();
	env.insert(QStringLiteral("GIT_CONFIG_NOSYSTEM"), QStringLiteral("1"));
	env.insert(QStringLiteral("GIT_CONFIG_GLOBAL"),
			   QStringLiteral("/dev/null"));
	env.insert(QStringLiteral("HOME"), m_instanceRoot);
	env.insert(QStringLiteral("GIT_TERMINAL_PROMPT"), QStringLiteral("0"));
	p.setProcessEnvironment(env);

	p.start();
	if (!p.waitForFinished(timeoutMs)) {
		p.kill();
		r.stderrBytes = "git timed out";
		return r;
	}

	r.exitCode = p.exitCode();
	r.stdoutBytes = p.readAllStandardOutput();
	r.stderrBytes = p.readAllStandardError();
	return r;
}

void GitRepo::writeGitIgnore() const
{
	QFile f(QDir(m_instanceRoot).filePath(QStringLiteral(".gitignore")));
	if (f.exists())
		return; // never clobber a user-edited gitignore
	if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
		return;
	f.write(kDefaultGitIgnore);
}

void GitRepo::writeRepoConfig() const
{
	// Pin the author identity locally so we don't depend on the user
	// having one configured globally.
	runGit({"config", "user.name", kPluginIdentityName});
	runGit({"config", "user.email", kPluginIdentityEmail});
	// Disable signing — local snapshots, no need for GPG.
	runGit({"config", "commit.gpgsign", "false"});
	// Tag the repo with the instance id so it's identifiable later.
	runGit({"config", "meshmc.instanceId", m_instanceId});
	// Make sure we use a deterministic default branch name.
	runGit({"symbolic-ref", "HEAD", "refs/heads/main"});
}

bool GitRepo::initialize(QString* errorMsg)
{
	if (isInitialized())
		return true;

	if (!QDir().mkpath(m_repoDir)) {
		if (errorMsg)
			*errorMsg = QStringLiteral("Failed to create %1").arg(m_repoDir);
		return false;
	}

	// We deliberately create a **non-bare** repository whose git-dir
	// lives at <instance>/.history and whose work-tree is <instance>
	// itself. Two things matter:
	//
	//   1. We do NOT pass `--bare`. A bare repo refuses every command
	//      that takes `--work-tree`, which means every subsequent
	//      `git --work-tree=<inst> --git-dir=<.history> add/commit/…`
	//      would die with "GIT_WORK_TREE not allowed without
	//      specifying GIT_DIR".
	//
	//   2. We pass `--git-dir=<.history>` to plant the repo directory
	//      directly where we want it, instead of letting git create
	//      <.history>/.git underneath. `--work-tree` is NOT a valid
	//      argument to `git init` (it would error out), so we strip
	//      it from this single call.
	GitResult res;
	{
		QString exe = gitExecutable();
		if (exe.isEmpty()) {
			if (errorMsg)
				*errorMsg = QStringLiteral("git executable not found in PATH");
			return false;
		}
		QStringList args;
		args << QStringLiteral("--git-dir") << m_repoDir
			 << QStringLiteral("init")
			 << QStringLiteral("--initial-branch=main");

		QProcess p;
		p.setProgram(exe);
		p.setArguments(args);
		auto env = QProcessEnvironment::systemEnvironment();
		env.insert(QStringLiteral("GIT_CONFIG_NOSYSTEM"), QStringLiteral("1"));
		env.insert(QStringLiteral("GIT_CONFIG_GLOBAL"),
				   QStringLiteral("/dev/null"));
		env.insert(QStringLiteral("HOME"), m_instanceRoot);
		env.insert(QStringLiteral("GIT_TERMINAL_PROMPT"), QStringLiteral("0"));
		p.setProcessEnvironment(env);
		p.start();
		if (!p.waitForFinished(30000)) {
			p.kill();
			if (errorMsg)
				*errorMsg = QStringLiteral("git init timed out");
			return false;
		}
		res.exitCode = p.exitCode();
		res.stdoutBytes = p.readAllStandardOutput();
		res.stderrBytes = p.readAllStandardError();
	}
	if (res.exitCode != 0) {
		if (errorMsg)
			*errorMsg = QString::fromUtf8(res.stderrBytes);
		return false;
	}

	writeRepoConfig();
	writeGitIgnore();

	// Initial empty commit so the log isn't empty out of the gate.
	auto add = runGit({"add", "--all"});
	Q_UNUSED(add);
	runGit(
		{"commit", "--allow-empty", "-m", "GitVersioning: initial snapshot"});

	return true;
}

GitRepoStatus GitRepo::status() const
{
	GitRepoStatus s;
	s.initialized = isInitialized();
	if (!s.initialized)
		return s;

	auto res = runGit({"status", "--porcelain=v1", "-uall"});
	if (res.exitCode == 0) {
		const auto lines =
			QString::fromUtf8(res.stdoutBytes).split('\n', Qt::SkipEmptyParts);
		for (const auto& line : lines) {
			if (line.size() < 2)
				continue;
			QChar x = line[0];
			QChar y = line[1];
			if (x == '?' && y == '?')
				++s.untrackedCount;
			else if (x == 'D' || y == 'D')
				++s.deletedCount;
			else
				++s.modifiedCount;
		}
		s.dirty = (s.modifiedCount + s.deletedCount + s.untrackedCount) > 0;
	}

	auto head = runGit({"rev-parse", "--short", "HEAD"});
	if (head.exitCode == 0)
		s.head = QString::fromUtf8(head.stdoutBytes).trimmed();
	else
		s.head = QStringLiteral("(empty)");

	return s;
}

QString GitRepo::commit(const QString& message, bool isPreLaunch,
						QString* errorMsg)
{
	if (!isInitialized()) {
		QString err;
		if (!initialize(&err)) {
			if (errorMsg)
				*errorMsg = err;
			return {};
		}
	}

	auto add = runGit({"add", "--all"});
	if (add.exitCode != 0) {
		if (errorMsg)
			*errorMsg = QString::fromUtf8(add.stderrBytes);
		return {};
	}

	QString trailer;
	if (isPreLaunch)
		trailer = QStringLiteral("\n\nMeshMC-Trigger: pre-launch");

	auto res =
		runGit({"commit", "--allow-empty-message", "-m", message + trailer});
	if (res.exitCode != 0) {
		// "nothing to commit" returns 1 — that's a no-op, not an error.
		QByteArray err = res.stderrBytes + res.stdoutBytes;
		if (err.contains("nothing to commit") || err.contains("nothing added"))
			return {};
		if (errorMsg)
			*errorMsg = QString::fromUtf8(err);
		return {};
	}

	auto sha = runGit({"rev-parse", "--short", "HEAD"});
	return QString::fromUtf8(sha.stdoutBytes).trimmed();
}

QList<GitCommit> GitRepo::log(int limit) const
{
	QList<GitCommit> result;
	if (!isInitialized())
		return result;

	// %x1F = unit-separator ASCII 0x1F (safe in commit metadata)
	// %x1E = record-separator ASCII 0x1E (safe between commits)
	const QString fmt = QStringLiteral(
		"%h%x1F%H%x1F%an%x1F%at%x1F%s%x1F%(trailers:key=MeshMC-Trigger,"
		"valueonly=true)%x1E");

	auto res = runGit({"log", "--max-count=" + QString::number(limit),
					   "--pretty=format:" + fmt});
	if (res.exitCode != 0)
		return result;

	const auto records = QString::fromUtf8(res.stdoutBytes)
							 .split(QChar('\x1E'), Qt::SkipEmptyParts);
	for (const auto& rec : records) {
		const auto parts = rec.split(QChar('\x1F'));
		if (parts.size() < 6)
			continue;
		GitCommit c;
		c.sha = parts[0].trimmed();
		c.fullSha = parts[1].trimmed();
		c.author = parts[2];
		c.when = QDateTime::fromSecsSinceEpoch(parts[3].trimmed().toLongLong());
		c.subject = parts[4];
		c.isPreLaunch = parts[5].trimmed() == QStringLiteral("pre-launch");
		result.append(c);
	}
	return result;
}

void GitRepo::fillCommitStats(GitCommit& c) const
{
	if (c.fullSha.isEmpty())
		return;

	auto res = runGit({"show", "--shortstat", "--format=", c.fullSha});
	if (res.exitCode != 0)
		return;

	// Output looks like:  "1 file changed, 12 insertions(+), 4 deletions(-)"
	QString text = QString::fromUtf8(res.stdoutBytes).trimmed();
	QRegularExpression re(
		R"((\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?)");
	auto m = re.match(text);
	if (m.hasMatch()) {
		c.filesChanged = m.captured(1).toInt();
		c.sizeAdded = m.captured(2).toLongLong();
		c.sizeRemoved = m.captured(3).toLongLong();
	}
}

bool GitRepo::restore(const QString& sha, QString* errorMsg)
{
	if (!isInitialized()) {
		if (errorMsg)
			*errorMsg = QStringLiteral("Repository not initialised");
		return false;
	}

	// Safety: first take an auto-snapshot of whatever is dirty so the
	// user can recover from a botched restore.
	commit(QStringLiteral("GitVersioning: auto-snapshot before restore"));

	auto res = runGit({"checkout", sha, "--", "."});
	if (res.exitCode != 0) {
		if (errorMsg)
			*errorMsg = QString::fromUtf8(res.stderrBytes);
		return false;
	}
	// After `checkout <sha> -- .`, HEAD didn't move but the index now
	// matches <sha>. Reset the index to the previous HEAD so subsequent
	// commits track changes against the restored content (not against
	// HEAD-before-restore).
	auto reset = runGit({"reset", "--mixed", "HEAD"});
	Q_UNUSED(reset);

	// And record a marker commit so the restore is visible in the log.
	commit(QStringLiteral("GitVersioning: restored to %1").arg(sha));
	return true;
}

QString GitRepo::resolveRef(const QString& name) const
{
	if (!isInitialized() || name.isEmpty())
		return {};
	auto res = runGit({"rev-parse", "--verify", name});
	if (res.exitCode != 0)
		return {};
	return QString::fromUtf8(res.stdoutBytes).trimmed();
}

QByteArray GitRepo::showFile(const QString& sha, const QString& path,
							 bool* ok) const
{
	if (ok)
		*ok = false;
	if (!isInitialized())
		return {};
	auto res = runGit({"show", sha + ":" + path});
	if (res.exitCode != 0)
		return {};
	if (ok)
		*ok = true;
	return res.stdoutBytes;
}

bool GitRepo::tag(const QString& name, const QString& sha, QString* errorMsg)
{
	if (!isInitialized()) {
		if (errorMsg)
			*errorMsg = QStringLiteral("Repository not initialised");
		return false;
	}
	QStringList args{"tag", name};
	if (!sha.isEmpty())
		args << sha;
	auto res = runGit(args);
	if (res.exitCode != 0) {
		if (errorMsg)
			*errorMsg = QString::fromUtf8(res.stderrBytes);
		return false;
	}
	return true;
}

bool GitRepo::dropHead(QString* errorMsg)
{
	if (!isInitialized()) {
		if (errorMsg)
			*errorMsg = QStringLiteral("Repository not initialised");
		return false;
	}
	auto res = runGit({"reset", "--hard", "HEAD~1"});
	if (res.exitCode != 0) {
		if (errorMsg)
			*errorMsg = QString::fromUtf8(res.stderrBytes);
		return false;
	}
	return true;
}
