Editor/UnityPackageWindow.cs
using System;

using System.IO;

using System.Linq;

using System.Threading;

using System.Threading.Tasks;

using Editor;

using Sandbox;



namespace ImportUnityPackage;



public sealed class UnityPackageWindow : Widget

{

	UnityArchive archive;

	ImportCatalog catalog;

	ImportPlan plan;

	UnityAsset describedAsset;

	CancellationTokenSource cancellation;

	Task<UnityArchive> readTask;

	Task<ImportResult> importTask;

	ImportPreparationResult preparation;

	ImportCompletion completion;

	string reportPath;

	readonly Button showReport;

	volatile ImportProgress latestProgress;

	bool closeWhenCancelled;

	readonly Button browse;

	readonly Button confirm;

	readonly Button cancel;

	readonly Label packageLabel;

	readonly Label status;

	readonly Label selection;

	readonly Checkbox additionalTerrain;

	readonly Button selectAll;

	readonly Button selectNone;

	readonly Button expandAll;

	readonly Button collapseAll;

	readonly IconButton expandLayer;

	readonly IconButton collapseLayer;

	readonly Button advancedToggle;

	readonly Widget advanced;

	readonly Label details;

	readonly UnityAssetTree tree;

	readonly Widget progressBar;

	bool Busy => readTask != null || importTask != null;

	ImportOptions Options => ImportOptions.Auto( additionalTerrain.Value );



	public UnityPackageWindow() : base( null, true )

	{

		WindowTitle = "Import Unity Package";

		SetWindowIcon( "move_to_inbox" );

		Size = new Vector2( 760, 680 );

		MinimumSize = new Vector2( 580, 500 );

		Layout = Layout.Column();

		Layout.Margin = 16;

		Layout.Spacing = 10;

		var fileRow = Layout.AddRow();

		fileRow.Spacing = 8;

		packageLabel = fileRow.Add( new Label( "Choose a .unitypackage file" ), 1 );

		browse = fileRow.Add( new Button( "Browse…" ) );

		browse.Clicked = Browse;

		var contents = Layout.AddRow();

		contents.Spacing = 8;

		contents.Add( new Label( "Package contents" ), 1 );

		expandAll = contents.Add( new Button( "Expand all" ) );

		collapseAll = contents.Add( new Button( "Collapse all" ) );

		expandAll.Clicked = () => tree.SetExpanded( true );

		collapseAll.Clicked = () => tree.SetExpanded( false );

		collapseLayer = contents.Add( new IconButton( "remove", () => tree.ChangeExpandedLayer( false ) ) { ToolTip = "Collapse one layer" } );

		expandLayer = contents.Add( new IconButton( "add", () => tree.ChangeExpandedLayer( true ) ) { ToolTip = "Expand one layer" } );

		var shortcuts = Layout.AddRow();

		shortcuts.Spacing = 8;

		selectAll = shortcuts.Add( new Button( "Select all" ) );

		selectNone = shortcuts.Add( new Button( "Clear" ) );

		shortcuts.AddStretchCell();

		selectAll.Clicked = () => SetSelection( a => a.Kind != UnityAssetKind.Unsupported );

		selectNone.Clicked = () => SetSelection( _ => false );

		var instructions = Layout.Add( new Label( "Select files or folders. Required materials and textures are included automatically; original sources are kept." ) );

		instructions.WordWrap = true;

		tree = Layout.Add( new UnityAssetTree( this, () => plan, RefreshSelection, DescribeAsset ), 1 );

		tree.MinimumHeight = 200;

		details = Layout.Add( new Label( "Select a row to see its output and dependency details." ) );

		details.WordWrap = true;

		selection = Layout.Add( new Label( "No package loaded." ) );

		selection.WordWrap = true;

		advancedToggle = Layout.Add( new Button( "Advanced ▸" ) );

		advanced = Layout.Add( new Widget( this ) );

		advanced.Layout = Layout.Column();

		additionalTerrain = advanced.Layout.Add( new Checkbox( "Also generate terrain materials (TMAT)" ) );

		additionalTerrain.ToolTip = "Adds TMAT output while keeping VMATs for models. Unity terrain layers always generate TMATs. This does not recreate Unity terrain.";

		additionalTerrain.StateChanged += _ => RefreshSelection();

		advanced.Visible = false;

		advancedToggle.Clicked = () => { advanced.Visible = !advanced.Visible; advancedToggle.Text = advanced.Visible ? "Advanced ▾" : "Advanced ▸"; };

		var scope = Layout.Add( new Label( "Custom shader appearance and Unity prefab assembly are not reproduced. Further conversion issues appear in the import report." ) );

		scope.WordWrap = true;

		progressBar = Layout.Add( new Widget( this ) { FixedHeight = 16 } );

		progressBar.OnPaintOverride = PaintProgress;

		status = Layout.Add( new Label( "Destination: original package folders under Assets/Imported." ) );

		status.WordWrap = true;

		var buttons = Layout.AddRow();

		buttons.Spacing = 8;

		showReport = buttons.Add( new Button( "Show report" ) { Visible = false } );

		showReport.Clicked = () => EditorUtility.OpenFolder( reportPath );

		buttons.AddStretchCell();

		cancel = buttons.Add( new Button( "Cancel" ) );

		cancel.Clicked = Cancel;

		confirm = buttons.Add( new Button.Primary( "Confirm import" ) );

		confirm.Clicked = Import;

		RefreshSelection();

	}



