using System.Collections.Concurrent; using LANCommander.SDK.Models; using LANCommander.Server.Services.Extensions; using LANCommander.Server.Services.Mappers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; using ChatMessage = LANCommander.Server.Data.Models.ChatMessage; using ChatThread = LANCommander.Server.Data.Models.ChatThread; using User = LANCommander.Server.Data.Models.User; namespace LANCommander.Server.Services { public sealed class ChatService( ILogger logger, SettingsProvider settingsProvider, IFusionCache cache, SdkMapper sdkMapper, 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); // Invalidate the user's cached thread list so it includes the new thread await cache.RemoveAsync(UserThreadCacheKey(userId)); } } 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.GetChatThreadAsync(threadId); if (current != null) await current.MessageReceivedAsync(sdkMapper.ToSdk(message)); } finally { gate.Release(); } return message; } public async Task> GetMessagesAsync(Guid threadId, int? count = 10, Guid? cursor = null) { var pageSize = Math.Max(1, count.GetValueOrDefault(10)); DateTime? createdBefore = null; // If cursor is provided, use it to find the boundary for loading older messages if (cursor.HasValue && cursor.Value != Guid.Empty) { var cursorMessage = await chatMessageService.GetAsync(cursor.Value); // Validate cursor message exists and belongs to this thread if (cursorMessage != null && cursorMessage.ThreadId == threadId) { createdBefore = cursorMessage.CreatedOn; } else { // Invalid cursor - return empty result logger.LogWarning("Invalid cursor message {CursorId} for thread {ThreadId}", cursor.Value, threadId); return new InfiniteResponse { Items = [], HasMore = false, }; } } // Load messages older than the cursor (or newest messages if no cursor) var messages = await chatMessageService.Query(q => { var query = q .Include(m => m.CreatedBy) .Where(m => m.ThreadId == threadId); // Apply cursor filter if provided if (createdBefore.HasValue) { query = query.Where(m => m.CreatedOn < createdBefore.Value); } return query .OrderByDescending(m => m.CreatedOn) .Take(pageSize); }).GetAsync(); var messageList = messages.ToList(); bool hasMore = false; // Check if there are more older messages beyond what we just loaded if (messageList.Count > 0) { // Use the oldest message in the batch as the boundary for checking if more exist var oldestMessage = messageList[messageList.Count - 1]; hasMore = await chatMessageService.Query(q => { return q .Where(m => m.ThreadId == threadId) .Where(m => m.CreatedOn < oldestMessage.CreatedOn); }).AnyAsync(); } else if (!createdBefore.HasValue) { // No cursor and no messages returned - check if any messages exist at all hasMore = await chatMessageService.Query(q => { return q.Where(m => m.ThreadId == threadId); }).AnyAsync(); } // If we had a cursor but got no results, there are no more older messages return new InfiniteResponse { Items = messageList.Select(sdkMapper.ToSdk), HasMore = hasMore, }; } public async Task GetThreadAsync(Guid threadId) => await chatThreadService .Include(t => t.Messages) .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) .Include(t => t.Messages); }).GetAsync(); return dbThreads .OrderByDescending(t => t.Messages != null && t.Messages.Count > 0 ? t.Messages.Max(m => m.CreatedOn) : 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 lastReadMessageId = await chatThreadReadStatusService.GetLastReadMessageIdAsync(threadId, userId); return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastReadMessageId); } } }