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

namespace Streamingle.Editor
{
    public static class TimelineCameraBatchExporter
    {
        private const string AuthoredSceneRoot = "Assets/Resourcedata/Character";
        private const string ReportFileName = "yamo_batch_report.json";
        private const string SourceFingerprintRegistryFileName =
            "camera_source_fingerprint_registry.json";
        private const string SourceFingerprintRegistrySchemaVersion = "1.0";
        private const string DatasetSchemaVersion = "1.3";
        private const int RevisionFingerprintLength = 16;

        public static string WriteAuthoredSceneInventoryForCli(string outputPath)
        {
            EnsureEditMode();

            var candidates = DiscoverCandidateScenes();
            var report = new InventoryReport
            {
                schemaVersion = "1.0",
                createdUtc = DateTime.UtcNow.ToString("O"),
                unityVersion = Application.unityVersion,
                assetRoot = AuthoredSceneRoot,
                candidateSceneCount = candidates.Count,
                uniqueTimelineCount = candidates
                    .SelectMany(candidate => candidate.timelines)
                    .Select(timeline => timeline.assetPath)
                    .Distinct(StringComparer.Ordinal)
                    .Count(),
                candidates = candidates
            };

            var fullPath = Path.GetFullPath(outputPath);
            WriteJsonAtomic(fullPath, report);
            return fullPath;
        }

        public static string WriteActiveSceneDiagnosticsForCli(string outputPath)
        {
            EnsureEditMode();

            var scene = SceneManager.GetActiveScene();
            var report = new ActiveSceneDiagnosticReport
            {
                schemaVersion = "1.0",
                createdUtc = DateTime.UtcNow.ToString("O"),
                scenePath = scene.path,
                directors = Resources.FindObjectsOfTypeAll<UnityEngine.Playables.PlayableDirector>()
                    .Where(director =>
                        director.gameObject.scene == scene &&
                        director.playableAsset is TimelineAsset)
                    .OrderBy(director => GetHierarchyPath(director.transform))
                    .Select(BuildDirectorDiagnostic)
                    .ToList()
            };

            var fullPath = Path.GetFullPath(outputPath);
            WriteJsonAtomic(fullPath, report);
            return fullPath;
        }

        public static string WriteAuthoredSceneSubjectReportForCli(
            string outputPath,
            int startIndex = 0,
            int maxScenes = 0,
            string skipIndexesCsv = "")
        {
            EnsureEditMode();
            EnsureNoDirtyScenes();

            var candidates = DiscoverCandidateScenes();
            if (candidates.Count == 0)
            {
                throw new InvalidOperationException(
                    "No authored camera scene candidates were discovered.");
            }

            if (startIndex < 0 || startIndex >= candidates.Count)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(startIndex),
                    startIndex,
                    $"Expected 0 through {candidates.Count - 1}.");
            }

