using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
using Tornado3_2026Election.Domain;

namespace Tornado3_2026Election.Services;

public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
    private const int DefaultKarismaPort = 30001;
    private const int DefaultCandidateSlotClearCount = 8;
    private const int DefaultTurnoutSlotClearCount = 7;
    private const string HistoricalTurnoutChartObjectName = "차트01";
    private const string HistoricalTurnoutCircleObjectPrefix = "원";
    private const string HistoricalTurnoutGroupObjectPrefix = "그룹";
    private const int HistoricalTurnoutChartCellRowCount = 8;
    private const int HistoricalTurnoutSlotCount = 6;
    private const float HistoricalTurnoutGroupBaseY = 2.5716248f;
    // Scene-local Y scale: 100% stays at the template's top group position; lower rates move downward.
    private const float HistoricalTurnoutYUnitsPerPercent = 6.32f;
    private const int HistoricalTurnoutLastPositionKeyIndex = 1;
    private const int NormalPreElectionTurnoutBarPositionKeyIndex = 1;
    private const float NormalPreElectionTurnoutBarEmptyY = -547.2f;
    private const float NormalPreElectionTurnoutBarFullY = -54.2f;
    private const int DefaultCouncilSeatSlotCount = 6;
    private const int CouncilSeatBarCropKeyIndex = 1;
    private const float CouncilSeatBarCropMin = 0f;
    private const float CouncilSeatBarCropMax = 100f;
    private static readonly float[] HistoricalWinnerSlotBaseX =
    [
        -753.63f,
        -538.78f,
        -323.92f,
        -109.06f,
        105.80f,
        320.66f,
        535.51f,
        750.37f
    ];
    private const int DefaultTopPanseSlotCount = 3;
    private const int DefaultNormalPanseMapSlotCount = 6;
    private const string CouncilSeatCandidateCodePrefix = "SEAT:";
    private const string PanseSummaryCandidateCodePrefix = "PANSE:";
    private const string PanseDemocraticPartyLabel = "더불어민주당";
    private const string PansePeoplePowerPartyLabel = "국민의힘";
    private const string PanseOtherPartyLabel = "무·기타";
    private static readonly string[] CandidatePhotoExtensions = [".png", ".jpg", ".jpeg", ".webp"];
    private static readonly IReadOnlyDictionary<Guid, int> EmptyCandidateRankMap = new Dictionary<Guid, int>();
    private static readonly string[] CandidateSlotVariablePrefixes =
    [
        "순위",
        "후보명",
        "정당명",
        "득표수",
        "득표율",
        "표차",
        "득표차",
        "유확당",
        "후보사진",
        "득표수바",
        "정당바",
        "정당판",
        "정당원",
        "정당색",
        "정당심볼",
        "그룹"
    ];
    private static readonly string[] TurnoutSlotVariablePrefixes =
    [
        "그룹",
        "바",
        "투표율",
        "선거구명",
        "시도명"
    ];
    private static readonly string[] CouncilSeatObjectSuffixes = ["A", "B", "C"];
    private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
    private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
    private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["서울"] = "서울특별시",
            ["부산"] = "부산광역시",
            ["대구"] = "대구광역시",
            ["인천"] = "인천광역시",
            ["광주"] = "광주광역시",
            ["대전"] = "대전광역시",
            ["울산"] = "울산광역시",
            ["세종"] = "세종특별자치시",
            ["경기"] = "경기도",
            ["강원"] = "강원특별자치도",
            ["충북"] = "충청북도",
            ["충남"] = "충청남도",
            ["전북"] = "전북특별자치도",
            ["전남"] = "전라남도",
            ["경북"] = "경상북도",
            ["경남"] = "경상남도",
            ["제주"] = "제주특별자치도"
        };
    private static readonly IReadOnlyDictionary<string, string> NormalTurnoutProvinceSceneNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["부산"] = "투표율_02_부산",
            ["대구"] = "투표율_03_대구",
            ["광주"] = "투표율_05_전남광주",
            ["전남"] = "투표율_05_전남광주",
            ["대전"] = "투표율_06_대전",
            ["세종"] = "투표율_07_세종",
            ["울산"] = "투표율_08_울산",
            ["강원"] = "투표율_10_강원",
            ["충남"] = "투표율_12_충남",
            ["전북"] = "투표율_14_전북",
            ["경북"] = "투표율_15_경북",
            ["경남"] = "투표율_16_경남"
        };
    private static readonly IReadOnlyDictionary<string, string> NormalRankProvinceSceneNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["부산"] = "시도별_02_부산",
            ["대구"] = "시도별_03_대구",
            ["광주"] = "시도별_05_전남광주",
            ["전남"] = "시도별_05_전남광주",
            ["대전"] = "시도별_06_대전",
            ["세종"] = "시도별_07_세종",
            ["울산"] = "시도별_08_울산",
            ["강원"] = "시도별_10_강원",
            ["충남"] = "시도별_12_충남",
            ["전북"] = "시도별_14_전북",
            ["경북"] = "시도별_15_경북",
            ["경남"] = "시도별_16_경남"
        };
    private static readonly IReadOnlyDictionary<string, string> NormalLandmarkVideoFileNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["부산"] = "02_부산.mp4",
            ["대구"] = "03_대구.mp4",
            ["광주"] = "05_광주.mp4",
            ["전남"] = "05_전남.mp4",
            ["대전"] = "06_대전.mp4",
            ["세종"] = "07_세종.mp4",
            ["울산"] = "08_울산.mp4",
            ["강원"] = "10_강원.mp4",
            ["충남"] = "12_충남.mp4",
            ["전북"] = "14_전북.mp4",
            ["경북"] = "15_경북.mp4",
            ["경남"] = "16_경남.mp4"
        };
    private static readonly IReadOnlyDictionary<string, string> NormalLandmarkVideoSourceFileNames =
        new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["광주"] = "05_전남광주.mp4",
            ["전남"] = "05_전남광주.mp4"
        };
    private static readonly string[] NormalPanseMapRegions =
    [
        "서울",
        "부산",
        "대구",
        "인천",
        "광주",
        "대전",
        "울산",
        "세종",
        "경기",
        "강원",
        "충북",
        "충남",
        "전북",
        "전남",
        "경북",
        "경남",
        "제주"
    ];
    private static readonly NormalPansePartySlot[] NormalPanseMapPartySlots =
    [
        new("더불어민주당", "더불어민주당"),
        new("국민의힘", "국민의힘"),
        new("조국혁신당", "조국혁신당"),
        new("개혁신당", "개혁신당"),
        new("진보당", "진보당"),
        new("무·기타", "무기타")
    ];

    private readonly TornadoManager _manager;
    private readonly LogService _logService;
    private readonly Func<string> _t3CutPathProvider;
    private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
    private readonly CutDebugStateStore _cutDebugStateStore;
    private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
    private readonly Func<BroadcastChannel, int>? _channelLayerProvider;
    private readonly string _connectionTarget;
    private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
    private readonly Dictionary<BroadcastChannel, bool> _pendingEndScenes = new();
    private readonly Dictionary<BroadcastChannel, KarismaChannelBinding> _pendingBindings = new();
    private readonly Dictionary<BroadcastChannel, KarismaChannelBinding> _activeBindings = new();
    private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
    private readonly Dictionary<BroadcastChannel, bool> _preparedFirstFramePaused = new();
    private TornadoConnectionState _state = TornadoConnectionState.Idle;
    private bool _disposed;

    private KarismaTornado3Adapter(
        TornadoManager manager,
        LogService logService,
        Func<string> t3CutPathProvider,
        KarismaSceneVariableCatalog sceneVariableCatalog,
        CutDebugStateStore cutDebugStateStore,
        string connectionTarget,
        IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings,
        Func<BroadcastChannel, int>? channelLayerProvider)
    {
        _manager = manager;
        _logService = logService;
        _t3CutPathProvider = t3CutPathProvider;
        _sceneVariableCatalog = sceneVariableCatalog;
        _cutDebugStateStore = cutDebugStateStore;
        _connectionTarget = connectionTarget;
        _bindings = bindings;
        _channelLayerProvider = channelLayerProvider;
        _manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
    }

    public string BackendName => "Karisma CG";

    public bool IsLiveCg => true;

    public bool IsConnected => _manager.IsConnected;

    public string ConnectionTarget => _connectionTarget;

    public TornadoConnectionState State
    {
        get => _state;
        private set
        {
            if (_state == value)
            {
                return;
            }

            _state = value;
            StateChanged?.Invoke(this, value);
        }
    }

    public event EventHandler<TornadoConnectionState>? StateChanged;

    public event EventHandler? ConnectionChanged;

    public static ITornado3Adapter CreateOrFallback(
        LogService logService,
        Func<string> t3CutPathProvider,
        CutDebugStateStore cutDebugStateStore,
        Func<BroadcastChannel, int>? channelLayerProvider = null)
    {
        return TryCreate(logService, t3CutPathProvider, cutDebugStateStore, out var adapter, channelLayerProvider)
            ? adapter
            : new MockTornado3Adapter(logService);
    }

    public static bool TryCreate(
        LogService logService,
        Func<string> t3CutPathProvider,
        CutDebugStateStore cutDebugStateStore,
        out ITornado3Adapter adapter,
        Func<BroadcastChannel, int>? channelLayerProvider = null)
    {
        var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
        if (string.IsNullOrWhiteSpace(host))
        {
            host = "127.0.0.1";
        }

        if (!int.TryParse(Environment.GetEnvironmentVariable("TORNADO_KARISMA_PORT"), out var port) || port <= 0)
        {
            port = DefaultKarismaPort;
            logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
        }

        var t3CutPath = TornadoPathResolver.GetDefaultT3CutPath();
        logService.Info($"Karisma adapter using fixed T3_Cut path '{t3CutPath}'.");

        if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
        {
            logService.Warning($"Karisma adapter disabled: fixed T3_Cut path does not exist. path='{t3CutPath}'");
            adapter = new MockTornado3Adapter(logService);
            return false;
        }

        var manager = new TornadoManager(host, port, logService);
        var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
        adapter = new KarismaTornado3Adapter(
            manager,
            logService,
            t3CutPathProvider,
            sceneVariableCatalog,
            cutDebugStateStore,
            $"{host}:{port}",
            BuildBindings(),
            channelLayerProvider);
        return true;
    }

    public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
                State = TornadoConnectionState.Ready;
            },
            "connect",
            cancellationToken).ConfigureAwait(false);
    }

    public Task<ThumbnailGenerationResult> GenerateThumbnailsAsync(
        IReadOnlyList<FormatTemplateDefinition> templates,
        VideoWallLayoutPreset videoWallLayoutPreset,
        CancellationToken cancellationToken)
    {
        var thumbnailGenerator = new KarismaThumbnailGeneratorService(_logService);
        return thumbnailGenerator.GenerateAsync(
            _manager,
            templates,
            ResolveT3CutPath(),
            videoWallLayoutPreset,
            cancellationToken);
    }

    public async Task ApplyCutAsync(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        string imageRootPath,
        CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                var binding = ResolveBinding(channel);
                var t3CutPath = ResolveT3CutPath();
                var channelWasOnAir = IsChannelOnAir(channel);
                var resolvedScene = ResolveSceneForSnapshot(
                    channel,
                    template,
                    cut,
                    snapshot,
                    t3CutPath,
                    channelWasOnAir,
                    cut.UseEndScene);

                State = TornadoConnectionState.Sending;
                await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
                var forceReloadScene = ShouldForceReloadScene(channel, template, resolvedScene);
                if (forceReloadScene)
                {
                    _logService.Info($"[{channel}] Force reloading turnout loop scene before value apply: {Path.GetFileName(resolvedScene.Path)}");
                }

                await LoadAndApplyCutSceneAsync(
                    channel,
                    template,
                    cut,
                    snapshot,
                    station,
                    t3CutPath,
                    resolvedScene,
                    resolvedScene.Alias,
                    forceReloadScene,
                    logDiagnostics: true,
                    cancellationToken).ConfigureAwait(false);

                _pendingScenes[channel] = resolvedScene.Alias;
                _pendingEndScenes[channel] = cut.UseEndScene;
                _preparedFirstFramePaused[channel] = false;
                _logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
            },
            $"apply {template.Id}/{cut.Name}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task<bool> TryCapturePendingCutPreviewAsync(
        BroadcastChannel channel,
        string fileName,
        int width,
        int height,
        int frame,
        CancellationToken cancellationToken)
    {
        if (_pendingScenes.TryGetValue(channel, out var sceneAlias) &&
            !string.IsNullOrWhiteSpace(sceneAlias))
        {
            try
            {
                await _manager.SaveSceneImageAsync(sceneAlias, fileName, width, height, frame, cancellationToken)
                    .ConfigureAwait(false);
                return true;
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (Exception ex)
            {
                _logService.Warning($"[{channel}] 현재 컷 씬 프리뷰 캡처 실패: {sceneAlias} / {ex.Message}");
            }
        }

        try
        {
            var binding = _activeBindings.TryGetValue(channel, out var activeBinding)
                ? activeBinding
                : ResolveBinding(channel);
            await _manager.SaveMixedPreviewImageAsync(
                    binding.OutputChannelIndex,
                    binding.LayerNo,
                    fileName,
                    width,
                    height,
                    cancellationToken)
                .ConfigureAwait(false);
            return true;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logService.Warning($"[{channel}] 현재 컷 프리뷰 캡처 실패: {ex.Message}");
            return false;
        }
    }

    public async Task<bool> TryCaptureCutPreviewAsync(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        string imageRootPath,
        string fileName,
        int width,
        int height,
        int frame,
        CancellationToken cancellationToken)
    {
        var previewAlias = string.Empty;
        try
        {
            var t3CutPath = ResolveT3CutPath();
            var resolvedScene = ResolveSceneForSnapshot(
                channel,
                template,
                cut,
                snapshot,
                t3CutPath,
                IsChannelOnAir(channel),
                cut.UseEndScene);
            previewAlias = BuildPreviewSceneAlias(channel, resolvedScene.Alias);

            await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
            await LoadAndApplyCutSceneAsync(
                channel,
                template,
                cut,
                snapshot,
                station,
                t3CutPath,
                resolvedScene,
                previewAlias,
                forceReloadScene: true,
                logDiagnostics: false,
                cancellationToken).ConfigureAwait(false);
            await _manager.SaveSceneImageAsync(previewAlias, fileName, width, height, frame, cancellationToken)
                .ConfigureAwait(false);
            return true;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logService.Warning($"[{channel}] 컷 프리뷰 캡처 실패: {template.Name}/{cut.Name} / {ex.Message}");
            return false;
        }
        finally
        {
            if (!string.IsNullOrWhiteSpace(previewAlias))
            {
                try
                {
                    await _manager.UnloadSceneAsync(previewAlias, CancellationToken.None).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logService.Warning($"[{channel}] 프리뷰 씬 언로드 실패: {previewAlias} / {ex.Message}");
                }
            }
        }
    }

    public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
                {
                    _logService.Warning($"[{channel}] No Karisma scene pending for Prepare.");
                    return;
                }

                var binding = ResolveBinding(channel);
                await _manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, sceneAlias, cancellationToken).ConfigureAwait(false);
                _pendingBindings[channel] = binding;
                State = TornadoConnectionState.Ready;
            },
            $"prepare {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                var binding = _pendingBindings.TryGetValue(channel, out var pendingBinding)
                    ? pendingBinding
                    : ResolveBinding(channel);
                await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
                await _manager.PauseAsync(binding.OutputChannelIndex, binding.LayerNo, cancellationToken).ConfigureAwait(false);

                _preparedFirstFramePaused[channel] = true;
                _activeBindings[channel] = binding;
                _logService.Info($"[{channel}] Karisma prepared first frame shown on PGM output={binding.OutputChannelIndex}:{binding.LayerNo}");
                State = TornadoConnectionState.Ready;
            },
            $"show first frame {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                var binding = _pendingBindings.TryGetValue(channel, out var pendingBinding)
                    ? pendingBinding
                    : ResolveBinding(channel);
                if (_preparedFirstFramePaused.TryGetValue(channel, out var isPaused) && isPaused)
                {
                    await _manager.ResumeAsync(binding.OutputChannelIndex, binding.LayerNo, cancellationToken).ConfigureAwait(false);
                }
                else
                {
                    await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
                }

                var isEndScene = _pendingEndScenes.TryGetValue(channel, out var pendingEndScene) && pendingEndScene;
                _channelOnAir[channel] = !isEndScene;
                _activeBindings[channel] = binding;
                _pendingBindings.Remove(channel);
                _preparedFirstFramePaused[channel] = false;
                State = TornadoConnectionState.OnAir;
            },
            $"take {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                var binding = _activeBindings.TryGetValue(channel, out var activeBinding)
                    ? activeBinding
                    : _pendingBindings.TryGetValue(channel, out var pendingBinding)
                        ? pendingBinding
                        : ResolveBinding(channel);
                await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
                await _manager.StopAsync(binding.OutputChannelIndex, binding.LayerNo, cancellationToken).ConfigureAwait(false);
                _channelOnAir[channel] = false;
                _pendingEndScenes[channel] = false;
                _pendingBindings.Remove(channel);
                _activeBindings.Remove(channel);
                _preparedFirstFramePaused[channel] = false;
                State = TornadoConnectionState.Idle;
                _logService.Info($"[{channel}] Karisma output layer cleared output={binding.OutputChannelIndex}:{binding.LayerNo}");
            },
            $"clear output {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                var binding = _activeBindings.TryGetValue(channel, out var activeBinding)
                    ? activeBinding
                    : ResolveBinding(channel);
                await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
                _channelOnAir[channel] = false;
                _pendingEndScenes[channel] = false;
                _pendingBindings.Remove(channel);
                _activeBindings.Remove(channel);
                _preparedFirstFramePaused[channel] = false;
                State = TornadoConnectionState.Idle;
            },
            $"out {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public async Task SavePendingSceneImageAsync(
        BroadcastChannel channel,
        string fileName,
        int width,
        int height,
        int frame,
        CancellationToken cancellationToken)
    {
        await ExecuteAsync(
            async () =>
            {
                if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
                {
                    throw new InvalidOperationException($"[{channel}] No Karisma scene pending for image capture.");
                }

                await _manager.SaveSceneImageAsync(sceneAlias, fileName, width, height, frame, cancellationToken)
                    .ConfigureAwait(false);
            },
            $"save image {channel}",
            cancellationToken).ConfigureAwait(false);
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;
        _manager.Dispose();
    }

    private KarismaResolvedScene ResolveSceneForSnapshot(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        bool useLoop,
        bool useEndScene)
    {
        var shouldUseLoop = useLoop && !ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name);
        var resolvedScene = KarismaSceneResolver.ResolveScene(
            template,
            cut,
            t3CutPath,
            shouldUseLoop,
            useEndScene);
        var unopposedWinnerScenePath = ResolveUnopposedWinnerScenePath(
            template,
            snapshot,
            resolvedScene.Path);
        if (!string.IsNullOrWhiteSpace(unopposedWinnerScenePath))
        {
            if (!string.Equals(resolvedScene.Path, unopposedWinnerScenePath, StringComparison.OrdinalIgnoreCase))
            {
                _logService.Info(
                    $"[{channel}] Unopposed winner scene selected: {Path.GetFileName(unopposedWinnerScenePath)} " +
                    $"alias={resolvedScene.Alias}");
            }

            return new KarismaResolvedScene(unopposedWinnerScenePath, resolvedScene.Alias);
        }

        var provinceScenePath = ResolveNormalProvinceScenePath(
            template,
            snapshot,
            t3CutPath,
            out var provinceSceneKind,
            out var provinceSceneRegionName);
        if (string.IsNullOrWhiteSpace(provinceScenePath))
        {
            provinceScenePath = ResolveNormalTurnoutVideoScenePath(
                channel,
                template,
                snapshot,
                resolvedScene.Path,
                out provinceSceneKind,
                out provinceSceneRegionName);
            if (string.IsNullOrWhiteSpace(provinceScenePath))
            {
                return resolvedScene;
            }
        }

        if (!string.Equals(resolvedScene.Path, provinceScenePath, StringComparison.OrdinalIgnoreCase))
        {
            _logService.Info(
                $"[{channel}] Normal {provinceSceneKind} scene selected: {Path.GetFileName(provinceScenePath)} " +
                $"region={provinceSceneRegionName} alias={resolvedScene.Alias}");
        }

        return new KarismaResolvedScene(provinceScenePath, resolvedScene.Alias);
    }

    private static string ResolveUnopposedWinnerScenePath(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        string sourceScenePath)
    {
        if (!IsWinnerTemplate(template.Name) ||
            !snapshot.Candidates.Any(ShouldHideVoteMetrics) ||
            string.IsNullOrWhiteSpace(sourceScenePath) ||
            !File.Exists(sourceScenePath))
        {
            return string.Empty;
        }

        var fileName = Path.GetFileName(sourceScenePath);
        if (fileName.StartsWith("__generated_unopposed_", StringComparison.OrdinalIgnoreCase))
        {
            return sourceScenePath;
        }

        var directory = Path.GetDirectoryName(sourceScenePath);
        if (string.IsNullOrWhiteSpace(directory))
        {
            return string.Empty;
        }

        var generatedScenePath = Path.Combine(directory, "__generated_unopposed_" + fileName);
        return TryEnsureUnopposedWinnerSceneCopy(sourceScenePath, generatedScenePath)
            ? generatedScenePath
            : string.Empty;
    }

    private static bool TryEnsureUnopposedWinnerSceneCopy(
        string sourceScenePath,
        string generatedScenePath)
    {
        try
        {
            if (File.Exists(generatedScenePath) &&
                File.GetLastWriteTimeUtc(generatedScenePath) >= File.GetLastWriteTimeUtc(sourceScenePath))
            {
                return true;
            }

            var bytes = File.ReadAllBytes(sourceScenePath);
            var replacementCount = 0;
            for (var index = 0; index + 1 < bytes.Length; index += 2)
            {
                if (bytes[index] != (byte)'%' || bytes[index + 1] != 0)
                {
                    continue;
                }

                bytes[index] = (byte)' ';
                replacementCount++;
            }

            if (replacementCount == 0)
            {
                return false;
            }

            File.WriteAllBytes(generatedScenePath, bytes);
            return true;
        }
        catch
        {
            return false;
        }
    }

    private static string ResolveNormalProvinceScenePath(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        out string sceneKind,
        out string regionName)
    {
        regionName = string.Empty;
        if (!TryResolveNormalProvinceSceneNames(template, out var sceneNames, out sceneKind))
        {
            return string.Empty;
        }

        regionName = ResolveNormalProvinceSceneRegionName(snapshot, sceneNames);
        if (string.IsNullOrWhiteSpace(regionName) ||
            !sceneNames.TryGetValue(regionName, out var sceneName))
        {
            return string.Empty;
        }

        var templatePath = Path.Combine(t3CutPath, template.Id);
        var templateFolderPath = Path.GetDirectoryName(templatePath);
        if (string.IsNullOrWhiteSpace(templateFolderPath))
        {
            return string.Empty;
        }

        var scenePath = Path.Combine(templateFolderPath, sceneName + ".tscn");
        return File.Exists(scenePath)
            ? scenePath
            : string.Empty;
    }

    private string ResolveNormalTurnoutVideoScenePath(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        string sourceScenePath,
        out string sceneKind,
        out string regionName)
    {
        sceneKind = "turnout video";
        regionName = string.Empty;
        if (!string.Equals(template.Name, "투표율_영상", StringComparison.Ordinal))
        {
            return string.Empty;
        }

        regionName = ResolveNormalProvinceSceneRegionName(snapshot, NormalLandmarkVideoFileNames);
        if (string.IsNullOrWhiteSpace(regionName) ||
            !NormalLandmarkVideoFileNames.TryGetValue(regionName, out var landmarkFileName))
        {
            return string.Empty;
        }

        const string sourceRelativePath = @"Video\MP4\랜드마크\02_부산.mp4";
        var targetRelativePath = Path.Combine("Video", "MP4", "랜드마크", landmarkFileName);
        if (string.Equals(sourceRelativePath, targetRelativePath, StringComparison.Ordinal))
        {
            return sourceScenePath;
        }

        var templateFolderPath = Path.GetDirectoryName(sourceScenePath);
        if (string.IsNullOrWhiteSpace(templateFolderPath) ||
            !TryEnsureLandmarkVideoFile(templateFolderPath, regionName, landmarkFileName))
        {
            return string.Empty;
        }

        var generatedScenePath = Path.Combine(
            templateFolderPath,
            $"__generated_{template.Name}_{Path.GetFileNameWithoutExtension(landmarkFileName)}.tscn");
        if (TryEnsureSameLengthScenePathPatch(sourceScenePath, generatedScenePath, sourceRelativePath, targetRelativePath))
        {
            return generatedScenePath;
        }

        _logService.Warning(
            $"[{channel}] 투표율_영상 지역별 배경을 적용하지 못했습니다. region={regionName} source={sourceRelativePath} target={targetRelativePath}");
        return string.Empty;
    }

    private static bool TryEnsureLandmarkVideoFile(
        string templateFolderPath,
        string regionName,
        string landmarkFileName)
    {
        var landmarkFolderPath = Path.Combine(templateFolderPath, "Video", "MP4", "랜드마크");
        var targetPath = Path.Combine(landmarkFolderPath, landmarkFileName);
        if (File.Exists(targetPath))
        {
            return true;
        }

        if (!NormalLandmarkVideoSourceFileNames.TryGetValue(regionName, out var sourceFileName))
        {
            return false;
        }

        var sourcePath = Path.Combine(landmarkFolderPath, sourceFileName);
        if (!File.Exists(sourcePath))
        {
            return false;
        }

        File.Copy(sourcePath, targetPath, overwrite: false);
        return true;
    }

    private static bool TryEnsureSameLengthScenePathPatch(
        string sourceScenePath,
        string generatedScenePath,
        string sourceRelativePath,
        string targetRelativePath)
    {
        if (sourceRelativePath.Length != targetRelativePath.Length)
        {
            return false;
        }

        var sourceBytes = Encoding.Unicode.GetBytes(sourceRelativePath);
        var targetBytes = Encoding.Unicode.GetBytes(targetRelativePath);

        if (File.Exists(generatedScenePath) &&
            File.GetLastWriteTimeUtc(generatedScenePath) >= File.GetLastWriteTimeUtc(sourceScenePath))
        {
            var generatedBytes = File.ReadAllBytes(generatedScenePath);
            if (IndexOfBytes(generatedBytes, targetBytes, 0) >= 0 &&
                IndexOfBytes(generatedBytes, sourceBytes, 0) < 0)
            {
                return true;
            }
        }

        var sceneBytes = File.ReadAllBytes(sourceScenePath);
        var replacementCount = 0;
        var matchIndex = IndexOfBytes(sceneBytes, sourceBytes, 0);
        while (matchIndex >= 0)
        {
            Array.Copy(targetBytes, 0, sceneBytes, matchIndex, targetBytes.Length);
            replacementCount++;
            matchIndex = IndexOfBytes(sceneBytes, sourceBytes, matchIndex + targetBytes.Length);
        }

        if (replacementCount == 0)
        {
            return false;
        }

        File.WriteAllBytes(generatedScenePath, sceneBytes);
        return true;
    }

    private static int IndexOfBytes(byte[] source, byte[] pattern, int startIndex)
    {
        for (var index = Math.Max(0, startIndex); index <= source.Length - pattern.Length; index++)
        {
            var matched = true;
            for (var patternIndex = 0; patternIndex < pattern.Length; patternIndex++)
            {
                if (source[index + patternIndex] == pattern[patternIndex])
                {
                    continue;
                }

                matched = false;
                break;
            }

            if (matched)
            {
                return index;
            }
        }

        return -1;
    }

    private static bool TryResolveNormalProvinceSceneNames(
        FormatTemplateDefinition template,
        out IReadOnlyDictionary<string, string> sceneNames,
        out string sceneKind)
    {
        if (string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
            string.Equals(template.Name, "투표율_시도별", StringComparison.Ordinal) ||
            string.Equals(template.Name, "투표율_영상", StringComparison.Ordinal))
        {
            sceneNames = NormalTurnoutProvinceSceneNames;
            sceneKind = "turnout";
            return true;
        }

        if (string.Equals(template.Name, "1-2위_광역단체장_시도별영상", StringComparison.Ordinal) ||
            string.Equals(template.Name, "1-2위_기초단체장_시도별영상", StringComparison.Ordinal))
        {
            sceneNames = NormalRankProvinceSceneNames;
            sceneKind = "rank";
            return true;
        }

        sceneNames = new Dictionary<string, string>(StringComparer.Ordinal);
        sceneKind = string.Empty;
        return false;
    }

    private static string ResolveNormalProvinceSceneRegionName(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, string> sceneNames)
    {
        var regionCandidates = new[]
        {
            snapshot.RegionName,
            snapshot.ElectionDistrictName,
            snapshot.DistrictName
        };

        foreach (var value in regionCandidates)
        {
            var directRegionName = ResolveRegionNameFromText(value);
            if (sceneNames.ContainsKey(directRegionName))
            {
                return directRegionName;
            }
        }

        foreach (var value in regionCandidates)
        {
            var normalizedRegionName = NormalizeRegionName(value);
            if (sceneNames.ContainsKey(normalizedRegionName))
            {
                return normalizedRegionName;
            }

            foreach (var regionName in sceneNames.Keys.OrderByDescending(name => name.Length))
            {
                if (normalizedRegionName.StartsWith(regionName, StringComparison.Ordinal))
                {
                    return regionName;
                }
            }
        }

        return string.Empty;
    }

    private static string ResolveRegionNameFromText(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var text = value.Replace(" ", string.Empty, StringComparison.Ordinal);
        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            var fullRegionName = pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal);
            if (text.Contains(fullRegionName, StringComparison.Ordinal) ||
                text.StartsWith(pair.Key, StringComparison.Ordinal))
            {
                return pair.Key;
            }
        }

        return string.Empty;
    }

    private async Task LoadAndApplyCutSceneAsync(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        string t3CutPath,
        KarismaResolvedScene resolvedScene,
        string sceneAlias,
        bool forceReloadScene,
        bool logDiagnostics,
        CancellationToken cancellationToken)
    {
        var payload = BuildSceneUpdatePayload(
            channel,
            template,
            cut,
            snapshot,
            station,
            t3CutPath,
            resolvedScene,
            logDiagnostics);

        await _manager.LoadSceneAsync(resolvedScene.Path, sceneAlias, forceReloadScene, cancellationToken)
            .ConfigureAwait(false);
        await _manager.ApplyValuesAsync(
            sceneAlias,
            payload.VisibilityUpdatesBeforeValue,
            payload.Values,
            payload.CounterNumberKeys,
            payload.ChartCellUpdates,
            payload.PositionUpdates,
            payload.CropKeyUpdates,
            payload.StyleColorUpdates,
            payload.VisibilityUpdatesAfterValue,
            cancellationToken).ConfigureAwait(false);
    }

    private SceneUpdatePayload BuildSceneUpdatePayload(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        string t3CutPath,
        KarismaResolvedScene resolvedScene,
        bool logDiagnostics)
    {
        var sceneVariables = ResolveSceneVariablesForPayload(template, t3CutPath, resolvedScene);
        var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
        var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
        var chartCellUpdates = BuildChartCellUpdates(template, snapshot, sceneVariables);
        var positionUpdates = BuildPositionUpdates(template, snapshot, sceneVariables);
        var cropKeyUpdates = BuildCropKeyUpdates(template, snapshot, sceneVariables);
        var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
        var candidateGroupVisibilityUpdates = BuildCandidateGroupVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
        var turnoutGroupVisibilityUpdates = BuildTurnoutGroupVisibilityUpdates(template, snapshot, sceneVariables);
        var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
        var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
        var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables);
        var cutDebug = _cutDebugStateStore.Get(channel).CreateSnapshot();
        var templateDebug = cutDebug.IsEnabled
            ? _cutDebugStateStore.FindTemplate(channel, template.Id)
            : null;
        var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
        var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
        var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
        var filteredCandidateGroupVisibilityUpdates = FilterVisibilityUpdatePair(candidateGroupVisibilityUpdates, cutDebug, templateDebug);
        var filteredTurnoutGroupVisibilityUpdates = FilterVisibilityUpdatePair(turnoutGroupVisibilityUpdates, cutDebug, templateDebug);
        var filteredJudgementVisibilityUpdates = FilterVisibilityUpdatePair(judgementVisibilityUpdates, cutDebug, templateDebug);
        var filteredHistoricalWinnerVisibilityUpdates = FilterVisibilityUpdatePair(historicalWinnerVisibilityUpdates, cutDebug, templateDebug);
        var filteredCareerPromiseVisibilityUpdates = FilterVisibilityUpdatePair(careerPromiseVisibilityUpdates, cutDebug, templateDebug);
        var overriddenValues = ApplyObjectValueOverrides(filteredValues, sceneVariables, templateDebug);
        var overriddenCounterNumberKeys = ApplyCounterNumberKeyOverrides(filteredCounterNumberKeys, sceneVariables, templateDebug);
        var overriddenStyleColorUpdates = ApplyStyleColorOverrides(filteredStyleColorUpdates, templateDebug);
        var overriddenCandidateGroupVisibilityUpdates = ApplyVisibilityOverrides(filteredCandidateGroupVisibilityUpdates, sceneVariables, templateDebug);
        var overriddenTurnoutGroupVisibilityUpdates = ApplyVisibilityOverrides(filteredTurnoutGroupVisibilityUpdates, sceneVariables, templateDebug);
        var overriddenJudgementVisibilityUpdates = ApplyVisibilityOverrides(filteredJudgementVisibilityUpdates, sceneVariables, templateDebug);
        var overriddenHistoricalWinnerVisibilityUpdates = ApplyVisibilityOverrides(filteredHistoricalWinnerVisibilityUpdates, sceneVariables, templateDebug);
        var overriddenCareerPromiseVisibilityUpdates = ApplyVisibilityOverrides(filteredCareerPromiseVisibilityUpdates, sceneVariables, templateDebug);
        var sceneFileValues = FilterValuesBySceneFile(overriddenValues, resolvedScene.Path);
        var sendCounterNumberKeys = UseFinalVoteRateCounterValuesOnly(
            FilterCounterNumberKeyUpdatesBySceneFile(overriddenCounterNumberKeys, resolvedScene.Path));
        var sendValues = RemoveEmptyResourceValues(sceneFileValues, sceneVariables);
        var sendChartCellUpdates = FilterChartCellUpdatesBySceneFile(chartCellUpdates, resolvedScene.Path);
        var sendPositionUpdates = FilterPositionUpdatesBySceneFile(positionUpdates, resolvedScene.Path);
        var sendCropKeyUpdates = FilterCropKeyUpdatesBySceneFile(cropKeyUpdates, resolvedScene.Path);
        var sendStyleColorUpdates = FilterStyleColorUpdatesBySceneFile(overriddenStyleColorUpdates, resolvedScene.Path);
        var overriddenVisibilityUpdatesBeforeValue = overriddenCandidateGroupVisibilityUpdates.HideBeforeValue
            .Concat(overriddenTurnoutGroupVisibilityUpdates.HideBeforeValue)
            .Concat(overriddenJudgementVisibilityUpdates.HideBeforeValue)
            .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
            .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
            .ToArray();
        var overriddenVisibilityUpdatesAfterValue = overriddenCandidateGroupVisibilityUpdates.ShowAfterValue
            .Concat(overriddenTurnoutGroupVisibilityUpdates.ShowAfterValue)
            .Concat(overriddenJudgementVisibilityUpdates.ShowAfterValue)
            .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
            .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
            .ToArray();
        var sendVisibilityUpdatesBeforeValue = FilterVisibilityUpdatesBySceneFile(overriddenVisibilityUpdatesBeforeValue, resolvedScene.Path);
        var sendVisibilityUpdatesAfterValue = FilterVisibilityUpdatesBySceneFile(overriddenVisibilityUpdatesAfterValue, resolvedScene.Path);

        if (logDiagnostics)
        {
            LogMissingExpectedSceneVariables(channel, template, sceneVariables);
            LogUnsupportedSceneVariables(channel, template, sceneVariables);
            LogTurnoutValuePayload(
                channel,
                template,
                resolvedScene,
                snapshot,
                sceneVariables,
                sendValues,
                sendCounterNumberKeys);
            LogCutDebugSummary(
                channel,
                template,
                cut,
                cutDebug,
                values.Count,
                sendValues.Count,
                sendValues.Keys,
                counterNumberKeys.Count,
                sendCounterNumberKeys.Count,
                sendCounterNumberKeys.Select(update => update.ObjectName),
                styleColorUpdates.Count,
                sendStyleColorUpdates.Count,
                sendStyleColorUpdates.Select(update => update.ObjectName),
                candidateGroupVisibilityUpdates.HideBeforeValue.Count + candidateGroupVisibilityUpdates.ShowAfterValue.Count +
                turnoutGroupVisibilityUpdates.HideBeforeValue.Count + turnoutGroupVisibilityUpdates.ShowAfterValue.Count +
                judgementVisibilityUpdates.HideBeforeValue.Count + judgementVisibilityUpdates.ShowAfterValue.Count +
                historicalWinnerVisibilityUpdates.HideBeforeValue.Count + historicalWinnerVisibilityUpdates.ShowAfterValue.Count +
                careerPromiseVisibilityUpdates.HideBeforeValue.Count + careerPromiseVisibilityUpdates.ShowAfterValue.Count,
                sendVisibilityUpdatesBeforeValue.Count + sendVisibilityUpdatesAfterValue.Count,
                sendVisibilityUpdatesBeforeValue
                    .Concat(sendVisibilityUpdatesAfterValue)
                    .Select(update => update.ObjectName));
            LogCutDebugOverrides(
                channel,
                template,
                cutDebug,
                filteredValues,
                overriddenValues,
                filteredCounterNumberKeys,
                sendCounterNumberKeys);
        }

        return new SceneUpdatePayload(
            sendValues,
            sendCounterNumberKeys,
            sendChartCellUpdates,
            sendPositionUpdates,
            sendCropKeyUpdates,
            sendStyleColorUpdates,
            sendVisibilityUpdatesBeforeValue,
            sendVisibilityUpdatesAfterValue);
    }

    private IReadOnlyDictionary<string, KarismaSceneVariableDefinition> ResolveSceneVariablesForPayload(
        FormatTemplateDefinition template,
        string t3CutPath,
        KarismaResolvedScene resolvedScene)
    {
        var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
        if (sceneVariables.Count > 0 || !IsNormalProvinceOverrideScenePath(resolvedScene.Path))
        {
            return sceneVariables;
        }

        var templateScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
        var fallbackSceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, templateScenePath);
        return fallbackSceneVariables.Count > 0
            ? fallbackSceneVariables
            : sceneVariables;
    }

    private static bool IsNormalProvinceOverrideScenePath(string scenePath)
    {
        var sceneName = Path.GetFileNameWithoutExtension(scenePath);
        return sceneName.StartsWith("__generated_", StringComparison.Ordinal) ||
               NormalTurnoutProvinceSceneNames.Values.Contains(sceneName, StringComparer.Ordinal) ||
               NormalRankProvinceSceneNames.Values.Contains(sceneName, StringComparer.Ordinal);
    }

    private static string BuildPreviewSceneAlias(BroadcastChannel channel, string sceneAlias)
    {
        return $"{sceneAlias}__preview_{channel}";
    }

    private async Task ExecuteAsync(Func<Task> action, string actionName, CancellationToken cancellationToken)
    {
        try
        {
            cancellationToken.ThrowIfCancellationRequested();
            await action().ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (TimeoutException ex)
        {
            State = TornadoConnectionState.Error;
            _logService.Error($"Karisma adapter timed out while trying to {actionName}: {ex.Message}");
            throw;
        }
        catch (Exception ex)
        {
            State = TornadoConnectionState.Error;
            _logService.Error($"Karisma adapter failed to {actionName}: {ex.Message}");
        }
    }

    private KarismaChannelBinding ResolveBinding(BroadcastChannel channel)
    {
        if (!_bindings.TryGetValue(channel, out var binding))
        {
            throw new InvalidOperationException($"No Karisma binding configured for channel: {channel}");
        }

        if (_channelLayerProvider is null)
        {
            return binding;
        }

        var layerNo = Math.Clamp(_channelLayerProvider(channel), 0, 99);
        return binding with { LayerNo = layerNo };
    }

    private static bool ShouldForceReloadScene(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        KarismaResolvedScene resolvedScene)
    {
        return channel == BroadcastChannel.TopLeft &&
               IsTurnoutTemplate(template.Name) &&
               Path.GetFileNameWithoutExtension(resolvedScene.Path)
                   .EndsWith("_loop", StringComparison.OrdinalIgnoreCase);
    }

    private string ResolveT3CutPath()
    {
        var path = TornadoPathResolver.GetDefaultT3CutPath();

        if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
        {
            throw new DirectoryNotFoundException($"Fixed T3_Cut path does not exist: {path}");
        }

        return path;
    }

    private bool IsChannelOnAir(BroadcastChannel channel)
    {
        return _channelOnAir.TryGetValue(channel, out var isOnAir) && isOnAir;
    }

    private static IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> BuildBindings()
    {
        return new Dictionary<BroadcastChannel, KarismaChannelBinding>
        {
            [BroadcastChannel.Normal] = ParseBinding("TORNADO_KARISMA_BIND_NORMAL", new KarismaChannelBinding(0, 0)),
            [BroadcastChannel.TopLeft] = ParseBinding("TORNADO_KARISMA_BIND_TOPLEFT", new KarismaChannelBinding(0, 1)),
            [BroadcastChannel.Bottom] = ParseBinding("TORNADO_KARISMA_BIND_BOTTOM", new KarismaChannelBinding(0, 2)),
            [BroadcastChannel.VideoWall] = ParseBinding("TORNADO_KARISMA_BIND_VIDEOWALL", new KarismaChannelBinding(1, 0))
        };
    }

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

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

    private static Dictionary<string, string> BuildObjectValues(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        BroadcastStationProfile station,
        string t3CutPath,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
        var countedRateDisplay = ShouldUseCountingCompleteLabel(template, snapshot)
            ? "개표완료"
            : FormatCountedRateLabel(CalculateCountedRate(snapshot));
        var referenceTimeDisplay = ResolveReferenceTimeDisplay(template, snapshot);
        var totalExpectedVotesDisplay = FormatCount(snapshot.TotalExpectedVotes);
        var turnoutVotesDisplay = FormatCount(snapshot.TurnoutVotes);
        var turnoutRateDisplay = FormatRate(snapshot.TurnoutRate);
        var nationalTurnoutRateDisplay = FormatRate(snapshot.NationalTurnoutRate);
        var isTurnoutTemplate = IsTurnoutTemplate(template.Name);
        var regionName = string.IsNullOrWhiteSpace(snapshot.RegionName) ? snapshot.DistrictName : snapshot.RegionName;
        var electionDistrictName = string.IsNullOrWhiteSpace(snapshot.ElectionDistrictName) ? snapshot.DistrictName : snapshot.ElectionDistrictName;
        var officeLabel = ShouldUseOfficeLabel(snapshot)
            ? ResolveOfficeLabel(snapshot, regionName, electionDistrictName)
            : string.Empty;
        var usesSeparateRegionAndDistrictLabels =
            !isTurnoutTemplate &&
            string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal);
        var shouldUseTurnoutDistrictLabel = isTurnoutTemplate &&
            !IsBottomTurnoutSidoTemplate(template) &&
            (IsBottomTurnoutDistrictTemplate(template) ||
             ShouldUseTurnoutDistrictLabel(snapshot, regionName, electionDistrictName));
        var turnoutDistrictDisplay =
            shouldUseTurnoutDistrictLabel
                ? ResolveTurnoutDistrictNameLabel(snapshot, regionName, electionDistrictName)
                : string.Empty;
        var electionDistrictDisplay = isTurnoutTemplate
            ? turnoutDistrictDisplay
            : string.IsNullOrWhiteSpace(officeLabel)
                ? electionDistrictName
                : officeLabel;
        var regionLabelDisplay = isTurnoutTemplate
            ? regionName
            : usesSeparateRegionAndDistrictLabels
                ? regionName
            : string.IsNullOrWhiteSpace(officeLabel) &&
                                 string.Equals(snapshot.ElectionType, "광역단체장", StringComparison.Ordinal)
                ? electionDistrictName
                : string.IsNullOrWhiteSpace(officeLabel)
                    ? regionName
                    : officeLabel;
        if (UsesShortHistoricalRegionLabel(template.Name))
        {
            regionLabelDisplay = NormalizeRegionName(regionLabelDisplay);
        }

        var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["TemplateId"] = template.Id,
            ["TemplateName"] = template.Name,
            ["CutName"] = cut.Name,
            ["Title"] = template.Name,
            ["StationId"] = station.Id,
            ["StationName"] = station.Name,
            ["StationLogoPath"] = station.LogoAssetPath,
            ["StationRegions"] = string.Join(", ", station.RegionFilters),
            ["BroadcastPhase"] = snapshot.BroadcastPhase.ToString(),
            ["ElectionType"] = snapshot.ElectionType,
            ["RegionName"] = regionLabelDisplay,
            ["ElectionDistrictName"] = electionDistrictDisplay,
            ["DistrictName"] = electionDistrictDisplay,
            ["DistrictCode"] = snapshot.DistrictCode,
            ["TotalExpectedVotes"] = snapshot.TotalExpectedVotes.ToString(CultureInfo.InvariantCulture),
            ["TotalExpectedVotesDisplay"] = totalExpectedVotesDisplay,
            ["TurnoutVotes"] = snapshot.TurnoutVotes.ToString(CultureInfo.InvariantCulture),
            ["TurnoutVotesDisplay"] = turnoutVotesDisplay,
            ["TurnoutRate"] = snapshot.TurnoutRate.ToString("0.0", CultureInfo.InvariantCulture),
            ["NationalTurnoutRate"] = snapshot.NationalTurnoutRate.ToString("0.0", CultureInfo.InvariantCulture),
            ["CountedVotes"] = snapshot.CountedVotes.ToString(CultureInfo.InvariantCulture),
            ["CountedVotesDisplay"] = snapshot.CountedVotes.ToString("N0", CultureInfo.InvariantCulture),
            ["RemainingVotes"] = snapshot.RemainingVotes.ToString(CultureInfo.InvariantCulture),
            ["RemainingVotesDisplay"] = snapshot.RemainingVotes.ToString("N0", CultureInfo.InvariantCulture),
            ["ImageRootPath"] = t3CutPath,
            ["T3CutPath"] = t3CutPath,
            ["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
        };

        var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
        if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseMapTemplate(template))
        {
            ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables);
        }

        if (isTurnoutTemplate)
        {
            ClearTurnoutSlotValues(values, DefaultTurnoutSlotClearCount, sceneVariables);
        }

        ClearLeaderValues(values);

        SetAliases(values, electionDistrictDisplay, "선거구명", "선거구명01");
        SetAliases(values, regionLabelDisplay, "시도명", "시도명01");
        SetAliases(values, countedRateDisplay, "개표율", "개표율01");
        SetAliases(values, turnoutRateDisplay, "투표율", "투표율01");
        SetAliases(values, nationalTurnoutRateDisplay, "전국투표율", "전국투표율01");
        SetAliases(values, referenceTimeDisplay, "기준시", "기준시01", "기준시02");
        SetAliases(values, totalExpectedVotesDisplay, "유권자수", "유권자수01");
        SetAliases(values, turnoutVotesDisplay, "투표자수", "투표자수01");

        if (IsHistoricalTurnoutTemplate(template.Name))
        {
            ApplyHistoricalTurnoutValues(values, snapshot);
        }

        if (snapshot.TurnoutBoardSlots.Count > 0)
        {
            ApplyTurnoutBoardValues(values, template, snapshot, t3CutPath, templateFolderPath);
        }

        if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
        {
            return FilterNormalPreElectionTurnoutDistrictBoardValues(values, sceneVariables);
        }

        if (IsTurnoutPhotoTemplate(template.Name))
        {
            ApplyTurnoutPhotoValues(values, t3CutPath, templateFolderPath);
        }

        if (IsTopPanseTemplate(template))
        {
            ApplyTopPanseSummaryValues(values, template, snapshot, sceneVariables);
            return FilterValuesForScene(values, sceneVariables, template);
        }

        if (IsNormalPanseMapTemplate(template))
        {
            ApplyNormalPanseMapValues(values, snapshot, sceneVariables);
            return FilterValuesForScene(values, sceneVariables, template);
        }

        if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
        {
            return FilterValuesForScene(values, sceneVariables, template);
        }

        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        var candidateVoteRanks = IsAllCandidateTemplate(template.Name)
            ? BuildCandidateVoteRankMap(snapshot.Candidates)
            : EmptyCandidateRankMap;
        var candidateImagePaths = new Dictionary<Guid, string>();
        var candidatePhotoFallbackPool = CreateCandidatePhotoFallbackPool(t3CutPath, templateFolderPath);

        for (var index = 0; index < orderedCandidates.Length; index++)
        {
            var candidate = orderedCandidates[index];
            var slot = index + 1;
            var ballotNumberDisplay = ResolveBallotNumberDisplay(candidate);
            var hideVoteMetrics = ShouldHideVoteMetrics(candidate);
            var voteCountDisplay = hideVoteMetrics ? string.Empty : FormatCount(candidate.VoteCount);
            var voteRateDisplay = hideVoteMetrics ? string.Empty : FormatRate(candidate.VoteRate);
            var voteGapDisplay = FormatCount(CalculateVoteGap(orderedCandidates, index));
            var candidateElectionDistrictDisplay = ResolveCandidateElectionDistrictDisplay(candidate, electionDistrictDisplay);
            var candidateRegionLabelDisplay = ResolveCandidateRegionLabelDisplay(candidate, regionLabelDisplay);
            if (UsesShortHistoricalRegionLabel(template.Name))
            {
                candidateRegionLabelDisplay = NormalizeRegionName(candidateRegionLabelDisplay);
            }

            var candidateCountedRateDisplay = ResolveCandidateCountedRateDisplay(
                template,
                snapshot,
                candidate,
                countedRateDisplay);
            var rank = ResolveCandidateDisplayRank(template, cut, slot, candidate, candidateVoteRanks);
            var rankDisplay = rank.ToString(CultureInfo.InvariantCulture);
            var rankImagePath = ResolveRankAssetPath(t3CutPath, templateFolderPath, rank);
            var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, candidate.EffectiveJudgement);
            var candidateImagePath = ResolveCandidateImagePath(
                t3CutPath,
                templateFolderPath,
                candidate,
                candidatePhotoFallbackPool,
                candidateImagePaths);
            var candidateAssetColorParty = ResolveCandidateAssetColorParty(template, snapshot, candidate);
            var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Bar);
            var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Plate);
            var partyOutlinePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Outline);
            var partyColorPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Color);
            var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Symbol);
            var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Group);
            var graphColorPath = ResolvePartyGraphColorAssetPath(templateFolderPath, template.Name, candidateAssetColorParty);

            values[$"Candidate{slot}Code"] = candidate.CandidateCode;
            values[$"Candidate{slot}Name"] = candidate.Name;
            values[$"Candidate{slot}Party"] = candidate.Party;
            values[$"Candidate{slot}VoteCount"] = hideVoteMetrics ? string.Empty : candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
            values[$"Candidate{slot}VoteCountDisplay"] = voteCountDisplay;
            values[$"Candidate{slot}VoteRate"] = voteRateDisplay;
            values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
            values[$"Candidate{slot}ImagePath"] = candidateImagePath;

            SetRankAliases(values, sceneVariables, rankDisplay, rankImagePath, GetRankAliases(template, slot));
            SetAliases(values, ballotNumberDisplay, $"기호{slot:00}", $"기호{slot}");
            if (IsBottomWinnerTemplate(template))
            {
                SetBottomWinnerBallotNumberAliases(values, slot, ballotNumberDisplay);
            }

            SetAliases(values, ResolveBallotLabelDisplay(template, ballotNumberDisplay), $"기호텍스트{slot:00}", $"기호텍스트{slot}");
            SetAliases(values, candidate.Name, $"후보명{slot:00}", $"후보명{slot}");
            SetAliases(values, candidate.Party, $"정당명{slot:00}", $"정당명{slot}");
            SetAliases(values, voteCountDisplay, $"득표수{slot:00}", $"득표수{slot}");
            var voteRateTagValue = ShouldUseNormalAllCandidateGovernorVoteRateSetValueZero(template, slot)
                ? "0"
                : voteRateDisplay;
            SetAliases(values, voteRateTagValue, $"득표율{slot:00}", $"득표율{slot}");
            SetAliases(values, voteGapDisplay, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
            SetAliases(
                values,
                candidateElectionDistrictDisplay,
                $"선거구명{slot:00}",
                $"선거구명{slot}");
            SetAliases(
                values,
                candidateRegionLabelDisplay,
                $"시도명{slot:00}",
                $"시도명{slot}");
            SetAliases(values, candidateCountedRateDisplay, $"개표율{slot:00}", $"개표율{slot}");
            SetOptionalAliases(values, judgementPath, GetJudgementAliases(slot));
            SetOptionalAliases(values, candidateImagePath, $"후보사진{slot:00}", $"후보사진{slot}");
            SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "득표수바", partyBarPath, $"득표수바{slot:00}", $"득표수바{slot}");
            SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당바", partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
            SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당판", partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
            SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당원", partyOutlinePath, $"정당원{slot:00}", $"정당원{slot}");
            SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당색", partyColorPath, $"정당색{slot:00}", $"정당색{slot}");
            SetOptionalAliases(values, partySymbolPath, $"정당심볼{slot:00}", $"정당심볼{slot}");
            SetOptionalAliases(values, groupPath, $"그룹{slot:00}", $"그룹{slot}");
            SetOptionalAliases(values, graphColorPath, $"그래프{slot:00}", $"그래프{slot}");
        }

        if (orderedCandidates.FirstOrDefault() is { } leader)
        {
            var hideLeaderVoteMetrics = ShouldHideVoteMetrics(leader);
            values["LeaderCode"] = leader.CandidateCode;
            values["LeaderName"] = leader.Name;
            values["LeaderParty"] = leader.Party;
            values["LeaderVoteCount"] = hideLeaderVoteMetrics ? string.Empty : leader.VoteCount.ToString(CultureInfo.InvariantCulture);
            values["LeaderVoteCountDisplay"] = hideLeaderVoteMetrics ? string.Empty : FormatCount(leader.VoteCount);
            values["LeaderVoteRate"] = hideLeaderVoteMetrics ? string.Empty : FormatRate(leader.VoteRate);
            values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
            values["LeaderImagePath"] = ResolveCandidateImagePath(
                t3CutPath,
                templateFolderPath,
                leader,
                candidatePhotoFallbackPool,
                candidateImagePaths);
        }

        if (IsCareerTemplate(template.Name))
        {
            ApplyCareerPromiseValues(values, template, t3CutPath, templateFolderPath, orderedCandidates.FirstOrDefault());
        }

        ApplyCouncilSeatTableValues(values, template);

        return FilterValuesForScene(values, sceneVariables, template);
    }

    private static void ApplyCouncilSeatTableValues(
        IDictionary<string, string> values,
        FormatTemplateDefinition template)
    {
        if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
        {
            RemoveCouncilSeatTableImageValues(values);
        }
    }

    private static void RemoveCouncilSeatTableImageValues(IDictionary<string, string> values)
    {
        foreach (var key in values.Keys
                     .Where(key => TryParseCouncilSeatSlot(key, "정당바", out _))
                     .ToArray())
        {
            values.Remove(key);
        }
    }

    private static void ApplyTopPanseSummaryValues(
        IDictionary<string, string> values,
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var rows = BuildTopPanseSummaries(template, snapshot);
        var slotCount = ResolveTopPanseSlotCount(sceneVariables);
        var shouldSetPartyLabels = !IsPanseEducationTemplate(template.Name);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            var row = slot <= rows.Length ? rows[slot - 1] : default;
            var partyLabel = string.IsNullOrWhiteSpace(row.Party) ? string.Empty : row.Party;
            var countDisplay = row.Count > 0
                ? row.Count.ToString(CultureInfo.InvariantCulture)
                : "0";

            if (shouldSetPartyLabels)
            {
                SetAliases(values, partyLabel, $"정당명{slot:00}", $"정당명{slot}");
            }

            SetAliases(values, countDisplay, $"의석수{slot:00}", $"의석수{slot}");
        }
    }

    private static void ApplyNormalPanseMapValues(
        IDictionary<string, string> values,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var rows = BuildNormalPanseMapSummaries(snapshot);
        var slotCount = ResolveNormalPanseMapSlotCount(sceneVariables);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
            SetAliases(values, count.ToString(CultureInfo.InvariantCulture), $"의석수{slot:00}", $"의석수{slot}");
        }
    }

    private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot)
    {
        var counts = snapshot.Candidates
            .Where(candidate => !IsPanseSummaryCandidate(candidate))
            .GroupBy(ResolveNormalPanseMapPartyKey, StringComparer.Ordinal)
            .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);

        return NormalPanseMapPartySlots
            .Select(slot => new PanseSummary(slot.Label, counts.TryGetValue(slot.ColorParty, out var count) ? count : 0))
            .ToArray();
    }

    private static PanseSummary[] BuildTopPanseSummaries(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot)
    {
        var candidates = snapshot.Candidates?.ToArray() ?? Array.Empty<CandidateEntry>();
        if (candidates.Length == 0)
        {
            return Array.Empty<PanseSummary>();
        }

        if (IsPanseEducationTemplate(template.Name))
        {
            return BuildEducationPanseSummaries(candidates);
        }

        var summaryCandidates = candidates
            .Where(candidate => IsPanseSummaryCandidate(candidate) || IsCouncilSeatSummaryCandidate(candidate))
            .ToArray();
        if (summaryCandidates.Length > 0)
        {
            return BuildPartyPanseSummaries(summaryCandidates, ResolvePanseParty, useVoteCountAsCount: true);
        }

        var countedCandidates = candidates
            .Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
            .ToArray();
        if (countedCandidates.Length > 0)
        {
            return BuildPartyPanseSummaries(countedCandidates, ResolvePanseParty, useVoteCountAsCount: false);
        }

        var leader = candidates
            .OrderByDescending(candidate => candidate.VoteCount)
            .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
            .FirstOrDefault();

        return leader is null
            ? Array.Empty<PanseSummary>()
            : BuildPartyPanseSummaries([leader], ResolvePanseParty, useVoteCountAsCount: false);
    }

    private static PanseSummary[] BuildEducationPanseSummaries(IReadOnlyList<CandidateEntry> candidates)
    {
        var summaryCandidates = candidates
            .Where(IsPanseSummaryCandidate)
            .ToArray();
        var sourceCandidates = summaryCandidates.Length > 0
            ? summaryCandidates
            : candidates
                .OrderByDescending(candidate => candidate.VoteCount)
                .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
                .Take(1)
                .ToArray();
        var grouped = GroupPanseSummaryCandidates(
                sourceCandidates,
                ResolveEducationPanseParty,
                useVoteCountAsCount: summaryCandidates.Length > 0)
            .ToDictionary(row => NormalizePansePartyKey(row.Party), row => row.Count, StringComparer.OrdinalIgnoreCase);

        var rows = new List<PanseSummary>(DefaultTopPanseSlotCount)
        {
            new("진보", grouped.TryGetValue("진보", out var progressiveCount) ? progressiveCount : 0),
            new("보수", grouped.TryGetValue("보수", out var conservativeCount) ? conservativeCount : 0),
            new("중도", grouped.TryGetValue("중도", out var moderateCount) ? moderateCount : 0)
        };

        var extraCount = grouped
            .Where(pair => pair.Key is not ("진보" or "보수" or "중도"))
            .Sum(pair => pair.Value);
        if (extraCount > 0)
        {
            rows[^1] = new PanseSummary("기타", rows[^1].Count + extraCount);
        }

        return rows.ToArray();
    }

    private static PanseSummary[] GroupPanseSummaryCandidates(
        IReadOnlyList<CandidateEntry> candidates,
        Func<CandidateEntry, string> partySelector,
        bool useVoteCountAsCount)
    {
        return candidates
            .GroupBy(candidate => NormalizePansePartyKey(partySelector(candidate)), StringComparer.OrdinalIgnoreCase)
            .Select(group =>
            {
                var first = group.First();
                var party = partySelector(first).Trim();
                if (string.IsNullOrWhiteSpace(party))
                {
                    party = "무소속";
                }

                var count = group.Sum(candidate => useVoteCountAsCount ? Math.Max(0, candidate.VoteCount) : 1);
                return new PanseSummary(party, count);
            })
            .Where(row => row.Count > 0)
            .OrderByDescending(row => row.Count)
            .ThenBy(row => row.Party, StringComparer.Ordinal)
            .Take(DefaultTopPanseSlotCount)
            .ToArray();
    }

    private static PanseSummary[] BuildPartyPanseSummaries(
        IReadOnlyList<CandidateEntry> candidates,
        Func<CandidateEntry, string> partySelector,
        bool useVoteCountAsCount)
    {
        var counts = candidates
            .GroupBy(candidate => ResolvePartyPanseGroup(partySelector(candidate)), StringComparer.Ordinal)
            .ToDictionary(
                group => group.Key,
                group => group.Sum(candidate => useVoteCountAsCount ? Math.Max(0, candidate.VoteCount) : 1),
                StringComparer.Ordinal);

        return
        [
            new PanseSummary(PanseDemocraticPartyLabel, counts.TryGetValue(PanseDemocraticPartyLabel, out var democraticCount) ? democraticCount : 0),
            new PanseSummary(PansePeoplePowerPartyLabel, counts.TryGetValue(PansePeoplePowerPartyLabel, out var peoplePowerCount) ? peoplePowerCount : 0),
            new PanseSummary(PanseOtherPartyLabel, counts.TryGetValue(PanseOtherPartyLabel, out var otherCount) ? otherCount : 0)
        ];
    }

    private static string ResolvePartyPanseGroup(string party)
    {
        var normalized = NormalizePansePartyKey(party);
        if (IsPanseDemocraticPartyKey(normalized))
        {
            return PanseDemocraticPartyLabel;
        }

        if (IsPansePeoplePowerPartyKey(normalized))
        {
            return PansePeoplePowerPartyLabel;
        }

        return PanseOtherPartyLabel;
    }

    private static string ResolveNormalPanseMapPartyKey(CandidateEntry candidate)
    {
        var normalized = NormalizePansePartyKey(ResolvePanseParty(candidate));
        if (IsPanseDemocraticPartyKey(normalized))
        {
            return "더불어민주당";
        }

        if (IsPansePeoplePowerPartyKey(normalized))
        {
            return "국민의힘";
        }

        foreach (var slot in NormalPanseMapPartySlots)
        {
            if (string.Equals(normalized, NormalizePansePartyKey(slot.ColorParty), StringComparison.Ordinal))
            {
                return slot.ColorParty;
            }
        }

        return "무기타";
    }

    private static bool IsPanseDemocraticPartyKey(string normalizedParty)
    {
        return string.Equals(normalizedParty, "더불어민주당", StringComparison.Ordinal) ||
               string.Equals(normalizedParty, "민주당", StringComparison.Ordinal);
    }

    private static bool IsPansePeoplePowerPartyKey(string normalizedParty)
    {
        return string.Equals(normalizedParty, "국민의힘", StringComparison.Ordinal);
    }

    private static bool IsPanseSummaryCandidate(CandidateEntry candidate)
    {
        return candidate.CandidateCode.StartsWith(PanseSummaryCandidateCodePrefix, StringComparison.OrdinalIgnoreCase);
    }

    private static string ResolvePanseParty(CandidateEntry candidate)
    {
        return FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "무소속");
    }

    private static string ResolveEducationPanseParty(CandidateEntry candidate)
    {
        var party = FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "중도");
        var normalized = NormalizePansePartyKey(party);
        if (normalized.Contains("진보", StringComparison.Ordinal))
        {
            return "진보";
        }

        if (normalized.Contains("보수", StringComparison.Ordinal))
        {
            return "보수";
        }

        if (normalized.Contains("중도", StringComparison.Ordinal))
        {
            return "중도";
        }

        return string.IsNullOrWhiteSpace(party) ? "중도" : party.Trim();
    }

    private static string ResolveCandidateAssetColorParty(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        CandidateEntry candidate)
    {
        if (IsEducationColorTemplate(template.Name, snapshot))
        {
            return ResolveEducationAssetColorParty(candidate);
        }

        return FirstNonWhiteSpace(candidate.EffectiveColorParty, candidate.Party, "무기타");
    }

    private static string ResolveEducationAssetColorParty(CandidateEntry candidate)
    {
        var normalized = NormalizePansePartyKey(FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "중도"));
        if (normalized.Contains("진보", StringComparison.Ordinal))
        {
            return "진보";
        }

        if (normalized.Contains("보수", StringComparison.Ordinal))
        {
            return "보수";
        }

        return "중도";
    }

    private static bool IsEducationColorTemplate(string templateName, ElectionDataSnapshot snapshot)
    {
        return string.Equals(snapshot.ElectionType, "교육감", StringComparison.Ordinal) ||
               templateName.Contains("교육감", StringComparison.Ordinal);
    }

    private static string NormalizePansePartyKey(string party)
    {
        return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
    }

    private static CouncilSeatSummary[] BuildCouncilSeatSummaries(ElectionDataSnapshot snapshot)
    {
        var syntheticSeatRows = snapshot.Candidates
            .Where(IsCouncilSeatSummaryCandidate)
            .ToArray();
        if (syntheticSeatRows.Length > 0)
        {
            return syntheticSeatRows
                .GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
                .Select(group =>
                {
                    var first = group.First();
                    var districtSeats = 0;
                    var proportionalSeats = 0;
                    var explicitTotalSeats = 0;
                    foreach (var candidate in group)
                    {
                        var seatCount = Math.Max(0, candidate.VoteCount);
                        switch (ResolveCouncilSeatSource(candidate))
                        {
                            case CouncilSeatSource.District:
                                districtSeats += seatCount;
                                break;
                            case CouncilSeatSource.Proportional:
                                proportionalSeats += seatCount;
                                break;
                            default:
                                explicitTotalSeats += seatCount;
                                break;
                        }
                    }

                    if (explicitTotalSeats > 0 && districtSeats + proportionalSeats == 0)
                    {
                        districtSeats = explicitTotalSeats;
                    }

                    return new CouncilSeatSummary(
                        ResolveCouncilSeatParty(first),
                        ResolveCouncilSeatColorParty(first),
                        districtSeats,
                        proportionalSeats);
                })
                .Where(row => row.TotalSeatCount > 0)
                .OrderByDescending(row => row.TotalSeatCount)
                .ThenBy(row => row.Party, StringComparer.Ordinal)
                .ToArray();
        }

        return snapshot.Candidates
            .Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
            .GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
            .Select(group =>
            {
                var first = group.First();
                var districtSeats = group.Count();
                return new CouncilSeatSummary(
                    ResolveCouncilSeatParty(first),
                    ResolveCouncilSeatColorParty(first),
                    districtSeats,
                    ProportionalSeatCount: 0);
            })
            .Where(row => row.TotalSeatCount > 0)
            .OrderByDescending(row => row.TotalSeatCount)
            .ThenBy(row => row.Party, StringComparer.Ordinal)
            .ToArray();
    }

    private static CouncilSeatSummary[] BuildCouncilSeatTableRows(ElectionDataSnapshot snapshot, int slotCount)
    {
        var sourceRows = BuildCouncilSeatSummaries(snapshot);
        var grouped = sourceRows
            .GroupBy(row => ResolveCouncilSeatTableSlotKey(row.ColorParty), StringComparer.Ordinal)
            .ToDictionary(
                group => group.Key,
                group =>
                {
                    var first = group.First();
                    return new CouncilSeatSummary(
                        first.Party,
                        first.ColorParty,
                        group.Sum(row => row.DistrictSeatCount),
                        group.Sum(row => row.ProportionalSeatCount));
                },
                StringComparer.Ordinal);

        var rows = new List<CouncilSeatSummary>(slotCount);
        foreach (var slot in NormalPanseMapPartySlots.Take(slotCount))
        {
            var key = ResolveCouncilSeatTableSlotKey(slot.ColorParty);
            rows.Add(grouped.TryGetValue(key, out var row)
                ? row with { Party = slot.Label, ColorParty = slot.ColorParty }
                : new CouncilSeatSummary(slot.Label, slot.ColorParty, 0, 0));
        }

        if (rows.Count >= slotCount)
        {
            return rows.ToArray();
        }

        var fixedKeys = NormalPanseMapPartySlots
            .Select(slot => ResolveCouncilSeatTableSlotKey(slot.ColorParty))
            .ToHashSet(StringComparer.Ordinal);
        rows.AddRange(grouped
            .Where(pair => !fixedKeys.Contains(pair.Key))
            .Select(pair => pair.Value)
            .OrderByDescending(row => row.TotalSeatCount)
            .ThenBy(row => row.Party, StringComparer.Ordinal)
            .Take(slotCount - rows.Count));

        return rows.ToArray();
    }

    private static string ResolveCouncilSeatTableSlotKey(string party)
    {
        var normalized = NormalizePansePartyKey(party);
        if (IsPanseDemocraticPartyKey(normalized))
        {
            return "더불어민주당";
        }

        if (IsPansePeoplePowerPartyKey(normalized))
        {
            return "국민의힘";
        }

        foreach (var slot in NormalPanseMapPartySlots)
        {
            if (string.Equals(normalized, NormalizePansePartyKey(slot.ColorParty), StringComparison.Ordinal) ||
                string.Equals(normalized, NormalizePansePartyKey(slot.Label), StringComparison.Ordinal))
            {
                return slot.ColorParty;
            }
        }

        return "무기타";
    }

    private static bool IsCouncilSeatSummaryCandidate(CandidateEntry candidate)
    {
        return candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase);
    }

    private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
    {
        return judgement is CandidateJudgement.Leading or
            CandidateJudgement.Confirmed or
            CandidateJudgement.Elected or
            CandidateJudgement.ElectedInProgress or
            CandidateJudgement.UnopposedElected or
            CandidateJudgement.ElectedAfterCountComplete;
    }

    private static string ResolveCouncilSeatPartyKey(CandidateEntry candidate)
    {
        var party = ResolveCouncilSeatParty(candidate);
        return string.IsNullOrWhiteSpace(party) ? "무소속" : party.Trim();
    }

    private static string ResolveCouncilSeatParty(CandidateEntry candidate)
    {
        if (!string.IsNullOrWhiteSpace(candidate.Party))
        {
            return candidate.Party.Trim();
        }

        return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
            ? "무소속"
            : candidate.EffectiveColorParty.Trim();
    }

    private static string ResolveCouncilSeatColorParty(CandidateEntry candidate)
    {
        return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
            ? ResolveCouncilSeatParty(candidate)
            : candidate.EffectiveColorParty.Trim();
    }

    private static CouncilSeatSource ResolveCouncilSeatSource(CandidateEntry candidate)
    {
        if (!candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase))
        {
            return CouncilSeatSource.District;
        }

        var suffix = candidate.CandidateCode.Substring(CouncilSeatCandidateCodePrefix.Length);
        if (suffix.StartsWith("D:", StringComparison.OrdinalIgnoreCase) ||
            suffix.StartsWith("DISTRICT:", StringComparison.OrdinalIgnoreCase) ||
            suffix.StartsWith("A:", StringComparison.OrdinalIgnoreCase))
        {
            return CouncilSeatSource.District;
        }

        if (suffix.StartsWith("P:", StringComparison.OrdinalIgnoreCase) ||
            suffix.StartsWith("PROPORTIONAL:", StringComparison.OrdinalIgnoreCase) ||
            suffix.StartsWith("B:", StringComparison.OrdinalIgnoreCase))
        {
            return CouncilSeatSource.Proportional;
        }

        return CouncilSeatSource.Total;
    }

    private static int ResolveCouncilSeatSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var maxSlot = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if ((TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) ||
                 TryParseCouncilSeatSlot(variableName, "정당바", out seatSlot)) &&
                seatSlot > maxSlot)
            {
                maxSlot = seatSlot;
            }
        }

        return maxSlot > 0 ? maxSlot : DefaultCouncilSeatSlotCount;
    }

    private static int ResolveTopPanseSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var maxSlot = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if ((TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) ||
                 MatchesIndexedVariable(variableName, "정당명") && TryParseSimpleIndexedSlot(variableName, "정당명", out seatSlot)) &&
                seatSlot > maxSlot)
            {
                maxSlot = seatSlot;
            }
        }

        return maxSlot > 0 ? maxSlot : DefaultTopPanseSlotCount;
    }

    private static int ResolveNormalPanseMapSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var maxSlot = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) &&
                seatSlot > maxSlot)
            {
                maxSlot = seatSlot;
            }
        }

        return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
    }

    private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot)
    {
        slot = 0;
        if (string.IsNullOrWhiteSpace(variableName) ||
            !variableName.StartsWith(prefix, StringComparison.Ordinal))
        {
            return false;
        }

        return int.TryParse(
            variableName.Substring(prefix.Length),
            NumberStyles.Integer,
            CultureInfo.InvariantCulture,
            out slot);
    }

    private static void ApplyCouncilSeatSlotValue(
        IDictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string prefix,
        int slot,
        string value)
    {
        var matched = false;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (!TryParseCouncilSeatSlot(variableName, prefix, out var parsedSlot) ||
                parsedSlot != slot)
            {
                continue;
            }

            values[variableName] = value;
            matched = true;
        }

        if (matched || sceneVariables.Count > 0)
        {
            return;
        }

        foreach (var suffix in CouncilSeatObjectSuffixes)
        {
            values[$"{prefix}{slot:00}{suffix}"] = value;
        }
    }

    private static bool TryParseCouncilSeatSlot(string variableName, string prefix, out int slot)
    {
        return TryParseCouncilSeatSlotColumn(variableName, prefix, out slot, out _);
    }

    private static bool TryParseCouncilSeatSlotColumn(
        string variableName,
        string prefix,
        out int slot,
        out CouncilSeatColumn column)
    {
        slot = 0;
        column = CouncilSeatColumn.Total;
        if (string.IsNullOrWhiteSpace(variableName) ||
            !variableName.StartsWith(prefix, StringComparison.Ordinal))
        {
            return false;
        }

        var suffix = variableName.Substring(prefix.Length);
        var digitLength = 0;
        while (digitLength < suffix.Length && char.IsDigit(suffix[digitLength]))
        {
            digitLength++;
        }

        if (digitLength < 2)
        {
            return false;
        }

        var slotText = digitLength >= 4
            ? suffix.Substring(digitLength - 2, 2)
            : suffix.Substring(0, 2);
        if (!int.TryParse(slotText, NumberStyles.None, CultureInfo.InvariantCulture, out slot) || slot <= 0)
        {
            return false;
        }

        column = ResolveCouncilSeatColumn(suffix.Substring(digitLength));
        return true;
    }

    private static CouncilSeatColumn ResolveCouncilSeatColumn(string suffix)
    {
        return suffix.Trim().ToUpperInvariant() switch
        {
            "A" => CouncilSeatColumn.District,
            "B" => CouncilSeatColumn.Proportional,
            _ => CouncilSeatColumn.Total
        };
    }

    private static int ResolveCouncilSeatColumnCount(CouncilSeatSummary summary, CouncilSeatColumn column)
    {
        return column switch
        {
            CouncilSeatColumn.District => summary.DistrictSeatCount,
            CouncilSeatColumn.Proportional => summary.ProportionalSeatCount,
            _ => summary.TotalSeatCount
        };
    }

    private static IReadOnlyList<KarismaStyleColorUpdate> BuildStyleColorUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsTopPanseTemplate(template))
        {
            return Array.Empty<KarismaStyleColorUpdate>();
        }

        var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
        if (IsNormalPanseMapTemplate(template))
        {
            return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
        }

        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        if (orderedCandidates.Length == 0)
        {
            return Array.Empty<KarismaStyleColorUpdate>();
        }

        var updates = new List<KarismaStyleColorUpdate>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        for (var index = 0; index < orderedCandidates.Length; index++)
        {
            var candidate = orderedCandidates[index];
            var slot = index + 1;
            var candidateAssetColorParty = ResolveCandidateAssetColorParty(template, snapshot, candidate);
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "기호", $"기호{slot:00}", $"기호{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "기호텍스트", $"기호텍스트{slot:00}", $"기호텍스트{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, ResolveCandidateNameStyleColorSection(template.Name), $"후보명{slot:00}", $"후보명{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표수바", $"득표수바{slot:00}", $"득표수바{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표수", $"득표수{slot:00}", $"득표수{slot}");
            AddStyleColorUpdates(
                updates,
                seen,
                sceneVariables,
                templateFolderPath,
                template.Name,
                candidateAssetColorParty,
                "정당바",
                !IsBottomWinnerTemplate(template),
                IsBottomWinnerTemplate(template) ? new[] { 0 } : null,
                $"정당바{slot:00}",
                $"정당바{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당판", $"정당판{slot:00}", $"정당판{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당원", $"정당원{slot:00}", $"정당원{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당색", $"정당색{slot:00}", $"정당색{slot}");
            AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당명", $"정당명{slot:00}", $"정당명{slot}");
            if (ShouldHideVoteMetrics(candidate))
            {
                AddTransparentStyleColorUpdates(updates, seen, sceneVariables, $"득표율{slot:00}", $"득표율{slot}");
            }
            else
            {
                AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표율", $"득표율{slot:00}", $"득표율{slot}");
                if (ShouldUsePartyColorShadowForCurrentLeaderVoteRate(template))
                {
                    AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당색", $"득표율{slot:00}", $"득표율{slot}");
                }
            }
            if (HasPanseGraphVariables(sceneVariables))
            {
                AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "지역명", $"득표율{slot:00}", $"득표율{slot}");
            }
        }

        return updates;
    }

    private static IReadOnlyList<KarismaStyleColorUpdate> BuildNormalPanseMapStyleColorUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        string templateFolderPath,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var updates = new List<KarismaStyleColorUpdate>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var supportedRegions = NormalPanseMapRegions.ToHashSet(StringComparer.Ordinal);

        foreach (var candidate in snapshot.Candidates.Where(candidate => !IsPanseSummaryCandidate(candidate)))
        {
            var regionName = ResolveNormalPanseMapRegionName(candidate, sceneVariables);
            if (string.IsNullOrWhiteSpace(regionName) || !supportedRegions.Contains(regionName))
            {
                continue;
            }

            AddStyleColorUpdates(
                updates,
                seen,
                sceneVariables,
                templateFolderPath,
                template.Name,
                ResolveNormalPanseMapPartyKey(candidate),
                "지역명",
                regionName);
        }

        return updates;
    }

    private static bool HasPanseGraphVariables(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        return sceneVariables.Keys.Any(variableName => MatchesIndexedVariable(variableName, "그래프"));
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildCandidateGroupVisibilityUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsNormalPanseMapTemplate(template))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        if (!template.RequiresCandidateData || IsHistoricalWinnerTemplate(template.Name) || IsCareerTemplate(template.Name))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var slotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
        if (slotCount <= 0)
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        var hideBeforeValue = new List<KarismaVisibilityUpdate>(slotCount);
        var showAfterValue = new List<KarismaVisibilityUpdate>(Math.Min(slotCount, orderedCandidates.Length));
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var hasPanseGraphVariables = HasPanseGraphVariables(sceneVariables);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetCandidateVisualVisibilityAliases(slot));
            AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetVoteMetricVisibilityAliases(slot));
            if (hasPanseGraphVariables)
            {
                AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"그래프{slot:00}", $"그래프{slot}", $"득표율{slot:00}", $"득표율{slot}");
            }
        }

        for (var index = 0; index < orderedCandidates.Length && index < slotCount; index++)
        {
            var slot = index + 1;
            AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetCandidateVisualVisibilityAliases(slot));
            if (!ShouldHideVoteMetrics(orderedCandidates[index]))
            {
                AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetVoteMetricVisibilityAliases(slot));
            }

            if (hasPanseGraphVariables)
            {
                AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"그래프{slot:00}", $"그래프{slot}", $"득표율{slot:00}", $"득표율{slot}");
            }
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static string[] GetCandidateVisualVisibilityAliases(int slot)
    {
        return
        [
            $"그룹{slot:00}",
            $"그룹{slot}",
            $"정당바{slot:00}",
            $"정당바{slot}",
            $"정당판{slot:00}",
            $"정당판{slot}",
            $"정당원{slot:00}",
            $"정당원{slot}",
            $"정당색{slot:00}",
            $"정당색{slot}",
            $"정당심볼{slot:00}",
            $"정당심볼{slot}",
            $"후보사진{slot:00}",
            $"후보사진{slot}"
        ];
    }

    private static string[] GetVoteMetricVisibilityAliases(int slot)
    {
        return
        [
            $"득표수{slot:00}",
            $"득표수{slot}",
            $"득표율{slot:00}",
            $"득표율{slot}"
        ];
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildTurnoutGroupVisibilityUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
        {
            return BuildNormalPreElectionTurnoutDistrictBoardVisibilityUpdates(snapshot, sceneVariables);
        }

        if (!IsTurnoutTemplate(template.Name) ||
            IsHistoricalTurnoutTemplate(template.Name))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var slotCount = ResolveTurnoutGroupSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var visibleSlots = snapshot.TurnoutBoardSlots.Count > 0
            ? snapshot.TurnoutBoardSlots
                .Where(entry => entry.Slot > 0 && entry.TurnoutRate > 0)
                .Select(entry => entry.Slot)
                .Distinct()
                .ToArray()
            : snapshot.TurnoutRate > 0
                ? new[] { 1 }
                : Array.Empty<int>();

        var hideBeforeValue = new List<KarismaVisibilityUpdate>(slotCount);
        var showAfterValue = new List<KarismaVisibilityUpdate>(visibleSlots.Length);
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var visibleSlotSet = new HashSet<int>(visibleSlots);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            if (!visibleSlotSet.Contains(slot))
            {
                AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetTurnoutSlotVisibilityAliases(template, slot));
            }
        }

        foreach (var slot in visibleSlots.Where(slot => slot <= slotCount))
        {
            AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetTurnoutSlotVisibilityAliases(template, slot));
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue)
        BuildNormalPreElectionTurnoutDistrictBoardVisibilityUpdates(
            ElectionDataSnapshot snapshot,
            IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveTurnoutGroupSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            slotCount = DefaultTurnoutSlotClearCount;
        }

        var slotsByNumber = snapshot.TurnoutBoardSlots
            .Where(entry => entry.Slot > 0)
            .GroupBy(entry => entry.Slot)
            .ToDictionary(group => group.Key, group => group.First());
        var hideBeforeValue = new List<KarismaVisibilityUpdate>(slotCount * 2);
        var showAfterValue = new List<KarismaVisibilityUpdate>(slotCount * 2);
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            var hasData = slotsByNumber.TryGetValue(slot, out var entry) && entry.HasTurnoutData;
            var targetUpdates = hasData ? showAfterValue : hideBeforeValue;
            var seen = hasData ? shown : hidden;
            AddVisibilityUpdate(targetUpdates, seen, $"바{slot:00}", hasData);
            AddVisibilityUpdate(targetUpdates, seen, $"바{slot}", hasData);
            AddVisibilityUpdate(targetUpdates, seen, $"투표율{slot:00}", hasData);
            AddVisibilityUpdate(targetUpdates, seen, $"투표율{slot}", hasData);
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static string[] GetTurnoutSlotVisibilityAliases(FormatTemplateDefinition template, int slot)
    {
        var aliases = new List<string>
        {
            $"그룹{slot:00}",
            $"그룹{slot}",
            $"바{slot:00}",
            $"바{slot}"
        };

        if (IsTopTurnoutDistrictBoardTemplate(template))
        {
            aliases.Add(GetTopTurnoutDistrictBoardRateAlias(slot));
            aliases.Add(GetTopTurnoutDistrictBoardDistrictAlias(slot));
            if (slot == 1)
            {
                aliases.Add("시도명01 1");
            }
        }
        else
        {
            aliases.Add($"투표율{slot:00}");
            aliases.Add($"투표율{slot}");
            aliases.Add($"선거구명{slot:00}");
            aliases.Add($"선거구명{slot}");
            aliases.Add($"시도명{slot:00}");
            aliases.Add($"시도명{slot}");
        }

        return aliases.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
    }

    private static int ResolveTurnoutGroupSlotCount(
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (!TryParseTurnoutSlotVariable(variableName, out var slot))
            {
                continue;
            }

            if (slot > 0)
            {
                slotCount = Math.Max(slotCount, slot);
            }
        }

        return slotCount;
    }

    private static bool TryParseTurnoutSlotVariable(string variableName, out int slot)
    {
        foreach (var prefix in TurnoutSlotVariablePrefixes)
        {
            if (!variableName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var suffix = variableName[prefix.Length..];
            return int.TryParse(suffix, NumberStyles.Integer, CultureInfo.InvariantCulture, out slot);
        }

        slot = 0;
        return false;
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildJudgementVisibilityUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsNormalPanseMapTemplate(template))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        var hideBeforeValue = new List<KarismaVisibilityUpdate>();
        var showAfterValue = new List<KarismaVisibilityUpdate>();
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        if (sceneVariables.Count > 0)
        {
            foreach (var variableName in sceneVariables.Keys.Where(IsJudgementVariableName).OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
            {
                AddVisibilityUpdate(hideBeforeValue, hidden, variableName, false);
            }
        }
        else
        {
            var slotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
            for (var slot = 1; slot <= slotCount; slot++)
            {
                AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetJudgementAliases(slot));
            }
        }

        for (var index = 0; index < orderedCandidates.Length; index++)
        {
            var slot = index + 1;
            var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, orderedCandidates[index].EffectiveJudgement);
            if (string.IsNullOrWhiteSpace(judgementPath))
            {
                continue;
            }

            AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetJudgementAliases(slot));
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildHistoricalWinnerVisibilityUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (!IsHistoricalWinnerTemplate(template.Name))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var slotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
        if (slotCount <= 0)
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var hideBeforeValue = new List<KarismaVisibilityUpdate>(slotCount);
        var showAfterValue = new List<KarismaVisibilityUpdate>(slotCount);
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"그룹{slot:00}", $"그룹{slot}");
        }

        var winnersBySlot = ResolveHistoricalWinnerSlotMap(snapshot, slotCount);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            if (!winnersBySlot.ContainsKey(slot))
            {
                continue;
            }

            AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"그룹{slot:00}", $"그룹{slot}");
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildCareerPromiseVisibilityUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (!IsCareerTemplate(template.Name))
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        var hideBeforeValue = new List<KarismaVisibilityUpdate>(3);
        var showAfterValue = new List<KarismaVisibilityUpdate>(3);
        var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        for (var slot = 1; slot <= 3; slot++)
        {
            AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"공약그룹{slot:00}", $"공약그룹{slot}");
        }

        var leader = orderedCandidates.FirstOrDefault();
        if (leader is null)
        {
            return (hideBeforeValue, showAfterValue);
        }

        var promises = GetVisibleCareerPromises(leader);
        for (var visibleSlot = 1; visibleSlot <= promises.Length; visibleSlot++)
        {
            var sceneSlot = ResolveCareerPromiseSceneSlot(visibleSlot);
            AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"공약그룹{sceneSlot:00}", $"공약그룹{sceneSlot}");
        }

        return (hideBeforeValue, showAfterValue);
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCounterNumberKeyUpdates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsHistoricalTurnoutTemplate(template.Name))
        {
            return BuildHistoricalTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
        }

        if (IsTurnoutTemplate(template.Name))
        {
            return BuildTurnoutCounterNumberKeyUpdates(template, snapshot, sceneVariables);
        }

        if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
        {
            return BuildCouncilSeatCounterNumberKeyUpdates(snapshot, sceneVariables);
        }

        if (IsTopPanseTemplate(template))
        {
            return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
        }

        if (IsNormalPanseMapTemplate(template))
        {
            return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
        }

        if (!IsAnimatedTemplate(template) && !HasVoteRateCounterVariables(sceneVariables))
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
        var slotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount * 2);
        var hasPanseGraphVariables = HasPanseGraphVariables(sceneVariables);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            if (hasPanseGraphVariables && slot > orderedCandidates.Length)
            {
                continue;
            }

            var variableName = $"득표율{slot:00}";
            if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
            {
                continue;
            }

            if (slot <= orderedCandidates.Length &&
                ShouldHideVoteMetrics(orderedCandidates[slot - 1]))
            {
                continue;
            }

            var voteRate = slot <= orderedCandidates.Length
                ? NormalizeRateForBroadcast(orderedCandidates[slot - 1].VoteRate)
                : 0;
            if (ShouldSendNormalAllCandidateGovernorVoteRateKeyZero(template, variableName))
            {
                updates.Add(new KarismaCounterNumberKeyUpdate(
                    variableName,
                    0,
                    0d,
                    AllowKeyZero: true,
                    AllowSetValue: true));
            }

            updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, voteRate));
        }

        return updates;
    }

    private static bool ShouldSendNormalAllCandidateGovernorVoteRateKeyZero(
        FormatTemplateDefinition template,
        string variableName)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               string.Equals(template.Name, "모든후보_광역단체장", StringComparison.Ordinal) &&
               string.Equals(variableName, "득표율01", StringComparison.Ordinal);
    }

    private static bool ShouldUseNormalAllCandidateGovernorVoteRateSetValueZero(
        FormatTemplateDefinition template,
        int slot)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               string.Equals(template.Name, "모든후보_광역단체장", StringComparison.Ordinal) &&
               slot == 1;
    }

    private static bool HasVoteRateCounterVariables(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        return sceneVariables.Values.Any(variable =>
            variable.Kind == KarismaSceneVariableKind.Counter &&
            IsVoteRateVariable(variable.Name));
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCouncilSeatCounterNumberKeyUpdates(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        var seatRows = BuildCouncilSeatTableRows(snapshot, slotCount);
        var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount * CouncilSeatObjectSuffixes.Length);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var seatRow = slot <= seatRows.Length ? seatRows[slot - 1] : default;
            AddCouncilSeatCounterSlotUpdates(updates, sceneVariables, slot, seatRow);
        }

        return updates;
    }

    private static void AddCouncilSeatCounterSlotUpdates(
        ICollection<KarismaCounterNumberKeyUpdate> updates,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        int slot,
        CouncilSeatSummary seatRow)
    {
        var matched = false;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out var column) ||
                parsedSlot != slot)
            {
                continue;
            }

            updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, ResolveCouncilSeatColumnCount(seatRow, column)));
            matched = true;
        }

        if (matched || sceneVariables.Count > 0)
        {
            return;
        }

        updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, seatRow.TotalSeatCount));
        foreach (var suffix in CouncilSeatObjectSuffixes)
        {
            var column = ResolveCouncilSeatColumn(suffix);
            updates.Add(new KarismaCounterNumberKeyUpdate(
                $"의석수{slot:00}{suffix}",
                1,
                ResolveCouncilSeatColumnCount(seatRow, column)));
        }
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTopPanseCounterNumberKeyUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var rows = BuildTopPanseSummaries(template, snapshot);
        var slotCount = ResolveTopPanseSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
            var matched = false;
            foreach (var variableName in sceneVariables.Keys)
            {
                if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out _) ||
                    parsedSlot != slot)
                {
                    continue;
                }

                updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, count));
                matched = true;
            }

            if (!matched && sceneVariables.Count == 0)
            {
                updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, count));
            }
        }

        return updates;
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildNormalPanseMapCounterNumberKeyUpdates(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var rows = BuildNormalPanseMapSummaries(snapshot);
        var slotCount = ResolveNormalPanseMapSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
            var matched = false;
            foreach (var variableName in sceneVariables.Keys)
            {
                if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out _) ||
                    parsedSlot != slot)
                {
                    continue;
                }

                updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, count));
                matched = true;
            }

            if (!matched && sceneVariables.Count == 0)
            {
                updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, count));
            }
        }

        return updates;
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var updates = new List<KarismaCounterNumberKeyUpdate>();

        void AddOrUpdate(string variableName, double numberValue)
        {
            if (!ShouldApplyCounterNumberKeyUpdate(template, variableName, sceneVariables))
            {
                return;
            }

            var update = new KarismaCounterNumberKeyUpdate(
                variableName,
                1,
                Math.Round(numberValue, 1, MidpointRounding.AwayFromZero));
            var index = updates.FindIndex(existing => string.Equals(existing.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
            if (index >= 0)
            {
                updates[index] = update;
            }
            else
            {
                updates.Add(update);
            }
        }

        var slotCount = ResolveTurnoutCounterSlotCount(sceneVariables);
        if (IsTopTurnoutDistrictBoardTemplate(template))
        {
            AddTopTurnoutDistrictBoardCounterNumberKeyUpdates(snapshot, AddOrUpdate);
            return updates;
        }

        if (snapshot.TurnoutBoardSlots.Count > 0)
        {
            var slotRates = snapshot.TurnoutBoardSlots
                .Where(entry => entry.Slot > 0)
                .GroupBy(entry => entry.Slot)
                .ToDictionary(group => group.Key, group => group.First().TurnoutRate);
            if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
            {
                for (var slot = 1; slot <= slotCount; slot++)
                {
                    var rate = slotRates.TryGetValue(slot, out var slotRate) ? slotRate : 0d;
                    AddOrUpdate($"투표율{slot:00}", rate);
                    AddOrUpdate($"투표율{slot}", rate);
                }
            }
            else
            {
                foreach (var pair in slotRates.Where(pair => pair.Value > 0).OrderBy(pair => pair.Key))
                {
                    var slot = pair.Key;
                    if (slot > slotCount)
                    {
                        continue;
                    }

                    AddOrUpdate($"투표율{slot:00}", pair.Value);
                    AddOrUpdate($"투표율{slot}", pair.Value);
                }
            }
        }
        else
        {
            AddOrUpdate("투표율", snapshot.TurnoutRate);
            AddOrUpdate("투표율01", snapshot.TurnoutRate);
            AddOrUpdate("투표율1", snapshot.TurnoutRate);
            for (var slot = 2; slot <= slotCount; slot++)
            {
                AddOrUpdate($"투표율{slot:00}", 0d);
                AddOrUpdate($"투표율{slot}", 0d);
            }
        }

        AddOrUpdate("전국투표율", snapshot.NationalTurnoutRate);
        AddOrUpdate("전국투표율01", snapshot.NationalTurnoutRate);
        AddOrUpdate("전국투표율1", snapshot.NationalTurnoutRate);

        return updates;
    }

    private static void AddTopTurnoutDistrictBoardCounterNumberKeyUpdates(
        ElectionDataSnapshot snapshot,
        Action<string, double> addOrUpdate)
    {
        var visibleSlots = snapshot.TurnoutBoardSlots
            .Where(entry => !entry.IsNational && entry.TurnoutRate > 0)
            .OrderBy(entry => entry.Slot)
            .Take(3)
            .ToArray();

        addOrUpdate("투표율01", visibleSlots.ElementAtOrDefault(0)?.TurnoutRate ?? 0d);
        addOrUpdate("투표율01 1", visibleSlots.ElementAtOrDefault(1)?.TurnoutRate ?? 0d);
        addOrUpdate("투표율01 2", visibleSlots.ElementAtOrDefault(2)?.TurnoutRate ?? 0d);
    }

    private static bool ShouldApplyCounterNumberKeyUpdate(
        FormatTemplateDefinition template,
        string variableName,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsTopTurnoutDistrictBoardTemplate(template))
        {
            return sceneVariables.Count == 0 ||
                   sceneVariables.ContainsKey(variableName) ||
                   IsTopTurnoutDistrictBoardVariable(variableName);
        }

        if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
        {
            return sceneVariables.Count == 0 ||
                   sceneVariables.ContainsKey(variableName) ||
                   MatchesIndexedVariable(variableName, "투표율");
        }

        return sceneVariables.Count == 0 ||
               sceneVariables.ContainsKey(variableName) ||
               (IsTopTurnoutDistrictBoardTemplate(template) && IsTopTurnoutDistrictBoardVariable(variableName));
    }

    private static int ResolveTurnoutCounterSlotCount(
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = DefaultTurnoutSlotClearCount;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (!variableName.StartsWith("투표율", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var suffix = variableName["투표율".Length..];
            if (int.TryParse(suffix, NumberStyles.Integer, CultureInfo.InvariantCulture, out var slot) &&
                slot > 0)
            {
                slotCount = Math.Max(slotCount, slot);
            }
        }

        return slotCount;
    }

    private static IReadOnlyList<KarismaChartCellUpdate> BuildChartCellUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (!IsHistoricalTurnoutTemplate(template.Name))
        {
            return Array.Empty<KarismaChartCellUpdate>();
        }

        if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(HistoricalTurnoutChartObjectName))
        {
            return Array.Empty<KarismaChartCellUpdate>();
        }

        var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
        var updates = new List<KarismaChartCellUpdate>(HistoricalTurnoutChartCellRowCount);
        updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 0, 0, 0f));

        for (var row = 0; row < HistoricalTurnoutChartCellRowCount; row++)
        {
            if (row is 0 or 7)
            {
                continue;
            }

            var sourceIndex = row - 1;
            var value = (float)Math.Round(rates[sourceIndex], 1, MidpointRounding.AwayFromZero);
            updates.Add(new KarismaChartCellUpdate(
                HistoricalTurnoutChartObjectName,
                row,
                0,
                value));
        }

        updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 7, 0, 100f));
        return updates;
    }

    private static IReadOnlyList<KarismaPositionUpdate> BuildPositionUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
        {
            return BuildNormalPreElectionTurnoutDistrictBoardPositionUpdates(snapshot, sceneVariables);
        }

        if (IsHistoricalWinnerTemplate(template.Name))
        {
            return BuildHistoricalWinnerPositionUpdates(snapshot, sceneVariables);
        }

        if (!IsHistoricalTurnoutTemplate(template.Name))
        {
            return Array.Empty<KarismaPositionUpdate>();
        }

        var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
        var slotCount = Math.Min(HistoricalTurnoutSlotCount, rates.Length);
        var updates = new List<KarismaPositionUpdate>(slotCount);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var groupName = $"{HistoricalTurnoutGroupObjectPrefix}{slot:00}";
            updates.Add(new KarismaPositionUpdate(
                groupName,
                0f,
                ResolveHistoricalTurnoutGroupY(rates[slot - 1]),
                0f,
                eKVectorType.VECTOR_TYPE_Y,
                HistoricalTurnoutLastPositionKeyIndex));
        }

        return updates;
    }

    private static IReadOnlyList<KarismaCropKeyUpdate> BuildCropKeyUpdates(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
        {
            return Array.Empty<KarismaCropKeyUpdate>();
        }

        var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaCropKeyUpdate>();
        }

        var rows = BuildCouncilSeatTableRows(snapshot, slotCount);
        var totalByColumn = CouncilSeatObjectSuffixes
            .Select(ResolveCouncilSeatColumn)
            .ToDictionary(
                column => column,
                column => rows.Sum(row => ResolveCouncilSeatColumnCount(row, column)));
        var updates = new List<KarismaCropKeyUpdate>(slotCount * CouncilSeatObjectSuffixes.Length);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            var row = slot <= rows.Length ? rows[slot - 1] : default;
            AddCouncilSeatBarCropUpdates(updates, sceneVariables, slot, row, totalByColumn);
        }

        return updates;
    }

    private static void AddCouncilSeatBarCropUpdates(
        ICollection<KarismaCropKeyUpdate> updates,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        int slot,
        CouncilSeatSummary seatRow,
        IReadOnlyDictionary<CouncilSeatColumn, int> totalByColumn)
    {
        var matched = false;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (!TryParseCouncilSeatSlotColumn(variableName, "정당바", out var parsedSlot, out var column) ||
                parsedSlot != slot)
            {
                continue;
            }

            updates.Add(CreateCouncilSeatBarCropUpdate(variableName, seatRow, column, totalByColumn));
            matched = true;
        }

        if (matched || sceneVariables.Count > 0)
        {
            return;
        }

        foreach (var suffix in CouncilSeatObjectSuffixes)
        {
            var column = ResolveCouncilSeatColumn(suffix);
            updates.Add(CreateCouncilSeatBarCropUpdate($"정당바{slot:00}{suffix}", seatRow, column, totalByColumn));
        }
    }

    private static KarismaCropKeyUpdate CreateCouncilSeatBarCropUpdate(
        string objectName,
        CouncilSeatSummary seatRow,
        CouncilSeatColumn column,
        IReadOnlyDictionary<CouncilSeatColumn, int> totalByColumn)
    {
        var value = ResolveCouncilSeatColumnCount(seatRow, column);
        var totalValue = totalByColumn.TryGetValue(column, out var total) ? total : 0;
        return new KarismaCropKeyUpdate(
            objectName,
            CouncilSeatBarCropKeyIndex,
            0f,
            0f,
            ResolveCouncilSeatBarRightCrop(value, totalValue),
            0f,
            eKCropKey.CROP_KEY_RIGHT);
    }

    private static float ResolveCouncilSeatBarRightCrop(int value, int totalValue)
    {
        if (totalValue <= 0 || value <= 0)
        {
            return CouncilSeatBarCropMin;
        }

        var ratio = Math.Clamp(value / (double)totalValue, 0d, 1d);
        return (float)(CouncilSeatBarCropMin + ratio * (CouncilSeatBarCropMax - CouncilSeatBarCropMin));
    }

    private static IReadOnlyList<KarismaPositionUpdate> BuildNormalPreElectionTurnoutDistrictBoardPositionUpdates(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveTurnoutGroupSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            slotCount = DefaultTurnoutSlotClearCount;
        }

        var ratesBySlot = snapshot.TurnoutBoardSlots
            .Where(entry => entry.Slot > 0)
            .GroupBy(entry => entry.Slot)
            .ToDictionary(group => group.Key, group => group.First().TurnoutRate);
        var updates = new List<KarismaPositionUpdate>(slotCount);
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        for (var slot = 1; slot <= slotCount; slot++)
        {
            var turnoutRate = ratesBySlot.TryGetValue(slot, out var rate) ? rate : 0d;
            var y = ResolveNormalPreElectionTurnoutBarY(turnoutRate);
            AddNormalPreElectionTurnoutBarPositionUpdate(updates, seen, sceneVariables, $"바{slot:00}", y);
            AddNormalPreElectionTurnoutBarPositionUpdate(updates, seen, sceneVariables, $"바{slot}", y);
        }

        return updates;
    }

    private static void AddNormalPreElectionTurnoutBarPositionUpdate(
        ICollection<KarismaPositionUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string objectName,
        float y)
    {
        if (!seen.Add(objectName))
        {
            return;
        }

        updates.Add(new KarismaPositionUpdate(
            objectName,
            0f,
            y,
            0f,
            eKVectorType.VECTOR_TYPE_Y,
            NormalPreElectionTurnoutBarPositionKeyIndex));
    }

    private static IReadOnlyList<KarismaPositionUpdate> BuildHistoricalWinnerPositionUpdates(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveHistoricalWinnerPositionSlotCount(sceneVariables);
        if (slotCount <= 0)
        {
            slotCount = DefaultCandidateSlotClearCount;
        }

        slotCount = Math.Min(slotCount, HistoricalWinnerSlotBaseX.Length);
        if (slotCount <= 0)
        {
            return Array.Empty<KarismaPositionUpdate>();
        }

        var visibleSlots = snapshot.HistoricalWinnerHistory
            .Where(entry => entry.ElectionOrder > 0 &&
                            entry.ElectionOrder <= slotCount &&
                            !string.IsNullOrWhiteSpace(entry.Name))
            .GroupBy(entry => entry.ElectionOrder)
            .Select(group => group.First().ElectionOrder)
            .OrderBy(slot => slot)
            .Take(slotCount)
            .ToArray();
        if (visibleSlots.Length == 0)
        {
            return Array.Empty<KarismaPositionUpdate>();
        }

        var startSlot = Math.Max(1, ((slotCount - visibleSlots.Length) / 2) + 1);
        var updates = new List<KarismaPositionUpdate>(visibleSlots.Length);
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        for (var index = 0; index < visibleSlots.Length; index++)
        {
            var sourceSlot = visibleSlots[index];
            var targetSlot = startSlot + index;
            if (targetSlot is < 1 || targetSlot > HistoricalWinnerSlotBaseX.Length)
            {
                continue;
            }

            var x = HistoricalWinnerSlotBaseX[targetSlot - 1];
            AddHistoricalWinnerGroupPositionUpdate(updates, seen, sceneVariables, $"그룹{sourceSlot:00}", x);
            AddHistoricalWinnerGroupPositionUpdate(updates, seen, sceneVariables, $"그룹{sourceSlot}", x);
        }

        return updates;
    }

    private static int ResolveHistoricalWinnerPositionSlotCount(
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (TryParseSimpleIndexedSlot(variableName, "그룹", out var slot) && slot > 0)
            {
                slotCount = Math.Max(slotCount, slot);
            }
        }

        return slotCount;
    }

    private static void AddHistoricalWinnerGroupPositionUpdate(
        ICollection<KarismaPositionUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string objectName,
        float x)
    {
        if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
        {
            return;
        }

        if (!seen.Add(objectName))
        {
            return;
        }

        updates.Add(new KarismaPositionUpdate(
            objectName,
            x,
            0f,
            0f,
            eKVectorType.VECTOR_TYPE_X));
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildHistoricalTurnoutCounterNumberKeyUpdates(
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var updates = new List<KarismaCounterNumberKeyUpdate>(HistoricalTurnoutSlotCount);
        for (var slot = 1; slot <= HistoricalTurnoutSlotCount; slot++)
        {
            var variableName = $"투표율{slot:00}";
            updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, 0));
        }

        var orderedTurnout = snapshot.HistoricalTurnoutHistory
            .OrderBy(entry => entry.Year)
            .ToArray();
        foreach (var turnout in orderedTurnout)
        {
            var slot = turnout.ElectionOrder - 2;
            if (slot is < 1 or > HistoricalTurnoutSlotCount)
            {
                continue;
            }

            var variableName = $"투표율{slot:00}";
            var index = updates.FindIndex(update => string.Equals(update.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
            var update = new KarismaCounterNumberKeyUpdate(
                variableName,
                1,
                Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero));

            if (index >= 0)
            {
                updates[index] = update;
            }
            else
            {
                updates.Add(update);
            }
        }

        return updates;
    }

    private static double[] ResolveHistoricalTurnoutSlotRates(ElectionDataSnapshot snapshot)
    {
        var rates = new double[HistoricalTurnoutSlotCount];
        foreach (var turnout in snapshot.HistoricalTurnoutHistory)
        {
            var slot = turnout.ElectionOrder - 2;
            if (slot is < 1 or > HistoricalTurnoutSlotCount)
            {
                continue;
            }

            rates[slot - 1] = Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero);
        }

        return rates;
    }

    private static float ResolveHistoricalTurnoutGroupY(double turnoutRate)
    {
        var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
        return HistoricalTurnoutGroupBaseY -
            (float)((100d - clampedRate) * HistoricalTurnoutYUnitsPerPercent);
    }

    private static float ResolveNormalPreElectionTurnoutBarY(double turnoutRate)
    {
        var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
        return NormalPreElectionTurnoutBarEmptyY +
            (float)(clampedRate / 100d * (NormalPreElectionTurnoutBarFullY - NormalPreElectionTurnoutBarEmptyY));
    }

    private void LogTurnoutValuePayload(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        KarismaResolvedScene resolvedScene,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        IReadOnlyDictionary<string, string> values,
        IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys)
    {
        if (!IsTurnoutTemplate(template.Name))
        {
            return;
        }

        var sceneName = Path.GetFileName(resolvedScene.Path);
        var turnoutText = values.TryGetValue("투표율01", out var turnoutValue) ? turnoutValue : "(missing)";
        var nationalText = values.TryGetValue("전국투표율01", out var nationalValue) ? nationalValue : "(missing)";
        var referenceText = values.TryGetValue("기준시01", out var referenceValue)
            ? referenceValue
            : values.TryGetValue("기준시", out referenceValue)
                ? referenceValue
                : "(missing)";
        var turnoutCounter = ResolveCounterPreview(counterNumberKeys, "투표율01");
        var nationalCounter = ResolveCounterPreview(counterNumberKeys, "전국투표율01");
        var hasTurnoutObject = sceneVariables.Count == 0 || sceneVariables.ContainsKey("투표율01");
        var hasNationalObject = sceneVariables.Count == 0 || sceneVariables.ContainsKey("전국투표율01");
        var provinceText = values.TryGetValue("시도명01", out var provinceValue)
            ? provinceValue
            : values.TryGetValue("시도명01 1", out provinceValue)
                ? provinceValue
                : string.Empty;
        var slotPreview = snapshot.TurnoutBoardSlots.Count == 0
            ? string.Empty
            : string.Join(", ", snapshot.TurnoutBoardSlots
                .OrderBy(slot => slot.Slot)
                .Select(slot => $"{slot.Slot}:{slot.RegionLabel}/{slot.Label}={slot.TurnoutRate:0.0}"));

        _logService.Info(
            $"[{channel}] Turnout payload {template.Name}: " +
            $"scene={sceneName}, " +
            $"snapshot={snapshot.TurnoutRate:0.0}/{snapshot.NationalTurnoutRate:0.0}, " +
            $"province={provinceText}, slots=[{slotPreview}], " +
            $"text 투표율01={turnoutText}, 전국투표율01={nationalText}, 기준시01={referenceText}, " +
            $"counter 투표율01={turnoutCounter}, 전국투표율01={nationalCounter}, " +
            $"sceneVars 투표율01={(hasTurnoutObject ? "Y" : "N")}, 전국투표율01={(hasNationalObject ? "Y" : "N")}");
    }

    private static string ResolveCounterPreview(
        IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
        string objectName)
    {
        var update = counterNumberKeys.FirstOrDefault(item =>
            string.Equals(item.ObjectName, objectName, StringComparison.OrdinalIgnoreCase));
        return string.IsNullOrWhiteSpace(update.ObjectName)
            ? "(missing)"
            : update.Number.ToString("0.0", CultureInfo.InvariantCulture);
    }

    private void LogCutDebugSummary(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        CutDebugSettingsSnapshot cutDebug,
        int originalValueCount,
        int filteredValueCount,
        IEnumerable<string> filteredValueKeys,
        int originalCounterCount,
        int filteredCounterCount,
        IEnumerable<string> filteredCounterKeys,
        int originalStyleCount,
        int filteredStyleCount,
        IEnumerable<string> filteredStyleKeys,
        int originalVisibilityCount,
        int filteredVisibilityCount,
        IEnumerable<string> filteredVisibilityKeys)
    {
        if (!cutDebug.IsEnabled)
        {
            return;
        }

        var valuePreview = BuildDebugKeyPreview(filteredValueKeys);
        var counterPreview = BuildDebugKeyPreview(filteredCounterKeys);
        var stylePreview = BuildDebugKeyPreview(filteredStyleKeys);
        var visibilityPreview = BuildDebugKeyPreview(filteredVisibilityKeys);

        _logService.Info(
            $"[{channel}] Cut debug active {template.Name}/{cut.Name}: " +
            $"text={(cutDebug.ApplyTextValues ? "ON" : "OFF")}, " +
            $"image={(cutDebug.ApplyImageValues ? "ON" : "OFF")}, " +
            $"visibility={(cutDebug.ApplyVisibilityValues ? "ON" : "OFF")}, " +
            $"voteRateText={(cutDebug.ApplyTextValues && cutDebug.ApplyVoteRateTextValues ? "ON" : "OFF")}, " +
            $"voteRateCounter={(cutDebug.ApplyVoteRateCounterValues ? "ON" : "OFF")}, " +
            $"partyBarColor={(cutDebug.ApplyPartyBarStyleColors ? "ON" : "OFF")}, " +
            $"partyPlateColor={(cutDebug.ApplyPartyPlateStyleColors ? "ON" : "OFF")}, " +
            $"voteRateColor={(cutDebug.ApplyVoteRateStyleColors ? "ON" : "OFF")} | " +
            $"values {filteredValueCount}/{originalValueCount}, " +
            $"counters {filteredCounterCount}/{originalCounterCount}, " +
            $"styles {filteredStyleCount}/{originalStyleCount}, " +
            $"visibility {filteredVisibilityCount}/{originalVisibilityCount} | " +
            $"valueKeys={valuePreview} | " +
            $"counterKeys={counterPreview} | " +
            $"styleKeys={stylePreview} | " +
            $"visibilityKeys={visibilityPreview}");
    }

    private static string BuildDebugKeyPreview(IEnumerable<string> keys)
    {
        var orderedKeys = keys
            .Where(key => !string.IsNullOrWhiteSpace(key))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
            .ToArray();

        if (orderedKeys.Length == 0)
        {
            return "(none)";
        }

        const int previewLimit = 8;
        return orderedKeys.Length <= previewLimit
            ? string.Join(", ", orderedKeys)
            : $"{string.Join(", ", orderedKeys.Take(previewLimit))}, ... (+{orderedKeys.Length - previewLimit})";
    }

    private void LogCutDebugOverrides(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        CutDebugSettingsSnapshot cutDebug,
        IReadOnlyDictionary<string, string> filteredValues,
        IReadOnlyDictionary<string, string> overriddenValues,
        IReadOnlyList<KarismaCounterNumberKeyUpdate> filteredCounterNumberKeys,
        IReadOnlyList<KarismaCounterNumberKeyUpdate> overriddenCounterNumberKeys)
    {
        if (!cutDebug.IsEnabled)
        {
            return;
        }

        var valueChanges = overriddenValues
            .Where(pair =>
                !filteredValues.TryGetValue(pair.Key, out var originalValue) ||
                !string.Equals(originalValue, pair.Value, StringComparison.Ordinal))
            .Select(pair => $"{pair.Key}={TrimDebugValue(pair.Value)}")
            .Take(8)
            .ToArray();

        var filteredCounterLookup = filteredCounterNumberKeys.ToDictionary(
            update => $"{update.ObjectName}|{update.KeyIndex}",
            update => update.Number,
            StringComparer.OrdinalIgnoreCase);
        var counterChanges = overriddenCounterNumberKeys
            .Where(update =>
            {
                var lookupKey = $"{update.ObjectName}|{update.KeyIndex}";
                return !filteredCounterLookup.TryGetValue(lookupKey, out var originalNumber) ||
                       Math.Abs(originalNumber - update.Number) > 0.0001d;
            })
            .Select(update => $"{update.ObjectName}#{update.KeyIndex}={update.Number:0.###}")
            .Take(8)
            .ToArray();

        _logService.Info(
            $"[{channel}] Cut debug overrides {template.Name}: " +
            $"values={(valueChanges.Length == 0 ? "(none)" : string.Join(", ", valueChanges))} | " +
            $"counters={(counterChanges.Length == 0 ? "(none)" : string.Join(", ", counterChanges))}");
    }

    private static string TrimDebugValue(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var fileName = Path.GetFileName(value);
        if (!string.IsNullOrWhiteSpace(fileName))
        {
            value = fileName;
        }

        return value.Length <= 48
            ? value
            : $"{value[..45]}...";
    }

    private static Dictionary<string, string> FilterObjectValues(
        IReadOnlyDictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
        }

        var filtered = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var pair in values)
        {
            if (!ShouldApplyObjectValue(pair.Key, sceneVariables, cutDebug, templateDebug))
            {
                continue;
            }

            filtered[pair.Key] = pair.Value;
        }

        return filtered;
    }

    private static Dictionary<string, string> RemoveEmptyResourceValues(
        IReadOnlyDictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var filtered = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var pair in values)
        {
            if (string.IsNullOrWhiteSpace(pair.Value) &&
                sceneVariables.TryGetValue(pair.Key, out var variableDefinition) &&
                variableDefinition.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource)
            {
                continue;
            }

            filtered[pair.Key] = pair.Value;
        }

        return filtered;
    }

    private static Dictionary<string, string> FilterValuesBySceneFile(
        IReadOnlyDictionary<string, string> values,
        string scenePath)
    {
        if (values.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
        }

        if (!values.Keys.Any(key => SceneFileContainsVariableName(sceneText, key)))
        {
            return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }

        return values
            .Where(pair => SceneFileContainsVariableName(sceneText, pair.Key))
            .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
    }

    private static bool SceneFileContainsVariableName(string sceneText, string variableName)
    {
        if (string.IsNullOrWhiteSpace(variableName))
        {
            return false;
        }

        var searchIndex = 0;
        while (searchIndex < sceneText.Length)
        {
            var matchIndex = sceneText.IndexOf(variableName, searchIndex, StringComparison.Ordinal);
            if (matchIndex < 0)
            {
                return false;
            }

            var beforeIndex = matchIndex - 1;
            var afterIndex = matchIndex + variableName.Length;
            if (IsSceneObjectNameBoundary(sceneText, beforeIndex) &&
                IsSceneObjectNameBoundary(sceneText, afterIndex))
            {
                return true;
            }

            if (IsSceneBinaryStringMatch(sceneText, matchIndex, variableName.Length))
            {
                return true;
            }

            searchIndex = matchIndex + variableName.Length;
        }

        return false;
    }

    private static bool IsSceneBinaryStringMatch(string sceneText, int matchIndex, int nameLength)
    {
        if (nameLength <= 0 || matchIndex < 4)
        {
            return false;
        }

        var byteLength = nameLength * 2;
        return sceneText[matchIndex - 3] == '\0' &&
               sceneText[matchIndex - 2] == nameLength &&
               sceneText[matchIndex - 1] == '\0' &&
               (sceneText[matchIndex - 4] == byteLength ||
                sceneText[matchIndex - 4] == '\u0004');
    }

    private static bool IsSceneObjectNameBoundary(string sceneText, int index)
    {
        if (index < 0 || index >= sceneText.Length)
        {
            return true;
        }

        var value = sceneText[index];
        return !char.IsLetterOrDigit(value) && value != '_';
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> FilterCounterNumberKeyUpdatesBySceneFile(
        IReadOnlyList<KarismaCounterNumberKeyUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaCounterNumberKeyUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> UseFinalVoteRateCounterValuesOnly(
        IReadOnlyList<KarismaCounterNumberKeyUpdate> updates)
    {
        if (updates.Count == 0)
        {
            return updates;
        }

        var filtered = updates
            .Where(update => update.KeyIndex != 0 ||
                             update.AllowKeyZero ||
                             !IsCandidateVoteRateVariable(update.ObjectName))
            .ToArray();
        return filtered.Length == updates.Count ? updates : filtered;
    }

    private static bool IsCandidateVoteRateVariable(string variableName)
    {
        return string.Equals(variableName, "득표율", StringComparison.Ordinal) ||
            MatchesIndexedVariable(variableName, "득표율");
    }

    private static IReadOnlyList<KarismaChartCellUpdate> FilterChartCellUpdatesBySceneFile(
        IReadOnlyList<KarismaChartCellUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaChartCellUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaPositionUpdate> FilterPositionUpdatesBySceneFile(
        IReadOnlyList<KarismaPositionUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaPositionUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaCropKeyUpdate> FilterCropKeyUpdatesBySceneFile(
        IReadOnlyList<KarismaCropKeyUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaCropKeyUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaStyleColorUpdate> FilterStyleColorUpdatesBySceneFile(
        IReadOnlyList<KarismaStyleColorUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaStyleColorUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaVisibilityUpdate> FilterVisibilityUpdatesBySceneFile(
        IReadOnlyList<KarismaVisibilityUpdate> updates,
        string scenePath)
    {
        if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
        {
            return updates;
        }

        string sceneText;
        try
        {
            sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
        }
        catch
        {
            return updates;
        }

        if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
        {
            return Array.Empty<KarismaVisibilityUpdate>();
        }

        return updates
            .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
            .ToArray();
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> FilterCounterNumberKeyUpdates(
        IReadOnlyList<KarismaCounterNumberKeyUpdate> updates,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return updates;
        }

        return updates
            .Where(update =>
            {
                if (cutDebug.IsEnabled &&
                    !cutDebug.ApplyVoteRateCounterValues &&
                    IsVoteRateVariable(update.ObjectName) &&
                    !IsCommonTurnoutValueVariable(update.ObjectName))
                {
                    return false;
                }

                return templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Counter);
            })
            .ToArray();
    }

    private static IReadOnlyList<KarismaStyleColorUpdate> FilterStyleColorUpdates(
        IReadOnlyList<KarismaStyleColorUpdate> updates,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return updates;
        }

        return updates
            .Where(update => ShouldApplyStyleColor(update.ObjectName, cutDebug, templateDebug))
            .ToArray();
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) FilterVisibilityUpdatePair(
        (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) updates,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return updates;
        }

        if (cutDebug.IsEnabled && !cutDebug.ApplyVisibilityValues)
        {
            return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
        }

        return (
            updates.HideBeforeValue
                .Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
                .ToArray(),
            updates.ShowAfterValue
                .Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
                .ToArray());
    }

    private static Dictionary<string, string> ApplyObjectValueOverrides(
        IReadOnlyDictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugTemplateState? templateDebug)
    {
        var result = new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
        if (templateDebug is null)
        {
            return result;
        }

        foreach (var item in templateDebug.Items)
        {
            if (item.Kind is not (CutDebugItemKind.TextValue or CutDebugItemKind.ImageValue) ||
                !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
                string.IsNullOrWhiteSpace(overrideValue.StringValue))
            {
                continue;
            }

            var targets = result.Keys
                .Where(key => MatchesDebugItemKey(key, item.Key))
                .ToArray();
            if (targets.Length == 0)
            {
                targets = sceneVariables.Keys
                    .Where(key => MatchesDebugItemKey(key, item.Key))
                    .ToArray();
            }

            if (targets.Length == 0)
            {
                targets = [item.Key];
            }

            foreach (var target in targets)
            {
                result[target] = overrideValue.StringValue;
            }
        }

        return result;
    }

    private static IReadOnlyList<KarismaCounterNumberKeyUpdate> ApplyCounterNumberKeyOverrides(
        IReadOnlyList<KarismaCounterNumberKeyUpdate> updates,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugTemplateState? templateDebug)
    {
        if (templateDebug is null)
        {
            return updates;
        }

        var result = updates.ToList();
        foreach (var item in templateDebug.Items)
        {
            if (item.Kind != CutDebugItemKind.Counter ||
                !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
                overrideValue.NumberValue is not double numberValue)
            {
                continue;
            }

            var matched = false;
            for (var index = 0; index < result.Count; index++)
            {
                if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
                {
                    continue;
                }

                result[index] = new KarismaCounterNumberKeyUpdate(
                    result[index].ObjectName,
                    result[index].KeyIndex,
                    numberValue,
                    result[index].AllowKeyZero,
                    result[index].AllowSetValue);
                matched = true;
            }

            if (matched)
            {
                continue;
            }

            var sceneTargets = sceneVariables.Keys
                .Where(key => MatchesDebugItemKey(key, item.Key))
                .ToArray();
            foreach (var target in sceneTargets)
            {
                result.Add(new KarismaCounterNumberKeyUpdate(target, 1, numberValue));
                matched = true;
            }

            if (!matched)
            {
                result.Add(new KarismaCounterNumberKeyUpdate(item.Key, 1, numberValue));
            }
        }

        return result;
    }

    private static IReadOnlyList<KarismaStyleColorUpdate> ApplyStyleColorOverrides(
        IReadOnlyList<KarismaStyleColorUpdate> updates,
        CutDebugTemplateState? templateDebug)
    {
        if (templateDebug is null)
        {
            return updates;
        }

        var result = updates.ToList();
        foreach (var item in templateDebug.Items)
        {
            if (item.Kind != CutDebugItemKind.StyleColor ||
                !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue))
            {
                continue;
            }

            for (var index = 0; index < result.Count; index++)
            {
                if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
                {
                    continue;
                }

                result[index] = result[index] with
                {
                    R = overrideValue.R,
                    G = overrideValue.G,
                    B = overrideValue.B,
                    A = overrideValue.A
                };
            }
        }

        return result;
    }

    private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) ApplyVisibilityOverrides(
        (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) updates,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugTemplateState? templateDebug)
    {
        if (templateDebug is null)
        {
            return updates;
        }

        var hide = updates.HideBeforeValue.ToList();
        var show = updates.ShowAfterValue.ToList();
        foreach (var item in templateDebug.Items)
        {
            if (item.Kind != CutDebugItemKind.Visibility ||
                !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
                overrideValue.BooleanValue is not bool isVisible)
            {
                continue;
            }

            var targets = hide.Select(update => update.ObjectName)
                .Concat(show.Select(update => update.ObjectName))
                .Concat(sceneVariables.Keys)
                .Where(key => MatchesDebugItemKey(key, item.Key))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToArray();

            hide.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
            show.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
            if (targets.Length == 0)
            {
                targets = [item.Key];
            }

            var targetList = isVisible ? show : hide;
            foreach (var target in targets)
            {
                targetList.Add(new KarismaVisibilityUpdate(target, isVisible));
            }
        }

        return (hide, show);
    }

    private static bool MatchesDebugItemKey(string candidateKey, string debugKey)
    {
        return string.Equals(
            CutDebugTemplateState.NormalizeKey(candidateKey),
            CutDebugTemplateState.NormalizeKey(debugKey),
            StringComparison.OrdinalIgnoreCase);
    }

    private static bool ShouldApplyObjectValue(
        string variableName,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return true;
        }

        var hasDefinition = sceneVariables.TryGetValue(variableName, out var variableDefinition);
        var isImageValue =
            (hasDefinition && variableDefinition is not null &&
             variableDefinition.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource) ||
            (!hasDefinition && IsImageValueVariable(variableName));

        if (isImageValue)
        {
            return (!cutDebug.IsEnabled || cutDebug.ApplyImageValues) &&
                (templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.ImageValue));
        }

        if (cutDebug.IsEnabled && !cutDebug.ApplyTextValues)
        {
            return false;
        }

        if (cutDebug.IsEnabled &&
            IsVoteRateVariable(variableName) &&
            !cutDebug.ApplyVoteRateTextValues &&
            !IsCommonTurnoutValueVariable(variableName))
        {
            return false;
        }

        return templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.TextValue);
    }

    private static bool ShouldApplyStyleColor(
        string objectName,
        CutDebugSettingsSnapshot cutDebug,
        CutDebugTemplateState? templateDebug)
    {
        if (!cutDebug.IsEnabled)
        {
            return true;
        }

        if (templateDebug is not null && !templateDebug.IsEnabled(objectName, CutDebugItemKind.StyleColor))
        {
            return false;
        }

        if (cutDebug.IsEnabled && IsVoteRateVariable(objectName))
        {
            return cutDebug.ApplyVoteRateStyleColors;
        }

        if (cutDebug.IsEnabled && IsPartyBarStyleObjectName(objectName))
        {
            return cutDebug.ApplyPartyBarStyleColors;
        }

        if (cutDebug.IsEnabled && IsPartyPlateStyleObjectName(objectName))
        {
            return cutDebug.ApplyPartyPlateStyleColors;
        }

        return true;
    }

    private static bool IsVoteRateVariable(string variableName)
    {
        return string.Equals(variableName, "득표율", StringComparison.Ordinal) ||
            string.Equals(variableName, "투표율", StringComparison.Ordinal) ||
            string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
            MatchesIndexedVariable(variableName, "득표율") ||
            MatchesIndexedVariable(variableName, "투표율") ||
            MatchesIndexedVariable(variableName, "전국투표율") ||
            TryParseCouncilSeatSlot(variableName, "의석수", out _);
    }

    private static bool IsImageValueVariable(string variableName)
    {
        return IsJudgementVariableName(variableName) ||
            TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
            MatchesIndexedVariable(variableName, "후보사진") ||
            MatchesIndexedVariable(variableName, "득표수바") ||
            MatchesIndexedVariable(variableName, "정당바") ||
            MatchesIndexedVariable(variableName, "정당판") ||
            MatchesIndexedVariable(variableName, "정당원") ||
            MatchesIndexedVariable(variableName, "정당색") ||
            MatchesIndexedVariable(variableName, "정당심볼") ||
            MatchesIndexedVariable(variableName, "공약그룹") ||
            MatchesIndexedVariable(variableName, "그룹") ||
            MatchesIndexedVariable(variableName, "바");
    }

    private static bool IsPartyBarStyleObjectName(string variableName)
    {
        return MatchesIndexedVariable(variableName, "득표수바") ||
            MatchesIndexedVariable(variableName, "정당바");
    }

    private static bool IsPartyPlateStyleObjectName(string variableName)
    {
        return MatchesIndexedVariable(variableName, "기호") ||
            MatchesIndexedVariable(variableName, "기호텍스트") ||
            MatchesIndexedVariable(variableName, "정당판") ||
            MatchesIndexedVariable(variableName, "정당원") ||
            MatchesIndexedVariable(variableName, "정당색") ||
            MatchesIndexedVariable(variableName, "정당명");
    }

    private static CandidateEntry[] GetOrderedCandidates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (IsHistoricalWinnerTemplate(template.Name))
        {
            return BuildHistoricalWinnerCandidates(template, cut, snapshot, sceneVariables);
        }

        CandidateEntry[] orderedCandidates;
        if (IsCurrentLeaderTemplate(template.Name))
        {
            orderedCandidates = snapshot.Candidates.ToArray();
        }
        else if (IsWinnerTemplate(template.Name))
        {
            orderedCandidates = snapshot.Candidates
                .Where(candidate => IsElectedJudgement(candidate.EffectiveJudgement))
                .ToArray();
        }
        else if (IsCareerTemplate(template.Name))
        {
            orderedCandidates = snapshot.Candidates
                .Select(candidate => new
                {
                    Candidate = candidate,
                    SortKey = BuildCareerCandidateSortKey(candidate)
                })
                .OrderBy(entry => entry.SortKey.Group)
                .ThenBy(entry => entry.SortKey.PrimaryNumber)
                .ThenBy(entry => entry.SortKey.Suffix, StringComparer.Ordinal)
                .ThenBy(entry => entry.SortKey.OriginalText, StringComparer.Ordinal)
                .ThenBy(entry => entry.Candidate.Name, StringComparer.Ordinal)
                .Select(entry => entry.Candidate)
                .ToArray();
        }
        else if (IsAllCandidateTemplate(template.Name))
        {
            orderedCandidates = snapshot.Candidates
                .Select(candidate => new
                {
                    Candidate = candidate,
                    SortKey = BuildBallotCandidateSortKey(candidate)
                })
                .OrderBy(entry => entry.SortKey.Group)
                .ThenBy(entry => entry.SortKey.PrimaryNumber)
                .ThenBy(entry => entry.SortKey.Suffix, StringComparer.Ordinal)
                .ThenBy(entry => entry.SortKey.OriginalText, StringComparer.Ordinal)
                .ThenBy(entry => entry.Candidate.Name, StringComparer.Ordinal)
                .Select(entry => entry.Candidate)
                .ToArray();
        }
        else
        {
            orderedCandidates = snapshot.Candidates
                .OrderByDescending(candidate => candidate.VoteCount)
                .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
                .ToArray();
        }

        if (cut.CandidateStartIndex > 0)
        {
            orderedCandidates = orderedCandidates
                .Skip(cut.CandidateStartIndex)
                .ToArray();
        }

        var maxCandidateSlots = ResolveMaxCandidateSlots(template, cut, sceneVariables);
        if (maxCandidateSlots > 0 && orderedCandidates.Length > maxCandidateSlots)
        {
            orderedCandidates = orderedCandidates.Take(maxCandidateSlots).ToArray();
        }

        return orderedCandidates;
    }

    private static (int Group, int PrimaryNumber, string Suffix, string OriginalText) BuildBallotCandidateSortKey(CandidateEntry candidate)
    {
        var ballotNumber = ResolveBallotNumberDisplay(candidate).Trim();
        if (string.IsNullOrWhiteSpace(ballotNumber))
        {
            return (2, int.MaxValue, string.Empty, string.Empty);
        }

        var numericPrefixLength = 0;
        while (numericPrefixLength < ballotNumber.Length && char.IsDigit(ballotNumber[numericPrefixLength]))
        {
            numericPrefixLength++;
        }

        if (numericPrefixLength == 0)
        {
            return (1, int.MaxValue, ballotNumber, ballotNumber);
        }

        var primaryNumberText = ballotNumber[..numericPrefixLength];
        if (!int.TryParse(primaryNumberText, NumberStyles.None, CultureInfo.InvariantCulture, out var primaryNumber))
        {
            primaryNumber = int.MaxValue;
        }

        return (0, primaryNumber, ballotNumber[numericPrefixLength..], ballotNumber);
    }

    private static (int Group, int PrimaryNumber, string Suffix, string OriginalText) BuildCareerCandidateSortKey(CandidateEntry candidate)
    {
        var ballotNumber = ResolveBallotNumberDisplay(candidate).Trim();
        if (string.IsNullOrWhiteSpace(ballotNumber))
        {
            return (2, int.MaxValue, string.Empty, string.Empty);
        }

        var numericPrefixLength = 0;
        while (numericPrefixLength < ballotNumber.Length && char.IsDigit(ballotNumber[numericPrefixLength]))
        {
            numericPrefixLength++;
        }

        if (numericPrefixLength == 0)
        {
            return (1, int.MaxValue, ballotNumber, ballotNumber);
        }

        var primaryNumberText = ballotNumber[..numericPrefixLength];
        if (!int.TryParse(primaryNumberText, NumberStyles.None, CultureInfo.InvariantCulture, out var primaryNumber))
        {
            primaryNumber = int.MaxValue;
        }

        var suffix = ballotNumber[numericPrefixLength..].TrimStart(' ', '-', '_');
        return (0, primaryNumber, suffix, ballotNumber);
    }

    private void LogMissingExpectedSceneVariables(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (!RequiresTopTwoCandidateNameVariables(template) || sceneVariables.Count == 0)
        {
            return;
        }

        var missingVariables = new List<string>();
        for (var slot = 1; slot <= 2; slot++)
        {
            var variableName = $"후보명{slot:00}";
            if (!IsSceneVariableAvailable(template, sceneVariables, variableName))
            {
                missingVariables.Add(variableName);
            }
        }

        if (missingVariables.Count == 0)
        {
            return;
        }

        _logService.Warning(
            $"[{channel}] Scene '{template.Id}' is missing candidate name variables: {string.Join(", ", missingVariables)}");
    }

    private static bool RequiresTopTwoCandidateNameVariables(FormatTemplateDefinition template)
    {
        if (template.RecommendedChannel != BroadcastChannel.TopLeft)
        {
            return false;
        }

        return string.Equals(template.Name, "광역단체장_2인", StringComparison.Ordinal) ||
               string.Equals(template.Name, "광역단체장_2인_텍스트", StringComparison.Ordinal) ||
               string.Equals(template.Name, "기초단체장_2인", StringComparison.Ordinal) ||
               string.Equals(template.Name, "기초단체장_2인_텍스트", StringComparison.Ordinal);
    }

    private static bool IsSceneVariableAvailable(
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string variableName)
    {
        return sceneVariables.ContainsKey(variableName) ||
               (RequiresTopTwoCandidateNameVariables(template) &&
                IsExpectedTopTwoCandidateNameVariable(template.Name, variableName));
    }

    private static bool IsExpectedTopTwoCandidateNameVariable(string templateName, string variableName)
    {
        if (!IsTopTwoCandidateTemplateName(templateName))
        {
            return false;
        }

        return string.Equals(variableName, "후보명01", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "후보명02", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsTopTwoCandidateTemplateName(string templateName)
    {
        return string.Equals(templateName, "광역단체장_2인", StringComparison.Ordinal) ||
               string.Equals(templateName, "광역단체장_2인_텍스트", StringComparison.Ordinal) ||
               string.Equals(templateName, "기초단체장_2인", StringComparison.Ordinal) ||
               string.Equals(templateName, "기초단체장_2인_텍스트", StringComparison.Ordinal);
    }

    private void LogUnsupportedSceneVariables(
        BroadcastChannel channel,
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (sceneVariables.Count == 0)
        {
            return;
        }

        var unsupportedVariables = sceneVariables.Keys
            .Where(variableName => !IsSupportedSceneVariable(variableName))
            .OrderBy(variableName => variableName, StringComparer.Ordinal)
            .ToArray();

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

        var preview = string.Join(", ", unsupportedVariables.Take(10));
        if (unsupportedVariables.Length > 10)
        {
            preview += ", ...";
        }

        _logService.Warning(
            $"[{channel}] Scene '{template.Id}' has {unsupportedVariables.Length} variables without runtime mapping: {preview}");
    }

    private static string ResolveCandidateImagePath(
        string t3CutPath,
        string templateFolderPath,
        CandidateEntry candidate,
        CandidatePhotoFallbackPool fallbackPool,
        IDictionary<Guid, string> cache)
    {
        if (cache.TryGetValue(candidate.Id, out var cachedPath))
        {
            return cachedPath;
        }

        var candidateImagePath = ResolveCandidateSpecificImagePath(t3CutPath, templateFolderPath, candidate);
        if (string.IsNullOrWhiteSpace(candidateImagePath))
        {
            candidateImagePath = fallbackPool.Next();
        }

        cache[candidate.Id] = candidateImagePath;
        return candidateImagePath;
    }

    private static string ResolveCandidateSpecificImagePath(
        string t3CutPath,
        string templateFolderPath,
        CandidateEntry candidate)
    {
        if (string.IsNullOrWhiteSpace(t3CutPath) ||
            string.IsNullOrWhiteSpace(candidate.CandidateCode))
        {
            return string.Empty;
        }

        var relativePaths = new List<string>();
        foreach (var extension in CandidatePhotoExtensions)
        {
            relativePaths.Add(Path.Combine("Images", "Photo", candidate.CandidateCode + extension));
            relativePaths.Add(candidate.CandidateCode + extension);
        }

        return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
    }

    private static CandidatePhotoFallbackPool CreateCandidatePhotoFallbackPool(
        string t3CutPath,
        string templateFolderPath)
    {
        if (string.IsNullOrWhiteSpace(t3CutPath))
        {
            return CandidatePhotoFallbackPool.Empty;
        }

        var photoPaths = new List<string>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var root in BuildAssetRoots(t3CutPath, templateFolderPath))
        {
            AddPhotoDirectory(Path.Combine(root, "Images", "Photo"));
        }

        return new CandidatePhotoFallbackPool(photoPaths);

        void AddPhotoDirectory(string directoryPath)
        {
            if (!Directory.Exists(directoryPath))
            {
                return;
            }

            foreach (var filePath in Directory.EnumerateFiles(directoryPath).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
            {
                if (!IsCandidatePhotoFile(filePath))
                {
                    continue;
                }

                var normalizedPath = Path.GetFullPath(filePath);
                if (seen.Add(normalizedPath))
                {
                    photoPaths.Add(normalizedPath);
                }
            }
        }
    }

    private static bool IsCandidatePhotoFile(string filePath)
    {
        var extension = Path.GetExtension(filePath);
        return CandidatePhotoExtensions.Any(candidateExtension =>
            string.Equals(candidateExtension, extension, StringComparison.OrdinalIgnoreCase));
    }

    private static string ResolveRankAssetPath(string t3CutPath, string templateFolderPath, int rank)
    {
        if (rank <= 0)
        {
            return string.Empty;
        }

        return ResolveAssetAcrossRoots(
            t3CutPath,
            templateFolderPath,
            new[] { Path.Combine("Images", "Rank", $"{rank}위.png") });
    }

    private static string ResolveJudgementAssetPath(string t3CutPath, string templateFolderPath, CandidateJudgement judgement)
    {
        string[] fileNames = judgement switch
        {
            CandidateJudgement.Leading => new[] { "유력.vrv" },
            CandidateJudgement.Confirmed => new[] { "확정.vrv", "확실.vrv" },
            CandidateJudgement.Elected => new[] { "당선.vrv" },
            CandidateJudgement.ElectedInProgress => new[] { "개표중당선.vrv", "개표중_당선.vrv", "당선.vrv" },
            CandidateJudgement.UnopposedElected => new[] { "무투표당선.vrv", "무투표_당선.vrv", "당선.vrv" },
            CandidateJudgement.ElectedAfterCountComplete => new[] { "개표마감당선.vrv", "개표마감_당선.vrv", "당선.vrv" },
            _ => Array.Empty<string>()
        };

        if (fileNames.Length == 0)
        {
            return string.Empty;
        }

        return ResolveAssetAcrossRoots(
            t3CutPath,
            templateFolderPath,
            fileNames.SelectMany(fileName => new[]
            {
                Path.Combine("Video", "Images", "Tag", fileName),
                Path.Combine("Images", "Tag", fileName)
            }));
    }

    private static string ResolvePartyAssetPath(
        string t3CutPath,
        string templateFolderPath,
        string templateName,
        string partyName,
        PartyAssetKind assetKind)
    {
        if (string.IsNullOrWhiteSpace(partyName))
        {
            return string.Empty;
        }

        var relativePaths = new List<string>();
        foreach (var candidateFileName in GetPartyFileNameCandidates(partyName))
        {
            AddPartyAssetRelativePaths(relativePaths, templateName, candidateFileName, assetKind);
        }

        var preferredUsage = assetKind switch
        {
            PartyAssetKind.Outline => PartyColorAssetUsage.Outline,
            PartyAssetKind.Color => PartyColorAssetUsage.Color,
            _ => (PartyColorAssetUsage?)null
        };
        if (preferredUsage is not null)
        {
            var preferredPath = PartyColorCatalog.ResolveFallbackAssetPath(templateFolderPath, templateName, partyName, preferredUsage.Value);
            if (!string.IsNullOrWhiteSpace(preferredPath))
            {
                return preferredPath;
            }
        }

        var resolvedPath = ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
        if (!string.IsNullOrWhiteSpace(resolvedPath))
        {
            return resolvedPath;
        }

        if (assetKind == PartyAssetKind.Symbol)
        {
            var otherPartySymbolPath = ResolveOtherPartyImageAssetPath(t3CutPath, templateFolderPath, templateName, assetKind);
            if (!string.IsNullOrWhiteSpace(otherPartySymbolPath))
            {
                return otherPartySymbolPath;
            }

            return PartyColorCatalog.ResolveFallbackAssetPath(
                templateFolderPath,
                templateName,
                partyName,
                PartyColorAssetUsage.Symbol);
        }

        var usage = assetKind switch
        {
            PartyAssetKind.Bar => PartyColorAssetUsage.Bar,
            PartyAssetKind.Plate => PartyColorAssetUsage.Plate,
            PartyAssetKind.Outline => PartyColorAssetUsage.Outline,
            PartyAssetKind.Color => PartyColorAssetUsage.Color,
            PartyAssetKind.Group => PartyColorAssetUsage.Group,
            _ => (PartyColorAssetUsage?)null
        };

        var fallbackAssetPath = usage is null
            ? string.Empty
            : PartyColorCatalog.ResolveFallbackAssetPath(templateFolderPath, templateName, partyName, usage.Value);
        return !string.IsNullOrWhiteSpace(fallbackAssetPath)
            ? fallbackAssetPath
            : ResolveOtherPartyImageAssetPath(t3CutPath, templateFolderPath, templateName, assetKind);
    }

    private static string ResolvePartyGraphColorAssetPath(
        string templateFolderPath,
        string templateName,
        string partyName)
    {
        return PartyColorCatalog.ResolveFallbackAssetPathForSection(
            templateFolderPath,
            templateName,
            "지역명",
            partyName,
            PartyColorAssetUsage.Bar);
    }

    private static string ResolveTransparentPngAssetPath()
    {
        var cacheDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "Tornado3_2026Election",
            "GeneratedPartyAssets");
        Directory.CreateDirectory(cacheDirectory);

        var filePath = Path.Combine(cacheDirectory, "transparent_1x1.png");
        if (!File.Exists(filePath))
        {
            File.WriteAllBytes(
                filePath,
                Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="));
        }

        return filePath;
    }

    private static void AddPartyAssetRelativePaths(
        ICollection<string> relativePaths,
        string templateName,
        string candidateFileName,
        PartyAssetKind assetKind)
    {
        switch (assetKind)
        {
            case PartyAssetKind.Bar:
                if (ShouldUseRoundPartyBarAsset(templateName))
                {
                    relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                }
                else
                {
                    relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
                    relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                }
                break;
            case PartyAssetKind.Plate:
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
                break;
            case PartyAssetKind.Outline:
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
                break;
            case PartyAssetKind.Color:
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                break;
            case PartyAssetKind.Symbol:
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
                break;
            case PartyAssetKind.Group:
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
                relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
                break;
        }
    }

    private static string ResolveOtherPartyImageAssetPath(
        string t3CutPath,
        string templateFolderPath,
        string templateName,
        PartyAssetKind assetKind)
    {
        var fallbackRelativePaths = new List<string>();
        foreach (var candidateFileName in GetOtherPartyFileNameCandidates())
        {
            AddPartyAssetRelativePaths(fallbackRelativePaths, templateName, candidateFileName, assetKind);
        }

        return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, fallbackRelativePaths);
    }

    private static string ResolveTemplateFolderPath(string t3CutPath, FormatTemplateDefinition template)
    {
        var normalizedTemplateId = template.Id
            .Replace('\\', Path.DirectorySeparatorChar)
            .Replace('/', Path.DirectorySeparatorChar);
        var relativeDirectory = Path.GetDirectoryName(normalizedTemplateId);
        return string.IsNullOrWhiteSpace(relativeDirectory)
            ? t3CutPath
            : Path.Combine(t3CutPath, relativeDirectory);
    }

    private static double CalculateCountedRate(ElectionDataSnapshot snapshot)
    {
        return snapshot.CountedRate;
    }

    private static int CalculateVoteGap(IReadOnlyList<CandidateEntry> orderedCandidates, int index)
    {
        if (orderedCandidates.Count <= 1 || index < 0 || index >= orderedCandidates.Count)
        {
            return 0;
        }

        var comparisonIndex = index < orderedCandidates.Count - 1
            ? index + 1
            : index - 1;
        return Math.Abs(orderedCandidates[index].VoteCount - orderedCandidates[comparisonIndex].VoteCount);
    }

    private static int ResolveCandidateDisplayRank(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        int slot,
        CandidateEntry candidate,
        IReadOnlyDictionary<Guid, int> candidateVoteRanks)
    {
        if (IsAllCandidateTemplate(template.Name))
        {
            if (candidate.BroadcastRank > 0)
            {
                return candidate.BroadcastRank;
            }

            if (candidateVoteRanks.TryGetValue(candidate.Id, out var voteRank))
            {
                return voteRank;
            }

            return 0;
        }

        return cut.CandidateStartIndex + slot;
    }

    private static IReadOnlyDictionary<Guid, int> BuildCandidateVoteRankMap(IReadOnlyList<CandidateEntry> candidates)
    {
        var rankedCandidates = candidates
            .Where(candidate => candidate.VoteCount > 0)
            .OrderByDescending(candidate => candidate.VoteCount)
            .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
            .ToArray();
        if (rankedCandidates.Length == 0)
        {
            return EmptyCandidateRankMap;
        }

        var ranks = new Dictionary<Guid, int>();
        int? previousVoteCount = null;
        var currentRank = 0;
        for (var index = 0; index < rankedCandidates.Length; index++)
        {
            var candidate = rankedCandidates[index];
            if (previousVoteCount != candidate.VoteCount)
            {
                currentRank = index + 1;
                previousVoteCount = candidate.VoteCount;
            }

            ranks[candidate.Id] = currentRank;
        }

        return ranks;
    }

    private static string ResolveCandidateElectionDistrictDisplay(CandidateEntry candidate, string fallback)
    {
        return FirstNonWhiteSpace(
            candidate.BroadcastElectionDistrictName,
            candidate.BroadcastDistrictName,
            fallback);
    }

    private static string ResolveCandidateRegionLabelDisplay(CandidateEntry candidate, string fallback)
    {
        return FirstNonWhiteSpace(candidate.BroadcastRegionName, fallback);
    }

    private static string FirstNonWhiteSpace(params string?[] values)
    {
        foreach (var value in values)
        {
            if (!string.IsNullOrWhiteSpace(value))
            {
                return value;
            }
        }

        return string.Empty;
    }

    private static string ResolveBallotNumberDisplay(CandidateEntry candidate)
    {
        var rawBallotNumber = !string.IsNullOrWhiteSpace(candidate.BallotNumber)
            ? candidate.BallotNumber.Trim()
            : candidate.CandidateCode?.Trim() ?? string.Empty;
        if (string.IsNullOrWhiteSpace(rawBallotNumber))
        {
            return string.Empty;
        }

        var numericPrefixLength = 0;
        while (numericPrefixLength < rawBallotNumber.Length && char.IsDigit(rawBallotNumber[numericPrefixLength]))
        {
            numericPrefixLength++;
        }

        if (numericPrefixLength == 0)
        {
            return rawBallotNumber;
        }

        var numericPrefix = rawBallotNumber[..numericPrefixLength];
        if (int.TryParse(numericPrefix, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedBallotNumber))
        {
            return parsedBallotNumber.ToString(CultureInfo.InvariantCulture) + rawBallotNumber[numericPrefixLength..];
        }

        var normalizedPrefix = numericPrefix.TrimStart('0');
        if (normalizedPrefix.Length == 0)
        {
            normalizedPrefix = "0";
        }

        return normalizedPrefix + rawBallotNumber[numericPrefixLength..];
    }

    private static string ResolveBallotLabelDisplay(FormatTemplateDefinition template, string ballotNumberDisplay)
    {
        if (IsCareerTemplate(template.Name) && HasMultiDigitBallotNumber(ballotNumberDisplay))
        {
            return string.Empty;
        }

        return "기호";
    }

    private static bool HasMultiDigitBallotNumber(string ballotNumberDisplay)
    {
        var digitCount = 0;
        foreach (var character in ballotNumberDisplay)
        {
            if (!char.IsDigit(character))
            {
                continue;
            }

            digitCount++;
            if (digitCount >= 2)
            {
                return true;
            }
        }

        return false;
    }

    private static bool IsAnimatedTemplate(FormatTemplateDefinition template)
    {
        return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
               template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase);
    }

    private static int ResolveMaxCandidateSlots(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var currentLeaderSlotCount = IsCurrentLeaderTemplate(template.Name)
            ? ResolveCurrentLeaderPageSize(template.Name)
            : 0;
        var bottomWinnerSlotCount = IsBottomWinnerTemplate(template) ? 3 : 0;
        var bottomAllCandidateSlotCount = template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template.Name)
            ? 3
            : 0;
        var sceneSlotCount = ResolveSceneCandidateSlotCount(sceneVariables);
        if (sceneSlotCount > 0)
        {
            return Math.Max(Math.Max(Math.Max(sceneSlotCount, currentLeaderSlotCount), bottomWinnerSlotCount), bottomAllCandidateSlotCount);
        }

        if (bottomWinnerSlotCount > 0 || bottomAllCandidateSlotCount > 0)
        {
            return Math.Max(bottomWinnerSlotCount, bottomAllCandidateSlotCount);
        }

        foreach (var source in new[] { cut.Name, template.Name, template.Id })
        {
            var slotCount = ResolveTemplateCandidateSlotCount(source);
            if (slotCount > 0)
            {
                return slotCount;
            }
        }

        return 0;
    }

    private static int ResolveBroadcastCandidateSlotCount(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveMaxCandidateSlots(template, cut, sceneVariables);
        if (slotCount > 0)
        {
            return slotCount;
        }

        if (IsHistoricalWinnerTemplate(template.Name))
        {
            return Math.Max(DefaultCandidateSlotClearCount, snapshot.HistoricalWinnerHistory.Count);
        }

        if (template.RequiresCandidateData)
        {
            return Math.Max(DefaultCandidateSlotClearCount, snapshot.Candidates.Count);
        }

        return 0;
    }

    private static int ResolveSceneCandidateSlotCount(
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        if (sceneVariables.Count == 0)
        {
            return 0;
        }

        var maxSlot = 0;
        foreach (var variableName in sceneVariables.Keys)
        {
            if (TryParseCandidateSlot(variableName, out var slot) && slot > maxSlot)
            {
                maxSlot = slot;
            }
        }

        return maxSlot;
    }

    private static bool TryParseCandidateSlot(string variableName, out int slot)
    {
        slot = 0;
        if (string.IsNullOrWhiteSpace(variableName))
        {
            return false;
        }

        foreach (var prefix in CandidateSlotVariablePrefixes)
        {
            if (!variableName.StartsWith(prefix, StringComparison.Ordinal))
            {
                continue;
            }

            var suffix = variableName.Substring(prefix.Length);
            if (suffix.Length is > 0 and <= 2 &&
                int.TryParse(suffix, NumberStyles.None, CultureInfo.InvariantCulture, out slot) &&
                slot > 0)
            {
                return true;
            }
        }

        return false;
    }

    private static int ResolveTemplateCandidateSlotCount(string source)
    {
        if (string.IsNullOrWhiteSpace(source))
        {
            return 0;
        }

        var sourceName = Path.GetFileNameWithoutExtension(
            source
                .Replace('/', Path.DirectorySeparatorChar)
                .Replace('\\', Path.DirectorySeparatorChar));

        var topRankMatch = TopRankSlotCountPattern.Match(sourceName);
        if (topRankMatch.Success &&
            int.TryParse(topRankMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var topRankSlotCount) &&
            topRankSlotCount > 0)
        {
            return topRankSlotCount;
        }

        var peopleMatch = PeopleSlotCountPattern.Match(sourceName);
        if (peopleMatch.Success &&
            int.TryParse(peopleMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var peopleSlotCount) &&
            peopleSlotCount > 0)
        {
            return peopleSlotCount;
        }

        if (IsCurrentLeaderTemplate(sourceName))
        {
            return ResolveCurrentLeaderPageSize(sourceName);
        }

        if (sourceName.StartsWith("당선_", StringComparison.Ordinal) ||
            sourceName.StartsWith("경력_", StringComparison.Ordinal))
        {
            return 1;
        }

        if (sourceName.Contains("접전", StringComparison.Ordinal))
        {
            return 2;
        }

        return 0;
    }

    private static bool IsSupportedSceneVariable(string variableName)
    {
        if (string.IsNullOrWhiteSpace(variableName))
        {
            return false;
        }

        return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or
            "기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
            NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
            IsBottomWinnerBallotNumberVariableName(variableName) ||
            MatchesIndexedVariable(variableName, "선거구명") ||
            MatchesIndexedVariable(variableName, "시도명") ||
            MatchesIndexedVariable(variableName, HistoricalTurnoutCircleObjectPrefix) ||
            MatchesIndexedVariable(variableName, "개표율") ||
            MatchesIndexedVariable(variableName, "투표율") ||
            MatchesIndexedVariable(variableName, "전국투표율") ||
            MatchesIndexedVariable(variableName, "사진") ||
            MatchesIndexedVariable(variableName, "순위") ||
            IsSpecialRankVariableName(variableName) ||
            MatchesIndexedVariable(variableName, "기호") ||
            MatchesIndexedVariable(variableName, "기호텍스트") ||
            MatchesIndexedVariable(variableName, "후보명") ||
            MatchesIndexedVariable(variableName, "정당명") ||
            TryParseCouncilSeatSlot(variableName, "의석수", out _) ||
            MatchesIndexedVariable(variableName, "득표수") ||
            MatchesIndexedVariable(variableName, "득표율") ||
            MatchesIndexedVariable(variableName, "표차") ||
            MatchesIndexedVariable(variableName, "득표차") ||
            MatchesIndexedVariable(variableName, "유확당") ||
            MatchesIndexedVariable(variableName, "후보사진") ||
            MatchesIndexedVariable(variableName, "득표수바") ||
            TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
            MatchesIndexedVariable(variableName, "정당바") ||
            MatchesIndexedVariable(variableName, "정당판") ||
            MatchesIndexedVariable(variableName, "정당원") ||
            MatchesIndexedVariable(variableName, "정당색") ||
            MatchesIndexedVariable(variableName, "정당심볼") ||
            MatchesIndexedVariable(variableName, "공약") ||
            MatchesIndexedVariable(variableName, "공약그룹") ||
            MatchesIndexedVariable(variableName, "그래프") ||
            MatchesIndexedVariable(variableName, "그룹") ||
            MatchesIndexedVariable(variableName, "바");
    }

    private static bool MatchesIndexedVariable(string variableName, string prefix)
    {
        if (!variableName.StartsWith(prefix, StringComparison.Ordinal))
        {
            return false;
        }

        var suffix = variableName.Substring(prefix.Length);
        return suffix.Length is > 0 and <= 2 && suffix.All(char.IsDigit);
    }

    private static string FormatCount(int value)
    {
        return value.ToString("N0", CultureInfo.InvariantCulture);
    }

    private static string FormatRate(double value)
    {
        return NormalizeRateForBroadcast(value).ToString("0.0", CultureInfo.InvariantCulture);
    }

    private static double NormalizeRateForBroadcast(double value)
    {
        if (double.IsNaN(value) || double.IsInfinity(value))
        {
            return 0d;
        }

        return Math.Round(Math.Clamp(value, 0d, 100d), 1, MidpointRounding.AwayFromZero);
    }

    private static string FormatCountedRateLabel(double value)
    {
        return FormattableString.Invariant($"개표 {FormatRate(value)}%");
    }

    private static string ResolveCandidateCountedRateDisplay(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        CandidateEntry candidate,
        string fallbackDisplay)
    {
        if (IsCurrentLeaderTemplate(template.Name))
        {
            var countedRate = candidate.BroadcastCountedRate ?? CalculateCountedRate(snapshot);
            return FormatCountedRateLabel(countedRate);
        }

        return candidate.BroadcastCountedRate.HasValue
            ? FormatRate(candidate.BroadcastCountedRate.Value)
            : fallbackDisplay;
    }

    private static bool ShouldUseCountingCompleteLabel(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot)
    {
        return IsWinnerTemplate(template.Name) &&
               IsBasicCouncilElectionType(snapshot.ElectionType);
    }

    private static bool ShouldHideVoteMetrics(CandidateEntry candidate)
    {
        return candidate.EffectiveJudgement == CandidateJudgement.UnopposedElected &&
               candidate.VoteCount <= 0;
    }

    private static string FormatClock(DateTimeOffset value)
    {
        return value.ToString("HH:mm", CultureInfo.InvariantCulture);
    }

    private static string ResolveReferenceTimeDisplay(ElectionDataSnapshot snapshot)
    {
        return string.IsNullOrWhiteSpace(snapshot.ReferenceTimeLabel)
            ? FormatClock(snapshot.ReceivedAt)
            : snapshot.ReferenceTimeLabel.Trim();
    }

    private static string ResolveReferenceTimeDisplay(
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot)
    {
        var display = ResolveReferenceTimeDisplay(snapshot);
        return ShouldWrapNormalTurnoutReferenceTime(template)
            ? WrapParenthesized(display)
            : display;
    }

    private static bool ShouldWrapNormalTurnoutReferenceTime(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               (string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
                template.Name.StartsWith("투표율_", StringComparison.Ordinal));
    }

    private static string WrapParenthesized(string value)
    {
        var trimmed = value.Trim();
        if (string.IsNullOrWhiteSpace(trimmed) ||
            (trimmed.StartsWith('(') && trimmed.EndsWith(')')))
        {
            return trimmed;
        }

        return $"({trimmed})";
    }

    private static bool IsJudgementVariableName(string variableName)
    {
        return string.Equals(variableName, "유확당", StringComparison.Ordinal) ||
            MatchesIndexedVariable(variableName, "유확당");
    }

    private static bool IsBottomWinnerBallotNumberVariableName(string variableName)
    {
        return string.Equals(variableName, "data01", StringComparison.Ordinal) ||
            string.Equals(variableName, "data01 1", StringComparison.Ordinal) ||
            string.Equals(variableName, "data01 2", StringComparison.Ordinal);
    }

    private static void SetAliases(IDictionary<string, string> values, string value, params string[] keys)
    {
        foreach (var key in keys)
        {
            if (!string.IsNullOrWhiteSpace(key))
            {
                values[key] = value;
            }
        }
    }

    private static void SetBottomWinnerBallotNumberAliases(IDictionary<string, string> values, int slot, string value)
    {
        switch (slot)
        {
            case 1:
                SetAliases(values, value, "data01");
                break;
            case 2:
                SetAliases(values, value, "data01 1");
                break;
            case 3:
                SetAliases(values, value, "data01 2");
                break;
        }
    }

    private static void ClearCandidateSlotValues(
        IDictionary<string, string> values,
        int slotCount,
        FormatTemplateDefinition template,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var transparentGraphPath = HasPanseGraphVariables(sceneVariables)
            ? ResolveTransparentPngAssetPath()
            : string.Empty;
        var transparentImagePath = sceneVariables.Values.Any(variable => variable.Kind == KarismaSceneVariableKind.Image)
            ? ResolveTransparentPngAssetPath()
            : string.Empty;

        for (var slot = 1; slot <= slotCount; slot++)
        {
            values[$"Candidate{slot}Code"] = string.Empty;
            values[$"Candidate{slot}Name"] = string.Empty;
            values[$"Candidate{slot}Party"] = string.Empty;
            values[$"Candidate{slot}VoteCount"] = string.Empty;
            values[$"Candidate{slot}VoteCountDisplay"] = string.Empty;
            values[$"Candidate{slot}VoteRate"] = string.Empty;
            values[$"Candidate{slot}Judgement"] = string.Empty;
            values[$"Candidate{slot}ImagePath"] = string.Empty;

            var rankAliases = GetRankAliases(template, slot);
            var judgementAliases = GetJudgementAliases(slot);
            SetAliases(values, ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, rankAliases), rankAliases);
            SetAliases(values, string.Empty, $"기호{slot:00}", $"기호{slot}");
            if (IsBottomWinnerTemplate(template))
            {
                SetBottomWinnerBallotNumberAliases(values, slot, string.Empty);
            }

            SetAliases(values, string.Empty, $"기호텍스트{slot:00}", $"기호텍스트{slot}");
            SetAliases(values, string.Empty, $"후보명{slot:00}", $"후보명{slot}");
            SetAliases(values, string.Empty, $"정당명{slot:00}", $"정당명{slot}");
            SetAliases(values, string.Empty, $"득표수{slot:00}", $"득표수{slot}");
            SetAliases(values, string.Empty, $"득표율{slot:00}", $"득표율{slot}");
            SetAliases(values, string.Empty, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
            SetAliases(values, string.Empty, $"선거구명{slot:00}", $"선거구명{slot}");
            SetAliases(values, string.Empty, $"시도명{slot:00}", $"시도명{slot}");
            SetAliases(values, string.Empty, $"개표율{slot:00}", $"개표율{slot}");
            SetAliases(values, ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, judgementAliases), judgementAliases);
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"후보사진{slot:00}", $"후보사진{slot}"),
                $"후보사진{slot:00}",
                $"후보사진{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"득표수바{slot:00}", $"득표수바{slot}"),
                $"득표수바{slot:00}",
                $"득표수바{slot}");
            if (!ShouldUseStyleColorOnlyForBottomPartyBar(template.Name, "정당바"))
            {
                SetAliases(
                    values,
                    ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당바{slot:00}", $"정당바{slot}"),
                    $"정당바{slot:00}",
                    $"정당바{slot}");
            }

            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당판{slot:00}", $"정당판{slot}"),
                $"정당판{slot:00}",
                $"정당판{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당원{slot:00}", $"정당원{slot}"),
                $"정당원{slot:00}",
                $"정당원{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당색{slot:00}", $"정당색{slot}"),
                $"정당색{slot:00}",
                $"정당색{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당심볼{slot:00}", $"정당심볼{slot}"),
                $"정당심볼{slot:00}",
                $"정당심볼{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"그룹{slot:00}", $"그룹{slot}"),
                $"그룹{slot:00}",
                $"그룹{slot}");
            if (!string.IsNullOrWhiteSpace(transparentGraphPath))
            {
                SetAliases(values, transparentGraphPath, $"그래프{slot:00}", $"그래프{slot}");
            }
        }
    }

    private static void ClearLeaderValues(IDictionary<string, string> values)
    {
        values["LeaderCode"] = string.Empty;
        values["LeaderName"] = string.Empty;
        values["LeaderParty"] = string.Empty;
        values["LeaderVoteCount"] = string.Empty;
        values["LeaderVoteCountDisplay"] = string.Empty;
        values["LeaderVoteRate"] = string.Empty;
        values["LeaderJudgement"] = string.Empty;
        values["LeaderImagePath"] = string.Empty;
    }

    private static void ClearTurnoutSlotValues(
        IDictionary<string, string> values,
        int slotCount,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        SetAliases(values, string.Empty, "시도명01 1", "시도명01 2", "시도명01 3", "투표율01 1", "투표율01 2");
        var transparentImagePath = sceneVariables.Values.Any(variable => variable.Kind == KarismaSceneVariableKind.Image)
            ? ResolveTransparentPngAssetPath()
            : string.Empty;

        for (var slot = 1; slot <= slotCount; slot++)
        {
            SetAliases(values, string.Empty, $"투표율{slot:00}", $"투표율{slot}");
            SetAliases(values, string.Empty, $"선거구명{slot:00}", $"선거구명{slot}");
            SetAliases(values, string.Empty, $"시도명{slot:00}", $"시도명{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"그룹{slot:00}", $"그룹{slot}"),
                $"그룹{slot:00}",
                $"그룹{slot}");
            SetAliases(
                values,
                ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"바{slot:00}", $"바{slot}"),
                $"바{slot:00}",
                $"바{slot}");
        }
    }

    private static string ResolveEmptyImageSlotValue(
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string transparentImagePath,
        params string[] aliases)
    {
        if (string.IsNullOrWhiteSpace(transparentImagePath))
        {
            return string.Empty;
        }

        return aliases.Any(alias =>
            sceneVariables.TryGetValue(alias, out var variable) &&
            variable.Kind == KarismaSceneVariableKind.Image)
            ? transparentImagePath
            : string.Empty;
    }

    private static string[] GetJudgementAliases(int slot)
    {
        return slot == 1
            ? ["유확당", $"유확당{slot:00}", $"유확당{slot}"]
            : [$"유확당{slot:00}", $"유확당{slot}"];
    }

    private static void SetOptionalAliases(IDictionary<string, string> values, string? value, params string[] keys)
    {
        if (!string.IsNullOrWhiteSpace(value))
        {
            SetAliases(values, value, keys);
        }
    }

    private static void SetOptionalAssetAliasesUnlessStyleBound(
        IDictionary<string, string> values,
        string templateFolderPath,
        FormatTemplateDefinition template,
        string sectionName,
        string? value,
        params string[] keys)
    {
        var templateName = template.Name;
        if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
        {
            foreach (var key in keys)
            {
                values.Remove(key);
            }

            return;
        }

        if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName) &&
            !ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName) &&
            !ShouldRestoreStyleBoundCandidateAsset(template, sectionName))
        {
            foreach (var key in keys)
            {
                values.Remove(key);
            }

            return;
        }

        SetOptionalAliases(values, value, keys);
    }

    private static bool ShouldRestoreStyleBoundCandidateAsset(FormatTemplateDefinition template, string sectionName)
    {
        if (ShouldUseStyleColorOnlyForBottomPartyBar(template.Name, sectionName))
        {
            return false;
        }

        if (template.RecommendedChannel != BroadcastChannel.Bottom)
        {
            return template.RecommendedChannel == BroadcastChannel.Normal &&
                   IsCurrentLeaderTemplate(template.Name) &&
                   sectionName is "정당바" or "정당색";
        }

        if (!IsBottomCandidateVisualTemplate(template.Name))
        {
            return false;
        }

        return sectionName is "득표수바" or "정당바" or "정당판" or "정당원" or "정당색";
    }

    private static bool ShouldUseStyleColorOnlyForBottomPartyBar(string templateName, string sectionName)
    {
        return string.Equals(sectionName, "정당바", StringComparison.Ordinal) &&
               (string.Equals(templateName, "1위_광역단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "1위_기초단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "1-2위_광역단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "1-2위_기초단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "1-3위_광역단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "1-3위_기초단체장", StringComparison.Ordinal) ||
                templateName.StartsWith("당선_", StringComparison.Ordinal) ||
                string.Equals(templateName, "전후보_광역단체장", StringComparison.Ordinal) ||
                string.Equals(templateName, "전후보_기초단체장", StringComparison.Ordinal));
    }

    private static bool IsBottomCandidateVisualTemplate(string templateName)
    {
        return templateName.StartsWith("1위_", StringComparison.Ordinal) ||
               templateName.StartsWith("1-2위_", StringComparison.Ordinal) ||
               templateName.StartsWith("1-3위_", StringComparison.Ordinal) ||
               templateName.StartsWith("당선_", StringComparison.Ordinal) ||
               templateName.StartsWith("전후보_", StringComparison.Ordinal) ||
               templateName.StartsWith("모든후보_", StringComparison.Ordinal);
    }

    private static void AddVisibilityUpdates(
        ICollection<KarismaVisibilityUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        bool isVisible,
        params string[] objectNames)
    {
        foreach (var objectName in objectNames)
        {
            if (string.IsNullOrWhiteSpace(objectName))
            {
                continue;
            }

            if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
            {
                continue;
            }

            AddVisibilityUpdate(updates, seen, objectName, isVisible);
        }
    }

    private static void AddVisibilityUpdate(
        ICollection<KarismaVisibilityUpdate> updates,
        ISet<string> seen,
        string objectName,
        bool isVisible)
    {
        var updateKey = FormattableString.Invariant($"{objectName}|{isVisible}");
        if (!seen.Add(updateKey))
        {
            return;
        }

        updates.Add(new KarismaVisibilityUpdate(objectName, isVisible));
    }

    private static void AddStyleColorUpdates(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string templateFolderPath,
        string templateName,
        string partyName,
        string sectionName,
        params string[] objectNames)
    {
        AddStyleColorUpdates(
            updates,
            seen,
            sceneVariables,
            templateFolderPath,
            templateName,
            partyName,
            sectionName,
            true,
            objectNames);
    }

    private static void AddStyleColorUpdates(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string templateFolderPath,
        string templateName,
        string partyName,
        string sectionName,
        bool requireSceneVariable,
        IReadOnlyCollection<int>? additionalOrders,
        params string[] objectNames)
    {
        AddStyleColorUpdatesCore(
            updates,
            seen,
            sceneVariables,
            templateFolderPath,
            templateName,
            partyName,
            sectionName,
            requireSceneVariable,
            additionalOrders,
            objectNames);
    }

    private static void AddStyleColorUpdates(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string templateFolderPath,
        string templateName,
        string partyName,
        string sectionName,
        bool requireSceneVariable,
        params string[] objectNames)
    {
        AddStyleColorUpdatesCore(
            updates,
            seen,
            sceneVariables,
            templateFolderPath,
            templateName,
            partyName,
            sectionName,
            requireSceneVariable,
            null,
            objectNames);
    }

    private static void AddStyleColorUpdatesCore(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string templateFolderPath,
        string templateName,
        string partyName,
        string sectionName,
        bool requireSceneVariable,
        IReadOnlyCollection<int>? additionalOrders,
        params string[] objectNames)
    {
        if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
        {
            return;
        }

        if (ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
        {
            return;
        }

        if (!PartyColorCatalog.TryResolveStyleColor(templateFolderPath, templateName, sectionName, partyName, out var styleColor))
        {
            return;
        }

        foreach (var objectName in objectNames)
        {
            if (string.IsNullOrWhiteSpace(objectName))
            {
                continue;
            }

            if (requireSceneVariable &&
                sceneVariables.Count > 0 &&
                !sceneVariables.ContainsKey(objectName) &&
                !IsExpectedTopTwoCandidateNameVariable(templateName, objectName))
            {
                continue;
            }

            AddStyleColorUpdate(updates, seen, objectName, styleColor, styleColor.Order);
            if (additionalOrders is null)
            {
                continue;
            }

            foreach (var additionalOrder in additionalOrders)
            {
                AddStyleColorUpdate(updates, seen, objectName, styleColor, additionalOrder);
            }
        }
    }

    private static void AddStyleColorUpdate(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        string objectName,
        PartyStyleColorSpec styleColor,
        int order)
    {
        var updateKey = FormattableString.Invariant(
            $"{objectName}|{styleColor.StyleType}|{order}|{styleColor.R}|{styleColor.G}|{styleColor.B}|{styleColor.A}");
        if (!seen.Add(updateKey))
        {
            return;
        }

        updates.Add(new KarismaStyleColorUpdate(
            objectName,
            styleColor.StyleType,
            order,
            styleColor.R,
            styleColor.G,
            styleColor.B,
            styleColor.A));
    }

    private static void AddTransparentStyleColorUpdates(
        ICollection<KarismaStyleColorUpdate> updates,
        ISet<string> seen,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        params string[] aliases)
    {
        foreach (var alias in aliases)
        {
            if (string.IsNullOrWhiteSpace(alias) ||
                (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(alias)) ||
                !seen.Add($"{alias}|transparent"))
            {
                continue;
            }

            updates.Add(new KarismaStyleColorUpdate(
                alias,
                eKStyleType.STYLE_TYPE_FACE,
                1,
                byte.MaxValue,
                byte.MaxValue,
                byte.MaxValue,
                0));
        }
    }

    private static string ResolveCandidateNameStyleColorSection(string templateName)
    {
        return ShouldUsePartyBarColorForCandidateName(templateName)
            ? "정당바"
            : "후보명";
    }

    private static bool ShouldUsePartyBarColorForCandidateName(string templateName)
    {
        return string.Equals(templateName, "광역단체장_2인_텍스트", StringComparison.Ordinal) ||
            string.Equals(templateName, "기초단체장_2인_텍스트", StringComparison.Ordinal);
    }

    private static bool ShouldUsePartyColorShadowForCurrentLeaderVoteRate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               IsCurrentLeaderTemplate(template.Name);
    }

    private static bool ShouldPreferAssetAliasForStyleBoundSection(string templateName, string sectionName)
    {
        if (string.IsNullOrWhiteSpace(templateName) || string.IsNullOrWhiteSpace(sectionName))
        {
            return false;
        }

        return false;
    }

    private static bool ShouldUseRoundPartyBarAsset(string templateName)
    {
        return string.Equals(templateName, "1-2위_기초단체장", StringComparison.Ordinal) ||
            templateName.StartsWith("1-2위_기초단체장_", StringComparison.Ordinal);
    }

    private static bool ShouldUseTemplateDefaultAppearance(string templateName, string sectionName)
    {
        return CutAppearancePolicyCatalog.UsesTemplateDefaultAppearance(templateName, sectionName);
    }

    private static void SetRankAliases(
        IDictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        string rankDisplay,
        string rankImagePath,
        params string[] keys)
    {
        foreach (var key in keys)
        {
            if (string.IsNullOrWhiteSpace(key))
            {
                continue;
            }

            if (sceneVariables.Count > 0 &&
                sceneVariables.TryGetValue(key, out var variable) &&
                variable.Kind == KarismaSceneVariableKind.Image &&
                !string.IsNullOrWhiteSpace(rankImagePath))
            {
                values[key] = rankImagePath;
                continue;
            }

            values[key] = rankDisplay;
        }
    }

    private static string[] GetRankAliases(FormatTemplateDefinition template, int slot)
    {
        var aliases = new List<string>
        {
            $"순위{slot:00}",
            $"순위{slot}"
        };

        if (slot == 3 && UsesLegacyThirdRankAlias(template))
        {
            aliases.Add("순위01 2");
        }

        return aliases.ToArray();
    }

    private static Dictionary<string, string> FilterValuesForScene(
        Dictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
        FormatTemplateDefinition template)
    {
        if (sceneVariables.Count == 0)
        {
            return values;
        }

        if (IsTopTurnoutDistrictBoardTemplate(template))
        {
            return values
                .Where(pair =>
                    IsSceneVariableAvailable(template, sceneVariables, pair.Key) ||
                    IsTopTurnoutDistrictBoardVariable(pair.Key) ||
                    IsCommonTurnoutValueVariable(pair.Key))
                .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
        }

        if (IsTopPanseTemplate(template))
        {
            return values
                .Where(pair => sceneVariables.ContainsKey(pair.Key) || IsTopPanseVariable(pair.Key))
                .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
        }

        return values
            .Where(pair =>
                IsSceneVariableAvailable(template, sceneVariables, pair.Key) ||
                (IsTurnoutTemplate(template.Name) && IsCommonTurnoutValueVariable(pair.Key)) ||
                (IsTopTurnoutDistrictBoardTemplate(template) && IsTopTurnoutDistrictBoardVariable(pair.Key)) ||
                IsExpectedTopRankVariable(template, pair.Key) ||
                IsLegacyThirdRankAlias(template, pair.Key))
            .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
    }

    private static bool IsExpectedTopRankVariable(FormatTemplateDefinition template, string variableName)
    {
        if (!TryParseSimpleIndexedSlot(variableName, "순위", out var slot))
        {
            return false;
        }

        var slotCount = ResolveTemplateCandidateSlotCount(template.Name);
        if (slotCount <= 0)
        {
            slotCount = ResolveTemplateCandidateSlotCount(template.Id);
        }

        return slotCount > 0 && slot <= slotCount;
    }

    private static Dictionary<string, string> FilterNormalPreElectionTurnoutDistrictBoardValues(
        Dictionary<string, string> values,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        return values
            .Where(pair =>
                IsNormalPreElectionTurnoutDistrictBoardTextValue(pair.Key))
            .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
    }

    private static bool IsNormalPreElectionTurnoutDistrictBoardTextValue(string variableName)
    {
        return string.Equals(variableName, "선거구명", StringComparison.OrdinalIgnoreCase) ||
               MatchesIndexedVariable(variableName, "선거구명") ||
               string.Equals(variableName, "기준시", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "기준시01", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "기준시02", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "시도명01", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "시도명1", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsLegacyThirdRankAlias(FormatTemplateDefinition template, string variableName)
    {
        return UsesLegacyThirdRankAlias(template) &&
               IsSpecialRankVariableName(variableName);
    }

    private static bool UsesLegacyThirdRankAlias(FormatTemplateDefinition template)
    {
        return template.Name.StartsWith("1-3위_ani_", StringComparison.Ordinal) ||
               string.Equals(template.Name, "1-3위_보궐선거", StringComparison.Ordinal);
    }

    private static bool IsSpecialRankVariableName(string variableName)
    {
        return string.Equals(variableName, "순위01 2", StringComparison.Ordinal);
    }

    private static bool IsCommonTurnoutValueVariable(string variableName)
    {
        return variableName is "투표율" or "투표율01" or "투표율1" or
            "전국투표율" or "전국투표율01" or "전국투표율1" or
            "기준시" or "기준시01" or "기준시02" or
            "유권자수" or "유권자수01" or "유권자수1" or
            "투표자수" or "투표자수01" or "투표자수1";
    }

    private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.TopLeft &&
               string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal);
    }

    private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
    }

    private static bool IsTopPanseTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.TopLeft &&
               template.Name.StartsWith("판세_", StringComparison.Ordinal);
    }

    private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Normal &&
               string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
    }

    private static bool IsPanseEducationTemplate(string templateName)
    {
        return string.Equals(templateName, "판세_교육감", StringComparison.Ordinal);
    }

    private static bool IsTopPanseVariable(string variableName)
    {
        return MatchesIndexedVariable(variableName, "정당명") ||
               TryParseCouncilSeatSlot(variableName, "의석수", out _);
    }

    private static bool IsTopTurnoutDistrictBoardVariable(string variableName)
    {
        return variableName is
                   "시도명01 1" or
                   "시도명01 2" or
                   "시도명01 3" or
                   "투표율01 1" or
                   "투표율01 2" or
                   "그룹01" or
                   "그룹02" or
                   "그룹03" ||
               string.Equals(variableName, "시도명01", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(variableName, "투표율01", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsNumberedVariable(string variableName, string prefix, int minSlot, int maxSlot)
    {
        if (!variableName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        var suffix = variableName[prefix.Length..];
        return int.TryParse(suffix, NumberStyles.Integer, CultureInfo.InvariantCulture, out var slot) &&
               slot >= minSlot &&
               slot <= maxSlot;
    }

    private static string ResolveAssetAcrossRoots(string t3CutPath, string templateFolderPath, IEnumerable<string> relativePaths)
    {
        var candidates = relativePaths
            .Where(path => !string.IsNullOrWhiteSpace(path))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();

        if (candidates.Length == 0)
        {
            return string.Empty;
        }

        foreach (var root in BuildAssetRoots(t3CutPath, templateFolderPath))
        {
            foreach (var relativePath in candidates)
            {
                var fullPath = Path.Combine(root, relativePath);
                if (File.Exists(fullPath))
                {
                    return fullPath;
                }
            }
        }

        return string.Empty;
    }

    private static IReadOnlyList<string> BuildAssetRoots(string t3CutPath, string templateFolderPath)
    {
        var roots = new List<string>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        AddRoot(templateFolderPath);

        var current = templateFolderPath;
        while (!string.IsNullOrWhiteSpace(current))
        {
            var parent = Path.GetDirectoryName(current);
            if (string.IsNullOrWhiteSpace(parent) ||
                !parent.StartsWith(t3CutPath, StringComparison.OrdinalIgnoreCase))
            {
                break;
            }

            AddRoot(parent);
            current = parent;
        }

        if (Directory.Exists(t3CutPath))
        {
            foreach (var directory in Directory.GetDirectories(t3CutPath))
            {
                AddRoot(directory);
            }
        }

        return roots;

        void AddRoot(string? path)
        {
            if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
            {
                return;
            }

            var normalizedPath = Path.GetFullPath(path);
            if (seen.Add(normalizedPath))
            {
                roots.Add(normalizedPath);
            }
        }
    }

    private sealed class CandidatePhotoFallbackPool
    {
        public static readonly CandidatePhotoFallbackPool Empty = new(Array.Empty<string>());
        private readonly string[] _paths;
        private readonly Queue<string> _remaining = new();

        public CandidatePhotoFallbackPool(IEnumerable<string> paths)
        {
            _paths = paths
                .Where(path => !string.IsNullOrWhiteSpace(path))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToArray();
        }

        public string Next()
        {
            if (_paths.Length == 0)
            {
                return string.Empty;
            }

            if (_remaining.Count == 0)
            {
                Refill();
            }

            return _remaining.Dequeue();
        }

        private void Refill()
        {
            foreach (var path in _paths)
            {
                _remaining.Enqueue(path);
            }
        }
    }

    private static IEnumerable<string> GetPartyFileNameCandidates(string partyName)
    {
        var trimmed = partyName.Trim();
        if (string.IsNullOrWhiteSpace(trimmed))
        {
            yield break;
        }

        if (IsIndependentOrOtherPartyName(trimmed))
        {
            yield return "무소속기타";
            yield return "무기타";
        }

        yield return trimmed;

        var noSpaces = trimmed.Replace(" ", string.Empty);
        if (!string.Equals(noSpaces, trimmed, StringComparison.Ordinal))
        {
            yield return noSpaces;
        }

        foreach (var alias in GetHistoricalPartyAliases(trimmed))
        {
            yield return alias;
        }
    }

    private static IEnumerable<string> GetOtherPartyFileNameCandidates()
    {
        yield return "무소속기타";
        yield return "무기타";
    }

    private static bool IsIndependentOrOtherPartyName(string partyName)
    {
        var normalized = partyName.Replace(" ", string.Empty, StringComparison.Ordinal);
        return normalized.Contains("무소속", StringComparison.Ordinal) ||
            normalized.Contains("기타", StringComparison.Ordinal) ||
            normalized.Contains("무기타", StringComparison.Ordinal);
    }

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

    private static bool IsTurnoutPhotoTemplate(string templateName)
    {
        return string.Equals(templateName, "투표율", StringComparison.Ordinal) ||
               string.Equals(templateName, "투표율_사진", StringComparison.Ordinal);
    }

    private static bool IsHistoricalWinnerTemplate(string templateName)
    {
        return templateName.StartsWith("사전_역대당선", StringComparison.Ordinal);
    }

    private static bool UsesShortHistoricalRegionLabel(string templateName)
    {
        return IsHistoricalTurnoutTemplate(templateName) ||
               string.Equals(templateName, "사전_역대당선자_기초단체장", StringComparison.Ordinal);
    }

    private static bool IsWinnerTemplate(string templateName)
    {
        return templateName.StartsWith("당선_", StringComparison.Ordinal);
    }

    private static bool IsBottomWinnerTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Bottom &&
               IsWinnerTemplate(template.Name);
    }

    private static bool IsBasicCouncilElectionType(string electionType)
    {
        return string.Equals(electionType, "광역의원", StringComparison.Ordinal) ||
               string.Equals(electionType, "기초의원", StringComparison.Ordinal);
    }

    private static bool IsCurrentLeaderTemplate(string templateName)
    {
        return templateName.StartsWith("이시각1위_", StringComparison.Ordinal) ||
               templateName.StartsWith("1위_", StringComparison.Ordinal);
    }

    private static int ResolveCurrentLeaderPageSize(string sourceName)
    {
        if (sourceName.StartsWith("1위_", StringComparison.Ordinal))
        {
            return 3;
        }

        if (sourceName.Contains("_L", StringComparison.Ordinal) ||
            sourceName.Contains("_8316", StringComparison.Ordinal) ||
            sourceName.Contains("_3840", StringComparison.Ordinal) ||
            sourceName.Contains("_2880", StringComparison.Ordinal) ||
            sourceName.Contains("_5760", StringComparison.Ordinal))
        {
            return 3;
        }

        return sourceName.Contains("_HD", StringComparison.Ordinal) ? 2 : 1;
    }

    private static bool IsElectedJudgement(CandidateJudgement judgement)
    {
        return judgement is CandidateJudgement.Elected
            or CandidateJudgement.ElectedInProgress
            or CandidateJudgement.UnopposedElected
            or CandidateJudgement.ElectedAfterCountComplete;
    }

    private static bool IsCareerTemplate(string templateName)
    {
        return templateName.StartsWith("경력_", StringComparison.Ordinal);
    }

    private static bool IsAllCandidateTemplate(string templateName)
    {
        return templateName.StartsWith("모든후보_", StringComparison.Ordinal) ||
               templateName.StartsWith("전후보_", StringComparison.Ordinal);
    }

    private static void ApplyHistoricalTurnoutValues(
        IDictionary<string, string> values,
        ElectionDataSnapshot snapshot)
    {
        for (var slot = 1; slot <= 6; slot++)
        {
            SetAliases(values, string.Empty, $"투표율{slot:00}", $"투표율{slot}");
        }

        var orderedTurnout = snapshot.HistoricalTurnoutHistory
            .OrderBy(entry => entry.Year)
            .ToArray();
        foreach (var turnout in orderedTurnout)
        {
            var slot = turnout.ElectionOrder - 2;
            if (slot is < 1 or > 6)
            {
                continue;
            }

            SetAliases(
                values,
                FormatRate(turnout.TurnoutRate),
                $"투표율{slot:00}",
                $"투표율{slot}");
        }
    }

    private static void ApplyTurnoutBoardValues(
        IDictionary<string, string> values,
        FormatTemplateDefinition template,
        ElectionDataSnapshot snapshot,
        string t3CutPath,
        string templateFolderPath)
    {
        var guideAssetPath = ResolveTurnoutDecorAssetPath(t3CutPath, templateFolderPath);
        if (IsTopTurnoutDistrictBoardTemplate(template))
        {
            ApplyTopTurnoutDistrictBoardValues(values, snapshot, guideAssetPath);
            return;
        }

        var shouldBlankDistrictLabels = IsBottomTurnoutSidoTemplate(template);
        foreach (var slotEntry in snapshot.TurnoutBoardSlots.OrderBy(entry => entry.Slot))
        {
            var slot = slotEntry.Slot;
            var rateDisplay = FormatRate(slotEntry.TurnoutRate);
            SetAliases(values, rateDisplay, $"투표율{slot:00}", $"투표율{slot}");

            if (!slotEntry.IsNational)
            {
                var regionLabel = string.IsNullOrWhiteSpace(slotEntry.RegionLabel)
                    ? slotEntry.Label
                    : slotEntry.RegionLabel;
                var districtLabel = shouldBlankDistrictLabels
                    ? string.Empty
                    : ResolveTurnoutDistrictNameLabel(snapshot, regionLabel, slotEntry.Label);
                SetAliases(values, districtLabel, $"선거구명{slot:00}", $"선거구명{slot}");
                SetAliases(values, regionLabel, $"시도명{slot:00}", $"시도명{slot}");
            }

            SetOptionalAliases(values, guideAssetPath, $"그룹{slot:00}", $"그룹{slot}");
            SetOptionalAliases(values, guideAssetPath, $"바{slot:00}", $"바{slot}");
        }

        if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
        {
            var provinceLabel = snapshot.TurnoutBoardSlots
                .Where(entry => !entry.IsNational)
                .Select(entry => string.IsNullOrWhiteSpace(entry.RegionLabel) ? snapshot.RegionName : entry.RegionLabel)
                .FirstOrDefault(label => !string.IsNullOrWhiteSpace(label)) ??
                snapshot.RegionName;
            if (!string.IsNullOrWhiteSpace(provinceLabel))
            {
                SetAliases(values, NormalizeRegionName(provinceLabel), "시도명01", "시도명1");
            }
        }
    }

    private static void ApplyTurnoutPhotoValues(
        IDictionary<string, string> values,
        string t3CutPath,
        string templateFolderPath)
    {
        var photoPath = ResolveAssetAcrossRoots(
            t3CutPath,
            templateFolderPath,
            [
                Path.Combine("Images", "Etc", "시도별투표율_사진.jpg"),
                Path.Combine("Images", "Etc", "투표율_sample2.jpg"),
                Path.Combine("Images", "Etc", "투표율_sample.jpg")
            ]);

        SetOptionalAliases(values, photoPath, "사진01", "사진02");
    }

    private static void ApplyTopTurnoutDistrictBoardValues(
        IDictionary<string, string> values,
        ElectionDataSnapshot snapshot,
        string guideAssetPath)
    {
        var visibleSlots = snapshot.TurnoutBoardSlots
            .Where(entry => !entry.IsNational && entry.TurnoutRate > 0)
            .OrderBy(entry => entry.Slot)
            .Take(3)
            .ToArray();
        var provinceLabel = visibleSlots
            .Select(entry => string.IsNullOrWhiteSpace(entry.RegionLabel) ? entry.Label : entry.RegionLabel)
            .FirstOrDefault(label => !string.IsNullOrWhiteSpace(label)) ??
            snapshot.RegionName;
        var provinceDisplay = NormalizeRegionName(provinceLabel);

        SetAliases(values, provinceDisplay, "시도명", "시도명01 1");
        for (var index = 0; index < visibleSlots.Length; index++)
        {
            var sceneSlot = index + 1;
            var slotEntry = visibleSlots[index];
            var rateDisplay = FormatRate(slotEntry.TurnoutRate);
            var regionLabel = string.IsNullOrWhiteSpace(slotEntry.RegionLabel)
                ? provinceLabel
                : slotEntry.RegionLabel;
            var districtLabel = ResolveTurnoutDistrictNameLabel(snapshot, regionLabel, slotEntry.Label);

            SetAliases(values, rateDisplay, GetTopTurnoutDistrictBoardRateAlias(sceneSlot));
            SetAliases(values, districtLabel, GetTopTurnoutDistrictBoardDistrictAlias(sceneSlot));
            SetOptionalAliases(values, guideAssetPath, $"그룹{sceneSlot:00}", $"그룹{sceneSlot}");
            SetOptionalAliases(values, guideAssetPath, $"바{sceneSlot:00}", $"바{sceneSlot}");
        }
    }

    private static string GetTopTurnoutDistrictBoardDistrictAlias(int slot)
    {
        return slot switch
        {
            1 => "시도명01",
            2 => "시도명01 2",
            3 => "시도명01 3",
            _ => $"시도명01 {slot}"
        };
    }

    private static string GetTopTurnoutDistrictBoardRateAlias(int slot)
    {
        return slot switch
        {
            1 => "투표율01",
            2 => "투표율01 1",
            3 => "투표율01 2",
            _ => $"투표율01 {slot - 1}"
        };
    }

    private static void ApplyCareerPromiseValues(
        IDictionary<string, string> values,
        FormatTemplateDefinition template,
        string t3CutPath,
        string templateFolderPath,
        CandidateEntry? candidate)
    {
        for (var slot = 1; slot <= 3; slot++)
        {
            SetAliases(values, string.Empty, $"공약{slot:00}", $"공약{slot}");
            SetAliases(values, string.Empty, $"공약그룹{slot:00}", $"공약그룹{slot}");
        }

        if (candidate is null)
        {
            return;
        }

        var promiseGroupPath = ResolvePartyAssetPath(
            t3CutPath,
            templateFolderPath,
            template.Name,
            candidate.EffectiveColorParty,
            PartyAssetKind.Group);
        var promises = GetVisibleCareerPromises(candidate);

        for (var visibleSlot = 1; visibleSlot <= promises.Length; visibleSlot++)
        {
            var sceneSlot = ResolveCareerPromiseSceneSlot(visibleSlot);
            var promise = promises[visibleSlot - 1];
            SetAliases(values, promise, $"공약{sceneSlot:00}", $"공약{sceneSlot}");

            if (!string.IsNullOrWhiteSpace(promiseGroupPath))
            {
                SetOptionalAliases(values, promiseGroupPath, $"공약그룹{sceneSlot:00}", $"공약그룹{sceneSlot}");
            }
        }
    }

    private static int ResolveCareerPromiseSceneSlot(int visibleSlot)
    {
        return Math.Clamp(visibleSlot, 1, 3);
    }

    private static string[] GetVisibleCareerPromises(CandidateEntry candidate)
    {
        return new[]
        {
            candidate.CareerPromise1?.Trim() ?? string.Empty,
            candidate.CareerPromise2?.Trim() ?? string.Empty,
            candidate.CareerPromise3?.Trim() ?? string.Empty
        }
            .Where(promise => !string.IsNullOrWhiteSpace(promise))
            .ToArray();
    }

    private static string ResolveTurnoutDecorAssetPath(string t3CutPath, string templateFolderPath)
    {
        return ResolveAssetAcrossRoots(
            t3CutPath,
            templateFolderPath,
            new[]
            {
                Path.Combine("Images", "Etc", "가이드.png"),
                Path.Combine("Images", "Etc", "guide.png")
            });
    }

    private static bool IsTurnoutTemplate(string templateName)
    {
        return templateName.Contains("투표율", StringComparison.Ordinal);
    }

    private static bool IsBottomTurnoutSidoTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Bottom &&
               (string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
                string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal));
    }

    private static bool IsBottomTurnoutDistrictTemplate(FormatTemplateDefinition template)
    {
        return template.RecommendedChannel == BroadcastChannel.Bottom &&
               (string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
                string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
    }

    private static CandidateEntry[] BuildHistoricalWinnerCandidates(
        FormatTemplateDefinition template,
        FormatCutDefinition cut,
        ElectionDataSnapshot snapshot,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        var slotCount = ResolveMaxCandidateSlots(template, cut, sceneVariables);
        if (slotCount <= 0)
        {
            slotCount = 8;
        }

        var winnersBySlot = ResolveHistoricalWinnerSlotMap(snapshot, slotCount);

        var candidates = new List<CandidateEntry>(slotCount);
        for (var slot = 1; slot <= slotCount; slot++)
        {
            if (winnersBySlot.TryGetValue(slot, out var winner))
            {
                candidates.Add(new CandidateEntry
                {
                    CandidateCode = $"H{winner.ElectionOrder:00}",
                    Name = winner.Name,
                    Party = winner.Party,
                    ColorParty = winner.ColorParty,
                    VoteCount = slotCount - slot,
                    VoteRate = 0,
                    HasImage = true,
                    ManualJudgement = CandidateJudgement.None,
                    AutomaticJudgement = CandidateJudgement.None
                });
            }
            else
            {
                candidates.Add(new CandidateEntry
                {
                    CandidateCode = $"H{slot:00}",
                    Name = string.Empty,
                    Party = string.Empty,
                    VoteCount = slotCount - slot,
                    VoteRate = 0,
                    HasImage = false,
                    ManualJudgement = CandidateJudgement.None,
                    AutomaticJudgement = CandidateJudgement.None
                });
            }
        }

        return candidates.ToArray();
    }

    private static IReadOnlyDictionary<int, PreElectionHistoricalWinnerEntry> ResolveHistoricalWinnerSlotMap(
        ElectionDataSnapshot snapshot,
        int slotCount)
    {
        if (slotCount <= 0)
        {
            return new Dictionary<int, PreElectionHistoricalWinnerEntry>();
        }

        var winners = snapshot.HistoricalWinnerHistory
            .Where(entry => entry.ElectionOrder > 0 && !string.IsNullOrWhiteSpace(entry.Name))
            .GroupBy(entry => entry.ElectionOrder)
            .Select(group => group.First())
            .OrderBy(entry => entry.ElectionOrder)
            .ToArray();
        if (winners.Length == 0)
        {
            return new Dictionary<int, PreElectionHistoricalWinnerEntry>();
        }

        return winners
            .Where(entry => entry.ElectionOrder <= slotCount)
            .ToDictionary(entry => entry.ElectionOrder, entry => entry);
    }

    private static IEnumerable<string> GetHistoricalPartyAliases(string partyName)
    {
        var normalized = partyName.Replace(" ", string.Empty, StringComparison.Ordinal);
        if (string.IsNullOrWhiteSpace(normalized))
        {
            yield break;
        }

        if (normalized is "국힘" or "미래통합당" or "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
        {
            yield return "국민의힘";
        }

        if (normalized is "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
        {
            yield return "더불어민주당";
        }

        if (normalized is "무소속")
        {
            yield return "무기타";
            yield return "무소속기타";
        }
    }

    private static bool ShouldUseOfficeLabel(ElectionDataSnapshot snapshot)
    {
        return string.Equals(snapshot.ElectionType, "광역단체장", StringComparison.Ordinal) ||
            string.Equals(snapshot.ElectionType, "교육감", StringComparison.Ordinal) ||
            string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal);
    }

    private static bool ShouldUseTurnoutDistrictLabel(
        ElectionDataSnapshot snapshot,
        string regionName,
        string electionDistrictName)
    {
        if (string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal))
        {
            return true;
        }

        var normalizedRegionName = NormalizeRegionName(regionName);
        var normalizedElectionDistrictName = NormalizeRegionName(electionDistrictName);
        return !string.IsNullOrWhiteSpace(normalizedRegionName) &&
               !string.IsNullOrWhiteSpace(normalizedElectionDistrictName) &&
               !string.Equals(normalizedRegionName, normalizedElectionDistrictName, StringComparison.Ordinal);
    }

    private static string ResolveTurnoutDistrictNameLabel(
        ElectionDataSnapshot snapshot,
        string regionName,
        string electionDistrictName)
    {
        var label = string.IsNullOrWhiteSpace(electionDistrictName)
            ? snapshot.DistrictName
            : electionDistrictName;
        if (string.IsNullOrWhiteSpace(label))
        {
            return string.Empty;
        }

        label = StripParenthesizedSuffix(label.Trim());
        label = StripParentRegionPrefix(label, regionName);

        return string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal)
            ? StripLocalGovernmentOfficeSuffix(label)
            : label;
    }

    private static string StripParenthesizedSuffix(string label)
    {
        var parenthesisIndex = label.IndexOf('(');
        return parenthesisIndex >= 0
            ? label[..parenthesisIndex].TrimEnd()
            : label;
    }

    private static string StripParentRegionPrefix(string label, string regionName)
    {
        var prefixes = new[]
            {
                regionName,
                ExpandRegionName(regionName),
                NormalizeRegionName(regionName)
            }
            .Where(prefix => !string.IsNullOrWhiteSpace(prefix))
            .Distinct(StringComparer.Ordinal)
            .OrderByDescending(prefix => prefix.Length);

        foreach (var prefix in prefixes)
        {
            if (label.Length <= prefix.Length ||
                !label.StartsWith(prefix, StringComparison.Ordinal))
            {
                continue;
            }

            var remaining = label[prefix.Length..].TrimStart();
            if (!string.IsNullOrWhiteSpace(remaining))
            {
                return remaining;
            }
        }

        return label;
    }

    private static string StripLocalGovernmentOfficeSuffix(string label)
    {
        if (label.EndsWith("구청장", StringComparison.Ordinal))
        {
            return $"{label[..^"구청장".Length]}구";
        }

        if (label.EndsWith("시장", StringComparison.Ordinal))
        {
            return $"{label[..^"시장".Length]}시";
        }

        if (label.EndsWith("군수", StringComparison.Ordinal))
        {
            return $"{label[..^"군수".Length]}군";
        }

        return label;
    }

    private static string ResolveOfficeLabel(
        ElectionDataSnapshot snapshot,
        string regionName,
        string electionDistrictName)
    {
        if (string.Equals(snapshot.ElectionType, "광역단체장", StringComparison.Ordinal))
        {
            var providedOfficeLabel = ResolveProvidedMayorGovernorOfficeLabel(electionDistrictName, snapshot.DistrictName);
            if (!string.IsNullOrWhiteSpace(providedOfficeLabel))
            {
                return providedOfficeLabel;
            }

            return BuildMayorGovernorOfficeLabel(regionName);
        }

        if (string.Equals(snapshot.ElectionType, "교육감", StringComparison.Ordinal))
        {
            var providedOfficeLabel = ResolveProvidedEducationOfficeLabel(electionDistrictName, snapshot.DistrictName);
            if (!string.IsNullOrWhiteSpace(providedOfficeLabel))
            {
                return providedOfficeLabel;
            }

            return BuildEducationOfficeLabel(regionName);
        }

        if (string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal))
        {
            return BuildLocalGovernmentOfficeLabel(electionDistrictName, snapshot.DistrictName);
        }

        return string.IsNullOrWhiteSpace(electionDistrictName) ? regionName : electionDistrictName;
    }

    private static string BuildMayorGovernorOfficeLabel(string regionName)
    {
        var fullRegionName = ExpandRegionName(regionName);
        if (string.IsNullOrWhiteSpace(fullRegionName))
        {
            return regionName ?? string.Empty;
        }

        var shortRegionName = NormalizeRegionName(fullRegionName);
        if (fullRegionName.EndsWith("시", StringComparison.Ordinal))
        {
            return $"{shortRegionName}시장";
        }

        if (fullRegionName.EndsWith("도", StringComparison.Ordinal))
        {
            return $"{shortRegionName}지사";
        }

        return fullRegionName;
    }

    private static string ResolveProvidedMayorGovernorOfficeLabel(params string[] labels)
    {
        foreach (var label in labels)
        {
            if (string.IsNullOrWhiteSpace(label))
            {
                continue;
            }

            var normalized = NormalizeMayorGovernorOfficeLabel(label);
            if (!string.IsNullOrWhiteSpace(normalized))
            {
                return normalized;
            }
        }

        return string.Empty;
    }

    private static string NormalizeMayorGovernorOfficeLabel(string label)
    {
        var trimmed = label.Trim();
        var suffix = trimmed.EndsWith("시장", StringComparison.Ordinal)
            ? "시장"
            : trimmed.EndsWith("지사", StringComparison.Ordinal)
                ? "지사"
                : string.Empty;
        if (string.IsNullOrWhiteSpace(suffix))
        {
            return string.Empty;
        }

        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
            {
                return $"{pair.Key}{suffix}";
            }
        }

        var regionPart = trimmed[..^suffix.Length];
        var shortRegionName = NormalizeRegionName(regionPart);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? trimmed
            : $"{shortRegionName}{suffix}";
    }

    private static string BuildEducationOfficeLabel(string regionName)
    {
        var shortRegionName = NormalizeRegionName(regionName);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? "교육감"
            : $"{shortRegionName}교육감";
    }

    private static string ResolveProvidedEducationOfficeLabel(params string[] labels)
    {
        foreach (var label in labels)
        {
            if (string.IsNullOrWhiteSpace(label))
            {
                continue;
            }

            var normalized = NormalizeEducationOfficeLabel(label);
            if (!string.IsNullOrWhiteSpace(normalized))
            {
                return normalized;
            }
        }

        return string.Empty;
    }

    private static string NormalizeEducationOfficeLabel(string label)
    {
        var trimmed = label.Trim();
        const string suffix = "교육감";
        if (!trimmed.EndsWith(suffix, StringComparison.Ordinal))
        {
            return string.Empty;
        }

        foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
        {
            if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
            {
                return $"{pair.Key}{suffix}";
            }
        }

        var regionPart = trimmed[..^suffix.Length];
        var shortRegionName = NormalizeRegionName(regionPart);
        return string.IsNullOrWhiteSpace(shortRegionName)
            ? trimmed
            : $"{shortRegionName}{suffix}";
    }

    private static string BuildLocalGovernmentOfficeLabel(string electionDistrictName, string fallbackDistrictName)
    {
        var label = string.IsNullOrWhiteSpace(electionDistrictName)
            ? fallbackDistrictName
            : electionDistrictName;
        if (string.IsNullOrWhiteSpace(label))
        {
            return string.Empty;
        }

        var trimmed = label.Trim();
        var parenthesisIndex = trimmed.IndexOf('(');
        if (parenthesisIndex >= 0)
        {
            trimmed = trimmed.Substring(0, parenthesisIndex).TrimEnd();
        }

        if (trimmed.Contains(' ', StringComparison.Ordinal))
        {
            trimmed = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? trimmed;
        }

        if (trimmed.EndsWith("시장", StringComparison.Ordinal) ||
            trimmed.EndsWith("군수", StringComparison.Ordinal) ||
            trimmed.EndsWith("구청장", StringComparison.Ordinal))
        {
            return trimmed;
        }

        if (trimmed.EndsWith("시", StringComparison.Ordinal))
        {
            return $"{trimmed}장";
        }

        if (trimmed.EndsWith("군", StringComparison.Ordinal))
        {
            return $"{trimmed}수";
        }

        if (trimmed.EndsWith("구", StringComparison.Ordinal))
        {
            return $"{trimmed}청장";
        }

        return trimmed;
    }

    private static string NormalizeRegionName(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var normalized = value.Trim();
        foreach (var pair in FullRegionNames)
        {
            normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
        }

        return normalized
            .Replace("특별자치도", string.Empty, StringComparison.Ordinal)
            .Replace("특별자치시", string.Empty, StringComparison.Ordinal)
            .Replace("특별시", string.Empty, StringComparison.Ordinal)
            .Replace("광역시", string.Empty, StringComparison.Ordinal)
            .Replace("구청장", "구", StringComparison.Ordinal)
            .Replace("시장", "시", StringComparison.Ordinal)
            .Replace("군수", "군", StringComparison.Ordinal)
            .Replace("지사", string.Empty, StringComparison.Ordinal)
            .Replace("교육감", string.Empty, StringComparison.Ordinal)
            .Replace(" ", string.Empty, StringComparison.Ordinal)
            .Trim();
    }

    private static string ResolveNormalPanseMapRegionName(
        CandidateEntry candidate,
        IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
    {
        bool SupportsRegionObject(string regionName)
            => sceneVariables.Count == 0 || sceneVariables.ContainsKey(regionName);

        var regionNames = new[]
            {
                candidate.BroadcastRegionName,
                candidate.BroadcastDistrictName,
                candidate.BroadcastElectionDistrictName
            }
            .Select(NormalizeRegionName)
            .Where(regionName => !string.IsNullOrWhiteSpace(regionName))
            .ToArray();

        foreach (var regionName in regionNames)
        {
            if (FullRegionNames.ContainsKey(regionName) && SupportsRegionObject(regionName))
            {
                return regionName;
            }
        }

        foreach (var regionName in regionNames)
        {
            if (regionName.Contains("광주", StringComparison.Ordinal) && SupportsRegionObject("광주"))
            {
                return "광주";
            }
        }

        foreach (var regionName in regionNames)
        {
            foreach (var region in NormalPanseMapRegions)
            {
                if (regionName.StartsWith(region, StringComparison.Ordinal) && SupportsRegionObject(region))
                {
                    return region;
                }
            }
        }

        return string.Empty;
    }

    private static string ExpandRegionName(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var normalized = NormalizeRegionName(value);
        return FullRegionNames.TryGetValue(normalized, out var fullName)
            ? fullName
            : value.Trim();
    }

    private enum PartyAssetKind
    {
        Bar,
        Plate,
        Outline,
        Color,
        Symbol,
        Group
    }

    private enum CouncilSeatColumn
    {
        District,
        Proportional,
        Total
    }

    private enum CouncilSeatSource
    {
        District,
        Proportional,
        Total
    }

    private readonly record struct CouncilSeatSummary(
        string Party,
        string ColorParty,
        int DistrictSeatCount,
        int ProportionalSeatCount)
    {
        public int TotalSeatCount => DistrictSeatCount + ProportionalSeatCount;
    }

    private readonly record struct PanseSummary(string Party, int Count);

    private readonly record struct NormalPansePartySlot(string Label, string ColorParty);

    private sealed record SceneUpdatePayload(
        IReadOnlyDictionary<string, string> Values,
        IReadOnlyList<KarismaCounterNumberKeyUpdate> CounterNumberKeys,
        IReadOnlyList<KarismaChartCellUpdate> ChartCellUpdates,
        IReadOnlyList<KarismaPositionUpdate> PositionUpdates,
        IReadOnlyList<KarismaCropKeyUpdate> CropKeyUpdates,
        IReadOnlyList<KarismaStyleColorUpdate> StyleColorUpdates,
        IReadOnlyList<KarismaVisibilityUpdate> VisibilityUpdatesBeforeValue,
        IReadOnlyList<KarismaVisibilityUpdate> VisibilityUpdatesAfterValue);

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

}
