using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;

namespace Tornado3_2026Election.ViewModels;

public sealed class ChannelScheduleViewModel : ObservableObject
{
    private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
    private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
    private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
    private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
    private readonly ChannelScheduleEngine _engine;
    private readonly ITornado3Adapter _adapter;
    private readonly CutDebugStateStore _cutDebugStateStore;
    private readonly DataViewModel _data;
    private readonly LogService _logService;
    private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
    private IReadOnlyList<FormatTemplateDefinition> _allFormats;
    private SelectionOption<CutCategory?>? _selectedFormatCategoryOption;
    private SelectionOption<string>? _selectedTurnoutRegionModeOption;
    private FormatTemplateDefinition? _selectedFormat;
    private CutDebugTemplateState? _selectedCutDebugTemplate;
    private ScheduleRegionOption? _selectedRegionOption;
    private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
    private CancellationTokenSource? _directPlaybackCts;
    private ChannelScheduleItem? _preparedDirectItem;
    private string _preparedDirectFormatId = string.Empty;
    private string _preparedDirectRegionKey = string.Empty;
    private bool _loopEnabled;
    private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
    private int _regionOptionsRevision;
    private string _lastRegionOptionFormatId = string.Empty;
    private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
    private double _selectedFormatThumbnailWidth = 320;
    private double _selectedFormatThumbnailHeight = 180;
    private double _selectedFormatDraftDurationSeconds;

    public ChannelScheduleViewModel(
        BroadcastChannel channel,
        string title,
        IReadOnlyList<FormatTemplateDefinition> formats,
        DataViewModel data,
        ITornado3Adapter adapter,
        CutDebugSettings cutDebug,
        CutDebugStateStore cutDebugStateStore,
        ChannelScheduleEngine engine,
        LogService logService)
    {
        Channel = channel;
        Title = title;
        _data = data;
        _adapter = adapter;
        CutDebug = cutDebug;
        _cutDebugStateStore = cutDebugStateStore;
        _engine = engine;
        _logService = logService;
        _allFormats = formats.ToArray();
        FormatCategoryOptions = [];
        TurnoutRegionModeOptions =
        [
            new SelectionOption<string>(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"),
            new SelectionOption<string>(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율")
        ];
        _selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
        AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
        RegionOptions = new ObservableCollection<ScheduleRegionOption>();
        EmptyBehaviorOptions =
        [
            new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
            new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.HoldLastFrame, "마지막 프레임 유지")
        ];
        Queue = engine.Queue;

        SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
        StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
        StopCommand = new AsyncRelayCommand(StopAsync);
        DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
        DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
        DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
        ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
        ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
        AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
        ResetQueueCommand = new RelayCommand(ResetQueue);
        RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
        MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
        MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
        PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
        IncreaseSelectedFormatDurationCommand = new RelayCommand(IncreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
        DecreaseSelectedFormatDurationCommand = new RelayCommand(DecreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
        ApplySelectedFormatDurationCommand = new RelayCommand(ApplySelectedFormatDuration, CanApplySelectedFormatDuration);
        SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);

        _engine.QueueChanged += (_, _) => RefreshSummary();
        _adapter.StateChanged += (_, _) => RefreshSummary();
        _adapter.ConnectionChanged += (_, _) => RefreshSummary();
        CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary), nameof(CutDebugPanelVisibility));
        _data.PropertyChanged += Data_PropertyChanged;
        Queue.CollectionChanged += Queue_CollectionChanged;

        RebuildFormatCategoryOptions();
        RebuildAvailableFormats();
        _ = RebuildRegionOptionsAsync();
        UpdateSelectedFormatThumbnailMetrics();
        ApplyQueueThumbnailLayouts();
        RefreshSummary();
    }

    public BroadcastChannel Channel { get; }

    public string Title { get; }

    public bool IsLiveCg => _adapter.IsLiveCg;

    public bool IsCgConnected => _adapter.IsConnected;

    public string CgBackendName => _adapter.BackendName;

    public string CgConnectionTarget => _adapter.ConnectionTarget;

    public string CgStatusSummary => IsLiveCg
        ? $"{(IsCgConnected ? "Connected" : "Disconnected")} / {CgBackendName} / {CgConnectionTarget}"
        : $"Disconnected / {CgBackendName} / {CgConnectionTarget}";

