Editor/ModelImportTransaction.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using System.Text.Json;

namespace Editor.Mcp;

internal sealed class ModelImportTransaction
{
	internal const long MaximumBytes = 1_073_741_824;
	internal const int MaximumImages = 128;
	internal const string MarkerName = ".sbox-mcp-import.json";
	private static readonly HashSet<string> ImageExtensions = new( StringComparer.OrdinalIgnoreCase )
	{
		".png", ".tga", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".exr", ".hdr"
	};
	private static readonly HashSet<string> SourceExtensions = new( StringComparer.OrdinalIgnoreCase )
	{
		".fbx", ".obj", ".dmx"
	};
	private static readonly HashSet<string> ReservedNames = new( StringComparer.OrdinalIgnoreCase )
	{
		"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
		"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
	};

	internal string TransactionId { get; } = Guid.NewGuid().ToString( "D" );
	internal string AssetsRoot { get; }
	internal string FinalDirectory { get; }
	internal string RelativeDirectory { get; }
	internal string SourceAsset { get; }
	internal string ModelAsset { get; }
	internal string MarkerAsset => JoinAsset( RelativeDirectory, MarkerName );
	internal IReadOnlyList<CopyPlan> Copies { get; }
	internal List<string> OwnedFiles { get; } = [];
	internal List<string> CompletedCopies { get; } = [];
	private readonly List<(string Path, SafeFileHandle Handle)> _directoryHandles = [];
	private readonly Dictionary<string, FileStream> _ownedStreams = new( StringComparer.OrdinalIgnoreCase );
	private FileStream _markerStream;
	internal List<string> CreatedDirectories { get; } = [];
	internal bool EngineMutationAttempted { get; set; }

	private ModelImportTransaction( string assetsRoot, string relativeDirectory, string finalDirectory,
		string sourceAsset, string modelAsset, IReadOnlyList<CopyPlan> copies )
	{
		AssetsRoot = assetsRoot;
		RelativeDirectory = relativeDirectory;
		FinalDirectory = finalDirectory;
		SourceAsset = sourceAsset;
		ModelAsset = modelAsset;
		Copies = copies;
	}

	internal static ModelImportTransaction Plan( string assetsRoot, string sourcePath, string targetDirectory,
		string modelName, bool copySiblingTextures )
	{
		if ( !OperatingSystem.IsWindows() )
			throw new ImportContractException( "ApiUnavailable", "Exclusive destination reservation is only supported on Windows." );
		if ( string.IsNullOrEmpty( assetsRoot ) )
			throw new ImportContractException( "NoActiveProject", "The active project has no Assets directory." );
		if ( string.IsNullOrWhiteSpace( sourcePath ) || !Path.IsPathFullyQualified( sourcePath ) )
			throw new ImportContractException( "InvalidInput", "sourcePath must be an absolute file path." );

		string canonicalSource;
		try { canonicalSource = Path.GetFullPath( sourcePath ); }
		catch ( Exception ) { throw new ImportContractException( "InvalidInput", "sourcePath is not a valid absolute path." ); }
		var sourceInfo = new FileInfo( canonicalSource );
		if ( !sourceInfo.Exists || (sourceInfo.Attributes & FileAttributes.Directory) != 0 )
			throw new ImportContractException( "InvalidInput", "sourcePath must identify a readable regular file." );
		if ( (sourceInfo.Attributes & FileAttributes.ReparsePoint) != 0 )
		{
			var target = sourceInfo.ResolveLinkTarget( true );
			if ( target is not FileInfo targetFile || !targetFile.Exists )
				throw new ImportContractException( "InvalidInput", "sourcePath link does not resolve to a readable regular file." );
			sourceInfo = targetFile;
			canonicalSource = targetFile.FullName;
		}
		if ( !SourceExtensions.Contains( sourceInfo.Extension ) )
			throw new ImportContractException( "InvalidInput", "sourcePath must have an .fbx, .obj, or .dmx extension." );
		using ( File.Open( canonicalSource, FileMode.Open, FileAccess.Read, FileShare.Read ) ) { }

		var effectiveName = string.IsNullOrEmpty( modelName ) ? Path.GetFileNameWithoutExtension( sourceInfo.Name ) : modelName;
		ValidateFileName( effectiveName, false );
		if ( effectiveName.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) )
			throw new ImportContractException( "InvalidInput", "modelName must not include a .vmdl suffix." );
		ValidateFileName( sourceInfo.Name, true );

