using System;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using NUnit.Framework;
using UnityEngine;

namespace Streamingle.Editor.Tests
{
    public sealed class AICameraGeneratorWindowTests
    {
        [Serializable]
        private sealed class DirectiveDocument
        {
            public string schemaVersion;
            public DirectiveShot[] shots;
        }

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

        private static string HybridMetadataJson(
            string generationMode = "hierarchical_hybrid_retarget_v12",
            string plannerMode = "hierarchical",
            double targetAspectRatio = 16d / 9d)
        {
            return "{" +
                   "\"schemaVersion\":\"3.6\"," +
                   $"\"generationMode\":\"{generationMode}\"," +
                   "\"songId\":\"song-id\"," +
                   "\"datasetId\":\"dataset-id\"," +
                   "\"frameCount\":6," +
                   "\"sampleRate\":60," +
                   "\"shotCount\":2," +
                   $"\"plannerMode\":\"{plannerMode}\"," +
                   "\"targetAspectRatio\":" +
                   targetAspectRatio.ToString(
                       "R",
                       CultureInfo.InvariantCulture) + "," +
                   "\"worldCameraFile\":\"generated_camera_world.f32\"," +
                   "\"timeFile\":\"time.f64\"," +
                   "\"shotsFile\":\"shots.json\"," +
                   "\"outputSha256\":{" +
                   "\"worldCamera\":\"camera-hash\"," +
                   "\"time\":\"time-hash\"," +
                   "\"shots\":\"shots-hash\"}}";
        }

        private static string HybridShotsJson(string schemaVersion = "1.2")
        {
            return "{" +
                   $"\"schemaVersion\":\"{schemaVersion}\"," +
                   "\"shots\":[" +
                   "{\"index\":0,\"startFrame\":0,\"endFrameExclusive\":3}," +
                   "{\"index\":1,\"startFrame\":3,\"endFrameExclusive\":6}]}";
        }

