using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Unity.Cinemachine.Editor;
using UnityEditor;
using UnityEditor.Recorder;
using UnityEditor.Recorder.Encoder;
using UnityEditor.Recorder.Input;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;

namespace Streamingle.Editor
{
    [InitializeOnLoad]
    public static class AICameraABRenderRunner
    {
        internal const string PhaseBuildPreview = "build_preview";
        internal const string PhaseEnterPlayMode = "enter_play_mode";
        internal const string PhaseRecording = "recording";
        internal const string PhaseFinalizeCandidate = "finalize_candidate";
        internal const string PhaseComplete = "complete";
        internal const string PhaseFailed = "failed";
        internal const string PhaseCancelled = "cancelled";

        private const string ManifestFileName = "manifest.json";
        private const string ActiveJobFileName = "active_job.json";
        private const double OutputFinalizationTimeoutSeconds = 60;
        private const double OutputQuietPeriodSeconds = 0.25;
        private const int RequiredStableOutputChecks = 2;

        private static AICameraABRenderJobState job;
        private static AICameraABRenderManifest manifest;
        private static RecorderController recorderController;
        private static RecorderControllerSettings controllerSettings;
        private static MovieRecorderSettings movieSettings;
        private static bool updateInProgress;

        static AICameraABRenderRunner()
        {
            EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
            EditorApplication.update += OnEditorUpdate;
            EditorApplication.delayCall += ResumePersistedJob;
        }

        public static string StartABRenderForCli(
            string candidateADirectory,
            string candidateBDirectory,
            string outputRoot,
            int startFrame,
            int endFrameExclusive,
            string codec,
            int width,
            int height)
        {
            var request = new AICameraABRenderRequest
            {
                candidateADirectory = candidateADirectory,
                candidateBDirectory = candidateBDirectory,
                outputRoot = outputRoot,
                startFrame = startFrame,
                endFrameExclusive = endFrameExclusive,
                codec = AICameraABRenderUtility.ParseCodec(codec),
                width = width,
                height = height
            };
            var startedJob = Start(request);
            return JsonUtility.ToJson(
                new StartResponse
                {
                    jobId = startedJob.jobId,
                    manifestPath = startedJob.manifestPath,
                    runDirectory = startedJob.runDirectory
                },
                true);
        }

        public static string GetABRenderStatusForCli(string jobId)
        {
            LoadPersistedState();
            if (job == null ||
                !string.Equals(job.jobId, jobId, StringComparison.Ordinal))
            {
                throw new InvalidOperationException(
                    $"A/B render job '{jobId}' is not the active job.");
            }

            if (!File.Exists(job.manifestPath))
            {
                throw new FileNotFoundException(
                    "A/B render manifest was not found.",
                    job.manifestPath);
            }

            return File.ReadAllText(job.manifestPath);
        }

        public static string CancelABRenderForCli(string jobId)
        {
            LoadPersistedState();
            if (job == null ||
                !string.Equals(job.jobId, jobId, StringComparison.Ordinal))
            {
                throw new InvalidOperationException(
                    $"A/B render job '{jobId}' is not the active job.");
            }

            if (AICameraABRenderUtility.IsTerminalPhase(job.phase))
            {
                return $"A/B render job '{jobId}' is already {job.phase}.";
            }

            job.cancelRequested = true;
            SaveJob();
            return $"Cancellation requested for A/B render job '{jobId}'.";
        }