            var firstIndex = startIndex;
            var endExclusive = maxScenes > 0
                ? Math.Min(candidates.Count, firstIndex + maxScenes)
                : candidates.Count;
            if (endExclusive <= firstIndex)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(maxScenes),
                    maxScenes,
                    "The requested range contains no scenes.");
            }

            var skippedIndexes = new HashSet<int>(
                ParseCandidateIndexes(skipIndexesCsv, nameof(skipIndexesCsv)));
            var invalidSkippedIndexes = skippedIndexes
                .Where(index => index < firstIndex || index >= endExclusive)
                .OrderBy(index => index)
                .ToList();
            if (invalidSkippedIndexes.Count > 0)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(skipIndexesCsv),
                    "Skipped candidate indexes must fall inside the requested " +
                    $"range [{firstIndex}, {endExclusive}): " +
                    string.Join(", ", invalidSkippedIndexes));
            }

            var fullOutputPath = Path.GetFullPath(outputPath);
            var outputDirectory = Path.GetDirectoryName(fullOutputPath)
                ?? throw new InvalidOperationException(
                    $"Unable to resolve the report directory for '{outputPath}'.");
            EnsureSafeOutputRoot(outputDirectory);
            var report = new SubjectBatchReport
            {
                schemaVersion = "1.0",
                startedUtc = DateTime.UtcNow.ToString("O"),
                lastUpdatedUtc = DateTime.UtcNow.ToString("O"),
                state = "running",
                unityVersion = Application.unityVersion,
                assetRoot = AuthoredSceneRoot,
                outputPath = fullOutputPath,
                candidateSceneCount = candidates.Count,
                candidateInventoryFingerprint = StableHash(string.Join(
                    "\n",
                    candidates.Select(candidate =>
                        candidate.scenePath + "|" +
                        string.Join(
                            ",",
                            candidate.timelines.Select(timeline =>
                                timeline.assetPath))))),
                startIndex = firstIndex,
                endExclusive = endExclusive,
                skippedCandidateIndexes = skippedIndexes
                    .OrderBy(index => index)
                    .ToList(),
                results = new List<SceneSubjectResult>()
            };
            WriteJsonAtomic(fullOutputPath, report);

            var originalSetup = EditorSceneManager.GetSceneManagerSetup();
            try
            {
                for (var index = firstIndex; index < endExclusive; index++)
                {
                    var candidate = candidates[index];
                    var result = skippedIndexes.Contains(index)
                        ? BuildSkippedSubjectResult(candidate, index)
                        : ScanSubjectCandidate(
                            candidate,
                            index,
                            candidates.Count);
                    report.results.Add(result);
                    report.processedSceneCount = report.results.Count;
                    report.lastUpdatedUtc = DateTime.UtcNow.ToString("O");
                    WriteJsonAtomic(fullOutputPath, report);
                }
            }
            catch (Exception exception)
            {
                report.state = "failed";
                report.fatalError = exception.ToString();
                throw;
            }
            finally
            {
                EditorUtility.ClearProgressBar();
                try
                {
                    RestoreSceneSetup(originalSetup);
                }
                catch (Exception exception)
                {
                    report.restoreError = exception.ToString();
                }

                if (!string.Equals(
                        report.state,
                        "failed",
                        StringComparison.Ordinal))
                {
                    var hasErrors = report.results.Any(result =>
                        result.status == "failed" ||
                        result.status == "analyzed_with_errors");
                    var hasSkips = report.results.Any(result =>
                        result.status == "skipped_dependency");
                    report.state = string.IsNullOrWhiteSpace(report.restoreError)
                        ? hasErrors
                            ? "completed_with_errors"
                            : hasSkips
                                ? "completed_with_skips"
                                : "completed"
                        : "completed_with_restore_error";
                }

                report.completedUtc = DateTime.UtcNow.ToString("O");
                report.lastUpdatedUtc = report.completedUtc;
                WriteJsonAtomic(fullOutputPath, report);
            }

            if (!string.IsNullOrWhiteSpace(report.restoreError))
            {
                throw new InvalidOperationException(
                    $"The subject report was written to '{fullOutputPath}', " +
                    "but the original scene setup could not be restored.");
            }

            return fullOutputPath;
        }

        public static string ExportAuthoredScenesForCli(
            string outputRoot,
            int startIndex = 0,
            int maxScenes = 0,
            bool requireAudio = false)
        {
            return ExportAuthoredScenesForCliInternal(
                outputRoot,
                startIndex,
                maxScenes,
                requireAudio,
                false,
                Array.Empty<int>(),
                Array.Empty<int>());
        }

        public static string ExportAuthoredScenesPreservingRevisionsForCli(
            string outputRoot,
            int startIndex = 0,
            int maxScenes = 0,
            bool requireAudio = false)
        {
            return ExportAuthoredScenesForCliInternal(
                outputRoot,
                startIndex,
                maxScenes,
                requireAudio,
                true,
                Array.Empty<int>(),
                Array.Empty<int>());
        }

        public static string ExportAuthoredScenesPreservingRevisionsWithSkipsForCli(
            string outputRoot,
            int startIndex = 0,
            int maxScenes = 0,
            bool requireAudio = false,
            string skipIndexesCsv = "")
        {
            return ExportAuthoredScenesForCliInternal(
                outputRoot,
                startIndex,
                maxScenes,
                requireAudio,
                true,
                ParseCandidateIndexes(
                    skipIndexesCsv,
                    nameof(skipIndexesCsv)),
                Array.Empty<int>());
        }

        public static string ExportAuthoredScenesPreservingRevisionsWithOverridesForCli(
            string outputRoot,
            int startIndex = 0,
            int maxScenes = 0,
            bool requireAudio = false,
            string skipIndexesCsv = "",
            string forceRevisionIndexesCsv = "")
        {
            return ExportAuthoredScenesForCliInternal(
                outputRoot,
                startIndex,
                maxScenes,
                requireAudio,
                true,
                ParseCandidateIndexes(
                    skipIndexesCsv,
                    nameof(skipIndexesCsv)),
                ParseCandidateIndexes(
                    forceRevisionIndexesCsv,
                    nameof(forceRevisionIndexesCsv)));
        }

        private static string ExportAuthoredScenesForCliInternal(
            string outputRoot,
            int startIndex,
            int maxScenes,
            bool requireAudio,
            bool preserveRevisions,
            IReadOnlyCollection<int> skippedCandidateIndexes,
            IReadOnlyCollection<int> forcedRevisionCandidateIndexes)
        {
            EnsureEditMode();
            EnsureNoDirtyScenes();

            var fullOutputRoot = Path.GetFullPath(outputRoot)
                .TrimEnd(
                    Path.DirectorySeparatorChar,
                    Path.AltDirectorySeparatorChar);
            EnsureSafeOutputRoot(fullOutputRoot);
            Directory.CreateDirectory(fullOutputRoot);
            var reportPath = Path.Combine(fullOutputRoot, ReportFileName);
            var sourceFingerprintRegistryPath = Path.Combine(
                fullOutputRoot,
                SourceFingerprintRegistryFileName);
            var sourceFingerprintRegistry = LoadSourceFingerprintRegistry(
                sourceFingerprintRegistryPath);
            var candidates = DiscoverCandidateScenes();
            if (candidates.Count == 0)
            {
                throw new InvalidOperationException(
                    "No authored camera scene candidates were discovered.");
            }

            if (startIndex < 0 || startIndex >= candidates.Count)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(startIndex),
                    startIndex,
                    $"Expected 0 through {candidates.Count - 1}.");
            }

            var firstIndex = startIndex;
            var endExclusive = maxScenes > 0
                ? Math.Min(candidates.Count, firstIndex + maxScenes)
                : candidates.Count;
            if (endExclusive <= firstIndex)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(maxScenes),
                    maxScenes,
                    "The requested range contains no scenes.");
            }

            var skippedIndexSet = new HashSet<int>(
                skippedCandidateIndexes ?? Array.Empty<int>());
            var outOfRangeSkippedIndexes = skippedIndexSet
                .Where(index => index < firstIndex || index >= endExclusive)
                .OrderBy(index => index)
                .ToList();
            if (outOfRangeSkippedIndexes.Count > 0)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(skippedCandidateIndexes),
                    "Skipped candidate indexes must fall inside the requested " +
                    $"range [{firstIndex}, {endExclusive}): " +
                    string.Join(", ", outOfRangeSkippedIndexes));
            }

            var sortedSkippedIndexes = skippedIndexSet
                .OrderBy(index => index)
                .ToList();
            var forcedRevisionIndexSet = new HashSet<int>(
                forcedRevisionCandidateIndexes ?? Array.Empty<int>());
            if (!preserveRevisions && forcedRevisionIndexSet.Count > 0)
            {
                throw new ArgumentException(
                    "Forced revisions require preserveRevisions=true.",
                    nameof(forcedRevisionCandidateIndexes));
            }

            var outOfRangeForcedIndexes = forcedRevisionIndexSet
                .Where(index => index < firstIndex || index >= endExclusive)
                .OrderBy(index => index)
                .ToList();
            if (outOfRangeForcedIndexes.Count > 0)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(forcedRevisionCandidateIndexes),
                    "Forced revision candidate indexes must fall inside the " +
                    $"requested range [{firstIndex}, {endExclusive}): " +
                    string.Join(", ", outOfRangeForcedIndexes));
            }

            var conflictingIndexes = skippedIndexSet
                .Intersect(forcedRevisionIndexSet)
                .OrderBy(index => index)
                .ToList();
            if (conflictingIndexes.Count > 0)
            {
                throw new ArgumentException(
                    "A candidate cannot be both skipped and forced to a " +
                    "revision: " + string.Join(", ", conflictingIndexes));
            }

            var sortedForcedRevisionIndexes = forcedRevisionIndexSet
                .OrderBy(index => index)
                .ToList();

            var report = new BatchReport
            {
                schemaVersion = preserveRevisions ? "1.3" : "1.1",
                startedUtc = DateTime.UtcNow.ToString("O"),
                lastUpdatedUtc = DateTime.UtcNow.ToString("O"),
                state = "running",
                unityVersion = Application.unityVersion,
                assetRoot = AuthoredSceneRoot,
                outputRoot = fullOutputRoot,
                sourceFingerprintRegistryPath =
                    sourceFingerprintRegistryPath,
                sourceFingerprintRegistrySchema =
                    SourceFingerprintRegistrySchemaVersion,
                sourceFingerprintRegistryEntryCount =
                    sourceFingerprintRegistry.entries.Count,
                candidateSceneCount = candidates.Count,
                candidateInventoryFingerprint = StableHash(string.Join(
                    "\n",
                    candidates.Select(candidate =>
                        candidate.scenePath + "|" +
                        string.Join(
                            ",",
                            candidate.timelines.Select(timeline =>
                                timeline.assetPath))))),
                startIndex = firstIndex,
                endExclusive = endExclusive,
                requireAudio = requireAudio,
                preserveRevisions = preserveRevisions,
                skippedCandidateIndexes = sortedSkippedIndexes,
                forcedRevisionCandidateIndexes =
                    sortedForcedRevisionIndexes,
                results = new List<SceneExportResult>()
            };
            WriteJsonAtomic(reportPath, report);

            var originalSetup = EditorSceneManager.GetSceneManagerSetup();
            try
            {
                for (var index = firstIndex; index < endExclusive; index++)
                {
                    var candidate = candidates[index];
                    var registryEntry = FindSourceFingerprintRegistryEntry(
                        sourceFingerprintRegistry,
                        BuildBaseDatasetName(candidate.scenePath),
                        candidate.scenePath);
                    var result = skippedIndexSet.Contains(index)
                        ? BuildSkippedCandidateResult(
                            candidate,
                            index,
                            fullOutputRoot)
                        : ExportCandidate(
                            candidate,
                            index,
                            candidates.Count,
                            fullOutputRoot,
                            requireAudio,
                            preserveRevisions,
                            forcedRevisionIndexSet.Contains(index),
                            registryEntry);
                    if (IsCompleteExportResult(result))
                    {
                        result.sourceFingerprintBaselineRecorded =
                            RecordSourceFingerprintBaseline(
                                sourceFingerprintRegistry,
                                fullOutputRoot,
                                result);
                        if (result.sourceFingerprintBaselineRecorded)
                        {
                            WriteJsonAtomic(
                                sourceFingerprintRegistryPath,
                                sourceFingerprintRegistry);
                        }
                    }
                    report.results.Add(result);
                    report.processedSceneCount = report.results.Count;
                    report.sourceFingerprintRegistryEntryCount =
                        sourceFingerprintRegistry.entries.Count;
                    report.lastUpdatedUtc = DateTime.UtcNow.ToString("O");
                    WriteJsonAtomic(reportPath, report);
                }
            }
            catch (Exception exception)
            {
                report.state = "failed";
                report.fatalError = exception.ToString();
                throw;
            }
            finally
            {
                EditorUtility.ClearProgressBar();
                try
                {
                    RestoreSceneSetup(originalSetup);
                }
                catch (Exception exception)
                {
                    report.restoreError = exception.ToString();
                }

                if (!string.Equals(
                        report.state,
                        "failed",
                        StringComparison.Ordinal))
                {
                    var hasSceneErrors = report.results.Any(result =>
                        result.status != "exported" &&
                        result.status != "already_complete" &&
                        result.status != "exported_with_skips" &&
                        result.status != "already_complete_with_skips" &&
                        result.status != "skipped_dependency");
                    var hasSkippedTracks = report.results.Any(result =>
                        result.skippedCameraTrackCount > 0);
                    var hasSkippedCandidates = report.results.Any(result =>
                        result.status == "skipped_dependency");
                    report.state = string.IsNullOrWhiteSpace(report.restoreError)
                        ? hasSceneErrors
                            ? "completed_with_errors"
                            : hasSkippedTracks || hasSkippedCandidates
                                ? "completed_with_skips"
                                : "completed"
                        : "completed_with_restore_error";
                }

                report.completedUtc = DateTime.UtcNow.ToString("O");
                report.lastUpdatedUtc = report.completedUtc;
                WriteJsonAtomic(reportPath, report);
            }

            var completedSuccessfully = string.Equals(
                    report.state,
                    "completed",
                    StringComparison.Ordinal) ||
                string.Equals(
                    report.state,
                    "completed_with_skips",
                    StringComparison.Ordinal);
            if (!completedSuccessfully)
            {
                throw new InvalidOperationException(
                    $"Batch finished with state '{report.state}'. " +
                    $"See '{reportPath}'.");
            }

            return reportPath;
        }

        private static void EnsureSafeOutputRoot(string outputRoot)
        {
            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                ?? throw new InvalidOperationException(
                    "Unable to resolve the Unity project root.");
            foreach (var protectedPath in new[]
            {
                projectRoot,
                Path.Combine(projectRoot, "Assets"),
                Path.Combine(projectRoot, "Packages"),
                Path.Combine(projectRoot, "Library")
            })
            {
                var normalizedProtected = Path.GetFullPath(protectedPath)
                    .TrimEnd(
                        Path.DirectorySeparatorChar,
                        Path.AltDirectorySeparatorChar);
                if (string.Equals(
                        outputRoot.TrimEnd(
                            Path.DirectorySeparatorChar,
                            Path.AltDirectorySeparatorChar),
                        normalizedProtected,
                        StringComparison.OrdinalIgnoreCase) ||
                    outputRoot.StartsWith(
                        normalizedProtected + Path.DirectorySeparatorChar,
                        StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidOperationException(
                        $"Output root must be outside the Unity project: " +
                        $"'{outputRoot}'.");
                }
            }
        }

        private static SceneSubjectResult BuildSkippedSubjectResult(
            SceneCandidate candidate,
            int index)
        {
            var timestamp = DateTime.UtcNow.ToString("O");
            return new SceneSubjectResult
            {
                candidateIndex = index,
                scenePath = candidate.scenePath,
                candidateTimelinePaths = candidate.timelines
                    .Select(timeline => timeline.assetPath)
                    .ToList(),
                status = "skipped_dependency",
                reason = "candidate_index_listed_in_skipIndexesCsv",
                error =
                    "The candidate was skipped explicitly because its Unity " +
                    "dependencies are unavailable. The scene was not opened.",
                startedUtc = timestamp,
                completedUtc = timestamp,
                timelines = new List<TimelineSubjectResult>()
            };
        }

        private static SceneSubjectResult ScanSubjectCandidate(
            SceneCandidate candidate,
            int index,
            int candidateCount)
        {
            var result = new SceneSubjectResult
            {
                candidateIndex = index,
                scenePath = candidate.scenePath,
                candidateTimelinePaths = candidate.timelines
                    .Select(timeline => timeline.assetPath)
                    .ToList(),
                startedUtc = DateTime.UtcNow.ToString("O"),
                timelines = new List<TimelineSubjectResult>()
            };
            var stopwatch = System.Diagnostics.Stopwatch.StartNew();
            try
            {
                EditorUtility.DisplayProgressBar(
                    "YAMO Timeline Subject Scan",
                    $"{index + 1:N0} / {candidateCount:N0}: " +
                    Path.GetFileNameWithoutExtension(candidate.scenePath),
                    (index + 1) / (float)Math.Max(1, candidateCount));

                var scene = EditorSceneManager.OpenScene(
                    candidate.scenePath,
                    OpenSceneMode.Single);
                if (!scene.IsValid() || !scene.isLoaded)
                {
                    throw new InvalidOperationException(
                        $"Unable to load scene '{candidate.scenePath}'.");
                }

                var directors = Resources
                    .FindObjectsOfTypeAll<UnityEngine.Playables.PlayableDirector>()
                    .Where(director =>
                        director.gameObject.scene == scene &&
                        director.playableAsset is TimelineAsset)
                    .OrderBy(
                        director => GetHierarchyPath(director.transform),
                        StringComparer.Ordinal)
                    .ThenBy(
                        director => AssetDatabase.GetAssetPath(
                            director.playableAsset),
                        StringComparer.Ordinal)
                    .ToList();

                foreach (var director in directors)
                {
                    try
                    {
                        result.timelines.Add(
                            AnalyzeTimelineSubjects(director));
                    }
                    catch (Exception exception)
                    {
                        var timeline = director.playableAsset as TimelineAsset;
                        result.timelines.Add(new TimelineSubjectResult
                        {
                            status = "failed",
                            directorPath = GetHierarchyPath(director.transform),
                            timelineAssetPath = timeline != null
                                ? AssetDatabase.GetAssetPath(timeline)
                                : string.Empty,
                            timelineName = timeline?.name ?? string.Empty,
                            subjectMode = "unknown",
                            subjectCountKnown = false,
                            subjectCount = -1,
                            confidence = "low",
                            reason = "Timeline subject analysis failed.",
                            error = exception.ToString(),
                            boundAnimatorPaths = new List<string>(),
                            boundPerformerPaths = new List<string>(),
                            cameraTargetPaths = new List<string>(),
                            cameraTargetAnimatorPaths = new List<string>(),
                            subjectAnimatorPaths = new List<string>(),
                            animationTracks =
                                new List<AnimationSubjectEvidence>(),
                            cameraTracks = new List<CameraSubjectEvidence>()
                        });
                    }
                }

                result.timelineCount = result.timelines.Count;
                if (directors.Count == 0)
                {
                    result.status = "analyzed_with_errors";
                    result.reason =
                        "The loaded scene contains no Timeline PlayableDirector.";
                }
                else if (result.timelines.Any(timeline =>
                             timeline.status == "failed"))
                {
                    result.status = "analyzed_with_errors";
                    result.reason = "One or more Timelines could not be analyzed.";
                }
                else
                {
                    result.status = "analyzed";
                    result.reason =
                        "Subjects were classified independently per Director/Timeline.";
                }
            }
            catch (Exception exception)
            {
                result.status = "failed";
                result.reason = "The candidate scene could not be analyzed.";
                result.error = exception.ToString();
            }
            finally
            {
                stopwatch.Stop();
                result.elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
                result.completedUtc = DateTime.UtcNow.ToString("O");
            }

            return result;
        }

        private static TimelineSubjectResult AnalyzeTimelineSubjects(
            UnityEngine.Playables.PlayableDirector director)
        {
            var timeline = director.playableAsset as TimelineAsset
                ?? throw new InvalidOperationException(
                    "PlayableDirector does not reference a TimelineAsset.");
            var tracks = timeline.GetOutputTracks().ToArray();
            var animationEvidence = new List<AnimationSubjectEvidence>();
            var boundAnimators = new List<Animator>();
            var unresolvedAnimationTrackCount = 0;
            var selectedExporterAnimator = SelectExporterAnimator(
                tracks,
                director);
            for (var trackIndex = 0;
                 trackIndex < tracks.Length;
                 trackIndex++)
            {
                if (tracks[trackIndex] is not AnimationTrack track)
                {
                    continue;
                }

                var binding = director.GetGenericBinding(track);
                var animator = ResolveBoundAnimator(binding);
                if (animator != null &&
                    !track.mutedInHierarchy &&
                    IsExporterCharacterAnimator(animator) &&
                    !boundAnimators.Contains(animator))
                {
                    boundAnimators.Add(animator);
                }
                else if (animator == null)
                {
                    unresolvedAnimationTrackCount++;
                }

                animationEvidence.Add(new AnimationSubjectEvidence
                {
                    trackIndex = trackIndex,
                    trackName = track.name,
                    muted = track.mutedInHierarchy,
                    bindingType = binding?.GetType().FullName ?? string.Empty,
                    bindingPath = GetBindingHierarchyPath(binding),
                    animatorPath = animator != null
                        ? GetHierarchyPath(animator.transform)
                        : string.Empty,
                    performerPath = animator != null
                        ? GetHierarchyPath(
                            NormalizePerformerTransform(animator))
                        : string.Empty,
                    animatorResolved = animator != null
                });
            }

            var cameraEvidence = new List<CameraSubjectEvidence>();
            var cameraTargetPaths = new HashSet<string>(StringComparer.Ordinal);
            var cameraPerformerPaths = new HashSet<string>(StringComparer.Ordinal);
            var unresolvedShotCount = 0;
            var unlinkedCameraTargetCount = 0;
            var sourceCameraTrackIndex = 0;
            var exportCameraTrackIndex = 0;
            for (var trackIndex = 0;
                 trackIndex < tracks.Length;
                 trackIndex++)
            {
                if (tracks[trackIndex] is not CinemachineTrack track)
                {
                    continue;
                }

                var clips = track
                    .GetClips()
                    .Where(clip => clip.asset is CinemachineShot)
                    .OrderBy(item => item.start)
                    .ThenBy(item => item.end)
                    .ToList();
                if (clips.Count == 0)
                {
                    continue;
                }

                var trackEvidence = new CameraSubjectEvidence
                {
                    cameraTrackIndex = sourceCameraTrackIndex++,
                    exportCameraTrackIndex = -1,
                    outputTrackIndex = trackIndex,
                    trackName = track.name,
                    muted = track.mutedInHierarchy,
                    selectedExporterAnimatorPath =
                        selectedExporterAnimator != null
                            ? GetHierarchyPath(
                                selectedExporterAnimator.transform)
                            : string.Empty,
                    selectedExporterPerformerPath =
                        selectedExporterAnimator != null
                            ? GetHierarchyPath(
                                NormalizePerformerTransform(
                                    selectedExporterAnimator))
                            : string.Empty,
                    shots = new List<ShotSubjectEvidence>()
                };
                var trackTargetPaths = new HashSet<string>(
                    StringComparer.Ordinal);
                var trackPerformerPaths = new HashSet<string>(
                    StringComparer.Ordinal);
                var trackHasUncertainty = false;
                foreach (var clip in clips)
                {
                    var shot = (CinemachineShot)clip.asset;

                    var shotEvidence = new ShotSubjectEvidence
                    {
                        clipName = clip.displayName,
                        start = clip.start,
                        end = clip.end,
                        followGroupMemberPaths = new List<string>(),
                        lookAtGroupMemberPaths = new List<string>(),
                        followPerformerPaths = new List<string>(),
                        lookAtPerformerPaths = new List<string>(),
                        targetPerformerPaths = new List<string>()
                    };
                    try
                    {
                        var virtualCamera = shot.VirtualCamera.Resolve(director);
                        if (virtualCamera == null)
                        {
                            unresolvedShotCount++;
                            trackHasUncertainty = true;
                            shotEvidence.status = "unresolved_camera";
                            shotEvidence.error =
                                "The Cinemachine shot virtual camera is unresolved.";
                            trackEvidence.shots.Add(shotEvidence);
                            continue;
                        }

                        shotEvidence.status = "resolved";
                        shotEvidence.virtualCameraResolved = true;
                        shotEvidence.virtualCameraPath =
                            GetHierarchyPath(virtualCamera.transform);
                        var follow = virtualCamera.Follow;
                        var lookAt = virtualCamera.LookAt;
                        var followResolution = ResolveCameraTarget(
                            follow,
                            boundAnimators);
                        var lookAtResolution = ResolveCameraTarget(
                            lookAt,
                            boundAnimators);
                        shotEvidence.followTargetPath =
                            followResolution.targetPath;
                        shotEvidence.lookAtTargetPath =
                            lookAtResolution.targetPath;
                        shotEvidence.followTargetAuthored =
                            followResolution.authored;
                        shotEvidence.followTargetResolved =
                            followResolution.resolved;
                        shotEvidence.lookAtTargetAuthored =
                            lookAtResolution.authored;
                        shotEvidence.lookAtTargetResolved =
                            lookAtResolution.resolved;
                        shotEvidence.followGroupMemberPaths =
                            followResolution.memberPaths;
                        shotEvidence.lookAtGroupMemberPaths =
                            lookAtResolution.memberPaths;
                        shotEvidence.followPerformerPaths =
                            followResolution.performerPaths;
                        shotEvidence.lookAtPerformerPaths =
                            lookAtResolution.performerPaths;

                        foreach (var resolution in new[]
                                 {
                                     followResolution,
                                     lookAtResolution
                                 })
                        {
                            if (!string.IsNullOrWhiteSpace(
                                    resolution.targetPath))
                            {
                                trackTargetPaths.Add(resolution.targetPath);
                                cameraTargetPaths.Add(resolution.targetPath);
                            }

                            foreach (var memberPath in resolution.memberPaths)
                            {
                                trackTargetPaths.Add(memberPath);
                                cameraTargetPaths.Add(memberPath);
                            }

                            if (resolution.authored && !resolution.resolved)
                            {
                                unlinkedCameraTargetCount++;
                                trackHasUncertainty = true;
                            }

                            foreach (var performerPath in
                                     resolution.performerPaths)
                            {
                                trackPerformerPaths.Add(performerPath);
                                cameraPerformerPaths.Add(performerPath);
                                if (!shotEvidence.targetPerformerPaths.Contains(
                                        performerPath))
                                {
                                    shotEvidence.targetPerformerPaths.Add(
                                        performerPath);
                                }
                            }
                        }

                        var hasAuthoredTarget = followResolution.authored ||
                            lookAtResolution.authored;
                        shotEvidence.targetEvidenceComplete =
                            hasAuthoredTarget &&
                            shotEvidence.targetPerformerPaths.Count > 0 &&
                            (!followResolution.authored ||
                             followResolution.resolved) &&
                            (!lookAtResolution.authored ||
                             lookAtResolution.resolved);
                        if (!shotEvidence.targetEvidenceComplete)
                        {
                            trackHasUncertainty = true;
                            shotEvidence.status = "unknown_target";
                            shotEvidence.reason = !hasAuthoredTarget
                                ? "The virtual camera has no Follow or LookAt target."
                                : "An authored camera target could not be mapped " +
                                  "to a performer.";
                        }
                        else
                        {
                            shotEvidence.reason =
                                "All authored targets map to performer roots.";
                        }

                        shotEvidence.targetPerformerPaths.Sort(
                            StringComparer.Ordinal);
                    }
                    catch (Exception exception)
                    {
                        unresolvedShotCount++;
                        trackHasUncertainty = true;
                        shotEvidence.status = "failed";
                        shotEvidence.error = exception.ToString();
                    }

                    trackEvidence.shots.Add(shotEvidence);
                }

                trackEvidence.shotCount = trackEvidence.shots.Count;
                var coverageEnd = clips[0].end;
                var hasCoverageGap = false;
                foreach (var clip in clips.Skip(1))
                {
                    if (clip.start > coverageEnd + 1e-7)
                    {
                        hasCoverageGap = true;
                        break;
                    }

                    coverageEnd = Math.Max(coverageEnd, clip.end);
                }

                var unresolvedVirtualCameraCount = trackEvidence.shots.Count(
                    shotEvidence => !shotEvidence.virtualCameraResolved);
                trackEvidence.passesExporterTrackFilter =
                    unresolvedVirtualCameraCount == 0 && !hasCoverageGap;
                if (trackEvidence.passesExporterTrackFilter)
                {
                    trackEvidence.exportCameraTrackIndex =
                        exportCameraTrackIndex++;
                }
                else
                {
                    trackEvidence.exporterTrackFilterReason =
                        $"unresolvedShots={unresolvedVirtualCameraCount}; " +
                        $"coverageGap={hasCoverageGap}";
                }

                trackEvidence.cameraTargetPaths = trackTargetPaths
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList();
                trackEvidence.subjectPerformerPaths = trackPerformerPaths
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList();
                ClassifyCameraTrack(trackEvidence, trackHasUncertainty);
                cameraEvidence.Add(trackEvidence);
            }

            var boundAnimatorPaths = boundAnimators
                .Select(animator => GetHierarchyPath(animator.transform))
                .Distinct(StringComparer.Ordinal)
                .OrderBy(path => path, StringComparer.Ordinal)
                .ToList();
            var boundPerformerPaths = boundAnimators
                .Select(animator => GetHierarchyPath(
                    NormalizePerformerTransform(animator)))
                .Distinct(StringComparer.Ordinal)
                .OrderBy(path => path, StringComparer.Ordinal)
                .ToList();
            var timelineSubjectMode = cameraEvidence.Count == 1
                ? cameraEvidence[0].subjectMode
                : "unknown";
            var timelineSubjectCountKnown = cameraEvidence.Count == 1 &&
                cameraEvidence[0].subjectCountKnown;
            var timelineSubjectCount = timelineSubjectCountKnown
                ? cameraEvidence[0].subjectCount
                : -1;
            var timelineConfidence = cameraEvidence.Count == 1
                ? cameraEvidence[0].confidence
                : "low";
            var timelineReason = cameraEvidence.Count == 1
                ? cameraEvidence[0].reason
                : cameraEvidence.Count == 0
                    ? "No Cinemachine track variant exists; subject remains unknown."
                    : "Subject labels are reported independently per cameraTrackIndex.";

            return new TimelineSubjectResult
            {
                status = "analyzed",
                directorPath = GetHierarchyPath(director.transform),
                timelineAssetPath = AssetDatabase.GetAssetPath(timeline),
                timelineName = timeline.name,
                duration = director.duration,
                subjectMode = timelineSubjectMode,
                subjectCountKnown = timelineSubjectCountKnown,
                subjectCount = timelineSubjectCount,
                confidence = timelineConfidence,
                reason = timelineReason,
                selectedExporterAnimatorPath =
                    selectedExporterAnimator != null
                        ? GetHierarchyPath(selectedExporterAnimator.transform)
                        : string.Empty,
                selectedExporterPerformerPath =
                    selectedExporterAnimator != null
                        ? GetHierarchyPath(
                            NormalizePerformerTransform(
                                selectedExporterAnimator))
                        : string.Empty,
                boundAnimatorPaths = boundAnimatorPaths,
                boundPerformerPaths = boundPerformerPaths,
                cameraTargetPaths = cameraTargetPaths
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList(),
                cameraTargetAnimatorPaths = cameraPerformerPaths
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList(),
                subjectAnimatorPaths = cameraPerformerPaths
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList(),
                unresolvedAnimationTrackCount =
                    unresolvedAnimationTrackCount,
                unresolvedShotCount = unresolvedShotCount,
                unlinkedCameraTargetCount = unlinkedCameraTargetCount,
                animationTracks = animationEvidence,
                cameraTracks = cameraEvidence
            };
        }

        private static void ClassifyCameraTrack(
            CameraSubjectEvidence evidence,
            bool hasUncertainty)
        {
            var count = evidence.subjectPerformerPaths.Count;
            evidence.subjectMode = "unknown";
            evidence.subjectCountKnown = false;
            evidence.subjectCount = -1;
            evidence.confidence = "low";
            if (count >= 2)
            {
                evidence.subjectMode = "multi_subject";
                evidence.subjectCountKnown = !hasUncertainty;
                evidence.subjectCount = evidence.subjectCountKnown
                    ? count
                    : -1;
                evidence.confidence = hasUncertainty ? "medium" : "high";
                evidence.reason = hasUncertainty
                    ? "At least two performer roots are explicitly targeted, " +
                      "but some shot target evidence is incomplete."
                    : $"All shot targets resolve to {count} performer roots.";
            }
            else if (count == 1 && !hasUncertainty)
            {
                evidence.subjectMode = "single_subject";
                evidence.subjectCountKnown = true;
                evidence.subjectCount = 1;
                evidence.confidence = "high";
                evidence.reason =
                    "Every shot target resolves to the same performer root.";
            }
            else
            {
                evidence.reason = count == 1
                    ? "One performer is evidenced, but at least one shot target " +
                      "is null, static, or unresolved."
                    : "No shot target can be confirmed as a performer; static " +
                      "or null targets remain unknown.";
            }

            var selectedPath = evidence.selectedExporterPerformerPath;
            evidence.selectedAnimatorTargetMatchKnown =
                !string.IsNullOrWhiteSpace(selectedPath) &&
                !hasUncertainty &&
                count > 0;
            evidence.allTargetsMatchSelectedExporterAnimator =
                evidence.selectedAnimatorTargetMatchKnown &&
                evidence.subjectPerformerPaths.All(path =>
                    string.Equals(
                        path,
                        selectedPath,
                        StringComparison.Ordinal));
        }

        private static Animator ResolveBoundAnimator(object binding)
        {
            if (binding is Animator animator)
            {
                return animator;
            }

            if (binding is GameObject gameObject)
            {
                return gameObject.GetComponent<Animator>();
            }

            return binding is Component component
                ? component.GetComponent<Animator>()
                : null;
        }

        private static Animator SelectExporterAnimator(
            IReadOnlyCollection<TrackAsset> tracks,
            UnityEngine.Playables.PlayableDirector director)
        {
            return tracks
                .OfType<AnimationTrack>()
                .Where(track => !track.mutedInHierarchy)
                .Select(track => director.GetGenericBinding(track) as Animator)
                .Where(IsExporterCharacterAnimator)
                .Distinct()
                .OrderByDescending(animator => animator.isHuman)
                .ThenByDescending(animator =>
                    animator.avatar != null && animator.avatar.isValid)
                .ThenByDescending(animator =>
                    animator.GetComponentsInChildren<Transform>(true).Length)
                .FirstOrDefault();
        }

        private static bool IsExporterCharacterAnimator(Animator animator)
        {
            if (animator == null)
            {
                return false;
            }

            var path = $"/{GetHierarchyPath(animator.transform)}/";
            return path.IndexOf(
                       "/Cam/",
                       StringComparison.OrdinalIgnoreCase) < 0 &&
                   path.IndexOf(
                       "/Cams/",
                       StringComparison.OrdinalIgnoreCase) < 0 &&
                   path.IndexOf(
                       "/Camera/",
                       StringComparison.OrdinalIgnoreCase) < 0 &&
                   animator.GetComponent<CinemachineVirtualCameraBase>() == null &&
                   path.IndexOf(
                       "Missing Prefab with guid:",
                       StringComparison.OrdinalIgnoreCase) < 0 &&
                   animator.name.IndexOf(
                       "Placeholder for referenced Animator",
                       StringComparison.OrdinalIgnoreCase) < 0;
        }

        private static Transform NormalizePerformerTransform(Animator animator)
        {
            var performer = animator.transform;
            for (var ancestor = performer.parent;
                 ancestor != null;
                 ancestor = ancestor.parent)
            {
                if (ancestor.GetComponent<Animator>() != null)
                {
                    performer = ancestor;
                }
            }

            return performer;
        }

        private static string GetBindingHierarchyPath(object binding)
        {
            return binding switch
            {
                Component component => GetHierarchyPath(component.transform),
                GameObject gameObject => GetHierarchyPath(gameObject.transform),
                _ => string.Empty
            };
        }

        private static CameraTargetResolution ResolveCameraTarget(
            Transform target,
            IReadOnlyCollection<Animator> boundAnimators)
        {
            var result = new CameraTargetResolution
            {
                authored = target != null,
                targetPath = target != null
                    ? GetHierarchyPath(target)
                    : string.Empty,
                memberPaths = new List<string>(),
                performerPaths = new List<string>()
            };
            if (target == null)
            {
                return result;
            }

            var effectiveTargets = new List<Transform>();
            if (target.TryGetComponent<CinemachineTargetGroup>(out var group))
            {
                effectiveTargets.AddRange((group.Targets ??
                        new List<CinemachineTargetGroup.Target>())
                    .Where(member =>
                        member != null &&
                        member.Weight > 0f &&
                        member.Object != null)
                    .Select(member => member.Object));
                result.memberPaths = effectiveTargets
                    .Select(GetHierarchyPath)
                    .Distinct(StringComparer.Ordinal)
                    .OrderBy(path => path, StringComparer.Ordinal)
                    .ToList();
            }
            else
            {
                effectiveTargets.Add(target);
            }

            var animators = new HashSet<Animator>();
            foreach (var effectiveTarget in effectiveTargets)
            {
                foreach (var animator in boundAnimators)
                {
                    if (animator != null &&
                        (effectiveTarget == animator.transform ||
                         effectiveTarget.IsChildOf(animator.transform) ||
                         animator.transform.IsChildOf(effectiveTarget)))
                    {
                        animators.Add(animator);
                    }
                }

            }

            result.performerPaths = animators
                .Where(animator => animator != null)
                .Select(animator => GetHierarchyPath(
                    NormalizePerformerTransform(animator)))
                .Distinct(StringComparer.Ordinal)
                .OrderBy(path => path, StringComparer.Ordinal)
                .ToList();
            result.resolved = effectiveTargets.Count > 0 &&
                result.performerPaths.Count > 0;
            return result;
        }

        private static IReadOnlyCollection<int> ParseCandidateIndexes(
            string indexesCsv,
            string parameterName)
        {
            if (string.IsNullOrWhiteSpace(indexesCsv))
            {
                return Array.Empty<int>();
            }

            var indexes = new HashSet<int>();
            foreach (var rawValue in indexesCsv.Split(','))
            {
                var value = rawValue.Trim();
                if (value.Length == 0 ||
                    !int.TryParse(
                        value,
                        NumberStyles.Integer,
                        CultureInfo.InvariantCulture,
                        out var index))
                {
                    throw new FormatException(
                        $"Invalid candidate index '{rawValue}' in " +
                        $"{parameterName} '{indexesCsv}'.");
                }

                indexes.Add(index);
            }

            return indexes.OrderBy(index => index).ToArray();
        }

        private static SceneExportResult BuildSkippedCandidateResult(
            SceneCandidate candidate,
            int index,
            string outputRoot)
        {
            var datasetPath = Path.Combine(
                outputRoot,
                BuildBaseDatasetName(candidate.scenePath));
            var timestamp = DateTime.UtcNow.ToString("O");
            Debug.LogWarning(
                $"[CW-AI] Skipping candidate {index}: " +
                $"{candidate.scenePath}");
            return new SceneExportResult
            {
                candidateIndex = index,
                scenePath = candidate.scenePath,
                timelinePaths = candidate.timelines
                    .Select(timeline => timeline.assetPath)
                    .ToList(),
                baseOutputPath = datasetPath,
                outputPath = datasetPath,
                status = "skipped_dependency",
                reason = "candidate_index_listed_in_skipIndexesCsv",
                error =
                    "The candidate was skipped explicitly because its Unity " +
                    "dependencies are unavailable. The scene was not opened.",
                startedUtc = timestamp,
                completedUtc = timestamp,
                elapsedSeconds = 0.0
            };
        }

        private static string BuildBaseDatasetName(string scenePath)
        {
            var sceneName = Path.GetFileNameWithoutExtension(scenePath);
            return
                $"TimelineCamera_60fps_YAMO_{StableHash(scenePath)}_" +
                SanitizeFileName(sceneName, 72);
        }

        private static SourceFingerprintRegistry LoadSourceFingerprintRegistry(
            string registryPath)
        {
            if (!File.Exists(registryPath))
            {
                return new SourceFingerprintRegistry
                {
                    schemaVersion = SourceFingerprintRegistrySchemaVersion,
                    fingerprintSchema = TimelineCameraDatasetExporter
                        .SourceFingerprintSchema,
                    entries = new List<SourceFingerprintRegistryEntry>()
                };
            }

            SourceFingerprintRegistry registry;
            try
            {
                registry = JsonUtility.FromJson<SourceFingerprintRegistry>(
                    File.ReadAllText(registryPath, Encoding.UTF8));
            }
            catch (Exception exception)
            {
                throw new InvalidDataException(
                    $"Unable to read source fingerprint registry " +
                    $"'{registryPath}'.",
                    exception);
            }

            if (registry == null ||
                !string.Equals(
                    registry.schemaVersion,
                    SourceFingerprintRegistrySchemaVersion,
                    StringComparison.Ordinal) ||
                !string.Equals(
                    registry.fingerprintSchema,
                    TimelineCameraDatasetExporter.SourceFingerprintSchema,
                    StringComparison.Ordinal))
            {
                throw new InvalidDataException(
                    $"Source fingerprint registry '{registryPath}' has an " +
                    "unsupported or missing schema marker.");
            }

            if (registry.entries == null)
            {
                throw new InvalidDataException(
                    $"Source fingerprint registry '{registryPath}' is " +
                    "missing its entries array.");
            }

            var duplicateDatasetNames = registry.entries
                .Where(entry => entry != null)
                .GroupBy(entry => entry.datasetName, StringComparer.Ordinal)
                .Where(group => group.Count() > 1)
                .Select(group => group.Key)
                .ToArray();
            if (registry.entries.Any(entry => entry == null) ||
                duplicateDatasetNames.Length > 0)
            {
                throw new InvalidDataException(
                    $"Source fingerprint registry '{registryPath}' contains " +
                    "null or duplicate dataset entries: " +
                    string.Join(", ", duplicateDatasetNames));
            }

            foreach (var entry in registry.entries)
            {
                ValidateSourceFingerprintRegistryEntry(entry, registryPath);
            }

            return registry;
        }

        private static void ValidateSourceFingerprintRegistryEntry(
            SourceFingerprintRegistryEntry entry,
            string registryPath)
        {
            var expectedDatasetName = string.IsNullOrWhiteSpace(entry.scenePath)
                ? string.Empty
                : BuildBaseDatasetName(entry.scenePath);
            if (string.IsNullOrWhiteSpace(entry.datasetName) ||
                string.IsNullOrWhiteSpace(entry.scenePath) ||
                string.IsNullOrWhiteSpace(entry.sourceFingerprint) ||
                string.IsNullOrWhiteSpace(entry.legacySourceFingerprint) ||
                string.IsNullOrWhiteSpace(entry.selectedOutputDirectory) ||
                !string.Equals(
                    entry.datasetName,
                    expectedDatasetName,
                    StringComparison.Ordinal) ||
                (!string.Equals(
                     entry.selectedOutputDirectory,
                     entry.datasetName,
                     StringComparison.Ordinal) &&
                 !entry.selectedOutputDirectory.StartsWith(
                     entry.datasetName + "_rev_",
                     StringComparison.Ordinal)) ||
                entry.selectedOutputDirectory == "." ||
                entry.selectedOutputDirectory == ".." ||
                entry.sourceFingerprint.Length != 64 ||
                entry.sourceFingerprint.Any(character =>
                    !Uri.IsHexDigit(character)) ||
                entry.legacySourceFingerprint.Length != 64 ||
                entry.legacySourceFingerprint.Any(character =>
                    !Uri.IsHexDigit(character)) ||
                !string.Equals(
                    entry.selectedOutputDirectory,
                    Path.GetFileName(entry.selectedOutputDirectory),
                    StringComparison.Ordinal) ||
                entry.selectedOutputDirectory.IndexOfAny(
                    new[]
                    {
                        Path.DirectorySeparatorChar,
                        Path.AltDirectorySeparatorChar
                    }) >= 0)
            {
                throw new InvalidDataException(
                    $"Source fingerprint registry '{registryPath}' contains " +
                    "an invalid entry.");
            }
        }

        private static SourceFingerprintRegistryEntry
            FindSourceFingerprintRegistryEntry(
                SourceFingerprintRegistry registry,
                string datasetName,
                string scenePath)
        {
            var entry = registry.entries.SingleOrDefault(candidate =>
                string.Equals(
                    candidate.datasetName,
                    datasetName,
                    StringComparison.Ordinal));
            if (entry != null &&
                !string.Equals(
                    entry.scenePath,
                    scenePath,
                    StringComparison.Ordinal))
            {
                throw new InvalidDataException(
                    $"Source fingerprint registry entry '{datasetName}' " +
                    $"belongs to '{entry.scenePath}', not '{scenePath}'.");
            }

            return entry;
        }

        private static bool IsCompleteExportResult(SceneExportResult result)
        {
            return result != null &&
                (result.status == "exported" ||
                 result.status == "exported_with_skips" ||
                 result.status == "already_complete" ||
                 result.status == "already_complete_with_skips");
        }

        private static bool RecordSourceFingerprintBaseline(
            SourceFingerprintRegistry registry,
            string outputRoot,
            SceneExportResult result)
        {
            if (!string.Equals(
                    result.sourceFingerprintSchema,
                    TimelineCameraDatasetExporter.SourceFingerprintSchema,
                    StringComparison.Ordinal) ||
                string.IsNullOrWhiteSpace(result.sourceFingerprint))
            {
                throw new InvalidDataException(
                    "A successful export result is missing its strong source " +
                    "fingerprint.");
            }

            var fullOutputRoot = Path.GetFullPath(outputRoot).TrimEnd(
                Path.DirectorySeparatorChar,
                Path.AltDirectorySeparatorChar);
            var fullSelectedOutput = Path.GetFullPath(result.outputPath)
                .TrimEnd(
                    Path.DirectorySeparatorChar,
                    Path.AltDirectorySeparatorChar);
            if (!string.Equals(
                    Path.GetDirectoryName(fullSelectedOutput),
                    fullOutputRoot,
                    StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidDataException(
                    $"Registered dataset output '{fullSelectedOutput}' is " +
                    $"not an immediate child of '{fullOutputRoot}'.");
            }

            var selectedOutputDirectory = Path.GetFileName(fullSelectedOutput);
            var datasetName = Path.GetFileName(result.baseOutputPath);
            var entry = FindSourceFingerprintRegistryEntry(
                registry,
                datasetName,
                result.scenePath);
            if (entry != null &&
                string.Equals(
                    entry.sourceFingerprint,
                    result.sourceFingerprint,
                    StringComparison.Ordinal) &&
                string.Equals(
                    entry.legacySourceFingerprint,
                    result.legacySourceFingerprint,
                    StringComparison.Ordinal) &&
                string.Equals(
                    entry.selectedOutputDirectory,
                    selectedOutputDirectory,
                    StringComparison.Ordinal))
            {
                return false;
            }

            var timestamp = DateTime.UtcNow.ToString("O");
            if (entry == null)
            {
                entry = new SourceFingerprintRegistryEntry
                {
                    datasetName = datasetName,
                    scenePath = result.scenePath
                };
                registry.entries.Add(entry);
            }

            entry.sourceFingerprint = result.sourceFingerprint;
            entry.legacySourceFingerprint = result.legacySourceFingerprint;
            entry.selectedOutputDirectory = selectedOutputDirectory;
            entry.updatedUtc = timestamp;
            registry.entries = registry.entries
                .OrderBy(item => item.datasetName, StringComparer.Ordinal)
                .ToList();
            registry.updatedUtc = timestamp;
            return true;
        }

        private static string ResolveRegisteredDatasetPath(
            string outputRoot,
            SourceFingerprintRegistryEntry entry)
        {
            var fullOutputRoot = Path.GetFullPath(outputRoot).TrimEnd(
                Path.DirectorySeparatorChar,
                Path.AltDirectorySeparatorChar);
            var registeredPath = Path.GetFullPath(Path.Combine(
                fullOutputRoot,
                entry.selectedOutputDirectory));
            if (!string.Equals(
                    Path.GetDirectoryName(registeredPath),
                    fullOutputRoot,
                    StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidDataException(
                    $"Registered output '{entry.selectedOutputDirectory}' " +
                    "escapes the dataset output root.");
            }

            return registeredPath;
        }

        private static bool AreSamePath(string first, string second)
        {
            return string.Equals(
                Path.GetFullPath(first).TrimEnd(
                    Path.DirectorySeparatorChar,
                    Path.AltDirectorySeparatorChar),
                Path.GetFullPath(second).TrimEnd(
                    Path.DirectorySeparatorChar,
                    Path.AltDirectorySeparatorChar),
                StringComparison.OrdinalIgnoreCase);
        }

        private static SceneExportResult ExportCandidate(
            SceneCandidate candidate,
            int index,
            int candidateCount,
            string outputRoot,
            bool requireAudio,
            bool preserveRevisions,
            bool forceRevision,
            SourceFingerprintRegistryEntry registryEntry)
        {
            var sceneName = Path.GetFileNameWithoutExtension(candidate.scenePath);
            var datasetName = BuildBaseDatasetName(candidate.scenePath);
            var datasetPath = Path.Combine(outputRoot, datasetName);
            var manifestPath = Path.Combine(datasetPath, "dataset_manifest.json");
            var result = new SceneExportResult
            {
                candidateIndex = index,
                scenePath = candidate.scenePath,
                timelinePaths = candidate.timelines
                    .Select(timeline => timeline.assetPath)
                    .ToList(),
                baseOutputPath = datasetPath,
                outputPath = datasetPath,
                forceRevisionRequested = forceRevision,
                startedUtc = DateTime.UtcNow.ToString("O")
            };

            var stopwatch = System.Diagnostics.Stopwatch.StartNew();
            var workingPath = Path.Combine(
                $"{outputRoot}.working",
                $"{index:D4}_{StableHash(candidate.scenePath)}_" +
                Guid.NewGuid().ToString("N"));
            try
            {
                EditorUtility.DisplayProgressBar(
                    "YAMO Camera Dataset Export",
                    $"{index + 1:N0} / {candidateCount:N0}: {sceneName}",
                    (index + 1) / (float)Math.Max(1, candidateCount));
                Debug.Log(
                    $"[CW-AI] Exporting scene {index + 1}/{candidateCount}: " +
                    candidate.scenePath);

                var openedScene = EditorSceneManager.OpenScene(
                    candidate.scenePath,
                    OpenSceneMode.Single);
                if (!openedScene.IsValid() || !openedScene.isLoaded)
                {
                    throw new InvalidOperationException(
                        $"Unable to load scene '{candidate.scenePath}'.");
                }

                var loadedTimelines = DiscoverLoadedSceneTimelines(openedScene);
                if (loadedTimelines.Count == 0)
                {
                    result.status = "not_exportable";
                    result.error =
                        "The loaded scene has no Cinemachine Timeline with shots.";
                    return result;
                }

                candidate = new SceneCandidate
                {
                    scenePath = candidate.scenePath,
                    timelines = loadedTimelines
                };
                result.timelinePaths = loadedTimelines
                    .Select(timeline => timeline.assetPath)
                    .ToList();

                var currentFingerprint = TimelineCameraDatasetExporter
                    .ComputeSceneCameraSourceFingerprint(
                        openedScene,
                        out var currentLegacyFingerprint);
                result.sourceFingerprintSchema =
                    TimelineCameraDatasetExporter.SourceFingerprintSchema;
                result.sourceFingerprint = currentFingerprint;
                result.legacySourceFingerprint = currentLegacyFingerprint;

                var selectedDatasetPath = datasetPath;
                var selectedManifestPath = manifestPath;

                if (registryEntry != null)
                {
                    result.sourceFingerprintRegistryEntryFound = true;
                    result.registeredSourceFingerprint =
                        registryEntry.sourceFingerprint;
                    var registeredDatasetPath =
                        ResolveRegisteredDatasetPath(outputRoot, registryEntry);
                    result.registeredOutputPath = registeredDatasetPath;
                    var registryFingerprintMatches = string.Equals(
                        registryEntry.sourceFingerprint,
                        currentFingerprint,
                        StringComparison.Ordinal);
                    result.sourceFingerprintRegistryMatched =
                        registryFingerprintMatches;

                    if (registryFingerprintMatches)
                    {
                        var registeredManifestPath = Path.Combine(
                            registeredDatasetPath,
                            "dataset_manifest.json");
                        if (!File.Exists(registeredManifestPath))
                        {
                            result.status = "invalid_registered_output";
                            result.error =
                                "The strong fingerprint registry points to a " +
                                "missing dataset manifest: " +
                                registeredManifestPath;
                            return result;
                        }

                        if (!ValidateCompleteDataset(
                                registeredDatasetPath,
                                candidate,
                                requireAudio,
                                currentFingerprint,
                                currentLegacyFingerprint,
                                out var legacyRegisteredFingerprintMatched,
                                out var registeredValidationError))
                        {
                            result.status = "invalid_registered_output";
                            result.error = registeredValidationError;
                            return result;
                        }

                        var registeredIsRevision = !AreSamePath(
                            registeredDatasetPath,
                            datasetPath);
                        var forceAlreadySatisfied =
                            forceRevision &&
                            registeredIsRevision &&
                            !legacyRegisteredFingerprintMatched;
                        if (!forceRevision || forceAlreadySatisfied)
                        {
                            result.outputPath = registeredDatasetPath;
                            result.isRevision = registeredIsRevision;
                            if (registeredIsRevision)
                            {
                                result.revisionOfOutputPath = datasetPath;
                                result.revisionSourceFingerprint =
                                    registryEntry.sourceFingerprint;
                                result.revisionReason =
                                    "Selected by the strong fingerprint " +
                                    "registry.";
                            }

                            result.forceRevisionApplied =
                                forceAlreadySatisfied;
                            result.fingerprintMigrationRequired =
                                legacyRegisteredFingerprintMatched;
                            PopulateTrackCounts(registeredDatasetPath, result);
                            result.status = result.skippedCameraTrackCount > 0
                                ? "already_complete_with_skips"
                                : "already_complete";
                            return result;
                        }

                        if (!preserveRevisions)
                        {
                            result.status = "invalid_existing";
                            result.error =
                                "A forced revision requires revision-preserving " +
                                "mode.";
                            return result;
                        }

                        result.revisionReason =
                            legacyRegisteredFingerprintMatched
                                ? "Explicit forced migration from the " +
                                  "registered legacy output to the strong " +
                                  "dependency fingerprint."
                                : "Explicit forced revision from the " +
                                  "registered base output.";
                    }
                    else
                    {
                        if (!preserveRevisions)
                        {
                            result.status = "invalid_existing";
                            result.error =
                                "The current strong source fingerprint differs " +
                                "from the registered baseline.";
                            return result;
                        }

                        result.revisionReason =
                            "The current strong source fingerprint differs " +
                            "from the registered baseline " +
                            registryEntry.sourceFingerprint + ".";
                    }

                    selectedDatasetPath = BuildRevisionDatasetPath(
                        outputRoot,
                        datasetName,
                        currentFingerprint);
                    selectedManifestPath = Path.Combine(
                        selectedDatasetPath,
                        "dataset_manifest.json");
                    result.outputPath = selectedDatasetPath;
                    result.isRevision = true;
                    result.revisionOfOutputPath = datasetPath;
                    result.revisionSourceFingerprint = currentFingerprint;
                    result.forceRevisionApplied = forceRevision;
                }
                else if (File.Exists(manifestPath))
                {
                    var baseIsValid = ValidateCompleteDataset(
                        datasetPath,
                        candidate,
                        requireAudio,
                        currentFingerprint,
                        currentLegacyFingerprint,
                        out var legacyBaseFingerprintMatched,
                        out var existingValidationError);
                    result.legacyBaseFingerprintMatched =
                        legacyBaseFingerprintMatched;
                    var strongRevisionPath = BuildRevisionDatasetPath(
                        outputRoot,
                        datasetName,
                        currentFingerprint);
                    var strongRevisionManifestPath = Path.Combine(
                        strongRevisionPath,
                        "dataset_manifest.json");
                    if (baseIsValid &&
                        (!preserveRevisions ||
                         (!forceRevision &&
                          !File.Exists(strongRevisionManifestPath))))
                    {
                        PopulateTrackCounts(datasetPath, result);
                        result.status = result.skippedCameraTrackCount > 0
                            ? "already_complete_with_skips"
                            : "already_complete";
                        result.fingerprintMigrationRequired =
                            legacyBaseFingerprintMatched;
                    }
                    else if (!preserveRevisions)
                    {
                        result.status = "invalid_existing";
                        result.error = existingValidationError;
                    }
                    else
                    {
                        selectedDatasetPath = strongRevisionPath;
                        selectedManifestPath = strongRevisionManifestPath;
                        result.outputPath = selectedDatasetPath;
                        result.isRevision = true;
                        result.revisionOfOutputPath = datasetPath;
                        result.revisionSourceFingerprint = currentFingerprint;
                        result.forceRevisionApplied = forceRevision;
                        result.revisionReason = forceRevision &&
                            legacyBaseFingerprintMatched
                                ? "Explicit forced migration from a matching " +
                                  "legacy semantic fingerprint to the strong " +
                                  "dependency fingerprint."
                                : forceRevision
                                    ? "Explicit forced revision. Base " +
                                      "validation: " + existingValidationError
                                    : baseIsValid
                                        ? "An existing strong revision for " +
                                          "the current source supersedes the " +
                                          "legacy base."
                                    : existingValidationError;
                    }

                    if (!preserveRevisions ||
                        !string.IsNullOrWhiteSpace(result.status))
                    {
                        return result;
                    }
                }
                else if (Directory.Exists(datasetPath))
                {
                    if (!preserveRevisions)
                    {
                        result.status = "partial_exists";
                        result.error =
                            "An existing output directory was preserved and not overwritten.";
                        return result;
                    }

                    selectedDatasetPath = BuildRevisionDatasetPath(
                        outputRoot,
                        datasetName,
                        currentFingerprint);
                    selectedManifestPath = Path.Combine(
                        selectedDatasetPath,
                        "dataset_manifest.json");
                    result.outputPath = selectedDatasetPath;
                    result.isRevision = true;
                    result.revisionOfOutputPath = datasetPath;
                    result.revisionSourceFingerprint = currentFingerprint;
                    result.forceRevisionApplied = forceRevision;
                    result.revisionReason =
                        forceRevision
                            ? "Explicit forced revision; the partial base " +
                              "output directory was preserved."
                            : "The base output directory is partial and was " +
                              "preserved.";
                }

                if (result.isRevision && File.Exists(selectedManifestPath))
                {
                    if (ValidateCompleteDataset(
                            selectedDatasetPath,
                            candidate,
                            requireAudio,
                            currentFingerprint,
                            currentLegacyFingerprint,
                            out var legacyRevisionFingerprintMatched,
                            out var revisionValidationError))
                    {
                        PopulateTrackCounts(selectedDatasetPath, result);
                        result.status = result.skippedCameraTrackCount > 0
                            ? "already_complete_with_skips"
                            : "already_complete";
                        result.fingerprintMigrationRequired =
                            legacyRevisionFingerprintMatched;
                    }
                    else
                    {
                        result.status = "invalid_existing_revision";
                        result.error = revisionValidationError;
                    }

                    return result;
                }

                if (result.isRevision &&
                    registryEntry == null &&
                    !forceRevision)
                {
                    var legacyRevisionPath = BuildRevisionDatasetPath(
                        outputRoot,
                        datasetName,
                        currentLegacyFingerprint);
                    var legacyRevisionManifestPath = Path.Combine(
                        legacyRevisionPath,
                        "dataset_manifest.json");
                    if (!AreSamePath(
                            legacyRevisionPath,
                            selectedDatasetPath) &&
                        File.Exists(legacyRevisionManifestPath) &&
                        ValidateCompleteDataset(
                            legacyRevisionPath,
                            candidate,
                            requireAudio,
                            currentFingerprint,
                            currentLegacyFingerprint,
                            out var legacyRevisionFingerprintMatched,
                            out _) &&
                        legacyRevisionFingerprintMatched)
                    {
                        result.outputPath = legacyRevisionPath;
                        result.legacyRevisionReused = true;
                        result.revisionSourceFingerprint =
                            currentLegacyFingerprint;
                        result.revisionReason =
                            "Reused the complete legacy revision as the first " +
                            "strong fingerprint registry baseline.";
                        result.fingerprintMigrationRequired = true;
                        PopulateTrackCounts(legacyRevisionPath, result);
                        result.status = result.skippedCameraTrackCount > 0
                            ? "already_complete_with_skips"
                            : "already_complete";
                        return result;
                    }
                }

                if (result.isRevision && Directory.Exists(selectedDatasetPath))
                {
                    result.status = "partial_revision_exists";
                    result.error =
                        "An existing revision directory was preserved and not overwritten.";
                    return result;
                }

                TimelineCameraDatasetExporter.ExportActiveScene60FpsToForCli(
                    workingPath,
                    requireAudio,
                    true);
                var workingManifestPath = Path.Combine(
                    workingPath,
                    "dataset_manifest.json");
                if (!File.Exists(workingManifestPath))
                {
                    throw new IOException(
                        $"Export completed without a manifest: '{workingManifestPath}'.");
                }

                if (!ValidateCompleteDataset(
                        workingPath,
                        candidate,
                        requireAudio,
                        currentFingerprint,
                        currentLegacyFingerprint,
                        out _,
                        out var validationError))
                {
                    throw new InvalidDataException(
                        $"Export validation failed: {validationError}");
                }

                Directory.CreateDirectory(outputRoot);
                Directory.Move(workingPath, selectedDatasetPath);
                PopulateTrackCounts(selectedDatasetPath, result);
                result.status = result.skippedCameraTrackCount > 0
                    ? "exported_with_skips"
                    : "exported";
            }
            catch (Exception exception)
            {
                result.status =
                    exception is InvalidOperationException &&
                    exception.Message.IndexOf(
                        "does not contain a supported camera Timeline",
                        StringComparison.Ordinal) >= 0
                        ? "not_exportable"
                        : "failed";
                result.error = exception.ToString();
                if (Directory.Exists(workingPath))
                {
                    result.partialOutputPath = workingPath;
                }

                Debug.LogError(
                    $"[CW-AI] Scene export failed: {candidate.scenePath}\n{exception}");
            }
            finally
            {
                stopwatch.Stop();
                result.elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
                result.completedUtc = DateTime.UtcNow.ToString("O");
            }

            return result;
        }

        private static void PopulateTrackCounts(
            string datasetPath,
            SceneExportResult result)
        {
            var manifest = JsonUtility.FromJson<DatasetManifestProbe>(
                File.ReadAllText(
                    Path.Combine(datasetPath, "dataset_manifest.json"),
                    Encoding.UTF8));
            result.discoveredCameraTrackCount =
                manifest?.discoveredCameraTrackCount ?? 0;
            result.exportedCameraTrackCount =
                manifest?.exportedCameraTrackCount ?? 0;
            result.skippedCameraTrackCount =
                manifest?.skippedCameraTracks?.Count ?? 0;
        }

        private static bool ValidateCompleteDataset(
            string datasetPath,
            SceneCandidate candidate,
            bool requireAudio,
            string currentStrongFingerprint,
            string currentLegacyFingerprint,
            out bool legacyFingerprintMatched,
            out string error)
        {
            legacyFingerprintMatched = false;
            try
            {
                var manifestPath = Path.Combine(
                    datasetPath,
                    "dataset_manifest.json");
                if (!File.Exists(manifestPath))
                {
                    error = "dataset_manifest.json is missing.";
                    return false;
                }

                var manifest = JsonUtility.FromJson<DatasetManifestProbe>(
                    File.ReadAllText(manifestPath, Encoding.UTF8));
                if (manifest == null)
                {
                    error = "dataset_manifest.json could not be parsed.";
                    return false;
                }

                if (!string.Equals(
                        manifest.schemaVersion,
                        DatasetSchemaVersion,
                        StringComparison.Ordinal))
                {
                    error =
                        $"Unsupported dataset schema '{manifest.schemaVersion}'.";
                    return false;
                }

                if (!string.Equals(
                        manifest.scenePath,
                        candidate.scenePath,
                        StringComparison.Ordinal))
                {
                    error =
                        $"Manifest scene '{manifest.scenePath}' does not match " +
                        $"'{candidate.scenePath}'.";
                    return false;
                }

                if (!TimelineCameraDatasetExporter
                        .TryMatchSceneCameraSourceFingerprint(
                            manifest.sourceFingerprintSchema,
                            manifest.sourceFingerprint,
                            currentStrongFingerprint,
                            currentLegacyFingerprint,
                            out legacyFingerprintMatched,
                            out var fingerprintError))
                {
                    error = fingerprintError;
                    return false;
                }

                if (manifest.sampleRate != 60 ||
                    manifest.songs == null ||
                    manifest.songs.Count == 0)
                {
                    error = "Manifest sample rate or song list is invalid.";
                    return false;
                }

                var skippedCount =
                    manifest.skippedCameraTracks?.Count ?? 0;
                if (manifest.discoveredCameraTrackCount <= 0 ||
                    manifest.exportedCameraTrackCount !=
                    manifest.songs.Count ||
                    manifest.discoveredCameraTrackCount !=
                    manifest.exportedCameraTrackCount + skippedCount)
                {
                    error =
                        "Camera track accounting in the manifest is inconsistent.";
                    return false;
                }

                var folderNames = new HashSet<string>(
                    StringComparer.OrdinalIgnoreCase);
                foreach (var song in manifest.songs)
                {
                    if (song == null ||
                        song.frameCount <= 0 ||
                        song.sharedBoneCount <= 0 ||
                        song.shotCount <= 0 ||
                        song.missingCameraFrames != 0 ||
                        song.missingJointSamples != 0 ||
                        song.missingRootSamples != 0 ||
                        string.IsNullOrWhiteSpace(song.cameraTrackName) ||
                        !folderNames.Add(song.folderName) ||
                        !candidate.timelines.Any(timeline =>
                            string.Equals(
                                timeline.assetPath,
                                song.motionTimelineAssetPath,
                                StringComparison.Ordinal)) ||
                        !TryResolveChildPath(
                            datasetPath,
                            song.folderName,
                            out var songPath))
                    {
                        error = "Manifest contains invalid song metadata.";
                        return false;
                    }

                    if (!ValidateFileSize(
                            songPath,
                            song.jointsFile,
                            (long)song.frameCount *
                            song.sharedBoneCount * 3 * sizeof(float),
                            out error) ||
                        !ValidateFileSize(
                            songPath,
                            song.cameraFile,
                            (long)song.frameCount * 9 * sizeof(float),
                            out error) ||
                        !ValidateFileSize(
                            songPath,
                            song.rootFile,
                            (long)song.frameCount * 14 * sizeof(float),
                            out error) ||
                        !ValidateFileSize(
                            songPath,
                            song.shotIndexFile,
                            (long)song.frameCount * sizeof(int),
                            out error) ||
                        !ValidateFileSize(
                            songPath,
                            song.timeFile,
                            (long)song.frameCount * sizeof(double),
                            out error) ||
                        !ValidateRequiredFile(
                            songPath,
                            "metadata.json",
                            out error,
                            true) ||
                        !ValidateRequiredFile(
                            songPath,
                            song.shotsFile,
                            out error,
                            true) ||
                        !ValidateRequiredFile(
                            songPath,
                            song.previewFile,
                            out error,
                            true))
                    {
                        return false;
                    }

                    if (requireAudio &&
                        string.IsNullOrWhiteSpace(song.audioFile))
                    {
                        error = "Required source audio is not embedded.";
                        return false;
                    }

                    if (!string.IsNullOrWhiteSpace(song.audioFile) &&
                        !ValidateRequiredFile(
                            songPath,
                            song.audioFile,
                            out error,
                            true))
                    {
                        return false;
                    }
                }

                error = string.Empty;
                return true;
            }
            catch (Exception exception)
            {
                error = exception.Message;
                return false;
            }
        }

        private static bool ValidateFileSize(
            string directory,
            string relativePath,
            long expectedBytes,
            out string error)
        {
            if (!TryResolveChildPath(
                    directory,
                    relativePath,
                    out var fullPath) ||
                !File.Exists(fullPath))
            {
                error = $"Required file '{relativePath}' is missing.";
                return false;
            }

            var actualBytes = new FileInfo(fullPath).Length;
            if (actualBytes != expectedBytes)
            {
                error =
                    $"File '{relativePath}' has {actualBytes} bytes; " +
                    $"{expectedBytes} were expected.";
                return false;
            }

            error = string.Empty;
            return true;
        }

        private static bool ValidateRequiredFile(
            string directory,
            string relativePath,
            out string error,
            bool requireNonEmpty = false)
        {
            if (!TryResolveChildPath(
                    directory,
                    relativePath,
                    out var fullPath) ||
                !File.Exists(fullPath))
            {
                error = $"Required file '{relativePath}' is missing.";
                return false;
            }

            if (requireNonEmpty && new FileInfo(fullPath).Length == 0)
            {
                error = $"Required file '{relativePath}' is empty.";
                return false;
            }

            error = string.Empty;
            return true;
        }

        private static bool TryResolveChildPath(
            string directory,
            string relativePath,
            out string fullPath)
        {
            fullPath = string.Empty;
            if (string.IsNullOrWhiteSpace(relativePath))
            {
                return false;
            }

            var root = Path.GetFullPath(directory)
                .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
                Path.DirectorySeparatorChar;
            var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
            if (!candidate.StartsWith(
                    root,
                    StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            fullPath = candidate;
            return true;
        }

        private static List<SceneCandidate> DiscoverCandidateScenes()
        {
            var timelineByGuid = new Dictionary<string, TimelineInventory>(
                StringComparer.Ordinal);
            foreach (var guid in AssetDatabase.FindAssets(
                "t:TimelineAsset",
                new[] { AuthoredSceneRoot }))
            {
                var assetPath = AssetDatabase.GUIDToAssetPath(guid);
                var timeline = AssetDatabase.LoadAssetAtPath<TimelineAsset>(assetPath);
                var inventory = BuildTimelineInventory(timeline);
                if (inventory == null)
                {
                    continue;
                }

                inventory.assetPath = assetPath;
                timelineByGuid[guid] = inventory;
            }

            var prefabTimelines = BuildPrefabTimelineMap(timelineByGuid);
            var candidates = new List<SceneCandidate>();
            foreach (var guid in AssetDatabase.FindAssets(
                "t:Scene",
                new[] { AuthoredSceneRoot }))
            {
                var scenePath = AssetDatabase.GUIDToAssetPath(guid);
                var timelines = FindPlayableDirectorTimelineGuids(scenePath)
                    .Concat(FindReferencedGuids(scenePath))
                    .Concat(FindSourcePrefabGuids(scenePath)
                        .SelectMany(prefabGuid =>
                            ResolvePrefabTimelineGuids(
                                prefabGuid,
                                prefabTimelines,
                                new HashSet<string>(StringComparer.Ordinal))))
                    .Where(timelineByGuid.ContainsKey)
                    .Select(timelineGuid => timelineByGuid[timelineGuid])
                    .GroupBy(timeline => timeline.assetPath, StringComparer.Ordinal)
                    .Select(group => group.First())
                    .OrderBy(timeline => timeline.assetPath, StringComparer.Ordinal)
                    .ToList();
                if (timelines.Count == 0)
                {
                    continue;
                }

                candidates.Add(new SceneCandidate
                {
                    scenePath = scenePath,
                    timelines = timelines
                });
            }

            return candidates
                .OrderBy(candidate => candidate.scenePath, StringComparer.Ordinal)
                .ToList();
        }

        private static TimelineInventory BuildTimelineInventory(
            TimelineAsset timeline)
        {
            if (timeline == null)
            {
                return null;
            }

            var tracks = timeline.GetOutputTracks().ToArray();
            var cameraTracks = tracks.OfType<CinemachineTrack>().ToArray();
            var shotCount = cameraTracks
                .SelectMany(track => track.GetClips())
                .Count(clip => clip.asset is CinemachineShot);
            if (shotCount == 0)
            {
                return null;
            }

            var audioTracks = tracks.OfType<AudioTrack>().ToArray();
            return new TimelineInventory
            {
                assetPath = AssetDatabase.GetAssetPath(timeline),
                timelineName = timeline.name,
                duration = timeline.duration,
                cameraTrackCount = cameraTracks.Length,
                shotCount = shotCount,
                animationTrackCount = tracks.OfType<AnimationTrack>().Count(),
                audioTrackCount = audioTracks.Length,
                audioClipCount = audioTracks
                    .SelectMany(track => track.GetClips())
                    .Count(clip =>
                        clip.asset is AudioPlayableAsset audioAsset &&
                        audioAsset.clip != null)
            };
        }

        private static List<TimelineInventory> DiscoverLoadedSceneTimelines(
            Scene scene)
        {
            return Resources
                .FindObjectsOfTypeAll<UnityEngine.Playables.PlayableDirector>()
                .Where(director =>
                    director.gameObject.scene == scene &&
                    director.playableAsset is TimelineAsset)
                .Select(director => BuildTimelineInventory(
                    director.playableAsset as TimelineAsset))
                .Where(inventory => inventory != null)
                .GroupBy(
                    inventory => inventory.assetPath,
                    StringComparer.Ordinal)
                .Select(group => group.First())
                .OrderBy(
                    inventory => inventory.assetPath,
                    StringComparer.Ordinal)
                .ToList();
        }

        private static Dictionary<string, PrefabTimelineReferences>
            BuildPrefabTimelineMap(
                IReadOnlyDictionary<string, TimelineInventory> timelineByGuid)
        {
            var result =
                new Dictionary<string, PrefabTimelineReferences>(
                    StringComparer.Ordinal);
            foreach (var prefabGuid in AssetDatabase.FindAssets(
                "t:Prefab",
                new[] { AuthoredSceneRoot }))
            {
                var prefabPath = AssetDatabase.GUIDToAssetPath(prefabGuid);
                result[prefabGuid] = new PrefabTimelineReferences
                {
                    timelineGuids = FindPlayableDirectorTimelineGuids(prefabPath)
                        .Where(timelineByGuid.ContainsKey)
                        .Distinct(StringComparer.Ordinal)
                        .ToList(),
                    sourcePrefabGuids = FindSourcePrefabGuids(prefabPath)
                        .Distinct(StringComparer.Ordinal)
                        .ToList()
                };
            }

            return result;
        }

        private static IEnumerable<string> ResolvePrefabTimelineGuids(
            string prefabGuid,
            IReadOnlyDictionary<string, PrefabTimelineReferences> prefabTimelines,
            HashSet<string> visiting)
        {
            if (!visiting.Add(prefabGuid) ||
                !prefabTimelines.TryGetValue(
                    prefabGuid,
                    out var references))
            {
                yield break;
            }

            foreach (var timelineGuid in references.timelineGuids)
            {
                yield return timelineGuid;
            }

            foreach (var sourcePrefabGuid in references.sourcePrefabGuids)
            {
                foreach (var timelineGuid in ResolvePrefabTimelineGuids(
                    sourcePrefabGuid,
                    prefabTimelines,
                    visiting))
                {
                    yield return timelineGuid;
                }
            }

            visiting.Remove(prefabGuid);
        }

        private static DirectorDiagnostic BuildDirectorDiagnostic(
            UnityEngine.Playables.PlayableDirector director)
        {
            var timeline = (TimelineAsset)director.playableAsset;
            var tracks = timeline.GetOutputTracks().ToArray();
            return new DirectorDiagnostic
            {
                directorPath = GetHierarchyPath(director.transform),
                timelinePath = AssetDatabase.GetAssetPath(timeline),
                timelineName = timeline.name,
                duration = director.duration,
                animationTracks = tracks
                    .OfType<AnimationTrack>()
                    .Select(track => BuildAnimationTrackDiagnostic(director, track))
                    .ToList(),
                cameraTrackCount = tracks.OfType<CinemachineTrack>().Count(),
                shotCount = tracks
                    .OfType<CinemachineTrack>()
                    .SelectMany(track => track.GetClips())
                    .Count(clip => clip.asset is CinemachineShot),
                resolvedCameraCount = tracks
                    .OfType<CinemachineTrack>()
                    .SelectMany(track => track.GetClips())
                    .Select(clip => clip.asset as CinemachineShot)
                    .Count(shot =>
                        shot != null &&
                        shot.VirtualCamera.Resolve(director) != null),
                audioClipPaths = tracks
                    .OfType<AudioTrack>()
                    .SelectMany(track => track.GetClips())
                    .Select(clip => clip.asset as AudioPlayableAsset)
                    .Where(asset => asset?.clip != null)
                    .Select(asset => AssetDatabase.GetAssetPath(asset.clip))
                    .ToList()
            };
        }

        private static AnimationTrackDiagnostic BuildAnimationTrackDiagnostic(
            UnityEngine.Playables.PlayableDirector director,
            AnimationTrack track)
        {
            var binding = director.GetGenericBinding(track);
            var animator = binding as Animator;
            var clips = track.GetClips()
                .Select(clip => clip.asset as AnimationPlayableAsset)
                .Where(asset => asset?.clip != null)
                .Select(asset => asset.clip)
                .ToList();
            if (track.infiniteClip != null)
            {
                clips.Add(track.infiniteClip);
            }

            var transformPaths = clips
                .Distinct()
                .SelectMany(AnimationUtility.GetCurveBindings)
                .Where(bindingInfo => bindingInfo.type == typeof(Transform))
                .Select(bindingInfo => bindingInfo.path)
                .Distinct(StringComparer.Ordinal)
                .ToArray();
            return new AnimationTrackDiagnostic
            {
                trackName = track.name,
                muted = track.muted,
                bindingType = binding?.GetType().FullName ?? string.Empty,
                bindingName = binding is UnityEngine.Object unityObject
                    ? unityObject.name
                    : string.Empty,
                animatorPath = animator != null
                    ? GetHierarchyPath(animator.transform)
                    : string.Empty,
                animatorIsHuman = animator != null && animator.isHuman,
                animatorActive = animator != null &&
                    animator.gameObject.activeInHierarchy,
                avatarName = animator?.avatar != null
                    ? animator.avatar.name
                    : string.Empty,
                avatarIsValid = animator?.avatar != null &&
                    animator.avatar.isValid,
                transformCount = animator != null
                    ? animator.GetComponentsInChildren<Transform>(true).Length
                    : 0,
                animationClipCount = clips.Distinct().Count(),
                transformCurvePathCount = transformPaths.Length,
                firstTransformPaths = transformPaths.Take(12).ToList()
            };
        }

        private static IEnumerable<string> FindPlayableDirectorTimelineGuids(
            string scenePath)
        {
            var fullPath = GetFullAssetPath(scenePath);
            var isPlayableDirector = false;
            foreach (var line in File.ReadLines(fullPath))
            {
                if (line.StartsWith("--- !u!", StringComparison.Ordinal))
                {
                    isPlayableDirector = line.StartsWith(
                        "--- !u!320 ",
                        StringComparison.Ordinal);
                    continue;
                }

                if (!isPlayableDirector ||
                    line.IndexOf(
                        "m_PlayableAsset:",
                        StringComparison.Ordinal) < 0)
                {
                    continue;
                }

                const string guidMarker = "guid: ";
                var guidStart = line.IndexOf(
                    guidMarker,
                    StringComparison.Ordinal);
                if (guidStart < 0)
                {
                    continue;
                }

                guidStart += guidMarker.Length;
                if (line.Length >= guidStart + 32)
                {
                    yield return line.Substring(guidStart, 32);
                }
            }
        }

        private static IEnumerable<string> FindSourcePrefabGuids(
            string assetPath)
        {
            var fullPath = GetFullAssetPath(assetPath);
            foreach (var line in File.ReadLines(fullPath))
            {
                if (line.IndexOf(
                        "m_SourcePrefab:",
                        StringComparison.Ordinal) < 0)
                {
                    continue;
                }

                const string guidMarker = "guid: ";
                var guidStart = line.IndexOf(
                    guidMarker,
                    StringComparison.Ordinal);
                if (guidStart < 0)
                {
                    continue;
                }

                guidStart += guidMarker.Length;
                if (line.Length >= guidStart + 32)
                {
                    yield return line.Substring(guidStart, 32);
                }
            }
        }

        private static IEnumerable<string> FindReferencedGuids(
            string assetPath)
        {
            var fullPath = GetFullAssetPath(assetPath);
            const string guidMarker = "guid: ";
            foreach (var line in File.ReadLines(fullPath))
            {
                var searchStart = 0;
                while (searchStart < line.Length)
                {
                    var guidStart = line.IndexOf(
                        guidMarker,
                        searchStart,
                        StringComparison.Ordinal);
                    if (guidStart < 0)
                    {
                        break;
                    }

                    guidStart += guidMarker.Length;
                    if (line.Length >= guidStart + 32)
                    {
                        yield return line.Substring(guidStart, 32);
                    }

                    searchStart = guidStart + 32;
                }
            }
        }

        private static string GetFullAssetPath(string assetPath)
        {
            var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
                ?? throw new InvalidOperationException(
                    "Unable to resolve the Unity project root.");
            return Path.GetFullPath(Path.Combine(projectRoot, assetPath));
        }

        private static void EnsureEditMode()
        {
            if (EditorApplication.isPlayingOrWillChangePlaymode)
            {
                throw new InvalidOperationException(
                    "Camera dataset operations must run in Edit Mode.");
            }
        }

        private static void EnsureNoDirtyScenes()
        {
            if (PrefabStageUtility.GetCurrentPrefabStage() != null)
            {
                throw new InvalidOperationException(
                    "Close Prefab Mode before batch export.");
            }

            var dirtyScenePaths = Enumerable.Range(0, SceneManager.sceneCount)
                .Select(SceneManager.GetSceneAt)
                .Where(scene => scene.isDirty)
                .Select(scene => string.IsNullOrWhiteSpace(scene.path)
                    ? $"<untitled:{scene.name}>"
                    : scene.path)
                .ToArray();
            if (dirtyScenePaths.Length > 0)
            {
                throw new InvalidOperationException(
                    "Save or discard dirty scenes before batch export: " +
                    string.Join(", ", dirtyScenePaths));
            }
        }

        private static void RestoreSceneSetup(SceneSetup[] setup)
        {
            if (setup != null && setup.Length > 0)
            {
                EditorSceneManager.RestoreSceneManagerSetup(setup);
                return;
            }

            EditorSceneManager.NewScene(
                NewSceneSetup.DefaultGameObjects,
                NewSceneMode.Single);
        }

        private static string StableHash(string value)
        {
            using var sha256 = SHA256.Create();
            var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(value));
            return string.Concat(bytes
                .Take(4)
                .Select(item => item.ToString("x2")));
        }

        private static string BuildRevisionDatasetPath(
            string outputRoot,
            string datasetName,
            string sourceFingerprint)
        {
            if (string.IsNullOrWhiteSpace(sourceFingerprint) ||
                sourceFingerprint.Length < RevisionFingerprintLength ||
                sourceFingerprint
                    .Take(RevisionFingerprintLength)
                    .Any(character => !Uri.IsHexDigit(character)))
            {
                throw new InvalidDataException(
                    "A full hexadecimal source fingerprint is required for " +
                    "a preserved dataset revision.");
            }

            var revisionSuffix = sourceFingerprint
                .Substring(0, RevisionFingerprintLength)
                .ToLowerInvariant();
            return Path.Combine(
                outputRoot,
                $"{datasetName}_rev_{revisionSuffix}");
        }

        private static string SanitizeFileName(string value, int maximumLength)
        {
            var invalidCharacters = Path.GetInvalidFileNameChars();
            var sanitized = new string(value
                .Select(character => invalidCharacters.Contains(character)
                    ? '_'
                    : character)
                .ToArray())
                .Trim();
            if (string.IsNullOrWhiteSpace(sanitized))
            {
                sanitized = "Scene";
            }

            return sanitized.Length <= maximumLength
                ? sanitized
                : sanitized.Substring(0, maximumLength);
        }

        private static string GetHierarchyPath(Transform transform)
        {
            var names = new List<string>();
            while (transform != null)
            {
                names.Add(transform.name);
                transform = transform.parent;
            }

            names.Reverse();
            return string.Join("/", names);
        }

        private static void WriteJsonAtomic<T>(string path, T value)
        {
            var directory = Path.GetDirectoryName(path);
            if (!string.IsNullOrWhiteSpace(directory))
            {
                Directory.CreateDirectory(directory);
            }

            var payload = JsonUtility.ToJson(value, true);
            const int maximumAttempts = 8;
            for (var attempt = 1; attempt <= maximumAttempts; attempt++)
            {
                var temporaryPath = $"{path}.tmp";
                try
                {
                    File.WriteAllText(
                        temporaryPath,
                        payload,
                        Encoding.UTF8);
                    if (File.Exists(path))
                    {
                        File.Replace(temporaryPath, path, null);
                    }
                    else
                    {
                        File.Move(temporaryPath, path);
                    }

                    return;
                }
                catch (IOException) when (attempt < maximumAttempts)
                {
                    System.Threading.Thread.Sleep(attempt * 125);
                }
                catch (UnauthorizedAccessException)
                    when (attempt < maximumAttempts)
                {
                    System.Threading.Thread.Sleep(attempt * 125);
                }
            }
        }

        [Serializable]
        private sealed class InventoryReport
        {
            public string schemaVersion;
            public string createdUtc;
            public string unityVersion;
            public string assetRoot;
            public int candidateSceneCount;
            public int uniqueTimelineCount;
            public List<SceneCandidate> candidates;
        }

        [Serializable]
        private sealed class ActiveSceneDiagnosticReport
        {
            public string schemaVersion;
            public string createdUtc;
            public string scenePath;
            public List<DirectorDiagnostic> directors;
        }

        [Serializable]
        private sealed class DirectorDiagnostic
        {
            public string directorPath;
            public string timelinePath;
            public string timelineName;
            public double duration;
            public List<AnimationTrackDiagnostic> animationTracks;
            public int cameraTrackCount;
            public int shotCount;
            public int resolvedCameraCount;
            public List<string> audioClipPaths;
        }

        [Serializable]
        private sealed class AnimationTrackDiagnostic
        {
            public string trackName;
            public bool muted;
            public string bindingType;
            public string bindingName;
            public string animatorPath;
            public bool animatorIsHuman;
            public bool animatorActive;
            public string avatarName;
            public bool avatarIsValid;
            public int transformCount;
            public int animationClipCount;
            public int transformCurvePathCount;
            public List<string> firstTransformPaths;
        }

        [Serializable]
        private sealed class SceneCandidate
        {
            public string scenePath;
            public List<TimelineInventory> timelines;
        }

        [Serializable]
        private sealed class TimelineInventory
        {
            public string assetPath;
            public string timelineName;
            public double duration;
            public int cameraTrackCount;
            public int shotCount;
            public int animationTrackCount;
            public int audioTrackCount;
            public int audioClipCount;
        }

        private sealed class PrefabTimelineReferences
        {
            public List<string> timelineGuids;
            public List<string> sourcePrefabGuids;
        }

        [Serializable]
        private sealed class SubjectBatchReport
        {
            public string schemaVersion;
            public string startedUtc;
            public string lastUpdatedUtc;
            public string completedUtc;
            public string state;
            public string unityVersion;
            public string assetRoot;
            public string outputPath;
            public int candidateSceneCount;
            public string candidateInventoryFingerprint;
            public int startIndex;
            public int endExclusive;
            public List<int> skippedCandidateIndexes;
            public int processedSceneCount;
            public List<SceneSubjectResult> results;
            public string restoreError;
            public string fatalError;
        }

        [Serializable]
        private sealed class SceneSubjectResult
        {
            public int candidateIndex;
            public string scenePath;
            public List<string> candidateTimelinePaths;
            public string status;
            public string reason;
            public string error;
            public string startedUtc;
            public string completedUtc;
            public double elapsedSeconds;
            public int timelineCount;
            public List<TimelineSubjectResult> timelines;
        }

        [Serializable]
        private sealed class TimelineSubjectResult
        {
            public string status;
            public string directorPath;
            public string timelineAssetPath;
            public string timelineName;
            public double duration;
            public string subjectMode;
            public bool subjectCountKnown;
            public int subjectCount;
            public string confidence;
            public string reason;
            public string error;
            public string selectedExporterAnimatorPath;
            public string selectedExporterPerformerPath;
            public List<string> boundAnimatorPaths;
            public List<string> boundPerformerPaths;
            public List<string> cameraTargetPaths;
            public List<string> cameraTargetAnimatorPaths;
            public List<string> subjectAnimatorPaths;
            public int unresolvedAnimationTrackCount;
            public int unresolvedShotCount;
            public int unlinkedCameraTargetCount;
            public List<AnimationSubjectEvidence> animationTracks;
            public List<CameraSubjectEvidence> cameraTracks;
        }

        [Serializable]
        private sealed class AnimationSubjectEvidence
        {
            public int trackIndex;
            public string trackName;
            public bool muted;
            public string bindingType;
            public string bindingPath;
            public string animatorPath;
            public string performerPath;
            public bool animatorResolved;
        }

        [Serializable]
        private sealed class CameraSubjectEvidence
        {
            public int cameraTrackIndex;
            public int exportCameraTrackIndex;
            public int outputTrackIndex;
            public string trackName;
            public bool muted;
            public bool passesExporterTrackFilter;
            public string exporterTrackFilterReason;
            public string subjectMode;
            public bool subjectCountKnown;
            public int subjectCount;
            public string confidence;
            public string reason;
            public string selectedExporterAnimatorPath;
            public string selectedExporterPerformerPath;
            public bool selectedAnimatorTargetMatchKnown;
            public bool allTargetsMatchSelectedExporterAnimator;
            public int shotCount;
            public List<string> cameraTargetPaths;
            public List<string> subjectPerformerPaths;
            public List<ShotSubjectEvidence> shots;
        }

        [Serializable]
        private sealed class ShotSubjectEvidence
        {
            public string status;
            public string reason;
            public string error;
            public string clipName;
            public double start;
            public double end;
            public bool virtualCameraResolved;
            public string virtualCameraPath;
            public string followTargetPath;
            public string lookAtTargetPath;
            public bool followTargetAuthored;
            public bool followTargetResolved;
            public bool lookAtTargetAuthored;
            public bool lookAtTargetResolved;
            public List<string> followGroupMemberPaths;
            public List<string> lookAtGroupMemberPaths;
            public List<string> followPerformerPaths;
            public List<string> lookAtPerformerPaths;
            public List<string> targetPerformerPaths;
            public bool targetEvidenceComplete;
        }

        private sealed class CameraTargetResolution
        {
            public bool authored;
            public bool resolved;
            public string targetPath;
            public List<string> memberPaths;
            public List<string> performerPaths;
        }

        [Serializable]
        private sealed class BatchReport
        {
            public string schemaVersion;
            public string startedUtc;
            public string lastUpdatedUtc;
            public string completedUtc;
            public string state;
            public string unityVersion;
            public string assetRoot;
            public string outputRoot;
            public string sourceFingerprintRegistryPath;
            public string sourceFingerprintRegistrySchema;
            public int sourceFingerprintRegistryEntryCount;
            public int candidateSceneCount;
            public string candidateInventoryFingerprint;
            public int startIndex;
            public int endExclusive;
            public bool requireAudio;
            public bool preserveRevisions;
            public List<int> skippedCandidateIndexes;
            public List<int> forcedRevisionCandidateIndexes;
            public int processedSceneCount;
            public List<SceneExportResult> results;
            public string restoreError;
            public string fatalError;
        }

        [Serializable]
        private sealed class SceneExportResult
        {
            public int candidateIndex;
            public string scenePath;
            public List<string> timelinePaths;
            public string baseOutputPath;
            public string outputPath;
            public bool isRevision;
            public string revisionOfOutputPath;
            public string revisionSourceFingerprint;
            public string revisionReason;
            public bool forceRevisionRequested;
            public bool forceRevisionApplied;
            public string sourceFingerprintSchema;
            public string sourceFingerprint;
            public string legacySourceFingerprint;
            public bool legacyBaseFingerprintMatched;
            public bool legacyRevisionReused;
            public bool fingerprintMigrationRequired;
            public bool sourceFingerprintRegistryEntryFound;
            public bool sourceFingerprintRegistryMatched;
            public bool sourceFingerprintBaselineRecorded;
            public string registeredSourceFingerprint;
            public string registeredOutputPath;
            public string status;
            public string reason;
            public string startedUtc;
            public string completedUtc;
            public double elapsedSeconds;
            public string error;
            public string partialOutputPath;
            public int discoveredCameraTrackCount;
            public int exportedCameraTrackCount;
            public int skippedCameraTrackCount;
        }

        [Serializable]
        private sealed class SourceFingerprintRegistry
        {
            public string schemaVersion;
            public string fingerprintSchema;
            public string updatedUtc;
            public List<SourceFingerprintRegistryEntry> entries;
        }

        [Serializable]
        private sealed class SourceFingerprintRegistryEntry
        {
            public string datasetName;
            public string scenePath;
            public string sourceFingerprint;
            public string legacySourceFingerprint;
            public string selectedOutputDirectory;
            public string updatedUtc;
        }

        [Serializable]
        private sealed class DatasetManifestProbe
        {
            public string schemaVersion;
            public string scenePath;
            public string sourceFingerprintSchema;
            public string sourceFingerprint;
            public int sampleRate;
            public int discoveredCameraTrackCount;
            public int exportedCameraTrackCount;
            public List<SkippedCameraTrackProbe> skippedCameraTracks;
            public List<SongProbe> songs;
        }

        [Serializable]
        private sealed class SkippedCameraTrackProbe
        {
            public string timelineAssetPath;
            public string trackName;
            public bool muted;
            public string reason;
        }

        [Serializable]
        private sealed class SongProbe
        {
            public string folderName;
            public int frameCount;
            public int sharedBoneCount;
            public int shotCount;
            public int missingCameraFrames;
            public int missingJointSamples;
            public int missingRootSamples;
            public string cameraTrackName;
            public string motionTimelineAssetPath;
            public string audioFile;
            public string jointsFile;
            public string cameraFile;
            public string rootFile;
            public string shotIndexFile;
            public string timeFile;
            public string shotsFile;
            public string previewFile;
        }
    }
}
