using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SVSim.Database.Models; namespace SVSim.Database.Services.Friend; public sealed class FriendService : IFriendService, IPlayedTogetherWriter { internal const int FriendMaxCount = 110; internal const int SendApplyMaxCount = 110; internal const int PlayedTogetherRetention = 50; // Cosmetic field defaults matching the prod capture's "no campaign, normal player" state. internal const string DefaultDeviceType = "2"; internal const string DefaultMaxFriend = "110"; internal const string DefaultIsReceivedTwoPickMission = "1"; internal const string DefaultBirth = "0"; internal const string DefaultMissionChangeTime = "2017-09-15 02:36:09"; internal const string DefaultMissionReceiveType = "0"; internal const string DefaultIsOfficial = "0"; internal const string DefaultIsOfficialMarkDisplayed = "0"; private readonly SVSimDbContext _db; private readonly ILogger _log; public FriendService(SVSimDbContext db, ILogger log) { _db = db; _log = log; } public async Task GetFriendsAsync(long viewerId, CancellationToken ct) { var friendIds = await _db.ViewerFriends .AsNoTracking() .Where(f => f.OwnerViewerId == viewerId) .OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId) .Select(f => f.FriendViewerId) .ToListAsync(ct); var friends = new List(friendIds.Count); foreach (var friendId in friendIds) { var entry = await BuildFriendEntryAsync(friendId, ct); if (entry is not null) friends.Add(entry); } return new FriendInfoResult(friends, friends.Count, FriendMaxCount); } public async Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerFriendApplies .Where(a => a.ToViewerId == viewerId) .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) .AsNoTracking() .ToListAsync(ct); var applies = new List(rows.Count); foreach (var row in rows) applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct)); return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0); } public async Task GetSendAppliesAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerFriendApplies .Where(a => a.FromViewerId == viewerId) .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) .AsNoTracking() .ToListAsync(ct); var applies = new List(rows.Count); foreach (var row in rows) applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct)); int remaining = Math.Max(0, SendApplyMaxCount - rows.Count); return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount); } public async Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerPlayedTogethers .Where(p => p.OwnerViewerId == viewerId) .OrderByDescending(p => p.PlayedAt) .AsNoTracking() .ToListAsync(ct); var entries = new List(rows.Count); foreach (var row in rows) { var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct); if (opp is null) continue; // opponent deleted; skip the dead row bool isFriend = await _db.ViewerFriends.AsNoTracking() .AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct); int friendStatus = 0; int friendApplyId = 0; if (isFriend) { friendStatus = 1; } else { var sent = await _db.ViewerFriendApplies.AsNoTracking() .Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId) .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; } else { var recv = await _db.ViewerFriendApplies.AsNoTracking() .Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId) .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; } } } entries.Add(new PlayedTogetherEntry( (int)opp.Id, opp.DisplayName, opp.CountryCode, ResolveRank(opp.DisplayName), opp.EmblemId, opp.DegreeId, FormatWireTimestamp(opp.LastLogin), FormatWireTimestamp(row.PlayedAt), friendStatus, friendApplyId, row.PlayedMode, row.BattleType, row.DeckFormat, row.TwoPickType)); } return new PlayedTogetherResult(entries); } public async Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) { if (targetViewerId == (int)viewerId) return null; return await BuildFriendEntryAsync(targetViewerId, ct); } public async Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) { if (targetViewerId == (int)viewerId) { _log.LogDebug("SendApply self-target ignored for viewer {ViewerId}", viewerId); return; } bool targetExists = await _db.Viewers.AsNoTracking().AnyAsync(v => v.Id == targetViewerId, ct); if (!targetExists) { _log.LogDebug("SendApply target {Target} not found", targetViewerId); return; } bool alreadyFriends = await _db.ViewerFriends.AsNoTracking() .AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId, ct); if (alreadyFriends) { _log.LogDebug("SendApply ignored — viewer {ViewerId} already friends with {Target}", viewerId, targetViewerId); return; } bool alreadyPending = await _db.ViewerFriendApplies.AsNoTracking() .AnyAsync(a => a.FromViewerId == viewerId && a.ToViewerId == targetViewerId, ct); if (alreadyPending) return; int outgoingCount = await _db.ViewerFriendApplies.CountAsync(a => a.FromViewerId == viewerId, ct); if (outgoingCount >= SendApplyMaxCount) { _log.LogInformation("SendApply hit cap of {Cap} for viewer {ViewerId}", SendApplyMaxCount, viewerId); return; } _db.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = viewerId, ToViewerId = targetViewerId, CreatedAt = DateTime.UtcNow, MissionType = 0, }); await _db.SaveChangesAsync(ct); } public async Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) { var apply = await _db.ViewerFriendApplies .FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct); if (apply is null) { _log.LogDebug("ApproveApply {ApplyId} not addressed to viewer {ViewerId}", applyId, viewerId); return; } long otherViewer = apply.FromViewerId; int myFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == viewerId, ct); int otherFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == otherViewer, ct); if (myFriendCount >= FriendMaxCount || otherFriendCount >= FriendMaxCount) { _log.LogInformation("ApproveApply hit friend cap (me={Me}, other={Other})", myFriendCount, otherFriendCount); return; } var now = DateTime.UtcNow; await using var tx = await _db.Database.BeginTransactionAsync(ct); _db.ViewerFriendApplies.Remove(apply); // Clean reverse-direction apply if it exists. var reverse = await _db.ViewerFriendApplies .FirstOrDefaultAsync(a => a.FromViewerId == viewerId && a.ToViewerId == otherViewer, ct); if (reverse is not null) _db.ViewerFriendApplies.Remove(reverse); _db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerId, FriendViewerId = otherViewer, CreatedAt = now }); _db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = otherViewer, FriendViewerId = viewerId, CreatedAt = now }); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } public async Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) { var apply = await _db.ViewerFriendApplies .FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct); if (apply is null) return; _db.ViewerFriendApplies.Remove(apply); await _db.SaveChangesAsync(ct); } public async Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct) { var apply = await _db.ViewerFriendApplies .FirstOrDefaultAsync(a => a.Id == applyId && a.FromViewerId == viewerId, ct); if (apply is null) return; _db.ViewerFriendApplies.Remove(apply); await _db.SaveChangesAsync(ct); } public async Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) { await _db.ViewerFriendApplies .Where(a => a.ToViewerId == viewerId) .ExecuteDeleteAsync(ct); } public async Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) { await _db.ViewerFriendApplies .Where(a => a.FromViewerId == viewerId) .ExecuteDeleteAsync(ct); } public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) { var rows = await _db.ViewerFriends .Where(f => (f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId) || (f.OwnerViewerId == targetViewerId && f.FriendViewerId == viewerId)) .ToListAsync(ct); if (rows.Count == 0) return; _db.ViewerFriends.RemoveRange(rows); await _db.SaveChangesAsync(ct); } public async Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) { if (ownerViewerId == opponentViewerId) return; var now = DateTime.UtcNow; var existing = await _db.ViewerPlayedTogethers .FirstOrDefaultAsync(p => p.OwnerViewerId == ownerViewerId && p.OpponentViewerId == opponentViewerId, ct); if (existing is null) { // Enforce per-viewer retention BEFORE insert: if at cap, drop the oldest first. int currentCount = await _db.ViewerPlayedTogethers.CountAsync(p => p.OwnerViewerId == ownerViewerId, ct); if (currentCount >= PlayedTogetherRetention) { var toEvict = await _db.ViewerPlayedTogethers .Where(p => p.OwnerViewerId == ownerViewerId) .OrderBy(p => p.PlayedAt).ThenBy(p => p.OpponentViewerId) .FirstAsync(ct); _db.ViewerPlayedTogethers.Remove(toEvict); } _db.ViewerPlayedTogethers.Add(new ViewerPlayedTogether { OwnerViewerId = ownerViewerId, OpponentViewerId = opponentViewerId, PlayedAt = now, PlayedMode = ctx.PlayedMode, BattleType = ctx.BattleType, DeckFormat = ctx.DeckFormat, TwoPickType = ctx.TwoPickType, }); } else { existing.PlayedAt = now; existing.PlayedMode = ctx.PlayedMode; existing.BattleType = ctx.BattleType; existing.DeckFormat = ctx.DeckFormat; existing.TwoPickType = ctx.TwoPickType; } await _db.SaveChangesAsync(ct); } // --- helpers --- private sealed record ViewerProjection( long Id, string DisplayName, DateTime LastLogin, string CountryCode, long EmblemId, int DegreeId); /// /// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record. /// We materialise the full entity rather than using Select() because EF Core /// ignores Include/ThenInclude when a Select projection is present. /// private async Task LoadViewerProjectionAsync(long viewerId, CancellationToken ct) { var v = await _db.Viewers .AsNoTracking() .Where(x => x.Id == viewerId) .Include(x => x.Info).ThenInclude(i => i.SelectedEmblem) .Include(x => x.Info).ThenInclude(i => i.SelectedDegree) .FirstOrDefaultAsync(ct); if (v is null) return null; return new ViewerProjection( v.Id, v.DisplayName, v.LastLogin, v.Info.CountryCode, v.Info.SelectedEmblem?.Id ?? 0, v.Info.SelectedDegree?.Id ?? 0); } private async Task BuildFriendEntryAsync(long friendViewerId, CancellationToken ct) { var v = await LoadViewerProjectionAsync(friendViewerId, ct); if (v is null) return null; return new FriendEntry( ViewerId: (int)v.Id, Name: v.DisplayName, CountryCode: v.CountryCode, Rank: ResolveRank(v.DisplayName), EmblemId: v.EmblemId, DegreeId: v.DegreeId, LastPlayTime: FormatWireTimestamp(v.LastLogin), DeviceType: DefaultDeviceType, MaxFriend: DefaultMaxFriend, IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission, Birth: DefaultBirth, MissionChangeTime: DefaultMissionChangeTime, MissionReceiveType: DefaultMissionReceiveType, IsOfficial: DefaultIsOfficial, IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed); } private async Task BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct) { var v = await LoadViewerProjectionAsync(otherViewerId, ct); // If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break. var displayName = v?.DisplayName ?? string.Empty; var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch; var countryCode = v?.CountryCode ?? string.Empty; var emblemId = v?.EmblemId ?? 0; var degreeId = v?.DegreeId ?? 0; return new FriendApplyEntry( Id: applyId, ViewerId: (int)otherViewerId, Name: displayName, CountryCode: countryCode, Rank: ResolveRank(displayName), EmblemId: emblemId, DegreeId: degreeId, LastPlayTime: FormatWireTimestamp(lastLogin), CreateTime: FormatWireTimestamp(createdAt), MissionType: missionType); } /// /// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands. /// private static int ResolveRank(string _) => 1; private static string FormatWireTimestamp(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); }