        public static string RetryFailedABRenderForCli(string jobId)
        {
            EnsureEditMode();
            LoadPersistedState();
            if (job == null ||
                !string.Equals(job.jobId, jobId, StringComparison.Ordinal))
            {
                throw new InvalidOperationException(
                    $"A/B render job '{jobId}' is not the active job.");
            }

            if (job.phase != PhaseFailed ||
                job.cleanupPending ||
                manifest == null ||
                job.candidateIndex < 0 ||
                job.candidateIndex >= manifest.candidates.Count)
            {
                throw new InvalidOperationException(
                    $"A/B render job '{jobId}' cannot be retried from its " +
                    $"current state.");
            }

            RestoreCinemachineSaveDuringPlay();
            if (!job.cinemachineSaveDuringPlayStateRestored)
            {
                throw new InvalidOperationException(
                    "Cinemachine Save During Play must be restored before " +
                    "retrying a failed render.");
            }

            EnsureCleanSavedScenes();
            for (var index = 0; index < job.candidateIndex; index++)
            {
                var completed = manifest.candidates[index];
                if (completed.status != "complete" ||
                    !File.Exists(completed.outputPath) ||
                    new FileInfo(completed.outputPath).Length !=
                    completed.outputBytes ||
                    !string.Equals(
                        AICameraABRenderUtility.ComputeSha256(
                            completed.outputPath),
                        completed.outputSha256,
                        StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidDataException(
                        $"Completed candidate {completed.label} no longer " +
                        "matches its manifest.");
                }
            }

            var candidate = GetCurrentCandidate();
            var currentInput = AICameraABRenderUtility.ReadCandidate(
                candidate.label,
                candidate.generatedDirectory).Result;
            if (!string.Equals(
                    currentInput.metadataSha256,
                    candidate.metadataSha256,
                    StringComparison.OrdinalIgnoreCase) ||
                !string.Equals(
                    currentInput.cameraSha256,
                    candidate.cameraSha256,
                    StringComparison.OrdinalIgnoreCase) ||
                !string.Equals(
                    currentInput.timeSha256,
                    candidate.timeSha256,
                    StringComparison.OrdinalIgnoreCase) ||
                !string.Equals(
                    currentInput.shotsSha256,
                    candidate.shotsSha256,
                    StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidDataException(
                    $"Candidate {candidate.label} inputs changed after the " +
                    "original render attempt.");
            }

            if (File.Exists(candidate.outputPath))
            {
                throw new InvalidOperationException(
                    $"Candidate {candidate.label} has a partial output. " +
                    "Move or remove it before retrying: " +
                    $"'{candidate.outputPath}'.");
            }

            candidate.status = "queued";
            candidate.startedUtc = string.Empty;
            candidate.completedUtc = string.Empty;
            candidate.outputBytes = 0;
            candidate.outputSha256 = string.Empty;
            candidate.error = string.Empty;
            manifest.state = "running";
            manifest.completedUtc = string.Empty;
            manifest.error = string.Empty;

            job.phase = PhaseBuildPreview;
            job.cancelRequested = false;
            job.cleanupPending = false;
            job.stableOutputChecks = 0;
            job.lastObservedOutputBytes = -1;
            job.finalizationStartedUtc = string.Empty;
            job.error = string.Empty;
            job.cinemachineSaveDuringPlayStateCaptured = true;
            job.cinemachineSaveDuringPlayOriginalEnabled =
                SaveDuringPlay.Enabled;
            job.cinemachineSaveDuringPlayStateRestored = false;

            SaveManifest();
            SaveJob();
            EnsureCinemachineSaveDuringPlayDisabled();
            EditorApplication.delayCall += ContinueInCurrentMode;
            return JsonUtility.ToJson(
                new StartResponse
                {
                    jobId = job.jobId,
                    manifestPath = job.manifestPath,
                    runDirectory = job.runDirectory
                },
                true);
        }

        internal static bool TryGetActiveStatus(
            out string jobId,
            out string phase,
            out string manifestPath)
        {
            LoadPersistedState();
            if (job == null)
            {
                jobId = string.Empty;
                phase = string.Empty;
                manifestPath = string.Empty;
                return false;
            }

            jobId = job.jobId;
            phase = job.phase;
            manifestPath = job.manifestPath;
            return true;
        }

        internal static AICameraABRenderJobState Start(
            AICameraABRenderRequest request)
        {
            EnsureEditMode();
            ClearPreviousTerminalJob();
            EnsureNoActiveJob();
            EnsureCleanSavedScenes();

            if (request == null)
            {
                throw new ArgumentNullException(nameof(request));
            }

            if (string.IsNullOrWhiteSpace(request.outputRoot))
            {
                request.outputRoot = GetDefaultOutputRoot();
            }

            var candidateA = AICameraABRenderUtility.ReadCandidate(
                "A",
                request.candidateADirectory);
            var candidateB = AICameraABRenderUtility.ReadCandidate(
                "B",
                request.candidateBDirectory);
            var fairness = AICameraABRenderUtility.ValidateCandidatePair(
                candidateA,
                candidateB,
                request);

            var activeScene = SceneManager.GetActiveScene();
            var originalDirector = FindDirector(
                activeScene,
                AICameraTimelinePreviewImporter.OriginalDirectorName);
            var originalTimeline = originalDirector.playableAsset as TimelineAsset
                ?? throw new InvalidOperationException(
                    $"'{AICameraTimelinePreviewImporter.OriginalDirectorName}' " +
                    "is not bound to a TimelineAsset.");
            var timelineFrameRate = originalTimeline.editorSettings.frameRate;
            if (Math.Abs(timelineFrameRate - candidateA.Result.sampleRate) > 0.001)
            {
                throw new InvalidDataException(
                    $"Timeline is {timelineFrameRate:F3} FPS but generated " +
                    $"camera data is {candidateA.Result.sampleRate} FPS.");
            }

            var runIdSource =
                $"{DateTime.UtcNow:yyyyMMdd_HHmmss_ffff}_{Guid.NewGuid():N}";
            var runId = runIdSource.Substring(0, 37);
            var runDirectory = Path.Combine(
                Path.GetFullPath(request.outputRoot),
                $"cw_ai_ab_{runId}");
            if (Directory.Exists(runDirectory))
            {
                throw new IOException(
                    $"A/B render directory already exists: '{runDirectory}'.");
            }

            var extension = AICameraABRenderUtility.GetExtension(request.codec);
            ConfigureCandidateOutput(
                candidateA.Result,
                runDirectory,
                extension);
            ConfigureCandidateOutput(
                candidateB.Result,
                runDirectory,
                extension);

            var manifestPath = Path.Combine(runDirectory, ManifestFileName);
            manifest = BuildManifest(
                request,
                activeScene,
                originalTimeline,
                candidateA.Result,
                candidateB.Result,
                fairness,
                runId);
            if (manifest.audio.Count == 0)
            {
                throw new InvalidOperationException(
                    "The original Timeline has no active AudioClip to use " +
                    "for the A/B render.");
            }

            Directory.CreateDirectory(runDirectory);
            job = new AICameraABRenderJobState
            {
                jobId = runId,
                phase = PhaseBuildPreview,
                manifestPath = manifestPath,
                runDirectory = runDirectory,
                candidateIndex = 0,
                request = request,
                sceneSetup = CaptureSceneSetup(),
                cinemachineSaveDuringPlayStateCaptured = true,
                cinemachineSaveDuringPlayOriginalEnabled =
                    SaveDuringPlay.Enabled
            };

            SaveManifest();
            SaveJob();
            EnsureCinemachineSaveDuringPlayDisabled();
            EditorApplication.delayCall += ContinueInCurrentMode;
            return job;
        }

        private static void ResumePersistedJob()
        {
            LoadPersistedState();
            if (job == null)
            {
                return;
            }

            EnsureCinemachineSaveDuringPlayDisabled();
            if (AICameraABRenderUtility.IsTerminalPhase(job.phase) &&
                !job.cleanupPending &&
                job.cinemachineSaveDuringPlayStateRestored)
            {
                return;
            }

            EditorApplication.delayCall += ContinueInCurrentMode;
        }

        private static void OnEditorUpdate()
        {
            if (updateInProgress)
            {
                return;
            }

            LoadPersistedState();
            if (job == null)
            {
                return;
            }

            updateInProgress = true;
            try
            {
                ContinueInCurrentMode();
            }
            catch (Exception exception)
            {
                FailJob(exception);
            }
            finally
            {
                updateInProgress = false;
            }
        }

        private static void ContinueInCurrentMode()
        {
            if (job == null)
            {
                return;
            }

            EnsureCinemachineSaveDuringPlayDisabled();
            if (Application.isPlaying)
            {
                ContinueInPlayMode();
                return;
            }

            if (EditorApplication.isPlayingOrWillChangePlaymode)
            {
                return;
            }

            ContinueInEditMode();
        }

        private static void ContinueInEditMode()
        {
            if (job.cleanupPending)
            {
                CleanupPreviewAndRestoreScene();
                job.cleanupPending = false;
                SaveJob();
            }

            if (AICameraABRenderUtility.IsTerminalPhase(job.phase))
            {
                RestoreCinemachineSaveDuringPlay();
                return;
            }

            if (job.cancelRequested &&
                !AICameraABRenderUtility.IsTerminalPhase(job.phase))
            {
                CancelJobInEditMode();
                return;
            }

            switch (job.phase)
            {
                case PhaseBuildPreview:
                    BuildPreviewAndEnterPlayMode();
                    break;
                case PhaseEnterPlayMode:
                    EditorApplication.isPlaying = true;
                    break;
                case PhaseRecording:
                    throw new InvalidOperationException(
                        "Play Mode exited before the Recorder completed.");
                case PhaseFinalizeCandidate:
                    TryFinalizeCandidate();
                    break;
                case PhaseComplete:
                case PhaseFailed:
                case PhaseCancelled:
                    break;
            }
        }

        private static void ContinueInPlayMode()
        {
            if (job.cancelRequested)
            {
                CancelJobInPlayMode();
                return;
            }

            switch (job.phase)
            {
                case PhaseEnterPlayMode:
                    StartRecordingInPlayMode();
                    break;
                case PhaseRecording:
                    MonitorRecordingInPlayMode();
                    break;
                case PhaseFinalizeCandidate:
                    EditorApplication.isPlaying = false;
                    break;
                case PhaseFailed:
                case PhaseCancelled:
                    EditorApplication.isPlaying = false;
                    break;
            }
        }

        private static void BuildPreviewAndEnterPlayMode()
        {
            RestoreCleanSceneSetup();
            var candidate = GetCurrentCandidate();
            candidate.status = "building_preview";
            SaveManifest();

            var activeScene = SceneManager.GetActiveScene();
            var hasExistingPreview = Resources
                .FindObjectsOfTypeAll<PlayableDirector>()
                .Any(director =>
                    director.gameObject.scene == activeScene &&
                    director.name ==
                    AICameraTimelinePreviewImporter.PreviewDirectorName);
            if (hasExistingPreview)
            {
                AICameraTimelinePreviewImporter.RemovePreviewForCli();
            }

            AICameraTimelinePreviewImporter.CreatePreviewForCli(
                candidate.generatedDirectory);
            activeScene = SceneManager.GetActiveScene();
            var previewDirector = FindDirector(
                activeScene,
                AICameraTimelinePreviewImporter.PreviewDirectorName);
            if (previewDirector.playableAsset is not TimelineAsset previewTimeline)
            {
                throw new InvalidOperationException(
                    "AI preview Director is not bound to a TimelineAsset.");
            }

            AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
                previewTimeline);
            EditorUtility.SetDirty(previewTimeline);
            AssetDatabase.SaveAssets();

            candidate.status = "waiting_for_play_mode";
            job.phase = PhaseEnterPlayMode;
            SaveManifest();
            SaveJob();
            EditorApplication.isPlaying = true;
        }

        private static void StartRecordingInPlayMode()
        {
            if (recorderController != null)
            {
                return;
            }

            var activeScene = SceneManager.GetActiveScene();
            var previewDirector = FindDirector(
                activeScene,
                AICameraTimelinePreviewImporter.PreviewDirectorName);
            if (Camera.main == null)
            {
                throw new InvalidOperationException(
                    "The active scene has no enabled Camera tagged MainCamera.");
            }

            var candidate = GetCurrentCandidate();
            var request = job.request;
            var cameraInput = new CameraInputSettings
            {
                Source = ImageSource.MainCamera,
                CaptureUI = false,
                OutputWidth = request.width,
                OutputHeight = request.height
            };
            movieSettings = ScriptableObject.CreateInstance<MovieRecorderSettings>();
            movieSettings.name = $"CW-AI A/B {candidate.label}";
            movieSettings.Enabled = true;
            movieSettings.EncoderSettings = new CoreEncoderSettings
            {
                Codec = request.codec == AICameraABRenderCodec.MP4
                    ? CoreEncoderSettings.OutputCodec.MP4
                    : CoreEncoderSettings.OutputCodec.WEBM,
                EncodingQuality =
                    CoreEncoderSettings.VideoEncodingQuality.High
            };
            movieSettings.CaptureAlpha = false;
            movieSettings.CaptureAudio = true;
            movieSettings.ImageInputSettings = cameraInput;
            movieSettings.OutputFile =
                candidate.outputBasePath.Replace('\\', '/');

            controllerSettings =
                ScriptableObject.CreateInstance<RecorderControllerSettings>();
            controllerSettings.AddRecorderSettings(movieSettings);
            controllerSettings.FrameRate = candidate.sampleRate;
            controllerSettings.FrameRatePlayback =
                FrameRatePlayback.Constant;
            controllerSettings.CapFrameRate = true;
            controllerSettings.ExitPlayMode = false;
            controllerSettings.SetRecordModeToFrameInterval(
                request.startFrame,
                AICameraABRenderUtility.ToInclusiveEndFrame(
                    request.startFrame,
                    request.endFrameExclusive,
                    candidate.frameCount));
            recorderController = new RecorderController(controllerSettings);

            previewDirector.time = 0;
            previewDirector.RebuildGraph();
            previewDirector.Evaluate();
            RecorderOptions.VerboseMode = false;
            recorderController.PrepareRecording();
            if (!recorderController.StartRecording())
            {
                throw new InvalidOperationException(
                    "Unity Recorder could not start the movie recording.");
            }

            previewDirector.Play();
            candidate.status = "recording";
            candidate.startedUtc = AICameraABRenderUtility.UtcNow();
            job.phase = PhaseRecording;
            manifest.state = "recording";
            SaveManifest();
            SaveJob();
        }

        private static void MonitorRecordingInPlayMode()
        {
            if (recorderController == null)
            {
                throw new InvalidOperationException(
                    "Recorder state was lost during Play Mode.");
            }

            if (recorderController.IsRecording())
            {
                return;
            }

            recorderController.StopRecording();
            ReleaseRecorderObjects();
            var candidate = GetCurrentCandidate();
            candidate.status = "finalizing";
            job.phase = PhaseFinalizeCandidate;
            job.finalizationStartedUtc = AICameraABRenderUtility.UtcNow();
            job.stableOutputChecks = 0;
            job.lastObservedOutputBytes = -1;
            manifest.state = "finalizing";
            SaveManifest();
            SaveJob();
            EditorApplication.isPlaying = false;
        }

        private static void TryFinalizeCandidate()
        {
            var candidate = GetCurrentCandidate();
            var outputPath = candidate.outputPath;
            var started = ParseUtcOrNow(job.finalizationStartedUtc);
            if (DateTime.UtcNow - started >
                TimeSpan.FromSeconds(OutputFinalizationTimeoutSeconds))
            {
                throw new TimeoutException(
                    $"Recorder output did not finalize within " +
                    $"{OutputFinalizationTimeoutSeconds:F0} seconds: " +
                    $"'{outputPath}'.");
            }

            if (!File.Exists(outputPath))
            {
                return;
            }

            var file = new FileInfo(outputPath);
            if (file.Length <= 0)
            {
                return;
            }

            if (file.Length == job.lastObservedOutputBytes &&
                DateTime.UtcNow - file.LastWriteTimeUtc >
                TimeSpan.FromSeconds(OutputQuietPeriodSeconds))
            {
                job.stableOutputChecks++;
            }
            else
            {
                job.stableOutputChecks = 0;
                job.lastObservedOutputBytes = file.Length;
            }

            SaveJob();
            if (job.stableOutputChecks < RequiredStableOutputChecks)
            {
                return;
            }

            try
            {
                candidate.outputSha256 =
                    AICameraABRenderUtility.ComputeSha256(outputPath);
            }
            catch (IOException)
            {
                job.stableOutputChecks = 0;
                SaveJob();
                return;
            }

            candidate.outputBytes = file.Length;
            candidate.completedUtc = AICameraABRenderUtility.UtcNow();
            candidate.status = "complete";
            job.cleanupPending = true;
            SaveManifest();
            CleanupPreviewAndRestoreScene();
            job.cleanupPending = false;

            job.candidateIndex++;
            job.stableOutputChecks = 0;
            job.lastObservedOutputBytes = -1;
            job.finalizationStartedUtc = string.Empty;
            if (job.candidateIndex < manifest.candidates.Count)
            {
                job.phase = PhaseBuildPreview;
                manifest.state = "running";
                SaveManifest();
                SaveJob();
                EditorApplication.delayCall += ContinueInCurrentMode;
                return;
            }

            job.phase = PhaseComplete;
            manifest.state = "complete";
            manifest.completedUtc = AICameraABRenderUtility.UtcNow();
            SaveManifest();
            RestoreCinemachineSaveDuringPlay();
            SaveJob();
            Debug.Log(
                $"CW-AI A/B render complete. Manifest: {job.manifestPath}");
        }

        private static void CancelJobInPlayMode()
        {
            try
            {
                recorderController?.StopRecording();
            }
            finally
            {
                ReleaseRecorderObjects();
            }

            SetCancelledState();
            job.cleanupPending = true;
            SaveManifest();
            SaveJob();
            EditorApplication.isPlaying = false;
        }

        private static void CancelJobInEditMode()
        {
            SetCancelledState();
            job.cleanupPending = true;
            SaveManifest();
            CleanupPreviewAndRestoreScene();
            job.cleanupPending = false;
            RestoreCinemachineSaveDuringPlay();
            SaveJob();
        }

        private static void SetCancelledState()
        {
            job.phase = PhaseCancelled;
            job.error = "Cancelled by user.";
            manifest.state = "cancelled";
            manifest.error = job.error;
            manifest.completedUtc = AICameraABRenderUtility.UtcNow();
            if (job.candidateIndex >= 0 &&
                job.candidateIndex < manifest.candidates.Count)
            {
                var candidate = GetCurrentCandidate();
                if (candidate.status != "complete")
                {
                    candidate.status = "cancelled";
                    candidate.error = job.error;
                }
            }
        }

        private static void FailJob(Exception exception)
        {
            try
            {
                LoadPersistedState();
                if (job == null)
                {
                    Debug.LogException(exception);
                    return;
                }

                try
                {
                    recorderController?.StopRecording();
                }
                catch (Exception stopException)
                {
                    Debug.LogException(stopException);
                }
                finally
                {
                    ReleaseRecorderObjects();
                }

                job.phase = PhaseFailed;
                job.error = exception.ToString();
                job.cleanupPending = true;
                if (manifest != null)
                {
                    manifest.state = "failed";
                    manifest.error = job.error;
                    manifest.completedUtc = AICameraABRenderUtility.UtcNow();
                    if (job.candidateIndex >= 0 &&
                        job.candidateIndex < manifest.candidates.Count)
                    {
                        var candidate = GetCurrentCandidate();
                        if (candidate.status != "complete")
                        {
                            candidate.status = "failed";
                            candidate.error = exception.Message;
                        }
                    }

                    SaveManifest();
                }

                SaveJob();
                if (Application.isPlaying ||
                    EditorApplication.isPlayingOrWillChangePlaymode)
                {
                    EditorApplication.isPlaying = false;
                }
                else
                {
                    CleanupPreviewAndRestoreScene();
                    job.cleanupPending = false;
                    RestoreCinemachineSaveDuringPlay();
                    SaveJob();
                }
            }
            finally
            {
                Debug.LogException(exception);
            }
        }

        private static void CleanupPreviewAndRestoreScene()
        {
            if (Application.isPlaying ||
                EditorApplication.isPlayingOrWillChangePlaymode)
            {
                return;
            }

            var activeScene = SceneManager.GetActiveScene();
            var hasPreview = Resources.FindObjectsOfTypeAll<PlayableDirector>()
                .Any(director =>
                    director.gameObject.scene == activeScene &&
                    director.name ==
                    AICameraTimelinePreviewImporter.PreviewDirectorName);
            if (hasPreview)
            {
                AICameraTimelinePreviewImporter.RemovePreviewForCli();
            }

            RestoreCleanSceneSetup();
        }

        private static void RestoreCleanSceneSetup()
        {
            if (job?.sceneSetup == null ||
                string.IsNullOrWhiteSpace(job.sceneSetup.activeScenePath))
            {
                throw new InvalidOperationException(
                    "The original Scene setup is unavailable.");
            }

            var entries = job.sceneSetup.scenes
                .Where(entry => !string.IsNullOrWhiteSpace(entry.path))
                .Select(entry => new SceneSetup
                {
                    path = entry.path,
                    isLoaded = entry.isLoaded,
                    isActive = entry.isActive
                })
                .ToArray();
            if (entries.Length == 0)
            {
                throw new InvalidOperationException(
                    "The original Scene setup contains no saved Scenes.");
            }

            EditorSceneManager.OpenScene(
                job.sceneSetup.activeScenePath,
                OpenSceneMode.Single);
            EditorSceneManager.RestoreSceneManagerSetup(entries);
        }

        private static AICameraABRenderManifest BuildManifest(
            AICameraABRenderRequest request,
            Scene activeScene,
            TimelineAsset timeline,
            AICameraABCandidateResult candidateA,
            AICameraABCandidateResult candidateB,
            AICameraABFairnessInfo fairness,
            string runId)
        {
            var timelinePath = AssetDatabase.GetAssetPath(timeline);
            var result = new AICameraABRenderManifest
            {
                jobId = runId,
                state = "running",
                createdUtc = AICameraABRenderUtility.UtcNow(),
                environment = new AICameraABEnvironmentInfo
                {
                    unityVersion = Application.unityVersion,
                    recorderVersion =
                        GetPackageVersion(typeof(RecorderController).Assembly),
                    cwAiVersion =
                        GetPackageVersion(typeof(AICameraABRenderRunner).Assembly),
                    operatingSystem = SystemInfo.operatingSystem
                },
                scene = new AICameraABSceneInfo
                {
                    scenePath = activeScene.path,
                    sceneGuid = AssetDatabase.AssetPathToGUID(activeScene.path),
                    timelinePath = timelinePath,
                    timelineGuid = AssetDatabase.AssetPathToGUID(timelinePath),
                    timelineFrameRate = timeline.editorSettings.frameRate
                },
                frameRange = new AICameraABFrameRange
                {
                    startFrame = request.startFrame,
                    endFrameExclusive = request.endFrameExclusive,
                    expectedFrameCount =
                        request.endFrameExclusive - request.startFrame,
                    startSeconds =
                        request.startFrame / (double)candidateA.sampleRate,
                    endSecondsExclusive =
                        request.endFrameExclusive /
                        (double)candidateA.sampleRate
                },
                movie = new AICameraABMovieSettings
                {
                    codec = request.codec.ToString(),
                    encoder = request.codec == AICameraABRenderCodec.MP4
                        ? "H.264"
                        : "VP8",
                    quality = "High",
                    width = request.width,
                    height = request.height,
                    aspectRatio = request.width / (double)request.height,
                    frameRate = candidateA.sampleRate,
                    cameraSource = "MainCamera",
                    captureAudio = true,
                    captureAlpha = false
                },
                fairness = fairness,
                audio = CollectAudioProvenance(timeline),
                candidates = new List<AICameraABCandidateResult>
                {
                    candidateA,
                    candidateB
                }
            };
            return result;
        }

        private static List<AICameraABAudioClipInfo> CollectAudioProvenance(
            TimelineAsset timeline)
        {
            var result = new List<AICameraABAudioClipInfo>();
            foreach (var track in timeline.GetOutputTracks()
                         .OfType<AudioTrack>()
                         .Where(track => !track.mutedInHierarchy))
            {
                foreach (var timelineClip in track.GetClips())
                {
                    if (timelineClip.asset is not AudioPlayableAsset audioAsset ||
                        audioAsset.clip == null)
                    {
                        continue;
                    }

                    var assetPath = AssetDatabase.GetAssetPath(audioAsset.clip);
                    var absolutePath = string.IsNullOrWhiteSpace(assetPath)
                        ? string.Empty
                        : Path.GetFullPath(Path.Combine(
                            GetProjectRoot(),
                            assetPath));
                    result.Add(new AICameraABAudioClipInfo
                    {
                        trackName = track.name,
                        clipName = audioAsset.clip.name,
                        assetPath = assetPath,
                        assetGuid = string.IsNullOrWhiteSpace(assetPath)
                            ? string.Empty
                            : AssetDatabase.AssetPathToGUID(assetPath),
                        assetSha256 =
                            !string.IsNullOrWhiteSpace(absolutePath) &&
                            File.Exists(absolutePath)
                                ? AICameraABRenderUtility.ComputeSha256(
                                    absolutePath)
                                : string.Empty,
                        timelineStart = timelineClip.start,
                        clipIn = timelineClip.clipIn,
                        duration = timelineClip.duration
                    });
                }
            }

            return result;
        }

        private static AICameraABSceneSetupSnapshot CaptureSceneSetup()
        {
            var result = new AICameraABSceneSetupSnapshot
            {
                activeScenePath = SceneManager.GetActiveScene().path
            };
            foreach (var scene in EditorSceneManager.GetSceneManagerSetup())
            {
                result.scenes.Add(new AICameraABSceneEntry
                {
                    path = scene.path,
                    isLoaded = scene.isLoaded,
                    isActive = scene.isActive
                });
            }

            return result;
        }

        private static void ConfigureCandidateOutput(
            AICameraABCandidateResult candidate,
            string runDirectory,
            string extension)
        {
            var directoryName = new DirectoryInfo(
                    candidate.generatedDirectory)
                .Name;
            var safeName = AICameraABRenderUtility.SanitizeFileName(
                directoryName);
            candidate.outputBasePath = Path.Combine(
                runDirectory,
                $"{candidate.label}_{safeName}");
            candidate.outputPath =
                $"{candidate.outputBasePath}.{extension}";
            if (File.Exists(candidate.outputPath))
            {
                throw new IOException(
                    $"Recorder output already exists: '{candidate.outputPath}'.");
            }
        }

        private static AICameraABCandidateResult GetCurrentCandidate()
        {
            if (manifest?.candidates == null ||
                job.candidateIndex < 0 ||
                job.candidateIndex >= manifest.candidates.Count)
            {
                throw new InvalidOperationException(
                    "A/B render candidate index is invalid.");
            }

            return manifest.candidates[job.candidateIndex];
        }

        private static PlayableDirector FindDirector(Scene scene, string name)
        {
            return Resources.FindObjectsOfTypeAll<PlayableDirector>()
                .Where(director =>
                    director.gameObject.scene == scene &&
                    director.name == name)
                .SingleOrDefault()
                ?? throw new InvalidOperationException(
                    $"Scene '{scene.name}' does not contain one " +
                    $"'{name}' PlayableDirector.");
        }

        private static void OnPlayModeStateChanged(
            PlayModeStateChange state)
        {
            LoadPersistedState();
            if (job == null)
            {
                return;
            }

            switch (state)
            {
                case PlayModeStateChange.EnteredPlayMode:
                case PlayModeStateChange.EnteredEditMode:
                    EditorApplication.delayCall += ContinueInCurrentMode;
                    break;
            }
        }

        private static void LoadPersistedState()
        {
            if (job == null)
            {
                job = AICameraABRenderUtility.ReadJson<
                    AICameraABRenderJobState>(GetActiveJobPath());
            }

            if (job != null &&
                manifest == null &&
                !string.IsNullOrWhiteSpace(job.manifestPath))
            {
                manifest = AICameraABRenderUtility.ReadJson<
                    AICameraABRenderManifest>(job.manifestPath);
            }
        }

        private static void SaveJob()
        {
            if (job != null)
            {
                AICameraABRenderUtility.WriteJsonAtomic(
                    GetActiveJobPath(),
                    job);
            }
        }

        private static void SaveManifest()
        {
            if (job != null && manifest != null)
            {
                AICameraABRenderUtility.WriteJsonAtomic(
                    job.manifestPath,
                    manifest);
            }
        }

        private static void ReleaseRecorderObjects()
        {
            recorderController = null;
            if (controllerSettings != null)
            {
                UnityEngine.Object.DestroyImmediate(controllerSettings);
                controllerSettings = null;
            }

            if (movieSettings != null)
            {
                UnityEngine.Object.DestroyImmediate(movieSettings);
                movieSettings = null;
            }
        }

        private static void EnsureEditMode()
        {
            if (EditorApplication.isPlayingOrWillChangePlaymode)
            {
                throw new InvalidOperationException(
                    "A/B rendering must be started in Edit Mode.");
            }
        }

        private static void EnsureNoActiveJob()
        {
            LoadPersistedState();
            if (job != null)
            {
                throw new InvalidOperationException(
                    $"A/B render job '{job.jobId}' is already {job.phase}.");
            }
        }

        private static void ClearPreviousTerminalJob()
        {
            LoadPersistedState();
            if (job == null)
            {
                return;
            }

            if (!AICameraABRenderUtility.IsTerminalPhase(job.phase) ||
                job.cleanupPending)
            {
                return;
            }

            EnsureCinemachineSaveDuringPlayDisabled();
            RestoreCinemachineSaveDuringPlay();
            if (!job.cinemachineSaveDuringPlayStateRestored)
            {
                throw new InvalidOperationException(
                    "Cinemachine Save During Play could not be restored.");
            }

            var path = GetActiveJobPath();
            if (File.Exists(path))
            {
                File.Delete(path);
            }

            job = null;
            manifest = null;
        }

        private static void EnsureCinemachineSaveDuringPlayDisabled()
        {
            if (job == null ||
                job.cinemachineSaveDuringPlayStateRestored)
            {
                return;
            }

            if (!job.cinemachineSaveDuringPlayStateCaptured)
            {
                job.cinemachineSaveDuringPlayOriginalEnabled =
                    SaveDuringPlay.Enabled;
                job.cinemachineSaveDuringPlayStateCaptured = true;
                SaveJob();
            }

            SaveDuringPlay.Enabled = false;
        }

        private static void RestoreCinemachineSaveDuringPlay()
        {
            if (job == null ||
                job.cinemachineSaveDuringPlayStateRestored ||
                !job.cinemachineSaveDuringPlayStateCaptured ||
                job.cleanupPending ||
                !AICameraABRenderUtility.IsTerminalPhase(job.phase) ||
                Application.isPlaying ||
                EditorApplication.isPlayingOrWillChangePlaymode)
            {
                return;
            }

            SaveDuringPlay.Enabled =
                job.cinemachineSaveDuringPlayOriginalEnabled;
            job.cinemachineSaveDuringPlayStateRestored = true;
            SaveJob();
        }

        private static void EnsureCleanSavedScenes()
        {
            var sceneCount = SceneManager.sceneCount;
            var dirtyScenes = new List<string>();
            for (var index = 0; index < sceneCount; index++)
            {
                var scene = SceneManager.GetSceneAt(index);
                if (string.IsNullOrWhiteSpace(scene.path))
                {
                    throw new InvalidOperationException(
                        $"Scene '{scene.name}' must be saved before A/B rendering.");
                }

                if (scene.isDirty)
                {
                    dirtyScenes.Add(scene.path);
                }
            }

            if (dirtyScenes.Count > 0)
            {
                throw new InvalidOperationException(
                    "Save or discard dirty Scenes before A/B rendering: " +
                    string.Join(", ", dirtyScenes));
            }
        }

        private static string GetPackageVersion(Assembly assembly)
        {
            try
            {
                return UnityEditor.PackageManager.PackageInfo
                           .FindForAssembly(assembly)?.version ??
                       assembly.GetName().Version?.ToString() ??
                       "unknown";
            }
            catch
            {
                return assembly.GetName().Version?.ToString() ?? "unknown";
            }
        }

        private static DateTime ParseUtcOrNow(string value)
        {
            return DateTime.TryParse(
                value,
                CultureInfo.InvariantCulture,
                DateTimeStyles.RoundtripKind,
                out var parsed)
                ? parsed.ToUniversalTime()
                : DateTime.UtcNow;
        }

        private static string GetProjectRoot()
        {
            return Directory.GetParent(Application.dataPath)?.FullName
                ?? throw new InvalidOperationException(
                    "Unable to resolve the Unity project root.");
        }

        private static string GetDefaultOutputRoot()
        {
            return Path.Combine(
                GetProjectRoot(),
                "Recordings",
                "CWAI_AB");
        }

        private static string GetActiveJobPath()
        {
            return Path.Combine(
                GetProjectRoot(),
                "Library",
                "CWAI",
                "ABRender",
                ActiveJobFileName);
        }

        [Serializable]
        private sealed class StartResponse
        {
            public string jobId;
            public string manifestPath;
            public string runDirectory;
        }
    }
}