		var relative = NormalizeTarget( targetDirectory );
		var canonicalRoot = Path.TrimEndingDirectorySeparator( Path.GetFullPath( assetsRoot ) );
		var final = Path.GetFullPath( Path.Combine( canonicalRoot, relative.Replace( '/', Path.DirectorySeparatorChar ) ) );
		EnsureContained( canonicalRoot, final );
		EnsureNoReparsePoints( canonicalRoot, final );
		if ( Directory.Exists( final ) || File.Exists( final ) )
			throw new ImportContractException( "DestinationExists", "The final import directory already exists." );

		var candidates = new List<FileInfo> { sourceInfo };
		if ( copySiblingTextures )
		{
			IEnumerable<FileInfo> siblings;
			try { siblings = sourceInfo.Directory!.EnumerateFiles().Where( x => ImageExtensions.Contains( x.Extension ) ); }
			catch ( Exception ) { throw new ImportContractException( "InvalidInput", "Sibling image files could not be enumerated." ); }
			candidates.AddRange( siblings.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase ) );
		}
		if ( candidates.Count - 1 > MaximumImages )
			throw new ImportContractException( "LimitExceeded", "The import selects more than 128 sibling images." );

		var names = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		long total = 0;
		var copies = new List<CopyPlan>( candidates.Count );
		foreach ( var file in candidates )
		{
			ValidateFileName( file.Name, true );
			if ( (file.Attributes & FileAttributes.ReparsePoint) != 0 )
				throw new ImportContractException( "InvalidInput", $"Selected file '{file.Name}' is a link." );
			if ( !names.Add( file.Name ) )
				throw new ImportContractException( "InvalidInput", "Selected filenames collide case-insensitively." );
			try { total = checked(total + file.Length); }
			catch ( OverflowException ) { throw new ImportContractException( "LimitExceeded", "Selected files exceed the byte limit." ); }
			if ( total > MaximumBytes )
				throw new ImportContractException( "LimitExceeded", "Selected files exceed 1,073,741,824 bytes." );
			copies.Add( new CopyPlan( file.FullName, Path.Combine( final, file.Name ), JoinAsset( relative, file.Name ), file.Length ) );
		}

		var modelFile = effectiveName + ".vmdl";
		ValidateFileName( modelFile, true );
		if ( !names.Add( modelFile ) || !names.Add( MarkerName ) )
			throw new ImportContractException( "InvalidInput", "Planned output filenames collide case-insensitively." );
		return new ModelImportTransaction( canonicalRoot, relative, final,
			JoinAsset( relative, sourceInfo.Name ), JoinAsset( relative, modelFile ), copies );
	}

	internal void ReserveDirectory()
	{
		var parent = Path.GetDirectoryName( FinalDirectory )!;
		var chain = new Stack<string>();
		for ( var cursor = parent; ; cursor = Path.GetDirectoryName( cursor )! )
		{
			chain.Push( cursor );
			if ( string.Equals( cursor, AssetsRoot, StringComparison.OrdinalIgnoreCase ) ) break;
		}
		while ( chain.Count > 0 )
		{
			var directory = chain.Pop();
			if ( !Directory.Exists( directory ) )
			{
				if ( CreateDirectoryW( directory, IntPtr.Zero ) ) CreatedDirectories.Add( directory );
				else
				{
					var error = Marshal.GetLastWin32Error();
					if ( error != 183 || !Directory.Exists( directory ) )
						throw new ImportContractException( "CopyFailed", $"A destination ancestor could not be reserved (Windows error {error})." );
				}
			}
			LockDirectory( directory );
		}
		if ( !CreateDirectoryW( FinalDirectory, IntPtr.Zero ) )
		{
			var error = Marshal.GetLastWin32Error();
			throw new ImportContractException( error == 183 ? "DestinationExists" : "CopyFailed",
				error == 183 ? "The final import directory was created by another operation." : $"The final import directory could not be reserved (Windows error {error})." );
		}
		CreatedDirectories.Add( FinalDirectory );
		LockDirectory( FinalDirectory );
	}

	internal IReadOnlyList<string> CopyInputs()
	{
		long streamed = 0;
		var buffer = new byte[128 * 1024];
		foreach ( var copy in Copies )
		{
			try
			{
				using var input = new FileStream( copy.SourceAbsolute, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, FileOptions.SequentialScan );
				var output = OpenOwnedFile( copy.DestinationAbsolute );
				OwnedFiles.Add( copy.DestinationAbsolute );
				_ownedStreams.Add( copy.DestinationAbsolute, output );
				int read;
				while ( (read = input.Read( buffer, 0, buffer.Length )) != 0 )
				{
					streamed = checked(streamed + read);
					if ( streamed > MaximumBytes )
						throw new ImportContractException( "LimitExceeded", "Selected files grew beyond 1,073,741,824 bytes while copying." );
					output.Write( buffer, 0, read );
				}
				output.Flush( true );
				CompletedCopies.Add( copy.DestinationAsset );
			}
			catch ( ImportContractException ) { throw; }
			catch ( Exception ) { throw new ImportContractException( "CopyFailed", $"Failed to copy '{copy.DestinationAsset}'." ); }
		}
		return CompletedCopies;
	}

	internal void WriteMarker( string writerState )
	{
		var marker = Path.Combine( FinalDirectory, MarkerName );
		var json = JsonSerializer.Serialize( new Marker( 1, TransactionId, SourceAsset, ModelAsset, writerState ) );
		if ( _markerStream is null )
		{
			_markerStream = OpenOwnedFile( marker );
			OwnedFiles.Add( marker );
			_ownedStreams.Add( marker, _markerStream );
		}
		_markerStream.Position = 0;
		_markerStream.SetLength( 0 );
		using ( var writer = new StreamWriter( _markerStream, System.Text.Encoding.UTF8, 1024, true ) )
		{
			writer.Write( json );
			writer.Flush();
		}
		_markerStream.Flush( true );
	}

	internal IReadOnlyList<string> CleanupPreEngine()
	{
		var remaining = new List<string>();
		var handleOwned = _ownedStreams.Keys.ToHashSet( StringComparer.OrdinalIgnoreCase );
		foreach ( var owned in _ownedStreams.ToArray() )
		{
			var disposition = new FileDispositionInfo { DeleteFile = true };
			try
			{
				if ( !SetFileInformationByHandle( owned.Value.SafeFileHandle, 4, ref disposition, (uint)Marshal.SizeOf<FileDispositionInfo>() ) )
					remaining.Add( ToReportedPath( owned.Key ) );
			}
			catch { remaining.Add( ToReportedPath( owned.Key ) ); }
			finally
			{
				try { owned.Value.Dispose(); }
				catch { remaining.Add( ToReportedPath( owned.Key ) ); }
			}
		}
		_ownedStreams.Clear();
		_markerStream = null;
		foreach ( var file in OwnedFiles.Where( x => !handleOwned.Contains( x ) ) )
			if ( File.Exists( file ) ) remaining.Add( ToReportedPath( file ) );
		foreach ( var directory in CreatedDirectories )
			if ( Directory.Exists( directory ) ) remaining.Add( ToReportedPath( directory ) );
		foreach ( var entry in _directoryHandles.ToArray() )
		{
			try { entry.Handle.Dispose(); }
			catch { remaining.Add( ToReportedPath( entry.Path ) ); }
		}
		_directoryHandles.Clear();
		return remaining.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();
	}

	internal string[] PendingPaths( IEnumerable<string> generated ) => new[] { RelativeDirectory }
		.Concat( OwnedFiles.Select( ToReportedPath ) ).Concat( generated )
		.Append( SourceAsset ).Append( ModelAsset )
		.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();

	internal string ToReportedPath( string absolute ) => IsContained( AssetsRoot, absolute )
		? Path.GetRelativePath( AssetsRoot, absolute ).Replace( '\\', '/' ) : absolute;

	internal static string NormalizeTarget( string target )
	{
		if ( string.IsNullOrWhiteSpace( target ) || target != target.Trim() )
			throw new ImportContractException( "InvalidInput", "targetDirectory must be a non-empty relative path without surrounding whitespace." );
		var normalized = target.Replace( '\\', '/' );
		if ( Path.IsPathRooted( target ) || normalized.StartsWith( "//", StringComparison.Ordinal ) || normalized.Contains( ':' ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory must be relative to Assets." );
		var parts = normalized.Split( '/', StringSplitOptions.None );
		if ( parts.Length == 0 || parts.Any( x => x.Length == 0 || x is "." or ".." ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory contains an empty, current, or parent component." );
		if ( parts[0].Equals( "Assets", StringComparison.OrdinalIgnoreCase ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory must not include an Assets prefix." );
		foreach ( var part in parts ) ValidateFileName( part, true );
		return string.Join( '/', parts );
	}

	internal static void ValidateFileName( string name, bool extensionAllowed )
	{
		if ( string.IsNullOrWhiteSpace( name ) || name != name.Trim() || name.EndsWith( ".", StringComparison.Ordinal ) )
			throw new ImportContractException( "InvalidInput", "A planned filename is empty, whitespace-padded, or ends in a dot." );
		if ( name.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 || name.Contains( '/' ) || name.Contains( '\\' ) )
			throw new ImportContractException( "InvalidInput", "A planned filename contains invalid characters or separators." );
		if ( !extensionAllowed && Path.GetFileName( name ) != name )
			throw new ImportContractException( "InvalidInput", "modelName must be a filename stem." );
		var deviceStem = name.Split( '.', 2 )[0];
		if ( ReservedNames.Contains( deviceStem ) )
			throw new ImportContractException( "InvalidInput", "A planned filename is a reserved Windows device name." );
	}

	internal static void EnsureNoReparsePoints( string root, string destination )
	{
		for ( var cursor = destination; !string.Equals( cursor, root, StringComparison.OrdinalIgnoreCase ); cursor = Path.GetDirectoryName( cursor )! )
		{
			if ( !Directory.Exists( cursor ) ) continue;
			if ( (File.GetAttributes( cursor ) & FileAttributes.ReparsePoint) != 0 )
				throw new ImportContractException( "InvalidInput", "The destination chain contains a symbolic link or junction." );
		}
	}
	private void LockDirectory( string directory )
	{
		var handle = CreateFileHandle( directory, 0x80000000, 0x00000001 | 0x00000002, IntPtr.Zero, 3,
			0x02000000 | 0x00200000, IntPtr.Zero );
		if ( handle.IsInvalid ) throw new ImportContractException( "CopyFailed", "The destination ancestry could not be locked against replacement." );
		if ( !GetFileInformationByHandle( handle, out var information ) || (information.FileAttributes & 0x400) != 0 )
		{
			handle.Dispose();
			throw new ImportContractException( "InvalidInput", "The destination chain contains or changed to a symbolic link or junction." );
		}
		_directoryHandles.Add( (directory, handle) );
	}

	private static FileStream OpenOwnedFile( string path )
	{
		var handle = CreateFileHandle( path, 0x80000000 | 0x40000000 | 0x00010000, 0x00000001,
			IntPtr.Zero, 1, 0x08000000, IntPtr.Zero );
		if ( handle.IsInvalid )
		{
			var error = Marshal.GetLastWin32Error();
			handle.Dispose();
			throw new IOException( $"Exclusive file creation failed (Windows error {error})." );
		}
		return new FileStream( handle, FileAccess.ReadWrite, 128 * 1024, false );
	}

	private void DisposeHandles()
	{
		foreach ( var stream in _ownedStreams.Values ) stream.Dispose();
		_ownedStreams.Clear();
		_markerStream = null;
		foreach ( var entry in _directoryHandles ) entry.Handle.Dispose();
		_directoryHandles.Clear();
	}

	internal void Dispose() => DisposeHandles();
	internal void PrepareForEngineMutation()
	{
		foreach ( var stream in _ownedStreams.Values ) stream.Dispose();
		_ownedStreams.Clear();
		_markerStream = null;
	}


	internal static bool IsContained( string root, string path )
	{
		var prefix = Path.TrimEndingDirectorySeparator( Path.GetFullPath( root ) ) + Path.DirectorySeparatorChar;
		var full = Path.GetFullPath( path );
		return full.StartsWith( prefix, StringComparison.OrdinalIgnoreCase );
	}

	private static void EnsureContained( string root, string path )
	{
		if ( !IsContained( root, path ) ) throw new ImportContractException( "InvalidInput", "targetDirectory escapes the active Assets root." );
	}

	private static string JoinAsset( string directory, string name ) => $"{directory.TrimEnd( '/' )}/{name}";

	[DllImport( "kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool CreateDirectoryW( string path, IntPtr securityAttributes );
	[DllImport( "kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateFileW" )]
	private static extern SafeFileHandle CreateFileHandle( string fileName, uint desiredAccess, uint shareMode,
		IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile );
	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out ByHandleFileInformation information );

	[StructLayout( LayoutKind.Sequential )]
	private struct ByHandleFileInformation
	{
		public uint FileAttributes;
		public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
		public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
		public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
		public uint VolumeSerialNumber;
		public uint FileSizeHigh;
		public uint FileSizeLow;
		public uint NumberOfLinks;
		public uint FileIndexHigh;
		public uint FileIndexLow;
	}
	[StructLayout( LayoutKind.Sequential )]
	private struct FileDispositionInfo
	{
		[MarshalAs( UnmanagedType.Bool )]
		public bool DeleteFile;
	}

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool SetFileInformationByHandle( SafeFileHandle handle, int fileInformationClass,
		ref FileDispositionInfo fileInformation, uint bufferSize );




	internal sealed record CopyPlan( string SourceAbsolute, string DestinationAbsolute, string DestinationAsset, long PreflightLength );
	internal sealed record Marker( int Version, string TransactionId, string SourceAsset, string ModelAsset, string WriterState );
}

internal class ImportContractException : Exception
{
	internal string Code { get; }
	internal ImportContractException( string code, string message ) : base( message ) => Code = code;
}