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

namespace Streamingle.Gaze.Tests
{
    public sealed class BlendshapeGazeDriverTests
    {
        [Test]
        public void Driver_OverridesBlendsAndRestoresExternalFacialWeight()
        {
            var root = new GameObject("GazeDriverTest");
            var originObject = new GameObject("Origin");
            var mesh = CreateBlendShapeMesh("eyeLookUpLeft");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 20f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookUpLeft")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;

                driver.SetRuntimeTargetPosition(Vector3.forward, 0.5f);
                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(50f).Within(0.001f));

                // Simulate the facial Animator/receiver writing a new base before LateUpdate.
                renderer.SetBlendShapeWeight(0, 40f);
                Assert.That(driver.RebuildCache(), Is.True);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.001f));
                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(60f).Within(0.001f));

                driver.ClearRuntimeTarget();
                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.001f));
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(root);
                UnityEngine.Object.DestroyImmediate(mesh);
                UnityEngine.Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void Driver_IgnoresNonFiniteInfluenceWithoutWritingBlendshapes()
        {
            var root = new GameObject("GazeNonFiniteInfluenceTest");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 23f);
                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.SetRuntimeTargetPosition(Vector3.forward * 10f, float.NaN);
                driver.EvaluateNow(true);

                Assert.That(driver.HasActiveRequest, Is.False);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(23f).Within(0.001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void Driver_AddsCorrectiveDeltaWithoutErasingExistingBlink()
        {
            var root = new GameObject("GazeCorrectiveTest");
            var originObject = new GameObject("Origin");
            var mesh = CreateBlendShapeMesh("eyeLookUpLeft", "eyeBlinkLeft");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 10f);
                renderer.SetBlendShapeWeight(1, 50f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookUpLeft"),
                    new GazeBlendShapeChannel("eyeBlinkLeft", GazeChannelUsage.Corrective)
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f, 30f });
                profile.SetSample(GazeCalibrationPoint.Center, new[] { 80f, 20f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                var upTarget = GazeDirectionSolver.DirectionFromAngles(0f, profile.MaxPitch);

                driver.SetRuntimeTargetPosition(upTarget, 1f, true);
                driver.EvaluateNow(true);

                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(80f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(60f).Within(0.001f));

                renderer.SetBlendShapeWeight(1, 70f);
                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(80f).Within(0.001f));

                driver.ClearRuntimeTarget();
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(70f).Within(0.001f));
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(root);
                UnityEngine.Object.DestroyImmediate(mesh);
                UnityEngine.Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void Driver_UsesAnimatedGazeOriginRotationBeforeHeadReferenceFallback()
        {
            var root = new GameObject("AnimatedGazeOriginTest");
            var animatedHead = new GameObject("Head");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                animatedHead.transform.SetParent(root.transform, false);
                originObject.transform.SetParent(animatedHead.transform, false);

                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 20f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                {
                    var column = index % 3;
                    profile.SetSample(
                        (GazeCalibrationPoint)index,
                        new[] { column == 0 ? 0f : column == 1 ? 50f : 100f });
                }

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                // Deliberately keep this fallback fixed. Gaze Origin must win.
                driver.HeadReference = root.transform;
                driver.SetRuntimeTargetPosition(Vector3.forward * 10f, 1f);

                driver.EvaluateNow(true);
                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(0f).Within(0.01f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(50f).Within(0.001f));

                animatedHead.transform.rotation = Quaternion.Euler(0f, 17.5f, 0f);
                driver.EvaluateNow(true);

                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(-17.5f).Within(0.01f));
                Assert.That(driver.LastDirection.NormalizedYaw, Is.EqualTo(-0.5f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(25f).Within(0.001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void Driver_ResolvesBlendedTargetDirectionFromLatestOriginPosition()
        {
            var root = new GameObject("LatestOriginDirectionTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                {
                    var column = index % 3;
                    profile.SetSample(
                        (GazeCalibrationPoint)index,
                        new[] { column == 0 ? 0f : column == 1 ? 50f : 100f });
                }

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                var direction = GazeDirectionSolver.DirectionFromAngles(profile.MaxYaw, 0f);
                driver.SetTimelineState(7119, new GazeTimelineState
                {
                    TargetDirection = direction,
                    HasTargetDirection = true,
                    Influence = 1f
                });

                // Simulate an Animation Track moving the head after the gaze mixer
                // submitted its blended direction.
                originObject.transform.position = new Vector3(12f, 3f, -7f);
                driver.EvaluateNow(true);

                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(profile.MaxYaw).Within(0.01f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(100f).Within(0.001f));
                Assert.That(
                    Vector3.Distance(
                        driver.LastTargetPosition,
                        originObject.transform.position + direction.normalized),
                    Is.LessThan(0.0001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void Driver_UsesLowestSourceIdWhenTimelineInfluencesAreEqual()
        {
            var root = new GameObject("GazeTimelineTieBreakTest");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                {
                    var column = index % 3;
                    profile.SetSample(
                        (GazeCalibrationPoint)index,
                        new[] { column == 0 ? 0f : column == 1 ? 50f : 100f });
                }

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.SetTimelineState(20, new GazeTimelineState
                {
                    TargetDirection = GazeDirectionSolver.DirectionFromAngles(profile.MaxYaw, 0f),
                    HasTargetDirection = true,
                    Influence = 1f
                });
                driver.SetTimelineState(10, new GazeTimelineState
                {
                    TargetDirection = GazeDirectionSolver.DirectionFromAngles(profile.MinYaw, 0f),
                    HasTargetDirection = true,
                    Influence = 1f
                });

                driver.EvaluateNow(true);

                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(profile.MinYaw).Within(0.01f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(0f).Within(0.001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void EditorFinalPass_ComposesOverLatestFacialWrite()
        {
            var root = new GameObject("GazeEditorFinalPassTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("eyeLookUpLeft", "unrelatedBlink");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 20f);
                renderer.SetBlendShapeWeight(1, 55f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookUpLeft")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                const int sourceId = 8127;
                driver.SetTimelineState(sourceId, new GazeTimelineState
                {
                    TargetPosition = Vector3.forward * 10f,
                    HasTargetPosition = true,
                    Influence = 0.5f
                });

                // Timeline submits gaze first. The Animation/Facial output writes
                // later in the same editor evaluation, before the queued final pass.
                BlendshapeGazeMixerBehaviour.ScheduleEditorFinalEvaluation(driver);
                root.transform.rotation = Quaternion.Euler(0f, 17.5f, 0f);
                renderer.SetBlendShapeWeight(0, 40f);
                renderer.SetBlendShapeWeight(1, 70f);
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(-17.5f).Within(0.01f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(60f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(70f).Within(0.001f));

                BlendshapeGazeMixerBehaviour.ScheduleEditorFinalEvaluation(driver);
                renderer.SetBlendShapeWeight(0, 10f);
                renderer.SetBlendShapeWeight(1, 35f);
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(45f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(35f).Within(0.001f));

                driver.ClearTimelineState(sourceId);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(10f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(35f).Within(0.001f));
            }
            finally
            {
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void EditorLateUpdate_ReappliesAfterAFacialWriteWithoutAnotherTimelineFrame()
        {
            var root = new GameObject("GazeEditorLateUpdateTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 20f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                const int sourceId = 9231;
                driver.SetTimelineState(sourceId, new GazeTimelineState
                {
                    TargetPosition = Vector3.forward * 10f,
                    HasTargetPosition = true,
                    Influence = 0.5f
                });

                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(50f).Within(0.001f));

                // A later editor writer overwrites gaze without causing another
                // Timeline ProcessFrame. ExecuteAlways LateUpdate must still win.
                renderer.SetBlendShapeWeight(0, 10f);
                originObject.transform.rotation = Quaternion.Euler(0f, 17.5f, 0f);
                InvokePrivateLateUpdate(driver);

                Assert.That(driver.LastDirection.RawYaw, Is.EqualTo(-17.5f).Within(0.01f));
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(45f).Within(0.001f));

                driver.ClearTimelineState(sourceId);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(10f).Within(0.001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void TimelinePreparation_CapturesEqualFacialValueWithoutFeedback()
        {
            var root = new GameObject("GazeEditorBaselineCollisionTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("animatedEye", "unanimatedEye");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 80f);
                renderer.SetBlendShapeWeight(1, 20f);

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("animatedEye"),
                    new GazeBlendShapeChannel("unanimatedEye")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 0f, 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                const int sourceId = 9232;
                driver.SetTimelineState(sourceId, new GazeTimelineState
                {
                    TargetPosition = Vector3.forward * 10f,
                    HasTargetPosition = true,
                    Influence = 0.5f
                });

                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(50f).Within(0.001f));

                driver.PrepareForTimelineEvaluation();
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(80f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(20f).Within(0.001f));

                // The next facial sample happens to equal the previous gaze result.
                renderer.SetBlendShapeWeight(0, 40f);
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 100f, 80f });
                driver.EvaluateNow(true);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(70f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(50f).Within(0.001f));

                for (var iteration = 0; iteration < 5; iteration++)
                {
                    driver.PrepareForTimelineEvaluation();
                    renderer.SetBlendShapeWeight(0, 40f);
                    driver.EvaluateNow(true);
                    Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(70f).Within(0.001f));
                    Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(50f).Within(0.001f));
                }

                driver.ClearTimelineState(sourceId);
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.001f));
                Assert.That(renderer.GetBlendShapeWeight(1), Is.EqualTo(20f).Within(0.001f));
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void DisablingDriver_CancelsQueuedEditorFinalEvaluation()
        {
            var root = new GameObject("GazeEditorCancellationTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var mesh = CreateBlendShapeMesh("eyeLookHorizontal");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();

            try
            {
                originObject.transform.SetParent(root.transform, false);
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 23f);
                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookHorizontal")
                });
                for (var index = 0; index < 9; index++)
                    profile.SetSample((GazeCalibrationPoint)index, new[] { 80f });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;
                driver.SetTimelineState(9233, new GazeTimelineState
                {
                    TargetPosition = Vector3.forward * 10f,
                    HasTargetPosition = true,
                    Influence = 1f
                });

                BlendshapeGazeMixerBehaviour.ScheduleEditorFinalEvaluation(driver);
                driver.enabled = false;
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();

                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(23f).Within(0.001f));
                Assert.That(driver.HasActiveRequest, Is.False);
            }
            finally
            {
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
            }
        }

        [Test]
        public void GatherProperties_RegistersResolvedBlendShapeAsAnimationBinding()
        {
            var root = new GameObject("GazeGatherPropertiesTest");
            var mesh = CreateBlendShapeMesh("Face.eyeLookUpLeft");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();
            var track = ScriptableObject.CreateInstance<BlendshapeGazeTrack>();

            try
            {
                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 37f);
                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("eyeLookUpLeft")
                });

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                var director = root.AddComponent<PlayableDirector>();
                director.SetGenericBinding(track, driver);
                var collector = new RecordingPropertyCollector();

                track.GatherProperties(director, collector);

                Assert.That(collector.CurveProperties, Has.Count.EqualTo(1));
                Assert.That(collector.CurveProperties[0].Root, Is.SameAs(root));
                Assert.That(collector.CurveProperties[0].Binding.path, Is.Empty);
                Assert.That(collector.CurveProperties[0].Binding.type, Is.EqualTo(typeof(SkinnedMeshRenderer)));
                Assert.That(
                    collector.CurveProperties[0].Binding.propertyName,
                    Is.EqualTo("blendShape.Face.eyeLookUpLeft"));

                // Exercise Timeline's real internal PropertyCollector. Unexpected
                // DrivenPropertyManager errors fail the Unity test automatically.
                // Use Timeline's own preview driver, while preserving any session
                // already owned by an open Timeline/Animation window.
                var previewDriver = GetTimelinePreviewDriver();
                if (!AnimationMode.InAnimationMode())
                {
                    var timelineCollector = CreateTimelinePropertyCollector();
                    AnimationMode.StartAnimationMode(previewDriver);
                    try
                    {
                        track.GatherProperties(director, timelineCollector);
                        renderer.SetBlendShapeWeight(0, 80f);
                    }
                    finally
                    {
                        AnimationMode.StopAnimationMode(previewDriver);
                    }

                    Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(37f).Within(0.001f));
                }
            }
            finally
            {
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
                Object.DestroyImmediate(track);
            }
        }

        [Test]
        public void ManualTimelineEvaluation_ComposesFacialAnimationAcrossRepeatedScrubs()
        {
            RunManualTimelineBaselineCollision(gazeTrackFirst: false);
            RunManualTimelineBaselineCollision(gazeTrackFirst: true);
        }

        private static void RunManualTimelineBaselineCollision(bool gazeTrackFirst)
        {
            var root = new GameObject(gazeTrackFirst
                ? "GazeFirstManualTimelineTest"
                : "AnimationFirstManualTimelineTest");
            var originObject = new GameObject("Face_GazeOrigin");
            var leftTarget = new GameObject("LeftTarget");
            var rightTarget = new GameObject("RightTarget");
            var mesh = CreateBlendShapeMesh("animatedEye");
            var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var facialClip = new AnimationClip { name = "FacialBaseline" };

            try
            {
                originObject.transform.SetParent(root.transform, false);
                leftTarget.transform.SetParent(root.transform, false);
                rightTarget.transform.SetParent(root.transform, false);
                leftTarget.transform.position =
                    GazeDirectionSolver.DirectionFromAngles(profile.MinYaw, 0f) * 10f;
                rightTarget.transform.position =
                    GazeDirectionSolver.DirectionFromAngles(profile.MaxYaw, 0f) * 10f;

                var renderer = root.AddComponent<SkinnedMeshRenderer>();
                renderer.sharedMesh = mesh;
                renderer.SetBlendShapeWeight(0, 80f);
                var animator = root.AddComponent<Animator>();
                animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;

                profile.ReplaceChannels(new[]
                {
                    new GazeBlendShapeChannel("animatedEye")
                });
                for (var index = 0; index < 9; index++)
                {
                    var column = index % 3;
                    profile.SetSample(
                        (GazeCalibrationPoint)index,
                        new[] { column == 0 ? 0f : column == 1 ? 50f : 100f });
                }

                var driver = root.AddComponent<BlendshapeGazeDriver>();
                driver.TargetRenderer = renderer;
                driver.Profile = profile;
                driver.GazeOrigin = originObject.transform;

                AnimationTrack animationTrack;
                BlendshapeGazeTrack gazeTrack;
                if (gazeTrackFirst)
                {
                    gazeTrack = timeline.CreateTrack<BlendshapeGazeTrack>(null, "Gaze");
                    animationTrack = timeline.CreateTrack<AnimationTrack>(null, "Facial");
                }
                else
                {
                    animationTrack = timeline.CreateTrack<AnimationTrack>(null, "Facial");
                    gazeTrack = timeline.CreateTrack<BlendshapeGazeTrack>(null, "Gaze");
                }

                facialClip.SetCurve(
                    string.Empty,
                    typeof(SkinnedMeshRenderer),
                    "blendShape.animatedEye",
                    new AnimationCurve(
                        new Keyframe(0f, 80f),
                        new Keyframe(0.25f, 80f),
                        new Keyframe(0.75f, 40f),
                        new Keyframe(1f, 40f)));
                var facialTimelineClip = animationTrack.CreateClip(facialClip);
                facialTimelineClip.start = 0d;
                facialTimelineClip.duration = 1d;

                var leftClip = gazeTrack.CreateClip<BlendshapeGazeClip>();
                leftClip.start = 0d;
                leftClip.duration = 0.5d;
                var leftAsset = (BlendshapeGazeClip)leftClip.asset;
                leftAsset.Target = new ExposedReference<Transform>
                {
                    defaultValue = leftTarget.transform
                };
                leftAsset.Strength = 0.5f;

                var rightClip = gazeTrack.CreateClip<BlendshapeGazeClip>();
                rightClip.start = 0.5d;
                rightClip.duration = 0.5d;
                var rightAsset = (BlendshapeGazeClip)rightClip.asset;
                rightAsset.Target = new ExposedReference<Transform>
                {
                    defaultValue = rightTarget.transform
                };
                rightAsset.Strength = 0.5f;

                var director = root.AddComponent<PlayableDirector>();
                director.timeUpdateMode = DirectorUpdateMode.Manual;
                director.playableAsset = timeline;
                director.SetGenericBinding(animationTrack, animator);
                director.SetGenericBinding(gazeTrack, driver);

                director.time = 0.25d;
                director.Evaluate();
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.01f));

                director.time = 0.75d;
                director.Evaluate();
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(70f).Within(0.01f));

                for (var iteration = 0; iteration < 5; iteration++)
                {
                    director.Evaluate();
                    BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                    Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(70f).Within(0.01f));
                }

                director.time = 0.25d;
                director.Evaluate();
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Assert.That(renderer.GetBlendShapeWeight(0), Is.EqualTo(40f).Within(0.01f));
            }
            finally
            {
                BlendshapeGazeMixerBehaviour.FlushPendingEditorEvaluations();
                Object.DestroyImmediate(root);
                Object.DestroyImmediate(mesh);
                Object.DestroyImmediate(profile);
                Object.DestroyImmediate(facialClip);
                Object.DestroyImmediate(timeline);
            }
        }

        private static void InvokePrivateLateUpdate(BlendshapeGazeDriver driver)
        {
            var method = typeof(BlendshapeGazeDriver).GetMethod(
                "LateUpdate",
                BindingFlags.Instance | BindingFlags.NonPublic);
            Assert.That(method, Is.Not.Null);
            method.Invoke(driver, null);
        }

        private static IPropertyCollector CreateTimelinePropertyCollector()
        {
            foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
            {
                var type = assembly.GetType("UnityEditor.Timeline.PropertyCollector");
                if (type != null)
                    return (IPropertyCollector)System.Activator.CreateInstance(type, true);
            }

            Assert.Fail("Unity Timeline PropertyCollector type was not loaded.");
            return null;
        }

        private static AnimationModeDriver GetTimelinePreviewDriver()
        {
            foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
            {
                var type = assembly.GetType("UnityEditor.Timeline.WindowState");
                var property = type?.GetProperty(
                    "previewDriver",
                    BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
                if (property?.GetValue(null) is AnimationModeDriver previewDriver)
                    return previewDriver;
            }

            Assert.Fail("Unity Timeline preview driver was not loaded.");
            return null;
        }

        private sealed class RecordingPropertyCollector : IPropertyCollector
        {
            internal readonly List<CurveProperty> CurveProperties =
                new List<CurveProperty>();

            public void PushActiveGameObject(GameObject gameObject) { }
            public void PopActiveGameObject() { }
            public void AddFromClip(AnimationClip clip) { }
            public void AddFromClips(IEnumerable<AnimationClip> clips) { }
            public void AddFromName<T>(string name) where T : Component { }
            public void AddFromName(string name) { }
            public void AddFromClip(GameObject obj, AnimationClip clip)
            {
                var bindings = AnimationUtility.GetCurveBindings(clip);
                for (var index = 0; index < bindings.Length; index++)
                    CurveProperties.Add(new CurveProperty(obj, bindings[index]));
            }
            public void AddFromClips(GameObject obj, IEnumerable<AnimationClip> clips) { }
            public void AddFromName<T>(GameObject obj, string name) where T : Component { }
            public void AddFromName(GameObject obj, string name) { }

            public void AddFromName(Component component, string name) { }

            public void AddFromComponent(GameObject obj, Component component) { }
            public void AddObjectProperties(Object obj, AnimationClip clip) { }
        }

        private readonly struct CurveProperty
        {
            internal CurveProperty(GameObject root, EditorCurveBinding binding)
            {
                Root = root;
                Binding = binding;
            }

            internal GameObject Root { get; }
            internal EditorCurveBinding Binding { get; }
        }

        private static Mesh CreateBlendShapeMesh(params string[] blendShapeNames)
        {
            var mesh = new Mesh { name = "GazeDriverTestMesh" };
            mesh.vertices = new[]
            {
                new Vector3(-0.1f, 0f, 0f),
                new Vector3(0.1f, 0f, 0f),
                new Vector3(0f, 0.1f, 0f)
            };
            mesh.triangles = new[] { 0, 1, 2 };
            for (var index = 0; index < blendShapeNames.Length; index++)
            {
                mesh.AddBlendShapeFrame(
                    blendShapeNames[index],
                    100f,
                    new[] { Vector3.up * 0.01f, Vector3.up * 0.01f, Vector3.up * 0.01f },
                    new Vector3[3],
                    new Vector3[3]);
            }
            return mesh;
        }
    }
}
