using System.Text.Json; using System.Text.RegularExpressions; using Tornado3_2026Election.Domain; using Tornado3_2026Election.Services; internal static class CurrentApiCutDiagnostics { 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($"- 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); ITornado3Adapter? adapter; try { adapter = CreateSendAdapter(options, logService); } catch (Exception ex) { Console.WriteLine(ex.Message); 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.Name, phase, options.DefaultElectionType); IReadOnlyList districts; try { districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false); } catch (Exception ex) { results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message)); continue; } var targets = ResolveTargets(districts, station, options) .ToArray(); if (targets.Length == 0) { results.Add(CurrentApiCutDiagnosticResult.NoTarget(station, template, phase, electionType)); 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 { var refreshResult = await apiClient .RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None) .ConfigureAwait(false); snapshot = CreateSnapshot(phase, electionType, refreshResult); PopulateDataFields(result, snapshot, refreshResult.SourcePath); } if (!ValidateSnapshotForFormat(template, snapshot, 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.ImageRootPath).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 IDisposable disposable) { disposable.Dispose(); } 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> GetDistrictsAsync( SbsElectionApiClient apiClient, IDictionary> districtCache, string electionType, BroadcastStationProfile station) { var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}"; if (!districtCache.TryGetValue(cacheKey, out var districts)) { districts = await apiClient .GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None) .ConfigureAwait(false); districtCache[cacheKey] = districts; } return districts; } 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 => configuredRegions.Contains(NormalizeRegion(district.RegionName))); } 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 }; } 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 = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName, 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 async Task SimulateSendAsync( ITornado3Adapter adapter, BroadcastStationProfile station, FormatTemplateDefinition template, ElectionDataSnapshot snapshot, string imageRootPath) { foreach (var cut in template.Cuts) { Exception? lastException = null; for (var attempt = 1; attempt <= 3; attempt++) { try { await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).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 async Task SendSingleCutAsync( ITornado3Adapter adapter, BroadcastStationProfile station, FormatTemplateDefinition template, FormatCutDefinition cut, ElectionDataSnapshot snapshot, string imageRootPath) { await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); ThrowIfAdapterErrored(adapter, "connect"); try { await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, 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"); } finally { 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 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, 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(); if (validCandidates.Length == 0) { errorMessage = "candidate list is empty"; return false; } if (validCandidates.Length != snapshot.Candidates.Count) { errorMessage = "required candidate fields are blank"; return false; } var requiredCandidateCount = ResolveRequiredCandidateCount(template); if (requiredCandidateCount > 0 && validCandidates.Length < requiredCandidateCount) { warning = $"template-slot-count-{requiredCandidateCount}-with-{validCandidates.Length}-candidates"; } if (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 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 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(string? formatName, BroadcastPhase phase, string defaultElectionType) { var resolvedFormatName = formatName ?? string.Empty; 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 "광역단체장"; } return phase == BroadcastPhase.PreElection ? "광역단체장" : defaultElectionType; } 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 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.Leader = snapshot.Candidates .OrderByDescending(candidate => candidate.VoteCount) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .FirstOrDefault()?.Name ?? string.Empty; } 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 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 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 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 "--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, SendLimit = sendLimit, ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = outputPath, DefaultElectionType = defaultElectionType }; } private static BroadcastPhase ParsePhase(string value) { return value.ToLowerInvariant() switch { "pre" or "pre-election" or "preelection" => BroadcastPhase.PreElection, _ => BroadcastPhase.Counting }; } } 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 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 string Leader { get; set; } = string.Empty; 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 }; } } }