Editor/HumanoidRetargeter/Embedded/ValveResourceFormat/IO/ModelExtract.ValveModel.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using HumanoidRetargeterVrf.Utils;
using System.Globalization;
using System.IO;
using System.Linq;
using HumanoidRetargeterVrf.ResourceTypes;
using HumanoidRetargeterVrf.ResourceTypes.ModelAnimation;
using HumanoidRetargeterVrf.ResourceTypes.ModelData;
using HumanoidRetargeterVrf.ResourceTypes.RubikonPhysics;
using HumanoidRetargeterVrf.Serialization.KeyValues;
using static HumanoidRetargeterVrf.IO.KVHelpers;

#nullable disable

namespace HumanoidRetargeterVrf.IO;

partial class ModelExtract
{
    #region Bone Constraints
    static string RemapBoneConstraintClassname(string className)
    {
        return className switch
        {
            "CTiltTwistConstraint" => "AnimConstraintTiltTwist",
            "CTwistConstraint" => "AnimConstraintTwist",
            "CAimConstraint" => "AnimConstraintAim",
            "COrientConstraint" => "AnimConstraintOrient",
            "CPointConstraint" => "AnimConstraintPoint",
            "CParentConstraint" => "AnimConstraintParent",
            "CMorphConstraint" => "AnimConstraintMorph",
            "CBoneConstraintPoseSpaceBone" => "AnimConstraintPoseSpaceBone",
            "CBoneConstraintPoseSpaceMorph" => "AnimConstraintPoseSpaceMorph",
            "CBoneConstraintDotToMorph" => "AnimConstraintDotToMorph",
            _ => null
        };
    }

    static void AddBoneConstraintProperty<T>(KVObject sourceObject, KVObject targetObject, string sourceName, string targetName)
    {
        if (sourceObject.ContainsKey(sourceName))
        {
            if (typeof(T) == typeof(global::System.Numerics.Quaternion))
            {
                var value = sourceObject.GetFloatArray(sourceName);
                var rot = new global::System.Numerics.Quaternion(value[0], value[1], value[2], value[3]);
                var angles = ToEulerAngles(rot);
                targetObject.AddProperty(targetName, angles);
            }
            else if (typeof(T) == typeof(global::System.Numerics.Vector3))
            {
                var value = sourceObject.GetFloatArray(sourceName);
                var pos = new global::System.Numerics.Vector3(value[0], value[1], value[2]);
                targetObject.AddProperty(targetName, pos);
            }
            else
            {
                var value = sourceObject.GetProperty<T>(sourceName);
                targetObject.AddProperty(targetName, value);
            }
        }
    }

    static KVObject ProcessBoneConstraintTarget(KVObject target)
    {
        var isAttachment = target.GetProperty<bool>("m_bIsAttachment");
        var targetHash = target.GetUInt32Property("m_nBoneHash");
        if (!StringToken.InvertedTable.TryGetValue(targetHash, out var targetName))
        {
#if DEBUG
            Console.WriteLine($"Couldn't find name of {(isAttachment ? "attachment" : "bone")} for bone constraint: {targetHash}");
#endif
            return null;
        }

        KVObject node;
        if (isAttachment)
        {
            node = MakeNode("AnimConstraintAttachmentInput", ("parent_attachment", targetName));
        }
        else
        {
            node = MakeNode("AnimConstraintBoneInput", ("parent_bone", targetName));
        }

        AddBoneConstraintProperty<double>(target, node, "m_flWeight", "weight");
        AddBoneConstraintProperty<global::System.Numerics.Vector3>(target, node, "m_vOffset", "relative_origin");
        AddBoneConstraintProperty<global::System.Numerics.Quaternion>(target, node, "m_qOffset", "relative_angles");
        return node;
    }

    static KVObject ProcessBoneConstraintSlave(KVObject slave)
    {
        var boneHash = slave.GetUInt32Property("m_nBoneHash");
        if (!StringToken.InvertedTable.TryGetValue(boneHash, out var boneName))
        {
#if DEBUG
            Console.WriteLine($"Couldn't find name of bone for bone constraint: {boneHash}");
#endif
            return null;
        }

        var node = MakeNode("AnimConstraintSlave", ("parent_bone", boneName));
        AddBoneConstraintProperty<double>(slave, node, "m_flWeight", "weight");
        AddBoneConstraintProperty<global::System.Numerics.Vector3>(slave, node, "m_vBasePosition", "relative_origin");
        AddBoneConstraintProperty<global::System.Numerics.Quaternion>(slave, node, "m_qBaseOrientation", "relative_angles");
        return node;
    }

