Code/WeaponImporter/HoldData.cs
using System.Globalization;
using System.Text;
using System.Text.Json;

namespace WeaponImporter;

/// <summary>One key of a hand contact track (see <see cref="WeaponHold.Contacts"/>).</summary>
public sealed class HoldContact
{
    /// <summary>True for the left (support) hand, false for the right hand.</summary>
    public bool Left { get; set; } = true;

    /// <summary>Normalized action time (0..1) at which the blend starts.</summary>
    public float T { get; set; }

    /// <summary>True blends to locked (weight 1), false to released (weight 0).</summary>
    public bool Lock { get; set; }

    /// <summary>Blend duration in seconds.</summary>
    public float Blend { get; set; }

    /// <summary>Weapon bone the hand target follows while locked ("" = the weapon itself).</summary>
    public string Follow { get; set; } = "";

    /// <summary>True when <see cref="Offset"/> was given.</summary>
    public bool HasOffset { get; set; }

    /// <summary>Extra weapon-space offset: position added, rotation pre-multiplied (weapon axes).</summary>
    public Transform Offset { get; set; } = Transform.Zero;
}

/// <summary>One baked weapon action (see <see cref="WeaponHold.Actions"/>).</summary>
public sealed class ActionInfo
{
    /// <summary>Lowercase role key (idle, fire, reload, ...).</summary>
    public string Role { get; set; } = "";

    /// <summary>Action duration in seconds (0 = use the sequence length, or 1 s).</summary>
    public float Seconds { get; set; }

    /// <summary>Weapon model sequence to play ("" = none).</summary>
    public string Sequence { get; set; } = "";

    /// <summary>Character animgraph bool whose rising edge starts the action ("" = code only).</summary>
    public string Trigger { get; set; } = "";
}

/// <summary>A baked finger bone local rotation.</summary>
public readonly struct FingerRotation
{
    /// <summary>Creates a finger entry.</summary>
    public FingerRotation( string bone, Rotation rotation )
    {
        Bone = bone;
        Rotation = rotation;
    }

    /// <summary>Bone name.</summary>
    public string Bone { get; }

    /// <summary>Rotation relative to the parent bone.</summary>
    public Rotation Rotation { get; }
}

/// <summary>Parsing, formatting and evaluation of the plain-string baked hold data.</summary>
public static class HoldData
{
    private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;

    /// <summary>Parses "px,py,pz,qx,qy,qz,qw" (invariant culture). The quaternion is normalized.</summary>
    public static bool TryParseTransform( string s, out Transform t )
    {
        t = Transform.Zero;
        if ( string.IsNullOrWhiteSpace( s ) )
            return false;

        var parts = s.Split( ',' );
        if ( parts.Length != 7 )
            return false;

        var v = new float[7];
        for ( int i = 0; i < 7; i++ )
        {
            if ( !TryFloat( parts[i], out v[i] ) )
                return false;
        }

        if ( !TryMakeRotation( v[3], v[4], v[5], v[6], out var rotation ) )
            return false;

        t = new Transform( new Vector3( v[0], v[1], v[2] ), rotation );
        return true;
    }

    /// <summary>Formats a transform as "px,py,pz,qx,qy,qz,qw" with 6 decimals (invariant culture).</summary>
    public static string FormatTransform( Transform t )
    {
        var p = t.Position;
        var r = t.Rotation;
        var sb = new StringBuilder( 96 );
        Append( sb, p.x ).Append( ',' );
        Append( sb, p.y ).Append( ',' );
        Append( sb, p.z ).Append( ',' );
        Append( sb, r.x ).Append( ',' );
        Append( sb, r.y ).Append( ',' );
        Append( sb, r.z ).Append( ',' );
        Append( sb, r.w );
        return sb.ToString();
    }

    /// <summary>
    /// Parses "bone=qx,qy,qz,qw;bone=..." into <paramref name="into"/> (cleared first).
    /// Empty input is valid and yields no entries.
    /// </summary>
    public static bool TryParseFingers( string s, List<FingerRotation> into, out string error )
    {
        into.Clear();
        error = null;
        if ( string.IsNullOrWhiteSpace( s ) )
            return true;

        foreach ( var raw in s.Split( ';' ) )
        {
            var entry = raw.Trim();
            if ( entry.Length == 0 )
                continue;

            int eq = entry.IndexOf( '=' );
            if ( eq <= 0 )
            {
                error = $"finger entry '{entry}' has no bone name";
                into.Clear();
                return false;
            }

            var name = entry.Substring( 0, eq ).Trim();
            var q = entry.Substring( eq + 1 ).Split( ',' );
            if ( q.Length != 4
                || !TryFloat( q[0], out var x ) || !TryFloat( q[1], out var y )
                || !TryFloat( q[2], out var z ) || !TryFloat( q[3], out var w )
                || !TryMakeRotation( x, y, z, w, out var rotation ) )
            {
                error = $"finger entry '{entry}' needs 4 finite quaternion components";
                into.Clear();
                return false;
            }

            into.Add( new FingerRotation( name, rotation ) );
        }

        return true;
    }

