Editor/ImportValidation.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Editor.Assets;
using Sandbox;

namespace ImportUnityPackage;

/// <summary>Development validation: fresh import, resource load, and actual preview-scene placement.</summary>
public static class ImportValidation
{
	static Task activeTask;
	static bool validateTerrain;
	static CancellationTokenSource cancellation;
	public sealed record Check( string Path, string Kind, bool Success, string Detail );

	[ConCmd( "unity_import_check_channels" )]
	public static void CheckChannels()
	{
		var source = Path.Combine( Path.GetTempPath(), "unity-channel-test-" + Guid.NewGuid().ToString( "N" ) + ".png" );
		try
		{
			using var fixture = new Bitmap( 2, 1 );
			fixture.SetPixel( 0, 0, new Color( 0.2f, 0.4f, 0.6f, 0.8f ) );
			fixture.SetPixel( 1, 0, new Color( 0.8f, 0.6f, 0.4f, 0.2f ) );
			File.WriteAllBytes( source, fixture.ToPng() );
			for ( int channel = 0; channel < 4; channel++ )
			{
				using var output = Bitmap.CreateFromBytes( TextureChannels.Extract( source, channel, 1, false, default ) );
				var value = output.GetPixel( 0, 0 );
				var expected = (channel + 1) * 0.2f;
				if ( Math.Abs( value.r - expected ) > 0.01 || Math.Abs( value.g - expected ) > 0.01 || Math.Abs( value.b - expected ) > 0.01 || value.a < 0.99 )
					throw new Exception( $"Channel {channel} extraction has incorrect pixel values: {value}" );
			}
			using var inverted = Bitmap.CreateFromBytes( TextureChannels.Extract( source, 3, 0.5, true, default ) );
			if ( Math.Abs( inverted.GetPixel( 0, 0 ).r - 0.6f ) > 0.01 ) throw new Exception( "Smoothness scaling/inversion failed" );
			Log.Info( "UNITY_CHANNEL_TEST PASS: RGBA separation and scaled smoothness inversion" );
		}
		finally { File.Delete( source ); }
	}

	[ConCmd( "unity_import_validate" )]
	public static void Start( string package, bool terrain = false, int skip = 0 ) => StartFiltered( package, "", terrain, skip );

	[ConCmd( "unity_import_validate_subset" )]
	public static void StartFiltered( string package, string filter, bool terrain = false, int skip = 0 )
	{
		if ( activeTask is { IsCompleted: false } ) { Log.Warning( "Unity import validation is already running." ); return; }
		
		validateTerrain = terrain;
		cancellation = new CancellationTokenSource();
		activeTask = RunFiles( package, Math.Max( 0, skip ), filter );
	}

	[ConCmd( "unity_import_validate_cancel" )]
	public static void Cancel() => cancellation?.Cancel();

	[ConCmd( "unity_import_recheck" )]
	public static void Recheck( string directory )
	{
		if ( activeTask is { IsCompleted: false } ) { Log.Warning( "Unity import validation is already running." ); return; }
		
		cancellation = new CancellationTokenSource();
		activeTask = RecheckAsync( directory );
	}

	static async Task RecheckAsync( string directory )
	{
		try
		{
			using var report = JsonDocument.Parse( File.ReadAllText( File.Exists( directory ) ? directory : Path.Combine( directory, "unity-import-report.json" ) ) );
			await Run( report.RootElement.GetProperty( "Package" ).GetString(), directory );
		}
		catch ( OperationCanceledException ) { Log.Info( "UNITY_VALIDATION cancelled" ); }
		finally { cancellation.Dispose(); cancellation = null; }
	}

	static Task RunFiles( string path, int skip ) => RunFiles( path, skip, "" );

	static async Task RunFiles( string path, int skip, string filter )
	{
		try
		{
			var packages = Directory.Exists( path ) ? Directory.GetFiles( path, "*.unitypackage" ).OrderBy( f => new FileInfo( f ).Length ).ToArray() : new[] { path };
			foreach ( var package in packages.Skip( skip ) ) { cancellation.Token.ThrowIfCancellationRequested(); await Run( package, null, filter ); }
		}
		catch ( OperationCanceledException ) { Log.Info( "UNITY_VALIDATION cancelled" ); }
		finally { cancellation.Dispose(); cancellation = null; }
	}

	static Task Run( string package, string existing = null ) => Run( package, existing, "" );

