Editor/WeaponImporter/Core/Generation/MaterialWriter.cs
#nullable enable annotations

using System.Globalization;
using System.Numerics;
using System.Text;
using WeaponImporter.Core.Geometry;

namespace WeaponImporter.Core.Generation;

/// <summary>A generated material: vmat text plus the texture files the caller must produce.</summary>
public sealed class MaterialOutput
{
    /// <summary>Asset-relative path of the vmat (forward slashes), e.g. "weapons/ak74/materials/ak74m.vmat".</summary>
    public required string VmatPath { get; init; }

    public required string VmatText { get; init; }

    /// <summary>
    /// Texture files referenced by the vmat. <c>Channel == All</c>: copy / extract the source as
    /// is. Any other channel: decode the source image and write that single channel as a
    /// grayscale PNG (the editor side does this; Core has no image decoder).
    /// </summary>
    public required List<(string RelativePath, TextureRef Source, TextureChannel Channel)> Textures { get; init; }
}

/// <summary>Writes s&amp;box <c>complex.shader</c> vmats for <see cref="MaterialInfo"/>.</summary>
public static class MaterialWriter
{
    public const string GeneratedMarker = "// Generated by Weapon Importer. Delete this line to keep your edits on reimport.";

    public const string DefaultColor = "materials/default/default_color.tga";
    public const string DefaultNormal = "materials/default/default_normal.tga";
    public const string DefaultRough = "materials/default/default_rough.tga";

    /// <param name="materialsFolderRelative">Asset-relative folder, e.g. "weapons/ak74/materials".</param>
    /// <param name="safeName">File-safe material name (e.g. the DMX faceSet name).</param>
    public static MaterialOutput Write(MaterialInfo m, string materialsFolderRelative, string safeName)
    {
        ArgumentNullException.ThrowIfNull(m);
        var folder = NormalizeFolder(materialsFolderRelative);
        var name = DmxNames.Material(string.IsNullOrWhiteSpace(safeName) ? m.Name : safeName);
        var textures = new List<(string RelativePath, TextureRef Source, TextureChannel Channel)>();

        string Add(TextureRef source, TextureChannel channel, string suffix)
        {
            var ext = channel == TextureChannel.All ? SafeExtension(source.Extension) : ".png";
            var path = Join(folder, $"{name}_{suffix}{ext}");
            if (!textures.Any(t => t.RelativePath == path))
                textures.Add((path, source, channel));
            return path;
        }

        // Scalar maps: explicit channel (glTF occlusion = R) or glTF packing (G rough, B metal).
        static TextureChannel ScalarChannel(TextureRef t, TextureChannel packed)
            => t.Channel != TextureChannel.All ? t.Channel : t.Packed ? packed : TextureChannel.All;

        var sb = new StringBuilder();
        void Line(string s) => sb.Append(s).Append('\n');
        void Key(string key, string value) => Line($"\t{key} \"{value}\"");

        Line(GeneratedMarker);
        Line("Layer0");
        Line("{");
        Line("\tshader \"shaders/complex.shader\"");
        Line("");

        // Feature flags first (vmat convention).
        var alphaTest = m.AlphaCutoff is not null;
        var translucent = !alphaTest && m.Translucent;
        if (alphaTest)
            Key("F_ALPHA_TEST", "1");
        if (translucent)
            Key("F_TRANSLUCENT", "1");
        if (m.DoubleSided)
            Key("F_RENDER_BACKFACES", "1");
        if (m.MetalnessTexture is not null)
            Key("F_METALNESS_TEXTURE", "1");
        if (m.EmissiveTexture is not null)
            Key("F_SELF_ILLUM", "1");
        if (alphaTest || translucent || m.DoubleSided || m.MetalnessTexture is not null || m.EmissiveTexture is not null)
            Line("");

        // Colour.
        Key("TextureColor", m.BaseColorTexture is { } color ? Add(color, TextureChannel.All, "color") : DefaultColor);
        var tint = m.BaseColor;
        Key("g_vColorTint", $"[{F(tint.X)} {F(tint.Y)} {F(tint.Z)} 1.000000]");

        // Opacity.
        if (alphaTest || translucent)
        {
            string? translucency = null;
            if (m.OpacityTexture is { } opacity)
                translucency = Add(opacity, opacity.Channel, "trans");
            else if (m.BaseColorTexture is { } baseTex)
                translucency = Add(baseTex, TextureChannel.A, "trans");
            if (translucency is not null)
                Key("TextureTranslucency", translucency);
            if (alphaTest)
                Key("g_flAlphaTestReference", F(m.AlphaCutoff ?? 0.5f));
            else if (translucency is null)
                Key("g_flOpacityScale", F(m.BaseColor.W));
        }

        // Normal.
        Key("TextureNormal", m.NormalTexture is { } normal ? Add(normal, TextureChannel.All, "normal") : DefaultNormal);

        // Roughness.
        if (m.RoughnessTexture is { } rough)
        {
            Key("TextureRoughness", Add(rough, ScalarChannel(rough, TextureChannel.G), "rough"));
            Key("g_flRoughnessScaleFactor", F(m.Roughness));
        }
        else
        {
            Key("TextureRoughness", DefaultRough);
            Key("g_flRoughnessScaleFactor", F(m.Roughness));
        }

        // Metalness.
        if (m.MetalnessTexture is { } metal)
            Key("TextureMetalness", Add(metal, ScalarChannel(metal, TextureChannel.B), "metal"));
        else
            Key("g_flMetalness", F(m.Metalness));

        // Ambient occlusion.
        if (m.AmbientOcclusionTexture is { } ao)
            Key("TextureAmbientOcclusion", Add(ao, ScalarChannel(ao, TextureChannel.R), "ao"));

        // Self illumination.
        if (m.EmissiveTexture is { } emissive)
            Key("TextureSelfIllumMask", Add(emissive, TextureChannel.All, "selfillum"));

        Line("}");
        return new MaterialOutput { VmatPath = Join(folder, name + ".vmat"), VmatText = sb.ToString(), Textures = textures };
    }

