Code/HumanoidRetargeter/Formats/Ant/AntImporter.cs
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;

namespace HumanoidRetargeter.Formats.Ant;

/// <summary>Sampling and naming options for <see cref="AntImporter"/>.</summary>
public sealed class AntImportOptions
{
    /// <summary>Resample rate for the imported clips (Hz).</summary>
    public float SampleFps { get; init; } = 30f;

    /// <summary>
    /// Authored tick rate of the package. ANT key times are in TICKS; Fight Night's packages
    /// are authored at 30 ticks per second (a 66-tick punch is 2.2 s, which matches the
    /// shipped clip lengths).
    /// </summary>
    public float TicksPerSecond { get; init; } = 30f;

    /// <summary>Base name for clips (ANT clip chunks carry no name of their own —
    /// see <see cref="AntImporter"/> remarks). Takes become <c>{Base}_1</c>, <c>_2</c>, …</summary>
    public string ClipNameBase { get; init; } = "clip";
}

/// <summary>
/// Imports EA ANT animation packages (<c>.cba</c>) into a <see cref="SourceScene"/>.
/// Container decoding lives in <see cref="AntStream"/>; the joint table arrives as companion
/// bytes via <see cref="AntSkeletonJson"/>.
/// </summary>
/// <remarks>
/// <para><b>Where the rest pose comes from.</b> ANT stores NO bind pose: its
/// <c>SkeletonAsset</c> carries only <c>JointName</c> and <c>ParentIndex</c> per joint. This
/// importer therefore adopts the clip's FIRST FRAME as the rest reference — the same rule the
/// retargeter already applies to non-anatomical binds (see <c>RestNormalizer</c>'s reference
/// pose and the mid-pose FBX path), and the only rest information the format actually
/// contains. Joints the clip does not animate keep an IDENTITY rest local and no channels:
/// they are helper/simulation bones (<c>Muscle_*</c>, <c>*_Jiggle</c>, <c>Offset_*</c>) that
/// carry no humanoid role, and an identity local leaves them rigidly attached to their parent
/// rather than inventing a pose for them.</para>
/// <para><b>Consequence for sparse packages.</b> Fight Night's <c>package_proxy_*</c> files
/// animate only 13 IK-proxy joints — no upper arms, hands or thighs — so they import cleanly
/// but cannot satisfy the retargeter's 15 required humanoid slots and are correctly rejected
/// downstream as non-humanoid. The full-body packages (<c>package_main</c>,
/// <c>package_frontend</c>, the <c>package_nis_*</c> cutscenes) are the importable ones.</para>
/// <para><b>Clip names.</b> Clip chunks carry tag/marker strings but no authored clip name in
/// a position this decoder trusts, so takes are named from the file stem plus their index —
/// the same convention <c>RwAnmImporter</c> uses for RenderWare banks.</para>
/// </remarks>
public static class AntImporter
{
    /// <summary>
    /// Reads an ANT package. <paramref name="skeletonData"/> must carry the companion joint
    /// table (see <see cref="AntSkeletonJson"/>); the animation alone cannot name its joints.
    /// </summary>
    /// <exception cref="FormatException">Not an ANT stream, missing/invalid companion joint
    /// table, no clips, or channels referencing joints outside the table.</exception>
    public static SourceScene Import(
        byte[] data, byte[]? skeletonData, AntImportOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(data);
        options ??= new AntImportOptions();
        if (!(options.SampleFps > 0f) || !float.IsFinite(options.SampleFps))
            throw new ArgumentOutOfRangeException(nameof(options), "SampleFps must be positive.");
        if (!(options.TicksPerSecond > 0f) || !float.IsFinite(options.TicksPerSecond))
            throw new ArgumentOutOfRangeException(nameof(options), "TicksPerSecond must be positive.");

        if (skeletonData is null)
            throw new FormatException(
                "EA ANT packages (.cba) bind animation channels to joints by INDEX and carry no "
                + "joint table. Supply the companion joint table (a JSON array of "
                + "{\"name\", \"parent\"} in joint-index order, as extracted from the rig "
                + "package) as the skeleton data.");

        var joints = AntSkeletonJson.Parse(skeletonData);
        var clipsData = AntStream.ParseClips(data);
        if (clipsData.Count == 0)
            throw new FormatException("EA ANT package contains no animation clips.");

        foreach (var clip in clipsData)
        {
            foreach (var channel in clip.Channels)
            {
                if (channel.JointIndex < 0 || channel.JointIndex >= joints.Count)
                    throw new FormatException(
                        $"Animation references joint index {channel.JointIndex} but the companion "
                        + $"joint table has {joints.Count} joints — mismatched rig package.");
            }
        }

        // Rest = first frame of the first clip that animates the joint (see remarks).
        var restLocals = new XForm[joints.Count];
        for (var i = 0; i < restLocals.Length; i++)
            restLocals[i] = XForm.Identity;
        var seeded = new bool[joints.Count];
        foreach (var clip in clipsData)
        {
            foreach (var channel in clip.Channels)
            {
                if (channel.Keys.Length > 0 && !seeded[channel.JointIndex])
                {
                    restLocals[channel.JointIndex] = channel.Keys[0];
                    seeded[channel.JointIndex] = true;
                }
            }
        }

        var definitions = new BoneDefinition[joints.Count];
        for (var i = 0; i < joints.Count; i++)
        {
            var parent = joints[i].Parent;
            definitions[i] = new BoneDefinition(
                joints[i].Name,
                parent >= 0 ? joints[parent].Name : null,
                restLocals[i]);
        }
        var skeleton = SkeletonModel.Create(definitions);

        // Skeleton.Create topologically sorts, so joint-table indices must be remapped.
        var boneOf = new int[joints.Count];
        for (var i = 0; i < joints.Count; i++)
            boneOf[i] = skeleton.IndexOf(joints[i].Name);

        var clips = new List<Clip>(clipsData.Count);
        for (var c = 0; c < clipsData.Count; c++)
        {
            var name = clipsData.Count > 1
                ? $"{options.ClipNameBase}_{c + 1}"
                : options.ClipNameBase;
            clips.Add(Sample(clipsData[c], skeleton, boneOf, restLocals, name, options));
        }

        var notes = new List<string>
        {
            $"EA ANT package: {clips.Count} clip(s) over {joints.Count} joints "
            + $"({CountAnimated(clipsData)} animated).",
            "ANT carries no bind pose (SkeletonAsset stores only JointName/ParentIndex); the "
            + "clip's first frame is the rest reference and unanimated joints keep an identity "
            + "rest local.",
        };

        // ANT authors Y-up, Z-forward (the joint tables and proxy tracks are consistent with
        // the Maya/MotionBuilder export convention the rig is built in). Units are the
        // package's own; the solver's hip-height normalization absorbs the absolute scale.
        return new SourceScene(skeleton, clips, unitScaleCm: 1f, notes: notes);
    }

