Editor/git/Config.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Sandbox.git;
using Sandbox.git.models;

namespace Sandbox.SandGit.git;

/// <summary>
/// Git config helpers, loosely mirroring Desktop's config.ts.
/// </summary>
public static class Config {
	const string OperationGetConfigValueInPath = "getConfigValueInPath";
	const string OperationGetGlobalConfigPath = "getGlobalConfigPath";
	const string OperationAddGlobalConfigValue = "addGlobalConfigValue";
	const string OperationSetConfigValueInPath = "setConfigValueInPath";
	const string OperationRemoveConfigValueInPath = "removeConfigValueInPath";

	/// <summary>
	/// Canonicalization type for git config values, see <c>git config --type</c>.
	/// </summary>
	public enum ConfigValueType {
		Bool,
		Int,
		BoolOrInt,
		Path,
		ExpiryDate,
		Color
	}

	// ─── Public API: get ─────────────────────────────────────────────────────

	/// <summary>
	/// Look up a config value by name in the repository.
	/// </summary>
	/// <param name="repository">The repository whose configuration to query.</param>
	/// <param name="name">The config key (e.g. "user.name").</param>
	/// <param name="onlyLocal">
	/// Whether the value should be resolved only from the local repository
	/// configuration (equivalent to <c>git config --local</c>).
	/// </param>
	public static Task<string> GetConfigValueAsync(
		Repository repository,
		string name,
		bool onlyLocal = false
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		return GetConfigValueInPathAsync(name, repository.Path, onlyLocal);
	}