    /// <summary>
    /// Writes the vmat and every whole-image texture under <paramref name="assetsRoot"/> (an
    /// absolute folder). Existing vmats whose first line is no longer <see cref="GeneratedMarker"/>
    /// are left alone. Returns the channel extractions that still need an image decoder.
    /// </summary>
    public static List<(string AbsolutePath, TextureRef Source, TextureChannel Channel)> WriteFiles(MaterialOutput output, string assetsRoot)
    {
        var pending = new List<(string, TextureRef, TextureChannel)>();
        var vmat = Absolute(assetsRoot, output.VmatPath);
        Directory.CreateDirectory(Path.GetDirectoryName(vmat) ?? assetsRoot);
        if (!File.Exists(vmat) || IsGenerated(File.ReadAllText(vmat)))
            File.WriteAllText(vmat, output.VmatText);
        foreach (var (relative, source, channel) in output.Textures)
        {
            var path = Absolute(assetsRoot, relative);
            Directory.CreateDirectory(Path.GetDirectoryName(path) ?? assetsRoot);
            if (channel != TextureChannel.All)
            {
                pending.Add((path, source, channel));
                continue;
            }
            if (source.FilePath is { } file)
            {
                if (!string.Equals(Path.GetFullPath(file), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase))
                    File.Copy(file, path, overwrite: true);
            }
            else if (source.Bytes is { } bytes)
            {
                File.WriteAllBytes(path, bytes);
            }
        }
        return pending;
    }

    /// <summary>Whether a vmat still carries the generated marker (safe to overwrite).</summary>
    public static bool IsGenerated(string vmatText)
    {
        var firstLine = vmatText.Split('\n')[0].TrimEnd('\r');
        return firstLine == GeneratedMarker;
    }

    private static string Absolute(string root, string relative)
        => Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));

    private static string NormalizeFolder(string folder)
    {
        var f = (folder ?? "").Replace('\\', '/').Trim().Trim('/');
        while (f.Contains("//"))
            f = f.Replace("//", "/");
        return f.ToLowerInvariant();
    }

    private static string Join(string folder, string file) => folder.Length == 0 ? file.ToLowerInvariant() : $"{folder}/{file.ToLowerInvariant()}";

    private static string SafeExtension(string ext)
    {
        var e = (ext ?? "").ToLowerInvariant();
        return e is ".png" or ".jpg" or ".jpeg" or ".tga" or ".psd" or ".tif" or ".tiff" or ".bmp" or ".exr" or ".webp" ? e : ".png";
    }

    private static string F(float v) => (float.IsFinite(v) ? v : 0f).ToString("0.000000", CultureInfo.InvariantCulture);
}