using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;

namespace Streamingle.Editor
{
    public sealed partial class AICameraGeneratorWindow : EditorWindow
    {
        private const string MenuPath =
            "Tools/Streamingle/AI 카메라 생성";
        private const string LegacyMenuPath =
            "Tools/Streamingle/Timeline/AI Camera Generator";
        private static readonly string DefaultCwAiRoot =
            ResolveDefaultCwAiRoot(
                Directory.GetParent(Application.dataPath)?.FullName ??
                Environment.CurrentDirectory,
                Environment.GetEnvironmentVariable("CWAI_ROOT"));
        private const string DefaultSongId = "";
        private const string DefaultDatasetId = "";
        private const int DefaultSeed = 20260804;
        private const int DefaultPreparationModelSeed = 20260729;
        private const double DefaultTargetAspectRatio = 16d / 9d;
        private const float AutonomousMotionIntensity = 1f;
        private const float AutonomousBodyFollowStrength = 0.18f;
        private const string SelectedShotDirectivesFileName =
            "selected_shot_directives.json";
        private const string RuntimeHistoryKeyPrefix =
            "Streamingle.AICameraGenerator.Runtime.";
        internal const string NextGenerationSeedLabel =
            "다음 생성에 사용할 Seed";
        internal const string SelectedCameraRegenerationButtonLabel =
            "선택한 카메라만 다른 안전 후보로 재생성 (위 옵션 적용)";
        internal const string ListedCameraRegenerationButtonLabel =
            "목록의 카메라만 다른 안전 후보로 재생성 (위 옵션 적용)";
        internal const string ReapplyCurrentResultButtonLabel =
            "현재 결과 다시 적용 (Seed 사용 안 함)";
        internal const string ReapplyCurrentResultWarning =
            "'현재 결과 다시 적용'은 생성 결과 폴더를 Timeline에 다시 불러오기만 " +
            "합니다. 새 후보를 생성하지 않으며, 위 Seed를 변경해도 결과는 " +
            "달라지지 않습니다.";
        internal const string SeedCandidateExplanation =
            "Seed를 바꾸면 같은 입력에서 계산한 안전 후보 풀을 재사용하고, " +
            "그 안에서 다른 카메라 조합을 선택합니다. 그래서 빠르지만 매번 " +
            "완전히 새로운 후보 풀을 계산하는 것은 아닙니다.";
        internal const string FreshCandidatePoolOptionLabel =
            "다음 고품질 생성에서 후보 풀 캐시 무시하고 재계산 (1회)";
        internal const string HybridQualityBackendLabel =
            "고품질 생성 (CWCameraWorker · 학습 데이터 기반)";
        internal const string HybridQualityBackendDescription =
            "축적된 작업자 카메라 데이터를 참고해 새 카메라를 생성합니다. " +
            "독립 실행형 CWCameraWorker가 생성하므로 별도 Python 설치가 필요하지 않습니다. " +
            "첫 실행 후 캐시가 만들어지면 이후 실행이 빨라집니다.";
        internal const string FullGenerationAutonomyExplanation =
            "전체 생성은 작업자 데이터의 컷 리듬과 카메라 문법을 사용해 자유롭게 " +
            "구성합니다. 아래 컷 리듬만 전체 결과에 적용됩니다. 거리·무빙·구도 " +
            "옵션은 전체 생성에 적용되지 않습니다.";
        internal const string CutRhythmExplanation =
            "자동은 음악·동작·같은 음원의 작업자 컷과 학습된 샷 길이를 함께 " +
            "분석합니다. 느리게/빠르게는 컷 수만 부드럽게 조절합니다.";
        internal const string SelectedShotOverrideExplanation =
            "아래 옵션은 선택한 카메라를 다시 생성할 때만 적용됩니다. " +
            "고품질 생성도 선택 구간 하나만 계산합니다. 나머지 카메라는 그대로 " +
            "유지되며 같은 카메라를 반복해서 다듬을 수 있습니다.";
        internal const string FullAutonomousGenerationButtonLabel =
            "전체 카메라 자율 생성";
        private static readonly string[] SimpleShotSizeNames =
            { "자동", "풀샷", "미디엄", "클로즈업" };
        private static readonly string[] SimpleMotionNames =
        {
            "자동", "고정", "앞으로 이동", "뒤로 이동", "왼쪽 회전",
            "오른쪽 회전", "왼쪽 이동", "오른쪽 이동", "위로 이동", "아래로 이동"
        };
        private static readonly string[] SimpleCompositionNames =
            { "자동", "중앙", "왼쪽 1/3", "오른쪽 1/3" };
        private static readonly string[] SimpleCutRhythmNames =
            { "자동 (권장)", "느리게", "보통", "빠르게" };

        [SerializeField] private string _cwAiRoot = DefaultCwAiRoot;
        [SerializeField] private string _generationInputRoot = string.Empty;
        [SerializeField] private string _cliExecutable = string.Empty;
        [SerializeField] private string _generatedFolder = string.Empty;
        [SerializeField] private string _songId = DefaultSongId;
        [SerializeField] private string _datasetId = DefaultDatasetId;
        [SerializeField] private int _datasetSongIndex;
        [SerializeField] private PlayableDirector _sourceDirector;
        [SerializeField] private int _seed = DefaultSeed;
        [SerializeField] private bool _forceFreshCandidatePool;
        [SerializeField] private bool _hasLastAppliedSeed;
        [SerializeField] private int _lastAppliedSeed;
        [SerializeField] private bool _lastAppliedSeedWasSelectedShot;
        [SerializeField] private int _lastAppliedSeedShotIndex = -1;
        [SerializeField] private CutRhythmPreference _cutRhythm =
            CutRhythmPreference.Auto;
        [SerializeField] private AICameraTimelinePreviewImporter
            .CurveSimplificationPreset _curvePreset =
                AICameraTimelinePreviewImporter
                    .CurveSimplificationPreset.Balanced;
        [SerializeField] private ShotSizePreference _shotSize =
            ShotSizePreference.Auto;
        [SerializeField] private MotionPreference _motion =
            MotionPreference.Auto;
        [SerializeField] private CompositionPreference _composition =
            CompositionPreference.Auto;
        [SerializeField] private float _distanceMeters;
        [SerializeField, Range(0f, 2f)] private float _motionIntensity = 1f;
        [SerializeField, Range(0f, 1f)] private float _bodyFollowStrength = 0.18f;
        [SerializeField] private int _selectedShotIndex;
        [SerializeField] private string _editorStyleId = string.Empty;
        [SerializeField] private bool _showDirectionSettings;
        [SerializeField] private bool _showAdvancedSettings;
        [SerializeField] private string _status =
            "모션과 음원이 들어 있는 Timeline을 선택하세요.";
        [SerializeField] private string _log = string.Empty;

        private AICameraCliRunner _cliRunner;
        private ProcessCompletionAction _completionAction;
        private string _runningOutputFolder = string.Empty;
        private int _runningShotIndex = -1;
        private int _runningSeed;
        private bool _generationOperationActive;
        private bool _runningSelectedShotOneShot;
        private bool _runningOutputFilesDetected;
        private double _generationStartedAt = -1d;
        private double _generationSourceDurationSeconds;
        private double _lastCompletedDurationSeconds = -1d;
        private float _generationStageProgress = -1f;
        private int _generationStageIndex;
        private int _generationStageCount = 4;
        private string _generationStage = string.Empty;
        private string _runningJobId = string.Empty;
        private double _workerEtaSeconds = -1d;
        private CacheObservation _preparationCacheObservation;
        private CacheObservation _candidateCacheObservation;
        private double _candidateCacheObservedAtElapsedSeconds = -1d;
        private double _importStageObservedAtElapsedSeconds = -1d;
        private string[] _shotNames = Array.Empty<string>();
        private string[] _datasetSongNames = Array.Empty<string>();
        private DatasetSongSummary[] _datasetSongs =
            Array.Empty<DatasetSongSummary>();
        private Vector2 _scrollPosition;
        private Vector2 _logScrollPosition;

        private enum ProcessCompletionAction
        {
            None,
            ImportFull,
            ReplaceSelectedShot,
            BuildPreparationCache
        }

        private enum CutRhythmPreference
        {
            Auto,
            Slow,
            Normal,
            Fast
        }

        private enum ShotSizePreference
        {
            Auto,
            Wide,
            Medium,
            Close
        }

        private enum MotionPreference
        {
            Auto,
            Static,
            DollyIn,
            DollyOut,
            OrbitLeft,
            OrbitRight,
            TruckLeft,
            TruckRight,
            CraneUp,
            CraneDown
        }

        private enum CompositionPreference
        {
            Auto,
            Center,
            LeftThird,
            RightThird
        }

        internal enum CacheObservation
        {
            Unknown,
            Hit,
            Miss
        }

        internal readonly struct RuntimeEstimate
        {
            internal RuntimeEstimate(
                bool isAvailable,
                bool isInitial,
                double minimumTotalSeconds,
                double maximumTotalSeconds,
                double minimumRemainingSeconds,
                double maximumRemainingSeconds)
            {
                IsAvailable = isAvailable;
                IsInitial = isInitial;
                MinimumTotalSeconds = minimumTotalSeconds;
                MaximumTotalSeconds = maximumTotalSeconds;
                MinimumRemainingSeconds = minimumRemainingSeconds;
                MaximumRemainingSeconds = maximumRemainingSeconds;
            }

            internal bool IsAvailable { get; }
            internal bool IsInitial { get; }
            internal double MinimumTotalSeconds { get; }
            internal double MaximumTotalSeconds { get; }
            internal double MinimumRemainingSeconds { get; }
            internal double MaximumRemainingSeconds { get; }
        }

        internal readonly struct GenerationControlScope
        {
            internal GenerationControlScope(
                string shotSize,
                string motion,
                string composition,
                float distanceMeters,
                float motionIntensity,
                float bodyFollowStrength,
                string directivesPath,
                bool usesSelectedShotOverrides)
            {
                ShotSize = shotSize;
                Motion = motion;
                Composition = composition;
                DistanceMeters = distanceMeters;
                MotionIntensity = motionIntensity;
                BodyFollowStrength = bodyFollowStrength;
                DirectivesPath = directivesPath;
                UsesSelectedShotOverrides = usesSelectedShotOverrides;
            }

            internal string ShotSize { get; }
            internal string Motion { get; }
            internal string Composition { get; }
            internal float DistanceMeters { get; }
            internal float MotionIntensity { get; }
            internal float BodyFollowStrength { get; }
            internal string DirectivesPath { get; }
            internal bool UsesSelectedShotOverrides { get; }
        }

        [Serializable]
        private sealed class GeneratedCameraMetadata
        {
            public string schemaVersion;
            public string generationMode;
            public string songId;
            public string datasetId;
            public int frameCount;
            public int sampleRate;
            public int shotCount;
            public string plannerMode;
            public double targetAspectRatio;
            public string worldCameraFile;
            public string timeFile;
            public string shotsFile;
            public GeneratedOutputSha256 outputSha256;
        }

        [Serializable]
        private sealed class GeneratedOutputSha256
        {
            public string worldCamera;
            public string time;
            public string shots;
        }

        [Serializable]
        private sealed class HybridShotFile
        {
            public string schemaVersion;
            public HybridShotBoundary[] shots;
        }

        [Serializable]
        private sealed class HybridShotBoundary
        {
            public int index;
            public int startFrame;
            public int endFrameExclusive;
        }

        [Serializable]
        private sealed class SelectedShotDirectiveDocument
        {
            public string schemaVersion = "camera-directives-v1";
            public SelectedShotDirective[] shots;
        }

        [Serializable]
        private sealed class SelectedShotDirective
        {
            public int index;
            public string shotSize;
            public string motion;
            public string composition;
            public float distanceMeters;
            public float motionIntensity;
            public float bodyFollowStrength;
        }

        [Serializable]
        private sealed class DatasetManifestSummary
        {
            public DatasetSongSummary[] songs;
        }

        [Serializable]
        private sealed class DatasetSongSummary
        {
            public string songName;
            public string folderName;
        }

        [Serializable]
        private sealed class DatasetCatalogSummary
        {
            public DatasetCatalogSongSummary[] songs;
        }

        [Serializable]
        private sealed class DatasetCatalogSongSummary
        {
            public string id;
            public string datasetId;
            public string folder;
        }

        [MenuItem(MenuPath)]
        public static void OpenWindow()
        {
            var window = GetWindow<AICameraGeneratorWindow>();
            window.titleContent = new GUIContent("AI 카메라 생성");
            window.minSize = new Vector2(520f, 480f);
            window.Show();
        }

        [MenuItem(LegacyMenuPath)]
        private static void OpenLegacyWindow()
        {
            OpenWindow();
        }

        private void OnEnable()
        {
            var repairedLibraryRoot = TryAutoRepairGenerationLibraryRoot();
            if (string.IsNullOrWhiteSpace(_cwAiRoot))
            {
                _cwAiRoot = DefaultCwAiRoot;
            }

            if (repairedLibraryRoot &&
                !string.IsNullOrWhiteSpace(_status) &&
                _status.IndexOf(
                    "training_index",
                    StringComparison.OrdinalIgnoreCase) >= 0)
            {
                _status =
                    "참조 데이터 경로를 자동으로 복구했습니다. " +
                    "전체 생성을 다시 실행하세요.";
            }

            if (string.IsNullOrWhiteSpace(_generatedFolder))
            {
                _generatedFolder = Path.Combine(
                    Directory.GetParent(Application.dataPath)?.FullName
                        ?? Environment.CurrentDirectory,
                    "GeneratedCameraOutputs");
            }

            if (string.IsNullOrWhiteSpace(_generationInputRoot))
            {
                var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
                _generationInputRoot = Path.Combine(
                    projectRoot ?? Environment.CurrentDirectory,
                    "DatasetExports");
            }

            if (string.IsNullOrWhiteSpace(_cliExecutable))
            {
                _cliExecutable = ResolveCliExecutable();
            }

            RefreshDatasetSongs(true);
            ValidateOrAutoSelectSourceDirector();
            RefreshShotNames();
            Undo.undoRedoPerformed -= OnUndoRedoPerformed;
            Undo.undoRedoPerformed += OnUndoRedoPerformed;
            EditorApplication.update -= OnEditorUpdate;
            if (_cliRunner != null)
            {
                EditorApplication.update += OnEditorUpdate;
            }
        }

        private void OnDisable()
        {
            Undo.undoRedoPerformed -= OnUndoRedoPerformed;
            // Keep polling an active worker even when the window is closed so its
            // redirected streams are drained and the requested import can finish.
            if (_cliRunner == null)
            {
                EditorApplication.update -= OnEditorUpdate;
            }
        }

        private void OnFocus()
        {
            ValidateOrAutoSelectSourceDirector();
            RefreshShotNames();
        }

        private void OnUndoRedoPerformed()
        {
            RefreshShotNames();
            Repaint();
        }

        private void DrawLegacyGui()
        {
            _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);

            EditorGUILayout.LabelField(
                "AI 카메라 생성",
                EditorStyles.largeLabel);
            EditorGUILayout.HelpBox(
                "생성할 Timeline을 고른 뒤 버튼 하나만 누르면 됩니다. " +
                "데이터 추출, 카메라 생성, Timeline 적용을 자동으로 처리합니다.",
                MessageType.Info);

            DrawSimpleWorkflow();

            EditorGUILayout.Space(12f);
            _showAdvancedSettings = EditorGUILayout.Foldout(
                _showAdvancedSettings,
                "고급 설정 및 관리",
                true);
            if (_showAdvancedSettings)
            {
                EditorGUILayout.HelpBox(
                    "경로, CWCameraWorker, 내부 입력, Seed와 수동 가져오기는 " +
                    "개발·세부 조정이 필요할 때만 사용하세요.",
                    MessageType.None);
                DrawPathSettings();
                EditorGUILayout.Space(8f);
                DrawTargetSettings();
                EditorGUILayout.Space(8f);
                DrawTimelineActions();
                EditorGUILayout.Space(8f);
                DrawGenerationActions();
                EditorGUILayout.Space(8f);
                DrawSceneCopyAction();
                EditorGUILayout.Space(8f);
                DrawStatusAndLog();
            }

            EditorGUILayout.EndScrollView();
        }

        private void DrawSimpleDirectionSettings()
        {
            EditorGUILayout.HelpBox(
                SelectedShotOverrideExplanation,
                MessageType.None);
            _shotSize = (ShotSizePreference)EditorGUILayout.Popup(
                "샷 거리",
                (int)_shotSize,
                SimpleShotSizeNames);
            _motion = (MotionPreference)EditorGUILayout.Popup(
                "카메라 움직임",
                (int)_motion,
                SimpleMotionNames);
            _composition = (CompositionPreference)EditorGUILayout.Popup(
                "인물 구도",
                (int)_composition,
                SimpleCompositionNames);
            _motionIntensity = EditorGUILayout.Slider(
                "움직임 강도",
                _motionIntensity,
                0f,
                2f);
            _distanceMeters = Mathf.Max(
                0f,
                EditorGUILayout.FloatField(
                    new GUIContent(
                        "직접 거리 (m)",
                        "0이면 선택한 샷 거리와 캐릭터 크기에 맞춰 자동 결정합니다."),
                    _distanceMeters));
            _bodyFollowStrength = EditorGUILayout.Slider(
                new GUIContent(
                    "몸 추종 강도",
                    "0이면 무대 기준으로 안정적이고, 1이면 캐릭터 이동을 강하게 따라갑니다."),
                _bodyFollowStrength,
                0f,
                1f);

            if (GUILayout.Button("선택 카메라 옵션을 자동으로 되돌리기"))
            {
                _shotSize = ShotSizePreference.Auto;
                _motion = MotionPreference.Auto;
                _composition = CompositionPreference.Auto;
                _motionIntensity = 1f;
                _distanceMeters = 0f;
                _bodyFollowStrength = 0.18f;
            }
        }

