using System.Collections.Concurrent; using LANCommander.Server.Data.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; namespace LANCommander.Server.Services { public sealed class ChatService( ILogger logger, SettingsProvider settingsProvider, IFusionCache cache, ChatMessageService chatMessageService, ChatThreadService chatThreadService, ChatThreadReadStatusService chatThreadReadStatusService, UserService userService) : BaseService(logger, settingsProvider) { private readonly ConcurrentDictionary _locks = new(); private readonly int _maxCachedMessages = 200; private static string ThreadCacheKey(Guid threadId) => $"Chat/Thread/{threadId}"; private static string UserThreadCacheKey(Guid userId) => $"Chat/User/{userId}/Threads"; public async Task StartThreadAsync() { var thread = await chatThreadService.AddAsync(new ChatThread()); return thread; } public async Task AddParticipantAsync(Guid threadId, Guid userId) { var thread = await chatThreadService.Include(t => t.Participants).GetAsync(threadId); var user = await userService.GetAsync(userId); if (thread != null && (thread.Participants == null || thread.Participants.All(p => p.Id != user.Id))) { logger.LogInformation("Adding participant {UserId} to thread {ThreadId}", user.UserName, thread.Id); if (thread.Participants == null) thread.Participants = new List(); thread.Participants.Add(user); await chatThreadService.UpdateAsync(thread); } } public async Task SendMessageAsync(Guid threadId, string content) { var message = await chatMessageService.AddAsync(new ChatMessage { ThreadId = threadId, Content = content, }); var cacheKey = ThreadCacheKey(threadId); var gate = _locks.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(); try { var current = await cache.TryGetAsync>(cacheKey); var messages = current.HasValue ? new List(current.Value) : new List(capacity: _maxCachedMessages); messages.RemoveAll(m => m.Id == message.Id); messages.Add(message); if (messages.Count > _maxCachedMessages) messages.RemoveRange(0, messages.Count - _maxCachedMessages); await cache.SetAsync(cacheKey, messages); } finally { gate.Release(); } return message; } public async Task> GetMessagesAsync(Guid threadId) { var cacheKey = ThreadCacheKey(threadId); var messages = await cache.GetOrSetAsync(cacheKey, async _ => { var dbMessages = await chatMessageService.Query(q => { return q .OrderByDescending(m => m.CreatedOn) .Take(_maxCachedMessages); }).GetAsync(); return dbMessages.Reverse().ToList(); }); return messages; } public async Task GetThreadAsync(Guid threadId) => await chatThreadService.Include(t => t.Participants).GetAsync(threadId); public async Task> GetThreadsAsync(Guid userId) { var cacheKey = UserThreadCacheKey(userId); var threads = await cache.GetOrSetAsync(cacheKey, async _ => { var user = await userService.GetAsync(userId); if (user == null) return new List(); var dbThreads = await chatThreadService.Query(q => { return q .AsNoTracking() .AsSplitQuery() .Where(t => t.Participants.Any(p => p.Id == userId)) .Include(t => t.Participants); }).GetAsync(); return dbThreads.OrderByDescending(t => t.CreatedOn).ToList(); }); return threads; } /// /// Gets a list of users stripped down to only IDs and usernames /// public async Task> GetUsersAsync() { var users = await userService.AsNoTracking().GetAsync(); return users.Select(u => new User { Id = u.Id, UserName = u.UserName, Alias = u.Alias, }).ToList(); } public async Task UpdateReadStatus(Guid threadId, Guid userId) { await chatThreadReadStatusService.UpdateReadStatus(threadId, userId); } public async Task GetUnreadMessageCountAsync(Guid threadId, Guid userId) { var lastRead = await chatThreadReadStatusService.GetLastReadAsync(threadId, userId); if (lastRead == null) return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastRead.GetValueOrDefault()); return 0; } } }