package com.tiedup.remake.client.gltf;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
 * Unit tests for {@link GltfPoseConverter#computeEndTick}.
 *
 * <p>This is the public entry point that controls how many ticks a GLB animation
 * occupies on the PlayerAnimator timeline. The P0-01 fix replaced a hardcoded
 * {@code endTick = 1} (which silently froze every animation to frame 0) with a
 * timestamp-derived computation. These tests pin down that behavior so a future
 * refactor can't silently re-introduce the bug.</p>
 *
 * <p>Full end-to-end tests (keyframe emission into a {@code KeyframeAnimation}
 * builder, multi-item composite, selective-part enabling) require instantiating
 * a complete {@code GltfData} and the PlayerAnimator library — deferred to a
 * GameTest or a later harness upgrade.</p>
 */
class GltfPoseConverterTest {

    private static final int TICKS_PER_SECOND = 20;

    private static GltfData.AnimationClip clip(float... timestamps) {
        int n = timestamps.length;
        Quaternionf[][] rotations = new Quaternionf[1][n];
        Vector3f[][] translations = new Vector3f[1][n];
        for (int i = 0; i < n; i++) {
            rotations[0][i] = new Quaternionf(); // identity
            translations[0][i] = new Vector3f();
        }
        return new GltfData.AnimationClip(timestamps, rotations, translations, n);
    }

    @Nested
    @DisplayName("computeEndTick — timestamp → tick conversion")
    class ComputeEndTickTests {

        @Test
        @DisplayName("null clip returns 1 (minimum valid endTick)")
        void nullClipReturnsOne() {
            assertEquals(1, GltfPoseConverter.computeEndTick(null));
        }

        @Test
        @DisplayName("zero-frame clip returns 1")
        void zeroFrameReturnsOne() {
            GltfData.AnimationClip c = new GltfData.AnimationClip(
                new float[0],
                new Quaternionf[0][],
                null,
                0
            );
            assertEquals(1, GltfPoseConverter.computeEndTick(c));
        }

        @Test
        @DisplayName("single-frame clip at t=0 returns 1")
        void singleFrameAtZero() {
            assertEquals(1, GltfPoseConverter.computeEndTick(clip(0.0f)));
        }

        @Test
        @DisplayName("3 frames at 0/0.5/1.0s returns 20 ticks")
        void threeFramesOneSecondSpan() {
            // Last timestamp 1.0s → 1.0 * 20 = 20 ticks.
            assertEquals(20, GltfPoseConverter.computeEndTick(clip(0f, 0.5f, 1.0f)));
        }

        @Test
        @DisplayName("baseline normalization: timestamps 0.5/1.0/1.5s → endTick 20 (span 1s)")
        void baselineNormalizesToSpan() {
            // If baseline wasn't subtracted, this would return round(1.5*20)=30.
            // With baseline=0.5, endTick = round((1.5-0.5)*20) = 20.
            assertEquals(
                20,
                GltfPoseConverter.computeEndTick(clip(0.5f, 1.0f, 1.5f))
            );
        }

        @Test
        @DisplayName("single-frame clip at t=3.0 still returns 1 (baseline collapses to zero)")
        void singleFrameAtLargeTime() {
            // times[0] = 3.0, baseline = 3.0, (3.0 - 3.0) * 20 = 0, clamped to 1.
            assertEquals(1, GltfPoseConverter.computeEndTick(clip(3.0f)));
        }

        @Test
        @DisplayName("long clip: 2.5s span returns 50 ticks")
        void longClipTwoAndHalfSeconds() {
            GltfData.AnimationClip c = clip(0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f);
            assertEquals(50, GltfPoseConverter.computeEndTick(c));
        }

        @Test
        @DisplayName("rounding: last timestamp 0.025s (< 0.5 tick) still yields at least 1 tick")
        void subTickTimestampClampsToOne() {
            // round(0.025 * 20) = round(0.5) = 0 or 1 depending on rounding mode.
            // Math.round(float) returns (int)Math.floor(x+0.5) = 1 here.
            // But even if it were 0, the Math.max(1, ...) clamp should protect us.
            int endTick = GltfPoseConverter.computeEndTick(clip(0f, 0.025f));
            assertEquals(
                1,
                endTick,
                "Very short spans must collapse to endTick=1, not 0 (would break PlayerAnimator)"
            );
        }

        @Test
        @DisplayName("high-FPS source preserves total span: 60 frames over 2s → 40 ticks")
        void highFpsPreservesSpan() {
            // 60 evenly-spaced frames from 0.0 to 2.0s.
            float[] times = new float[60];
            for (int i = 0; i < 60; i++) {
                times[i] = i * (2.0f / 59.0f);
            }
            GltfData.AnimationClip c = clip(times);
            assertEquals(
                Math.round(2.0f * TICKS_PER_SECOND),
                GltfPoseConverter.computeEndTick(c)
            );
        }

        @Test
        @DisplayName("timestamps array longer than frameCount: uses min(len-1, frameCount-1)")
        void timestampsLongerThanFrameCount() {
            // frameCount=3 means only the first 3 timestamps matter even if array is longer.
            Quaternionf[][] rotations = new Quaternionf[1][3];
            rotations[0][0] = new Quaternionf();
            rotations[0][1] = new Quaternionf();
            rotations[0][2] = new Quaternionf();
            GltfData.AnimationClip c = new GltfData.AnimationClip(
                new float[] { 0f, 0.5f, 1.0f, 99.0f, 99.0f }, // 5 timestamps
                rotations,
                null,
                3 // but only 3 active frames
            );
            // Last valid index = min(5-1, 3-1) = 2 → times[2] = 1.0 → 20 ticks.
            assertEquals(20, GltfPoseConverter.computeEndTick(c));
        }
    }
}
