using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; using ChessCubing.App.Models; using ChessCubing.App.Models.Social; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.SignalR.Client; namespace ChessCubing.App.Services; public sealed class SocialRealtimeService( NavigationManager navigation, AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; private readonly NavigationManager _navigation = navigation; private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider; private readonly SemaphoreSlim _gate = new(1, 1); private HubConnection? _hubConnection; private string? _currentSubject; private string? _joinedPlaySessionId; private HashSet _knownPresenceSubjects = new(StringComparer.Ordinal); private HashSet _onlineSubjects = new(StringComparer.Ordinal); private PlayInviteMessage? _incomingPlayInvite; private PlayInviteMessage? _outgoingPlayInvite; private PlaySessionResponse? _activePlaySession; private CollaborativeMatchSnapshot? _collaborativeSnapshot; private string? _lastInviteNotice; private int _socialVersion; private bool _started; public event Action? Changed; public PlayInviteMessage? IncomingPlayInvite => _incomingPlayInvite; public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite; public PlaySessionResponse? ActivePlaySession => _activePlaySession; public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot; public string? LastInviteNotice => _lastInviteNotice; public int SocialVersion => _socialVersion; public async Task EnsureStartedAsync() { if (_started) { await SyncConnectionAsync(); return; } _started = true; _authenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged; await SyncConnectionAsync(); } public bool IsUserOnline(string subject) => _onlineSubjects.Contains(subject); public bool? GetKnownOnlineStatus(string subject) { if (_onlineSubjects.Contains(subject)) { return true; } return _knownPresenceSubjects.Contains(subject) ? false : null; } public async Task EnsureJoinedPlaySessionAsync(string? sessionId) { var normalizedSessionId = sessionId?.Trim(); if (string.IsNullOrWhiteSpace(normalizedSessionId)) { return; } await EnsureStartedAsync(); await JoinPlaySessionCoreAsync(normalizedSessionId); } public async Task PublishMatchStateAsync(MatchState? match, string route) { var sessionId = match?.CollaborationSessionId ?? _activePlaySession?.SessionId ?? _joinedPlaySessionId; if (string.IsNullOrWhiteSpace(sessionId)) { return; } await EnsureJoinedPlaySessionAsync(sessionId); if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) { throw new InvalidOperationException("La connexion temps reel n'est pas prete."); } var payload = match is null ? null : JsonSerializer.Serialize(match, JsonOptions); await _hubConnection.InvokeAsync("PublishMatchState", sessionId, payload, route); } public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor) { _lastInviteNotice = null; if (_hubConnection is null) { throw new InvalidOperationException("La connexion temps reel n'est pas prete."); } var invite = await _hubConnection.InvokeAsync("SendPlayInvite", recipientSubject, recipientColor); ApplyInvite(invite); RaiseChanged(); } public async Task RespondToIncomingPlayInviteAsync(bool accept) { if (_hubConnection is null || _incomingPlayInvite is null) { return; } var inviteId = _incomingPlayInvite.InviteId; await _hubConnection.InvokeAsync("RespondToPlayInvite", inviteId, accept); if (!accept) { _incomingPlayInvite = null; RaiseChanged(); } } public async Task CancelOutgoingPlayInviteAsync() { if (_hubConnection is null || _outgoingPlayInvite is null) { return; } var inviteId = _outgoingPlayInvite.InviteId; await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId); } public void ClearInviteNotice() { if (string.IsNullOrWhiteSpace(_lastInviteNotice)) { return; } _lastInviteNotice = null; RaiseChanged(); } public async ValueTask DisposeAsync() { _authenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged; await _gate.WaitAsync(); try { if (_hubConnection is not null) { await _hubConnection.DisposeAsync(); _hubConnection = null; } } finally { _gate.Release(); _gate.Dispose(); } } private void HandleAuthenticationStateChanged(Task authenticationStateTask) => _ = SyncConnectionAsync(); private async Task SyncConnectionAsync() { await _gate.WaitAsync(); try { var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); var user = authState.User; var subject = ResolveSubject(user); var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal); if (string.IsNullOrWhiteSpace(subject)) { await DisconnectUnsafeAsync(clearSessionState: true); return; } if (_hubConnection is not null && string.Equals(subject, _currentSubject, StringComparison.Ordinal) && _hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting) { return; } await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState); _currentSubject = subject; _hubConnection = BuildHubConnection(); RegisterHandlers(_hubConnection); await _hubConnection.StartAsync(); if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId)) { await JoinPlaySessionCoreAsync(_joinedPlaySessionId); } } finally { _gate.Release(); } } private HubConnection BuildHubConnection() => new HubConnectionBuilder() .WithUrl(_navigation.ToAbsoluteUri("/hubs/social")) .WithAutomaticReconnect() .Build(); private void RegisterHandlers(HubConnection connection) { connection.On("PresenceSnapshot", message => { _knownPresenceSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal); _onlineSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal); RaiseChanged(); }); connection.On("PresenceChanged", message => { _knownPresenceSubjects.Add(message.Subject); if (message.IsOnline) { _onlineSubjects.Add(message.Subject); } else { _onlineSubjects.Remove(message.Subject); } RaiseChanged(); }); connection.On("SocialChanged", async () => { Interlocked.Increment(ref _socialVersion); await RequestPresenceSnapshotAsync(); RaiseChanged(); }); connection.On("PlayInviteUpdated", message => { ApplyInvite(message); RaiseChanged(); }); connection.On("PlayInviteClosed", message => { if (_incomingPlayInvite?.InviteId == message.InviteId) { _incomingPlayInvite = null; } if (_outgoingPlayInvite?.InviteId == message.InviteId) { _outgoingPlayInvite = null; } _lastInviteNotice = message.Message; RaiseChanged(); }); connection.On("CollaborativeMatchStateUpdated", message => { ApplyCollaborativeState(message); RaiseChanged(); }); connection.On("PlayInviteAccepted", async session => { _incomingPlayInvite = null; _outgoingPlayInvite = null; _activePlaySession = session; _lastInviteNotice = "La partie est confirmee. Les deux ecrans resteront synchronises pendant le match."; await JoinPlaySessionCoreAsync(session.SessionId); RaiseChanged(); }); connection.Reconnected += async _ => { await RequestPresenceSnapshotAsync(); if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId)) { await JoinPlaySessionCoreAsync(_joinedPlaySessionId); } RaiseChanged(); }; connection.Closed += async _ => { if (!string.IsNullOrWhiteSpace(_currentSubject)) { await SyncConnectionAsync(); } }; } private async Task RequestPresenceSnapshotAsync() { if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) { return; } try { await _hubConnection.InvokeAsync("RequestPresenceSnapshot"); } catch { // La vue peut continuer avec le dernier etat connu si le snapshot echoue ponctuellement. } } private async Task JoinPlaySessionCoreAsync(string sessionId) { if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) { return; } if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId) && !string.Equals(_joinedPlaySessionId, sessionId, StringComparison.Ordinal)) { try { await _hubConnection.InvokeAsync("LeavePlaySession", _joinedPlaySessionId); } catch { // La prochaine connexion recreera les groupes si besoin. } } var latestState = await _hubConnection.InvokeAsync("JoinPlaySession", sessionId); _joinedPlaySessionId = sessionId; if (latestState is not null) { ApplyCollaborativeState(latestState); } else if (_collaborativeSnapshot?.SessionId != sessionId) { _collaborativeSnapshot = null; } } private void ApplyInvite(PlayInviteMessage invite) { if (string.IsNullOrWhiteSpace(_currentSubject)) { return; } if (string.Equals(invite.RecipientSubject, _currentSubject, StringComparison.Ordinal)) { _incomingPlayInvite = invite; } if (string.Equals(invite.SenderSubject, _currentSubject, StringComparison.Ordinal)) { _outgoingPlayInvite = invite; } } private void ApplyCollaborativeState(CollaborativeMatchStateMessage message) { if (_collaborativeSnapshot is not null && string.Equals(_collaborativeSnapshot.SessionId, message.SessionId, StringComparison.Ordinal) && message.Revision <= _collaborativeSnapshot.Revision) { return; } MatchState? match = null; if (!string.IsNullOrWhiteSpace(message.MatchJson)) { try { match = JsonSerializer.Deserialize(message.MatchJson, JsonOptions); if (match is not null) { match.CollaborationSessionId = message.SessionId; MatchEngine.NormalizeRecoveredMatch(match); } } catch { match = null; } } _collaborativeSnapshot = new CollaborativeMatchSnapshot { SessionId = message.SessionId, Match = match, Route = string.IsNullOrWhiteSpace(message.Route) ? "/application.html" : message.Route, SenderSubject = message.SenderSubject, Revision = message.Revision, UpdatedUtc = message.UpdatedUtc, }; } private async Task DisconnectUnsafeAsync(bool clearSessionState) { _currentSubject = clearSessionState ? null : _currentSubject; _knownPresenceSubjects.Clear(); _onlineSubjects.Clear(); _incomingPlayInvite = null; _outgoingPlayInvite = null; if (clearSessionState) { _activePlaySession = null; _joinedPlaySessionId = null; _collaborativeSnapshot = null; _lastInviteNotice = null; } if (_hubConnection is not null) { await _hubConnection.DisposeAsync(); _hubConnection = null; } RaiseChanged(); } private void RaiseChanged() => Changed?.Invoke(); private static string? ResolveSubject(ClaimsPrincipal user) => user.Identity?.IsAuthenticated == true ? user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value : null; }