using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

namespace Streamingle.Editor
{
    public sealed class AICameraCurveSimplifierTests
    {
        [Test]
        public void ExactPresetKeepsEverySampleAndNormalizesQuaternionSigns()
        {
            var samples = CreateSamples(121);

            var result = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
            var expected = Enumerable.Range(0, samples.Times.Length).ToArray();

            CollectionAssert.AreEqual(expected, result.PositionIndices);
            CollectionAssert.AreEqual(expected, result.RotationIndices);
            CollectionAssert.AreEqual(expected, result.FieldOfViewIndices);
            CollectionAssert.AreEqual(expected, result.DutchIndices);
            for (var index = 1; index < result.Rotations.Length; index++)
            {
                Assert.That(
                    Quaternion.Dot(
                        result.Rotations[index - 1],
                        result.Rotations[index]),
                    Is.GreaterThanOrEqualTo(0f),
                    $"Quaternion sign changed at sample {index}.");
            }
        }

        [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
        [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
        public void ReducedPresetsRespectConfiguredVectorAngleAndLensErrors(
            AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
        {
            var samples = CreateSamples(241);
            var result = Simplify(samples, preset);
            var settings =
                AICameraTimelinePreviewImporter.GetCurveSimplificationSettings(preset);

            AssertVectorError(
                result,
                settings.PositionErrorMeters + 0.00001f);
            AssertQuaternionError(
                result,
                settings.QuaternionAngleErrorDegrees + 0.0001f);
            AssertScalarError(
                result.Times,
                result.FieldOfView,
                result.FieldOfViewIndices,
                settings.FieldOfViewErrorDegrees + 0.00001f,
                "FOV");
            AssertScalarError(
                result.Times,
                result.Dutch,
                result.DutchIndices,
                settings.DutchErrorDegrees + 0.00001f,
                "Dutch");
        }

        [Test]
        public void EveryChannelPreservesEndpointsAndGlobalExtrema()
        {
            var samples = CreateSamples(181);
            var result = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            AssertEndpoints(result.PositionIndices, samples.Times.Length);
            AssertEndpoints(result.RotationIndices, samples.Times.Length);
            AssertEndpoints(result.FieldOfViewIndices, samples.Times.Length);
            AssertEndpoints(result.DutchIndices, samples.Times.Length);

            AssertExtrema(
                result.PositionIndices,
                result.Positions.Select(value => value.x).ToArray());
            AssertExtrema(
                result.PositionIndices,
                result.Positions.Select(value => value.y).ToArray());
            AssertExtrema(
                result.PositionIndices,
                result.Positions.Select(value => value.z).ToArray());
            AssertExtrema(result.FieldOfViewIndices, result.FieldOfView);
            AssertExtrema(result.DutchIndices, result.Dutch);
            AssertExtrema(
                result.RotationIndices,
                result.Rotations.Select(value => value.x).ToArray());
            AssertExtrema(
                result.RotationIndices,
                result.Rotations.Select(value => value.y).ToArray());
            AssertExtrema(
                result.RotationIndices,
                result.Rotations.Select(value => value.z).ToArray());
            AssertExtrema(
                result.RotationIndices,
                result.Rotations.Select(value => value.w).ToArray());
        }

        [Test]
        public void QuaternionComponentsUseOneStrictlyIncreasingTimeIndexSet()
        {
            var samples = CreateSamples(151);
            var result = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);

            Assert.That(result.RotationIndices, Is.Ordered.Ascending);
            Assert.That(
                result.RotationIndices.Distinct().Count(),
                Is.EqualTo(result.RotationIndices.Length));
            foreach (var index in result.RotationIndices)
            {
                Assert.That(index, Is.InRange(0, result.Times.Length - 1));
            }

            for (var index = 1; index < result.Rotations.Length; index++)
            {
                Assert.That(
                    Quaternion.Dot(
                        result.Rotations[index - 1],
                        result.Rotations[index]),
                    Is.GreaterThanOrEqualTo(0f));
            }
        }

        [Test]
        public void EditablePresetReducesSmoothCameraDataMoreThanExact()
        {
            var samples = CreateSamples(301);
            var exact = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
            var editable = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            Assert.That(
                editable.PositionIndices.Length,
                Is.LessThan(exact.PositionIndices.Length));
            Assert.That(
                editable.RotationIndices.Length,
                Is.LessThan(exact.RotationIndices.Length));
            Assert.That(
                editable.FieldOfViewIndices.Length,
                Is.LessThan(exact.FieldOfViewIndices.Length));
            Assert.That(
                editable.DutchIndices.Length,
                Is.LessThan(exact.DutchIndices.Length));
        }

        [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
        [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
        public void ReducedPositionCurvesUseAuditedC2SplineWithoutOvershoot(
            AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
        {
            var samples = CreateQuadraticMotionSamples(241);
            var simplified = Simplify(samples, preset);

            var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
                simplified,
                preset);

            Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason);
            Assert.That(simplified.PositionUsedExactFallback, Is.False);
            Assert.That(
                curves.XCurve.length,
                Is.LessThan(samples.Times.Length));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            AssertCurveDoesNotOvershootSelectedSegments(
                curves.XCurve,
                simplified.Times,
                simplified.PositionIndices);
            AssertCurveDoesNotOvershootSelectedSegments(
                curves.YCurve,
                simplified.Times,
                simplified.PositionIndices);
            AssertCurveDoesNotOvershootSelectedSegments(
                curves.ZCurve,
                simplified.Times,
                simplified.PositionIndices);

            var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
                simplified.Times,
                simplified.Positions,
                curves.XCurve,
                curves.YCurve,
                curves.ZCurve,
                AICameraTimelinePreviewImporter
                    .GetCurveSimplificationSettings(preset)
                    .PositionErrorMeters);
            Assert.That(audit.Passed, Is.True, audit.FailureReason);

            var rawMaximumJerk = MeasureMaximumJerk(
                simplified.Times,
                simplified.Positions);
            var evaluatedMaximumJerk = MeasureMaximumJerk(
                simplified.Times,
                EvaluatePositionCurves(simplified.Times, curves));
            var allowedMaximumJerk = Mathf.Max(
                250f,
                Mathf.Max(rawMaximumJerk * 3f, rawMaximumJerk + 150f));
            Assert.That(
                evaluatedMaximumJerk,
                Is.LessThanOrEqualTo(allowedMaximumJerk));
        }

        [Test]
        public void SparsePositionErrorIsAdaptivelyRefinedWithoutDenseFallback()
        {
            const int count = 61;
            var times = new double[count];
            var positions = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60.0;
                times[index] = time;
                positions[index] = new Vector3(
                    Mathf.Sin((float)time * Mathf.PI * 2f),
                    1.6f,
                    -4f);
            }

            var simplified = CreatePositionOnlySimplifiedCurves(
                times,
                positions,
                new[] { 0, count - 1 });
            var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason);
            Assert.That(simplified.PositionUsedExactFallback, Is.False);
            Assert.That(curves.XCurve.length, Is.GreaterThan(2));
            Assert.That(curves.XCurve.length, Is.LessThan(count));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            var settings = AICameraTimelinePreviewImporter
                .GetCurveSimplificationSettings(
                    AICameraTimelinePreviewImporter
                        .CurveSimplificationPreset.Editable);
            for (var index = 0; index < count; index++)
            {
                Assert.That(
                    Mathf.Abs(
                        curves.XCurve.Evaluate((float)times[index]) -
                        positions[index].x),
                    Is.LessThanOrEqualTo(
                        settings.PositionErrorMeters + 0.0001f));
            }
        }

