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

using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Import;

using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;

/// <summary>How the weapon was placed in separate first-person arms.</summary>
public sealed record ArmsFit(string ArmsPath, string AttachBone, float RightError, float LeftError, float Shift, int Clips, string Reason);

/// <summary>
/// First-person arms shipped separately from the weapon: one arms model for a whole pack, with
/// per-weapon arm animations ("FP_Arms_Pistol_01_Fire.fbx") and the weapon's own animations
/// ("Pistol_01_Fire.fbx"). The weapon is put in the arms' item bone (the pack's own setup),
/// checked against where the palms are, and each arm animation is paired with the weapon's
/// animation of the same action, giving one asset with arms, like a file that has them built in.
/// </summary>
public static class FirstPersonArms
{
    private const int MaxDepth = 5;
    private const int MaxFilesScanned = 4000;
    private static readonly string[] ArmWords = { "arm", "arms", "hand", "hands", "viewmodel", "fparms", "fpsarms", "fparm" };
    private static readonly string[] ItemBones = { "hand_item_r", "item_r", "weapon", "weapon_r", "gun", "gun_r", "prop_r", "r_prop", "ik_hand_gun", "hand_gun", "attach_r", "weapon_bone", "wpn" };

    /// <summary>
    /// The pack's arms model for this weapon, or null: an arms-only model near the weapon (up to
    /// four folders up) whose own folder holds animations named after the weapon.
    /// </summary>
    public static string? Find(string weaponPath)
    {
        var stem = Path.GetFileNameWithoutExtension(weaponPath);
        if (stem.Length < 3)
            return null;
        var dir = Path.GetDirectoryName(Path.GetFullPath(weaponPath));
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        for (var up = 0; up < 4 && !string.IsNullOrEmpty(dir) && !IsBroadFolder(dir); up++, dir = Path.GetDirectoryName(dir))
        {
            foreach (var candidate in Files(dir, MaxDepth, "*.fbx").Take(MaxFilesScanned))
            {
                if (!seen.Add(candidate) || string.Equals(Path.GetFullPath(candidate), Path.GetFullPath(weaponPath), StringComparison.OrdinalIgnoreCase))
                    continue;
                var tokens = NameTokens.Split(Path.GetFileNameWithoutExtension(candidate));
                if (!NameTokens.Has(tokens, ArmWords) && !ArmWords.Any(w => NameTokens.Joined(tokens).Contains(w, StringComparison.Ordinal)))
                    continue;
                if (NameTokens.Joined(tokens).Contains(NameTokens.Joined(NameTokens.Split(stem)), StringComparison.Ordinal))
                    continue; // an animation of the arms, not the arms model
                if (AnimationsFor(candidate, stem).Any())
                    return candidate;
            }
        }
        return null;
    }

    /// <summary>Animation files of the arms that belong to this weapon ("FP_Arms_Pistol_01_Fire").</summary>
    public static IEnumerable<string> AnimationsFor(string armsPath, string weaponStem)
    {
        var dir = Path.GetDirectoryName(Path.GetFullPath(armsPath));
        if (string.IsNullOrEmpty(dir))
            yield break;
        // Named after both: "FP_Arms_Pistol_01_Fire" for "FP_Arms" holding "Pistol_01".
        var key = NameTokens.Joined(NameTokens.Split(weaponStem));
        var arms = NameTokens.Joined(NameTokens.Split(Path.GetFileNameWithoutExtension(armsPath)));
        foreach (var f in Files(dir, MaxDepth, "*.fbx").Take(MaxFilesScanned))
        {
            if (string.Equals(Path.GetFullPath(f), Path.GetFullPath(armsPath), StringComparison.OrdinalIgnoreCase))
                continue;
            var name = NameTokens.Joined(NameTokens.Split(Path.GetFileNameWithoutExtension(f)));
            if (name.Contains(key, StringComparison.Ordinal) && name.Contains(arms, StringComparison.Ordinal))
                yield return f;
        }
    }