    private static int CountAnimated(List<AntStream.ClipData> clips)
    {
        var set = new HashSet<int>();
        foreach (var clip in clips)
        {
            foreach (var channel in clip.Channels)
                set.Add(channel.JointIndex);
        }
        return set.Count;
    }

    private static Clip Sample(
        AntStream.ClipData data, SkeletonModel skeleton, int[] boneOf, XForm[] restLocals,
        string name, AntImportOptions options)
    {
        var seconds = Math.Max(data.DurationTicks, 0) / options.TicksPerSecond;
        var frameCount = Math.Max(1, (int)Math.Round(seconds * options.SampleFps) + 1);

        var frames = new List<XForm[]>(frameCount);
        for (var f = 0; f < frameCount; f++)
        {
            var tick = f / options.SampleFps * options.TicksPerSecond;
            var locals = new XForm[skeleton.Count];
            for (var i = 0; i < locals.Length; i++)
                locals[i] = skeleton[i].RestLocal;

            foreach (var channel in data.Channels)
            {
                var bone = boneOf[channel.JointIndex];
                if (bone >= 0)
                    locals[bone] = SampleAt(channel, tick);
            }
            frames.Add(locals);
        }

        QuaternionContinuity.AlignFrames(frames);
        return new Clip(name, options.SampleFps, looping: false, frames);
    }

    /// <summary>Linear/slerp interpolation of a channel at a tick (keys are sparse and
    /// unevenly spaced — a 66-tick punch typically carries 4 keys).</summary>
    private static XForm SampleAt(AntStream.Channel channel, float tick)
    {
        var times = channel.Times;
        var keys = channel.Keys;
        if (keys.Length == 1 || tick <= times[0])
            return keys[0];
        if (tick >= times[^1])
            return keys[^1];

        var hi = 1;
        while (hi < times.Length - 1 && times[hi] < tick)
            hi++;
        var lo = hi - 1;
        var span = times[hi] - times[lo];
        var u = span > 1e-6f ? Math.Clamp((tick - times[lo]) / span, 0f, 1f) : 0f;

        return new XForm(
            Vector3.Lerp(keys[lo].Pos, keys[hi].Pos, u),
            MathQ.Normalize(Quaternion.Slerp(keys[lo].Rot, keys[hi].Rot, u)));
    }
}