    /// <summary>
    /// Parses the Actions JSON object into <paramref name="into"/> (cleared first, keys lowercase).
    /// Empty input is valid.
    /// </summary>
    public static bool TryParseActions( string json, Dictionary<string, ActionInfo> into, out string error )
    {
        into.Clear();
        error = null;
        if ( string.IsNullOrWhiteSpace( json ) )
            return true;

        try
        {
            using var doc = JsonDocument.Parse( json );
            if ( doc.RootElement.ValueKind != JsonValueKind.Object )
            {
                error = "actions JSON must be an object";
                return false;
            }

            foreach ( var prop in doc.RootElement.EnumerateObject() )
            {
                if ( prop.Value.ValueKind != JsonValueKind.Object )
                    continue;

                var role = prop.Name.Trim().ToLowerInvariant();
                var info = new ActionInfo
                {
                    Role = role,
                    Seconds = MathF.Max( 0f, ReadFloat( prop.Value, "seconds", 0f ) ),
                    Sequence = ReadString( prop.Value, "sequence" ),
                    Trigger = ReadString( prop.Value, "trigger" ),
                };
                into[role] = info;
            }

            return true;
        }
        catch ( JsonException e )
        {
            into.Clear();
            error = "actions JSON is invalid: " + e.Message;
            return false;
        }
    }

    /// <summary>
    /// Parses the Contacts JSON object into <paramref name="into"/> (cleared first, keys lowercase,
    /// keys of each role sorted by time). Empty input is valid.
    /// </summary>
    public static bool TryParseContacts( string json, Dictionary<string, HoldContact[]> into, out string error )
    {
        into.Clear();
        error = null;
        if ( string.IsNullOrWhiteSpace( json ) )
            return true;

        try
        {
            using var doc = JsonDocument.Parse( json );
            if ( doc.RootElement.ValueKind != JsonValueKind.Object )
            {
                error = "contacts JSON must be an object";
                return false;
            }

            foreach ( var prop in doc.RootElement.EnumerateObject() )
            {
                if ( prop.Value.ValueKind != JsonValueKind.Array )
                    continue;

                var keys = new List<HoldContact>();
                foreach ( var item in prop.Value.EnumerateArray() )
                {
                    if ( item.ValueKind != JsonValueKind.Object )
                        continue;

                    var key = new HoldContact
                    {
                        Left = !string.Equals( ReadString( item, "hand" ).Trim(), "R", StringComparison.OrdinalIgnoreCase ),
                        T = Math.Clamp( ReadFloat( item, "t", 0f ), 0f, 1f ),
                        Lock = ReadBool( item, "lock", false ),
                        Blend = MathF.Max( 0f, ReadFloat( item, "blend", 0f ) ),
                        Follow = ReadString( item, "follow" ).Trim(),
                    };

                    if ( TryReadOffset( item, out var offset ) )
                    {
                        key.HasOffset = true;
                        key.Offset = offset;
                    }

                    keys.Add( key );
                }

                // Stable sort by time so authoring order breaks ties.
                into[prop.Name.Trim().ToLowerInvariant()] = keys.OrderBy( k => k.T ).ToArray();
            }

            return true;
        }
        catch ( JsonException e )
        {
            into.Clear();
            error = "contacts JSON is invalid: " + e.Message;
            return false;
        }
    }

    /// <summary>
    /// Evaluates a hand contact track. The hand starts locked on <paramref name="baseTarget"/>;
    /// each key reached blends (smoothstep over its blend seconds) towards locked or released.
    /// Lock keys also blend the target to the key's own target (resolved by <paramref name="resolve"/>),
    /// release keys keep the previous target. Returns the weight (0..1).
    /// </summary>
    /// <param name="keys">Keys sorted by time; only keys matching <paramref name="left"/> are used.</param>
    /// <param name="left">Evaluate the left-hand (true) or right-hand (false) keys.</param>
    /// <param name="seconds">Elapsed action time in seconds.</param>
    /// <param name="duration">Action duration in seconds (for the normalized key times).</param>
    /// <param name="baseTarget">Weapon-space target before any key.</param>
    /// <param name="resolve">Resolves a lock key's weapon-space target (null = base target + offset).</param>
    /// <param name="target">Resulting weapon-space hand target.</param>
    public static float EvaluateContacts( HoldContact[] keys, bool left, float seconds, float duration,
        Transform baseTarget, Func<HoldContact, Transform> resolve, out Transform target )
    {
        float weight = 1f;
        target = baseTarget;
        if ( keys == null || keys.Length == 0 )
            return weight;

        duration = MathF.Max( duration, 1e-4f );
        for ( int i = 0; i < keys.Length; i++ )
        {
            var key = keys[i];
            if ( key.Left != left )
                continue;

            float start = key.T * duration;
            if ( seconds < start )
                break;

            float s = key.Blend <= 0f ? 1f : SmoothStep( (seconds - start) / key.Blend );
            float before = weight;
            weight = weight + ((key.Lock ? 1f : 0f) - weight) * s;

            if ( key.Lock )
            {
                var keyTarget = resolve != null ? resolve( key ) : ApplyOffset( key, baseTarget );
                // Coming from a released hand (playing its own animation) the old target has no
                // weight, so the new one is taken at once rather than slid across the weapon.
                target = before < ReleasedWeight ? keyTarget : Lerp( target, keyTarget, s );
            }
        }

        return Math.Clamp( weight, 0f, 1f );
    }

