using System.Collections.Concurrent; using System.Text.Json; using Hokm.Server.Data; using Hokm.Server.Game; using Hokm.Server.Hubs; using Hokm.Server.Profiles; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace Hokm.Server.Social; public class SocialService { private readonly AppDbContext _db; private readonly GameManager _mgr; private readonly IHubContext _hub; /// Max outgoing friend requests allowed per user within a rolling hour. public const int FriendReqLimit = 10; private static readonly TimeSpan FriendReqWindow = TimeSpan.FromHours(1); // Process-wide log of each user's recent outgoing-request timestamps (resets on restart). private static readonly ConcurrentDictionary> _reqLog = new(); public SocialService(AppDbContext db, GameManager mgr, IHubContext hub) { _db = db; _mgr = mgr; _hub = hub; } /// /// Records an outgoing friend-request attempt against the rolling-hour cap. /// Returns false (with the minutes until a slot frees) when over the limit. /// private static bool TryRecordRequest(string uid, out int retryMins) { retryMins = 0; var now = DateTime.UtcNow; var list = _reqLog.GetOrAdd(uid, _ => new List()); lock (list) { list.RemoveAll(t => now - t >= FriendReqWindow); if (list.Count >= FriendReqLimit) { retryMins = Math.Max(1, (int)Math.Ceiling((FriendReqWindow - (now - list[0])).TotalMinutes)); return false; } list.Add(now); return true; } } private async Task FriendDtoFor(string userId) { var row = await _db.Profiles.FindAsync(userId); var p = row != null ? JsonSerializer.Deserialize(row.Json, JsonOpts.Default) : null; return new FriendDto { Id = userId, Username = p?.Username ?? userId, DisplayName = p?.DisplayName ?? userId, Avatar = p?.Avatar ?? "a-fox", Level = p?.Level ?? 1, Rating = p?.Rating ?? 1000, Status = _mgr.IsOnline(userId) ? "online" : "offline", }; } /* ----------------------------- friends ----------------------------- */ public async Task> ListFriends(string uid) { var ids = await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync(); var list = new List(); foreach (var id in ids) list.Add(await FriendDtoFor(id)); return list; } public async Task> ListRequests(string uid) { var reqs = await _db.FriendRequests.Where(r => r.ToUserId == uid).ToListAsync(); var list = new List(); foreach (var r in reqs) list.Add(new FriendRequestDto { Id = r.Id.ToString(), From = await FriendDtoFor(r.FromUserId), CreatedAt = new DateTimeOffset(r.CreatedAt).ToUnixTimeMilliseconds() }); return list; } public async Task<(bool ok, string messageFa, string messageEn)> AddFriend(string uid, string query) { var digits = new string(query.Where(char.IsDigit).ToArray()); var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim()); return await AddFriendById(uid, targetId); } /// Send a friend request to a concrete user id (rate-limited to 10/hour). public async Task<(bool ok, string messageFa, string messageEn)> AddFriendById(string uid, string targetId) { targetId = targetId.Trim(); var target = await _db.Profiles.FindAsync(targetId); if (target == null || targetId == uid) return (false, "کاربر پیدا نشد", "User not found"); if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId)) return (false, "از قبل دوست هستید", "Already friends"); // Already pending → idempotent success, doesn't consume the hourly quota. if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId)) return (true, "درخواست دوستی ارسال شد", "Friend request sent"); if (!TryRecordRequest(uid, out var mins)) return (false, $"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی می‌توانید بفرستید. {mins} دقیقه دیگر تلاش کنید.", $"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min."); _db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow }); await _db.SaveChangesAsync(); await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid)); return (true, "درخواست دوستی ارسال شد", "Friend request sent"); } /* --------------------------- discovery ----------------------------- */ private PlayerSummaryDto ToSummary(ProfileDto p, HashSet friendIds, HashSet sentIds) => new() { Id = p.Id, DisplayName = p.DisplayName, Avatar = p.Avatar, AvatarImage = p.AvatarImage, Level = p.Level, Rating = p.Rating, Status = _mgr.IsOnline(p.Id) ? "online" : "offline", Gender = p.Gender ?? "", Title = p.Title, IsFriend = friendIds.Contains(p.Id), RequestSent = sentIds.Contains(p.Id), }; /// Search players by display name (case-insensitive contains). public async Task> SearchPlayers(string uid, string query) { query = (query ?? "").Trim(); if (query.Length == 0) return new(); var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet(); var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet(); var rows = await _db.Profiles.Where(p => p.Id != uid).ToListAsync(); var list = new List(); foreach (var row in rows) { var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); if (p?.DisplayName == null) continue; if (!p.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) continue; list.Add(ToSummary(p, friendIds, sentIds)); if (list.Count >= 20) break; } return list; } /// Suggested players to befriend (online-first, excludes existing friends). public async Task> Suggested(string uid) { var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet(); var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet(); var rows = await _db.Profiles.Where(p => p.Id != uid).Take(80).ToListAsync(); var list = new List(); foreach (var row in rows) { var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); if (p == null || friendIds.Contains(p.Id)) continue; list.Add(ToSummary(p, friendIds, sentIds)); } // Online players first, then by rating. return list .OrderByDescending(x => x.Status == "online") .ThenByDescending(x => x.Rating) .Take(12) .ToList(); } /// Another player's public profile + achievement board (no private fields). public async Task GetPublicProfile(string uid, string targetId) { targetId = targetId.Trim(); var row = await _db.Profiles.FindAsync(targetId); if (row == null) return null; var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); if (p == null) return null; var isFriend = await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId); var requestSent = await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId); var isYou = targetId == uid; // Social links honor the owner's privacy: public → everyone, friends → only // friends (and the owner), hidden → nobody. var vis = string.IsNullOrEmpty(p.SocialsVisibility) ? "public" : p.SocialsVisibility; var canSeeSocials = isYou || vis == "public" || (vis == "friends" && isFriend); return new PublicProfileDto { Id = p.Id, DisplayName = p.DisplayName, Avatar = p.Avatar, AvatarImage = p.AvatarImage, Plan = p.Plan, Title = p.Title, Level = p.Level, Rating = p.Rating, Stats = p.Stats, Achievements = p.Achievements, Unlocked = p.Unlocked, CreatedAt = p.CreatedAt, Gender = p.Gender ?? "", Socials = canSeeSocials ? p.Socials : null, IsFriend = isFriend, IsYou = isYou, RequestSent = requestSent, }; } public async Task Accept(string uid, long requestId) { var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid); if (req == null) return; _db.Friends.Add(new FriendEdgeRow { UserId = uid, FriendId = req.FromUserId }); _db.Friends.Add(new FriendEdgeRow { UserId = req.FromUserId, FriendId = uid }); _db.FriendRequests.Remove(req); await _db.SaveChangesAsync(); await _hub.Clients.User(req.FromUserId).SendAsync("social", "friend-added"); } public async Task Decline(string uid, long requestId) { var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid); if (req != null) { _db.FriendRequests.Remove(req); await _db.SaveChangesAsync(); } } public async Task Remove(string uid, string friendId) { var edges = await _db.Friends.Where(f => (f.UserId == uid && f.FriendId == friendId) || (f.UserId == friendId && f.FriendId == uid)).ToListAsync(); _db.Friends.RemoveRange(edges); await _db.SaveChangesAsync(); await _hub.Clients.User(friendId).SendAsync("social", "friend-removed"); } /* ------------------------------- chat ------------------------------ */ public async Task> Conversations(string uid) { var msgs = await _db.Messages.Where(m => m.UserId == uid || m.PeerId == uid).ToListAsync(); var byPartner = msgs.GroupBy(m => m.UserId == uid ? m.PeerId : m.UserId); var convs = new List(); foreach (var g in byPartner) { var last = g.OrderByDescending(m => m.CreatedAt).First(); convs.Add(new ConversationDto { Friend = await FriendDtoFor(g.Key), LastMessage = ToDto(last, uid), Unread = g.Count(m => m.PeerId == uid && !m.ReadByPeer), }); } return convs.OrderByDescending(c => c.LastMessage?.Ts ?? 0).ToList(); } public async Task> Messages(string uid, string peerId) { var msgs = await _db.Messages .Where(m => (m.UserId == uid && m.PeerId == peerId) || (m.UserId == peerId && m.PeerId == uid)) .OrderBy(m => m.CreatedAt).ToListAsync(); var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList(); if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); } return msgs.Select(m => ToDto(m, uid)).ToList(); } public async Task Send(string uid, string peerId, string text) { var m = new MessageRow { UserId = uid, PeerId = peerId, Text = text.Trim(), CreatedAt = DateTime.UtcNow }; _db.Messages.Add(m); await _db.SaveChangesAsync(); await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid }); return ToDto(m, uid); } private static ChatMessageDto ToDto(MessageRow m, string uid) => new() { Id = m.Id.ToString(), FromMe = m.UserId == uid, Text = m.Text, Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(), }; }