using System.Text.Json; using Backend.Domain; using Microsoft.EntityFrameworkCore; namespace Backend.Data; public static partial class SeedDataBootstrapper { private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season) { var categories = await db.Categories .Where(item => item.SeasonId == season.Id) .ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase); var candidates = await db.Candidates .Where(item => item.SeasonId == season.Id) .ToArrayAsync(); var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db); await EnsureSeedReviewNominationsAsync(db, season, categories, candidates); if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id)) { db.ClipSubmissions.AddRange( new ClipSubmission { SeasonId = season.Id, CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"), CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"), SubmittedByTwitchId = "local_user_3", ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment", Title = "Starlight Debut Moment", Creator = "Hoshimi Miyu", Platform = "Twitch", Status = "approved", ReviewNote = "Geprüfter Clip für Voting-Vorschau.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero), ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero), }, new ClipSubmission { SeasonId = season.Id, CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"), CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"), SubmittedByTwitchId = "local_user_4", ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype", Title = "Finale-Hype mit Chat-Chaos", Creator = "Kurainu", Platform = "Twitch", Status = "approved", ReviewNote = "Geprüfter Clip für Voting-Vorschau.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero), ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero), }, new ClipSubmission { SeasonId = season.Id, CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"), CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"), SubmittedByTwitchId = "local_user", ClipUrl = "https://clips.twitch.tv/EpicGamingMoment", Title = "Epischer Clutch im Finale", Creator = "Kurainu", Platform = "Twitch", Status = "pending", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero), }, new ClipSubmission { SeasonId = season.Id, CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"), CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"), SubmittedByTwitchId = "local_user_2", ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment", Title = "Live-Cover mit Gänsehaut", Creator = "Melo Diva", Platform = "YouTube", Status = "approved", ReviewNote = "Geprüfter Clip für Review-Workflow.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero), ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero), }); } if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed")) { db.RiskFlags.Add(new RiskFlag { SeasonId = season.Id, TwitchUserId = "sample_user", Source = "seed", Type = "rapid_vote_updates", Severity = "medium", Status = "open", Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.", CreatedFromIp = "127.0.0.1", UserAgent = "seed-bootstrap", MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }), CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero), }); } if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize")) { db.AdminAuditEntries.Add(new AdminAuditEntry { AdminTwitchUserId = "system", ActionType = "seed.initialize", EntityType = "database", EntityId = season.Year.ToString(), Summary = "Startinhalte wurden in der Datenbank bereitgestellt.", MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }), CreatedFromIp = "seed", UserAgent = "seed-bootstrap", CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero), }); } } private static int? ResolveCategoryId(IReadOnlyDictionary categories, string slug) => categories.TryGetValue(slug, out var category) ? category.Id : null; private static int? ResolveCandidateId( IReadOnlyDictionary categories, IEnumerable candidates, string categorySlug, string displayName) { var categoryId = ResolveCategoryId(categories, categorySlug); return categoryId is int resolvedCategoryId ? candidates.FirstOrDefault(item => item.CategoryId == resolvedCategoryId && string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id : null; } private static async Task NormalizeLegacyDemoLabelsAsync(AwardsDbContext db) { var legacySessions = await db.UserSessions .Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user") .ToArrayAsync(); foreach (var session in legacySessions) { session.TwitchUserId = session.TwitchUserId switch { "admin_demo" => "jayuhime_admin", "jayuhime_demo" => "jayuhime_viewer", "demo_user" => "local_user", _ => session.TwitchUserId, }; session.DisplayName = session.DisplayName switch { "Admin Demo" => "Jayuhime Admin", "Demo User" => "Local User", _ => session.DisplayName, }; } var legacyClipSubmissions = await db.ClipSubmissions .Where(item => item.SubmittedByTwitchId == "demo_user" || item.SubmittedByTwitchId == "demo_user_2" || item.ClipUrl.Contains("Demo") || item.ClipUrl.Contains("demo") || (item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip"))) .ToArrayAsync(); foreach (var clip in legacyClipSubmissions) { clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch { "demo_user" => "local_user", "demo_user_2" => "local_user_2", _ => clip.SubmittedByTwitchId, }; clip.ClipUrl = clip.ClipUrl .Replace("DemoGamingMoment", "EpicGamingMoment") .Replace("demoSong", "liveCoverMoment"); clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip"); } await LinkExistingClipsToCandidatesAsync(db); var legacyRiskFlags = await db.RiskFlags .Where(item => item.Source == "demo" || item.Summary.StartsWith("Demo:") || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user") .ToArrayAsync(); foreach (var flag in legacyRiskFlags) { flag.Source = "seed"; flag.TwitchUserId = flag.TwitchUserId switch { "demo_user" => "local_user", "jayuhime_demo" => "jayuhime_viewer", _ => flag.TwitchUserId, }; flag.Summary = flag.Summary.Replace("Demo: ", string.Empty); flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent; } var legacyAuditEntries = await db.AdminAuditEntries .Where(item => item.ActionType == "demo.seed" || item.Summary.Contains("Demo-Inhalte") || item.AdminTwitchUserId == "admin_demo" || item.AdminTwitchUserId == "jayuhime_demo") .ToArrayAsync(); foreach (var entry in legacyAuditEntries) { entry.AdminTwitchUserId = entry.AdminTwitchUserId switch { "admin_demo" => "jayuhime_admin", "jayuhime_demo" => "jayuhime_viewer", _ => entry.AdminTwitchUserId, }; if (entry.ActionType == "demo.seed") { entry.ActionType = "seed.initialize"; } if (entry.Summary.Contains("Demo-Inhalte")) { entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt."; } } return new LegacySeedState( legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"), legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize")); } private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db) { var clips = await db.ClipSubmissions .Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty) .ToArrayAsync(); if (clips.Length == 0) { return; } var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray(); var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray(); var candidates = await db.Candidates .Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId)) .ToArrayAsync(); foreach (var clip in clips) { var creatorKey = NormalizeSeedCandidateKey(clip.Creator); var candidate = candidates.FirstOrDefault(item => item.SeasonId == clip.SeasonId && item.CategoryId == clip.CategoryId && (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey || NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey)); if (candidate is not null) { clip.CandidateId = candidate.Id; } } } private static string NormalizeSeedCandidateKey(string value) => new( value .Trim() .TrimStart('@') .ToLowerInvariant() .Where(char.IsLetterOrDigit) .ToArray()); private static async Task EnsureSeedReviewNominationsAsync( AwardsDbContext db, Season season, IReadOnlyDictionary categories, Candidate[] candidates) { var staleSeedNominations = await db.Nominations .Where(item => item.SeasonId == season.Id && ( item.SubmittedByTwitchId.StartsWith("seed_review_") || item.SubmittedByTwitchId == "twitch_hoshi" || item.SubmittedByTwitchId == "twitch_kurainu" || item.SubmittedByTwitchId.StartsWith("demo_user") || item.SubmittedByTwitchId.StartsWith("local_user") )) .ToArrayAsync(); if (staleSeedNominations.Length > 0) { db.Nominations.RemoveRange(staleSeedNominations); await db.SaveChangesAsync(); } var orderedCategories = categories.Values .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .ToArray(); var candidatesByCategoryId = candidates .GroupBy(item => item.CategoryId) .ToDictionary( grouping => grouping.Key, grouping => grouping.OrderBy(item => item.DisplayName).ToArray()); var seedNominations = new List(); var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); foreach (var category in orderedCategories) { candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates); var existingCandidate = categoryCandidates?.FirstOrDefault(); seedNominations.AddRange(BuildPendingSeedGroup( category, existingCandidate, groupKey: "existing", firstSubmitter: $"seed_review_{category.Slug}_existing_a", secondSubmitter: $"seed_review_{category.Slug}_existing_b", createdAt, useSuggestedCategory: true, trackerStatus: "resolved")); createdAt = createdAt.AddMinutes(8); seedNominations.AddRange(BuildPendingSeedGroup( category, existingCandidate: null, groupKey: "fresh", firstSubmitter: $"seed_review_{category.Slug}_fresh_a", secondSubmitter: $"seed_review_{category.Slug}_fresh_b", createdAt, useSuggestedCategory: false, trackerStatus: "unsupported_platform")); createdAt = createdAt.AddMinutes(8); } foreach (var category in orderedCategories.Take(2)) { seedNominations.AddRange(BuildReviewedSeedGroup( category, status: "rejected", displayName: $"{category.GroupName} Review Return", submittedByPrefix: $"seed_review_{category.Slug}_rejected", createdAt, reviewedByTwitchId: "jayuhime_admin", candidateId: null, candidateDisplayName: null)); createdAt = createdAt.AddMinutes(10); } foreach (var category in orderedCategories.Skip(2).Take(2)) { candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates); var candidate = categoryCandidates?.FirstOrDefault(); seedNominations.AddRange(BuildReviewedSeedGroup( category, status: "approved", displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick", submittedByPrefix: $"seed_review_{category.Slug}_approved", createdAt, reviewedByTwitchId: "jayuhime_admin", candidateId: candidate?.Id, candidateDisplayName: candidate?.DisplayName)); createdAt = createdAt.AddMinutes(10); } db.Nominations.AddRange(seedNominations); await db.SaveChangesAsync(); } private static IEnumerable BuildPendingSeedGroup( Category category, Candidate? existingCandidate, string groupKey, string firstSubmitter, string secondSubmitter, DateTimeOffset createdAt, bool useSuggestedCategory, string trackerStatus) { var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey); var platform = existingCandidate?.Platform ?? "YouTube"; var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey); var streamUrl = BuildSeedStreamUrl(platform, channelSlug); var avgViewers = ResolveSeedViewerValue(category); int? suggestedCategoryId = useSuggestedCategory ? category.Id : null; yield return new Nomination { SeasonId = category.SeasonId, CategoryId = category.Id, CategoryGroupName = category.GroupName, SubmittedByTwitchId = firstSubmitter, CandidateText = displayName, StreamUrl = streamUrl, ResolvedChannel = channelSlug.TrimStart('@'), ResolvedPlatform = platform, AvgViewers = avgViewers, SuggestedCategoryId = suggestedCategoryId, TrackerStatus = trackerStatus, TrackerCheckedAt = createdAt.AddMinutes(2), TrackingReviewStatus = "clear", Status = "pending", CreatedAt = createdAt, }; yield return new Nomination { SeasonId = category.SeasonId, CategoryId = category.Id, CategoryGroupName = category.GroupName, SubmittedByTwitchId = secondSubmitter, CandidateText = displayName, StreamUrl = streamUrl, ResolvedChannel = channelSlug.TrimStart('@'), ResolvedPlatform = platform, AvgViewers = avgViewers, SuggestedCategoryId = suggestedCategoryId, TrackerStatus = trackerStatus, TrackerCheckedAt = createdAt.AddMinutes(3), TrackingReviewStatus = "clear", Status = "pending", CreatedAt = createdAt.AddMinutes(1), }; } private static IEnumerable BuildReviewedSeedGroup( Category category, string status, string displayName, string submittedByPrefix, DateTimeOffset createdAt, string reviewedByTwitchId, int? candidateId, string? candidateDisplayName) { var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}"); var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug); var reviewNote = status == "approved" ? "Seed-Datensatz: bereits als Kandidat übernommen." : "Seed-Datensatz: bewusst verworfen für Undo-Tests."; yield return new Nomination { SeasonId = category.SeasonId, CategoryId = category.Id, CategoryGroupName = category.GroupName, SubmittedByTwitchId = $"{submittedByPrefix}_a", CandidateId = candidateId, CandidateText = displayName, StreamUrl = streamUrl, ResolvedChannel = channelSlug.TrimStart('@'), ResolvedPlatform = "Twitch", AvgViewers = ResolveSeedViewerValue(category), SuggestedCategoryId = category.Id, TrackerStatus = "resolved", TrackerCheckedAt = createdAt.AddMinutes(2), TrackingReviewStatus = "reviewed", TrackingReviewNote = reviewNote, TrackingReviewedByTwitchId = reviewedByTwitchId, TrackingReviewedAt = createdAt.AddMinutes(4), Status = status, ReviewNote = reviewNote, ReviewedByTwitchId = reviewedByTwitchId, CreatedAt = createdAt, ReviewedAt = createdAt.AddMinutes(4), }; yield return new Nomination { SeasonId = category.SeasonId, CategoryId = category.Id, CategoryGroupName = category.GroupName, SubmittedByTwitchId = $"{submittedByPrefix}_b", CandidateId = candidateId, CandidateText = displayName, StreamUrl = streamUrl, ResolvedChannel = channelSlug.TrimStart('@'), ResolvedPlatform = "Twitch", AvgViewers = ResolveSeedViewerValue(category), SuggestedCategoryId = category.Id, TrackerStatus = "resolved", TrackerCheckedAt = createdAt.AddMinutes(3), TrackingReviewStatus = "reviewed", TrackingReviewNote = reviewNote, TrackingReviewedByTwitchId = reviewedByTwitchId, TrackingReviewedAt = createdAt.AddMinutes(5), Status = status, ReviewNote = reviewNote, ReviewedByTwitchId = reviewedByTwitchId, CreatedAt = createdAt.AddMinutes(1), ReviewedAt = createdAt.AddMinutes(5), }; } private static string BuildFreshSeedName(Category category, string groupKey) => groupKey switch { "fresh" => $"{category.GroupName} Spotlight {category.Name}", _ => $"{category.GroupName} {category.Name} Pick", }; private static string BuildSeedChannelSlug(Category category, string suffix) { var raw = $"{category.Slug}-{suffix}" .Trim() .TrimStart('@') .ToLowerInvariant(); return new string(raw.Where(char.IsLetterOrDigit).ToArray()); } private static string BuildSeedStreamUrl(string platform, string channelSlug) => platform.Trim().ToLowerInvariant() switch { "youtube" => $"https://www.youtube.com/@{channelSlug}", "kick" => $"https://kick.com/{channelSlug}", "cake" => $"https://cake.gg/{channelSlug}", _ => $"https://www.twitch.tv/{channelSlug}", }; private static int ResolveSeedViewerValue(Category category) { if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max) { return min + ((max - min) / 2); } if (category.ViewerRangeMin is int lowerBound) { return lowerBound + 12; } if (category.ViewerRangeMax is int upperBound) { return Math.Max(1, upperBound - 5); } return 25; } private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed); }