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

namespace ImportUnityPackage;

/// <summary>Best-effort conversion of text-serialized Standard/URP material properties.</summary>
public sealed class UnityMaterial
{
	public Dictionary<string, string> Textures { get; } = new();
	public Dictionary<string, double> Numbers { get; } = new();
	public Dictionary<string, double[]> Colors { get; } = new();
	public List<string> Warnings { get; } = new();
	readonly Dictionary<string, string> channelImages = new();
	readonly Dictionary<string, double[]> textureScale = new();
	readonly Dictionary<string, double[]> textureOffset = new();
	bool shaderAlphaTest;
	bool shaderBackfaces;
	bool shaderTranslucent;
	bool terrainLayer;
	HashSet<string> declaredProperties;
	static readonly string[] ColorProperties = { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" };
	public IEnumerable<string> ColorTextureGuids => ColorProperties.Where( Textures.ContainsKey ).Select( p => Textures[p] );
	bool AlphaTest => shaderAlphaTest || Number( "_Mode", 0 ) == 1 || Number( "_AlphaClip", 0 ) == 1;
	bool Translucent => shaderTranslucent || Number( "_Mode", 0 ) >= 2 || Number( "_Surface", 0 ) == 1;
	public string ShaderName { get; private set; }
	public string ShaderGuid { get; private set; }
	static readonly Regex GuidPattern = new( @"guid:\s*([a-fA-F0-9]{32})" );

	public static IEnumerable<string> References( string text ) => GuidPattern.Matches( text )
		.Select( m => m.Groups[1].Value ).Where( g => g.Any( c => c != '0' ) ).Distinct( StringComparer.OrdinalIgnoreCase );

	public static UnityMaterial Parse( string text )
	{
		if ( text.Contains( "TerrainLayer:" ) && !text.Contains( '\0' ) ) return ParseTerrainLayer( text );
		if ( !text.Contains( "Material:" ) || text.Contains( '\0' ) )
			throw new InvalidDataException( "Material is not Unity text YAML. Re-export it with Asset Serialization set to Force Text in Unity." );
		var result = new UnityMaterial();
		result.ShaderGuid = Regex.Match( text, @"m_Shader:[^\r\n]*guid:\s*([a-fA-F0-9]{32})" ).Groups[1].Value;
		string property = null;
		foreach ( var line in text.Split( '\n' ) )
		{
			var item = Regex.Match( line, @"^\s*-\s*(_[A-Za-z0-9_]+):\s*(.*)$" );
			if ( item.Success )
			{
				property = item.Groups[1].Value;
				var value = item.Groups[2].Value.Trim();
				if ( double.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) && double.IsFinite( number ) )
					result.Numbers[property] = number;
				if ( value.StartsWith( "{" ) )
				{
					var channels = Regex.Matches( value, @"[rgba]:\s*([-+0-9.eE]+)" );
					if ( channels.Count == 4 ) result.Colors[property] = channels.Select( m => double.Parse( m.Groups[1].Value, CultureInfo.InvariantCulture ) ).ToArray();
				}
			}
			if ( property != null && line.Contains( "m_Texture:" ) )
			{
				var guid = GuidPattern.Match( line );
				if ( guid.Success && guid.Groups[1].Value.Any( c => c != '0' ) ) result.Textures[property] = guid.Groups[1].Value;
			}
			if ( property != null && (line.Contains( "m_Scale:" ) || line.Contains( "m_Offset:" )) )
			{
				var xy = Regex.Match( line, @"x:\s*([-+0-9.eE]+),\s*y:\s*([-+0-9.eE]+)" );
				if ( xy.Success ) (line.Contains( "m_Scale:" ) ? result.textureScale : result.textureOffset)[property] =
					new[] { double.Parse( xy.Groups[1].Value, CultureInfo.InvariantCulture ), double.Parse( xy.Groups[2].Value, CultureInfo.InvariantCulture ) };
			}
			if ( line.Contains( "m_Scale:" ) && !Regex.IsMatch( line, @"x:\s*1(?:\.0+)?\s*,\s*y:\s*1(?:\.0+)?\s*}" ) ||
				line.Contains( "m_Offset:" ) && !Regex.IsMatch( line, @"x:\s*0(?:\.0+)?\s*,\s*y:\s*0(?:\.0+)?\s*}" ) )
				result.Warnings.Add( "Texture tiling/offset needs manual adjustment." );
		}
		if ( result.Textures.ContainsKey( "_MetallicGlossMap" ) || result.Textures.ContainsKey( "_MaskMap" ) )
			result.Warnings.Add( "Packed metallic/smoothness or HDRP mask maps need channel separation; scalar metallic/roughness values were used." );
		result.Warnings.Add( "Review the converted material: custom shaders, normal-map conventions and advanced Unity settings are not reproduced exactly." );
		return result;
	}

