using Microsoft.EntityFrameworkCore; using SVSim.Database.Entities.Guild; using SVSim.Database.Models.Config; using SVSim.Database.Repositories.Guild; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; namespace SVSim.Database.Services.Guild; public sealed class GuildService : IGuildService { private readonly IGuildRepository _guilds; private readonly IGuildMemberRepository _members; private readonly IGuildInviteRepository _invites; private readonly IGuildJoinRequestRepository _joinRequests; private readonly IGuildChatMessageRepository _chatMessages; private readonly IGameConfigService _config; private readonly IGuildIdGenerator _idGen; private readonly IGuildChatService _chat; private readonly IViewerRepository _viewers; private readonly SVSimDbContext _db; public GuildService( IGuildRepository guilds, IGuildMemberRepository members, IGuildInviteRepository invites, IGuildJoinRequestRepository joinRequests, IGuildChatMessageRepository chatMessages, IGameConfigService config, IGuildIdGenerator idGen, IGuildChatService chat, IViewerRepository viewers, SVSimDbContext db) { _guilds = guilds; _members = members; _invites = invites; _joinRequests = joinRequests; _chatMessages = chatMessages; _config = config; _idGen = idGen; _chat = chat; _viewers = viewers; _db = db; } public async Task GetMyGuildAsync(long viewerId, CancellationToken ct = default) { var membership = await _members.GetMembershipAsync(viewerId, ct); if (membership is null) return null; var guild = await _guilds.GetWithMembersAsync(membership.GuildId, ct); if (guild is null || guild.BreakupAt is not null) return null; int joinReqCount = await _joinRequests.CountPendingForGuildAsync(guild.GuildId, ct); int inviteCount = await _invites.CountPendingForInviteeAsync(viewerId, ct); return new GuildFullView(guild, guild.Members, joinReqCount, inviteCount); } public Task GetActiveAsync(int guildId, CancellationToken ct = default) => _guilds.GetActiveByIdAsync(guildId, ct); public async Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default) { var cfg = _config.Get(); var rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct); var guildIds = rows.Select(r => r.GuildId).ToList(); var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct); var leaderIds = rows.Select(r => r.LeaderViewerId).Distinct().ToList(); var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct); return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0), leaderNames.GetValueOrDefault(r.LeaderViewerId, ""))).ToList(); } public async Task CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64) return new(GuildOpResultCode.NameInvalid); if (req.Activity is < 1 or > 16) return new(GuildOpResultCode.NameInvalid); if (req.JoinCondition is < 1 or > 3) return new(GuildOpResultCode.NameInvalid); if (await _members.GetMembershipAsync(viewerId, ct) is not null) return new(GuildOpResultCode.AlreadyInGuild); if (await _guilds.NameExistsAsync(req.Name, ct)) return new(GuildOpResultCode.NameTaken); var viewer = await _db.Viewers .Include(v => v.Info.SelectedEmblem) .FirstOrDefaultAsync(v => v.Id == viewerId, ct); if (viewer is null) return new(GuildOpResultCode.PermissionDenied); var id = await _idGen.NextAsync( (cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c), ct); var now = DateTime.UtcNow; var g = new Entities.Guild.Guild { GuildId = id, Name = req.Name, Description = "", LeaderViewerId = viewerId, EmblemId = DefaultEmblemId(viewer), Activity = (GuildActivity)req.Activity, JoinCondition = (GuildJoinCondition)req.JoinCondition, CreatedAt = now, }; await _guilds.AddAsync(g, ct); await _members.AddAsync( new GuildMember { GuildId = id, ViewerId = viewerId, Role = GuildRole.Leader, JoinedAt = now }, ct); // Side-effects: clear any pending invites + cancel pending join requests for this viewer. await _invites.ConsumePendingForViewerAsync(viewerId, now, ct); await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct); // Update Viewer.GuildId pointer for quick lookups. viewer.GuildId = id; await _db.SaveChangesAsync(ct); await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct); return new(GuildOpResultCode.Ok, GuildId: id); } /// /// Returns the viewer's currently-equipped emblem id, or 100000000 (default) if none. /// private static long DefaultEmblemId(Models.Viewer viewer) { var emblemId = viewer.Info?.SelectedEmblem?.Id; return emblemId is > 0 ? emblemId.Value : 100_000_000L; } public async Task UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default) { var m = await _members.GetMembershipAsync(viewerId, ct); if (m is null) return new(GuildOpResultCode.NotInGuild); if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); if (req.Name is not null && (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64)) return new(GuildOpResultCode.NameInvalid); if (req.Activity.HasValue && req.Activity.Value is < 1 or > 16) return new(GuildOpResultCode.NameInvalid); if (req.JoinCondition.HasValue && req.JoinCondition.Value is < 1 or > 3) return new(GuildOpResultCode.NameInvalid); await _guilds.UpdateActivityAndJoinConditionAsync(m.GuildId, req.Activity, req.JoinCondition, req.Name, ct); return GuildOpResult.Ok; } public async Task UpdateDescriptionAsync(long viewerId, string description, CancellationToken ct = default) { var m = await _members.GetMembershipAsync(viewerId, ct); if (m is null) return new(GuildOpResultCode.NotInGuild); if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); if (description.Length > 512) return new(GuildOpResultCode.NameInvalid); await _guilds.UpdateDescriptionAsync(m.GuildId, description, ct); await _chat.EmitSystemEventAsync(m.GuildId, viewerId, GuildChatMessageType.Description, body: null, ct); return GuildOpResult.Ok; } public async Task UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default) { var m = await _members.GetMembershipAsync(viewerId, ct); if (m is null) return new(GuildOpResultCode.NotInGuild); if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); await _guilds.UpdateEmblemAsync(m.GuildId, emblemId, ct); return GuildOpResult.Ok; } public async Task BreakupAsync(long viewerId, CancellationToken ct = default) { var membership = await _members.GetMembershipAsync(viewerId, ct); if (membership is null) return new(GuildOpResultCode.NotInGuild); if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); var now = DateTime.UtcNow; var members = await _members.ListByGuildAsync(membership.GuildId, ct); foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct); await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct); await _invites.DeleteAllForGuildAsync(membership.GuildId, ct); await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct); await _members.DeleteAllForGuildAsync(membership.GuildId, ct); await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct); return GuildOpResult.Ok; } public async Task InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) { // Caller must be a guild member with Leader or SubLeader role. var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct); if (callerMembership is null) return new(GuildOpResultCode.NotInGuild); if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader)) return new(GuildOpResultCode.PermissionDenied); // Target must not already be in any guild. var targetMembership = await _members.GetMembershipAsync(targetViewerId, ct); if (targetMembership is not null) return new(GuildOpResultCode.AlreadyInGuild); // No duplicate pending invite to the same target from the same guild. var existing = await _invites.GetAsync(callerMembership.GuildId, targetViewerId, ct); if (existing is { Status: GuildInviteStatus.Pending }) return new(GuildOpResultCode.InviteAlreadyResolved); var now = DateTime.UtcNow; await _invites.AddAsync(new GuildInvite { GuildId = callerMembership.GuildId, InviteeViewerId = targetViewerId, InviterViewerId = callerViewerId, Status = GuildInviteStatus.Pending, CreatedAt = now, }, ct); return GuildOpResult.Ok; } public async Task CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default) { // Caller must be in their guild with Leader or SubLeader role. var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct); if (callerMembership is null) return new(GuildOpResultCode.NotInGuild); if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader)) return new(GuildOpResultCode.PermissionDenied); var invite = await _invites.GetByIdAsync(inviteId, ct); if (invite is null) return new(GuildOpResultCode.InviteNotFound); // Caller must belong to the same guild as the invite. if (invite.GuildId != callerMembership.GuildId) return new(GuildOpResultCode.PermissionDenied); if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved); await _invites.UpdateStatusAsync(invite.GuildId, invite.InviteeViewerId, GuildInviteStatus.Canceled, DateTime.UtcNow, ct); return GuildOpResult.Ok; } public async Task RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default) { // Invitee rejects — no guild membership check needed. var invite = await _invites.GetByIdAsync(inviteId, ct); if (invite is null) return new(GuildOpResultCode.InviteNotFound); // Caller must be the invitee. if (invite.InviteeViewerId != callerViewerId) return new(GuildOpResultCode.PermissionDenied); if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved); await _invites.UpdateStatusAsync(invite.GuildId, callerViewerId, GuildInviteStatus.Rejected, DateTime.UtcNow, ct); return GuildOpResult.Ok; } public async Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default) { if (await _members.GetMembershipAsync(viewerId, ct) is not null) return new(GuildOpResultCode.AlreadyInGuild); var g = await _guilds.GetActiveByIdAsync(guildId, ct); if (g is null) return new(GuildOpResultCode.GuildNotFound); var cfg = _config.Get(); var memberCount = await _members.CountByGuildAsync(guildId, ct); if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached); var now = DateTime.UtcNow; bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct) is { Status: GuildInviteStatus.Pending }; if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite) return new(GuildOpResultCode.PermissionDenied); if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite) { var existing = await _joinRequests.GetAsync(guildId, viewerId, ct); if (existing is { Status: GuildJoinRequestStatus.Pending }) { // Already pending — idempotent, no-op. return new(GuildOpResultCode.Ok, GuildStatus: 1); // APPLYING } if (existing is not null) { // Row exists but was previously Canceled/Rejected — reset to Pending in place. existing.Status = GuildJoinRequestStatus.Pending; existing.CreatedAt = now; existing.RespondedAt = null; await _db.SaveChangesAsync(ct); } else { await _joinRequests.AddAsync(new GuildJoinRequest { GuildId = guildId, ViewerId = viewerId, Status = GuildJoinRequestStatus.Pending, CreatedAt = now, }, ct); } return new(GuildOpResultCode.Ok, GuildStatus: 1); // APPLYING } await CommitJoinAsync(viewerId, guildId, now, ct); return new(GuildOpResultCode.Ok, GuildStatus: 2); // JOINING } private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct) { await _members.AddAsync( new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now }, ct); await _viewers.SetGuildIdAsync(viewerId, guildId, ct); await _invites.ConsumePendingForViewerAsync(viewerId, now, ct); await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct); await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct); } public async Task CancelJoinRequestAsync(long viewerId, CancellationToken ct = default) { // Cancel all pending requests for this viewer (client sends no guildId — see decompile). var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct); if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound); var now = DateTime.UtcNow; foreach (var req in pending) await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct); return GuildOpResult.Ok; } public async Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) { var m = await _members.GetMembershipAsync(callerViewerId, ct); if (m is null) return new(GuildOpResultCode.NotInGuild); if (m.Role is not (GuildRole.Leader or GuildRole.SubLeader)) return new(GuildOpResultCode.PermissionDenied); var req = await _joinRequests.GetAsync(m.GuildId, applicantViewerId, ct); if (req is null) return new(GuildOpResultCode.JoinRequestNotFound); if (req.Status != GuildJoinRequestStatus.Pending) return new(GuildOpResultCode.JoinRequestAlreadyResolved); var cfg = _config.Get(); var memberCount = await _members.CountByGuildAsync(m.GuildId, ct); if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached); var now = DateTime.UtcNow; await _joinRequests.UpdateStatusAsync(m.GuildId, applicantViewerId, GuildJoinRequestStatus.Accepted, now, ct); await CommitJoinAsync(applicantViewerId, m.GuildId, now, ct); return GuildOpResult.Ok; } public async Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) { var m = await _members.GetMembershipAsync(callerViewerId, ct); if (m is null) return new(GuildOpResultCode.NotInGuild); if (m.Role is not (GuildRole.Leader or GuildRole.SubLeader)) return new(GuildOpResultCode.PermissionDenied); var req = await _joinRequests.GetAsync(m.GuildId, applicantViewerId, ct); if (req is null) return new(GuildOpResultCode.JoinRequestNotFound); if (req.Status != GuildJoinRequestStatus.Pending) return new(GuildOpResultCode.JoinRequestAlreadyResolved); var now = DateTime.UtcNow; await _joinRequests.UpdateStatusAsync(m.GuildId, applicantViewerId, GuildJoinRequestStatus.Rejected, now, ct); return GuildOpResult.Ok; } public Task LeaveAsync(long viewerId, CancellationToken ct = default) => throw new NotImplementedException(); public Task RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) => throw new NotImplementedException(); public Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default) => throw new NotImplementedException(); public Task> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default) => _invites.ListPendingForInviteeAsync(viewerId, ct); public async Task> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default) { var membership = await _members.GetMembershipAsync(callerViewerId, ct); if (membership is null) return Array.Empty(); var pendingInvites = await _invites.ListPendingForGuildAsync(membership.GuildId, ct); if (pendingInvites.Count == 0) return Array.Empty(); var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList(); var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct); // Load emblem + degree data for invitees. var viewerRows = await _db.Viewers .AsNoTracking() .Include(v => v.Info.SelectedEmblem) .Include(v => v.Info.SelectedDegree) .Where(v => inviteeIds.Contains(v.Id)) .ToDictionaryAsync(v => v.Id, ct); var result = new List(pendingInvites.Count); foreach (var invite in pendingInvites) { viewerRows.TryGetValue(invite.InviteeViewerId, out var v); result.Add(new GuildOutgoingInviteEntry( InviteId: invite.Id, InviteeViewerId: invite.InviteeViewerId, Name: names.GetValueOrDefault(invite.InviteeViewerId, ""), EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L, CountryCode: v?.Info?.CountryCode ?? "", Rank: 1, DegreeId: v?.Info?.SelectedDegree?.Id ?? 0, CreatedAt: invite.CreatedAt)); } return result; } public async Task> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default) { // ListPendingForInviteeAsync already includes the Guild nav prop. var pendingInvites = await _invites.ListPendingForInviteeAsync(viewerId, ct); if (pendingInvites.Count == 0) return Array.Empty(); var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList(); var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct); var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Distinct().ToList(); var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct); return pendingInvites.Select(invite => new GuildReceivedInviteEntry( InviteId: invite.Id, Guild: invite.Guild, MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0), LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList(); } public async Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default) { var membership = await _members.GetMembershipAsync(viewerId, ct); if (membership is null) return Array.Empty(); // Only leader/subleader may view the list — return empty for regular members. if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader)) return Array.Empty(); var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct); if (requests.Count == 0) return Array.Empty(); var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList(); var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct); var viewerRows = await _db.Viewers .AsNoTracking() .Include(v => v.Info.SelectedEmblem) .Include(v => v.Info.SelectedDegree) .Where(v => applicantIds.Contains(v.Id)) .ToDictionaryAsync(v => v.Id, ct); return requests.Select(r => { viewerRows.TryGetValue(r.ViewerId, out var v); return new GuildJoinRequestEntry( ApplicantViewerId: r.ViewerId, Name: names.GetValueOrDefault(r.ViewerId, ""), EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L, CountryCode: v?.Info?.CountryCode ?? "", Rank: 1, DegreeId: v?.Info?.SelectedDegree?.Id ?? 0, IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true, RequestedAt: r.CreatedAt); }).ToList(); } }