Editor/ConnecterImporter.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace Editor;

public sealed record ConnecterEditorImportResult( ConnecterImportResult Result, Asset PrimaryAsset );

public static class ConnecterImporter
{
	private static readonly string[] TextureSuffixes =
	[
		"_basecolor", "_base_color", "_albedo", "_color", "_diffuse", "_diff",
		"_normal", "_normaldx", "_normalgl", "_nrm",
		"_roughness", "_rough", "_metallic", "_metal",
		"_ambientocclusion", "_ao", "_height", "_displacement",
		"_opacity", "_alpha", "_emissive", "_selfillum", "_mask", "_trans"
	];

	public static ConnecterEditorImportResult Import( ConnecterAssetRecord record, ConnecterImportOptions options )
	{
		if ( record is null )
			throw new ArgumentNullException( nameof( record ) );

		if ( options is null )
			throw new ArgumentNullException( nameof( options ) );

		if ( !record.CanImport )
			return new ConnecterEditorImportResult( new ConnecterImportResult( record.FullPath, null, null, false, record.Warning ), null );

		Directory.CreateDirectory( options.ProjectAssetsPath );

		if ( record.IsDirectory )
			return ImportDirectory( record, options );

		return ImportFile( record, options );
	}

	public static (int Count, long Bytes) GetDirectoryStats( string directory )
	{
		var count = 0;
		var bytes = 0L;

		foreach ( var file in SafeEnumerateFiles( directory, true ) )
		{
			try
			{
				var info = new FileInfo( file );
				count++;
				bytes += info.Length;
			}
			catch
			{
			}
		}

		return (count, bytes);
	}

	private static ConnecterEditorImportResult ImportDirectory( ConnecterAssetRecord record, ConnecterImportOptions options )
	{
		var parentDestination = ConnecterPathUtility.GetImportDirectory( record, options );
		var destinationDirectory = Path.Combine( parentDestination, ConnecterPathUtility.SanitizePathSegment( record.Name ) );

		EnsureDestinationInsideProject( options.ProjectAssetsPath, destinationDirectory );
		CopyDirectory( record.FullPath, destinationDirectory, options.OverwriteChangedFiles );

		Asset firstAsset = null;
		foreach ( var file in SafeEnumerateFiles( destinationDirectory, true ) )
		{
			var kind = ConnecterAssetClassifier.Classify( file );
			var primaryPath = file;

			if ( kind == ConnecterAssetKind.ModelSource )
			{
				primaryPath = CreateModelDoc( options.ProjectAssetsPath, file );
				AssetSystem.RegisterFile( file );
			}

			var asset = AssetSystem.RegisterFile( primaryPath );
			_ = asset?.CompileIfNeededAsync();
			firstAsset ??= asset;
		}

		return new ConnecterEditorImportResult(
			new ConnecterImportResult( record.FullPath, destinationDirectory, firstAsset?.AbsolutePath, true, $"Imported folder to {destinationDirectory}" ),
			firstAsset );
	}

	private static ConnecterEditorImportResult ImportFile( ConnecterAssetRecord record, ConnecterImportOptions options )
	{
		var destinationDirectory = ConnecterPathUtility.GetImportDirectory( record, options );
		EnsureDestinationInsideProject( options.ProjectAssetsPath, destinationDirectory );
		Directory.CreateDirectory( destinationDirectory );

		var copiedFiles = new List<string>();
		foreach ( var source in GetFileImportSet( record, options.CopyFolderDependencies ) )
		{
			var destination = Path.Combine( destinationDirectory, Path.GetFileName( source ) );
			CopyFileIfNeeded( source, destination, options.OverwriteChangedFiles );
			copiedFiles.Add( destination );
		}

		var importedSource = copiedFiles.FirstOrDefault( x => string.Equals( Path.GetFileName( x ), record.Name, StringComparison.OrdinalIgnoreCase ) )
			?? copiedFiles.FirstOrDefault();

		if ( importedSource is null )
		{
			return new ConnecterEditorImportResult(
				new ConnecterImportResult( record.FullPath, null, null, false, "No files were imported." ),
				null );
		}

		var primaryPath = importedSource;
		if ( record.Kind == ConnecterAssetKind.ModelSource )
		{
			primaryPath = CreateModelDoc( options.ProjectAssetsPath, importedSource );
		}

		var primaryAsset = AssetSystem.RegisterFile( primaryPath );
		if ( !string.Equals( primaryPath, importedSource, StringComparison.OrdinalIgnoreCase ) )
		{
			AssetSystem.RegisterFile( importedSource );
		}

		_ = primaryAsset?.CompileIfNeededAsync();

		return new ConnecterEditorImportResult(
			new ConnecterImportResult( record.FullPath, importedSource, primaryPath, true, $"Imported {record.Name}" ),
			primaryAsset );
	}

	private static string CreateModelDoc( string projectAssetsPath, string importedSourcePath )
	{
		var relativeSource = ConnecterPathUtility.GetAssetRelativePath( projectAssetsPath, importedSourcePath );
		var modelPath = Path.Combine( Path.GetDirectoryName( importedSourcePath )!, $"{Path.GetFileNameWithoutExtension( importedSourcePath )}.vmdl" );
		var modelDoc = ConnecterModelDocBuilder.CreateModelDoc( relativeSource, Path.GetFileNameWithoutExtension( importedSourcePath ) );

		File.WriteAllText( modelPath, modelDoc );
		return modelPath;
	}

