Editor/Converters/FabDecalConverter.cs
using System;
using System.IO;
using System.Text;

namespace FabBridge.Converters;

/// <summary>
/// Generates s&box .decal (DecalDefinition) resources from a Fab texture-only asset.
/// Builds a Color+Alpha PNG by combining BaseColor and Opacity (Fab ships them separately),
/// then writes a JSON .decal file referencing it.
/// </summary>
public static class FabDecalConverter
{
	public class ConversionResult
	{
		public bool Success { get; set; }
		public string DecalPath { get; set; }
		public string RelativePath { get; set; }
		public string ColorWithAlphaPath { get; set; }
		public string Error { get; set; }
		public Asset Asset { get; set; }
	}

	/// <summary>
	/// True if the asset looks like a decal candidate: no mesh data, and has both a color and
	/// an opacity texture in the converted results. The opacity map is the strong signal —
	/// non-decal tiling materials don't ship one.
	/// </summary>
	public static bool LooksLikeDecal( FabAsset fabAsset, List<FabTextureConverter.ConversionResult> textureResults )
	{
		if ( fabAsset == null || textureResults == null )
			return false;

		var allMeshes = fabAsset.GetAllMeshes();
		if ( allMeshes.Count > 0 )
		{
			Log.Info( $"FabDecalConverter: Not a decal — asset has {allMeshes.Count} mesh(es)" );
			return false;
		}

		var hasMeshComponents = fabAsset.Components?.Any( c => IsMeshFile( c.Path ) ) ?? false;
		if ( hasMeshComponents )
		{
			Log.Info( "FabDecalConverter: Not a decal — asset has mesh components" );
			return false;
		}

		var hasColor = FindColorTexture( textureResults ) != null;
		var hasOpacity = FindOpacityTexture( textureResults ) != null;
		Log.Info( $"FabDecalConverter: hasColor={hasColor}, hasOpacity={hasOpacity}" );

		return hasColor && hasOpacity;
	}

	/// <summary>
	/// Generate a .decal resource for the given Fab asset and its converted textures.
	/// Assumes <see cref="LooksLikeDecal"/> already returned true.
	/// </summary>
	public static ConversionResult Generate( FabAsset fabAsset, List<FabTextureConverter.ConversionResult> textureResults, string destinationFolder )
	{
		var result = new ConversionResult();

		try
		{
			Directory.CreateDirectory( destinationFolder );
			var baseName = fabAsset.GetSafeFileName();

			var colorResult = FindColorTexture( textureResults );
			var opacityResult = FindOpacityTexture( textureResults );
			var normalResult = FindTextureBySuffix( textureResults, "_normal", "normal" );
			var heightResult = FindTextureBySuffix( textureResults, "_height", "displacement", "height" );

			if ( colorResult == null || opacityResult == null )
			{
				result.Error = "Missing required color or opacity texture for decal";
				return result;
			}

			// Bake a Color+Alpha PNG that the decal can use directly as ColorTexture.
			var colorAlphaPath = Path.Combine( destinationFolder, $"{baseName}_color_alpha.png" );
			if ( !BuildColorWithAlpha( colorResult.DestinationPath, opacityResult.DestinationPath, colorAlphaPath ) )
			{
				result.Error = "Failed to combine color and opacity into RGBA PNG";
				return result;
			}

			result.ColorWithAlphaPath = colorAlphaPath;

			// Register so the engine knows about it before the .decal references it.
			AssetSystem.RegisterFile( colorAlphaPath );

			// Build relative paths for the .decal references.
			var colorAlphaRel = FabTextureConverter.GetRelativePath( colorAlphaPath );
			var normalRel = normalResult != null ? normalResult.RelativePath : null;
			var heightRel = heightResult != null ? heightResult.RelativePath : null;

			// Write the .decal file. Format mirrors the simple GameResource serialization used
			// by the engine's own decals (see addons/base/Assets/decals/circle.decal).
			var decalPath = Path.Combine( destinationFolder, $"{baseName}.decal" );
			var json = BuildDecalJson( colorAlphaRel, normalRel, heightRel );
			File.WriteAllText( decalPath, json );

			// Force a recompile of any stale compiled output.
			var compiledPath = decalPath + "_c";
			if ( File.Exists( compiledPath ) )
			{
				try { File.Delete( compiledPath ); }
				catch { /* best-effort */ }
			}

			var decalAsset = AssetSystem.RegisterFile( decalPath ) ?? AssetSystem.FindByPath( decalPath );
			if ( decalAsset == null )
			{
				result.Error = "Failed to register .decal asset";
				return result;
			}

			result.Asset = decalAsset;
			result.DecalPath = decalPath;
			result.RelativePath = decalAsset.Path;
			result.Success = true;

			Log.Info( $"FabBridge: Created decal at {decalPath}" );
		}
		catch ( Exception ex )
		{
			result.Error = ex.Message;
			Log.Error( $"FabBridge: Decal generation failed: {ex.Message}" );
		}

		return result;
	}