	public void ConfigureShader( string source )
	{
		ShaderName = Regex.Match( source, "Shader\\s+\"([^\"]+)\"" ).Groups[1].Value;
		declaredProperties = Regex.Matches( source, "(?m)^\\s*(?:\\[[^\\]\\r\\n]*\\]\\s*)*(_[A-Za-z0-9_]+)\\s*\\(\\s*\"[^\"]*\"\\s*," )
			.Select( m => m.Groups[1].Value ).ToHashSet();
		if ( declaredProperties.Count > 0 )
		{
			// Unity retains old shader properties in materials. Only use properties declared by the active shader.
			foreach ( var stale in Textures.Keys.Where( k => !declaredProperties.Contains( k ) ).ToArray() ) Textures.Remove( stale );
		}
		shaderAlphaTest = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"TransparentCutout\"" );
		shaderBackfaces = Regex.IsMatch( source, @"(?m)^\s*Cull\s+Off\s*$", RegexOptions.IgnoreCase );
		shaderTranslucent = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"Transparent\"" );
		if ( !string.IsNullOrEmpty( ShaderName ) ) Warnings.Add( $"Shader '{ShaderName}' is approximated using s&box's complex shader; shader code and graph behavior are not translated." );
	}

	public void PrepareChannels( Func<string, int, double, bool, string> extract )
	{
		void Channel( string target, string guid, int channel, double scale = 1, bool invert = false )
		{
			var path = extract( guid, channel, scale, invert );
			if ( path != null ) channelImages[target] = path;
		}
		if ( Textures.TryGetValue( "_MetallicGlossMap", out var packed ) )
		{
			Channel( "metal", packed, 0 );
			Channel( "rough", packed, 3, Number( "_GlossMapScale", Number( "_Smoothness", 1 ) ), true );
		}
		if ( Textures.TryGetValue( "_MaskMap", out var mask ) )
		{
			Channel( "metal", mask, 0 );
			Channel( "ao", mask, 1 );
			// TerrainLit stores height in B; HDRP Lit stores a detail mask instead.
			if ( terrainLayer ) Channel( "height", mask, 2 );
			Channel( "rough", mask, 3, 1, true );
		}
		if ( Textures.TryGetValue( "_OcclusionMap", out var ao ) ) Channel( "ao", ao, 1 );
		if ( AlphaTest || Translucent )
		{
			foreach ( var property in ColorProperties )
			{
				if ( !Textures.TryGetValue( property, out var guid ) ) continue;
				Channel( "opacity", guid, 3 );
				if ( channelImages.ContainsKey( "opacity" ) ) break;
			}
		}
		if ( channelImages.Count > 0 ) Warnings.RemoveAll( w => w.StartsWith( "Packed metallic/" ) || w.StartsWith( "Unity terrain mask channels" ) );
	}

	static UnityMaterial ParseTerrainLayer( string text )
	{
		var result = new UnityMaterial { terrainLayer = true };
		foreach ( var (source, target) in new[] { ("m_DiffuseTexture", "_MainTex"), ("m_NormalMapTexture", "_BumpMap"), ("m_MaskMapTexture", "_MaskMap") } )
		{
			var line = text.Split( '\n' ).FirstOrDefault( l => l.TrimStart().StartsWith( source + ":", StringComparison.Ordinal ) );
			if ( line != null && References( line ).FirstOrDefault() is string guid ) result.Textures[target] = guid;
		}
		foreach ( var (source, target) in new[] { ("m_Metallic", "_Metallic"), ("m_Smoothness", "_Smoothness") } )
		{
			var match = Regex.Match( text, @"(?m)^\s*" + source + @":\s*([-+0-9.eE]+)" );
			if ( match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ) result.Numbers[target] = value;
		}
		result.Warnings.Add( "Terrain layer converted; review world tiling, normal strength and remapping in the terrain material editor." );
		if ( result.Textures.ContainsKey( "_MaskMap" ) ) result.Warnings.Add( "Unity terrain mask channels need separation; packed mask was preserved as a source texture." );
		return result;
	}

	string Texture( Func<string, string> resolve, params string[] names )
	{
		foreach ( var name in names )
			if ( Textures.TryGetValue( name, out var guid ) && resolve( guid ) is string path ) return path;
		return null;
	}
	public double Number( string name, double fallback ) => Numbers.GetValueOrDefault( name, fallback );
	static string N( double value ) => double.IsFinite( value ) ? value.ToString( "0.######", CultureInfo.InvariantCulture ) : "0";
	internal static string Quote( string value ) => JsonSerializer.Serialize( value );

	public string ToVmat( Func<string, string> resolve )
	{
		var text = new StringBuilder( "Layer0\n{\n\tshader \"shaders/complex.shader_c\"\n" );
		void Set( string key, string value ) => text.AppendLine( $"\t{key} {Quote( value )}" );
		Set( "TextureColor", Texture( resolve, "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" ) ?? "materials/default/default_color.tga" );
		var colorProperty = new[] { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" }.FirstOrDefault( p => Textures.TryGetValue( p, out var guid ) && resolve( guid ) != null );
		if ( colorProperty != null )
		{
			if ( textureScale.TryGetValue( colorProperty, out var scale ) ) Set( "g_vTexCoordScale", $"[{N( scale[0] )} {N( scale[1] )}]" );
			if ( textureOffset.TryGetValue( colorProperty, out var offset ) ) Set( "g_vTexCoordOffset", $"[{N( offset[0] )} {N( offset[1] )}]" );
		}
		Set( "TextureNormal", Texture( resolve, "_BumpMap", "_Normal", "_NormalMap" ) ?? "materials/default/default_normal.tga" );
		Set( "TextureAmbientOcclusion", channelImages.GetValueOrDefault( "ao" ) ?? Texture( resolve, "_OcclusionMap", "_Occlusion" ) ?? "materials/default/default_ao.tga" );
		Set( "TextureRoughness", channelImages.GetValueOrDefault( "rough" ) ?? "materials/default/default_rough.tga" );
		var roughness = 1 - Math.Clamp( Number( "_Smoothness", Number( "_Glossiness", 0.5 ) ), 0, 1 );
		Set( "g_flRoughnessScaleFactor", N( channelImages.ContainsKey( "rough" ) ? 1 : roughness ) );
		if ( channelImages.TryGetValue( "metal", out var metal ) )
		{
			text.AppendLine( "\tF_METALNESS_TEXTURE 1" );
			Set( "TextureMetalness", metal );
		}
		Set( "g_flMetalness", N( Math.Clamp( Number( "_Metallic", 0 ), 0, 1 ) ) );
		if ( Colors.TryGetValue( "_BaseColor", out var color ) || colorProperty == "_Diffuse" && Colors.TryGetValue( "_MainColor", out color ) || Colors.TryGetValue( "_Color", out color ) )
			Set( "g_vColorTint", $"[{N( color[0] )} {N( color[1] )} {N( color[2] )} {N( color[3] )}]" );
		if ( channelImages.TryGetValue( "opacity", out var opacity ) ) Set( "TextureTranslucency", opacity );
		if ( AlphaTest )
		{
			text.AppendLine( "\tF_ALPHA_TEST 1" );
			Set( "g_flAlphaTestReference", N( Number( "_Cutoff", 0.5 ) ) );
		}
		if ( shaderBackfaces || Number( "_Cull", 2 ) == 0 ) text.AppendLine( "\tF_RENDER_BACKFACES 1" );
		if ( Translucent ) text.AppendLine( "\tF_TRANSLUCENT 1" );
		var emission = Texture( resolve, "_EmissionMap", "_EmissiveColorMap" );
		if ( emission != null )
		{
			text.AppendLine( "\tF_SELF_ILLUM 1" );
			Set( "TextureSelfIllumMask", emission );
		}
		return text.AppendLine( "}" ).ToString();
	}

	public string ToTmat( Func<string, string> resolve )
	{
		// TerrainMaterial image fields are source image paths, not .vmat references.
		var values = new Dictionary<string, object>
		{
			["__version"] = 1,
			["AlbedoImage"] = "materials/default/default_color.tga",
			["NormalImage"] = "materials/default/default_normal.tga",
			["RoughnessImage"] = "materials/default/default_rough.tga",
			["AOImage"] = "materials/default/default_ao.tga",
			["HeightImage"] = "materials/default/default_height.tga"
		};
		void Set( string key, params string[] names )
		{
			var value = Texture( resolve, names );
			if ( value != null ) values[key] = value;
		}
		Set( "AlbedoImage", "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" );
		Set( "NormalImage", "_BumpMap", "_Normal", "_NormalMap" );
		Set( "AOImage", "_OcclusionMap", "_Occlusion" );
		Set( "HeightImage", "_ParallaxMap", "_HeightMap" );
		foreach ( var (channel, field) in new[] { ("rough", "RoughnessImage"), ("ao", "AOImage"), ("height", "HeightImage") } )
			if ( channelImages.TryGetValue( channel, out var image ) ) values[field] = image;
		return JsonSerializer.Serialize( values, new JsonSerializerOptions { WriteIndented = true } );
	}
}