	private static IReadOnlyList<string> GetFileImportSet( ConnecterAssetRecord record, bool includeDependencies )
	{
		if ( !includeDependencies )
			return [record.FullPath];

		var result = new HashSet<string>( StringComparer.OrdinalIgnoreCase )
		{
			record.FullPath
		};

		var folder = Path.GetDirectoryName( record.FullPath );
		if ( string.IsNullOrWhiteSpace( folder ) || !Directory.Exists( folder ) )
			return result.ToList();

		var extension = Path.GetExtension( record.FullPath );
		var baseName = Path.GetFileNameWithoutExtension( record.FullPath );

		foreach ( var sibling in SafeEnumerateFiles( folder, false ) )
		{
			var siblingExtension = Path.GetExtension( sibling );
			var siblingBase = Path.GetFileNameWithoutExtension( sibling );

			if ( siblingExtension.Equals( ".mtl", StringComparison.OrdinalIgnoreCase )
				&& (siblingBase.Equals( baseName, StringComparison.OrdinalIgnoreCase ) || extension.Equals( ".obj", StringComparison.OrdinalIgnoreCase )) )
			{
				result.Add( sibling );
			}

			if ( ConnecterAssetClassifier.IsTextureDependency( sibling ) )
			{
				if ( GetTextureFamilyName( siblingBase ).Equals( GetTextureFamilyName( baseName ), StringComparison.OrdinalIgnoreCase )
					|| siblingBase.Contains( baseName, StringComparison.OrdinalIgnoreCase )
					|| baseName.Contains( siblingBase, StringComparison.OrdinalIgnoreCase ) )
				{
					result.Add( sibling );
				}
			}
		}

		foreach ( var mtl in result.Where( x => Path.GetExtension( x ).Equals( ".mtl", StringComparison.OrdinalIgnoreCase ) ).ToArray() )
		{
			foreach ( var texture in ReadMaterialTextureReferences( folder, mtl ) )
			{
				result.Add( texture );
			}
		}

		return result.ToList();
	}

	private static string GetTextureFamilyName( string name )
	{
		var lower = name.ToLowerInvariant();
		lower = Regex.Replace( lower, @"_\d+$", "" );

		foreach ( var suffix in TextureSuffixes.OrderByDescending( x => x.Length ) )
		{
			if ( lower.EndsWith( suffix ) )
				return lower[..^suffix.Length];
		}

		return lower;
	}

	private static IEnumerable<string> ReadMaterialTextureReferences( string sourceFolder, string materialPath )
	{
		foreach ( var line in File.ReadLines( materialPath ) )
		{
			var trimmed = line.Trim();
			if ( trimmed.Length == 0 || trimmed.StartsWith( "#" ) )
				continue;

			if ( !trimmed.StartsWith( "map_", StringComparison.OrdinalIgnoreCase ) && !trimmed.StartsWith( "bump", StringComparison.OrdinalIgnoreCase ) )
				continue;

			var parts = trimmed.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
			var pathPart = parts.LastOrDefault();
			if ( string.IsNullOrWhiteSpace( pathPart ) )
				continue;

			var candidate = Path.GetFullPath( Path.Combine( sourceFolder, pathPart.Trim( '"' ) ) );
			if ( File.Exists( candidate ) )
				yield return candidate;
		}
	}

	private static void CopyDirectory( string sourceDirectory, string destinationDirectory, bool overwriteChangedFiles )
	{
		foreach ( var directory in SafeEnumerateDirectories( sourceDirectory, true ) )
		{
			var relative = Path.GetRelativePath( sourceDirectory, directory );
			Directory.CreateDirectory( Path.Combine( destinationDirectory, relative ) );
		}

		foreach ( var file in SafeEnumerateFiles( sourceDirectory, true ) )
		{
			var relative = Path.GetRelativePath( sourceDirectory, file );
			CopyFileIfNeeded( file, Path.Combine( destinationDirectory, relative ), overwriteChangedFiles );
		}
	}

	private static void CopyFileIfNeeded( string sourceFile, string destinationFile, bool overwriteChangedFiles )
	{
		Directory.CreateDirectory( Path.GetDirectoryName( destinationFile )! );

		if ( File.Exists( destinationFile ) )
		{
			var sourceInfo = new FileInfo( sourceFile );
			var destinationInfo = new FileInfo( destinationFile );

			if ( !overwriteChangedFiles || (sourceInfo.Length == destinationInfo.Length && sourceInfo.LastWriteTimeUtc <= destinationInfo.LastWriteTimeUtc) )
				return;
		}

		File.Copy( sourceFile, destinationFile, true );
		File.SetLastWriteTimeUtc( destinationFile, File.GetLastWriteTimeUtc( sourceFile ) );
	}

	private static IEnumerable<string> SafeEnumerateDirectories( string folderPath, bool recursive )
	{
		var options = new EnumerationOptions
		{
			IgnoreInaccessible = true,
			RecurseSubdirectories = recursive,
			AttributesToSkip = FileAttributes.Hidden | FileAttributes.System
		};

		try
		{
			return Directory.EnumerateDirectories( folderPath, "*", options );
		}
		catch
		{
			return [];
		}
	}

	private static IEnumerable<string> SafeEnumerateFiles( string folderPath, bool recursive )
	{
		var options = new EnumerationOptions
		{
			IgnoreInaccessible = true,
			RecurseSubdirectories = recursive,
			AttributesToSkip = FileAttributes.Hidden | FileAttributes.System
		};

		try
		{
			return Directory.EnumerateFiles( folderPath, "*", options );
		}
		catch
		{
			return [];
		}
	}

	private static void EnsureDestinationInsideProject( string projectAssetsPath, string destination )
	{
		if ( !ConnecterPathUtility.IsPathInside( projectAssetsPath, destination ) )
			throw new InvalidOperationException( $"Refusing to import outside project assets: {destination}" );
	}
}