    static void ProcessBoneConstraintChildren(KVObject boneConstraint, KVObject node)
    {
        var targets = boneConstraint.GetArray("m_targets")
                                    .Select(p => ProcessBoneConstraintTarget(p))
                                    .Where(p => p != null);

        IEnumerable<KVObject> children;
        if (node.GetStringProperty("_class") == "AnimConstraintParent")
        {
            //Parent constraints only have a single slave and it's not a child node in the .vmdl
            children = targets;

            var constrainedBoneData = boneConstraint.GetArray("m_slaves")[0];
            var slave = ProcessBoneConstraintSlave(constrainedBoneData);
            if (slave != null) node.AddProperty("constrained_bone", slave.GetStringProperty("parent_bone"));
            AddBoneConstraintProperty<double>(constrainedBoneData, node, "m_flWeight", "weight");
            AddBoneConstraintProperty<global::System.Numerics.Vector3>(constrainedBoneData, node, "m_vBasePosition", "translation_offset");

            //Order of angles is different for some reason
            var rotArray = constrainedBoneData.GetFloatArray("m_qBaseOrientation");
            var rot = new global::System.Numerics.Quaternion(rotArray[0], rotArray[1], rotArray[2], rotArray[3]);
            var angles = ToEulerAngles(rot);
            angles = new global::System.Numerics.Vector3(angles.Z, angles.X, angles.Y);
            node.AddProperty("rotation_offset_xyz", angles);
        }
        else
        {
            var slaves = boneConstraint.GetArray("m_slaves")
                                        .Select(p => ProcessBoneConstraintSlave(p))
                                        .Where(p => p != null);

            children = slaves.Concat(targets);
        }

        var childrenKV = new KVObject(null, true);
        foreach (var child in children)
        {
            childrenKV.AddItem(child);
        }
        node.AddProperty("children", childrenKV);
    }

    static KVObject ProcessBoneConstraint(KVObject boneConstraint)
    {
        if (boneConstraint == null) //ModelDoc will compile constraints as null if it considers them invalid
        {
            return null;
        }

        var className = boneConstraint.GetStringProperty("_class");
        var targetClassName = RemapBoneConstraintClassname(className);
        if (targetClassName == null)
        {
#if DEBUG
            Console.WriteLine($"Skipping unknown bone constraint type: {className}");
#endif
            return null;
        }

        var node = MakeNode(targetClassName);

        // These constraints are stored the same way in the .vmdl and the compiled model
        if (targetClassName is "AnimConstraintPoseSpaceBone"
                            or "AnimConstraintPoseSpaceMorph"
                            or "AnimConstraintDotToMorph")
        {

            return MakeNode(targetClassName, boneConstraint);
        }

        ProcessBoneConstraintChildren(boneConstraint, node);

        AddBoneConstraintProperty<long>(boneConstraint, node, "m_nTargetAxis", "input_axis");
        AddBoneConstraintProperty<long>(boneConstraint, node, "m_nSlaveAxis", "slave_axis");
        AddBoneConstraintProperty<global::System.Numerics.Quaternion>(boneConstraint, node, "m_qAimOffset", "aim_offset");
        AddBoneConstraintProperty<global::System.Numerics.Vector3>(boneConstraint, node, "m_vUpVector", "up_vector");
        AddBoneConstraintProperty<long>(boneConstraint, node, "m_nUpType", "up_type");
        AddBoneConstraintProperty<global::System.Numerics.Quaternion>(boneConstraint, node, "m_qParentBindRotation", "parent_bind_rotation");
        AddBoneConstraintProperty<global::System.Numerics.Quaternion>(boneConstraint, node, "m_qChildBindRotation", "child_bind_rotation");
        AddBoneConstraintProperty<bool>(boneConstraint, node, "m_bInverse", "inverse");
        AddBoneConstraintProperty<string>(boneConstraint, node, "m_sTargetMorph", "target_morph_control");
        AddBoneConstraintProperty<long>(boneConstraint, node, "m_nSlaveChannel", "slave_channel");
        AddBoneConstraintProperty<double>(boneConstraint, node, "m_flMin", "min");
        AddBoneConstraintProperty<double>(boneConstraint, node, "m_flMax", "max");

        return node;
    }

    KVObject ExtractBoneConstraints(KVObject[] boneConstraintsList)
    {
        var stringTokenKeys = model.Skeleton.Bones.Select(b => b.Name);
        if (RenderMeshesToExtract.Count > 0)
        {
            var mesh = RenderMeshesToExtract.First().Mesh;
            stringTokenKeys = stringTokenKeys.Concat(mesh.Attachments.Keys);
        }

        StringToken.Store(stringTokenKeys);

        var childrenKV = new KVObject(null, true, boneConstraintsList.Length);

        foreach (var boneConstraint in boneConstraintsList)
        {
            var constraint = ProcessBoneConstraint(boneConstraint);
            if (constraint != null)
            {
                childrenKV.AddItem(constraint);
            }
        }

        var constraintListNode = MakeNode("AnimConstraintList",
            ("children", childrenKV)
        );

        return constraintListNode;
    }
    #endregion

