using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
namespace SVSim.EmulatedEntrypoint.Services;
///
/// Builds the shared consumed by the client's
/// DeckGroupListData(jsonData, format). Used by /deck/info, /deck/my_list,
/// and /practice/deck_list — all three return the same wire shape (default decks +
/// per-class leader-skin settings + the viewer's decks).
///
/// distinguishes the deck *builder* screens
/// (/deck/*, which need empty "New Deck" tiles up to the slot cap) from the deck *select*
/// screens (/practice/deck_list, where prod returns the real decks unpadded — confirmed by
/// the 2026-05-29 practice capture returning empty user-deck arrays for a fresh account).
///
public interface IDeckListBuilder
{
Task BuildAsync(long viewerId, Format requestFormat, bool padEmptySlots);
///
/// Pads a viewer's real deck list with empty-slot placeholders up to the slot cap. Exposed for
/// deck-builder endpoints (e.g. /deck/update) that return a deck list directly rather
/// than through .
///
List PadEmptySlots(List realDecks);
}
public class DeckListBuilder : IDeckListBuilder
{
private readonly IDeckRepository _deckRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly SVSimDbContext _dbContext;
private readonly DeckOptions _deckOptions;
private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower,
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
public DeckListBuilder(
IDeckRepository deckRepository,
IGlobalsRepository globalsRepository,
SVSimDbContext dbContext,
IOptions deckOptions)
{
_deckRepository = deckRepository;
_globalsRepository = globalsRepository;
_dbContext = dbContext;
_deckOptions = deckOptions.Value;
}
public async Task BuildAsync(long viewerId, Format requestFormat, bool padEmptySlots)
{
var defaultDecks = await _globalsRepository.GetDefaultDecks();
// user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite
// the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's
// ViewerClassData rows, matching how /load/index's user_class_list reads them. The global
// DefaultLeaderSkinSettings table is now used only as initial seed values for fresh
// viewers (ViewerRepository.RegisterViewer); the per-class current skin is on
// viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update.
var viewerClasses = await _dbContext.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Classes)
.Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id })
.ToListAsync();
var response = new DeckListResponse
{
DefaultDeckList = defaultDecks.ToDictionary(
d => d.Id.ToString(),
d => new DefaultDeck
{
DeckNo = d.DeckNo,
ClassId = d.ClassId,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
DeckName = d.DeckName,
CardIdArray = System.Text.Json.JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions) ?? new(),
// TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands.
// Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks.
IsCompleteDeck = 1,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
}),
UserLeaderSkinSettingList = viewerClasses.ToDictionary(
vc => vc.Id.ToString(),
vc => new UserLeaderSkinSetting
{
ClassId = vc.Id,
IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted
LeaderSkinId = vc.LeaderSkinId,
}),
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
};
if (requestFormat == Format.All)
{
// Prod's All-format response emits these three per-format lists (each [] for fresh viewers).
// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
response.UserDeckRotation = MaybePad(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(), padEmptySlots);
response.UserDeckUnlimited = MaybePad(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(), padEmptySlots);
response.UserDeckMyRotation = MaybePad(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList(), padEmptySlots);
// trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list
// (specific format). Empty array in the 2026-05-23 prod capture.
response.TrialDeckList = new();
}
else
{
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
response.UserDeckList = MaybePad(decks.Select(d => new UserDeck(d)).ToList(), padEmptySlots);
}
return response;
}
private List MaybePad(List realDecks, bool pad) => pad ? PadEmptySlots(realDecks) : realDecks;
///
/// Pads a viewer's real deck list with empty-slot placeholders up to .
/// Required on the deck *builder* screens because the client's
/// DeckUI.DeckViewData.CreateDeckViewList only renders a "New Deck" tile when the response
/// contains an entry whose card_id_array is empty — without padding, the player cannot
/// create additional decks once any exist. Deck *select* screens (practice) skip padding: prod
/// returns the real decks unpadded there.
///
public List PadEmptySlots(List realDecks)
{
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
var result = new List(realDecks);
for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++)
{
if (!taken.Contains(slot))
{
result.Add(UserDeck.CreateEmptySlot(slot));
}
}
return result;
}
}