    /// <summary>
    /// The weapon held by the arms: one asset with the arms' skeleton and mesh, the weapon under
    /// the arms' item bone, and a clip per arm animation with the weapon's matching clip on it.
    /// Null when the arms have no animations for this weapon or can't hold it.
    /// </summary>
    public static WeaponAsset? Combine(WeaponAsset weapon, string weaponPath, string armsPath, out ArmsFit? fit, IProgress<string>? progress = null, CancellationToken cancel = default)
    {
        fit = null;
        var arms = WeaponLoader.Load(armsPath, withAnimationFiles: false);
        var sk = arms.Skeleton;
        var right = HandRig.Build(sk, Side.Right);
        if (right is null)
            return null;
        var left = HandRig.Build(sk, Side.Left);
        var stem = Path.GetFileNameWithoutExtension(weaponPath);
        var armsStem = Path.GetFileNameWithoutExtension(armsPath);

        // The arm animations for this weapon, named by action ("Fire", "Breathing_Aiming").
        var armClips = new List<Clip>();
        foreach (var file in AnimationsFor(armsPath, stem))
        {
            cancel.ThrowIfCancellationRequested();
            progress?.Report($"Reading arm animation {Path.GetFileName(file)}");
            try
            {
                var name = ActionName(Path.GetFileNameWithoutExtension(file), armsStem, stem);
                if (AnimationFiles.Read(File.ReadAllBytes(file), sk, name) is { } clips)
                    armClips.AddRange(clips.Where(c => armClips.All(a => !string.Equals(a.Name, c.Name, StringComparison.OrdinalIgnoreCase))));
            }
            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)
            {
            }
        }
        if (armClips.Count == 0)
            return null;

        var attach = AttachBone(sk, right);
        var reference = armClips.FirstOrDefault(c => AnimationClassifier.Classify(c.Name).Role == AnimationRole.Idle) ?? armClips[0];
        var analysis = WeaponAnalyzer.Analyze(weapon);
        var place = Place(arms, reference, attach, right, left, analysis, out var rightError, out var leftError, out var shift, out var reason);