    public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }

    public ObservableCollection<SelectionOption<CutCategory?>> FormatCategoryOptions { get; }

    public IReadOnlyList<SelectionOption<string>> TurnoutRegionModeOptions { get; }

    public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }

    public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }

    public ObservableCollection<ChannelScheduleItem> Queue { get; }

    public AsyncRelayCommand SchedulePrepareCommand { get; }

    public AsyncRelayCommand StartCommand { get; }

    public AsyncRelayCommand StopCommand { get; }

    public AsyncRelayCommand DirectPrepareCommand { get; }

    public AsyncRelayCommand DirectStartCommand { get; }

    public AsyncRelayCommand DirectStopCommand { get; }

    public AsyncRelayCommand ForceNextCommand { get; }

    public AsyncRelayCommand ForceQueueNextCommand { get; }

    public CutDebugSettings CutDebug { get; }

    public RelayCommand AddFormatCommand { get; }

    public RelayCommand ResetQueueCommand { get; }

    public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }

    public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }

    public RelayCommand<ChannelScheduleItem> MoveDownCommand { get; }

    public RelayCommand<ChannelScheduleItem> PromoteToNextCommand { get; }

    public RelayCommand IncreaseSelectedFormatDurationCommand { get; }

    public RelayCommand DecreaseSelectedFormatDurationCommand { get; }

    public RelayCommand ApplySelectedFormatDurationCommand { get; }

    public event EventHandler<FormatTemplateDefinition>? FormatDurationChanged;

    public SelectionOption<CutCategory?>? SelectedFormatCategoryOption
    {
        get => _selectedFormatCategoryOption;
        set
        {
            if (value is null)
            {
                return;
            }

            if (SetProperty(ref _selectedFormatCategoryOption, value))
            {
                RebuildAvailableFormats();
                _ = RebuildRegionOptionsAsync();
                RefreshSummary();
            }
        }
    }

    public SelectionOption<string>? SelectedTurnoutRegionModeOption
    {
        get => _selectedTurnoutRegionModeOption;
        set
        {
            if (value is null)
            {
                return;
            }

            if (SetProperty(ref _selectedTurnoutRegionModeOption, value))
            {
                _ = RebuildRegionOptionsAsync();
                AddFormatCommand.NotifyCanExecuteChanged();
                RefreshSummary();
            }
        }
    }

    public Visibility TurnoutRegionModeVisibility =>
        UsesTurnoutRegionMode(SelectedFormat) ? Visibility.Visible : Visibility.Collapsed;

    public FormatTemplateDefinition? SelectedFormat
    {
        get => _selectedFormat;
        set
        {
            if (SetProperty(ref _selectedFormat, value))
            {
                EnsureTurnoutRegionModeSelection();
                ResetSelectedFormatDurationDraft();
                OnPropertyChanged(nameof(TurnoutRegionModeVisibility));
                NotifySelectedFormatPreviewChanged();
                SyncSelectedCutDebugTemplate();
                _ = RebuildRegionOptionsAsync();
                AddFormatCommand.NotifyCanExecuteChanged();
                DirectPrepareCommand.NotifyCanExecuteChanged();
                DirectStartCommand.NotifyCanExecuteChanged();
            }
        }
    }

    public ScheduleRegionOption? SelectedRegionOption
    {
        get => _selectedRegionOption;
        set
        {
            if (SetProperty(ref _selectedRegionOption, value))
            {
                AddFormatCommand.NotifyCanExecuteChanged();
                DirectPrepareCommand.NotifyCanExecuteChanged();
                DirectStartCommand.NotifyCanExecuteChanged();
            }
        }
    }

    private void EnsureTurnoutRegionModeSelection()
    {
        if (!UsesTurnoutRegionMode(SelectedFormat))
        {
            return;
        }

        if (_selectedTurnoutRegionModeOption is not null)
        {
            return;
        }

        _selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
        OnPropertyChanged(nameof(SelectedTurnoutRegionModeOption));
    }

    private static bool UsesTurnoutRegionMode(FormatTemplateDefinition? format)
    {
        return format is not null &&
               string.Equals(format.Name, "투표율_사진", StringComparison.Ordinal);
    }

    public bool LoopEnabled
    {
        get => _loopEnabled;
        set
        {
            if (SetProperty(ref _loopEnabled, value))
            {
                _engine.LoopEnabled = value;
                _engine.RefreshQueueMarkers();
                RefreshSummary();
            }
        }
    }

    public EmptyScheduleBehavior EmptyScheduleBehavior
    {
        get => _emptyScheduleBehavior;
        set
        {
            if (SetProperty(ref _emptyScheduleBehavior, value))
            {
                _engine.EmptyScheduleBehavior = value;
                SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(value);
            }
        }
    }

    public SelectionOption<EmptyScheduleBehavior>? SelectedEmptyBehaviorOption
    {
        get => _selectedEmptyBehaviorOption;
        set
        {
            if (value is null)
            {
                return;
            }

            if (SetProperty(ref _selectedEmptyBehaviorOption, value) && _emptyScheduleBehavior != value.Value)
            {
                EmptyScheduleBehavior = value.Value;
            }
        }
    }

    public TornadoConnectionState AdapterState => _adapter.State;

    public string AdapterStateLabel => AdapterState switch
    {
        TornadoConnectionState.Idle => "대기",
        TornadoConnectionState.Ready => "준비 완료",
        TornadoConnectionState.Sending => "전송 중",
        TornadoConnectionState.OnAir => "송출 중",
        TornadoConnectionState.Error => "오류",
        _ => "대기"
    };

    public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}";

    public bool IsPlaying => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
        || _engine.ActivePlaybackItem is not null;

    public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;

    public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
        ? "준비"
        : IsPlaying
        ? "송출 중"
        : "대기";

    public string CurrentItemName => CurrentPlaybackItem?.DisplayName ?? "대기 화면";

    public string NextItemName => InternalNextPlaybackItem?.InternalNextPreviewDisplayName
        ?? QueueNextPlaybackItem?.DisplayName
        ?? "다음 컷 없음";

    public ImageSource? CurrentPreviewSource => CurrentPlaybackItem?.PreviewSource;

    public ImageSource? NextPreviewSource => InternalNextPlaybackItem?.InternalNextPreviewSource
        ?? QueueNextPlaybackItem?.PreviewSource;

    public string CurrentPreviewStatusLabel => CurrentPlaybackItem?.PreviewStatusLabel ?? "송출 중인 컷 없음";

    public string NextPreviewStatusLabel => InternalNextPlaybackItem?.InternalNextPreviewStatusLabel
        ?? QueueNextPlaybackItem?.PreviewStatusLabel
        ?? "다음 컷 없음";

    public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;

    public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;

    public double NextPreviewWidth => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Width;

    public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;

    public int QueuedItemCount => Queue.Count;

    public string QueueFootnote => $"목록 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";

    public string QueueSummary
    {
        get
        {
            var current = CurrentPlaybackItem?.DisplayName ?? "-";
            var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
                ?? QueueNextPlaybackItem?.DisplayName
                ?? "-";
            return $"현재 {current} / 다음 {next} / 목록 {Queue.Count}건";
        }
    }

    public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";

    public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";

    public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";

    public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";

    public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";

    public string SelectedFormatPath => SelectedFormat?.Id.Replace('\\', '/') ?? string.Empty;

    public string SelectedFormatThumbnailStatus => SelectedFormat is null
        ? string.Empty
        : CutThumbnailAssetCatalog.HasThumbnail(SelectedFormat)
            ? "등록된 썸네일을 표시 중"
            : "썸네일이 없어 기본 아이콘을 표시 중";

    public double SelectedFormatMinimumDurationSeconds => SelectedFormat is null
        ? 1d
        : ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(SelectedFormat);

    public double SelectedFormatDraftDurationSeconds
    {
        get => _selectedFormatDraftDurationSeconds;
        set
        {
            var normalized = SelectedFormat is null
                ? Math.Max(1d, Math.Round(value, 1, MidpointRounding.AwayFromZero))
                : ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, SelectedFormat);
            if (SetProperty(ref _selectedFormatDraftDurationSeconds, normalized))
            {
                NotifySelectedFormatDurationStateChanged();
            }
        }
    }

    public bool HasSelectedFormatDurationChange => SelectedFormat is not null &&
        Math.Abs(SelectedFormatDraftDurationSeconds - ResolveSelectedFormatDurationSeconds(SelectedFormat)) >= 0.001d;

    public string SelectedFormatDurationStatusLabel => HasSelectedFormatDurationChange ? "미적용" : "적용됨";

    public string CutDebugSummary => CutDebug.Summary;

    public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;

    public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;

    public int CutDebugItemCount => CutDebugItems.Count;

    public string CutDebugTextTargets => BuildCutDebugTextTargets(SelectedFormat);

    public string CutDebugImageTargets => BuildCutDebugImageTargets(SelectedFormat);

    public string CutDebugVisibilityTargets => BuildCutDebugVisibilityTargets(SelectedFormat);

    public string CutDebugVoteRateTextTargets => BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat));

    public string CutDebugVoteRateCounterTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} key#1";

    public string CutDebugPartyBarColorTargets => JoinTargets(
        BuildIndexedTargetRange("기호", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("기호텍스트", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("득표수바", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("정당바", ResolveCutDebugSlotCount(SelectedFormat)));

    public string CutDebugPartyPlateColorTargets => JoinTargets(
        BuildIndexedTargetRange("정당판", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("정당원", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("정당색", ResolveCutDebugSlotCount(SelectedFormat)),
        BuildIndexedTargetRange("정당명", ResolveCutDebugSlotCount(SelectedFormat)));

    public string CutDebugVoteRateColorTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} edge/face";

    public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null
        ? null
        : CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat);

    public double SelectedFormatThumbnailWidth => _selectedFormatThumbnailWidth;

    public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;

    private ChannelScheduleItem? CurrentPlaybackItem =>
        _engine.ActivePlaybackItem;

    private ChannelScheduleItem? InternalNextPlaybackItem =>
        CurrentPlaybackItem is { HasInternalNextPreview: true } item ? item : null;

    private ChannelScheduleItem? QueueNextPlaybackItem =>
        Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next);

    private ChannelScheduleItem? NextPlaybackItem =>
        InternalNextPlaybackItem ?? QueueNextPlaybackItem;

    public async Task RefreshRegionOptionsAsync()
    {
        await RebuildRegionOptionsAsync();
    }

    public void RefreshSelectedFormatPreview()
    {
        NotifySelectedFormatPreviewChanged();
    }

    public void RefreshQueueThumbnails()
    {
        foreach (var item in Queue)
        {
            item.RefreshThumbnail();
        }
    }

    public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
    {
        _allFormats = formats.ToArray();
        RebuildFormatCategoryOptions();
        RebuildAvailableFormats();
        _ = RebuildRegionOptionsAsync();
        ApplyQueueThumbnailLayouts();
        RefreshSummary();
    }

    public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
    {
        if (_videoWallLayoutPreset == videoWallLayoutPreset)
        {
            return;
        }

        _videoWallLayoutPreset = videoWallLayoutPreset;
        UpdateSelectedFormatThumbnailMetrics();
        ApplyQueueThumbnailLayouts();
        NotifyPlaybackPreviewChanged();
    }

    private async Task StartAsync()
    {
        await _engine.StartAsync().ConfigureAwait(false);
        RefreshSummary();
        _logService.Info($"[{Title}] 스케줄 시작");
    }

    private async Task PrepareScheduleAsync()
    {
        try
        {
            await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
            RefreshSummary();
            _logService.Info($"[{Title}] 스케줄 다음 컷 준비");
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            RefreshSummary();
            _logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
        }
    }

    private async Task StopAsync()
    {
        await _engine.StopAsync().ConfigureAwait(false);
        RefreshSummary();
        _logService.Info($"[{Title}] 스케줄 정지");
    }

    private async Task DirectPrepareAsync()
    {
        var selectedFormat = SelectedFormat;
        var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
        if (selectedFormat is null || regionOption is null)
        {
            _logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
            return;
        }

        await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
        _directPlaybackCts?.Cancel();
        _directPlaybackCts?.Dispose();

        var prepareCts = new CancellationTokenSource();
        _directPlaybackCts = prepareCts;
        var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
        item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
            selectedFormat,
            _videoWallLayoutPreset,
            ThumbnailDisplayContext.Queue));
        _preparedDirectItem = item;
        _preparedDirectFormatId = selectedFormat.Id;
        _preparedDirectRegionKey = BuildRegionOptionKey(regionOption);

        try
        {
            _logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
            await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
            if (!prepareCts.IsCancellationRequested)
            {
                _logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            ClearPreparedDirectState(item);
            _logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
        }
        finally
        {
            if (ReferenceEquals(_directPlaybackCts, prepareCts))
            {
                _directPlaybackCts = null;
            }

            prepareCts.Dispose();
            RefreshSummary();
        }
    }

    private async Task DirectStartAsync()
    {
        var selectedFormat = SelectedFormat;
        var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
        if (selectedFormat is null || regionOption is null)
        {
            _logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
            return;
        }

        var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
        if (preparedItem is null)
        {
            await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
            _directPlaybackCts?.Cancel();
            _directPlaybackCts?.Dispose();
        }

        var playbackCts = new CancellationTokenSource();
        _directPlaybackCts = playbackCts;
        var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
        if (preparedItem is null)
        {
            item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
                selectedFormat,
                _videoWallLayoutPreset,
                ThumbnailDisplayContext.Queue));
        }

        try
        {
            _logService.Info($"[{Title}] 선택 컷 바로 송출: {selectedFormat.Name} / {regionOption.Label}");
            await _engine.PlayDirectAsync(item, selectedFormat, playbackCts.Token).ConfigureAwait(false);
            if (!playbackCts.IsCancellationRequested)
            {
                _logService.Info($"[{Title}] 선택 컷 바로 송출 완료: {selectedFormat.Name}");
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logService.Error($"[{Title}] 선택 컷 바로 송출 실패: {ex.Message}");
        }
        finally
        {
            ClearPreparedDirectState(item);
            if (ReferenceEquals(_directPlaybackCts, playbackCts))
            {
                _directPlaybackCts = null;
            }

            playbackCts.Dispose();
            RefreshSummary();
        }
    }

    private async Task DirectStopAsync()
    {
        _directPlaybackCts?.Cancel();
        await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
        ClearPreparedDirectState(_preparedDirectItem);
        _engine.ClearDirectPlayback();
        RefreshSummary();
        _logService.Info($"[{Title}] 선택 컷 송출 정지");
    }

    private ChannelScheduleItem? ResolvePreparedDirectItem(
        FormatTemplateDefinition selectedFormat,
        ScheduleRegionOption regionOption)
    {
        if (_preparedDirectItem is null ||
            !_engine.IsPreparedItem(_preparedDirectItem) ||
            !string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
            !string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
        {
            return null;
        }

        return _preparedDirectItem;
    }

    private void ClearPreparedDirectState(ChannelScheduleItem? item)
    {
        if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
        {
            return;
        }

        _preparedDirectItem = null;
        _preparedDirectFormatId = string.Empty;
        _preparedDirectRegionKey = string.Empty;
    }

    private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
    {
        return string.Join(
            "\u001F",
            regionOption.Scope,
            regionOption.ElectionType ?? string.Empty,
            regionOption.Label ?? string.Empty,
            regionOption.DistrictCode ?? string.Empty);
    }

    private async Task ForceNextAsync()
    {
        await _engine.ForceNextAsync().ConfigureAwait(false);
        RefreshSummary();
        _logService.Info($"[{Title}] 현재 컷을 강제로 전환");
    }

    private async Task ForceQueueNextAsync()
    {
        await _engine.ForceQueueNextAsync().ConfigureAwait(false);
        RefreshSummary();
        _logService.Info($"[{Title}] 다음 순서를 즉시 송출");
    }

    private void AddFormat()
    {
        if (SelectedFormat is null)
        {
            return;
        }

        var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
        if (regionOption is null)
        {
            _logService.Warning($"[{Title}] 선택 가능한 지역 정보가 아직 준비되지 않았습니다.");
            return;
        }

        var item = ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption);
        item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue));
        _engine.Enqueue(item);
        RefreshSummary();
    }

    private void ResetQueue()
    {
        _engine.Reset();
        RefreshSummary();
        _logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
    }

    private void RemoveItem(ChannelScheduleItem? item)
    {
        if (!_engine.Remove(item))
        {
            _logService.Warning($"[{Title}] 송출 중 항목은 제거할 수 없습니다.");
            return;
        }

        RefreshSummary();
    }

    private void MoveUp(ChannelScheduleItem? item)
    {
        _engine.MoveUp(item);
        RefreshSummary();
    }

    private void MoveDown(ChannelScheduleItem? item)
    {
        _engine.MoveDown(item);
        RefreshSummary();
    }

    private void PromoteToNext(ChannelScheduleItem? item)
    {
        _engine.PromoteToNext(item);
        RefreshSummary();
    }

    private void IncreaseSelectedFormatDuration()
    {
        SelectedFormatDraftDurationSeconds += 1d;
    }

    private void DecreaseSelectedFormatDuration()
    {
        SelectedFormatDraftDurationSeconds -= 1d;
    }

    private void ApplySelectedFormatDuration()
    {
        var selectedFormat = SelectedFormat;
        if (selectedFormat is null)
        {
            return;
        }

        var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(SelectedFormatDraftDurationSeconds, selectedFormat);
        var changed = false;
        foreach (var cut in selectedFormat.Cuts)
        {
            if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
            {
                continue;
            }

            cut.DurationSeconds = normalized;
            changed = true;
        }

        SelectedFormatDraftDurationSeconds = normalized;
        NotifySelectedFormatDurationStateChanged();
        if (changed)
        {
            FormatDurationChanged?.Invoke(this, selectedFormat);
            _logService.Info($"[{Title}] 컷 송출 시간 적용: {selectedFormat.Name} {normalized:0.#}초");
        }
    }

    private bool CanAdjustSelectedFormatDuration()
    {
        return SelectedFormat is not null;
    }

    private bool CanApplySelectedFormatDuration()
    {
        return HasSelectedFormatDurationChange;
    }

    public void RefreshSummary()
    {
        _engine.RefreshQueueMarkers();
        OnPropertyChanged(
            nameof(AdapterState),
            nameof(AdapterStateText),
            nameof(AdapterStateLabel),
            nameof(IsPlaying),
            nameof(PlaybackIconBrush),
            nameof(TransmissionLabel),
            nameof(CurrentItemName),
            nameof(NextItemName),
            nameof(CurrentPreviewSource),
            nameof(NextPreviewSource),
            nameof(CurrentPreviewStatusLabel),
            nameof(NextPreviewStatusLabel),
            nameof(CurrentPreviewWidth),
            nameof(CurrentPreviewHeight),
            nameof(NextPreviewWidth),
            nameof(NextPreviewHeight),
            nameof(QueuedItemCount),
            nameof(QueueFootnote),
            nameof(QueueSummary),
            nameof(IsCgConnected),
            nameof(CgStatusSummary),
            nameof(LoopSummary),
            nameof(EmptyBehaviorLabel),
            nameof(OperatorQuickSummary));
    }

    private bool CanAddFormat()
    {
        return SelectedFormat is not null &&
            SelectedRegionOption is not null;
    }

    private bool CanDirectStart()
    {
        return CanAddFormat();
    }

    private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
    {
        if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
        {
            RebuildFormatCategoryOptions();
            RebuildAvailableFormats();
            _ = RebuildRegionOptionsAsync();
            RefreshSummary();
        }
    }

    private void RebuildAvailableFormats()
    {
        var selectedFormatId = SelectedFormat?.Id;
        var selectedCategory = SelectedFormatCategoryOption?.Value;
        var filteredFormats = _allFormats
            .Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
            .ToArray();

        AvailableFormats.Clear();
        foreach (var format in filteredFormats)
        {
            AvailableFormats.Add(format);
        }

        var nextSelectedFormat = !string.IsNullOrWhiteSpace(selectedFormatId)
            ? AvailableFormats.FirstOrDefault(format => string.Equals(format.Id, selectedFormatId, StringComparison.Ordinal))
            : null;
        nextSelectedFormat ??= AvailableFormats.FirstOrDefault();
        if (!ReferenceEquals(SelectedFormat, nextSelectedFormat))
        {
            SelectedFormat = nextSelectedFormat;
        }

        UpdateSelectedFormatThumbnailMetrics();
        SyncSelectedCutDebugTemplate();
        AddFormatCommand.NotifyCanExecuteChanged();
        DirectPrepareCommand.NotifyCanExecuteChanged();
        DirectStartCommand.NotifyCanExecuteChanged();
        OnPropertyChanged(nameof(QueueFootnote));
    }

    private void RebuildFormatCategoryOptions()
    {
        var selectedCategory = SelectedFormatCategoryOption?.Value;
        var options = CreateFormatCategoryOptions(_allFormats);

        FormatCategoryOptions.Clear();
        foreach (var option in options)
        {
            FormatCategoryOptions.Add(option);
        }

        var nextSelectedOption = selectedCategory.HasValue
            ? FormatCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
            : null;
        nextSelectedOption ??= FormatCategoryOptions.FirstOrDefault();

        if (!ReferenceEquals(_selectedFormatCategoryOption, nextSelectedOption))
        {
            _selectedFormatCategoryOption = nextSelectedOption;
            OnPropertyChanged(nameof(SelectedFormatCategoryOption));
        }
    }

    private static IReadOnlyList<SelectionOption<CutCategory?>> CreateFormatCategoryOptions(
        IReadOnlyList<FormatTemplateDefinition> formats)
    {
        List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
        var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
        {
            BuildCategoryResultKey(formats.Select(format => format.Id))
        };

        foreach (var category in CutCategoryResolver.GetOrderedCategories())
        {
            var matchingFormats = formats
                .Where(format => CutCategoryResolver.IsMatch(format, category))
                .ToArray();
            if (matchingFormats.Length == 0)
            {
                continue;
            }

            if (!seenResultKeys.Add(BuildCategoryResultKey(matchingFormats.Select(format => format.Id))))
            {
                continue;
            }

            options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
        }

        return options;
    }

    private static string BuildCategoryResultKey(IEnumerable<string> formatIds)
    {
        return string.Join(
            "\u001F",
            formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
    }

    private async Task RebuildRegionOptionsAsync()
    {
        var revision = Interlocked.Increment(ref _regionOptionsRevision);
        var selectedFormat = SelectedFormat;
        var previousSelection = SelectedRegionOption;

        if (selectedFormat is null)
        {
            RegionOptions.Clear();
            SelectedRegionOption = null;
            _lastRegionOptionFormatId = string.Empty;
            AddFormatCommand.NotifyCanExecuteChanged();
            DirectPrepareCommand.NotifyCanExecuteChanged();
            DirectStartCommand.NotifyCanExecuteChanged();
            return;
        }

        var previousRegionOptionFormatId = _lastRegionOptionFormatId;
        var turnoutPhotoMode = UsesTurnoutRegionMode(selectedFormat)
            ? SelectedTurnoutRegionModeOption?.Value
            : null;
        var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat, turnoutPhotoMode);
        if (revision != _regionOptionsRevision)
        {
            return;
        }

        RegionOptions.Clear();
        foreach (var option in options)
        {
            RegionOptions.Add(option);
        }

        var shouldUseDefaultSelection = !string.Equals(
            previousRegionOptionFormatId,
            selectedFormat.Id,
            StringComparison.Ordinal);
        SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
        _lastRegionOptionFormatId = selectedFormat.Id;
        AddFormatCommand.NotifyCanExecuteChanged();
        DirectPrepareCommand.NotifyCanExecuteChanged();
        DirectStartCommand.NotifyCanExecuteChanged();
    }

    private void NotifySelectedFormatPreviewChanged()
    {
        UpdateSelectedFormatThumbnailMetrics();
        OnPropertyChanged(
            nameof(CutDebugTextTargets),
            nameof(CutDebugImageTargets),
            nameof(CutDebugVisibilityTargets),
            nameof(CutDebugVoteRateTextTargets),
            nameof(CutDebugVoteRateCounterTargets),
            nameof(CutDebugPartyBarColorTargets),
            nameof(CutDebugPartyPlateColorTargets),
            nameof(CutDebugVoteRateColorTargets),
            nameof(SelectedFormatName),
            nameof(SelectedFormatDescription),
            nameof(SelectedFormatPath),
            nameof(SelectedFormatThumbnailStatus),
            nameof(SelectedFormatThumbnailSource),
            nameof(SelectedFormatThumbnailWidth),
            nameof(SelectedFormatThumbnailHeight));
    }

    private void ResetSelectedFormatDurationDraft()
    {
        var durationSeconds = SelectedFormat is null
            ? 0d
            : ResolveSelectedFormatDurationSeconds(SelectedFormat);
        SetProperty(ref _selectedFormatDraftDurationSeconds, durationSeconds, nameof(SelectedFormatDraftDurationSeconds));
        NotifySelectedFormatDurationStateChanged();
    }

    private void NotifySelectedFormatDurationStateChanged()
    {
        OnPropertyChanged(
            nameof(SelectedFormatMinimumDurationSeconds),
            nameof(HasSelectedFormatDurationChange),
            nameof(SelectedFormatDurationStatusLabel));
        IncreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
        DecreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
        ApplySelectedFormatDurationCommand.NotifyCanExecuteChanged();
    }

    private static double ResolveSelectedFormatDurationSeconds(FormatTemplateDefinition format)
    {
        return ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
            format.Cuts.FirstOrDefault()?.DurationSeconds ?? ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(format),
            format);
    }

    private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.OldItems is not null)
        {
            foreach (var item in e.OldItems.OfType<ChannelScheduleItem>())
            {
                item.PropertyChanged -= QueueItem_PropertyChanged;
            }
        }

        if (e.NewItems is not null)
        {
            foreach (var item in e.NewItems.OfType<ChannelScheduleItem>())
            {
                item.PropertyChanged -= QueueItem_PropertyChanged;
                item.PropertyChanged += QueueItem_PropertyChanged;
            }
        }

        ApplyQueueThumbnailLayouts();
        NotifyPlaybackStateChanged();
        NotifyPlaybackPreviewChanged();
    }

    private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName is nameof(ChannelScheduleItem.State)
            or nameof(ChannelScheduleItem.DisplayName)
            or nameof(ChannelScheduleItem.CurrentRegionLabel)
            or nameof(ChannelScheduleItem.PreviewSource)
            or nameof(ChannelScheduleItem.PreviewStatusLabel)
            or nameof(ChannelScheduleItem.InternalNextPreviewSource)
            or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
            or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
            or nameof(ChannelScheduleItem.HasInternalNextPreview)
            or nameof(ChannelScheduleItem.ThumbnailSource))
        {
            if (e.PropertyName is nameof(ChannelScheduleItem.State)
                or nameof(ChannelScheduleItem.DisplayName)
                or nameof(ChannelScheduleItem.CurrentRegionLabel))
            {
                NotifyPlaybackStateChanged();
            }

            NotifyPlaybackPreviewChanged();
        }
    }

    private void NotifyPlaybackStateChanged()
    {
        OnPropertyChanged(
            nameof(IsPlaying),
            nameof(PlaybackIconBrush),
            nameof(TransmissionLabel),
            nameof(CurrentItemName),
            nameof(NextItemName),
            nameof(QueuedItemCount),
            nameof(QueueFootnote),
            nameof(QueueSummary));
    }

    private void NotifyPlaybackPreviewChanged()
    {
        OnPropertyChanged(
            nameof(CurrentPreviewSource),
            nameof(NextPreviewSource),
            nameof(CurrentPreviewStatusLabel),
            nameof(NextPreviewStatusLabel),
            nameof(CurrentPreviewWidth),
            nameof(CurrentPreviewHeight),
            nameof(NextPreviewWidth),
            nameof(NextPreviewHeight));
    }

    private ThumbnailDisplayMetrics ResolvePlaybackPreviewMetrics(ChannelScheduleItem? item)
    {
        var template = item is null
            ? null
            : _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));

        return template is null
            ? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview)
            : ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview);
    }

    private static ScheduleRegionOption? ResolvePreferredRegionOption(
        IReadOnlyList<ScheduleRegionOption> options,
        ScheduleRegionOption? previousSelection,
        FormatTemplateDefinition selectedFormat,
        bool shouldUseDefaultSelection)
    {
        if (options.Count == 0)
        {
            return null;
        }

        if (previousSelection is null || shouldUseDefaultSelection)
        {
            return ResolveDefaultRegionOption(options, selectedFormat);
        }

        if (previousSelection.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
        {
            var matchedSingle = options.FirstOrDefault(option =>
                option.Scope == previousSelection.Scope &&
                string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal) &&
                string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
            if (matchedSingle is not null)
            {
                return matchedSingle;
            }
        }

        return options.FirstOrDefault(option =>
                option.Scope == previousSelection.Scope &&
                string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal)) ??
            options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
            ResolveDefaultRegionOption(options, selectedFormat);
    }

    private static ScheduleRegionOption ResolveDefaultRegionOption(
        IReadOnlyList<ScheduleRegionOption> options,
        FormatTemplateDefinition selectedFormat)
    {
        var defaultScope = UsesAllDefaultRegionScope(selectedFormat)
            ? ScheduleRegionScope.All
            : ScheduleRegionScope.StationRegions;

        return options.FirstOrDefault(option => option.Scope == defaultScope) ?? options[0];
    }

    private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
    {
        if (IsBottomTurnoutSidoFormat(format))
        {
            return true;
        }

        if (IsBottomTurnoutDistrictFormat(format))
        {
            return false;
        }

        var source = $"{format.Name} {format.Id}";
        return source.Contains("광역단체장", StringComparison.Ordinal) ||
            source.Contains("교육감", StringComparison.Ordinal);
    }

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

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

    private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
    {
        return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
    }

    private void UpdateSelectedFormatThumbnailMetrics()
    {
        var metrics = SelectedFormat is null
            ? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview)
            : ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview);

        SetProperty(ref _selectedFormatThumbnailWidth, metrics.Width, nameof(SelectedFormatThumbnailWidth));
        SetProperty(ref _selectedFormatThumbnailHeight, metrics.Height, nameof(SelectedFormatThumbnailHeight));
    }

    private void ApplyQueueThumbnailLayouts()
    {
        foreach (var item in Queue)
        {
            var template = _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
            var metrics = template is null
                ? ThumbnailLayoutResolver.ResolveDisplayMetrics(item.Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue)
                : ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue);
            item.UpdateThumbnailLayout(metrics);
        }
    }

    private void SyncSelectedCutDebugTemplate()
    {
        if (SelectedFormat is null)
        {
            _selectedCutDebugTemplate = null;
            OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
            return;
        }

        var templateState = _cutDebugStateStore.GetTemplate(Channel, SelectedFormat.Id, SelectedFormat.Name);
        templateState.SyncItems(BuildCutDebugItemDescriptors(SelectedFormat));
        _selectedCutDebugTemplate = templateState;
        OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
    }

    private static string BuildCutDebugTextTargets(FormatTemplateDefinition? format)
    {
        var slotCount = ResolveCutDebugSlotCount(format);
        return JoinTargets(
            "시도명01",
            "개표율01",
            BuildIndexedTargetRange("순위", slotCount),
            BuildIndexedTargetRange("후보명", slotCount),
            BuildIndexedTargetRange("정당명", slotCount),
            BuildIndexedTargetRange("득표수", slotCount),
            BuildIndexedTargetRange("득표율", slotCount));
    }

    private static string BuildCutDebugImageTargets(FormatTemplateDefinition? format)
    {
        var slotCount = ResolveCutDebugSlotCount(format);
        var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
        return JoinTargets(
            chartTarget,
            BuildIndexedTargetRange("유확당", slotCount),
            BuildIndexedTargetRange("점선", slotCount),
            BuildIndexedTargetRange("후보사진", slotCount),
            BuildIndexedTargetRange("득표수바", slotCount),
            BuildIndexedTargetRange("정당바", slotCount),
            BuildIndexedTargetRange("정당판", slotCount),
            BuildIndexedTargetRange("정당원", slotCount),
            BuildIndexedTargetRange("정당색", slotCount),
            BuildIndexedTargetRange("정당심볼", slotCount));
    }

    private static string BuildCutDebugVisibilityTargets(FormatTemplateDefinition? format)
    {
        var slotCount = ResolveCutDebugSlotCount(format);
        var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
        return JoinTargets(
            chartTarget,
            BuildIndexedTargetRange("점선", slotCount),
            BuildIndexedTargetRange("유확당", slotCount),
            BuildIndexedTargetRange("그룹", slotCount),
            BuildIndexedTargetRange("공약그룹", 3));
    }

    private static int ResolveCutDebugSlotCount(FormatTemplateDefinition? format)
    {
        if (format is null)
        {
            return 2;
        }

        var source = $"{format.Name} {format.Id}";
        var topRankMatch = TopRankSlotCountPattern.Match(source);
        if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount))
        {
            return Math.Max(1, topRankSlotCount);
        }

        var peopleMatch = PeopleSlotCountPattern.Match(source);
        if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount))
        {
            return Math.Max(1, peopleSlotCount);
        }

        return 2;
    }

    private static string BuildIndexedTargetRange(string prefix, int slotCount)
    {
        slotCount = Math.Max(1, slotCount);
        return slotCount == 1
            ? $"{prefix}01"
            : $"{prefix}01~{slotCount:00}";
    }

    private static string JoinTargets(params string[] targets)
    {
        return string.Join(", ", targets.Where(target => !string.IsNullOrWhiteSpace(target)));
    }

    private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugItemDescriptors(FormatTemplateDefinition? format)
    {
        if (format is null)
        {
            return Array.Empty<CutDebugItemDescriptor>();
        }

        var slotCount = ResolveCutDebugSlotCount(format);
        var descriptors = new List<CutDebugItemDescriptor>();

        AddDescriptor(descriptors, "선거구명01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "시도명01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "개표율01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "기준시01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "기준시02", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "유권자수01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "투표자수01", CutDebugItemKind.TextValue, "공통 텍스트");
        AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.Counter, "카운터");
        if (!ShouldExcludeHistoricalTurnoutGraph(format))
        {
            AddDescriptor(descriptors, "차트01", CutDebugItemKind.ImageValue, "이미지/리소스");
            AddDescriptor(descriptors, "차트01", CutDebugItemKind.Visibility, "표시/숨김");
        }

        AddIndexedDescriptors(descriptors, "순위", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "후보명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "득표수", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "득표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "선거구명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "시도명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "개표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
        AddIndexedDescriptors(descriptors, "공약", 3, CutDebugItemKind.TextValue, "공약 텍스트");

        AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "후보사진", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "정당심볼", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
        AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");

        AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.Counter, "카운터");
        AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.Counter, "카운터");

        AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.StyleColor, "색상");
        AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.StyleColor, "색상");

        AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
        AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
        AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.Visibility, "표시/숨김");
        AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.Visibility, "표시/숨김");

        return descriptors;
    }

    private static void AddIndexedDescriptors(
        ICollection<CutDebugItemDescriptor> descriptors,
        string prefix,
        int slotCount,
        CutDebugItemKind kind,
        string groupLabel)
    {
        for (var slot = 1; slot <= Math.Max(1, slotCount); slot++)
        {
            AddDescriptor(descriptors, $"{prefix}{slot:00}", kind, groupLabel);
        }
    }

    private static void AddDescriptor(
        ICollection<CutDebugItemDescriptor> descriptors,
        string key,
        CutDebugItemKind kind,
        string groupLabel)
    {
        descriptors.Add(new CutDebugItemDescriptor(key, kind, groupLabel));
    }

    private static bool ShouldExcludeHistoricalTurnoutGraph(FormatTemplateDefinition? format)
    {
        return format is not null &&
               format.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal);
    }
}