        private void DrawSimpleWorkflow()
        {
            EditorGUILayout.Space(6f);
            EditorGUILayout.LabelField("1. 생성할 Timeline", EditorStyles.boldLabel);
            DrawSimpleSourceDirectorSelector();
            DrawGenerationInputSummary();

            EditorGUILayout.Space(10f);
            EditorGUILayout.LabelField("2. 전체 자율 생성", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox(
                HybridQualityBackendDescription,
                MessageType.None);
            EditorGUILayout.HelpBox(
                FullGenerationAutonomyExplanation,
                MessageType.Info);
            _cutRhythm = (CutRhythmPreference)EditorGUILayout.Popup(
                "컷 리듬",
                (int)_cutRhythm,
                SimpleCutRhythmNames);
            EditorGUILayout.HelpBox(
                CutRhythmExplanation,
                MessageType.None);
            DrawCandidatePoolControls();
            using (new EditorGUI.DisabledScope(
                       IsProcessRunning || !IsSimpleSourceDirectorReady()))
            {
                if (GUILayout.Button(
                        IsProcessRunning
                            ? "생성 중..."
                            : FullAutonomousGenerationButtonLabel,
                        GUILayout.Height(48f)))
                {
                    ExportGenerateAndImport();
                }
            }

            if (!IsSimpleSourceDirectorReady())
            {
                EditorGUILayout.HelpBox(
                    _sourceDirector == null
                        ? "생성할 Timeline을 먼저 선택하세요."
                        : "선택한 Timeline의 모션·캐릭터·음원 구성을 확인하세요.",
                    MessageType.Warning);
            }

            EditorGUILayout.Space(8f);
            EditorGUILayout.LabelField("상태", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox(
                string.IsNullOrWhiteSpace(_status) ? "준비됨" : _status,
                IsProcessRunning ? MessageType.Info : MessageType.None);
            DrawGenerationTiming();
            if (IsProcessRunning)
            {
                using (new EditorGUI.DisabledScope(
                           _cliRunner?.CancellationRequested == true))
                {
                    if (GUILayout.Button("생성 취소", GUILayout.Height(28f)))
                    {
                        CancelGeneration();
                    }
                }
            }
            if (_hasLastAppliedSeed)
            {
                EditorGUILayout.HelpBox(
                    BuildAppliedSeedSummary(
                        _lastAppliedSeed,
                        _seed,
                        _lastAppliedSeedWasSelectedShot,
                        _lastAppliedSeedShotIndex),
                    MessageType.Info);
            }

            if (_shotNames.Length == 0)
            {
                return;
            }

            EditorGUILayout.Space(10f);
            EditorGUILayout.LabelField("3. 결과 다듬기", EditorStyles.boldLabel);
            var hasTimelineCameraSelection =
                TrySyncSelectedShotFromEditor(out var selectedCameraName);
            _selectedShotIndex = Mathf.Clamp(
                _selectedShotIndex,
                0,
                _shotNames.Length - 1);
            if (hasTimelineCameraSelection)
            {
                EditorGUILayout.HelpBox(
                    $"Timeline/Hierarchy 선택: {selectedCameraName}  " +
                    $"(카메라 {_selectedShotIndex:D3})",
                    MessageType.Info);
            }
            else
            {
                EditorGUILayout.HelpBox(
                    "Timeline의 AI Cinemachine 클립 또는 Hierarchy의 AI 카메라를 " +
                    "선택하면 해당 카메라를 자동으로 인식합니다. 아래 목록으로도 " +
                    "직접 고를 수 있습니다.",
                    MessageType.None);
                _selectedShotIndex = EditorGUILayout.Popup(
                    "카메라 직접 선택",
                    _selectedShotIndex,
                    _shotNames);
            }

            EditorGUILayout.Space(6f);
            _showDirectionSettings = EditorGUILayout.Foldout(
                _showDirectionSettings,
                "선택 카메라 재생성 옵션",
                true);
            if (_showDirectionSettings)
            {
                using (new EditorGUI.DisabledScope(IsProcessRunning))
                {
                    DrawSimpleDirectionSettings();
                }
            }

            using (new EditorGUI.DisabledScope(IsProcessRunning))
            {
                using (new EditorGUILayout.HorizontalScope())
                {
                    if (GUILayout.Button(
                            hasTimelineCameraSelection
                                ? SelectedCameraRegenerationButtonLabel
                                : ListedCameraRegenerationButtonLabel,
                            GUILayout.Height(34f)))
                    {
                        StartGeneration(
                            ProcessCompletionAction.ReplaceSelectedShot);
                    }

                    if (GUILayout.Button(
                            "생성 결과 지우기",
                            GUILayout.Height(34f)))
                    {
                        RunEditorAction(
                            "AI 카메라 결과를 제거하는 중...",
                            delegate
                            {
                                var result = RemoveScopedPreview();
                                RefreshShotNames();
                                return result;
                            });
                    }
                }

                EditorGUILayout.Space(10f);
                EditorGUILayout.LabelField(
                    "4. 컷 수정 기록",
                    EditorStyles.boldLabel);
                EditorGUILayout.HelpBox(
                    "카메라 재생성을 모두 마친 뒤 AI Generated Cinemachine " +
                    "트랙의 컷을 수정하고 저장하세요. 유지한 컷도 좋은 제안으로 " +
                    "기록됩니다. 입력한 ID는 로컬에서 SHA-256으로 익명화되며, " +
                    "씬과 Timeline은 저장하지 않고 외부 JSON만 생성합니다.",
                    MessageType.None);
                _editorStyleId = EditorGUILayout.TextField(
                    "익명 편집 스타일 ID",
                    _editorStyleId);
                using (new EditorGUI.DisabledScope(
                           string.IsNullOrWhiteSpace(_editorStyleId) ||
                           _sourceDirector == null))
                {
                    if (GUILayout.Button(
                            "컷 수정 기록 저장",
                            GUILayout.Height(34f)))
                    {
                        RunEditorAction(
                            "컷 수정 기록을 저장하는 중...",
                            CaptureCutCorrectionSnapshot);
                    }
                }

                EditorGUILayout.Space(8f);
                if (GUILayout.Button(
                        "완성본 씬으로 저장",
                        GUILayout.Height(38f)))
                {
                    SaveActiveSceneCopy();
                }
            }
        }

        private void DrawCandidatePoolControls()
        {
            EditorGUILayout.HelpBox(
                SeedCandidateExplanation,
                MessageType.None);
            _forceFreshCandidatePool = EditorGUILayout.ToggleLeft(
                new GUIContent(
                    FreshCandidatePoolOptionLabel,
                    "후보 캐시를 읽지 않고 이번 생성의 후보를 처음부터 다시 계산합니다. " +
                    "평소보다 오래 걸리며, 생성 시작 후 자동으로 해제됩니다."),
                _forceFreshCandidatePool);
            if (_forceFreshCandidatePool)
            {
                EditorGUILayout.HelpBox(
                    "이번 생성은 후보 캐시를 사용하지 않아 시간이 더 오래 걸립니다.",
                    MessageType.Warning);
            }
        }

        private void DrawSimpleSourceDirectorSelector()
        {
            var directors = GetAvailableSourceDirectors();
            if (directors.Length == 0)
            {
                _sourceDirector = null;
                EditorGUILayout.HelpBox(
                    "현재 씬에서 사용할 수 있는 Timeline을 찾지 못했습니다.",
                    MessageType.Error);
                return;
            }

            if (_sourceDirector == null && directors.Length == 1)
            {
                _sourceDirector = directors[0];
                RefreshShotNames();
            }

            var currentIndex = Array.IndexOf(directors, _sourceDirector);
            var labels = new string[directors.Length + 1];
            labels[0] = "Timeline을 선택하세요";
            for (var index = 0; index < directors.Length; index++)
            {
                labels[index + 1] = GetSourceDirectorLabel(directors[index]);
            }

            var selectedIndex = EditorGUILayout.Popup(
                "원본 Timeline",
                currentIndex + 1,
                labels);
            if (selectedIndex - 1 != currentIndex)
            {
                _sourceDirector = selectedIndex <= 0
                    ? null
                    : directors[selectedIndex - 1];
                RefreshShotNames();
            }

            if (_sourceDirector != null)
            {
                EditorGUILayout.LabelField(
                    "선택됨",
                    GetSourceDirectorLabel(_sourceDirector),
                    EditorStyles.miniLabel);
            }
        }

        private static PlayableDirector[] GetAvailableSourceDirectors()
        {
            var scene = SceneManager.GetActiveScene();
            if (!scene.IsValid() || !scene.isLoaded)
            {
                return Array.Empty<PlayableDirector>();
            }

            return Resources.FindObjectsOfTypeAll<PlayableDirector>()
                .Where(value =>
                    value != null &&
                    value.gameObject.scene == scene &&
                    value.playableAsset is TimelineAsset &&
                    !IsAuxiliaryMotionDirector(value) &&
                    !string.Equals(
                        value.name,
                        "Timeline_AI_Final",
                        StringComparison.OrdinalIgnoreCase) &&
                    !AICameraTimelinePreviewImporter
                        .IsPreviewDirectorForEditor(value))
                .OrderBy(value => GetHierarchyPath(value.transform),
                    StringComparer.OrdinalIgnoreCase)
                .ToArray();
        }

        private static bool IsAuxiliaryMotionDirector(
            PlayableDirector director)
        {
            if (director == null ||
                !string.Equals(
                    director.name,
                    "Motion",
                    StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            return director.transform.parent != null &&
                   director.transform.parent.GetComponent<PlayableDirector>()
                       ?.playableAsset is TimelineAsset;
        }

        private static string GetSourceDirectorLabel(PlayableDirector director)
        {
            if (director == null)
            {
                return "(선택 안 됨)";
            }

            var timelineName =
                (director.playableAsset as TimelineAsset)?.name ??
                "TimelineAsset 없음";
            return $"{GetHierarchyPath(director.transform)}  ·  {timelineName}";
        }

        private void DrawGenerationInputSummary()
        {
            if (_sourceDirector == null)
            {
                return;
            }

            var summary = TimelineCameraDatasetExporter
                .GetGenerationInputSummaryForEditor(_sourceDirector);
            if (!summary.isValid)
            {
                EditorGUILayout.HelpBox(
                    string.IsNullOrWhiteSpace(summary.error)
                        ? "캐릭터 모션과 음원을 확인할 수 없습니다."
                        : summary.error,
                    MessageType.Error);
                return;
            }

            EditorGUILayout.HelpBox(
                $"캐릭터: {summary.characterName}\n" +
                $"음원: {summary.audioClipName}\n" +
                $"길이: {summary.duration:F1}초\n" +
                "기존 카메라는 필요하지 않으며 생성 입력으로 사용하지 않습니다.",
                MessageType.None);
        }

        private bool IsSimpleSourceDirectorReady()
        {
            var scene = SceneManager.GetActiveScene();
            if (_sourceDirector == null ||
                !scene.IsValid() ||
                !scene.isLoaded ||
                _sourceDirector.gameObject.scene != scene ||
                _sourceDirector.playableAsset is not TimelineAsset ||
                AICameraTimelinePreviewImporter
                    .IsPreviewDirectorForEditor(_sourceDirector))
            {
                return false;
            }

            return TimelineCameraDatasetExporter
                .GetGenerationInputSummaryForEditor(_sourceDirector)
                .isValid;
        }

        private void DrawPathSettings()
        {
            EditorGUILayout.LabelField("경로", EditorStyles.boldLabel);

            using (new EditorGUILayout.HorizontalScope())
            {
                _cwAiRoot = EditorGUILayout.TextField(
                    "참조 데이터 루트",
                    _cwAiRoot);
                if (GUILayout.Button("찾기", GUILayout.Width(54f)))
                {
                    var selected = EditorUtility.OpenFolderPanel(
                        "CW-AI data library",
                        ExistingDirectoryOrFallback(
                            _cwAiRoot,
                            Directory.GetParent(Application.dataPath)?.FullName),
                        string.Empty);
                    if (!string.IsNullOrWhiteSpace(selected))
                    {
                        _cwAiRoot = selected;
                    }
                }
            }

            using (new EditorGUILayout.HorizontalScope())
            {
                _cliExecutable = EditorGUILayout.TextField(
                    "CWCameraWorker",
                    _cliExecutable);
                if (GUILayout.Button("자동", GUILayout.Width(54f)))
                {
                    _cliExecutable = ResolveCliExecutable(true);
                    SetStatus(string.IsNullOrWhiteSpace(_cliExecutable)
                        ? "CWCameraWorker.exe를 자동으로 찾지 못했습니다."
                        : $"CWCameraWorker: {_cliExecutable}");
                }

                if (GUILayout.Button("찾기", GUILayout.Width(54f)))
                {
                    var selected = EditorUtility.OpenFilePanel(
                        "CWCameraWorker executable",
                        ExistingDirectoryOrFallback(
                            Path.GetDirectoryName(_cliExecutable),
                            _cwAiRoot),
                        "exe");
                    if (!string.IsNullOrWhiteSpace(selected))
                    {
                        _cliExecutable = selected;
                    }
                }
            }

            using (new EditorGUILayout.HorizontalScope())
            {
                _generatedFolder = EditorGUILayout.TextField(
                    "생성 결과 폴더",
                    _generatedFolder);
                if (GUILayout.Button("찾기", GUILayout.Width(54f)))
                {
                    var selected = EditorUtility.OpenFolderPanel(
                        "Generated AI camera directory",
                        ExistingDirectoryOrFallback(
                            _generatedFolder,
                            Path.Combine(_cwAiRoot, "examples")),
                        string.Empty);
                    if (!string.IsNullOrWhiteSpace(selected))
                    {
                        _generatedFolder = selected;
                    }
                }
            }

            using (new EditorGUILayout.HorizontalScope())
            {
                _generationInputRoot = EditorGUILayout.TextField(
                    new GUIContent(
                        "입력 저장 폴더",
                        "선택한 Timeline의 모션·음원 생성 입력을 저장하고 읽을 폴더입니다."),
                    _generationInputRoot);
                if (GUILayout.Button("찾기", GUILayout.Width(54f)))
                {
                    var selected = EditorUtility.OpenFolderPanel(
                        "Generation input DatasetExports directory",
                        ExistingDirectoryOrFallback(
                            _generationInputRoot,
                            Directory.GetParent(Application.dataPath)?.FullName),
                        string.Empty);
                    if (!string.IsNullOrWhiteSpace(selected))
                    {
                        _generationInputRoot = selected;
                        RefreshDatasetSongs(true);
                    }
                }
            }
        }

        private void DrawTargetSettings()
        {
            EditorGUILayout.LabelField(
                "개발용 입력 데이터",
                EditorStyles.boldLabel);
            using (new EditorGUILayout.HorizontalScope())
            {
                EditorGUI.BeginChangeCheck();
                _datasetId = EditorGUILayout.TextField(
                    "입력 묶음 ID",
                    _datasetId);
                if (EditorGUI.EndChangeCheck())
                {
                    RefreshDatasetSongs(true);
                }

                if (GUILayout.Button("목록", GUILayout.Width(64f)))
                {
                    RefreshDatasetSongs(true);
                }
            }

            if (_datasetSongNames.Length > 0)
            {
                _datasetSongIndex = Mathf.Clamp(
                    _datasetSongIndex,
                    0,
                    _datasetSongNames.Length - 1);
                var selectedSongIndex = EditorGUILayout.Popup(
                    "입력 레코드",
                    _datasetSongIndex,
                    _datasetSongNames);
                if (selectedSongIndex != _datasetSongIndex)
                {
                    _datasetSongIndex = selectedSongIndex;
                    _songId = _datasetSongs[_datasetSongIndex].songName;
                    TrySelectSourceDirectorForSong(_songId, true);
                }
            }

            EditorGUI.BeginChangeCheck();
            _songId = EditorGUILayout.TextField("입력 ID / 이름", _songId);
            if (EditorGUI.EndChangeCheck())
            {
                var matchingIndex = Array.FindIndex(
                    _datasetSongs,
                    value => string.Equals(
                        value.songName?.Trim(),
                        _songId.Trim(),
                        StringComparison.Ordinal));
                if (matchingIndex < 0 &&
                    IsSongId(_songId))
                {
                    matchingIndex = FindSongIndexFromCatalog(_songId.Trim());
                }
                if (matchingIndex >= 0)
                {
                    _datasetSongIndex = matchingIndex;
                    TrySelectSourceDirectorForSong(
                        _datasetSongs[matchingIndex].songName,
                        true);
                }
            }
            using (new EditorGUILayout.HorizontalScope())
            {
                _sourceDirector = (PlayableDirector)EditorGUILayout.ObjectField(
                    new GUIContent(
                        "원본 PlayableDirector",
                        "TimeLine/<곡> 구조에서는 선택한 곡의 원본 Director를 명시합니다."),
                    _sourceDirector,
                    typeof(PlayableDirector),
                    true);
                if (GUILayout.Button("자동 찾기", GUILayout.Width(74f)))
                {
                    TrySelectSourceDirectorForSong(GetSelectedSongName(), true);
                }
            }
            if (_sourceDirector != null)
            {
                EditorGUILayout.LabelField(
                    "Timeline",
                    (_sourceDirector.playableAsset as TimelineAsset)?.name ??
                    "(TimelineAsset 없음)",
                    EditorStyles.miniLabel);
            }
            _seed = EditorGUILayout.IntField(
                new GUIContent(
                    NextGenerationSeedLabel,
                    "같은 안전 후보 풀에서 다음 카메라 조합을 선택할 때 사용할 값입니다."),
                _seed);
            _curvePreset =
                (AICameraTimelinePreviewImporter.CurveSimplificationPreset)
                EditorGUILayout.EnumPopup("키 단순화", _curvePreset);
            EditorGUILayout.HelpBox(
                FullGenerationAutonomyExplanation + " 선택 카메라용 옵션은 " +
                "위의 '결과 다듬기' 영역에서 설정합니다.",
                MessageType.Info);
            EditorGUILayout.HelpBox(
                SeedCandidateExplanation + " 생성이 성공하면 Seed는 다음 선택을 " +
                "위해 자동으로 1 증가합니다. '현재 결과 다시 적용'은 Seed를 " +
                "사용하지 않습니다.",
                MessageType.None);
            EditorGUILayout.HelpBox(
                HybridQualityBackendDescription,
                MessageType.None);
        }

        private void DrawTimelineActions()
        {
            EditorGUILayout.LabelField("Timeline", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox(
                ReapplyCurrentResultWarning,
                MessageType.Warning);
            using (new EditorGUI.DisabledScope(IsProcessRunning))
            {
                using (new EditorGUILayout.HorizontalScope())
                {
                    if (GUILayout.Button("현재 Timeline 데이터 추출", GUILayout.Height(30f)))
                    {
                        RunEditorAction(
                            "Timeline 데이터를 추출하는 중...",
                            delegate
                            {
                                var output =
                                    TimelineCameraDatasetExporter
                                        .ExportAll60FpsForCli();
                                ConfigureExportedDataset(
                                    output,
                                    _songId.Trim());
                                return $"추출 완료: {output}\n" +
                                       $"곡 {_datasetSongNames.Length:N0}개를 찾았습니다.";
                            });
                    }

                    if (GUILayout.Button(
                            new GUIContent(
                                ReapplyCurrentResultButtonLabel,
                                ReapplyCurrentResultWarning),
                            GUILayout.Height(30f)))
                    {
                        ImportFullResult(_generatedFolder);
                    }

                    if (GUILayout.Button("결과 제거", GUILayout.Height(30f)))
                    {
                        RunEditorAction(
                            "AI 카메라 결과를 제거하는 중...",
                            delegate
                            {
                                var result =
                                    RemoveScopedPreview();
                                RefreshShotNames();
                                return result;
                            });
                    }
                }
            }

            using (new EditorGUILayout.HorizontalScope())
            {
                EditorGUILayout.PrefixLabel("선택 카메라");
                if (_shotNames.Length == 0)
                {
                    EditorGUILayout.LabelField(
                        "가져온 AI 카메라가 없습니다.",
                        EditorStyles.miniLabel);
                }
                else
                {
                    _selectedShotIndex = Mathf.Clamp(
                        _selectedShotIndex,
                        0,
                        _shotNames.Length - 1);
                    _selectedShotIndex = EditorGUILayout.Popup(
                        _selectedShotIndex,
                        _shotNames);
                }

                if (GUILayout.Button("새로고침", GUILayout.Width(74f)))
                {
                    RefreshShotNames();
                }
            }

            using (new EditorGUI.DisabledScope(
                       IsProcessRunning || _shotNames.Length == 0))
            {
                if (GUILayout.Button(
                        new GUIContent(
                            "현재 결과로 선택 카메라만 교체 (Seed 사용 안 함)",
                            ReapplyCurrentResultWarning),
                        GUILayout.Height(28f)))
                {
                    ImportSelectedShot(_generatedFolder, _selectedShotIndex);
                }
            }
        }

        private void DrawGenerationActions()
        {
            EditorGUILayout.LabelField(
                "안전 후보 선택 및 생성 (비동기)",
                EditorStyles.boldLabel);

            using (new EditorGUI.DisabledScope(IsProcessRunning))
            {
                using (new EditorGUILayout.HorizontalScope())
                {
                    if (GUILayout.Button(
                            "전체 자율 생성 후 가져오기",
                            GUILayout.Height(34f)))
                    {
                        StartGeneration(ProcessCompletionAction.ImportFull);
                    }

                    using (new EditorGUI.DisabledScope(_shotNames.Length == 0))
                    {
                        if (GUILayout.Button(
                                "선택 카메라 재생성 (옵션 적용)",
                                GUILayout.Height(34f)))
                        {
                            StartGeneration(
                                ProcessCompletionAction.ReplaceSelectedShot);
                        }
                    }
                }

                if (GUILayout.Button(
                        "고품질 생성 준비 캐시 만들기 / 갱신",
                        GUILayout.Height(28f)))
                {
                    StartPreparationCacheBuild();
                }
            }

            if (IsProcessRunning)
            {
                EditorGUILayout.HelpBox(
                    $"생성 중 (Seed {_runningSeed})\n{_runningOutputFolder}",
                    MessageType.Info);
                using (new EditorGUI.DisabledScope(
                           _cliRunner?.CancellationRequested == true))
                {
                    if (GUILayout.Button("생성 취소", GUILayout.Height(28f)))
                    {
                        CancelGeneration();
                    }
                }
            }
        }

        private void DrawSceneCopyAction()
        {
            EditorGUILayout.LabelField("완성본 보존", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox(
                "현재 씬 상태를 원본과 별개의 *_AI_Camera_Final.unity 파일로 " +
                "저장합니다. 현재 열린 씬 경로와 저장 여부는 바뀌지 않습니다.",
                MessageType.None);
            using (new EditorGUI.DisabledScope(IsProcessRunning))
            {
                if (GUILayout.Button(
                        "현재 씬을 AI Camera Final 복사본으로 저장",
                        GUILayout.Height(30f)))
                {
                    SaveActiveSceneCopy();
                }
            }
        }

        private void DrawStatusAndLog()
        {
            EditorGUILayout.LabelField("상태", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox(
                string.IsNullOrWhiteSpace(_status) ? "준비됨" : _status,
                MessageType.None);

            using (new EditorGUILayout.HorizontalScope())
            {
                EditorGUILayout.LabelField("로그", EditorStyles.boldLabel);
                if (GUILayout.Button("지우기", GUILayout.Width(54f)))
                {
                    _log = string.Empty;
                }
            }

            _logScrollPosition = EditorGUILayout.BeginScrollView(
                _logScrollPosition,
                GUILayout.MinHeight(150f),
                GUILayout.MaxHeight(280f));
            EditorGUILayout.SelectableLabel(
                string.IsNullOrWhiteSpace(_log)
                    ? "아직 로그가 없습니다."
                    : _log,
                EditorStyles.textArea,
                GUILayout.ExpandHeight(true));
            EditorGUILayout.EndScrollView();
        }

        private bool IsProcessRunning
        {
            get { return _cliRunner != null; }
        }

        private void DrawGenerationTiming()
        {
            if (_generationOperationActive)
            {
                var stageText = _generationStageIndex > 0
                    ? $"{_generationStageIndex}/{_generationStageCount}단계 · " +
                      _generationStage
                    : _generationStage;
                if (_generationStageProgress >= 0f)
                {
                    var progressRect = GUILayoutUtility.GetRect(
                        18f,
                        18f,
                        GUILayout.ExpandWidth(true));
                    EditorGUI.ProgressBar(
                        progressRect,
                        Mathf.Clamp01(_generationStageProgress),
                        stageText);
                }
                else
                {
                    EditorGUILayout.LabelField(
                        stageText,
                        EditorStyles.miniBoldLabel);
                }

                EditorGUILayout.LabelField(
                    BuildRunningTimingText(),
                    EditorStyles.miniLabel);
                return;
            }

            var recentDuration = _lastCompletedDurationSeconds > 0d
                ? _lastCompletedDurationSeconds
                : EditorPrefs.GetFloat(
                    RuntimeHistoryKey("LastSeconds"),
                    -1f);
            if (recentDuration > 0d)
            {
                EditorGUILayout.LabelField(
                    $"최근 완료 소요시간: " +
                    $"{FormatObservedDuration(recentDuration)}",
                    EditorStyles.miniLabel);
            }
        }

        private void BeginGenerationOperation(
            string stage,
            int stageIndex)
        {
            _generationOperationActive = true;
            _generationStartedAt = EditorApplication.timeSinceStartup;
            _generationSourceDurationSeconds = GetSourceDurationSeconds();
            _generationStageProgress = -1f;
            _generationStageIndex = stageIndex;
            _generationStageCount = 4;
            _generationStage = stage;
            _runningOutputFilesDetected = false;
            _preparationCacheObservation = CacheObservation.Unknown;
            _candidateCacheObservation = CacheObservation.Unknown;
            _candidateCacheObservedAtElapsedSeconds = -1d;
            _importStageObservedAtElapsedSeconds = stageIndex >= 4 ? 0d : -1d;
            _workerEtaSeconds = -1d;
            Repaint();
        }

        private string BuildRunningTimingText()
        {
            var elapsed = Math.Max(
                0d,
                EditorApplication.timeSinceStartup - _generationStartedAt);
            if (_workerEtaSeconds >= 0d)
            {
                return $"경과 {FormatObservedDuration(elapsed)} · " +
                       $"예상 남은 시간 {FormatObservedDuration(_workerEtaSeconds)}";
            }

            if (_runningSelectedShotOneShot)
            {
                return BuildSelectedShotGenerationTimingText(
                    elapsed,
                    EditorPrefs.GetFloat(
                        RuntimeHistoryKey("SelectedShot.LastSeconds"),
                        -1f),
                    _preparationCacheObservation,
                    _candidateCacheObservation);
            }

            return BuildGenerationTimingText(
                elapsed,
                GetCurrentRuntimeEstimate(elapsed),
                _preparationCacheObservation,
                _candidateCacheObservation);
        }

        private void UpdateGenerationProgress(
            string stage,
            int stageIndex,
            float stageProgress = -1f)
        {
            if (!_generationOperationActive)
            {
                return;
            }

            _generationStage = stage;
            _generationStageIndex = Mathf.Clamp(
                stageIndex,
                1,
                _generationStageCount);
            if (_generationStageIndex >= 4 &&
                _importStageObservedAtElapsedSeconds < 0d)
            {
                _importStageObservedAtElapsedSeconds = Math.Max(
                    0d,
                    EditorApplication.timeSinceStartup - _generationStartedAt);
            }
            _generationStageProgress = stageProgress < 0f
                ? -1f
                : Mathf.Clamp01(stageProgress);
            Repaint();
        }

        private void CompleteGenerationOperation(bool recordObservation)
        {
            if (!_generationOperationActive)
            {
                return;
            }

            var elapsed = Math.Max(
                0d,
                EditorApplication.timeSinceStartup - _generationStartedAt);
            if (recordObservation && elapsed > 0d)
            {
                _lastCompletedDurationSeconds = elapsed;
                if (_runningSelectedShotOneShot)
                {
                    EditorPrefs.SetFloat(
                        RuntimeHistoryKey("SelectedShot.LastSeconds"),
                        (float)elapsed);
                }
                else
                {
                    RecordRuntimeObservation(
                        elapsed,
                        _generationSourceDurationSeconds,
                        _preparationCacheObservation,
                        _candidateCacheObservation);
                }
                AppendLog(
                    $"[timing] completed in " +
                    $"{FormatObservedDuration(elapsed)}");
            }

            _generationOperationActive = false;
            _generationStageProgress = -1f;
            _generationStageIndex = 0;
            _generationStage = string.Empty;
            _runningSelectedShotOneShot = false;
            _runningOutputFilesDetected = false;
            _candidateCacheObservedAtElapsedSeconds = -1d;
            _importStageObservedAtElapsedSeconds = -1d;
            Repaint();
        }

        private double GetSourceDurationSeconds()
        {
            if (_sourceDirector == null)
            {
                return 0d;
            }

            var duration = _sourceDirector.duration;
            if (double.IsNaN(duration) ||
                double.IsInfinity(duration) ||
                duration <= 0d)
            {
                duration = _sourceDirector.playableAsset?.duration ?? 0d;
            }

            return double.IsNaN(duration) ||
                   double.IsInfinity(duration) ||
                   duration <= 0d
                ? 0d
                : duration;
        }

        private RuntimeEstimate GetCurrentRuntimeEstimate(
            double elapsedSeconds)
        {
            var profileSuffix = RuntimeProfileSuffix(
                _preparationCacheObservation,
                _candidateCacheObservation);
            var profilePrefix = RuntimeHistoryKey(
                profileSuffix);
            var sampleCount = EditorPrefs.GetInt(profilePrefix + "Count", 0);
            var historyMatchesCacheState = sampleCount > 0;
            var keyPrefix = historyMatchesCacheState
                ? profilePrefix
                : RuntimeHistoryKey(string.Empty);
            sampleCount = EditorPrefs.GetInt(keyPrefix + "Count", 0);
            var meanRate = EditorPrefs.GetFloat(
                keyPrefix + "SecondsPerTimelineSecond",
                -1f);
            var deviationRate = EditorPrefs.GetFloat(
                keyPrefix + "Deviation",
                Math.Max(0f, meanRate) * 0.25f);
            return CalculateRuntimeEstimate(
                _generationSourceDurationSeconds,
                elapsedSeconds,
                sampleCount,
                meanRate,
                deviationRate,
                _preparationCacheObservation,
                _candidateCacheObservation,
                _generationStageIndex,
                historyMatchesCacheState,
                _candidateCacheObservedAtElapsedSeconds,
                _importStageObservedAtElapsedSeconds);
        }

        internal static RuntimeEstimate CalculateRuntimeEstimate(
            double sourceDurationSeconds,
            double elapsedSeconds,
            int sampleCount,
            double meanSecondsPerTimelineSecond,
            double deviationSecondsPerTimelineSecond,
            CacheObservation preparationCache = CacheObservation.Unknown,
            CacheObservation candidateCache = CacheObservation.Unknown,
            int stageIndex = 0,
            bool historyMatchesCacheState = false,
            double candidateCacheObservedAtElapsedSeconds = double.NaN,
            double importStageObservedAtElapsedSeconds = double.NaN)
        {
            if (double.IsNaN(sourceDurationSeconds) ||
                double.IsInfinity(sourceDurationSeconds) ||
                sourceDurationSeconds <= 0d)
            {
                return new RuntimeEstimate(
                    false,
                    false,
                    0d,
                    0d,
                    0d,
                    0d);
            }

            elapsedSeconds = Math.Max(0d, elapsedSeconds);
            var hasHistory = sampleCount > 0 &&
                             meanSecondsPerTimelineSecond > 0d &&
                             !double.IsNaN(meanSecondsPerTimelineSecond) &&
                             !double.IsInfinity(meanSecondsPerTimelineSecond);
            double minimumTotal;
            double maximumTotal;
            var initialEstimate = !hasHistory;
            if (initialEstimate)
            {
                // Conservative cold-start ranges measured on this production
                // workstation. They are intentionally broad and are replaced
                // by observed worker timings after the first completed run.
                var baselineTotal = 20d + sourceDurationSeconds * 0.42d;
                minimumTotal = baselineTotal * 0.75d;
                maximumTotal = baselineTotal * 1.40d;
            }
            else
            {
                var safeDeviation = double.IsNaN(
                                        deviationSecondsPerTimelineSecond) ||
                                    double.IsInfinity(
                                        deviationSecondsPerTimelineSecond)
                    ? meanSecondsPerTimelineSecond * 0.25d
                    : Math.Max(0d, deviationSecondsPerTimelineSecond);
                var estimatedTotal =
                    meanSecondsPerTimelineSecond * sourceDurationSeconds;
                var spread = Math.Max(
                    estimatedTotal * (sampleCount == 1 ? 0.30d : 0.15d),
                    safeDeviation * sourceDurationSeconds * 1.5d);
                minimumTotal = Math.Max(0d, estimatedTotal - spread);
                maximumTotal = Math.Max(minimumTotal, estimatedTotal + spread);
            }

            // Until an observation exists for this exact cache profile, use
            // conservative measured penalties/budgets to correct the general
            // backend history as soon as the Python stage log reveals a hit or miss.
            if (!historyMatchesCacheState)
            {
                if (candidateCache == CacheObservation.Hit)
                {
                    var minimumAfterHit = 2d + sourceDurationSeconds * 0.005d;
                    var maximumAfterHit = 12d + sourceDurationSeconds * 0.04d;
                    var observationElapsed =
                        double.IsNaN(candidateCacheObservedAtElapsedSeconds) ||
                        double.IsInfinity(candidateCacheObservedAtElapsedSeconds) ||
                        candidateCacheObservedAtElapsedSeconds < 0d
                            ? elapsedSeconds
                            : Math.Min(
                                elapsedSeconds,
                                candidateCacheObservedAtElapsedSeconds);
                    minimumTotal = observationElapsed + minimumAfterHit;
                    maximumTotal = observationElapsed + maximumAfterHit;
                }
                else if (preparationCache == CacheObservation.Miss)
                {
                    minimumTotal += 10d + sourceDurationSeconds * 0.04d;
                    maximumTotal += 25d + sourceDurationSeconds * 0.12d;
                }
            }

            var minimumRemaining = Math.Max(0d, minimumTotal - elapsedSeconds);
            var maximumRemaining = Math.Max(
                minimumRemaining,
                maximumTotal - elapsedSeconds);

            // Once Unity begins validating/importing output, generation itself
            // is complete. Bound stale historical estimates to the observed
            // import tail instead of showing a long, misleading countdown.
            if (stageIndex >= 4)
            {
                minimumRemaining = 0d;
                var observedImportStart =
                    double.IsNaN(importStageObservedAtElapsedSeconds) ||
                    double.IsInfinity(importStageObservedAtElapsedSeconds) ||
                    importStageObservedAtElapsedSeconds < 0d
                        ? elapsedSeconds
                        : Math.Min(
                            elapsedSeconds,
                            importStageObservedAtElapsedSeconds);
                var importTailBudget =
                    20d + sourceDurationSeconds * 0.02d;
                var importTailRemaining = Math.Max(
                    0d,
                    observedImportStart + importTailBudget - elapsedSeconds);
                maximumRemaining = Math.Min(
                    maximumRemaining,
                    importTailRemaining);
            }

            minimumTotal = Math.Max(elapsedSeconds, elapsedSeconds + minimumRemaining);
            maximumTotal = Math.Max(minimumTotal, elapsedSeconds + maximumRemaining);
            return new RuntimeEstimate(
                true,
                initialEstimate,
                minimumTotal,
                maximumTotal,
                minimumRemaining,
                maximumRemaining);
        }

        internal static string BuildGenerationTimingText(
            double elapsedSeconds,
            RuntimeEstimate estimate,
            CacheObservation preparationCache,
            CacheObservation candidateCache)
        {
            var elapsedText = $"경과 {FormatObservedDuration(elapsedSeconds)}";
            var cacheText = BuildCacheStatusText(
                preparationCache,
                candidateCache);
            if (!estimate.IsAvailable)
            {
                return $"{elapsedText} · {cacheText} · " +
                       "첫 완료 기록을 수집 중";
            }

            var estimateLabel = estimate.IsInitial
                ? "초기 추정 총"
                : "최근 실측 예상 총";
            var totalText = FormatDurationRange(
                estimate.MinimumTotalSeconds,
                estimate.MaximumTotalSeconds);
            var remainingText = estimate.MaximumRemainingSeconds <= 0d
                ? estimate.IsInitial
                    ? "초기 추정 범위를 넘어 작업 중"
                    : "최근 실측 범위를 넘어 작업 중"
                : "남은 시간 " + FormatDurationRange(
                    estimate.MinimumRemainingSeconds,
                    estimate.MaximumRemainingSeconds);
            return $"{elapsedText} · {estimateLabel} {totalText} · " +
                   $"{remainingText} · {cacheText}";
        }

        internal static string BuildSelectedShotGenerationTimingText(
            double elapsedSeconds,
            double lastCompletedSeconds,
            CacheObservation preparationCache,
            CacheObservation candidateCache)
        {
            elapsedSeconds = Math.Max(0d, elapsedSeconds);
            var elapsedText = $"경과 {FormatObservedDuration(elapsedSeconds)}";
            var cacheText = BuildCacheStatusText(
                preparationCache,
                candidateCache);
            if (double.IsNaN(lastCompletedSeconds) ||
                double.IsInfinity(lastCompletedSeconds) ||
                lastCompletedSeconds <= 0d)
            {
                return $"{elapsedText} · 선택 카메라 1개만 계산 중 · " +
                       $"첫 단일 샷 완료 기록을 수집 중 · {cacheText}";
            }

            var minimumTotal = Math.Max(1d, lastCompletedSeconds * 0.65d);
            var maximumTotal = Math.Max(
                minimumTotal,
                lastCompletedSeconds * 1.35d);
            var minimumRemaining = Math.Max(
                0d,
                minimumTotal - elapsedSeconds);
            var maximumRemaining = Math.Max(
                minimumRemaining,
                maximumTotal - elapsedSeconds);
            var remainingText = maximumRemaining <= 0d
                ? "최근 단일 샷 시간을 넘어 작업 중"
                : "예상 남은 시간 " + FormatDurationRange(
                    minimumRemaining,
                    maximumRemaining);
            return $"{elapsedText} · 선택 카메라 1개만 계산 중 · " +
                   $"최근 단일 샷 기준 총 " +
                   $"{FormatDurationRange(minimumTotal, maximumTotal)} · " +
                   $"{remainingText} · {cacheText}";
        }

        internal static string BuildCacheStatusText(
            CacheObservation preparationCache,
            CacheObservation candidateCache)
        {
            if (candidateCache == CacheObservation.Hit)
            {
                return "안전 후보 캐시 사용 중";
            }

            if (candidateCache == CacheObservation.Miss)
            {
                return preparationCache == CacheObservation.Miss
                    ? "준비 캐시 구성 후 새 후보 풀 계산 중"
                    : "새 후보 풀 계산 중";
            }

            if (preparationCache == CacheObservation.Hit)
            {
                return "준비 캐시 사용 · 후보 캐시 확인 중";
            }

            if (preparationCache == CacheObservation.Miss)
            {
                return "준비 캐시 구성 중";
            }

            return "캐시 상태 확인 중";
        }

        internal static CacheObservation ParseCacheObservation(
            string message,
            string cacheName)
        {
            if (string.IsNullOrWhiteSpace(message) ||
                string.IsNullOrWhiteSpace(cacheName))
            {
                return CacheObservation.Unknown;
            }

            var normalized = message
                .ToLowerInvariant()
                .Replace('_', ' ')
                .Replace('-', ' ');
            var normalizedCacheName = cacheName.Trim().ToLowerInvariant();
            if (normalized.IndexOf(
                    normalizedCacheName + " cache",
                    StringComparison.Ordinal) < 0)
            {
                return CacheObservation.Unknown;
            }

            if (normalized.IndexOf("hit", StringComparison.Ordinal) >= 0)
            {
                return CacheObservation.Hit;
            }

            return normalized.IndexOf("miss", StringComparison.Ordinal) >= 0 ||
                   normalized.IndexOf("invalid", StringComparison.Ordinal) >= 0 ||
                   normalized.IndexOf("disabled", StringComparison.Ordinal) >= 0 ||
                   normalized.IndexOf("bypass", StringComparison.Ordinal) >= 0 ||
                   normalized.IndexOf("failed", StringComparison.Ordinal) >= 0 ||
                   normalized.IndexOf("mode off", StringComparison.Ordinal) >= 0
                ? CacheObservation.Miss
                : CacheObservation.Unknown;
        }

        internal static string BuildAppliedSeedSummary(
            int appliedSeed,
            int nextSeed,
            bool selectedShot,
            int shotIndex)
        {
            if (selectedShot)
            {
                var cameraLabel = shotIndex >= 0
                    ? $"{shotIndex:D3}"
                    : "선택 카메라";
                return $"마지막으로 안전 후보를 적용한 카메라: {cameraLabel}\n" +
                       $"해당 카메라 Seed: {appliedSeed}\n" +
                       "나머지 카메라는 기존 결과를 유지합니다.\n" +
                       $"다음 생성에 사용할 Seed: {nextSeed}";
            }

            return $"현재 전체 생성 결과 Seed: {appliedSeed}\n" +
                   $"다음 생성에 사용할 Seed: {nextSeed}";
        }

        private static void RecordRuntimeObservation(
            double elapsedSeconds,
            double sourceDurationSeconds,
            CacheObservation preparationCache,
            CacheObservation candidateCache)
        {
            if (elapsedSeconds <= 0d || sourceDurationSeconds <= 0d)
            {
                return;
            }

            RecordRuntimeObservationAtKey(
                RuntimeHistoryKey(string.Empty),
                elapsedSeconds,
                sourceDurationSeconds);
            EditorPrefs.SetFloat(
                RuntimeHistoryKey("LastSeconds"),
                (float)elapsedSeconds);

            if (preparationCache != CacheObservation.Unknown ||
                candidateCache != CacheObservation.Unknown)
            {
                RecordRuntimeObservationAtKey(
                    RuntimeHistoryKey(
                        RuntimeProfileSuffix(
                            preparationCache,
                            candidateCache)),
                    elapsedSeconds,
                    sourceDurationSeconds);
            }
        }

        private static void RecordRuntimeObservationAtKey(
            string keyPrefix,
            double elapsedSeconds,
            double sourceDurationSeconds)
        {
            var sampleRate = elapsedSeconds / sourceDurationSeconds;
            var count = EditorPrefs.GetInt(keyPrefix + "Count", 0);
            var previousMean = EditorPrefs.GetFloat(
                keyPrefix + "SecondsPerTimelineSecond",
                (float)sampleRate);
            var previousDeviation = EditorPrefs.GetFloat(
                keyPrefix + "Deviation",
                (float)(sampleRate * 0.25d));
            var blend = count <= 0 ? 1d : 0.35d;
            var updatedMean = previousMean * (1d - blend) +
                              sampleRate * blend;
            var updatedDeviation = previousDeviation * (1d - blend) +
                                   Math.Abs(sampleRate - previousMean) * blend;
            EditorPrefs.SetInt(keyPrefix + "Count", Math.Min(1000, count + 1));
            EditorPrefs.SetFloat(
                keyPrefix + "SecondsPerTimelineSecond",
                (float)updatedMean);
            EditorPrefs.SetFloat(
                keyPrefix + "Deviation",
                (float)updatedDeviation);
        }

        private static string RuntimeProfileSuffix(
            CacheObservation preparationCache,
            CacheObservation candidateCache)
        {
            return $"Profile.Preparation{preparationCache}." +
                   $"Candidate{candidateCache}.";
        }

        private static string RuntimeHistoryKey(string suffix)
        {
            return RuntimeHistoryKeyPrefix + "CliHybridQuality." + suffix;
        }

        private static string FormatObservedDuration(double seconds)
        {
            var rounded = Math.Max(0, (int)Math.Round(seconds));
            if (rounded < 60)
            {
                return $"{rounded}초";
            }

            return $"{rounded / 60}분 {rounded % 60}초";
        }

        private static string FormatDurationRange(
            double minimumSeconds,
            double maximumSeconds)
        {
            var minimum = RoundEstimateSeconds(minimumSeconds);
            var maximum = Math.Max(minimum, RoundEstimateSeconds(maximumSeconds));
            if (minimum == maximum)
            {
                return FormatCoarseDuration(maximum);
            }

            return $"{FormatCoarseDuration(minimum)}~" +
                   FormatCoarseDuration(maximum);
        }

        private static int RoundEstimateSeconds(double seconds)
        {
            var interval = seconds >= 60d ? 10d : 5d;
            return Math.Max(
                0,
                (int)(Math.Round(seconds / interval) * interval));
        }

        private static string FormatCoarseDuration(int seconds)
        {
            if (seconds < 60)
            {
                return $"{seconds}초";
            }

            var minutes = seconds / 60;
            var remaining = seconds % 60;
            return remaining == 0
                ? $"{minutes}분"
                : $"{minutes}분 {remaining}초";
        }

        private bool ImportFullResult(string folder)
        {
            var importSucceeded = RunEditorAction(
                "전체 AI 카메라 결과를 가져오는 중...",
                delegate
                {
                    ValidateGeneratedFolder(folder);
                    AICameraTimelinePreviewImporter
                        .ValidateGeneratedDirectoryForCli(folder);
                    if (GetScopedShotNames().Length > 0)
                    {
                        RemoveScopedPreview();
                    }

                    var result =
                        CreateScopedPreview(folder);
                    return result;
                });
            ApplyImportedGeneratedFolder(folder, importSucceeded);
            return importSucceeded;
        }

        private bool ImportSelectedShot(string folder, int shotIndex)
        {
            var importSucceeded = RunEditorAction(
                $"카메라 {shotIndex:D3}을(를) 교체하는 중...",
                delegate
                {
                    ValidateGeneratedFolder(folder);
                    var result =
                        RegenerateScopedShot(folder, shotIndex);
                    return result;
                });
            ApplyImportedGeneratedFolder(folder, importSucceeded);
            return importSucceeded;
        }

        private void ApplyImportedGeneratedFolder(
            string folder,
            bool importSucceeded)
        {
            _generatedFolder = GeneratedFolderAfterImport(
                _generatedFolder,
                folder,
                importSucceeded);
            if (!importSucceeded)
            {
                return;
            }

            _hasLastAppliedSeed = false;
            _lastAppliedSeedWasSelectedShot = false;
            _lastAppliedSeedShotIndex = -1;
            RefreshShotNames();
        }

        internal static string GeneratedFolderAfterImport(
            string currentFolder,
            string candidateFolder,
            bool importSucceeded)
        {
            if (!importSucceeded)
            {
                return currentFolder;
            }

            if (string.IsNullOrWhiteSpace(candidateFolder))
            {
                throw new ArgumentException(
                    "The imported generation folder is required.",
                    nameof(candidateFolder));
            }

            return Path.GetFullPath(candidateFolder);
        }

        private string ResolveSelectedShotBaseGenerationFolder()
        {
            if (_sourceDirector == null ||
                !AICameraTimelinePreviewImporter
                    .TryGetPreviewGenerationProvenanceForEditor(
                        _sourceDirector,
                        out var provenance))
            {
                return _generatedFolder;
            }

            var resolved = ResolveGeneratedFolderFromProvenance(
                _cwAiRoot,
                provenance.CwAiRelativeGeneratedFolder,
                provenance.AbsoluteGeneratedFolder);
            ValidateProvenanceMetadataSha256(
                resolved,
                provenance.MetadataSha256);
            _generatedFolder = resolved;
            return resolved;
        }

        private string CaptureCutCorrectionSnapshot()
        {
            var generatedDirectory =
                ResolveSelectedShotBaseGenerationFolder();
            var outputDirectory = Path.Combine(
                Path.GetFullPath(_cwAiRoot),
                "UserCorrectionLogs",
                "Cuts");
            var outputPath = AICameraCutCorrectionRecorder.CaptureForCli(
                _sourceDirector,
                generatedDirectory,
                _editorStyleId,
                outputDirectory);
            return $"컷 수정 기록 저장 완료: {outputPath}";
        }

        private void RestoreGeneratedFolderFromPreviewProvenance()
        {
            if (_sourceDirector == null)
            {
                return;
            }

            try
            {
                if (!AICameraTimelinePreviewImporter
                        .TryGetPreviewGenerationProvenanceForEditor(
                            _sourceDirector,
                            out var provenance))
                {
                    return;
                }

                var resolved = ResolveGeneratedFolderFromProvenance(
                    _cwAiRoot,
                    provenance.CwAiRelativeGeneratedFolder,
                    provenance.AbsoluteGeneratedFolder);
                ValidateProvenanceMetadataSha256(
                    resolved,
                    provenance.MetadataSha256);
                _generatedFolder = resolved;
            }
            catch (Exception exception)
            {
                AppendLog("[preview provenance] " + exception.Message);
            }
        }

        internal static string ResolveGeneratedFolderFromProvenance(
            string cwAiRoot,
            string cwAiRelativeGeneratedFolder,
            string absoluteGeneratedFolder)
        {
            string relativeCandidate = null;
            if (!string.IsNullOrWhiteSpace(cwAiRoot) &&
                !string.IsNullOrWhiteSpace(cwAiRelativeGeneratedFolder))
            {
                var relative = cwAiRelativeGeneratedFolder
                    .Trim()
                    .Replace('/', Path.DirectorySeparatorChar)
                    .Replace(Path.AltDirectorySeparatorChar,
                        Path.DirectorySeparatorChar);
                if (Path.IsPathRooted(relative) ||
                    relative.Split(Path.DirectorySeparatorChar)
                        .Any(segment =>
                            string.IsNullOrWhiteSpace(segment) ||
                            segment == "." ||
                            segment == ".."))
                {
                    throw new InvalidDataException(
                        "Preview provenance contains an invalid CW-AI relative path.");
                }

                var fullRoot = Path.GetFullPath(cwAiRoot)
                    .TrimEnd(
                        Path.DirectorySeparatorChar,
                        Path.AltDirectorySeparatorChar);
                relativeCandidate = Path.GetFullPath(
                    Path.Combine(fullRoot, relative));
                if (!relativeCandidate.StartsWith(
                        fullRoot + Path.DirectorySeparatorChar,
                        StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidDataException(
                        "Preview provenance resolves outside the configured CW-AI root.");
                }

                if (Directory.Exists(relativeCandidate))
                {
                    return relativeCandidate;
                }
            }

            if (!string.IsNullOrWhiteSpace(absoluteGeneratedFolder))
            {
                var absoluteCandidate = Path.GetFullPath(
                    absoluteGeneratedFolder);
                if (Directory.Exists(absoluteCandidate))
                {
                    return absoluteCandidate;
                }

                if (relativeCandidate == null)
                {
                    relativeCandidate = absoluteCandidate;
                }
            }

            if (!string.IsNullOrWhiteSpace(relativeCandidate))
            {
                return relativeCandidate;
            }

            throw new InvalidDataException(
                "Preview provenance does not contain a generated folder path.");
        }

        internal static void ValidateProvenanceMetadataSha256(
            string generatedFolder,
            string expectedSha256)
        {
            if (string.IsNullOrWhiteSpace(expectedSha256) ||
                expectedSha256.Trim().Length != 64 ||
                expectedSha256.Any(character => !Uri.IsHexDigit(character)))
            {
                throw new InvalidDataException(
                    "Preview provenance metadata SHA-256 is missing or invalid.");
            }

            var metadataPath = Path.Combine(
                Path.GetFullPath(generatedFolder),
                "metadata.json");
            if (!File.Exists(metadataPath))
            {
                throw new FileNotFoundException(
                    "Preview provenance metadata.json was not found.",
                    metadataPath);
            }

            using var algorithm = SHA256.Create();
            var actualSha256 = string.Concat(
                algorithm.ComputeHash(File.ReadAllBytes(metadataPath))
                    .Select(value => value.ToString("x2")));
            if (!string.Equals(
                    actualSha256,
                    expectedSha256.Trim(),
                    StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidDataException(
                    "Preview provenance metadata SHA-256 does not match the " +
                    "materialized generated folder.");
            }
        }

        private void StartGeneration(ProcessCompletionAction completionAction)
        {
            if (IsProcessRunning)
            {
                SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
                return;
            }

            try
            {
                ValidateGenerationSettings(completionAction);
                var selectedShotOneShot =
                    completionAction ==
                    ProcessCompletionAction.ReplaceSelectedShot;
                var baseGenerationFolder = selectedShotOneShot
                    ? ValidateSelectedShotBaseGeneration(
                        ResolveSelectedShotBaseGenerationFolder())
                    : string.Empty;
                if (selectedShotOneShot && _sourceDirector != null)
                {
                    AICameraTimelinePreviewImporter
                        .EnsurePreviewGenerationProvenanceForEditor(
                            _sourceDirector,
                            baseGenerationFolder,
                            _cwAiRoot);
                }
                _runningSelectedShotOneShot = selectedShotOneShot;
                if (!_generationOperationActive)
                {
                    BeginGenerationOperation(
                        "생성 준비",
                        2);
                }

                var outputFolder = CreateUniqueOutputFolderPath();
                if (_forceFreshCandidatePool)
                {
                    _candidateCacheObservation = CacheObservation.Miss;
                    _candidateCacheObservedAtElapsedSeconds = Math.Max(
                        0d,
                        EditorApplication.timeSinceStartup -
                        _generationStartedAt);
                }

                var datasetRoot = Path.Combine(
                    Path.GetFullPath(_cwAiRoot),
                    "DatasetExports");
                var trainingIndex = Path.Combine(
                    Path.GetFullPath(_cwAiRoot),
                    "reports",
                    "training_index.json");
                var controls = CreateGenerationControlScope(
                    completionAction ==
                    ProcessCompletionAction.ReplaceSelectedShot,
                    _selectedShotIndex,
                    outputFolder,
                    ToCliValue(_shotSize),
                    ToCliValue(_motion),
                    ToCliValue(_composition),
                    _distanceMeters,
                    _motionIntensity,
                    _bodyFollowStrength);

                var arguments = BuildGeneratorArguments(
                    datasetRoot,
                    trainingIndex,
                    outputFolder,
                    _seed,
                    controls,
                    selectedShotOneShot ? _selectedShotIndex : -1,
                    baseGenerationFolder).ToList();
                _runningJobId = "unity-" + Guid.NewGuid().ToString("N");
                var cancellationFile = CreateCancellationFilePath(_runningJobId);
                AppendOption(arguments, "--event-format", "jsonl");
                AppendOption(arguments, "--job-id", _runningJobId);
                AppendOption(arguments, "--cancel-file", cancellationFile);

                _completionAction = completionAction;
                _runningOutputFolder = outputFolder;
                _runningShotIndex = _selectedShotIndex;
                _runningSeed = _seed;
                _log = string.Empty;
                UpdateGenerationProgress(
                    selectedShotOneShot
                        ? "선택 카메라 입력 및 캐시 확인"
                        : "입력 분석 및 준비 캐시 확인",
                    2);
                SetStatus(
                    (controls.UsesSelectedShotOverrides
                        ? $"선택 카메라 {_runningShotIndex:D3} 단일 샷 재생성 " +
                          $"시작 (옵션 적용) — Seed {_runningSeed}\n"
                        : $"전체 카메라 자율 생성 시작 — Seed {_runningSeed}\n") +
                    $"{_runningOutputFolder}");
                AppendLog(
                    $"[start] {_cliExecutable} " +
                    AICameraCliRunner.JoinArguments(arguments));

                _cliRunner = new AICameraCliRunner();
                _cliRunner.Start(new AICameraCliRunner.ProcessRequest(
                    _cliExecutable.Trim(),
                    Path.GetFullPath(_cwAiRoot),
                    arguments,
                    cancellationFile));

                // This is deliberately a one-shot escape hatch. Normal Seed
                // changes should return to the fast, reusable candidate pool.
                _forceFreshCandidatePool = false;
                EditorApplication.update -= OnEditorUpdate;
                EditorApplication.update += OnEditorUpdate;
            }
            catch (Exception exception)
            {
                CleanupProcess();
                CompleteGenerationOperation(false);
                ReportException("카메라 생성을 시작하지 못했습니다.", exception);
            }
        }

        private void StartPreparationCacheBuild()
        {
            if (IsProcessRunning)
            {
                SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
                return;
            }

            try
            {
                ValidateGenerationSettings(
                    ProcessCompletionAction.BuildPreparationCache);
                BeginGenerationOperation(
                    "고품질 생성 준비 캐시 구성",
                    2);
                var repositoryRoot = Path.GetFullPath(_cwAiRoot);
                var arguments = BuildPreparationCacheArguments(
                    Path.Combine(repositoryRoot, "DatasetExports"),
                    Path.Combine(repositoryRoot, "reports", "training_index.json"))
                    .ToList();
                _runningJobId = "unity-cache-" + Guid.NewGuid().ToString("N");
                var cancellationFile = CreateCancellationFilePath(_runningJobId);
                AppendOption(arguments, "--event-format", "jsonl");
                AppendOption(arguments, "--job-id", _runningJobId);
                AppendOption(arguments, "--cancel-file", cancellationFile);
                _completionAction = ProcessCompletionAction.BuildPreparationCache;
                _runningOutputFolder = Path.Combine(
                    ResolveProjectCacheRoot(),
                    "hybrid_preparation");
                _runningSeed = _seed;
                _log = string.Empty;
                SetStatus("고품질 생성 준비 캐시를 만드는 중...");
                AppendLog(
                    $"[cache-build] {_cliExecutable} " +
                    AICameraCliRunner.JoinArguments(arguments));
                _cliRunner = new AICameraCliRunner();
                _cliRunner.Start(new AICameraCliRunner.ProcessRequest(
                    _cliExecutable.Trim(),
                    repositoryRoot,
                    arguments,
                    cancellationFile));
                EditorApplication.update -= OnEditorUpdate;
                EditorApplication.update += OnEditorUpdate;
            }
            catch (Exception exception)
            {
                CleanupProcess();
                CompleteGenerationOperation(false);
                ReportException("준비 캐시 생성을 시작하지 못했습니다.", exception);
            }
        }

        private void ExportGenerateAndImport()
        {
            if (IsProcessRunning)
            {
                SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
                return;
            }

            try
            {
                if (!IsSimpleSourceDirectorReady())
                {
                    throw new InvalidOperationException(
                        "모션과 음원이 들어 있는 원본 Timeline을 선택하세요.");
                }

                BeginGenerationOperation(
                    "Timeline 모션·음원 추출",
                    1);
                SetStatus("선택한 Timeline에서 모션과 음원을 추출하는 중...");
                var selectedSourceDirector = _sourceDirector;
                EditorUtility.DisplayProgressBar(
                    "AI 카메라 생성",
                    "Timeline 모션·음원을 추출하는 중...",
                    0.02f);
                string inputDirectory;
                try
                {
                    inputDirectory = TimelineCameraDatasetExporter
                        .ExportGenerationInput60FpsForCli(
                            selectedSourceDirector);
                }
                finally
                {
                    EditorUtility.ClearProgressBar();
                }
                ConfigureExportedDataset(inputDirectory, string.Empty);
                _sourceDirector = selectedSourceDirector;
                if (_datasetSongs.Length != 1)
                {
                    throw new InvalidDataException(
                        "생성 입력은 정확히 하나의 Timeline 레코드여야 합니다.");
                }

                _datasetSongIndex = 0;
                _songId = _datasetSongs[0].songName.Trim();
                AppendLog($"[generation-input] {inputDirectory}");

                StartGeneration(ProcessCompletionAction.ImportFull);
            }
            catch (Exception exception)
            {
                EditorUtility.ClearProgressBar();
                CompleteGenerationOperation(false);
                ReportException("새 카메라 생성을 시작하지 못했습니다.", exception);
            }
        }

        private void OnEditorUpdate()
        {
            DrainProcessMessages();
            DetectGeneratedOutputFiles();
            if (this != null)
            {
                Repaint();
            }

            if (_cliRunner == null)
            {
                return;
            }

            int exitCode;
            try
            {
                if (!_cliRunner.TryGetExitCode(out exitCode))
                {
                    return;
                }
            }
            catch (Exception exception)
            {
                ReportException(
                    "CWCameraWorker 상태를 확인하지 못했습니다.",
                    exception);
                CleanupProcess();
                CompleteGenerationOperation(false);
                return;
            }

            DrainProcessMessages();

            var completedAction = _completionAction;
            var completedFolder = _runningOutputFolder;
            var completedShotIndex = _runningShotIndex;
            var completedSeed = _runningSeed;
            var wasCancelled = _cliRunner.CancellationRequested;
            CleanupProcess();

            if (wasCancelled)
            {
                CompleteGenerationOperation(false);
                SetStatus("카메라 생성이 취소되었습니다.");
                AppendLog("[cancelled] CWCameraWorker stopped by user request");
                return;
            }

            if (exitCode != 0)
            {
                CompleteGenerationOperation(false);
                SetStatus(
                    $"생성 실패 — CWCameraWorker 종료 코드 {exitCode}. 로그를 확인하세요.");
                AppendLog($"[failed] exit code {exitCode}");
                return;
            }

            if (completedAction == ProcessCompletionAction.BuildPreparationCache)
            {
                // Cache-build time is not a camera-generation observation and
                // must not distort the generation ETA history.
                CompleteGenerationOperation(false);
                SetStatus("고품질 생성 준비 캐시가 최신 상태입니다.");
                AppendLog($"[cache-ready] {completedFolder}");
                return;
            }

            try
            {
                UpdateGenerationProgress("생성 결과 검증 및 Timeline 적용", 4);
                ValidateGeneratedFolder(completedFolder);
                AppendLog($"[complete] {completedFolder}");

                var importSucceeded = true;
                if (completedAction == ProcessCompletionAction.ImportFull)
                {
                    importSucceeded = ImportFullResult(completedFolder);
                }
                else if (completedAction ==
                         ProcessCompletionAction.ReplaceSelectedShot)
                {
                    importSucceeded = ImportSelectedShot(
                        completedFolder,
                        completedShotIndex);
                }

                if (!importSucceeded)
                {
                    CompleteGenerationOperation(false);
                    return;
                }

                _seed = completedSeed == int.MaxValue
                    ? 1
                    : completedSeed + 1;
                _lastAppliedSeed = completedSeed;
                _hasLastAppliedSeed = true;
                _lastAppliedSeedWasSelectedShot =
                    completedAction ==
                    ProcessCompletionAction.ReplaceSelectedShot;
                _lastAppliedSeedShotIndex =
                    _lastAppliedSeedWasSelectedShot
                        ? completedShotIndex
                        : -1;
                CompleteGenerationOperation(true);
            }
            catch (Exception exception)
            {
                CompleteGenerationOperation(false);
                ReportException(
                    "생성은 완료됐지만 Unity Timeline 반영에 실패했습니다. " +
                    "생성 결과 폴더는 유지됩니다.",
                    exception);
            }
        }

        private void DrainProcessMessages()
        {
            if (_cliRunner == null)
            {
                return;
            }

            while (_cliRunner.TryDequeueMessage(out var processMessage))
            {
                if (processMessage.IsEvent)
                {
                    ApplyCliEvent(processMessage.Event);
                    continue;
                }

                UpdateProgressFromProcessMessage(processMessage.Text);
                AppendLog(
                    processMessage.IsError
                        ? "[stderr] " + processMessage.Text
                        : processMessage.Text);
            }
        }

        private void ApplyCliEvent(AICameraCliRunner.CliEvent cliEvent)
        {
            if (!string.IsNullOrWhiteSpace(cliEvent.ProtocolVersion) &&
                !string.Equals(
                    cliEvent.ProtocolVersion,
                    AICameraCliRunner.EventProtocolVersion,
                    StringComparison.Ordinal))
            {
                AppendLog(
                    $"[worker protocol] expected " +
                    $"{AICameraCliRunner.EventProtocolVersion}, received " +
                    cliEvent.ProtocolVersion);
            }

            if (!string.IsNullOrWhiteSpace(cliEvent.JobId) &&
                !string.Equals(
                    cliEvent.JobId,
                    _runningJobId,
                    StringComparison.Ordinal))
            {
                AppendLog(
                    "[worker event ignored] unexpected jobId " +
                    cliEvent.JobId);
                return;
            }

            if (cliEvent.EtaSeconds >= 0d)
            {
                _workerEtaSeconds = cliEvent.EtaSeconds;
            }

            var eventType = cliEvent.EventType.ToLowerInvariant();
            var display = !string.IsNullOrWhiteSpace(cliEvent.Message)
                ? cliEvent.Message
                : !string.IsNullOrWhiteSpace(cliEvent.Phase)
                    ? cliEvent.Phase
                    : cliEvent.EventType;
            if (eventType == "progress" || eventType == "stage")
            {
                UpdateGenerationProgress(
                    display,
                    CliStageIndex(cliEvent.Phase),
                    cliEvent.Progress);
            }
            else if (eventType == "result" || eventType == "complete")
            {
                UpdateGenerationProgress("생성 결과 저장 및 검증", 3, 1f);
            }
            else if (eventType == "cancelled")
            {
                SetStatus("CWCameraWorker가 취소 요청을 처리하는 중입니다.");
            }
            else if (eventType == "error")
            {
                AppendLog(
                    "[worker error] " +
                    (!string.IsNullOrWhiteSpace(cliEvent.Error)
                        ? cliEvent.Error
                        : display));
            }

            if (!string.IsNullOrWhiteSpace(cliEvent.Message))
            {
                UpdateProgressFromProcessMessage(cliEvent.Message);
            }
            AppendLog($"[worker:{cliEvent.EventType}] {display}");
        }

        private static int CliStageIndex(string phase)
        {
            var normalized = (phase ?? string.Empty).Trim().ToLowerInvariant();
            if (normalized.Contains("candidate") ||
                normalized.Contains("retarget") ||
                normalized.Contains("generate") ||
                normalized.Contains("output") ||
                normalized.Contains("publish") ||
                normalized.Contains("result"))
            {
                return 3;
            }

            return 2;
        }

        private void UpdateProgressFromProcessMessage(string message)
        {
            if (!_generationOperationActive || string.IsNullOrWhiteSpace(message))
            {
                return;
            }

            var candidateObservation = ParseCacheObservation(
                message,
                "candidate");
            if (candidateObservation != CacheObservation.Unknown)
            {
                if (candidateObservation != _candidateCacheObservation ||
                    _candidateCacheObservedAtElapsedSeconds < 0d)
                {
                    _candidateCacheObservedAtElapsedSeconds = Math.Max(
                        0d,
                        EditorApplication.timeSinceStartup -
                        _generationStartedAt);
                }

                _candidateCacheObservation = candidateObservation;
                UpdateGenerationProgress(
                    candidateObservation == CacheObservation.Hit
                        ? _runningSelectedShotOneShot
                            ? "선택 카메라 안전 후보 캐시 사용"
                            : "안전 후보 캐시 사용 · Seed로 카메라 조합 선택"
                        : _runningSelectedShotOneShot
                            ? "선택 카메라 안전 후보 계산 중"
                            : "새 안전 후보 풀 계산 중",
                    3);
                return;
            }

            var preparationObservation = ParseCacheObservation(
                message,
                "preparation");
            if (preparationObservation != CacheObservation.Unknown)
            {
                _preparationCacheObservation = preparationObservation;
                UpdateGenerationProgress(
                    preparationObservation == CacheObservation.Hit
                        ? "준비 캐시 사용 · 후보 캐시 확인"
                        : "참조 카메라 분석 및 생성 모델 준비 (첫 실행은 오래 걸릴 수 있음)",
                    2);
            }
            else if (message.IndexOf(
                         "Hybrid camera saved to:",
                         StringComparison.OrdinalIgnoreCase) >= 0)
            {
                UpdateGenerationProgress("생성 결과 저장 및 검증", 3);
            }
        }

        private void DetectGeneratedOutputFiles()
        {
            if (!_generationOperationActive ||
                _cliRunner == null ||
                _runningOutputFilesDetected ||
                string.IsNullOrWhiteSpace(_runningOutputFolder))
            {
                return;
            }

            if (File.Exists(Path.Combine(
                    _runningOutputFolder,
                    "generated_camera_world.f32")) &&
                File.Exists(Path.Combine(
                    _runningOutputFolder,
                    "shots.json")))
            {
                _runningOutputFilesDetected = true;
                UpdateGenerationProgress("생성 결과 저장 및 검증", 3);
            }
        }

        private void CleanupProcess()
        {
            if (_cliRunner != null)
            {
                try
                {
                    if (_cliRunner.TryGetExitCode(out _))
                    {
                        _cliRunner.CompleteAndDispose();
                    }
                    else
                    {
                        _cliRunner.Dispose();
                    }
                }
                catch (InvalidOperationException)
                {
                    _cliRunner.Dispose();
                }

                _cliRunner = null;
            }

            _completionAction = ProcessCompletionAction.None;
            _runningOutputFolder = string.Empty;
            _runningShotIndex = -1;
            _runningSeed = 0;
            _runningJobId = string.Empty;
            _workerEtaSeconds = -1d;
            EditorApplication.update -= OnEditorUpdate;
        }

        private void CancelGeneration()
        {
            if (_cliRunner == null || !_cliRunner.IsRunning)
            {
                SetStatus("실행 중인 생성 작업이 없습니다.");
                return;
            }

            try
            {
                if (_cliRunner.RequestCancellation())
                {
                    SetStatus("카메라 생성 취소를 요청했습니다. 현재 단계를 정리하는 중입니다.");
                    AppendLog("[cancel-requested] CWCameraWorker");
                }
            }
            catch (Exception exception)
            {
                ReportException("카메라 생성 취소를 요청하지 못했습니다.", exception);
            }
        }

        private void ValidateGenerationSettings(
            ProcessCompletionAction completionAction)
        {
            TryAutoRepairGenerationLibraryRoot();
            var selectedShotRegeneration =
                completionAction ==
                ProcessCompletionAction.ReplaceSelectedShot;
            if (string.IsNullOrWhiteSpace(_cwAiRoot) ||
                !Directory.Exists(_cwAiRoot))
            {
                throw new DirectoryNotFoundException(
                    $"CW-AI 루트를 찾을 수 없습니다: {_cwAiRoot}");
            }

            var datasetRoot = Path.Combine(
                Path.GetFullPath(_cwAiRoot),
                "DatasetExports");
            if (!Directory.Exists(datasetRoot))
            {
                throw new DirectoryNotFoundException(
                    $"DatasetExports를 찾을 수 없습니다: {datasetRoot}");
            }

            var trainingIndex = Path.Combine(
                Path.GetFullPath(_cwAiRoot),
                "reports",
                "training_index.json");
            if (!File.Exists(trainingIndex))
            {
                throw new FileNotFoundException(
                    "학습 참조 인덱스를 찾지 못했습니다. " +
                    "고급·진단에서 참조 데이터 루트를 확인하세요.\n" +
                    trainingIndex,
                    trainingIndex);
            }

            var cutRankerModel = Path.Combine(
                Path.GetFullPath(_cwAiRoot),
                "models",
                "cut_ranker_v2.json");
            if (!File.Exists(cutRankerModel))
            {
                throw new FileNotFoundException(
                    "컷 타이밍 모델을 찾지 못했습니다. " +
                    "고급·진단에서 참조 데이터 루트를 확인하세요.\n" +
                    cutRankerModel,
                    cutRankerModel);
            }

            if (string.IsNullOrWhiteSpace(_cliExecutable) ||
                !File.Exists(_cliExecutable))
            {
                _cliExecutable = ResolveCliExecutable();
            }

            if (string.IsNullOrWhiteSpace(_cliExecutable) ||
                !File.Exists(_cliExecutable))
            {
                throw new FileNotFoundException(
                    "CWCameraWorker.exe를 찾을 수 없습니다. 고급 설정에서 실행 파일을 지정하세요.",
                    _cliExecutable);
            }

            ValidateCommonGenerationControls(selectedShotRegeneration);
        }

        private void ValidateCommonGenerationControls(
            bool selectedShotRegeneration)
        {
            if (string.IsNullOrWhiteSpace(_songId))
            {
                throw new InvalidOperationException(
                    "생성 입력 레코드를 찾지 못했습니다. Timeline을 다시 추출하세요.");
            }

            if (string.IsNullOrWhiteSpace(_datasetId))
            {
                throw new InvalidOperationException(
                    "생성 입력 묶음을 찾지 못했습니다. Timeline을 다시 추출하세요.");
            }

            if (!selectedShotRegeneration)
            {
                return;
            }

            if (_shotNames.Length == 0 ||
                _selectedShotIndex < 0 ||
                _selectedShotIndex >= _shotNames.Length)
            {
                throw new InvalidOperationException(
                    "재생성할 AI 카메라를 먼저 선택하세요.");
            }

            if (_distanceMeters < 0f ||
                float.IsNaN(_distanceMeters) ||
                float.IsInfinity(_distanceMeters))
            {
                throw new InvalidOperationException(
                    "직접 거리는 0 이상의 유한한 값이어야 합니다.");
            }

            if (_motionIntensity < 0f ||
                float.IsNaN(_motionIntensity) ||
                float.IsInfinity(_motionIntensity))
            {
                throw new InvalidOperationException(
                    "무빙 강도는 0 이상의 유한한 값이어야 합니다.");
            }

            if (_bodyFollowStrength < 0f ||
                _bodyFollowStrength > 1f ||
                float.IsNaN(_bodyFollowStrength) ||
                float.IsInfinity(_bodyFollowStrength))
            {
                throw new InvalidOperationException(
                    "몸 추종 강도는 0에서 1 사이여야 합니다.");
            }

        }

        private static GeneratedCameraMetadata ValidateGeneratedFolder(
            string folder)
        {
            if (string.IsNullOrWhiteSpace(folder))
            {
                throw new ArgumentException(
                    "생성 결과 폴더를 지정하세요.",
                    nameof(folder));
            }

            var fullFolder = Path.GetFullPath(folder);
            if (!Directory.Exists(fullFolder))
            {
                throw new DirectoryNotFoundException(
                    $"생성 결과 폴더를 찾을 수 없습니다: {fullFolder}");
            }

            var metadataPath = Path.Combine(fullFolder, "metadata.json");
            if (!File.Exists(metadataPath))
            {
                throw new FileNotFoundException(
                    "metadata.json을 찾을 수 없습니다.",
                    metadataPath);
            }

            var metadata = JsonUtility.FromJson<GeneratedCameraMetadata>(
                File.ReadAllText(metadataPath));
            if (metadata == null ||
                metadata.frameCount <= 0 ||
                metadata.sampleRate <= 0)
            {
                throw new InvalidDataException(
                    "metadata.json의 frameCount 또는 sampleRate가 올바르지 않습니다.");
            }

            ValidateGeneratedFile(
                fullFolder,
                metadata.worldCameraFile,
                "world camera");
            ValidateGeneratedFile(fullFolder, metadata.timeFile, "time");
            ValidateGeneratedFile(fullFolder, metadata.shotsFile, "shots");
            return metadata;
        }

        private string ValidateSelectedShotBaseGeneration(
            string folder)
        {
            try
            {
                var metadata = ValidateGeneratedFolder(folder);
                var shotsPath = Path.Combine(
                    Path.GetFullPath(folder),
                    metadata.shotsFile);
                var shots = JsonUtility.FromJson<HybridShotFile>(
                    File.ReadAllText(shotsPath));
                ValidateHybridBaseGenerationContract(
                    metadata,
                    shots,
                    DefaultTargetAspectRatio);
                if (!string.Equals(
                        metadata.datasetId,
                        (_datasetId ?? string.Empty).Trim(),
                        StringComparison.Ordinal))
                {
                    throw new InvalidDataException(
                        "기준 결과 datasetId가 현재 Timeline 입력과 다릅니다.");
                }

                var requestedSong = (_songId ?? string.Empty).Trim();
                if (IsSongId(requestedSong) &&
                    !string.Equals(
                        metadata.songId,
                        requestedSong,
                        StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidDataException(
                        "기준 결과 songId가 현재 Timeline 입력과 다릅니다.");
                }

                if (_selectedShotIndex < 0 ||
                    _selectedShotIndex >= metadata.shotCount)
                {
                    throw new InvalidDataException(
                        "선택 카메라 인덱스가 기준 결과의 컷 범위를 벗어났습니다.");
                }

                var sourceDuration = GetSourceDurationSeconds();
                var expectedFrameCount = sourceDuration > 0d
                    ? (int)Math.Round(sourceDuration * metadata.sampleRate)
                    : metadata.frameCount;
                if (expectedFrameCount != metadata.frameCount)
                {
                    throw new InvalidDataException(
                        "기준 결과 frameCount가 현재 Timeline 길이와 다릅니다.");
                }
                AICameraTimelinePreviewImporter
                    .ValidateGeneratedDirectoryForCli(folder);
                return Path.GetFullPath(folder);
            }
            catch (Exception exception)
            {
                throw new InvalidOperationException(
                    "현재 결과를 선택 카메라 고품질 재생성의 기준으로 " +
                    $"사용할 수 없습니다: {exception.Message} " +
                    "먼저 고품질 방식으로 전체 카메라 자율 생성을 실행하세요.",
                    exception);
            }
        }

        internal static void ValidateHybridBaseGenerationContractForTests(
            string metadataJson,
            string shotsJson)
        {
            var metadata = JsonUtility.FromJson<GeneratedCameraMetadata>(
                metadataJson);
            var shots = JsonUtility.FromJson<HybridShotFile>(shotsJson);
            ValidateHybridBaseGenerationContract(
                metadata,
                shots,
                DefaultTargetAspectRatio);
        }

        private static void ValidateHybridBaseGenerationContract(
            GeneratedCameraMetadata metadata,
            HybridShotFile shotFile,
            double expectedAspectRatio)
        {
            if (metadata == null)
            {
                throw new InvalidDataException(
                    "기준 결과 metadata.json이 올바른 JSON 객체가 아닙니다.");
            }

            if (string.IsNullOrWhiteSpace(metadata.schemaVersion) ||
                !metadata.schemaVersion.StartsWith(
                    "3.",
                    StringComparison.Ordinal))
            {
                throw new InvalidDataException(
                    "기준 결과 metadata schema 3.x가 필요합니다.");
            }

            if (string.IsNullOrWhiteSpace(metadata.generationMode) ||
                metadata.generationMode.IndexOf(
                    "hybrid",
                    StringComparison.OrdinalIgnoreCase) < 0)
            {
                throw new InvalidDataException(
                    "기준 결과는 Hybrid composite가 아닙니다 " +
                    "(generationMode 확인 필요). 다른 생성 방식의 결과는 사용할 수 없습니다.");
            }

            if (!string.Equals(
                    metadata.plannerMode,
                    "hierarchical",
                    StringComparison.Ordinal))
            {
                throw new InvalidDataException(
                    "기준 결과 plannerMode는 hierarchical이어야 합니다.");
            }

            if (double.IsNaN(metadata.targetAspectRatio) ||
                double.IsInfinity(metadata.targetAspectRatio) ||
                metadata.targetAspectRatio <= 0d ||
                Math.Abs(metadata.targetAspectRatio - expectedAspectRatio) >
                1e-9d)
            {
                throw new InvalidDataException(
                    $"기준 결과 targetAspectRatio는 {expectedAspectRatio:R}이어야 합니다.");
            }

            if (metadata.frameCount <= 0 ||
                metadata.shotCount <= 0 ||
                string.IsNullOrWhiteSpace(metadata.songId) ||
                string.IsNullOrWhiteSpace(metadata.datasetId))
            {
                throw new InvalidDataException(
                    "기준 결과의 songId, datasetId, frameCount 또는 shotCount가 " +
                    "누락되었습니다.");
            }

            if (!IsDirectGeneratedPayloadName(metadata.worldCameraFile) ||
                !IsDirectGeneratedPayloadName(metadata.timeFile) ||
                !IsDirectGeneratedPayloadName(metadata.shotsFile))
            {
                throw new InvalidDataException(
                    "기준 결과 payload 파일은 결과 폴더의 직접 구성원이어야 합니다.");
            }

            if (metadata.outputSha256 == null ||
                string.IsNullOrWhiteSpace(metadata.outputSha256.worldCamera) ||
                string.IsNullOrWhiteSpace(metadata.outputSha256.time) ||
                string.IsNullOrWhiteSpace(metadata.outputSha256.shots))
            {
                throw new InvalidDataException(
                    "기준 결과 outputSha256 무결성 정보가 필요합니다.");
            }

            if (shotFile == null ||
                !string.Equals(
                    shotFile.schemaVersion,
                    "1.2",
                    StringComparison.Ordinal) ||
                shotFile.shots == null)
            {
                throw new InvalidDataException(
                    "기준 결과 shots.json schema 1.2가 필요합니다.");
            }

            if (shotFile.shots.Length != metadata.shotCount)
            {
                throw new InvalidDataException(
                    "기준 결과 shotCount와 shots.json 행 수가 일치하지 않습니다.");
            }

            var previousEnd = 0;
            for (var index = 0; index < shotFile.shots.Length; index++)
            {
                var shot = shotFile.shots[index];
                if (shot == null ||
                    shot.index != index ||
                    shot.startFrame != previousEnd ||
                    shot.startFrame >= shot.endFrameExclusive ||
                    shot.endFrameExclusive > metadata.frameCount)
                {
                    throw new InvalidDataException(
                        $"기준 결과 shots.json의 {index}번 컷 경계가 올바르지 않습니다.");
                }

                previousEnd = shot.endFrameExclusive;
            }

            if (previousEnd != metadata.frameCount)
            {
                throw new InvalidDataException(
                    "기준 결과 컷이 전체 frameCount를 연속해서 덮지 않습니다.");
            }
        }

        private static bool IsDirectGeneratedPayloadName(string value)
        {
            return !string.IsNullOrWhiteSpace(value) &&
                   !Path.IsPathRooted(value) &&
                   string.Equals(
                       value,
                       Path.GetFileName(value),
                       StringComparison.Ordinal);
        }

        private static void ValidateGeneratedFile(
            string folder,
            string relativePath,
            string label)
        {
            if (string.IsNullOrWhiteSpace(relativePath))
            {
                throw new InvalidDataException(
                    $"metadata.json에 {label} 파일명이 없습니다.");
            }

            var path = Path.Combine(folder, relativePath);
            if (!File.Exists(path))
            {
                throw new FileNotFoundException(
                    $"{label} 파일을 찾을 수 없습니다.",
                    path);
            }
        }

        private IReadOnlyList<string> BuildGeneratorArguments(
            string datasetRoot,
            string trainingIndex,
            string outputFolder,
            int seed,
            GenerationControlScope controls,
            int onlyShotIndex,
            string baseGenerationFolder)
        {
            var arguments = new List<string> { "generate" };
            AppendCurrentInputSelector(arguments);
            AppendOption(arguments, "--dataset-id", _datasetId.Trim());
            AppendOption(arguments, "--output", outputFolder);
            AppendOption(
                arguments,
                "--seed",
                seed.ToString(CultureInfo.InvariantCulture));
            AppendOption(arguments, "--data", datasetRoot);
            var generationInputRoot = string.IsNullOrWhiteSpace(
                _generationInputRoot)
                ? string.Empty
                : Path.GetFullPath(_generationInputRoot);
            if (!string.IsNullOrWhiteSpace(generationInputRoot) &&
                !string.Equals(
                    generationInputRoot.TrimEnd('\\', '/'),
                    Path.GetFullPath(datasetRoot).TrimEnd('\\', '/'),
                    StringComparison.OrdinalIgnoreCase))
            {
                AppendArgument(arguments, generationInputRoot);
            }
            AppendOption(arguments, "--training-index", trainingIndex);
            AppendOption(arguments, "--library-split", "all");
            AppendOption(arguments, "--planner", "hierarchical");
            AppendOption(arguments, "--cut-planner", "data_driven");
            AppendOption(arguments, "--cut-density", ToCliValue(_cutRhythm));
            AppendOption(
                arguments,
                "--cut-ranker-model",
                Path.Combine(
                    Path.GetFullPath(_cwAiRoot),
                    "models",
                    "cut_ranker_v2.json"));
            var compositionValue = controls.Composition;
            AppendOption(
                arguments,
                "--horizontal-framing",
                HybridHorizontalFraming(compositionValue));
            AppendOption(
                arguments,
                "--aspect-ratio",
                DefaultTargetAspectRatio.ToString(
                    "R",
                    CultureInfo.InvariantCulture));
            AppendOption(arguments, "--shot-size", controls.ShotSize);
            AppendOption(arguments, "--motion", controls.Motion);
            AppendOption(
                arguments,
                "--composition",
                compositionValue);
            AppendOption(
                arguments,
                "--distance-meters",
                controls.DistanceMeters.ToString(
                    "R",
                    CultureInfo.InvariantCulture));
            AppendOption(
                arguments,
                "--motion-intensity",
                controls.MotionIntensity.ToString(
                    "R",
                    CultureInfo.InvariantCulture));
            AppendOption(
                arguments,
                "--body-follow-strength",
                controls.BodyFollowStrength.ToString(
                    "R",
                    CultureInfo.InvariantCulture));
            if (!string.IsNullOrWhiteSpace(controls.DirectivesPath))
            {
                AppendOption(
                    arguments,
                    "--directives-json",
                    Path.GetFullPath(controls.DirectivesPath));
            }
            AppendSelectedShotOneShotArguments(
                arguments,
                onlyShotIndex,
                baseGenerationFolder);
            AppendOption(
                arguments,
                "--cache-dir",
                Path.Combine(
                    ResolveProjectCacheRoot(),
                    "camera_director"));
            AppendOption(
                arguments,
                "--preparation-cache-root",
                Path.Combine(
                    ResolveProjectCacheRoot(),
                    "hybrid_preparation"));
            AppendOption(arguments, "--preparation-cache-mode", "auto");
            AppendOption(
                arguments,
                "--candidate-cache-root",
                Path.Combine(
                    ResolveProjectCacheRoot(),
                    "hybrid_candidates"));
            AppendOption(
                arguments,
                "--candidate-cache-mode",
                CandidateCacheMode(_forceFreshCandidatePool));
            AppendOption(
                arguments,
                "--preparation-model-seed",
                DefaultPreparationModelSeed.ToString(
                    CultureInfo.InvariantCulture));
            return arguments;
        }

        internal static string BuildSelectedShotOneShotArguments(
            int onlyShotIndex,
            string baseGenerationFolder)
        {
            var arguments = new List<string>();
            AppendSelectedShotOneShotArguments(
                arguments,
                onlyShotIndex,
                baseGenerationFolder);
            return AICameraCliRunner.JoinArguments(arguments);
        }

        private static void AppendSelectedShotOneShotArguments(
            ICollection<string> arguments,
            int onlyShotIndex,
            string baseGenerationFolder)
        {
            if (onlyShotIndex < 0)
            {
                return;
            }

            if (string.IsNullOrWhiteSpace(baseGenerationFolder))
            {
                throw new ArgumentException(
                    "A base generation folder is required for one-shot regeneration.",
                    nameof(baseGenerationFolder));
            }

            AppendOption(
                arguments,
                "--only-shot-index",
                onlyShotIndex.ToString(CultureInfo.InvariantCulture));
            AppendOption(
                arguments,
                "--base-generation",
                Path.GetFullPath(baseGenerationFolder));
        }

        private IReadOnlyList<string> BuildPreparationCacheArguments(
            string datasetRoot,
            string trainingIndex)
        {
            var arguments = new List<string> { "prepare-cache" };
            AppendCurrentInputSelector(arguments);
            AppendOption(arguments, "--dataset-id", _datasetId.Trim());
            AppendOption(arguments, "--data", datasetRoot);
            var generationInputRoot = string.IsNullOrWhiteSpace(
                _generationInputRoot)
                ? string.Empty
                : Path.GetFullPath(_generationInputRoot);
            if (!string.IsNullOrWhiteSpace(generationInputRoot) &&
                !string.Equals(
                    generationInputRoot.TrimEnd('\\', '/'),
                    Path.GetFullPath(datasetRoot).TrimEnd('\\', '/'),
                    StringComparison.OrdinalIgnoreCase))
            {
                AppendArgument(arguments, generationInputRoot);
            }
            AppendOption(arguments, "--training-index", trainingIndex);
            AppendOption(arguments, "--library-split", "all");
            AppendOption(
                arguments,
                "--preparation-model-seed",
                DefaultPreparationModelSeed.ToString(
                    CultureInfo.InvariantCulture));
            AppendOption(arguments, "--beam-width", "6");
            AppendOption(
                arguments,
                "--cache-dir",
                Path.Combine(
                    ResolveProjectCacheRoot(),
                    "camera_director"));
            AppendOption(
                arguments,
                "--output-root",
                Path.Combine(
                    ResolveProjectCacheRoot(),
                    "hybrid_preparation"));
            return arguments;
        }

        private static string ToCliValue(Enum value)
        {
            var name = value.ToString();
            var builder = new StringBuilder(name.Length + 4);
            for (var index = 0; index < name.Length; index++)
            {
                var character = name[index];
                if (index > 0 && char.IsUpper(character))
                {
                    builder.Append('_');
                }

                builder.Append(char.ToLowerInvariant(character));
            }

            return builder.ToString();
        }

        internal static string CutDensityCliValueForTests(int value)
        {
            if (!Enum.IsDefined(typeof(CutRhythmPreference), value))
            {
                throw new ArgumentOutOfRangeException(nameof(value));
            }

            return ToCliValue((CutRhythmPreference)value);
        }

        internal static string CandidateCacheMode(
            bool forceFreshCandidatePool)
        {
            return forceFreshCandidatePool ? "off" : "auto";
        }

        internal static GenerationControlScope CreateGenerationControlScope(
            bool selectedShotRegeneration,
            int selectedShotIndex,
            string outputFolder,
            string shotSize,
            string motion,
            string composition,
            float distanceMeters,
            float motionIntensity,
            float bodyFollowStrength)
        {
            // Full generation is intentionally autonomous. Serialized values
            // from a previous per-shot edit must never leak into a new result.
            if (!selectedShotRegeneration)
            {
                return new GenerationControlScope(
                    "auto",
                    "auto",
                    "auto",
                    0f,
                    AutonomousMotionIntensity,
                    AutonomousBodyFollowStrength,
                    string.Empty,
                    false);
            }

            if (selectedShotIndex < 0)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(selectedShotIndex),
                    "Selected-shot regeneration requires a valid shot index.");
            }

            if (string.IsNullOrWhiteSpace(outputFolder))
            {
                throw new ArgumentException(
                    "Selected-shot regeneration requires an output folder.",
                    nameof(outputFolder));
            }

            var fullOutputFolder = Path.GetFullPath(outputFolder);
            Directory.CreateDirectory(fullOutputFolder);
            var directivesPath = Path.Combine(
                fullOutputFolder,
                SelectedShotDirectivesFileName);
            var document = new SelectedShotDirectiveDocument
            {
                shots = new[]
                {
                    new SelectedShotDirective
                    {
                        index = selectedShotIndex,
                        shotSize = shotSize,
                        motion = motion,
                        composition = composition,
                        distanceMeters = distanceMeters,
                        motionIntensity = motionIntensity,
                        bodyFollowStrength = bodyFollowStrength
                    }
                }
            };
            File.WriteAllText(
                directivesPath,
                JsonUtility.ToJson(document, true),
                new UTF8Encoding(false));

            // The global controls remain autonomous; only the selected row in
            // the directive document receives the editor overrides.
            return new GenerationControlScope(
                "auto",
                "auto",
                "auto",
                0f,
                AutonomousMotionIntensity,
                AutonomousBodyFollowStrength,
                directivesPath,
                true);
        }

        internal static string HybridHorizontalFraming(string composition)
        {
            var normalized = (composition ?? string.Empty)
                .Trim()
                .ToLowerInvariant()
                .Replace('-', '_');
            switch (normalized)
            {
                case "auto":
                    return "balanced";
                case "center":
                case "centered":
                    return "centered";
                case "left_third":
                    return "left_third";
                case "right_third":
                    return "right_third";
                default:
                    throw new ArgumentException(
                        $"Unsupported composition: {composition}",
                        nameof(composition));
            }
        }

        internal static string PythonSongSelectorOption(string selector)
        {
            return IsSongId(selector)
                ? "--song-id"
                : "--song";
        }

        internal static bool IsSongId(string selector)
        {
            var normalized = (selector ?? string.Empty).Trim();
            return normalized.Length == 16 && normalized.All(Uri.IsHexDigit);
        }

        private void AppendCurrentInputSelector(ICollection<string> arguments)
        {
            var normalized = (_songId ?? string.Empty).Trim();
            var isGenerationInput = !string.IsNullOrWhiteSpace(_datasetId) &&
                                    _datasetId.Trim().StartsWith(
                                        "GenerationInput_",
                                        StringComparison.OrdinalIgnoreCase);
            AppendOption(
                arguments,
                isGenerationInput
                    ? "--song"
                    : PythonSongSelectorOption(normalized),
                normalized);
        }

        private string CreateUniqueOutputFolderPath()
        {
            var parent = Path.Combine(
                Directory.GetParent(Application.dataPath)?.FullName
                    ?? Environment.CurrentDirectory,
                "GeneratedCameraOutputs");
            Directory.CreateDirectory(parent);

            var baseName =
                $"camera_{DateTime.Now:yyyyMMdd_HHmmss}_seed_{_seed}";
            var candidate = Path.Combine(parent, baseName);
            var suffix = 1;
            while (Directory.Exists(candidate) || File.Exists(candidate))
            {
                candidate = Path.Combine(parent, $"{baseName}_{suffix:D2}");
                suffix++;
            }

            return candidate;
        }

        private void SaveActiveSceneCopy()
        {
            RunEditorAction(
                "AI Camera Final 씬 복사본을 저장하는 중...",
                delegate
                {
                    var scene = SceneManager.GetActiveScene();
                    if (!scene.IsValid() || !scene.isLoaded)
                    {
                        throw new InvalidOperationException(
                            "활성 씬이 올바르게 로드되지 않았습니다.");
                    }

                    if (string.IsNullOrWhiteSpace(scene.path))
                    {
                        throw new InvalidOperationException(
                            "먼저 현재 씬을 프로젝트에 저장하세요.");
                    }

                    ValidateOrAutoSelectSourceDirector();
                    var sourceDirector = _sourceDirector ??
                        Resources.FindObjectsOfTypeAll<PlayableDirector>()
                            .SingleOrDefault(value =>
                                value.gameObject.scene == scene &&
                                value.name ==
                                    AICameraTimelinePreviewImporter.OriginalDirectorName &&
                                value.playableAsset is TimelineAsset)
                        ?? throw new InvalidOperationException(
                            "완성본을 만들 원본 PlayableDirector를 선택하세요.");
                    var sourcePreviewDirector =
                        Resources.FindObjectsOfTypeAll<PlayableDirector>()
                            .Where(value =>
                                value.gameObject.scene == scene &&
                                AICameraTimelinePreviewImporter
                                    .IsPreviewDirectorForEditor(value))
                            .SingleOrDefault(value =>
                                value.name ==
                                AICameraTimelinePreviewImporter
                                    .GetScopedPreviewDirectorName(sourceDirector)) ??
                        Resources.FindObjectsOfTypeAll<PlayableDirector>()
                            .Where(value =>
                                value.gameObject.scene == scene &&
                                AICameraTimelinePreviewImporter
                                    .IsPreviewDirectorForEditor(value))
                            .SingleOrDefault();
                    if (sourcePreviewDirector == null)
                    {
                        throw new InvalidOperationException(
                            "선택한 원본 Director의 AI camera preview가 없습니다.");
                    }

                    var directory = Path.GetDirectoryName(scene.path);
                    var uniquePath = GetNextFinalScenePath(scene.path);
                    var finalAssetFolder =
                        $"{directory?.Replace('\\', '/')}/AI_CameraFinal/" +
                        Path.GetFileNameWithoutExtension(uniquePath);
                    var activeSceneBefore = SceneManager.GetActiveScene();
                    var sourcePathBefore = scene.path;
                    var sourceDirtyBefore = scene.isDirty;
                    var sourceTimelineBefore = sourceDirector.playableAsset;
                    var sourceTimeBefore = sourceDirector.time;
                    var previewTimelineBefore = sourcePreviewDirector.playableAsset;
                    var previewTimeBefore = sourcePreviewDirector.time;
                    Scene destinationScene = default;
                    var sceneAssetCreated = false;
                    try
                    {
                        if (!EditorSceneManager.SaveScene(scene, uniquePath, true))
                        {
                            throw new IOException(
                                $"씬 복사본을 저장하지 못했습니다: {uniquePath}");
                        }

                        sceneAssetCreated = true;
                        destinationScene = EditorSceneManager.OpenScene(
                            uniquePath,
                            OpenSceneMode.Additive);
                        var destinationDirector =
                            Resources.FindObjectsOfTypeAll<PlayableDirector>()
                                .SingleOrDefault(value =>
                                    value.gameObject.scene == destinationScene &&
                                    value.name == sourcePreviewDirector.name)
                            ?? throw new InvalidOperationException(
                                "복사된 씬에서 AI preview Director를 찾지 못했습니다.");
                        var result = AICameraTimelinePreviewImporter.SaveFinalForEditor(
                            sourceDirector,
                            destinationDirector,
                            finalAssetFolder);

                        var disposablePreviewRoots =
                            Resources.FindObjectsOfTypeAll<GameObject>()
                                .Where(value =>
                                    value != null &&
                                    value.scene == destinationScene &&
                                    value != result.CameraRoot &&
                                    (value.name ==
                                         AICameraTimelinePreviewImporter
                                             .PreviewCameraName ||
                                     value.name.StartsWith(
                                         AICameraTimelinePreviewImporter
                                             .PreviewCameraName + "__",
                                         StringComparison.Ordinal)))
                                .ToArray();
                        foreach (var previewRoot in disposablePreviewRoots)
                        {
                            UnityEngine.Object.DestroyImmediate(previewRoot);
                        }

                        destinationDirector.name = "Timeline_AI_Final";
                        EditorUtility.SetDirty(destinationDirector);
                        EditorSceneManager.MarkSceneDirty(destinationScene);
                        if (!EditorSceneManager.SaveScene(destinationScene))
                        {
                            throw new IOException(
                                $"독립 Final 씬을 저장하지 못했습니다: {uniquePath}");
                        }

                        AssetDatabase.SaveAssets();
                        return string.Join(
                            Environment.NewLine,
                            $"독립 Final 씬 저장 완료: {uniquePath}",
                            $"Final assets: {result.AssetFolderPath}",
                            $"Timeline: {AssetDatabase.GetAssetPath(result.Timeline)}",
                            $"Animation clips: {result.AnimationClips.Count:N0}");
                    }
                    catch
                    {
                        if (destinationScene.IsValid() && destinationScene.isLoaded)
                        {
                            EditorSceneManager.CloseScene(destinationScene, true);
                        }

                        if (sceneAssetCreated)
                        {
                            AssetDatabase.DeleteAsset(uniquePath);
                        }

                        throw;
                    }
                    finally
                    {
                        if (destinationScene.IsValid() && destinationScene.isLoaded)
                        {
                            EditorSceneManager.CloseScene(destinationScene, true);
                        }

                        if (activeSceneBefore.IsValid() && activeSceneBefore.isLoaded &&
                            SceneManager.GetActiveScene() != activeSceneBefore)
                        {
                            SceneManager.SetActiveScene(activeSceneBefore);
                        }

                        if (scene.path != sourcePathBefore ||
                            sourceDirector.playableAsset != sourceTimelineBefore ||
                            sourcePreviewDirector.playableAsset != previewTimelineBefore ||
                            Math.Abs(sourceDirector.time - sourceTimeBefore) > double.Epsilon ||
                            Math.Abs(sourcePreviewDirector.time - previewTimeBefore) >
                                double.Epsilon ||
                            scene.isDirty != sourceDirtyBefore)
                        {
                            throw new InvalidOperationException(
                                "Final 저장 후 원본 scene/path/Timeline 상태가 달라졌습니다.");
                        }
                    }
                });
        }

        internal static string GetNextFinalScenePath(string sourceScenePath)
        {
            var normalized = (sourceScenePath ?? string.Empty)
                .Replace('\\', '/');
            var directory = Path.GetDirectoryName(normalized)
                ?.Replace('\\', '/');
            var sourceName = Path.GetFileNameWithoutExtension(normalized);
            if (string.IsNullOrWhiteSpace(directory) ||
                string.IsNullOrWhiteSpace(sourceName) ||
                !normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
            {
                throw new ArgumentException(
                    "Source scene must be an Assets-relative .unity path.",
                    nameof(sourceScenePath));
            }

            const string marker = "_AI_Camera_Final";
            var markerIndex = sourceName.LastIndexOf(
                marker,
                StringComparison.Ordinal);
            string finalBaseName;
            var version = 0;
            if (markerIndex >= 0)
            {
                finalBaseName = sourceName.Substring(0, markerIndex) + marker;
                var suffix = sourceName.Substring(markerIndex + marker.Length);
                if (suffix.StartsWith("_v", StringComparison.OrdinalIgnoreCase) &&
                    int.TryParse(
                        suffix.Substring(2),
                        NumberStyles.None,
                        CultureInfo.InvariantCulture,
                        out var parsedVersion))
                {
                    version = Math.Max(1, parsedVersion + 1);
                }
                else
                {
                    version = 2;
                }
            }
            else
            {
                finalBaseName = sourceName + marker;
            }

            while (true)
            {
                var fileName = version > 0
                    ? $"{finalBaseName}_v{version}.unity"
                    : $"{finalBaseName}.unity";
                var candidate = $"{directory}/{fileName}";
                if (AssetDatabase.LoadAssetAtPath<SceneAsset>(candidate) == null &&
                    !File.Exists(Path.GetFullPath(candidate)))
                {
                    return candidate;
                }

                version = Math.Max(2, version + 1);
            }
        }

        private void RefreshShotNames()
        {
            try
            {
                RestoreGeneratedFolderFromPreviewProvenance();
                _shotNames =
                    GetScopedShotNames() ??
                    Array.Empty<string>();
                if (_shotNames.Length == 0)
                {
                    _selectedShotIndex = 0;
                }
                else
                {
                    _selectedShotIndex = Mathf.Clamp(
                        _selectedShotIndex,
                        0,
                        _shotNames.Length - 1);
                }
            }
            catch (Exception exception)
            {
                _shotNames = Array.Empty<string>();
                AppendLog("[shot list] " + exception.Message);
            }
        }

        private bool TrySyncSelectedShotFromEditor(out string cameraName)
        {
            cameraName = string.Empty;
            if (_sourceDirector == null || _shotNames.Length == 0)
            {
                return false;
            }

            try
            {
                if (!AICameraTimelinePreviewImporter
                        .TryGetSelectedGeneratedShotForEditor(
                            _sourceDirector,
                            out var shotIndex,
                            out cameraName) ||
                    shotIndex < 0 ||
                    shotIndex >= _shotNames.Length)
                {
                    cameraName = string.Empty;
                    return false;
                }

                _selectedShotIndex = shotIndex;
                return true;
            }
            catch
            {
                // Timeline selection is optional. Keep the explicit popup as a
                // stable fallback when no Timeline window/preview is available.
                cameraName = string.Empty;
                return false;
            }
        }

        private bool ConfigureExportedDataset(
            string datasetDirectory,
            string preferredSong)
        {
            var datasetInfo = new DirectoryInfo(datasetDirectory);
            _generationInputRoot = datasetInfo.Parent?.FullName
                ?? throw new InvalidOperationException(
                    "내보낸 데이터셋의 상위 폴더를 확인할 수 없습니다.");
            _datasetId = datasetInfo.Name;
            _songId = preferredSong ?? string.Empty;
            return RefreshDatasetSongs(true);
        }

        private string[] GetScopedShotNames()
        {
            return _sourceDirector != null
                ? AICameraTimelinePreviewImporter.GetGeneratedShotNamesForEditor(
                    _sourceDirector)
                : AICameraTimelinePreviewImporter.GetGeneratedShotNamesForEditor();
        }

        private string RemoveScopedPreview()
        {
            return _sourceDirector != null
                ? AICameraTimelinePreviewImporter.RemovePreviewForCli(_sourceDirector)
                : AICameraTimelinePreviewImporter.RemovePreviewForCli();
        }

        private string CreateScopedPreview(string folder)
        {
            return _sourceDirector != null
                ? AICameraTimelinePreviewImporter.CreatePreviewForCli(
                    folder,
                    _curvePreset,
                    _sourceDirector,
                    _cwAiRoot)
                : AICameraTimelinePreviewImporter.CreatePreviewForCli(
                    folder,
                    _curvePreset);
        }

        private string RegenerateScopedShot(string folder, int shotIndex)
        {
            return _sourceDirector != null
                ? AICameraTimelinePreviewImporter.RegenerateShotForCli(
                    folder,
                    shotIndex,
                    _curvePreset,
                    _sourceDirector,
                    _cwAiRoot)
                : AICameraTimelinePreviewImporter.RegenerateShotForCli(
                    folder,
                    shotIndex,
                    _curvePreset);
        }

        private bool RefreshDatasetSongs(bool selectFirstWhenMissing)
        {
            _datasetSongNames = Array.Empty<string>();
            _datasetSongs = Array.Empty<DatasetSongSummary>();
            if (string.IsNullOrWhiteSpace(_generationInputRoot) ||
                string.IsNullOrWhiteSpace(_datasetId))
            {
                _datasetSongIndex = 0;
                return false;
            }

            var manifestPath = Path.Combine(
                Path.GetFullPath(_generationInputRoot),
                _datasetId.Trim(),
                "dataset_manifest.json");
            if (!File.Exists(manifestPath))
            {
                _datasetSongIndex = 0;
                return false;
            }

            var manifest = JsonUtility.FromJson<DatasetManifestSummary>(
                File.ReadAllText(manifestPath));
            _datasetSongs = (manifest?.songs ?? Array.Empty<DatasetSongSummary>())
                .Where(value => value != null &&
                                !string.IsNullOrWhiteSpace(value.songName))
                .ToArray();
            _datasetSongNames = _datasetSongs
                .Select((value, index) =>
                {
                    var duplicate = _datasetSongs.Count(other =>
                        string.Equals(
                            other.songName?.Trim(),
                            value.songName?.Trim(),
                            StringComparison.Ordinal)) > 1;
                    return duplicate
                        ? $"{value.songName.Trim()} [{value.folderName ?? index.ToString()}]"
                        : value.songName.Trim();
                })
                .ToArray();
            if (_datasetSongNames.Length == 0)
            {
                _datasetSongIndex = 0;
                return false;
            }

            var currentSong = (_songId ?? string.Empty).Trim();
            var matchingIndex = Array.FindIndex(
                _datasetSongs,
                value => string.Equals(
                    value.songName?.Trim(),
                    currentSong,
                    StringComparison.Ordinal));
            if (matchingIndex < 0 && IsSongId(currentSong))
            {
                matchingIndex = FindSongIndexFromCatalog(currentSong);
            }

            if (matchingIndex >= 0)
            {
                _datasetSongIndex = matchingIndex;
                // Preserve an explicit catalog ID. A popup selection intentionally
                // switches back to the human-readable song-name selector.
                if (!IsSongId(currentSong))
                {
                    _songId = _datasetSongs[matchingIndex].songName.Trim();
                }
                TrySelectSourceDirectorForSong(
                    _datasetSongs[matchingIndex].songName,
                    false);
                return true;
            }

            _datasetSongIndex = 0;
            if (selectFirstWhenMissing)
            {
                _songId = _datasetSongs[0].songName.Trim();
                TrySelectSourceDirectorForSong(_songId, true);
            }

            return false;
        }

        private int FindSongIndexFromCatalog(string songId)
        {
            var catalogPath = Path.Combine(
                Path.GetFullPath(_generationInputRoot),
                "dataset_catalog.json");
            if (!File.Exists(catalogPath))
            {
                return -1;
            }

            try
            {
                var catalog = JsonUtility.FromJson<DatasetCatalogSummary>(
                    File.ReadAllText(catalogPath));
                var entry = catalog?.songs?.FirstOrDefault(value =>
                    value != null &&
                    string.Equals(value.id, songId, StringComparison.OrdinalIgnoreCase) &&
                    string.Equals(value.datasetId, _datasetId.Trim(),
                        StringComparison.Ordinal));
                var folderName = string.IsNullOrWhiteSpace(entry?.folder)
                    ? string.Empty
                    : entry.folder.Replace('\\', '/').Split('/').Last();
                return Array.FindIndex(
                    _datasetSongs,
                    value => string.Equals(
                        value.folderName,
                        folderName,
                        StringComparison.OrdinalIgnoreCase));
            }
            catch (Exception exception)
            {
                AppendLog("[song catalog] " + exception.Message);
                return -1;
            }
        }

        private string GetSelectedSongName()
        {
            return _datasetSongs.Length > 0 &&
                   _datasetSongIndex >= 0 &&
                   _datasetSongIndex < _datasetSongs.Length
                ? _datasetSongs[_datasetSongIndex].songName
                : (_songId ?? string.Empty).Trim();
        }

        private void ValidateOrAutoSelectSourceDirector()
        {
            var scene = SceneManager.GetActiveScene();
            if (_sourceDirector != null &&
                _sourceDirector.gameObject.scene == scene &&
                _sourceDirector.playableAsset is TimelineAsset)
            {
                return;
            }

            _sourceDirector = null;
            TrySelectSourceDirectorForSong(GetSelectedSongName(), false);
        }

        private void TrySelectSourceDirectorForSong(
            string songName,
            bool replaceExisting)
        {
            var scene = SceneManager.GetActiveScene();
            if (!scene.IsValid() || !scene.isLoaded)
            {
                return;
            }

            if (!replaceExisting && _sourceDirector != null &&
                _sourceDirector.gameObject.scene == scene &&
                _sourceDirector.playableAsset is TimelineAsset)
            {
                return;
            }

            var normalizedSong = (songName ?? string.Empty).Trim();
            var directors = Resources.FindObjectsOfTypeAll<PlayableDirector>()
                .Where(value =>
                    value != null &&
                    value.gameObject.scene == scene &&
                    value.playableAsset is TimelineAsset &&
                    !AICameraTimelinePreviewImporter
                        .IsPreviewDirectorForEditor(value))
                .ToArray();
            if (directors.Length == 0)
            {
                _sourceDirector = null;
                return;
            }

            var matches = directors
                .Select(value => new
                {
                    Director = value,
                    Score = ScoreDirectorForSong(value, normalizedSong)
                })
                .Where(value => value.Score > 0)
                .OrderByDescending(value => value.Score)
                .ToArray();
            if (matches.Length > 0 &&
                (matches.Length == 1 || matches[0].Score > matches[1].Score))
            {
                _sourceDirector = matches[0].Director;
                return;
            }

            // Backward compatibility: the original single-Timeline scene still
            // auto-selects its only usable director.
            _sourceDirector = directors.Length == 1 ? directors[0] : null;
        }

        private static int ScoreDirectorForSong(
            PlayableDirector director,
            string songName)
        {
            if (director == null || string.IsNullOrWhiteSpace(songName))
            {
                return 0;
            }

            var score = 0;
            if (string.Equals(director.name, songName,
                    StringComparison.OrdinalIgnoreCase))
            {
                score += 100;
            }

            if (string.Equals(director.transform.parent?.name, songName,
                    StringComparison.OrdinalIgnoreCase))
            {
                score += 80;
            }

            if (director.playableAsset is TimelineAsset timeline &&
                timeline.name.IndexOf(songName,
                    StringComparison.OrdinalIgnoreCase) >= 0)
            {
                score += 40;
            }

            var path = GetHierarchyPath(director.transform);
            if (path.IndexOf($"TimeLine/{songName}",
                    StringComparison.OrdinalIgnoreCase) >= 0)
            {
                score += 120;
            }

            return score;
        }

        private static string GetHierarchyPath(Transform transform)
        {
            var parts = new System.Collections.Generic.Stack<string>();
            for (var current = transform; current != null; current = current.parent)
            {
                parts.Push(current.name);
            }

            return string.Join("/", parts);
        }

        private bool RunEditorAction(
            string runningStatus,
            Func<string> action)
        {
            try
            {
                SetStatus(runningStatus);
                var result = action();
                SetStatus(result);
                AppendLog(result);
                return true;
            }
            catch (Exception exception)
            {
                ReportException(runningStatus + " 실패", exception);
                return false;
            }
        }

        private void ReportException(string context, Exception exception)
        {
            SetStatus($"{context}\n{exception.Message}");
            AppendLog("[error] " + exception);
            UnityEngine.Debug.LogException(exception);
        }

        private void SetStatus(string value)
        {
            _status = value;
            if (this != null)
            {
                Repaint();
            }
        }

        private void AppendLog(string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return;
            }

            if (!string.IsNullOrEmpty(_log))
            {
                _log += Environment.NewLine;
            }

            _log += value;
            const int maximumLogCharacters = 40000;
            if (_log.Length > maximumLogCharacters)
            {
                _log = _log.Substring(_log.Length - maximumLogCharacters);
            }

            _logScrollPosition.y = float.MaxValue;
        }

        private string ResolveCliExecutable(bool ignoreConfigured = false)
        {
            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                              ?? Environment.CurrentDirectory;
            return ResolveCliExecutablePath(
                ignoreConfigured ? string.Empty : _cliExecutable,
                _cwAiRoot,
                projectRoot,
                ResolvePhysicalPackageRoot());
        }

        internal static string ResolveCliExecutablePath(
            string configuredExecutable,
            string cwAiRoot,
            string projectRoot,
            string packageRoot)
        {
            var normalizedConfigured = TryGetFullPath(configuredExecutable);
            if (!string.IsNullOrWhiteSpace(normalizedConfigured) &&
                !IsCameraAiPackageCachePath(normalizedConfigured) &&
                File.Exists(normalizedConfigured))
            {
                // A valid path outside PackageCache was explicitly selected by
                // the user and must remain authoritative.
                return normalizedConfigured;
            }

            var normalizedPackageRoot = TryGetFullPath(packageRoot);
            if (!string.IsNullOrWhiteSpace(normalizedPackageRoot))
            {
                var packagedWorker = Path.Combine(
                    normalizedPackageRoot,
                    "Tools~",
                    "CWCameraWorker",
                    AICameraCliRunner.ExecutableName);
                if (File.Exists(packagedWorker))
                {
                    return Path.GetFullPath(packagedWorker);
                }

                packagedWorker = Path.Combine(
                    normalizedPackageRoot,
                    "Tools~",
                    AICameraCliRunner.ExecutableName);
                if (File.Exists(packagedWorker))
                {
                    return Path.GetFullPath(packagedWorker);
                }
            }

            // Never fall back to a serialized worker or data root from an old
            // Git package revision merely because Unity has not evicted that
            // PackageCache directory yet.
            var safeConfigured = IsCameraAiPackageCachePath(configuredExecutable)
                ? string.Empty
                : normalizedConfigured;
            var safeCwAiRoot = IsCameraAiPackageCachePath(cwAiRoot)
                ? string.Empty
                : TryGetFullPath(cwAiRoot);
            return AICameraCliRunner.ResolveExecutable(
                safeConfigured,
                safeCwAiRoot,
                projectRoot,
                normalizedPackageRoot);
        }

        internal static string ResolveDefaultCwAiRoot(
            string projectRoot,
            string configuredRoot)
        {
            return ResolveDefaultCwAiRoot(
                projectRoot,
                configuredRoot,
                ResolvePhysicalPackageRoot());
        }

        internal static string ResolveDefaultCwAiRoot(
            string projectRoot,
            string configuredRoot,
            string packageRoot)
        {
            var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot)
                ? Environment.CurrentDirectory
                : Path.GetFullPath(projectRoot);

            var normalizedConfigured = TryGetFullPath(configuredRoot);
            if (!string.IsNullOrWhiteSpace(normalizedConfigured) &&
                !IsCameraAiPackageCachePath(normalizedConfigured) &&
                IsGenerationLibraryRoot(normalizedConfigured))
            {
                // Preserve a valid repository or data library that the user
                // explicitly selected outside Unity's PackageCache.
                return normalizedConfigured;
            }

            var candidates = new List<string>();
            var normalizedPackageRoot = TryGetFullPath(packageRoot);
            if (!string.IsNullOrWhiteSpace(normalizedPackageRoot))
            {
                candidates.Add(Path.Combine(
                    normalizedPackageRoot,
                    "RuntimeData~"));
            }

            // This logical package path supports embedded/local packages when
            // FileUtil cannot resolve a physical path during early domain load.
            candidates.Add(Path.Combine(
                normalizedProjectRoot,
                "Packages",
                "com.mingle.cw-ai",
                "RuntimeData~"));
            candidates.AddRange(new[]
            {
                normalizedProjectRoot,
                Path.Combine(
                    Directory.GetParent(normalizedProjectRoot)?.FullName ??
                    normalizedProjectRoot,
                    "CW-AI"),
                Environment.CurrentDirectory
            });

            foreach (var candidate in candidates)
            {
                if (string.IsNullOrWhiteSpace(candidate))
                {
                    continue;
                }

                string fullPath;
                try
                {
                    fullPath = Path.GetFullPath(candidate);
                }
                catch (Exception)
                {
                    continue;
                }

                if (IsGenerationLibraryRoot(fullPath))
                {
                    return fullPath;
                }
            }

            return Path.Combine(
                Directory.GetParent(normalizedProjectRoot)?.FullName ??
                normalizedProjectRoot,
                "CW-AI");
        }

        private static string ResolvePhysicalPackageRoot()
        {
            try
            {
                return TryGetFullPath(FileUtil.GetPhysicalPath(
                    "Packages/com.mingle.cw-ai"));
            }
            catch (Exception)
            {
                return string.Empty;
            }
        }

        private static bool IsCameraAiPackageCachePath(string candidate)
        {
            var fullPath = TryGetFullPath(candidate);
            if (string.IsNullOrWhiteSpace(fullPath))
            {
                return false;
            }

            var normalized = fullPath.Replace('\\', '/');
            return normalized.IndexOf(
                       "/Library/PackageCache/com.mingle.cw-ai@",
                       StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private static string TryGetFullPath(string candidate)
        {
            if (string.IsNullOrWhiteSpace(candidate))
            {
                return string.Empty;
            }

            try
            {
                return Path.GetFullPath(candidate.Trim());
            }
            catch (Exception)
            {
                return string.Empty;
            }
        }

        private static string ResolveProjectCacheRoot()
        {
            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                              ?? Environment.CurrentDirectory;
            return ResolveProjectCacheRoot(
                projectRoot,
                Environment.GetEnvironmentVariable("CWAI_CACHE_ROOT"));
        }

        internal static string ResolveProjectCacheRoot(
            string projectRoot,
            string configuredRoot)
        {
            if (!string.IsNullOrWhiteSpace(configuredRoot))
            {
                return Path.GetFullPath(configuredRoot);
            }

            var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot)
                ? Environment.CurrentDirectory
                : Path.GetFullPath(projectRoot);
            return Path.Combine(normalizedProjectRoot, "Library", "CWAI");
        }

        internal static bool IsGenerationLibraryRoot(string candidate)
        {
            if (string.IsNullOrWhiteSpace(candidate))
            {
                return false;
            }

            try
            {
                var fullPath = Path.GetFullPath(candidate);
                return Directory.Exists(Path.Combine(fullPath, "DatasetExports")) &&
                       File.Exists(Path.Combine(
                           fullPath,
                           "reports",
                           "training_index.json")) &&
                       File.Exists(Path.Combine(
                           fullPath,
                           "models",
                           "cut_ranker_v2.json"));
            }
            catch (Exception)
            {
                return false;
            }
        }

        private bool TryAutoRepairGenerationLibraryRoot()
        {
            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                              ?? Environment.CurrentDirectory;
            var resolvedRoot = ResolveDefaultCwAiRoot(projectRoot, _cwAiRoot);
            var currentRoot = string.Empty;
            if (!string.IsNullOrWhiteSpace(_cwAiRoot))
            {
                try
                {
                    currentRoot = Path.GetFullPath(_cwAiRoot);
                }
                catch (Exception)
                {
                    currentRoot = string.Empty;
                }
            }
            var previousRoot = _cwAiRoot;
            var repairedLibraryRoot = !string.Equals(
                currentRoot.TrimEnd('\\', '/'),
                resolvedRoot.TrimEnd('\\', '/'),
                StringComparison.OrdinalIgnoreCase);
            if (repairedLibraryRoot)
            {
                _cwAiRoot = resolvedRoot;
                AppendLog(
                    "[library-root-auto-repair] " +
                    $"{previousRoot} -> {_cwAiRoot}");
            }

            var previousExecutable = _cliExecutable;
            var resolvedExecutable = ResolveCliExecutable();
            if (!string.Equals(
                    TryGetFullPath(previousExecutable),
                    TryGetFullPath(resolvedExecutable),
                    StringComparison.OrdinalIgnoreCase))
            {
                _cliExecutable = resolvedExecutable;
                AppendLog(
                    "[worker-path-auto-repair] " +
                    $"{previousExecutable} -> {_cliExecutable}");
            }
            return repairedLibraryRoot;
        }

        private static string ExistingDirectoryOrFallback(
            string candidate,
            string fallback)
        {
            if (!string.IsNullOrWhiteSpace(candidate))
            {
                if (Directory.Exists(candidate))
                {
                    return candidate;
                }

                var parent = Path.GetDirectoryName(candidate);
                if (!string.IsNullOrWhiteSpace(parent) &&
                    Directory.Exists(parent))
                {
                    return parent;
                }
            }

            return !string.IsNullOrWhiteSpace(fallback) &&
                   Directory.Exists(fallback)
                ? fallback
                : Application.dataPath;
        }

        private static string CreateCancellationFilePath(string jobId)
        {
            if (string.IsNullOrWhiteSpace(jobId) ||
                jobId.Any(character =>
                    !(char.IsLetterOrDigit(character) ||
                      character == '-' || character == '_')))
            {
                throw new ArgumentException(
                    "The camera worker job id is invalid.",
                    nameof(jobId));
            }

            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                              ?? Environment.CurrentDirectory;
            return Path.Combine(
                projectRoot,
                "Library",
                "CWAI",
                "Jobs",
                jobId + ".cancel");
        }

        private static void AppendOption(
            ICollection<string> arguments,
            string option,
            string value)
        {
            if (arguments == null)
            {
                throw new ArgumentNullException(nameof(arguments));
            }

            arguments.Add(option);
            AppendArgument(arguments, value);
        }

        private static void AppendArgument(
            ICollection<string> arguments,
            string value)
        {
            if (arguments == null)
            {
                throw new ArgumentNullException(nameof(arguments));
            }

            arguments.Add(value ?? string.Empty);
        }
    }
}
