Editor/WeaponImporter/Core/Import/AnimationFiles.cs
#nullable enable annotations

using WeaponImporter.Core.Formats.Fbx;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Import;

/// <summary>
/// Animations shipped as separate files: many packs export the rigged mesh once and every
/// action as its own animation-only FBX ("Fbx/animations/RIG_Fire.fbx"). Such files beside the
/// weapon (same folder or a subfolder) whose skeleton is the weapon's own are read and their
/// clips added to the weapon, mapped onto its skeleton by bone name.
/// </summary>
public static class AnimationFiles
{
    public const int MaxFiles = 64;
    public const long MaxFileBytes = 256L * 1024 * 1024;

    /// <summary>Share of an animation file's bones the weapon must have for the file to belong to it.</summary>
    public const float MinBoneMatch = 0.9f;

    /// <summary>Other FBX files in the weapon's folder and up to two subfolder levels below it.</summary>
    public static IEnumerable<string> Candidates(string path)
    {
        var dir = Path.GetDirectoryName(Path.GetFullPath(path));
        if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
            return Array.Empty<string>();
        var self = Path.GetFullPath(path);
        IEnumerable<string> Files(string folder, int depth)
        {
            IEnumerable<string> here;
            try
            {
                here = Directory.EnumerateFiles(folder, "*.fbx").OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
            }
            catch (Exception e) when (e is IOException or UnauthorizedAccessException)
            {
                yield break;
            }
            foreach (var f in here)
                yield return f;
            if (depth == 0)
                yield break;
            List<string> subs;
            try
            {
                subs = Directory.EnumerateDirectories(folder).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
            }
            catch (Exception e) when (e is IOException or UnauthorizedAccessException)
            {
                yield break;
            }
            foreach (var sub in subs)
                foreach (var f in Files(sub, depth - 1))
                    yield return f;
        }
        var stem = Path.GetFileNameWithoutExtension(path);
        return Files(dir, 2)
            .Where(f => !string.Equals(Path.GetFullPath(f), self, StringComparison.OrdinalIgnoreCase) && LooksLikeAnimationOf(stem, dir, f))
            .Take(MaxFiles * 4);
    }