        [Test]
        public void HighFrequencyPositionExceedingRefinementBudgetUsesAuditedDenseFallback()
        {
            const int count = 1201;
            var times = new double[count];
            var positions = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                times[index] = time;
                positions[index] = new Vector3(
                    Mathf.Sin(time * Mathf.PI * 6f),
                    1.6f,
                    -4f);
            }

            var simplified = CreatePositionOnlySimplifiedCurves(
                times,
                positions,
                new[] { 0, count - 1 });
            var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            Assert.That(curves.UsedExactFallback, Is.True);
            Assert.That(curves.XCurve.length, Is.EqualTo(count));
            AssertCurveIsC2(curves.XCurve);
            var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
                times,
                positions,
                curves.XCurve,
                curves.YCurve,
                curves.ZCurve,
                AICameraTimelinePreviewImporter
                    .GetCurveSimplificationSettings(
                        AICameraTimelinePreviewImporter
                            .CurveSimplificationPreset.Editable)
                    .PositionErrorMeters);
            Assert.That(audit.Passed, Is.True, audit.FailureReason);
        }

        [Test]
        public void MotionAuditRejectsPathologicalMidpointVelocityRegression()
        {
            const int count = 61;
            var times = new double[count];
            var positions = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60.0;
                times[index] = time;
                positions[index] = new Vector3((float)time, 0f, 0f);
            }

            var pathologicalX = new AnimationCurve(
                new Keyframe(0f, 0f, 1f, 80f),
                new Keyframe(1f, 1f, -80f, 1f));
            var constant = AnimationCurve.Linear(0f, 0f, 1f, 0f);

            var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
                times,
                positions,
                pathologicalX,
                constant,
                constant,
                100f);

            Assert.That(audit.Passed, Is.False);
            Assert.That(
                audit.FailureReason,
                Does.Contain("regressed").Or.Contain("not C1").Or.Contain("not C2"));
        }

        [Test]
        public void AuditGridUsesExactSourceTimesAndDropsFloatingTailDuplicates()
        {
            var frame = 1.0 / 60.0;
            var times = new[]
            {
                0.0,
                frame,
                frame * 2.0,
                frame * 3.0,
                frame * 3.0 + 0.00000000005
            };

            var auditTimes = AICameraTimelinePreviewImporter
                .BuildPositionCurveAuditTimes(times);

            Assert.That(auditTimes.Count, Is.EqualTo(7));
            Assert.That(auditTimes[0], Is.EqualTo(times[0]));
            Assert.That(auditTimes[2], Is.EqualTo(times[1]));
            Assert.That(auditTimes[4], Is.EqualTo(times[2]));
            Assert.That(auditTimes[6], Is.EqualTo(times[3]));
            for (var index = 1; index < auditTimes.Count; index++)
            {
                Assert.That(
                    auditTimes[index] - auditTimes[index - 1],
                    Is.GreaterThan(frame * 0.49),
                    $"Audit time {index} introduced a floating-point tail.");
            }
        }

        [Test]
        public void AlignedMotionAuditFindsLocalJerkHiddenByEarlierGlobalMaximum()
        {
            const int count = 40;
            var times = new double[count];
            var reference = new Vector3[count];
            var candidate = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                times[index] = index / 60.0;
            }

            reference[3] = new Vector3(0.1f, 0f, 0f);
            Array.Copy(reference, candidate, count);
            candidate[30] = new Vector3(0.001f, 0f, 0f);

            Assert.That(
                MeasureMaximumJerk(times, candidate),
                Is.EqualTo(MeasureMaximumJerk(times, reference))
                    .Within(0.01f),
                "The earlier source jolt must mask the new jolt in a global-max audit.");
            Assert.That(
                AICameraTimelinePreviewImporter.TryDescribeAlignedMotionRegression(
                    times,
                    reference,
                    candidate,
                    out var failureReason),
                Is.True);
            Assert.That(failureReason, Does.Contain("windowed jerk"));
        }

        [Test]
        public void MotionRetentionAuditRejectsErasedSubtleLowFrequencyMove()
        {
            const int count = 241;
            var times = new double[count];
            var positions = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60.0;
                times[index] = time;
                positions[index] = new Vector3(
                    0.006f * Mathf.Sin((float)(time * Math.PI * 0.5)),
                    0f,
                    0f);
            }

            var erased = AnimationCurve.Linear(
                0f,
                0f,
                (float)times[count - 1],
                0f);
            var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
                times,
                positions,
                erased,
                erased,
                erased,
                0.02f);

            Assert.That(audit.Passed, Is.False);
            Assert.That(
                audit.FailureReason,
                Does.Contain("meaningful motion retention"));
        }

        [Test]
        public void MotionRetentionAuditIgnoresSubDeadbandPositionNoise()
        {
            const int count = 121;
            var times = new double[count];
            var positions = new Vector3[count];
            for (var index = 0; index < count; index++)
            {
                times[index] = index / 60.0;
                positions[index] = new Vector3(
                    index % 2 == 0 ? 0.0001f : -0.0001f,
                    0f,
                    0f);
            }

            var denoised = AnimationCurve.Linear(
                0f,
                0f,
                (float)times[count - 1],
                0f);
            var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
                times,
                positions,
                denoised,
                denoised,
                denoised,
                0.02f);

            Assert.That(audit.Passed, Is.True, audit.FailureReason);
        }

        [Test]
        public void ExactPositionCurvesRetainEverySampleWithC2Tangents()
        {
            var samples = CreateSamples(91);
            var simplified = Simplify(
                samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);

            var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);

            Assert.That(curves.UsedExactFallback, Is.False);
            Assert.That(curves.XCurve.length, Is.EqualTo(samples.Times.Length));
            Assert.That(curves.YCurve.length, Is.EqualTo(samples.Times.Length));
            Assert.That(curves.ZCurve.length, Is.EqualTo(samples.Times.Length));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            for (var index = 0; index < samples.Times.Length; index++)
            {
                var time = (float)samples.Times[index];
                Assert.That(
                    curves.XCurve.Evaluate(time),
                    Is.EqualTo(samples.Positions[index].x).Within(0.000001f));
                Assert.That(
                    curves.YCurve.Evaluate(time),
                    Is.EqualTo(samples.Positions[index].y).Within(0.000001f));
                Assert.That(
                    curves.ZCurve.Evaluate(time),
                    Is.EqualTo(samples.Positions[index].z).Within(0.000001f));
            }
        }

        [Test]
        public void ExactPositionCurvesRejectUnboundedBetweenFrameJerk()
        {
            var times = new[]
            {
                0.0,
                1.0 / 60.0,
                2.0 / 60.0,
                2.0
            };
            var positions = new[]
            {
                new Vector3(0f, 1.6f, -4f),
                new Vector3(0f, 1.6f, -4f),
                new Vector3(1f, 1.6f, -4f),
                new Vector3(1f, 1.6f, -4f)
            };
            var simplified = CreatePositionOnlySimplifiedCurves(
                times,
                positions,
                Enumerable.Range(0, times.Length).ToArray());

            var exception = Assert.Throws<InvalidOperationException>(() =>
                AICameraTimelinePreviewImporter.BuildPositionCurves(
                    simplified,
                    AICameraTimelinePreviewImporter
                        .CurveSimplificationPreset.Exact));

            Assert.That(
                exception.Message,
                Does.Contain("Exact dense C2 position curves failed"));
            Assert.That(exception.Message, Does.Contain("high-rate motion audit"));
        }

        [Test]
        public void EditablePresetPreservesStopThroughDirectionChangeKeys()
        {
            const int count = 121;
            const int center = 60;
            const int preserveRadius = 21;
            var times = new double[count];
            var positions = new Vector3[count];
            var rotations = new Quaternion[count];
            var fieldOfView = new float[count];
            var dutch = new float[count];
            for (var index = 0; index < count; index++)
            {
                times[index] = index / 60.0;
                float x;
                if (index <= center)
                {
                    var t = index / (float)center;
                    x = -t * t * t + t * t + t;
                }
                else
                {
                    var t = (index - center) /
                            (float)(count - 1 - center);
                    var progress = -t * t * t + 2f * t * t;
                    x = 1f - progress;
                }

                positions[index] = new Vector3(x, 1.6f, -4f);
                rotations[index] = Quaternion.identity;
                fieldOfView[index] = 40f;
                dutch[index] = 0f;
            }

            var result = Simplify(
                new CameraSamples(
                    times,
                    positions,
                    rotations,
                    fieldOfView,
                    dutch),
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            for (var index = center - preserveRadius;
                 index <= center + preserveRadius;
                 index++)
            {
                Assert.That(
                    result.PositionIndices,
                    Does.Contain(index),
                    $"Direction-change key {index} was simplified away.");
            }
        }

        [Test]
        public void EditablePresetPreservesBrakedRightAngleTurnKeys()
        {
            const int count = 121;
            const int center = 60;
            const int preserveRadius = 21;
            var times = new double[count];
            var positions = new Vector3[count];
            var rotations = new Quaternion[count];
            var fieldOfView = new float[count];
            var dutch = new float[count];
            for (var index = 0; index < count; index++)
            {
                times[index] = index / 60.0;
                if (index <= center)
                {
                    var t = index / (float)center;
                    var progress = -t * t * t + t * t + t;
                    positions[index] = new Vector3(progress, 1.6f, -4f);
                }
                else
                {
                    var t = (index - center) /
                            (float)(count - 1 - center);
                    var progress = -t * t * t + 2f * t * t;
                    positions[index] = new Vector3(1f, 1.6f, -4f + progress);
                }

                rotations[index] = Quaternion.identity;
                fieldOfView[index] = 40f;
                dutch[index] = 0f;
            }

            var result = Simplify(
                new CameraSamples(
                    times,
                    positions,
                    rotations,
                    fieldOfView,
                    dutch),
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            for (var index = center - preserveRadius;
                 index <= center + preserveRadius;
                 index++)
            {
                Assert.That(
                    result.PositionIndices,
                    Does.Contain(index),
                    $"Right-angle braking key {index} was simplified away.");
            }
        }

        [Test]
        public void EditablePresetPreservesBrakedFortyFiveDegreeTurnKeys()
        {
            const int count = 121;
            const int center = 60;
            const int preserveRadius = 21;
            var times = new double[count];
            var positions = new Vector3[count];
            var rotations = new Quaternion[count];
            var fieldOfView = new float[count];
            var dutch = new float[count];
            var outgoing = new Vector3(1f, 0f, 1f).normalized;
            for (var index = 0; index < count; index++)
            {
                times[index] = index / 60.0;
                if (index <= center)
                {
                    var t = index / (float)center;
                    var progress = -t * t * t + t * t + t;
                    positions[index] = new Vector3(progress, 1.6f, -4f);
                }
                else
                {
                    var t = (index - center) /
                            (float)(count - 1 - center);
                    var progress = -t * t * t + 2f * t * t;
                    positions[index] =
                        new Vector3(1f, 1.6f, -4f) + outgoing * progress;
                }

                rotations[index] = Quaternion.identity;
                fieldOfView[index] = 40f;
                dutch[index] = 0f;
            }

            var result = Simplify(
                new CameraSamples(
                    times,
                    positions,
                    rotations,
                    fieldOfView,
                    dutch),
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            for (var index = center - preserveRadius;
                 index <= center + preserveRadius;
                 index++)
            {
                Assert.That(
                    result.PositionIndices,
                    Does.Contain(index),
                    $"Forty-five-degree braking key {index} was simplified away.");
            }
        }

        [Test]
        public void ExplicitDirectorScopesSeparateSameNamedSongDirectors()
        {
            var firstObject = new GameObject("SongDirector");
            var secondObject = new GameObject("SongDirector");
            var firstTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var secondTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var firstDirector = firstObject.AddComponent<PlayableDirector>();
                var secondDirector = secondObject.AddComponent<PlayableDirector>();
                firstDirector.playableAsset = firstTimeline;
                secondDirector.playableAsset = secondTimeline;

                var firstName = AICameraTimelinePreviewImporter
                    .GetScopedPreviewDirectorName(firstDirector);
                var secondName = AICameraTimelinePreviewImporter
                    .GetScopedPreviewDirectorName(secondDirector);

                StringAssert.StartsWith(
                    AICameraTimelinePreviewImporter.PreviewDirectorName + "__",
                    firstName);
                Assert.That(secondName, Is.Not.EqualTo(firstName));
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(firstObject);
                UnityEngine.Object.DestroyImmediate(secondObject);
                UnityEngine.Object.DestroyImmediate(firstTimeline);
                UnityEngine.Object.DestroyImmediate(secondTimeline);
            }
        }

        [Test]
        public void C1OnlyCurveIsRejectedAndItsJerkDivergesWithSampleRate()
        {
            var curve = new AnimationCurve(
                new Keyframe(0f, 0f, 0f, 0f),
                new Keyframe(1f, 1f, 0f, 0f),
                new Keyframe(2f, 1f, 0f, 0f));
            AssertCurveIsC1(curve);

            Assert.That(
                AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure(
                    curve,
                    "C1-only fixture",
                    out var failureReason),
                Is.True);
            Assert.That(failureReason, Does.Contain("not C2"));

            var jerk120 = MeasureMaximumJerk(
                CreateUniformTimes(0f, 2f, 120),
                EvaluateScalarCurveAsPositions(curve, 0f, 2f, 120));
            var jerk240 = MeasureMaximumJerk(
                CreateUniformTimes(0f, 2f, 240),
                EvaluateScalarCurveAsPositions(curve, 0f, 2f, 240));
            var jerk480 = MeasureMaximumJerk(
                CreateUniformTimes(0f, 2f, 480),
                EvaluateScalarCurveAsPositions(curve, 0f, 2f, 480));

            Assert.That(jerk240, Is.GreaterThan(jerk120 * 1.7f));
            Assert.That(jerk480, Is.GreaterThan(jerk240 * 1.7f));
        }

        [Test]
        public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly()
        {
            var previewObject = new GameObject(
                AICameraTimelinePreviewImporter.PreviewDirectorName + "__Song_1234");
            var ordinaryObject = new GameObject("Song");
            var previewTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var ordinaryTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                previewTimeline.CreateTrack<AnimationTrack>(
                    null,
                    AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
                var preview = previewObject.AddComponent<PlayableDirector>();
                var ordinary = ordinaryObject.AddComponent<PlayableDirector>();
                preview.playableAsset = previewTimeline;
                ordinary.playableAsset = ordinaryTimeline;

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .IsPreviewDirectorForEditor(preview),
                    Is.True);
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .IsPreviewDirectorForEditor(ordinary),
                    Is.False);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(previewObject);
                UnityEngine.Object.DestroyImmediate(ordinaryObject);
                UnityEngine.Object.DestroyImmediate(previewTimeline);
                UnityEngine.Object.DestroyImmediate(ordinaryTimeline);
            }
        }

        [Test]
        public void FinalAssetFolderMustStayBelowAssets()
        {
            Assert.That(
                AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
                    "Assets/Shows/AI_CameraFinal/SongA/"),
                Is.EqualTo("Assets/Shows/AI_CameraFinal/SongA"));
            Assert.Throws<ArgumentException>(() =>
                AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
                    "Assets/../Outside"));
            Assert.Throws<ArgumentException>(() =>
                AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
                    "C:/Outside"));
            Assert.Throws<ArgumentException>(() =>
                AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
                    "Assets"));
        }

        [Test]
        public void FinalizationClearsDisposablePreviewTrackBindings()
        {
            var directorObject = new GameObject("FinalDestinationDirector");
            var animatorObject = new GameObject("DisposablePreviewAnimator");
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                var animator = animatorObject.AddComponent<Animator>();
                var track = timeline.CreateTrack<AnimationTrack>(
                    null,
                    "Disposable Preview Track");
                director.playableAsset = timeline;
                director.SetGenericBinding(track, animator);
                Assert.That(director.GetGenericBinding(track), Is.SameAs(animator));

                AICameraTimelinePreviewImporter
                    .ClearTimelineBindingsForFinalization(director, timeline);

                Assert.That(director.GetGenericBinding(track), Is.Null);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(directorObject);
                UnityEngine.Object.DestroyImmediate(animatorObject);
                UnityEngine.Object.DestroyImmediate(timeline);
            }
        }

        private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
            Simplify(
                CameraSamples samples,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
        {
            return AICameraTimelinePreviewImporter.SimplifyCameraCurves(
                samples.Times,
                samples.Positions,
                samples.Rotations,
                samples.FieldOfView,
                samples.Dutch,
                preset);
        }

        private static CameraSamples CreateQuadraticMotionSamples(int count)
        {
            var times = new double[count];
            var positions = new Vector3[count];
            var rotations = new Quaternion[count];
            var fieldOfView = new float[count];
            var dutch = new float[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60.0;
                var timeFloat = (float)time;
                times[index] = time;
                positions[index] = new Vector3(
                    0.4f * timeFloat + 0.15f * timeFloat * timeFloat,
                    1.6f + 0.02f * timeFloat * timeFloat,
                    -4f + 0.1f * timeFloat);
                rotations[index] = Quaternion.identity;
                fieldOfView[index] = 40f;
                dutch[index] = 0f;
            }

            return new CameraSamples(
                times,
                positions,
                rotations,
                fieldOfView,
                dutch);
        }

        private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
            CreatePositionOnlySimplifiedCurves(
                double[] times,
                Vector3[] positions,
                int[] positionIndices)
        {
            var rotations = Enumerable.Repeat(
                    Quaternion.identity,
                    times.Length)
                .ToArray();
            var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray();
            var dutch = new float[times.Length];
            var allIndices = Enumerable.Range(0, times.Length).ToArray();
            return new AICameraTimelinePreviewImporter.SimplifiedCameraCurves(
                times,
                positions,
                rotations,
                fieldOfView,
                dutch,
                positionIndices,
                allIndices,
                allIndices,
                allIndices);
        }

        private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
            CreateRotationOnlySimplifiedCurves(
                double[] times,
                Quaternion[] rotations,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
        {
            var positions = Enumerable.Repeat(Vector3.zero, times.Length).ToArray();
            var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray();
            var dutch = new float[times.Length];
            return AICameraTimelinePreviewImporter.SimplifyCameraCurves(
                times,
                positions,
                rotations,
                fieldOfView,
                dutch,
                preset);
        }

        private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
            CreateRotationOnlySimplifiedCurves(
                double[] times,
                Quaternion[] rotations,
                int[] rotationIndices)
        {
            var positions = Enumerable.Repeat(Vector3.zero, times.Length).ToArray();
            var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray();
            var dutch = new float[times.Length];
            var allIndices = Enumerable.Range(0, times.Length).ToArray();
            return new AICameraTimelinePreviewImporter.SimplifiedCameraCurves(
                times,
                positions,
                rotations,
                fieldOfView,
                dutch,
                allIndices,
                rotationIndices,
                allIndices,
                allIndices);
        }

        private static AnimationCurve BuildQuaternionCornerCurve(
            IReadOnlyList<float> times,
            IReadOnlyList<float> values)
        {
            var firstSlope =
                (values[1] - values[0]) / (times[1] - times[0]);
            var secondSlope =
                (values[2] - values[1]) / (times[2] - times[1]);
            return new AnimationCurve(
                new Keyframe(
                    times[0],
                    values[0],
                    firstSlope,
                    firstSlope),
                new Keyframe(
                    times[1],
                    values[1],
                    firstSlope,
                    secondSlope),
                new Keyframe(
                    times[2],
                    values[2],
                    secondSlope,
                    secondSlope));
        }

        private static void SetRotationCurve(
            AnimationClip clip,
            string propertyName,
            AnimationCurve curve)
        {
            AnimationUtility.SetEditorCurve(
                clip,
                EditorCurveBinding.FloatCurve(
                    string.Empty,
                    typeof(Transform),
                    propertyName),
                curve);
        }

        private static AnimationCurve GetRotationCurve(
            AnimationClip clip,
            string propertyName)
        {
            return AnimationUtility.GetEditorCurve(
                clip,
                EditorCurveBinding.FloatCurve(
                    string.Empty,
                    typeof(Transform),
                    propertyName));
        }

        private static void AssertQuaternionEndpoints(
            IReadOnlyList<double> times,
            IReadOnlyList<Quaternion> rotations,
            AICameraTimelinePreviewImporter.RotationCurveBuildResult curves)
        {
            var endpointTimes = new[] { times[0], times[times.Count - 1] };
            var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves(
                endpointTimes,
                curves);
            Assert.That(
                Quaternion.Angle(rotations[0], evaluated[0]),
                Is.LessThan(0.05f));
            Assert.That(
                Quaternion.Angle(rotations[rotations.Count - 1], evaluated[1]),
                Is.LessThan(0.05f));
        }

        private static void AssertCurveIsC1(AnimationCurve curve)
        {
            var keys = curve.keys;
            for (var index = 0; index < keys.Length; index++)
            {
                Assert.That(
                    keys[index].inTangent,
                    Is.EqualTo(keys[index].outTangent).Within(0.000001f),
                    $"Curve key {index} has a velocity discontinuity.");
            }
        }

        private static void AssertCurveIsC2(AnimationCurve curve)
        {
            AssertCurveIsC1(curve);
            var failed = AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure(
                curve,
                "test curve",
                out var failureReason);
            Assert.That(failed, Is.False, failureReason);
        }

        private static void AssertCurveDoesNotOvershootSelectedSegments(
            AnimationCurve curve,
            IReadOnlyList<double> times,
            IReadOnlyList<int> indices)
        {
            const int samplesPerSegment = 16;
            for (var segment = 1; segment < indices.Count; segment++)
            {
                var start = (float)times[indices[segment - 1]];
                var end = (float)times[indices[segment]];
                var startValue = curve.Evaluate(start);
                var endValue = curve.Evaluate(end);
                var minimum = Mathf.Min(startValue, endValue) - 0.000001f;
                var maximum = Mathf.Max(startValue, endValue) + 0.000001f;
                for (var sample = 0; sample <= samplesPerSegment; sample++)
                {
                    var time = Mathf.Lerp(
                        start,
                        end,
                        sample / (float)samplesPerSegment);
                    Assert.That(
                        curve.Evaluate(time),
                        Is.InRange(minimum, maximum),
                        $"Curve overshot selected segment {segment - 1}.");
                }
            }
        }

        [Test]
        public void Shot68StyleLongRotationUsesAuditedSparseC2Curves()
        {
            const int count = 1081;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                var normalized = index / (float)(count - 1);
                var eased = normalized * normalized * (3f - 2f * normalized);
                times[index] = time;
                rotations[index] = Quaternion.Euler(
                    -3f + 1.25f * Mathf.Sin(time * 0.22f),
                    -24f + 48f * eased + 1.5f * Mathf.Sin(time * 0.3f),
                    0.6f * Mathf.Sin(time * 0.27f));
            }

            var simplified = CreateRotationOnlySimplifiedCurves(
                times,
                rotations,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
            var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);

            Assert.That(
                simplified.RotationIndices.Length,
                Is.LessThan(count / 4),
                "The long smooth shot should remain meaningfully editable.");
            Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason);
            Assert.That(curves.XCurve.length, Is.LessThan(count / 4));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            AssertCurveIsC2(curves.WCurve);
            AssertQuaternionEndpoints(times, rotations, curves);

            var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                times,
                rotations,
                curves.XCurve,
                curves.YCurve,
                curves.ZCurve,
                curves.WCurve,
                AICameraTimelinePreviewImporter
                    .GetCurveSimplificationSettings(
                        AICameraTimelinePreviewImporter
                            .CurveSimplificationPreset.Balanced)
                    .QuaternionAngleErrorDegrees);
            Assert.That(audit.Passed, Is.True, audit.FailureReason);
        }

        [Test]
        public void AngularMotionAuditRejectsSparseLinearVelocityCorner()
        {
            const int count = 121;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                times[index] = time;
                rotations[index] = Quaternion.Euler(0f, 15f * time, 0f);
            }

            var cornerRotations = new[]
            {
                Quaternion.Euler(0f, 0f, 0f),
                Quaternion.Euler(0f, 10f, 0f),
                Quaternion.Euler(0f, 30f, 0f)
            };
            var cornerTimes = new[] { 0f, 1f, 2f };
            var xCurve = BuildQuaternionCornerCurve(
                cornerTimes,
                cornerRotations.Select(value => value.x).ToArray());
            var yCurve = BuildQuaternionCornerCurve(
                cornerTimes,
                cornerRotations.Select(value => value.y).ToArray());
            var zCurve = BuildQuaternionCornerCurve(
                cornerTimes,
                cornerRotations.Select(value => value.z).ToArray());
            var wCurve = BuildQuaternionCornerCurve(
                cornerTimes,
                cornerRotations.Select(value => value.w).ToArray());

            var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                times,
                rotations,
                xCurve,
                yCurve,
                zCurve,
                wCurve,
                180f);

            Assert.That(audit.Passed, Is.False);
            Assert.That(
                audit.FailureReason,
                Does.Contain("angular").Or.Contain("rotation"));
        }

        [Test]
        public void Shot66StyleMissedCorrectionIsAdaptivelyRefinedAsC2Rotation()
        {
            const int count = 121;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                var correctionDistance = (index - 82f) / 8f;
                var correction = 3f * Mathf.Exp(
                    -correctionDistance * correctionDistance);
                times[index] = time;
                rotations[index] = Quaternion.Euler(
                    0.4f * Mathf.Sin(time),
                    8f * time + correction,
                    0f);
            }

            var simplified = CreateRotationOnlySimplifiedCurves(
                times,
                rotations,
                new[] { 0, count - 1 });
            var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason);
            Assert.That(simplified.RotationUsedDenseFallback, Is.False);
            Assert.That(curves.XCurve.length, Is.GreaterThan(2));
            Assert.That(curves.XCurve.length, Is.LessThan(count));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            AssertCurveIsC2(curves.WCurve);
            AssertQuaternionEndpoints(times, rotations, curves);

            var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves(
                times,
                curves);
            for (var index = 0; index < count; index++)
            {
                Assert.That(
                    Quaternion.Angle(rotations[index], evaluated[index]),
                    Is.LessThan(0.51f),
                    $"Adaptive C2 refinement missed source rotation {index}.");
            }
        }

        [Test]
        public void HighFrequencyRotationExceedingRefinementBudgetUsesDenseC2Fallback()
        {
            const int count = 1201;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                times[index] = time;
                rotations[index] = Quaternion.Euler(
                    0f,
                    10f * Mathf.Sin(time * Mathf.PI * 6f),
                    0f);
            }

            var simplified = CreateRotationOnlySimplifiedCurves(
                times,
                rotations,
                new[] { 0, count - 1 });
            var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);

            Assert.That(curves.UsedDenseFallback, Is.True);
            Assert.That(curves.XCurve.length, Is.EqualTo(count));
            AssertCurveIsC2(curves.XCurve);
            AssertCurveIsC2(curves.YCurve);
            AssertCurveIsC2(curves.ZCurve);
            AssertCurveIsC2(curves.WCurve);
            var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                times,
                rotations,
                curves.XCurve,
                curves.YCurve,
                curves.ZCurve,
                curves.WCurve,
                AICameraTimelinePreviewImporter
                    .GetCurveSimplificationSettings(
                        AICameraTimelinePreviewImporter
                            .CurveSimplificationPreset.Editable)
                    .QuaternionAngleErrorDegrees);
            Assert.That(audit.Passed, Is.True, audit.FailureReason);
        }

        [Test]
        public void UnityQuaternionContinuityPassPreservesAuditedC2Curves()
        {
            const int count = 241;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                times[index] = time;
                rotations[index] = Quaternion.Euler(
                    2f * Mathf.Sin(time * 0.45f),
                    12f * Mathf.Sin(time * 0.3f),
                    0.7f * Mathf.Sin(time * 0.6f));
            }

            var simplified = CreateRotationOnlySimplifiedCurves(
                times,
                rotations,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
            var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
            var clip = new AnimationClip();
            try
            {
                SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve);
                SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve);
                SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve);
                SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve);
                clip.EnsureQuaternionContinuity();

                var xCurve = GetRotationCurve(clip, "m_LocalRotation.x");
                var yCurve = GetRotationCurve(clip, "m_LocalRotation.y");
                var zCurve = GetRotationCurve(clip, "m_LocalRotation.z");
                var wCurve = GetRotationCurve(clip, "m_LocalRotation.w");
                AssertCurveIsC2(xCurve);
                AssertCurveIsC2(yCurve);
                AssertCurveIsC2(zCurve);
                AssertCurveIsC2(wCurve);

                var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                    times,
                    rotations,
                    xCurve,
                    yCurve,
                    zCurve,
                    wCurve,
                    AICameraTimelinePreviewImporter
                        .GetCurveSimplificationSettings(
                            AICameraTimelinePreviewImporter
                                .CurveSimplificationPreset.Balanced)
                        .QuaternionAngleErrorDegrees);
                Assert.That(audit.Passed, Is.True, audit.FailureReason);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(clip);
            }
        }

        [Test]
        public void Crossing180WithSignFlipsStaysC2AndSamplesWithoutNearZero()
        {
            const int count = 241;
            var times = new double[count];
            var rotations = new Quaternion[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60f;
                times[index] = time;
                var rotation = Quaternion.Euler(
                    1.5f * Mathf.Sin(time * 0.4f),
                    170f + 10f * time,
                    0.5f * Mathf.Sin(time * 0.7f));
                rotations[index] = index % 7 == 0
                    ? new Quaternion(
                        -rotation.x,
                        -rotation.y,
                        -rotation.z,
                        -rotation.w)
                    : rotation;
            }

            var simplified = CreateRotationOnlySimplifiedCurves(
                times,
                rotations,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
            var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
                simplified,
                AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
            var clip = new AnimationClip();
            var target = new GameObject("QuaternionSampleTarget");
            try
            {
                SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve);
                SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve);
                SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve);
                SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve);
                clip.EnsureQuaternionContinuity();

                var xCurve = GetRotationCurve(clip, "m_LocalRotation.x");
                var yCurve = GetRotationCurve(clip, "m_LocalRotation.y");
                var zCurve = GetRotationCurve(clip, "m_LocalRotation.z");
                var wCurve = GetRotationCurve(clip, "m_LocalRotation.w");
                AssertCurveIsC2(xCurve);
                AssertCurveIsC2(yCurve);
                AssertCurveIsC2(zCurve);
                AssertCurveIsC2(wCurve);

                var previous = Quaternion.identity;
                var auditTimes = AICameraTimelinePreviewImporter
                    .BuildUniformCurveAuditTimes(times, 240);
                for (var index = 0; index < auditTimes.Count; index++)
                {
                    var time = (float)auditTimes[index];
                    var raw = new Quaternion(
                        xCurve.Evaluate(time),
                        yCurve.Evaluate(time),
                        zCurve.Evaluate(time),
                        wCurve.Evaluate(time));
                    var magnitude = Mathf.Sqrt(
                        raw.x * raw.x +
                        raw.y * raw.y +
                        raw.z * raw.z +
                        raw.w * raw.w);
                    Assert.That(
                        magnitude,
                        Is.GreaterThan(0.9f),
                        $"Quaternion curve approached zero at {time:F4} s.");
                    var expected = Normalize(raw);
                    if (index > 0 && Quaternion.Dot(previous, expected) < 0f)
                    {
                        expected = new Quaternion(
                            -expected.x,
                            -expected.y,
                            -expected.z,
                            -expected.w);
                    }

                    clip.SampleAnimation(target, time);
                    Assert.That(
                        Quaternion.Angle(expected, target.transform.localRotation),
                        Is.LessThan(0.1f),
                        $"SampleAnimation diverged at {time:F4} s.");
                    previous = expected;
                }

                var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                    times,
                    rotations,
                    xCurve,
                    yCurve,
                    zCurve,
                    wCurve,
                    AICameraTimelinePreviewImporter
                        .GetCurveSimplificationSettings(
                            AICameraTimelinePreviewImporter
                                .CurveSimplificationPreset.Balanced)
                        .QuaternionAngleErrorDegrees);
                Assert.That(audit.Passed, Is.True, audit.FailureReason);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(target);
                UnityEngine.Object.DestroyImmediate(clip);
            }
        }

        [Test]
        public void RotationAuditRejectsNearZeroQuaternionCurve()
        {
            var times = new[] { 0.0, 1.0 };
            var rotations = new[] { Quaternion.identity, Quaternion.identity };
            var zero = AnimationCurve.Linear(0f, 0f, 1f, 0f);

            var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
                times,
                rotations,
                zero,
                zero,
                zero,
                zero,
                180f);

            Assert.That(audit.Passed, Is.False);
            Assert.That(audit.FailureReason, Does.Contain("zero quaternion"));
        }

        private static Vector3[] EvaluatePositionCurves(
            IReadOnlyList<double> times,
            AICameraTimelinePreviewImporter.PositionCurveBuildResult curves)
        {
            var result = new Vector3[times.Count];
            for (var index = 0; index < times.Count; index++)
            {
                var time = (float)times[index];
                result[index] = new Vector3(
                    curves.XCurve.Evaluate(time),
                    curves.YCurve.Evaluate(time),
                    curves.ZCurve.Evaluate(time));
            }

            return result;
        }

        private static float MeasureMaximumJerk(
            IReadOnlyList<double> times,
            IReadOnlyList<Vector3> positions)
        {
            if (times.Count < 4)
            {
                return 0f;
            }

            var velocities = new Vector3[times.Count - 1];
            var velocityTimes = new double[velocities.Length];
            for (var index = 0; index < velocities.Length; index++)
            {
                velocities[index] =
                    (positions[index + 1] - positions[index]) /
                    (float)(times[index + 1] - times[index]);
                velocityTimes[index] = (times[index + 1] + times[index]) * 0.5;
            }

            var accelerations = new Vector3[velocities.Length - 1];
            var accelerationTimes = new double[accelerations.Length];
            for (var index = 0; index < accelerations.Length; index++)
            {
                accelerations[index] =
                    (velocities[index + 1] - velocities[index]) /
                    (float)(velocityTimes[index + 1] - velocityTimes[index]);
                accelerationTimes[index] =
                    (velocityTimes[index + 1] + velocityTimes[index]) * 0.5;
            }

            var maximumJerk = 0f;
            for (var index = 0; index < accelerations.Length - 1; index++)
            {
                var jerk =
                    (accelerations[index + 1] - accelerations[index]) /
                    (float)(accelerationTimes[index + 1] -
                            accelerationTimes[index]);
                maximumJerk = Mathf.Max(maximumJerk, jerk.magnitude);
            }

            return maximumJerk;
        }

        private static double[] CreateUniformTimes(
            float startTime,
            float endTime,
            int samplesPerSecond)
        {
            var intervalCount = Mathf.Max(
                1,
                Mathf.CeilToInt((endTime - startTime) * samplesPerSecond));
            var times = new double[intervalCount + 1];
            for (var index = 0; index <= intervalCount; index++)
            {
                times[index] = (float)Mathf.Lerp(
                    startTime,
                    endTime,
                    index / (float)intervalCount);
            }

            return times;
        }

        private static Vector3[] EvaluateScalarCurveAsPositions(
            AnimationCurve curve,
            float startTime,
            float endTime,
            int samplesPerSecond)
        {
            var times = CreateUniformTimes(
                startTime,
                endTime,
                samplesPerSecond);
            var positions = new Vector3[times.Length];
            for (var index = 0; index < times.Length; index++)
            {
                positions[index] = new Vector3(
                    curve.Evaluate((float)times[index]),
                    0f,
                    0f);
            }

            return positions;
        }

        private static CameraSamples CreateSamples(int count)
        {
            var times = new double[count];
            var positions = new Vector3[count];
            var rotations = new Quaternion[count];
            var fieldOfView = new float[count];
            var dutch = new float[count];
            for (var index = 0; index < count; index++)
            {
                var time = index / 60.0;
                times[index] = time;
                positions[index] = new Vector3(
                    1.2f * Mathf.Sin((float)time * 0.8f),
                    1.6f + 0.25f * Mathf.Cos((float)time * 1.1f),
                    -3.2f + 0.7f * Mathf.Sin((float)time * 0.45f));
                var rotation = Quaternion.Euler(
                    8f * Mathf.Sin((float)time * 0.7f),
                    35f * Mathf.Sin((float)time * 0.35f),
                    3f * Mathf.Cos((float)time * 0.9f));
                rotations[index] = index % 11 == 0
                    ? new Quaternion(
                        -rotation.x,
                        -rotation.y,
                        -rotation.z,
                        -rotation.w)
                    : rotation;
                fieldOfView[index] =
                    42f + 7f * Mathf.Sin((float)time * 0.55f);
                dutch[index] =
                    2.5f * Mathf.Cos((float)time * 0.75f);
            }

            return new CameraSamples(
                times,
                positions,
                rotations,
                fieldOfView,
                dutch);
        }

        private static void AssertVectorError(
            AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
            float maximumError)
        {
            ForEverySegmentSample(
                result.Times,
                result.PositionIndices,
                (sample, left, right, blend) =>
                {
                    var reconstructed = Vector3.LerpUnclamped(
                        result.Positions[left],
                        result.Positions[right],
                        blend);
                    Assert.That(
                        Vector3.Distance(result.Positions[sample], reconstructed),
                        Is.LessThanOrEqualTo(maximumError),
                        $"Position error exceeded at sample {sample}.");
                });
        }

        private static void AssertQuaternionError(
            AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
            float maximumError)
        {
            ForEverySegmentSample(
                result.Times,
                result.RotationIndices,
                (sample, left, right, blend) =>
                {
                    var reconstructed = Normalize(new Quaternion(
                        Mathf.LerpUnclamped(
                            result.Rotations[left].x,
                            result.Rotations[right].x,
                            blend),
                        Mathf.LerpUnclamped(
                            result.Rotations[left].y,
                            result.Rotations[right].y,
                            blend),
                        Mathf.LerpUnclamped(
                            result.Rotations[left].z,
                            result.Rotations[right].z,
                            blend),
                        Mathf.LerpUnclamped(
                            result.Rotations[left].w,
                            result.Rotations[right].w,
                            blend)));
                    Assert.That(
                        Quaternion.Angle(result.Rotations[sample], reconstructed),
                        Is.LessThanOrEqualTo(maximumError),
                        $"Quaternion angle error exceeded at sample {sample}.");
                });
        }

        private static void AssertScalarError(
            IReadOnlyList<double> times,
            IReadOnlyList<float> values,
            IReadOnlyList<int> indices,
            float maximumError,
            string label)
        {
            ForEverySegmentSample(
                times,
                indices,
                (sample, left, right, blend) =>
                {
                    var reconstructed = Mathf.LerpUnclamped(
                        values[left],
                        values[right],
                        blend);
                    Assert.That(
                        Mathf.Abs(values[sample] - reconstructed),
                        Is.LessThanOrEqualTo(maximumError),
                        $"{label} error exceeded at sample {sample}.");
                });
        }

        private static void ForEverySegmentSample(
            IReadOnlyList<double> times,
            IReadOnlyList<int> indices,
            Action<int, int, int, float> assertion)
        {
            for (var segmentIndex = 1;
                 segmentIndex < indices.Count;
                 segmentIndex++)
            {
                var left = indices[segmentIndex - 1];
                var right = indices[segmentIndex];
                var duration = times[right] - times[left];
                for (var sample = left; sample <= right; sample++)
                {
                    var blend = duration > double.Epsilon
                        ? (float)((times[sample] - times[left]) / duration)
                        : 0f;
                    assertion(sample, left, right, blend);
                }
            }
        }

        private static void AssertEndpoints(IReadOnlyCollection<int> indices, int count)
        {
            Assert.That(indices, Does.Contain(0));
            Assert.That(indices, Does.Contain(count - 1));
        }

        private static void AssertExtrema(
            IReadOnlyCollection<int> indices,
            IReadOnlyList<float> values)
        {
            var minimum = Enumerable.Range(0, values.Count)
                .OrderBy(index => values[index])
                .First();
            var maximum = Enumerable.Range(0, values.Count)
                .OrderByDescending(index => values[index])
                .First();
            Assert.That(indices, Does.Contain(minimum));
            Assert.That(indices, Does.Contain(maximum));
        }

        private static Quaternion Normalize(Quaternion value)
        {
            var magnitude = Mathf.Sqrt(
                value.x * value.x +
                value.y * value.y +
                value.z * value.z +
                value.w * value.w);
            return new Quaternion(
                value.x / magnitude,
                value.y / magnitude,
                value.z / magnitude,
                value.w / magnitude);
        }

        private readonly struct CameraSamples
        {
            public CameraSamples(
                double[] times,
                Vector3[] positions,
                Quaternion[] rotations,
                float[] fieldOfView,
                float[] dutch)
            {
                Times = times;
                Positions = positions;
                Rotations = rotations;
                FieldOfView = fieldOfView;
                Dutch = dutch;
            }

            public double[] Times { get; }
            public Vector3[] Positions { get; }
            public Quaternion[] Rotations { get; }
            public float[] FieldOfView { get; }
            public float[] Dutch { get; }
        }
    }
}
