Code/Target/VmdlSetupService.cs
#nullable enable
namespace HumanoidHandRetargeter.Target;

public sealed record HandAnimationEntry(string Name, string SourceFilename, bool Looping = false);
public sealed record ConfiguredAnimation(string RequestedName, string SequenceName, string SourceFilename);

public sealed class VmdlSetupOptions
{
    public required string ModelPath { get; init; }
    public required string BindPoseSource { get; init; }
    public string BindPoseName { get; init; } = "bindPose";
    public bool AutoConfigureAnimGraph { get; init; } = true;
    /// <summary>Arms bonemerge onto a weapon; this service must not assign a weapon graph to arms.</summary>
    public bool WeaponCompatibleArms { get; init; }
}

/// <summary>Reviewable, pure setup output. File persistence and asset compilation belong to the editor.</summary>
public sealed record VmdlSetupResult(string VmdlText, bool Changed, bool BindPoseAdded,
    string? GeneratedGraphPath, string? GeneratedGraphText,
    IReadOnlyList<ConfiguredAnimation> Animations, IReadOnlyList<string> Changes);

/// <summary>Hand-specific specialization of the audited VmdlAugmenter. Uses its KV3 tree
/// representation to preserve unknown settings and avoid editing ModelDoc text in UI code.</summary>
public static class VmdlSetupService
{
    public static VmdlSetupResult Prepare(string existingVmdl, IEnumerable<HandAnimationEntry> animations,
        VmdlSetupOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentNullException.ThrowIfNull(animations);
        var modelPath = NormalizeAssetPath(options.ModelPath, ".vmdl");
        var bindSource = NormalizeAssetPath(options.BindPoseSource, ".dmx");
        ValidateName(options.BindPoseName);
        var entries = animations.ToArray();
        foreach (var entry in entries)
        {
            ArgumentNullException.ThrowIfNull(entry);
            ValidateName(entry.Name);
            NormalizeAssetPath(entry.SourceFilename, ".dmx");
        }
        if (entries.Select(e => e.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count() != entries.Length)
            throw new ArgumentException("The batch contains duplicate animation names.", nameof(animations));
        if (entries.Any(e => string.Equals(e.Name, options.BindPoseName, StringComparison.OrdinalIgnoreCase)))
            throw new ArgumentException("The bind-pose name is reserved; rename the input animation.", nameof(animations));

        var doc = Kv3.Parse(existingVmdl);
        if (doc.Root is not KvObject document || document.GetOrNull("rootNode") is not KvObject root
            || root.GetString("_class") != "RootNode")
            throw new FormatException("VMDL has no ModelDoc RootNode.");
        var changes = new List<string>();
        var rootChildren = Children(root);
        var lists = rootChildren.Items.OfType<KvObject>().Where(n => n.GetString("_class") == "AnimationList").ToArray();
        if (lists.Length > 1) throw new FormatException("VMDL has multiple AnimationLists; resolve this before setup.");
        var list = lists.FirstOrDefault();
        if (list is null)
        {
            list = new KvObject { ["_class"] = new KvString("AnimationList"), ["children"] = new KvArray(),
                ["default_root_bone_name"] = new KvString("") };
            rootChildren.Items.Add(list);
            changes.Add("Added AnimationList.");
        }
        var nodes = SequenceNodes(Children(list)).ToList();
        var names = new Dictionary<string, KvObject>(StringComparer.OrdinalIgnoreCase);
        foreach (var node in nodes)
            if (node.GetString("name") is { Length: > 0 } name && !names.TryAdd(name, node))
                throw new FormatException($"VMDL contains duplicate sequence name '{name}'.");
        var configured = new List<ConfiguredAnimation>();
        var bindAdded = false;
        string bindName;
        if (names.TryGetValue(options.BindPoseName, out var existingBind)
            && existingBind.GetString("_class") == "AnimFile")
        {
            bindName = options.BindPoseName;
            if (string.IsNullOrWhiteSpace(existingBind.GetString("source_filename")))
                throw new FormatException("The existing bind pose has no source file.");
        }
        else
        {
            var before = names.Count;
            bindName = Upsert(new(options.BindPoseName, bindSource));
            bindAdded = names.Count > before;
        }
        foreach (var entry in entries)
            configured.Add(new(entry.Name, Upsert(entry), NormalizeAssetPath(entry.SourceFilename, ".dmx")));

        string? graphPath = null, graphText = null;
        var existingGraph = root.GetOrNull("anim_graph_name");
        if (existingGraph is not null && existingGraph is not KvString)
            throw new FormatException("VMDL anim_graph_name must be a string.");
        if (options.AutoConfigureAnimGraph && !options.WeaponCompatibleArms
            && string.IsNullOrWhiteSpace(root.GetString("anim_graph_name")))
        {
            graphPath = modelPath[..^5] + "_hands.vanmgrph";
            var idle = entries.FirstOrDefault(e => e.Looping && e.Name.Contains("idle", StringComparison.OrdinalIgnoreCase)
                && !e.Name.EndsWith("_delta", StringComparison.OrdinalIgnoreCase));
            var initialSequence = idle is null ? bindName : configured.Single(c => c.RequestedName == idle.Name).SequenceName;
            graphText = HandAnimGraph.Create(initialSequence, modelPath);
            root["anim_graph_name"] = new KvString(graphPath);
            changes.Add($"Assigned standalone hand graph '{graphPath}' using '{initialSequence}'.");
        }
        var changed = changes.Count > 0;
        return new(changed ? Kv3.Serialize(doc) : existingVmdl, changed, bindAdded,
            graphPath, graphText, configured.AsReadOnly(), changes.AsReadOnly());

        string Upsert(HandAnimationEntry entry)
        {
            var path = NormalizeAssetPath(entry.SourceFilename, ".dmx");
            var name = entry.Name;
            var suffix = 0;
            while (names.TryGetValue(name, out var occupied)
                && (occupied.GetString("_class") != "AnimFile"
                    || !string.Equals(occupied.GetString("source_filename")?.Replace('\\', '/'), path, StringComparison.OrdinalIgnoreCase)))
                name = entry.Name + "_retargeted" + (++suffix == 1 ? "" : "_" + suffix);
            if (names.TryGetValue(name, out var current))
            {
                // Update only the setting owned by this request. Preserve events, fades,
                // compression, filters, frame ranges and other authored children.
                if (current.GetOrNull("looping") is not KvBool loop || loop.Value != entry.Looping)
                {
                    current["looping"] = new KvBool(entry.Looping);
                    changes.Add($"Updated looping for '{name}'.");
                }
            }
            else
            {
                var node = BuildAnimFile(name, path, entry.Looping);
                Children(list).Items.Add(node);
                names.Add(name, node);
                changes.Add($"Added sequence '{name}'.");
            }
            return name;
        }
    }

    private static KvArray Children(KvObject node)
    {
        if (node.GetOrNull("children") is KvArray children) return children;
        if (node.GetOrNull("children") is not null) throw new FormatException("ModelDoc children must be an array.");
        var result = new KvArray();
        node["children"] = result;
        return result;
    }

    private static IEnumerable<KvObject> SequenceNodes(KvArray children)
    {
        foreach (var node in children.Items.OfType<KvObject>())
        {
            if (node.GetString("_class") == "Folder")
            {
                foreach (var child in SequenceNodes(Children(node))) yield return child;
            }
            else yield return node;
        }
    }

    /// <summary>Normalizes and validates an Assets-relative reference for setup and editor IO.</summary>
    public static string NormalizeAssetPath(string path, string extension)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(path);
        path = path.Replace('\\', '/');
        if (path.StartsWith('/') || path.Contains(':') || path.Any(char.IsControl)
            || path.Split('/').Any(p => p is "" or "." or "..")
            || !path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
            throw new ArgumentException($"Expected an assets-relative {extension} path.", nameof(path));
        return path;
    }

    private static void ValidateName(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        if (name.Any(char.IsControl)) throw new ArgumentException("Sequence names cannot contain control characters.");
    }

    // Same AnimFile defaults as the audited VmdlWriter.BuildAnimFileNode.
    private static KvObject BuildAnimFile(string name, string path, bool looping)
        => new()
        {
            ["_class"] = new KvString("AnimFile"), ["name"] = new KvString(name),
            ["activity_name"] = new KvString(""), ["activity_weight"] = new KvLong(1),
            ["weight_list_name"] = new KvString(""), ["fade_in_time"] = new KvDouble(.2),
            ["fade_out_time"] = new KvDouble(.2), ["looping"] = new KvBool(looping),
            ["delta"] = new KvBool(false), ["worldSpace"] = new KvBool(false), ["hidden"] = new KvBool(false),
            ["anim_markup_ordered"] = new KvBool(false), ["disable_compression"] = new KvBool(false),
            ["disable_interpolation"] = new KvBool(false), ["enable_scale"] = new KvBool(false),
            ["source_filename"] = new KvString(path), ["start_frame"] = new KvLong(-1),
            ["end_frame"] = new KvLong(-1), ["framerate"] = new KvDouble(-1),
            ["take"] = new KvLong(0), ["reverse"] = new KvBool(false)
        };
}