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

namespace Streamingle.Editor.Tests
{
    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));
            }
            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);
            }
        }

        [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 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")));
        }
    }
}