    internal static global::System.Numerics.Vector3 ToEulerAngles(global::System.Numerics.Quaternion q)
    {
        // Normalized double intermediates avoid catastrophic cancellation at pitch
        // +/-90 degrees. At the pole, choose roll=0 and retain the combined yaw.
        double x = q.X, y = q.Y, z = q.Z, w = q.W;
        var norm = x * x + y * y + z * z + w * w;
        if (!double.IsFinite(norm) || norm < 1e-20) throw new InvalidDataException("Invalid bone rotation.");
        var k = 2 / norm;
        var sinp = k * (w * y - z * x);
        var pole = Math.Abs(sinp) > .9999995;
        var pitch = pole ? Math.CopySign(Math.PI / 2, sinp) : Math.Asin(sinp);
        var yaw = pole ? Math.Atan2(k * (w * z - x * y), 1 - k * (x * x + z * z))
            : Math.Atan2(k * (w * z + x * y), 1 - k * (y * y + z * z));
        var roll = pole ? 0 : Math.Atan2(k * (w * x + y * z), 1 - k * (x * x + y * y));
        return new global::System.Numerics.Vector3((float)pitch, (float)yaw, (float)roll) * (180 / MathF.PI);
    }

    static void AddBonesRecursive(IEnumerable<Bone> bones, KVObject parent)
    {
        foreach (var bone in bones)
        {
            var boneDefinitionNode = MakeNode(
                "Bone",
                ("name", bone.Name),
                ("origin", bone.Position),
                ("angles", ToEulerAngles(bone.Angle)),
                ("do_not_discard", true)
            );

            parent.AddItem(boneDefinitionNode);

            if (bone.Children.Count > 0)
            {
                var childBones = new KVObject(null, isArray: true);
                boneDefinitionNode.AddProperty("children", childBones);
                AddBonesRecursive(bone.Children, childBones);
            }
        }
    }