	static async Task Run( string package, string existing, string filter )
	{
		var checks = new List<Check>();
		var reportName = Path.GetFileNameWithoutExtension( package ) + (string.IsNullOrEmpty( filter ) ? "" : "-subset-" + Path.GetFileName( filter ));
		var reportDirectory = Path.Combine( Project.Current.GetRootPath(), ".verification", "engine", reportName );
		Directory.CreateDirectory( reportDirectory );
		foreach ( var preview in Directory.GetFiles( reportDirectory, "*.png" ) ) File.Delete( preview );
		string destination = null;
		string[] reviewNotes = Array.Empty<string>();
		void Save( bool complete ) => File.WriteAllText( Path.Combine( reportDirectory, "result.json" ), JsonSerializer.Serialize(
			new { Package = package, Destination = destination, Complete = complete, Checked = checks.Count, Failed = checks.Count( c => !c.Success ), ReviewNotes = reviewNotes, Checks = checks },
			new JsonSerializerOptions { WriteIndented = true } ) );
		try
		{
			Log.Info( $"UNITY_VALIDATION reading {package}" );
			var assetsRoot = Project.Current.GetAssetsPath();
			ImportResult result;
			if ( existing == null )
			{
				using var archive = await Task.Run( () => UnityArchive.Read( package, null, cancellation.Token ) );
				if ( !string.IsNullOrEmpty( filter ) )
				{
					// An @JSON manifest selects several exact source paths in one archive pass.
					var selectedPaths = filter.StartsWith( "@" )
						? new HashSet<string>( (JsonSerializer.Deserialize<string[]>( File.ReadAllText( filter[1..] ) ) ?? Array.Empty<string>())
							.Select( p => p.Replace( '\\', '/' ).Replace( "Assets/", "", StringComparison.OrdinalIgnoreCase ) ), StringComparer.OrdinalIgnoreCase )
						: null;
					foreach ( var asset in archive.Assets ) asset.Selected = asset.Kind == UnityAssetKind.Model &&
						(selectedPaths != null ? selectedPaths.Contains( asset.Path.Replace( "Assets/", "", StringComparison.OrdinalIgnoreCase ) ) : asset.Path.Contains( filter, StringComparison.OrdinalIgnoreCase ));
					if ( !archive.Assets.Any( a => a.Selected ) ) throw new InvalidOperationException( "No models matched the requested selection." );
				}
				result = await Task.Run( () => UnityImport.Run( archive, assetsRoot, ImportOptions.Auto( validateTerrain ), null, cancellation.Token, TextureChannels.Extract ) );
			}
			else
			{
				using var report = JsonDocument.Parse( File.ReadAllText( File.Exists( existing ) ? existing : Path.Combine( existing, "unity-import-report.json" ) ) );
				var baseDirectory = report.RootElement.TryGetProperty( "Destination", out var root ) ? root.GetString() : existing;
				result = new( baseDirectory, report.RootElement.GetProperty( "Assets" ).GetInt32(), report.RootElement.GetProperty( "Converted" ).GetInt32(),
					report.RootElement.GetProperty( "Files" ).EnumerateArray().Select( f => Path.Combine( baseDirectory, f.GetString() ) ).ToArray(), Array.Empty<string>() );
			}
			destination = result.Directory;
			reviewNotes = result.Warnings;
			Save( false );
			Log.Info( $"UNITY_VALIDATION imported {result.AssetCount} sources to {destination}" );
			// Match the window: register mesh sources as well as generated resources before compiling dependencies.
			foreach ( var file in result.Files.Where( f => UnityArchive.Classify( f ) is UnityAssetKind.Texture or UnityAssetKind.Model ||
				Path.GetExtension( f ) is ".vmat" or ".tmat" or ".vmdl" or ".vtex" ) ) AssetSystem.RegisterFile( file );
			foreach ( var file in result.Files.Where( f => UnityArchive.Classify( f ) == UnityAssetKind.Texture ) )
			{
				cancellation.Token.ThrowIfCancellationRequested();
				var relative = Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' );
				try
				{
					AssetSystem.RegisterFile( file );
					var texturePath = relative;
					if ( UnityImport.NeedsTextureResource( file ) )
					{
						var textureAsset = AssetSystem.RegisterFile( file + ".vtex" );
						await textureAsset.CompileIfNeededAsync();
						texturePath += ".vtex";
					}
					using var texture = Texture.Load( texturePath );
					checks.Add( new( relative, "texture", texture != null && !texture.IsError, texture == null ? "null texture" : $"{texture.Width}x{texture.Height}" ) );
				}
				catch ( Exception ex ) { checks.Add( new( relative, "texture", false, ex.Message ) ); }
				if ( checks.Count % 20 == 0 ) { Save( false ); Log.Info( $"UNITY_VALIDATION {checks.Count} checked; {checks.Count( c => !c.Success )} failed" ); }
				await Task.Delay( 1 );
			}
			// Queue a bounded number of independent material compiles on the editor context.
			using var compileSlots = new SemaphoreSlim( 4 );
			await Task.WhenAll( result.Files.Where( f => f.EndsWith( ".vmat" ) || f.EndsWith( ".tmat" ) ).Select( async file =>
			{
				await compileSlots.WaitAsync( cancellation.Token );
				try { await AssetSystem.RegisterFile( file ).CompileIfNeededAsync(); }
				finally { compileSlots.Release(); }
			} ) );
			foreach ( var file in result.Files.Where( f => f.EndsWith( ".vmat" ) || f.EndsWith( ".tmat" ) || f.EndsWith( ".vmdl" ) ) )
			{
				cancellation.Token.ThrowIfCancellationRequested();
				var relative = Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' );
				try
				{
					var asset = AssetSystem.RegisterFile( file );
					await asset.CompileIfNeededAsync();
					// The return value is false when an already compiled asset needs no work.
					if ( !asset.IsCompiled || asset.IsCompileFailed ) throw new Exception( "Asset compilation failed" );
					if ( file.EndsWith( ".vmdl" ) )
					{
						var model = await Model.LoadAsync( asset.Path );
						if ( model == null || model.IsError || model.MeshCount == 0 ) throw new Exception( "Missing/error/empty model" );
						var materials = model.Materials.ToArray();
						var missing = materials.Where( m => m == null || m.ShaderName.Contains( "error", StringComparison.OrdinalIgnoreCase ) ||
							!m.Name.StartsWith( "imported/", StringComparison.OrdinalIgnoreCase ) ||
							(m.Name.Contains( "/_defaults/unassigned", StringComparison.OrdinalIgnoreCase ) || m.Name.Contains( "/_unity_generated/defaults/unassigned", StringComparison.OrdinalIgnoreCase )) ).Select( m => m?.Name ?? "null" ).ToArray();
						using var preview = AssetPreview.CreateForAsset( asset );
						await preview.InitializeScene();
						await preview.InitializeAsset();
						preview.ScreenSize = new Vector2Int( 192, 192 );
						preview.UpdateScene( 0.125f, 0.1f );
						if ( !preview.PrimaryObject.IsValid() || preview.PrimaryObject.GetComponent<ModelRenderer>()?.Model == null )
							throw new Exception( "Model could not be placed into the preview scene" );
						using var bitmap = new Bitmap( 192, 192 );
						await preview.RenderToBitmap( bitmap );
						File.WriteAllBytes( Path.Combine( reportDirectory, $"{checks.Count:D4}-{Path.GetFileNameWithoutExtension( file )}.png" ), bitmap.ToPng() );
						checks.Add( new( relative, "model-scene", missing.Length == 0, $"{model.MeshCount} meshes; bounds {model.Bounds.Size}; materials: {string.Join( ", ", materials.Select( m => m?.Name ?? "null" ) )}; missing: {string.Join( ", ", missing )}" ) );
					}
					else if ( file.EndsWith( ".vmat" ) )
					{
						var material = await Material.LoadAsync( asset.Path );
						checks.Add( new( relative, "material", material != null && !material.ShaderName.Contains( "error", StringComparison.OrdinalIgnoreCase ), material?.ShaderName ?? "null" ) );
					}
					else checks.Add( new( relative, "terrain-material", true, "Compiled" ) );
				}
				catch ( Exception ex ) { checks.Add( new( relative, Path.GetExtension( file ), false, ex.Message ) ); }
				Save( false );
				Log.Info( $"UNITY_VALIDATION {checks.Count} checked; {checks.Count( c => !c.Success )} failed; {relative}" );
				await Task.Delay( 1 );
			}
			Save( true );
			Log.Info( $"UNITY_VALIDATION COMPLETE: {checks.Count} checked, {checks.Count( c => !c.Success )} failed. {reportDirectory}" );
		}
		catch ( OperationCanceledException ) { Save( false ); throw; }
		catch ( Exception ex ) { checks.Add( new( package, "fatal", false, ex.ToString() ) ); Save( true ); Log.Error( $"UNITY_VALIDATION FAILED {ex}" ); }
	}
}