using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using UnityEngine.Timeline;
using Object = UnityEngine.Object;

namespace Streamingle.Editor.Tests
{
    public sealed class PreviewCloneActivationProbe : MonoBehaviour
    {
        internal static int EnableCount { get; set; }

        private void OnEnable()
        {
            EnableCount++;
        }
    }

    public sealed class AICameraTimelinePreviewImporterTests
    {
        [Test]
        public void BrainResolutionUsesOnlySceneBrainWithoutCinemachineTrack()
        {
            var directorObject = new GameObject("Director");
            var cameraObject = new GameObject("Render Camera");
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                cameraObject.AddComponent<Camera>();
                var brain = cameraObject.AddComponent<CinemachineBrain>();

                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { brain }),
                    Is.SameAs(brain));
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .GetPreviewOutputStatusForEditor(brain),
                    Does.Contain("are active"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionPrefersUniqueMainCamera()
        {
            var directorObject = new GameObject("Director");
            var mainCameraObject = new GameObject("Main Camera");
            var secondaryCameraObject = new GameObject("Secondary Camera");
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                mainCameraObject.tag = "MainCamera";
                mainCameraObject.AddComponent<Camera>();
                var mainBrain = mainCameraObject.AddComponent<CinemachineBrain>();
                secondaryCameraObject.AddComponent<Camera>();
                var secondaryBrain =
                    secondaryCameraObject.AddComponent<CinemachineBrain>();

                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { secondaryBrain, mainBrain }),
                    Is.SameAs(mainBrain));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(mainCameraObject);
                Object.DestroyImmediate(secondaryCameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionRejectsAmbiguousSceneBrains()
        {
            var directorObject = new GameObject("Director");
            var firstCameraObject = new GameObject("First Camera");
            var secondCameraObject = new GameObject("Second Camera");
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                firstCameraObject.AddComponent<Camera>();
                var firstBrain = firstCameraObject.AddComponent<CinemachineBrain>();
                secondCameraObject.AddComponent<Camera>();
                var secondBrain = secondCameraObject.AddComponent<CinemachineBrain>();

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ResolveCinemachineBrainForEditor(
                            director,
                            timeline,
                            new[] { firstBrain, secondBrain }),
                    Throws.TypeOf<InvalidOperationException>()
                        .With.Message.Contains("Multiple enabled CinemachineBrain"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(firstCameraObject);
                Object.DestroyImmediate(secondCameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionUsesDisabledUniqueDescendantBinding()
        {
            var directorObject = new GameObject("Director");
            var childDirectorObject = new GameObject("Camera Director");
            var cameraObject = new GameObject("Configured Camera");
            childDirectorObject.transform.SetParent(directorObject.transform, false);
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            TimelineAsset childTimeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var childDirector =
                    childDirectorObject.AddComponent<PlayableDirector>();
                cameraObject.AddComponent<Camera>();
                var brain = cameraObject.AddComponent<CinemachineBrain>();
                childTimeline = CreateTimelineBoundToBrain(
                    childDirector,
                    brain,
                    "Child Camera Track");
                childDirectorObject.SetActive(false);
                brain.enabled = false;

                LogAssert.Expect(
                    LogType.Warning,
                    "The CinemachineBrain explicitly bound to a Timeline or its " +
                    "Camera is disabled. Camera AI will use the configured Brain " +
                    "without enabling either component automatically.");
                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { brain }),
                    Is.SameAs(brain));
                Assert.That(brain.enabled, Is.False);
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .GetPreviewOutputStatusForEditor(brain),
                    Does.Contain("enable it to see AI camera playback"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(childTimeline);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionDeduplicatesSameBrainAcrossDescendants()
        {
            var directorObject = new GameObject("Director");
            var firstChildObject = new GameObject("First Camera Director");
            var secondChildObject = new GameObject("Second Camera Director");
            var cameraObject = new GameObject("Configured Camera");
            firstChildObject.transform.SetParent(directorObject.transform, false);
            secondChildObject.transform.SetParent(directorObject.transform, false);
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            TimelineAsset firstChildTimeline = null;
            TimelineAsset secondChildTimeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var firstChildDirector =
                    firstChildObject.AddComponent<PlayableDirector>();
                var secondChildDirector =
                    secondChildObject.AddComponent<PlayableDirector>();
                cameraObject.AddComponent<Camera>();
                var brain = cameraObject.AddComponent<CinemachineBrain>();
                firstChildTimeline = CreateTimelineBoundToBrain(
                    firstChildDirector,
                    brain,
                    "First Child Camera Track");
                secondChildTimeline = CreateTimelineBoundToBrain(
                    secondChildDirector,
                    brain,
                    "Second Child Camera Track");

                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        Array.Empty<CinemachineBrain>()),
                    Is.SameAs(brain));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(firstChildTimeline);
                Object.DestroyImmediate(secondChildTimeline);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionRejectsAmbiguousDescendantBindings()
        {
            var directorObject = new GameObject("Director");
            var firstChildObject = new GameObject("First Camera Director");
            var secondChildObject = new GameObject("Second Camera Director");
            var firstCameraObject = new GameObject("First Configured Camera");
            var secondCameraObject = new GameObject("Second Configured Camera");
            firstChildObject.transform.SetParent(directorObject.transform, false);
            secondChildObject.transform.SetParent(directorObject.transform, false);
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            TimelineAsset firstChildTimeline = null;
            TimelineAsset secondChildTimeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var firstChildDirector =
                    firstChildObject.AddComponent<PlayableDirector>();
                var secondChildDirector =
                    secondChildObject.AddComponent<PlayableDirector>();
                firstCameraObject.AddComponent<Camera>();
                var firstBrain =
                    firstCameraObject.AddComponent<CinemachineBrain>();
                secondCameraObject.AddComponent<Camera>();
                var secondBrain =
                    secondCameraObject.AddComponent<CinemachineBrain>();
                firstChildTimeline = CreateTimelineBoundToBrain(
                    firstChildDirector,
                    firstBrain,
                    "First Child Camera Track");
                secondChildTimeline = CreateTimelineBoundToBrain(
                    secondChildDirector,
                    secondBrain,
                    "Second Child Camera Track");

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ResolveCinemachineBrainForEditor(
                            director,
                            timeline,
                            new[] { firstBrain, secondBrain }),
                    Throws.TypeOf<InvalidOperationException>()
                        .With.Message.Contains("Descendant PlayableDirectors"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(firstCameraObject);
                Object.DestroyImmediate(secondCameraObject);
                Object.DestroyImmediate(firstChildTimeline);
                Object.DestroyImmediate(secondChildTimeline);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionPrefersDirectBindingOverDescendants()
        {
            var directorObject = new GameObject("Director");
            var childDirectorObject = new GameObject("Camera Director");
            var directCameraObject = new GameObject("Direct Camera");
            var childCameraObject = new GameObject("Child Camera");
            childDirectorObject.transform.SetParent(directorObject.transform, false);
            TimelineAsset timeline = null;
            TimelineAsset childTimeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                var childDirector =
                    childDirectorObject.AddComponent<PlayableDirector>();
                directCameraObject.AddComponent<Camera>();
                var directBrain =
                    directCameraObject.AddComponent<CinemachineBrain>();
                childCameraObject.AddComponent<Camera>();
                var childBrain =
                    childCameraObject.AddComponent<CinemachineBrain>();
                timeline = CreateTimelineBoundToBrain(
                    director,
                    directBrain,
                    "Direct Camera Track");
                childTimeline = CreateTimelineBoundToBrain(
                    childDirector,
                    childBrain,
                    "Child Camera Track");

                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { childBrain, directBrain }),
                    Is.SameAs(directBrain));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(directCameraObject);
                Object.DestroyImmediate(childCameraObject);
                Object.DestroyImmediate(childTimeline);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionRejectsExplicitBrainFromAnotherScene()
        {
            var directorObject = new GameObject("Director");
            var cameraObject = new GameObject("Foreign Camera");
            var foreignScene = EditorSceneManager.NewPreviewScene();
            SceneManager.MoveGameObjectToScene(cameraObject, foreignScene);
            TimelineAsset timeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                cameraObject.AddComponent<Camera>();
                var brain = cameraObject.AddComponent<CinemachineBrain>();
                timeline = CreateTimelineBoundToBrain(
                    director,
                    brain,
                    "Foreign Camera Track");

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ResolveCinemachineBrainForEditor(
                            director,
                            timeline,
                            new[] { brain }),
                    Throws.TypeOf<InvalidOperationException>()
                        .With.Message.Contains("same scene"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(timeline);
                EditorSceneManager.ClosePreviewScene(foreignScene);
            }
        }

        [Test]
        public void BrainResolutionRejectsExplicitBrainWithoutCamera()
        {
            var directorObject = new GameObject("Director");
            var cameraObject = new GameObject("Camera-less Brain");
            TimelineAsset timeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                var camera = cameraObject.AddComponent<Camera>();
                var brain = cameraObject.AddComponent<CinemachineBrain>();
                Object.DestroyImmediate(camera);
                timeline = CreateTimelineBoundToBrain(
                    director,
                    brain,
                    "Invalid Camera Track");

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ResolveCinemachineBrainForEditor(
                            director,
                            timeline,
                            new[] { brain }),
                    Throws.TypeOf<InvalidOperationException>()
                        .With.Message.Contains("Camera component"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionAcceptsSeparateControlledOutputCamera()
        {
            var directorObject = new GameObject("Director");
            var brainObject = new GameObject("Configured Brain");
            var outputCameraObject = new GameObject("Controlled Output Camera");
            TimelineAsset timeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                var brain = brainObject.AddComponent<CinemachineBrain>();
                var outputCamera = outputCameraObject.AddComponent<Camera>();
                brain.ControlledObject = outputCameraObject;
                timeline = CreateTimelineBoundToBrain(
                    director,
                    brain,
                    "Controlled Output Camera Track");

                Assert.That(brain.OutputCamera, Is.SameAs(outputCamera));
                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { brain }),
                    Is.SameAs(brain));
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .GetPreviewOutputStatusForEditor(brain),
                    Does.Contain(outputCameraObject.name));
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .GetPreviewOutputStatusForEditor(brain),
                    Does.Contain("are active"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(brainObject);
                Object.DestroyImmediate(outputCameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionUsesControlledOutputMainCameraForFallback()
        {
            var directorObject = new GameObject("Director");
            var mainBrainObject = new GameObject("Main Output Brain");
            var mainCameraObject = new GameObject("Main Output Camera");
            var secondaryCameraObject = new GameObject("Secondary Camera");
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var mainBrain = mainBrainObject.AddComponent<CinemachineBrain>();
                mainCameraObject.tag = "MainCamera";
                mainCameraObject.AddComponent<Camera>();
                mainBrain.ControlledObject = mainCameraObject;
                secondaryCameraObject.AddComponent<Camera>();
                var secondaryBrain =
                    secondaryCameraObject.AddComponent<CinemachineBrain>();

                Assert.That(
                    AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
                        director,
                        timeline,
                        new[] { secondaryBrain, mainBrain }),
                    Is.SameAs(mainBrain));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(mainBrainObject);
                Object.DestroyImmediate(mainCameraObject);
                Object.DestroyImmediate(secondaryCameraObject);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void BrainResolutionRejectsControlledOutputCameraFromAnotherScene()
        {
            var directorObject = new GameObject("Director");
            var brainObject = new GameObject("Configured Brain");
            var outputCameraObject = new GameObject("Foreign Output Camera");
            var foreignScene = EditorSceneManager.NewPreviewScene();
            SceneManager.MoveGameObjectToScene(outputCameraObject, foreignScene);
            TimelineAsset timeline = null;
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                var brain = brainObject.AddComponent<CinemachineBrain>();
                outputCameraObject.AddComponent<Camera>();
                brain.ControlledObject = outputCameraObject;
                timeline = CreateTimelineBoundToBrain(
                    director,
                    brain,
                    "Foreign Controlled Output Track");

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ResolveCinemachineBrainForEditor(
                            director,
                            timeline,
                            new[] { brain }),
                    Throws.TypeOf<InvalidOperationException>()
                        .With.Message.Contains("output Camera")
                        .And.Message.Contains("same scene"));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(brainObject);
                Object.DestroyImmediate(outputCameraObject);
                Object.DestroyImmediate(timeline);
                EditorSceneManager.ClosePreviewScene(foreignScene);
            }
        }

        [Test]
        public void InactiveStagingCloneNeverActivatesAndPreservesSourcePlayback()
        {
            var parentObject = new GameObject("Preview Parent");
            var sourceObject = new GameObject("Source Director");
            var motionObject = new GameObject("Motion Director");
            var authoredCameraObject = new GameObject("Authored Virtual Camera");
            sourceObject.transform.SetParent(parentObject.transform, false);
            motionObject.transform.SetParent(sourceObject.transform, false);
            authoredCameraObject.transform.SetParent(
                motionObject.transform,
                false);
            parentObject.transform.position = new Vector3(4f, -2f, 7f);
            sourceObject.transform.localPosition = new Vector3(1f, 2f, 3f);
            var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var motionTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            GameObject cloneObject = null;
            PlayableDirector motionDirector = null;
            try
            {
                var sourceDirector =
                    sourceObject.AddComponent<PlayableDirector>();
                sourceDirector.playOnAwake = false;
                sourceDirector.playableAsset = sourceTimeline;
                motionDirector = motionObject.AddComponent<PlayableDirector>();
                motionDirector.playOnAwake = true;
                motionDirector.timeUpdateMode = DirectorUpdateMode.Manual;
                motionDirector.playableAsset = motionTimeline;
                var authoredCamera = authoredCameraObject
                    .AddComponent<CinemachineCamera>();
                sourceObject.AddComponent<PreviewCloneActivationProbe>();

                motionDirector.Play();
                motionDirector.time = 0.4d;
                motionDirector.Evaluate();
                var sourceWorldPosition = sourceObject.transform.position;
                var sourcePlaybackState = motionDirector.state;
                PreviewCloneActivationProbe.EnableCount = 0;

                cloneObject = AICameraTimelinePreviewImporter
                    .InstantiateInactivePreviewCloneForEditor(
                        sourceObject,
                        parentObject.transform);
                var cloneMotionDirector = cloneObject.transform
                    .Find(motionObject.name)
                    .GetComponent<PlayableDirector>();
                var cloneAuthoredCamera = cloneObject.transform
                    .Find($"{motionObject.name}/{authoredCameraObject.name}")
                    .GetComponent<CinemachineCamera>();

                Assert.That(cloneObject.transform.parent, Is.SameAs(parentObject.transform));
                Assert.That(cloneObject.transform.position, Is.EqualTo(sourceWorldPosition));
                Assert.That(cloneObject.activeSelf, Is.False);
                Assert.That(cloneObject.activeInHierarchy, Is.False);
                Assert.That(PreviewCloneActivationProbe.EnableCount, Is.Zero);
                Assert.That(cloneMotionDirector.playOnAwake, Is.True);
                Assert.That(cloneMotionDirector.playableGraph.IsValid(), Is.False);
                Assert.That(cloneAuthoredCamera.enabled, Is.True);
                Assert.That(cloneAuthoredCamera.isActiveAndEnabled, Is.False);
                Assert.That(sourceObject.activeSelf, Is.True);
                Assert.That(sourceObject.activeInHierarchy, Is.True);
                Assert.That(motionDirector.state, Is.EqualTo(sourcePlaybackState));
                Assert.That(motionDirector.time, Is.EqualTo(0.4d).Within(0.000001d));
                Assert.That(authoredCamera.enabled, Is.True);
                Assert.That(authoredCamera.isActiveAndEnabled, Is.True);
            }
            finally
            {
                PreviewCloneActivationProbe.EnableCount = 0;
                if (motionDirector != null)
                {
                    motionDirector.Stop();
                }

                Object.DestroyImmediate(cloneObject);
                Object.DestroyImmediate(parentObject);
                Object.DestroyImmediate(motionTimeline);
                Object.DestroyImmediate(sourceTimeline);
            }
        }

        [Test]
        public void PreviewCloneSuppressesOnlyAuthoredCinemachineOutputs()
        {
            var sourceObject = new GameObject("Source Director");
            var motionObject = new GameObject("Motion Director");
            var authoredVirtualCameraObject =
                new GameObject("Authored Virtual Camera");
            var cameraObject = new GameObject("Authored Camera");
            motionObject.transform.SetParent(sourceObject.transform, false);
            authoredVirtualCameraObject.transform.SetParent(
                motionObject.transform,
                false);
            var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            TimelineAsset motionTimeline = null;
            GameObject previewObject = null;
            try
            {
                var sourceDirector =
                    sourceObject.AddComponent<PlayableDirector>();
                sourceDirector.playOnAwake = false;
                sourceDirector.playableAsset = sourceTimeline;
                var motionDirector =
                    motionObject.AddComponent<PlayableDirector>();
                motionDirector.playOnAwake = false;
                var motionAnimator = motionObject.AddComponent<Animator>();
                var authoredVirtualCamera = authoredVirtualCameraObject
                    .AddComponent<CinemachineCamera>();
                cameraObject.AddComponent<Camera>();
                var authoredBrain =
                    cameraObject.AddComponent<CinemachineBrain>();
                motionTimeline = CreateTimelineBoundToBrain(
                    motionDirector,
                    authoredBrain,
                    "Authored Camera Track");
                var motionTrack = motionTimeline.CreateTrack<AnimationTrack>(
                    null,
                    "Motion Track");
                motionDirector.SetGenericBinding(motionTrack, motionAnimator);

                previewObject = Object.Instantiate(sourceObject);
                var previewMotionObject =
                    previewObject.transform.Find(motionObject.name).gameObject;
                var previewMotionDirector =
                    previewMotionObject.GetComponent<PlayableDirector>();
                var previewMotionAnimator =
                    previewMotionObject.GetComponent<Animator>();
                var previewAuthoredVirtualCamera = previewMotionObject.transform
                    .Find(authoredVirtualCameraObject.name)
                    .GetComponent<CinemachineCamera>();
                var authoredCameraTrack = motionTimeline.GetOutputTracks()
                    .OfType<CinemachineTrack>()
                    .Single();
                var motionTimelineWasDirty = EditorUtility.IsDirty(motionTimeline);
                var suppressionState = AICameraTimelinePreviewImporter
                    .CaptureAuthoredCinemachineState(
                        previewObject.GetComponent<PlayableDirector>());

                Assert.That(previewObject.activeSelf, Is.True);
                Assert.That(
                    previewMotionDirector.GetGenericBinding(authoredCameraTrack),
                    Is.SameAs(authoredBrain));
                Assert.That(
                    previewMotionDirector.GetGenericBinding(motionTrack),
                    Is.SameAs(previewMotionAnimator));
                Assert.That(previewAuthoredVirtualCamera.enabled, Is.True);
                Assert.That(authoredVirtualCamera.enabled, Is.True);

                AICameraTimelinePreviewImporter
                    .SuppressAuthoredCinemachineOutputsForEditor(
                        previewObject.GetComponent<PlayableDirector>());

                Assert.That(
                    previewMotionDirector.GetGenericBinding(authoredCameraTrack),
                    Is.Null);
                Assert.That(
                    previewMotionDirector.GetGenericBinding(motionTrack),
                    Is.SameAs(previewMotionAnimator));
                Assert.That(previewAuthoredVirtualCamera.enabled, Is.False);
                Assert.That(authoredVirtualCamera.enabled, Is.True);
                Assert.That(
                    previewMotionDirector.playableAsset,
                    Is.SameAs(motionTimeline));
                Assert.That(
                    motionTimeline.GetOutputTracks(),
                    Does.Contain(authoredCameraTrack));
                Assert.That(
                    motionTimeline.GetOutputTracks(),
                    Does.Contain(motionTrack));
                Assert.That(
                    motionDirector.GetGenericBinding(authoredCameraTrack),
                    Is.SameAs(authoredBrain));
                Assert.That(
                    motionDirector.GetGenericBinding(motionTrack),
                    Is.SameAs(motionAnimator));
                Assert.That(
                    motionDirector.playableAsset,
                    Is.SameAs(motionTimeline));
                Assert.That(
                    EditorUtility.IsDirty(motionTimeline),
                    Is.EqualTo(motionTimelineWasDirty));

                AICameraTimelinePreviewImporter.RestoreAuthoredCinemachineState(
                    suppressionState);

                Assert.That(
                    previewMotionDirector.GetGenericBinding(authoredCameraTrack),
                    Is.SameAs(authoredBrain));
                Assert.That(
                    previewMotionDirector.GetGenericBinding(motionTrack),
                    Is.SameAs(previewMotionAnimator));
                Assert.That(previewAuthoredVirtualCamera.enabled, Is.True);
                Assert.That(authoredVirtualCamera.enabled, Is.True);
            }
            finally
            {
                Object.DestroyImmediate(previewObject);
                Object.DestroyImmediate(sourceObject);
                Object.DestroyImmediate(cameraObject);
                Object.DestroyImmediate(motionTimeline);
                Object.DestroyImmediate(sourceTimeline);
            }
        }

        [Test]
        public void PreviewCloneKeepsParentGeneratedCinemachineBinding()
        {
            var sourceObject = new GameObject("Source Director");
            var motionObject = new GameObject("Motion Director");
            var generatedCameraObject = new GameObject("Generated Camera");
            var authoredCameraObject = new GameObject("Authored Camera");
            motionObject.transform.SetParent(sourceObject.transform, false);
            TimelineAsset sourceTimeline = null;
            TimelineAsset motionTimeline = null;
            GameObject previewObject = null;
            try
            {
                var sourceDirector =
                    sourceObject.AddComponent<PlayableDirector>();
                sourceDirector.playOnAwake = false;
                var motionDirector =
                    motionObject.AddComponent<PlayableDirector>();
                motionDirector.playOnAwake = false;
                generatedCameraObject.AddComponent<Camera>();
                var generatedBrain =
                    generatedCameraObject.AddComponent<CinemachineBrain>();
                authoredCameraObject.AddComponent<Camera>();
                var authoredBrain =
                    authoredCameraObject.AddComponent<CinemachineBrain>();
                sourceTimeline = CreateTimelineBoundToBrain(
                    sourceDirector,
                    generatedBrain,
                    AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName);
                motionTimeline = CreateTimelineBoundToBrain(
                    motionDirector,
                    authoredBrain,
                    "Authored Camera Track");
                var generatedTrack = sourceTimeline.GetOutputTracks()
                    .OfType<CinemachineTrack>()
                    .Single();
                var authoredTrack = motionTimeline.GetOutputTracks()
                    .OfType<CinemachineTrack>()
                    .Single();

                previewObject = Object.Instantiate(sourceObject);
                var previewDirector =
                    previewObject.GetComponent<PlayableDirector>();
                var previewMotionDirector = previewObject.transform
                    .Find(motionObject.name)
                    .GetComponent<PlayableDirector>();

                AICameraTimelinePreviewImporter
                    .SuppressAuthoredCinemachineOutputsForEditor(
                        previewDirector);

                Assert.That(
                    previewDirector.GetGenericBinding(generatedTrack),
                    Is.SameAs(generatedBrain));
                Assert.That(
                    previewMotionDirector.GetGenericBinding(authoredTrack),
                    Is.Null);
                Assert.That(
                    sourceDirector.GetGenericBinding(generatedTrack),
                    Is.SameAs(generatedBrain));
                Assert.That(
                    motionDirector.GetGenericBinding(authoredTrack),
                    Is.SameAs(authoredBrain));
            }
            finally
            {
                Object.DestroyImmediate(previewObject);
                Object.DestroyImmediate(sourceObject);
                Object.DestroyImmediate(generatedCameraObject);
                Object.DestroyImmediate(authoredCameraObject);
                Object.DestroyImmediate(motionTimeline);
                Object.DestroyImmediate(sourceTimeline);
            }
        }

        [Test]
        public void FinalSuppressionDoesNotCreatePartialUndoEntry()
        {
            var destinationObject = new GameObject("Destination Director");
            var motionObject = new GameObject("Motion Director");
            var authoredVirtualCameraObject =
                new GameObject("Authored Virtual Camera");
            var outputCameraObject = new GameObject("Output Camera");
            var undoSentinelObject = new GameObject("Undo Sentinel");
            motionObject.transform.SetParent(destinationObject.transform, false);
            authoredVirtualCameraObject.transform.SetParent(
                motionObject.transform,
                false);
            var destinationTimeline =
                ScriptableObject.CreateInstance<TimelineAsset>();
            TimelineAsset motionTimeline = null;
            try
            {
                var destinationDirector =
                    destinationObject.AddComponent<PlayableDirector>();
                destinationDirector.playableAsset = destinationTimeline;
                var motionDirector =
                    motionObject.AddComponent<PlayableDirector>();
                var authoredVirtualCamera = authoredVirtualCameraObject
                    .AddComponent<CinemachineCamera>();
                outputCameraObject.AddComponent<Camera>();
                var brain = outputCameraObject.AddComponent<CinemachineBrain>();
                motionTimeline = CreateTimelineBoundToBrain(
                    motionDirector,
                    brain,
                    "Authored Camera Track");
                var cameraTrack = motionTimeline.GetOutputTracks()
                    .OfType<CinemachineTrack>()
                    .Single();

                Undo.ClearAll();
                Undo.IncrementCurrentGroup();
                var sentinelGroup = Undo.GetCurrentGroup();
                Undo.RegisterCompleteObjectUndo(
                    undoSentinelObject.transform,
                    "Final Suppression Undo Sentinel");
                undoSentinelObject.transform.position = Vector3.one;
                Undo.CollapseUndoOperations(sentinelGroup);
                Undo.FlushUndoRecordObjects();
                Undo.IncrementCurrentGroup();

                AICameraTimelinePreviewImporter
                    .SuppressAuthoredCinemachineOutputsForEditor(
                        destinationDirector,
                        false);
                Undo.FlushUndoRecordObjects();

                Assert.That(
                    motionDirector.GetGenericBinding(cameraTrack),
                    Is.Null);
                Assert.That(authoredVirtualCamera.enabled, Is.False);

                Undo.PerformUndo();

                Assert.That(undoSentinelObject.transform.position, Is.EqualTo(Vector3.zero));
                Assert.That(
                    motionDirector.GetGenericBinding(cameraTrack),
                    Is.Null);
                Assert.That(authoredVirtualCamera.enabled, Is.False);
            }
            finally
            {
                Undo.ClearAll();
                Object.DestroyImmediate(destinationObject);
                Object.DestroyImmediate(outputCameraObject);
                Object.DestroyImmediate(undoSentinelObject);
                Object.DestroyImmediate(motionTimeline);
                Object.DestroyImmediate(destinationTimeline);
            }
        }

        [Test]
        public void SuppressionRebuildsPlayingDirectorAndReleasesBrainOverride()
        {
            var sourceObject = new GameObject("Source Director");
            var motionObject = new GameObject("Motion Director");
            var authoredCameraObject = new GameObject("Authored Virtual Camera");
            var outputCameraObject = new GameObject("Output Camera");
            motionObject.transform.SetParent(sourceObject.transform, false);
            authoredCameraObject.transform.SetParent(
                motionObject.transform,
                false);
            var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var motionTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
            PlayableDirector motionDirector = null;
            try
            {
                var sourceDirector =
                    sourceObject.AddComponent<PlayableDirector>();
                sourceDirector.playOnAwake = false;
                sourceDirector.playableAsset = sourceTimeline;
                motionDirector = motionObject.AddComponent<PlayableDirector>();
                motionDirector.playOnAwake = false;
                motionDirector.timeUpdateMode = DirectorUpdateMode.Manual;
                motionDirector.playableAsset = motionTimeline;
                var authoredCamera =
                    authoredCameraObject.AddComponent<CinemachineCamera>();
                outputCameraObject.AddComponent<Camera>();
                var brain = outputCameraObject.AddComponent<CinemachineBrain>();
                brain.UpdateMethod = CinemachineBrain.UpdateMethods.ManualUpdate;

                var track = motionTimeline.CreateTrack<CinemachineTrack>(
                    null,
                    "Authored Camera Track");
                var clip = track.CreateDefaultClip();
                clip.start = 0d;
                clip.duration = 1d;
                var shot = clip.asset as CinemachineShot;
                Assert.That(shot, Is.Not.Null);
                var exposedName = new PropertyName("AuthoredCameraForSuppressionTest");
                shot.VirtualCamera.exposedName = exposedName;
                shot.VirtualCamera.defaultValue = authoredCamera;
                motionDirector.SetReferenceValue(exposedName, authoredCamera);
                motionDirector.SetGenericBinding(track, brain);

                motionDirector.Play();
                motionDirector.time = 0.25d;
                motionDirector.Evaluate();
                brain.ManualUpdate(100, 1f / 60f);

                Assert.That(motionDirector.playableGraph.IsValid(), Is.True);
                Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
                Assert.That(brain.ActiveVirtualCamera, Is.SameAs(authoredCamera));
                var suppressionState = AICameraTimelinePreviewImporter
                    .CaptureAuthoredCinemachineState(sourceDirector);

                AICameraTimelinePreviewImporter
                    .SuppressAuthoredCinemachineOutputsForEditor(sourceDirector);
                brain.ManualUpdate(101, 1f / 60f);

                Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
                Assert.That(motionDirector.time, Is.EqualTo(0.25d).Within(0.000001d));
                Assert.That(motionDirector.GetGenericBinding(track), Is.Null);
                Assert.That(authoredCamera.enabled, Is.False);
                Assert.That(brain.ActiveVirtualCamera, Is.Not.SameAs(authoredCamera));

                AICameraTimelinePreviewImporter.RestoreAuthoredCinemachineState(
                    suppressionState);
                brain.ManualUpdate(102, 1f / 60f);

                Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
                Assert.That(motionDirector.time, Is.EqualTo(0.25d).Within(0.000001d));
                Assert.That(
                    motionDirector.GetGenericBinding(track),
                    Is.SameAs(brain));
                Assert.That(authoredCamera.enabled, Is.True);
                Assert.That(brain.ActiveVirtualCamera, Is.SameAs(authoredCamera));
            }
            finally
            {
                if (motionDirector != null)
                {
                    motionDirector.Stop();
                }

                Object.DestroyImmediate(sourceObject);
                Object.DestroyImmediate(outputCameraObject);
                Object.DestroyImmediate(motionTimeline);
                Object.DestroyImmediate(sourceTimeline);
            }
        }

        [TestCase(false)]
        [TestCase(true)]
        public void GeneratedDirectorySupportsLegacyAndDeclaredHashes(
            bool includeHashes)
        {
            var directory = CreateGeneratedDirectory(includeHashes);
            try
            {
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Does.Contain("1 frames, 1 shots at 60 FPS"));
            }
            finally
            {
                Directory.Delete(directory, true);
            }
        }

        [TestCase("generated_camera_world.f32", "worldCamera")]
        [TestCase("time.f64", "time")]
        [TestCase("shots.json", "shots")]
        public void GeneratedDirectoryRejectsTamperedDeclaredPayloadBeforeParsing(
            string fileName,
            string payloadName)
        {
            var directory = CreateGeneratedDirectory(true);
            try
            {
                File.AppendAllText(
                    Path.Combine(directory, fileName),
                    "tampered",
                    new UTF8Encoding(false));

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains(
                            $"{payloadName} payload SHA-256 mismatch"));
            }
            finally
            {
                Directory.Delete(directory, true);
            }
        }

        [Test]
        public void GeneratedDirectoryRejectsAbsolutePayloadPath()
        {
            var directory = CreateGeneratedDirectory(true);
            try
            {
                ReplaceMetadataPayloadPath(
                    directory,
                    "worldCameraFile",
                    Path.Combine(directory, "generated_camera_world.f32"));

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains("must be relative"));
            }
            finally
            {
                Directory.Delete(directory, true);
            }
        }

        [Test]
        public void GeneratedDirectoryRejectsParentTraversalEvenWhenHashMatches()
        {
            var directory = CreateGeneratedDirectory(true);
            var outsidePath = Path.Combine(
                Path.GetDirectoryName(directory) ?? Path.GetTempPath(),
                $"cw_ai_outside_{Guid.NewGuid():N}.f32");
            try
            {
                File.Copy(
                    Path.Combine(directory, "generated_camera_world.f32"),
                    outsidePath);
                ReplaceMetadataPayloadPath(
                    directory,
                    "worldCameraFile",
                    "../" + Path.GetFileName(outsidePath));

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains("invalid segment"));
            }
            finally
            {
                Directory.Delete(directory, true);
                if (File.Exists(outsidePath))
                {
                    File.Delete(outsidePath);
                }
            }
        }

