Editor/WeaponImporter/Tool/Engine/PreviewMaterials.cs
using System.Collections.Concurrent;
using Sandbox;
using SkiaSharp;
using WeaponImporter.Core.Geometry;

namespace WeaponImporter.Tool;

/// <summary>
/// In-memory materials for the preview: each material's base color texture (linked in the file
/// or attached by Attach Textures), or its base color when it has none. Images are decoded off
/// the main thread and cached, so switching pages or characters never decodes twice.
/// </summary>
public static class PreviewMaterials
{
    /// <summary>Largest texture side in the preview (keeps big packs quick and light).</summary>
    public const int MaxSize = 1024;

    private sealed record Decoded( int Width, int Height, byte[] Rgba );

    private static readonly ConcurrentDictionary<string, Decoded> _images = new();

    /// <summary>One material per mesh material (same order); null entries fall back to the default.</summary>
    public static async Task<Material[]> BuildAsync( IReadOnlyList<MaterialInfo> materials, CancellationToken cancel = default )
    {
        var decoded = await Task.Run( () => materials.Select( m => m.BaseColorTexture is { } t ? Decode( t ) : null ).ToArray(), cancel );
        await EditorThread.SwitchToMainThread();
        cancel.ThrowIfCancellationRequested();
        LastReport.Clear();
        var result = new Material[materials.Count];
        for ( var i = 0; i < materials.Count; i++ )
            result[i] = Create( materials[i], decoded[i] );
        return result;
    }

    /// <summary>What the last build gave each material (diagnostics).</summary>
    public static List<string> LastReport { get; } = new();

    private static Material Create( MaterialInfo info, Decoded image )
    {
        // A copy of a configured complex-shader material (a bare Material.Create lacks its setup).
        // Resource names can't carry dots ("Boots.001" reads as an extension).
        var name = System.Text.RegularExpressions.Regex.Replace( info.Name ?? "material", "[^A-Za-z0-9_]", "_" );
        var material = Material.Load( PreviewModels.SurfaceMaterial )?.CreateCopy( $"weapon_importer_preview_{name}" );
        if ( material is null )
            return null;
        var c = info.BaseColor;
        Texture texture;
        if ( image is not null )
            texture = Texture.Create( image.Width, image.Height ).WithData( image.Rgba ).WithMips().Finish();
        else
            texture = Texture.Create( 1, 1 ).WithData( new[] { ToByte( c.X ), ToByte( c.Y ), ToByte( c.Z ), (byte)255 } ).Finish();
        material.Set( "g_tColor", texture );
        if ( image is not null )
            material.Set( "g_vColorTint", new Color( c.X, c.Y, c.Z, 1f ) );
        var back = material.GetTexture( "g_tColor" );
        LastReport.Add( $"{info.Name}: {material.ShaderName} · {(image is null ? "base color" : $"{image.Width}×{image.Height}")} · set {(back is null ? "none" : $"{back.Width}×{back.Height}")}" );
        return material;
    }

    private static byte ToByte( float v ) => (byte)Math.Clamp( (int)MathF.Round( v * 255f ), 0, 255 );

    /// <summary>The image as RGBA (at most <see cref="MaxSize"/>), or null when it can't be read.</summary>
    private static Decoded Decode( TextureRef texture )
    {
        var key = texture.FilePath is { } path
            ? $"{path}|{(File.Exists( path ) ? File.GetLastWriteTimeUtc( path ).Ticks : 0)}"
            : $"{texture.Name}|{texture.Bytes?.Length}";
        if ( _images.TryGetValue( key, out var cached ) )
            return cached;
        Decoded result = null;
        try
        {
            var bytes = texture.ReadBytes();
            using var bitmap = texture.Extension == ".tga" ? Tga.Decode( bytes ) : SKBitmap.Decode( bytes );
            if ( bitmap is not null )
                result = ToRgba( bitmap );
        }
        catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException or IndexOutOfRangeException )
        {
            Log.Info( $"[weapon importer] preview: {texture.Name}{texture.Extension} could not be read ({e.Message}); showing the base color." );
        }
        _images[key] = result;
        return result;
    }

    private static Decoded ToRgba( SKBitmap source )
    {
        var scale = MathF.Min( 1f, (float)MaxSize / Math.Max( source.Width, source.Height ) );
        var width = Math.Max( 1, (int)(source.Width * scale) );
        var height = Math.Max( 1, (int)(source.Height * scale) );
        using var rgba = new SKBitmap( width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul );
        using ( var canvas = new SKCanvas( rgba ) )
        using ( var paint = new SKPaint { FilterQuality = SKFilterQuality.Medium } )
            canvas.DrawBitmap( source, new SKRect( 0, 0, width, height ), paint );
        return new Decoded( width, height, rgba.Bytes );
    }

    /// <summary>Truevision TGA (the engine reads it, Skia doesn't): 24/32-bit, raw or RLE.</summary>
    private static class Tga
    {
        public static SKBitmap Decode( byte[] d )
        {
            int idLength = d[0], type = d[2];
            int width = d[12] | d[13] << 8, height = d[14] | d[15] << 8, bpp = d[16] / 8;
            var topDown = (d[17] & 0x20) != 0;
            if ( (type != 2 && type != 10) || (bpp != 3 && bpp != 4) || width == 0 || height == 0 )
                return null;
            var pixels = new byte[width * height * 4];
            var src = 18 + idLength;
            var count = width * height;
            void Put( int i, int at )
            {
                pixels[i * 4] = d[at + 2];
                pixels[i * 4 + 1] = d[at + 1];
                pixels[i * 4 + 2] = d[at];
                pixels[i * 4 + 3] = bpp == 4 ? d[at + 3] : (byte)255;
            }
            for ( var i = 0; i < count; )
            {
                if ( type == 2 )
                {
                    Put( i++, src );
                    src += bpp;
                    continue;
                }
                var header = d[src++];
                var run = (header & 0x7F) + 1;
                if ( (header & 0x80) != 0 )
                {
                    for ( var k = 0; k < run && i < count; k++ )
                        Put( i++, src );
                    src += bpp;
                }
                else
                {
                    for ( var k = 0; k < run && i < count; k++, src += bpp )
                        Put( i++, src );
                }
            }
            if ( !topDown )
            {
                var row = width * 4;
                var tmp = new byte[row];
                for ( var y = 0; y < height / 2; y++ )
                {
                    Buffer.BlockCopy( pixels, y * row, tmp, 0, row );
                    Buffer.BlockCopy( pixels, (height - 1 - y) * row, pixels, y * row, row );
                    Buffer.BlockCopy( tmp, 0, pixels, (height - 1 - y) * row, row );
                }
            }
            var bitmap = new SKBitmap( width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul );
            System.Runtime.InteropServices.Marshal.Copy( pixels, 0, bitmap.GetPixels(), pixels.Length );
            return bitmap;
        }
    }
}