using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
///
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one seed 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 — seeds/build-deck-catalog.json → enriches 7 series + 53 products
/// (tier backfill for missing intro/regular prices is performed by the extractor)
/// 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 seedDir)
{
var seed = SeedLoader.LoadList(Path.Combine(seedDir, "build-deck-catalog.json"));
if (seed.Count == 0) return 0;
int touchedSeries = 0, touchedProducts = 0;
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);
foreach (var s in seed)
{
if (s.SeriesId == 0) continue;
if (!existingSeries.TryGetValue(s.SeriesId, out var seriesRow))
{
// Catalog typically runs after the series CSV; if a seed series isn't in the
// CSV we create a bare stub so the FK from products holds.
seriesRow = new BuildDeckSeriesEntry
{
Id = s.SeriesId, NameKey = string.Empty, IntroKey = string.Empty,
TitlePath = string.Empty, DrumrollPath = string.Empty,
};
db.BuildDeckSeries.Add(seriesRow);
existingSeries[s.SeriesId] = seriesRow;
}
seriesRow.OrderIndex = s.OrderId;
seriesRow.IsNew = s.IsNew;
seriesRow.IsEnabled = true;
seriesRow.SeriesRewards.Clear();
foreach (var r in s.SeriesRewards)
{
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
{
TierIndex = r.TierIndex,
ItemIndex = r.ItemIndex,
RewardType = (UserGoodsType)r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
MessageId = r.MessageId,
});
}
touchedSeries++;
foreach (var p in s.Products)
{
if (!existingProducts.TryGetValue(p.ProductId, out var productRow))
{
productRow = new BuildDeckProductEntry { Id = p.ProductId, SeriesId = s.SeriesId };
db.BuildDeckProducts.Add(productRow);
existingProducts[p.ProductId] = productRow;
}
productRow.SeriesId = s.SeriesId;
productRow.LeaderId = p.LeaderId;
productRow.DeckCode = p.DeckCode;
productRow.ProductNameKey = p.ProductName;
productRow.FeaturedCardId = p.FeaturedCardId;
productRow.PurchaseNumMax = p.PurchaseNumMax;
productRow.IsEnabled = true;
productRow.IntroPriceCrystal = p.IntroPriceCrystal;
productRow.RegularPriceCrystal = p.RegularPriceCrystal;
productRow.IntroPriceRupy = p.IntroPriceRupy;
productRow.RegularPriceRupy = p.RegularPriceRupy;
productRow.Rewards.Clear();
foreach (var r in p.Rewards)
{
productRow.Rewards.Add(new BuildDeckProductRewardEntry
{
RewardIndex = r.RewardIndex,
RewardType = (UserGoodsType)r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
MessageId = r.MessageId,
});
}
touchedProducts++;
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[BuildDeckImporter] Catalog: series={touchedSeries}, products={touchedProducts}");
return touchedSeries + touchedProducts;
}
///
/// 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(',');
}
}
}