    /// <summary>
    /// Converts the model to Valve model format as a string.
    /// </summary>
    public string ToValveModel()
    {
        var kv = new KVObject(null);

        var root = MakeListNode("RootNode");
        kv.AddProperty("rootNode", root.Node);

        Lazy<KVObject> MakeLazyList(string className)
        {
            return new Lazy<KVObject>(() =>
            {
                var list = MakeListNode(className);
                root.Children.AddItem(list.Node);

                return list.Children;
            });
        }

        var materialGroupList = MakeLazyList("MaterialGroupList");
        var renderMeshList = MakeLazyList("RenderMeshList");
        var bodyGroupList = MakeLazyList("BodyGroupList");
        var animationList = MakeLazyList("AnimationList");
        var physicsShapeList = MakeLazyList("PhysicsShapeList");
        var attachmentList = MakeLazyList("AttachmentList");
        var skeleton = MakeLazyList("Skeleton");
        var modelModifierList = MakeLazyList("ModelModifierList");
        var weightLists = MakeLazyList("WeightListList");
        var hitboxSetList = MakeLazyList("HitboxSetList");

        var boneMarkupList = MakeListNode("BoneMarkupList");
        root.Children.AddItem(boneMarkupList.Node);
        boneMarkupList.Node.AddProperty("bone_cull_type", "None");

        if (RenderMeshesToExtract.Count != 0)
        {
            foreach (var renderMesh in RenderMeshesToExtract)
            {
                var renderMeshFile = MakeNode(
                    "RenderMeshFile",
                    ("name", renderMesh.Name),
                    ("filename", renderMesh.FileName)
                );

                if (renderMesh.ImportFilter != default)
                {
                    var importFilter = new KVObject("import_filter");
                    {
                        importFilter.AddProperty("exclude_by_default", renderMesh.ImportFilter.ExcludeByDefault);
                        importFilter.AddProperty("exception_list", KVValue.MakeArray(renderMesh.ImportFilter.Filter));
                    }

                    renderMeshFile.AddProperty("import_filter", importFilter);
                }

                renderMeshList.Value.AddItem(renderMeshFile);
            }

            {
                // Mesh/Body Groups
                var meshGroups = model.Data.GetArray<string>("m_meshGroups");
                var meshGroupMasks = model.Data.GetUnsignedIntegerArray("m_refMeshGroupMasks");
                var hideInTools = Array.Empty<string>();
                if (model.Data.GetArray<string>("m_BodyGroupsHiddenInTools") is string[] hideBodyGroups)
                {
                    hideInTools = hideBodyGroups;
                }

                var groupedChoices = new Dictionary<string, List<(int ChoiceIndex, string FullName, string ChoiceName)>>();

                for (var i = 0; i < meshGroups.Length; i++)
                {
                    var fullName = meshGroups[i];
                    var split = fullName.Split("_@");

                    if (split.Length < 2)
                    {
                        continue;
                    }

                    var groupName = split[0];
                    var choiceName = split[1];

                    groupedChoices.TryAdd(groupName, []);
                    groupedChoices[groupName].Add((i, fullName, choiceName));
                }

                foreach (var (groupName, choices) in groupedChoices)
                {
                    var choiceList = new KVObject(null, isArray: true, choices.Count);
                    var bodyGroup = MakeNode("BodyGroup",
                        ("name", groupName),
                        ("children", choiceList)
                    );

                    if (hideInTools.Contains(groupName))
                    {
                        bodyGroup.AddProperty("hidden_in_tools", true);
                    }

                    var i = 0;
                    foreach (var (index, key, name) in choices)
                    {
                        var meshGroupChoice = MakeNode("BodyGroupChoice");

                        if (name != i.ToString(CultureInfo.InvariantCulture))
                        {
                            meshGroupChoice.AddProperty("name", name);
                        }

                        if (hideInTools.Contains(key))
                        {
                            meshGroupChoice.AddProperty("hide_in_tools", true);
                        }

                        var meshes = new KVObject(null, isArray: true);
                        meshGroupChoice.AddProperty("meshes", meshes);

                        foreach (var renderMesh in RenderMeshesToExtract)
                        {
                            // No mask will show up as 'Empty' in editor
                            var mask = renderMesh.Index < meshGroupMasks.Length ? meshGroupMasks[renderMesh.Index] : 0UL;

                            if ((mask & 1UL << index) == 0)
                            {
                                continue;
                            }

                            meshes.AddProperty(null, renderMesh.Name);
                        }

                        choiceList.AddItem(meshGroupChoice);
                        i++;
                    }

                    bodyGroupList.Value.AddItem(bodyGroup);
                }
            }

            var mesh = RenderMeshesToExtract.First();
            var attachments = mesh.Mesh.Attachments;

            foreach (var attachment in attachments.Values)
            {
                var mainInfluence = attachment[^1];

                var node = MakeNode("Attachment",
                    ("name", attachment.Name),
                    ("ignore_rotation", attachment.IgnoreRotation),
                    ("parent_bone", mainInfluence.Name),
                    ("relative_origin", mainInfluence.Offset),
                    ("relative_angles", ToEulerAngles(mainInfluence.Rotation)),
                    ("weight", mainInfluence.Weight)
                );

                if (attachment.Length > 1)
                {
                    var children = new KVObject(null, true, attachment.Length - 1);
                    for (var i = 0; i < attachment.Length - 1; i++)
                    {
                        var influence = attachment[i];
                        var childNode = MakeNode("AttachmentInfluence",
                            ("parent_bone", influence.Name),
                            ("relative_origin", influence.Offset),
                            ("relative_angles", ToEulerAngles(influence.Rotation)),
                            ("weight", influence.Weight)
                        );

                        children.AddItem(childNode);
                    }
                    node.AddProperty("children", children);
                }

                attachmentList.Value.AddItem(node);
            }
        }

        var modelSequenceData = model?.Resource.GetBlockByType(BlockType.ASEQ) as KeyValuesOrNTRO;
        var additionalSequenceData = new Dictionary<string, KVObject>();

        if (modelSequenceData != null)
        {
            ExtractSequenceData(modelSequenceData);

            foreach (var data in modelSequenceData.Data.GetArray("m_localS1SeqDescArray"))
            {
                additionalSequenceData.Add(data.GetStringProperty("m_sName"), data);
            }
        }

        if (AnimationsToExtract.Count > 0)
        {
            var animationToFolder = new Dictionary<string, KVObject>(AnimationsToExtract.Count);
            if (modelSequenceData?.Data.GetSubCollection("m_keyValues") is KVObject sequenceKeyValues)
            {
                if (sequenceKeyValues.GetSubCollection("faceposer_folders") is KVObject faceposerFolders)
                {
                    foreach (var folder in faceposerFolders)
                    {
                        var folderName = folder.Key;
                        var animationNames = faceposerFolders.GetArray<string>(folderName);

                        var (folderNode, children) = MakeListNode("Folder");
                        folderNode.AddProperty("name", folderName);
                        animationList.Value.AddItem(folderNode);

                        foreach (var animationName in animationNames)
                        {
                            animationToFolder.Add(animationName, children);
                        }
                    }
                }
            }

            foreach (var animation in AnimationsToExtract)
            {
                var animationFile = MakeNode(
                    "AnimFile",
                    ("name", animation.Anim.Name),
                    ("source_filename", animation.FileName),
                    ("fade_in_time", animation.Anim.SequenceParams.FadeInTime),
                    ("fade_out_time", animation.Anim.SequenceParams.FadeOutTime),
                    ("looping", animation.Anim.IsLooping),
                    ("delta", animation.Anim.Delta),
                    ("worldSpace", animation.Anim.Worldspace),
                    ("hidden", animation.Anim.Hidden)
                );

                if (animation.Anim.Activities.Length > 0)
                {
                    var activity = animation.Anim.Activities[0];
                    animationFile.AddProperty("activity_name", activity.Name);
                    animationFile.AddProperty("activity_weight", activity.Weight);
                }

                var childrenKV = new KVObject(null, true, 1);
                if (animation.Anim.HasMovementData())
                {
                    var flags = animation.Anim.Movements[0].MotionFlags;
                    var extractMotion = MakeNode("ExtractMotion",
                        ("extract_tx", flags.HasFlag(ModelAnimationMotionFlags.TX)),
                        ("extract_ty", flags.HasFlag(ModelAnimationMotionFlags.TY)),
                        ("extract_tz", flags.HasFlag(ModelAnimationMotionFlags.TZ)),
                        ("extract_rz", flags.HasFlag(ModelAnimationMotionFlags.RZ)),
                        ("linear", flags.HasFlag(ModelAnimationMotionFlags.Linear)),
                        ("quadratic", false),
                        ("motion_type", "uniform")
                    );

                    childrenKV.AddItem(extractMotion);
                }
                foreach (var animEvent in animation.Anim.Events)
                {
                    var animEventNode = MakeNode("AnimEvent",
                        ("event_class", animEvent.Name),
                        ("event_frame", animEvent.Frame)
                    );

                    if (animEvent.EventData != null)
                    {
                        animEventNode.AddProperty("event_keys", animEvent.EventData);
                    }
                    childrenKV.AddItem(animEventNode);
                }

                if (additionalSequenceData.TryGetValue(animation.Anim.Name, out var sequenceData))
                {
                    var sequenceKeys = sequenceData.GetSubCollection("m_SequenceKeys");
                    if (sequenceKeys != null)
                    {
                        // other keys seen:
                        // bind_pose = true

                        if (sequenceKeys.GetSubCollection("AnimGameplayTiming") is KVObject animGameplayTiming)
                        {
                            childrenKV.AddItem(MakeNode("AnimGameplayTiming", animGameplayTiming));
                        }
                    }
                }

                if (childrenKV.Count > 0)
                {
                    animationFile.AddProperty("children", childrenKV);
                }

                var folderOrRoot = animationToFolder.GetValueOrDefault(animation.Anim.Name, animationList.Value);
                folderOrRoot.AddItem(animationFile);
            }
        }

        if (PhysHullsToExtract.Count > 0 || PhysMeshesToExtract.Count > 0)
        {
            if (Type == ModelExtractType.Map_PhysicsToRenderMesh)
            {
                var globalReplace = PhysicsToRenderMaterialNameProvider == null;
                var remapTable = globalReplace ? null
                    : SurfaceTagCombos.ToDictionary(
                        combo => combo.StringMaterial,
                        combo => PhysicsToRenderMaterialNameProvider(combo)
                    );

                RemapMaterials(remapTable, globalReplace);
            }

            foreach (var (physHull, fileName) in PhysHullsToExtract)
            {
                HandlePhysMeshNode(physHull, fileName);
            }

            foreach (var (physMesh, fileName) in PhysMeshesToExtract)
            {
                HandlePhysMeshNode(physMesh, fileName);
            }
        }

        if (model != null)
        {
            ExtractModelKeyValues(root.Node);
            ExtractHitboxSets();

            if (model.Skeleton.Roots.Length > 0)
            {
                AddBonesRecursive(model.Skeleton.Roots, skeleton.Value);
            }
        }

        if (physAggregateData is not null)
        {
            var boneNames = physAggregateData.Data.GetArray<string>("m_boneNames");
            boneNames ??= [];

            for (var i = 0; i < physAggregateData.Parts.Length; i++)
            {
                var physicsPart = physAggregateData.Parts[i];
                var parentBone = boneNames.Length > i ? boneNames[i] : string.Empty;

                foreach (var sphere in physicsPart.Shape.Spheres)
                {
                    var physicsShapeSphere = MakeNode(
                        "PhysicsShapeSphere",
                        ("parent_bone", parentBone),
                        ("surface_prop", PhysicsSurfaceNames[sphere.SurfacePropertyIndex]),
                        ("collision_tags", string.Join(" ", PhysicsCollisionTags[sphere.CollisionAttributeIndex])),
                        ("radius", sphere.Shape.Radius),
                        ("center", sphere.Shape.Center),
                        ("name", sphere.UserFriendlyName ?? string.Empty)
                    );

                    physicsShapeList.Value.AddItem(physicsShapeSphere);
                }

                foreach (var capsule in physicsPart.Shape.Capsules)
                {
                    var physicsShapeCapsule = MakeNode(
                        "PhysicsShapeCapsule",
                        ("parent_bone", parentBone),
                        ("surface_prop", PhysicsSurfaceNames[capsule.SurfacePropertyIndex]),
                        ("collision_tags", string.Join(" ", PhysicsCollisionTags[capsule.CollisionAttributeIndex])),
                        ("radius", capsule.Shape.Radius),
                        ("point0", capsule.Shape.Center[0]),
                        ("point1", capsule.Shape.Center[1]),
                        ("name", capsule.UserFriendlyName ?? string.Empty)
                    );

                    physicsShapeList.Value.AddItem(physicsShapeCapsule);
                }
            }
        }

        if (Translation != global::System.Numerics.Vector3.Zero)
        {
            modelModifierList.Value.AddItem(MakeNode("ModelModifier_Translate", ("translation", Translation)));
        }


        return new KV3File(kv, format: KV3IDLookup.Get("modeldoc28")).ToString();

        #region Local Functions
        void HandlePhysMeshNode<TShape>(ShapeDescriptor<TShape> shapeDesc, string fileName)
            where TShape : struct
        {
            var surfacePropName = PhysicsSurfaceNames[shapeDesc.SurfacePropertyIndex];
            var collisionTags = PhysicsCollisionTags[shapeDesc.CollisionAttributeIndex];

            if (Type == ModelExtractType.Map_PhysicsToRenderMesh)
            {
                renderMeshList.Value.AddItem(MakeNode("RenderMeshFile", ("filename", fileName)));
                return;
            }

            var className = shapeDesc switch
            {
                HullDescriptor => "PhysicsHullFile",
                MeshDescriptor => "PhysicsMeshFile",
                _ => throw new NotImplementedException()
            };

            var shapeName = shapeDesc.UserFriendlyName ?? Path.GetFileNameWithoutExtension(fileName);

            // TODO: per faceSet surface_prop
            var physicsShapeFile = MakeNode(
                className,
                ("filename", fileName),
                ("surface_prop", surfacePropName),
                ("collision_tags", string.Join(" ", collisionTags)),
                ("name", shapeName)
            );

            physicsShapeList.Value.AddItem(physicsShapeFile);
        }

        void RemapMaterials(
            IReadOnlyDictionary<string, string> remapTable = null,
            bool globalReplace = false,
            string globalDefault = "materials/tools/toolsnodraw.vmat")
        {
            var remaps = new KVObject(null, isArray: true);
            materialGroupList.Value.AddItem(
                MakeNode(
                    "DefaultMaterialGroup",
                    ("remaps", remaps),
                    ("use_global_default", globalReplace),
                    ("global_default_material", globalDefault)
                )
            );

            if (globalReplace || remapTable == null)
            {
                return;
            }

            foreach (var (from, to) in remapTable)
            {
                var remap = new KVObject(null);
                remap.AddProperty("from", from);
                remap.AddProperty("to", to);
                remaps.AddItem(remap);
            }
        }

        KVObject GetHitboxNode(Hitbox hitbox)
        {
            var node = hitbox.ShapeType switch
            {
                Hitbox.HitboxShape.Box => MakeNode("Hitbox",
                    ("hitbox_mins", hitbox.MinBounds),
                    ("hitbox_maxs", hitbox.MaxBounds)
                ),
                Hitbox.HitboxShape.Capsule => MakeNode("HitboxCapsule",
                    ("radius", hitbox.ShapeRadius),
                    ("point0", hitbox.MinBounds),
                    ("point1", hitbox.MaxBounds)
                ),
                Hitbox.HitboxShape.Sphere => MakeNode("HitboxSphere",
                    ("center", hitbox.MinBounds),
                    ("radius", hitbox.ShapeRadius)
                ),
                _ => throw new NotImplementedException($"Unknown hitbox shape type: {hitbox.ShapeType}")
            };

            node.AddProperty("name", hitbox.Name);
            node.AddProperty("parent_bone", hitbox.BoneName);
            node.AddProperty("surface_property", hitbox.SurfaceProperty);
            node.AddProperty("translation_only", hitbox.TranslationOnly);
            node.AddProperty("group_id", hitbox.GroupId);

            return node;
        }

        void ExtractHitboxSets()
        {
            if (model.HitboxSets == null)
            {
                return;
            }

            foreach (var pair in model.HitboxSets)
            {
                var children = new KVObject(null, true, pair.Value.Length);
                var hitboxSet = MakeNode("HitboxSet", ("name", pair.Key), ("children", children));

                foreach (var hitbox in pair.Value)
                {
                    var hitboxNode = GetHitboxNode(hitbox);
                    children.AddItem(hitboxNode);
                }

                hitboxSetList.Value.AddItem(hitboxSet);
            }
        }

        void ExtractSequenceData(KeyValuesOrNTRO sequenceData)
        {
            var boneMasks = sequenceData.Data.GetArray<KVObject>("m_localBoneMaskArray");
            var boneNames = sequenceData.Data.GetArray<string>("m_localBoneNameArray");

            foreach (var boneMask in boneMasks)
            {
                var name = boneMask.GetProperty<string>("m_sName");
                var boneArray = boneMask.GetIntegerArray("m_nLocalBoneArray");
                var boneWeights = boneMask.GetFloatArray("m_flBoneWeightArray");
                // master_morph_weight = m_flDefaultMorphCtrlWeight

                // skip default mask
                if (name == "default" && boneArray.Length == 0)
                {
                    continue;
                }

                var weights = new KVObject(null, isArray: true, boneArray.Length);
                var weightListNode = MakeNode("WeightList",
                    ("name", name),
                    ("weights", weights)
                );

                foreach (var (boneIndex, boneWeight) in boneArray.Zip(boneWeights))
                {
                    var weightDefinition = new KVObject(null, 2);
                    var boneName = boneNames[boneIndex];

                    weightDefinition.AddProperty("bone", boneName);
                    weightDefinition.AddProperty("weight", boneWeight);
                    weights.AddItem(weightDefinition);
                }

                weightLists.Value.AddItem(weightListNode);
            }
        }

        void ExtractModelKeyValues(KVObject rootNode)
        {
            if (model.Data.ContainsKey("m_refAnimIncludeModels"))
            {
                foreach (var animIncludeModel in model.Data.GetArray<string>("m_refAnimIncludeModels"))
                {
                    animationList.Value.AddItem(MakeNode("AnimIncludeModel", ("model", animIncludeModel)));
                }
            }

            var breakPieceList = MakeLazyList("BreakPieceList");
            var gameDataList = MakeLazyList("GameDataList");

            KVObject keyvalues = model.KeyValues;

            if (keyvalues.Count == 0)
            {
                return;
            }

            if (keyvalues.ContainsKey("anim_graph_resource"))
            {
                rootNode.AddProperty("anim_graph_name", keyvalues.GetProperty<string>("anim_graph_resource"));
            }

            if (keyvalues.ContainsKey("BoneConstraintList"))
            {
                var boneConstraintListData = keyvalues.GetArray("BoneConstraintList");
                var boneConstraintList = ExtractBoneConstraints(boneConstraintListData);
                root.Children.AddItem(boneConstraintList);
            }

            var genericDataClasses = new string[] {
                "prop_data",
                "character_arm_config",
                "vr_carry_type",
                "door_sounds",
                "nav_data",
                "npc_foot_sweep",
                "ai_model_info",
                "breakable_door_model",
                "dynamic_interactions",
                "explosion_behavior",
                "eye_occlusion_renderer",
                "fire_interactions",
                "gastank_markup",
                "hand_conform_data",
                "handpose_data",
                "ikdata", // Animgraph IK nodes reference these chains; preserve solver settings and joint constraints.
                "physgun_interactions",
                "weapon_metadata",
                "glove_viewmodel_reference",
                "composite_material_order",
                "patch_camera_preset_list",
                "camera_settings",
                "scene_data_map",
                "particle_settings",
                "damage_number_settings",
                "CitadelCameraSettings_t",
                "CCitadelHeroModelGameData_t",
                "CCitadelNPCModelGameData_t",
                "CitadelUnitStatusSettings_t",
                "CitadelModelDamageNumberSettings_t",
                "CitadelModelParticleSettings_t",
                "CitadelTaggedSoundSettings_t",
                "CitadelModelSceneData_t",
                "CitadelMuzzleSettings_t",
                "CitadelTeamRelativeParticleSettings_t",
                //"AttachmentCameraData", - is autogenerated from AttachmentCameraPreview/ExporttoRuntimeModel modeldoc node/parameter
                "CDestructiblePart",
                "CDestructiblePartsSystemData",
                "DeformablePropModelGameData_t",
                "CPhysicsBodyGameMarkupData",
                "electrical_interactions",
                "world_interactions",
            };

            var genericDataClassesList = new (string ListKey, string Class)[] {
            ("ao_proxy_capsule_list", "ao_proxy_capsule"),
            ("ao_proxy_box_list", "ao_proxy_box"),
            ("particles_list", "particle"),
            ("hand_pose_list", "hand_pose_pair"),
            ("eye_data_list", "eye"),
            ("bodygroup_driven_morph_list", "bodygroup_driven_morph"),
            ("materialgroup_driven_morph_list", "materialgroup_driven_morph"),
            ("animating_breakable_stage_list", "animating_breakable_stage"),
            ("cables_list", "cable"),
            ("high_quality_shadows_region_list", "high_quality_shadows_region"),
            ("particle_cfg_list", "particle_cfg"),
            ("snapshot_weights_upperbody_list", "snapshot_weights_upperbody"),
            ("snapshot_weights_all_list", "snapshot_weights_all"),
            ("bodygroup_preset_list", "bodygroup_preset"),
            ("muzzle_desc_list", "muzzle_settings"),
            ("unit_status_settings_list", "unit_status_settings"),
            ("team_relative_particles_cfg_list", "team_relative_particle_settings"),
            ("CNPCPhysicsHull", "CNPCPhysicsHull"), // exports as list, needs m_sName changed to name near game_class
        };

            foreach (var genericDataClass in genericDataClasses)
            {
                if (keyvalues.ContainsKey(genericDataClass))
                {
                    var genericData = keyvalues.GetProperty<KVObject>(genericDataClass);
                    if (genericData != null)
                    {
                        AddGenericGameData(gameDataList.Value, genericDataClass, genericData);
                    }
                }
            }

            foreach (var genericDataClass in genericDataClassesList)
            {
                var dataKey = genericDataClass.ListKey;
                if (keyvalues.ContainsKey(dataKey))
                {
                    var genericDataList = keyvalues.GetArray<KVObject>(dataKey);
                    foreach (var genericData in genericDataList)
                    {
                        AddGenericGameData(gameDataList.Value, genericDataClass.Class, genericData);
                    }
                }
            }

            if (keyvalues.ContainsKey("LookAtList"))
            {
                var lookAtList = keyvalues.GetSubCollection("LookAtList");
                foreach (var item in lookAtList)
                {
                    var lookAtChain = item.Value as KVObject;
                    AddGenericGameData(gameDataList.Value, "LookAtChain", lookAtChain, "lookat_chain");
                }
            }

            if (keyvalues.ContainsKey("MovementSettings"))
            {
                var movementSettings = keyvalues.GetProperty<KVObject>("MovementSettings");
                AddGenericGameData(gameDataList.Value, "MovementSettings", movementSettings, "movementsettings");
            }

            if (keyvalues.ContainsKey("FeetSettings"))
            {
                var feetSettings = keyvalues.GetProperty<KVObject>("FeetSettings");
                var feetNode = ConvertFeetSettings(feetSettings);
                if (feetNode != null)
                {
                    gameDataList.Value.AddItem(feetNode);
                }
            }

            if (keyvalues.ContainsKey("break_list"))
            {
                foreach (var breakPiece in keyvalues.GetArray<KVObject>("break_list"))
                {
                    var breakPieceFile = MakeNode("BreakPieceExternal", breakPiece);
                    breakPieceList.Value.AddItem(breakPieceFile);
                }
            }

            static KVObject ConvertFeetSettings(KVObject feetSettings)
            {
                var children = new KVObject(null, isArray: true);

                // Field mappings from compiled to source names
                var footFieldMappings = new (string CompiledName, string SourceName)[]
                {
                    ("m_name", "name"),
                    ("m_ankleBoneName", "anklebone"),
                    ("m_toeBoneName", "toebone"),
                    ("m_vBallOffset", "balloffset"),
                    ("m_vHeelOffset", "heeloffset"),
                    ("m_flTraceHeight", "traceheight"),
                    ("m_flTraceRadius", "traceradius"),
                };

                // Convert each foot entry to a Foot child node
                foreach (var footEntry in feetSettings.Properties)
                {
                    if (footEntry.Value.Value is not KVObject footData)
                    {
                        continue;
                    }

                    var footNode = MakeNode("Foot");

                    // Map compiled field names to source field names
                    foreach (var (compiledName, sourceName) in footFieldMappings)
                    {
                        if (footData.Properties.TryGetValue(compiledName, out var value))
                        {
                            footNode.AddProperty(sourceName, value);
                        }
                    }

                    // autolevel is typically true by default in source format
                    footNode.AddProperty("autolevel", true);

                    children.AddItem(footNode);
                }

                if (children.Count == 0)
                {
                    return null;
                }

                // Create the Feet node
                var feetNode = MakeNode("Feet", ("children", children));

                // Parent-level field mappings
                var parentFieldMappings = new (string CompiledName, string SourceName)[]
                {
                    ("m_flLockTolerance", "locktolerance"),
                    ("m_flHeightTolerance", "heighttolerance"),
                    ("m_bSanitizeTrajectories", "sanitizetrajectories"),
                };

                // Add parent-level properties if they exist
                foreach (var (compiledName, sourceName) in parentFieldMappings)
                {
                    if (feetSettings.Properties.TryGetValue(compiledName, out var value))
                    {
                        feetNode.AddProperty(sourceName, value);
                    }
                }

                return feetNode;
            }

            static void AddGenericGameData(KVObject gameDataList, string genericDataClass, KVObject genericData, string dataKey = null)
            {
                // Remove quotes from keys
                genericData.Properties.Keys.ToList().ForEach(k =>
                {
                    var trimmed = k.Trim('"');
                    if (trimmed != k)
                    {
                        genericData.Properties[trimmed] = genericData.Properties[k];
                        genericData.Properties.Remove(k);
                    }
                });

                var name = "";
                if (genericData.ContainsKey("name"))
                {
                    name = genericData.GetStringProperty("name");
                }

                KVObject genericGameData;
                if (dataKey == null)
                {
                    genericGameData = MakeNode("GenericGameData",
                        ("name", name),
                        ("game_class", genericDataClass),
                        ("game_keys", genericData)
                    );
                }
                else
                {
                    genericGameData = MakeNode(genericDataClass,
                        ("name", name),
                        (dataKey, genericData)
                    );
                }

                gameDataList.AddItem(genericGameData);
            }
        }
        #endregion
    }
}