using EchoHub.Client.Config; using EchoHub.Client.UI.Dialogs; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Serilog; namespace EchoHub.Client.Services; /// /// Result of a successful connection, returned to AppOrchestrator for UI updates. /// internal record ConnectResult( LoginResponse Login, List Channels, List DefaultHistory); /// /// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking. /// Fires events so AppOrchestrator can update the UI without managing connection internals. /// internal sealed class ConnectionManager : IAsyncDisposable { private EchoHubConnection? _connection; private ApiClient? _apiClient; private readonly ClientEncryptionService _encryption = new(); private readonly HashSet _joinedChannels = []; // ── Properties ──────────────────────────────────────────────────────── public bool IsConnected => _connection?.IsConnected == true; public bool IsAuthenticated => _apiClient is not null; public ApiClient? Api => _apiClient; // ── Events (forwarded from SignalR) ─────────────────────────────────── public event Action? MessageReceived; public event Action? UserJoined; public event Action? UserLeft; public event Action? UserStatusChanged; public event Action? UserKicked; public event Action? UserBanned; public event Action? ForceDisconnected; public event Action? MessageDeleted; public event Action? ChannelNuked; public event Action? ChannelUpdated; public event Action? Error; public event Action? ConnectionStatusChanged; public event Action? Reconnected; // ── Connect ─────────────────────────────────────────────────────────── /// /// Full connection flow: authenticate → encryption → SignalR → join default channel. /// Calls with progress messages for UI updates. /// Throws on auth failure (caller handles saved-session expiry, etc.). /// public async Task ConnectAsync(ConnectDialogResult info, Action onStatus) { _apiClient?.Dispose(); _apiClient = new ApiClient(info.ServerUrl); try { onStatus("Authenticating..."); LoginResponse loginResponse; if (info.SavedRefreshToken is not null) { loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken); Log.Information("Authenticated via saved session for {User}", loginResponse.Username); } else if (info.IsRegister) { loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password); } else { loginResponse = await _apiClient.LoginAsync(info.Username, info.Password); } // Auto-persist rotated refresh tokens for Remember Me _apiClient.OnTokensRefreshed += HandleTokensRefreshed; // E2E encryption key onStatus("Fetching encryption key..."); try { var encryptionKey = await _apiClient.GetEncryptionKeyAsync(); _encryption.SetKey(encryptionKey); Log.Information("E2E encryption key established"); } catch (Exception ex) { Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted"); } onStatus("Authenticated, connecting..."); if (_connection is not null) await _connection.DisposeAsync(); _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); WireConnectionEvents(_connection); await _connection.ConnectAsync(); var channels = await _apiClient.GetChannelsAsync(); onStatus("Connected"); // Join default channel + fetch history _joinedChannels.Clear(); _joinedChannels.Add(HubConstants.DefaultChannel); await _connection.JoinChannelAsync(HubConstants.DefaultChannel); List history = []; try { history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); } catch { // History might not be available } return new ConnectResult(loginResponse, channels, history); } catch { if (_connection is not null) { await _connection.DisposeAsync(); _connection = null; } _apiClient.Dispose(); _apiClient = null; throw; } } // ── Cleanup ─────────────────────────────────────────────────────────── /// /// Disconnect and dispose connection + API client, clear channel tracking. /// public async Task CleanupAsync() { if (_connection is not null) { await _connection.DisconnectAsync(); await _connection.DisposeAsync(); _connection = null; } _apiClient?.Dispose(); _apiClient = null; _joinedChannels.Clear(); } /// /// Revoke refresh token on the server. Call afterwards. /// public async Task LogoutAsync() { if (_apiClient is not null) await _apiClient.LogoutAsync(); } // ── Channel Operations ──────────────────────────────────────────────── public async Task> JoinChannelAsync(string channelName) { if (_connection is null) throw new InvalidOperationException("Not connected"); _joinedChannels.Add(channelName); return await _connection.JoinChannelAsync(channelName); } public async Task LeaveChannelAsync(string channelName) { if (_connection is null) throw new InvalidOperationException("Not connected"); await _connection.LeaveChannelAsync(channelName); _joinedChannels.Remove(channelName); } /// /// Track a channel as joined (returns true if newly added). /// public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName); public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName); // ── Delegate Operations ─────────────────────────────────────────────── public Task SendMessageAsync(string channel, string content) => _connection?.SendMessageAsync(channel, content) ?? throw new InvalidOperationException("Not connected"); public Task> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) => _connection?.GetHistoryAsync(channel, count, offset) ?? throw new InvalidOperationException("Not connected"); public Task> GetOnlineUsersAsync(string channel) => _connection?.GetOnlineUsersAsync(channel) ?? throw new InvalidOperationException("Not connected"); public Task UpdateStatusAsync(UserStatus status, string? message) => _connection?.UpdateStatusAsync(status, message) ?? throw new InvalidOperationException("Not connected"); // ── Reconnect ───────────────────────────────────────────────────────── /// /// Rejoin all previously tracked channels after a reconnect. /// public async Task RejoinChannelsAsync() { var channels = _joinedChannels.ToList(); if (channels.Count == 0 || _connection is null) return; _joinedChannels.Clear(); foreach (var channel in channels) { _joinedChannels.Add(channel); await _connection.JoinChannelAsync(channel); } Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count); } // ── SignalR Event Wiring ────────────────────────────────────────────── private void WireConnectionEvents(EchoHubConnection connection) { connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg); connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence); connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user); connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p); connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason); connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason); connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason); connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id); connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch); connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch); connection.OnError += msg => Error?.Invoke(msg); connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status); connection.OnReconnected += () => Reconnected?.Invoke(); } // ── Token Persistence ───────────────────────────────────────────────── private void HandleTokensRefreshed() { if (_apiClient?.RefreshToken is null) return; var config = ConfigManager.Load(); var server = config.SavedServers.FirstOrDefault(s => string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase)); if (server is not null && server.RememberMe) { server.RefreshToken = _apiClient.RefreshToken; ConfigManager.Save(config); } } // ── Dispose ─────────────────────────────────────────────────────────── public async ValueTask DisposeAsync() { _apiClient?.Dispose(); if (_connection is not null) await _connection.DisposeAsync(); } }