using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; using LightlessSyncServer.Models; using Microsoft.Extensions.Logging; namespace LightlessSyncServer.Services; public sealed class ChatChannelService { private readonly ILogger _logger; private readonly Dictionary _zoneDefinitions; private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); private const int MaxMessagesPerChannel = 200; public ChatChannelService(ILogger logger) { _logger = logger; _zoneDefinitions = ChatZoneDefinitions.Defaults .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); } public IReadOnlyList GetZoneChannelInfos() => _zoneDefinitions.Values .Select(definition => new ZoneChatChannelInfoDto( definition.Descriptor, definition.DisplayName, definition.TerritoryNames)) .ToArray(); public bool TryResolveZone(string? key, out ZoneChannelDefinition definition) { definition = default; if (string.IsNullOrWhiteSpace(key)) return false; return _zoneDefinitions.TryGetValue(key, out definition); } public ChatPresenceEntry? UpdateZonePresence( string userUid, ZoneChannelDefinition definition, ushort worldId, ushort territoryId, string? hashedCid, bool isLightfinder, bool isActive) { if (worldId == 0 || !WorldRegistry.IsKnownWorld(worldId)) { _logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: unknown world {WorldId}", userUid, definition.Key, worldId); return null; } if (!definition.TerritoryIds.Contains(territoryId)) { _logger.LogWarning("Rejected zone chat presence for {User} in {Zone}: invalid territory {TerritoryId}", userUid, definition.Key, territoryId); return null; } var descriptor = definition.Descriptor with { WorldId = worldId, ZoneId = territoryId }; var participant = new ChatParticipantInfo( Token: string.Empty, UserUid: userUid, User: null, HashedCid: isLightfinder ? hashedCid : null, IsLightfinder: isLightfinder); return UpdatePresence( userUid, descriptor, definition.DisplayName, participant, isActive, replaceExistingOfSameType: true); } public ChatPresenceEntry? UpdateGroupPresence( string userUid, string groupId, string displayName, UserData user, string? hashedCid, bool isActive) { var descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Group, WorldId = 0, ZoneId = 0, CustomKey = groupId }; var participant = new ChatParticipantInfo( Token: string.Empty, UserUid: userUid, User: user, HashedCid: hashedCid, IsLightfinder: !string.IsNullOrEmpty(hashedCid)); return UpdatePresence( userUid, descriptor, displayName, participant, isActive, replaceExistingOfSameType: false); } public bool TryGetPresence(string userUid, ChatChannelDescriptor channel, out ChatPresenceEntry presence) { var key = ChannelKey.FromDescriptor(channel); lock (_syncRoot) { if (_presenceByUser.TryGetValue(userUid, out var entries) && entries.TryGetValue(key, out presence)) { return true; } } presence = default; return false; } public IReadOnlyCollection GetMembers(ChatChannelDescriptor channel) { var key = ChannelKey.FromDescriptor(channel); lock (_syncRoot) { if (_membersByChannel.TryGetValue(key, out var members)) { return members.ToArray(); } } return Array.Empty(); } public string RecordMessage(ChatChannelDescriptor channel, ChatParticipantInfo participant, string message, DateTime sentAtUtc) { var key = ChannelKey.FromDescriptor(channel); var messageId = Guid.NewGuid().ToString("N"); var entry = new ChatMessageLogEntry( messageId, channel, sentAtUtc, participant.Token, participant.UserUid, participant.User, participant.IsLightfinder, participant.HashedCid, message); lock (_syncRoot) { if (!_messagesByChannel.TryGetValue(key, out var list)) { list = new LinkedList(); _messagesByChannel[key] = list; } var node = list.AddLast(entry); _messageIndex[messageId] = (key, node); while (list.Count > MaxMessagesPerChannel) { var removedNode = list.First; if (removedNode is null) { break; } list.RemoveFirst(); _messageIndex.Remove(removedNode.Value.MessageId); } } return messageId; } public bool TryGetMessage(string messageId, out ChatMessageLogEntry entry) { lock (_syncRoot) { if (_messageIndex.TryGetValue(messageId, out var located)) { entry = located.Node.Value; return true; } } entry = default; return false; } public IReadOnlyList GetRecentMessages(ChatChannelDescriptor descriptor, int maxCount) { lock (_syncRoot) { var key = ChannelKey.FromDescriptor(descriptor); if (!_messagesByChannel.TryGetValue(key, out var list) || list.Count == 0) { return Array.Empty(); } var take = Math.Min(maxCount, list.Count); var result = new ChatMessageLogEntry[take]; var node = list.Last; for (var i = take - 1; i >= 0 && node is not null; i--) { result[i] = node.Value; node = node.Previous; } return result; } } public bool RemovePresence(string userUid, ChatChannelDescriptor? channel = null) { ArgumentException.ThrowIfNullOrEmpty(userUid); lock (_syncRoot) { if (!_presenceByUser.TryGetValue(userUid, out var entries)) { return false; } if (channel is null) { foreach (var existing in entries.Keys.ToList()) { RemovePresenceInternal(userUid, entries, existing); } _presenceByUser.Remove(userUid); return true; } var key = ChannelKey.FromDescriptor(channel.Value); var removed = RemovePresenceInternal(userUid, entries, key); if (entries.Count == 0) { _presenceByUser.Remove(userUid); } return removed; } } public bool TryResolveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant) { var key = ChannelKey.FromDescriptor(channel); lock (_syncRoot) { if (_participantsByChannel.TryGetValue(key, out var participants) && participants.TryGetValue(token, out participant)) { return true; } } participant = default; return false; } public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder) { ArgumentException.ThrowIfNullOrEmpty(userUid); lock (_syncRoot) { if (!_presenceByUser.TryGetValue(userUid, out var entries) || entries.Count == 0) { return; } foreach (var (key, existing) in entries.ToArray()) { var updatedParticipant = existing.Participant with { HashedCid = isLightfinder ? hashedCid : null, IsLightfinder = isLightfinder }; var updatedEntry = existing with { Participant = updatedParticipant, UpdatedAt = DateTime.UtcNow }; entries[key] = updatedEntry; if (_participantsByChannel.TryGetValue(key, out var participants)) { participants[updatedParticipant.Token] = updatedParticipant; } } } } private ChatPresenceEntry? UpdatePresence( string userUid, ChatChannelDescriptor descriptor, string displayName, ChatParticipantInfo participant, bool isActive, bool replaceExistingOfSameType) { ArgumentException.ThrowIfNullOrEmpty(userUid); var normalizedDescriptor = descriptor.WithNormalizedCustomKey(); var key = ChannelKey.FromDescriptor(normalizedDescriptor); lock (_syncRoot) { if (!_presenceByUser.TryGetValue(userUid, out var entries)) { if (!isActive) return null; entries = new Dictionary(); _presenceByUser[userUid] = entries; } string? reusableToken = null; if (entries.TryGetValue(key, out var existing)) { reusableToken = existing.Participant.Token; RemovePresenceInternal(userUid, entries, key); } if (replaceExistingOfSameType) { foreach (var candidate in entries.Keys.Where(k => k.Type == key.Type).ToList()) { if (entries.TryGetValue(candidate, out var entry)) { reusableToken ??= entry.Participant.Token; } RemovePresenceInternal(userUid, entries, candidate); } if (!isActive) { if (entries.Count == 0) { _presenceByUser.Remove(userUid); } _logger.LogDebug("Chat presence cleared for {User} ({Type})", userUid, normalizedDescriptor.Type); return null; } } else if (!isActive) { var removed = RemovePresenceInternal(userUid, entries, key); if (removed) { _logger.LogDebug("Chat presence removed for {User} from {Channel}", userUid, Describe(key)); } if (entries.Count == 0) { _presenceByUser.Remove(userUid); } return null; } var token = !string.IsNullOrEmpty(participant.Token) ? participant.Token : reusableToken ?? GenerateToken(); var finalParticipant = participant with { Token = token }; var entryToStore = new ChatPresenceEntry( normalizedDescriptor, key, displayName, finalParticipant, DateTime.UtcNow); entries[key] = entryToStore; if (!_membersByChannel.TryGetValue(key, out var members)) { members = new HashSet(StringComparer.Ordinal); _membersByChannel[key] = members; } members.Add(userUid); if (!_participantsByChannel.TryGetValue(key, out var participantsByToken)) { participantsByToken = new Dictionary(StringComparer.Ordinal); _participantsByChannel[key] = participantsByToken; } participantsByToken[token] = finalParticipant; _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); return entryToStore; } } private bool RemovePresenceInternal(string userUid, Dictionary entries, ChannelKey key) { if (!entries.TryGetValue(key, out var existing)) { return false; } entries.Remove(key); if (_membersByChannel.TryGetValue(key, out var members)) { members.Remove(userUid); if (members.Count == 0) { _membersByChannel.Remove(key); // Preserve message history even when a channel becomes empty so moderation can still resolve reports. } } if (_participantsByChannel.TryGetValue(key, out var participants)) { participants.Remove(existing.Participant.Token); if (participants.Count == 0) { _participantsByChannel.Remove(key); } } return true; } private static string GenerateToken() { Span buffer = stackalloc byte[8]; RandomNumberGenerator.Fill(buffer); return Convert.ToHexString(buffer); } private static string Describe(ChannelKey key) => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; }