    /// <summary>Contact weight under which a hand counts as released (its target no longer shows).</summary>
    public const float ReleasedWeight = 0.02f;

    /// <summary>Applies a key's optional offset to a weapon-space target.</summary>
    public static Transform ApplyOffset( HoldContact key, Transform target )
    {
        if ( key == null || !key.HasOffset )
            return target;
        return new Transform( target.Position + key.Offset.Position, key.Offset.Rotation * target.Rotation );
    }

    /// <summary>Position lerp + shortest-path rotation slerp (scale 1).</summary>
    public static Transform Lerp( Transform a, Transform b, float t )
    {
        if ( t <= 0f )
            return a;
        if ( t >= 1f )
            return b;
        return new Transform( a.Position + (b.Position - a.Position) * t, ArmSolver.Slerp( a.Rotation, b.Rotation, t ) );
    }

    /// <summary>Clamped smoothstep.</summary>
    public static float SmoothStep( float x )
    {
        x = Math.Clamp( x, 0f, 1f );
        return x * x * (3f - 2f * x);
    }

    private static bool TryFloat( string s, out float value )
    {
        if ( float.TryParse( s.Trim(), NumberStyles.Float, Invariant, out value ) && float.IsFinite( value ) )
            return true;
        value = 0f;
        return false;
    }

    private static bool TryMakeRotation( float x, float y, float z, float w, out Rotation rotation )
    {
        rotation = Rotation.Identity;
        float len = MathF.Sqrt( x * x + y * y + z * z + w * w );
        if ( !float.IsFinite( len ) || len < 1e-6f )
            return false;
        rotation = new Rotation( x / len, y / len, z / len, w / len );
        return true;
    }

    private static StringBuilder Append( StringBuilder sb, float v )
    {
        if ( !float.IsFinite( v ) )
            v = 0f;
        var text = v.ToString( "F6", Invariant );
        // Avoid "-0.000000".
        if ( text == "-0.000000" )
            text = "0.000000";
        return sb.Append( text );
    }

    private static bool TryGet( JsonElement obj, string name, out JsonElement value )
    {
        foreach ( var p in obj.EnumerateObject() )
        {
            if ( string.Equals( p.Name, name, StringComparison.OrdinalIgnoreCase ) )
            {
                value = p.Value;
                return true;
            }
        }

        value = default;
        return false;
    }

    private static float ReadFloat( JsonElement obj, string name, float fallback )
    {
        if ( !TryGet( obj, name, out var v ) )
            return fallback;
        if ( v.ValueKind == JsonValueKind.Number && v.TryGetDouble( out var d ) && double.IsFinite( d ) )
            return (float)d;
        if ( v.ValueKind == JsonValueKind.String && TryFloat( v.GetString() ?? "", out var f ) )
            return f;
        return fallback;
    }

    private static string ReadString( JsonElement obj, string name )
    {
        if ( !TryGet( obj, name, out var v ) )
            return "";
        return v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
    }

    private static bool ReadBool( JsonElement obj, string name, bool fallback )
    {
        if ( !TryGet( obj, name, out var v ) )
            return fallback;
        return v.ValueKind switch
        {
            JsonValueKind.True => true,
            JsonValueKind.False => false,
            JsonValueKind.Number => v.TryGetDouble( out var d ) && d != 0,
            JsonValueKind.String => string.Equals( v.GetString(), "true", StringComparison.OrdinalIgnoreCase ),
            _ => fallback,
        };
    }

    private static bool TryReadOffset( JsonElement obj, out Transform offset )
    {
        offset = Transform.Zero;
        if ( !TryGet( obj, "offset", out var v ) )
            return false;

        if ( v.ValueKind == JsonValueKind.String )
            return TryParseTransform( v.GetString(), out offset );

        if ( v.ValueKind != JsonValueKind.Array || v.GetArrayLength() != 7 )
            return false;

        var f = new float[7];
        int i = 0;
        foreach ( var e in v.EnumerateArray() )
        {
            if ( e.ValueKind != JsonValueKind.Number || !e.TryGetDouble( out var d ) || !double.IsFinite( d ) )
                return false;
            f[i++] = (float)d;
        }

        if ( !TryMakeRotation( f[3], f[4], f[5], f[6], out var rotation ) )
            return false;

        offset = new Transform( new Vector3( f[0], f[1], f[2] ), rotation );
        return true;
    }
}