using System.Diagnostics; using System.Drawing; using System.Drawing.Imaging; using System.Globalization; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Security.Cryptography; using Tornado3_2026Election.Domain; using Tornado3_2026Election.Services; internal static class CurrentApiCutDiagnostics { private const string PanseSummaryCandidateCodePrefix = "PANSE:"; private const string CouncilSeatDistrictCandidateCodePrefix = "SEAT:D:"; private const string PanseDemocraticPartyLabel = "더불어민주당"; private const string PansePeoplePowerPartyLabel = "국민의힘"; private const string PanseOtherPartyLabel = "무·기타"; private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled); private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled); private static readonly IReadOnlyDictionary RegionAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["서울"] = "서울", ["서울특별시"] = "서울", ["부산"] = "부산", ["부산광역시"] = "부산", ["대구"] = "대구", ["대구광역시"] = "대구", ["인천"] = "인천", ["인천광역시"] = "인천", ["광주"] = "광주", ["광주광역시"] = "광주", ["대전"] = "대전", ["대전광역시"] = "대전", ["울산"] = "울산", ["울산광역시"] = "울산", ["세종"] = "세종", ["세종특별자치시"] = "세종", ["경기"] = "경기", ["경기도"] = "경기", ["강원"] = "강원", ["강원도"] = "강원", ["강원특별자치도"] = "강원", ["충북"] = "충북", ["충청북도"] = "충북", ["충남"] = "충남", ["충청남도"] = "충남", ["전북"] = "전북", ["전라북도"] = "전북", ["전북특별자치도"] = "전북", ["전남"] = "전남", ["전라남도"] = "전남", ["경북"] = "경북", ["경상북도"] = "경북", ["경남"] = "경남", ["경상남도"] = "경남", ["제주"] = "제주", ["제주도"] = "제주", ["제주특별자치도"] = "제주" }; public static async Task RunAsync(string[] args) { var options = CurrentApiCutDiagnosticsOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Console.WriteLine("Current API cut diagnostics starting."); Console.WriteLine($"- Phase: {options.Phase}"); Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}"); Console.WriteLine($"- Region Scope: {options.RegionScope}"); Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}"); Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}"); Console.WriteLine($"- Scene Capture: {(options.CaptureSceneImages ? "on" : "off")}"); Console.WriteLine($"- Mixed Preview Capture: {(options.CaptureMixedPreviewImages ? "on" : "off")}"); Console.WriteLine($"- PGM Capture: {(options.CapturePgmImages ? "on" : "off")}"); Console.WriteLine($"- Keep On Air Between Sends: {(options.KeepOnAirBetweenSends ? "yes" : "no")}"); Console.WriteLine($"- Output: {options.OutputPath}"); var stationCatalog = new StationCatalogService().GetAll(); var stations = options.AllStations ? stationCatalog : stationCatalog .Where(station => string.Equals(station.Id, options.StationId, StringComparison.OrdinalIgnoreCase)) .ToArray(); if (stations.Count == 0) { Console.WriteLine($"No station matched '{options.StationId}'."); return 1; } var formats = new FormatCatalogService(options.ImageRootPath) .GetAll() .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall) .Where(template => string.IsNullOrWhiteSpace(options.Filter) || template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) || template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase)) .Where(template => string.IsNullOrWhiteSpace(options.ExcludeFilter) || (!template.Id.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase) && !template.Name.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase))) .ToArray(); if (options.TemplateLimit is int templateLimit && templateLimit > 0) { formats = formats.Take(templateLimit).ToArray(); } var phase = options.Phase == BroadcastPhase.PreElection ? BroadcastPhase.PreElection : BroadcastPhase.Counting; formats = formats .Where(template => template.IsAvailableInPhase(phase)) .ToArray(); Console.WriteLine($"- Templates: {formats.Length}"); Console.WriteLine(); using var apiClient = new SbsElectionApiClient(); var logService = new LogService(); var preElectionHistoryService = new PreElectionHistoryService(logService); var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService); ITornado3Adapter? adapter; try { adapter = CreateSendAdapter(options, logService); } catch (Exception ex) { Console.WriteLine(ex.Message); return 1; } var pgmWindow = options.CapturePgmImages ? TryFindPgmWindow() : null; if (options.CapturePgmImages && pgmWindow is null) { Console.WriteLine("PGM window was not found. Start Tornado3 PGM before using --capture-pgm-images."); return 1; } var districtCache = new Dictionary>(StringComparer.Ordinal); var results = new List(); var simulatedSendCount = 0; foreach (var station in stations) { foreach (var template in formats) { var electionType = ResolveScheduleElectionType(template, phase, options.DefaultElectionType); IReadOnlyList districts; try { districts = await GetDistrictsAsync( apiClient, districtCache, electionType, station, options.RegionScope == "all" || IsNormalPanseMapTemplate(template), template, preElectionHistoryService) .ConfigureAwait(false); } catch (Exception ex) { results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message)); continue; } var targets = IsNormalPanseMapTemplate(template) ? districts.ToArray() : ResolveTargets(districts, station, options).ToArray(); if (targets.Length == 0) { results.Add(CurrentApiCutDiagnosticResult.NoTarget(station, template, phase, electionType)); continue; } if (ShouldUseAggregateCurrentLeaderSnapshot(template, phase)) { foreach (var targetGroup in ResolveCurrentLeaderGroups(template, targets)) { var result = new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Region = string.Join(", ", targetGroup.Select(target => target.DisplayName)), DistrictCode = string.Join(",", targetGroup.Select(target => target.DistrictCode)), Status = "unknown" }; try { var snapshot = await CreateAggregatePanseSnapshotAsync( apiClient, electionType, targetGroup, template, CancellationToken.None) .ConfigureAwait(false); PopulateDataFields(result, snapshot, "GET /gaepyo aggregate current leader"); var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template); if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, out var validationError, out var warning)) { result.Status = "validation-failed"; result.Detail = validationError; result.Warning = warning; } else if (adapter is not null && simulatedSendCount < options.SendLimit) { await SimulateSendAsync(adapter, station, template, snapshot, options, result, pgmWindow).ConfigureAwait(false); simulatedSendCount++; result.Status = options.LiveSend ? "sent-live" : "sent-mock"; result.Detail = options.LiveSend ? "validated and live send completed" : "validated and mock send completed"; result.Warning = warning; } else { result.Status = "valid"; result.Detail = adapter is null ? "validated" : "validated; send limit reached"; result.Warning = warning; } } catch (Exception ex) { result.Status = "api-or-send-failed"; result.Detail = ex.Message; } results.Add(result); } continue; } if (ShouldUseAggregateTurnoutSnapshot(template, phase, electionType)) { var result = new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Region = string.Join(", ", targets.Select(target => target.DisplayName)), DistrictCode = string.Join(",", targets.Select(target => target.DistrictCode)), Status = "unknown" }; try { var snapshot = await CreateAggregateTurnoutSnapshotAsync( apiClient, electionType, districts, targets, template, CancellationToken.None) .ConfigureAwait(false); PopulateDataFields(result, snapshot, "GET /tupyo aggregate overview"); var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template); if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, out var validationError, out var warning)) { result.Status = "validation-failed"; result.Detail = validationError; result.Warning = warning; } else if (adapter is not null && simulatedSendCount < options.SendLimit) { await SimulateSendAsync(adapter, station, template, snapshot, options, result, pgmWindow).ConfigureAwait(false); simulatedSendCount++; result.Status = options.LiveSend ? "sent-live" : "sent-mock"; result.Detail = options.LiveSend ? "validated and live send completed" : "validated and mock send completed"; result.Warning = warning; } else { result.Status = "valid"; result.Detail = adapter is null ? "validated" : "validated; send limit reached"; result.Warning = warning; } } catch (Exception ex) { result.Status = "api-or-send-failed"; result.Detail = ex.Message; } results.Add(result); continue; } if (ShouldUseAggregateCouncilSeatSnapshot(template, phase)) { foreach (var targetGroup in ResolveAggregateCouncilSeatGroups(targets)) { var result = new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Region = string.Join(", ", targetGroup.Select(target => target.DisplayName)), DistrictCode = string.Join(",", targetGroup.Select(target => target.DistrictCode)), Status = "unknown" }; try { var snapshot = await CreateAggregateCouncilSeatSnapshotAsync( apiClient, electionType, targetGroup, CancellationToken.None) .ConfigureAwait(false); PopulateDataFields(result, snapshot, "GET /gaepyo aggregate council seats"); var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template); if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, out var validationError, out var warning)) { result.Status = "validation-failed"; result.Detail = validationError; result.Warning = warning; } else if (adapter is not null && simulatedSendCount < options.SendLimit) { await SimulateSendAsync(adapter, station, template, snapshot, options, result, pgmWindow).ConfigureAwait(false); simulatedSendCount++; result.Status = options.LiveSend ? "sent-live" : "sent-mock"; result.Detail = options.LiveSend ? "validated and live send completed" : "validated and mock send completed"; result.Warning = warning; } else { result.Status = "valid"; result.Detail = adapter is null ? "validated" : "validated; send limit reached"; result.Warning = warning; } } catch (Exception ex) { result.Status = "api-or-send-failed"; result.Detail = ex.Message; } results.Add(result); } continue; } if (ShouldUseAggregatePanseSnapshot(template, phase)) { var result = new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Region = string.Join(", ", targets.Select(target => target.DisplayName)), DistrictCode = string.Join(",", targets.Select(target => target.DistrictCode)), Status = "unknown" }; try { var snapshot = await CreateAggregatePanseSnapshotAsync( apiClient, electionType, targets, template, CancellationToken.None) .ConfigureAwait(false); PopulateDataFields(result, snapshot, "GET /gaepyo aggregate panse"); var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template); if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, out var validationError, out var warning)) { result.Status = "validation-failed"; result.Detail = validationError; result.Warning = warning; } else if (adapter is not null && simulatedSendCount < options.SendLimit) { await SimulateSendAsync(adapter, station, template, snapshot, options, result, pgmWindow).ConfigureAwait(false); simulatedSendCount++; result.Status = options.LiveSend ? "sent-live" : "sent-mock"; result.Detail = options.LiveSend ? "validated and live send completed" : "validated and mock send completed"; result.Warning = warning; } else { result.Status = "valid"; result.Detail = adapter is null ? "validated" : "validated; send limit reached"; result.Warning = warning; } } catch (Exception ex) { result.Status = "api-or-send-failed"; result.Detail = ex.Message; } results.Add(result); continue; } foreach (var target in targets) { var result = new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Region = target.DisplayName, DistrictCode = target.DistrictCode, Status = "unknown" }; try { ElectionDataSnapshot snapshot; if (UsesStoredPreElectionHistory(template)) { snapshot = CreateStoredPreElectionHistorySnapshot( phase, electionType, target, preElectionHistoryService); PopulateDataFields(result, snapshot, "stored pre-election history"); } else if (ShouldUseSingleTurnoutOverviewSnapshot(template, phase, electionType)) { snapshot = await CreateSingleTurnoutOverviewSnapshotAsync( apiClient, electionType, districts, target, CancellationToken.None) .ConfigureAwait(false); PopulateDataFields(result, snapshot, "GET /tupyo overview"); } else { var refreshPhase = ResolveScheduleRefreshPhase(template, phase); var refreshResult = await apiClient .RefreshAsync(refreshPhase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None) .ConfigureAwait(false); snapshot = CreateSnapshot(refreshPhase, electionType, refreshResult); PopulateDataFields(result, snapshot, refreshResult.SourcePath); } var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template); if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, out var validationError, out var warning)) { result.Status = "validation-failed"; result.Detail = validationError; result.Warning = warning; } else if (adapter is not null && simulatedSendCount < options.SendLimit) { await SimulateSendAsync(adapter, station, template, snapshot, options, result, pgmWindow).ConfigureAwait(false); simulatedSendCount++; result.Status = options.LiveSend ? "sent-live" : "sent-mock"; result.Detail = options.LiveSend ? "validated and live send completed" : "validated and mock send completed"; result.Warning = warning; } else { result.Status = "valid"; result.Detail = adapter is null ? "validated" : "validated; send limit reached"; result.Warning = warning; } } catch (Exception ex) { result.Status = "api-or-send-failed"; result.Detail = ex.Message; } results.Add(result); } } } if (adapter is not null && options.KeepOnAirBetweenSends) { await TryOutAllAsync(adapter).ConfigureAwait(false); } if (adapter is IDisposable disposable) { disposable.Dispose(); } WriteKarismaLog(options, logService); WriteReports(options, results); PrintSummary(results, options.OutputPath); return results.Any(result => result.Status is "validation-failed" or "api-or-send-failed" or "no-target") ? 1 : 0; } private static ITornado3Adapter? CreateSendAdapter(CurrentApiCutDiagnosticsOptions options, LogService logService) { if (!options.SimulateSend) { return null; } if (!options.LiveSend) { return new MockTornado3Adapter(logService); } var cutDebugStateStore = new CutDebugStateStore(); if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg) { throw new InvalidOperationException("Karisma adapter is not available. Live send cannot continue."); } return adapter; } private static string ResolveSendModeLabel(CurrentApiCutDiagnosticsOptions options) { if (!options.SimulateSend) { return "off"; } return options.LiveSend ? $"live ({options.SendLimit})" : $"mock ({options.SendLimit})"; } private static async Task CreateAggregateTurnoutSnapshotAsync( SbsElectionApiClient apiClient, string electionType, IReadOnlyList allDistricts, IReadOnlyList selectedDistricts, FormatTemplateDefinition template, CancellationToken cancellationToken) { var overview = await apiClient .GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken) .ConfigureAwait(false); var primaryDistrict = selectedDistricts.FirstOrDefault(); var primaryItem = FindTurnoutOverviewItem(overview.Items, primaryDistrict); var includeNationalSlot = IsBottomTurnoutBoardTemplate(template); var maxRegionalSlots = includeNationalSlot ? 4 : IsTopTurnoutDistrictBoardTemplate(template) ? 3 : 7; var turnoutBoardSlots = new List(); if (includeNationalSlot) { turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(1, "전국", overview.NationalTurnoutRate, true)); } var nextSlot = turnoutBoardSlots.Count + 1; foreach (var district in selectedDistricts.Take(maxRegionalSlots)) { var item = FindTurnoutOverviewItem(overview.Items, district); if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0) { continue; } turnoutBoardSlots.Add(new TurnoutBoardSlotEntry( nextSlot++, ResolveTurnoutBoardDistrictLabel(electionType, item, district), item.TurnoutRate, RegionLabel: ResolveTurnoutBoardRegionLabel(item, district))); } if (turnoutBoardSlots.Count == (includeNationalSlot ? 1 : 0)) { throw new InvalidOperationException("No positive turnout board slots were available."); } var regionName = primaryItem?.RegionName ?? primaryDistrict?.RegionName ?? string.Empty; var districtName = primaryItem?.DisplayName ?? primaryDistrict?.DisplayName ?? regionName; var electionDistrictName = ResolveTurnoutElectionDistrictName( electionType, primaryItem, primaryDistrict, regionName, districtName); var totalExpectedVotes = includeNationalSlot ? overview.TotalExpectedVotes : primaryItem?.TotalExpectedVotes ?? 0; var turnoutVotes = includeNationalSlot ? overview.TurnoutVotes : primaryItem?.TurnoutVotes ?? 0; var snapshotReferenceTimeLabel = includeNationalSlot ? overview.ReferenceTimeLabel : FirstNonWhiteSpace(primaryItem?.ReferenceTimeLabel, overview.ReferenceTimeLabel); return new ElectionDataSnapshot { BroadcastPhase = BroadcastPhase.PreElection, ElectionType = electionType, DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName, DistrictCode = primaryItem?.DistrictCode ?? primaryDistrict?.DistrictCode ?? string.Empty, RegionName = regionName, ElectionDistrictName = electionDistrictName, Candidates = Array.Empty(), TotalExpectedVotes = Math.Max(0, totalExpectedVotes), TurnoutVotes = Math.Max(0, turnoutVotes), CountedVotesFromApi = null, RemainingVotesFromApi = null, CountedRateFromApi = null, ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt, ReferenceTimeLabel = snapshotReferenceTimeLabel, TurnoutBoardSlots = turnoutBoardSlots, NationalTurnoutRateOverride = overview.NationalTurnoutRate }; } private static async Task CreateAggregatePanseSnapshotAsync( SbsElectionApiClient apiClient, string electionType, IReadOnlyList selectedDistricts, FormatTemplateDefinition template, CancellationToken cancellationToken) { var refreshResults = await apiClient .GetCountingSnapshotsAsync(electionType, selectedDistricts, cancellationToken) .ConfigureAwait(false); if (refreshResults.Count == 0) { throw new InvalidOperationException("No counting data was available for panse aggregation."); } var candidates = IsCurrentLeaderTemplate(template) || IsNormalPanseMapTemplate(template) ? BuildRegionalLeaderCandidates(refreshResults) : IsPanseEducationTemplate(template) ? BuildEducationPanseSummaryCandidates(refreshResults) : BuildPartyPanseSummaryCandidates(electionType, refreshResults); if (candidates.Length == 0) { throw new InvalidOperationException("No candidates were available for panse aggregation."); } var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes)); var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0)); var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0)); var countedRate = totalVotes <= 0 ? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max() : Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero); var primaryDistrict = selectedDistricts.FirstOrDefault(); var regionName = ResolveAggregateRegionLabel(selectedDistricts); var districtName = selectedDistricts.Count == 1 ? primaryDistrict?.DisplayName ?? regionName : regionName; return new ElectionDataSnapshot { BroadcastPhase = BroadcastPhase.Counting, ElectionType = electionType, DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName, DistrictCode = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictCode ?? string.Empty : string.Empty, RegionName = regionName, ElectionDistrictName = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictName ?? districtName : regionName, Candidates = candidates, TotalExpectedVotes = totalVotes, TurnoutVotes = turnoutVotes, CountedVotesFromApi = countedVotes, RemainingVotesFromApi = remainingVotes, CountedRateFromApi = countedRate, ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max() }; } private static async Task CreateAggregateCouncilSeatSnapshotAsync( SbsElectionApiClient apiClient, string electionType, IReadOnlyList selectedDistricts, CancellationToken cancellationToken) { var refreshResults = await apiClient .GetCountingSnapshotsAsync(electionType, selectedDistricts, cancellationToken) .ConfigureAwait(false); if (refreshResults.Count == 0) { throw new InvalidOperationException("No counting data was available for council seat aggregation."); } var candidates = BuildCouncilSeatSummaryCandidates(refreshResults); if (candidates.Length == 0) { throw new InvalidOperationException("No council seat candidates were available for aggregation."); } var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes)); var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0)); var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0)); var countedRate = ResolveCouncilSeatAggregateCountedRate(refreshResults); var primaryDistrict = selectedDistricts.FirstOrDefault(); var regionName = ResolveAggregateRegionLabel(selectedDistricts); var districtName = selectedDistricts.Count == 1 ? primaryDistrict?.DisplayName ?? regionName : regionName; return new ElectionDataSnapshot { BroadcastPhase = BroadcastPhase.Counting, ElectionType = electionType, DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName, DistrictCode = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictCode ?? string.Empty : string.Empty, RegionName = regionName, ElectionDistrictName = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictName ?? districtName : regionName, Candidates = candidates, TotalExpectedVotes = totalVotes, TurnoutVotes = turnoutVotes, CountedVotesFromApi = countedVotes, RemainingVotesFromApi = remainingVotes, CountedRateFromApi = countedRate, ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max() }; } private static IReadOnlyList> ResolveAggregateCouncilSeatGroups( IReadOnlyList targets) { return targets .GroupBy(ResolveCouncilSeatGroupKey, StringComparer.OrdinalIgnoreCase) .Select(group => (IReadOnlyList)group.ToArray()) .ToArray(); } private static IReadOnlyList> ResolveCurrentLeaderGroups( FormatTemplateDefinition template, IReadOnlyList targets) { if (IsBottomCurrentLeaderTemplate(template)) { return [targets]; } var pageSize = Math.Max(1, ResolveCurrentLeaderPageSize(template)); return targets .Select((target, index) => new { Target = target, PageIndex = index / pageSize }) .GroupBy(item => item.PageIndex) .Select(group => (IReadOnlyList)group .Select(item => item.Target) .ToArray()) .ToArray(); } private static double ResolveCouncilSeatAggregateCountedRate( IReadOnlyList refreshResults) { return refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max(); } private static string ResolveCouncilSeatGroupKey(SbsElectionApiClient.DistrictSelectionOption target) { return FirstNonWhiteSpace(target.RegionName, target.ParentRegionCode, target.DisplayName, "ALL"); } private static CandidateEntry[] BuildCouncilSeatSummaryCandidates( IReadOnlyList refreshResults) { var seatCandidates = refreshResults.Any(result => !result.CountingClosed) ? refreshResults.SelectMany(ResolveCurrentCouncilSeatCandidates).ToArray() : refreshResults .SelectMany(result => result.Candidates ?? Array.Empty()) .Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement)) .ToArray(); return BuildCouncilSeatSummaryCandidates(seatCandidates); } private static IEnumerable ResolveCurrentCouncilSeatCandidates( SbsElectionApiClient.SbsElectionRefreshResult result) { var candidates = result.Candidates ?? Array.Empty(); if (candidates.Count == 0) { return Array.Empty(); } var seatCount = Math.Max( result.SeatCount, candidates.Select(candidate => candidate.BroadcastSeatCount).DefaultIfEmpty(0).Max()); if (seatCount <= 0) { return Array.Empty(); } var rankedCandidates = candidates .Where(candidate => candidate.BroadcastRank > 0 && candidate.BroadcastRank <= seatCount) .OrderBy(candidate => candidate.BroadcastRank) .ThenByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .ToArray(); if (rankedCandidates.Length > 0) { return rankedCandidates; } return candidates .OrderByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .Take(seatCount) .ToArray(); } private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(IReadOnlyList candidates) { return candidates .GroupBy(candidate => ResolvePanseParty(candidate), StringComparer.OrdinalIgnoreCase) .Select(group => { var first = group.First(); return new { Party = group.Key, ColorParty = FirstNonWhiteSpace(first.EffectiveColorParty, first.Party, group.Key), SeatCount = group.Count() }; }) .Where(row => row.SeatCount > 0) .OrderByDescending(row => row.SeatCount) .ThenBy(row => row.Party, StringComparer.Ordinal) .Select((row, index) => new CandidateEntry { CandidateCode = $"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}", BallotNumber = (index + 1).ToString(CultureInfo.InvariantCulture), Name = row.Party, Party = row.Party, ColorParty = row.ColorParty, VoteCount = row.SeatCount, VoteRate = row.SeatCount, HasImage = false, ManualJudgement = CandidateJudgement.None, AutomaticJudgement = CandidateJudgement.Elected }) .ToArray(); } private static string ResolveAggregateRegionLabel( IReadOnlyList selectedDistricts) { var regionNames = selectedDistricts .Select(target => target.RegionName) .Where(regionName => !string.IsNullOrWhiteSpace(regionName)) .Distinct(StringComparer.Ordinal) .ToArray(); if (regionNames.Length == 1) { return regionNames[0]; } return "전체"; } private static CandidateEntry[] BuildRegionalLeaderCandidates( IReadOnlyList refreshResults) { return refreshResults .Select(CreateCurrentLeaderCandidate) .Where(candidate => candidate is not null) .Cast() .ToArray(); } private static CandidateEntry[] BuildEducationPanseSummaryCandidates( IReadOnlyList refreshResults) { var leaders = refreshResults .Select(CreateCurrentLeaderCandidate) .Where(candidate => candidate is not null) .Cast() .ToArray(); var counts = leaders .GroupBy(candidate => NormalizePansePartyKey(ResolveEducationPanseParty(candidate)), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); var rows = new[] { new PanseSummaryRow("진보", counts.TryGetValue("진보", out var progressiveCount) ? progressiveCount : 0), new PanseSummaryRow("보수", counts.TryGetValue("보수", out var conservativeCount) ? conservativeCount : 0), new PanseSummaryRow("중도", counts.TryGetValue("중도", out var moderateCount) ? moderateCount : 0) }; var extraCount = counts .Where(pair => pair.Key is not ("진보" or "보수" or "중도")) .Sum(pair => pair.Value); if (extraCount > 0) { rows[^1] = new PanseSummaryRow("기타", rows[^1].Count + extraCount); } return CreatePanseSummaryCandidates(rows); } private static CandidateEntry[] BuildPartyPanseSummaryCandidates( string electionType, IReadOnlyList refreshResults) { var leaders = refreshResults .Select(CreateCurrentLeaderCandidate) .Where(candidate => candidate is not null) .Cast() .ToArray(); return BuildPanseSummaryCandidates(leaders, ResolvePanseParty, countByVoteCount: false); } private static CandidateEntry? CreateCurrentLeaderCandidate( SbsElectionApiClient.SbsElectionRefreshResult result) { var leader = (result.Candidates ?? Array.Empty()) .OrderByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .FirstOrDefault(); if (leader is null) { return null; } var candidate = leader.Clone(); candidate.BroadcastDistrictName = FirstNonWhiteSpace(result.DistrictName, result.ElectionDistrictName); candidate.BroadcastRegionName = result.RegionName; candidate.BroadcastElectionDistrictName = FirstNonWhiteSpace(result.ElectionDistrictName, result.DistrictName); candidate.BroadcastDistrictCode = result.DistrictCode; candidate.BroadcastCountedRate = result.CountedRate; return candidate; } private static CandidateEntry[] BuildPanseSummaryCandidates( IReadOnlyList candidates, Func partySelector, bool countByVoteCount) { var counts = candidates .GroupBy(candidate => ResolvePartyPanseGroup(partySelector(candidate)), StringComparer.Ordinal) .ToDictionary( group => group.Key, group => group.Sum(candidate => countByVoteCount ? Math.Max(0, candidate.VoteCount) : 1), StringComparer.Ordinal); var rows = new[] { new PanseSummaryRow(PanseDemocraticPartyLabel, counts.TryGetValue(PanseDemocraticPartyLabel, out var democraticCount) ? democraticCount : 0), new PanseSummaryRow(PansePeoplePowerPartyLabel, counts.TryGetValue(PansePeoplePowerPartyLabel, out var peoplePowerCount) ? peoplePowerCount : 0), new PanseSummaryRow(PanseOtherPartyLabel, counts.TryGetValue(PanseOtherPartyLabel, out var otherCount) ? otherCount : 0) }; return CreatePanseSummaryCandidates(rows); } private static string ResolvePartyPanseGroup(string party) { var normalized = NormalizePansePartyKey(party); if (IsPanseDemocraticPartyKey(normalized)) { return PanseDemocraticPartyLabel; } if (IsPansePeoplePowerPartyKey(normalized)) { return PansePeoplePowerPartyLabel; } return PanseOtherPartyLabel; } 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 CandidateEntry[] CreatePanseSummaryCandidates(IReadOnlyList rows) { return rows .Select((row, index) => new CandidateEntry { CandidateCode = $"{PanseSummaryCandidateCodePrefix}{index + 1:00}", BallotNumber = (index + 1).ToString(), Name = row.Party, Party = row.Party, ColorParty = row.Party, VoteCount = row.Count, VoteRate = row.Count, HasImage = false, ManualJudgement = CandidateJudgement.None, AutomaticJudgement = CandidateJudgement.Elected }) .ToArray(); } 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 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 NormalizePansePartyKey(string party) { return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character))); } private static async Task CreateSingleTurnoutOverviewSnapshotAsync( SbsElectionApiClient apiClient, string electionType, IReadOnlyList allDistricts, SbsElectionApiClient.DistrictSelectionOption target, CancellationToken cancellationToken) { var overview = await apiClient .GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken) .ConfigureAwait(false); var item = FindTurnoutOverviewItem(overview.Items, target); if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0) { throw new InvalidOperationException("Selected region has no positive turnout overview data."); } var regionName = item.RegionName ?? target.RegionName ?? string.Empty; var districtName = item.DisplayName ?? target.DisplayName ?? regionName; var electionDistrictName = ResolveTurnoutElectionDistrictName( electionType, item, target, regionName, districtName); return new ElectionDataSnapshot { BroadcastPhase = BroadcastPhase.PreElection, ElectionType = electionType, DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName, DistrictCode = item.DistrictCode ?? target.DistrictCode ?? string.Empty, RegionName = regionName, ElectionDistrictName = electionDistrictName, Candidates = Array.Empty(), TotalExpectedVotes = Math.Max(0, item.TotalExpectedVotes), TurnoutVotes = Math.Max(0, item.TurnoutVotes), CountedVotesFromApi = null, RemainingVotesFromApi = null, CountedRateFromApi = null, ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt, ReferenceTimeLabel = FirstNonWhiteSpace(item.ReferenceTimeLabel, overview.ReferenceTimeLabel), NationalTurnoutRateOverride = overview.NationalTurnoutRate }; } private static bool ShouldUseAggregateTurnoutSnapshot( FormatTemplateDefinition template, BroadcastPhase phase, string electionType) { return phase == BroadcastPhase.PreElection && SupportsPreElectionTurnout(electionType) && (IsBottomTurnoutBoardTemplate(template) || IsRegionalTurnoutBoardTemplate(template) || IsTopTurnoutDistrictBoardTemplate(template)); } private static bool ShouldUseAggregatePanseSnapshot( FormatTemplateDefinition template, BroadcastPhase phase) { return phase == BroadcastPhase.Counting && ((template.RecommendedChannel == BroadcastChannel.TopLeft && template.Name.StartsWith("판세_", StringComparison.Ordinal)) || IsNormalPanseMapTemplate(template)); } private static bool ShouldUseAggregateCurrentLeaderSnapshot( FormatTemplateDefinition template, BroadcastPhase phase) { return phase == BroadcastPhase.Counting && IsCurrentLeaderTemplate(template); } private static bool IsCurrentLeaderTemplate(FormatTemplateDefinition template) { return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal) || IsBottomCurrentLeaderTemplate(template); } private static bool IsBottomCurrentLeaderTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Bottom && template.Name.StartsWith("1위_", StringComparison.Ordinal); } private static int ResolveCurrentLeaderPageSize(FormatTemplateDefinition template) { if (template.RecommendedChannel == BroadcastChannel.Bottom && template.Name.StartsWith("1위_", StringComparison.Ordinal)) { return 3; } if (template.Name.Contains("_L", StringComparison.Ordinal) || template.Id.Contains("_L", StringComparison.Ordinal) || template.SceneWidth >= 5000) { return 3; } return template.Name.Contains("_HD", StringComparison.Ordinal) || template.Id.Contains("_HD", StringComparison.Ordinal) ? 2 : 1; } private static bool ShouldUseAggregateCouncilSeatSnapshot( FormatTemplateDefinition template, BroadcastPhase phase) { return phase == BroadcastPhase.Counting && ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name); } private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Normal && string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); } private static bool IsPanseEducationTemplate(FormatTemplateDefinition template) { return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal); } private static bool IsCouncilElectionType(string electionType) { return string.Equals(electionType, "광역의원", StringComparison.Ordinal) || string.Equals(electionType, "기초의원", StringComparison.Ordinal); } private static bool ShouldUseSingleTurnoutOverviewSnapshot( FormatTemplateDefinition template, BroadcastPhase phase, string electionType) { return phase == BroadcastPhase.PreElection && SupportsPreElectionTurnout(electionType) && !ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) && template.Name.Contains("투표율", StringComparison.Ordinal); } private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Bottom && (string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) || string.Equals(template.Name, "투표율", StringComparison.Ordinal)); } private static bool IsRegionalTurnoutBoardTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Normal && string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal); } private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.TopLeft && string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal); } private static SbsElectionApiClient.TurnoutOverviewItem? FindTurnoutOverviewItem( IReadOnlyList items, SbsElectionApiClient.DistrictSelectionOption? district) { if (district is null || items.Count == 0) { return null; } if (!string.IsNullOrWhiteSpace(district.DistrictCode)) { var matchedByCode = items.FirstOrDefault(item => string.Equals(item.DistrictCode, district.DistrictCode, StringComparison.OrdinalIgnoreCase)); if (matchedByCode is not null) { return matchedByCode; } } return items.FirstOrDefault(item => string.Equals(item.RegionName, district.RegionName, StringComparison.Ordinal) || string.Equals(item.DisplayName, district.DisplayName, StringComparison.Ordinal) || string.Equals(item.DistrictName, district.DistrictName, StringComparison.Ordinal)); } private static string ResolveTurnoutElectionDistrictName( string electionType, SbsElectionApiClient.TurnoutOverviewItem? item, SbsElectionApiClient.DistrictSelectionOption? district, string regionName, string districtName) { if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal)) { return FirstNonWhiteSpace( item?.DistrictName, district?.DistrictName, districtName, regionName); } return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName; } private static string ResolveTurnoutBoardDistrictLabel( string electionType, SbsElectionApiClient.TurnoutOverviewItem item, SbsElectionApiClient.DistrictSelectionOption district) { if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal)) { return FirstNonWhiteSpace( item.DistrictName, district.DistrictName, item.DisplayName, district.DisplayName, item.RegionName, district.RegionName); } return ResolveTurnoutBoardRegionLabel(item, district); } private static string ResolveTurnoutBoardRegionLabel( SbsElectionApiClient.TurnoutOverviewItem item, SbsElectionApiClient.DistrictSelectionOption district) { return FirstNonWhiteSpace( item.RegionName, district.RegionName, item.DisplayName, district.DisplayName); } private static string FirstNonWhiteSpace(params string?[] values) { foreach (var value in values) { if (!string.IsNullOrWhiteSpace(value)) { return value; } } return string.Empty; } private static async Task> GetDistrictsAsync( SbsElectionApiClient apiClient, IDictionary> districtCache, string electionType, BroadcastStationProfile station, bool useAllRegions, FormatTemplateDefinition template, PreElectionHistoryService preElectionHistoryService) { if (UsesHistoricalStoredOptions(template)) { return GetStoredHistoryDistricts(electionType, preElectionHistoryService); } var regionFilters = useAllRegions ? Array.Empty() : station.RegionFilters; var cacheKey = $"{electionType}|{string.Join(",", regionFilters)}"; if (!districtCache.TryGetValue(cacheKey, out var districts)) { districts = await apiClient .GetDistrictOptionsAsync(electionType, regionFilters, CancellationToken.None) .ConfigureAwait(false); districtCache[cacheKey] = districts; } return districts; } private static IReadOnlyList GetStoredHistoryDistricts( string electionType, PreElectionHistoryService preElectionHistoryService) { return preElectionHistoryService .GetSelectionRecords(electionType) .Where(record => !string.IsNullOrWhiteSpace(ResolveStoredHistoryDisplayName(record))) .OrderBy(record => SbsElectionApiClient.ResolveBasicApiSidoCode(record.RegionName), StringComparer.Ordinal) .ThenBy(record => ResolveStoredHistoryDisplayName(record), StringComparer.Ordinal) .Select(CreateStoredHistoryDistrict) .ToArray(); } private static SbsElectionApiClient.DistrictSelectionOption CreateStoredHistoryDistrict( PreElectionHistoryRecord record) { var regionName = string.IsNullOrWhiteSpace(record.RegionName) ? record.DisplayName : record.RegionName; var districtName = string.IsNullOrWhiteSpace(record.DistrictName) ? ResolveStoredHistoryDisplayName(record) : record.DistrictName; var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName); var districtCode = string.Equals( PreElectionHistoryService.NormalizeElectionType(record.ElectionType), "기초단체장", StringComparison.Ordinal) ? record.Key : parentRegionCode; return new SbsElectionApiClient.DistrictSelectionOption( ResolveStoredHistoryDisplayName(record), districtCode, regionName, districtName, parentRegionCode); } private static string ResolveStoredHistoryDisplayName(PreElectionHistoryRecord record) { if (!string.IsNullOrWhiteSpace(record.DisplayName)) { return record.DisplayName; } if (!string.IsNullOrWhiteSpace(record.DistrictName)) { return record.DistrictName; } return record.RegionName ?? string.Empty; } private static IEnumerable ResolveTargets( IReadOnlyList districts, BroadcastStationProfile station, CurrentApiCutDiagnosticsOptions options) { IEnumerable targets = options.RegionScope switch { "all" => districts, _ => ResolveStationTargets(districts, station) }; if (options.MaxRegions > 0) { targets = targets.Take(options.MaxRegions); } return targets; } private static IEnumerable ResolveStationTargets( IReadOnlyList districts, BroadcastStationProfile station) { var configuredRegions = station.RegionFilters .Select(NormalizeRegion) .Where(region => !string.IsNullOrWhiteSpace(region)) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (configuredRegions.Count == 0) { return districts; } return districts.Where(district => GetNormalizedRegionKeys(district.RegionName).Any(configuredRegions.Contains)); } private static ElectionDataSnapshot CreateSnapshot( BroadcastPhase phase, string electionType, SbsElectionApiClient.SbsElectionRefreshResult refreshResult) { var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName) ? refreshResult.ElectionDistrictName : refreshResult.DistrictName; var regionName = string.IsNullOrWhiteSpace(refreshResult.RegionName) ? districtName : refreshResult.RegionName; var electionDistrictName = string.IsNullOrWhiteSpace(refreshResult.ElectionDistrictName) ? districtName : refreshResult.ElectionDistrictName; return new ElectionDataSnapshot { BroadcastPhase = phase, ElectionType = electionType, DistrictName = districtName ?? string.Empty, DistrictCode = refreshResult.DistrictCode ?? string.Empty, RegionName = regionName ?? string.Empty, ElectionDistrictName = electionDistrictName ?? string.Empty, Candidates = refreshResult.Candidates ?? Array.Empty(), TotalExpectedVotes = refreshResult.TotalExpectedVotes, TurnoutVotes = refreshResult.TurnoutVotes, CountedVotesFromApi = refreshResult.CountedVotes, RemainingVotesFromApi = refreshResult.RemainingVotes, CountedRateFromApi = refreshResult.CountedRate, ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt, ReferenceTimeLabel = refreshResult.ReferenceTimeLabel }; } private static ElectionDataSnapshot CreateStoredPreElectionHistorySnapshot( BroadcastPhase phase, string electionType, SbsElectionApiClient.DistrictSelectionOption target, PreElectionHistoryService preElectionHistoryService) { var regionName = target.RegionName ?? string.Empty; var districtName = !string.IsNullOrWhiteSpace(target.DistrictName) ? target.DistrictName : !string.IsNullOrWhiteSpace(target.DisplayName) ? target.DisplayName : regionName; var history = preElectionHistoryService.ResolveHistory(electionType, regionName, districtName); return new ElectionDataSnapshot { BroadcastPhase = phase, ElectionType = electionType, DistrictName = districtName, DistrictCode = target.DistrictCode, RegionName = regionName, ElectionDistrictName = ResolveHistoricalElectionDistrictName(electionType, regionName, districtName), Candidates = Array.Empty(), TotalExpectedVotes = 0, TurnoutVotes = 0, CountedVotesFromApi = null, RemainingVotesFromApi = null, CountedRateFromApi = null, ReceivedAt = DateTimeOffset.Now, HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray() ?? Array.Empty(), HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray() ?? Array.Empty() }; } private static string ResolveHistoricalElectionDistrictName( string electionType, string regionName, string districtName) { if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal)) { return string.IsNullOrWhiteSpace(districtName) ? regionName : districtName; } return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName; } private static async Task SimulateSendAsync( ITornado3Adapter adapter, BroadcastStationProfile station, FormatTemplateDefinition template, ElectionDataSnapshot snapshot, CurrentApiCutDiagnosticsOptions options, CurrentApiCutDiagnosticResult result, PgmWindow? pgmWindow) { foreach (var cut in ResolveDiagnosticPlaybackCuts(template, snapshot)) { Exception? lastException = null; for (var attempt = 1; attempt <= 3; attempt++) { try { await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result, pgmWindow).ConfigureAwait(false); lastException = null; break; } catch (Exception ex) when (attempt < 3) { lastException = ex; await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false); await Task.Delay(750, CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { lastException = ex; } } if (lastException is not null) { throw lastException; } } } private static IReadOnlyList ResolveDiagnosticPlaybackCuts( FormatTemplateDefinition template, ElectionDataSnapshot snapshot) { if (!IsCandidatePagedTemplate(template) || template.Cuts.Count == 0) { return template.Cuts; } var candidateCount = Math.Max(snapshot.Candidates.Count, 1); var pageSize = ResolveCandidatePageSize(template); var pageStarts = Enumerable .Range(0, (int)Math.Ceiling(candidateCount / (double)pageSize)) .Select(pageIndex => pageIndex * pageSize) .ToArray(); var playbackCuts = new List(template.Cuts.Count * pageStarts.Length); foreach (var baseCut in template.Cuts) { foreach (var candidateStartIndex in pageStarts) { var isLastPage = candidateStartIndex == pageStarts[^1]; var cutName = ResolveCandidatePagedCutName(template, baseCut.Name, candidateStartIndex, isLastPage); playbackCuts.Add(new FormatCutDefinition { Name = cutName, DurationSeconds = baseCut.DurationSeconds, CandidateStartIndex = candidateStartIndex, UseEndScene = baseCut.UseEndScene, SceneIdOverride = ResolvePagedSceneId(template, cutName) }); } } return playbackCuts; } private static string ResolveCandidatePagedCutName( FormatTemplateDefinition template, string cutName, int candidateStartIndex, bool isLastPage) { if (IsAllCandidateTemplate(template)) { if (candidateStartIndex == 0) { return cutName; } if (template.RecommendedChannel == BroadcastChannel.Bottom) { return ResolveSuffixedCutName(cutName, "_loop"); } if (isLastPage) { return ResolveSuffixedCutName(cutName, "_END"); } return ResolveCandidatePageSize(template) == 1 ? ResolveSuffixedCutName(cutName, "_loop") : cutName; } return candidateStartIndex == 0 ? cutName : ResolveSuffixedCutName(cutName, "_loop"); } private static string ResolveSuffixedCutName(string cutName, string suffix) { if (cutName.EndsWith(suffix, StringComparison.Ordinal)) { return cutName; } const string inSuffix = "_in"; if (string.Equals(suffix, "_loop", StringComparison.Ordinal) && cutName.EndsWith(inSuffix, StringComparison.Ordinal)) { return cutName[..^inSuffix.Length] + suffix; } return cutName + suffix; } private static string ResolvePagedSceneId(FormatTemplateDefinition template, string cutName) { var folderName = Path.GetDirectoryName(template.Id); return string.IsNullOrWhiteSpace(folderName) ? cutName : Path.Combine(folderName, cutName); } private static bool IsCandidatePagedTemplate(FormatTemplateDefinition template) { return IsCareerTemplate(template) || IsAllCandidateTemplate(template) || IsBottomCurrentLeaderTemplate(template); } private static bool IsCareerTemplate(FormatTemplateDefinition template) { return template.Name.StartsWith("경력_", StringComparison.Ordinal); } private static BroadcastPhase ResolveScheduleRefreshPhase(FormatTemplateDefinition template, BroadcastPhase phase) { return phase == BroadcastPhase.PreElection && IsCareerTemplate(template) ? BroadcastPhase.Counting : phase; } private static bool IsAllCandidateTemplate(FormatTemplateDefinition template) { return template.Name.StartsWith("모든후보_", StringComparison.Ordinal) || template.Name.StartsWith("전후보_", StringComparison.Ordinal); } private static int ResolveCandidatePageSize(FormatTemplateDefinition template) { if (IsBottomCurrentLeaderTemplate(template) || (template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template))) { return 3; } if (!IsAllCandidateTemplate(template)) { return 1; } return template.SceneWidth >= 5000 || template.Name.Contains("8316", StringComparison.Ordinal) || template.Name.Contains("3840", StringComparison.Ordinal) || template.Name.Contains("2880", StringComparison.Ordinal) || template.Name.Contains("5760", StringComparison.Ordinal) || template.Id.Contains("_L", StringComparison.Ordinal) ? 3 : 1; } private static async Task SendSingleCutAsync( ITornado3Adapter adapter, BroadcastStationProfile station, FormatTemplateDefinition template, FormatCutDefinition cut, ElectionDataSnapshot snapshot, CurrentApiCutDiagnosticsOptions options, CurrentApiCutDiagnosticResult result, PgmWindow? pgmWindow) { await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "connect"); try { await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "apply"); await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "prepare"); await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "take"); if (options.CaptureMixedPreviewImages) { await CaptureMixedPreviewImagesIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false); } if (options.CaptureSceneImages || options.CapturePgmImages) { await Task.Delay(options.OnAirDelayMs, CancellationToken.None).ConfigureAwait(false); } await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false); CapturePgmImageIfRequested(pgmWindow, template, cut, options, result); } finally { if (!options.KeepOnAirBetweenSends) { await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false); if (adapter.IsLiveCg) { await Task.Delay(250, CancellationToken.None).ConfigureAwait(false); } } } } private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel) { try { await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "out"); } catch { if (!adapter.IsLiveCg) { throw; } } } private static async Task TryOutAllAsync(ITornado3Adapter adapter) { foreach (var channel in Enum.GetValues()) { try { await TryOutAsync(adapter, channel).ConfigureAwait(false); } catch { } } } private static async Task CaptureSceneImageIfRequestedAsync( ITornado3Adapter adapter, FormatTemplateDefinition template, FormatCutDefinition cut, CurrentApiCutDiagnosticsOptions options, CurrentApiCutDiagnosticResult result) { if (!options.CaptureSceneImages || adapter is not KarismaTornado3Adapter karismaAdapter) { return; } var captureDirectory = Path.Combine(options.OutputPath, "captures"); Directory.CreateDirectory(captureDirectory); var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal); if (districtToken.Length > 40) { districtToken = districtToken[..40]; } var fileStem = SanitizeFileName( $"{result.Station}_{result.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}"); var (width, height) = ResolveSceneCaptureSize(template); var captureFrames = options.SceneCaptureFrames.Count == 0 ? new[] { -1 } : options.SceneCaptureFrames; for (var index = 0; index < captureFrames.Count; index++) { var frame = captureFrames[index]; var frameToken = frame < 0 ? "final" : $"f{frame:0000}"; var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}_{frameToken}.png")); await karismaAdapter.SavePendingSceneImageAsync( template.RecommendedChannel, outputPath, width, height, frame, CancellationToken.None) .ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "capture"); if (index == captureFrames.Count - 1) { result.CapturePath = outputPath; result.CaptureHash = ComputeSha256(outputPath); result.CaptureBytes = new FileInfo(outputPath).Length; } } } private static async Task CaptureMixedPreviewImagesIfRequestedAsync( ITornado3Adapter adapter, FormatTemplateDefinition template, FormatCutDefinition cut, CurrentApiCutDiagnosticsOptions options, CurrentApiCutDiagnosticResult result) { if (!options.CaptureMixedPreviewImages) { return; } var captureDirectory = Path.Combine(options.OutputPath, "captures"); Directory.CreateDirectory(captureDirectory); var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal); if (districtToken.Length > 40) { districtToken = districtToken[..40]; } var fileStem = SanitizeFileName( $"{result.Station}_{result.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}_MIXED"); var (width, height) = ResolveSceneCaptureSize(template); var captureDelays = options.MixedPreviewCaptureDelaysMs.Count == 0 ? new[] { options.OnAirDelayMs } : options.MixedPreviewCaptureDelaysMs .Where(delay => delay >= 0) .Distinct() .OrderBy(delay => delay) .ToArray(); var elapsedMs = 0; for (var index = 0; index < captureDelays.Length; index++) { var delayMs = captureDelays[index]; var waitMs = Math.Max(0, delayMs - elapsedMs); if (waitMs > 0) { await Task.Delay(waitMs, CancellationToken.None).ConfigureAwait(false); } elapsedMs = delayMs; var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}_d{delayMs:0000}.png")); var captured = await adapter.TryCapturePendingCutPreviewAsync( template.RecommendedChannel, outputPath, width, height, frame: -1, CancellationToken.None) .ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "mixed-preview-capture"); if (!captured) { result.Warning = AppendWarning(result.Warning, $"mixed preview capture failed at {delayMs}ms"); continue; } if (index == captureDelays.Length - 1) { result.MixedPreviewCapturePath = outputPath; result.MixedPreviewCaptureHash = ComputeSha256(outputPath); result.MixedPreviewCaptureBytes = new FileInfo(outputPath).Length; } } } private static void CapturePgmImageIfRequested( PgmWindow? pgmWindow, FormatTemplateDefinition template, FormatCutDefinition cut, CurrentApiCutDiagnosticsOptions options, CurrentApiCutDiagnosticResult result) { if (!options.CapturePgmImages || pgmWindow is null) { return; } var captureDirectory = Path.Combine(options.OutputPath, "captures"); Directory.CreateDirectory(captureDirectory); var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal); if (districtToken.Length > 40) { districtToken = districtToken[..40]; } var fileStem = SanitizeFileName( $"{result.Station}_{result.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}_PGM"); var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}.png")); using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Bounds); bitmap.Save(outputPath, ImageFormat.Png); result.PgmCapturePath = outputPath; result.PgmCaptureHash = ComputeSha256(outputPath); result.PgmCaptureBytes = new FileInfo(outputPath).Length; } private static PgmWindow? TryFindPgmWindow() { var process = Process.GetProcessesByName("Tornado3") .FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal)); if (process is not null && process.MainWindowHandle != IntPtr.Zero) { var processWindow = TryBuildPgmWindow(process.MainWindowHandle); if (processWindow is not null) { return processWindow; } } var windows = new List(); EnumWindows((windowHandle, _) => { if (!IsWindowVisible(windowHandle) || !string.Equals(GetWindowTitle(windowHandle), "PGM", StringComparison.Ordinal)) { return true; } var window = TryBuildPgmWindow(windowHandle); if (window is not null) { windows.Add(window.Value); } return true; }, IntPtr.Zero); return windows.Count == 0 ? null : windows .OrderByDescending(window => window.Bounds.Width * window.Bounds.Height) .First(); } private static PgmWindow? TryBuildPgmWindow(IntPtr windowHandle) { if (!GetWindowRect(windowHandle, out var rect)) { return null; } var width = rect.Right - rect.Left; var height = rect.Bottom - rect.Top; if (width <= 0 || height <= 0) { return null; } return new PgmWindow(windowHandle, new Rect(rect.Left, rect.Top, width, height)); } private static string GetWindowTitle(IntPtr windowHandle) { var length = GetWindowTextLength(windowHandle); if (length <= 0) { return string.Empty; } var builder = new StringBuilder(length + 1); _ = GetWindowText(windowHandle, builder, builder.Capacity); return builder.ToString(); } private static Bitmap CaptureWindowBitmap(Rect bounds) { var width = Math.Max(1, bounds.Width); var height = Math.Max(1, bounds.Height); var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(bitmap); graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy); return bitmap; } private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template) { var sourceWidth = template.SceneWidth.GetValueOrDefault(1920); var sourceHeight = template.SceneHeight.GetValueOrDefault(1080); if (sourceWidth <= 0 || sourceHeight <= 0) { return (1280, 720); } const int maxWidth = 1280; if (sourceWidth <= maxWidth) { return (sourceWidth, sourceHeight); } var scale = maxWidth / (double)sourceWidth; return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero))); } private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); } private static string SanitizeFileName(string value) { var invalidChars = Path.GetInvalidFileNameChars(); var sanitized = new string(value.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()).Trim(); if (sanitized.Length > 80) { sanitized = sanitized[..80]; } return string.IsNullOrWhiteSpace(sanitized) ? "capture.png" : sanitized; } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect); private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsWindowVisible(IntPtr hWnd); private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action) { if (adapter.State == TornadoConnectionState.Error) { throw new InvalidOperationException($"Karisma live send failed during {action}."); } } private static bool ValidateSnapshotForFormat( FormatTemplateDefinition template, ElectionDataSnapshot snapshot, IReadOnlyDictionary sceneVariables, out string errorMessage, out string warning) { warning = string.Empty; if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name)) { if (snapshot.HistoricalTurnoutHistory.Count == 0) { errorMessage = "historical turnout data is empty"; return false; } errorMessage = string.Empty; return true; } if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name)) { if (snapshot.HistoricalWinnerHistory.Count == 0) { errorMessage = "historical winner data is empty"; return false; } errorMessage = string.Empty; return true; } if (IsTurnoutTemplate(template) && (snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0)) { errorMessage = "turnout votes/rate is zero"; return false; } if (!template.RequiresCandidateData) { errorMessage = string.Empty; return true; } var validCandidates = snapshot.Candidates .Where(IsCandidateReadyForBroadcast) .ToArray(); var isWinnerTemplate = IsWinnerTemplate(template); if (validCandidates.Length == 0) { errorMessage = isWinnerTemplate ? "winner candidate list is empty" : "candidate list is empty"; return false; } if (isWinnerTemplate && !validCandidates.Any(candidate => IsElectedJudgement(candidate.EffectiveJudgement))) { errorMessage = "elected judgement is missing"; return false; } if (!isWinnerTemplate && validCandidates.Length != snapshot.Candidates.Count) { errorMessage = "required candidate fields are blank"; return false; } if (sceneVariables.Count > 0 && RequiresTopTwoCandidateNameVariables(template) && !HasCandidateNameVariables(sceneVariables, Math.Min(2, validCandidates.Length), out var missingCandidateNameVariables)) { errorMessage = $"candidate name variables are missing from scene: {string.Join(", ", missingCandidateNameVariables)}"; return false; } var requiredCandidateCount = ResolveRequiredCandidateCount(template); if (requiredCandidateCount > 0 && validCandidates.Length < requiredCandidateCount) { warning = $"template-slot-count-{requiredCandidateCount}-with-{validCandidates.Length}-candidates"; } if (!isWinnerTemplate && !IsCareerTemplate(template) && snapshot.BroadcastPhase == BroadcastPhase.Counting) { if (snapshot.CountedVotes <= 0 && snapshot.CountedRate <= 0) { errorMessage = "counted votes and counted rate are both zero"; return false; } if (snapshot.CountedVotes > 0 && snapshot.CountedRate <= 0) { warning = JoinWarning(warning, "counted-rate-zero-with-positive-counted-votes"); } if (!validCandidates.Any(candidate => candidate.VoteCount > 0 || candidate.VoteRate > 0)) { errorMessage = "candidate vote data is empty"; return false; } } if (template.RequiresImage && snapshot.Candidates.Any(candidate => !candidate.HasImage)) { errorMessage = "candidate image is required but missing"; return false; } errorMessage = string.Empty; return true; } private static IReadOnlyDictionary ResolveSceneVariablesForTemplate( KarismaSceneVariableCatalog sceneVariableCatalog, string t3CutPath, FormatTemplateDefinition template) { try { var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false); return sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path); } catch { return new Dictionary(StringComparer.OrdinalIgnoreCase); } } 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 HasCandidateNameVariables( IReadOnlyDictionary sceneVariables, int requiredSlotCount, out IReadOnlyList missingVariables) { var missing = new List(); for (var slot = 1; slot <= requiredSlotCount; slot++) { var variableName = $"후보명{slot:00}"; if (!sceneVariables.ContainsKey(variableName)) { missing.Add(variableName); } } missingVariables = missing; return missing.Count == 0; } private static bool UsesStoredPreElectionHistory(FormatTemplateDefinition template) { return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) || ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name); } private static string JoinWarning(string current, string next) { if (string.IsNullOrWhiteSpace(current)) { return next; } return $"{current}; {next}"; } private static bool IsCandidateReadyForBroadcast(CandidateEntry candidate) { return !string.IsNullOrWhiteSpace(candidate.Name) && !string.IsNullOrWhiteSpace(candidate.Party) && !string.IsNullOrWhiteSpace(candidate.CandidateCode); } private static string GetTemplateBaseName(string? source) { if (string.IsNullOrWhiteSpace(source)) { return string.Empty; } return Path.GetFileNameWithoutExtension( source .Replace('/', Path.DirectorySeparatorChar) .Replace('\\', Path.DirectorySeparatorChar)); } private static bool IsWinnerTemplate(FormatTemplateDefinition template) { return IsWinnerTemplate(template.Name) || IsWinnerTemplate(template.Id) || template.Cuts.Any(cut => IsWinnerTemplate(cut.Name)); } private static bool IsWinnerTemplate(string? source) { return GetTemplateBaseName(source).StartsWith("당선_", StringComparison.Ordinal); } private static bool IsElectedJudgement(CandidateJudgement judgement) { return judgement is CandidateJudgement.Elected or CandidateJudgement.ElectedInProgress or CandidateJudgement.UnopposedElected or CandidateJudgement.ElectedAfterCountComplete; } private static bool IsTurnoutTemplate(FormatTemplateDefinition template) { return template.Name.Contains("투표율", StringComparison.Ordinal); } private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template) { foreach (var source in new[] { template.Cuts.FirstOrDefault()?.Name, template.Name, template.Id }) { var count = ResolveRequiredCandidateCount(source); if (count > 0) { return count; } } return 0; } private static int ResolveRequiredCandidateCount(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, out var topRankSlotCount) && topRankSlotCount > 0) { return topRankSlotCount; } var peopleMatch = PeopleSlotCountPattern.Match(sourceName); if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount) && peopleSlotCount > 0) { return peopleSlotCount; } if (sourceName.StartsWith("1위_", StringComparison.Ordinal) || sourceName.Contains("이시각1위", StringComparison.Ordinal) || sourceName.StartsWith("당선_", StringComparison.Ordinal) || sourceName.StartsWith("경력_", StringComparison.Ordinal)) { return 1; } if (sourceName.Contains("접전", StringComparison.Ordinal)) { return 2; } return 0; } private static string ResolveScheduleElectionType( FormatTemplateDefinition template, BroadcastPhase phase, string defaultElectionType) { if (IsTopTurnoutDistrictBoardTemplate(template)) { return "기초단체장"; } if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name)) { return "광역단체장"; } return ResolveScheduleElectionType(template.Name, phase, defaultElectionType); } private static string ResolveScheduleElectionType(string? formatName, BroadcastPhase phase, string defaultElectionType) { var resolvedFormatName = formatName ?? string.Empty; if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName)) { return "광역단체장"; } if (resolvedFormatName.Contains("교육감", StringComparison.Ordinal)) { return "교육감"; } if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal)) { return "기초의원"; } if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal)) { return "기초단체장"; } if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal)) { return "광역의원"; } if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal)) { return "국회의원"; } if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal)) { return "광역단체장"; } if (phase == BroadcastPhase.PreElection) { return SupportsPreElectionTurnout(defaultElectionType) ? defaultElectionType : "광역단체장"; } return defaultElectionType; } private static bool UsesHistoricalStoredOptions(FormatTemplateDefinition template) { return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) || ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name); } private static bool SupportsPreElectionTurnout(string? electionType) { return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) || string.Equals(electionType, "교육감", StringComparison.Ordinal) || string.Equals(electionType, "기초단체장", StringComparison.Ordinal); } private static string NormalizeRegion(string? regionName) { if (string.IsNullOrWhiteSpace(regionName)) { return string.Empty; } var trimmed = regionName.Trim(); return RegionAliases.TryGetValue(trimmed, out var normalized) ? normalized : trimmed; } private static IEnumerable GetNormalizedRegionKeys(string? regionName) { var normalizedRegion = NormalizeRegion(regionName); if (string.IsNullOrWhiteSpace(normalizedRegion)) { yield break; } yield return normalizedRegion; if (string.Equals(normalizedRegion, "전남광주", StringComparison.OrdinalIgnoreCase) || string.Equals(normalizedRegion, "광주전남", StringComparison.OrdinalIgnoreCase)) { yield return "광주"; yield return "전남"; } } private static void PopulateDataFields( CurrentApiCutDiagnosticResult result, ElectionDataSnapshot snapshot, string sourcePath) { result.SourcePath = sourcePath; result.CandidateCount = snapshot.Candidates.Count; result.PositiveCandidateVoteCount = snapshot.Candidates.Count(candidate => candidate.VoteCount > 0 || candidate.VoteRate > 0); result.CountedVotes = snapshot.CountedVotes; result.CountedRate = snapshot.CountedRate; result.TurnoutVotes = snapshot.TurnoutVotes; result.TurnoutRate = snapshot.TurnoutRate; result.NationalTurnoutRate = snapshot.NationalTurnoutRate; result.Leader = snapshot.Candidates .OrderByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .FirstOrDefault()?.Name ?? string.Empty; result.Candidates = snapshot.Candidates .OrderByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .Select((candidate, index) => new CurrentApiCandidateDiagnostic { Rank = index + 1, BallotNumber = candidate.BallotNumber, Name = candidate.Name, Party = candidate.Party, VoteCount = candidate.VoteCount, VoteRate = candidate.VoteRate, VoteRateDisplay = candidate.VoteRateDisplay, Judgement = candidate.EffectiveJudgement.ToString() }) .ToList(); } private static void WriteReports( CurrentApiCutDiagnosticsOptions options, IReadOnlyList results) { var jsonPath = Path.Combine(options.OutputPath, "current-api-cut-diagnostics.json"); File.WriteAllText( jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true })); var summaryPath = Path.Combine(options.OutputPath, "summary.md"); using var writer = new StreamWriter(summaryPath); writer.WriteLine("# Current API Cut Diagnostics"); writer.WriteLine(); writer.WriteLine($"- Phase: {options.Phase}"); writer.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}"); writer.WriteLine($"- Results: {results.Count}"); writer.WriteLine(); writer.WriteLine("## Status Counts"); writer.WriteLine(); foreach (var group in results.GroupBy(result => result.Status).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) { writer.WriteLine($"- {group.Key}: {group.Count()}"); } var warningGroups = results .Where(result => !string.IsNullOrWhiteSpace(result.Warning)) .GroupBy(result => result.Warning) .OrderByDescending(group => group.Count()) .ToArray(); if (warningGroups.Length > 0) { writer.WriteLine(); writer.WriteLine("## Warning Counts"); writer.WriteLine(); foreach (var group in warningGroups) { writer.WriteLine($"- {group.Key}: {group.Count()}"); } } var failures = results .Where(result => result.Status is "validation-failed" or "api-or-send-failed" or "no-target") .Take(60) .ToArray(); if (failures.Length > 0) { writer.WriteLine(); writer.WriteLine("## Failures"); writer.WriteLine(); foreach (var failure in failures) { writer.WriteLine($"- [{failure.Status}] {failure.Station} {failure.TemplateName} / {failure.Region}: {failure.Detail}"); } } } private static void PrintSummary(IReadOnlyList results, string outputPath) { Console.WriteLine(); Console.WriteLine("Summary"); foreach (var group in results.GroupBy(result => result.Status).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) { Console.WriteLine($"- {group.Key}: {group.Count()}"); } var warnings = results.Count(result => !string.IsNullOrWhiteSpace(result.Warning)); Console.WriteLine($"- warnings: {warnings}"); Console.WriteLine($"- report: {Path.Combine(outputPath, "summary.md")}"); } private static string AppendWarning(string current, string warning) { if (string.IsNullOrWhiteSpace(current)) { return warning; } return current + "; " + warning; } private static void WriteKarismaLog(CurrentApiCutDiagnosticsOptions options, LogService logService) { var logPath = Path.Combine(options.OutputPath, "karisma-log.txt"); var lines = logService.Entries .Reverse() .Where(entry => entry is not null) .Select(entry => $"{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{entry.Level}] {entry.Message ?? string.Empty}"); File.WriteAllLines(logPath, lines); } private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds); private readonly record struct Rect(int Left, int Top, int Width, int Height); [StructLayout(LayoutKind.Sequential)] private readonly struct NativeRect { public int Left { get; init; } public int Top { get; init; } public int Right { get; init; } public int Bottom { get; init; } } private sealed class CurrentApiCutDiagnosticsOptions { public BroadcastPhase Phase { get; init; } = BroadcastPhase.Counting; public string StationId { get; init; } = "KNN"; public bool AllStations { get; init; } public string RegionScope { get; init; } = "station"; public int MaxRegions { get; init; } public bool IncludeVideoWall { get; init; } public int? TemplateLimit { get; init; } public string Filter { get; init; } = string.Empty; public string ExcludeFilter { get; init; } = string.Empty; public bool SimulateSend { get; init; } = true; public bool LiveSend { get; init; } public bool CaptureSceneImages { get; init; } public bool CaptureMixedPreviewImages { get; init; } public bool CapturePgmImages { get; init; } public IReadOnlyList SceneCaptureFrames { get; init; } = Array.Empty(); public IReadOnlyList MixedPreviewCaptureDelaysMs { get; init; } = Array.Empty(); public bool KeepOnAirBetweenSends { get; init; } public int OnAirDelayMs { get; init; } = 900; public int SendLimit { get; init; } = 24; public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath(); public string OutputPath { get; init; } = Path.Combine( "artifacts", "current-api-cut-diagnostics", DateTime.Now.ToString("yyyyMMdd_HHmmss")); public string DefaultElectionType { get; init; } = "광역단체장"; public static CurrentApiCutDiagnosticsOptions Parse(string[] args) { var phase = BroadcastPhase.Counting; var stationId = "KNN"; var allStations = false; var regionScope = "station"; var maxRegions = 0; var includeVideoWall = false; int? templateLimit = null; var filter = string.Empty; var excludeFilter = string.Empty; var simulateSend = true; var liveSend = false; var captureSceneImages = false; var captureMixedPreviewImages = false; var capturePgmImages = false; IReadOnlyList sceneCaptureFrames = Array.Empty(); IReadOnlyList mixedPreviewCaptureDelaysMs = Array.Empty(); var keepOnAirBetweenSends = false; var onAirDelayMs = 900; var sendLimit = 24; var outputPath = Path.Combine( "artifacts", "current-api-cut-diagnostics", DateTime.Now.ToString("yyyyMMdd_HHmmss")); var defaultElectionType = "광역단체장"; for (var index = 0; index < args.Length; index++) { var arg = args[index]; string NextValue() => index + 1 < args.Length ? args[++index] : string.Empty; switch (arg.ToLowerInvariant()) { case "--phase": phase = ParsePhase(NextValue()); break; case "--station": stationId = NextValue(); break; case "--all-stations": allStations = true; break; case "--region-scope": regionScope = NextValue().ToLowerInvariant() == "all" ? "all" : "station"; break; case "--max-regions": int.TryParse(NextValue(), out maxRegions); break; case "--include-video-wall": includeVideoWall = true; break; case "--limit": if (int.TryParse(NextValue(), out var parsedLimit)) { templateLimit = parsedLimit; } break; case "--filter": filter = NextValue(); break; case "--exclude-filter": excludeFilter = NextValue(); break; case "--no-send": simulateSend = false; liveSend = false; break; case "--live-send": simulateSend = true; liveSend = true; break; case "--capture-scene-images": captureSceneImages = true; break; case "--capture-scene-frames": sceneCaptureFrames = ParseFrameList(NextValue()); captureSceneImages = true; break; case "--capture-mixed-preview-images": captureMixedPreviewImages = true; break; case "--capture-mixed-preview-delays-ms": mixedPreviewCaptureDelaysMs = ParseFrameList(NextValue()); captureMixedPreviewImages = true; break; case "--capture-pgm-images": capturePgmImages = true; break; case "--keep-on-air-between-sends": keepOnAirBetweenSends = true; break; case "--on-air-delay-ms": if (int.TryParse(NextValue(), out var parsedOnAirDelayMs)) { onAirDelayMs = Math.Max(0, parsedOnAirDelayMs); } break; case "--send-limit": if (int.TryParse(NextValue(), out var parsedSendLimit)) { sendLimit = Math.Max(0, parsedSendLimit); } break; case "--image-root": _ = NextValue(); break; case "--output": outputPath = NextValue(); break; case "--election-type": defaultElectionType = NextValue(); break; } } return new CurrentApiCutDiagnosticsOptions { Phase = phase, StationId = stationId, AllStations = allStations, RegionScope = regionScope, MaxRegions = Math.Max(0, maxRegions), IncludeVideoWall = includeVideoWall, TemplateLimit = templateLimit, Filter = filter, ExcludeFilter = excludeFilter, SimulateSend = simulateSend, LiveSend = liveSend, CaptureSceneImages = captureSceneImages, CaptureMixedPreviewImages = captureMixedPreviewImages, CapturePgmImages = capturePgmImages, SceneCaptureFrames = sceneCaptureFrames, MixedPreviewCaptureDelaysMs = mixedPreviewCaptureDelaysMs, KeepOnAirBetweenSends = keepOnAirBetweenSends, OnAirDelayMs = onAirDelayMs, SendLimit = sendLimit, ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = Path.GetFullPath(outputPath), DefaultElectionType = defaultElectionType }; } private static BroadcastPhase ParsePhase(string value) { return value.ToLowerInvariant() switch { "pre" or "pre-election" or "preelection" => BroadcastPhase.PreElection, _ => BroadcastPhase.Counting }; } private static IReadOnlyList ParseFrameList(string value) { if (string.IsNullOrWhiteSpace(value)) { return Array.Empty(); } return value .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var frame) ? frame : (int?)null) .Where(frame => frame.HasValue) .Select(frame => frame!.Value) .Distinct() .ToArray(); } } private sealed class CurrentApiCutDiagnosticResult { public string Station { get; set; } = string.Empty; public string Channel { get; set; } = string.Empty; public string TemplateId { get; set; } = string.Empty; public string TemplateName { get; set; } = string.Empty; public string Phase { get; set; } = string.Empty; public string ElectionType { get; set; } = string.Empty; public string Region { get; set; } = string.Empty; public string DistrictCode { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public string Detail { get; set; } = string.Empty; public string Warning { get; set; } = string.Empty; public string SourcePath { get; set; } = string.Empty; public string CapturePath { get; set; } = string.Empty; public string CaptureHash { get; set; } = string.Empty; public long CaptureBytes { get; set; } public string MixedPreviewCapturePath { get; set; } = string.Empty; public string MixedPreviewCaptureHash { get; set; } = string.Empty; public long MixedPreviewCaptureBytes { get; set; } public string PgmCapturePath { get; set; } = string.Empty; public string PgmCaptureHash { get; set; } = string.Empty; public long PgmCaptureBytes { get; set; } public int CandidateCount { get; set; } public int PositiveCandidateVoteCount { get; set; } public int CountedVotes { get; set; } public double CountedRate { get; set; } public int TurnoutVotes { get; set; } public double TurnoutRate { get; set; } public double NationalTurnoutRate { get; set; } public string Leader { get; set; } = string.Empty; public List Candidates { get; set; } = new List(); public static CurrentApiCutDiagnosticResult NoTarget( BroadcastStationProfile station, FormatTemplateDefinition template, BroadcastPhase phase, string electionType) { return new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Status = "no-target", Detail = "no matching schedule regions" }; } public static CurrentApiCutDiagnosticResult DistrictLoadFailed( BroadcastStationProfile station, FormatTemplateDefinition template, BroadcastPhase phase, string electionType, string detail) { return new CurrentApiCutDiagnosticResult { Station = station.Id, Channel = template.RecommendedChannel.ToString(), TemplateId = template.Id, TemplateName = template.Name, Phase = phase.ToString(), ElectionType = electionType, Status = "api-or-send-failed", Detail = detail }; } } private sealed class CurrentApiCandidateDiagnostic { public int Rank { get; set; } public string BallotNumber { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Party { get; set; } = string.Empty; public int VoteCount { get; set; } public double VoteRate { get; set; } public string VoteRateDisplay { get; set; } = string.Empty; public string Judgement { get; set; } = string.Empty; } private readonly record struct PanseSummaryRow(string Party, int Count); }