using System.Text.Json; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Models; using static SVSim.Bootstrap.Importers.ImporterBase; namespace SVSim.Bootstrap.Importers; /// /// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture JSON. /// Three methods run in dependency order (see Bootstrap/Program.cs): /// 1. ImportSeriesAsync — build_deck_series_master.csv → 22 series rows (all IsEnabled=false initially) /// 2. ImportCatalogAsync — prod-captures/build_deck-info-*.json → enriches 7 series + 53 products (Task 15) /// 3. ImportPackageAsync — build_deck_package_master.csv → card lists for all 112 products, /// creates disabled stubs for products not seeded by the catalog importer /// Idempotent — re-runnable on the same files. /// public class BuildDeckImporter { private const string BuildDeckSubdir = "build-deck"; public async Task ImportSeriesAsync(SVSimDbContext db, string dataDir) { string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_series_master.csv"); if (!File.Exists(csvPath)) { Console.Error.WriteLine($"[BuildDeckImporter] series CSV missing: {csvPath}"); return 0; } var rows = ReadCsv(csvPath).Skip(1).ToList(); // skip header int created = 0, updated = 0; var existing = await db.BuildDeckSeries.ToDictionaryAsync(s => s.Id); foreach (var cols in rows) { if (cols.Length < 5) continue; if (!int.TryParse(cols[0], out int id)) continue; if (existing.TryGetValue(id, out var row)) { // Update CSV-derived fields; do not flip IsEnabled or OrderIndex (catalog importer owns those) bool changed = false; if (row.NameKey != cols[1]) { row.NameKey = cols[1]; changed = true; } if (row.IntroKey != cols[2]) { row.IntroKey = cols[2]; changed = true; } if (row.TitlePath != cols[3]) { row.TitlePath = cols[3]; changed = true; } if (row.DrumrollPath != cols[4]) { row.DrumrollPath = cols[4]; changed = true; } if (changed) updated++; } else { db.BuildDeckSeries.Add(new BuildDeckSeriesEntry { Id = id, NameKey = cols[1], IntroKey = cols[2], TitlePath = cols[3], DrumrollPath = cols[4], OrderIndex = 0, IsNew = false, IsEnabled = false, }); created++; } } await db.SaveChangesAsync(); Console.WriteLine($"[BuildDeckImporter] Series: created={created}, updated={updated}"); return created + updated; } public async Task ImportPackageAsync(SVSimDbContext db, string dataDir) { string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_package_master.csv"); if (!File.Exists(csvPath)) { Console.Error.WriteLine($"[BuildDeckImporter] package CSV missing: {csvPath}"); return 0; } var rows = ReadCsv(csvPath).Skip(1).ToList(); // header: product_id,card_id,number,is_spot var byProduct = rows .Where(c => c.Length >= 4) .GroupBy(c => int.Parse(c[0])) .ToDictionary(g => g.Key, g => g.Select(c => new BuildDeckProductCardEntry { CardId = long.Parse(c[1]), Number = int.Parse(c[2]), IsSpot = int.Parse(c[3]) != 0, }).ToList()); // Load existing products (we may have stubs from a prior run or rows created by catalog importer) var existing = await db.BuildDeckProducts.Include(p => p.Cards).ToDictionaryAsync(p => p.Id); int created = 0, updated = 0; foreach (var (productId, cardEntries) in byProduct) { if (existing.TryGetValue(productId, out var product)) { // Replace card list wholesale — CSV is authoritative. product.Cards.Clear(); foreach (var c in cardEntries) product.Cards.Add(c); updated++; } else { int? seriesId = InferSeriesId(productId); if (seriesId is null) { Console.Error.WriteLine($"[BuildDeckImporter] product {productId} has no inferable series; skipping"); continue; } db.BuildDeckProducts.Add(new BuildDeckProductEntry { Id = productId, SeriesId = seriesId.Value, LeaderId = 0, DeckCode = string.Empty, ProductNameKey = string.Empty, FeaturedCardId = 0, PurchaseNumMax = 1, IntroPriceCrystal = null, RegularPriceCrystal = null, IntroPriceRupy = null, RegularPriceRupy = null, IsEnabled = false, Cards = cardEntries, }); created++; } } await db.SaveChangesAsync(); Console.WriteLine($"[BuildDeckImporter] Package: created={created}, updated={updated}"); return created + updated; } public async Task ImportCatalogAsync(SVSimDbContext db, string capturesDir) { var data = LoadCapture(capturesDir, "build_deck-info"); if (data is null) return 0; int touchedSeries = 0, touchedProducts = 0; // Load existing rows for fast lookup var existingSeries = await db.BuildDeckSeries .Include(s => s.SeriesRewards) .ToDictionaryAsync(s => s.Id); var existingProducts = await db.BuildDeckProducts .Include(p => p.Rewards) .ToDictionaryAsync(p => p.Id); // The captured data root is an object keyed by order_id string ("15"…"21"); iterate values. foreach (var seriesNode in data.Value.EnumerateObject()) { var s = seriesNode.Value; int seriesId = GetInt(s, "series_id"); int orderId = GetInt(s, "order_id"); bool isNew = GetBool(s, "is_new"); if (!existingSeries.TryGetValue(seriesId, out var seriesRow)) { // Catalog runs before package importer in production, so series rows from the series // CSV should already exist. If not (e.g. the capture has a series the CSV doesn't), // create a bare row so the FK from products holds. seriesRow = new BuildDeckSeriesEntry { Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty, TitlePath = string.Empty, DrumrollPath = string.Empty, }; db.BuildDeckSeries.Add(seriesRow); existingSeries[seriesId] = seriesRow; } seriesRow.OrderIndex = orderId; seriesRow.IsNew = isNew; seriesRow.IsEnabled = true; // Series rewards: replace wholesale (capture is authoritative for enabled series) seriesRow.SeriesRewards.Clear(); if (s.TryGetProperty("series_rewards", out var seriesRewards) && seriesRewards.ValueKind == JsonValueKind.Object) { foreach (var tier in seriesRewards.EnumerateObject()) { if (!int.TryParse(tier.Name, out int tierIndex)) continue; if (!tier.Value.TryGetProperty("reward_list", out var rewardList) || rewardList.ValueKind != JsonValueKind.Array) continue; int itemIndex = 0; foreach (var r in rewardList.EnumerateArray()) { seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry { TierIndex = tierIndex, ItemIndex = itemIndex++, RewardType = GetInt(r, "reward_type"), RewardDetailId = GetLong(r, "reward_detail_id"), RewardNumber = GetInt(r, "reward_number"), MessageId = GetInt(r, "message_id"), }); } } } touchedSeries++; // Products if (!s.TryGetProperty("products", out var products) || products.ValueKind != JsonValueKind.Array) continue; // First pass: parse each captured product, track intro/regular tiers per product. var capturedThisSeries = new List(); foreach (var p in products.EnumerateArray()) { int productId = GetInt(p, "product_id"); if (!existingProducts.TryGetValue(productId, out var productRow)) { productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId }; db.BuildDeckProducts.Add(productRow); existingProducts[productId] = productRow; } productRow.SeriesId = seriesId; productRow.LeaderId = GetInt(p, "leader_id"); productRow.DeckCode = GetString(p, "deck_code"); productRow.ProductNameKey = GetString(p, "product_name"); productRow.FeaturedCardId = GetLong(p, "featured_card_id"); productRow.PurchaseNumMax = GetInt(p, "purchase_num_max"); productRow.IsEnabled = true; bool isFirstPrice = GetBool(p, "is_first_price"); // Tier-aware price ingestion: each captured row has ONE price tier (intro OR regular). int? priceCrystal = p.TryGetProperty("price_crystal", out var pc) && pc.ValueKind != JsonValueKind.Null ? (int?)GetInt(p, "price_crystal") : null; int? priceRupy = p.TryGetProperty("price_rupy", out var pr) && pr.ValueKind != JsonValueKind.Null ? (int?)GetInt(p, "price_rupy") : null; if (priceCrystal is not null) { if (isFirstPrice) productRow.IntroPriceCrystal = priceCrystal; else productRow.RegularPriceCrystal = priceCrystal; } if (priceRupy is not null) { if (isFirstPrice) productRow.IntroPriceRupy = priceRupy; else productRow.RegularPriceRupy = priceRupy; } // Product rewards: replace wholesale productRow.Rewards.Clear(); if (p.TryGetProperty("rewards", out var rewards) && rewards.ValueKind == JsonValueKind.Object) { foreach (var r in rewards.EnumerateObject()) { if (!int.TryParse(r.Name, out int idx)) continue; productRow.Rewards.Add(new BuildDeckProductRewardEntry { RewardIndex = idx, RewardType = GetInt(r.Value, "reward_type"), RewardDetailId = GetLong(r.Value, "reward_detail_id"), RewardNumber = GetInt(r.Value, "reward_number"), MessageId = GetInt(r.Value, "message_id"), }); } } capturedThisSeries.Add(productRow); touchedProducts++; } // Second pass: backfill missing tier per-series when sibling products share a unique value. BackfillSeriesTier(capturedThisSeries); } await db.SaveChangesAsync(); Console.WriteLine($"[BuildDeckImporter] Catalog: series={touchedSeries}, products={touchedProducts}"); return touchedSeries + touchedProducts; } private static void BackfillSeriesTier(IReadOnlyList productsInSeries) { // For each (Currency, Tier) pair, if all populated values across siblings are the same, // propagate that value to products that are missing the corresponding tier. BackfillIntroCrystal(productsInSeries); BackfillRegularCrystal(productsInSeries); BackfillIntroRupy(productsInSeries); BackfillRegularRupy(productsInSeries); } private static void BackfillIntroCrystal(IReadOnlyList products) { var distinct = products.Where(p => p.IntroPriceCrystal.HasValue).Select(p => p.IntroPriceCrystal!.Value).Distinct().ToList(); if (distinct.Count != 1) return; int value = distinct[0]; foreach (var p in products) { if (p.IntroPriceCrystal is null) p.IntroPriceCrystal = value; } } private static void BackfillRegularCrystal(IReadOnlyList products) { var distinct = products.Where(p => p.RegularPriceCrystal.HasValue).Select(p => p.RegularPriceCrystal!.Value).Distinct().ToList(); if (distinct.Count != 1) return; int value = distinct[0]; foreach (var p in products) { // For PurchaseNumMax == 1 products, never backfill the Regular tier — they have no second buy. if (p.PurchaseNumMax <= 1) continue; if (p.RegularPriceCrystal is null) p.RegularPriceCrystal = value; } } private static void BackfillIntroRupy(IReadOnlyList products) { var distinct = products.Where(p => p.IntroPriceRupy.HasValue).Select(p => p.IntroPriceRupy!.Value).Distinct().ToList(); if (distinct.Count != 1) return; int value = distinct[0]; foreach (var p in products) { if (p.IntroPriceRupy is null) p.IntroPriceRupy = value; } } private static void BackfillRegularRupy(IReadOnlyList products) { var distinct = products.Where(p => p.RegularPriceRupy.HasValue).Select(p => p.RegularPriceRupy!.Value).Distinct().ToList(); if (distinct.Count != 1) return; int value = distinct[0]; foreach (var p in products) { if (p.PurchaseNumMax <= 1) continue; if (p.RegularPriceRupy is null) p.RegularPriceRupy = value; } } /// /// Maps a product_id to its series_id using the numeric pattern derived from the /info capture /// and CSV inspection. /// Sets 1–7: products 1–7, 201–299, 301–399, 401–499, 501–599, 601–699, 701–799 → series 101–107 /// Temporary Deck: products 10001–10099 → series 10100 /// Trial series: products NNxx where NN in [119,…,132] → series NN00 (divide-by-100 * 100) /// internal static int? InferSeriesId(int productId) => productId switch { >= 1 and <= 7 => 101, >= 201 and <= 299 => 102, >= 301 and <= 399 => 103, >= 401 and <= 499 => 104, >= 501 and <= 599 => 105, >= 601 and <= 699 => 106, >= 701 and <= 799 => 107, >= 10001 and <= 10099 => 10100, >= 11901 and <= 13299 => (productId / 100) * 100, _ => null, }; private static IEnumerable ReadCsv(string path) { foreach (var raw in File.ReadAllLines(path, System.Text.Encoding.UTF8)) { // Strip UTF-8 BOM on the first line if present var line = raw.TrimStart(''); if (string.IsNullOrWhiteSpace(line)) continue; yield return line.Split(','); } } }