Editor/Core/UnityModel.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

public sealed class UnityModel
{
	public List<string> Materials { get; } = new();
	public List<string> Meshes { get; } = new();
	public List<string> PrefabMaterials { get; } = new();
	public Dictionary<string, string> MaterialAlbedoFiles { get; } = new( StringComparer.OrdinalIgnoreCase );
	public double? UnitScaleCentimeters { get; private set; }
	public double ImportScale( string metadata )
	{
		double Setting( string key, double fallback )
		{
			var match = Regex.Match( metadata ?? "", @"(?m)^\s*" + key + @":\s*([-+0-9.eE]+)" );
			return match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) && double.IsFinite( value ) ? value : fallback;
		}
		var globalScale = Setting( "globalScale", 1 );
		var meters = Setting( "useFileScale", 1 ) == 0 ? 1 : (UnitScaleCentimeters ?? 2.54) / 100;
		var scale = globalScale * meters / 0.0254;
		if ( !double.IsFinite( scale ) || scale <= 0 ) throw new InvalidDataException( "Model import scale must be finite and positive." );
		return scale;
	}
	public string[] HighestDetailMeshes => Meshes.Where( n => Regex.IsMatch( n, @"(?i)(?:^|[_ .-])LOD0(?:$|[_ .-])" ) ).ToArray();
	public static string Normalize( string name ) => Regex.Replace( name.ToLowerInvariant(), "[^a-z]", "" );

	public static UnityModel Read( UnityAsset asset )
	{
		var result = new UnityModel();
		if ( !asset.Path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase ) ) return result;
		using var stream = File.OpenRead( asset.Source );
		using var reader = new BinaryReader( stream, Encoding.UTF8 );
		var header = Encoding.ASCII.GetString( reader.ReadBytes( 23 ) );
		if ( !header.StartsWith( "Kaydara FBX Binary", StringComparison.Ordinal ) )
		{
			stream.Position = 0;
			using var textReader = new StreamReader( stream );
			var links = new UnityAsciiFbxLinks();
			string line;
			while ( (line = textReader.ReadLine()) != null )
			{
				links.Observe( line );
				var unit = Regex.Match( line, "^\\s*(?:P|Property):\\s*\"UnitScaleFactor\".*?,\\s*([-+0-9.eE]+)\\s*$" );
				if ( unit.Success && double.TryParse( unit.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var centimeters ) && centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;
				var material = Regex.Match( line, "^\\s*Material:\\s*(?:[-0-9]+,\\s*)?\"Material::([^\"]+)\"" );
				if ( material.Success && !result.Materials.Contains( material.Groups[1].Value ) ) result.Materials.Add( material.Groups[1].Value );
				var mesh = Regex.Match( line, "^\\s*Model:\\s*(?:[-0-9]+,\\s*)?\"Model::([^\"]+)\",\\s*\"Mesh\"" );
				if ( mesh.Success && !result.Meshes.Contains( mesh.Groups[1].Value ) ) result.Meshes.Add( mesh.Groups[1].Value );
			}
			foreach ( var link in links.Resolve() ) result.MaterialAlbedoFiles[link.Key] = link.Value;
			return result;
		}
		var wide = reader.ReadUInt32() >= 7500;
		var materialNames = new Dictionary<long, string>();
		var textureFiles = new Dictionary<long, string>();
		var diffuseLinks = new List<(long Texture, long Material)>();
		int nodes = 0;
		void ReadNodes( long limit, bool objects = false, bool connections = false, long textureId = 0, bool settings = false )
		{
			while ( stream.Position + (wide ? 25 : 13) <= limit )
			{
				if ( ++nodes > 200000 ) throw new InvalidDataException( "Too many FBX nodes." );
				var end = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var properties = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var propertyBytes = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var nameBytes = reader.ReadByte();
				if ( end == 0 ) return;
				if ( end <= stream.Position || end > stream.Length ) throw new InvalidDataException( "Invalid FBX node offset." );
				var name = Encoding.UTF8.GetString( reader.ReadBytes( nameBytes ) );
				var children = stream.Position + propertyBytes;
				if ( children > end ) throw new InvalidDataException( "Invalid FBX property size." );
				object Property()
				{
						var type = (char)reader.ReadByte();
						if ( type == 'L' ) return reader.ReadInt64();
						if ( type == 'I' ) return reader.ReadInt32();
						if ( type == 'D' ) return reader.ReadDouble();
						if ( type == 'F' ) return reader.ReadSingle();
						if ( type == 'S' )
						{
							var length = reader.ReadInt32();
							if ( length < 0 || length > 1024 * 1024 || stream.Position + length > children ) throw new InvalidDataException( "Invalid FBX string." );
							return Encoding.UTF8.GetString( reader.ReadBytes( length ) );
						}
						throw new InvalidDataException( "Unsupported FBX object property." );
				}
				if ( objects && name is "Material" or "Model" or "Texture" && properties >= 3 )
				{
					var id = Convert.ToInt64( Property() );
					var objectName = (Property() as string ?? "").Split( '\0' )[0];
					var typeName = Property() as string;
					if ( name == "Material" ) { result.Materials.Add( objectName ); materialNames[id] = objectName; }
					if ( name == "Model" && typeName == "Mesh" ) result.Meshes.Add( objectName );
					if ( name == "Texture" ) { stream.Position = children; ReadNodes( end, textureId: id ); }
				}
				else if ( textureId != 0 && name is "FileName" or "RelativeFilename" && properties > 0 ) textureFiles[textureId] = Property() as string;
				else if ( settings && name is "P" or "Property" && properties >= 4 && properties <= 8 )
				{
					if ( Property() as string == "UnitScaleFactor" )
					{
						object value = null;
						for ( var index = 1; index < properties; index++ ) value = Property();
						var centimeters = Convert.ToDouble( value, CultureInfo.InvariantCulture );
						if ( centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;
					}
				}
				else if ( connections && name == "C" && properties >= 4 )
				{
					var kind = Property() as string;
					var child = Convert.ToInt64( Property() );
					var parent = Convert.ToInt64( Property() );
					var channel = Property() as string ?? "";
					if ( kind == "OP" && (channel == "DiffuseColor" || channel.EndsWith( "|base_color_map", StringComparison.Ordinal )) ) diffuseLinks.Add( (child, parent) );
				}
				if ( name == "Objects" ) { stream.Position = children; ReadNodes( end, true ); }
				if ( name == "Connections" ) { stream.Position = children; ReadNodes( end, connections: true ); }
				if ( name == "GlobalSettings" || settings && name is "Properties70" or "Properties60" ) { stream.Position = children; ReadNodes( end, settings: true ); }
				stream.Position = end;
			}
		}
		ReadNodes( stream.Length, false );
		foreach ( var link in diffuseLinks )
			if ( materialNames.TryGetValue( link.Material, out var material ) && textureFiles.TryGetValue( link.Texture, out var file ) && !string.IsNullOrEmpty( file ) )
				result.MaterialAlbedoFiles[material] = file.Replace( '\\', '/' ).Split( '/' ).Last();
		return result;
	}

	public static void AssignPrefabMaterials( UnityArchive archive )
	{
		var models = archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var materialIds = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		var candidates = new Dictionary<string, List<(string Path, string[] Materials)>>( StringComparer.OrdinalIgnoreCase );
		foreach ( var prefab in archive.Assets.Where( a => a.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) ) )
		{
			if ( new FileInfo( prefab.Source ).Length > 16 * 1024 * 1024 ) continue;
			var text = File.ReadAllText( prefab.Source );
			var references = UnityMaterial.References( text ).ToArray();
			// Collision meshes can be separate FBX files; only visible mesh components
			// participate in choosing the renderer's material group.
			var visualBlocks = Regex.Matches( text, @"(?ms)^--- !u!(?:33|137) &[^\r\n]+\r?\n.*?(?=^--- !u!|\z)" );
			var visualReferences = visualBlocks.Count > 0
				? visualBlocks.Cast<Match>().SelectMany( b => UnityMaterial.References( b.Value ) )
				: references.AsEnumerable();
			var modelIds = visualReferences.Where( models.ContainsKey ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
			// Compound prefabs cannot define a single model's complete material group.
			if ( modelIds.Length != 1 ) continue;
			var mats = references.Where( materialIds.Contains ).ToArray();
			if ( mats.Length == 0 ) continue;
			if ( !candidates.TryGetValue( modelIds[0], out var list ) ) candidates[modelIds[0]] = list = new();
			list.Add( (prefab.Path, mats) );
		}
		foreach ( var (guid, list) in candidates )
		{
			var model = models[guid];
			var name = Path.GetFileNameWithoutExtension( model.Path );
			var best = list.OrderBy( p => Path.GetFileNameWithoutExtension( p.Path ).Equals( name, StringComparison.OrdinalIgnoreCase ) ? 0 : 1 )
				.ThenBy( p => p.Path, StringComparer.OrdinalIgnoreCase ).First();
			model.ModelInfo.PrefabMaterials.AddRange( best.Materials );
		}
	}
}