using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;

internal static class LiveCutValidation
{
    public static async Task<int> RunAsync(string[] args)
    {
        var options = LiveCutValidationOptions.Parse(args);
        Directory.CreateDirectory(options.OutputPath);

        Console.WriteLine("Karisma live-cut validation starting.");
        Console.WriteLine($"- Image Root: {options.ImageRootPath}");
        Console.WriteLine($"- Output: {options.OutputPath}");
        Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
        Console.WriteLine($"- Capture Mode: {options.CaptureMode}");

        var logService = new LogService();
        var cutDebugStateStore = new CutDebugStateStore();
        if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
        {
            Console.WriteLine("Karisma adapter is not available. Validation cannot continue.");
            return 1;
        }

        var station = new BroadcastStationProfile
        {
            Id = "TJB",
            Name = "TJB",
            LogoAssetPath = options.StationLogoPath,
            RegionFilters = ["대전", "세종", "충남"]
        };

        var cutItems = new FormatCatalogService().GetAll()
            .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
            .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
            .ToList();

        cutItems = ApplyTemplateFilter(cutItems, options.Filter);

        if (options.Limit is int limit && limit > 0)
        {
            cutItems = cutItems.Take(limit).ToList();
        }

        Console.WriteLine($"- Cuts: {cutItems.Count}");
        Console.WriteLine();

        var pgmWindow = TryFindPgmWindow();
        var results = new List<LiveCutValidationResult>();

        try
        {
            await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);

            for (var index = 0; index < cutItems.Count; index++)
            {
                var item = cutItems[index];
                var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
                var result = new LiveCutValidationResult
                {
                    Index = index + 1,
                    TemplateId = item.Template.Id,
                    TemplateName = item.Template.Name,
                    CutName = item.Cut.Name,
                    Channel = item.Template.RecommendedChannel.ToString(),
                    Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
                    CaptureMode = options.CaptureMode.ToString(),
                    CaptureComparable = options.CaptureMode == LiveCutCaptureMode.Scene ||
                                        (pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall),
                    OutputVisibleInPgm = pgmWindow is not null &&
                                         item.Template.RecommendedChannel != BroadcastChannel.VideoWall
                };

                Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id}");

                try
                {
                    await OutAllAsync(adapter).ConfigureAwait(false);
                    await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);

                    var snapshotA = CreateSnapshot(
                        item.Template.Name,
                        index,
                        variant: 0,
                        preElection,
                        options.SwapTopTwoCandidates,
                        options.CycleTopThreeCandidates,
                        options.StressTopRankValues);
                    var snapshotB = CreateSnapshot(
                        item.Template.Name,
                        index,
                        variant: 1,
                        preElection,
                        options.SwapTopTwoCandidates,
                        options.CycleTopThreeCandidates,
                        options.StressTopRankValues);

                    await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
                    await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
                    await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
                    await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);

                    result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
                    result.HashA = await CaptureValidationImageAsync(
                        adapter,
                        pgmWindow,
                        item,
                        options,
                        result.CaptureAPath,
                        result.OutputVisibleInPgm,
                        CancellationToken.None).ConfigureAwait(false);