        [Test]
        public void GeneratedDirectoryRejectsExplicitNullHashObject()
        {
            var directory = CreateGeneratedDirectory(false);
            try
            {
                var metadataPath = Path.Combine(directory, "metadata.json");
                var json = File.ReadAllText(metadataPath);
                File.WriteAllText(
                    metadataPath,
                    json.Substring(0, json.Length - 1) +
                    ",\"outputSha256\":null}",
                    new UTF8Encoding(false));

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains(
                            "metadata.outputSha256 must be a JSON object"));
            }
            finally
            {
                Directory.Delete(directory, true);
            }
        }

        [Test]
        public void GeneratedDirectoryRejectsIncompleteDeclaredHashes()
        {
            var directory = CreateGeneratedDirectory(true);
            try
            {
                var metadataPath = Path.Combine(directory, "metadata.json");
                var timePath = Path.Combine(directory, "time.f64");
                var json = File.ReadAllText(metadataPath).Replace(
                    $",\"time\":\"{ComputeSha256(timePath)}\"",
                    string.Empty);
                File.WriteAllText(
                    metadataPath,
                    json,
                    new UTF8Encoding(false));

                Assert.That(
                    () => AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(directory),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains(
                            "metadata.outputSha256.time is missing"));
            }
            finally
            {
                Directory.Delete(directory, true);
            }
        }

        [Test]
        public void PreviewProvenancePersistsHiddenRelativeAndAbsoluteFallback()
        {
            var cwAiRoot = Path.Combine(
                Path.GetTempPath(),
                $"cw_ai_provenance_root_{Guid.NewGuid():N}");
            var generatedDirectory = Path.Combine(
                cwAiRoot,
                "examples",
                "UnityGenerated",
                "camera_test");
            Directory.CreateDirectory(generatedDirectory);
            File.WriteAllText(
                Path.Combine(generatedDirectory, "metadata.json"),
                "{\"generation\":1}",
                new UTF8Encoding(false));
            var timelinePath =
                $"Assets/__CWAI_Provenance_{Guid.NewGuid():N}.playable";
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                AssetDatabase.CreateAsset(timeline, timelinePath);
                var provenance = AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        generatedDirectory,
                        cwAiRoot,
                        false);
                AssetDatabase.SaveAssets();
                AssetDatabase.ImportAsset(
                    timelinePath,
                    ImportAssetOptions.ForceSynchronousImport);

                var reloaded = AssetDatabase.LoadAssetAtPath<TimelineAsset>(
                    timelinePath);
                Assert.That(reloaded, Is.Not.Null);
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            reloaded,
                            out var info),
                    Is.True);
                Assert.That(
                    info.CwAiRelativeGeneratedFolder,
                    Is.EqualTo("examples/UnityGenerated/camera_test"));
                Assert.That(
                    info.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(generatedDirectory)));
                Assert.That(
                    info.MetadataSha256,
                    Is.EqualTo(ComputeSha256(
                        Path.Combine(generatedDirectory, "metadata.json"))));
                Assert.That(
                    provenance.hideFlags,
                    Is.EqualTo(
                        HideFlags.HideInHierarchy |
                        HideFlags.HideInInspector));
            }
            finally
            {
                AssetDatabase.DeleteAsset(timelinePath);
                Directory.Delete(cwAiRoot, true);
            }
        }

        [Test]
        public void PreviewProvenanceUndoRedoRestoresMaterializedBase()
        {
            var cwAiRoot = Path.Combine(
                Path.GetTempPath(),
                $"cw_ai_provenance_undo_{Guid.NewGuid():N}");
            var first = Path.Combine(cwAiRoot, "outputs", "first");
            var second = Path.Combine(cwAiRoot, "outputs", "second");
            Directory.CreateDirectory(first);
            Directory.CreateDirectory(second);
            File.WriteAllText(
                Path.Combine(first, "metadata.json"),
                "{\"generation\":1}",
                new UTF8Encoding(false));
            File.WriteAllText(
                Path.Combine(second, "metadata.json"),
                "{\"generation\":2}",
                new UTF8Encoding(false));
            var timelinePath =
                $"Assets/__CWAI_ProvenanceUndo_{Guid.NewGuid():N}.playable";
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                AssetDatabase.CreateAsset(timeline, timelinePath);
                AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        first,
                        cwAiRoot,
                        false);
                AssetDatabase.SaveAssets();

                Undo.IncrementCurrentGroup();
                var undoGroup = Undo.GetCurrentGroup();
                Undo.SetCurrentGroupName("Test AI Camera Provenance Undo");
                AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        second,
                        cwAiRoot,
                        true);
                Undo.CollapseUndoOperations(undoGroup);
                Undo.FlushUndoRecordObjects();

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out var changed),
                    Is.True);
                Assert.That(
                    changed.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(second)));

                Undo.PerformUndo();
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out var undone),
                    Is.True);
                Assert.That(
                    undone.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(first)));

                Undo.PerformRedo();
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out var redone),
                    Is.True);
                Assert.That(
                    redone.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(second)));
            }
            finally
            {
                AssetDatabase.DeleteAsset(timelinePath);
                Directory.Delete(cwAiRoot, true);
            }
        }

        [Test]
        public void FinalTimelineCleanupRemovesPreviewOnlyProvenanceSubasset()
        {
            var generatedDirectory = Path.Combine(
                Path.GetTempPath(),
                $"cw_ai_provenance_final_{Guid.NewGuid():N}");
            Directory.CreateDirectory(generatedDirectory);
            File.WriteAllText(
                Path.Combine(generatedDirectory, "metadata.json"),
                "{\"generation\":1}",
                new UTF8Encoding(false));
            var timelinePath =
                $"Assets/__CWAI_ProvenanceFinal_{Guid.NewGuid():N}.playable";
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                AssetDatabase.CreateAsset(timeline, timelinePath);
                AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        generatedDirectory,
                        string.Empty,
                        false);
                AssetDatabase.SaveAssets();

                AICameraTimelinePreviewImporter
                    .RemovePreviewGenerationProvenanceForTests(timeline);

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out _),
                    Is.False);
                Assert.That(
                    AssetDatabase.LoadAllAssetsAtPath(timelinePath)
                        .OfType<AICameraPreviewGenerationProvenance>(),
                    Is.Empty);
            }
            finally
            {
                AssetDatabase.DeleteAsset(timelinePath);
                Directory.Delete(generatedDirectory, true);
            }
        }

        [Test]
        public void ShotDisplayNameAndProvenanceRestoreInOneUndoGroup()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                $"cw_ai_track_undo_{Guid.NewGuid():N}");
            var first = Path.Combine(root, "first");
            var second = Path.Combine(root, "second");
            Directory.CreateDirectory(first);
            Directory.CreateDirectory(second);
            File.WriteAllText(
                Path.Combine(first, "metadata.json"),
                "{\"generation\":1}");
            File.WriteAllText(
                Path.Combine(second, "metadata.json"),
                "{\"generation\":2}");
            var timelinePath =
                $"Assets/__CWAI_TrackUndo_{Guid.NewGuid():N}.playable";
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            try
            {
                AssetDatabase.CreateAsset(timeline, timelinePath);
                var track = timeline.CreateTrack<AnimationTrack>(null, "Shot");
                var shot = track.CreateDefaultClip();
                shot.displayName = "Before";
                AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        first,
                        root,
                        false);
                EditorUtility.SetDirty(track);
                AssetDatabase.SaveAssets();

                Undo.IncrementCurrentGroup();
                var undoGroup = Undo.GetCurrentGroup();
                Undo.SetCurrentGroupName("Test Shot and Provenance Undo");
                Undo.RegisterCompleteObjectUndo(track, "Test Shot and Provenance Undo");
                shot.displayName = "After";
                AICameraTimelinePreviewImporter
                    .UpsertPreviewGenerationProvenanceForTests(
                        timeline,
                        second,
                        root,
                        true);
                Undo.CollapseUndoOperations(undoGroup);
                Undo.FlushUndoRecordObjects();

                Undo.PerformUndo();
                Assert.That(shot.displayName, Is.EqualTo("Before"));
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out var undone),
                    Is.True);
                Assert.That(
                    undone.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(first)));

                Undo.PerformRedo();
                Assert.That(shot.displayName, Is.EqualTo("After"));
                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForTests(
                            timeline,
                            out var redone),
                    Is.True);
                Assert.That(
                    redone.AbsoluteGeneratedFolder,
                    Is.EqualTo(Path.GetFullPath(second)));
            }
            finally
            {
                AssetDatabase.DeleteAsset(timelinePath);
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void GeneratedMotionUsesOneAnimatorAndTrackPerCamera()
        {
            var directorObject = new GameObject("Director");
            var cameraRoot = new GameObject("AI Camera Root");
            var firstCamera = new GameObject("Shot_000");
            var secondCamera = new GameObject("Shot_001");
            firstCamera.transform.SetParent(cameraRoot.transform, false);
            secondCamera.transform.SetParent(cameraRoot.transform, false);
            firstCamera.transform.SetLocalPositionAndRotation(
                new Vector3(1.25f, 1.8f, -3.5f),
                Quaternion.Euler(8f, 135f, 0f));
            secondCamera.transform.SetLocalPositionAndRotation(
                new Vector3(-2f, 0.9f, 6f),
                Quaternion.Euler(-4f, 20f, 2f));
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var firstClip = new AnimationClip();
            var secondClip = new AnimationClip();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var group = timeline.CreateTrack<GroupTrack>(
                    null,
                    AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);

                var firstTrack = AICameraTimelinePreviewImporter
                    .CreateGeneratedAnimationTrackForCamera(
                        timeline,
                        group,
                        director,
                        firstCamera,
                        firstClip,
                        0,
                        firstCamera.name,
                        0,
                        1);
                var secondTrack = AICameraTimelinePreviewImporter
                    .CreateGeneratedAnimationTrackForCamera(
                        timeline,
                        group,
                        director,
                        secondCamera,
                        secondClip,
                        1,
                        secondCamera.name,
                        1,
                        2);

                var firstAnimator = firstCamera.GetComponent<Animator>();
                var secondAnimator = secondCamera.GetComponent<Animator>();
                Assert.That(cameraRoot.GetComponent<Animator>(), Is.Null);
                Assert.That(firstAnimator, Is.Not.Null);
                Assert.That(secondAnimator, Is.Not.Null);
                Assert.That(firstAnimator, Is.Not.SameAs(secondAnimator));
                Assert.That(
                    firstAnimator.cullingMode,
                    Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
                Assert.That(
                    secondAnimator.cullingMode,
                    Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
                Assert.That(director.GetGenericBinding(firstTrack), Is.SameAs(firstAnimator));
                Assert.That(director.GetGenericBinding(secondTrack), Is.SameAs(secondAnimator));
                Assert.That(firstTrack.parent, Is.SameAs(group));
                Assert.That(secondTrack.parent, Is.SameAs(group));
                Assert.That(
                    firstTrack.position,
                    Is.EqualTo(firstCamera.transform.localPosition));
                Assert.That(
                    secondTrack.position,
                    Is.EqualTo(secondCamera.transform.localPosition));
                Assert.That(
                    Quaternion.Angle(
                        firstTrack.rotation,
                        firstCamera.transform.localRotation),
                    Is.LessThan(0.001f));
                Assert.That(
                    Quaternion.Angle(
                        secondTrack.rotation,
                        secondCamera.transform.localRotation),
                    Is.LessThan(0.001f));
                Assert.That(
                    System.Linq.Enumerable.Count(firstTrack.GetClips()),
                    Is.EqualTo(1));
                Assert.That(
                    System.Linq.Enumerable.Count(secondTrack.GetClips()),
                    Is.EqualTo(1));
                StringAssert.StartsWith(
                    AICameraTimelinePreviewImporter.GeneratedAnimationTrackPrefix,
                    firstTrack.name);
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraRoot);
                Object.DestroyImmediate(firstClip);
                Object.DestroyImmediate(secondClip);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void ShotSelectionResolvesTimelineClipAssetAndCamera()
        {
            var directorObject = new GameObject("Director");
            var cameraRoot = new GameObject("AI Camera Root");
            var firstCamera = new GameObject("Shot_000");
            var secondCamera = new GameObject("Shot_001");
            firstCamera.transform.SetParent(cameraRoot.transform, false);
            secondCamera.transform.SetParent(cameraRoot.transform, false);
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var firstClip = new AnimationClip();
            var secondClip = new AnimationClip();
            try
            {
                var director = directorObject.AddComponent<PlayableDirector>();
                director.playableAsset = timeline;
                var group = timeline.CreateTrack<GroupTrack>(
                    null,
                    AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
                AICameraTimelinePreviewImporter
                    .CreateGeneratedAnimationTrackForCamera(
                        timeline,
                        group,
                        director,
                        firstCamera,
                        firstClip,
                        0,
                        firstCamera.name,
                        0,
                        1);
                var secondTrack = AICameraTimelinePreviewImporter
                    .CreateGeneratedAnimationTrackForCamera(
                        timeline,
                        group,
                        director,
                        secondCamera,
                        secondClip,
                        1,
                        secondCamera.name,
                        1,
                        2);
                var selectedTimelineClip = System.Linq.Enumerable.Single(
                    secondTrack.GetClips());

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryResolveGeneratedShotIndexForEditor(
                            timeline,
                            cameraRoot,
                            selectedTimelineClip,
                            null,
                            null,
                            out var timelineIndex),
                    Is.True);
                Assert.That(timelineIndex, Is.EqualTo(1));

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryResolveGeneratedShotIndexForEditor(
                            timeline,
                            cameraRoot,
                            null,
                            null,
                            secondClip,
                            out var assetIndex),
                    Is.True);
                Assert.That(assetIndex, Is.EqualTo(1));

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryResolveGeneratedShotIndexForEditor(
                            timeline,
                            cameraRoot,
                            null,
                            firstCamera,
                            null,
                            out var cameraIndex),
                    Is.True);
                Assert.That(cameraIndex, Is.EqualTo(0));
            }
            finally
            {
                Object.DestroyImmediate(directorObject);
                Object.DestroyImmediate(cameraRoot);
                Object.DestroyImmediate(firstClip);
                Object.DestroyImmediate(secondClip);
                Object.DestroyImmediate(timeline);
            }
        }

        [Test]
        public void LegacySharedAnimationTrackStillResolvesShots()
        {
            var cameraRoot = new GameObject("Legacy AI Camera Root");
            var firstCamera = new GameObject("Shot_000");
            var secondCamera = new GameObject("Shot_001");
            firstCamera.transform.SetParent(cameraRoot.transform, false);
            secondCamera.transform.SetParent(cameraRoot.transform, false);
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var firstClip = new AnimationClip();
            var secondClip = new AnimationClip();
            try
            {
                var track = timeline.CreateTrack<AnimationTrack>(
                    null,
                    AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
                var firstTimelineClip = track.CreateClip(firstClip);
                firstTimelineClip.start = 0;
                firstTimelineClip.duration = 1;
                var secondTimelineClip = track.CreateClip(secondClip);
                secondTimelineClip.start = 1;
                secondTimelineClip.duration = 1;

                Assert.That(
                    AICameraTimelinePreviewImporter
                        .TryResolveGeneratedShotIndexForEditor(
                            timeline,
                            cameraRoot,
                            secondTimelineClip,
                            null,
                            null,
                            out var shotIndex),
                    Is.True);
                Assert.That(shotIndex, Is.EqualTo(1));
            }
            finally
            {
                Object.DestroyImmediate(cameraRoot);
                Object.DestroyImmediate(firstClip);
                Object.DestroyImmediate(secondClip);
                Object.DestroyImmediate(timeline);
            }
        }

        private static TimelineAsset CreateTimelineBoundToBrain(
            PlayableDirector director,
            CinemachineBrain brain,
            string trackName)
        {
            var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
            var track = timeline.CreateTrack<CinemachineTrack>(null, trackName);
            director.playableAsset = timeline;
            director.SetGenericBinding(track, brain);
            return timeline;
        }

        private static string CreateGeneratedDirectory(bool includeHashes)
        {
            var directory = Path.Combine(
                Path.GetTempPath(),
                $"cw_ai_importer_test_{Guid.NewGuid():N}");
            Directory.CreateDirectory(directory);
            var cameraPath = Path.Combine(
                directory,
                "generated_camera_world.f32");
            var timePath = Path.Combine(directory, "time.f64");
            var shotsPath = Path.Combine(directory, "shots.json");
            using (var writer = new BinaryWriter(File.Create(cameraPath)))
            {
                writer.Write(0f);
                writer.Write(1.5f);
                writer.Write(-3f);
                writer.Write(0f);
                writer.Write(0f);
                writer.Write(0f);
                writer.Write(1f);
                writer.Write(60f);
                writer.Write(0f);
            }

            using (var writer = new BinaryWriter(File.Create(timePath)))
            {
                writer.Write(0d);
            }

            File.WriteAllText(
                shotsPath,
                "{\"shots\":[{\"index\":0,\"cameraName\":\"Shot_000\"," +
                "\"shotType\":\"medium\",\"startFrame\":0," +
                "\"endFrameExclusive\":1}]}",
                new UTF8Encoding(false));

            var hashes = includeHashes
                ? ",\"outputSha256\":{" +
                  $"\"worldCamera\":\"{ComputeSha256(cameraPath)}\"," +
                  $"\"time\":\"{ComputeSha256(timePath)}\"," +
                  $"\"shots\":\"{ComputeSha256(shotsPath)}\"}}"
                : string.Empty;
            File.WriteAllText(
                Path.Combine(directory, "metadata.json"),
                "{\"songName\":\"Hash Validation Test\",\"sampleRate\":60," +
                "\"frameCount\":1,\"duration\":0.016666666666666666," +
                "\"worldCameraFile\":\"generated_camera_world.f32\"," +
                "\"timeFile\":\"time.f64\",\"shotsFile\":\"shots.json\"," +
                $"\"lookAtUsed\":false{hashes}}}",
                new UTF8Encoding(false));
            return directory;
        }

        private static void ReplaceMetadataPayloadPath(
            string directory,
            string propertyName,
            string replacementPath)
        {
            var metadataPath = Path.Combine(directory, "metadata.json");
            var json = File.ReadAllText(metadataPath);
            var marker = $"\"{propertyName}\":\"";
            var valueStart = json.IndexOf(marker, StringComparison.Ordinal);
            Assert.That(valueStart, Is.GreaterThanOrEqualTo(0));
            valueStart += marker.Length;
            var valueEnd = json.IndexOf('"', valueStart);
            Assert.That(valueEnd, Is.GreaterThan(valueStart));
            var escapedReplacement = replacementPath
                .Replace("\\", "\\\\")
                .Replace("\"", "\\\"");
            json = json.Substring(0, valueStart) +
                   escapedReplacement +
                   json.Substring(valueEnd);
            File.WriteAllText(
                metadataPath,
                json,
                new UTF8Encoding(false));
        }

        private static string ComputeSha256(string path)
        {
            using var algorithm = SHA256.Create();
            return string.Concat(
                algorithm.ComputeHash(File.ReadAllBytes(path))
                    .Select(value => value.ToString("x2")));
        }
    }
}