        [Test]
        public void DefaultCwAiRootPrefersConfiguredThenSiblingRepository()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-root-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var configured = Path.Combine(root, "ConfiguredCWAI");
            var sibling = Path.Combine(root, "CW-AI");
            Directory.CreateDirectory(project);
            CreateCwAiRootMarker(configured);
            CreateCwAiRootMarker(sibling);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        configured,
                        Path.Combine(root, "MissingPackage")),
                    Is.EqualTo(Path.GetFullPath(configured)));
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        string.Empty,
                        Path.Combine(root, "MissingPackage")),
                    Is.EqualTo(Path.GetFullPath(sibling)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void DefaultCwAiRootSkipsProjectCopyWithoutTrainingIndex()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-incomplete-root-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var sibling = Path.Combine(root, "CW-AI");
            Directory.CreateDirectory(Path.Combine(project, "DatasetExports"));
            Directory.CreateDirectory(Path.Combine(
                project,
                "MachineLearning",
                "CameraDirector"));
            CreateCwAiRootMarker(sibling);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.IsGenerationLibraryRoot(project),
                    Is.False);
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        project,
                        Path.Combine(root, "MissingPackage")),
                    Is.EqualTo(Path.GetFullPath(sibling)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void DefaultCwAiRootSkipsMalformedConfiguredPath()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-malformed-root-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var sibling = Path.Combine(root, "CW-AI");
            Directory.CreateDirectory(project);
            CreateCwAiRootMarker(sibling);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        "\0invalid",
                        Path.Combine(root, "MissingPackage")),
                    Is.EqualTo(Path.GetFullPath(sibling)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void ProjectCacheRootDefaultsToWritableProjectLibrary()
        {
            var project = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-cache-root-tests-" + Guid.NewGuid().ToString("N"));
            var configured = Path.Combine(project, "ConfiguredCache");

            Assert.That(
                AICameraGeneratorWindow.ResolveProjectCacheRoot(
                    project,
                    string.Empty),
                Is.EqualTo(Path.Combine(
                    Path.GetFullPath(project),
                    "Library",
                    "CWAI")));
            Assert.That(
                AICameraGeneratorWindow.ResolveProjectCacheRoot(
                    project,
                    configured),
                Is.EqualTo(Path.GetFullPath(configured)));
        }

        private static void CreateCwAiRootMarker(string root)
        {
            Directory.CreateDirectory(Path.Combine(root, "DatasetExports"));
            Directory.CreateDirectory(Path.Combine(
                root,
                "MachineLearning",
                "CameraDirector"));
            var reports = Directory.CreateDirectory(Path.Combine(root, "reports"));
            File.WriteAllText(
                Path.Combine(reports.FullName, "training_index.json"),
                "{}");
            var models = Directory.CreateDirectory(Path.Combine(root, "models"));
            File.WriteAllText(
                Path.Combine(models.FullName, "cut_ranker_v2.json"),
                "{}");
        }

        [Test]
        public void DefaultCwAiRootReplacesStalePackageCacheRevision()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-package-root-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var staleRuntime = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@old-revision",
                "RuntimeData~");
            var currentPackage = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@current-revision");
            var currentRuntime = Path.Combine(currentPackage, "RuntimeData~");
            CreateCwAiRootMarker(staleRuntime);
            CreateCwAiRootMarker(currentRuntime);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        staleRuntime,
                        currentPackage),
                    Is.EqualTo(Path.GetFullPath(currentRuntime)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void DefaultCwAiRootPreservesValidExternalSelection()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-external-root-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var external = Path.Combine(root, "ExternalCWAI");
            var currentPackage = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@current-revision");
            CreateCwAiRootMarker(external);
            CreateCwAiRootMarker(Path.Combine(currentPackage, "RuntimeData~"));
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
                        project,
                        external,
                        currentPackage),
                    Is.EqualTo(Path.GetFullPath(external)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void CliExecutableReplacesStalePackageCacheRevision()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-worker-path-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var staleWorker = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@old-revision",
                "Tools~",
                "CWCameraWorker",
                AICameraCliRunner.ExecutableName);
            var currentPackage = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@current-revision");
            var currentWorker = Path.Combine(
                currentPackage,
                "Tools~",
                "CWCameraWorker",
                AICameraCliRunner.ExecutableName);
            Directory.CreateDirectory(Path.GetDirectoryName(staleWorker));
            Directory.CreateDirectory(Path.GetDirectoryName(currentWorker));
            File.WriteAllText(staleWorker, string.Empty);
            File.WriteAllText(currentWorker, string.Empty);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveCliExecutablePath(
                        staleWorker,
                        string.Empty,
                        project,
                        currentPackage),
                    Is.EqualTo(Path.GetFullPath(currentWorker)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void CliExecutablePreservesValidExternalSelection()
        {
            var root = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-external-worker-tests-" + Guid.NewGuid().ToString("N"));
            var project = Path.Combine(root, "UnityProject");
            var externalWorker = Path.Combine(
                root,
                "ExternalWorker",
                AICameraCliRunner.ExecutableName);
            var currentPackage = Path.Combine(
                project,
                "Library",
                "PackageCache",
                "com.mingle.cw-ai@current-revision");
            var currentWorker = Path.Combine(
                currentPackage,
                "Tools~",
                "CWCameraWorker",
                AICameraCliRunner.ExecutableName);
            Directory.CreateDirectory(Path.GetDirectoryName(externalWorker));
            Directory.CreateDirectory(Path.GetDirectoryName(currentWorker));
            File.WriteAllText(externalWorker, string.Empty);
            File.WriteAllText(currentWorker, string.Empty);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow.ResolveCliExecutablePath(
                        externalWorker,
                        string.Empty,
                        project,
                        currentPackage),
                    Is.EqualTo(Path.GetFullPath(externalWorker)));
            }
            finally
            {
                Directory.Delete(root, true);
            }
        }

        [Test]
        public void CliWorkerIsTheOnlyHighQualityBackend()
        {
            Assert.That(
                AICameraGeneratorWindow.HybridQualityBackendLabel,
                Does.Contain("CWCameraWorker"));
            Assert.That(
                AICameraGeneratorWindow.HybridQualityBackendDescription,
                Does.Contain("별도 Python 설치가 필요하지 않습니다"));
        }

        [TestCase(0, "auto")]
        [TestCase(1, "slow")]
        [TestCase(2, "normal")]
        [TestCase(3, "fast")]
        public void CutRhythmUsesStableDataPlannerCliValues(
            int value,
            string expected)
        {
            Assert.That(
                AICameraGeneratorWindow.CutDensityCliValueForTests(value),
                Is.EqualTo(expected));
            Assert.That(
                AICameraGeneratorWindow.CutRhythmExplanation,
                Does.Contain("음악"));
        }

        [TestCase("auto", "balanced")]
        [TestCase("center", "centered")]
        [TestCase("centered", "centered")]
        [TestCase("left_third", "left_third")]
        [TestCase("right-third", "right_third")]
        public void HybridHorizontalFramingPreservesExplicitDirection(
            string composition,
            string expected)
        {
            Assert.That(
                AICameraGeneratorWindow.HybridHorizontalFraming(composition),
                Is.EqualTo(expected));
        }

        [Test]
        public void FullGenerationIgnoresPersistedManualOverrides()
        {
            var unusedOutput = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-full-scope-" + Guid.NewGuid().ToString("N"));

            var scope = AICameraGeneratorWindow.CreateGenerationControlScope(
                false,
                12,
                unusedOutput,
                "close",
                "orbit_right",
                "left_third",
                9.5f,
                2f,
                1f);

            Assert.That(scope.ShotSize, Is.EqualTo("auto"));
            Assert.That(scope.Motion, Is.EqualTo("auto"));
            Assert.That(scope.Composition, Is.EqualTo("auto"));
            Assert.That(scope.DistanceMeters, Is.Zero);
            Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
            Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
            Assert.That(scope.DirectivesPath, Is.Empty);
            Assert.That(scope.UsesSelectedShotOverrides, Is.False);
            Assert.That(Directory.Exists(unusedOutput), Is.False);
        }

        [Test]
        public void SelectedShotRegenerationSerializesOnlyThatShotsOverrides()
        {
            var output = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-selected-scope-" + Guid.NewGuid().ToString("N"));
            try
            {
                var scope =
                    AICameraGeneratorWindow.CreateGenerationControlScope(
                        true,
                        7,
                        output,
                        "medium",
                        "truck_left",
                        "right_third",
                        6.25f,
                        1.4f,
                        0.08f);

                Assert.That(scope.ShotSize, Is.EqualTo("auto"));
                Assert.That(scope.Motion, Is.EqualTo("auto"));
                Assert.That(scope.Composition, Is.EqualTo("auto"));
                Assert.That(scope.DistanceMeters, Is.Zero);
                Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
                Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
                Assert.That(scope.UsesSelectedShotOverrides, Is.True);
                Assert.That(File.Exists(scope.DirectivesPath), Is.True);

                var document = JsonUtility.FromJson<DirectiveDocument>(
                    File.ReadAllText(scope.DirectivesPath));
                Assert.That(
                    document.schemaVersion,
                    Is.EqualTo("camera-directives-v1"));
                Assert.That(document.shots, Has.Length.EqualTo(1));
                Assert.That(document.shots[0].index, Is.EqualTo(7));
                Assert.That(document.shots[0].shotSize, Is.EqualTo("medium"));
                Assert.That(document.shots[0].motion, Is.EqualTo("truck_left"));
                Assert.That(
                    document.shots[0].composition,
                    Is.EqualTo("right_third"));
                Assert.That(document.shots[0].distanceMeters, Is.EqualTo(6.25f));
                Assert.That(document.shots[0].motionIntensity, Is.EqualTo(1.4f));
                Assert.That(document.shots[0].bodyFollowStrength, Is.EqualTo(0.08f));
            }
            finally
            {
                if (Directory.Exists(output))
                {
                    Directory.Delete(output, true);
                }
            }
        }

        [Test]
        public void FullGenerationDoesNotEmitSelectedShotOneShotArguments()
        {
            Assert.That(
                AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
                    -1,
                    string.Empty),
                Is.Empty);
        }

        [Test]
        public void SelectedShotOneShotArgumentsIncludeIndexAndBaseGeneration()
        {
            var baseGeneration = Path.Combine(
                Path.GetTempPath(),
                "cw ai base generation");

            var arguments =
                AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
                    7,
                    baseGeneration);

            Assert.That(arguments, Does.Contain("--only-shot-index"));
            Assert.That(arguments, Does.Contain("7"));
            Assert.That(arguments, Does.Contain("--base-generation"));
            Assert.That(
                arguments,
                Does.Contain(Path.GetFullPath(baseGeneration)));
        }

        [Test]
        public void SelectedShotTimingUsesSeparateOneShotHistory()
        {
            var text =
                AICameraGeneratorWindow.BuildSelectedShotGenerationTimingText(
                    4d,
                    10d,
                    AICameraGeneratorWindow.CacheObservation.Hit,
                    AICameraGeneratorWindow.CacheObservation.Miss);

            Assert.That(text, Does.Contain("선택 카메라 1개만 계산 중"));
            Assert.That(text, Does.Contain("최근 단일 샷 기준 총"));
            Assert.That(text, Does.Contain("새 후보 풀 계산 중"));
        }

        [Test]
        public void FailedImportPreservesPreviousGeneratedFolder()
        {
            var previous = Path.Combine(Path.GetTempPath(), "previous-base");
            var failedCandidate = Path.Combine(
                Path.GetTempPath(),
                "failed-candidate");

            Assert.That(
                AICameraGeneratorWindow.GeneratedFolderAfterImport(
                    previous,
                    failedCandidate,
                    false),
                Is.EqualTo(previous));
        }

        [Test]
        public void SuccessfulImportAdvancesGeneratedFolder()
        {
            var candidate = Path.Combine(Path.GetTempPath(), "new-base");

            Assert.That(
                AICameraGeneratorWindow.GeneratedFolderAfterImport(
                    "previous-base",
                    candidate,
                    true),
                Is.EqualTo(Path.GetFullPath(candidate)));
        }

        [Test]
        public void HybridBaseContractAcceptsFullCompositeSchema()
        {
            Assert.DoesNotThrow(() =>
                AICameraGeneratorWindow
                    .ValidateHybridBaseGenerationContractForTests(
                        HybridMetadataJson(),
                        HybridShotsJson()));
        }

        [Test]
        public void HybridBaseContractRejectsNonHybridOutputBeforeWorkerStarts()
        {
            var exception = Assert.Throws<InvalidDataException>(() =>
                AICameraGeneratorWindow
                    .ValidateHybridBaseGenerationContractForTests(
                        HybridMetadataJson(
                            generationMode: "unity_native_fast",
                            plannerMode: string.Empty,
                            targetAspectRatio: 0d),
                        HybridShotsJson("1.3")));

            Assert.That(exception.Message, Does.Contain("Hybrid"));
        }

        [Test]
        public void HybridBaseContractRequiresTargetAspectRatio()
        {
            var exception = Assert.Throws<InvalidDataException>(() =>
                AICameraGeneratorWindow
                    .ValidateHybridBaseGenerationContractForTests(
                        HybridMetadataJson(targetAspectRatio: 0d),
                        HybridShotsJson()));

            Assert.That(exception.Message, Does.Contain("targetAspectRatio"));
        }

        [Test]
        public void HybridBaseContractRequiresShotsSchema12()
        {
            var exception = Assert.Throws<InvalidDataException>(() =>
                AICameraGeneratorWindow
                    .ValidateHybridBaseGenerationContractForTests(
                        HybridMetadataJson(),
                        HybridShotsJson("1.3")));

            Assert.That(exception.Message, Does.Contain("schema 1.2"));
        }

        [Test]
        public void WindowProvenanceRecoveryPrefersCwAiRelativePathAfterMove()
        {
            var newRoot = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-moved-" + Guid.NewGuid().ToString("N"));
            var relative = Path.Combine(
                "examples",
                "UnityGenerated",
                "camera_result");
            var movedFolder = Path.Combine(newRoot, relative);
            Directory.CreateDirectory(movedFolder);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow
                        .ResolveGeneratedFolderFromProvenance(
                            newRoot,
                            relative.Replace('\\', '/'),
                            @"D:\OldPc\CW-AI\examples\UnityGenerated\camera_result"),
                    Is.EqualTo(Path.GetFullPath(movedFolder)));
            }
            finally
            {
                Directory.Delete(newRoot, true);
            }
        }

        [Test]
        public void WindowProvenanceRecoveryUsesAbsoluteFallback()
        {
            var fallback = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-fallback-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(fallback);
            try
            {
                Assert.That(
                    AICameraGeneratorWindow
                        .ResolveGeneratedFolderFromProvenance(
                            Path.Combine(fallback, "missing-root"),
                            "examples/UnityGenerated/missing",
                            fallback),
                    Is.EqualTo(Path.GetFullPath(fallback)));
            }
            finally
            {
                Directory.Delete(fallback, true);
            }
        }

        [Test]
        public void WindowProvenanceRejectsMetadataChangedBehindStoredHash()
        {
            var generatedFolder = Path.Combine(
                Path.GetTempPath(),
                "cw-ai-provenance-hash-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(generatedFolder);
            var metadataPath = Path.Combine(generatedFolder, "metadata.json");
            try
            {
                File.WriteAllText(metadataPath, "{\"generation\":1}");
                string expected;
                using (var algorithm = SHA256.Create())
                {
                    expected = BitConverter.ToString(
                            algorithm.ComputeHash(File.ReadAllBytes(metadataPath)))
                        .Replace("-", string.Empty)
                        .ToLowerInvariant();
                }

                Assert.DoesNotThrow(() =>
                    AICameraGeneratorWindow.ValidateProvenanceMetadataSha256(
                        generatedFolder,
                        expected));
                File.WriteAllText(metadataPath, "{\"generation\":2}");
                Assert.That(
                    () => AICameraGeneratorWindow
                        .ValidateProvenanceMetadataSha256(
                            generatedFolder,
                            expected),
                    Throws.TypeOf<InvalidDataException>()
                        .With.Message.Contains("does not match"));
            }
            finally
            {
                Directory.Delete(generatedFolder, true);
            }
        }

        [Test]
        public void ColdStartHybridEstimateIsBroadAndMarkedInitial()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                204.48d,
                0d,
                0,
                -1d,
                0d);

            Assert.That(estimate.IsAvailable, Is.True);
            Assert.That(estimate.IsInitial, Is.True);
            Assert.That(
                estimate.MinimumTotalSeconds,
                Is.EqualTo(79.4112d).Within(1e-4));
            Assert.That(
                estimate.MaximumTotalSeconds,
                Is.EqualTo(148.23424d).Within(1e-4));
            Assert.That(
                estimate.MinimumRemainingSeconds,
                Is.EqualTo(79.4112d).Within(1e-4));
            Assert.That(
                estimate.MaximumRemainingSeconds,
                Is.EqualTo(148.23424d).Within(1e-4));
        }

        [Test]
        public void HistoricalEstimateUsesObservedRateAndElapsedTime()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                20d,
                4,
                0.5d,
                0.05d);

            Assert.That(estimate.IsAvailable, Is.True);
            Assert.That(estimate.IsInitial, Is.False);
            Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(85d));
            Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(115d));
            Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(65d));
            Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(95d));
        }

        [Test]
        public void CandidateCacheHitReplacesGeneralHistoryWithShortTailBudget()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                10d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Hit,
                3,
                false);

            Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(13d));
            Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(30d));
            Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(3d));
            Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(20d));
        }

        [Test]
        public void CandidateCacheHitTailCountsDownFromObservationTime()
        {
            var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                10d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Hit,
                3,
                false,
                10d);
            var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                15d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Hit,
                3,
                false,
                10d);

            Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(20d));
            Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(15d));
            Assert.That(
                later.MaximumRemainingSeconds,
                Is.LessThan(initial.MaximumRemainingSeconds));
            Assert.That(later.MaximumTotalSeconds, Is.EqualTo(30d));
        }

        [Test]
        public void PreparationCacheMissAddsColdPreparationPenalty()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                20d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Miss,
                AICameraGeneratorWindow.CacheObservation.Miss,
                2,
                false);

            Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(103d));
            Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(164d));
            Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(83d));
            Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(144d));
        }

        [Test]
        public void ImportStageBoundsStaleRemainingEstimate()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                20d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Miss,
                4,
                true);

            Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
            Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(24d));
            Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(44d));
        }

        [Test]
        public void ImportStageTailCountsDownFromObservationTime()
        {
            var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                20d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Miss,
                4,
                true,
                double.NaN,
                20d);
            var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                21d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Miss,
                4,
                true,
                double.NaN,
                20d);

            Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(24d));
            Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(23d));
            Assert.That(
                later.MaximumRemainingSeconds,
                Is.LessThan(initial.MaximumRemainingSeconds));
            Assert.That(initial.MaximumTotalSeconds, Is.EqualTo(44d));
            Assert.That(later.MaximumTotalSeconds, Is.EqualTo(44d));
        }

        [Test]
        public void InvalidDurationDoesNotClaimAnEstimate()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                0d,
                10d,
                2,
                0.5d,
                0.1d);

            Assert.That(estimate.IsAvailable, Is.False);
            Assert.That(estimate.MinimumTotalSeconds, Is.Zero);
            Assert.That(estimate.MaximumTotalSeconds, Is.Zero);
            Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
            Assert.That(estimate.MaximumRemainingSeconds, Is.Zero);
        }

        [Test]
        public void TimingTextShowsElapsedTotalRemainingAndCacheState()
        {
            var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
                200d,
                10d,
                4,
                0.5d,
                0.05d,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Hit,
                3,
                false);

            var text = AICameraGeneratorWindow.BuildGenerationTimingText(
                10d,
                estimate,
                AICameraGeneratorWindow.CacheObservation.Hit,
                AICameraGeneratorWindow.CacheObservation.Hit);

            Assert.That(text, Does.Contain("경과 10초"));
            Assert.That(text, Does.Contain("최근 실측 예상 총"));
            Assert.That(text, Does.Contain("남은 시간"));
            Assert.That(text, Does.Contain("안전 후보 캐시 사용 중"));
        }

        [TestCase("Candidate cache hit: abc", "candidate", 1)]
        [TestCase("[candidate-cache] miss: none", "candidate", 2)]
        [TestCase(
            "Candidate cache runtime validation failed; falling back",
            "candidate",
            2)]
        [TestCase("Preparation cache invalid: sha mismatch", "preparation", 2)]
        [TestCase("unrelated message", "candidate", 0)]
        public void CacheLogObservationParsesStableSignals(
            string message,
            string cacheName,
            int expected)
        {
            Assert.That(
                AICameraGeneratorWindow.ParseCacheObservation(
                    message,
                    cacheName),
                Is.EqualTo(
                    (AICameraGeneratorWindow.CacheObservation)expected));
        }

        [Test]
        public void CandidateCacheModeDefaultsToAutoAndFreshPoolUsesOff()
        {
            Assert.That(
                AICameraGeneratorWindow.CandidateCacheMode(false),
                Is.EqualTo("auto"));
            Assert.That(
                AICameraGeneratorWindow.CandidateCacheMode(true),
                Is.EqualTo("off"));
        }

        [Test]
        public void AppliedSeedSummarySeparatesCurrentResultFromNextGeneration()
        {
            var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
                20489696,
                20489697,
                false,
                -1);

            Assert.That(
                summary,
                Is.EqualTo(
                    "현재 전체 생성 결과 Seed: 20489696\n" +
                    "다음 생성에 사용할 Seed: 20489697"));
        }

        [Test]
        public void AppliedSeedSummaryMarksSelectedShotAsMixedResult()
        {
            var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
                20489696,
                20489697,
                true,
                8);

            Assert.That(summary, Does.Contain("카메라: 008"));
            Assert.That(summary, Does.Contain("해당 카메라 Seed: 20489696"));
            Assert.That(summary, Does.Contain("나머지 카메라는 기존 결과"));
        }

        [Test]
        public void SeedAndReapplyLabelsDescribeDifferentActions()
        {
            Assert.That(
                AICameraGeneratorWindow.NextGenerationSeedLabel,
                Does.Contain("다음 생성"));
            Assert.That(
                AICameraGeneratorWindow.SelectedCameraRegenerationButtonLabel,
                Does.Contain("안전 후보"));
            Assert.That(
                AICameraGeneratorWindow.ListedCameraRegenerationButtonLabel,
                Does.Contain("안전 후보"));
            Assert.That(
                AICameraGeneratorWindow.ReapplyCurrentResultButtonLabel,
                Does.Contain("Seed 사용 안 함"));
            Assert.That(
                AICameraGeneratorWindow.ReapplyCurrentResultWarning,
                Does.Contain("새 후보를 생성하지 않으며"));
            Assert.That(
                AICameraGeneratorWindow.SeedCandidateExplanation,
                Does.Contain("안전 후보 풀을 재사용"));
            Assert.That(
                AICameraGeneratorWindow.FreshCandidatePoolOptionLabel,
                Does.Contain("후보 풀 캐시 무시하고 재계산"));
            Assert.That(
                AICameraGeneratorWindow.FullAutonomousGenerationButtonLabel,
                Does.Contain("자율 생성"));
            Assert.That(
                AICameraGeneratorWindow.FullGenerationAutonomyExplanation,
                Does.Contain("전체 생성에 적용되지 않습니다"));
            Assert.That(
                AICameraGeneratorWindow.SelectedShotOverrideExplanation,
                Does.Contain("선택한 카메라를 다시 생성할 때만"));
        }
    }
}
