Editor/ImportAssetPreparation.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace ImportUnityPackage;

public record ImportPreparationResult( string[] Warnings, bool Cancelled, int Rebuilt )
{
	public string[] Errors { get; init; } = Array.Empty<string>();
}

/// <summary>Runs on the editor context after the imported folder has been committed.</summary>
public static class ImportAssetPreparation
{
	static Task repairTask;

	/// <summary>Repair already compiled resources in an existing import without reimporting source files.</summary>
	[ConCmd( "unity_import_repair_resources" )]
	public static void Repair( string directory )
	{
		if ( repairTask is { IsCompleted: false } ) return;
		repairTask = RepairAsync( directory );
	}

	static async Task RepairAsync( string directory )
	{
		try
		{
			var assets = Project.Current.GetAssetsPath();
			var root = Path.GetFullPath( Path.Combine( assets, "imported" ) ) + Path.DirectorySeparatorChar;
			var target = Path.GetFullPath( Path.IsPathRooted( directory ) ? directory : Path.Combine( assets, directory ) );
			if ( !(target.Equals( root.TrimEnd( Path.DirectorySeparatorChar ), StringComparison.OrdinalIgnoreCase ) || target.StartsWith( root, StringComparison.OrdinalIgnoreCase )) || !Directory.Exists( target ) )
				throw new ArgumentException( "Choose an existing package folder under this project's Assets/imported." );
			var stale = Directory.EnumerateFiles( target, "*", SearchOption.AllDirectories )
				.Where( f => ResourceOrder( f ) > 0 )
				.Where( f => AssetSystem.FindByPath( f ) is { IsCompiled: true } asset && !asset.IsCompiledAndUpToDate ).ToArray();
			Log.Info( $"UNITY_RESOURCE_REPAIR: preparing {stale.Length} out-of-date resources in {target}" );
			var result = await Run( stale, null, CancellationToken.None );
			foreach ( var warning in result.Warnings ) Log.Warning( warning );
			foreach ( var error in result.Errors ) Log.Error( error );
			Log.Info( $"UNITY_RESOURCE_REPAIR complete: {stale.Length} checked, {result.Rebuilt} full rebuilds, {result.Warnings.Length} warnings, {result.Errors.Length} errors." );
		}
		catch ( Exception ex ) { Log.Error( $"UNITY_RESOURCE_REPAIR failed: {ex}" ); }
	}

	public static async Task<ImportPreparationResult> RunImport( ImportResult result, IProgress<ImportProgress> progress, CancellationToken cancel )
	{
		var files = result.Files.ToHashSet( StringComparer.OrdinalIgnoreCase );
		var warnings = new List<string>();
		foreach ( var changed in result.ChangedFiles )
		{
			if ( cancel.IsCancellationRequested ) break;
			try
			{
				var asset = AssetSystem.RegisterFile( changed );
				if ( asset == null ) continue;
				foreach ( var dependant in asset.GetDependants( true ) )
					if ( ResourceOrder( dependant.Path ) > 0 && dependant.HasSourceFile ) files.Add( dependant.GetSourceFile( true ) );
			}
			catch ( Exception ex ) { warnings.Add( $"{changed}: could not discover affected resources: {ex.Message}" ); }
		}
		var prepared = await Run( files, progress, cancel );
		return prepared with { Warnings = warnings.Concat( prepared.Warnings ).ToArray() };
	}

	public static async Task<ImportPreparationResult> Run( IEnumerable<string> files, IProgress<ImportProgress> progress, CancellationToken cancel )
	{
		var warnings = new List<string>();
		var errors = new List<string>();
		var resources = new List<Asset>();
		var rebuilt = 0;
		try
		{
			// Register all sources before compiling textures, then materials, then models.
			foreach ( var file in files.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( ResourceOrder ) )
			{
				cancel.ThrowIfCancellationRequested();
				if ( Path.GetExtension( file ).ToLowerInvariant() is ".mat" or ".json" or ".mtl" ) continue;
				try
				{
					var asset = AssetSystem.RegisterFile( file );
					if ( ResourceOrder( file ) > 0 )
					{
						if ( asset == null ) throw new InvalidOperationException( "Asset registration returned no resource" );
						resources.Add( asset );
					}
				}
				catch ( Exception ex ) { errors.Add( $"{file}: registration failed: {ex.Message}" ); }
			}
			for ( var i = 0; i < resources.Count; i++ )
			{
				cancel.ThrowIfCancellationRequested();
				var asset = resources[i];
				progress?.Report( new( (double)i / resources.Count, $"Preparing resource {i + 1}/{resources.Count}: {asset.Name}" ) );
				try
				{
					if ( !asset.IsCompiledAndUpToDate ) await asset.CompileIfNeededAsync();
					cancel.ThrowIfCancellationRequested();
					// Incremental compilation can leave generated children absent from the asset registry,
					// even when their _c files exist. One full rebuild restores those dependencies.
					if ( !asset.IsCompileFailed && !asset.IsCompiledAndUpToDate )
					{
						asset.Compile( true );
						rebuilt++;
					}
					if ( asset.IsCompileFailed || !asset.IsCompiledAndUpToDate )
						errors.Add( $"{asset.Path}: compilation or dependency preparation failed; see the editor console." );
				}
				catch ( OperationCanceledException ) { throw; }
				catch ( Exception ex ) { errors.Add( $"{asset.Path}: resource preparation failed: {ex.Message}" ); }
				await Task.Delay( 1, cancel );
			}
		}
		catch ( OperationCanceledException ) when ( cancel.IsCancellationRequested )
		{
			return new( warnings.ToArray(), true, rebuilt ) { Errors = errors.ToArray() };
		}
		progress?.Report( new( 1, "Import complete" ) );
		return new( warnings.ToArray(), false, rebuilt ) { Errors = errors.ToArray() };
	}

	static int ResourceOrder( string file ) => Path.GetExtension( file ).ToLowerInvariant() switch
	{
		".vtex" => 1,
		".vmat" or ".tmat" => 2,
		".vmdl" => 3,
		_ => 0
	};
}