Core/Mapping/HandDetectionProfiles.cs
#nullable enable
using System.Text.RegularExpressions;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;

namespace HumanoidHandRetargeter.Mapping;

/// <summary>Reference conventions supply semantic hints; they never bypass hierarchy validation.</summary>
internal static partial class HandDetectionProfiles
{
    internal sealed record Alias(string Role,string Name);
    internal sealed record Hint(string Role,HandSide Side,string Profile);
    internal sealed class Profile
    {
        public string Name { get; }
        private readonly Regex[] prefixes;
        public Dictionary<string,string[]> Roles { get; }
        public int RoleCount { get; }
        public Profile(string name,string[] patterns,Alias[] aliases)
        {
            Name=name;prefixes=patterns.Select(p=>new Regex(p,RegexOptions.IgnoreCase|RegexOptions.CultureInvariant)).ToArray();
            Roles=aliases.GroupBy(a=>Key(a.Name)).ToDictionary(g=>g.Key,g=>g.Select(a=>a.Role).Distinct().ToArray());
            RoleCount=aliases.Select(a=>a.Role).Distinct().Count();
        }
        public string Strip(string name)
        {
            // Strip FBX namespaces independently of exporter prefixes (e.g. Character:CC_Base_L_Hand).
            name=name[(name.LastIndexOf(':')+1)..];
            var hash=name.LastIndexOf('#');
            if(hash>=0&&name[(hash+1)..].All(char.IsDigit))name=name[..hash];
            foreach(var prefix in prefixes)name=prefix.Replace(name,"");
            return name;
        }
    }
    internal static string Key(string name)=>string.Concat(name.Where(char.IsLetterOrDigit)).ToLowerInvariant();

    internal static Hint?[] Resolve(Skel rig)
    {
        var proposals=new Dictionary<int,List<(int Score,Hint Hint)>>();
        foreach(var profile in Catalog)
        {
            var matches=new Dictionary<int,string>();
            foreach(var bone in rig.Bones)
                if(profile.Roles.TryGetValue(Key(profile.Strip(bone.Name)),out var roles)&&roles.Length==1)
                    matches[bone.Index]=roles[0];
            foreach(var wrist in matches.Where(p=>p.Value is "HandL" or "HandR"))
            {
                var side=wrist.Value[^1];
                var relevant=matches.Where(p=>p.Value[^1]==side && (p.Key==wrist.Key ||
                    (IsDigit(p.Value)&&rig.DescendsFrom(p.Key,wrist.Key)) ||
                    (IsArm(p.Value)&&rig.DescendsFrom(wrist.Key,p.Key)))).ToArray();
                // Match coverage distinguishes e.g. SOMA metacarpals from Mixamo phalanges.
                // Namespace evidence breaks equivalent-name ties without overpowering anatomy.
                var score=relevant.Length*1000+relevant.Select(p=>p.Value).Distinct().Count()*100/Math.Max(1,profile.RoleCount/2)
                    +relevant.Count(p=>profile.Strip(rig[p.Key].Name)!=rig[p.Key].Name)*2;
                foreach(var match in relevant)
                {
                    if(!proposals.TryGetValue(match.Key,out var list))proposals[match.Key]=list=new();
                    list.Add((score,new(match.Value[..^1],side=='L'?HandSide.Left:HandSide.Right,profile.Name)));
                }
            }
        }
        var hints=new Hint?[rig.Count];
        foreach(var pair in proposals)
        {
            var best=pair.Value.Where(p=>p.Score==pair.Value.Max(v=>v.Score)).Select(p=>p.Hint).ToArray();
            if(best.Select(p=>(p.Role,p.Side)).Distinct().Count()==1)hints[pair.Key]=best[0];
        }
        ResolveNumberedFpsHands(rig,hints);
        ResolveUnlabelledSplitArms(rig,hints);
        return hints;
    }
    // Numbered FPS rays can run from thumb to pinky or in reverse, with an
    // additional palm joint on the four fingers. Require the whole convention
    // and its radial geometry; bare finger numbers alone are ambiguous.
    private static void ResolveNumberedFpsHands(Skel rig,Hint?[] hints)
    {
        foreach(var side in new[]{HandSide.Left,HandSide.Right})
        {
            var suffix=side==HandSide.Left?"l":"r";
            string Name(int bone)=>Key(rig[bone].Name[(rig[bone].Name.LastIndexOf(':')+1)..]).Replace("left","l").Replace("right","r");
            var wrists=rig.Bones.Where(b=>Name(b.Index)=="hand"+suffix).ToArray();
            if(wrists.Length!=1)continue;
            var wrist=wrists[0].Index;
            foreach(var thumbNumber in new[]{1,5})
            {
            var chains=new List<int[]>();
            for(var finger=1;finger<=5;finger++)
            {
                var chain=new List<int>();var parent=wrist;
                for(var joint=1;joint<=(finger==thumbNumber?3:4);joint++)
                {
                    var pattern=$"^finger0*{finger}0*{joint}{suffix}$";
                    var children=rig.ChildrenOf(parent).Where(i=>Regex.IsMatch(Name(i),pattern)).ToArray();
                    if(children.Length!=1)break;
                    parent=children[0];chain.Add(parent);
                }
                if(chain.Count!=(finger==thumbNumber?3:4))break;
                // A three-joint thumb must not just be a truncated four-joint finger.
                if(finger==thumbNumber&&rig.ChildrenOf(parent).Any(i=>Regex.IsMatch(Name(i),$"^finger0*{finger}0*4{suffix}$")))break;
                chains.Add(chain.ToArray());
            }
            if(chains.Count!=5)continue;
            var thumb=rig.RestWorld[chains[thumbNumber-1][0]].Pos;
            var radial=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?3:1][0]].Pos);
            var ulnar=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?0:4][0]].Pos);
            if(!float.IsFinite(radial)||!float.IsFinite(ulnar)||radial>=ulnar*.8f)continue;
            var roles=thumbNumber==5?new[]{"Pinky","Ring","Middle","Index","Thumb"}:new[]{"Thumb","Index","Middle","Ring","Pinky"};
            if(chains.SelectMany(c=>c).Any(i=>hints[i] is not null))continue;
            for(var finger=0;finger<5;finger++)
                for(var joint=0;joint<chains[finger].Length;joint++)
                    hints[chains[finger][joint]]=new(roles[finger]+(finger!=thumbNumber-1&&joint==0?"Meta":"Prox"),side,"numbered_fps_hands");
            }
        }
    }
    internal static bool IsDigit(string role)=>new[]{"Thumb","Index","Middle","Ring","Pinky"}.Any(role.StartsWith);
    private static bool IsArm(string role)=>role.StartsWith("Clavicle")||role.StartsWith("UpperArm")||role.StartsWith("LowerArm");
}