Editor/UI/MaterialAssets.cs
// Texture matching, conversion, alpha extraction and vmat generation copied from
// humanoid-retargeter Editor/HumanoidRetargeter/EditorPipeline.cs; adapted to rigger materials.
using Editor;
using Sandbox;
namespace HumanoidRigger.Editor;
internal static partial class MaterialAssets
{
	internal static IReadOnlyDictionary<int, string> GenerateVmats( SourceMaterial[] materialInfo, string directory )
	{
		try
		{
			var assetsPath = Project.Current?.GetAssetsPath();
			if ( assetsPath is null )
				return null;

			var materials = materialInfo.Select(m => m.Name).ToList();
			var textures = new List<string>();
			foreach ( var pattern in new[] { "*.png", "*.jpg", "*.jpeg", "*.tga", "*.dds", "*.webp" } )
			{
				textures.AddRange( Directory.GetFiles( directory, pattern ) );
				var texturesDir = Path.Combine( directory, "textures" );
				if ( Directory.Exists( texturesDir ) )
					textures.AddRange( Directory.GetFiles( texturesDir, pattern, SearchOption.AllDirectories ) );
			}

			textures.RemoveAll( path => path.EndsWith( "_hr.png", StringComparison.OrdinalIgnoreCase )
				|| path.EndsWith( "_hr_alpha.png", StringComparison.OrdinalIgnoreCase ) );

			// Tokens shared by most of the texture set (the character's base name, e.g.
			// dante+dark) must never decide a match on their own - "mi_danteDark_vest"
			// would otherwise take the hair texture purely on those (observed: the hair
			// mask ended up on the eyes). A match needs at least one DISTINCTIVE token.
			var tokenCounts = new Dictionary<string, int>( StringComparer.Ordinal );
			foreach ( var candidate in textures )
			{
				foreach ( var token in TextureNames.Tokens( Path.GetFileNameWithoutExtension( candidate ) ) )
					tokenCounts[token] = tokenCounts.GetValueOrDefault( token ) + 1;
			}
			var ubiquitous = tokenCounts
				.Where( kv => kv.Value >= 2 && kv.Value * 2 >= textures.Count )
				.Select( kv => kv.Key )
				.ToHashSet( StringComparer.Ordinal );

			var remaps = new Dictionary<int, string>();
			for(int materialIndex=0;materialIndex<materials.Count;materialIndex++)
			{
				var material=materials[materialIndex];
				var safeMaterial = new string( material
					.Select( c => char.IsLetterOrDigit( c ) || c == '_' ? c : '_' ).ToArray() );
				if ( string.IsNullOrEmpty( safeMaterial ) )
					safeMaterial = "material";
				var vmatPath = Path.Combine( directory, "material_" + materialIndex + "_" + safeMaterial + ".vmat" );
				// Remap the BARE reference the mesh carries to the real file, generated or
				// pre-existing (resource paths are lowercase by engine convention).
				remaps[materialIndex] =
					Path.GetRelativePath( assetsPath, vmatPath ).Replace( '\\', '/' ).ToLowerInvariant();
				if ( File.Exists( vmatPath ) )
				{
					// Files still carrying the auto-generated header are ours to UPGRADE -
					// vmats from an older library version keep old defects forever
					// otherwise (user report: opaque eyelashes generated before alpha-test
					// support existed). Deleting the header line makes manual edits
					// permanent.
					try
					{
						using var reader = new StreamReader( vmatPath );
						if ( reader.ReadLine()?.Contains( "Auto-generated by sbox-humanoid-rigger" ) != true )
							continue;
					}
					catch
					{
						continue;
					}
				}

				// Suffix conventions collected from real exports (Sketchfab rips, Unity
				// packs, Blender/Substance/Marmoset outputs) - the user's assets keep
				// arriving with new ones, so every known spelling is listed.
				var authored = materialInfo[materialIndex];
				var color = FindAuthoredTexture( authored?.ColorTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{
						"_d", "_dm", "_dif", "_diff", "_diffuse", "diffuse",
						"_alb", "_albedo", "albedo", "_basecolor", "_base_color", "basecolor",
						"_bc", "_col", "_color", "_colour", "color", "_clr", "_base",
					} )
					// Stand-in when the set ships no diffuse for this material (real case:
					// a vest with only a specular map): a distinctively NAMED non-normal
					// map carries the garment's actual detail and reads far better than
					// flat placeholder white.
					?? BestTextureMatch( material, textures, new[]
					{
						"_s", "_spec", "_specular", "_m", "_metal", "_metallic", "_metalness",
						"_ao", "_occlusion", "_mask", "_e", "_emissive", "_emission", "_glow",
					} )
					// Last resort: ANY distinctively named non-normal image. Plain-named
					// texture sets carry no suffix at all (real case: material "homer"
					// shipping "homer.png" - both suffix passes skipped it and the model
					// rendered untextured).
					?? BestTextureMatch( material, textures
						.Where( t => !Path.GetFileNameWithoutExtension( t )
							.ToLowerInvariant().EndsWith( "_n" ) )
						.ToList(), new[] { "" } )
					?? SingleColorTexture( textures );
				var normal = FindAuthoredTexture( authored?.NormalTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_n", "_nrm", "_nm", "_nor", "_norm", "_normal", "normal", "_normalmap", "_bump" } );
				var rough = FindAuthoredTexture( authored?.RoughnessTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_r", "_rough", "_roughness", "roughness", "_g", "_gloss", "_glossiness" } );
				var metal = FindAuthoredTexture( authored?.MetalnessTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_m", "_metal", "_metallic", "_metalness", "metallic" } );
				var occlusion = FindAuthoredTexture( authored?.OcclusionTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_ao", "_occlusion", "_ambientocclusion" } );
				var emissive = FindAuthoredTexture( authored?.EmissiveTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_e", "_emissive", "_emission", "_glow" } );
				var opacity = FindAuthoredTexture( authored?.OpacityTexture, textures )
					?? BestTextureMatch( material, textures, new[]
					{ "_a", "_alpha", "_opacity", "_trans", "_transparency" } );
				if(authored?.AuthoredPbr==true)
				{
					color=FindAuthoredTexture(authored.ColorTexture,textures);normal=FindAuthoredTexture(authored.NormalTexture,textures);
					rough=FindAuthoredTexture(authored.RoughnessTexture,textures);metal=FindAuthoredTexture(authored.MetalnessTexture,textures);
					occlusion=FindAuthoredTexture(authored.OcclusionTexture,textures);emissive=FindAuthoredTexture(authored.EmissiveTexture,textures);opacity=FindAuthoredTexture(authored.OpacityTexture,textures);
				}

				// Card/strand geometry (lashes, hair, brows, anything the modeler named
				// "masked") is authored for alpha testing - rendered opaque it shows as
				// solid white sheets (user report: "the makeup around the eye is white").
				var materialTokens = TextureNames.Tokens( material );
				var alphaTest = authored?.AlphaTest == true || (authored?.AuthoredPbr!=true && authored?.Translucent != true
					&& materialTokens.Any( token =>
						token is "mask" or "masked" or "lash" or "lashes" or "eyelash" or "eyelashes"
							or "hair" or "hairs" or "brow" or "brows" or "eyebrow" or "eyebrows"
							or "fur" or "feather" or "feathers" ));
				var translucent = authored?.Translucent == true || opacity is not null;
				if ((alphaTest || translucent) && opacity is null)
					opacity = color; // common packed RGBA texture (including glTF baseColor)
				if (opacity is not null && string.Equals(opacity, color, StringComparison.OrdinalIgnoreCase))
				{
					opacity = ExtractPackedAlpha(opacity);
					// Exporters often connect the diffuse image to opacity even when every
					// pixel is opaque. Do not put that surface in the translucent sorting pass.
					if ( opacity is null )
					{
						alphaTest = false;
						translucent = false;
					}
				}

				color = PrepareTexture( color );
				normal = PrepareTexture( normal );
				rough = PrepareTexture( rough );
				metal = PrepareTexture( metal );
				occlusion = PrepareTexture( occlusion );
				emissive = PrepareTexture( emissive );
				opacity = PrepareTexture( opacity );

				var builder = new System.Text.StringBuilder();
				builder.AppendLine( "// Auto-generated by sbox-humanoid-rigger from the target model's material list." );
				builder.AppendLine( "// Regenerated on conversion while this header stays - DELETE THE LINE ABOVE to make manual edits permanent." );
				builder.AppendLine( "Layer0" );
				builder.AppendLine( "{" );
				builder.AppendLine( authored?.Unlit == true ? "\tshader \"shaders/unlit.shader\"" : authored?.VertexColors == true && color is null
					? "\tshader \"shaders/vertex_color.shader\""
					: "\tshader \"shaders/complex.shader\"" );
				if ( alphaTest )
				{
					builder.AppendLine( "\tF_ALPHA_TEST 1" );
					builder.AppendLine( $"\tg_flAlphaTestReference \"{(authored?.AlphaCutoff ?? 0.5f).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture )}\"" );
				}
				if ( translucent )
					builder.AppendLine( "\tF_TRANSLUCENT 1" );
				if(authored?.OpacityFactor is {} opacityFactor && opacityFactor!=1)
					builder.AppendLine(FormattableString.Invariant($"\tg_flOpacityScale \"{opacityFactor:R}\""));
				if ( authored?.DoubleSided == true )
					builder.AppendLine( "\tF_RENDER_BACKFACES 1" );
				builder.AppendLine( $"\tTextureColor \"{(color ?? "materials/default/default_color.tga")}\"" );
				if ( (color is null || authored?.AuthoredPbr==true) && authored?.ColorFactor is { } tint )
					builder.AppendLine( FormattableString.Invariant(
						$"\tg_vColorTint \"[{tint.X:R} {tint.Y:R} {tint.Z:R} 1]\"" ) );
				if ( opacity is not null )
					builder.AppendLine( $"\tTextureTranslucency \"{opacity}\"" );
				builder.AppendLine( $"\tTextureNormal \"{(normal ?? "materials/default/default_normal.tga")}\"" );
				builder.AppendLine( $"\tTextureRoughness \"{(rough ?? "materials/default/default_rough.tga")}\"" );
				if ( metal is not null )
				{
					builder.AppendLine( "\tF_METALNESS_TEXTURE 1" );
					builder.AppendLine( $"\tTextureMetalness \"{metal}\"" );
				}
				if ( occlusion is not null )
					builder.AppendLine( $"\tTextureAmbientOcclusion \"{occlusion}\"" );
				if ( emissive is not null )
				{
					builder.AppendLine( "\tF_SELF_ILLUM 1" );
					builder.AppendLine( $"\tTextureSelfIllumMask \"{emissive}\"" );
					if(authored?.AuthoredPbr==true)
					{
						var emission=authored.EmissiveFactor;
						builder.AppendLine("\tg_flSelfIllumAlbedoFactor 0");
						builder.AppendLine("\tg_flSelfIllumBrightness 1");
						builder.AppendLine(FormattableString.Invariant($"\tg_vSelfIllumTint \"[{emission.X:R} {emission.Y:R} {emission.Z:R} 1]\""));
					}
				}
				builder.AppendLine( "}" );
				File.WriteAllText( vmatPath, builder.ToString() );
				Try( () => AssetSystem.RegisterFile( vmatPath ) );
				Log.Info( $"[sbox-humanoid-rigger] generated material {Path.GetFileName( vmatPath )} "
					+ $"(color: {color ?? "default"}, normal: {normal ?? "default"}, rough: {rough ?? "default"})" );
			}

			return remaps;

			// The texture compiler rejects JPEG's .jpeg extension and WebP. Keep matching
			// against the authored files, then convert the selected image for every channel.
			string PrepareTexture( string relative )
			{
				if ( relative is null || Path.GetExtension( relative ).ToLowerInvariant() is not (".jpeg" or ".webp") )
					return relative;
				var source = Path.Combine( assetsPath, relative );
				var output = source + "_hr.png";
				using var bitmap = SkiaSharp.SKBitmap.Decode( source )
					?? throw new FormatException( $"Cannot decode texture '{relative}'." );
				using var image = SkiaSharp.SKImage.FromBitmap( bitmap );
				using var data = image.Encode( SkiaSharp.SKEncodedImageFormat.Png, 100 );
				using ( var stream = File.Open( output, FileMode.Create, FileAccess.Write, FileShare.Read ) )
					data.SaveTo( stream );
				Try( () => AssetSystem.RegisterFile( output ) );
				return Path.GetRelativePath( assetsPath, output ).Replace( '\\', '/' );
			}

			string FindAuthoredTexture( string reference, List<string> candidates )
			{
				if ( string.IsNullOrEmpty( reference ) )
					return null;
				var decoded = Uri.UnescapeDataString( reference.Split( '?', '#' )[0] )
					.Replace( '/', Path.DirectorySeparatorChar );
				if ( !Path.IsPathRooted( decoded ) )
				{
					var direct = Path.GetFullPath( Path.Combine( directory, decoded ) );
					var root = Path.GetFullPath( directory ).TrimEnd( Path.DirectorySeparatorChar )
						+ Path.DirectorySeparatorChar;
					if ( direct.StartsWith( root, StringComparison.OrdinalIgnoreCase ) && File.Exists( direct ) )
						return Path.GetRelativePath( assetsPath, direct ).Replace( '\\', '/' );
				}
				var name = Path.GetFileName( decoded );
				var stem = Path.GetFileNameWithoutExtension( name );
				var match = candidates.FirstOrDefault( candidate =>
					string.Equals( Path.GetFileName( candidate ), name, StringComparison.OrdinalIgnoreCase ) )
					?? candidates.FirstOrDefault( candidate => string.Equals(
						Path.GetFileNameWithoutExtension( candidate ), stem,
						StringComparison.OrdinalIgnoreCase ) );
				return match is null ? null
					: Path.GetRelativePath( assetsPath, match ).Replace( '\\', '/' );
			}

			// complex.shader's TextureTranslucency input reads a grayscale image; it does
			// not implicitly select TextureColor.A. Preserve packed-RGBA materials by
			// extracting that authored alpha channel beside the copied source texture.
			string ExtractPackedAlpha( string relative )
			{
				try
				{
					var source = Path.GetFullPath( Path.Combine(
						assetsPath, relative.Replace( '/', Path.DirectorySeparatorChar ) ) );
					using var bitmap = SkiaSharp.SKBitmap.Decode( source );
					if ( bitmap is null || bitmap.Width == 0 || bitmap.Height == 0 )
						return null;

					var hasAlpha = false;
					for ( var y = 0; y < bitmap.Height && !hasAlpha; y++ )
					{
						for ( var x = 0; x < bitmap.Width; x++ )
						{
							if ( bitmap.GetPixel( x, y ).Alpha < 255 )
							{
								hasAlpha = true;
								break;
							}
						}
					}
					if ( !hasAlpha )
						return null;

					using var mask = new SkiaSharp.SKBitmap(
						bitmap.Width, bitmap.Height, SkiaSharp.SKColorType.Rgba8888,
						SkiaSharp.SKAlphaType.Opaque );
					for ( var y = 0; y < bitmap.Height; y++ )
					{
						for ( var x = 0; x < bitmap.Width; x++ )
						{
							var alpha = bitmap.GetPixel( x, y ).Alpha;
							mask.SetPixel( x, y, new SkiaSharp.SKColor( alpha, alpha, alpha ) );
						}
					}

					var output = Path.Combine( Path.GetDirectoryName( source ),
						Path.GetFileNameWithoutExtension( source ) + "_hr_alpha.png" );
					using var image = SkiaSharp.SKImage.FromBitmap( mask );
					using var data = image.Encode( SkiaSharp.SKEncodedImageFormat.Png, 100 );
					using ( var stream = File.Open( output, FileMode.Create, FileAccess.Write, FileShare.Read ) )
						data.SaveTo( stream );
					Try( () => AssetSystem.RegisterFile( output ) );
					return Path.GetRelativePath( assetsPath, output ).Replace( '\\', '/' );
				}
				catch ( Exception e )
				{
					Log.Warning( $"[sbox-humanoid-rigger] packed alpha extraction failed: {e.Message}" );
					return null;
				}
			}

			string SingleColorTexture( List<string> candidates )
			{
				var match=TextureNames.SingleColor(candidates);
                return match is null ? null : Path.GetRelativePath(assetsPath,match).Replace('\\','/');
			}

			string BestTextureMatch( string material, List<string> candidates, string[] suffixes )
			{
				var materialTokens = TextureNames.Tokens( material );
				var scored = new List<(string Path, int Score)>();
				foreach ( var candidate in candidates )
				{
					// Tokenize the RAW stem: lower-casing first would erase its camelCase
					// boundaries ("t_danteDark_head_d" -> one "dantedark" token that can
					// never match the material's dante+dark tokens - observed as the head
					// and lower body rendering untextured white while the arms worked).
					var stem = Path.GetFileNameWithoutExtension( candidate );
					// Numbered layer variants ("eye_diff", "eye_diff2", "eye_diff3") are
					// all diffuse CANDIDATES - suffixes match with trailing digits ignored.
					var stemNoDigits = stem.TrimEnd( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' );
					if ( !suffixes.Any( s => stem.EndsWith( s, StringComparison.OrdinalIgnoreCase )
						|| stemNoDigits.EndsWith( s, StringComparison.OrdinalIgnoreCase ) ) )
						continue;
					var shared = TextureNames.Tokens( stem ).Where( materialTokens.Contains ).ToList();
					var distinctive = shared.Count( t => !ubiquitous.Contains( t ) );
					scored.Add( (candidate, distinctive * 10 + shared.Count) );
				}
				if ( scored.Count == 0 )
					return null;

				var bestScore = scored.Max( c => c.Score );
				var top = scored.Where( c => c.Score == bestScore ).ToList();
				var variantSet = top.All( c => SameVariantFamily( top[0].Path, c.Path ) );

				// A DISTINCTIVE shared token (score >= 10) always wins. Base-name-only
				// overlap is accepted only when unambiguous: single-set exports name
				// everything '<character>_*' ("sonic_mat" + "sonic_diff" is the only
				// diffuse that shares anything - a correct match the old distinctive-only
				// rule rejected, body rendered untextured). Base-only ties across
				// DIFFERENT names stay rejected (the case that mapped hair onto the
				// eyes); numbered variants of ONE name are a layer set, decided below.
				if ( bestScore == 0 || (bestScore < 10 && !variantSet) )
					return null;

				// Composite-shader layer sets ship several same-named images (a mobile
				// eye: gray ball with black pupil + white sclera mask + catchlight dot;
				// the game blends them in a custom shader). A single stand-in must be the
				// layer a viewer would call "the texture": the one with the BRIGHTEST
				// CENTRAL region - masks and catchlights are black-centered, and the
				// pupil-hole layer rendered Sonic's eyes solid black.
				var best = variantSet && top.Count > 1
					? top.OrderByDescending( CenterBrightness ).First().Path
					: top[0].Path;
				if ( variantSet && top.Count > 1 )
					Log.Info( $"[sbox-humanoid-rigger] '{material}': picked "
						+ $"{Path.GetFileName( best )} from {top.Count} layer variants by center brightness" );
				return Path.GetRelativePath( assetsPath, best ).Replace( '\\', '/' );
			}

			static bool SameVariantFamily( string a, string b )
			{
				static string Family( string p ) => Path.GetFileNameWithoutExtension( p )
					.TrimEnd( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' )
					.ToLowerInvariant();
				return Family( a ) == Family( b );
			}

			// Mean luminance of the central half of the image, sparsely sampled.
			static float CenterBrightness( (string Path, int Score) candidate )
			{
				try
				{
					using var bitmap = SkiaSharp.SKBitmap.Decode( candidate.Path );
					if ( bitmap is null || bitmap.Width == 0 || bitmap.Height == 0 )
						return -1f;
					float sum = 0;
					var samples = 0;
					var stepX = Math.Max( 1, bitmap.Width / 32 );
					var stepY = Math.Max( 1, bitmap.Height / 32 );
					for ( var y = bitmap.Height / 4; y < bitmap.Height * 3 / 4; y += stepY )
					{
						for ( var x = bitmap.Width / 4; x < bitmap.Width * 3 / 4; x += stepX )
						{
							var c = bitmap.GetPixel( x, y );
							sum += (0.299f * c.Red + 0.587f * c.Green + 0.114f * c.Blue)
								* (c.Alpha / 255f) / 255f;
							samples++;
						}
					}
					return samples > 0 ? sum / samples : -1f;
				}
				catch
				{
					return -1f; // undecodable: rank below anything readable
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[sbox-humanoid-rigger] vmat generation failed: {e.Message}" );
			throw;
		}
	}


}