	public void Browse()

	{

		if ( Busy ) return;

		var dialog = new FileDialog( this ) { Title = "Import Unity Package", DefaultSuffix = ".unitypackage" };

		dialog.SetFindFile();

		dialog.SetNameFilter( "Unity Package (*.unitypackage)" );

		var appData = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData );

		var unityDirectory = Path.Combine( appData, "Unity" );

		if ( !string.IsNullOrEmpty( appData ) && Directory.Exists( unityDirectory ) )

			dialog.Directory = unityDirectory;

		if ( !dialog.Execute() ) return;

		LoadPackage( dialog.SelectedFile );

	}



	/// <summary>Load a package into the selection window, including from other editor tools.</summary>

	public void LoadPackage( string file )

	{

		if ( Busy ) return;

		archive?.Dispose();

		archive = null;

		catalog = null;

		plan = null;

		describedAsset = null;

		tree.SetItems( Array.Empty<TreeNode>() );

		completion = null;

		showReport.Visible = false;

		WindowTitle = "Import Unity Package";

		packageLabel.Text = Path.GetFileName( file );

		packageLabel.ToolTip = file;

		cancellation = new CancellationTokenSource();

		latestProgress = new( 0, "Reading package…" );

		readTask = Task.Run( () =>

		{

			var loaded = UnityArchive.Read( file, new ProgressSink( this ), cancellation.Token );

			try

			{

				latestProgress = new( 1, "Resolving package dependencies…" );

				catalog = ImportCatalog.Read( loaded, cancellation.Token );

				return loaded;

			}

			catch { loaded.Dispose(); throw; }

		} );

		RefreshSelection();

	}



	void Import()

	{

		if ( Busy || plan == null || plan.Assets.Count == 0 ) return;

		var assets = Project.Current?.GetAssetsPath();

		if ( string.IsNullOrEmpty( assets ) )

		{

			EditorUtility.DisplayDialog( "Import unavailable", "Open a local s&box project before importing." );

			return;

		}

		var confirmedPlan = plan;

		cancellation = new CancellationTokenSource();

		latestProgress = new( 0, "Preparing import…" );

		preparation = null;

		completion = null;

		showReport.Visible = false;

		WindowTitle = "Import Unity Package";

		importTask = ImportAndPrepare( confirmedPlan, assets, cancellation.Token );

		RefreshSelection();

	}



	async Task<ImportResult> ImportAndPrepare( ImportPlan confirmedPlan, string assets, CancellationToken token )

	{

		using var merge = await Task.Run( () => ImportMergePlan.Create( confirmedPlan, assets, token ) );

		latestProgress = new( 0, "Review destination files…" );

		await ImportConflictWindow.Review( "Review import destinations", merge.Destination, merge.Sources, token );

		await Task.Run( () => merge.Prepare( new ProgressSink( this, 0, 0.75 ), token, TextureChannels.Extract ) );

		var generatedConflicts = merge.Outputs.Where( c => c.Conflict && !merge.Sources.Any( a => a.Path.Equals( c.Path, StringComparison.OrdinalIgnoreCase ) ) ).ToArray();

		if ( generatedConflicts.Length > 0 )

		{

			latestProgress = new( 0.75, "Review converted resource conflicts…" );

			await ImportConflictWindow.Review( "Review converted resources", merge.Destination, generatedConflicts, token );

		}

		var result = await Task.Run( () => merge.Commit( new ProgressSink( this, 0, 0.8 ), token ) );

		latestProgress = new( 0.8, "Preparing imported resources…" );

		preparation = await ImportAssetPreparation.RunImport( result, new ProgressSink( this, 0.8, 0.2 ), token );

		return result;

	}



	void Cancel()

	{

		if ( !Busy ) { Close(); return; }

		cancellation.Cancel();

		cancel.Enabled = false;

		status.Text = "Cancelling…";

	}



	[EditorEvent.Frame]

	void Tick()

	{

		if ( !IsValid ) return;

		if ( Busy && latestProgress != null )

		{

			status.Text = cancellation.IsCancellationRequested ? "Cancelling…" : $"{latestProgress.Fraction:P0} — {latestProgress.Message}";

			progressBar.Update();

		}

		if ( readTask?.IsCompleted == true )

		{

			var task = readTask;

			readTask = null;

			FinishOperation( () =>

			{

				archive = task.GetAwaiter().GetResult();

				tree.Load( archive.Assets );

				status.Text = "Ready. Original folders will be preserved under Assets/Imported. Existing files are reviewed before writing.";

			} );

		}

		if ( importTask?.IsCompleted == true )

		{

			var task = importTask;

			importTask = null;

			FinishOperation( () =>

			{

				var result = task.GetAwaiter().GetResult();

				SetCompletion( result );

				EditorUtility.DisplayDialog( completion.Title, completion.Message );

			} );

		}

	}



	void SetCompletion( ImportResult result )

	{

		completion = ImportCompletion.Create( result, preparation?.Warnings, preparation?.Errors, preparation?.Cancelled == true );

		foreach ( var error in completion.Errors ) Log.Error( error );

		// Detailed compatibility warnings belong in the report, not hundreds of console lines.

		Log.Info( $"{completion.Title}: {result.AssetCount} assets. {completion.Counts}. Report: {result.ReportPath}" );

		reportPath = result.ReportPath;

		try { completion.WriteReport( reportPath ); }

		catch ( Exception ex ) { Log.Warning( $"Could not append completion details to the import report: {ex.Message}" ); }

		showReport.Visible = !string.IsNullOrWhiteSpace( reportPath ) && File.Exists( reportPath );

		ApplyCompletion();

	}



	void ApplyCompletion()

	{

		status.Text = completion.Status;

		WindowTitle = $"Import Unity Package — {completion.Title}";

		if ( !completion.Cancelled ) latestProgress = new( 1, completion.Title );

		progressBar.Update();

	}



	void FinishOperation( Action action )

	{

		try { action(); }

		catch ( OperationCanceledException ex )

		{

			status.Text = "Cancelled. No import changes were kept.";

			if ( ex.Data["StagingCleanupError"] is string cleanup ) { status.Text += " Temporary files could not be removed; see the console."; Log.Warning( cleanup ); }

		}

		catch ( Exception ex )

		{

			status.Text = $"Import failed: {ex.Message}";

			Log.Error( $"{status.Text}\n{ex}" );

			if ( ex.Data["StagingCleanupError"] is string cleanup ) Log.Warning( cleanup );

			EditorUtility.DisplayDialog( "Unity package import failed", ex.Message );

		}

		finally

		{

			cancellation.Dispose();

			cancellation = null;

			RefreshSelection();

			if ( closeWhenCancelled ) Close();

		}

	}



	void SetSelection( Func<UnityAsset, bool> select )

	{

		if ( Busy || archive == null ) return;

		foreach ( var asset in archive.Assets ) asset.Selected = select( asset );

		RefreshSelection();

	}



	void DescribeAsset( UnityAsset asset )

	{

		describedAsset = asset;

		if ( asset == null ) { details.Text = "Select a row to see its output and dependency details."; return; }

		var entry = plan?.Find( asset );

		var reasons = entry?.RequiredBy.Select( d => $"{d.Asset.Path} ({d.Reason})" ).Distinct().ToArray() ?? Array.Empty<string>();

		var problems = plan?.Issues.Where( i => i.Asset == asset.Path ).Select( i => i.Message ).ToArray() ?? Array.Empty<string>();

		details.Text = asset.Path + "\n" + (entry?.OutputLabel ?? (asset.Kind == UnityAssetKind.Unsupported ? "Unsupported" : "Not included")) +

			(reasons.Length == 0 ? "" : " · Required by: " + string.Join( "; ", reasons.Take( 3 ) ) + (reasons.Length > 3 ? $"; +{reasons.Length - 3} more" : "")) +

			(problems.Length == 0 ? "" : "\n" + string.Join( " ", problems.Take( 2 ) ));

		details.ToolTip = string.Join( "\n", reasons.Concat( problems ) );

	}



	void RefreshSelection()

	{

		if ( confirm == null ) return;

		browse.Enabled = tree.Enabled = additionalTerrain.Enabled = advancedToggle.Enabled = !Busy;

		selectAll.Enabled = selectNone.Enabled = expandAll.Enabled = collapseAll.Enabled = !Busy && archive != null;

		expandLayer.Enabled = collapseLayer.Enabled = !Busy && archive != null;

		cancel.Enabled = !Busy || cancellation?.IsCancellationRequested != true;

		cancel.Text = Busy ? "Cancel operation" : completion == null ? "Cancel" : "Done";

		if ( !Busy ) plan = catalog?.CreatePlan( Options );

		confirm.Enabled = !Busy && plan?.Assets.Count > 0;

		confirm.Text = completion == null ? "Review import" : "Import again";

		selection.Text = plan == null ? "No package loaded." :

			$"{plan.Assets.Count( a => a.Explicit ):N0} selected + {plan.Assets.Count( a => !a.Explicit ):N0} dependencies = {plan.Assets.Count:N0} files to import\n" +

			$"Outputs: {plan.Assets.Count( a => a.Vmdl ):N0} VMDL · {plan.Assets.Count( a => a.Vmat ):N0} VMAT · {plan.Assets.Count( a => a.Tmat ):N0} TMAT · {plan.Issues.Count:N0} reference / source issues · {archive.Assets.Count( a => a.Kind == UnityAssetKind.Unsupported ):N0} unsupported files";

		selection.ToolTip = plan == null ? "" : string.Join( "\n", plan.Issues.Select( i => $"{i.Asset}: {i.Message}" ) );

		DescribeAsset( describedAsset );

		tree.Update();

	}



	bool PaintProgress()

	{

		Paint.ClearPen();

		Paint.SetBrush( Theme.ControlBackground );

		Paint.DrawRect( progressBar.LocalRect, 3 );

		var rect = progressBar.LocalRect;

		rect.Width *= (float)Math.Clamp( latestProgress?.Fraction ?? 0, 0, 1 );

		Paint.SetBrush( Theme.Blue );

		Paint.DrawRect( rect, 3 );

		return false;

	}



	protected override bool OnClose()

	{

		if ( Busy ) { closeWhenCancelled = true; Cancel(); return false; }

		archive?.Dispose();

		archive = null;

		return true;

	}



	sealed class ProgressSink( UnityPackageWindow window, double start = 0, double scale = 1 ) : IProgress<ImportProgress>

	{

		public void Report( ImportProgress value ) => window.latestProgress = new( start + scale * value.Fraction, value.Message );

	}

}