using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Clan; using Nuuru.Server.Models; using Nuuru.Server.Services.Storage; namespace Nuuru.Server.Services { public interface IClanService { Task CreateClanAsync(Guid userId, CreateClanRequest request); Task InviteUserAsync(Guid inviterId, int clanId, Guid targetUserId); Task AcceptInviteAsync(Guid userId, int inviteId); Task DeclineInviteAsync(Guid userId, int inviteId); Task> GetPendingInvitesAsync(Guid userId); Task> GetSentInvitesAsync(Guid leaderId, int clanId); Task RevokeInviteAsync(Guid leaderId, int clanId, int inviteId); Task ApplyToClanAsync(Guid userId, int clanId); Task AcceptApplicationAsync(Guid leaderId, int clanId, int applicationId); Task RejectApplicationAsync(Guid leaderId, int clanId, int applicationId); Task> GetPendingApplicationsAsync(Guid leaderId, int clanId); Task LeaveClanAsync(Guid userId); Task KickMemberAsync(Guid leaderId, int clanId, Guid userId); Task GetClanAsync(int clanId); Task GetClanByTagAsync(string tag); Task> GetMembersAsync(int clanId); Task GetUserClanAsync(Guid userId); Task UpdateClanAsync(Guid leaderId, int clanId, UpdateClanRequest request); Task ExpandSlotsAsync(Guid leaderId, int clanId); Task DepositToTreasuryAsync(Guid userId, int amount); Task PurchaseClanForumAsync(Guid leaderId, int clanId); Task WithdrawFromTreasuryAsync(Guid leaderId, int clanId, Guid targetUserId, int amount); Task UploadBadgeAsync(Guid leaderId, int clanId, Stream imageStream, string fileName, CropRect? crop = null); Task GetBadgeFileAsync(int clanId); Task<(IEnumerable Items, int TotalCount)> GetLeaderboardAsync(string sort, int page, int pageSize); } public class ClanService : IClanService { private const int ClanCreationCost = 2000; private static readonly Dictionary ExpansionCosts = new() { { 10, 500 }, { 15, 1000 }, { 20, 2000 }, { 30, 5000 } }; private readonly ApplicationDbContext _context; private readonly IBointsService _bointsService; private readonly IUserBadgeService _userBadgeService; private readonly IFileStorageService _fileStorage; private readonly ILogger _logger; public ClanService( ApplicationDbContext context, IBointsService bointsService, IUserBadgeService userBadgeService, IFileStorageService fileStorage, ILogger logger) { _context = context; _bointsService = bointsService; _userBadgeService = userBadgeService; _fileStorage = fileStorage; _logger = logger; } public async Task CreateClanAsync(Guid userId, CreateClanRequest request) { // Check user isn't already in a clan if (await _context.ClanMembers.AnyAsync(m => m.UserId == userId)) return null; // Check balance var user = await _context.Users.FindAsync(userId); if (user == null || user.Boints < ClanCreationCost) return null; // Check unique name/tag if (await _context.Clans.AnyAsync(c => c.Name == request.Name || c.Tag == request.Tag)) return null; // Debit await _bointsService.DebitAsync(userId, ClanCreationCost, BointsReason.Purchase); var clan = new Clan { Name = request.Name, Tag = request.Tag, Color = request.Color, LeaderId = userId }; _context.Clans.Add(clan); await _context.SaveChangesAsync(); // Add creator as member _context.ClanMembers.Add(new ClanMember { ClanId = clan.Id, UserId = userId }); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} created clan {ClanName} [{ClanTag}]", userId, clan.Name, clan.Tag); return await GetClanAsync(clan.Id); } public async Task InviteUserAsync(Guid inviterId, int clanId, Guid targetUserId) { var clan = await _context.Clans.Include(c => c.Members).FirstOrDefaultAsync(c => c.Id == clanId); if (clan == null || clan.LeaderId != inviterId) return ServiceResult.Fail("Only the clan leader can invite."); if (clan.Members.Count >= clan.MaxMembers) return ServiceResult.Fail("Clan is full."); if (await _context.ClanMembers.AnyAsync(m => m.UserId == targetUserId)) return ServiceResult.Fail("User is already in a clan."); if (!await _context.Users.AnyAsync(u => u.Id == targetUserId)) return ServiceResult.Fail("User not found."); var existing = await _context.ClanInvites .FirstOrDefaultAsync(i => i.ClanId == clanId && i.InvitedUserId == targetUserId && i.ExpiresAt > DateTime.UtcNow); if (existing != null) return ServiceResult.Fail("User already has a pending invite."); _context.ClanInvites.Add(new ClanInvite { ClanId = clanId, InvitedUserId = targetUserId, InvitedByUserId = inviterId }); await _context.SaveChangesAsync(); _logger.LogInformation("User {InviterId} invited {TargetId} to clan {ClanId}", inviterId, targetUserId, clanId); return ServiceResult.Ok(); } public async Task AcceptInviteAsync(Guid userId, int inviteId) { var invite = await _context.ClanInvites .Include(i => i.Clan).ThenInclude(c => c.Members) .FirstOrDefaultAsync(i => i.Id == inviteId && i.InvitedUserId == userId && !i.IsApplication && i.ExpiresAt > DateTime.UtcNow); if (invite == null) return ServiceResult.Fail("Invite not found or expired."); if (await _context.ClanMembers.AnyAsync(m => m.UserId == userId)) return ServiceResult.Fail("You are already in a clan."); if (invite.Clan.Members.Count >= invite.Clan.MaxMembers) return ServiceResult.Fail("Clan is full."); _context.ClanMembers.Add(new ClanMember { ClanId = invite.ClanId, UserId = userId }); var allInvites = await _context.ClanInvites.Where(i => i.InvitedUserId == userId).ToListAsync(); _context.ClanInvites.RemoveRange(allInvites); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} accepted invite to clan {ClanId}", userId, invite.ClanId); return ServiceResult.Ok(); } public async Task DeclineInviteAsync(Guid userId, int inviteId) { var invite = await _context.ClanInvites .FirstOrDefaultAsync(i => i.Id == inviteId && i.InvitedUserId == userId && !i.IsApplication); if (invite == null) return ServiceResult.Fail("Invite not found."); _context.ClanInvites.Remove(invite); await _context.SaveChangesAsync(); return ServiceResult.Ok(); } public async Task> GetPendingInvitesAsync(Guid userId) { return await _context.ClanInvites .Where(i => i.InvitedUserId == userId && !i.IsApplication && i.ExpiresAt > DateTime.UtcNow) .Include(i => i.Clan) .Include(i => i.InvitedByUser) .Select(i => new ClanInviteDto { Id = i.Id, ClanId = i.ClanId, ClanName = i.Clan.Name, ClanTag = i.Clan.Tag, ClanColor = i.Clan.Color, InvitedByUserName = i.InvitedByUser.UserName!, CreatedAt = i.CreatedAt, ExpiresAt = i.ExpiresAt }) .ToListAsync(); } public async Task> GetSentInvitesAsync(Guid leaderId, int clanId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return []; var invites = await _context.ClanInvites .Where(i => i.ClanId == clanId && !i.IsApplication && i.ExpiresAt > DateTime.UtcNow) .Include(i => i.InvitedUser) .Include(i => i.InvitedByUser) .ToListAsync(); var userIds = invites.Select(i => i.InvitedUserId).Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); return invites.Select(i => { displayInfoMap.TryGetValue(i.InvitedUserId, out var displayInfo); return new ClanInviteDto { Id = i.Id, ClanId = i.ClanId, ClanName = clan.Name, ClanTag = clan.Tag, ClanColor = clan.Color, InvitedByUserName = i.InvitedByUser.UserName!, InvitedUserName = i.InvitedUser.UserName, InvitedUserId = i.InvitedUserId, InvitedUserAvatarUrl = ApplicationUser.GetAvatarUrl(i.InvitedUser.UserName, i.InvitedUser.AvatarStorageIdentifier), InvitedUserRoleColor = displayInfo?.RoleColor, CreatedAt = i.CreatedAt, ExpiresAt = i.ExpiresAt }; }).ToList(); } public async Task RevokeInviteAsync(Guid leaderId, int clanId, int inviteId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can revoke invites."); var invite = await _context.ClanInvites.FirstOrDefaultAsync(i => i.Id == inviteId && i.ClanId == clanId); if (invite == null) return ServiceResult.Fail("Invite not found."); _context.ClanInvites.Remove(invite); await _context.SaveChangesAsync(); return ServiceResult.Ok(); } public async Task ApplyToClanAsync(Guid userId, int clanId) { if (await _context.ClanMembers.AnyAsync(m => m.UserId == userId)) return ServiceResult.Fail("You are already in a clan."); var clan = await _context.Clans.Include(c => c.Members).FirstOrDefaultAsync(c => c.Id == clanId); if (clan == null) return ServiceResult.Fail("Clan not found."); if (clan.Members.Count >= clan.MaxMembers) return ServiceResult.Fail("Clan is full."); if (await _context.ClanInvites.AnyAsync(i => i.ClanId == clanId && i.InvitedUserId == userId && i.ExpiresAt > DateTime.UtcNow)) return ServiceResult.Fail("You already have a pending application."); _context.ClanInvites.Add(new ClanInvite { ClanId = clanId, InvitedUserId = userId, InvitedByUserId = userId, IsApplication = true }); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} applied to clan {ClanId}", userId, clanId); return ServiceResult.Ok(); } public async Task AcceptApplicationAsync(Guid leaderId, int clanId, int applicationId) { var clan = await _context.Clans.Include(c => c.Members).FirstOrDefaultAsync(c => c.Id == clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can accept applications."); var application = await _context.ClanInvites .FirstOrDefaultAsync(i => i.Id == applicationId && i.ClanId == clanId && i.IsApplication && i.ExpiresAt > DateTime.UtcNow); if (application == null) return ServiceResult.Fail("Application not found or expired."); if (clan.Members.Count >= clan.MaxMembers) return ServiceResult.Fail("Clan is full."); if (await _context.ClanMembers.AnyAsync(m => m.UserId == application.InvitedUserId)) return ServiceResult.Fail("User is already in a clan."); _context.ClanMembers.Add(new ClanMember { ClanId = clanId, UserId = application.InvitedUserId }); var allInvites = await _context.ClanInvites.Where(i => i.InvitedUserId == application.InvitedUserId).ToListAsync(); _context.ClanInvites.RemoveRange(allInvites); await _context.SaveChangesAsync(); _logger.LogInformation("Leader {LeaderId} accepted application from {UserId} to clan {ClanId}", leaderId, application.InvitedUserId, clanId); return ServiceResult.Ok(); } public async Task RejectApplicationAsync(Guid leaderId, int clanId, int applicationId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can reject applications."); var application = await _context.ClanInvites .FirstOrDefaultAsync(i => i.Id == applicationId && i.ClanId == clanId && i.IsApplication); if (application == null) return ServiceResult.Fail("Application not found."); _context.ClanInvites.Remove(application); await _context.SaveChangesAsync(); return ServiceResult.Ok(); } public async Task> GetPendingApplicationsAsync(Guid leaderId, int clanId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return []; return await _context.ClanInvites .Where(i => i.ClanId == clanId && i.IsApplication && i.ExpiresAt > DateTime.UtcNow) .Include(i => i.InvitedUser) .Select(i => new ClanApplicationDto { Id = i.Id, ApplicantId = i.InvitedUserId, ApplicantUserName = i.InvitedUser.UserName!, ApplicantAvatarUrl = ApplicationUser.GetAvatarUrl(i.InvitedUser.UserName, i.InvitedUser.AvatarStorageIdentifier), CreatedAt = i.CreatedAt }) .ToListAsync(); } public async Task LeaveClanAsync(Guid userId) { var member = await _context.ClanMembers.Include(m => m.Clan).FirstOrDefaultAsync(m => m.UserId == userId); if (member == null) return ServiceResult.Fail("You are not in a clan."); if (member.Clan.LeaderId == userId) return ServiceResult.Fail("Leaders cannot leave. Transfer leadership or disband first."); _context.ClanMembers.Remove(member); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} left clan {ClanId}", userId, member.ClanId); return ServiceResult.Ok(); } public async Task KickMemberAsync(Guid leaderId, int clanId, Guid userId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can kick members."); if (leaderId == userId) return ServiceResult.Fail("You cannot kick yourself."); var member = await _context.ClanMembers.FirstOrDefaultAsync(m => m.ClanId == clanId && m.UserId == userId); if (member == null) return ServiceResult.Fail("User is not a member of this clan."); _context.ClanMembers.Remove(member); await _context.SaveChangesAsync(); _logger.LogInformation("Leader {LeaderId} kicked {UserId} from clan {ClanId}", leaderId, userId, clanId); return ServiceResult.Ok(); } public async Task GetClanAsync(int clanId) { var clan = await _context.Clans .Include(c => c.Leader) .Include(c => c.Members) .FirstOrDefaultAsync(c => c.Id == clanId); return clan == null ? null : MapToDto(clan); } public async Task GetClanByTagAsync(string tag) { var clan = await _context.Clans .Include(c => c.Leader) .Include(c => c.Members) .FirstOrDefaultAsync(c => c.Tag == tag); return clan == null ? null : MapToDto(clan); } public async Task> GetMembersAsync(int clanId) { var members = await _context.ClanMembers .Where(m => m.ClanId == clanId) .Include(m => m.User) .ToListAsync(); var userIds = members.Select(m => m.UserId).ToList(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); return members.Select(m => { displayInfoMap.TryGetValue(m.UserId, out var displayInfo); return new ClanMemberDto { UserId = m.UserId, UserName = m.User.UserName!, AvatarUrl = ApplicationUser.GetAvatarUrl(m.User.UserName, m.User.AvatarStorageIdentifier), RoleColor = displayInfo?.RoleColor, JoinedAt = m.JoinedAt, ForcedDisplayName = (displayInfo?.BointsEnabled ?? true) ? m.User.ActiveForcedDisplayName : null }; }).ToList(); } public async Task GetUserClanAsync(Guid userId) { var member = await _context.ClanMembers .FirstOrDefaultAsync(m => m.UserId == userId); if (member == null) return null; return await GetClanAsync(member.ClanId); } public async Task UpdateClanAsync(Guid leaderId, int clanId, UpdateClanRequest request) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can update settings."); if (request.Tag != null) { if (await _context.Clans.AnyAsync(c => c.Tag == request.Tag && c.Id != clanId)) return ServiceResult.Fail("Tag is already taken by another clan."); clan.Tag = request.Tag; } if (request.Color != null) clan.Color = request.Color; if (request.TaxRate.HasValue) clan.TaxRate = Math.Clamp(request.TaxRate.Value, 0, 20); if (request.UseBadgeAsTag.HasValue) clan.UseBadgeAsTag = request.UseBadgeAsTag.Value && clan.BadgeStorageIdentifier != null; await _context.SaveChangesAsync(); return ServiceResult.Ok(); } public async Task ExpandSlotsAsync(Guid leaderId, int clanId) { var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can expand slots."); var nextTier = ExpansionCosts.Keys.Where(k => k > clan.MaxMembers).OrderBy(k => k).FirstOrDefault(); if (nextTier == 0) return ServiceResult.Fail("Already at maximum member slots."); var cost = ExpansionCosts[nextTier]; if (clan.Treasury < cost) return ServiceResult.Fail($"Insufficient treasury. Need {cost} boints, have {clan.Treasury}."); clan.Treasury -= cost; clan.MaxMembers = nextTier; await _context.SaveChangesAsync(); _logger.LogInformation("Clan {ClanId} expanded to {MaxMembers} slots (cost: {Cost})", clanId, nextTier, cost); return ServiceResult.Ok(); } public async Task DepositToTreasuryAsync(Guid userId, int amount) { if (amount <= 0) return ServiceResult.Fail("Amount must be positive."); var member = await _context.ClanMembers.Include(m => m.Clan).FirstOrDefaultAsync(m => m.UserId == userId); if (member == null) return ServiceResult.Fail("You are not in a clan."); var user = await _context.Users.FindAsync(userId); if (user == null || user.Boints < amount) return ServiceResult.Fail($"Insufficient boints. You have {user?.Boints ?? 0}."); user.Boints -= amount; member.Clan.Treasury += amount; _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = -amount, Reason = BointsReason.ClanTreasuryDeposit }); await _context.SaveChangesAsync(); return ServiceResult.Ok(); } public async Task WithdrawFromTreasuryAsync(Guid leaderId, int clanId, Guid targetUserId, int amount) { if (amount <= 0) return ServiceResult.Fail("Amount must be positive."); var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can withdraw."); if (clan.Treasury < amount) return ServiceResult.Fail("Insufficient treasury balance."); var target = await _context.Users.FindAsync(targetUserId); if (target == null) return ServiceResult.Fail("User not found."); // Target must be a member of this clan var isMember = await _context.ClanMembers.AnyAsync(m => m.ClanId == clanId && m.UserId == targetUserId); if (!isMember) return ServiceResult.Fail("Target user is not a member of this clan."); clan.Treasury -= amount; target.Boints += amount; _context.BointsLedger.Add(new BointsLedger { UserId = targetUserId, Amount = amount, Reason = BointsReason.ClanTreasuryWithdraw, SourceUserId = leaderId }); await _context.SaveChangesAsync(); _logger.LogInformation("Leader {LeaderId} withdrew {Amount} from clan {ClanId} treasury to user {TargetId}", leaderId, amount, clanId, targetUserId); return ServiceResult.Ok(); } public async Task UploadBadgeAsync(Guid leaderId, int clanId, Stream imageStream, string fileName, CropRect? crop = null) { const int BadgeSize = 128; var clan = await _context.Clans.FindAsync(clanId); if (clan == null) return ServiceResult.Fail("Clan not found."); if (clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can upload the badge."); // Delete old badge if exists if (!string.IsNullOrEmpty(clan.BadgeStorageIdentifier)) await _fileStorage.DeleteFileAsync(clan.BadgeStorageIdentifier); // Crop + resize to 128x128 WebP via FFmpeg using var memoryStream = new MemoryStream(); await imageStream.CopyToAsync(memoryStream); memoryStream.Position = 0; var tempInput = Path.GetTempFileName(); var tempOutput = Path.ChangeExtension(Path.GetTempFileName(), ".webp"); try { await using (var fs = System.IO.File.Create(tempInput)) await memoryStream.CopyToAsync(fs); var scaleFilter = crop != null ? $"crop={crop.Size}:{crop.Size}:{crop.X}:{crop.Y},scale={BadgeSize}:{BadgeSize}" : $"scale={BadgeSize}:{BadgeSize}:force_original_aspect_ratio=increase,crop={BadgeSize}:{BadgeSize}"; await FFMpegCore.FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .WithCustomArgument($"-vf \"{scaleFilter}\"") .WithCustomArgument("-quality 85") .ForceFormat("webp")) .ProcessAsynchronously(); await using var outputStream = System.IO.File.OpenRead(tempOutput); var result = await _fileStorage.SaveFileAsync(outputStream, "badge.webp", new FileStorageOptions { IsPublic = true, UploaderId = leaderId }); if (!result.Success) return ServiceResult.Fail(result.ErrorMessage ?? "Failed to save badge."); clan.BadgeStorageIdentifier = result.FileIdentifier; await _context.SaveChangesAsync(); _logger.LogInformation("Clan {ClanId} uploaded badge", clanId); return ServiceResult.Ok(); } finally { if (System.IO.File.Exists(tempInput)) System.IO.File.Delete(tempInput); if (System.IO.File.Exists(tempOutput)) System.IO.File.Delete(tempOutput); } } public async Task GetBadgeFileAsync(int clanId) { var clan = await _context.Clans.FindAsync(clanId); if (clan?.BadgeStorageIdentifier == null) return null; return await _fileStorage.GetFileAsync(clan.BadgeStorageIdentifier); } public async Task PurchaseClanForumAsync(Guid leaderId, int clanId) { const int forumCost = 500; var clan = await _context.Clans.FindAsync(clanId); if (clan == null || clan.LeaderId != leaderId) return ServiceResult.Fail("Only the clan leader can purchase a forum."); if (clan.Treasury < forumCost) return ServiceResult.Fail($"Insufficient treasury. Need {forumCost}, have {clan.Treasury}."); if (await _context.ClanForumCategories.AnyAsync(c => c.ClanId == clanId)) return ServiceResult.Fail("Clan already has a forum."); var slug = $"clan-{clanId}"; var category = new Models.Forum.ForumCategory { Name = $"{clan.Name} Clan Forum", Slug = slug, Description = $"Private forum for [{clan.Tag}] {clan.Name}", DisplayOrder = 999, Color = clan.Color }; _context.ForumCategories.Add(category); await _context.SaveChangesAsync(); _context.ClanForumCategories.Add(new Models.ClanForumCategory { ClanId = clanId, ForumCategoryId = category.Id }); clan.Treasury -= forumCost; await _context.SaveChangesAsync(); _logger.LogInformation("Clan {ClanId} purchased forum category {Slug}", clanId, slug); return ServiceResult.Ok(); } public async Task<(IEnumerable Items, int TotalCount)> GetLeaderboardAsync(string sort, int page, int pageSize) { var query = _context.Clans.AsQueryable(); var totalCount = await query.CountAsync(); var ordered = sort switch { "members" => query.OrderByDescending(c => c.Members.Count), "treasury" => query.OrderByDescending(c => c.Treasury), _ => query.OrderByDescending(c => c.Members.Count) }; var items = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) .Select(c => new ClanLeaderboardDto { Id = c.Id, Name = c.Name, Tag = c.Tag, Color = c.Color, Treasury = c.Treasury, MemberCount = c.Members.Count }) .ToListAsync(); return (items, totalCount); } private static ClanDto MapToDto(Clan clan) => new() { Id = clan.Id, Name = clan.Name, Tag = clan.Tag, Color = clan.Color, LeaderId = clan.LeaderId, LeaderName = clan.Leader?.UserName ?? "Unknown", Treasury = clan.Treasury, MemberCount = clan.Members.Count, MaxMembers = clan.MaxMembers, TaxRate = clan.TaxRate, CreatedAt = clan.CreatedAt, BadgeUrl = clan.BadgeStorageIdentifier != null ? $"/api/clans/{clan.Id}/badge?v={clan.BadgeStorageIdentifier[..8]}" : null, UseBadgeAsTag = clan.UseBadgeAsTag }; } }