    /// <summary>
    /// Only files named or placed like this weapon's animations are opened (a pack folder can hold
    /// dozens of other large weapons): "Rifle_Fire.fbx" or "[email protected]" beside "Rifle.fbx", or
    /// anything under a folder with "anim" in its name.
    /// </summary>
    public static bool LooksLikeAnimationOf(string weaponStem, string weaponDir, string file)
    {
        var name = Path.GetFileNameWithoutExtension(file);
        if (name.StartsWith(weaponStem, StringComparison.OrdinalIgnoreCase) || name.Contains('@') || name.Contains("anim", StringComparison.OrdinalIgnoreCase))
            return true;
        var folder = Path.GetRelativePath(weaponDir, Path.GetDirectoryName(file) ?? weaponDir);
        return folder != "." && folder.Contains("anim", StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>
    /// The weapon with the clips of every matching animation-only file next to it added. Files
    /// with geometry (other weapons, other exports of this one) and files whose skeleton isn't
    /// this weapon's are left alone.
    /// </summary>
    public static WeaponAsset Merge(WeaponAsset asset, string path)
    {
        if (asset.Kind != SourceKind.Fbx || asset.Skeleton.Count < 2 || string.IsNullOrEmpty(path))
            return asset;
        var clips = asset.Clips.ToList();
        var names = new HashSet<string>(clips.Select(c => c.Name), StringComparer.OrdinalIgnoreCase);
        var stem = Path.GetFileNameWithoutExtension(path);
        var used = new List<string>();
        var read = 0;
        foreach (var file in Candidates(path))
        {
            if (read >= MaxFiles)
                break;
            try
            {
                if (new FileInfo(file).Length > MaxFileBytes)
                    continue;
                var data = File.ReadAllBytes(file);
                var added = Read(data, asset.Skeleton, ClipName(stem, Path.GetFileNameWithoutExtension(file)));
                if (added is null)
                    continue;
                read++;
                var any = false;
                foreach (var clip in added)
                {
                    if (!names.Add(clip.Name))
                        continue;
                    clips.Add(clip);
                    any = true;
                }
                if (any)
                    used.Add(Path.GetRelativePath(Path.GetDirectoryName(Path.GetFullPath(path))!, file));
            }
            catch (Exception e) when (e is IOException or UnauthorizedAccessException or FormatException or EndOfStreamException or IndexOutOfRangeException or ArgumentException or InvalidOperationException or KeyNotFoundException or NotSupportedException or ArithmeticException)
            {
                // Not an animation of this weapon (or unreadable): skip it.
            }
        }
        if (used.Count == 0)
            return asset;
        var copy = new WeaponAsset { CameraViews = asset.CameraViews, Name = asset.Name, Kind = asset.Kind, SourcePath = asset.SourcePath, Skeleton = asset.Skeleton, Mesh = asset.Mesh, Clips = clips, Attachments = asset.Attachments };
        copy.Notes.AddRange(asset.Notes);
        var folders = used.Select(u => Path.GetDirectoryName(u) is { Length: > 0 } d ? d + Path.DirectorySeparatorChar : "").Distinct().ToList();
        copy.Notes.Add($"{clips.Count - asset.Clips.Count} animations loaded from {used.Count} separate file{(used.Count == 1 ? "" : "s")}{(folders.Count == 1 && folders[0].Length > 0 ? $" in {folders[0]}" : "")}.");
        return copy;
    }

    /// <summary>
    /// Clips of an animation-only FBX on <paramref name="target"/>, or null when the file has
    /// geometry or its skeleton isn't the target's. One take is named <paramref name="name"/>;
    /// several are named after their takes.
    /// </summary>
    public static List<Clip>? Read(byte[] data, Skeleton target, string name)
    {
        var scene = FbxScene.Build(FbxTokenizer.Parse(data));
        if (scene.ObjectsById.Values.Any(o => o.NodeType == "Geometry" && o.SubClass == "Mesh"))
            return null;
        if (scene.Stacks.Count == 0)
            return null;
        var imported = FbxImporter.Import(data, new FbxImportOptions { SampleFps = 30f });
        if (imported.Clips.Count == 0)
            return null;
        var conversion = SpaceConversion.For(imported);
        var skeleton = conversion.Skeleton(imported.Skeleton);
        var clips = conversion.Clips(imported.Clips, imported.Skeleton, skeleton);

        // Same skeleton: nearly every bone of the file is one of the weapon's.
        var map = new int[target.Count];
        var shared = 0;
        for (var i = 0; i < target.Count; i++)
        {
            map[i] = skeleton.IndexOf(target[i].Name);
            if (map[i] >= 0)
                shared++;
        }
        if (shared < Math.Max(2, MinBoneMatch * skeleton.Count))
            return null;

        // Exported with other bone axes than the model (its never-moving armature root turned by
        // 90/180 degrees): re-express every bone in the model's axes, keeping how it moves in the
        // world. The file's first frame is the pose it was authored from (the model's rest).
        var convert = ConventionDiffers(target, skeleton, clips, map);
        XForm[]? toModel = null;
        if (convert && clips.FirstOrDefault(c => c.FrameCount > 0) is { } first)
        {
            var refWorld = new Pose(first.Frames[0]).ToWorld(skeleton);
            var restWorld = target.RestWorld;
            toModel = new XForm[target.Count];
            for (var i = 0; i < target.Count; i++)
                toModel[i] = map[i] >= 0 ? XForm.Compose(refWorld[map[i]].Inverse(), restWorld[i]) : XForm.Identity;
        }

        var result = new List<Clip>(clips.Count);
        foreach (var clip in clips)
        {
            if (clip.FrameCount == 0)
                continue;
            var frames = new List<XForm[]>(clip.FrameCount);
            foreach (var frame in clip.Frames)
            {
                var locals = new XForm[target.Count];
                if (toModel is not null)
                {
                    var world = new Pose(frame).ToWorld(skeleton);
                    var modelWorld = new XForm[target.Count];
                    for (var i = 0; i < target.Count; i++)
                    {
                        var parent = target[i].ParentIndex;
                        modelWorld[i] = map[i] >= 0 ? XForm.Compose(world[map[i]], toModel[i])
                            : parent >= 0 ? XForm.Compose(modelWorld[parent], target[i].RestLocal) : target[i].RestLocal;
                        locals[i] = parent >= 0 ? XForm.Compose(modelWorld[parent].Inverse(), modelWorld[i]) : modelWorld[i];
                    }
                }
                else
                {
                    for (var i = 0; i < target.Count; i++)
                        locals[i] = map[i] >= 0 && map[i] < frame.Length ? frame[map[i]] : target[i].RestLocal;
                }
                frames.Add(locals);
            }
            var clipName = clips.Count == 1 ? name : $"{name}_{TakeName(clip.Name)}";
            result.Add(new Clip(clipName, clip.Fps, clip.Looping, frames, clip.NativeFps));
        }
        return result;
    }

    /// <summary>
    /// The file's armature root never moves yet sits turned against the model's (by more than
    /// 60 degrees): the file was exported with other bone axes, not posed differently.
    /// </summary>
    private static bool ConventionDiffers(Skeleton target, Skeleton file, List<Clip> clips, int[] map)
    {
        for (var i = 0; i < target.Count; i++)
        {
            if (target[i].ParentIndex >= 0 || map[i] < 0)
                continue;
            var j = map[i];
            if (Angle(target[i].RestLocal.Rot, file[j].RestLocal.Rot) < 60f)
                continue;
            var still = clips.All(c => c.Frames.All(f => j < f.Length && Angle(f[j].Rot, file[j].RestLocal.Rot) < 1f && System.Numerics.Vector3.Distance(f[j].Pos, file[j].RestLocal.Pos) < 0.05f));
            if (still)
                return true;
        }
        return false;
    }

    private static float Angle(System.Numerics.Quaternion a, System.Numerics.Quaternion b)
        => 2f * MathF.Acos(MathF.Min(1f, MathF.Abs(System.Numerics.Quaternion.Dot(a, b)))) * 57.29578f;

    /// <summary>"RIG_Comando_Fire" beside "RIG_Comando" is "Fire"; unrelated names stay whole.</summary>
    public static string ClipName(string weaponStem, string fileStem)
    {
        var n = 0;
        while (n < weaponStem.Length && n < fileStem.Length && char.ToLowerInvariant(weaponStem[n]) == char.ToLowerInvariant(fileStem[n]))
            n++;
        // Cut at a separator so "Rifle_Fire" beside "Rifle" gives "Fire", not "_Fire" or "ire".
        while (n > 0 && !IsSeparator(fileStem[n - 1]) && !(n == weaponStem.Length && n < fileStem.Length && IsSeparator(fileStem[n])))
            n--;
        var rest = fileStem[n..].TrimStart('_', '-', ' ', '.', '@');
        return rest.Length > 0 ? rest : fileStem;
    }

    private static bool IsSeparator(char c) => c is '_' or '-' or ' ' or '.' or '@';

    /// <summary>"Armature|Fire" -> "Fire".</summary>
    private static string TakeName(string take)
    {
        var bar = take.LastIndexOf('|');
        return bar >= 0 && bar + 1 < take.Length ? take[(bar + 1)..] : take;
    }
}