	/// <summary>
	/// Look up a global config value by name.
	/// </summary>
	public static Task<string> GetGlobalConfigValueAsync(string name) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		return GetConfigValueInPathAsync(name, path: null, onlyLocal: false);
	}

	/// <summary>
	/// Look up a boolean config value by name in the repository.
	/// </summary>
	/// <remarks>
	/// Uses git's own boolean parsing semantics via <c>git config --type bool</c>.
	/// Returns null if the value is not set.
	/// </remarks>
	public static async Task<bool> GetBooleanConfigValueAsync(
		Repository repository,
		string name,
		bool onlyLocal = false
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		var value = await GetConfigValueInPathAsync(
			name,
			repository.Path,
			onlyLocal,
			ConfigValueType.Bool
		).ConfigureAwait(false);

		return value != "false";
	}

	/// <summary>
	/// Look up a global boolean config value by name.
	/// </summary>
	public static async Task<bool> GetGlobalBooleanConfigValueAsync(string name) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var value = await GetConfigValueInPathAsync(
			name,
			path: null,
			onlyLocal: false,
			type: ConfigValueType.Bool
		).ConfigureAwait(false);

		return value != "false";
	}

	/// <summary>
	/// Get the path to the global git config file.
	/// </summary>
	/// <remarks>
	/// Implemented via <c>git config --global --list --show-origin</c> to avoid
	/// launching an editor. Parses the first origin line and normalizes the path.
	/// </remarks>
	public static async Task<string> GetGlobalConfigPathAsync() {
		var args = new[] { "config", "--global", "--list", "--show-origin" };

		var result = await Core.GitAsync(
			args,
			Environment.CurrentDirectory,
			OperationGetGlobalConfigPath
		).ConfigureAwait(false);

		var firstLine = result.Stdout
			.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
			.FirstOrDefault();

		if ( string.IsNullOrWhiteSpace(firstLine) )
			return string.Empty;

		const string prefix = "file:";
		var prefixIndex = firstLine.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
		if ( prefixIndex < 0 )
			return string.Empty;

		var start = prefixIndex + prefix.Length;
		var end = firstLine.IndexOf('\t', start);
		if ( end < 0 )
			end = firstLine.Length;

		var path = firstLine.Substring(start, end - start).Trim();
		if ( string.IsNullOrEmpty(path) )
			return string.Empty;

		return Path.GetFullPath(path);
	}

	// ─── Public API: set/add/remove ──────────────────────────────────────────

	/// <summary>
	/// Set a local config value by name.
	/// </summary>
	public static Task SetConfigValueAsync(
		Repository repository,
		string name,
		string value
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		return SetConfigValueInPathAsync(name, value, repository.Path);
	}

	/// <summary>
	/// Set a global config value by name.
	/// </summary>
	public static Task SetGlobalConfigValueAsync(
		string name,
		string value
	) {
		return SetConfigValueInPathAsync(name, value, path: null);
	}

	/// <summary>
	/// Add a global config value by name (does not replace existing values).
	/// </summary>
	public static async Task AddGlobalConfigValueAsync(string name, string value) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var args = new[] { "config", "--global", "--add", name, value };
		_ = await Core.GitAsync(
			args,
			Environment.CurrentDirectory,
			OperationAddGlobalConfigValue
		).ConfigureAwait(false);
	}

	/// <summary>
	/// Adds a path to the <c>safe.directory</c> configuration variable if it's not already present.
	/// </summary>
	public static async Task AddSafeDirectoryAsync(string path) {
		if ( string.IsNullOrWhiteSpace(path) )
			throw new ArgumentException("Path is required.", nameof(path));

		if ( RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && path[0] == '/' ) {
			path = "%(prefix)/" + path;
		}

		await AddGlobalConfigValueIfMissingAsync("safe.directory", path).ConfigureAwait(false);
	}

	/// <summary>
	/// Add a global config value if the given value is not already present.
	/// </summary>
	public static async Task AddGlobalConfigValueIfMissingAsync(string name, string value) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var args = new[] { "config", "--global", "-z", "--get-all", name, value };

		var successCodes = new HashSet<int> { 0, 1 };
		var result = await Core.GitAsync(
			args,
			Environment.CurrentDirectory,
			OperationAddGlobalConfigValue,
			successCodes
		).ConfigureAwait(false);

		var pieces = (result.Stdout ?? string.Empty).Split('\0');
		var hasValue = pieces.Any(p => string.Equals(p, value, StringComparison.Ordinal));

		if ( result.ExitCode == 1 || !hasValue ) {
			await AddGlobalConfigValueAsync(name, value).ConfigureAwait(false);
		}
	}

	/// <summary>
	/// Remove a local config value by name.
	/// </summary>
	public static Task RemoveConfigValueAsync(
		Repository repository,
		string name
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		return RemoveConfigValueInPathAsync(name, repository.Path);
	}

	/// <summary>
	/// Remove a global config value by name.
	/// </summary>
	public static Task RemoveGlobalConfigValueAsync(string name) {
		return RemoveConfigValueInPathAsync(name, path: null);
	}

	// ─── Private helpers ─────────────────────────────────────────────────────

	static async Task<string> GetConfigValueInPathAsync(
		string name,
		string path,
		bool onlyLocal = false,
		ConfigValueType? type = null
	) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var args = new List<string> { "config", "-z" };

		if ( string.IsNullOrEmpty(path) ) {
			args.Add("--global");
		} else if ( onlyLocal ) {
			args.Add("--local");
		}

		if ( type.HasValue ) {
			args.Add("--type");
			args.Add(TypeToString(type.Value));
		}

		args.Add(name);

		var successCodes = new HashSet<int> { 0, 1 };
		var workingDir = string.IsNullOrEmpty(path) ? Environment.CurrentDirectory : path;

		var result = await Core.GitAsync(
			args.ToArray(),
			workingDir,
			OperationGetConfigValueInPath,
			successCodes
		).ConfigureAwait(false);

		if ( result.ExitCode == 1 )
			return string.Empty;

		var output = result.Stdout ?? string.Empty;
		var pieces = output.Split('\0');
		return pieces.Length > 0 ? pieces[0] : string.Empty;
	}

	static Task SetConfigValueInPathAsync(
		string name,
		string value,
		string path
	) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var args = new List<string> { "config" };

		if ( string.IsNullOrEmpty(path) ) {
			args.Add("--global");
		}

		args.Add("--replace-all");
		args.Add(name);
		args.Add(value);

		var workingDir = string.IsNullOrEmpty(path) ? Environment.CurrentDirectory : path;
		return Core.GitAsync(
			args.ToArray(),
			workingDir,
			OperationSetConfigValueInPath
		);
	}

	static Task RemoveConfigValueInPathAsync(
		string name,
		string path
	) {
		if ( string.IsNullOrWhiteSpace(name) )
			throw new ArgumentException("Config name is required.", nameof(name));

		var args = new List<string> { "config" };

		if ( string.IsNullOrEmpty(path) ) {
			args.Add("--global");
		}

		args.Add("--unset-all");
		args.Add(name);

		var workingDir = string.IsNullOrEmpty(path) ? Environment.CurrentDirectory : path;
		return Core.GitAsync(
			args.ToArray(),
			workingDir,
			OperationRemoveConfigValueInPath
		);
	}

	static string TypeToString(ConfigValueType type) =>
		type switch {
			ConfigValueType.Bool => "bool",
			ConfigValueType.Int => "int",
			ConfigValueType.BoolOrInt => "bool-or-int",
			ConfigValueType.Path => "path",
			ConfigValueType.ExpiryDate => "expiry-date",
			ConfigValueType.Color => "color",
			_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
		};
}