Editor/CitizenRetarget/NativeHelperInstaller.cs
#nullable enable

using System.IO.Compression;
using System.Net.Http;
using System.Security.Cryptography;

namespace Editor.CitizenRetarget;

internal sealed class NativeHelperInstallResult
{
	public bool Installed { get; init; }
	public Uri PackageUri { get; init; } = null!;
	public string PackageSha256 { get; init; } = string.Empty;
	public string DllPath { get; init; } = string.Empty;
	public long PackageBytes { get; init; }
	public long DllBytes { get; init; }
}

internal static class NativeHelperInstaller
{
	public const string Version = "0.1.0-alpha.2";
	public const string DllFileName = "ual2_ufbx_helper.dll";
	public const string PackageAssetName = "carl-native-win-x64-v0.1.0-alpha.2.zip";
	public const string PackageUrl = "https://github.com/pumped-bit/citizen-animation-retargeting-library/releases/download/v0.1.0-alpha.2/carl-native-win-x64-v0.1.0-alpha.2.zip";
	public const string ExpectedPackageSha256 = "84c790b5f7bae26467a810ca75c989a1e71d3b82a4adfcc20b1506eb986f5d77";

	private static readonly HttpClient Http = new();

	public static Task<NativeHelperInstallResult> InstallDefaultWinX64Async(
		string targetDirectory,
		Action<float, string>? progress = null,
		CancellationToken cancellationToken = default )
	{
		return InstallFromPackageAsync(
			new Uri( PackageUrl ),
			ExpectedPackageSha256,
			targetDirectory,
			progress: progress,
			cancellationToken: cancellationToken );
	}

	public static async Task<NativeHelperInstallResult> InstallFromPackageAsync(
		Uri packageUri,
		string expectedPackageSha256,
		string targetDirectory,
		string dllFileName = DllFileName,
		Action<float, string>? progress = null,
		Func<Uri, CancellationToken, Task<Stream>>? openPackageAsync = null,
		CancellationToken cancellationToken = default )
	{
		if ( packageUri is null )
			throw new ArgumentNullException( nameof( packageUri ) );
		if ( string.IsNullOrWhiteSpace( expectedPackageSha256 ) )
			throw new ArgumentException( "Expected package SHA256 is required.", nameof( expectedPackageSha256 ) );
		if ( string.IsNullOrWhiteSpace( targetDirectory ) )
			throw new ArgumentException( "Target directory is required.", nameof( targetDirectory ) );
		if ( string.IsNullOrWhiteSpace( dllFileName ) )
			throw new ArgumentException( "DLL filename is required.", nameof( dllFileName ) );

		progress?.Invoke( 0.04f, $"Downloading {PackageAssetName}..." );
		var packageBytes = openPackageAsync is null
			? await DownloadPackageBytesAsync( packageUri, progress, cancellationToken )
			: await ReadPackageBytesAsync( packageUri, openPackageAsync, cancellationToken );

		progress?.Invoke( 0.74f, "Verifying native helper package checksum..." );
		var actualSha = Sha256Hex( packageBytes );
		var expectedSha = NormalizeSha256( expectedPackageSha256 );
		if ( !actualSha.Equals( expectedSha, StringComparison.OrdinalIgnoreCase ) )
			throw new InvalidDataException( $"Downloaded package SHA256 mismatch. Expected {expectedSha}, got {actualSha}." );

		progress?.Invoke( 0.86f, "Extracting native FBX helper..." );
		var dllPath = await ExtractDllAsync( packageBytes, targetDirectory, dllFileName, cancellationToken );

		progress?.Invoke( 1f, "Native FBX helper installed." );
		return new NativeHelperInstallResult
		{
			Installed = true,
			PackageUri = packageUri,
			PackageSha256 = actualSha,
			DllPath = dllPath,
			PackageBytes = packageBytes.LongLength,
			DllBytes = new FileInfo( dllPath ).Length
		};
	}