	/// <summary>
	/// Combine an RGB color image and a grayscale opacity image into a single RGBA PNG.
	/// If the two source images differ in size, the alpha is sampled at scaled coordinates.
	/// </summary>
	private static bool BuildColorWithAlpha( string colorPath, string opacityPath, string outputPath )
	{
		try
		{
			if ( !File.Exists( colorPath ) || !File.Exists( opacityPath ) )
			{
				Log.Warning( $"FabBridge: BuildColorWithAlpha - source missing (color: {File.Exists( colorPath )}, opacity: {File.Exists( opacityPath )})" );
				return false;
			}

			using var colorBmp = Bitmap.CreateFromBytes( File.ReadAllBytes( colorPath ) );
			using var alphaBmp = Bitmap.CreateFromBytes( File.ReadAllBytes( opacityPath ) );
			if ( colorBmp == null || alphaBmp == null )
				return false;

			int w = colorBmp.Width;
			int h = colorBmp.Height;

			using var output = new Bitmap( w, h );

			var colorPixels = colorBmp.GetPixels32();
			var alphaPixels = alphaBmp.GetPixels32();

			int aw = alphaBmp.Width;
			int ah = alphaBmp.Height;
			bool sameSize = aw == w && ah == h;

			var result = new Color32[w * h];
			for ( int y = 0; y < h; y++ )
			{
				for ( int x = 0; x < w; x++ )
				{
					int ci = y * w + x;
					var c = colorPixels[ci];

					byte a;
					if ( sameSize )
					{
						a = alphaPixels[ci].r;
					}
					else
					{
						// Nearest-neighbor sample for mismatched sizes; rare for Fab but handle it.
						int ax = Math.Min( x * aw / w, aw - 1 );
						int ay = Math.Min( y * ah / h, ah - 1 );
						a = alphaPixels[ay * aw + ax].r;
					}

					result[ci] = new Color32( c.r, c.g, c.b, a );
				}
			}

			// Write back via SetPixels then encode.
			var asColors = new Color[result.Length];
			for ( int i = 0; i < result.Length; i++ )
			{
				var p = result[i];
				asColors[i] = new Color( p.r / 255f, p.g / 255f, p.b / 255f, p.a / 255f );
			}
			output.SetPixels( asColors );

			File.WriteAllBytes( outputPath, output.ToPng() );
			Log.Info( $"FabBridge: Wrote {w}x{h} color+alpha PNG to {outputPath}" );
			return true;
		}
		catch ( Exception ex )
		{
			Log.Error( $"FabBridge: BuildColorWithAlpha failed: {ex.Message}" );
			return false;
		}
	}

	private static string BuildDecalJson( string colorRel, string normalRel, string heightRel )
	{
		// Engine accepts either a plain string path or an inline imagefile compiler block for
		// each texture field. The string form is simpler and what circle.decal uses.
		var sb = new StringBuilder();
		sb.Append( "{\n" );
		sb.Append( $"  \"ColorTexture\": \"{colorRel}\",\n" );
		sb.Append( normalRel != null ? $"  \"NormalTexture\": \"{normalRel}\",\n" : "  \"NormalTexture\": null,\n" );
		sb.Append( "  \"RoughMetalOcclusionTexture\": null,\n" );
		sb.Append( "  \"EmissiveTexture\": null,\n" );
		sb.Append( "  \"EmissionEnergy\": 1,\n" );
		sb.Append( heightRel != null ? $"  \"HeightTexture\": \"{heightRel}\",\n" : "  \"HeightTexture\": null,\n" );
		sb.Append( "  \"ParallaxStrength\": 1,\n" );
		sb.Append( "  \"Tint\": \"1,1,1,1\",\n" );
		sb.Append( "  \"ColorMix\": 1,\n" );
		sb.Append( "  \"Width\": 64,\n" );
		sb.Append( "  \"Height\": 64,\n" );
		sb.Append( "  \"FilterMode\": \"Anisotropic\",\n" );
		sb.Append( "  \"__references\": [],\n" );
		sb.Append( "  \"__version\": 0\n" );
		sb.Append( "}\n" );
		return sb.ToString();
	}

	/// <summary>
	/// Find a converted texture by matching its filename against the given keywords.
	/// FabTextureConverter renames files with the s&box-style suffix (e.g. "_color", "_normal",
	/// "_opacity"), so the keywords should include both the suffix form and the raw Fab type name.
	/// </summary>
	private static FabTextureConverter.ConversionResult FindTextureBySuffix( List<FabTextureConverter.ConversionResult> results, params string[] keywords )
	{
		foreach ( var r in results )
		{
			if ( r == null || !r.Success || string.IsNullOrEmpty( r.DestinationPath ) )
				continue;

			var name = Path.GetFileNameWithoutExtension( r.DestinationPath )?.ToLowerInvariant() ?? "";
			foreach ( var kw in keywords )
			{
				if ( name.Contains( kw ) )
					return r;
			}
		}
		return null;
	}

	private static FabTextureConverter.ConversionResult FindColorTexture( List<FabTextureConverter.ConversionResult> results )
	{
		// "_color" is the s&box suffix; "basecolor"/"albedo"/"diffuse" are raw Fab type names that may
		// also appear in the filename. Excluding "_color_alpha" so a baked decal output isn't picked.
		foreach ( var r in results )
		{
			if ( r == null || !r.Success || string.IsNullOrEmpty( r.DestinationPath ) )
				continue;

			var name = Path.GetFileNameWithoutExtension( r.DestinationPath )?.ToLowerInvariant() ?? "";
			if ( name.Contains( "_color_alpha" ) )
				continue;
			if ( name.Contains( "_color" ) || name.Contains( "basecolor" ) || name.Contains( "albedo" ) || name.Contains( "diffuse" ) )
				return r;
		}
		return null;
	}

	private static FabTextureConverter.ConversionResult FindOpacityTexture( List<FabTextureConverter.ConversionResult> results )
	{
		return FindTextureBySuffix( results, "_opacity", "opacity", "alpha" );
	}

	private static bool IsMeshFile( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return false;
		var ext = Path.GetExtension( path )?.ToLowerInvariant();
		return ext == ".fbx" || ext == ".obj" || ext == ".gltf" || ext == ".glb" || ext == ".dae";
	}
}