Editor/WeaponImporter/Core/Geometry/MaterialInfo.cs
#nullable enable annotations

using System.Numerics;

namespace WeaponImporter.Core.Geometry;

using Vector4 = System.Numerics.Vector4;

/// <summary>A colour channel of an image; <see cref="All"/> means the whole image.</summary>
public enum TextureChannel { All, R, G, B, A }

/// <summary>
/// An image a material uses: either a file on disk (<see cref="FilePath"/>, absolute) or bytes
/// embedded in the source file (<see cref="Bytes"/>). <see cref="Name"/> is a stable,
/// file-system-safe identifier (no extension) derived from the source.
/// </summary>
public sealed record TextureRef
{
    /// <summary>Absolute path of the image, or null when the image is embedded.</summary>
    public string? FilePath { get; init; }

    /// <summary>Embedded image bytes (FBX Video Content, glTF bufferView / data URI).</summary>
    public byte[]? Bytes { get; init; }

    /// <summary>File extension including the dot, lowercase (".png", ".jpg", ".tga").</summary>
    public string Extension { get; init; } = ".png";

    /// <summary>Stable name of the image (source file name without extension, or glTF image name).</summary>
    public string Name { get; init; } = "texture";

    /// <summary>
    /// glTF metallicRoughness packing: G = roughness, B = metalness. The same instance is then
    /// used for both <see cref="MaterialInfo.RoughnessTexture"/> and <see cref="MaterialInfo.MetalnessTexture"/>.
    /// </summary>
    public bool Packed { get; init; }

    /// <summary>Channel a scalar map reads (glTF occlusion reads R). All = the image as is.</summary>
    public TextureChannel Channel { get; init; } = TextureChannel.All;

    public bool IsEmbedded => Bytes is not null;

    public static TextureRef FromFile(string path) => new()
    {
        FilePath = path,
        Extension = ExtensionOf(path),
        Name = SafeName(FileNameOf(path)),
    };

    public static TextureRef FromBytes(byte[] bytes, string extension, string name) => new()
    {
        Bytes = bytes,
        Extension = NormalizeExtension(extension, bytes),
        Name = SafeName(name),
    };

    /// <summary>Reads the image bytes (file or embedded).</summary>
    public byte[] ReadBytes() => Bytes ?? File.ReadAllBytes(FilePath ?? throw new InvalidOperationException("Texture has no source."));

    /// <summary>File name part of a path written with either separator.</summary>
    public static string FileNameOf(string path)
    {
        var cut = Math.Max(path.LastIndexOf('/'), path.LastIndexOf('\\'));
        return cut >= 0 ? path[(cut + 1)..] : path;
    }

    public static string ExtensionOf(string path)
    {
        var file = FileNameOf(path);
        var dot = file.LastIndexOf('.');
        return dot >= 0 ? file[dot..].ToLowerInvariant() : "";
    }

    /// <summary>Lowercase [a-z0-9_] name without extension.</summary>
    public static string SafeName(string raw)
    {
        var file = FileNameOf(raw);
        var dot = file.LastIndexOf('.');
        if (dot > 0)
            file = file[..dot];
        var chars = file.ToLowerInvariant().Select(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ? c : '_').ToArray();
        var s = new string(chars);
        while (s.Contains("__"))
            s = s.Replace("__", "_");
        s = s.Trim('_');
        return s.Length == 0 ? "texture" : s;
    }

    private static string NormalizeExtension(string extension, byte[] bytes)
    {
        var e = (extension ?? "").Trim().ToLowerInvariant();
        e = e switch
        {
            "image/png" => ".png",
            "image/jpeg" or "image/jpg" => ".jpg",
            "image/webp" => ".webp",
            _ => e,
        };
        if (e.Length > 0 && e[0] != '.')
            e = "." + e;
        if (e.Length > 1)
            return e;
        // Sniff magic numbers.
        if (bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
            return ".png";
        if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
            return ".jpg";
        return ".png";
    }
}

/// <summary>
/// A render material read from the source (FBX Material / glTF material), engine agnostic.
/// Scalars follow glTF metallic-roughness semantics: when a texture is present the scalar
/// multiplies it.
/// </summary>
public sealed record MaterialInfo
{
    public string Name { get; init; } = "default";

    /// <summary>Linear-ish RGBA base colour factor (alpha = opacity).</summary>
    public Vector4 BaseColor { get; init; } = Vector4.One;

    public TextureRef? BaseColorTexture { get; init; }
    public TextureRef? NormalTexture { get; init; }
    public TextureRef? RoughnessTexture { get; init; }
    public TextureRef? MetalnessTexture { get; init; }
    public TextureRef? AmbientOcclusionTexture { get; init; }
    public TextureRef? EmissiveTexture { get; init; }

    /// <summary>Separate opacity image (FBX TransparentColor/Opacity map). Null = base colour alpha.</summary>
    public TextureRef? OpacityTexture { get; init; }

    /// <summary>Alpha-test threshold (glTF MASK); null = no alpha test.</summary>
    public float? AlphaCutoff { get; init; }

    /// <summary>Alpha-blended (glTF BLEND / FBX opacity below 1).</summary>
    public bool Translucent { get; init; }

    public bool DoubleSided { get; init; }

    public float Roughness { get; init; } = 1f;
    public float Metalness { get; init; }

    public static MaterialInfo Default { get; } = new();
}