package com.tiedup.remake.client.gltf;

import dev.kosmx.playerAnim.core.data.AnimationFormat;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.core.util.Ease;
import java.util.HashSet;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import org.joml.Vector3f;

/**
 * Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation.
 * <p>
 * Data is expected to be already in MC coordinate space (converted by GlbParser).
 * For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll).
 * For lower bones: extracts bend angle from delta quaternion.
 * <p>
 * The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376),
 * so no angle scaling is needed. If the pivots don't match, fix the Blender model.
 * <p>
 * Produces a static looping pose (beginTick=0, endTick=1, looped).
 */
@OnlyIn(Dist.CLIENT)
public final class GltfPoseConverter {

    private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");

    private static final int TICKS_PER_SECOND = 20;
    private static final Ease DEFAULT_EASE = Ease.LINEAR;

    private GltfPoseConverter() {}

    /**
     * Compute the end tick (inclusive) of a clip's keyframe timeline, relative
     * to the clip's first timestamp. Returns 1 for null or empty clips (minimum
     * valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
     *
     * <p>The baseline subtraction ensures clips authored with an NLA onset
     * ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
     * undefined on each loop — the clip is always timeline-normalized to start
     * at tick 0.</p>
     */
    public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
        if (
            clip == null ||
            clip.frameCount() == 0 ||
            clip.timestamps().length == 0
        ) {
            return 1;
        }
        float[] times = clip.timestamps();
        int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
        float baseline = times[0];
        return Math.max(
            1,
            Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
        );
    }

    /**
     * Convert a frame index to an MC tick based on the clip's timestamps,
     * relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
     */
    private static int frameToTick(
        @Nullable GltfData.AnimationClip clip,
        int frameIndex,
        float baselineSeconds
    ) {
        if (clip == null) return 0;
        float[] times = clip.timestamps();
        if (frameIndex >= times.length) return 0;
        return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
    }

    /** Return the timestamp baseline for the clip, or 0 if absent. */
    private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
        if (clip == null || clip.timestamps().length == 0) return 0f;
        return clip.timestamps()[0];
    }

    /**
     * Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
     * Uses the default (first) animation clip.
     * GltfData must already be in MC coordinate space.
     *
     * @param data parsed glTF data (in MC space)
     * @return a static looping KeyframeAnimation suitable for PlayerAnimator
     */
    public static KeyframeAnimation convert(GltfData data) {
        return convertClip(data, data.rawGltfAnimation(), "gltf_pose");
    }

    /**
     * Convert a specific named animation from GltfData to a KeyframeAnimation.
     * Falls back to the default animation if the name is not found.
     *
     * @param data          parsed glTF data (in MC space)
     * @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
     * @return a static looping KeyframeAnimation suitable for PlayerAnimator
     */
    public static KeyframeAnimation convert(
        GltfData data,
        String animationName
    ) {
        GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
        if (rawClip == null) {
            LOGGER.warn(
                "[GltfPipeline] Animation '{}' not found, falling back to default",
                animationName
            );
            return convert(data);
        }
        return convertClip(data, rawClip, "gltf_" + animationName);
    }

    /**
     * Convert a GLB animation with selective part enabling and free-bone support.
     *
     * <p>Owned parts are always enabled in the output animation. Free parts (in
     * {@code enabledParts} but not in {@code ownedParts}) are only enabled if the
     * GLB contains actual keyframe data for them. Parts not in {@code enabledParts}
     * at all are always disabled (pass through to lower layers).</p>
     *
     * @param data          parsed glTF data (in MC space)
     * @param animationName animation name in GLB, or null for default
     * @param ownedParts    parts the item explicitly owns (always enabled)
     * @param enabledParts  parts the item may animate (owned + free); free parts
     *                      are only enabled if the GLB has keyframes for them
     * @return KeyframeAnimation with selective parts active
     */
    public static KeyframeAnimation convertSelective(
        GltfData data,
        @Nullable String animationName,
        Set<String> ownedParts,
        Set<String> enabledParts
    ) {
        GltfData.AnimationClip rawClip;
        String animName;
        if (animationName != null) {
            rawClip = data.getRawAnimation(animationName);
            animName = "gltf_" + animationName;
        } else {
            rawClip = null;
            animName = "gltf_pose";
        }
        if (rawClip == null) {
            rawClip = data.rawGltfAnimation();
        }
        return convertClipSelective(
            data,
            rawClip,
            animName,
            ownedParts,
            enabledParts
        );
    }

    /**
     * Internal: convert a specific raw animation clip with selective part enabling
     * and free-bone support.
     *
     * <p>Tracks which PlayerAnimator parts received actual keyframe data from the GLB.
     * A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}.
     * This information is used by {@link #enableSelectiveParts} to decide whether
     * free parts should be enabled.</p>
     *
     * @param ownedParts   parts the item explicitly owns (always enabled)
     * @param enabledParts parts the item may animate (owned + free)
     */
    private static KeyframeAnimation convertClipSelective(
        GltfData data,
        @Nullable GltfData.AnimationClip rawClip,
        String animName,
        Set<String> ownedParts,
        Set<String> enabledParts
    ) {
        KeyframeAnimation.AnimationBuilder builder =
            new KeyframeAnimation.AnimationBuilder(
                AnimationFormat.JSON_EMOTECRAFT
            );

        int endTick = computeEndTick(rawClip);
        int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;

        builder.beginTick = 0;
        builder.endTick = endTick;
        builder.stopTick = endTick;
        builder.isLooped = true;
        builder.returnTick = 0;
        builder.name = animName;

        // Track which PlayerAnimator part names received actual animation data.
        // Joint-level; not frame-dependent — we detect once on frame 0.
        Set<String> partsWithKeyframes = new HashSet<>();

        // Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
        // rates (24/30/60 FPS Blender) produce multiple frames that round to the
        // same tick; emit once per unique tick (keep the first) so artists see
        // deterministic behavior rather than relying on PlayerAnimator's "last
        // inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
        float baseline = timelineBaseline(rawClip);
        int lastTick = Integer.MIN_VALUE;

        for (int f = 0; f < frameCount; f++) {
            int tick = frameToTick(rawClip, f, baseline);
            if (tick == lastTick) continue;
            applyFrameToBuilder(
                builder,
                data,
                rawClip,
                f,
                tick,
                DEFAULT_EASE,
                /* ownedFilter */null,
                /* keyframeCollector */f == 0 ? partsWithKeyframes : null
            );
            lastTick = tick;
        }

        // Selective: enable owned parts always, free parts only for "Full" animations
        // that explicitly opt into full-body control.
        enableSelectiveParts(
            builder,
            ownedParts,
            enabledParts,
            partsWithKeyframes,
            animName
        );

        KeyframeAnimation anim = builder.build();
        LOGGER.debug(
            "[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
            animName,
            frameCount,
            endTick,
            ownedParts,
            enabledParts,
            partsWithKeyframes
        );
        return anim;
    }

    /**
     * Apply a single frame's delta rotations for every known bone to the builder,
     * writing one keyframe per bone at {@code tick}.
     *
     * @param ownedFilter         if non-null, only bones whose animPart is in this
     *                            set are written (shared-builder multi-item path)
     * @param keyframeCollector   if non-null, parts that have explicit rotation or
     *                            translation channels are added to this set
     */
    private static void applyFrameToBuilder(
        KeyframeAnimation.AnimationBuilder builder,
        GltfData data,
        @Nullable GltfData.AnimationClip rawClip,
        int frameIndex,
        int tick,
        Ease ease,
        @Nullable Set<String> ownedFilter,
        @Nullable Set<String> keyframeCollector
    ) {
        String[] jointNames = data.jointNames();
        Quaternionf[] rawRestRotations = data.rawGltfRestRotations();

        // Two known bones can map to the same PlayerAnimator part (e.g.
        // `body` + `torso` → "body"). Both would write to the same
        // StateCollection and the second write silently wins; instead,
        // first-in-array-order wins and subsequent collisions are skipped.
        // Lower bones don't conflict with upper bones (separate axis).
        Set<String> claimedUpperParts = new java.util.HashSet<>();

        for (int j = 0; j < data.jointCount(); j++) {
            String boneName = jointNames[j];
            if (!GltfBoneMapper.isKnownBone(boneName)) continue;

            String animPart = GltfBoneMapper.getAnimPartName(boneName);
            if (animPart == null) continue;

            boolean isLower = GltfBoneMapper.isLowerBone(boneName);

            // Apply ownedFilter BEFORE claiming the slot: a bone that this item
            // doesn't own must not reserve the upper-part slot, otherwise a
            // later owned bone mapping to the same slot gets spuriously
            // rejected by the collision check below.
            if (ownedFilter != null) {
                if (!ownedFilter.contains(animPart)) continue;
                // For lower bones, also require the upper bone's part to be owned.
                if (isLower) {
                    String upper = GltfBoneMapper.getUpperBoneFor(boneName);
                    if (upper != null) {
                        String upperPart = GltfBoneMapper.getAnimPartName(upper);
                        if (
                            upperPart == null ||
                            !ownedFilter.contains(upperPart)
                        ) continue;
                    }
                }
            }

            if (!isLower && !claimedUpperParts.add(animPart)) {
                // Another upper bone already claimed this PlayerAnimator part.
                // Skip the duplicate write so HashMap iteration order can't
                // silently flip which bone drives the pose.
                if (frameIndex == 0) {
                    LOGGER.warn(
                        "[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
                        boneName,
                        animPart
                    );
                }
                continue;
            }

            Quaternionf animQ = getRawAnimQuaternion(
                rawClip,
                rawRestRotations,
                j,
                frameIndex
            );
            Quaternionf restQ = rawRestRotations[j];

            // delta_local = inverse(rest_q) * anim_q  (bone-local frame)
            Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
            // delta_parent = rest * delta_local * inv(rest)
            Quaternionf deltaParent = new Quaternionf(restQ)
                .mul(deltaLocal)
                .mul(new Quaternionf(restQ).invert());
            // glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
            Quaternionf deltaQ = new Quaternionf(deltaParent);
            deltaQ.x = -deltaQ.x;
            deltaQ.y = -deltaQ.y;

            if (isLower) {
                convertLowerBone(builder, boneName, deltaQ, tick, ease);
            } else {
                convertUpperBone(builder, boneName, deltaQ, tick, ease);
            }

            if (keyframeCollector != null) {
                // Translation-only channels count as "explicit": a pure-
                // translation animation (e.g. a rigid-body bounce) still
                // feeds keyframes to PlayerAnimator, so its part must be
                // claimed for composite merging.
                boolean hasExplicitAnim =
                    rawClip != null &&
                    ((j < rawClip.rotations().length &&
                            rawClip.rotations()[j] != null) ||
                        (rawClip.translations() != null &&
                            j < rawClip.translations().length &&
                            rawClip.translations()[j] != null));
                if (hasExplicitAnim) {
                    keyframeCollector.add(animPart);
                    if (isLower) {
                        String upper = GltfBoneMapper.getUpperBoneFor(boneName);
                        if (upper != null) {
                            String upperPart = GltfBoneMapper.getAnimPartName(
                                upper
                            );
                            if (upperPart != null) keyframeCollector.add(
                                upperPart
                            );
                        }
                    }
                }
            }
        }
    }

    /**
     * Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
     *
     * <p>Only writes keyframes for bones that map to a part in {@code ownedParts}.
     * Other bones are skipped entirely. This allows multiple items to contribute
     * to the same animation builder without overwriting each other's keyframes.</p>
     *
     * @param builder     the shared animation builder to add keyframes to
     * @param data        parsed glTF data
     * @param rawClip     the raw animation clip, or null for rest pose
     * @param ownedParts  parts this item exclusively owns (only these get keyframes)
     * @return set of part names that received actual keyframe data from the GLB
     */
    public static Set<String> addBonesToBuilder(
        KeyframeAnimation.AnimationBuilder builder,
        GltfData data,
        @Nullable GltfData.AnimationClip rawClip,
        Set<String> ownedParts
    ) {
        Set<String> partsWithKeyframes = new HashSet<>();
        int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
        float baseline = timelineBaseline(rawClip);
        int lastTick = Integer.MIN_VALUE;

        for (int f = 0; f < frameCount; f++) {
            int tick = frameToTick(rawClip, f, baseline);
            if (tick == lastTick) continue;
            applyFrameToBuilder(
                builder,
                data,
                rawClip,
                f,
                tick,
                DEFAULT_EASE,
                ownedParts,
                f == 0 ? partsWithKeyframes : null
            );
            lastTick = tick;
        }

        return partsWithKeyframes;
    }

    /**
     * Convert an animation clip using skeleton data from a separate source.
     *
     * <p>This is useful when the animation clip is stored separately from the
     * skeleton (e.g., furniture seat animations where the Player_* armature's
     * clips are parsed into a separate map from the skeleton GltfData).</p>
     *
     * <p>The resulting animation has all parts fully enabled. Callers should
     * create a mutable copy and selectively disable parts as needed.</p>
     *
     * @param skeleton the GltfData providing rest pose, joint names, and joint count
     * @param clip     the raw animation clip (in glTF space) to convert
     * @param animName debug name for the resulting animation
     * @return a static looping KeyframeAnimation with all parts enabled
     */
    public static KeyframeAnimation convertWithSkeleton(
        GltfData skeleton,
        GltfData.AnimationClip clip,
        String animName
    ) {
        return convertClip(skeleton, clip, animName);
    }

    /**
     * Internal: convert a specific raw animation clip to a KeyframeAnimation.
     */
    private static KeyframeAnimation convertClip(
        GltfData data,
        @Nullable GltfData.AnimationClip rawClip,
        String animName
    ) {
        KeyframeAnimation.AnimationBuilder builder =
            new KeyframeAnimation.AnimationBuilder(
                AnimationFormat.JSON_EMOTECRAFT
            );

        int endTick = computeEndTick(rawClip);
        int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
        float baseline = timelineBaseline(rawClip);
        int lastTick = Integer.MIN_VALUE;

        builder.beginTick = 0;
        builder.endTick = endTick;
        builder.stopTick = endTick;
        builder.isLooped = true;
        builder.returnTick = 0;
        builder.name = animName;

        for (int f = 0; f < frameCount; f++) {
            int tick = frameToTick(rawClip, f, baseline);
            if (tick == lastTick) continue;
            applyFrameToBuilder(
                builder,
                data,
                rawClip,
                f,
                tick,
                DEFAULT_EASE,
                /* ownedFilter */null,
                /* keyframeCollector */null
            );
            lastTick = tick;
        }

        builder.fullyEnableParts();

        KeyframeAnimation anim = builder.build();
        LOGGER.debug(
            "[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
            animName,
            frameCount,
            endTick
        );
        return anim;
    }

    /**
     * Get the raw animation quaternion for a joint at a specific frame.
     * Falls back to rest rotation if the clip is null, has no data for this joint,
     * or has an empty channel. Clamps frameIndex to the last available frame if
     * the joint's channel is shorter than the shared timestamps array.
     */
    private static Quaternionf getRawAnimQuaternion(
        @Nullable GltfData.AnimationClip rawClip,
        Quaternionf[] rawRestRotations,
        int jointIndex,
        int frameIndex
    ) {
        if (
            rawClip != null &&
            jointIndex < rawClip.rotations().length &&
            rawClip.rotations()[jointIndex] != null
        ) {
            Quaternionf[] channel = rawClip.rotations()[jointIndex];
            if (channel.length > 0) {
                int safeFrame = Math.min(frameIndex, channel.length - 1);
                return channel[safeFrame];
            }
        }
        // Defensive: under a well-formed GLB, jointCount == restRotations.length
        // (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
        // that invariant is ever broken by a future parser path.
        if (jointIndex >= rawRestRotations.length) {
            return new Quaternionf();
        }
        return rawRestRotations[jointIndex];
    }

    private static void convertUpperBone(
        KeyframeAnimation.AnimationBuilder builder,
        String boneName,
        Quaternionf deltaQ,
        int tick,
        Ease ease
    ) {
        // "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
        // Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
        // middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
        Vector3f euler = new Vector3f();
        deltaQ.getEulerAnglesZYX(euler);
        float pitch = euler.x;
        float yaw = euler.y;
        float roll = euler.z;

        String animPart = GltfBoneMapper.getAnimPartName(boneName);
        if (animPart == null) return;

        KeyframeAnimation.StateCollection part = getPartByName(
            builder,
            animPart
        );
        if (part == null) return;

        part.pitch.addKeyFrame(tick, pitch, ease);
        part.yaw.addKeyFrame(tick, yaw, ease);
        part.roll.addKeyFrame(tick, roll, ease);
    }

    private static void convertLowerBone(
        KeyframeAnimation.AnimationBuilder builder,
        String boneName,
        Quaternionf deltaQ,
        int tick,
        Ease ease
    ) {
        // Canonicalize q: q and -q represent the same rotation. Always pick the
        // hemisphere with w >= 0 so consecutive frames don't pop across the
        // double-cover boundary when interpolating.
        float qx = deltaQ.x;
        float qy = deltaQ.y;
        float qz = deltaQ.z;
        float qw = deltaQ.w;
        if (qw < 0) {
            qx = -qx;
            qy = -qy;
            qz = -qz;
            qw = -qw;
        }

        // Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
        float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));

        float bendDirection = 0.0f;
        if (qx * qx + qz * qz > 0.001f) {
            bendDirection = (float) Math.atan2(qz, qx);
        }

        String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
        if (upperBone == null) return;

        String animPart = GltfBoneMapper.getAnimPartName(upperBone);
        if (animPart == null) return;

        KeyframeAnimation.StateCollection part = getPartByName(
            builder,
            animPart
        );
        if (part == null || !part.isBendable) return;

        part.bend.addKeyFrame(tick, angle, ease);
        part.bendDirection.addKeyFrame(tick, bendDirection, ease);
    }

    private static KeyframeAnimation.StateCollection getPartByName(
        KeyframeAnimation.AnimationBuilder builder,
        String name
    ) {
        return switch (name) {
            case "head" -> builder.head;
            case "body" -> builder.body;
            case "rightArm" -> builder.rightArm;
            case "leftArm" -> builder.leftArm;
            case "rightLeg" -> builder.rightLeg;
            case "leftLeg" -> builder.leftLeg;
            default -> null;
        };
    }

    /**
     * Enable parts selectively based on ownership and keyframe presence.
     *
     * <ul>
     *   <li>Owned parts: always enabled (the item controls these bones)</li>
     *   <li>Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)</li>
     *   <li>Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)</li>
     *   <li>Other items' parts: disabled (pass through to their own layer)</li>
     * </ul>
     *
     * <p>The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's
     * explicit declaration that this animation is designed to control the entire body,
     * not just the item's owned regions. Without this prefix, free bones are never enabled,
     * even if the GLB contains keyframes for them. This prevents accidental bone hijacking
     * when an artist keyframes all bones in Blender by default.</p>
     *
     * @param builder            the animation builder with keyframes already added
     * @param ownedParts         parts the item explicitly owns (always enabled)
     * @param enabledParts       parts the item may animate (owned + free)
     * @param partsWithKeyframes parts that received actual animation data from the GLB
     * @param animName           resolved animation name (checked for "Full" prefix)
     */
    private static void enableSelectiveParts(
        KeyframeAnimation.AnimationBuilder builder,
        Set<String> ownedParts,
        Set<String> enabledParts,
        Set<String> partsWithKeyframes,
        String animName
    ) {
        boolean isFullBodyAnimation = isFullBodyAnimName(animName);
        boolean allowFreeHead = isFullHeadAnimName(animName);
        enableSelectivePartsCore(
            builder,
            ownedParts,
            enabledParts,
            partsWithKeyframes,
            isFullBodyAnimation,
            allowFreeHead
        );
    }

    /**
     * Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
     * declares opt-in to full-body free-bone animation. See the "Full" prefix
     * convention in {@link #enableSelectiveParts}.
     */
    public static boolean isFullBodyAnimName(@Nullable String animName) {
        return animName != null && animName.startsWith("gltf_Full");
    }

    /**
     * Check whether a resolved-and-prefixed animation name opts in to head
     * animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
     * protected by default to preserve vanilla head-tracking on bondage items
     * that don't specifically want to animate it.
     */
    public static boolean isFullHeadAnimName(@Nullable String animName) {
        return isFullBodyAnimName(animName) &&
            animName.startsWith("gltf_FullHead");
    }

    /**
     * Composite variant of {@link #enableSelectiveParts} used by the multi-item
     * path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
     * compute the three aggregates themselves: {@code allOwnedParts} is the
     * union of owned regions across all items, {@code partsWithKeyframes} is
     * the union of keyframe parts returned by each {@link #addBonesToBuilder}
     * call, and the two Full/FullHead flags should be true if ANY item in the
     * composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
     */
    public static void enableSelectivePartsComposite(
        KeyframeAnimation.AnimationBuilder builder,
        Set<String> allOwnedParts,
        Set<String> partsWithKeyframes,
        boolean isFullBodyAnimation,
        boolean allowFreeHead
    ) {
        // In the composite path every animation part is implicitly in
        // enabledParts — if a FullX animation has keyframes for it, we want it
        // enabled. Pass ALL_PARTS as the enabled set so the single-item
        // opt-out path is a no-op.
        enableSelectivePartsCore(
            builder,
            allOwnedParts,
            ALL_PARTS_SET,
            partsWithKeyframes,
            isFullBodyAnimation,
            allowFreeHead
        );
    }

    private static final Set<String> ALL_PARTS_SET = Set.of(
        "head",
        "body",
        "rightArm",
        "leftArm",
        "rightLeg",
        "leftLeg"
    );

    private static void enableSelectivePartsCore(
        KeyframeAnimation.AnimationBuilder builder,
        Set<String> ownedParts,
        Set<String> enabledParts,
        Set<String> partsWithKeyframes,
        boolean isFullBodyAnimation,
        boolean allowFreeHead
    ) {
        for (String partName : ALL_PARTS_SET) {
            KeyframeAnimation.StateCollection part = getPartByName(
                builder,
                partName
            );
            if (part != null) {
                if (ownedParts.contains(partName)) {
                    // Always enable owned parts — the item controls these bones
                    part.fullyEnablePart(false);
                } else if (
                    isFullBodyAnimation &&
                    enabledParts.contains(partName) &&
                    partsWithKeyframes.contains(partName) &&
                    (!"head".equals(partName) || allowFreeHead)
                ) {
                    // Full-body animation: free part WITH keyframes — enable.
                    // The "Full" prefix is the artist's explicit opt-in to animate
                    // bones outside their declared regions.
                    // Head is protected by default (preserves vanilla head tracking).
                    // Use "Head" in the animation name (e.g., FullHeadStruggle) to
                    // explicitly opt-in to head control for that animation.
                    part.fullyEnablePart(false);
                } else {
                    // Non-Full animation, other item's part, or free part without keyframes.
                    // Disabled parts pass through to the lower-priority context layer.
                    part.setEnabled(false);
                }
            }
        }
    }
}