                    await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
                    await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
                    await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
                    await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);

                    result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
                    result.HashB = await CaptureValidationImageAsync(
                        adapter,
                        pgmWindow,
                        item,
                        options,
                        result.CaptureBPath,
                        result.OutputVisibleInPgm,
                        CancellationToken.None).ConfigureAwait(false);
                    result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
                    result.Success = true;
                    result.Detail = result.CaptureComparable
                        ? (result.VisualChanged ? $"{options.CaptureMode} A/B capture changed" : $"{options.CaptureMode} A/B capture hash identical")
                        : "VideoWall output is not visible in the current PGM window";
                }
                catch (Exception exception)
                {
                    result.Success = false;
                    result.Detail = exception.Message;
                }
                finally
                {
                    try
                    {
                        await adapter.OutAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
                    }
                    catch
                    {
                    }

                    await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
                    results.Add(result);
                }
            }
        }
        finally
        {
            try
            {
                await OutAllAsync(adapter).ConfigureAwait(false);
            }
            catch
            {
            }

            if (adapter is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }

        WriteReports(options, results);

        var successCount = results.Count(result => result.Success);
        var changedCount = results.Count(result => result.Success && result.VisualChanged);
        var unchangedCount = results.Count(result => result.Success && result.CaptureComparable && !result.VisualChanged);
        var failureCount = results.Count(result => !result.Success);

        Console.WriteLine();
        Console.WriteLine("Summary");
        Console.WriteLine($"- Success: {successCount}/{results.Count}");
        Console.WriteLine($"- Visual Changed: {changedCount}");
        Console.WriteLine($"- Unchanged Captures: {unchangedCount}");
        Console.WriteLine($"- Failures: {failureCount}");
        Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");

        return failureCount == 0 ? 0 : 1;
    }

    public static async Task<int> RunCutDebugSweepAsync(string[] args)
    {
        var options = CutDebugSweepOptions.Parse(args);
        Directory.CreateDirectory(options.OutputPath);

        Console.WriteLine("Karisma cut-debug sweep starting.");
        Console.WriteLine($"- Image Root: {options.ImageRootPath}");
        Console.WriteLine($"- Output: {options.OutputPath}");
        Console.WriteLine($"- Mode: {options.Mode}");
        Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
        Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
        Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
        Console.WriteLine($"- Recommendations Loaded: {CutDebugRecommendationCatalog.Count}");

        var pgmWindow = TryFindPgmWindow();
        if (pgmWindow is null)
        {
            Console.WriteLine("PGM window was not found. Open the PGM window first and rerun.");
            return 1;
        }

        var logService = new LogService();
        var cutDebugStateStore = new CutDebugStateStore();
        if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
        {
            Console.WriteLine("Karisma adapter is not available. Sweep cannot continue.");
            return 1;
        }

        var station = CreateValidationStation(options.StationLogoPath);
        var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
        var cutItems = new FormatCatalogService().GetAll()
            .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
            .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
            .ToList();

        cutItems = ApplyTemplateFilter(cutItems, options.Filter);
        cutItems = ApplyCutFilter(cutItems, options.CutName);
        if (options.ExcludeHistoricalTurnout)
        {
            cutItems = cutItems
                .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
                .ToList();
        }

        if (options.Limit is int limit && limit > 0)
        {
            cutItems = cutItems.Take(limit).ToList();
        }

        if (cutItems.Count == 0)
        {
            Console.WriteLine("No cuts matched the requested filter.");
            return 1;
        }

        Console.WriteLine($"- Cuts: {cutItems.Count}");
        Console.WriteLine();

        var results = new List<CutDebugSweepResult>();
        var replacementAssets = EnsureCutDebugReplacementAssets(options.OutputPath);

        try
        {
            await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);

            for (var index = 0; index < cutItems.Count; index++)
            {
                var item = cutItems[index];
                Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");

                var result = new CutDebugSweepResult
                {
                    Index = index + 1,
                    TemplateId = item.Template.Id,
                    TemplateName = item.Template.Name,
                    CutName = item.Cut.Name,
                    Channel = item.Template.RecommendedChannel.ToString()
                };
                CutDebugTemplateState? templateState = null;

                try
                {
                    var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
                    result.ScenePath = resolvedScene.Path;

                    var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
                    templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
                    templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));
                    if (CutDebugRecommendationCatalog.TryGetRecommendation(item.Template.Id, out var recommendation))
                    {
                        Console.WriteLine($"    preferred: {recommendation.Key} ({recommendation.Kind})");
                    }

                    var sweepItems = OrderSweepItems(item.Template.Id, templateState.Items, sceneVariables)
                        .Where(debugItem => options.IncludeItem(debugItem.Key, debugItem.Kind))
                        .ToList();
                    var hasExplicitItemFilter = !string.IsNullOrWhiteSpace(options.KeyFilter) ||
                                                !string.IsNullOrWhiteSpace(options.KindFilter);
                    var targetChangedItems = 0;

                    if (options.MaxItems is int maxItems && maxItems > 0)
                    {
                        if (hasExplicitItemFilter)
                        {
                            sweepItems = sweepItems.Take(maxItems).ToList();
                        }
                        else
                        {
                            targetChangedItems = maxItems;
                        }
                    }

                    result.ItemCount = sweepItems.Count;

                    var settings = cutDebugStateStore.Get(item.Template.RecommendedChannel);
                    EnableAllDebugCategories(settings);
                    templateState.ClearOverrides();
                    SetAllItemsEnabled(templateState.Items, true);

                    var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
                    var snapshot = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);

                    var baselineAPath = Path.Combine(
                        options.OutputPath,
                        $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_A.png");
                    var baselineBPath = Path.Combine(
                        options.OutputPath,
                        $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_B.png");

                    var baselineA = await CaptureSweepImageAsync(
                        adapter,
                        pgmWindow.Value,
                        item,
                        snapshot,
                        station,
                        options,
                        baselineAPath).ConfigureAwait(false);

                    var baselineB = await CaptureSweepImageAsync(
                        adapter,
                        pgmWindow.Value,
                        item,
                        snapshot,
                        station,
                        options,
                        baselineBPath).ConfigureAwait(false);

                    result.BaselineAPath = baselineA.Path;
                    result.BaselineBPath = baselineB.Path;
                    result.BaselineHash = baselineB.Hash;

                    var baselineDiffPath = Path.Combine(
                        options.OutputPath,
                        $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_diff.png");
                    var baselineDiff = CompareImages(baselineA.Path, baselineB.Path, baselineDiffPath);
                    result.NoiseDiffPath = baselineDiffPath;
                    result.NoiseChangedPixels = baselineDiff.ChangedPixels;
                    result.SignificanceThresholdPixels = Math.Max(250, baselineDiff.ChangedPixels * 2);

                    var changedItemCount = 0;
                    foreach (var sweepItem in sweepItems)
                    {
                        templateState.ClearOverrides();
                        SetAllItemsEnabled(templateState.Items, true);
                        ConfigureSweepItem(templateState, sweepItem, options.Mode, replacementAssets);

                        var caseToken = $"{sweepItem.Key}_{sweepItem.Kind}_{options.Mode}";
                        var casePath = Path.Combine(
                            options.OutputPath,
                            $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}.png");
                        var diffPath = Path.Combine(
                            options.OutputPath,
                            $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}_diff.png");

                        var logCountBefore = logService.Entries.Count;
                        var caseCapture = await CaptureSweepImageAsync(
                            adapter,
                            pgmWindow.Value,
                            item,
                            snapshot,
                            station,
                            options,
                            casePath).ConfigureAwait(false);
                        var logPreview = CaptureRecentLogs(logService, logCountBefore, 30);

                        var diff = CompareImages(baselineB.Path, caseCapture.Path, diffPath);
                        var itemResult = new CutDebugItemSweepResult
                        {
                            Key = sweepItem.Key,
                            Kind = sweepItem.Kind.ToString(),
                            GroupLabel = sweepItem.GroupLabel,
                            CapturePath = caseCapture.Path,
                            DiffPath = diffPath,
                            Hash = caseCapture.Hash,
                            ChangedPixels = diff.ChangedPixels,
                            ChangeRatio = diff.ChangeRatio,
                            AverageChannelDelta = diff.AverageChannelDelta,
                            SignificantChange = diff.ChangedPixels > result.SignificanceThresholdPixels,
                            BoundingBox = diff.BoundingBox,
                            LogPreview = logPreview,
                            Detail = diff.ChangedPixels == 0
                                ? $"No visual change ({options.Mode})"
                                : $"mode={options.Mode}, pixels={diff.ChangedPixels}, ratio={diff.ChangeRatio:P4}, bbox={diff.BoundingBox ?? "(none)"}"
                        };
                        result.Items.Add(itemResult);

                        if (itemResult.ChangedPixels > 0)
                        {
                            changedItemCount++;
                            if (targetChangedItems > 0 && changedItemCount >= targetChangedItems)
                            {
                                break;
                            }
                        }
                    }

                    result.Success = true;
                    result.ItemCount = result.Items.Count;
                    result.Detail = targetChangedItems > 0
                        ? $"items={result.ItemCount}, targetChanged={targetChangedItems}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}"
                        : $"items={result.ItemCount}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}";
                }
                catch (Exception ex)
                {
                    result.Success = false;
                    result.Detail = ex.Message;
                }
                finally
                {
                    templateState?.ClearOverrides();

                    try
                    {
                        await OutAllAsync(adapter).ConfigureAwait(false);
                    }
                    catch
                    {
                    }

                    await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
                    results.Add(result);
                }
            }
        }
        finally
        {
            try
            {
                await OutAllAsync(adapter).ConfigureAwait(false);
            }
            catch
            {
            }

            if (adapter is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }

        WriteCutDebugSweepReports(options, results);

        var totalItems = results.Sum(result => result.Items.Count);
        var changedItems = results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0));
        var significantItems = results.Sum(result => result.Items.Count(item => item.SignificantChange));
        var failures = results.Count(result => !result.Success);

        Console.WriteLine();
        Console.WriteLine("Summary");
        Console.WriteLine($"- Cuts: {results.Count}");
        Console.WriteLine($"- Swept Items: {totalItems}");
        Console.WriteLine($"- Changed Items: {changedItems}");
        Console.WriteLine($"- Significant Items: {significantItems}");
        Console.WriteLine($"- Failures: {failures}");
        Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");

        return failures == 0 ? 0 : 1;
    }

    public static int RunCutDebugCoverageReport(string[] args)
    {
        var options = CutDebugCoverageOptions.Parse(args);
        Directory.CreateDirectory(options.OutputPath);

        Console.WriteLine("Karisma cut-debug coverage report starting.");
        Console.WriteLine($"- Image Root: {options.ImageRootPath}");
        Console.WriteLine($"- Output: {options.OutputPath}");
        Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
        Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
        Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");

        var logService = new LogService();
        var cutDebugStateStore = new CutDebugStateStore();
        var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
        var cutItems = new FormatCatalogService().GetAll()
            .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
            .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
            .ToList();

        cutItems = ApplyTemplateFilter(cutItems, options.Filter);
        cutItems = ApplyCutFilter(cutItems, options.CutName);

        if (options.ExcludeHistoricalTurnout)
        {
            cutItems = cutItems
                .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
                .ToList();
        }

        if (options.Limit is int limit && limit > 0)
        {
            cutItems = cutItems.Take(limit).ToList();
        }

        if (cutItems.Count == 0)
        {
            Console.WriteLine("No cuts matched the requested filter.");
            return 1;
        }

        Console.WriteLine($"- Cuts: {cutItems.Count}");
        Console.WriteLine();

        var results = new List<CutDebugCoverageResult>();

        for (var index = 0; index < cutItems.Count; index++)
        {
            var item = cutItems[index];
            var result = new CutDebugCoverageResult
            {
                Index = index + 1,
                TemplateId = item.Template.Id,
                TemplateName = item.Template.Name,
                CutName = item.Cut.Name,
                Channel = item.Template.RecommendedChannel.ToString(),
                ExcludedHistoricalTurnout = options.ExcludeHistoricalTurnout && IsHistoricalTurnoutTemplate(item.Template.Name)
            };

            Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");

            try
            {
                var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
                var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
                var templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
                templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));

                result.ScenePath = resolvedScene.Path;
                result.SceneVariableCount = sceneVariables.Count;
                result.ItemCount = templateState.Items.Count;
                result.TextItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.TextValue);
                result.ImageItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.ImageValue);
                result.CounterItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Counter);
                result.StyleItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.StyleColor);
                result.VisibilityItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Visibility);
                result.SampleKeys = string.Join(", ", templateState.Items.Take(12).Select(debugItem => $"{debugItem.Key}:{debugItem.Kind}"));
                result.Success = true;
                result.Detail = $"sceneVars={result.SceneVariableCount}, items={result.ItemCount}";
            }
            catch (Exception ex)
            {
                result.Success = false;
                result.Detail = ex.Message;
            }

            results.Add(result);
        }

        WriteCutDebugCoverageReports(options, results);

        var successCount = results.Count(result => result.Success);
        var zeroCoverageCount = results.Count(result => result.Success && result.ItemCount == 0);
        var failureCount = results.Count(result => !result.Success);

        Console.WriteLine();
        Console.WriteLine("Summary");
        Console.WriteLine($"- Success: {successCount}/{results.Count}");
        Console.WriteLine($"- Zero Coverage: {zeroCoverageCount}");
        Console.WriteLine($"- Failures: {failureCount}");
        Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");

        return failureCount == 0 ? 0 : 1;
    }

    public static async Task<int> RunPartyColorAuditAsync(string[] args)
    {
        var options = PartyColorAuditOptions.Parse(args);
        Directory.CreateDirectory(options.OutputPath);

        Console.WriteLine("Karisma party-color live audit starting.");
        Console.WriteLine($"- Image Root: {options.ImageRootPath}");
        Console.WriteLine($"- Output: {options.OutputPath}");
        Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
        Console.WriteLine($"- Capture Mode: {options.CaptureMode}");
        Console.WriteLine("- Data Path: ApplyCutAsync -> Prepare -> Take -> Capture");

        var pgmWindow = options.CaptureMode == LiveCutCaptureMode.Pgm ? TryFindPgmWindow() : null;
        if (options.CaptureMode == LiveCutCaptureMode.Pgm && pgmWindow is null)
        {
            Console.WriteLine("PGM window was not found. Open the PGM window or rerun with --capture-mode scene.");
            return 1;
        }

        var logService = new LogService();
        var cutDebugStateStore = new CutDebugStateStore();
        if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
        {
            Console.WriteLine("Karisma adapter is not available. Audit cannot continue.");
            return 1;
        }

        var station = CreateValidationStation(options.StationLogoPath);
        var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
        var cutItems = new FormatCatalogService().GetAll()
            .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
            .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
            .ToList();

        cutItems = ApplyTemplateFilter(cutItems, options.Filter);
        cutItems = ApplyCutFilter(cutItems, options.CutName);

        var results = new List<PartyColorAuditResult>();
        var targetItems = new List<(LiveCutWorkItem Item, PartyColorAuditResult Result)>();

        for (var index = 0; index < cutItems.Count; index++)
        {
            var item = cutItems[index];
            var result = new PartyColorAuditResult
            {
                Index = index + 1,
                TemplateId = item.Template.Id,
                TemplateName = item.Template.Name,
                CutName = item.Cut.Name,
                Channel = item.Template.RecommendedChannel.ToString(),
                CaptureMode = options.CaptureMode.ToString()
            };

            try
            {
                var resolvedScene = KarismaSceneResolver.ResolveScene(
                    item.Template,
                    item.Cut,
                    options.ImageRootPath,
                    useLoop: false,
                    useEnd: item.Cut.UseEndScene);
                result.ScenePath = resolvedScene.Path;

                var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
                var descriptors = BuildCutDebugDescriptors(item.Template, sceneVariables);
                result.SceneVariableCount = sceneVariables.Count;
                result.TextItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.TextValue);
                result.ImageItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.ImageValue);
                result.CounterItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.Counter);
                result.StyleItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.StyleColor);
                result.VisibilityItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.Visibility);

                var decision = ClassifyPartyColorAuditTarget(item.Template, descriptors);
                result.VerificationTarget = decision.Include;
                result.ExclusionReason = decision.Reason;
                result.TargetReason = decision.Reason;
                result.Verdict = decision.Include ? "Pending" : "Excluded";
                if (decision.Include)
                {
                    targetItems.Add((item, result));
                }
            }
            catch (Exception ex)
            {
                result.VerificationTarget = false;
                result.ExclusionReason = "Scene metadata could not be inspected.";
                result.Verdict = "Fail";
                result.Detail = ex.Message;
            }

            results.Add(result);
        }

        if (options.Limit is int limit && limit > 0 && targetItems.Count > limit)
        {
            var selected = targetItems.Take(limit).ToHashSet();
            foreach (var skipped in targetItems.Skip(limit))
            {
                skipped.Result.VerificationTarget = false;
                skipped.Result.ExclusionReason = "Skipped by --limit.";
                skipped.Result.Verdict = "Excluded";
            }

            targetItems = targetItems.Where(entry => selected.Contains(entry)).ToList();
        }

        Console.WriteLine($"- Catalog Cuts: {results.Count}");
        Console.WriteLine($"- Audit Targets: {targetItems.Count}");
        Console.WriteLine();

        if (targetItems.Count == 0)
        {
            WritePartyColorAuditReports(options, results);
            Console.WriteLine("No cuts matched the party-color audit target rules.");
            return 1;
        }

        TornadoManager? rawManager = null;
        try
        {
            await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
            rawManager = ResolveAdapterTornadoManager(adapter);

            for (var targetIndex = 0; targetIndex < targetItems.Count; targetIndex++)
            {
                var (item, result) = targetItems[targetIndex];
                Console.WriteLine($"[{targetIndex + 1}/{targetItems.Count}] {item.Template.Id} / {item.Cut.Name}");

                try
                {
                    await OutAllAsync(adapter).ConfigureAwait(false);
                    await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);

                    var fileStem = $"{targetIndex + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}";
                    result.Step1OriginalCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step1_original.png");
                    result.Step2SamePartyCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step2_same_party_data.png");
                    result.Step3OtherPartyCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step3_other_party_data.png");
                    result.Step1VsStep2DiffPath = Path.Combine(options.OutputPath, $"{fileStem}_step1_vs_step2_diff.png");
                    result.Step2VsStep3DiffPath = Path.Combine(options.OutputPath, $"{fileStem}_step2_vs_step3_diff.png");
                    result.CaptureFrame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut);

                    result.Step1Hash = await CaptureOriginalSceneImageAsync(
                        rawManager,
                        pgmWindow,
                        item,
                        options,
                        result.Step1OriginalCapturePath).ConfigureAwait(false);
                    result.Step1ColorStats = AnalyzePartyColorFamily(result.Step1OriginalCapturePath);

                    var samePartyFamily = ResolveAuditCandidateFamily(
                        item.Template.Name,
                        result.Step1ColorStats.DominantFamily);
                    var otherPartyFamily = ResolveOtherAuditCandidateFamily(item.Template.Name, samePartyFamily);
                    var useRedLikeSameParty = samePartyFamily == PartyColorFamily.Red;
                    var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
                    var snapshotSameParty = CreateSnapshot(
                        item.Template.Name,
                        targetIndex,
                        variant: 0,
                        preElection,
                        useRedLikeSameParty,
                        cycleTopThreeCandidates: false,
                        stressTopRankValues: true,
                        forcedPartyFamily: samePartyFamily);
                    var snapshotOtherParty = CreateSnapshot(
                        item.Template.Name,
                        targetIndex,
                        variant: 1,
                        preElection,
                        otherPartyFamily is null ? !useRedLikeSameParty : otherPartyFamily == PartyColorFamily.Red,
                        cycleTopThreeCandidates: false,
                        stressTopRankValues: true,
                        forcedPartyFamily: otherPartyFamily);

                    result.Step2Hash = await CapturePartyColorDataImageAsync(
                        adapter,
                        pgmWindow,
                        item,
                        snapshotSameParty,
                        station,
                        options,
                        result.Step2SamePartyCapturePath).ConfigureAwait(false);
                    result.Step2ColorStats = AnalyzePartyColorFamily(result.Step2SamePartyCapturePath);

                    result.Step3Hash = await CapturePartyColorDataImageAsync(
                        adapter,
                        pgmWindow,
                        item,
                        snapshotOtherParty,
                        station,
                        options,
                        result.Step3OtherPartyCapturePath).ConfigureAwait(false);
                    result.Step3ColorStats = AnalyzePartyColorFamily(result.Step3OtherPartyCapturePath);

                    var step1VsStep2Diff = CompareImages(
                        result.Step1OriginalCapturePath,
                        result.Step2SamePartyCapturePath,
                        result.Step1VsStep2DiffPath);
                    var step2VsStep3Diff = CompareImages(
                        result.Step2SamePartyCapturePath,
                        result.Step3OtherPartyCapturePath,
                        result.Step2VsStep3DiffPath);
                    result.Step1VsStep2ChangedPixels = step1VsStep2Diff.ChangedPixels;
                    result.Step1VsStep2ChangeRatio = step1VsStep2Diff.ChangeRatio;
                    result.Step2VsStep3ChangedPixels = step2VsStep3Diff.ChangedPixels;
                    result.Step2VsStep3ChangeRatio = step2VsStep3Diff.ChangeRatio;

                    var samePartyComparison = EvaluateSamePartyColor(
                        result.Step1ColorStats,
                        result.Step2ColorStats,
                        step1VsStep2Diff);
                    var otherPartyComparison = EvaluateOtherPartyColorChange(
                        result.Step2ColorStats,
                        result.Step3ColorStats,
                        step2VsStep3Diff);

                    result.Step1VsStep2Result = samePartyComparison.Detail;
                    result.Step2VsStep3Result = otherPartyComparison.Detail;
                    result.ReviewRequired = samePartyComparison.Status == "Review" || otherPartyComparison.Status == "Review";
                    result.Verdict = samePartyComparison.Status == "Fail" || otherPartyComparison.Status == "Fail"
                        ? "Fail"
                        : "Pass";
                    result.Detail = BuildPartyColorAuditDetail(result);
                    if (result.Verdict == "Fail")
                    {
                        result.SuspectedCodeLocation = PartyColorAuditSuspectedCodeLocation;
                        result.FixTarget = PartyColorAuditFixTarget;
                    }
                }
                catch (Exception ex)
                {
                    result.Verdict = "Fail";
                    result.Detail = ex.Message;
                    result.SuspectedCodeLocation = PartyColorAuditSuspectedCodeLocation;
                    result.FixTarget = PartyColorAuditFixTarget;
                }
                finally
                {
                    try
                    {
                        await OutAllAsync(adapter).ConfigureAwait(false);
                    }
                    catch
                    {
                    }

                    await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
                }
            }
        }
        finally
        {
            try
            {
                await OutAllAsync(adapter).ConfigureAwait(false);
            }
            catch
            {
            }

            if (adapter is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }

        WritePartyColorAuditReports(options, results);

        var auditedCount = results.Count(result => result.VerificationTarget);
        var passCount = results.Count(result => result.VerificationTarget && result.Verdict == "Pass");
        var failCount = results.Count(result => result.VerificationTarget && result.Verdict == "Fail");
        var reviewCount = results.Count(result => result.VerificationTarget && result.ReviewRequired);
        var excludedCount = results.Count(result => !result.VerificationTarget);

        Console.WriteLine();
        Console.WriteLine("Summary");
        Console.WriteLine($"- Audited Cuts: {auditedCount}");
        Console.WriteLine($"- Pass: {passCount}");
        Console.WriteLine($"- Fail: {failCount}");
        Console.WriteLine($"- Review Required: {reviewCount}");
        Console.WriteLine($"- Excluded: {excludedCount}");
        Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");

        return failCount == 0 ? 0 : 1;
    }

    private static PartyColorAuditTargetDecision ClassifyPartyColorAuditTarget(
        FormatTemplateDefinition template,
        IReadOnlyList<CutDebugItemDescriptor> descriptors)
    {
        if (TryResolvePartyColorAuditExclusion(template, out var exclusionReason))
        {
            return new PartyColorAuditTargetDecision(false, exclusionReason);
        }

        if (!LooksLikePartyColorAuditTemplate(template))
        {
            return new PartyColorAuditTargetDecision(false, "Excluded: no party/candidate/tendency color workflow in the cut name.");
        }

        var hasStyleColor = descriptors.Any(descriptor => descriptor.Kind == CutDebugItemKind.StyleColor);
        var hasDynamicDataSurface = descriptors.Any(descriptor =>
            descriptor.Kind is CutDebugItemKind.TextValue or
                CutDebugItemKind.ImageValue or
                CutDebugItemKind.Counter);

        if (!template.RequiresCandidateData && !hasStyleColor)
        {
            return new PartyColorAuditTargetDecision(false, "Excluded: no candidate data or style-color surface detected.");
        }

        if (!hasStyleColor && !hasDynamicDataSurface)
        {
            return new PartyColorAuditTargetDecision(false, "Excluded: no dynamic text/image/counter/style items detected.");
        }

        if (hasStyleColor)
        {
            return new PartyColorAuditTargetDecision(true, "Included: dynamic style-color items detected.");
        }

        return new PartyColorAuditTargetDecision(true, "Included: candidate-data cut with dynamic data surfaces.");
    }

    private static bool TryResolvePartyColorAuditExclusion(
        FormatTemplateDefinition template,
        out string reason)
    {
        var name = template.Name;
        if (name.Contains("투표율", StringComparison.Ordinal))
        {
            reason = "Excluded: turnout cut.";
            return true;
        }

        if (name.Contains("타이틀", StringComparison.Ordinal))
        {
            reason = "Excluded: title cut.";
            return true;
        }

        if (name.Contains("의원표", StringComparison.Ordinal))
        {
            reason = "Excluded: proportional council vote-count cut; party color/image should not change.";
            return true;
        }

        if (name.StartsWith("사전_역대투표율", StringComparison.Ordinal))
        {
            reason = "Excluded: historical turnout cut.";
            return true;
        }

        reason = string.Empty;
        return false;
    }

    private static bool LooksLikePartyColorAuditTemplate(FormatTemplateDefinition template)
    {
        var name = template.Name;
        return name.StartsWith("1위_", StringComparison.Ordinal) ||
               name.StartsWith("1-2위", StringComparison.Ordinal) ||
               name.StartsWith("1-3위", StringComparison.Ordinal) ||
               name.StartsWith("당선_", StringComparison.Ordinal) ||
               name.StartsWith("전후보_", StringComparison.Ordinal) ||
               name.StartsWith("모든후보_", StringComparison.Ordinal) ||
               name.StartsWith("이시각1위_", StringComparison.Ordinal) ||
               name.StartsWith("접전_", StringComparison.Ordinal) ||
               name.StartsWith("초접전_", StringComparison.Ordinal) ||
               name.StartsWith("경력_", StringComparison.Ordinal) ||
               name.StartsWith("판세_", StringComparison.Ordinal) ||
               name.Contains("의원표", StringComparison.Ordinal) ||
               name.Contains("역대당선자", StringComparison.Ordinal) ||
               name.Contains("교육감", StringComparison.Ordinal) ||
               template.RequiresCandidateData;
    }

    private static async Task<string> CaptureOriginalSceneImageAsync(
        TornadoManager manager,
        PgmWindow? pgmWindow,
        LiveCutWorkItem item,
        PartyColorAuditOptions options,
        string outputPath)
    {
        var resolvedScene = KarismaSceneResolver.ResolveScene(
            item.Template,
            item.Cut,
            options.ImageRootPath,
            useLoop: false,
            useEnd: item.Cut.UseEndScene);
        var alias = $"audit_raw_{item.Template.Id}_{Guid.NewGuid():N}"
            .Replace('\\', '_')
            .Replace('/', '_');

        try
        {
            await manager.LoadSceneAsync(resolvedScene.Path, alias, forceReload: true, cancellationToken: CancellationToken.None)
                .ConfigureAwait(false);

            if (options.CaptureMode == LiveCutCaptureMode.Scene)
            {
                var (width, height) = ResolveSceneCaptureSize(item.Template);
                var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut);
                await manager.SaveSceneImageAsync(alias, outputPath, width, height, frame, CancellationToken.None)
                    .ConfigureAwait(false);
                return ComputeSha256(outputPath);
            }

            if (pgmWindow is null)
            {
                throw new InvalidOperationException("PGM capture requested, but the PGM window was not found.");
            }

            var binding = ResolveAuditBinding(item.Template.RecommendedChannel);
            await manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, alias, CancellationToken.None)
                .ConfigureAwait(false);
            await manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken: CancellationToken.None)
                .ConfigureAwait(false);
            await Task.Delay(ResolveFinalPgmCaptureDelayMs(item.Template, item.Cut)).ConfigureAwait(false);
            return CapturePgm(pgmWindow, outputPath, skipCapture: false);
        }
        finally
        {
            try
            {
                await manager.UnloadSceneAsync(alias, CancellationToken.None).ConfigureAwait(false);
            }
            catch
            {
            }
        }
    }

    private static async Task<string> CapturePartyColorDataImageAsync(
        ITornado3Adapter adapter,
        PgmWindow? pgmWindow,
        LiveCutWorkItem item,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        PartyColorAuditOptions options,
        string outputPath)
    {
        await OutAllAsync(adapter).ConfigureAwait(false);
        await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);

        await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, station, options.ImageRootPath, CancellationToken.None)
            .ConfigureAwait(false);
        await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
        await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
        if (options.CaptureMode == LiveCutCaptureMode.Pgm)
        {
            await Task.Delay(ResolveFinalPgmCaptureDelayMs(item.Template, item.Cut)).ConfigureAwait(false);
        }

        if (options.CaptureMode == LiveCutCaptureMode.Pgm)
        {
            var outputVisibleInPgm = pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall;
            return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm);
        }

        if (adapter is not KarismaTornado3Adapter karismaAdapter)
        {
            throw new InvalidOperationException("Scene capture mode requires the Karisma adapter.");
        }

        var (width, height) = ResolveSceneCaptureSize(item.Template);
        var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut);
        await karismaAdapter.SavePendingSceneImageAsync(
                item.Template.RecommendedChannel,
                outputPath,
                width,
                height,
                frame,
                cancellationToken: CancellationToken.None)
            .ConfigureAwait(false);
        return ComputeSha256(outputPath);
    }

    private static int ResolveFinalSceneCaptureFrame(FormatTemplateDefinition template, FormatCutDefinition cut)
    {
        const int framesPerSecond = 30;
        var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
        return Math.Max(0, (int)Math.Round(durationSeconds * framesPerSecond, MidpointRounding.AwayFromZero) - 1);
    }

    private static int ResolveFinalPgmCaptureDelayMs(FormatTemplateDefinition template, FormatCutDefinition cut)
    {
        var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
        return Math.Max(ResolveTemplateOnAirDelayMs(template, 900), (int)Math.Ceiling(durationSeconds * 1000d));
    }

    private static TornadoManager ResolveAdapterTornadoManager(ITornado3Adapter adapter)
    {
        if (adapter is not KarismaTornado3Adapter karismaAdapter)
        {
            throw new InvalidOperationException("Party color audit requires the Karisma adapter.");
        }

        var managerField = typeof(KarismaTornado3Adapter).GetField(
            "_manager",
            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        return managerField?.GetValue(karismaAdapter) as TornadoManager
               ?? throw new InvalidOperationException("Karisma manager could not be resolved for raw scene capture.");
    }

    private static AuditKarismaBinding ResolveAuditBinding(BroadcastChannel channel)
    {
        return channel switch
        {
            BroadcastChannel.Normal => ParseAuditBinding("TORNADO_KARISMA_BIND_NORMAL", new AuditKarismaBinding(0, 0)),
            BroadcastChannel.TopLeft => ParseAuditBinding("TORNADO_KARISMA_BIND_TOPLEFT", new AuditKarismaBinding(0, 1)),
            BroadcastChannel.Bottom => ParseAuditBinding("TORNADO_KARISMA_BIND_BOTTOM", new AuditKarismaBinding(0, 2)),
            BroadcastChannel.VideoWall => ParseAuditBinding("TORNADO_KARISMA_BIND_VIDEOWALL", new AuditKarismaBinding(1, 0)),
            _ => new AuditKarismaBinding(0, 0)
        };
    }

    private static AuditKarismaBinding ParseAuditBinding(string environmentVariableName, AuditKarismaBinding fallback)
    {
        var raw = Environment.GetEnvironmentVariable(environmentVariableName);
        if (string.IsNullOrWhiteSpace(raw))
        {
            return fallback;
        }

        var parts = raw.Split(':', ',', ';');
        return parts.Length >= 2 &&
               int.TryParse(parts[0], out var outputChannelIndex) &&
               int.TryParse(parts[1], out var layerNo)
            ? new AuditKarismaBinding(outputChannelIndex, layerNo)
            : fallback;
    }

    private static PartyColorStats AnalyzePartyColorFamily(string imagePath)
    {
        using var bitmap = new Bitmap(imagePath);
        var redPixels = 0;
        var bluePixels = 0;
        var greenPixels = 0;
        var coloredPixels = 0;
        var step = Math.Max(1, Math.Min(bitmap.Width, bitmap.Height) / 360);

        for (var y = 0; y < bitmap.Height; y += step)
        {
            for (var x = 0; x < bitmap.Width; x += step)
            {
                var color = bitmap.GetPixel(x, y);
                if (color.A < 128)
                {
                    continue;
                }

                var max = Math.Max(color.R, Math.Max(color.G, color.B));
                var min = Math.Min(color.R, Math.Min(color.G, color.B));
                if (max < 50 || max == 0)
                {
                    continue;
                }

                var saturation = (max - min) / (double)max;
                if (saturation < 0.28)
                {
                    continue;
                }

                var hue = color.GetHue();
                if ((hue <= 15 || hue >= 345) &&
                    color.R >= 120 &&
                    color.R > color.G * 1.35 &&
                    color.R > color.B * 1.35)
                {
                    redPixels++;
                    coloredPixels++;
                }
                else if (hue is >= 185 and <= 265 &&
                         color.B >= 90 &&
                         color.B > color.R * 1.2 &&
                         color.B > color.G * 1.05)
                {
                    bluePixels++;
                    coloredPixels++;
                }
                else if (hue is >= 70 and <= 170 &&
                         color.G >= 90 &&
                         color.G > color.R * 1.15 &&
                         color.G > color.B * 1.15)
                {
                    greenPixels++;
                    coloredPixels++;
                }
            }
        }

        var dominant = ResolveDominantPartyColor(redPixels, bluePixels, greenPixels, coloredPixels);
        return new PartyColorStats
        {
            DominantFamily = dominant,
            RedPixels = redPixels,
            BluePixels = bluePixels,
            GreenPixels = greenPixels,
            ColoredPixels = coloredPixels
        };
    }

    private static PartyColorFamily ResolveDominantPartyColor(int redPixels, int bluePixels, int greenPixels, int coloredPixels)
    {
        if (coloredPixels < 120)
        {
            return PartyColorFamily.Unknown;
        }

        var ranked = new[]
            {
                (Family: PartyColorFamily.Red, Count: redPixels),
                (Family: PartyColorFamily.Blue, Count: bluePixels),
                (Family: PartyColorFamily.Green, Count: greenPixels)
            }
            .OrderByDescending(item => item.Count)
            .ToArray();

        if (ranked[0].Count < 120)
        {
            return PartyColorFamily.Unknown;
        }

        if (ranked[1].Count > 0 && ranked[0].Count < ranked[1].Count * 1.18)
        {
            return PartyColorFamily.Mixed;
        }

        return ranked[0].Family;
    }

    private static AuditComparison EvaluateSamePartyColor(
        PartyColorStats step1,
        PartyColorStats step2,
        ImageDiffResult diff)
    {
        if (step1.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed ||
            step2.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed)
        {
            return new AuditComparison(
                "Review",
                $"Review: same-party comparison needs visual check. step1={step1.DominantFamily}, step2={step2.DominantFamily}, diff={diff.ChangeRatio:P3}.");
        }

        if (step1.DominantFamily != step2.DominantFamily)
        {
            return new AuditComparison(
                "Fail",
                $"Fail: same-party color family changed from {step1.DominantFamily} to {step2.DominantFamily}.");
        }

        return new AuditComparison(
            "Pass",
            $"Pass: same-party dominant color family stayed {step2.DominantFamily}; diff={diff.ChangeRatio:P3}.");
    }

    private static AuditComparison EvaluateOtherPartyColorChange(
        PartyColorStats step2,
        PartyColorStats step3,
        ImageDiffResult diff)
    {
        if (diff.ChangedPixels < 250 && diff.ChangeRatio < 0.0005)
        {
            return new AuditComparison(
                "Fail",
                $"Fail: changing party data produced almost no visual change. diff={diff.ChangeRatio:P3}.");
        }

        if (step2.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed ||
            step3.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed)
        {
            return new AuditComparison(
                "Review",
                $"Review: other-party comparison needs visual check. step2={step2.DominantFamily}, step3={step3.DominantFamily}, diff={diff.ChangeRatio:P3}.");
        }

        if (step2.DominantFamily == step3.DominantFamily)
        {
            return new AuditComparison(
                "Review",
                $"Review: screen changed, but dominant color family stayed {step3.DominantFamily}; diff={diff.ChangeRatio:P3}.");
        }

        return new AuditComparison(
            "Pass",
            $"Pass: dominant color family changed from {step2.DominantFamily} to {step3.DominantFamily}; diff={diff.ChangeRatio:P3}.");
    }

    private static string BuildPartyColorAuditDetail(PartyColorAuditResult result)
    {
        return string.Join(
            " | ",
            new[]
            {
                $"step1={result.Step1ColorStats?.DominantFamily}",
                $"step2={result.Step2ColorStats?.DominantFamily}",
                $"step3={result.Step3ColorStats?.DominantFamily}",
                $"step1vs2={result.Step1VsStep2ChangeRatio:P3}",
                $"step2vs3={result.Step2VsStep3ChangeRatio:P3}"
            });
    }

    private static void WritePartyColorAuditReports(PartyColorAuditOptions options, IReadOnlyList<PartyColorAuditResult> results)
    {
        var jsonPath = Path.Combine(options.OutputPath, "results.json");
        var csvPath = Path.Combine(options.OutputPath, "results.csv");
        var excludedPath = Path.Combine(options.OutputPath, "excluded.csv");
        var summaryPath = Path.Combine(options.OutputPath, "summary.md");

        File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);

        var csv = new StringBuilder();
        csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,CaptureFrame,VerificationTarget,ExclusionReason,Step1OriginalCapturePath,Step2SamePartyCapturePath,Step3OtherPartyCapturePath,Step1VsStep2Result,Step2VsStep3Result,Verdict,ReviewRequired,Step1Family,Step2Family,Step3Family,Step1VsStep2ChangeRatio,Step2VsStep3ChangeRatio,SuspectedCodeLocation,FixTarget,Detail");
        foreach (var result in results.Where(result => result.VerificationTarget))
        {
            csv.AppendLine(string.Join(",",
                Csv(result.Index.ToString()),
                Csv(result.TemplateId),
                Csv(result.TemplateName),
                Csv(result.CutName),
                Csv(result.Channel),
                Csv(result.CaptureFrame.ToString()),
                Csv(result.VerificationTarget.ToString()),
                Csv(result.ExclusionReason ?? string.Empty),
                Csv(result.Step1OriginalCapturePath ?? string.Empty),
                Csv(result.Step2SamePartyCapturePath ?? string.Empty),
                Csv(result.Step3OtherPartyCapturePath ?? string.Empty),
                Csv(result.Step1VsStep2Result ?? string.Empty),
                Csv(result.Step2VsStep3Result ?? string.Empty),
                Csv(result.Verdict),
                Csv(result.ReviewRequired.ToString()),
                Csv(result.Step1ColorStats?.DominantFamily.ToString() ?? string.Empty),
                Csv(result.Step2ColorStats?.DominantFamily.ToString() ?? string.Empty),
                Csv(result.Step3ColorStats?.DominantFamily.ToString() ?? string.Empty),
                Csv(result.Step1VsStep2ChangeRatio.ToString("0.000000")),
                Csv(result.Step2VsStep3ChangeRatio.ToString("0.000000")),
                Csv(result.SuspectedCodeLocation ?? string.Empty),
                Csv(result.FixTarget ?? string.Empty),
                Csv(result.Detail ?? string.Empty)));
        }

        File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);

        var excluded = new StringBuilder();
        excluded.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,ExclusionReason,SceneVariableCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount");
        foreach (var result in results.Where(result => !result.VerificationTarget))
        {
            excluded.AppendLine(string.Join(",",
                Csv(result.Index.ToString()),
                Csv(result.TemplateId),
                Csv(result.TemplateName),
                Csv(result.CutName),
                Csv(result.Channel),
                Csv(result.ExclusionReason ?? string.Empty),
                Csv(result.SceneVariableCount.ToString()),
                Csv(result.TextItemCount.ToString()),
                Csv(result.ImageItemCount.ToString()),
                Csv(result.CounterItemCount.ToString()),
                Csv(result.StyleItemCount.ToString()),
                Csv(result.VisibilityItemCount.ToString())));
        }

        File.WriteAllText(excludedPath, excluded.ToString(), Encoding.UTF8);

        var audited = results.Where(result => result.VerificationTarget).ToList();
        var failures = audited.Where(result => result.Verdict == "Fail").ToList();
        var review = audited.Where(result => result.ReviewRequired).ToList();
        var summary = new StringBuilder();
        summary.AppendLine("# Party Color Live Audit");
        summary.AppendLine();
        summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
        summary.AppendLine($"- Image Root: {options.ImageRootPath}");
        summary.AppendLine($"- Capture Mode: {options.CaptureMode}");
        summary.AppendLine("- Scene captures use the explicit final frame derived from cut duration, not `frame=-1`.");
        summary.AppendLine($"- Catalog Cuts: {results.Count}");
        summary.AppendLine($"- Audited Cuts: {audited.Count}");
        summary.AppendLine($"- Pass: {audited.Count(result => result.Verdict == "Pass")}");
        summary.AppendLine($"- Fail: {failures.Count}");
        summary.AppendLine($"- Review Required: {review.Count}");
        summary.AppendLine($"- Excluded Cuts: {results.Count(result => !result.VerificationTarget)}");
        summary.AppendLine();

        if (failures.Count > 0)
        {
            summary.AppendLine("## Failures");
            summary.AppendLine();
            foreach (var result in failures)
            {
                summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Step1VsStep2Result} {result.Step2VsStep3Result}");
            }

            summary.AppendLine();
        }

        if (review.Count > 0)
        {
            summary.AppendLine("## Needs Visual Review");
            summary.AppendLine();
            foreach (var result in review.Take(40))
            {
                summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Step1VsStep2Result} {result.Step2VsStep3Result}");
            }

            if (review.Count > 40)
            {
                summary.AppendLine($"- ... {review.Count - 40} more");
            }

            summary.AppendLine();
        }

        summary.AppendLine("## Files");
        summary.AppendLine();
        summary.AppendLine("- `results.csv`");
        summary.AppendLine("- `results.json`");
        summary.AppendLine("- `excluded.csv`");
        summary.AppendLine("- `*_step1_original.png`");
        summary.AppendLine("- `*_step2_same_party_data.png`");
        summary.AppendLine("- `*_step3_other_party_data.png`");
        summary.AppendLine("- `*_diff.png`");

        File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
    }

    private static async Task<SweepCaptureResult> CaptureSweepImageAsync(
        ITornado3Adapter adapter,
        PgmWindow pgmWindow,
        LiveCutWorkItem item,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        CutDebugSweepOptions options,
        string outputPath)
    {
        await OutAllAsync(adapter).ConfigureAwait(false);
        await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);

        await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
        await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
        await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
        await Task.Delay(ResolveSweepOnAirDelayMs(item.Template, options)).ConfigureAwait(false);

        return new SweepCaptureResult(outputPath, CapturePgm(pgmWindow, outputPath, skipCapture: false));
    }

    private static void EnableAllDebugCategories(CutDebugSettings settings)
    {
        settings.IsEnabled = true;
        settings.ApplyTextValues = true;
        settings.ApplyImageValues = true;
        settings.ApplyVisibilityValues = true;
        settings.ApplyVoteRateTextValues = true;
        settings.ApplyVoteRateCounterValues = true;
        settings.ApplyPartyBarStyleColors = true;
        settings.ApplyPartyPlateStyleColors = true;
        settings.ApplyVoteRateStyleColors = true;
    }

    private static void SetAllItemsEnabled(IEnumerable<CutDebugItemState> items, bool isEnabled)
    {
        foreach (var item in items)
        {
            item.IsEnabled = isEnabled;
        }
    }

    private static IReadOnlyList<CutDebugItemState> OrderSweepItems(
        string templateId,
        IEnumerable<CutDebugItemState> items,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var sceneVariableKeys = sceneVariables.Keys
            .Select(CutDebugTemplateState.NormalizeKey)
            .Where(key => !string.IsNullOrWhiteSpace(key))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        var hasRecommendation = CutDebugRecommendationCatalog.TryGetRecommendation(templateId, out var recommendation);

        return items
            .OrderBy(item => hasRecommendation ? !MatchesRecommendation(item, recommendation) : false)
            .ThenBy(item => !sceneVariableKeys.Contains(CutDebugTemplateState.NormalizeKey(item.Key)))
            .ThenBy(item => GetSweepKeyPriority(item.Key))
            .ThenBy(item => GetSweepKindPriority(item.Kind))
            .ThenBy(item => ResolveDebugItemIndex(item.Key) ?? int.MaxValue)
            .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
            .ToArray();
    }

    private static bool MatchesRecommendation(CutDebugItemState item, CutDebugRecommendation recommendation)
    {
        return item.Kind == recommendation.Kind &&
               string.Equals(
                   CutDebugTemplateState.NormalizeKey(item.Key),
                   CutDebugTemplateState.NormalizeKey(recommendation.Key),
                   StringComparison.OrdinalIgnoreCase);
    }

    private static void ConfigureSweepItem(
        CutDebugTemplateState templateState,
        CutDebugItemState sweepItem,
        CutDebugSweepMode mode,
        CutDebugReplacementAssets replacementAssets)
    {
        if (mode == CutDebugSweepMode.Disable)
        {
            sweepItem.IsEnabled = false;
            return;
        }

        var itemIndex = ResolveDebugItemIndex(sweepItem.Key);
        switch (sweepItem.Kind)
        {
            case CutDebugItemKind.TextValue:
                templateState.SetOverride(
                    sweepItem.Key,
                    sweepItem.Kind,
                    CutDebugOverride.ForString(itemIndex.HasValue ? $"DBG{itemIndex:00}" : "DEBUG"));
                break;
            case CutDebugItemKind.ImageValue:
                templateState.SetOverride(
                    sweepItem.Key,
                    sweepItem.Kind,
                    CutDebugOverride.ForString(itemIndex.GetValueOrDefault(1) % 2 == 0
                        ? replacementAssets.CyanPath
                        : replacementAssets.MagentaPath));
                break;
            case CutDebugItemKind.Counter:
                templateState.SetOverride(
                    sweepItem.Key,
                    sweepItem.Kind,
                    CutDebugOverride.ForNumber(itemIndex.GetValueOrDefault(1) % 2 == 0 ? 12.3 : 87.6));
                break;
            case CutDebugItemKind.StyleColor:
                templateState.SetOverride(
                    sweepItem.Key,
                    sweepItem.Kind,
                    itemIndex.GetValueOrDefault(1) % 2 == 0
                        ? CutDebugOverride.ForColor(0, 255, 255)
                        : CutDebugOverride.ForColor(255, 0, 255));
                break;
            case CutDebugItemKind.Visibility:
                templateState.SetOverride(
                    sweepItem.Key,
                    sweepItem.Kind,
                    CutDebugOverride.ForVisibility(false));
                break;
        }
    }

    private static CutDebugReplacementAssets EnsureCutDebugReplacementAssets(string outputPath)
    {
        var assetsPath = Path.Combine(outputPath, "_debug_assets");
        Directory.CreateDirectory(assetsPath);

        var magentaPath = Path.Combine(assetsPath, "debug_magenta.png");
        var cyanPath = Path.Combine(assetsPath, "debug_cyan.png");
        WriteDebugReplacementAsset(magentaPath, Color.FromArgb(255, 255, 0, 255), Color.FromArgb(255, 255, 255, 0));
        WriteDebugReplacementAsset(cyanPath, Color.FromArgb(255, 0, 255, 255), Color.FromArgb(255, 0, 0, 0));
        return new CutDebugReplacementAssets(magentaPath, cyanPath);
    }

    private static void WriteDebugReplacementAsset(string path, Color background, Color accent)
    {
        if (File.Exists(path))
        {
            return;
        }

        using var bitmap = new Bitmap(384, 384, PixelFormat.Format32bppArgb);
        using var graphics = Graphics.FromImage(bitmap);
        graphics.Clear(background);

        using var borderPen = new Pen(accent, 24);
        graphics.DrawRectangle(borderPen, 12, 12, bitmap.Width - 24, bitmap.Height - 24);
        graphics.DrawLine(borderPen, 24, 24, bitmap.Width - 24, bitmap.Height - 24);
        graphics.DrawLine(borderPen, bitmap.Width - 24, 24, 24, bitmap.Height - 24);

        using var font = new Font("Arial", 72, FontStyle.Bold, GraphicsUnit.Pixel);
        using var brush = new SolidBrush(accent);
        var text = "DBG";
        var size = graphics.MeasureString(text, font);
        graphics.DrawString(
            text,
            font,
            brush,
            (bitmap.Width - size.Width) / 2,
            (bitmap.Height - size.Height) / 2);

        bitmap.Save(path, ImageFormat.Png);
    }

    private static int? ResolveDebugItemIndex(string key)
    {
        if (string.IsNullOrWhiteSpace(key) || key.Length < 2)
        {
            return null;
        }

        var suffix = key[^2..];
        return int.TryParse(suffix, out var parsed) && parsed > 0
            ? parsed
            : null;
    }

    private static int GetSweepKindPriority(CutDebugItemKind kind)
    {
        return kind switch
        {
            CutDebugItemKind.ImageValue => 0,
            CutDebugItemKind.Counter => 1,
            CutDebugItemKind.TextValue => 2,
            CutDebugItemKind.StyleColor => 3,
            CutDebugItemKind.Visibility => 4,
            _ => 9
        };
    }

    private static int GetSweepKeyPriority(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            return 99;
        }

        var normalizedKey = CutDebugTemplateState.NormalizeKey(key);
        return normalizedKey switch
        {
            var value when value.StartsWith("후보사진", StringComparison.OrdinalIgnoreCase) => 0,
            var value when value.StartsWith("후보명", StringComparison.OrdinalIgnoreCase) => 1,
            var value when value.StartsWith("투표율", StringComparison.OrdinalIgnoreCase) => 2,
            var value when value.StartsWith("전국투표율", StringComparison.OrdinalIgnoreCase) => 2,
            var value when value.StartsWith("득표율", StringComparison.OrdinalIgnoreCase) => 2,
            var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
            var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
            var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
            var value when value.StartsWith("의석수", StringComparison.OrdinalIgnoreCase) => 5,
            var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
            var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
            var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
            var value when value.StartsWith("정당바", StringComparison.OrdinalIgnoreCase) => 8,
            var value when value.StartsWith("정당판", StringComparison.OrdinalIgnoreCase) => 8,
            var value when value.StartsWith("득표수바", StringComparison.OrdinalIgnoreCase) => 8,
            var value when value.StartsWith("기호", StringComparison.OrdinalIgnoreCase) => 9,
            var value when value.StartsWith("순위", StringComparison.OrdinalIgnoreCase) => 9,
            var value when value.StartsWith("기준시", StringComparison.OrdinalIgnoreCase) => 10,
            _ => 20
        };
    }

    private static int ResolveSweepOnAirDelayMs(FormatTemplateDefinition template, CutDebugSweepOptions options)
    {
        return ResolveTemplateOnAirDelayMs(template, options.OnAirDelayMs);
    }

    private static int ResolveTemplateOnAirDelayMs(FormatTemplateDefinition template, int configuredDelay)
    {
        var delay = configuredDelay;
        if (template.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal))
        {
            return Math.Max(delay, 8000);
        }

        return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
               template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase)
            ? Math.Max(delay, 3500)
            : delay;
    }

    private static string? CaptureRecentLogs(LogService logService, int previousCount, int maxLines)
    {
        var addedCount = Math.Max(0, logService.Entries.Count - previousCount);
        if (addedCount == 0)
        {
            return null;
        }

        return string.Join(
            " || ",
            logService.Entries
                .Take(Math.Min(addedCount, maxLines))
                .Select(entry => entry.Message));
    }

    private static BroadcastStationProfile CreateValidationStation(string stationLogoPath)
    {
        return new BroadcastStationProfile
        {
            Id = "AUTO",
            Name = "AUTO",
            LogoAssetPath = stationLogoPath,
            RegionFilters = ["서울", "인천", "충남"]
        };
    }

    private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugDescriptors(
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var descriptors = new List<CutDebugItemDescriptor>();

        foreach (var variable in sceneVariables.Values.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase))
        {
            var normalizedKey = CutDebugTemplateState.NormalizeKey(variable.Name);
            if (string.IsNullOrWhiteSpace(normalizedKey))
            {
                continue;
            }

            if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
            {
                continue;
            }

            var primaryKind = variable.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource
                ? CutDebugItemKind.ImageValue
                : CutDebugItemKind.TextValue;
            descriptors.Add(new CutDebugItemDescriptor(normalizedKey, primaryKind, ResolveDebugGroupLabel(normalizedKey, primaryKind)));

            if (variable.Kind == KarismaSceneVariableKind.Counter || IsVoteRateDebugKey(normalizedKey))
            {
                descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Counter, "counter"));
            }

            if (IsStyleDebugKey(normalizedKey))
            {
                descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.StyleColor, "style"));
            }

            if (IsVisibilityDebugKey(normalizedKey))
            {
                descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Visibility, "visibility"));
            }
        }

        descriptors.AddRange(
            BuildHeuristicCutDebugDescriptors(template)
                .Where(descriptor => ShouldIncludeHeuristicDescriptor(template, sceneVariables, descriptor)));

        return descriptors;
    }

    private static bool ShouldExcludeHistoricalTurnoutGraph(
        FormatTemplateDefinition template,
        string normalizedKey)
    {
        return IsHistoricalTurnoutTemplate(template.Name) &&
               string.Equals(normalizedKey, "차트01", StringComparison.OrdinalIgnoreCase);
    }

    private static bool ShouldIncludeHeuristicDescriptor(
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugItemDescriptor descriptor)
    {
        var normalizedKey = CutDebugTemplateState.NormalizeKey(descriptor.Key);
        if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
        {
            return false;
        }

        if (string.Equals(normalizedKey, "전국투표율01", StringComparison.OrdinalIgnoreCase))
        {
            return sceneVariables.Keys.Any(key =>
                string.Equals(
                    CutDebugTemplateState.NormalizeKey(key),
                    normalizedKey,
                    StringComparison.OrdinalIgnoreCase));
        }

        return true;
    }

    private static IEnumerable<CutDebugItemDescriptor> BuildHeuristicCutDebugDescriptors(FormatTemplateDefinition template)
    {
        var slotCount = ResolveDebugSlotCount(template);

        foreach (var prefix in new[] { "시도명", "개표율", "투표율", "전국투표율", "기준시", "유권자수", "투표자수" })
        {
            yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
        }

        foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "의석수", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
        {
            for (var slot = 1; slot <= slotCount; slot++)
            {
                yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.TextValue, "text");
            }
        }

        foreach (var prefix in new[] { "유확당", "후보사진", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당심볼", "그룹", "바" })
        {
            for (var slot = 1; slot <= slotCount; slot++)
            {
                yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.ImageValue, "image");
            }
        }

        foreach (var slot in Enumerable.Range(1, slotCount))
        {
            yield return new CutDebugItemDescriptor($"득표율{slot:00}", CutDebugItemKind.Counter, "counter");
        }

        yield return new CutDebugItemDescriptor("전국투표율01", CutDebugItemKind.Counter, "counter");

        foreach (var prefix in new[] { "기호", "기호텍스트", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당명", "득표율" })
        {
            for (var slot = 1; slot <= slotCount; slot++)
            {
                yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.StyleColor, "style");
            }
        }

        foreach (var prefix in new[] { "유확당", "그룹" })
        {
            for (var slot = 1; slot <= slotCount; slot++)
            {
                yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.Visibility, "visibility");
            }
        }

        foreach (var slot in Enumerable.Range(1, 3))
        {
            yield return new CutDebugItemDescriptor($"공약그룹{slot:00}", CutDebugItemKind.Visibility, "visibility");
        }
    }

    private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
    {
        if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
        {
            return 6;
        }

        var source = $"{template.Name} {template.Id}";
        var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
        if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
        {
            return Math.Max(1, topRankSlots);
        }

        var peopleMatch = System.Text.RegularExpressions.Regex.Match(source, @"(\d+)인");
        if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlots))
        {
            return Math.Max(1, peopleSlots);
        }

        return 2;
    }

    private static string ResolveDebugGroupLabel(string key, CutDebugItemKind kind)
    {
        return kind switch
        {
            CutDebugItemKind.TextValue when key.StartsWith("공약", StringComparison.Ordinal) => "promise",
            CutDebugItemKind.TextValue => "text",
            CutDebugItemKind.ImageValue => "image",
            CutDebugItemKind.Counter => "counter",
            CutDebugItemKind.StyleColor => "style",
            CutDebugItemKind.Visibility => "visibility",
            _ => "other"
        };
    }

    private static bool IsVoteRateDebugKey(string key)
    {
        return key.StartsWith("득표율", StringComparison.Ordinal) ||
               key.StartsWith("투표율", StringComparison.Ordinal) ||
               key.StartsWith("전국투표율", StringComparison.Ordinal);
    }

    private static bool IsHistoricalTurnoutTemplate(string templateName)
    {
        return templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal);
    }

    private static bool IsStyleDebugKey(string key)
    {
        return key.StartsWith("기호", StringComparison.Ordinal) ||
               key.StartsWith("득표수바", StringComparison.Ordinal) ||
               key.StartsWith("정당바", StringComparison.Ordinal) ||
               key.StartsWith("정당판", StringComparison.Ordinal) ||
               key.StartsWith("정당원", StringComparison.Ordinal) ||
               key.StartsWith("정당색", StringComparison.Ordinal) ||
               key.StartsWith("정당명", StringComparison.Ordinal) ||
               key.StartsWith("득표율", StringComparison.Ordinal);
    }

    private static bool IsVisibilityDebugKey(string key)
    {
        return key.StartsWith("유확당", StringComparison.Ordinal) ||
               key.StartsWith("그룹", StringComparison.Ordinal) ||
               key.StartsWith("공약그룹", StringComparison.Ordinal);
    }

    private static ImageDiffResult CompareImages(string baselinePath, string candidatePath, string diffPath)
    {
        using var baselineOriginal = new Bitmap(baselinePath);
        using var candidateOriginal = new Bitmap(candidatePath);
        using var baseline = EnsureArgbBitmap(baselineOriginal);
        using var candidate = EnsureArgbBitmap(candidateOriginal);

        if (baseline.Width != candidate.Width || baseline.Height != candidate.Height)
        {
            throw new InvalidOperationException("Baseline and candidate captures have different dimensions.");
        }

        var width = baseline.Width;
        var height = baseline.Height;
        using var diffBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);

        var baselineData = baseline.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
        var candidateData = candidate.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
        var diffData = diffBitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);

        try
        {
            var baselineStride = Math.Abs(baselineData.Stride);
            var candidateStride = Math.Abs(candidateData.Stride);
            var diffStride = Math.Abs(diffData.Stride);
            var baselineBytes = new byte[baselineStride * height];
            var candidateBytes = new byte[candidateStride * height];
            var diffBytes = new byte[diffStride * height];

            Marshal.Copy(baselineData.Scan0, baselineBytes, 0, baselineBytes.Length);
            Marshal.Copy(candidateData.Scan0, candidateBytes, 0, candidateBytes.Length);

            var changedPixels = 0;
            long totalDelta = 0;
            int? minX = null;
            int? minY = null;
            int? maxX = null;
            int? maxY = null;

            for (var y = 0; y < height; y++)
            {
                var baselineRowOffset = y * baselineStride;
                var candidateRowOffset = y * candidateStride;
                var diffRowOffset = y * diffStride;
                for (var x = 0; x < width; x++)
                {
                    var baselineOffset = baselineRowOffset + (x * 4);
                    var candidateOffset = candidateRowOffset + (x * 4);
                    var diffOffset = diffRowOffset + (x * 4);
                    var blueDelta = Math.Abs(baselineBytes[baselineOffset] - candidateBytes[candidateOffset]);
                    var greenDelta = Math.Abs(baselineBytes[baselineOffset + 1] - candidateBytes[candidateOffset + 1]);
                    var redDelta = Math.Abs(baselineBytes[baselineOffset + 2] - candidateBytes[candidateOffset + 2]);
                    var alphaDelta = Math.Abs(baselineBytes[baselineOffset + 3] - candidateBytes[candidateOffset + 3]);
                    var delta = blueDelta + greenDelta + redDelta + alphaDelta;

                    if (delta > 24)
                    {
                        changedPixels++;
                        totalDelta += delta;
                        minX = !minX.HasValue || x < minX.Value ? x : minX.Value;
                        minY = !minY.HasValue || y < minY.Value ? y : minY.Value;
                        maxX = !maxX.HasValue || x > maxX.Value ? x : maxX.Value;
                        maxY = !maxY.HasValue || y > maxY.Value ? y : maxY.Value;

                        diffBytes[diffOffset] = 0;
                        diffBytes[diffOffset + 1] = (byte)Math.Min(255, delta);
                        diffBytes[diffOffset + 2] = 255;
                        diffBytes[diffOffset + 3] = 255;
                    }
                    else
                    {
                        diffBytes[diffOffset] = 0;
                        diffBytes[diffOffset + 1] = 0;
                        diffBytes[diffOffset + 2] = 0;
                        diffBytes[diffOffset + 3] = 255;
                    }
                }
            }

            Marshal.Copy(diffBytes, 0, diffData.Scan0, diffBytes.Length);
            Directory.CreateDirectory(Path.GetDirectoryName(diffPath)!);
            diffBitmap.Save(diffPath, ImageFormat.Png);

            var totalPixels = Math.Max(1, width * height);
            var boundingBox = minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue
                ? $"{minX.Value},{minY.Value} - {maxX.Value},{maxY.Value}"
                : null;

            return new ImageDiffResult(
                changedPixels,
                changedPixels / (double)totalPixels,
                changedPixels == 0 ? 0 : totalDelta / (double)changedPixels,
                boundingBox);
        }
        finally
        {
            baseline.UnlockBits(baselineData);
            candidate.UnlockBits(candidateData);
            diffBitmap.UnlockBits(diffData);
        }
    }

    private static Bitmap EnsureArgbBitmap(Bitmap source)
    {
        if (source.PixelFormat == PixelFormat.Format32bppArgb)
        {
            return (Bitmap)source.Clone();
        }

        var converted = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
        using var graphics = Graphics.FromImage(converted);
        graphics.DrawImage(source, 0, 0, source.Width, source.Height);
        return converted;
    }

    private static void WriteCutDebugSweepReports(CutDebugSweepOptions options, IReadOnlyList<CutDebugSweepResult> results)
    {
        var jsonPath = Path.Combine(options.OutputPath, "results.json");
        var csvPath = Path.Combine(options.OutputPath, "items.csv");
        var summaryPath = Path.Combine(options.OutputPath, "summary.md");

        File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);

        var csv = new StringBuilder();
        csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Key,Kind,GroupLabel,ChangedPixels,ChangeRatio,AverageChannelDelta,SignificantChange,BoundingBox,CapturePath,DiffPath,LogPreview,Detail");
        foreach (var result in results)
        {
            foreach (var item in result.Items)
            {
                csv.AppendLine(string.Join(",",
                    Csv(result.Index.ToString()),
                    Csv(result.TemplateId),
                    Csv(result.TemplateName),
                    Csv(result.CutName),
                    Csv(result.Channel),
                    Csv(item.Key),
                    Csv(item.Kind),
                    Csv(item.GroupLabel),
                    Csv(item.ChangedPixels.ToString()),
                    Csv(item.ChangeRatio.ToString("0.000000")),
                    Csv(item.AverageChannelDelta.ToString("0.00")),
                    Csv(item.SignificantChange.ToString()),
                    Csv(item.BoundingBox ?? string.Empty),
                    Csv(item.CapturePath ?? string.Empty),
                    Csv(item.DiffPath ?? string.Empty),
                    Csv(item.LogPreview ?? string.Empty),
                    Csv(item.Detail ?? string.Empty)));
            }
        }

        File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);

        var summary = new StringBuilder();
        summary.AppendLine("# Cut Debug Sweep");
        summary.AppendLine();
        summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
        summary.AppendLine($"- Image Root: {options.ImageRootPath}");
        summary.AppendLine($"- Output: {options.OutputPath}");
        summary.AppendLine($"- Mode: {options.Mode}");
        summary.AppendLine($"- Cuts: {results.Count}");
        summary.AppendLine($"- Total Items: {results.Sum(result => result.Items.Count)}");
        summary.AppendLine($"- Changed Items: {results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0))}");
        summary.AppendLine($"- Significant Items: {results.Sum(result => result.Items.Count(item => item.SignificantChange))}");
        summary.AppendLine();

        foreach (var result in results)
        {
            summary.AppendLine($"## `{result.TemplateId}` / `{result.CutName}`");
            summary.AppendLine();
            summary.AppendLine($"- Success: {result.Success}");
            summary.AppendLine($"- Scene: `{result.ScenePath}`");
            summary.AppendLine($"- Items: {result.ItemCount}");
            summary.AppendLine($"- Changed Items: {result.Items.Count(item => item.ChangedPixels > 0)}");
            summary.AppendLine($"- Baseline Noise Pixels: {result.NoiseChangedPixels}");
            summary.AppendLine($"- Significant Threshold Pixels: {result.SignificanceThresholdPixels}");
            summary.AppendLine($"- Detail: {result.Detail}");
            summary.AppendLine();

            var topItems = result.Items
                .OrderByDescending(item => item.SignificantChange)
                .ThenByDescending(item => item.ChangedPixels)
                .Take(20)
                .ToList();

            if (topItems.Count == 0)
            {
                summary.AppendLine("- No item results.");
                summary.AppendLine();
                continue;
            }

            summary.AppendLine("| Key | Kind | Group | Pixels | Ratio | Significant | Bounding Box |");
            summary.AppendLine("| --- | --- | --- | ---: | ---: | --- | --- |");
            foreach (var item in topItems)
            {
                summary.AppendLine($"| `{item.Key}` | `{item.Kind}` | `{item.GroupLabel}` | {item.ChangedPixels} | {item.ChangeRatio:0.000000} | {(item.SignificantChange ? "yes" : "no")} | `{item.BoundingBox ?? string.Empty}` |");
            }

            summary.AppendLine();
        }

        summary.AppendLine("## Files");
        summary.AppendLine();
        summary.AppendLine("- `results.json`");
        summary.AppendLine("- `items.csv`");
        summary.AppendLine("- `*_baseline_A.png`, `*_baseline_B.png`, `*_baseline_diff.png`");
        summary.AppendLine("- `*_{Kind}_{Mode}.png`, `*_diff.png`");

        File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
    }

    private static void WriteCutDebugCoverageReports(CutDebugCoverageOptions options, IReadOnlyList<CutDebugCoverageResult> results)
    {
        var jsonPath = Path.Combine(options.OutputPath, "results.json");
        var csvPath = Path.Combine(options.OutputPath, "results.csv");
        var summaryPath = Path.Combine(options.OutputPath, "summary.md");

        File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);

        var csv = new StringBuilder();
        csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Success,ScenePath,SceneVariableCount,ItemCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount,SampleKeys,Detail");
        foreach (var result in results)
        {
            csv.AppendLine(string.Join(",",
                Csv(result.Index.ToString()),
                Csv(result.TemplateId),
                Csv(result.TemplateName),
                Csv(result.CutName),
                Csv(result.Channel),
                Csv(result.Success.ToString()),
                Csv(result.ScenePath ?? string.Empty),
                Csv(result.SceneVariableCount.ToString()),
                Csv(result.ItemCount.ToString()),
                Csv(result.TextItemCount.ToString()),
                Csv(result.ImageItemCount.ToString()),
                Csv(result.CounterItemCount.ToString()),
                Csv(result.StyleItemCount.ToString()),
                Csv(result.VisibilityItemCount.ToString()),
                Csv(result.SampleKeys ?? string.Empty),
                Csv(result.Detail ?? string.Empty)));
        }

        File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);

        var zeroCoverage = results
            .Where(result => result.Success && result.ItemCount == 0)
            .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
            .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
            .ToList();
        var failures = results
            .Where(result => !result.Success)
            .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
            .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
            .ToList();
        var strongestCoverage = results
            .Where(result => result.Success)
            .OrderByDescending(result => result.ItemCount)
            .ThenBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
            .Take(15)
            .ToList();

        var summary = new StringBuilder();
        summary.AppendLine("# Cut Debug Coverage");
        summary.AppendLine();
        summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
        summary.AppendLine($"- Image Root: {options.ImageRootPath}");
        summary.AppendLine($"- Output: {options.OutputPath}");
        summary.AppendLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
        summary.AppendLine($"- Cuts: {results.Count}");
        summary.AppendLine($"- Zero Coverage: {zeroCoverage.Count}");
        summary.AppendLine($"- Failures: {failures.Count}");
        summary.AppendLine();

        if (zeroCoverage.Count > 0)
        {
            summary.AppendLine("## Zero Coverage");
            summary.AppendLine();
            foreach (var result in zeroCoverage)
            {
                summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`");
            }

            summary.AppendLine();
        }

        if (failures.Count > 0)
        {
            summary.AppendLine("## Failures");
            summary.AppendLine();
            foreach (var result in failures)
            {
                summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Detail}");
            }

            summary.AppendLine();
        }

        if (strongestCoverage.Count > 0)
        {
            summary.AppendLine("## Top Coverage");
            summary.AppendLine();
            foreach (var result in strongestCoverage)
            {
                summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: items={result.ItemCount}, sceneVars={result.SceneVariableCount}");
            }

            summary.AppendLine();
        }

        summary.AppendLine("## Files");
        summary.AppendLine();
        summary.AppendLine("- `results.csv`");
        summary.AppendLine("- `results.json`");

        File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
    }

    private static async Task OutAllAsync(ITornado3Adapter adapter)
    {
        foreach (var channel in new[]
                 {
                     BroadcastChannel.Normal,
                     BroadcastChannel.TopLeft,
                     BroadcastChannel.Bottom,
                     BroadcastChannel.VideoWall
                 })
        {
            try
            {
                await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
            }
            catch
            {
            }
        }
    }

    private static bool ShouldUsePreElectionSnapshot(string templateName)
    {
        return templateName.Contains("투표율", StringComparison.Ordinal) ||
               templateName.StartsWith("사전_", StringComparison.Ordinal);
    }

    private static ElectionDataSnapshot CreateSnapshot(
        string templateName,
        int index,
        int variant,
        bool preElection,
        bool swapTopTwoCandidates,
        bool cycleTopThreeCandidates = false,
        bool stressTopRankValues = false,
        PartyColorFamily? forcedPartyFamily = null)
    {
        var metadata = BuildScenarioMetadata(templateName, index, variant);
        return new ElectionDataSnapshot
        {
            BroadcastPhase = preElection ? BroadcastPhase.PreElection : BroadcastPhase.Counting,
            ElectionType = metadata.ElectionType,
            DistrictName = metadata.DistrictName,
            DistrictCode = metadata.DistrictCode,
            RegionName = metadata.RegionName,
            ElectionDistrictName = metadata.ElectionDistrictName,
            Candidates = preElection
                ? Array.Empty<CandidateEntry>()
                : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates, cycleTopThreeCandidates, stressTopRankValues, forcedPartyFamily),
            TotalExpectedVotes = metadata.TotalExpectedVotes,
            TurnoutVotes = metadata.TurnoutVotes,
            CountedVotesFromApi = metadata.CountedVotes,
            RemainingVotesFromApi = Math.Max(0, metadata.TurnoutVotes - metadata.CountedVotes),
            CountedRateFromApi = metadata.CountedRate,
            ReceivedAt = metadata.ReceivedAt,
            HistoricalTurnoutHistory = CreateHistoricalTurnout(metadata, variant),
            HistoricalWinnerHistory = CreateHistoricalWinners(templateName, variant, forcedPartyFamily),
            TurnoutBoardSlots = CreateTurnoutBoardSlots(metadata, variant),
            NationalTurnoutRateOverride = metadata.NationalTurnoutRate
        };
    }

    private static IReadOnlyList<CandidateEntry> CreateCandidates(
        string templateName,
        ScenarioMetadata metadata,
        int variant,
        bool swapTopTwoCandidates,
        bool cycleTopThreeCandidates,
        bool stressTopRankValues,
        PartyColorFamily? forcedPartyFamily)
    {
        if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(templateName))
        {
            return CreateCouncilSeatCandidates(templateName, metadata, variant, stressTopRankValues);
        }

        var candidateNames = ResolveCandidateNames(templateName);
        var parties = ResolveAuditParties(templateName, candidateNames.Length, forcedPartyFamily);
        var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
        if (stressTopRankValues && variant % 2 == 1)
        {
            shares = StressTopRankShares(shares);
        }

        var automaticJudgement = ResolveAutomaticJudgement(templateName);
        var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();

        if (swapTopTwoCandidates && identityOrder.Length >= 2)
        {
            (identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
        }

        if (cycleTopThreeCandidates && variant % 2 == 1)
        {
            CycleTopCandidateIdentities(identityOrder);
        }

        var candidates = new List<CandidateEntry>(candidateNames.Length);
        for (var index = 0; index < candidateNames.Length; index++)
        {
            var identityIndex = identityOrder[index];
            candidates.Add(new CandidateEntry
            {
                CandidateCode = (index + 1).ToString(),
                Name = candidateNames[identityIndex],
                Party = parties[identityIndex],
                ColorParty = parties[identityIndex],
                VoteRate = shares[index],
                VoteCount = (int)Math.Round(metadata.CountedVotes * shares[index] / 100d, MidpointRounding.AwayFromZero),
                HasImage = true,
                ManualJudgement = CandidateJudgement.None,
                AutomaticJudgement = index == 0 ? automaticJudgement : CandidateJudgement.None
            });
        }

        return candidates;
    }

    private static IReadOnlyList<CandidateEntry> CreateCouncilSeatCandidates(
        string templateName,
        ScenarioMetadata metadata,
        int variant,
        bool stressTopRankValues)
    {
        var parties = ResolveParties(6);
        var totalSeats = ResolveCouncilSeatPattern(templateName, variant, stressTopRankValues)
            .Take(parties.Length)
            .ToArray();
        var proportionalSeats = ResolveCouncilSeatProportionalPattern(templateName, variant, stressTopRankValues)
            .Take(parties.Length)
            .ToArray();
        var candidates = new List<CandidateEntry>(parties.Length * 2);

        for (var partyIndex = 0; partyIndex < parties.Length; partyIndex++)
        {
            var party = parties[partyIndex];
            var proportionalCount = Math.Min(totalSeats[partyIndex], proportionalSeats.ElementAtOrDefault(partyIndex));
            var districtCount = Math.Max(0, totalSeats[partyIndex] - proportionalCount);
            candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:D:{partyIndex + 1:00}", party, partyIndex, districtCount));

            if (proportionalCount > 0)
            {
                candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:P:{partyIndex + 1:00}", party, partyIndex, proportionalCount));
            }
        }

        return candidates;
    }

    private static CandidateEntry CreateCouncilSeatSummaryCandidate(string candidateCode, string party, int partyIndex, int seatCount)
    {
        return new CandidateEntry
        {
            CandidateCode = candidateCode,
            BallotNumber = (partyIndex + 1).ToString(),
            Name = party,
            Party = party,
            ColorParty = party,
            VoteRate = seatCount,
            VoteCount = seatCount,
            HasImage = false,
            ManualJudgement = CandidateJudgement.None,
            AutomaticJudgement = CandidateJudgement.Elected
        };
    }

    private static int[] ResolveCouncilSeatPattern(string templateName, int variant, bool stressTopRankValues)
    {
        if (templateName.Contains("기초의원", StringComparison.Ordinal))
        {
            return stressTopRankValues && variant % 2 == 1
                ? [34, 21, 13, 7, 3, 1]
                : [29, 25, 9, 5, 2, 1];
        }

        return stressTopRankValues && variant % 2 == 1
            ? [22, 17, 9, 4, 2, 1]
            : [18, 15, 6, 3, 1, 1];
    }

    private static int[] ResolveCouncilSeatProportionalPattern(string templateName, int variant, bool stressTopRankValues)
    {
        if (templateName.Contains("기초의원", StringComparison.Ordinal))
        {
            return stressTopRankValues && variant % 2 == 1
                ? [5, 3, 2, 1, 0, 0]
                : [4, 3, 1, 0, 0, 0];
        }

        return stressTopRankValues && variant % 2 == 1
            ? [4, 3, 2, 1, 0, 0]
            : [3, 3, 1, 0, 0, 0];
    }

    private static void CycleTopCandidateIdentities(int[] identityOrder)
    {
        var topCount = Math.Min(3, identityOrder.Length);
        if (topCount < 2)
        {
            return;
        }

        var first = identityOrder[0];
        for (var index = 0; index < topCount - 1; index++)
        {
            identityOrder[index] = identityOrder[index + 1];
        }

        identityOrder[topCount - 1] = first;
    }

    private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
    {
        // The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
        // For automated debug captures, seed the text values so they follow that baked-in graph shape.
        var seeds = string.Equals(metadata.ElectionType, "교육감", StringComparison.Ordinal)
            ? HistoricalTurnoutEducationSeeds
            : HistoricalTurnoutLocalSeeds;
        var entries = new List<PreElectionHistoricalTurnoutEntry>(seeds.Count);
        var variantOffset = variant * 0.5;

        for (var index = 0; index < seeds.Count; index++)
        {
            var seed = seeds[index];
            var electors = Math.Max(1000, metadata.TotalExpectedVotes - 75000 + (index * 25000));
            var rate = Math.Clamp(seed.TurnoutRate + variantOffset, 0.1, 99.9);
            entries.Add(new PreElectionHistoricalTurnoutEntry
            {
                ElectionOrder = seed.ElectionOrder,
                Year = seed.Year,
                Electors = electors,
                Votes = (int)Math.Round(electors * rate / 100d, MidpointRounding.AwayFromZero),
                TurnoutRate = rate
            });
        }

        return entries;
    }

    private static IReadOnlyList<PreElectionHistoricalWinnerEntry> CreateHistoricalWinners(
        string templateName,
        int variant,
        PartyColorFamily? forcedPartyFamily)
    {
        var forcedParty = ResolveAuditPartyName(templateName, forcedPartyFamily);
        string PartyOrDefault(string fallback) =>
            string.IsNullOrWhiteSpace(forcedParty) ? fallback : forcedParty;

        return new[]
        {
            new PreElectionHistoricalWinnerEntry
            {
                ElectionOrder = 5,
                Year = 2010,
                Name = "김민수",
                Party = PartyOrDefault("열린우리당"),
                ColorParty = PartyOrDefault("열린우리당"),
                Note = string.Empty
            },
            new PreElectionHistoricalWinnerEntry
            {
                ElectionOrder = 6,
                Year = 2014,
                Name = "박정우",
                Party = PartyOrDefault("새누리당"),
                ColorParty = PartyOrDefault("새누리당"),
                Note = string.Empty
            },
            new PreElectionHistoricalWinnerEntry
            {
                ElectionOrder = 7,
                Year = 2018,
                Name = "이서연",
                Party = PartyOrDefault("더불어민주당"),
                ColorParty = PartyOrDefault("더불어민주당"),
                Note = string.Empty
            },
            new PreElectionHistoricalWinnerEntry
            {
                ElectionOrder = 8,
                Year = 2022,
                Name = variant == 0 ? "최도윤" : "최서준",
                Party = PartyOrDefault(variant == 0 ? "국민의힘" : "더불어민주당"),
                ColorParty = PartyOrDefault(variant == 0 ? "국민의힘" : "더불어민주당"),
                Note = variant == 0 ? "재선" : "정권교체"
            }
        };
    }

    private static IReadOnlyList<TurnoutBoardSlotEntry> CreateTurnoutBoardSlots(ScenarioMetadata metadata, int variant)
    {
        return new[]
        {
            new TurnoutBoardSlotEntry(1, "전국", metadata.NationalTurnoutRate, true),
            new TurnoutBoardSlotEntry(2, metadata.RegionName, 61.4 + (metadata.TemplateSeed % 5) + variant * 1.7),
            new TurnoutBoardSlotEntry(3, "세종", 59.2 + (metadata.TemplateSeed % 3) + variant * 1.3),
            new TurnoutBoardSlotEntry(4, "충북", 57.8 + (metadata.TemplateSeed % 4) + variant * 1.1),
            new TurnoutBoardSlotEntry(5, "충남", 60.1 + (metadata.TemplateSeed % 2) + variant * 1.5)
        };
    }

    private static string[] ResolveCandidateNames(string templateName)
    {
        if (templateName.Contains("교육감", StringComparison.Ordinal))
        {
            return ["김하늘", "이준호", "박소라"];
        }

        if (templateName.Contains("기초의원", StringComparison.Ordinal))
        {
            return ["김민재", "이소율", "박태훈", "최수빈"];
        }

        if (templateName.Contains("기초단체장", StringComparison.Ordinal))
        {
            return ["윤서진", "강민호", "정다온", "한지후"];
        }

        if (templateName.Contains("광역의원", StringComparison.Ordinal))
        {
            return ["송현우", "배지민", "임서아", "조하람"];
        }

        return ["김하늘", "이준호", "박소라", "최민석", "정서윤"];
    }

    private static string[] ResolveParties(int count)
    {
        return new[] { "더불어민주당", "국민의힘", "조국혁신당", "개혁신당", "무소속" }
            .Take(count)
            .ToArray();
    }

    private static string[] ResolveEducationParties(int count)
    {
        return new[] { "\uC9C4\uBCF4", "\uBCF4\uC218", "\uC911\uB3C4", "\uC9C4\uBCF4", "\uBCF4\uC218" }
            .Take(count)
            .ToArray();
    }

    private static string[] ResolveAuditParties(
        string templateName,
        int count,
        PartyColorFamily? forcedPartyFamily)
    {
        var forcedParty = ResolveAuditPartyName(templateName, forcedPartyFamily);
        if (!string.IsNullOrWhiteSpace(forcedParty))
        {
            return Enumerable.Repeat(forcedParty, count).ToArray();
        }

        return IsEducationTemplateName(templateName)
            ? ResolveEducationParties(count)
            : ResolveParties(count);
    }

    private static string? ResolveAuditPartyName(string templateName, PartyColorFamily? forcedPartyFamily)
    {
        if (forcedPartyFamily is null or PartyColorFamily.Unknown or PartyColorFamily.Mixed)
        {
            return null;
        }

        if (IsEducationTemplateName(templateName))
        {
            return forcedPartyFamily.Value switch
            {
                PartyColorFamily.Green => "진보",
                PartyColorFamily.Red => "보수",
                PartyColorFamily.Blue => "중도",
                _ => null
            };
        }

        return forcedPartyFamily.Value switch
        {
            PartyColorFamily.Red => "국민의힘",
            PartyColorFamily.Blue => "더불어민주당",
            PartyColorFamily.Green => "조국혁신당",
            _ => null
        };
    }

    private static PartyColorFamily? ResolveAuditCandidateFamily(
        string templateName,
        PartyColorFamily family)
    {
        if (family is PartyColorFamily.Unknown or PartyColorFamily.Mixed)
        {
            return null;
        }

        if (!IsEducationTemplateName(templateName) && family == PartyColorFamily.Green)
        {
            return PartyColorFamily.Blue;
        }

        return family;
    }

    private static PartyColorFamily? ResolveOtherAuditCandidateFamily(
        string templateName,
        PartyColorFamily? samePartyFamily)
    {
        if (IsEducationTemplateName(templateName))
        {
            return samePartyFamily switch
            {
                PartyColorFamily.Green => PartyColorFamily.Red,
                PartyColorFamily.Red => PartyColorFamily.Green,
                PartyColorFamily.Blue => PartyColorFamily.Green,
                _ => null
            };
        }

        return samePartyFamily switch
        {
            PartyColorFamily.Red => PartyColorFamily.Blue,
            PartyColorFamily.Blue => PartyColorFamily.Red,
            _ => null
        };
    }

    private static bool IsEducationTemplateName(string templateName)
    {
        return templateName.Contains("\uAD50\uC721\uAC10", StringComparison.Ordinal);
    }

    private static double[] ResolveVoteShares(string templateName, int count, int variant)
    {
        double[] shares = templateName switch
        {
            var name when name.Contains("초접전", StringComparison.Ordinal) => [50.1 + (variant * 0.2), 49.6 - (variant * 0.1), 0.3, 0, 0],
            var name when name.Contains("접전", StringComparison.Ordinal) => [50.9 + (variant * 0.2), 47.9 - (variant * 0.1), 1.2, 0, 0],
            var name when name.Contains("당선", StringComparison.Ordinal) => [56.4 + (variant * 0.5), 31.2 - (variant * 0.2), 8.7, 3.7, 0],
            var name when name.Contains("판세", StringComparison.Ordinal) => [53.1 + (variant * 0.3), 37.6 - (variant * 0.2), 6.5, 2.8, 0],
            var name when name.Contains("이시각1위", StringComparison.Ordinal) => [52.4 + (variant * 0.4), 39.5 - (variant * 0.2), 5.1, 3.0, 0],
            var name when name.Contains("1-3위", StringComparison.Ordinal) => [47.8 + (variant * 0.3), 34.4 + (variant * 0.1), 13.2 - (variant * 0.2), 3.1, 1.5],
            var name when name.Contains("모든후보", StringComparison.Ordinal) || name.Contains("전후보", StringComparison.Ordinal) => [43.9 + (variant * 0.3), 30.5 + (variant * 0.1), 11.8, 7.2, 6.6 - (variant * 0.4)],
            _ => [51.8 + (variant * 0.3), 38.7 - (variant * 0.2), 6.1, 3.4, 0]
        };

        shares = shares.Take(count).ToArray();
        var total = shares.Sum();
        for (var index = 0; index < shares.Length; index++)
        {
            shares[index] = Math.Round(shares[index] * 100d / total, 1, MidpointRounding.AwayFromZero);
        }

        var delta = 100d - shares.Sum();
        shares[0] = Math.Round(shares[0] + delta, 1, MidpointRounding.AwayFromZero);
        return shares;
    }

    private static double[] StressTopRankShares(double[] shares)
    {
        var stressed = shares.ToArray();
        if (stressed.Length >= 3)
        {
            var trailingTotal = Math.Round(stressed.Skip(3).Sum(), 1, MidpointRounding.AwayFromZero);
            var topTotal = Math.Max(0d, 100d - trailingTotal);
            stressed[0] = Math.Round(topTotal * 0.46d, 1, MidpointRounding.AwayFromZero);
            stressed[1] = Math.Round(topTotal * 0.32d, 1, MidpointRounding.AwayFromZero);
            stressed[2] = Math.Round(topTotal - stressed[0] - stressed[1], 1, MidpointRounding.AwayFromZero);
        }
        else if (stressed.Length == 2)
        {
            stressed[0] = 62.7d;
            stressed[1] = 37.3d;
        }
        else if (stressed.Length == 1)
        {
            stressed[0] = 99.9d;
        }

        var delta = Math.Round(100d - stressed.Sum(), 1, MidpointRounding.AwayFromZero);
        if (stressed.Length > 0)
        {
            stressed[0] = Math.Round(stressed[0] + delta, 1, MidpointRounding.AwayFromZero);
        }

        return stressed;
    }

    private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
    {
        if (templateName.Contains("당선", StringComparison.Ordinal))
        {
            return CandidateJudgement.Elected;
        }

        if (templateName.Contains("판세", StringComparison.Ordinal) ||
            templateName.Contains("이시각1위", StringComparison.Ordinal))
        {
            return CandidateJudgement.Leading;
        }

        if (templateName.Contains("접전", StringComparison.Ordinal) ||
            templateName.Contains("초접전", StringComparison.Ordinal))
        {
            return CandidateJudgement.None;
        }

        return CandidateJudgement.Leading;
    }

    private static ScenarioMetadata BuildScenarioMetadata(string templateName, int index, int variant)
    {
        var seed = index + 1;
        var totalExpectedVotes = 1_800_000 + (seed * 12_500);
        var turnoutVotes = 1_050_000 + (seed * 7_500) + (variant * 31_000);
        var countedRate = Math.Min(97.9, 68.4 + ((seed % 6) * 2.1) + (variant * 6.4));
        var countedVotes = (int)Math.Round(turnoutVotes * countedRate / 100d, MidpointRounding.AwayFromZero);

        if (templateName.Contains("교육감", StringComparison.Ordinal))
        {
            return new ScenarioMetadata("교육감", "30", "대전광역시", "대전", "대전광역시교육감", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 58.7 + (seed % 4) + (variant * 1.9), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
        }

        if (templateName.Contains("기초의원", StringComparison.Ordinal))
        {
            return new ScenarioMetadata("기초의원", "44131", "천안시 가선거구", "충남", "천안시의원 가선거구", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 54.8 + (seed % 5) + (variant * 1.7), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
        }

        if (templateName.Contains("기초단체장", StringComparison.Ordinal))
        {
            return new ScenarioMetadata("기초단체장", "44131", "천안시", "충남", "천안시장", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.1 + (seed % 4) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
        }

        if (templateName.Contains("광역의원", StringComparison.Ordinal))
        {
            return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
        }

        if (templateName.Contains("보궐선거", StringComparison.Ordinal))
        {
            return new ScenarioMetadata("국회의원", "2411502", "평택 을", "경기", "평택 을", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.5 + (seed % 4) + (variant * 1.5), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
        }

        return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
    }

    private static string CapturePgm(PgmWindow? pgmWindow, string outputPath, bool skipCapture)
    {
        if (skipCapture || pgmWindow is null)
        {
            return string.Empty;
        }

        Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
        using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Handle, pgmWindow.Value.Bounds);
        bitmap.Save(outputPath, ImageFormat.Png);
        return ComputeSha256(outputPath);
    }

    private static async Task<string> CaptureValidationImageAsync(
        ITornado3Adapter adapter,
        PgmWindow? pgmWindow,
        LiveCutWorkItem item,
        LiveCutValidationOptions options,
        string outputPath,
        bool outputVisibleInPgm,
        CancellationToken cancellationToken)
    {
        if (options.CaptureMode == LiveCutCaptureMode.Pgm)
        {
            return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm);
        }

        if (adapter is not KarismaTornado3Adapter karismaAdapter)
        {
            throw new InvalidOperationException("Scene capture mode requires the Karisma adapter.");
        }

        var (width, height) = ResolveSceneCaptureSize(item.Template);
        var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut);
        await karismaAdapter.SavePendingSceneImageAsync(
                item.Template.RecommendedChannel,
                outputPath,
                width,
                height,
                frame,
                cancellationToken: cancellationToken)
            .ConfigureAwait(false);

        return ComputeSha256(outputPath);
    }

    private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
    {
        var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
        var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
        if (sourceWidth <= 0 || sourceHeight <= 0)
        {
            return (1280, 720);
        }

        const int maxWidth = 1280;
        if (sourceWidth <= maxWidth)
        {
            return (sourceWidth, sourceHeight);
        }

        var scale = maxWidth / (double)sourceWidth;
        return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
    }

    private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
    {
        var width = Math.Max(1, bounds.Width);
        var height = Math.Max(1, bounds.Height);
        var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
        using var graphics = Graphics.FromImage(bitmap);

        graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
        return bitmap;
    }

    private static string ComputeSha256(string path)
    {
        using var stream = File.OpenRead(path);
        using var sha256 = SHA256.Create();
        return Convert.ToHexString(sha256.ComputeHash(stream));
    }

    private static PgmWindow? TryFindPgmWindow()
    {
        var process = Process.GetProcessesByName("Tornado3")
            .FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
        if (process is null || process.MainWindowHandle == IntPtr.Zero)
        {
            return null;
        }

        if (!GetWindowRect(process.MainWindowHandle, out var rect))
        {
            return null;
        }

        return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
    }

    private static void WriteReports(LiveCutValidationOptions options, IReadOnlyList<LiveCutValidationResult> results)
    {
        var csvPath = Path.Combine(options.OutputPath, "results.csv");
        var jsonPath = Path.Combine(options.OutputPath, "results.json");
        var summaryPath = Path.Combine(options.OutputPath, "summary.md");

        var csv = new StringBuilder();
        csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,CaptureMode,CaptureComparable,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
        foreach (var result in results)
        {
            csv.AppendLine(string.Join(",",
                Csv(result.Index.ToString()),
                Csv(result.TemplateId),
                Csv(result.TemplateName),
                Csv(result.CutName),
                Csv(result.Channel),
                Csv(result.Phase),
                Csv(result.CaptureMode),
                Csv(result.CaptureComparable.ToString()),
                Csv(result.Success.ToString()),
                Csv(result.VisualChanged.ToString()),
                Csv(result.OutputVisibleInPgm.ToString()),
                Csv(result.CaptureAPath ?? string.Empty),
                Csv(result.CaptureBPath ?? string.Empty),
                Csv(result.HashA ?? string.Empty),
                Csv(result.HashB ?? string.Empty),
                Csv(result.Detail ?? string.Empty)));
        }

        File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
        File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);

        var successCount = results.Count(result => result.Success);
        var changedCount = results.Count(result => result.Success && result.VisualChanged);
        var unchanged = results.Where(result => result.Success && result.CaptureComparable && !result.VisualChanged).ToList();
        var failures = results.Where(result => !result.Success).ToList();

        var summary = new StringBuilder();
        summary.AppendLine("# Live Cut Validation");
        summary.AppendLine();
        summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
        summary.AppendLine($"- Image Root: {options.ImageRootPath}");
        summary.AppendLine($"- Output: {options.OutputPath}");
        summary.AppendLine($"- Capture Mode: {options.CaptureMode}");
        summary.AppendLine($"- Success: {successCount}/{results.Count}");
        summary.AppendLine($"- Visual Changed: {changedCount}");
        summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
        summary.AppendLine($"- Failures: {failures.Count}");
        summary.AppendLine();

        if (unchanged.Count > 0)
        {
            summary.AppendLine("## Unchanged Captures");
            summary.AppendLine();
            foreach (var result in unchanged)
            {
                summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
            }

            summary.AppendLine();
        }

        if (failures.Count > 0)
        {
            summary.AppendLine("## Failures");
            summary.AppendLine();
            foreach (var result in failures)
            {
                summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
            }

            summary.AppendLine();
        }

        summary.AppendLine("## Files");
        summary.AppendLine();
        summary.AppendLine("- `results.csv`");
        summary.AppendLine("- `results.json`");
        summary.AppendLine("- `*_A.png`, `*_B.png`");

        File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
    }

    private static string Csv(string value)
    {
        return $"\"{value.Replace("\"", "\"\"")}\"";
    }

    private static List<LiveCutWorkItem> ApplyTemplateFilter(IEnumerable<LiveCutWorkItem> items, string? filter)
    {
        var list = items.ToList();
        if (string.IsNullOrWhiteSpace(filter))
        {
            return list;
        }

        var exactMatches = list
            .Where(item =>
                string.Equals(item.Template.Id, filter, StringComparison.OrdinalIgnoreCase) ||
                string.Equals(item.Template.Name, filter, StringComparison.OrdinalIgnoreCase))
            .ToList();

        if (exactMatches.Count > 0)
        {
            return exactMatches;
        }

        return list
            .Where(item =>
                item.Template.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
                item.Template.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
            .ToList();
    }

    private static List<LiveCutWorkItem> ApplyCutFilter(IEnumerable<LiveCutWorkItem> items, string? cutName)
    {
        var list = items.ToList();
        if (string.IsNullOrWhiteSpace(cutName))
        {
            return list;
        }

        var exactMatches = list
            .Where(item => string.Equals(item.Cut.Name, cutName, StringComparison.OrdinalIgnoreCase))
            .ToList();

        if (exactMatches.Count > 0)
        {
            return exactMatches;
        }

        return list
            .Where(item => item.Cut.Name.Contains(cutName, StringComparison.OrdinalIgnoreCase))
            .ToList();
    }

    private static string SanitizeFileName(string value)
    {
        var invalidChars = Path.GetInvalidFileNameChars();
        var builder = new StringBuilder(value.Length);
        foreach (var character in value)
        {
            builder.Append(invalidChars.Contains(character) ? '_' : character);
        }

        var sanitized = builder.ToString().Trim();
        if (sanitized.Length > 80)
        {
            sanitized = sanitized[..80];
        }

        return string.IsNullOrWhiteSpace(sanitized) ? "cut" : sanitized;
    }

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);

    private sealed record LiveCutValidationOptions
    {
        public string ImageRootPath { get; init; } = string.Empty;
        public string OutputPath { get; init; } = string.Empty;
        public string StationLogoPath { get; init; } = string.Empty;
        public string? Filter { get; init; }
        public int? Limit { get; init; }
        public int OnAirDelayMs { get; init; } = 900;
        public int BetweenDelayMs { get; init; } = 250;
        public bool IncludeVideoWall { get; init; }
        public bool SwapTopTwoCandidates { get; init; }
        public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Pgm;
        public bool CycleTopThreeCandidates { get; init; }
        public bool StressTopRankValues { get; init; }

        public static LiveCutValidationOptions Parse(string[] args)
        {
            var repoRoot = Environment.CurrentDirectory;
            var options = new LiveCutValidationOptions
            {
                ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
                OutputPath = Path.Combine(repoRoot, "artifacts", "live-cut-validation", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
                StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
            };

            for (var index = 0; index < args.Length; index++)
            {
                switch (args[index])
                {
                    case "--image-root":
                        _ = RequireValue(args, ref index, "--image-root");
                        break;
                    case "--output":
                        options = options with { OutputPath = RequireValue(args, ref index, "--output") };
                        break;
                    case "--filter":
                        options = options with { Filter = RequireValue(args, ref index, "--filter") };
                        break;
                    case "--limit":
                        options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
                        break;
                    case "--onair-delay-ms":
                        options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
                        break;
                    case "--between-delay-ms":
                        options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
                        break;
                    case "--include-videowall":
                        options = options with { IncludeVideoWall = true };
                        break;
                    case "--swap-top-two":
                        options = options with { SwapTopTwoCandidates = true };
                        break;
                    case "--cycle-top-three":
                        options = options with { CycleTopThreeCandidates = true };
                        break;
                    case "--stress-top-ranks":
                        options = options with { StressTopRankValues = true };
                        break;
                    case "--capture-mode":
                        options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) };
                        break;
                    default:
                        throw new ArgumentException($"Unknown option: {args[index]}");
                }
            }

            return options with
            {
                ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
                OutputPath = Path.GetFullPath(options.OutputPath),
                StationLogoPath = Path.GetFullPath(options.StationLogoPath)
            };
        }

        private static string RequireValue(string[] args, ref int index, string optionName)
        {
            index++;
            if (index >= args.Length)
            {
                throw new ArgumentException($"Missing value for {optionName}.");
            }

            return args[index];
        }

        private static LiveCutCaptureMode ParseCaptureMode(string raw)
        {
            return raw.Trim().ToLowerInvariant() switch
            {
                "pgm" or "window" => LiveCutCaptureMode.Pgm,
                "scene" or "save-scene-image" => LiveCutCaptureMode.Scene,
                _ => throw new ArgumentException($"Unknown capture mode: {raw}")
            };
        }
    }

    private sealed record PartyColorAuditOptions
    {
        public string ImageRootPath { get; init; } = string.Empty;
        public string OutputPath { get; init; } = string.Empty;
        public string StationLogoPath { get; init; } = string.Empty;
        public string? Filter { get; init; }
        public string? CutName { get; init; }
        public int? Limit { get; init; }
        public int OnAirDelayMs { get; init; } = 900;
        public int BetweenDelayMs { get; init; } = 250;
        public bool IncludeVideoWall { get; init; }
        public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Scene;

        public static PartyColorAuditOptions Parse(string[] args)
        {
            var repoRoot = Environment.CurrentDirectory;
            var options = new PartyColorAuditOptions
            {
                ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
                OutputPath = Path.Combine(repoRoot, "artifacts", "party-color-live-audit", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
                StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
            };

            for (var index = 0; index < args.Length; index++)
            {
                switch (args[index])
                {
                    case "--image-root":
                        options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
                        break;
                    case "--output":
                        options = options with { OutputPath = RequireValue(args, ref index, "--output") };
                        break;
                    case "--filter":
                        options = options with { Filter = RequireValue(args, ref index, "--filter") };
                        break;
                    case "--cut-name":
                        options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
                        break;
                    case "--limit":
                        options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
                        break;
                    case "--onair-delay-ms":
                        options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
                        break;
                    case "--between-delay-ms":
                        options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
                        break;
                    case "--include-videowall":
                        options = options with { IncludeVideoWall = true };
                        break;
                    case "--capture-mode":
                        options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) };
                        break;
                    default:
                        throw new ArgumentException($"Unknown option: {args[index]}");
                }
            }

            return options with
            {
                ImageRootPath = Path.GetFullPath(options.ImageRootPath),
                OutputPath = Path.GetFullPath(options.OutputPath),
                StationLogoPath = Path.GetFullPath(options.StationLogoPath)
            };
        }

        private static string RequireValue(string[] args, ref int index, string optionName)
        {
            index++;
            if (index >= args.Length)
            {
                throw new ArgumentException($"Missing value for {optionName}.");
            }

            return args[index];
        }

        private static LiveCutCaptureMode ParseCaptureMode(string raw)
        {
            return raw.Trim().ToLowerInvariant() switch
            {
                "pgm" or "window" => LiveCutCaptureMode.Pgm,
                "scene" or "save-scene-image" => LiveCutCaptureMode.Scene,
                _ => throw new ArgumentException($"Unknown capture mode: {raw}")
            };
        }
    }

    private sealed record CutDebugCoverageOptions
    {
        public string ImageRootPath { get; init; } = string.Empty;
        public string OutputPath { get; init; } = string.Empty;
        public string? Filter { get; init; }
        public string? CutName { get; init; }
        public int? Limit { get; init; }
        public bool IncludeVideoWall { get; init; }
        public bool ExcludeHistoricalTurnout { get; init; }

        public static CutDebugCoverageOptions Parse(string[] args)
        {
            var repoRoot = Environment.CurrentDirectory;
            var options = new CutDebugCoverageOptions
            {
                ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
                OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-coverage", DateTime.Now.ToString("yyyyMMdd_HHmmss"))
            };

            for (var index = 0; index < args.Length; index++)
            {
                switch (args[index])
                {
                    case "--image-root":
                        _ = RequireValue(args, ref index, "--image-root");
                        break;
                    case "--output":
                        options = options with { OutputPath = RequireValue(args, ref index, "--output") };
                        break;
                    case "--filter":
                        options = options with { Filter = RequireValue(args, ref index, "--filter") };
                        break;
                    case "--cut-name":
                        options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
                        break;
                    case "--limit":
                        options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
                        break;
                    case "--include-videowall":
                        options = options with { IncludeVideoWall = true };
                        break;
                    case "--exclude-historical-turnout":
                        options = options with { ExcludeHistoricalTurnout = true };
                        break;
                    default:
                        throw new ArgumentException($"Unknown option: {args[index]}");
                }
            }

            return options with
            {
                ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
                OutputPath = Path.GetFullPath(options.OutputPath)
            };
        }

        private static string RequireValue(string[] args, ref int index, string optionName)
        {
            index++;
            if (index >= args.Length)
            {
                throw new ArgumentException($"Missing value for {optionName}.");
            }

            return args[index];
        }
    }

    private sealed record CutDebugSweepOptions
    {
        public string ImageRootPath { get; init; } = string.Empty;
        public string OutputPath { get; init; } = string.Empty;
        public string StationLogoPath { get; init; } = string.Empty;
        public string? Filter { get; init; }
        public string? CutName { get; init; }
        public int? Limit { get; init; }
        public int? MaxItems { get; init; }
        public int OnAirDelayMs { get; init; } = 1200;
        public int BetweenDelayMs { get; init; } = 300;
        public bool IncludeVideoWall { get; init; }
        public bool ExcludeHistoricalTurnout { get; init; }
        public string? KindFilter { get; init; }
        public string? KeyFilter { get; init; }
        public CutDebugSweepMode Mode { get; init; } = CutDebugSweepMode.Disable;
        public bool SwapTopTwoCandidates { get; init; }

        public static CutDebugSweepOptions Parse(string[] args)
        {
            var repoRoot = Environment.CurrentDirectory;
            var options = new CutDebugSweepOptions
            {
                ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
                OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-sweep", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
                StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
            };

            for (var index = 0; index < args.Length; index++)
            {
                switch (args[index])
                {
                    case "--image-root":
                        _ = RequireValue(args, ref index, "--image-root");
                        break;
                    case "--output":
                        options = options with { OutputPath = RequireValue(args, ref index, "--output") };
                        break;
                    case "--station-logo":
                        options = options with { StationLogoPath = RequireValue(args, ref index, "--station-logo") };
                        break;
                    case "--filter":
                        options = options with { Filter = RequireValue(args, ref index, "--filter") };
                        break;
                    case "--cut-name":
                        options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
                        break;
                    case "--limit":
                        options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
                        break;
                    case "--max-items":
                        options = options with { MaxItems = int.Parse(RequireValue(args, ref index, "--max-items")) };
                        break;
                    case "--onair-delay-ms":
                        options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
                        break;
                    case "--between-delay-ms":
                        options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
                        break;
                    case "--include-videowall":
                        options = options with { IncludeVideoWall = true };
                        break;
                    case "--exclude-historical-turnout":
                        options = options with { ExcludeHistoricalTurnout = true };
                        break;
                    case "--swap-top-two":
                        options = options with { SwapTopTwoCandidates = true };
                        break;
                    case "--kind":
                        options = options with { KindFilter = RequireValue(args, ref index, "--kind") };
                        break;
                    case "--key":
                        options = options with { KeyFilter = RequireValue(args, ref index, "--key") };
                        break;
                    case "--mode":
                        options = options with { Mode = ParseMode(RequireValue(args, ref index, "--mode")) };
                        break;
                    default:
                        throw new ArgumentException($"Unknown option: {args[index]}");
                }
            }

            return options with
            {
                ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
                OutputPath = Path.GetFullPath(options.OutputPath),
                StationLogoPath = Path.GetFullPath(options.StationLogoPath)
            };
        }

        public bool IncludeItem(string key, CutDebugItemKind kind)
        {
            if (!string.IsNullOrWhiteSpace(KeyFilter) &&
                !key.Contains(KeyFilter, StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            if (!string.IsNullOrWhiteSpace(KindFilter) &&
                !string.Equals(kind.ToString(), KindFilter, StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            return true;
        }

        private static CutDebugSweepMode ParseMode(string raw)
        {
            return raw.ToLowerInvariant() switch
            {
                "off" or "disable" => CutDebugSweepMode.Disable,
                "replace" or "override" => CutDebugSweepMode.Replace,
                _ => throw new ArgumentException($"Unknown sweep mode: {raw}")
            };
        }

        private static string RequireValue(string[] args, ref int index, string optionName)
        {
            index++;
            if (index >= args.Length)
            {
                throw new ArgumentException($"Missing value for {optionName}.");
            }

            return args[index];
        }
    }

    private enum CutDebugSweepMode
    {
        Disable,
        Replace
    }

    private enum LiveCutCaptureMode
    {
        Pgm,
        Scene
    }

    private enum PartyColorFamily
    {
        Unknown,
        Mixed,
        Red,
        Blue,
        Green
    }

    private const string PartyColorAuditSuspectedCodeLocation =
        "Tornado3_2026Election/Services/KarismaTornado3Adapter.cs: ResolveCandidateAssetColorParty, AddStyleColorUpdates, SetOptionalAssetAliasesUnlessStyleBound; Tornado3_2026Election/Services/PartyColorCatalog.cs: TryResolveStyleColor, ResolveFallbackAssetPath";

    private const string PartyColorAuditFixTarget =
        "KarismaTornado3Adapter.ResolveCandidateAssetColorParty / AddStyleColorUpdates / PartyColorCatalog rgb mapping";

    private readonly record struct PartyColorAuditTargetDecision(bool Include, string Reason);

    private readonly record struct AuditKarismaBinding(int OutputChannelIndex, int LayerNo);

    private readonly record struct AuditComparison(string Status, string Detail);

    private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);

    private sealed class PartyColorStats
    {
        public PartyColorFamily DominantFamily { get; init; }
        public int RedPixels { get; init; }
        public int BluePixels { get; init; }
        public int GreenPixels { get; init; }
        public int ColoredPixels { get; init; }
    }

    private sealed class CutDebugSweepResult
    {
        public int Index { get; init; }
        public string TemplateId { get; init; } = string.Empty;
        public string TemplateName { get; init; } = string.Empty;
        public string CutName { get; init; } = string.Empty;
        public string Channel { get; init; } = string.Empty;
        public string ScenePath { get; set; } = string.Empty;
        public bool Success { get; set; }
        public int ItemCount { get; set; }
        public string? BaselineAPath { get; set; }
        public string? BaselineBPath { get; set; }
        public string? BaselineHash { get; set; }
        public string? NoiseDiffPath { get; set; }
        public int NoiseChangedPixels { get; set; }
        public int SignificanceThresholdPixels { get; set; }
        public List<CutDebugItemSweepResult> Items { get; } = [];
        public string? Detail { get; set; }
    }

    private sealed class CutDebugCoverageResult
    {
        public int Index { get; init; }
        public string TemplateId { get; init; } = string.Empty;
        public string TemplateName { get; init; } = string.Empty;
        public string CutName { get; init; } = string.Empty;
        public string Channel { get; init; } = string.Empty;
        public string? ScenePath { get; set; }
        public bool Success { get; set; }
        public bool ExcludedHistoricalTurnout { get; set; }
        public int SceneVariableCount { get; set; }
        public int ItemCount { get; set; }
        public int TextItemCount { get; set; }
        public int ImageItemCount { get; set; }
        public int CounterItemCount { get; set; }
        public int StyleItemCount { get; set; }
        public int VisibilityItemCount { get; set; }
        public string? SampleKeys { get; set; }
        public string? Detail { get; set; }
    }

    private sealed class PartyColorAuditResult
    {
        public int Index { get; init; }
        public string TemplateId { get; init; } = string.Empty;
        public string TemplateName { get; init; } = string.Empty;
        public string CutName { get; init; } = string.Empty;
        public string Channel { get; init; } = string.Empty;
        public string CaptureMode { get; init; } = string.Empty;
        public string? ScenePath { get; set; }
        public int CaptureFrame { get; set; }
        public bool VerificationTarget { get; set; }
        public string? ExclusionReason { get; set; }
        public string? TargetReason { get; set; }
        public int SceneVariableCount { get; set; }
        public int TextItemCount { get; set; }
        public int ImageItemCount { get; set; }
        public int CounterItemCount { get; set; }
        public int StyleItemCount { get; set; }
        public int VisibilityItemCount { get; set; }
        public string? Step1OriginalCapturePath { get; set; }
        public string? Step2SamePartyCapturePath { get; set; }
        public string? Step3OtherPartyCapturePath { get; set; }
        public string? Step1VsStep2DiffPath { get; set; }
        public string? Step2VsStep3DiffPath { get; set; }
        public string? Step1Hash { get; set; }
        public string? Step2Hash { get; set; }
        public string? Step3Hash { get; set; }
        public PartyColorStats? Step1ColorStats { get; set; }
        public PartyColorStats? Step2ColorStats { get; set; }
        public PartyColorStats? Step3ColorStats { get; set; }
        public string? Step1VsStep2Result { get; set; }
        public string? Step2VsStep3Result { get; set; }
        public int Step1VsStep2ChangedPixels { get; set; }
        public double Step1VsStep2ChangeRatio { get; set; }
        public int Step2VsStep3ChangedPixels { get; set; }
        public double Step2VsStep3ChangeRatio { get; set; }
        public string Verdict { get; set; } = string.Empty;
        public bool ReviewRequired { get; set; }
        public string? Detail { get; set; }
        public string? SuspectedCodeLocation { get; set; }
        public string? FixTarget { get; set; }
    }

    private sealed class CutDebugItemSweepResult
    {
        public string Key { get; init; } = string.Empty;
        public string Kind { get; init; } = string.Empty;
        public string GroupLabel { get; init; } = string.Empty;
        public string? CapturePath { get; init; }
        public string? DiffPath { get; init; }
        public string? Hash { get; init; }
        public int ChangedPixels { get; init; }
        public double ChangeRatio { get; init; }
        public double AverageChannelDelta { get; init; }
        public bool SignificantChange { get; init; }
        public string? BoundingBox { get; init; }
        public string? LogPreview { get; init; }
        public string? Detail { get; init; }
    }

    private readonly record struct SweepCaptureResult(string Path, string Hash);

    private readonly record struct ImageDiffResult(
        int ChangedPixels,
        double ChangeRatio,
        double AverageChannelDelta,
        string? BoundingBox);

    private sealed class LiveCutValidationResult
    {
        public int Index { get; init; }
        public string TemplateId { get; init; } = string.Empty;
        public string TemplateName { get; init; } = string.Empty;
        public string CutName { get; init; } = string.Empty;
        public string Channel { get; init; } = string.Empty;
        public string Phase { get; init; } = string.Empty;
        public string CaptureMode { get; set; } = string.Empty;
        public bool CaptureComparable { get; set; }
        public bool Success { get; set; }
        public bool VisualChanged { get; set; }
        public bool OutputVisibleInPgm { get; set; }
        public string? CaptureAPath { get; set; }
        public string? CaptureBPath { get; set; }
        public string? HashA { get; set; }
        public string? HashB { get; set; }
        public string? Detail { get; set; }
    }

    private readonly record struct LiveCutWorkItem(FormatTemplateDefinition Template, FormatCutDefinition Cut);

    private readonly record struct ScenarioMetadata(
        string ElectionType,
        string DistrictCode,
        string DistrictName,
        string RegionName,
        string ElectionDistrictName,
        int TotalExpectedVotes,
        int TurnoutVotes,
        int CountedVotes,
        double CountedRate,
        double NationalTurnoutRate,
        DateTimeOffset ReceivedAt,
        int TemplateSeed);

    private readonly record struct HistoricalTurnoutSeed(
        int ElectionOrder,
        int Year,
        double TurnoutRate);

    private static readonly IReadOnlyList<HistoricalTurnoutSeed> HistoricalTurnoutLocalSeeds =
    [
        new(3, 2002, 55.4),
        new(4, 2006, 42.8),
        new(5, 2010, 56.9),
        new(6, 2014, 45.1),
        new(7, 2018, 45.8),
        new(8, 2022, 55.7)
    ];

    private static readonly IReadOnlyList<HistoricalTurnoutSeed> HistoricalTurnoutEducationSeeds =
    [
        new(5, 2010, 53.6),
        new(6, 2014, 47.9),
        new(7, 2018, 56.1),
        new(8, 2022, 50.4)
    ];

    private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds);

    private readonly record struct Rect(int Left, int Top, int Width, int Height);

    [StructLayout(LayoutKind.Sequential)]
    private readonly struct NativeRect
    {
        public int Left { get; init; }
        public int Top { get; init; }
        public int Right { get; init; }
        public int Bottom { get; init; }
    }
}