        var combined = Build(arms, weapon, attach, place, armClips);
        combined.Notes.AddRange(arms.Notes.Select(n => $"Arms: {n}"));
        combined.Notes.Add($"First-person arms from {Path.GetFileName(armsPath)}: the weapon is held in '{sk[attach].Name}' ({reason}); {armClips.Count} arm animations.");
        fit = new ArmsFit(armsPath, sk[attach].Name, rightError, leftError, shift, armClips.Count, reason);
        return combined;
    }

    /// <summary>"FP_Arms_Pistol_01_Fire_Aiming" beside "FP_Arms" for "Pistol_01" is "Fire_Aiming".</summary>
    public static string ActionName(string fileStem, string armsStem, string weaponStem)
    {
        var name = AnimationFiles.ClipName(armsStem, fileStem);
        var i = name.IndexOf(weaponStem, StringComparison.OrdinalIgnoreCase);
        if (i >= 0)
            name = (name[..i] + name[(i + weaponStem.Length)..]).Trim('_', '-', ' ', '.', '@');
        return name.Length > 0 ? name : fileStem;
    }

    /// <summary>The bone the pack parents weapons to, else the right hand.</summary>
    private static int AttachBone(Skeleton sk, HandRig right)
    {
        foreach (var name in ItemBones)
            for (var b = 0; b < sk.Count; b++)
                if (string.Equals(sk[b].Name, name, StringComparison.OrdinalIgnoreCase))
                    return b;
        // Any "item"/"weapon" bone under the right hand.
        for (var b = 0; b < sk.Count; b++)
        {
            var t = NameTokens.Split(sk[b].Name);
            if (NameTokens.Has(t, "item", "weapon", "gun", "prop") && Descends(sk, b, right.Hand))
                return b;
        }
        return right.Hand;
    }

    private static bool Descends(Skeleton sk, int bone, int ancestor)
    {
        for (var b = bone; b >= 0; b = sk[b].ParentIndex)
            if (b == ancestor)
                return true;
        return false;
    }

    /// <summary>
    /// Weapon model space in the attach bone's space. The pack's own setup is the weapon's origin
    /// on the attach bone; when both palms miss their grips the same way (the weapon's pivot
    /// differs from the pack's scene) the weapon is shifted by that amount.
    /// </summary>
    private static XForm Place(WeaponAsset arms, Clip reference, int attach, HandRig right, HandRig? left, WeaponAnalysis a, out float rightError, out float leftError, out float shift, out string reason)
    {
        Vector3 ToModel(Vector3 c) => Vector3.Transform((c - a.CanonicalOffset) / a.Scale, Quaternion.Conjugate(a.ModelToCanonical));
        var attachWorld = arms.BoneWorld(attach, reference, 0);
        Vector3 Palm(HandRig rig) => arms.BoneWorld(rig.Hand, reference, 0).TransformPoint(rig.PalmCenter + rig.PalmNormal * rig.PalmThickness * 0.5f);

        var grip = a.Primary is { } p ? ToModel(p.Surface.Contact) : ToModel(a.WeaponBounds.Center);
        Vector3? support = a.Support is { } s && Vector3.Distance(s.Surface.Contact, a.Primary?.Surface.Contact ?? s.Surface.Contact) > 3f ? ToModel(s.Surface.Contact) : null;
        var place = XForm.Identity;
        var palmR = Palm(right);
        var dR = palmR - attachWorld.TransformPoint(grip);
        var dL = Vector3.Zero;
        if (left is not null && support is { } sup)
            dL = Palm(left) - attachWorld.TransformPoint(sup);
        rightError = dR.Length();
        leftError = support is null || left is null ? 0f : dL.Length();
        shift = 0f;
        reason = "the pack's own placement";

        // Both hands off the same way: the weapon's pivot differs from the pack's scene.
        var agree = support is null || left is null ? rightError > 2.5f : rightError > 2.5f && leftError > 2.5f && Vector3.Distance(dR, dL) < MathF.Max(2f, 0.5f * MathF.Max(rightError, leftError));
        if (agree)
        {
            var move = support is null || left is null ? dR : (dR + dL) * 0.5f;
            var local = Vector3.Transform(move, Quaternion.Conjugate(attachWorld.Rot));
            place = new XForm(local, Quaternion.Identity);
            shift = move.Length();
            rightError = (dR - move).Length();
            leftError = support is null || left is null ? 0f : (dL - move).Length();
            reason = $"shifted {shift:0.0} in onto the palms";
        }
        return place;
    }

    /// <summary>The arms with the weapon under <paramref name="attach"/>, and one clip per arm animation.</summary>
    private static WeaponAsset Build(WeaponAsset arms, WeaponAsset weapon, int attach, XForm place, List<Clip> armClips)
    {
        var ask = arms.Skeleton;
        var wsk = weapon.Skeleton;
        var names = new HashSet<string>(ask.Bones.Select(b => b.Name), StringComparer.OrdinalIgnoreCase);
        string Unique(string n)
        {
            var name = n;
            for (var i = 2; names.Contains(name); i++)
                name = $"{n}_{i}";
            names.Add(name);
            return name;
        }
        var weaponNames = wsk.Bones.Select(b => Unique(b.Name)).ToArray();

        // Skeleton: the arms, then the weapon with its roots under the attach bone.
        var defs = new List<BoneDefinition>(ask.Count + wsk.Count);
        foreach (var b in ask.Bones)
            defs.Add(new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : ask[b.ParentIndex].Name, b.RestLocal));
        XForm RootLocal(XForm weaponLocal) => XForm.Compose(place, weaponLocal);
        foreach (var b in wsk.Bones)
            defs.Add(new BoneDefinition(weaponNames[b.Index], b.ParentIndex < 0 ? ask[attach].Name : weaponNames[b.ParentIndex], b.ParentIndex < 0 ? RootLocal(b.RestLocal) : b.RestLocal));
        var skeleton = Skeleton.Create(defs);
        var map = new int[wsk.Count];
        for (var i = 0; i < wsk.Count; i++)
            map[i] = skeleton.IndexOf(weaponNames[i]);
        var armMap = new int[ask.Count];
        for (var i = 0; i < ask.Count; i++)
            armMap[i] = skeleton.IndexOf(ask[i].Name);

        // Mesh: the weapon's vertices move from its rest to its place in the arms' rest.
        var weaponToCombined = XForm.Compose(skeleton.RestWorld[attach], place);
        var mesh = Merge(arms.Mesh, armMap, weapon.Mesh, map, weaponToCombined);

        // Clips: the arm animation with the weapon's clip of the same action (else its idle).
        var weaponIdle = weapon.Clips.FirstOrDefault(c => AnimationClassifier.Classify(c.Name).Role == AnimationRole.Idle);
        var clips = new List<Clip>();
        foreach (var armClip in armClips)
        {
            var own = weapon.Clips.FirstOrDefault(c => string.Equals(c.Name, armClip.Name, StringComparison.OrdinalIgnoreCase))
                ?? weapon.Clips.FirstOrDefault(c => AnimationClassifier.Classify(c.Name).Role is var r && r != AnimationRole.Unknown && r == AnimationClassifier.Classify(armClip.Name).Role)
                ?? weaponIdle;
            var frames = new List<XForm[]>(armClip.FrameCount);
            for (var f = 0; f < armClip.FrameCount; f++)
            {
                var locals = new XForm[skeleton.Count];
                for (var i = 0; i < ask.Count; i++)
                    locals[armMap[i]] = armClip.Frames[f][i];
                XForm[]? w = null;
                if (own is { FrameCount: > 0 })
                {
                    var t = f / armClip.Fps;
                    w = own.Frames[Math.Clamp((int)MathF.Round(t * own.Fps), 0, own.FrameCount - 1)];
                }
                for (var i = 0; i < wsk.Count; i++)
                {
                    var local = w is not null && i < w.Length ? w[i] : wsk[i].RestLocal;
                    locals[map[i]] = wsk[i].ParentIndex < 0 ? RootLocal(local) : local;
                }
                frames.Add(locals);
            }
            clips.Add(new Clip(armClip.Name, armClip.Fps, armClip.Looping, frames, armClip.NativeFps));
        }

        var asset = new WeaponAsset { CameraViews = arms.CameraViews, Name = weapon.Name, Kind = weapon.Kind, SourcePath = weapon.SourcePath, Skeleton = skeleton, Mesh = mesh, Clips = clips, Attachments = weapon.Attachments };
        asset.Notes.AddRange(weapon.Notes);
        return asset;
    }

    /// <summary>The arms' mesh and the weapon's (moved into place) as one, with both sets of materials.</summary>
    private static TriMesh Merge(TriMesh arms, int[] armMap, TriMesh weapon, int[] weaponMap, XForm weaponToCombined)
    {
        var positions = new List<Vector3>(arms.Positions.Length + weapon.Positions.Length);
        var bones = new List<int>();
        positions.AddRange(arms.Positions);
        bones.AddRange(Enumerable.Range(0, arms.Positions.Length).Select(v => arms.VertexBone is { } vb && vb[v] >= 0 && vb[v] < armMap.Length ? armMap[vb[v]] : 0));
        foreach (var p in weapon.Positions)
            positions.Add(weaponToCombined.TransformPoint(p));
        bones.AddRange(Enumerable.Range(0, weapon.Positions.Length).Select(v => weapon.VertexBone is { } vb && vb[v] >= 0 && vb[v] < weaponMap.Length ? weaponMap[vb[v]] : weaponMap[0]));

        var indices = arms.Indices.Concat(weapon.Indices.Select(i => i + arms.Positions.Length)).ToArray();
        var parts = arms.PartNames.Concat(weapon.PartNames).ToList();
        var triPart = Enumerable.Range(0, arms.TriangleCount).Select(t => arms.TrianglePart is { } tp ? tp[t] : 0)
            .Concat(Enumerable.Range(0, weapon.TriangleCount).Select(t => (weapon.TrianglePart is { } tp ? tp[t] : 0) + arms.PartNames.Count)).ToArray();
        var materials = arms.Materials.Concat(weapon.Materials).ToList();
        var triMaterial = Enumerable.Range(0, arms.TriangleCount).Select(t => arms.TriangleMaterial is { } tm ? tm[t] : 0)
            .Concat(Enumerable.Range(0, weapon.TriangleCount).Select(t => (weapon.TriangleMaterial is { } tm ? tm[t] : 0) + arms.Materials.Count)).ToArray();

        Vector3[]? normals = null;
        if (arms.CornerNormals is not null || weapon.CornerNormals is not null)
        {
            var armN = arms.CornerNormals ?? new TriMesh(arms.Positions, arms.Indices).EnsureNormals();
            var wN = weapon.CornerNormals ?? new TriMesh(weapon.Positions, weapon.Indices).EnsureNormals();
            normals = armN.Concat(wN.Select(n => Vector3.Transform(n, weaponToCombined.Rot))).ToArray();
        }
        Vector2[]? uvs = null;
        if (arms.CornerUVs is not null || weapon.CornerUVs is not null)
            uvs = (arms.CornerUVs ?? new Vector2[arms.Indices.Length]).Concat(weapon.CornerUVs ?? new Vector2[weapon.Indices.Length]).ToArray();
        return new TriMesh(positions.ToArray(), indices, bones.ToArray(), triPart, parts, normals, uvs, triMaterial, materials);
    }

    /// <summary>Folders too broad to search (a drive, the user's home, Desktop, Downloads, Documents).</summary>
    private static bool IsBroadFolder(string dir)
    {
        var full = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
        if (Path.GetPathRoot(full)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) == full)
            return true;
        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile).TrimEnd(Path.DirectorySeparatorChar);
        if (home.Length > 0 && string.Equals(full, home, StringComparison.OrdinalIgnoreCase))
            return true;
        var name = Path.GetFileName(full).ToLowerInvariant();
        return name is "desktop" or "downloads" or "documents" or "users" or "home";
    }

    private static IEnumerable<string> Files(string folder, int depth, string pattern)
    {
        List<string> here;
        try
        {
            here = Directory.EnumerateFiles(folder, pattern).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, pattern))
                yield return f;
    }
}