	private static async Task<byte[]> ReadPackageBytesAsync(
		Uri packageUri,
		Func<Uri, CancellationToken, Task<Stream>> openPackageAsync,
		CancellationToken cancellationToken )
	{
		await using var packageStream = await openPackageAsync( packageUri, cancellationToken );
		if ( packageStream is null )
			throw new InvalidOperationException( "Package stream provider returned null." );

		using var buffer = new MemoryStream();
		await packageStream.CopyToAsync( buffer, cancellationToken );
		return buffer.ToArray();
	}

	private static async Task<byte[]> DownloadPackageBytesAsync(
		Uri packageUri,
		Action<float, string>? progress,
		CancellationToken cancellationToken )
	{
		using var request = new HttpRequestMessage( HttpMethod.Get, packageUri );
		request.Headers.UserAgent.ParseAdd( $"CARL/{Version}" );

		using var response = await Http.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken );
		response.EnsureSuccessStatusCode();

		var contentLength = response.Content.Headers.ContentLength;
		await using var remoteStream = await response.Content.ReadAsStreamAsync( cancellationToken );
		using var buffer = new MemoryStream( contentLength is > 0 and <= int.MaxValue ? (int)contentLength.Value : 0 );
		var chunk = new byte[64 * 1024];
		long totalRead = 0;

		while ( true )
		{
			var read = await remoteStream.ReadAsync( chunk.AsMemory( 0, chunk.Length ), cancellationToken );
			if ( read <= 0 )
				break;

			await buffer.WriteAsync( chunk.AsMemory( 0, read ), cancellationToken );
			totalRead += read;

			if ( contentLength is > 0 )
			{
				var downloadProgress = Math.Clamp( (float)totalRead / contentLength.Value, 0f, 1f );
				progress?.Invoke( 0.08f + downloadProgress * 0.62f, $"Downloading native helper package ({totalRead / 1024:N0} KB)..." );
			}
			else
			{
				progress?.Invoke( 0.36f, $"Downloading native helper package ({totalRead / 1024:N0} KB)..." );
			}
		}

		return buffer.ToArray();
	}

	private static async Task<string> ExtractDllAsync(
		byte[] packageBytes,
		string targetDirectory,
		string dllFileName,
		CancellationToken cancellationToken )
	{
		using var archive = new ZipArchive( new MemoryStream( packageBytes, writable: false ), ZipArchiveMode.Read );
		var entry = archive.Entries.FirstOrDefault( candidate =>
			Path.GetFileName( candidate.FullName ).Equals( dllFileName, StringComparison.OrdinalIgnoreCase ) );
		if ( entry is null )
			throw new InvalidDataException( $"Native helper package does not contain {dllFileName}." );
		if ( entry.Length <= 0 )
			throw new InvalidDataException( $"{dllFileName} in the native helper package is empty." );

		Directory.CreateDirectory( targetDirectory );
		var dllPath = Path.Combine( targetDirectory, dllFileName );
		var tempPath = Path.Combine( targetDirectory, $"{dllFileName}.download" );

		try
		{
			await using ( var entryStream = entry.Open() )
			await using ( var output = File.Create( tempPath ) )
			{
				await entryStream.CopyToAsync( output, cancellationToken );
			}

			File.Move( tempPath, dllPath, overwrite: true );
			return dllPath;
		}
		catch
		{
			TryDelete( tempPath );
			throw;
		}
	}

	private static string NormalizeSha256( string value )
	{
		var trimmed = value.Trim();
		var firstToken = trimmed.Split( [' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries ).FirstOrDefault() ?? string.Empty;
		return firstToken.ToLowerInvariant();
	}

	private static string Sha256Hex( byte[] bytes )
	{
		return Convert.ToHexString( SHA256.HashData( bytes ) ).ToLowerInvariant();
	}

	private static void TryDelete( string path )
	{
		try
		{
			if ( File.Exists( path ) )
				File.Delete( path );
		}
		catch
		{
			// Best-effort cleanup of a partial download.
		}
	}
}